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); + } +}