// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using Aspire.Cli.Acquisition;
using Microsoft.Extensions.Logging;
using System.Diagnostics;
using System.Text;
namespace Aspire.Cli.Tests.Acquisition;
/// <summary>
/// Behavior tests for <see cref="PeerInstallProbe"/>. These tests spawn
/// a real child process — a tiny test helper binary built into the test
/// project — to exercise the timeout / stdout-cap / kill paths against
/// real process semantics.
/// </summary>
public class PeerInstallProbeTests(ITestOutputHelper outputHelper) : IDisposable
{
// Route internal probe diagnostics (LogDebug for "JSON without an
// installation row", "invalid JSON", etc.) into the xunit test output
// so a failure log tells us why the probe took whichever code path it
// took. Keep the factory alive for the lifetime of the test class so
// logs aren't cut off mid-probe by an early dispose.
private readonly ILoggerFactory _loggerFactory = LoggerFactory.Create(builder => builder.AddXunit(outputHelper, LogLevel.Trace));
public void Dispose() => _loggerFactory.Dispose();
private ILogger<PeerInstallProbe> ProbeLogger => _loggerFactory.CreateLogger<PeerInstallProbe>();
// Surface the actual Failed.Reason on Ok-expected assertions. Without
// this helper, Assert.IsType<Ok>(result) discards the (often
// multi-line) failure reason and reports only "expected Ok, got
// Failed" — useless for diagnosing CI-only failures.
private static PeerProbeResult.Ok AssertProbeOk(PeerProbeResult result)
{
if (result is PeerProbeResult.Failed failed)
{
Assert.Fail($"Expected PeerProbeResult.Ok, got PeerProbeResult.Failed. Reason:{Environment.NewLine}{failed.Reason}");
}
return Assert.IsType<PeerProbeResult.Ok>(result);
}
// Construct a probe with a much wider timeout than production's 5s default.
//
// These positive-path tests assert how the probe interprets a successful
// peer's output, not the timeout behavior — but the FakePeerScript helper
// on Windows shells out to cmd.exe (and powershell.exe in the stderr
// variant), which under heavy CI load (saturated CPU, slow disk) can take
// several seconds just to start. With the production 5s timeout we
// intermittently see the probe synthesize
// `Failed: "Peer probe timed out after 5.0s."` before the fake peer even
// produces stdout, even though the peer would complete instantly given a
// bit more wallclock.
//
// The timeout path itself is covered by ProbeAsync_PeerHangs_TimesOutAndReturnsFailed
// and ProbeAsync_CallerCancels_KillsSpawnedProcess, so widening the
// budget here removes the CI flake without losing coverage of the 5s
// production behavior.
private PeerInstallProbe CreateProbeWithGenerousTimeout()
=> new(TimeSpan.FromSeconds(30), ProbeLogger);
[Fact]
public async Task ProbeAsync_BinaryNotFound_ReturnsFailed()
{
var probe = CreateProbeWithGenerousTimeout();
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var missing = Path.Combine(workspace.WorkspaceRoot.FullName, "does-not-exist");
var result = await probe.ProbeAsync(missing, TestContext.Current.CancellationToken);
Assert.IsType<PeerProbeResult.Failed>(result);
}
[Fact]
public async Task ProbeAsync_InvokesPeerWithDoctorSelfFormatJson()
{
// The peer must be asked to describe ONLY itself. Without --self,
// `aspire doctor` would run full installation discovery and the peer would
// recursively probe back into us — and into every other peer it
// finds — turning a single discovery invocation into a fan-out bounded
// only by the per-level timeout. `--format json` selects the
// machine-readable contract because the human-readable table is the
// default when `--format` is omitted.
using var fakePeer = FakePeerScript.BuildArgvRecorder(outputHelper);
var probe = CreateProbeWithGenerousTimeout();
var result = await probe.ProbeAsync(fakePeer.Path, TestContext.Current.CancellationToken);
AssertProbeOk(result);
Assert.NotNull(fakePeer.ArgvFile);
Assert.True(File.Exists(fakePeer.ArgvFile), $"Expected argv recorder file at {fakePeer.ArgvFile} to exist.");
var argv = await File.ReadAllLinesAsync(fakePeer.ArgvFile, TestContext.Current.CancellationToken);
Assert.Equal(["doctor", "--self", "--format", "json"], argv);
}
[Fact]
public async Task ProbeAsync_PeerEmitsValidJsonArray_ReturnsOk()
{
using var fakePeer = FakePeerScript.Build(
outputHelper,
stdout: """
{
"checks": [],
"summary": { "passed": 0, "warnings": 0, "failed": 0 },
"installations": [
{
"path": "/peer/aspire",
"version": "12.5.0",
"channel": "stable",
"route": "script",
"pathStatus": "shadowed",
"status": "ok"
}
]
}
""",
exitCode: 0);
var probe = CreateProbeWithGenerousTimeout();
var result = await probe.ProbeAsync(fakePeer.Path, TestContext.Current.CancellationToken);
var ok = AssertProbeOk(result);
Assert.Equal("12.5.0", ok.Info.Version);
Assert.Equal("stable", ok.Info.Channel);
Assert.Equal("script", ok.Info.Route);
Assert.Equal(InstallationPathStatus.Shadowed, ok.Info.PathStatus);
}
[Fact]
public async Task ProbeAsync_PeerOmitsPathStatus_DefaultsToNotOnPath()
{
using var fakePeer = FakePeerScript.Build(
outputHelper,
stdout: """
[
{
"path": "/peer/aspire",
"version": "12.5.0",
"status": "ok"
}
]
""",
exitCode: 0);
var probe = CreateProbeWithGenerousTimeout();
var result = await probe.ProbeAsync(fakePeer.Path, TestContext.Current.CancellationToken);
var ok = AssertProbeOk(result);
Assert.Equal(InstallationPathStatus.NotOnPath, ok.Info.PathStatus);
}
[Fact]
public async Task ProbeAsync_PeerEmitsInvalidPathStatus_DefaultsToNotOnPath()
{
using var fakePeer = FakePeerScript.Build(
outputHelper,
stdout: """
[
{
"path": "/peer/aspire",
"version": "12.5.0",
"pathStatus": 123,
"status": "ok"
}
]
""",
exitCode: 0);
var probe = CreateProbeWithGenerousTimeout();
var result = await probe.ProbeAsync(fakePeer.Path, TestContext.Current.CancellationToken);
var ok = AssertProbeOk(result);
Assert.Equal(InstallationPathStatus.NotOnPath, ok.Info.PathStatus);
}
[Fact]
public async Task ProbeAsync_PeerExitsNonZero_ReturnsFailedWhenVersionAlsoFails()
{
// doctor path scripted to exit 7; --version not supported by this
// script (the default EmitExit body) → fallback path also fails
// and the user sees the failure.
using var fakePeer = FakePeerScript.Build(outputHelper, stdout: "{}", exitCode: 7);
var failed = await ProbeFakeFailureAsync(fakePeer);
Assert.Contains("code 7", failed.Reason);
}
[Fact]
public async Task ProbeAsync_PeerExitsNonZero_IncludesCapturedStderr()
{
using var fakePeer = FakePeerScript.Build(outputHelper, stdout: "{}", stderr: "peer exploded", exitCode: 7);
var failed = await ProbeFakeFailureAsync(fakePeer);
Assert.Contains("Peer exited with code 7", failed.Reason, StringComparison.Ordinal);
Assert.Contains("stderr: peer exploded", failed.Reason, StringComparison.Ordinal);
}
[Fact]
public async Task ProbeAsync_PeerFailureStderr_StripsAnsiEscapes()
{
using var fakePeer = FakePeerScript.Build(outputHelper, stdout: "{}", stderr: "\u001b[31mhello\u001b[0m", exitCode: 7);
var failed = await ProbeFakeFailureAsync(fakePeer);
Assert.Contains("stderr: hello", failed.Reason, StringComparison.Ordinal);
Assert.DoesNotContain("\u001b[31m", failed.Reason, StringComparison.Ordinal);
Assert.DoesNotContain("\u001b[0m", failed.Reason, StringComparison.Ordinal);
}
[Fact]
public async Task ProbeAsync_PeerFailureStderr_StripsControlCharactersExceptNewline()
{
using var fakePeer = FakePeerScript.Build(outputHelper, stdout: "{}", stderr: "first\0\u0001\nsecond\u0002", exitCode: 7);
var failed = await ProbeFakeFailureAsync(fakePeer);
Assert.Contains("stderr: first\nsecond", failed.Reason, StringComparison.Ordinal);
Assert.DoesNotContain("\0", failed.Reason, StringComparison.Ordinal);
Assert.DoesNotContain("\u0001", failed.Reason, StringComparison.Ordinal);
Assert.DoesNotContain("\u0002", failed.Reason, StringComparison.Ordinal);
}
[Fact]
public async Task ProbeAsync_PeerFailureStderr_ReportsTruncationWhenByteCapIsExceeded()
{
using var fakePeer = FakePeerScript.BuildRepeatedStderr(outputHelper, PeerInstallProbe.OutputCap + 10, exitCode: 7);
var failed = await ProbeFakeFailureAsync(fakePeer);
Assert.Contains("... [truncated]", failed.Reason, StringComparison.Ordinal);
}
[Fact]
public async Task ProbeAsync_PeerExitsNonZero_WithEmptyStderr_KeepsReasonUnchanged()
{
using var fakePeer = FakePeerScript.Build(outputHelper, stdout: "{}", stderr: string.Empty, exitCode: 7);
var failed = await ProbeFakeFailureAsync(fakePeer);
Assert.Equal("Peer exited with code 7 (and --version fallback).", failed.Reason);
}
[Fact]
public async Task ProbeAsync_PeerExitsNonZero_FallsBackToVersionAndReturnsPartialOk()
{
// Older peers (predating rich self-probe support) exit non-zero for
// the primary probe but support `--version`. The probe must fall back so we
// still surface a version string for those installs.
using var fakePeer = FakePeerScript.BuildDoctorOrVersion(
outputHelper,
doctorStdout: string.Empty,
doctorExitCode: 1,
versionStdout: "13.4.0-pr.16817.g790d6fa3\n",
versionExitCode: 0);
var probe = CreateProbeWithGenerousTimeout();
var result = await probe.ProbeAsync(fakePeer.Path, TestContext.Current.CancellationToken);
var ok = AssertProbeOk(result);
Assert.Equal("13.4.0-pr.16817.g790d6fa3", ok.Info.Version);
// Fallback can't read route or channel from the older peer; the
// discovery layer overlays the route from the local sidecar.
Assert.Null(ok.Info.Channel);
}
[Fact]
public async Task ProbeAsync_BothInfoAndVersionFail_ReturnsFailed()
{
// When both attempts fail, the primary failure reason is what the
// user sees (with a (and --version fallback) suffix so they know
// we tried).
using var fakePeer = FakePeerScript.BuildDoctorOrVersion(
outputHelper,
doctorStdout: string.Empty,
doctorExitCode: 1,
versionStdout: string.Empty,
versionExitCode: 1);
var probe = CreateProbeWithGenerousTimeout();
var result = await probe.ProbeAsync(fakePeer.Path, TestContext.Current.CancellationToken);
var failed = Assert.IsType<PeerProbeResult.Failed>(result);
Assert.Contains("--version fallback", failed.Reason, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task ProbeAsync_PeerEmitsEmptyArray_FallsBackToVersion()
{
// Empty rich output is treated as "doctor didn't tell us anything useful"
// and triggers the --version fallback. With no version response
// scripted either, the overall probe fails.
using var fakePeer = FakePeerScript.BuildDoctorOrVersion(
outputHelper,
doctorStdout: "[]",
doctorExitCode: 0,
versionStdout: string.Empty,
versionExitCode: 1);
var probe = CreateProbeWithGenerousTimeout();
var result = await probe.ProbeAsync(fakePeer.Path, TestContext.Current.CancellationToken);
Assert.IsType<PeerProbeResult.Failed>(result);
}
[Fact]
public async Task ProbeAsync_PeerEmitsInvalidJson_FallsBackToVersion()
{
// Invalid JSON on the doctor path is treated as a peer failure mode
// where the command emits help / error text, and triggers the
// --version fallback.
using var fakePeer = FakePeerScript.BuildDoctorOrVersion(
outputHelper,
doctorStdout: "not json at all",
doctorExitCode: 0,
versionStdout: "9.0.0\n",
versionExitCode: 0);
var probe = CreateProbeWithGenerousTimeout();
var result = await probe.ProbeAsync(fakePeer.Path, TestContext.Current.CancellationToken);
var ok = AssertProbeOk(result);
Assert.Equal("9.0.0", ok.Info.Version);
}
[Theory]
[InlineData("[1]", "number")]
[InlineData("[null]", "null")]
[InlineData("[\"string\"]", "string")]
[InlineData("[[]]", "nested array")]
public async Task ProbeAsync_PeerEmitsArrayWithNonObjectFirstElement_FallsBackToVersion(string doctorStdout, string kind)
{
// The peer emitted a syntactically valid JSON array but the first
// element is not an object. InstallationInfoParser.Parse calls
// TryGetProperty on the element, which throws InvalidOperationException
// for non-object kinds — that would otherwise abort the whole
// discovery walk for the caller. The probe must treat it as a
// wrong-shape response and fall back to --version.
_ = kind; // surfaced in test name for debuggability
using var fakePeer = FakePeerScript.BuildDoctorOrVersion(
outputHelper,
doctorStdout: doctorStdout,
doctorExitCode: 0,
versionStdout: "9.0.0\n",
versionExitCode: 0);
var probe = CreateProbeWithGenerousTimeout();
var result = await probe.ProbeAsync(fakePeer.Path, TestContext.Current.CancellationToken);
var ok = AssertProbeOk(result);
Assert.Equal("9.0.0", ok.Info.Version);
}
[Fact]
public async Task ProbeAsync_PeerHangs_TimesOutAndReturnsFailed()
{
// Sleep significantly longer than the probe timeout we configure so
// the timeout path is the one that completes the await.
var fakeSleep = TimeSpan.FromSeconds(30);
using var fakePeer = FakePeerScript.BuildSleeper(outputHelper, sleepSeconds: (int)fakeSleep.TotalSeconds);
// Construct a probe with a deliberately tight timeout so the test
// doesn't have to wait the production 5s budget.
var probe = new PeerInstallProbe(TimeSpan.FromMilliseconds(300), ProbeLogger);
var sw = System.Diagnostics.Stopwatch.StartNew();
var result = await probe.ProbeAsync(fakePeer.Path, TestContext.Current.CancellationToken);
sw.Stop();
var failed = Assert.IsType<PeerProbeResult.Failed>(result);
Assert.Contains("timed out", failed.Reason, StringComparison.OrdinalIgnoreCase);
// The probe is configured with a 300ms timeout; the outer budget here
// is a sanity bound against a probe that ignores its configured
// timeout entirely (the bug class this test catches). Windows CI under
// saturated CPU / slow disk has been observed to take ~5s just for the
// fake-peer cmd.exe spawn + kill round-trip, so the budget needs to be
// well above 5s to avoid noise without losing the bound. The important
// invariant is that the probe returns through its timeout path well
// before the fake peer could exit on its own.
Assert.True(sw.Elapsed < fakeSleep / 2,
$"Expected probe to return before the fake peer could exit on its own; took {sw.Elapsed}.");
}
[Fact]
public async Task ProbeAsync_CallerCancels_KillsSpawnedProcess()
{
Assert.SkipWhen(OperatingSystem.IsWindows(),
"This regression test records the shell process id using POSIX $$; Windows process-tree cancellation is covered by production code.");
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var pidFile = Path.Combine(workspace.WorkspaceRoot.FullName, "peer.pid");
using var fakePeer = FakePeerScript.BuildSleeperWithPidFile(outputHelper, pidFile, sleepSeconds: 30);
var probe = new PeerInstallProbe(TimeSpan.FromSeconds(30), ProbeLogger);
using var cts = new CancellationTokenSource();
var probeTask = probe.ProbeAsync(fakePeer.Path, cts.Token);
using var process = await WaitForProcessIdAsync(pidFile, TestContext.Current.CancellationToken);
cts.Cancel();
await Assert.ThrowsAsync<OperationCanceledException>(() => probeTask);
await WaitForExitAsync(process, TestContext.Current.CancellationToken);
Assert.True(process.HasExited);
}
private async Task<PeerProbeResult.Failed> ProbeFakeFailureAsync(FakeScriptResult fakePeer)
{
// Spawn the production probe against the scripted peer and assert the
// result is Failed. Centralizing the spawn + assertion keeps each
// negative-path test focused on the failure reason it cares about.
//
// The 30s timeout is well above the production 5s default. Under heavy
// CI load (saturated CPU, slow disk) the fake peer script — which on
// Windows shells out to powershell.exe to emit raw stderr bytes — can
// take several seconds just to start. These tests are about how the
// probe formats a Failed result from real peer stderr/exit semantics,
// not about the timeout behavior (see ProbeAsync_PeerHangs_TimesOutAndReturnsFailed
// for that). A wider budget here removes the CI flake without changing
// what's being tested.
var probe = new PeerInstallProbe(TimeSpan.FromSeconds(30), ProbeLogger);
var result = await probe.ProbeAsync(fakePeer.Path, TestContext.Current.CancellationToken);
return Assert.IsType<PeerProbeResult.Failed>(result);
}
private static async Task<Process> WaitForProcessIdAsync(string pidFile, CancellationToken cancellationToken)
{
while (true)
{
if (File.Exists(pidFile))
{
var pidText = await File.ReadAllTextAsync(pidFile, cancellationToken);
if (int.TryParse(pidText.Trim(), System.Globalization.CultureInfo.InvariantCulture, out var pid))
{
return Process.GetProcessById(pid);
}
}
await Task.Delay(20, cancellationToken);
}
}
private static async Task WaitForExitAsync(Process process, CancellationToken cancellationToken)
{
var deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(5);
while (!process.HasExited && DateTimeOffset.UtcNow < deadline)
{
await Task.Delay(20, cancellationToken);
process.Refresh();
}
}
}
/// <summary>
/// Builds a tiny shell/batch script in a temp dir that emits scripted
/// stdout/stderr and exits with a given code. Used as a stand-in peer in
/// PeerInstallProbeTests so we don't have to spawn a real Aspire CLI.
/// </summary>
internal static class FakePeerScript
{
/// <summary>
/// Produces a script that writes <paramref name="stdout"/> verbatim
/// and exits with <paramref name="exitCode"/>. The script dispatches on
/// its first argument, so it works with both the probe's
/// <c>doctor --self --format json</c> invocation and the <c>--version</c>
/// fallback.
/// </summary>
internal static FakeScriptResult Build(ITestOutputHelper outputHelper, string stdout, int exitCode)
{
return Build(outputHelper, stdout, stderr: string.Empty, exitCode);
}
internal static FakeScriptResult Build(ITestOutputHelper outputHelper, string stdout, string stderr, int exitCode)
{
return BuildInternal(outputHelper, body: ScriptBody.EmitAndExit(stdout, stderr, exitCode));
}
/// <summary>
/// Builds a script that responds differently to <c>doctor</c> vs
/// <c>--version</c> arguments so PeerInstallProbeTests can exercise
/// the rich-probe → version fallback path.
/// </summary>
internal static FakeScriptResult BuildDoctorOrVersion(
ITestOutputHelper outputHelper,
string doctorStdout,
int doctorExitCode,
string versionStdout,
int versionExitCode)
{
return BuildInternal(outputHelper, body: ScriptBody.DoctorOrVersion(
doctorStdout, doctorExitCode, versionStdout, versionExitCode));
}
internal static FakeScriptResult BuildSleeper(ITestOutputHelper outputHelper, int sleepSeconds)
{
return BuildInternal(outputHelper, body: ScriptBody.Sleep(sleepSeconds));
}
internal static FakeScriptResult BuildSleeperWithPidFile(ITestOutputHelper outputHelper, string pidFile, int sleepSeconds)
{
return BuildInternal(outputHelper, body: ScriptBody.SleepWithPidFile(pidFile, sleepSeconds));
}
internal static FakeScriptResult BuildRepeatedStderr(ITestOutputHelper outputHelper, int byteCount, int exitCode)
{
return BuildInternal(outputHelper, body: ScriptBody.StderrRepeat(byteCount, exitCode));
}
/// <summary>
/// Builds a script that records each positional argument (one per line)
/// to an in-workspace file and then emits a minimal valid doctor JSON so
/// the probe completes via the primary path without falling back to
/// <c>--version</c>. The recorded argv file path is exposed on the
/// returned <see cref="FakeScriptResult.ArgvFile"/>.
/// </summary>
internal static FakeScriptResult BuildArgvRecorder(ITestOutputHelper outputHelper)
{
var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var argvFile = Path.Combine(workspace.WorkspaceRoot.FullName, "argv.txt");
var path = OperatingSystem.IsWindows()
? Path.Combine(workspace.WorkspaceRoot.FullName, "peer.cmd")
: Path.Combine(workspace.WorkspaceRoot.FullName, "peer");
var body = ScriptBody.ArgvRecorder(argvFile);
var content = OperatingSystem.IsWindows() ? body.RenderBatch() : body.RenderShell();
File.WriteAllText(path, content);
if (!OperatingSystem.IsWindows())
{
File.SetUnixFileMode(path,
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
UnixFileMode.OtherRead | UnixFileMode.OtherExecute);
}
DumpScript(outputHelper, path, content);
return new FakeScriptResult(path, workspace, ArgvFile: argvFile);
}
private static FakeScriptResult BuildInternal(ITestOutputHelper outputHelper, ScriptBody body)
{
var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var path = OperatingSystem.IsWindows()
? Path.Combine(workspace.WorkspaceRoot.FullName, "peer.cmd")
: Path.Combine(workspace.WorkspaceRoot.FullName, "peer");
var content = OperatingSystem.IsWindows() ? body.RenderBatch() : body.RenderShell();
File.WriteAllText(path, content);
if (!OperatingSystem.IsWindows())
{
// chmod +x for /bin/sh execution. File.SetUnixFileMode is the
// .NET-supported way to do this on Unix.
File.SetUnixFileMode(path,
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
UnixFileMode.OtherRead | UnixFileMode.OtherExecute);
}
DumpScript(outputHelper, path, content);
return new FakeScriptResult(path, workspace);
}
// Write the rendered script body to the test output so a failed run's
// log shows exactly what the probe executed (and where). xUnit only
// surfaces test output for failing tests, so passing runs aren't
// affected.
private static void DumpScript(ITestOutputHelper outputHelper, string path, string content)
{
outputHelper.WriteLine($"[FakePeerScript] --- begin script at {path} ---");
outputHelper.WriteLine(content);
outputHelper.WriteLine($"[FakePeerScript] --- end script at {path} ---");
}
}
internal sealed record FakeScriptResult(string Path, TemporaryWorkspace Workspace, string? ArgvFile = null) : IDisposable
{
public void Dispose() => Workspace.Dispose();
}
internal abstract record ScriptBody
{
public abstract string RenderShell();
public abstract string RenderBatch();
public static ScriptBody EmitAndExit(string stdout, string stderr, int exitCode) => new EmitExit(stdout, stderr, exitCode);
public static ScriptBody Sleep(int seconds) => new SleepScript(seconds);
public static ScriptBody SleepWithPidFile(string pidFile, int seconds) => new SleepWithPidFileScript(pidFile, seconds);
public static ScriptBody StderrRepeat(int byteCount, int exitCode) => new StderrRepeatScript(byteCount, exitCode);
public static ScriptBody DoctorOrVersion(string doctorStdout, int doctorExitCode, string versionStdout, int versionExitCode)
=> new DoctorOrVersionScript(doctorStdout, doctorExitCode, versionStdout, versionExitCode);
public static ScriptBody ArgvRecorder(string argvFile) => new ArgvRecorderScript(argvFile);
private sealed record EmitExit(string Stdout, string Stderr, int ExitCode) : ScriptBody
{
public override string RenderShell()
{
// The script behaves differently based on its first arg:
// - "doctor" → emit the scripted stdout and exit with the scripted code
// - anything else (e.g. "--version") → emit nothing and exit 127
// This lets PeerInstallProbeTests isolate the "rich probe failed"
// case without the fallback `--version` accidentally succeeding
// by virtue of the script ignoring its args.
return $"""
#!/bin/sh
if [ "$1" != "doctor" ]; then
exit 127
fi
cat <<'__ASPIRE_PEER_EOF__'
{Stdout}
__ASPIRE_PEER_EOF__
{RenderShellStderr(Stderr)}
exit {ExitCode}
""";
}
public override string RenderBatch()
{
var lines = Stdout.Split('\n');
var sb = new System.Text.StringBuilder();
sb.AppendLine("@echo off");
AppendBatchContainsArgGuard(sb, "doctor");
foreach (var line in lines)
{
sb.Append("echo ").AppendLine(line.TrimEnd('\r'));
}
AppendBatchStderr(sb, Stderr);
sb.AppendLine($"exit /b {ExitCode}");
return sb.ToString();
}
}
private sealed record StderrRepeatScript(int ByteCount, int ExitCode) : ScriptBody
{
public override string RenderShell() =>
$"""
#!/bin/sh
if [ "$1" != "doctor" ]; then
exit 127
fi
dd if=/dev/zero bs={ByteCount} count=1 2>/dev/null | LC_ALL=C tr '\000' 'x' 1>&2
exit {ExitCode}
""";
public override string RenderBatch()
{
var sb = new StringBuilder();
sb.AppendLine("@echo off");
AppendBatchContainsArgGuard(sb, "doctor");
sb.AppendLine($"powershell -NoProfile -ExecutionPolicy Bypass -Command \"[Console]::Error.Write(('x' * {ByteCount}))\"");
sb.AppendLine($"exit /b {ExitCode}");
return sb.ToString();
}
}
private sealed record SleepScript(int Seconds) : ScriptBody
{
public override string RenderShell() =>
$"""
#!/bin/sh
sleep {Seconds}
""";
public override string RenderBatch() =>
// Built-in timeout /t requires interactive console handling
// sometimes; ping localhost is the conventional sleep stand-in.
$"""
@echo off
ping -n {Seconds + 1} 127.0.0.1 > nul
""";
}
private sealed record SleepWithPidFileScript(string PidFile, int Seconds) : ScriptBody
{
public override string RenderShell() =>
$$"""
#!/bin/sh
printf '%s\n' "$$" > '{{PidFile}}'
sleep {{Seconds}}
""";
public override string RenderBatch() =>
throw new PlatformNotSupportedException("POSIX pid-file sleeper is not supported on Windows.");
}
private sealed record DoctorOrVersionScript(string DoctorStdout, int DoctorExitCode, string VersionStdout, int VersionExitCode) : ScriptBody
{
public override string RenderShell()
{
return $"""
#!/bin/sh
if [ "$1" = "doctor" ]; then
cat <<'__ASPIRE_DOCTOR_EOF__'
{DoctorStdout}
__ASPIRE_DOCTOR_EOF__
exit {DoctorExitCode}
fi
if [ "$1" = "--version" ]; then
cat <<'__ASPIRE_VERSION_EOF__'
{VersionStdout}
__ASPIRE_VERSION_EOF__
exit {VersionExitCode}
fi
exit 127
""";
}
public override string RenderBatch()
{
var sb = new System.Text.StringBuilder();
sb.AppendLine("@echo off");
sb.AppendLine("echo %* | findstr /C:\"doctor\" > nul");
sb.AppendLine("if not errorlevel 1 goto :doctor");
sb.AppendLine("echo %* | findstr /C:\"--version\" > nul");
sb.AppendLine("if not errorlevel 1 goto :version");
sb.AppendLine("exit /b 127");
sb.AppendLine(":doctor");
foreach (var line in DoctorStdout.Split('\n'))
{
sb.Append("echo ").AppendLine(line.TrimEnd('\r'));
}
sb.AppendLine($"exit /b {DoctorExitCode}");
sb.AppendLine(":version");
foreach (var line in VersionStdout.Split('\n'))
{
sb.Append("echo ").AppendLine(line.TrimEnd('\r'));
}
sb.AppendLine($"exit /b {VersionExitCode}");
return sb.ToString();
}
}
private sealed record ArgvRecorderScript(string ArgvFile) : ScriptBody
{
// Minimal valid doctor JSON: enough for the probe to take the primary
// path (no fallback to --version), so the recorded argv reflects the
// first invocation only.
private const string DoctorJson = """{"checks":[],"summary":{"passed":0,"warnings":0,"failed":0},"installations":[{"path":"/peer/aspire","version":"1.0.0","status":"ok"}]}""";
public override string RenderShell()
{
// POSIX: truncate the recorder file, then write one arg per line,
// honoring quoted args via "$@" (not $*) so multi-word args round-trip.
return $$"""
#!/bin/sh
: > "{{ArgvFile}}"
for a in "$@"; do
printf '%s\n' "$a" >> "{{ArgvFile}}"
done
cat <<'__ASPIRE_PEER_EOF__'
{{DoctorJson}}
__ASPIRE_PEER_EOF__
exit 0
""";
}
public override string RenderBatch()
{
// Batch: shift through %1 until empty, appending each arg on its
// own line. type nul > <file> creates an empty file (truncate).
var sb = new System.Text.StringBuilder();
sb.AppendLine("@echo off");
sb.AppendLine($"type nul > \"{ArgvFile}\"");
sb.AppendLine(":loop");
sb.AppendLine("if \"%~1\"==\"\" goto :emit");
sb.AppendLine($"echo %~1>>\"{ArgvFile}\"");
sb.AppendLine("shift");
sb.AppendLine("goto :loop");
sb.AppendLine(":emit");
sb.AppendLine($"echo {DoctorJson}");
sb.AppendLine("exit /b 0");
return sb.ToString();
}
}
private static string RenderShellStderr(string stderr)
{
if (stderr.Length == 0)
{
return string.Empty;
}
return $"printf '{ToShellPrintfEscaped(stderr)}' 1>&2";
}
private static string ToShellPrintfEscaped(string value)
{
var builder = new StringBuilder();
foreach (var valueByte in Encoding.UTF8.GetBytes(value))
{
builder.Append('\\').Append(Convert.ToString(valueByte, 8).PadLeft(3, '0'));
}
return builder.ToString();
}
private static void AppendBatchStderr(StringBuilder sb, string stderr)
{
if (stderr.Length == 0)
{
return;
}
var encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(stderr));
sb.AppendLine($"powershell -NoProfile -ExecutionPolicy Bypass -Command \"$bytes=[Convert]::FromBase64String('{encoded}'); [Console]::Error.Write([Text.Encoding]::UTF8.GetString($bytes))\"");
}
private static void AppendBatchContainsArgGuard(StringBuilder sb, string arg)
{
sb.AppendLine($"echo %* | findstr /C:\"{arg}\" > nul");
sb.AppendLine("if errorlevel 1 exit /b 127");
}
}