namespace ProtoTest.Demo;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using ProtoTest.Core;
///
/// Starts the sample application as a standalone process over the suite's store, so browser tests have a
/// real address. It is run-scoped infrastructure: the host starts it, exposes its address as the web
/// session's base URL, and releases it with the run - the journey file only contains the journey.
///
internal sealed class StandaloneSampleApp(
string connectionString,
string databaseProvider = "sqlite",
Func? messagingConnection = null) : IProtoSettingsInfrastructure
{
private const int OutputTailLines = 40;
private static readonly TimeSpan StartupTimeout = TimeSpan.FromSeconds(30);
private readonly ConcurrentQueue _output = new();
private Process? _process;
private string _baseUrl = string.Empty;
public string Id => "application:northstar-standalone";
public string Kind => "application";
public string Description => "Northstar standalone application";
public ProtoResourceScope Scope => ProtoResourceScope.Run;
public IReadOnlyDictionary Settings => new Dictionary
{
["ProtoTest:Web:Sessions:Default:BaseUrl"] = _baseUrl
};
public async ValueTask StartAsync(CancellationToken cancellationToken = default)
{
var port = FreePort();
_baseUrl = $"http://127.0.0.1:{port}";
var start = new ProcessStartInfo(
"dotnet",
$"\"{Path.Combine(AppContext.BaseDirectory, "ProtoTest.SampleApp.dll")}\"")
{
WorkingDirectory = AppContext.BaseDirectory,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
};
start.Environment["ASPNETCORE_URLS"] = _baseUrl;
start.Environment["ConnectionStrings__Northstar"] = connectionString;
start.Environment["Database__Provider"] = databaseProvider;
start.Environment["ProtoTest__TestSupport"] = "true";
// The UI instance reads the store; the suite's in-process instance owns webhook dispatch.
start.Environment["Northstar__DisableWebhookDispatcher"] = "true";
// The built console lives in the repository, not in the test output, so the absolute path is
// passed as process environment: the standalone app serves the same build the in-process one does.
if (ConsoleBuild.DistFolder is { } dist)
{
start.Environment["Northstar__Ui__Path"] = dist;
}
// Paying an invoice in the browser publishes through the same broker the tests await on.
if (messagingConnection?.Invoke() is { Length: > 0 } broker)
{
start.Environment["Messaging__RabbitMq__ConnectionString"] = broker;
}
_process = Process.Start(start)!;
// Redirected pipes fill up and block the child once nobody drains them, so a chatty application
// would deadlock the run. The handlers keep both streams flowing and remember the tail, so a
// failure to become healthy can report why instead of a bare timeout.
_process.OutputDataReceived += (_, e) => Remember(e.Data);
_process.ErrorDataReceived += (_, e) => Remember(e.Data);
_process.BeginOutputReadLine();
_process.BeginErrorReadLine();
using var client = new HttpClient
{
BaseAddress = new Uri(_baseUrl),
Timeout = TimeSpan.FromSeconds(5)
};
var deadline = DateTime.UtcNow + StartupTimeout;
while (DateTime.UtcNow < deadline)
{
if (_process.HasExited)
{
throw new InvalidOperationException(
$"The standalone sample application exited with code {_process.ExitCode} before becoming " +
$"healthy at {_baseUrl}.{Environment.NewLine}Last output:{Environment.NewLine}{Tail()}");
}
try
{
using var response = await client.GetAsync("/health", cancellationToken);
if (response.IsSuccessStatusCode)
{
return;
}
}
catch (HttpRequestException)
{
// Still starting.
}
catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested)
{
// The per-request timeout elapsed; the process may still be starting.
}
await Task.Delay(250, cancellationToken);
}
throw new InvalidOperationException(
$"The standalone sample application did not become healthy at {_baseUrl} within " +
$"{(int)StartupTimeout.TotalSeconds}s.{Environment.NewLine}Last output:{Environment.NewLine}{Tail()}");
}
public ValueTask ReleaseAsync(ProtoResourceReleaseContext context)
{
_process?.Kill(entireProcessTree: true);
_process?.Dispose();
_process = null;
return ValueTask.CompletedTask;
}
private void Remember(string? line)
{
if (line is null)
{
return;
}
_output.Enqueue(line);
while (_output.Count > OutputTailLines && _output.TryDequeue(out _))
{
}
}
private string Tail() => _output.IsEmpty ? "(no output)" : string.Join(Environment.NewLine, _output);
private static int FreePort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
}