Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 56 additions & 1 deletion src/AdaptiveRemote.App/Services/Lifecycle/AcceleratedServices.cs
Original file line number Diff line number Diff line change
@@ -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; }
Expand All @@ -31,8 +35,59 @@ public void ConfigureHost(IHostBuilder hostBuilder)
.ConfigureServices(AddPrecreatedServices);
}

/// <summary>
/// Initializes test coordinator if in test mode and waits for test initialization.
/// Should be called before building the host.
/// </summary>
public void InitializeTestCoordinator(IConfiguration configuration, ILoggerFactory? loggerFactory = null)
{
ILogger<TestEndpointCoordinator>? logger = loggerFactory?.CreateLogger<TestEndpointCoordinator>();
_testCoordinator = new TestEndpointCoordinator(configuration, logger);

if (_testCoordinator.IsTestModeEnabled)
{
// Coordinator will be signaled by test via RPC
// The WaitForTestInitialization will be called later before Build()
}
}

/// <summary>
/// Waits for test initialization if in test mode.
/// Returns true if ready to continue, false if timeout.
/// </summary>
public bool WaitForTestInitialization()
{
if (_testCoordinator == null)
{
return true; // No test coordinator, continue immediately
}

return _testCoordinator.WaitForTestInitialization();
}

/// <summary>
/// Applies pending test service registrations.
/// Should be called before adding other services.
/// </summary>
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);
}
}
}
211 changes: 211 additions & 0 deletions src/AdaptiveRemote.App/Services/Testing/EarlyTestEndpointListener.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using StreamJsonRpc;
using System.Net;
using System.Net.Sockets;

namespace AdaptiveRemote.Services.Testing;

/// <summary>
/// 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().
/// </summary>
public class EarlyTestEndpointListener : IDisposable
{
private readonly TestEndpointCoordinator _coordinator;
private readonly ILogger<EarlyTestEndpointListener>? _logger;
private readonly int _controlPort;
private TcpListener? _listener;
private TcpClient? _client;
private bool _disposed;
private ITestEndpoint? _forwardTarget;
private ForwardingTestEndpoint? _forwardingEndpoint;

public EarlyTestEndpointListener(
IConfiguration configuration,
TestEndpointCoordinator coordinator,
ILogger<EarlyTestEndpointListener>? logger = null)
{
_coordinator = coordinator;
_logger = logger;

int? port = configuration.GetValue<int?>("test:ControlPort");
if (!port.HasValue)
{
throw new InvalidOperationException("EarlyTestEndpointListener requires test:ControlPort to be configured");
}

_controlPort = port.Value;
}

/// <summary>
/// Starts listening for the first test connection.
/// </summary>
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();
}

/// <summary>
/// Waits for and accepts the first test connection, then sets up RPC with a forwarding target.
/// Returns true if connection established, false if timeout.
/// </summary>
public bool WaitForConnection(TimeSpan timeout)
{
if (_listener == null)
{
throw new InvalidOperationException("Must call StartListening() before WaitForConnection()");
}

if (_client != null)
{
return true; // Already connected
}

_logger?.LogInformation("Waiting for test connection (timeout: {Timeout})", timeout);

try
{
Task<TcpClient> acceptTask = _listener.AcceptTcpClientAsync();
if (!acceptTask.Wait(timeout))
{
_logger?.LogError("Timeout waiting for test connection");
return false;
}

_client = acceptTask.Result;
_logger?.LogInformation("Test client connected");

// Set up JSON-RPC on the connection with a forwarding wrapper
// Target will be set later via SetForwardTarget
NetworkStream stream = _client.GetStream();
_forwardingEndpoint = new ForwardingTestEndpoint(_coordinator, _logger);
JsonRpc rpc = JsonRpc.Attach(stream, _forwardingEndpoint);

_logger?.LogInformation("RPC endpoint ready");
return true;
}
catch (Exception ex)
{
_logger?.LogError(ex, "Failed to accept test connection");
return false;
}
}

/// <summary>
/// Sets the forward target for service creation calls.
/// Should be called after the host is built and TestEndpointService is available.
/// </summary>
public void SetForwardTarget(ITestEndpoint target)
{
if (_forwardingEndpoint != null)
{
_forwardingEndpoint.SetTarget(target);
}
}

/// <summary>
/// Stops listening for new connections.
/// </summary>
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();
}

/// <summary>
/// Forwarding wrapper that handles early calls and forwards others to TestEndpointService.
/// </summary>
private class ForwardingTestEndpoint : ITestEndpoint
{
private readonly TestEndpointCoordinator _coordinator;
private ITestEndpoint? _target;
private readonly ILogger? _logger;

public ForwardingTestEndpoint(TestEndpointCoordinator coordinator, ILogger? logger)
{
_coordinator = coordinator;
_logger = logger;
}

public void SetTarget(ITestEndpoint target)
{
_target = target;
}

// Early initialization methods - handle locally
public Task RegisterServiceAsync(string serviceTypeName, string implementationTypeName, string assemblyPath, CancellationToken cancellationToken)
{
_coordinator.RegisterService(serviceTypeName, implementationTypeName, assemblyPath);
return Task.CompletedTask;
}

public Task ContinueStartupAsync(CancellationToken cancellationToken)
{
_coordinator.ContinueStartup();
return Task.CompletedTask;
}

// Service creation methods - forward to TestEndpointService (which has scope provider)
public Task<IApplicationTestService> CreateTestServiceAsync(string assemblyPath, string typeName, CancellationToken cancellationToken)
{
if (_target == null)
{
throw new InvalidOperationException("Forward target not set. Call SetForwardTarget after host is built.");
}
return _target.CreateTestServiceAsync(assemblyPath, typeName, cancellationToken);
}

public Task<ITestLogger> CreateTestLoggerAsync(string assemblyPath, string typeName, CancellationToken cancellationToken)
{
if (_target == null)
{
throw new InvalidOperationException("Forward target not set. Call SetForwardTarget after host is built.");
}
return _target.CreateTestLoggerAsync(assemblyPath, typeName, cancellationToken);
}

public Task<IUITestService> CreateUITestServiceAsync(string assemblyPath, string typeName, CancellationToken cancellationToken)
{
if (_target == null)
{
throw new InvalidOperationException("Forward target not set. Call SetForwardTarget after host is built.");
}
return _target.CreateUITestServiceAsync(assemblyPath, typeName, cancellationToken);
}

public Task<ITestSpeechRecognitionService> CreateTestSpeechServiceAsync(string assemblyPath, string typeName, CancellationToken cancellationToken)
{
if (_target == null)
{
throw new InvalidOperationException("Forward target not set. Call SetForwardTarget after host is built.");
}
return _target.CreateTestSpeechServiceAsync(assemblyPath, typeName, cancellationToken);
}
}
}
26 changes: 26 additions & 0 deletions src/AdaptiveRemote.App/Services/Testing/ITestEndpoint.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,30 @@ public partial interface ITestEndpoint
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A proxy to the UI test service that can be used to interact with the UI.</returns>
Task<IUITestService> CreateUITestServiceAsync(string assemblyPath, string typeName, CancellationToken cancellationToken);

/// <summary>
/// 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.
/// </summary>
/// <param name="assemblyPath">Full path to the assembly containing the test speech service type.</param>
/// <param name="typeName">Fully qualified name of the test speech service type to instantiate.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A proxy to the test speech service that can be used to simulate speech.</returns>
Task<ITestSpeechRecognitionService> CreateTestSpeechServiceAsync(string assemblyPath, string typeName, CancellationToken cancellationToken);

/// <summary>
/// Registers a service implementation to be added to the host's DI container before startup.
/// Must be called before ContinueStartup().
/// </summary>
/// <param name="serviceTypeName">Fully qualified name of the service interface or abstract type.</param>
/// <param name="implementationTypeName">Fully qualified name of the implementation type.</param>
/// <param name="assemblyPath">Full path to the assembly containing the implementation type.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
Task RegisterServiceAsync(string serviceTypeName, string implementationTypeName, string assemblyPath, CancellationToken cancellationToken);

/// <summary>
/// Signals that test initialization is complete and the host can continue with its startup sequence.
/// </summary>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
Task ContinueStartupAsync(CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using PolyType;
using StreamJsonRpc;

namespace AdaptiveRemote.Services.Testing;

/// <summary>
/// Interface for controlling speech recognition in tests.
/// Allows tests to simulate speech input programmatically.
/// </summary>
[RpcMarshalable]
[JsonRpcContract]
[GenerateShape(IncludeMethods = MethodShapeFlags.PublicInstance)]
public partial interface ITestSpeechRecognitionService : IDisposable
{
/// <summary>
/// Simulates speaking a phrase that should be recognized by the speech recognition system.
/// </summary>
/// <param name="text">The text that was "spoken".</param>
/// <param name="confidence">Confidence level (0-100), defaults to 80.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>Task that completes when the speech has been processed.</returns>
Task SpeakPhraseAsync(string text, int confidence, CancellationToken cancellationToken);
}
24 changes: 24 additions & 0 deletions src/AdaptiveRemote.App/Services/Testing/IUITestService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,28 @@ public partial interface IUITestService : IDisposable
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <exception cref="InvalidOperationException">Thrown if multiple buttons match the label or if the button is not visible/enabled.</exception>
Task ClickButtonAsync(string label, CancellationToken cancellationToken);

/// <summary>
/// Checks if text content is visible in the UI.
/// </summary>
/// <param name="text">The text to search for (case-sensitive).</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>True if the text is visible anywhere in the UI, false otherwise.</returns>
Task<bool> IsTextVisibleAsync(string text, CancellationToken cancellationToken);

/// <summary>
/// Clicks on an element containing the specified text in the UI.
/// The element must be visible and clickable.
/// </summary>
/// <param name="text">The exact text content to find and click (case-sensitive).</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <exception cref="InvalidOperationException">Thrown if the text is not found or not clickable.</exception>
Task ClickTextAsync(string text, CancellationToken cancellationToken);

/// <summary>
/// Gets the text content from the conversation speaking message div, if visible.
/// </summary>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The speaking message text if visible, otherwise null.</returns>
Task<string?> GetSpeakingMessageAsync(CancellationToken cancellationToken);
}
Loading