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)