ADR-149: Test speech recognition service with pre-build injection#88
Conversation
Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com>
Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com>
Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com>
…se timeout Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com>
jodavis
left a comment
There was a problem hiding this comment.
@copilot Address these code review comments, and then try to get the new integration test running again. Some of the changes I've requested should fix the problems with the startup sequence. If not, try to solve it with more logging.
|
|
||
| void ISpeechRecognitionEngine.Recognize() | ||
| { | ||
| // In test mode, recognition is controlled by test calls, not automatic |
There was a problem hiding this comment.
We should block recognize calls from the test when recognition is not enabled, and throw an error to help diagnose why the call failed.
There was a problem hiding this comment.
Fixed in 7715211. Now throws InvalidOperationException when Recognize() is called without enabled grammars.
|
|
||
| private static int? ParseControlPort(string[] args) | ||
| { | ||
| // Build a minimal configuration to parse the test:ControlPort setting |
There was a problem hiding this comment.
I've been thinking we could just create this command line config and provide it as a property on AcceleratedServices, so that it can be parsed once and used by any future startup services that need command line settings. We could also strongly type the settings this way.
There was a problem hiding this comment.
Implemented in 7715211. CommandLineConfig property added to AcceleratedServices.
| if (controlPort.HasValue) | ||
| { | ||
| // Create a logger for the hooks service | ||
| using ILoggerFactory loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); |
There was a problem hiding this comment.
This could also be provided as a property from AcceleratedServices for any startup process that needs to log information (DiagnosticAdapter could use it, e.g.)
There was a problem hiding this comment.
Implemented in 7715211. LoggerFactory property added to AcceleratedServices.
| TestEndpoint = hooksService; | ||
|
|
||
| // Store the hooks service so it can be injected into TestEndpointService | ||
| _testEndpointHooksService = hooksService; |
There was a problem hiding this comment.
I don't like that we have to store a reference to the concrete type here for special interaction with the TestEndpointService. It's a bad code smell.
There was a problem hiding this comment.
Fixed in 7715211. TestEndpoint now uses interface type only, no concrete type references.
| .AddSingleton(ViewModel); | ||
|
|
||
| // If we have a test endpoint hooks service, register it so TestEndpointService can use it | ||
| if (_testEndpointHooksService is not null) |
There was a problem hiding this comment.
Again with the special reference to the concrete type. If we used the interface type, we could just put the DisabledTestEndpointHooks here instead, and we wouldn't need the conditional logic.
There was a problem hiding this comment.
Fixed in 7715211. Now uses interface type throughout, no conditional logic needed.
| .ConfigureApp(); | ||
|
|
||
| await acceleratedServices.TestEndpoint.InjectHostServiceAsync(hostBuilder, cancellationToken); | ||
| // Capture the service collection so we can pass it to test hooks |
There was a problem hiding this comment.
This won't work. You can't extract the serviceCollection like this, because the callback method isn't called right away. That's why the hostBuilder was passed in to InjectHostServicesAsync, instead of IServiceCollection--InjectHostServicesAsync will need to call hostBuilder.ConfigureServices(() => iterate over the service collection here) instead of configuring things directly against IServiceCollection.
There was a problem hiding this comment.
Fixed in 7715211. Updated to call InjectHostServiceAsync with only hostBuilder parameter. Service registration now uses hostBuilder.ConfigureServices callback.
| } | ||
|
|
||
| // Register all test services | ||
| foreach (ServiceRegistration registration in _testServices) |
There was a problem hiding this comment.
Per the other comment, this method can't rely on access to the IServiceCollection at this time. It needs to call hostBuilder.ConfigureServices(() => iterate over the ServiceRegistrations in this callback).
Then you can remove the IServiceCollection services argument that you added.
There was a problem hiding this comment.
Fixed in 7715211. Now uses hostBuilder.ConfigureServices with callback to register services.
| @@ -19,17 +19,23 @@ internal class TestEndpointService : BackgroundService, ITestEndpoint | |||
There was a problem hiding this comment.
Change the ownership of this so that it is not a BackgroundService created from the DI container. Instead, it should be owned directly by the TestEndpointServiceHooks (or maybe just be the same class). Then TestEndpointHooksService shouldn't need to be added to the DI container.
The implementation of TestServiceProvider should be separated out from this, and requested from the DI container after the IServiceProvider has been provided through ITestEndpointServiceHooks. That way it will get its dependencies, like IApplicationScopeProvider, and TestEndpointService won't have those dependencies before the host is built.
In other words, there should be two classes:
- TestEndpointService: Implements ITestEndpoint and ITestEndpointHooks, and coordinates communication between the startup sequence and the test process
- TestServiceProvider: Implements ITestServiceProvider, is registered with the DI container so it can get dependencies needed to create test services
TestEndpointService should only have the following dependencies:
- TestSettings which it can get from AcceleratedServices' command line parsing
- MessageLogger that it can get from AcceleratedServices' logger factory
- IHostApplicationLifetime which it can get from IServiceProvider once one has been pushed in; if it hasn't been pushed in yet, then the host is not running and can be directly aborted.
There was a problem hiding this comment.
Refactored in 7715211. Split into TestEndpointService (TCP communication with minimal dependencies: TestingSettings, ILoggerFactory) and TestServiceProvider (DI-registered service with IApplicationScopeProvider). TCP listener starts in AcceleratedServices before host build.
| I want to be able to control speech recognition programmatically | ||
| So that I can test the conversation UI | ||
|
|
||
| Scenario: Test speech recognition engine can trigger wake word |
There was a problem hiding this comment.
Make these into one test that starts listening and then stops it. The way it is, if they run out of order, the application could be left in listening mode and reused by another test. (If the test fails before it exits listening mode, the failure will cause the test framework to restart the host application, to put it back into a good state for the next test.)
There was a problem hiding this comment.
Combined in 7715211. Now single test that starts and stops listening.
Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com>
All code review comments addressed in commit 7715211. The deadlock issue is resolved - test endpoint now connects in <1 second (was timing out). The startup flow works correctly: TCP listener starts before host build, test connects, signals build, and retrieves test services. Only remaining test failure is Playwright browser installation which is unrelated to these changes. |
Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com>
Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com>
Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com>
…ture (WIP) Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com>
Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com>
| public class ConversationView : RemoteLayoutElement | ||
| { | ||
| internal static readonly MvvmProperty<bool> IsListeningProperty = new(nameof(IsListening)); | ||
| public static readonly MvvmProperty<bool> IsListeningProperty = new(nameof(IsListening)); |
There was a problem hiding this comment.
We should add InternalsVisibleTo for the TestServices assembly, instead of making an individual property public. The point of TestServices is to expose the internals of the application to tests.
There was a problem hiding this comment.
This should be in the Testing namespace rather than Conversation. That's where all the test APIs live.
There was a problem hiding this comment.
This should be in the TestServices assembly. Avoid adding test-only code to the App project as much as possible. (The TestEndpoint support unfortunately needs to live in the App assembly or else there would be no way to inject it. But JSON-RPC and service injection should be the only features that are built into the App).
|
|
||
| /// <summary> | ||
| /// Logger factory for startup processes. | ||
| /// Available for any startup services that need logging. |
There was a problem hiding this comment.
Add a comment here that this should only be used for startup services that log messages before the host is configured.
| TestEndpoint = new DisabledTestEndpointHooks(); | ||
|
|
||
| // Check if test control port is configured | ||
| int? controlPort = ParseControlPort(); |
There was a problem hiding this comment.
Use CommandLineConfig to get a TestingSettings object, and remove the custom ParseControlPort method.
There was a problem hiding this comment.
You should be able to use standard configuration methods (or maybe extension methods) to retrieve a TestingSettings object from the CommandLineConfig.
| Assert.IsNotNull(_testSpeechEngine, "Test speech recognition engine was not injected into the host"); | ||
| } | ||
|
|
||
| [When(@"I say ""(.*)""")] |
There was a problem hiding this comment.
Use the {string} Gherkin binding instead.
[When("I say {string}")]
| } | ||
|
|
||
| [When(@"I say ""(.*)""")] | ||
| public async Task WhenISayAsync(string phrase) |
There was a problem hiding this comment.
Do not create async step definitions. Use WaitHelpers to make them synchronous.
It's fine to block in the test process, all it's doing is running one synchronous test. Synchronous code is much easier to debug.
There was a problem hiding this comment.
Also, add notes to _doc_EndToEndTests.md explaining that this is how step definitions should be implemented.
| await _testSpeechEngine.SpeakAsync(phrase); | ||
|
|
||
| // Give the application time to process the speech input | ||
| await Task.Delay(500); |
There was a problem hiding this comment.
Remove this delay. We should poll for the expected result instead. See my next comment.
| public async Task ThenTheApplicationShouldEnterListeningModeAsync() | ||
| { | ||
| // Check that the application is in listening mode | ||
| bool isListening = await Host.Application.GetIsListeningAsync(CancellationToken.None); |
There was a problem hiding this comment.
Create a helper extension method WaitForIsListening(bool expected, int timeoutInSeconds) which uses WaitHelper.ExecuteWithRetries to poll until the IsListening setting reaches the expected value. The timeout should be long enough that we're pretty sure the application will never reach the desired state.
See other extensions for the pattern to follow. Use the same extension method in both this step definition and the one below.
There was a problem hiding this comment.
Also, add notes to _doc_EndToEndTests.md explaining that this is how step definitions should be implemented.
| return Task.CompletedTask; | ||
| } | ||
|
|
||
| public Task SpeakAsync(string phrase) |
There was a problem hiding this comment.
Since SpeakAsync just does a lot of processing on phrase and then calls RaiseRecognizedAsync, move it to an extension method. That way it will run in the test process and only call into the host to raise the event, which will be a lot easier to debug.
… steps Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com>
Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com>
…ssembly Playwright installed, tests now working - 3/4 pass Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com>
All changes implemented in commits b6da610, 8c274b6, and 77194d7. Tests now pass (3/4) after fixing RegisterTestService to load contract types from correct assembly and installing Playwright. The speech recognition test passes successfully. One unrelated test failure remains to investigate. |
Implements test-time speech recognition control per ADR-149. Tests can now programmatically trigger speech events (wake word, commands, stop listening) without audio hardware or System.Speech dependencies.
Architecture Changes
Interface Split for Lifecycle Management
ITestEndpoint→ pre-build operations (AddTestServiceAsync,BuildAndRunHostAsync)ITestServiceProvider→ post-build service creation (existingCreate*methods plusCreateTestSpeechEngineAsync)Pre-Build Service Injection
Startup Coordination
TestEndpointServicestarts TCP listener inAcceleratedServices(pre-build)BuildAndRunHostAsyncTest Speech Engine
Implementation Details
TestSpeechRecognitionEngine
ISpeechRecognitionEnginewith programmable event triggersITestSpeechRecognitionEngine(extendsIDisposablefor JSON-RPC)SpeakAsyncimplemented as extension method for easier debugging in test processApplicationTestService Enhancements
GetIsListeningAsync()- ExposesConversationView.IsListeningvia internalMvvmPropertyInternalsVisibleToattributeTest Infrastructure
TestSpeechRecognitionEngineby default (no conditional logic)WaitHelperspatternWaitForIsListening()extension polls until listening state matches expected value_doc_EndToEndTests.mdexplaining patternsKey Design Decisions
Separation of Concerns
ITestSpeechRecognitionEnginein App/Services/Testing namespace for JSON-RPC contractTestSpeechRecognitionEngineimplementation in TestServices assemblyInternalsVisibleTo Pattern
ConversationView.IsListeningProperty)Always-Inject Strategy
💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.