From e6b0a7757b11c8fc409997284e88f5433b40c763 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 18:55:54 +0000 Subject: [PATCH 1/8] Initial plan From 5af7142c1527ddff3f74f1776ddc712e1f737e34 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 19:01:38 +0000 Subject: [PATCH 2/8] Add SimulatedTiVo device implementation with interfaces Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> --- .../Host/ITestEnvironment.cs | 29 ++++ .../Host/TestEnvironment.cs | 86 ++++++++++ .../SimulatedTiVo/ITestDevice.cs | 29 ++++ .../SimulatedTiVo/ITestDeviceBuilder.cs | 20 +++ .../SimulatedTiVo/RecordedMessage.cs | 22 +++ .../SimulatedTiVo/SimulatedTiVoDevice.cs | 148 ++++++++++++++++++ .../SimulatedTiVoDeviceBuilder.cs | 40 +++++ 7 files changed, 374 insertions(+) create mode 100644 test/AdaptiveRemote.EndtoEndTests.TestServices/Host/ITestEnvironment.cs create mode 100644 test/AdaptiveRemote.EndtoEndTests.TestServices/Host/TestEnvironment.cs create mode 100644 test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/ITestDevice.cs create mode 100644 test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/ITestDeviceBuilder.cs create mode 100644 test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/RecordedMessage.cs create mode 100644 test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/SimulatedTiVoDevice.cs create mode 100644 test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/SimulatedTiVoDeviceBuilder.cs diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/ITestEnvironment.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/ITestEnvironment.cs new file mode 100644 index 0000000..d1446ea --- /dev/null +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/ITestEnvironment.cs @@ -0,0 +1,29 @@ +namespace AdaptiveRemote.EndtoEndTests.SimulatedTiVo; + +/// +/// Manages simulated test devices for a test run. +/// +public interface ITestEnvironment : IDisposable +{ + /// + /// Registers a device builder with the given name. + /// + /// The unique name for the device. + /// The device builder to register. + void RegisterDevice(string name, ITestDeviceBuilder builder); + + /// + /// Starts a registered device and returns the running device instance. + /// + /// The name of the device to start. + /// The running test device. + ITestDevice StartDevice(string name); + + /// + /// Attempts to retrieve a running device by name. + /// + /// The name of the device to retrieve. + /// The running device, if found. + /// True if the device was found; otherwise, false. + bool TryGetDevice(string name, out ITestDevice? device); +} diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/TestEnvironment.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/TestEnvironment.cs new file mode 100644 index 0000000..b72a1d5 --- /dev/null +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/TestEnvironment.cs @@ -0,0 +1,86 @@ +using AdaptiveRemote.EndtoEndTests.SimulatedTiVo; + +namespace AdaptiveRemote.EndtoEndTests.Host; + +/// +/// Default implementation of . +/// +public sealed class TestEnvironment : ITestEnvironment +{ + private readonly Dictionary _builders = new(); + private readonly Dictionary _devices = new(); + private bool _disposed; + + /// + public void RegisterDevice(string name, ITestDeviceBuilder builder) + { + if (_builders.ContainsKey(name)) + { + throw new InvalidOperationException($"Device with name '{name}' is already registered."); + } + + _builders[name] = builder; + } + + /// + public ITestDevice StartDevice(string name) + { + if (!_builders.TryGetValue(name, out ITestDeviceBuilder? builder)) + { + throw new InvalidOperationException($"No device builder registered with name '{name}'."); + } + + if (_devices.ContainsKey(name)) + { + throw new InvalidOperationException($"Device with name '{name}' is already started."); + } + + ITestDevice device = builder.Start(); + _devices[name] = device; + return device; + } + + /// + public bool TryGetDevice(string name, out ITestDevice? device) + { + return _devices.TryGetValue(name, out device); + } + + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + foreach (ITestDevice device in _devices.Values) + { + try + { + device.Dispose(); + } + catch + { + // Ignore disposal errors + } + } + + _devices.Clear(); + + foreach (ITestDeviceBuilder builder in _builders.Values) + { + try + { + builder.Dispose(); + } + catch + { + // Ignore disposal errors + } + } + + _builders.Clear(); + _disposed = true; + } +} diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/ITestDevice.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/ITestDevice.cs new file mode 100644 index 0000000..f77dab7 --- /dev/null +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/ITestDevice.cs @@ -0,0 +1,29 @@ +namespace AdaptiveRemote.EndtoEndTests.SimulatedTiVo; + +/// +/// Represents a running simulated test device. +/// All methods are synchronous for ease of use in test scenarios. +/// +public interface ITestDevice : IDisposable +{ + /// + /// Stops the device and releases resources. Safe to call multiple times. + /// + void Stop(); + + /// + /// Gets the TCP port the device is listening on. Valid while device is running. + /// + int Port { get; } + + /// + /// Returns a copy of messages recorded by the device. + /// + /// A read-only list of recorded messages. + IReadOnlyList GetRecordedMessages(); + + /// + /// Clears all recorded messages to prepare for the next test. + /// + void ClearRecordedMessages(); +} diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/ITestDeviceBuilder.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/ITestDeviceBuilder.cs new file mode 100644 index 0000000..983b384 --- /dev/null +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/ITestDeviceBuilder.cs @@ -0,0 +1,20 @@ +namespace AdaptiveRemote.EndtoEndTests.SimulatedTiVo; + +/// +/// Builder pattern for configuring and starting a simulated test device. +/// +public interface ITestDeviceBuilder : IDisposable +{ + /// + /// Configures the TCP port for the device. Use 0 for an ephemeral port. + /// + /// The port number to use. + /// This builder instance for fluent configuration. + ITestDeviceBuilder WithPort(int port); + + /// + /// Starts the device synchronously and returns the running device. + /// + /// A running test device instance. + ITestDevice Start(); +} diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/RecordedMessage.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/RecordedMessage.cs new file mode 100644 index 0000000..64f7a28 --- /dev/null +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/RecordedMessage.cs @@ -0,0 +1,22 @@ +namespace AdaptiveRemote.EndtoEndTests.SimulatedTiVo; + +/// +/// Represents a message recorded by a simulated test device. +/// +public sealed record RecordedMessage +{ + /// + /// Gets the timestamp when the message was recorded. + /// + public DateTimeOffset Timestamp { get; init; } + + /// + /// Gets the raw ASCII payload of the message. + /// + public string Payload { get; init; } = string.Empty; + + /// + /// Gets a value indicating whether this message was received from the application (true) or sent to it (false). + /// + public bool Incoming { get; init; } +} diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/SimulatedTiVoDevice.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/SimulatedTiVoDevice.cs new file mode 100644 index 0000000..d22af7b --- /dev/null +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/SimulatedTiVoDevice.cs @@ -0,0 +1,148 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.Sockets; +using System.Text; +using Microsoft.Extensions.Logging; + +namespace AdaptiveRemote.EndtoEndTests.SimulatedTiVo; + +/// +/// Simulates a TiVo device for E2E testing. +/// Accepts TCP connections and records messages according to the TiVo protocol. +/// +public sealed class SimulatedTiVoDevice : ITestDevice +{ + private readonly ILogger _logger; + private readonly TcpListener _listener; + private readonly CancellationTokenSource _cancellationTokenSource = new(); + private readonly ConcurrentBag _recordedMessages = new(); + private readonly Task _listenerTask; + private bool _disposed; + + internal SimulatedTiVoDevice(int port, ILogger logger) + { + _logger = logger; + _listener = new TcpListener(IPAddress.Loopback, port); + _listener.Start(); + Port = ((IPEndPoint)_listener.LocalEndpoint).Port; + + _logger.LogInformation("SimulatedTiVoDevice started on port {Port}", Port); + + _listenerTask = Task.Run(() => AcceptConnectionsAsync(_cancellationTokenSource.Token)); + } + + /// + public int Port { get; } + + /// + public IReadOnlyList GetRecordedMessages() + { + return _recordedMessages.ToList(); + } + + /// + public void ClearRecordedMessages() + { + _recordedMessages.Clear(); + _logger.LogInformation("Cleared recorded messages"); + } + + /// + public void Stop() + { + if (_disposed) + { + return; + } + + _logger.LogInformation("Stopping SimulatedTiVoDevice on port {Port}", Port); + + _cancellationTokenSource.Cancel(); + _listener.Stop(); + + try + { + WaitHelpers.WaitForAsyncTask(_ => _listenerTask, TimeSpan.FromSeconds(5)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error waiting for listener task to complete"); + } + + _disposed = true; + } + + /// + public void Dispose() + { + Stop(); + _cancellationTokenSource.Dispose(); + } + + private async Task AcceptConnectionsAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + TcpClient client = await _listener.AcceptTcpClientAsync(cancellationToken); + _logger.LogInformation("Accepted connection from {RemoteEndPoint}", client.Client.RemoteEndPoint); + + // Handle client in background + _ = Task.Run(() => HandleClientAsync(client, cancellationToken), cancellationToken); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error accepting connection"); + break; + } + } + } + + private async Task HandleClientAsync(TcpClient client, CancellationToken cancellationToken) + { + try + { + using (client) + using (NetworkStream stream = client.GetStream()) + using (StreamReader reader = new(stream, Encoding.ASCII)) + { + while (!cancellationToken.IsCancellationRequested && client.Connected) + { + string? line = await reader.ReadLineAsync(cancellationToken); + + if (line is null) + { + break; + } + + RecordedMessage message = new RecordedMessage + { + Timestamp = DateTimeOffset.UtcNow, + Payload = line, + Incoming = true + }; + + _recordedMessages.Add(message); + _logger.LogInformation("Received message: {Payload}", line); + } + } + } + catch (OperationCanceledException) + { + // Expected on shutdown + } + catch (Exception ex) + { + _logger.LogError(ex, "Error handling client connection"); + } + finally + { + _logger.LogInformation("Client disconnected"); + } + } +} diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/SimulatedTiVoDeviceBuilder.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/SimulatedTiVoDeviceBuilder.cs new file mode 100644 index 0000000..17f8172 --- /dev/null +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/SimulatedTiVoDeviceBuilder.cs @@ -0,0 +1,40 @@ +using Microsoft.Extensions.Logging; + +namespace AdaptiveRemote.EndtoEndTests.SimulatedTiVo; + +/// +/// Builder for creating and starting a SimulatedTiVoDevice. +/// +public sealed class SimulatedTiVoDeviceBuilder : ITestDeviceBuilder +{ + private readonly ILogger _logger; + private int _port = 0; // Default to ephemeral port + + /// + /// Initializes a new instance of the class. + /// + /// Logger for the simulated device. + public SimulatedTiVoDeviceBuilder(ILogger logger) + { + _logger = logger; + } + + /// + public ITestDeviceBuilder WithPort(int port) + { + _port = port; + return this; + } + + /// + public ITestDevice Start() + { + return new SimulatedTiVoDevice(_port, _logger); + } + + /// + public void Dispose() + { + // No resources to dispose in builder + } +} From bfada98156f1a8dcdbfb67d68e0c8eaabfddeac1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 19:05:13 +0000 Subject: [PATCH 3/8] Add test steps and host integration for simulated TiVo device Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> --- .../HostSteps.cs | 24 +++++- .../StepsBase.cs | 20 ++++- .../TiVoSteps.cs | 81 +++++++++++++++++++ .../Logging/TestContextLoggerExtensions.cs | 10 +++ 4 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 test/AdaptiveRemote.EndToEndTests.Steps/TiVoSteps.cs diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/HostSteps.cs b/test/AdaptiveRemote.EndToEndTests.Steps/HostSteps.cs index 79cc181..8adc509 100644 --- a/test/AdaptiveRemote.EndToEndTests.Steps/HostSteps.cs +++ b/test/AdaptiveRemote.EndToEndTests.Steps/HostSteps.cs @@ -1,6 +1,7 @@ using AdaptiveRemote.EndtoEndTests; using AdaptiveRemote.EndtoEndTests.Host; using AdaptiveRemote.EndtoEndTests.Logging; +using AdaptiveRemote.EndtoEndTests.SimulatedTiVo; using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; using Reqnroll; @@ -13,6 +14,13 @@ public class HostSteps : StepsBase private string LogFilePath => Path.Combine(TestContext.TestResultsDirectory!, TestContext.TestName + ".log"); [BeforeScenario] + public void OnBeforeScenario_SetUpTestEnvironment() + { + ITestEnvironment testEnvironment = new TestEnvironment(); + ProvideContainerObject(testEnvironment); + } + + [BeforeScenario(Order = 200)] public void OnBeforeScenario_SetUpHostFactory(AdaptiveRemoteHostSettings hostSettings) { if (!File.Exists(hostSettings.ExePath)) @@ -25,7 +33,17 @@ public void OnBeforeScenario_SetUpHostFactory(AdaptiveRemoteHostSettings hostSet Assert.Inconclusive($"Working directory not found: {hostSettings.WorkingDirectory}"); } - hostSettings = hostSettings.AddCommandLineArgs($"--tivo:Fake=True --broadlink:Fake=True --log:FilePath=\"{LogFilePath}\""); + // Check if we have a simulated TiVo device running + ITestEnvironment testEnvironment = GetContainerObject(); + string tivoArgs = "--tivo:Fake=True"; + + if (testEnvironment.TryGetDevice("TiVo", out ITestDevice? tivoDevice) && tivoDevice != null) + { + // Use the simulated device instead of fake + tivoArgs = $"--tivo:IP=127.0.0.1:{tivoDevice.Port}"; + } + + hostSettings = hostSettings.AddCommandLineArgs($"{tivoArgs} --broadlink:Fake=True --log:FilePath=\"{LogFilePath}\""); ProvideContainerObjectFactory(() => AdaptiveRemoteHost.CreateBuilder(hostSettings) .ConfigureLogging(builder => @@ -89,5 +107,9 @@ public void OnAfterScenario_AttachLogsToTestContextAndStopHost() { Host.Stop(); } + + // Clean up test environment + ITestEnvironment? testEnvironment = GetContainerObjectOrDefault(); + testEnvironment?.Dispose(); } } diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/StepsBase.cs b/test/AdaptiveRemote.EndToEndTests.Steps/StepsBase.cs index e6e7cf1..85a66ed 100644 --- a/test/AdaptiveRemote.EndToEndTests.Steps/StepsBase.cs +++ b/test/AdaptiveRemote.EndToEndTests.Steps/StepsBase.cs @@ -22,13 +22,31 @@ public abstract class StepsBase : IContainerDependentObject public ILogger Logger => _logger ??= Host.CreateLogger(GetType().Name); - private ObjectType GetContainerObject() + protected ObjectType GetContainerObject() where ObjectType : notnull { Assert.IsNotNull(_container, "Attempting to access container object before IContainerDependentObject.SetObjectContainer has been called"); return _container.Resolve(); } + protected ObjectType? GetContainerObjectOrDefault() + where ObjectType : class + { + if (_container == null) + { + return null; + } + + try + { + return _container.Resolve(); + } + catch + { + return null; + } + } + protected void ProvideContainerObject(ObjectType instance) where ObjectType : class { diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/TiVoSteps.cs b/test/AdaptiveRemote.EndToEndTests.Steps/TiVoSteps.cs new file mode 100644 index 0000000..ab6227b --- /dev/null +++ b/test/AdaptiveRemote.EndToEndTests.Steps/TiVoSteps.cs @@ -0,0 +1,81 @@ +using AdaptiveRemote.EndtoEndTests; +using AdaptiveRemote.EndtoEndTests.Logging; +using AdaptiveRemote.EndtoEndTests.SimulatedTiVo; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Reqnroll; + +namespace AdaptiveRemote.EndToEndTests.Steps; + +[Binding] +public class TiVoSteps : StepsBase +{ + private const string TiVoDeviceName = "TiVo"; + + [BeforeScenario("@tivo", Order = 100)] + public void OnBeforeScenario_StartSimulatedTiVoDevice() + { + ITestEnvironment testEnvironment = GetContainerObject(); + Microsoft.Extensions.Logging.ILogger logger = TestContext.GetLogger("SimulatedTiVoDevice"); + + ITestDeviceBuilder builder = new SimulatedTiVoDeviceBuilder(logger); + testEnvironment.RegisterDevice(TiVoDeviceName, builder); + + ITestDevice device = testEnvironment.StartDevice(TiVoDeviceName); + + TestContext.WriteLine($"Simulated TiVo device started on port {device.Port}"); + } + + [Given(@"there is a simulated TiVo device")] + public void GivenThereIsASimulatedTiVoDevice() + { + ITestEnvironment testEnvironment = GetContainerObject(); + + if (!testEnvironment.TryGetDevice(TiVoDeviceName, out ITestDevice? _)) + { + Assert.Fail("Simulated TiVo device is not running. Ensure the test scenario is tagged with @tivo."); + } + + TestContext.WriteLine("Simulated TiVo device is running"); + } + + [Given(@"the application is in the Ready state")] + public void GivenTheApplicationIsInTheReadyState() + { + if (!IsHostRunning) + { + Assert.Fail("Cannot check application state. The application is not started."); + } + + // Wait for the application to be in Ready state + Host.Application.WaitForPhase(LifecyclePhase.Ready, timeout: TimeSpan.FromSeconds(60)); + } + + [Then(@"I should see the TiVo receives a {string} message")] + public void ThenIShouldSeeTheTiVoReceivesAMessage(string expectedCommand) + { + ITestEnvironment testEnvironment = GetContainerObject(); + + if (!testEnvironment.TryGetDevice(TiVoDeviceName, out ITestDevice? device)) + { + Assert.Fail("TiVo device is not running"); + } + + // Poll for the message with a timeout of 3 seconds + bool found = WaitHelpers.ExecuteWithRetries( + () => + { + IReadOnlyList messages = device!.GetRecordedMessages(); + return messages.Any(m => m.Incoming && m.Payload.Contains(expectedCommand, StringComparison.OrdinalIgnoreCase)); + }, + timeoutInSeconds: 3); + + if (!found) + { + IReadOnlyList messages = device!.GetRecordedMessages(); + string recordedMessages = string.Join(", ", messages.Select(m => $"[{m.Timestamp:HH:mm:ss.fff}] {(m.Incoming ? "←" : "→")} {m.Payload}")); + Assert.Fail($"Expected TiVo to receive a message containing '{expectedCommand}', but it was not found. Recorded messages: {recordedMessages}"); + } + + TestContext.WriteLine($"Successfully verified TiVo received message containing: {expectedCommand}"); + } +} diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/Logging/TestContextLoggerExtensions.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/Logging/TestContextLoggerExtensions.cs index 7a73b92..0bf10e9 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/Logging/TestContextLoggerExtensions.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/Logging/TestContextLoggerExtensions.cs @@ -7,4 +7,14 @@ public static class TestContextLoggerExtensions { public static ILoggingBuilder AddTestContext(this ILoggingBuilder builder, TestContext testContext) => builder.AddProvider(new TestContextLoggerProvider(testContext)); + + public static ILogger GetLogger(this TestContext testContext, string categoryName) + { + ILoggerFactory factory = LoggerFactory.Create(builder => + { + builder.AddTestContext(testContext); + }); + + return factory.CreateLogger(categoryName); + } } From 85f83ca0c30d626bc11a5611eb47e726085fd93b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 19:11:21 +0000 Subject: [PATCH 4/8] Fix SimulatedTiVoDevice to handle \r line terminator and add TiVo E2E test Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> --- .../TiVoDevice.feature | 17 +++++++++++++ .../HostSteps.cs | 2 +- .../TiVoSteps.cs | 4 ++-- .../SimulatedTiVo/SimulatedTiVoDevice.cs | 24 ++++++++++++++++++- 4 files changed, 43 insertions(+), 4 deletions(-) create mode 100644 test/AdaptiveRemote.EndToEndTests.Features/TiVoDevice.feature diff --git a/test/AdaptiveRemote.EndToEndTests.Features/TiVoDevice.feature b/test/AdaptiveRemote.EndToEndTests.Features/TiVoDevice.feature new file mode 100644 index 0000000..4e09f94 --- /dev/null +++ b/test/AdaptiveRemote.EndToEndTests.Features/TiVoDevice.feature @@ -0,0 +1,17 @@ +@tivo +Feature: TiVo Device Integration + As a user + I want the application to communicate with TiVo devices + So that I can control my TiVo using the adaptive remote + +Scenario: TiVo receives Play command + Given there is a simulated TiVo device + And 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 'Play' button + Then I should see the TiVo receives a "PLAY" message + 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/HostSteps.cs b/test/AdaptiveRemote.EndToEndTests.Steps/HostSteps.cs index 8adc509..af5c607 100644 --- a/test/AdaptiveRemote.EndToEndTests.Steps/HostSteps.cs +++ b/test/AdaptiveRemote.EndToEndTests.Steps/HostSteps.cs @@ -13,7 +13,7 @@ public class HostSteps : StepsBase { private string LogFilePath => Path.Combine(TestContext.TestResultsDirectory!, TestContext.TestName + ".log"); - [BeforeScenario] + [BeforeScenario(Order = 50)] public void OnBeforeScenario_SetUpTestEnvironment() { ITestEnvironment testEnvironment = new TestEnvironment(); diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/TiVoSteps.cs b/test/AdaptiveRemote.EndToEndTests.Steps/TiVoSteps.cs index ab6227b..47ba8c1 100644 --- a/test/AdaptiveRemote.EndToEndTests.Steps/TiVoSteps.cs +++ b/test/AdaptiveRemote.EndToEndTests.Steps/TiVoSteps.cs @@ -60,14 +60,14 @@ public void ThenIShouldSeeTheTiVoReceivesAMessage(string expectedCommand) Assert.Fail("TiVo device is not running"); } - // Poll for the message with a timeout of 3 seconds + // Poll for the message with a timeout of 5 seconds bool found = WaitHelpers.ExecuteWithRetries( () => { IReadOnlyList messages = device!.GetRecordedMessages(); return messages.Any(m => m.Incoming && m.Payload.Contains(expectedCommand, StringComparison.OrdinalIgnoreCase)); }, - timeoutInSeconds: 3); + timeoutInSeconds: 5); if (!found) { diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/SimulatedTiVoDevice.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/SimulatedTiVoDevice.cs index d22af7b..6c45f37 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/SimulatedTiVoDevice.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/SimulatedTiVoDevice.cs @@ -113,7 +113,7 @@ private async Task HandleClientAsync(TcpClient client, CancellationToken cancell { while (!cancellationToken.IsCancellationRequested && client.Connected) { - string? line = await reader.ReadLineAsync(cancellationToken); + string? line = await ReadLineAsync(reader, cancellationToken); if (line is null) { @@ -145,4 +145,26 @@ private async Task HandleClientAsync(TcpClient client, CancellationToken cancell _logger.LogInformation("Client disconnected"); } } + + private static async Task ReadLineAsync(StreamReader reader, CancellationToken cancellationToken) + { + StringBuilder line = new(); + int value; + + while ((value = await Task.Run(reader.Read, cancellationToken)) >= 0) + { + char c = (char)value; + + if (c == '\r' || c == '\n') + { + // End of line reached + return line.ToString(); + } + + line.Append(c); + } + + // End of stream reached + return line.Length > 0 ? line.ToString() : null; + } } From 468f7a2ee8896f4a5eeec564ead76eddc48ba2f0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 19:12:07 +0000 Subject: [PATCH 5/8] Replace spec with documentation for SimulatedTiVoDevice Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> --- .../_doc_SimulatedTiVoDevice.md | 216 ++++++++++++++++++ .../_spec_SimulatedTiVoDevice.md | 126 ---------- 2 files changed, 216 insertions(+), 126 deletions(-) create mode 100644 test/AdaptiveRemote.EndtoEndTests.TestServices/_doc_SimulatedTiVoDevice.md delete mode 100644 test/AdaptiveRemote.EndtoEndTests.TestServices/_spec_SimulatedTiVoDevice.md diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/_doc_SimulatedTiVoDevice.md b/test/AdaptiveRemote.EndtoEndTests.TestServices/_doc_SimulatedTiVoDevice.md new file mode 100644 index 0000000..3903b4b --- /dev/null +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/_doc_SimulatedTiVoDevice.md @@ -0,0 +1,216 @@ +# Simulated TiVo Device for Testing + +Issue: ADR-120 — Simulated TiVo device for testing + +## Overview + +The simulated TiVo device provides a locally runnable test double that implements the TiVo TCP-based protocol, enabling end-to-end tests to validate TiVo device interactions without requiring physical hardware. + +## Architecture + +### Components + +#### SimulatedTiVoDevice +The core TCP server that listens for connections and records incoming messages. + +**Location:** `test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/SimulatedTiVoDevice.cs` + +**Key Features:** +- Binds to loopback (127.0.0.1) by default for security +- Supports ephemeral ports (port 0) for parallel test execution +- Records all incoming messages with timestamps +- Implements the TiVo line-based ASCII protocol (messages terminated with `\r`) +- Thread-safe message recording using `ConcurrentBag` +- Async TCP connection handling +- Clean shutdown support + +#### TestEnvironment +Manages the lifecycle of simulated devices during test runs. + +**Location:** `test/AdaptiveRemote.EndtoEndTests.TestServices/Host/TestEnvironment.cs` + +**Key Features:** +- Device registration and lifecycle management +- Name-based device lookup +- Automatic cleanup on disposal +- Builder pattern support + +#### Test Steps +Integration with Reqnroll test framework via step definitions. + +**Location:** `test/AdaptiveRemote.EndToEndTests.Steps/TiVoSteps.cs` + +**Key Features:** +- `@tivo` tag support for scenarios requiring simulated device +- Automatic device startup before host initialization +- Message verification with configurable timeout +- Clear error messages with recorded message history + +### Interfaces + +```csharp +// Base recorded message structure +public sealed record RecordedMessage +{ + public DateTimeOffset Timestamp { get; init; } + public string Payload { get; init; } + public bool Incoming { get; init; } +} + +// Running device interface +public interface ITestDevice : IDisposable +{ + void Stop(); + int Port { get; } + IReadOnlyList GetRecordedMessages(); + void ClearRecordedMessages(); +} + +// Device builder interface +public interface ITestDeviceBuilder : IDisposable +{ + ITestDeviceBuilder WithPort(int port); + ITestDevice Start(); +} + +// Test environment interface +public interface ITestEnvironment : IDisposable +{ + void RegisterDevice(string name, ITestDeviceBuilder builder); + ITestDevice StartDevice(string name); + bool TryGetDevice(string name, out ITestDevice? device); +} +``` + +## Protocol Implementation + +### TiVo TCP Protocol +- **Port:** Configurable (default 31339 for real devices; ephemeral for tests) +- **Transport:** TCP over loopback +- **Message Format:** Line-based ASCII with `\r` (carriage return) terminator +- **Command Format:** `IRCODE {command}\r` (e.g., `IRCODE PLAY\r`) + +### Key Implementation Details + +**Line Terminator Handling:** +The TiVo protocol uses `\r` (carriage return) as the line terminator, not the standard `\n` (line feed) or `\r\n` (carriage return + line feed). This required a custom `ReadLineAsync` implementation to properly detect end-of-line. + +**Message Recording:** +All incoming messages are recorded immediately upon receipt with: +- UTC timestamp +- Raw payload (without line terminator) +- Direction flag (incoming vs. outgoing) + +## Usage + +### Basic Test Scenario + +```gherkin +@tivo +Feature: TiVo Device Integration + Scenario: TiVo receives Play command + Given there is a simulated TiVo device + And the application is not running + When I start the application + Then I should see the application in the Ready phase + When I click on the 'Play' button + Then I should see the TiVo receives a "PLAY" message +``` + +### Test Step Definitions + +```csharp +[Given(@"there is a simulated TiVo device")] +public void GivenThereIsASimulatedTiVoDevice() +{ + // Device is automatically started via BeforeScenario hook + // This step just verifies it's running +} + +[Then(@"I should see the TiVo receives a {string} message")] +public void ThenIShouldSeeTheTiVoReceivesAMessage(string expectedCommand) +{ + // Polls for message with 5-second timeout + // Returns detailed error with all recorded messages on failure +} +``` + +### Configuration + +The simulated device is automatically configured when a test scenario is tagged with `@tivo`: + +1. **BeforeScenario (Order=50):** TestEnvironment is created +2. **BeforeScenario (Order=100, @tivo):** Simulated TiVo device is started on ephemeral port +3. **BeforeScenario (Order=200):** Host is started with `--tivo:IP=127.0.0.1:{port}` argument +4. **AfterScenario:** Cleanup of devices and host + +## Testing + +### Running E2E Tests + +```bash +# Build headless host for Linux +dotnet build src/AdaptiveRemote.Headless/AdaptiveRemote.Headless.csproj -r linux-x64 + +# Install Playwright browsers (one-time) +pwsh src/AdaptiveRemote.Headless/bin/Debug/net8.0/playwright.ps1 install chromium + +# Run all headless E2E tests +dotnet test test/AdaptiveRemote.EndToEndTests.HeadlessHost/AdaptiveRemote.EndToEndTests.HeadlessHost.csproj +``` + +### Test Results + +As of ADR-120 implementation: +- ✅ Application startup and shutdown without errors +- ✅ TiVo receives Play command + +Both tests pass consistently on Linux (Ubuntu) with Playwright headless browser. + +## Design Decisions + +### Why In-Process? +Running the simulated device in the test process (rather than as a separate service) provides: +- Simpler lifecycle management +- No inter-process communication overhead +- Direct access to recorded messages +- Easier debugging + +### Why Loopback Only? +Binding to loopback (127.0.0.1) by default ensures: +- No firewall configuration required +- No security risks from external connections +- Consistent behavior across environments + +### Why Ephemeral Ports? +Using port 0 (ephemeral) by default enables: +- Parallel test execution without port conflicts +- No need for port coordination between tests +- CI/CD pipeline compatibility + +### Why ConcurrentBag for Message Recording? +`ConcurrentBag` provides: +- Thread-safe message recording +- Low contention for add operations +- Simple API for test verification + +## Known Limitations + +1. **No Response Simulation:** The current implementation only records incoming messages. It does not send responses back to the client. This is sufficient for testing command transmission but not for testing response handling. + +2. **Single Connection:** While the device accepts multiple connections sequentially, it does not handle multiple simultaneous connections. This is not a limitation for current test scenarios. + +3. **No Message Replay:** Messages are only recorded, not replayed. Tests must poll for messages within the assertion timeout. + +## Future Enhancements + +- **Response Simulation:** Add support for scripted responses to enable testing of bidirectional communication +- **Message Filtering:** Add query methods for filtering recorded messages by timestamp, content, or pattern +- **Connection Metrics:** Track connection count, duration, and bandwidth for performance testing +- **Multi-Device Support:** Extend TestEnvironment to support multiple device types (e.g., Broadlink) + +## References + +- TiVo Protocol: Uses I8Beef.TiVo library implementation as reference +- Test Framework: Reqnroll (SpecFlow successor) with MSTest +- Original Specification: `test/AdaptiveRemote.EndtoEndTests.TestServices/_spec_SimulatedTiVoDevice.md` (superseded by this document) diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/_spec_SimulatedTiVoDevice.md b/test/AdaptiveRemote.EndtoEndTests.TestServices/_spec_SimulatedTiVoDevice.md deleted file mode 100644 index 101b36f..0000000 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/_spec_SimulatedTiVoDevice.md +++ /dev/null @@ -1,126 +0,0 @@ -# Simulated devices for testing - -Issue: ADR-120 — Simulated TiVo device for testing - --- - -**Scope:** -- Provide a locally runnable simulated TiVo device that speaks the same TCP-based protocol used by the real TiVo device so end-to-end tests can validate messages and behaviors. - - -**Goal:** -- Replace the current `NullCommandService`-based tests with a deterministic, inspectable test device that records and validates messages from the application under test. - -**Background:** -- Current tests use `NullCommandService` (via the `--tivo:Fake=True` command line argument) which cannot validate real protocol-level interactions. A simulated device will allow higher-fidelity E2E tests for device-specific flows. -- Related issues: ADR-120 (this doc). - -**Assumptions & Constraints:** - - Runs on Windows and Linux test hosts; should run in-process in the test process. - - Communication with the application is TCP-based and should accept/emit the same messages as the real device. - - Record messages via the project's logging pipeline (`ILogger`); CI captures test logs so no custom JSON persistence is required. - - Accessibility, security, and platform compatibility follow existing test-host constraints from the project. - - Concrete constraints: - - Bind TCP listener to loopback only by default. - - No admin/elevated privileges required to run. - - Do not write artifacts outside of test-controlled directories. - - Support Windows and Linux test runners used by CI; avoid native-only dependencies. - -**Functional Requirements:** -1. The simulator exposes a configurable TCP endpoint (port selectable per-test). -2. It accepts connections and parses incoming messages according to the TiVo protocol used by the app. -3. It records all inbound and outbound messages with timestamps for later inspection. -4. Scripted responses are out of scope for the initial implementation and may be added later if needed. -5. It provides a synchronous in-process .NET control API for tests to query recorded messages. -6. It runs embedded inside the test process (in-process only). Step/BeforeScenario bindings will be responsible for starting and stopping devices. - -**Non‑Functional Requirements:** -- Deterministic behavior under tests (configurable timeouts and delays). -- Lightweight; start/stop quickly to keep test run-time low. -- Secure by default: bind to loopback only unless explicitly configured. -- Logging must integrate with existing test logs and be easy to attach on CI failures. - -**Acceptance Criteria:** -- A test can start the simulator, perform an application flow, and then assert that a specific message was received within a timeout. -- Recorded message payloads are accessible to the test and match expected protocol fields. -- Simulator supports injecting a scripted response and the application under test reacts as if a real device responded. - -**Design / Architecture (high-level):** -- Component: `SimulatedTiVoDevice` - - TCP listener on configured port - - Message parser/serializer matching the TiVo protocol (derive behavior from `AdaptiveRemote.App/Services/TiVo/TiVoService.cs`). - - Recorder store (in-memory) and surface messages via `ILogger` so existing test logs capture them. - - Control API: in-process .NET API (synchronous) for test orchestration -- Component: `TestEnvironment` - - A high-level manager for test device builders and running devices. - - Implementation will live in the Host folder of `AdaptiveRemote.EndToEndTests.TestServices` so the test host builder can compose devices and the Step/BeforeScenario bindings can start/stop them. - -The following is a C# API that follows a DeviceBuilder pattern (preferred). Builders are responsible for construction and starting; started devices expose only synchronous methods for test use. Long-running or async work may be wrapped by `WaitHelpers.WaitForAsyncTask` in the implementation. - -```csharp -// Minimal representation of a recorded message (line-based ASCII payload) -public sealed record RecordedMessage -{ - public DateTimeOffset Timestamp { get; init; } - public string Payload { get; init; } = string.Empty; // raw ASCII line - public bool Incoming { get; init; } // true if received from the app -} - -// Represents a started, running test device. Methods are synchronous. -public interface ITestDevice : IDisposable -{ - // Stop the device and release resources. Safe to call multiple times. - void Stop(); - - // The TCP port the device is listening on. Valid while device is running. - int Port { get; } - - // Return a copy of recorded messages observed by the device. - IReadOnlyList GetRecordedMessages(); - - // Clear the messages that have been recorded so far, to prepare for the next test - void ClearRecordedMessages(); -} - -// Builder pattern: configure and start a device. Start returns a running ITestDevice. -public interface ITestDeviceBuilder : IDisposable -{ - // Optionally configure a preferred port (0 for ephemeral). - ITestDeviceBuilder WithPort(int port); - - // Start the device synchronously and return the running device. - ITestDevice Start(); -} - -// Simple TestEnvironment manager to hold builders/devices for a test run -public interface ITestEnvironment : IDisposable -{ - // Register device builder - void RegisterDevice(string name, ITestDeviceBuilder builder); - - // Start a named device and return the running device - ITestDevice StartDevice(string name); - - // Retrieve a running device - bool TryGetDevice(string name, out ITestDevice? device); -} -``` - -**Implementation Plan (proposed tasks):** -1. Create `SimulatedTiVoDevice` in a new folder within the TestServices project: `SimulatedTiVo` -2. Implement a minimal TCP listener and a simple message parser that accepts TiVo command messages (derive behavior from the client code in `AdaptiveRemote.App/Services/TiVo/TiVoService.cs`). -3. Add recorder and control API (`ITestDevice`) that exposes recorded messages for verification and logs messages via `ILogger`. -4. Add `TestEnvironment` to the Host folder of `AdaptiveRemote.EndToEndTests.TestServices` to manage simulated devices; Step/BeforeScenario bindings will call into it to start/stop devices. -5. Add step bindings and test hooks to `AdaptiveRemote.EndToEndTests.Steps` to register/start/stop devices. -6. Add Feature files to `AdaptiveRemote.EndToEndTests.Features` for new TiVo scenarios (Gherkin steps already proposed in the test suite). - -**Proposed Gherkin test steps:** - Given there is a simulated TiVo device #Ensure the TiVo simulator is running - And the application is in the Ready state #Ensure the application is started and ready; shut down and restart if it's in an error state - When I click on the Play button #Step already exists - Then I should see the TiVo receives a Play message #Check the TiVo simulator for the expected message - -**Risks & Mitigations:** -- Risk: Flaky timing interactions. Mitigation: Expose configurable delays/timeouts and use WaitHelpers.ExecuteWithRetries to poll for assertions. See `IApplicationTestServiceExtensions.WaitForPhase` as an example. - - From 7f9c377500d728ca4dab8673b776d819afc06615 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 19:15:05 +0000 Subject: [PATCH 6/8] Address code review feedback: improve thread safety, async patterns, and message matching Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> --- .../TiVoSteps.cs | 7 +++-- .../SimulatedTiVo/SimulatedTiVoDevice.cs | 29 ++++++++++++++----- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/TiVoSteps.cs b/test/AdaptiveRemote.EndToEndTests.Steps/TiVoSteps.cs index 47ba8c1..37674c4 100644 --- a/test/AdaptiveRemote.EndToEndTests.Steps/TiVoSteps.cs +++ b/test/AdaptiveRemote.EndToEndTests.Steps/TiVoSteps.cs @@ -61,11 +61,12 @@ public void ThenIShouldSeeTheTiVoReceivesAMessage(string expectedCommand) } // Poll for the message with a timeout of 5 seconds + // Look for the full IRCODE command format for more precise matching bool found = WaitHelpers.ExecuteWithRetries( () => { IReadOnlyList messages = device!.GetRecordedMessages(); - return messages.Any(m => m.Incoming && m.Payload.Contains(expectedCommand, StringComparison.OrdinalIgnoreCase)); + return messages.Any(m => m.Incoming && m.Payload.Equals($"IRCODE {expectedCommand}", StringComparison.OrdinalIgnoreCase)); }, timeoutInSeconds: 5); @@ -73,9 +74,9 @@ public void ThenIShouldSeeTheTiVoReceivesAMessage(string expectedCommand) { IReadOnlyList messages = device!.GetRecordedMessages(); string recordedMessages = string.Join(", ", messages.Select(m => $"[{m.Timestamp:HH:mm:ss.fff}] {(m.Incoming ? "←" : "→")} {m.Payload}")); - Assert.Fail($"Expected TiVo to receive a message containing '{expectedCommand}', but it was not found. Recorded messages: {recordedMessages}"); + Assert.Fail($"Expected TiVo to receive message 'IRCODE {expectedCommand}', but it was not found. Recorded messages: {recordedMessages}"); } - TestContext.WriteLine($"Successfully verified TiVo received message containing: {expectedCommand}"); + TestContext.WriteLine($"Successfully verified TiVo received message: IRCODE {expectedCommand}"); } } diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/SimulatedTiVoDevice.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/SimulatedTiVoDevice.cs index 6c45f37..89a812b 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/SimulatedTiVoDevice.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/SimulatedTiVoDevice.cs @@ -43,7 +43,12 @@ public IReadOnlyList GetRecordedMessages() /// public void ClearRecordedMessages() { - _recordedMessages.Clear(); + // Create a new bag instead of clearing the existing one for thread safety + while (_recordedMessages.TryTake(out _)) + { + // Remove all items + } + _logger.LogInformation("Cleared recorded messages"); } @@ -85,7 +90,8 @@ private async Task AcceptConnectionsAsync(CancellationToken cancellationToken) { try { - TcpClient client = await _listener.AcceptTcpClientAsync(cancellationToken); + // Use CancellationToken.None to explicitly indicate we handle cancellation via the while loop condition + TcpClient client = await _listener.AcceptTcpClientAsync(CancellationToken.None); _logger.LogInformation("Accepted connection from {RemoteEndPoint}", client.Client.RemoteEndPoint); // Handle client in background @@ -149,11 +155,20 @@ private async Task HandleClientAsync(TcpClient client, CancellationToken cancell private static async Task ReadLineAsync(StreamReader reader, CancellationToken cancellationToken) { StringBuilder line = new(); - int value; - while ((value = await Task.Run(reader.Read, cancellationToken)) >= 0) + while (!cancellationToken.IsCancellationRequested) { - char c = (char)value; + // Use ReadAsync with a buffer instead of Task.Run with synchronous Read + char[] buffer = new char[1]; + int bytesRead = await reader.ReadAsync(buffer, cancellationToken); + + if (bytesRead == 0) + { + // End of stream reached + return line.Length > 0 ? line.ToString() : null; + } + + char c = buffer[0]; if (c == '\r' || c == '\n') { @@ -164,7 +179,7 @@ private async Task HandleClientAsync(TcpClient client, CancellationToken cancell line.Append(c); } - // End of stream reached - return line.Length > 0 ? line.ToString() : null; + // Cancelled + return null; } } From 66a718dbd1d4f361c56a5a13f22e097e611b1108 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 28 Jan 2026 07:16:00 +0000 Subject: [PATCH 7/8] Rename ITest* to ISimulated*, remove GetContainerObjectOrDefault, move Ready state step to HostSteps, make simulated TiVo available for all tests Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> --- .../TiVoDevice.feature | 4 +- .../HostSteps.cs | 45 ++++++++++++++----- .../StepsBase.cs | 22 ++------- .../TiVoSteps.cs | 44 +----------------- ...nvironment.cs => ISimulatedEnvironment.cs} | 12 ++--- ...Environment.cs => SimulatedEnvironment.cs} | 22 ++++----- .../Logging/TestContextLoggerExtensions.cs | 10 ----- .../{ITestDevice.cs => ISimulatedDevice.cs} | 4 +- ...eBuilder.cs => ISimulatedDeviceBuilder.cs} | 10 ++--- .../SimulatedTiVo/SimulatedTiVoDevice.cs | 2 +- .../SimulatedTiVoDeviceBuilder.cs | 6 +-- 11 files changed, 68 insertions(+), 113 deletions(-) rename test/AdaptiveRemote.EndtoEndTests.TestServices/Host/{ITestEnvironment.cs => ISimulatedEnvironment.cs} (70%) rename test/AdaptiveRemote.EndtoEndTests.TestServices/Host/{TestEnvironment.cs => SimulatedEnvironment.cs} (65%) rename test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/{ITestDevice.cs => ISimulatedDevice.cs} (89%) rename test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/{ITestDeviceBuilder.cs => ISimulatedDeviceBuilder.cs} (62%) diff --git a/test/AdaptiveRemote.EndToEndTests.Features/TiVoDevice.feature b/test/AdaptiveRemote.EndToEndTests.Features/TiVoDevice.feature index 4e09f94..2314215 100644 --- a/test/AdaptiveRemote.EndToEndTests.Features/TiVoDevice.feature +++ b/test/AdaptiveRemote.EndToEndTests.Features/TiVoDevice.feature @@ -1,12 +1,10 @@ -@tivo Feature: TiVo Device Integration As a user I want the application to communicate with TiVo devices So that I can control my TiVo using the adaptive remote Scenario: TiVo receives Play command - Given there is a simulated TiVo device - And the application is not running + 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 diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/HostSteps.cs b/test/AdaptiveRemote.EndToEndTests.Steps/HostSteps.cs index af5c607..ceb9d9f 100644 --- a/test/AdaptiveRemote.EndToEndTests.Steps/HostSteps.cs +++ b/test/AdaptiveRemote.EndToEndTests.Steps/HostSteps.cs @@ -11,13 +11,28 @@ namespace AdaptiveRemote.EndToEndTests.Steps; [Binding] public class HostSteps : StepsBase { + private const string TiVoDeviceName = "TiVo"; private string LogFilePath => Path.Combine(TestContext.TestResultsDirectory!, TestContext.TestName + ".log"); [BeforeScenario(Order = 50)] - public void OnBeforeScenario_SetUpTestEnvironment() + public void OnBeforeScenario_SetUpSimulatedEnvironment() { - ITestEnvironment testEnvironment = new TestEnvironment(); - ProvideContainerObject(testEnvironment); + SimulatedEnvironment simulatedEnvironment = new SimulatedEnvironment(); + ProvideContainerObject(simulatedEnvironment); + + // Register and start the simulated TiVo device for all tests + // Create a simple logger that writes to TestContext + ILoggerFactory loggerFactory = LoggerFactory.Create(builder => + { + builder.AddProvider(new TestContextLoggerProvider(TestContext)); + }); + ILogger logger = loggerFactory.CreateLogger("SimulatedTiVoDevice"); + + ISimulatedDeviceBuilder builder = new SimulatedTiVoDeviceBuilder(logger); + simulatedEnvironment.RegisterDevice(TiVoDeviceName, builder); + ISimulatedDevice device = simulatedEnvironment.StartDevice(TiVoDeviceName); + + TestContext.WriteLine($"Simulated TiVo device started on port {device.Port}"); } [BeforeScenario(Order = 200)] @@ -33,13 +48,10 @@ public void OnBeforeScenario_SetUpHostFactory(AdaptiveRemoteHostSettings hostSet Assert.Inconclusive($"Working directory not found: {hostSettings.WorkingDirectory}"); } - // Check if we have a simulated TiVo device running - ITestEnvironment testEnvironment = GetContainerObject(); + // Use the simulated TiVo device string tivoArgs = "--tivo:Fake=True"; - - if (testEnvironment.TryGetDevice("TiVo", out ITestDevice? tivoDevice) && tivoDevice != null) + if (SimulatedEnvironment.TryGetDevice(TiVoDeviceName, out ISimulatedDevice? tivoDevice) && tivoDevice != null) { - // Use the simulated device instead of fake tivoArgs = $"--tivo:IP=127.0.0.1:{tivoDevice.Port}"; } @@ -69,6 +81,18 @@ public void GivenTheApplicationIsNotRunning() } } + [Given(@"the application is in the Ready state")] + public void GivenTheApplicationIsInTheReadyState() + { + if (!IsHostRunning) + { + Assert.Fail("Cannot check application state. The application is not started."); + } + + // Wait for the application to be in Ready state + Host.Application.WaitForPhase(LifecyclePhase.Ready, timeout: TimeSpan.FromSeconds(60)); + } + [When(@"I start the application")] public void WhenIStartTheApplication() { @@ -108,8 +132,7 @@ public void OnAfterScenario_AttachLogsToTestContextAndStopHost() Host.Stop(); } - // Clean up test environment - ITestEnvironment? testEnvironment = GetContainerObjectOrDefault(); - testEnvironment?.Dispose(); + // Clean up simulated environment + SimulatedEnvironment?.Dispose(); } } diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/StepsBase.cs b/test/AdaptiveRemote.EndToEndTests.Steps/StepsBase.cs index 85a66ed..d4385ed 100644 --- a/test/AdaptiveRemote.EndToEndTests.Steps/StepsBase.cs +++ b/test/AdaptiveRemote.EndToEndTests.Steps/StepsBase.cs @@ -1,4 +1,5 @@ using AdaptiveRemote.EndtoEndTests.Host; +using AdaptiveRemote.EndtoEndTests.SimulatedTiVo; using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; using Reqnroll.BoDi; @@ -10,6 +11,7 @@ public abstract class StepsBase : IContainerDependentObject { private IObjectContainer? _container; private AdaptiveRemoteHost? _host; + private ISimulatedEnvironment? _simulatedEnvironment; private ILogger? _logger; public void SetObjectContainer(IObjectContainer container) => _container = container; @@ -20,6 +22,8 @@ public abstract class StepsBase : IContainerDependentObject public TestContext TestContext => GetContainerObject(); + public ISimulatedEnvironment SimulatedEnvironment => _simulatedEnvironment ??= GetContainerObject(); + public ILogger Logger => _logger ??= Host.CreateLogger(GetType().Name); protected ObjectType GetContainerObject() @@ -29,24 +33,6 @@ protected ObjectType GetContainerObject() return _container.Resolve(); } - protected ObjectType? GetContainerObjectOrDefault() - where ObjectType : class - { - if (_container == null) - { - return null; - } - - try - { - return _container.Resolve(); - } - catch - { - return null; - } - } - protected void ProvideContainerObject(ObjectType instance) where ObjectType : class { diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/TiVoSteps.cs b/test/AdaptiveRemote.EndToEndTests.Steps/TiVoSteps.cs index 37674c4..7104dc4 100644 --- a/test/AdaptiveRemote.EndToEndTests.Steps/TiVoSteps.cs +++ b/test/AdaptiveRemote.EndToEndTests.Steps/TiVoSteps.cs @@ -1,5 +1,4 @@ using AdaptiveRemote.EndtoEndTests; -using AdaptiveRemote.EndtoEndTests.Logging; using AdaptiveRemote.EndtoEndTests.SimulatedTiVo; using Microsoft.VisualStudio.TestTools.UnitTesting; using Reqnroll; @@ -11,51 +10,10 @@ public class TiVoSteps : StepsBase { private const string TiVoDeviceName = "TiVo"; - [BeforeScenario("@tivo", Order = 100)] - public void OnBeforeScenario_StartSimulatedTiVoDevice() - { - ITestEnvironment testEnvironment = GetContainerObject(); - Microsoft.Extensions.Logging.ILogger logger = TestContext.GetLogger("SimulatedTiVoDevice"); - - ITestDeviceBuilder builder = new SimulatedTiVoDeviceBuilder(logger); - testEnvironment.RegisterDevice(TiVoDeviceName, builder); - - ITestDevice device = testEnvironment.StartDevice(TiVoDeviceName); - - TestContext.WriteLine($"Simulated TiVo device started on port {device.Port}"); - } - - [Given(@"there is a simulated TiVo device")] - public void GivenThereIsASimulatedTiVoDevice() - { - ITestEnvironment testEnvironment = GetContainerObject(); - - if (!testEnvironment.TryGetDevice(TiVoDeviceName, out ITestDevice? _)) - { - Assert.Fail("Simulated TiVo device is not running. Ensure the test scenario is tagged with @tivo."); - } - - TestContext.WriteLine("Simulated TiVo device is running"); - } - - [Given(@"the application is in the Ready state")] - public void GivenTheApplicationIsInTheReadyState() - { - if (!IsHostRunning) - { - Assert.Fail("Cannot check application state. The application is not started."); - } - - // Wait for the application to be in Ready state - Host.Application.WaitForPhase(LifecyclePhase.Ready, timeout: TimeSpan.FromSeconds(60)); - } - [Then(@"I should see the TiVo receives a {string} message")] public void ThenIShouldSeeTheTiVoReceivesAMessage(string expectedCommand) { - ITestEnvironment testEnvironment = GetContainerObject(); - - if (!testEnvironment.TryGetDevice(TiVoDeviceName, out ITestDevice? device)) + if (!SimulatedEnvironment.TryGetDevice(TiVoDeviceName, out ISimulatedDevice? device)) { Assert.Fail("TiVo device is not running"); } diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/ITestEnvironment.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/ISimulatedEnvironment.cs similarity index 70% rename from test/AdaptiveRemote.EndtoEndTests.TestServices/Host/ITestEnvironment.cs rename to test/AdaptiveRemote.EndtoEndTests.TestServices/Host/ISimulatedEnvironment.cs index d1446ea..e182f10 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/ITestEnvironment.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/ISimulatedEnvironment.cs @@ -1,23 +1,23 @@ namespace AdaptiveRemote.EndtoEndTests.SimulatedTiVo; /// -/// Manages simulated test devices for a test run. +/// Manages simulated devices for a test run. /// -public interface ITestEnvironment : IDisposable +public interface ISimulatedEnvironment : IDisposable { /// /// Registers a device builder with the given name. /// /// The unique name for the device. /// The device builder to register. - void RegisterDevice(string name, ITestDeviceBuilder builder); + void RegisterDevice(string name, ISimulatedDeviceBuilder builder); /// /// Starts a registered device and returns the running device instance. /// /// The name of the device to start. - /// The running test device. - ITestDevice StartDevice(string name); + /// The running simulated device. + ISimulatedDevice StartDevice(string name); /// /// Attempts to retrieve a running device by name. @@ -25,5 +25,5 @@ public interface ITestEnvironment : IDisposable /// The name of the device to retrieve. /// The running device, if found. /// True if the device was found; otherwise, false. - bool TryGetDevice(string name, out ITestDevice? device); + bool TryGetDevice(string name, out ISimulatedDevice? device); } diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/TestEnvironment.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/SimulatedEnvironment.cs similarity index 65% rename from test/AdaptiveRemote.EndtoEndTests.TestServices/Host/TestEnvironment.cs rename to test/AdaptiveRemote.EndtoEndTests.TestServices/Host/SimulatedEnvironment.cs index b72a1d5..674f270 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/TestEnvironment.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/SimulatedEnvironment.cs @@ -3,16 +3,16 @@ namespace AdaptiveRemote.EndtoEndTests.Host; /// -/// Default implementation of . +/// Default implementation of . /// -public sealed class TestEnvironment : ITestEnvironment +public sealed class SimulatedEnvironment : ISimulatedEnvironment { - private readonly Dictionary _builders = new(); - private readonly Dictionary _devices = new(); + private readonly Dictionary _builders = new(); + private readonly Dictionary _devices = new(); private bool _disposed; /// - public void RegisterDevice(string name, ITestDeviceBuilder builder) + public void RegisterDevice(string name, ISimulatedDeviceBuilder builder) { if (_builders.ContainsKey(name)) { @@ -23,9 +23,9 @@ public void RegisterDevice(string name, ITestDeviceBuilder builder) } /// - public ITestDevice StartDevice(string name) + public ISimulatedDevice StartDevice(string name) { - if (!_builders.TryGetValue(name, out ITestDeviceBuilder? builder)) + if (!_builders.TryGetValue(name, out ISimulatedDeviceBuilder? builder)) { throw new InvalidOperationException($"No device builder registered with name '{name}'."); } @@ -35,13 +35,13 @@ public ITestDevice StartDevice(string name) throw new InvalidOperationException($"Device with name '{name}' is already started."); } - ITestDevice device = builder.Start(); + ISimulatedDevice device = builder.Start(); _devices[name] = device; return device; } /// - public bool TryGetDevice(string name, out ITestDevice? device) + public bool TryGetDevice(string name, out ISimulatedDevice? device) { return _devices.TryGetValue(name, out device); } @@ -54,7 +54,7 @@ public void Dispose() return; } - foreach (ITestDevice device in _devices.Values) + foreach (ISimulatedDevice device in _devices.Values) { try { @@ -68,7 +68,7 @@ public void Dispose() _devices.Clear(); - foreach (ITestDeviceBuilder builder in _builders.Values) + foreach (ISimulatedDeviceBuilder builder in _builders.Values) { try { diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/Logging/TestContextLoggerExtensions.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/Logging/TestContextLoggerExtensions.cs index 0bf10e9..7a73b92 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/Logging/TestContextLoggerExtensions.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/Logging/TestContextLoggerExtensions.cs @@ -7,14 +7,4 @@ public static class TestContextLoggerExtensions { public static ILoggingBuilder AddTestContext(this ILoggingBuilder builder, TestContext testContext) => builder.AddProvider(new TestContextLoggerProvider(testContext)); - - public static ILogger GetLogger(this TestContext testContext, string categoryName) - { - ILoggerFactory factory = LoggerFactory.Create(builder => - { - builder.AddTestContext(testContext); - }); - - return factory.CreateLogger(categoryName); - } } diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/ITestDevice.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/ISimulatedDevice.cs similarity index 89% rename from test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/ITestDevice.cs rename to test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/ISimulatedDevice.cs index f77dab7..907d3c6 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/ITestDevice.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/ISimulatedDevice.cs @@ -1,10 +1,10 @@ namespace AdaptiveRemote.EndtoEndTests.SimulatedTiVo; /// -/// Represents a running simulated test device. +/// Represents a running simulated device. /// All methods are synchronous for ease of use in test scenarios. /// -public interface ITestDevice : IDisposable +public interface ISimulatedDevice : IDisposable { /// /// Stops the device and releases resources. Safe to call multiple times. diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/ITestDeviceBuilder.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/ISimulatedDeviceBuilder.cs similarity index 62% rename from test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/ITestDeviceBuilder.cs rename to test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/ISimulatedDeviceBuilder.cs index 983b384..8c9c354 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/ITestDeviceBuilder.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/ISimulatedDeviceBuilder.cs @@ -1,20 +1,20 @@ namespace AdaptiveRemote.EndtoEndTests.SimulatedTiVo; /// -/// Builder pattern for configuring and starting a simulated test device. +/// Builder pattern for configuring and starting a simulated device. /// -public interface ITestDeviceBuilder : IDisposable +public interface ISimulatedDeviceBuilder : IDisposable { /// /// Configures the TCP port for the device. Use 0 for an ephemeral port. /// /// The port number to use. /// This builder instance for fluent configuration. - ITestDeviceBuilder WithPort(int port); + ISimulatedDeviceBuilder WithPort(int port); /// /// Starts the device synchronously and returns the running device. /// - /// A running test device instance. - ITestDevice Start(); + /// A running simulated device instance. + ISimulatedDevice Start(); } diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/SimulatedTiVoDevice.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/SimulatedTiVoDevice.cs index 89a812b..af15b29 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/SimulatedTiVoDevice.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/SimulatedTiVoDevice.cs @@ -10,7 +10,7 @@ namespace AdaptiveRemote.EndtoEndTests.SimulatedTiVo; /// Simulates a TiVo device for E2E testing. /// Accepts TCP connections and records messages according to the TiVo protocol. /// -public sealed class SimulatedTiVoDevice : ITestDevice +public sealed class SimulatedTiVoDevice : ISimulatedDevice { private readonly ILogger _logger; private readonly TcpListener _listener; diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/SimulatedTiVoDeviceBuilder.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/SimulatedTiVoDeviceBuilder.cs index 17f8172..c435c31 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/SimulatedTiVoDeviceBuilder.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/SimulatedTiVo/SimulatedTiVoDeviceBuilder.cs @@ -5,7 +5,7 @@ namespace AdaptiveRemote.EndtoEndTests.SimulatedTiVo; /// /// Builder for creating and starting a SimulatedTiVoDevice. /// -public sealed class SimulatedTiVoDeviceBuilder : ITestDeviceBuilder +public sealed class SimulatedTiVoDeviceBuilder : ISimulatedDeviceBuilder { private readonly ILogger _logger; private int _port = 0; // Default to ephemeral port @@ -20,14 +20,14 @@ public SimulatedTiVoDeviceBuilder(ILogger logger) } /// - public ITestDeviceBuilder WithPort(int port) + public ISimulatedDeviceBuilder WithPort(int port) { _port = port; return this; } /// - public ITestDevice Start() + public ISimulatedDevice Start() { return new SimulatedTiVoDevice(_port, _logger); } From 61d38c9c32886cd2ad8c9348c1b6f0849c4c3158 Mon Sep 17 00:00:00 2001 From: Joe Davis Date: Wed, 28 Jan 2026 10:02:57 -0800 Subject: [PATCH 8/8] Some minor name changes and timing fixes --- .../AdaptiveRemote.EndToEndTests.Steps/HostSteps.cs | 4 ++-- .../AdaptiveRemote.EndToEndTests.Steps/StepsBase.cs | 4 ++-- .../AdaptiveRemote.EndToEndTests.Steps/TiVoSteps.cs | 2 +- .../IUITestServiceExtensions.cs | 13 ++++++++++--- 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/HostSteps.cs b/test/AdaptiveRemote.EndToEndTests.Steps/HostSteps.cs index ceb9d9f..beac06a 100644 --- a/test/AdaptiveRemote.EndToEndTests.Steps/HostSteps.cs +++ b/test/AdaptiveRemote.EndToEndTests.Steps/HostSteps.cs @@ -50,7 +50,7 @@ public void OnBeforeScenario_SetUpHostFactory(AdaptiveRemoteHostSettings hostSet // Use the simulated TiVo device string tivoArgs = "--tivo:Fake=True"; - if (SimulatedEnvironment.TryGetDevice(TiVoDeviceName, out ISimulatedDevice? tivoDevice) && tivoDevice != null) + if (Environment.TryGetDevice(TiVoDeviceName, out ISimulatedDevice? tivoDevice) && tivoDevice != null) { tivoArgs = $"--tivo:IP=127.0.0.1:{tivoDevice.Port}"; } @@ -133,6 +133,6 @@ public void OnAfterScenario_AttachLogsToTestContextAndStopHost() } // Clean up simulated environment - SimulatedEnvironment?.Dispose(); + Environment?.Dispose(); } } diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/StepsBase.cs b/test/AdaptiveRemote.EndToEndTests.Steps/StepsBase.cs index d4385ed..b841f37 100644 --- a/test/AdaptiveRemote.EndToEndTests.Steps/StepsBase.cs +++ b/test/AdaptiveRemote.EndToEndTests.Steps/StepsBase.cs @@ -22,11 +22,11 @@ public abstract class StepsBase : IContainerDependentObject public TestContext TestContext => GetContainerObject(); - public ISimulatedEnvironment SimulatedEnvironment => _simulatedEnvironment ??= GetContainerObject(); + public ISimulatedEnvironment Environment => _simulatedEnvironment ??= GetContainerObject(); public ILogger Logger => _logger ??= Host.CreateLogger(GetType().Name); - protected ObjectType GetContainerObject() + private ObjectType GetContainerObject() where ObjectType : notnull { Assert.IsNotNull(_container, "Attempting to access container object before IContainerDependentObject.SetObjectContainer has been called"); diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/TiVoSteps.cs b/test/AdaptiveRemote.EndToEndTests.Steps/TiVoSteps.cs index 7104dc4..fc860e0 100644 --- a/test/AdaptiveRemote.EndToEndTests.Steps/TiVoSteps.cs +++ b/test/AdaptiveRemote.EndToEndTests.Steps/TiVoSteps.cs @@ -13,7 +13,7 @@ public class TiVoSteps : StepsBase [Then(@"I should see the TiVo receives a {string} message")] public void ThenIShouldSeeTheTiVoReceivesAMessage(string expectedCommand) { - if (!SimulatedEnvironment.TryGetDevice(TiVoDeviceName, out ISimulatedDevice? device)) + if (!Environment.TryGetDevice(TiVoDeviceName, out ISimulatedDevice? device)) { Assert.Fail("TiVo device is not running"); } diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/IUITestServiceExtensions.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/IUITestServiceExtensions.cs index 9fba0d9..ccc92e1 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/IUITestServiceExtensions.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/IUITestServiceExtensions.cs @@ -68,10 +68,17 @@ public static void ClickButton(this IUITestService service, string label, int ti /// Thrown when the operation times out. public static void ClickButton(this IUITestService service, string label, TimeSpan timeout) { - bool succeeded = WaitHelpers.WaitForAsyncTask(ct => service.ClickButtonAsync(label, ct)); - if (!succeeded) + try { - throw new TimeoutException($"Clicking button '{label}' did not complete within timeout."); + bool succeeded = WaitHelpers.WaitForAsyncTask(ct => service.ClickButtonAsync(label, ct), timeout); + if (!succeeded) + { + throw new TimeoutException($"Clicking button '{label}' did not complete within timeout."); + } + } + catch (AggregateException ex) when (label.Equals("Exit", StringComparison.OrdinalIgnoreCase) && ex.InnerException is StreamJsonRpc.ConnectionLostException) + { + // This exception occurs sometimes when clicking the "Exit" button if the application shuts down to fast } } }