Skip to content
Merged
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
15 changes: 15 additions & 0 deletions test/AdaptiveRemote.EndToEndTests.Features/TiVoDevice.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
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 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
49 changes: 47 additions & 2 deletions test/AdaptiveRemote.EndToEndTests.Steps/HostSteps.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -10,9 +11,31 @@ 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]
[BeforeScenario(Order = 50)]
public void OnBeforeScenario_SetUpSimulatedEnvironment()
{
SimulatedEnvironment simulatedEnvironment = new SimulatedEnvironment();
ProvideContainerObject<ISimulatedEnvironment>(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)]
public void OnBeforeScenario_SetUpHostFactory(AdaptiveRemoteHostSettings hostSettings)
{
if (!File.Exists(hostSettings.ExePath))
Expand All @@ -25,7 +48,14 @@ 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}\"");
// Use the simulated TiVo device
string tivoArgs = "--tivo:Fake=True";
if (Environment.TryGetDevice(TiVoDeviceName, out ISimulatedDevice? tivoDevice) && tivoDevice != null)
{
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 =>
Expand All @@ -51,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()
{
Expand Down Expand Up @@ -89,5 +131,8 @@ public void OnAfterScenario_AttachLogsToTestContextAndStopHost()
{
Host.Stop();
}

// Clean up simulated environment
Environment?.Dispose();
}
}
4 changes: 4 additions & 0 deletions test/AdaptiveRemote.EndToEndTests.Steps/StepsBase.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using AdaptiveRemote.EndtoEndTests.Host;
using AdaptiveRemote.EndtoEndTests.SimulatedTiVo;
using Microsoft.Extensions.Logging;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Reqnroll.BoDi;
Expand All @@ -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;
Expand All @@ -20,6 +22,8 @@ public abstract class StepsBase : IContainerDependentObject

public TestContext TestContext => GetContainerObject<TestContext>();

public ISimulatedEnvironment Environment => _simulatedEnvironment ??= GetContainerObject<ISimulatedEnvironment>();

public ILogger Logger => _logger ??= Host.CreateLogger(GetType().Name);

private ObjectType GetContainerObject<ObjectType>()
Expand Down
40 changes: 40 additions & 0 deletions test/AdaptiveRemote.EndToEndTests.Steps/TiVoSteps.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using AdaptiveRemote.EndtoEndTests;
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";

[Then(@"I should see the TiVo receives a {string} message")]
public void ThenIShouldSeeTheTiVoReceivesAMessage(string expectedCommand)
{
if (!Environment.TryGetDevice(TiVoDeviceName, out ISimulatedDevice? device))
{
Assert.Fail("TiVo device is not running");
}

// 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<RecordedMessage> messages = device!.GetRecordedMessages();
return messages.Any(m => m.Incoming && m.Payload.Equals($"IRCODE {expectedCommand}", StringComparison.OrdinalIgnoreCase));
},
timeoutInSeconds: 5);

if (!found)
{
IReadOnlyList<RecordedMessage> 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 message 'IRCODE {expectedCommand}', but it was not found. Recorded messages: {recordedMessages}");
}

TestContext.WriteLine($"Successfully verified TiVo received message: IRCODE {expectedCommand}");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
namespace AdaptiveRemote.EndtoEndTests.SimulatedTiVo;

/// <summary>
/// Manages simulated devices for a test run.
/// </summary>
public interface ISimulatedEnvironment : IDisposable
{
/// <summary>
/// Registers a device builder with the given name.
/// </summary>
/// <param name="name">The unique name for the device.</param>
/// <param name="builder">The device builder to register.</param>
void RegisterDevice(string name, ISimulatedDeviceBuilder builder);

/// <summary>
/// Starts a registered device and returns the running device instance.
/// </summary>
/// <param name="name">The name of the device to start.</param>
/// <returns>The running simulated device.</returns>
ISimulatedDevice StartDevice(string name);

/// <summary>
/// Attempts to retrieve a running device by name.
/// </summary>
/// <param name="name">The name of the device to retrieve.</param>
/// <param name="device">The running device, if found.</param>
/// <returns>True if the device was found; otherwise, false.</returns>
bool TryGetDevice(string name, out ISimulatedDevice? device);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
using AdaptiveRemote.EndtoEndTests.SimulatedTiVo;

namespace AdaptiveRemote.EndtoEndTests.Host;

/// <summary>
/// Default implementation of <see cref="ISimulatedEnvironment"/>.
/// </summary>
public sealed class SimulatedEnvironment : ISimulatedEnvironment
{
private readonly Dictionary<string, ISimulatedDeviceBuilder> _builders = new();
private readonly Dictionary<string, ISimulatedDevice> _devices = new();
private bool _disposed;

/// <inheritdoc/>
public void RegisterDevice(string name, ISimulatedDeviceBuilder builder)
{
if (_builders.ContainsKey(name))
{
throw new InvalidOperationException($"Device with name '{name}' is already registered.");
}

_builders[name] = builder;
}

/// <inheritdoc/>
public ISimulatedDevice StartDevice(string name)
{
if (!_builders.TryGetValue(name, out ISimulatedDeviceBuilder? 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.");
}

ISimulatedDevice device = builder.Start();
_devices[name] = device;
return device;
}

/// <inheritdoc/>
public bool TryGetDevice(string name, out ISimulatedDevice? device)
{
return _devices.TryGetValue(name, out device);
}

/// <inheritdoc/>
public void Dispose()
{
if (_disposed)
{
return;
}

foreach (ISimulatedDevice device in _devices.Values)
{
try
{
device.Dispose();
}
catch
{
// Ignore disposal errors
}
}

_devices.Clear();

foreach (ISimulatedDeviceBuilder builder in _builders.Values)
{
try
{
builder.Dispose();
}
catch
{
// Ignore disposal errors
}
}

_builders.Clear();
_disposed = true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,17 @@ public static void ClickButton(this IUITestService service, string label, int ti
/// <exception cref="TimeoutException">Thrown when the operation times out.</exception>
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
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
namespace AdaptiveRemote.EndtoEndTests.SimulatedTiVo;

/// <summary>
/// Represents a running simulated device.
/// All methods are synchronous for ease of use in test scenarios.
/// </summary>
public interface ISimulatedDevice : IDisposable
{
/// <summary>
/// Stops the device and releases resources. Safe to call multiple times.
/// </summary>
void Stop();

/// <summary>
/// Gets the TCP port the device is listening on. Valid while device is running.
/// </summary>
int Port { get; }

/// <summary>
/// Returns a copy of messages recorded by the device.
/// </summary>
/// <returns>A read-only list of recorded messages.</returns>
IReadOnlyList<RecordedMessage> GetRecordedMessages();

/// <summary>
/// Clears all recorded messages to prepare for the next test.
/// </summary>
void ClearRecordedMessages();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace AdaptiveRemote.EndtoEndTests.SimulatedTiVo;

/// <summary>
/// Builder pattern for configuring and starting a simulated device.
/// </summary>
public interface ISimulatedDeviceBuilder : IDisposable
{
/// <summary>
/// Configures the TCP port for the device. Use 0 for an ephemeral port.
/// </summary>
/// <param name="port">The port number to use.</param>
/// <returns>This builder instance for fluent configuration.</returns>
ISimulatedDeviceBuilder WithPort(int port);

/// <summary>
/// Starts the device synchronously and returns the running device.
/// </summary>
/// <returns>A running simulated device instance.</returns>
ISimulatedDevice Start();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
namespace AdaptiveRemote.EndtoEndTests.SimulatedTiVo;

/// <summary>
/// Represents a message recorded by a simulated test device.
/// </summary>
public sealed record RecordedMessage
{
/// <summary>
/// Gets the timestamp when the message was recorded.
/// </summary>
public DateTimeOffset Timestamp { get; init; }

/// <summary>
/// Gets the raw ASCII payload of the message.
/// </summary>
public string Payload { get; init; } = string.Empty;

/// <summary>
/// Gets a value indicating whether this message was received from the application (true) or sent to it (false).
/// </summary>
public bool Incoming { get; init; }
}
Loading