File: Pipelines\ExtensionE2eRecorderStepTests.cs
Web Access
Project: src\tests\Infrastructure.Tests\Infrastructure.Tests.csproj (Infrastructure.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Aspire.TestUtilities;
using Xunit;
 
namespace Infrastructure.Tests.Pipelines;
 
/// <summary>
/// Runs the <c>Install E2E recorder</c> step of <c>.github/workflows/extension-e2e-tests.yml</c>
/// inside the same wrapper GitHub Actions builds around a <c>pwsh</c> step, pinning the contract
/// that the step can never fail a shard.
///
/// ffmpeg only powers the diagnostic screen recordings, and run-e2e.js already skips recording with
/// a warning when the binary is missing, so a failed install has to stay a warning. That was not
/// true once: bounding the apt-get calls with <c>timeout</c> stopped them hanging, but the failed
/// exit code then leaked out and failed six shards outright, because GitHub appends
/// <c>if ((Test-Path -LiteralPath variable:/LASTEXITCODE)) { exit $LASTEXITCODE }</c> to every pwsh
/// step. See
/// https://docs.github.com/actions/reference/workflows-and-actions/workflow-syntax#exit-codes-and-error-action-preference.
///
/// Running the step body on its own cannot catch that regression, because the appended epilogue is
/// the thing that turns a leftover <c>$LASTEXITCODE</c> into a step failure. So these tests wrap the
/// body the way GitHub does, and the always-fails case is covered twice: once as authored, and once
/// with the explicit <c>exit 0</c> stripped, which must reproduce the original failure. Without that
/// control the harness could pass while proving nothing.
/// </summary>
public sealed class ExtensionE2eRecorderStepTests(ITestOutputHelper testOutput)
{
    private const string RecorderStepName = "Install E2E recorder";
 
    /// <summary>Exit code the apt-get stub uses for a failed install, distinct from the 124 that a real timeout returns.</summary>
    private const int StubAptFailureExitCode = 100;
 
    /// <summary>What GitHub wraps around the <c>run:</c> body of a pwsh step.</summary>
    private const string GitHubPwshPrologue = "$ErrorActionPreference = 'stop'";
    private const string GitHubPwshEpilogue = "if ((Test-Path -LiteralPath variable:/LASTEXITCODE)) { exit $LASTEXITCODE }";
 
    private const string ExplicitSuccessExit = "exit 0";
    private const string RetryBackoff = "Start-Sleep -Seconds 15";
 
    private const string RetryWarning = "::warning::Attempt 1 to install ffmpeg failed; retrying.";
    private const string GaveUpWarning = "::warning::ffmpeg could not be installed; E2E screen recordings are disabled for this shard.";
 
    [Theory]
    // As authored: every apt-get outcome has to leave the shard alive.
    [InlineData("always-fails", true, 0, 2, new[] { RetryWarning, GaveUpWarning })]
    [InlineData("fails-once", true, 0, 2, new[] { RetryWarning })]
    [InlineData("succeeds", true, 0, 1, new string[0])]
    // Control: the same body without its explicit exit must reproduce the shard-wide failure,
    // otherwise the three cases above would pass even if the epilogue were not being applied.
    [InlineData("always-fails", false, StubAptFailureExitCode, 2, new[] { RetryWarning, GaveUpWarning })]
    [RequiresTools(["pwsh"])]
    public async Task RecorderStepSurvivesEveryAptOutcome(
        string aptBehavior,
        bool keepExplicitExit,
        int expectedExitCode,
        int expectedInstallAttempts,
        string[] expectedWarnings)
    {
        // The stubs below are shell scripts, and this step only ever runs on the Linux shards
        // (`if: ${{ matrix.useXvfb }}`), so there is nothing to pin on Windows.
        Assert.SkipWhen(OperatingSystem.IsWindows(), "The recorder step and its stubs are Unix-only.");
 
        using var workspace = TemporaryWorkspace.Create(testOutput);
 
        var stepScript = ExtensionE2eWorkflow.StepScript(RecorderStepName);
        var stubDirectory = CreateCommandStubs(workspace, aptBehavior, out var invocationLog, out var pwshPath);
        var scriptPath = Path.Combine(workspace.Path, "step.ps1");
        File.WriteAllText(scriptPath, BuildStepScript(stepScript, keepExplicitExit));
 
        using var command = new PowerShellCommand(scriptPath, testOutput, label: aptBehavior)
            .WithEnvironmentVariable("PATH", stubDirectory)
            .WithTimeout(TimeSpan.FromMinutes(2));
        var result = await command.ExecuteAsync();
 
        var invocations = File.Exists(invocationLog) ? File.ReadAllLines(invocationLog) : [];
        var installAttempts = invocations.Count(line => line.StartsWith("apt-get install", StringComparison.Ordinal));
 
        Assert.Equal(expectedExitCode, result.ExitCode);
        Assert.Equal(expectedInstallAttempts, installAttempts);
 
        // Distinguishes a genuine retry from a run that merely failed twice: both make two install
        // attempts, and only the warnings say whether the second one succeeded. Without this, a
        // stub that could not track attempts would quietly degrade fails-once into always-fails.
        var warnings = result.Output
            .Split('\n')
            .Select(line => line.Trim('\r'))
            .Where(line => line.StartsWith("::warning::", StringComparison.Ordinal))
            .ToArray();
        Assert.Equal(expectedWarnings, warnings);
 
        // Plain `timeout` only sends SIGTERM, and apt/dpkg defer signals around critical sections,
        // so without the escalation a wedged child can outlive its own timeout - the hang this step
        // was bounded to prevent.
        var timeoutInvocations = invocations
            .Where(line => line.StartsWith("timeout ", StringComparison.Ordinal))
            .ToArray();
        Assert.Equal(expectedInstallAttempts * 2, timeoutInvocations.Length);
        for (var attempt = 0; attempt < expectedInstallAttempts; attempt++)
        {
            Assert.EndsWith("apt-get update", timeoutInvocations[attempt * 2], StringComparison.Ordinal);
            Assert.Contains("apt-get install", timeoutInvocations[(attempt * 2) + 1], StringComparison.Ordinal);
        }
 
        Assert.All(
            timeoutInvocations,
            line => Assert.Contains("--kill-after=", line, StringComparison.Ordinal));
 
        Assert.NotEmpty(pwshPath);
    }
 
    /// <summary>
    /// Wraps the step body the way the runner does before executing it.
    ///
    /// The authored cases deliberately run whatever the workflow says without first checking it for
    /// an <c>exit 0</c>. Deleting that line then surfaces as the failure it actually causes - the
    /// step exiting non-zero and taking the shard with it - rather than as a structural complaint
    /// about the script's last line.
    /// </summary>
    private static string BuildStepScript(string stepScript, bool keepExplicitExit)
    {
        var body = stepScript.TrimEnd();
        if (!keepExplicitExit)
        {
            // Only the control needs the line to exist, because it is built by removing it. Deriving
            // the control this way rather than pasting a copy of the old body keeps it from drifting
            // away from what the workflow actually runs.
            Assert.EndsWith(ExplicitSuccessExit, body, StringComparison.Ordinal);
            body = body[..body.LastIndexOf(ExplicitSuccessExit, StringComparison.Ordinal)].TrimEnd();
        }
 
        // Asserted before it is replaced, so shortening the wait here cannot quietly stop matching
        // the workflow. The delay itself is not what these tests pin; the exit code contract is.
        Assert.Contains(RetryBackoff, body, StringComparison.Ordinal);
        body = body.Replace(RetryBackoff, "Start-Sleep -Milliseconds 50", StringComparison.Ordinal);
 
        return string.Join(Environment.NewLine, [GitHubPwshPrologue, body, GitHubPwshEpilogue]);
    }
 
    /// <summary>
    /// Builds a directory of stub executables that becomes the whole PATH for the step.
    ///
    /// Replacing PATH rather than prepending is deliberate: the step's first branch is
    /// <c>Get-Command ffmpeg</c>, so a machine that happens to have ffmpeg installed would otherwise
    /// silently skip the install path these tests exist to cover. pwsh is linked in because it has
    /// to stay resolvable once PATH is replaced.
    /// </summary>
    private static string CreateCommandStubs(TemporaryWorkspace workspace, string aptBehavior, out string invocationLog, out string pwshPath)
    {
        var stubDirectory = workspace.CreateDirectory("stubs").FullName;
        invocationLog = Path.Combine(workspace.Path, "invocations.log");
        var attemptCounter = Path.Combine(workspace.Path, "install-attempts");
 
        // `sudo timeout --kill-after=30 180 apt-get ...`, so both wrappers have to hand off to the
        // rest of the command line for the apt-get stub to ever run.
        WriteStub(stubDirectory, "sudo", $"""
            #!/bin/sh
            echo "sudo $*" >> "{invocationLog}"
            exec "$@"
            """);
 
        WriteStub(stubDirectory, "timeout", $"""
            #!/bin/sh
            echo "timeout $*" >> "{invocationLog}"
            while [ $# -gt 0 ]; do
              case "$1" in
                --kill-after=*) shift ;;
                -k) shift 2 ;;
                *) break ;;
              esac
            done
            shift
            exec "$@"
            """);
 
        // Only the install decides success; `apt-get update` is allowed to fail because the install
        // can still succeed from the indices already on the runner image.
        //
        // The attempt count is kept with shell builtins alone. PATH is replaced by this directory,
        // so external tools are deliberately unreachable, and reaching for one here would silently
        // read zero attempts every time and turn the fails-once case into a second always-fails run.
        WriteStub(stubDirectory, "apt-get", $"""
            #!/bin/sh
            echo "apt-get $*" >> "{invocationLog}"
            if [ "$1" != "install" ]; then exit 0; fi
            echo attempt >> "{attemptCounter}"
            attempts=0
            while read -r _line; do attempts=$((attempts + 1)); done < "{attemptCounter}"
            case "{aptBehavior}" in
              succeeds) exit 0 ;;
              fails-once) [ "$attempts" -ge 2 ] && exit 0; exit {StubAptFailureExitCode} ;;
              *) exit {StubAptFailureExitCode} ;;
            esac
            """);
 
        pwshPath = PathLookupHelper.FindFullPathFromPath("pwsh") ?? string.Empty;
        Assert.SkipWhen(pwshPath.Length == 0, "pwsh is not on PATH.");
        File.CreateSymbolicLink(Path.Combine(stubDirectory, "pwsh"), pwshPath);
 
        return stubDirectory;
    }
 
    private static void WriteStub(string directory, string name, string contents)
    {
        var stubPath = Path.Combine(directory, name);
        File.WriteAllText(stubPath, contents);
 
        // The test skips on Windows before reaching this, but Assert.SkipWhen is invisible to the
        // platform-compatibility analyzer, so the guard has to be one it can see.
        if (!OperatingSystem.IsWindows())
        {
            File.SetUnixFileMode(stubPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
        }
    }
}