diff --git a/src/AdaptiveRemote.App/Services/Lifecycle/AcceleratedServices.cs b/src/AdaptiveRemote.App/Services/Lifecycle/AcceleratedServices.cs
index 0986d20..0cd4978 100644
--- a/src/AdaptiveRemote.App/Services/Lifecycle/AcceleratedServices.cs
+++ b/src/AdaptiveRemote.App/Services/Lifecycle/AcceleratedServices.cs
@@ -1,12 +1,16 @@
using AdaptiveRemote.Models;
+using AdaptiveRemote.Services.Testing;
+using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
namespace AdaptiveRemote.Services.Lifecycle;
public class AcceleratedServices
{
private readonly string[] _args;
+ private TestEndpointCoordinator? _testCoordinator;
public LifecycleView ViewModel { get; }
public ILifecycleViewController Controller { get; }
@@ -31,8 +35,59 @@ public void ConfigureHost(IHostBuilder hostBuilder)
.ConfigureServices(AddPrecreatedServices);
}
+ ///
+ /// Initializes test coordinator if in test mode and waits for test initialization.
+ /// Should be called before building the host.
+ ///
+ public void InitializeTestCoordinator(IConfiguration configuration, ILoggerFactory? loggerFactory = null)
+ {
+ ILogger? logger = loggerFactory?.CreateLogger();
+ _testCoordinator = new TestEndpointCoordinator(configuration, logger);
+
+ if (_testCoordinator.IsTestModeEnabled)
+ {
+ // Coordinator will be signaled by test via RPC
+ // The WaitForTestInitialization will be called later before Build()
+ }
+ }
+
+ ///
+ /// Waits for test initialization if in test mode.
+ /// Returns true if ready to continue, false if timeout.
+ ///
+ public bool WaitForTestInitialization()
+ {
+ if (_testCoordinator == null)
+ {
+ return true; // No test coordinator, continue immediately
+ }
+
+ return _testCoordinator.WaitForTestInitialization();
+ }
+
+ ///
+ /// Applies pending test service registrations.
+ /// Should be called before adding other services.
+ ///
+ public void ApplyTestServiceRegistrations(IServiceCollection services)
+ {
+ _testCoordinator?.ApplyServiceRegistrations(services);
+ }
+
protected virtual void AddPrecreatedServices(IServiceCollection services)
- => services
+ {
+ // Apply test service registrations first
+ ApplyTestServiceRegistrations(services);
+
+ // Add accelerated services
+ services
.AddSingleton(Controller)
.AddSingleton(ViewModel);
+
+ // Add test coordinator if available
+ if (_testCoordinator != null)
+ {
+ services.AddSingleton(_testCoordinator);
+ }
+ }
}
diff --git a/src/AdaptiveRemote.App/Services/Testing/EarlyTestEndpointListener.cs b/src/AdaptiveRemote.App/Services/Testing/EarlyTestEndpointListener.cs
new file mode 100644
index 0000000..cdc421a
--- /dev/null
+++ b/src/AdaptiveRemote.App/Services/Testing/EarlyTestEndpointListener.cs
@@ -0,0 +1,211 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
+using StreamJsonRpc;
+using System.Net;
+using System.Net.Sockets;
+
+namespace AdaptiveRemote.Services.Testing;
+
+///
+/// Early TCP listener for test endpoint that starts before host Build().
+/// Accepts the first test connection, handles early RPC calls (RegisterService, ContinueStartup),
+/// then forwards subsequent calls to TestEndpointService after Build().
+///
+public class EarlyTestEndpointListener : IDisposable
+{
+ private readonly TestEndpointCoordinator _coordinator;
+ private readonly ILogger? _logger;
+ private readonly int _controlPort;
+ private TcpListener? _listener;
+ private TcpClient? _client;
+ private bool _disposed;
+ private ITestEndpoint? _forwardTarget;
+ private ForwardingTestEndpoint? _forwardingEndpoint;
+
+ public EarlyTestEndpointListener(
+ IConfiguration configuration,
+ TestEndpointCoordinator coordinator,
+ ILogger? logger = null)
+ {
+ _coordinator = coordinator;
+ _logger = logger;
+
+ int? port = configuration.GetValue("test:ControlPort");
+ if (!port.HasValue)
+ {
+ throw new InvalidOperationException("EarlyTestEndpointListener requires test:ControlPort to be configured");
+ }
+
+ _controlPort = port.Value;
+ }
+
+ ///
+ /// Starts listening for the first test connection.
+ ///
+ public void StartListening()
+ {
+ if (_listener != null)
+ {
+ return; // Already listening
+ }
+
+ _logger?.LogInformation("Starting early test endpoint listener on port {Port}", _controlPort);
+
+ _listener = new TcpListener(IPAddress.Loopback, _controlPort);
+ _listener.Start();
+ }
+
+ ///
+ /// Waits for and accepts the first test connection, then sets up RPC with a forwarding target.
+ /// Returns true if connection established, false if timeout.
+ ///
+ public bool WaitForConnection(TimeSpan timeout)
+ {
+ if (_listener == null)
+ {
+ throw new InvalidOperationException("Must call StartListening() before WaitForConnection()");
+ }
+
+ if (_client != null)
+ {
+ return true; // Already connected
+ }
+
+ _logger?.LogInformation("Waiting for test connection (timeout: {Timeout})", timeout);
+
+ try
+ {
+ Task acceptTask = _listener.AcceptTcpClientAsync();
+ if (!acceptTask.Wait(timeout))
+ {
+ _logger?.LogError("Timeout waiting for test connection");
+ return false;
+ }
+
+ _client = acceptTask.Result;
+ _logger?.LogInformation("Test client connected");
+
+ // Set up JSON-RPC on the connection with a forwarding wrapper
+ // Target will be set later via SetForwardTarget
+ NetworkStream stream = _client.GetStream();
+ _forwardingEndpoint = new ForwardingTestEndpoint(_coordinator, _logger);
+ JsonRpc rpc = JsonRpc.Attach(stream, _forwardingEndpoint);
+
+ _logger?.LogInformation("RPC endpoint ready");
+ return true;
+ }
+ catch (Exception ex)
+ {
+ _logger?.LogError(ex, "Failed to accept test connection");
+ return false;
+ }
+ }
+
+ ///
+ /// Sets the forward target for service creation calls.
+ /// Should be called after the host is built and TestEndpointService is available.
+ ///
+ public void SetForwardTarget(ITestEndpoint target)
+ {
+ if (_forwardingEndpoint != null)
+ {
+ _forwardingEndpoint.SetTarget(target);
+ }
+ }
+
+ ///
+ /// Stops listening for new connections.
+ ///
+ public void StopListening()
+ {
+ if (_listener != null)
+ {
+ _logger?.LogInformation("Stopping early test endpoint listener");
+ _listener.Stop();
+ _listener = null;
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ StopListening();
+ _client?.Dispose();
+ }
+
+ ///
+ /// Forwarding wrapper that handles early calls and forwards others to TestEndpointService.
+ ///
+ private class ForwardingTestEndpoint : ITestEndpoint
+ {
+ private readonly TestEndpointCoordinator _coordinator;
+ private ITestEndpoint? _target;
+ private readonly ILogger? _logger;
+
+ public ForwardingTestEndpoint(TestEndpointCoordinator coordinator, ILogger? logger)
+ {
+ _coordinator = coordinator;
+ _logger = logger;
+ }
+
+ public void SetTarget(ITestEndpoint target)
+ {
+ _target = target;
+ }
+
+ // Early initialization methods - handle locally
+ public Task RegisterServiceAsync(string serviceTypeName, string implementationTypeName, string assemblyPath, CancellationToken cancellationToken)
+ {
+ _coordinator.RegisterService(serviceTypeName, implementationTypeName, assemblyPath);
+ return Task.CompletedTask;
+ }
+
+ public Task ContinueStartupAsync(CancellationToken cancellationToken)
+ {
+ _coordinator.ContinueStartup();
+ return Task.CompletedTask;
+ }
+
+ // Service creation methods - forward to TestEndpointService (which has scope provider)
+ public Task CreateTestServiceAsync(string assemblyPath, string typeName, CancellationToken cancellationToken)
+ {
+ if (_target == null)
+ {
+ throw new InvalidOperationException("Forward target not set. Call SetForwardTarget after host is built.");
+ }
+ return _target.CreateTestServiceAsync(assemblyPath, typeName, cancellationToken);
+ }
+
+ public Task CreateTestLoggerAsync(string assemblyPath, string typeName, CancellationToken cancellationToken)
+ {
+ if (_target == null)
+ {
+ throw new InvalidOperationException("Forward target not set. Call SetForwardTarget after host is built.");
+ }
+ return _target.CreateTestLoggerAsync(assemblyPath, typeName, cancellationToken);
+ }
+
+ public Task CreateUITestServiceAsync(string assemblyPath, string typeName, CancellationToken cancellationToken)
+ {
+ if (_target == null)
+ {
+ throw new InvalidOperationException("Forward target not set. Call SetForwardTarget after host is built.");
+ }
+ return _target.CreateUITestServiceAsync(assemblyPath, typeName, cancellationToken);
+ }
+
+ public Task CreateTestSpeechServiceAsync(string assemblyPath, string typeName, CancellationToken cancellationToken)
+ {
+ if (_target == null)
+ {
+ throw new InvalidOperationException("Forward target not set. Call SetForwardTarget after host is built.");
+ }
+ return _target.CreateTestSpeechServiceAsync(assemblyPath, typeName, cancellationToken);
+ }
+ }
+}
diff --git a/src/AdaptiveRemote.App/Services/Testing/ITestEndpoint.cs b/src/AdaptiveRemote.App/Services/Testing/ITestEndpoint.cs
index 400a9a3..c3ec7df 100644
--- a/src/AdaptiveRemote.App/Services/Testing/ITestEndpoint.cs
+++ b/src/AdaptiveRemote.App/Services/Testing/ITestEndpoint.cs
@@ -43,4 +43,30 @@ public partial interface ITestEndpoint
/// Cancellation token for the operation.
/// A proxy to the UI test service that can be used to interact with the UI.
Task CreateUITestServiceAsync(string assemblyPath, string typeName, CancellationToken cancellationToken);
+
+ ///
+ /// Dynamically loads a test speech recognition service from the specified assembly and type.
+ /// The service is instantiated within the application's DI scope so it can access the speech recognition engine.
+ ///
+ /// Full path to the assembly containing the test speech service type.
+ /// Fully qualified name of the test speech service type to instantiate.
+ /// Cancellation token for the operation.
+ /// A proxy to the test speech service that can be used to simulate speech.
+ Task CreateTestSpeechServiceAsync(string assemblyPath, string typeName, CancellationToken cancellationToken);
+
+ ///
+ /// Registers a service implementation to be added to the host's DI container before startup.
+ /// Must be called before ContinueStartup().
+ ///
+ /// Fully qualified name of the service interface or abstract type.
+ /// Fully qualified name of the implementation type.
+ /// Full path to the assembly containing the implementation type.
+ /// Cancellation token for the operation.
+ Task RegisterServiceAsync(string serviceTypeName, string implementationTypeName, string assemblyPath, CancellationToken cancellationToken);
+
+ ///
+ /// Signals that test initialization is complete and the host can continue with its startup sequence.
+ ///
+ /// Cancellation token for the operation.
+ Task ContinueStartupAsync(CancellationToken cancellationToken);
}
diff --git a/src/AdaptiveRemote.App/Services/Testing/ITestSpeechRecognitionService.cs b/src/AdaptiveRemote.App/Services/Testing/ITestSpeechRecognitionService.cs
new file mode 100644
index 0000000..e50d2cf
--- /dev/null
+++ b/src/AdaptiveRemote.App/Services/Testing/ITestSpeechRecognitionService.cs
@@ -0,0 +1,23 @@
+using PolyType;
+using StreamJsonRpc;
+
+namespace AdaptiveRemote.Services.Testing;
+
+///
+/// Interface for controlling speech recognition in tests.
+/// Allows tests to simulate speech input programmatically.
+///
+[RpcMarshalable]
+[JsonRpcContract]
+[GenerateShape(IncludeMethods = MethodShapeFlags.PublicInstance)]
+public partial interface ITestSpeechRecognitionService : IDisposable
+{
+ ///
+ /// Simulates speaking a phrase that should be recognized by the speech recognition system.
+ ///
+ /// The text that was "spoken".
+ /// Confidence level (0-100), defaults to 80.
+ /// Cancellation token for the operation.
+ /// Task that completes when the speech has been processed.
+ Task SpeakPhraseAsync(string text, int confidence, CancellationToken cancellationToken);
+}
diff --git a/src/AdaptiveRemote.App/Services/Testing/IUITestService.cs b/src/AdaptiveRemote.App/Services/Testing/IUITestService.cs
index f40d209..e82186d 100644
--- a/src/AdaptiveRemote.App/Services/Testing/IUITestService.cs
+++ b/src/AdaptiveRemote.App/Services/Testing/IUITestService.cs
@@ -38,4 +38,28 @@ public partial interface IUITestService : IDisposable
/// Cancellation token for the operation.
/// Thrown if multiple buttons match the label or if the button is not visible/enabled.
Task ClickButtonAsync(string label, CancellationToken cancellationToken);
+
+ ///
+ /// Checks if text content is visible in the UI.
+ ///
+ /// The text to search for (case-sensitive).
+ /// Cancellation token for the operation.
+ /// True if the text is visible anywhere in the UI, false otherwise.
+ Task IsTextVisibleAsync(string text, CancellationToken cancellationToken);
+
+ ///
+ /// Clicks on an element containing the specified text in the UI.
+ /// The element must be visible and clickable.
+ ///
+ /// The exact text content to find and click (case-sensitive).
+ /// Cancellation token for the operation.
+ /// Thrown if the text is not found or not clickable.
+ Task ClickTextAsync(string text, CancellationToken cancellationToken);
+
+ ///
+ /// Gets the text content from the conversation speaking message div, if visible.
+ ///
+ /// Cancellation token for the operation.
+ /// The speaking message text if visible, otherwise null.
+ Task GetSpeakingMessageAsync(CancellationToken cancellationToken);
}
diff --git a/src/AdaptiveRemote.App/Services/Testing/TestEndpointCoordinator.cs b/src/AdaptiveRemote.App/Services/Testing/TestEndpointCoordinator.cs
new file mode 100644
index 0000000..0501264
--- /dev/null
+++ b/src/AdaptiveRemote.App/Services/Testing/TestEndpointCoordinator.cs
@@ -0,0 +1,130 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using System.Collections.Concurrent;
+using System.Reflection;
+
+namespace AdaptiveRemote.Services.Testing;
+
+///
+/// Coordinates test endpoint initialization and service registration before host startup.
+/// Blocks host build until test connection is established and services are registered.
+///
+public class TestEndpointCoordinator
+{
+ private readonly IConfiguration _configuration;
+ private readonly ILogger? _logger;
+ private readonly ManualResetEventSlim _startupGate = new(initialState: false);
+ private readonly ConcurrentQueue _pendingRegistrations = new();
+ private readonly TimeSpan _connectionTimeout = TimeSpan.FromSeconds(30);
+
+ public TestEndpointCoordinator(IConfiguration configuration, ILogger? logger = null)
+ {
+ _configuration = configuration;
+ _logger = logger;
+ }
+
+ ///
+ /// Gets whether test mode is enabled (test:ControlPort is configured).
+ ///
+ public bool IsTestModeEnabled => _configuration.GetValue("test:ControlPort").HasValue;
+
+ ///
+ /// Registers a service to be added to the DI container.
+ ///
+ public void RegisterService(string serviceTypeName, string implementationTypeName, string assemblyPath)
+ {
+ _logger?.LogInformation("Registering test service: {ServiceType} -> {ImplementationType}",
+ serviceTypeName, implementationTypeName);
+
+ _pendingRegistrations.Enqueue(new ServiceRegistration(serviceTypeName, implementationTypeName, assemblyPath));
+ }
+
+ ///
+ /// Signals that test initialization is complete and startup can continue.
+ ///
+ public void ContinueStartup()
+ {
+ _logger?.LogInformation("Test initialization complete, continuing startup");
+ _startupGate.Set();
+ }
+
+ ///
+ /// Blocks until test initialization is complete or timeout occurs.
+ /// Returns true if successful, false if timeout.
+ /// If no services are registered, continues immediately without waiting.
+ ///
+ public bool WaitForTestInitialization()
+ {
+ if (!IsTestModeEnabled)
+ {
+ return true; // Not in test mode, continue immediately
+ }
+
+ // If no services pending registration, don't wait - continue immediately
+ // This maintains backward compatibility with tests that don't use service injection
+ if (_pendingRegistrations.IsEmpty)
+ {
+ _logger?.LogInformation("No test services to register, continuing startup immediately");
+ return true;
+ }
+
+ _logger?.LogInformation("Waiting for test initialization (timeout: {Timeout})", _connectionTimeout);
+
+ bool success = _startupGate.Wait(_connectionTimeout);
+
+ if (!success)
+ {
+ _logger?.LogError("Test initialization timeout after {Timeout}", _connectionTimeout);
+ }
+
+ return success;
+ }
+
+ ///
+ /// Applies all pending service registrations to the service collection.
+ ///
+ public void ApplyServiceRegistrations(IServiceCollection services)
+ {
+ while (_pendingRegistrations.TryDequeue(out ServiceRegistration? registration))
+ {
+ _logger?.LogInformation("Applying service registration: {ServiceType} -> {ImplementationType}",
+ registration.ServiceTypeName, registration.ImplementationTypeName);
+
+ try
+ {
+ Assembly assembly = Assembly.LoadFrom(registration.AssemblyPath);
+
+ Type? serviceType = Type.GetType(registration.ServiceTypeName)
+ ?? assembly.GetType(registration.ServiceTypeName);
+
+ Type? implementationType = assembly.GetType(registration.ImplementationTypeName);
+
+ if (serviceType == null)
+ {
+ _logger?.LogError("Service type not found: {ServiceType}", registration.ServiceTypeName);
+ continue;
+ }
+
+ if (implementationType == null)
+ {
+ _logger?.LogError("Implementation type not found: {ImplementationType}",
+ registration.ImplementationTypeName);
+ continue;
+ }
+
+ services.AddSingleton(serviceType, implementationType);
+
+ _logger?.LogInformation("Successfully registered {ServiceType} -> {ImplementationType}",
+ serviceType.Name, implementationType.Name);
+ }
+ catch (Exception ex)
+ {
+ _logger?.LogError(ex, "Failed to register service: {ServiceType} -> {ImplementationType}",
+ registration.ServiceTypeName, registration.ImplementationTypeName);
+ }
+ }
+ }
+
+ private record ServiceRegistration(string ServiceTypeName, string ImplementationTypeName, string AssemblyPath);
+}
diff --git a/src/AdaptiveRemote.App/Services/Testing/TestEndpointService.cs b/src/AdaptiveRemote.App/Services/Testing/TestEndpointService.cs
index 3b1ba3d..578db5c 100644
--- a/src/AdaptiveRemote.App/Services/Testing/TestEndpointService.cs
+++ b/src/AdaptiveRemote.App/Services/Testing/TestEndpointService.cs
@@ -14,21 +14,26 @@ namespace AdaptiveRemote.Services.Testing;
///
/// Provides a test control endpoint via TCP/JSON-RPC for E2E testing.
/// Enabled when --test:ControlPort argument is provided.
+/// In test mode with early listener, this service doesn't create its own listener -
+/// the RPC calls are forwarded from EarlyTestEndpointListener.
///
internal class TestEndpointService : BackgroundService, ITestEndpoint
{
private readonly TestingSettings _settings;
- private readonly IApplicationScopeProvider _scopeProvider;
+ private readonly IApplicationScopeProvider? _scopeProvider;
+ private readonly TestEndpointCoordinator? _coordinator;
private readonly MessageLogger _logger;
private TcpListener? _listener;
public TestEndpointService(
IOptions settings,
- IApplicationScopeProvider scopeProvider,
- ILogger logger)
+ IApplicationScopeProvider? scopeProvider,
+ ILogger logger,
+ TestEndpointCoordinator? coordinator = null)
{
_settings = settings.Value;
_scopeProvider = scopeProvider;
+ _coordinator = coordinator;
_logger = new(logger);
}
@@ -40,6 +45,31 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
return;
}
+ // In test mode with EarlyTestEndpointListener, we don't need to do anything here
+ // The RPC calls are being forwarded from the early listener
+ // Just wait to be cancelled
+ if (_coordinator != null)
+ {
+ _logger.TestEndpointService_StartingTestControlEndpoint(_settings.ControlPort.Value);
+
+ try
+ {
+ // Just wait for cancellation
+ await Task.Delay(Timeout.Infinite, stoppingToken);
+ }
+ catch (OperationCanceledException)
+ {
+ // Normal shutdown
+ }
+ finally
+ {
+ _logger.TestEndpointService_StopTestControlEndpoint();
+ }
+
+ return;
+ }
+
+ // No coordinator - start our own listener (non-test-mode or fallback)
_logger.TestEndpointService_StartingTestControlEndpoint(_settings.ControlPort.Value);
_listener = new TcpListener(IPAddress.Loopback, _settings.ControlPort.Value);
@@ -108,9 +138,39 @@ public Task CreateTestLoggerAsync(string assemblyPath, string typeN
public Task CreateUITestServiceAsync(string assemblyPath, string typeName, CancellationToken cancellationToken)
=> CreateRemotableServiceAsync(assemblyPath, typeName, cancellationToken);
+ public Task CreateTestSpeechServiceAsync(string assemblyPath, string typeName, CancellationToken cancellationToken)
+ => CreateRemotableServiceAsync(assemblyPath, typeName, cancellationToken);
+
+ public Task RegisterServiceAsync(string serviceTypeName, string implementationTypeName, string assemblyPath, CancellationToken cancellationToken)
+ {
+ if (_coordinator == null)
+ {
+ throw new InvalidOperationException("Test coordinator not available. Service registration is only supported in test mode.");
+ }
+
+ _coordinator.RegisterService(serviceTypeName, implementationTypeName, assemblyPath);
+ return Task.CompletedTask;
+ }
+
+ public Task ContinueStartupAsync(CancellationToken cancellationToken)
+ {
+ if (_coordinator == null)
+ {
+ throw new InvalidOperationException("Test coordinator not available. Startup continuation is only supported in test mode.");
+ }
+
+ _coordinator.ContinueStartup();
+ return Task.CompletedTask;
+ }
+
private async Task CreateRemotableServiceAsync(string assemblyPath, string typeName, CancellationToken cancellationToken)
where ServiceType : class
{
+ if (_scopeProvider == null)
+ {
+ throw new InvalidOperationException("Cannot create test services: IApplicationScopeProvider not available. This should not happen in normal operation.");
+ }
+
_logger.TestEndpointService_LoadingTestService(typeName, assemblyPath);
Assembly assembly = Assembly.LoadFrom(assemblyPath);
diff --git a/src/AdaptiveRemote.App/Services/Testing/TestSpeechRecognitionEngine.cs b/src/AdaptiveRemote.App/Services/Testing/TestSpeechRecognitionEngine.cs
new file mode 100644
index 0000000..3552d86
--- /dev/null
+++ b/src/AdaptiveRemote.App/Services/Testing/TestSpeechRecognitionEngine.cs
@@ -0,0 +1,72 @@
+using AdaptiveRemote.Services.Conversation;
+using System.Diagnostics.CodeAnalysis;
+
+namespace AdaptiveRemote.Services.Testing;
+
+///
+/// Test-controllable speech recognition engine that allows programmatic speech simulation.
+/// Used in E2E tests to simulate speech input without requiring actual speech recognition hardware.
+///
+public class TestSpeechRecognitionEngine : ISpeechRecognitionEngine
+{
+ private readonly Dictionary _grammars = new();
+ private event EventHandler? _recognized;
+
+ event EventHandler ISpeechRecognitionEngine.SpeechRecognized
+ {
+ add => _recognized += value;
+ remove => _recognized -= value;
+ }
+
+ event EventHandler ISpeechRecognitionEngine.SpeechRejected
+ {
+ add { }
+ remove { }
+ }
+
+ void ISpeechRecognitionEngine.LoadGrammar(IGrammar grammar) => _grammars.Add(grammar.Name ?? string.Empty, grammar);
+ void ISpeechRecognitionEngine.UnloadGrammar(IGrammar grammar) => _grammars.Remove(grammar.Name ?? string.Empty);
+ void ISpeechRecognitionEngine.UnloadAllGrammars() => _grammars.Clear();
+ void ISpeechRecognitionEngine.Recognize() { }
+ void ISpeechRecognitionEngine.RecognizeAsyncCancel() { }
+ void ISpeechRecognitionEngine.SetConfidenceThreshold(int threshold) { }
+
+ ///
+ /// Simulates speaking a phrase. This is called by the test service to trigger speech recognition.
+ ///
+ public void SimulateSpeech(string text, int confidence)
+ {
+ // Determine the semantics based on the recognized text
+ TestRecognitionResult result = text switch
+ {
+ "Hey Remote" => new(text, confidence, ("system", "STARTLISTENING")),
+ "Stop Listening" or "Thank you" => new(text, confidence, ("system", "STOPLISTENING"), ("thankyou", "true")),
+ _ => new(text, confidence, ("command", text)) // Generic command
+ };
+
+ _recognized?.Invoke(this, new(result));
+ }
+
+ private class TestRecognitionResult : IRecognizedSpeech
+ {
+ internal TestRecognitionResult(string text, int confidence, params (string, string)[] semantics)
+ {
+ Text = text;
+ Confidence = confidence;
+ _semantics = semantics;
+ }
+
+ public string Text { get; }
+ public int Confidence { get; }
+
+ private readonly (string, string)[] _semantics;
+
+ bool IRecognizedSpeech.ContainsSemanticValue(string key)
+ => _semantics.Any(x => x.Item1 == key);
+
+ bool IRecognizedSpeech.TryGetSemanticValue(string key, [NotNullWhen(true)] out string? value)
+ => (value = _semantics.Where(x => x.Item1 == key).Select(x => x.Item2).FirstOrDefault()) is not null;
+
+ void IRecognizedSpeech.WriteToWaveStream(Stream waveStream) => throw new NotImplementedException();
+ }
+}
diff --git a/src/AdaptiveRemote.Headless/Program.cs b/src/AdaptiveRemote.Headless/Program.cs
index 1b7a1eb..8c07ca8 100644
--- a/src/AdaptiveRemote.Headless/Program.cs
+++ b/src/AdaptiveRemote.Headless/Program.cs
@@ -4,6 +4,10 @@
using AdaptiveRemote.Services.Lifecycle;
using AdaptiveRemote.Services.Testing;
using Microsoft.AspNetCore.Components.Server.Circuits;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using System.Net.Sockets;
+using StreamJsonRpc;
WebApplicationOptions options = new()
{
@@ -11,30 +15,97 @@
Args = args
};
-WebApplication.CreateBuilder(options)
- .ConfigureAppServices(args)
+WebApplicationBuilder builder = WebApplication.CreateBuilder(options);
+
+// Configure accelerated services
+AcceleratedServices accelerated = new(args);
+
+// If in test mode, set up early test endpoint listener
+EarlyTestEndpointListener? earlyListener = null;
+TestEndpointCoordinator? testCoordinator = null;
+
+if (builder.Configuration.GetValue("test:ControlPort").HasValue)
+{
+ // Create test coordinator
+ ILoggerFactory loggerFactory = LoggerFactory.Create(b => b.AddConsole().SetMinimumLevel(LogLevel.Information));
+ ILogger coordLogger = loggerFactory.CreateLogger();
+ testCoordinator = new TestEndpointCoordinator(builder.Configuration, coordLogger);
+
+ // Start early listener
+ ILogger listenerLogger = loggerFactory.CreateLogger();
+ earlyListener = new EarlyTestEndpointListener(builder.Configuration, testCoordinator, listenerLogger);
+ earlyListener.StartListening();
+
+ // Wait for test connection
+ if (!earlyListener.WaitForConnection(TimeSpan.FromSeconds(30)))
+ {
+ Console.Error.WriteLine("Failed to establish test connection within timeout");
+ Environment.Exit(1);
+ return;
+ }
+
+ // Initialize accelerated services with coordinator
+ accelerated.InitializeTestCoordinator(builder.Configuration, loggerFactory);
+
+ // Wait for test to register services and signal ready
+ if (!accelerated.WaitForTestInitialization())
+ {
+ Console.Error.WriteLine("Test initialization timeout");
+ Environment.Exit(1);
+ return;
+ }
+
+ // Stop listening for new connections (we have the one we need)
+ earlyListener.StopListening();
+}
+
+// Configure app services
+accelerated.ConfigureHost(builder.Host);
+
+// Add test coordinator to services if available
+if (testCoordinator != null)
+{
+ builder.Services.AddSingleton(testCoordinator);
+}
+
+// Configure other services
+builder
.ConfigureStubSpeechServices()
.ConfigureBlazorServices()
- .ConfigurePlaywrightBrowser()
- .Build()
- .AddHostingRoutes()
- .Run();
+ .ConfigurePlaywrightBrowser();
-internal static class Configuration
+// Build the app
+WebApplication app = builder.Build();
+
+// After build, set the forward target for early listener
+if (earlyListener != null)
{
- internal static WebApplicationBuilder ConfigureAppServices(this WebApplicationBuilder builder, string[] args)
+ // Get TestEndpointService from DI (it's registered via OptionallyAddTestHookEndpoint in ConfigureApp)
+ var testEndpointService = app.Services.GetServices()
+ .OfType()
+ .FirstOrDefault();
+
+ if (testEndpointService != null)
{
- AcceleratedServices accelerated = new(args);
- accelerated.ConfigureHost(builder.Host);
- return builder;
+ earlyListener.SetForwardTarget(testEndpointService);
}
+}
+
+// Add routes and run
+app.AddHostingRoutes().Run();
+
+// Dispose early listener after app stops
+earlyListener?.Dispose();
+internal static class Configuration
+{
internal static WebApplicationBuilder ConfigureStubSpeechServices(this WebApplicationBuilder builder)
{
builder.Services
.AddSingleton()
.AddSingleton()
.AddSingleton();
+
return builder;
}
diff --git a/test/AdaptiveRemote.EndToEndTests.Features/ConversationModalUI.feature b/test/AdaptiveRemote.EndToEndTests.Features/ConversationModalUI.feature
new file mode 100644
index 0000000..90cf773
--- /dev/null
+++ b/test/AdaptiveRemote.EndToEndTests.Features/ConversationModalUI.feature
@@ -0,0 +1,13 @@
+Feature: Conversation Modal UI
+ As a user
+ I want to see modal messages when using the conversation system
+ So that I know when the system is listening and can interact with it
+
+Scenario: Conversation modal message displays when speech is recognized
+ Given the application is running
+ And the application is in the Ready state
+ When I say "Hey Remote"
+ Then I should see the text "I'm listening..." is visible
+ When I say "Thank you"
+ Then I should see the speaking message "You're welcome!" is visible
+ And I should see the text "I'm listening..." is not visible
diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/ConversationSteps.cs b/test/AdaptiveRemote.EndToEndTests.Steps/ConversationSteps.cs
new file mode 100644
index 0000000..f203cd1
--- /dev/null
+++ b/test/AdaptiveRemote.EndToEndTests.Steps/ConversationSteps.cs
@@ -0,0 +1,33 @@
+using AdaptiveRemote.EndtoEndTests;
+using Microsoft.Extensions.Logging;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Reqnroll;
+
+namespace AdaptiveRemote.EndToEndTests.Steps;
+
+[Binding]
+public class ConversationSteps : StepsBase
+{
+ [When(@"I say {string}")]
+ public void WhenISay(string phrase)
+ {
+ Assert.IsNotNull(Host, "Cannot speak phrase '{0}'. The application is not started.", phrase);
+ Logger.LogInformation("Simulating speech: {Phrase}", phrase);
+ Host.Speech.SpeakPhrase(phrase);
+ }
+
+ [Then(@"I should see the speaking message {string} is visible")]
+ public void ThenIShouldSeeTheSpeakingMessageIsVisible(string expectedMessage)
+ {
+ Assert.IsNotNull(Host, "Cannot check speaking message. The application is not started.");
+
+ bool found = Host.UI.WaitForSpeakingMessage(expectedMessage, timeoutInSeconds: 5);
+ if (!found)
+ {
+ string? actualMessage = Host.UI.GetSpeakingMessage(timeoutInSeconds: 1);
+ Assert.Fail($"Expected speaking message '{expectedMessage}' but got '{actualMessage}'");
+ }
+
+ Logger.LogInformation("Speaking message verified: {Message}", expectedMessage);
+ }
+}
diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/UISteps.cs b/test/AdaptiveRemote.EndToEndTests.Steps/UISteps.cs
index f99f79f..b4ffc5a 100644
--- a/test/AdaptiveRemote.EndToEndTests.Steps/UISteps.cs
+++ b/test/AdaptiveRemote.EndToEndTests.Steps/UISteps.cs
@@ -21,4 +21,25 @@ public void ThenIShouldSeeTheButtonIsEnabled(string buttonLabel)
Assert.IsTrue(Host.UI.IsButtonEnabled(buttonLabel), "Button {0} was not enabled", buttonLabel);
}
+ [When(@"I click on the text {string}")]
+ public void WhenIClickOnTheText(string text)
+ {
+ Assert.IsNotNull(Host, "Cannot click the text '{0}'. The application is not started.", text);
+ Host.UI.ClickText(text);
+ }
+
+ [Then(@"I should see the text {string} is visible")]
+ public void ThenIShouldSeeTheTextIsVisible(string text)
+ {
+ Assert.IsNotNull(Host, "Cannot check if text '{0}' is visible. The application is not started.", text);
+ Assert.IsTrue(Host.UI.IsTextVisible(text), "Text '{0}' was not visible", text);
+ }
+
+ [Then(@"I should see the text {string} is not visible")]
+ public void ThenIShouldSeeTheTextIsNotVisible(string text)
+ {
+ Assert.IsNotNull(Host, "Cannot check if text '{0}' is not visible. The application is not started.", text);
+ Assert.IsFalse(Host.UI.IsTextVisible(text), "Text '{0}' was visible but should not be", text);
+ }
+
}
diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/AdaptiveRemoteHost.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/AdaptiveRemoteHost.cs
index fbb13c6..a1da1a0 100644
--- a/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/AdaptiveRemoteHost.cs
+++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/AdaptiveRemoteHost.cs
@@ -14,6 +14,7 @@ public partial class AdaptiveRemoteHost : IDisposable
private readonly ILogger _logger;
private readonly Lazy _lazyTestService;
private readonly Lazy _lazyUITestService;
+ private readonly Lazy _lazySpeechTestService;
private readonly ITestEndpoint _testEndpoint;
private readonly StringBuilder _standardOutput;
@@ -51,6 +52,9 @@ private AdaptiveRemoteHost(AdaptiveRemoteHostSettings settings,
UIServiceType.BlazorWebView => CreateLazyTestService(),
_ => throw new InvalidOperationException($"Unsupported UIServiceType '{_settings.UIService}'")
};
+
+ // Create speech test service
+ _lazySpeechTestService = CreateLazyTestService();
}
private Lazy CreateLazyTestService()
@@ -77,6 +81,8 @@ private Lazy CreateLazyTestService()
public IUITestService UI => _lazyUITestService.Value;
+ public ITestSpeechRecognitionService Speech => _lazySpeechTestService.Value;
+
public ILogger CreateLogger() => _loggerFactory.CreateLogger();
public ILogger CreateLogger(string category) => _loggerFactory.CreateLogger(category);
diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/ITestEndpointExtensions.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/ITestEndpointExtensions.cs
index 7f2d96f..01871de 100644
--- a/test/AdaptiveRemote.EndtoEndTests.TestServices/ITestEndpointExtensions.cs
+++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/ITestEndpointExtensions.cs
@@ -18,6 +18,7 @@ public static async Task CreateTestServiceAsync controlService.CreateTestServiceAsync,
nameof(IUITestService) => controlService.CreateUITestServiceAsync,
nameof(ITestLogger) => controlService.CreateTestLoggerAsync,
+ nameof(ITestSpeechRecognitionService) => controlService.CreateTestSpeechServiceAsync,
_ => throw new InvalidOperationException($"There is no method on ITestEndpoint to create a service of type {typeof(ContractType).AssemblyQualifiedName}")
};
diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/ITestSpeechRecognitionServiceExtensions.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/ITestSpeechRecognitionServiceExtensions.cs
new file mode 100644
index 0000000..a5cbc35
--- /dev/null
+++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/ITestSpeechRecognitionServiceExtensions.cs
@@ -0,0 +1,38 @@
+using AdaptiveRemote.Services.Testing;
+
+namespace AdaptiveRemote.EndtoEndTests;
+
+///
+/// Synchronous wrapper extensions for ITestSpeechRecognitionService to simplify test code.
+///
+public static class ITestSpeechRecognitionServiceExtensions
+{
+ public const int DefaultTimeoutInSeconds = 60;
+ public const int DefaultConfidence = 80;
+
+ ///
+ /// Simulates speaking a phrase (synchronous wrapper).
+ ///
+ /// The test speech service.
+ /// The text that was "spoken".
+ /// Confidence level (0-100), defaults to 80.
+ /// Optional timeout for the operation.
+ public static void SpeakPhrase(this ITestSpeechRecognitionService service, string text, int confidence = DefaultConfidence, int timeoutInSeconds = DefaultTimeoutInSeconds)
+ => service.SpeakPhrase(text, confidence, TimeSpan.FromSeconds(timeoutInSeconds));
+
+ ///
+ /// Simulates speaking a phrase (synchronous wrapper).
+ ///
+ /// The test speech service.
+ /// The text that was "spoken".
+ /// Confidence level (0-100).
+ /// Timeout for the operation.
+ public static void SpeakPhrase(this ITestSpeechRecognitionService service, string text, int confidence, TimeSpan timeout)
+ {
+ bool succeeded = WaitHelpers.WaitForAsyncTask(ct => service.SpeakPhraseAsync(text, confidence, ct), timeout);
+ if (!succeeded)
+ {
+ throw new TimeoutException($"Speaking phrase '{text}' did not complete within timeout.");
+ }
+ }
+}
diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/IUITestServiceExtensions.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/IUITestServiceExtensions.cs
index ccc92e1..7036424 100644
--- a/test/AdaptiveRemote.EndtoEndTests.TestServices/IUITestServiceExtensions.cs
+++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/IUITestServiceExtensions.cs
@@ -81,4 +81,94 @@ public static void ClickButton(this IUITestService service, string label, TimeSp
// This exception occurs sometimes when clicking the "Exit" button if the application shuts down to fast
}
}
+
+ ///
+ /// Checks if text content is visible in the UI (synchronous wrapper).
+ ///
+ /// The UI test service.
+ /// The text to search for (case-sensitive).
+ /// Optional timeout for the operation.
+ /// True if the text is visible anywhere in the UI, false otherwise.
+ public static bool IsTextVisible(this IUITestService service, string text, int timeoutInSeconds = DefaultUITimeoutInSeconds)
+ => service.IsTextVisible(text, TimeSpan.FromSeconds(timeoutInSeconds));
+
+ ///
+ /// Checks if text content is visible in the UI (synchronous wrapper).
+ ///
+ /// The UI test service.
+ /// The text to search for (case-sensitive).
+ /// Timeout for the operation.
+ /// True if the text is visible anywhere in the UI, false otherwise.
+ public static bool IsTextVisible(this IUITestService service, string text, TimeSpan timeout)
+ => WaitHelpers.ExecuteWithRetries(ct => service.IsTextVisibleAsync(text, ct), timeout);
+
+ ///
+ /// Clicks on an element containing the specified text in the UI (synchronous wrapper).
+ ///
+ /// The UI test service.
+ /// The exact text content to find and click (case-sensitive).
+ /// Optional timeout for the operation.
+ /// Thrown when the operation times out.
+ public static void ClickText(this IUITestService service, string text, int timeoutInSeconds = DefaultUITimeoutInSeconds)
+ => service.ClickText(text, TimeSpan.FromSeconds(timeoutInSeconds));
+
+ ///
+ /// Clicks on an element containing the specified text in the UI (synchronous wrapper).
+ ///
+ /// The UI test service.
+ /// The exact text content to find and click (case-sensitive).
+ /// Timeout for the operation.
+ /// Thrown when the operation times out.
+ public static void ClickText(this IUITestService service, string text, TimeSpan timeout)
+ {
+ bool succeeded = WaitHelpers.WaitForAsyncTask(ct => service.ClickTextAsync(text, ct), timeout);
+ if (!succeeded)
+ {
+ throw new TimeoutException($"Clicking text '{text}' did not complete within timeout.");
+ }
+ }
+
+ ///
+ /// Gets the text content from the conversation speaking message div (synchronous wrapper).
+ ///
+ /// The UI test service.
+ /// Optional timeout for the operation.
+ /// The speaking message text if visible, otherwise null.
+ public static string? GetSpeakingMessage(this IUITestService service, int timeoutInSeconds = DefaultUITimeoutInSeconds)
+ => service.GetSpeakingMessage(TimeSpan.FromSeconds(timeoutInSeconds));
+
+ ///
+ /// Gets the text content from the conversation speaking message div (synchronous wrapper).
+ ///
+ /// The UI test service.
+ /// Timeout for the operation.
+ /// The speaking message text if visible, otherwise null.
+ public static string? GetSpeakingMessage(this IUITestService service, TimeSpan timeout)
+ => WaitHelpers.WaitForAsyncTask(service.GetSpeakingMessageAsync, timeout);
+
+ ///
+ /// Waits for the speaking message to match the expected text.
+ ///
+ /// The UI test service.
+ /// The expected message text.
+ /// Optional timeout for the operation.
+ /// True if the message appears within the timeout, false otherwise.
+ public static bool WaitForSpeakingMessage(this IUITestService service, string expectedMessage, int timeoutInSeconds = DefaultUITimeoutInSeconds)
+ => service.WaitForSpeakingMessage(expectedMessage, TimeSpan.FromSeconds(timeoutInSeconds));
+
+ ///
+ /// Waits for the speaking message to match the expected text.
+ ///
+ /// The UI test service.
+ /// The expected message text.
+ /// Timeout for the operation.
+ /// True if the message appears within the timeout, false otherwise.
+ public static bool WaitForSpeakingMessage(this IUITestService service, string expectedMessage, TimeSpan timeout)
+ {
+ return WaitHelpers.ExecuteWithRetries(async ct =>
+ {
+ string? actualMessage = await service.GetSpeakingMessageAsync(ct);
+ return actualMessage == expectedMessage;
+ }, timeout);
+ }
}
diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/PlaywrightUITestService.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/PlaywrightUITestService.cs
index be49ba3..364b9c6 100644
--- a/test/AdaptiveRemote.EndtoEndTests.TestServices/PlaywrightUITestService.cs
+++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/PlaywrightUITestService.cs
@@ -86,6 +86,58 @@ private static async Task IsButtonDisabledAsync(ILocator locator)
return hasDisabledAttribute || isAriaDisabled;
}
+ public async Task IsTextVisibleAsync(string text, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ ILocator locator = CurrentPage.GetByText(text, new() { Exact = true });
+ return await locator.IsVisibleAsync();
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ public async Task ClickTextAsync(string text, CancellationToken cancellationToken = default)
+ {
+ ILocator locator = CurrentPage.GetByText(text, new() { Exact = true });
+
+ // Verify the text is visible
+ bool isVisible = await locator.IsVisibleAsync();
+ if (!isVisible)
+ {
+ throw new InvalidOperationException($"Text '{text}' is not visible.");
+ }
+
+ // Click the element containing the text
+ await locator.ClickAsync(new LocatorClickOptions
+ {
+ Timeout = DefaultTimeoutMs
+ });
+ }
+
+ public async Task GetSpeakingMessageAsync(CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ ILocator locator = CurrentPage.Locator("div.conversation-speaking-message span");
+ bool isVisible = await locator.IsVisibleAsync();
+ if (!isVisible)
+ {
+ return null;
+ }
+
+ string text = await locator.TextContentAsync() ?? string.Empty;
+ // Remove surrounding quotes if present
+ return text.Trim('"');
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
private ILocator GetButtonLocatorByLabel(string label)
{
// Use Playwright's getByRole with exact match - it will throw meaningful errors
diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/TestSpeechRecognitionService.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/TestSpeechRecognitionService.cs
new file mode 100644
index 0000000..592298a
--- /dev/null
+++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/TestSpeechRecognitionService.cs
@@ -0,0 +1,37 @@
+using AdaptiveRemote.Services.Conversation;
+
+namespace AdaptiveRemote.Services.Testing;
+
+///
+/// Test service that provides RPC control over speech recognition for E2E tests.
+///
+public class TestSpeechRecognitionService : ITestSpeechRecognitionService
+{
+ private readonly ISpeechRecognitionEngine _speechEngine;
+
+ public TestSpeechRecognitionService(ISpeechRecognitionEngine speechEngine)
+ {
+ _speechEngine = speechEngine;
+ }
+
+ public Task SpeakPhraseAsync(string text, int confidence, CancellationToken cancellationToken)
+ {
+ if (_speechEngine is not TestSpeechRecognitionEngine testEngine)
+ {
+ throw new InvalidOperationException(
+ $"Speech recognition engine is not a {nameof(TestSpeechRecognitionEngine)}. " +
+ $"Actual type: {_speechEngine.GetType().Name}");
+ }
+
+ // Simulate the speech on a background thread to avoid blocking
+ Task.Run(() => testEngine.SimulateSpeech(text, confidence), cancellationToken);
+
+ return Task.CompletedTask;
+ }
+
+ public void Dispose()
+ {
+ // Nothing to dispose
+ GC.SuppressFinalize(this);
+ }
+}