From 5a55003aebe4a1d003c83e8638c19c1e1cd4c69d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 04:57:00 +0000 Subject: [PATCH 1/8] Initial plan From 529f6417683dfcab7b697c3efdceac59e08bce3a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 05:07:50 +0000 Subject: [PATCH 2/8] Add conversation modal UI integration test (ADR-141) Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> --- .../Services/Testing/IUITestService.cs | 17 +++++++ .../ConversationModalUI.feature | 18 ++++++++ .../UISteps.cs | 21 +++++++++ .../IUITestServiceExtensions.cs | 46 +++++++++++++++++++ .../PlaywrightUITestService.cs | 31 +++++++++++++ 5 files changed, 133 insertions(+) create mode 100644 test/AdaptiveRemote.EndToEndTests.Features/ConversationModalUI.feature diff --git a/src/AdaptiveRemote.App/Services/Testing/IUITestService.cs b/src/AdaptiveRemote.App/Services/Testing/IUITestService.cs index f40d209..ec92971 100644 --- a/src/AdaptiveRemote.App/Services/Testing/IUITestService.cs +++ b/src/AdaptiveRemote.App/Services/Testing/IUITestService.cs @@ -38,4 +38,21 @@ 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); } diff --git a/test/AdaptiveRemote.EndToEndTests.Features/ConversationModalUI.feature b/test/AdaptiveRemote.EndToEndTests.Features/ConversationModalUI.feature new file mode 100644 index 0000000..68184eb --- /dev/null +++ b/test/AdaptiveRemote.EndToEndTests.Features/ConversationModalUI.feature @@ -0,0 +1,18 @@ +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 listening mode is activated + Given the application is not running + When I start the application + Then I should see the application in the Ready phase + And I should not see any warning or error messages in the logs + When I click on the text 'Say "Hey Remote" to get my attention' + Then I should see the text "I'm listening..." is visible + When I click on the text "I'm listening..." + Then I should see the text "I'm listening..." is not visible + And I should see the text 'Say "Hey Remote" to get my attention' is visible + When I click on the 'Exit' button + And I wait for the application to shut down + Then I should not see any warning or error messages in the logs 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/IUITestServiceExtensions.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/IUITestServiceExtensions.cs index ccc92e1..8dbe2f1 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/IUITestServiceExtensions.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/IUITestServiceExtensions.cs @@ -81,4 +81,50 @@ 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."); + } + } } diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/PlaywrightUITestService.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/PlaywrightUITestService.cs index be49ba3..a381a0e 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/PlaywrightUITestService.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/PlaywrightUITestService.cs @@ -86,6 +86,37 @@ 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 + }); + } + private ILocator GetButtonLocatorByLabel(string label) { // Use Playwright's getByRole with exact match - it will throw meaningful errors From b25352c0603141b8c7925eb4b26c5cb600983bfa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 05:33:09 +0000 Subject: [PATCH 3/8] Implement test speech recognition system for E2E tests Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> --- .../Services/Testing/ITestEndpoint.cs | 10 +++ .../Testing/ITestSpeechRecognitionService.cs | 23 ++++++ .../Services/Testing/IUITestService.cs | 7 ++ .../Services/Testing/TestEndpointService.cs | 3 + .../Testing/TestSpeechRecognitionEngine.cs | 72 +++++++++++++++++++ src/AdaptiveRemote.Headless/Program.cs | 15 +++- .../ConversationModalUI.feature | 22 +++--- .../ConversationSteps.cs | 39 ++++++++++ .../Host/AdaptiveRemoteHost.cs | 6 ++ .../ITestEndpointExtensions.cs | 1 + ...ITestSpeechRecognitionServiceExtensions.cs | 38 ++++++++++ .../IUITestServiceExtensions.cs | 18 +++++ .../PlaywrightUITestService.cs | 21 ++++++ .../TestSpeechRecognitionService.cs | 37 ++++++++++ 14 files changed, 297 insertions(+), 15 deletions(-) create mode 100644 src/AdaptiveRemote.App/Services/Testing/ITestSpeechRecognitionService.cs create mode 100644 src/AdaptiveRemote.App/Services/Testing/TestSpeechRecognitionEngine.cs create mode 100644 test/AdaptiveRemote.EndToEndTests.Steps/ConversationSteps.cs create mode 100644 test/AdaptiveRemote.EndtoEndTests.TestServices/ITestSpeechRecognitionServiceExtensions.cs create mode 100644 test/AdaptiveRemote.EndtoEndTests.TestServices/TestSpeechRecognitionService.cs diff --git a/src/AdaptiveRemote.App/Services/Testing/ITestEndpoint.cs b/src/AdaptiveRemote.App/Services/Testing/ITestEndpoint.cs index 400a9a3..91e5660 100644 --- a/src/AdaptiveRemote.App/Services/Testing/ITestEndpoint.cs +++ b/src/AdaptiveRemote.App/Services/Testing/ITestEndpoint.cs @@ -43,4 +43,14 @@ 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); } 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 ec92971..e82186d 100644 --- a/src/AdaptiveRemote.App/Services/Testing/IUITestService.cs +++ b/src/AdaptiveRemote.App/Services/Testing/IUITestService.cs @@ -55,4 +55,11 @@ public partial interface IUITestService : IDisposable /// 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/TestEndpointService.cs b/src/AdaptiveRemote.App/Services/Testing/TestEndpointService.cs index 3b1ba3d..bb53dc4 100644 --- a/src/AdaptiveRemote.App/Services/Testing/TestEndpointService.cs +++ b/src/AdaptiveRemote.App/Services/Testing/TestEndpointService.cs @@ -108,6 +108,9 @@ 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); + private async Task CreateRemotableServiceAsync(string assemblyPath, string typeName, CancellationToken cancellationToken) where ServiceType : class { 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..6353be3 100644 --- a/src/AdaptiveRemote.Headless/Program.cs +++ b/src/AdaptiveRemote.Headless/Program.cs @@ -33,8 +33,19 @@ internal static WebApplicationBuilder ConfigureStubSpeechServices(this WebApplic { builder.Services .AddSingleton() - .AddSingleton() - .AddSingleton(); + .AddSingleton(); + + // Use TestSpeechRecognitionEngine if test control port is specified, otherwise use stub + bool isTestMode = builder.Configuration.GetValue("test:ControlPort").HasValue; + if (isTestMode) + { + builder.Services.AddSingleton(); + } + else + { + builder.Services.AddSingleton(); + } + return builder; } diff --git a/test/AdaptiveRemote.EndToEndTests.Features/ConversationModalUI.feature b/test/AdaptiveRemote.EndToEndTests.Features/ConversationModalUI.feature index 68184eb..b08a00a 100644 --- a/test/AdaptiveRemote.EndToEndTests.Features/ConversationModalUI.feature +++ b/test/AdaptiveRemote.EndToEndTests.Features/ConversationModalUI.feature @@ -3,16 +3,12 @@ Feature: Conversation Modal UI 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 listening mode is activated - Given the application is not running - When I start the application - Then I should see the application in the Ready phase - And I should not see any warning or error messages in the logs - When I click on the text 'Say "Hey Remote" to get my attention' - Then I should see the text "I'm listening..." is visible - When I click on the text "I'm listening..." - Then I should see the text "I'm listening..." is not visible - And I should see the text 'Say "Hey Remote" to get my attention' is visible - When I click on the 'Exit' button - And I wait for the application to shut down - Then I should not see any warning or error messages in the logs +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 speaking message "Hey Remote" is visible + And I should see the text "I'm listening..." is visible + When I say "Stop Listening" + Then I should see the speaking message "Stop Listening" 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..49a4f3c --- /dev/null +++ b/test/AdaptiveRemote.EndToEndTests.Steps/ConversationSteps.cs @@ -0,0 +1,39 @@ +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."); + + // Wait a bit for the UI to update and speech processing + string? actualMessage = null; + for (int i = 0; i < 50; i++) // Try for up to 5 seconds + { + actualMessage = Host.UI.GetSpeakingMessage(); + if (actualMessage == expectedMessage) + { + Logger.LogInformation("Speaking message verified: {Message}", actualMessage); + return; + } + Thread.Sleep(100); + } + + Assert.Fail($"Expected speaking message '{expectedMessage}' but got '{actualMessage}'"); + } +} 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 8dbe2f1..0044122 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/IUITestServiceExtensions.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/IUITestServiceExtensions.cs @@ -127,4 +127,22 @@ public static void ClickText(this IUITestService service, string text, TimeSpan 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); } diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/PlaywrightUITestService.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/PlaywrightUITestService.cs index a381a0e..364b9c6 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/PlaywrightUITestService.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/PlaywrightUITestService.cs @@ -117,6 +117,27 @@ await locator.ClickAsync(new LocatorClickOptions }); } + 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); + } +} From 8fa666aceaa488ca88184d4691a2f6f4750ef851 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 05:39:25 +0000 Subject: [PATCH 4/8] Complete conversation modal UI test with speech simulation Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> --- .../ConversationModalUI.feature | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/test/AdaptiveRemote.EndToEndTests.Features/ConversationModalUI.feature b/test/AdaptiveRemote.EndToEndTests.Features/ConversationModalUI.feature index b08a00a..10aabf0 100644 --- a/test/AdaptiveRemote.EndToEndTests.Features/ConversationModalUI.feature +++ b/test/AdaptiveRemote.EndToEndTests.Features/ConversationModalUI.feature @@ -7,8 +7,6 @@ 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 speaking message "Hey Remote" is visible - And I should see the text "I'm listening..." is visible - When I say "Stop Listening" - Then I should see the speaking message "Stop Listening" is visible - And I should see the text "I'm listening..." is not visible + Then I should see the text "I'm listening..." is visible + When I say "Thank you" + Then I should see the text "I'm listening..." is not visible From b2fd5bc2087e4b0f515ad1ad1e2208beba4ff8ad Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 15:04:57 +0000 Subject: [PATCH 5/8] Refactor conversation steps to use WaitHelpers extension Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> --- .../ConversationModalUI.feature | 3 ++- .../ConversationSteps.cs | 16 ++++-------- .../IUITestServiceExtensions.cs | 26 +++++++++++++++++++ 3 files changed, 33 insertions(+), 12 deletions(-) diff --git a/test/AdaptiveRemote.EndToEndTests.Features/ConversationModalUI.feature b/test/AdaptiveRemote.EndToEndTests.Features/ConversationModalUI.feature index 10aabf0..90cf773 100644 --- a/test/AdaptiveRemote.EndToEndTests.Features/ConversationModalUI.feature +++ b/test/AdaptiveRemote.EndToEndTests.Features/ConversationModalUI.feature @@ -9,4 +9,5 @@ Scenario: Conversation modal message displays when speech is recognized 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 text "I'm listening..." is not visible + 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 index 49a4f3c..f203cd1 100644 --- a/test/AdaptiveRemote.EndToEndTests.Steps/ConversationSteps.cs +++ b/test/AdaptiveRemote.EndToEndTests.Steps/ConversationSteps.cs @@ -21,19 +21,13 @@ public void ThenIShouldSeeTheSpeakingMessageIsVisible(string expectedMessage) { Assert.IsNotNull(Host, "Cannot check speaking message. The application is not started."); - // Wait a bit for the UI to update and speech processing - string? actualMessage = null; - for (int i = 0; i < 50; i++) // Try for up to 5 seconds + bool found = Host.UI.WaitForSpeakingMessage(expectedMessage, timeoutInSeconds: 5); + if (!found) { - actualMessage = Host.UI.GetSpeakingMessage(); - if (actualMessage == expectedMessage) - { - Logger.LogInformation("Speaking message verified: {Message}", actualMessage); - return; - } - Thread.Sleep(100); + string? actualMessage = Host.UI.GetSpeakingMessage(timeoutInSeconds: 1); + Assert.Fail($"Expected speaking message '{expectedMessage}' but got '{actualMessage}'"); } - Assert.Fail($"Expected speaking message '{expectedMessage}' but got '{actualMessage}'"); + Logger.LogInformation("Speaking message verified: {Message}", expectedMessage); } } diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/IUITestServiceExtensions.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/IUITestServiceExtensions.cs index 0044122..7036424 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/IUITestServiceExtensions.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/IUITestServiceExtensions.cs @@ -145,4 +145,30 @@ public static void ClickText(this IUITestService service, string text, TimeSpan /// 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); + } } From 8fce469dff653b3cc87d37a7e5848e2f29eeb4b3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 15:11:21 +0000 Subject: [PATCH 6/8] Add test speech recognition for E2E conversation tests Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> --- .../Services/Lifecycle/AcceleratedServices.cs | 57 ++++++++- .../Services/Testing/ITestEndpoint.cs | 16 +++ .../Testing/TestEndpointCoordinator.cs | 121 ++++++++++++++++++ .../Services/Testing/TestEndpointService.cs | 27 +++- src/AdaptiveRemote.Headless/Program.cs | 36 ++---- 5 files changed, 233 insertions(+), 24 deletions(-) create mode 100644 src/AdaptiveRemote.App/Services/Testing/TestEndpointCoordinator.cs 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/ITestEndpoint.cs b/src/AdaptiveRemote.App/Services/Testing/ITestEndpoint.cs index 91e5660..c3ec7df 100644 --- a/src/AdaptiveRemote.App/Services/Testing/ITestEndpoint.cs +++ b/src/AdaptiveRemote.App/Services/Testing/ITestEndpoint.cs @@ -53,4 +53,20 @@ public partial interface ITestEndpoint /// 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/TestEndpointCoordinator.cs b/src/AdaptiveRemote.App/Services/Testing/TestEndpointCoordinator.cs new file mode 100644 index 0000000..342fc5e --- /dev/null +++ b/src/AdaptiveRemote.App/Services/Testing/TestEndpointCoordinator.cs @@ -0,0 +1,121 @@ +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. + /// + public bool WaitForTestInitialization() + { + if (!IsTestModeEnabled) + { + return true; // Not in test mode, continue immediately + } + + _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 bb53dc4..775b243 100644 --- a/src/AdaptiveRemote.App/Services/Testing/TestEndpointService.cs +++ b/src/AdaptiveRemote.App/Services/Testing/TestEndpointService.cs @@ -19,16 +19,19 @@ internal class TestEndpointService : BackgroundService, ITestEndpoint { private readonly TestingSettings _settings; private readonly IApplicationScopeProvider _scopeProvider; + private readonly TestEndpointCoordinator? _coordinator; private readonly MessageLogger _logger; private TcpListener? _listener; public TestEndpointService( IOptions settings, IApplicationScopeProvider scopeProvider, - ILogger logger) + ILogger logger, + TestEndpointCoordinator? coordinator = null) { _settings = settings.Value; _scopeProvider = scopeProvider; + _coordinator = coordinator; _logger = new(logger); } @@ -111,6 +114,28 @@ public Task CreateUITestServiceAsync(string assemblyPath, string 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 { diff --git a/src/AdaptiveRemote.Headless/Program.cs b/src/AdaptiveRemote.Headless/Program.cs index 6353be3..637f982 100644 --- a/src/AdaptiveRemote.Headless/Program.cs +++ b/src/AdaptiveRemote.Headless/Program.cs @@ -11,40 +11,32 @@ Args = args }; -WebApplication.CreateBuilder(options) - .ConfigureAppServices(args) +WebApplicationBuilder builder = WebApplication.CreateBuilder(options); + +// Configure accelerated services +AcceleratedServices accelerated = new(args); +accelerated.ConfigureHost(builder.Host); + +// Configure other services +builder .ConfigureStubSpeechServices() .ConfigureBlazorServices() - .ConfigurePlaywrightBrowser() + .ConfigurePlaywrightBrowser(); + +// Build and run +builder .Build() .AddHostingRoutes() .Run(); internal static class Configuration { - internal static WebApplicationBuilder ConfigureAppServices(this WebApplicationBuilder builder, string[] args) - { - AcceleratedServices accelerated = new(args); - accelerated.ConfigureHost(builder.Host); - return builder; - } - internal static WebApplicationBuilder ConfigureStubSpeechServices(this WebApplicationBuilder builder) { builder.Services .AddSingleton() - .AddSingleton(); - - // Use TestSpeechRecognitionEngine if test control port is specified, otherwise use stub - bool isTestMode = builder.Configuration.GetValue("test:ControlPort").HasValue; - if (isTestMode) - { - builder.Services.AddSingleton(); - } - else - { - builder.Services.AddSingleton(); - } + .AddSingleton() + .AddSingleton(); return builder; } From 4712a0c29b8bc1ca73aa1652bcf286b6f28afd42 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 15:24:00 +0000 Subject: [PATCH 7/8] WIP: Restructure test endpoint initialization with early listener Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> --- .../Services/Lifecycle/AcceleratedServices.cs | 26 +++ .../Testing/EarlyTestEndpointListener.cs | 170 ++++++++++++++++++ .../Testing/TestEndpointCoordinator.cs | 9 + .../Services/Testing/TestEndpointService.cs | 36 +++- src/AdaptiveRemote.Headless/Program.cs | 77 +++++++- 5 files changed, 311 insertions(+), 7 deletions(-) create mode 100644 src/AdaptiveRemote.App/Services/Testing/EarlyTestEndpointListener.cs diff --git a/src/AdaptiveRemote.App/Services/Lifecycle/AcceleratedServices.cs b/src/AdaptiveRemote.App/Services/Lifecycle/AcceleratedServices.cs index 0cd4978..9edefc2 100644 --- a/src/AdaptiveRemote.App/Services/Lifecycle/AcceleratedServices.cs +++ b/src/AdaptiveRemote.App/Services/Lifecycle/AcceleratedServices.cs @@ -74,6 +74,32 @@ public void ApplyTestServiceRegistrations(IServiceCollection services) _testCoordinator?.ApplyServiceRegistrations(services); } + /// + /// Creates a TestEndpointService for early initialization (before DI is fully configured). + /// + public ITestEndpoint? CreateEarlyTestEndpoint(IConfiguration configuration, ILoggerFactory loggerFactory) + { + if (_testCoordinator == null) + { + return null; + } + + int? controlPort = configuration.GetValue("test:ControlPort"); + if (!controlPort.HasValue) + { + return null; + } + + TestingSettings settings = new() { ControlPort = controlPort.Value }; + ILogger logger = loggerFactory.CreateLogger(); + + return new TestEndpointService( + Microsoft.Extensions.Options.Options.Create(settings), + null, // ScopeProvider will be null initially, but service creation happens later when DI is ready + logger, + _testCoordinator); + } + protected virtual void AddPrecreatedServices(IServiceCollection services) { // Apply test service registrations first diff --git a/src/AdaptiveRemote.App/Services/Testing/EarlyTestEndpointListener.cs b/src/AdaptiveRemote.App/Services/Testing/EarlyTestEndpointListener.cs new file mode 100644 index 0000000..f7c2a94 --- /dev/null +++ b/src/AdaptiveRemote.App/Services/Testing/EarlyTestEndpointListener.cs @@ -0,0 +1,170 @@ +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; + + 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, ITestEndpoint forwardTarget) + { + if (_listener == null) + { + throw new InvalidOperationException("Must call StartListening() before WaitForConnection()"); + } + + if (_client != null) + { + return true; // Already connected + } + + _forwardTarget = forwardTarget; + _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 + NetworkStream stream = _client.GetStream(); + ForwardingTestEndpoint forwarder = new(_coordinator, _forwardTarget, _logger); + JsonRpc rpc = JsonRpc.Attach(stream, forwarder); + + _logger?.LogInformation("RPC endpoint ready"); + return true; + } + catch (Exception ex) + { + _logger?.LogError(ex, "Failed to accept test connection"); + return false; + } + } + + /// + /// 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 readonly ITestEndpoint _target; + private readonly ILogger? _logger; + + public ForwardingTestEndpoint(TestEndpointCoordinator coordinator, ITestEndpoint target, ILogger? logger) + { + _coordinator = coordinator; + _target = target; + _logger = logger; + } + + // 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) + => _target.CreateTestServiceAsync(assemblyPath, typeName, cancellationToken); + + public Task CreateTestLoggerAsync(string assemblyPath, string typeName, CancellationToken cancellationToken) + => _target.CreateTestLoggerAsync(assemblyPath, typeName, cancellationToken); + + public Task CreateUITestServiceAsync(string assemblyPath, string typeName, CancellationToken cancellationToken) + => _target.CreateUITestServiceAsync(assemblyPath, typeName, cancellationToken); + + public Task CreateTestSpeechServiceAsync(string assemblyPath, string typeName, CancellationToken cancellationToken) + => _target.CreateTestSpeechServiceAsync(assemblyPath, typeName, cancellationToken); + } +} diff --git a/src/AdaptiveRemote.App/Services/Testing/TestEndpointCoordinator.cs b/src/AdaptiveRemote.App/Services/Testing/TestEndpointCoordinator.cs index 342fc5e..0501264 100644 --- a/src/AdaptiveRemote.App/Services/Testing/TestEndpointCoordinator.cs +++ b/src/AdaptiveRemote.App/Services/Testing/TestEndpointCoordinator.cs @@ -52,6 +52,7 @@ public void ContinueStartup() /// /// 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() { @@ -60,6 +61,14 @@ public bool WaitForTestInitialization() 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); diff --git a/src/AdaptiveRemote.App/Services/Testing/TestEndpointService.cs b/src/AdaptiveRemote.App/Services/Testing/TestEndpointService.cs index 775b243..578db5c 100644 --- a/src/AdaptiveRemote.App/Services/Testing/TestEndpointService.cs +++ b/src/AdaptiveRemote.App/Services/Testing/TestEndpointService.cs @@ -14,18 +14,20 @@ 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, + IApplicationScopeProvider? scopeProvider, ILogger logger, TestEndpointCoordinator? coordinator = null) { @@ -43,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); @@ -139,6 +166,11 @@ public Task ContinueStartupAsync(CancellationToken cancellationToken) 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.Headless/Program.cs b/src/AdaptiveRemote.Headless/Program.cs index 637f982..79c2e05 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() { @@ -15,19 +19,82 @@ // Configure accelerated services AcceleratedServices accelerated = new(args); + +// If in test mode, set up early test endpoint listener +EarlyTestEndpointListener? earlyListener = null; +TestEndpointCoordinator? testCoordinator = null; +ITestEndpoint? testEndpoint = 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); + + // Initialize accelerated services with coordinator + accelerated.InitializeTestCoordinator(builder.Configuration, loggerFactory); + + // Create TestEndpointService early via factory + testEndpoint = accelerated.CreateEarlyTestEndpoint(builder.Configuration, loggerFactory); + + if (testEndpoint != null) + { + // Start early listener with forwarding to TestEndpointService + ILogger listenerLogger = loggerFactory.CreateLogger(); + earlyListener = new EarlyTestEndpointListener(builder.Configuration, testCoordinator, listenerLogger); + earlyListener.StartListening(); + + // Wait for test connection + if (!earlyListener.WaitForConnection(TimeSpan.FromSeconds(30), testEndpoint)) + { + Console.Error.WriteLine("Failed to establish test connection within timeout"); + Environment.Exit(1); + return; + } + + // 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); +} + +// Add pre-created TestEndpoint as HostedService if available +if (testEndpoint is IHostedService hostedService) +{ + builder.Services.AddSingleton(hostedService); +} + // Configure other services builder .ConfigureStubSpeechServices() .ConfigureBlazorServices() .ConfigurePlaywrightBrowser(); -// Build and run -builder - .Build() - .AddHostingRoutes() - .Run(); +// Build the app +WebApplication app = builder.Build(); + +// Dispose early listener (connection is now handled by TestEndpointService) +earlyListener?.Dispose(); + +// Add routes and run +app.AddHostingRoutes().Run(); internal static class Configuration { From b38c08235ab1618f2ae233adad36896b897d2128 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 15:27:34 +0000 Subject: [PATCH 8/8] Complete test endpoint initialization restructuring architecture Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> --- .../Services/Lifecycle/AcceleratedServices.cs | 26 ------- .../Testing/EarlyTestEndpointListener.cs | 63 +++++++++++++--- src/AdaptiveRemote.Headless/Program.cs | 73 ++++++++++--------- 3 files changed, 89 insertions(+), 73 deletions(-) diff --git a/src/AdaptiveRemote.App/Services/Lifecycle/AcceleratedServices.cs b/src/AdaptiveRemote.App/Services/Lifecycle/AcceleratedServices.cs index 9edefc2..0cd4978 100644 --- a/src/AdaptiveRemote.App/Services/Lifecycle/AcceleratedServices.cs +++ b/src/AdaptiveRemote.App/Services/Lifecycle/AcceleratedServices.cs @@ -74,32 +74,6 @@ public void ApplyTestServiceRegistrations(IServiceCollection services) _testCoordinator?.ApplyServiceRegistrations(services); } - /// - /// Creates a TestEndpointService for early initialization (before DI is fully configured). - /// - public ITestEndpoint? CreateEarlyTestEndpoint(IConfiguration configuration, ILoggerFactory loggerFactory) - { - if (_testCoordinator == null) - { - return null; - } - - int? controlPort = configuration.GetValue("test:ControlPort"); - if (!controlPort.HasValue) - { - return null; - } - - TestingSettings settings = new() { ControlPort = controlPort.Value }; - ILogger logger = loggerFactory.CreateLogger(); - - return new TestEndpointService( - Microsoft.Extensions.Options.Options.Create(settings), - null, // ScopeProvider will be null initially, but service creation happens later when DI is ready - logger, - _testCoordinator); - } - protected virtual void AddPrecreatedServices(IServiceCollection services) { // Apply test service registrations first diff --git a/src/AdaptiveRemote.App/Services/Testing/EarlyTestEndpointListener.cs b/src/AdaptiveRemote.App/Services/Testing/EarlyTestEndpointListener.cs index f7c2a94..cdc421a 100644 --- a/src/AdaptiveRemote.App/Services/Testing/EarlyTestEndpointListener.cs +++ b/src/AdaptiveRemote.App/Services/Testing/EarlyTestEndpointListener.cs @@ -20,6 +20,7 @@ public class EarlyTestEndpointListener : IDisposable private TcpClient? _client; private bool _disposed; private ITestEndpoint? _forwardTarget; + private ForwardingTestEndpoint? _forwardingEndpoint; public EarlyTestEndpointListener( IConfiguration configuration, @@ -58,7 +59,7 @@ public void StartListening() /// 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, ITestEndpoint forwardTarget) + public bool WaitForConnection(TimeSpan timeout) { if (_listener == null) { @@ -70,7 +71,6 @@ public bool WaitForConnection(TimeSpan timeout, ITestEndpoint forwardTarget) return true; // Already connected } - _forwardTarget = forwardTarget; _logger?.LogInformation("Waiting for test connection (timeout: {Timeout})", timeout); try @@ -86,9 +86,10 @@ public bool WaitForConnection(TimeSpan timeout, ITestEndpoint forwardTarget) _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(); - ForwardingTestEndpoint forwarder = new(_coordinator, _forwardTarget, _logger); - JsonRpc rpc = JsonRpc.Attach(stream, forwarder); + _forwardingEndpoint = new ForwardingTestEndpoint(_coordinator, _logger); + JsonRpc rpc = JsonRpc.Attach(stream, _forwardingEndpoint); _logger?.LogInformation("RPC endpoint ready"); return true; @@ -100,6 +101,18 @@ public bool WaitForConnection(TimeSpan timeout, ITestEndpoint forwardTarget) } } + /// + /// 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. /// @@ -131,16 +144,20 @@ public void Dispose() private class ForwardingTestEndpoint : ITestEndpoint { private readonly TestEndpointCoordinator _coordinator; - private readonly ITestEndpoint _target; + private ITestEndpoint? _target; private readonly ILogger? _logger; - public ForwardingTestEndpoint(TestEndpointCoordinator coordinator, ITestEndpoint target, ILogger? logger) + public ForwardingTestEndpoint(TestEndpointCoordinator coordinator, ILogger? logger) { _coordinator = coordinator; - _target = target; _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) { @@ -156,15 +173,39 @@ public Task ContinueStartupAsync(CancellationToken cancellationToken) // Service creation methods - forward to TestEndpointService (which has scope provider) public Task CreateTestServiceAsync(string assemblyPath, string typeName, CancellationToken cancellationToken) - => _target.CreateTestServiceAsync(assemblyPath, typeName, 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) - => _target.CreateTestLoggerAsync(assemblyPath, typeName, 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) - => _target.CreateUITestServiceAsync(assemblyPath, typeName, 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) - => _target.CreateTestSpeechServiceAsync(assemblyPath, typeName, 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.Headless/Program.cs b/src/AdaptiveRemote.Headless/Program.cs index 79c2e05..8c07ca8 100644 --- a/src/AdaptiveRemote.Headless/Program.cs +++ b/src/AdaptiveRemote.Headless/Program.cs @@ -23,7 +23,6 @@ // If in test mode, set up early test endpoint listener EarlyTestEndpointListener? earlyListener = null; TestEndpointCoordinator? testCoordinator = null; -ITestEndpoint? testEndpoint = null; if (builder.Configuration.GetValue("test:ControlPort").HasValue) { @@ -32,38 +31,32 @@ 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); - // Create TestEndpointService early via factory - testEndpoint = accelerated.CreateEarlyTestEndpoint(builder.Configuration, loggerFactory); - - if (testEndpoint != null) + // Wait for test to register services and signal ready + if (!accelerated.WaitForTestInitialization()) { - // Start early listener with forwarding to TestEndpointService - ILogger listenerLogger = loggerFactory.CreateLogger(); - earlyListener = new EarlyTestEndpointListener(builder.Configuration, testCoordinator, listenerLogger); - earlyListener.StartListening(); - - // Wait for test connection - if (!earlyListener.WaitForConnection(TimeSpan.FromSeconds(30), testEndpoint)) - { - Console.Error.WriteLine("Failed to establish test connection within timeout"); - Environment.Exit(1); - return; - } - - // 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(); + 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 @@ -75,12 +68,6 @@ builder.Services.AddSingleton(testCoordinator); } -// Add pre-created TestEndpoint as HostedService if available -if (testEndpoint is IHostedService hostedService) -{ - builder.Services.AddSingleton(hostedService); -} - // Configure other services builder .ConfigureStubSpeechServices() @@ -90,12 +77,26 @@ // Build the app WebApplication app = builder.Build(); -// Dispose early listener (connection is now handled by TestEndpointService) -earlyListener?.Dispose(); +// After build, set the forward target for early listener +if (earlyListener != null) +{ + // Get TestEndpointService from DI (it's registered via OptionallyAddTestHookEndpoint in ConfigureApp) + var testEndpointService = app.Services.GetServices() + .OfType() + .FirstOrDefault(); + + if (testEndpointService != null) + { + 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)