Skip to content
Closed
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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,17 @@ This can be seen directly in this repository by launching the **SmokeTest.exe**:

![SmokeTest Output](output.png)

### Inspecting the launch outcome

`Launch` reports an unexpected tool exit to standard error, stops the remaining tools, and returns after cleanup. To distinguish a tool exit from requested shutdown in code, use `LaunchWithResult` with the same optional parameters:

```csharp
var result = await Particular.PlatformLauncher.LaunchWithResult();
return result.UnexpectedExit == null ? 0 : 1;
```

`UnexpectedExit` contains the tool name, exit code, and recent standard output and standard error. An exit code of zero is still unexpected while the launcher is running. The property is null after shutdown requested by Ctrl+C or the cancellation token, including cancellation before startup completes. Setup, monitoring, and cleanup errors can still fault the returned task.

## Options

The `PlatformLauncher.Launch()` API has very limited options available as optional parameters, which can be mixed as needed.
Expand Down
73 changes: 73 additions & 0 deletions src/Particular.PlatformSample.Tests/ChildProcessTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
namespace Particular.PlatformSample.Tests
{
using System;
using System.Diagnostics;
using System.IO;
using System.Threading.Tasks;
using NUnit.Framework;

[TestFixture]
public class ChildProcessTests
{
[TestCase(false)]
[TestCase(true)]
public async Task ReportsMissingRuntimeEvenWhenConsoleOutputIsHidden(bool showConsoleOutput)
{
var runtimeConfig = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.runtimeconfig.json");
try
{
await File.WriteAllTextAsync(runtimeConfig, """
{
"runtimeOptions": {
"tfm": "net10.0",
"rollForward": "Disable",
"framework": {
"name": "Microsoft.NETCore.App",
"version": "999.0.0"
}
}
}
""");

var startInfo = new ProcessStartInfo(Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? "dotnet");
startInfo.ArgumentList.Add("exec");
startInfo.ArgumentList.Add("--runtimeconfig");
startInfo.ArgumentList.Add(runtimeConfig);
startInfo.ArgumentList.Add(typeof(ChildProcessTests).Assembly.Location);
startInfo.Environment.Remove("DOTNET_ROLL_FORWARD");

await using var child = new ChildProcess("ServiceControl", startInfo, showConsoleOutput);
var exit = await child.Exited.WaitAsync(TimeSpan.FromSeconds(15));

Assert.Multiple(() =>
{
Assert.That(exit.ProcessName, Is.EqualTo("ServiceControl"));
Assert.That(exit.ExitCode, Is.Not.Zero);
Assert.That(exit.StandardError, Does.Contain("Microsoft.NETCore.App").And.Contain("999.0.0"));
});
}
finally
{
File.Delete(runtimeConfig);
}
}

[TestCase(false)]
[TestCase(true)]
public async Task ReportsSuccessfulExitAndCapturesStandardOutput(bool showConsoleOutput)
{
var startInfo = new ProcessStartInfo(Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? "dotnet");
startInfo.ArgumentList.Add("--list-runtimes");

await using var child = new ChildProcess("ServiceControl.Monitoring", startInfo, showConsoleOutput);
var exit = await child.Exited.WaitAsync(TimeSpan.FromSeconds(15));

Assert.Multiple(() =>
{
Assert.That(exit.ProcessName, Is.EqualTo("ServiceControl.Monitoring"));
Assert.That(exit.ExitCode, Is.Zero);
Assert.That(exit.StandardOutput, Does.Contain("Microsoft.NETCore.App"));
});
}
}
}
87 changes: 87 additions & 0 deletions src/Particular.PlatformSample.Tests/PlatformLauncherTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
namespace Particular.PlatformSample.Tests
{
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;

[TestFixture]
public class PlatformLauncherTests
{
[TestCase(0)]
[TestCase(1)]
public async Task ReturnsUnexpectedExitWithoutFaulting(int exitCode)
{
var exit = new ProcessExit("ServiceControl", exitCode, "output", "error");
var operation = new TaskCompletionSource();

var result = await PlatformLauncher.WaitForTaskOrUnexpectedExit(operation.Task, Task.FromResult(exit), CancellationToken.None);

Assert.That(result, Is.SameAs(exit));
}

[Test]
public async Task ContinuesMonitoringAfterReadinessCompletes()
{
var exitSource = new TaskCompletionSource<ProcessExit>();
var startupResult = await PlatformLauncher.WaitForTaskOrUnexpectedExit(Task.CompletedTask, exitSource.Task, CancellationToken.None);
Assert.That(startupResult, Is.Null);

var operation = new TaskCompletionSource();
var running = PlatformLauncher.WaitForTaskOrUnexpectedExit(operation.Task, exitSource.Task, CancellationToken.None);
var exit = new ProcessExit("ServiceControl.Audit", 1, "", "stopped");
exitSource.SetResult(exit);

Assert.That(await running, Is.SameAs(exit));
}

[TestCase(false)]
[TestCase(true)]
public async Task RequestedShutdownTakesPrecedenceOverToolExit(bool toolAlreadyExited)
{
using var shutdown = new CancellationTokenSource();
shutdown.Cancel();
var operation = new TaskCompletionSource();
var exitSource = new TaskCompletionSource<ProcessExit>();
if (toolAlreadyExited)
{
exitSource.SetResult(new ProcessExit("ServiceControl", 0, "", ""));
}

var result = await PlatformLauncher.WaitForTaskOrUnexpectedExit(operation.Task, exitSource.Task, shutdown.Token);

Assert.That(result, Is.Null);
}

[Test]
public async Task PropagatesOperationErrors()
{
var exitSource = new TaskCompletionSource<ProcessExit>();
var operation = Task.FromException(new IOException("Readiness failed."));

await Assert.ThrowsAsync<IOException>(async () =>
await PlatformLauncher.WaitForTaskOrUnexpectedExit(operation, exitSource.Task, CancellationToken.None));
}

[Test]
public async Task PropagatesMonitoringErrors()
{
var operation = new TaskCompletionSource();
var exitTask = Task.FromException<ProcessExit>(new IOException("Monitoring failed."));

await Assert.ThrowsAsync<IOException>(async () =>
await PlatformLauncher.WaitForTaskOrUnexpectedExit(operation.Task, exitTask, CancellationToken.None));
}

[Test]
public async Task CancellationBeforeLaunchReturnsRequestedShutdown()
{
using var shutdown = new CancellationTokenSource();
shutdown.Cancel();

var result = await PlatformLauncher.LaunchWithResult(cancellationToken: shutdown.Token);

Assert.That(result.UnexpectedExit, Is.Null);
}
}
}
71 changes: 56 additions & 15 deletions src/Particular.PlatformSample.Tests/VisualTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ namespace Particular.PlatformSample.Tests;
[Parallelizable(ParallelScope.None)]
public class VisualTests
{
Task launcherTask;
Task<LaunchResult> launcherTask;
CancellationTokenSource closePlatformTokenSource;
ChromeDriver driver;

Expand All @@ -22,19 +22,38 @@ public async Task Setup()
{
closePlatformTokenSource = new CancellationTokenSource();

launcherTask = Task.Run(async () =>
{
await PlatformLauncher.Launch(cancellationToken: closePlatformTokenSource.Token);
});
launcherTask = Task.Run(() => PlatformLauncher.LaunchWithResult(cancellationToken: closePlatformTokenSource.Token));

using var timeoutTokenSource = new CancellationTokenSource(60_000);

while (TestPortsInternal.ServicePulse == 0)
while (TestPortsInternal.ServicePulse == 0 && !launcherTask.IsCompleted)
{
await Task.Delay(1000, closePlatformTokenSource.Token);
await Task.Delay(1000, timeoutTokenSource.Token);
}

await Network.WaitForHttpOk($"http://localhost:{TestPortsInternal.ServicePulse}", cancellationToken: timeoutTokenSource.Token);
var readinessTask = Network.WaitForHttpOk($"http://localhost:{TestPortsInternal.ServicePulse}", cancellationToken: timeoutTokenSource.Token);
try
{
if (await Task.WhenAny(readinessTask, launcherTask) == launcherTask)
{
var result = await launcherTask;
Assert.Fail($"The launcher stopped before ServicePulse was ready. Tool: {result.UnexpectedExit?.ProcessName}, exit code: {result.UnexpectedExit?.ExitCode}.");
}

await readinessTask;
}
finally
{
await timeoutTokenSource.CancelAsync();
try
{
await readinessTask;
}
catch (OperationCanceledException) when (timeoutTokenSource.Token.IsCancellationRequested)
{
// Observe the pending readiness request when the launcher stops first.
}
}

var chromeOpts = new ChromeOptions();
chromeOpts.AddArgument("--headless=new");
Expand All @@ -46,7 +65,7 @@ public async Task Setup()
chromeOpts.AddArgument("--disk-cache-size=1");
chromeOpts.AddArgument("--media-cache-size=1");
chromeOpts.AddArgument("--incognito");
chromeOpts.AddArgument("--remote-debugging-port=9222");
chromeOpts.AddArgument("--remote-debugging-pipe");
chromeOpts.AddArgument("--aggressive-cache-discard");

if (Environment.GetEnvironmentVariable("CI") == "true")
Expand All @@ -58,17 +77,39 @@ public async Task Setup()
}

var chromeService = ChromeDriverService.CreateDefaultService();
driver = new ChromeDriver(chromeService, chromeOpts, TimeSpan.FromSeconds(90));
try
{
driver = new ChromeDriver(chromeService, chromeOpts, TimeSpan.FromSeconds(90));
}
catch
{
chromeService.Dispose();
throw;
}
}

[OneTimeTearDown]
public async Task TearDown()
{
await closePlatformTokenSource.CancelAsync();
await launcherTask;
closePlatformTokenSource.Dispose();
driver.Close();
await driver.DisposeAsync();
try
{
await closePlatformTokenSource.CancelAsync();
var result = await launcherTask;
Assert.That(result.UnexpectedExit, Is.Null, "A platform tool exited unexpectedly during the visual tests.");
}
finally
{
closePlatformTokenSource.Dispose();
if (launcherTask?.IsCompleted == true)
{
launcherTask.Dispose();
}

if (driver != null)
{
await driver.DisposeAsync();
}
}
}

[Test, CancelAfter(30_000)]
Expand Down
Loading
Loading