File: Pipelines\TrayBundleTests.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 System.Formats.Tar;
using System.IO.Compression;
using System.Runtime.Versioning;
using System.Xml.Linq;
using Aspire.SelectTests;
using Aspire.Tools.CreateLayout;
using Aspire.TestUtilities;
using Xunit;
using YamlDotNet.RepresentationModel;
 
namespace Infrastructure.Tests;
 
public sealed class TrayBundleTests(ITestOutputHelper output)
{
    [Theory]
    [InlineData("linux-x64")]
    [InlineData("linux-arm64")]
    public void LinuxLayoutsDoNotPackageTray(string rid)
    {
        using var workspace = TemporaryWorkspace.Create(output);
        using var builder = new LayoutBuilder(workspace.Path, workspace.Path, rid, "Debug", "test", false, "missing.app", "missing-windows");
 
        builder.CopyTray();
 
        Assert.Empty(Directory.EnumerateFileSystemEntries(workspace.Path));
    }
 
    [Theory]
    [InlineData("osx-arm64")]
    [InlineData("osx-x64")]
    public void MacLayoutRequiresExplicitTrayInput(string rid)
    {
        using var workspace = TemporaryWorkspace.Create(output);
        using var builder = new LayoutBuilder(workspace.Path, workspace.Path, rid, "Debug", "test", false, null, null);
 
        var error = Assert.Throws<InvalidOperationException>(builder.CopyTray);
 
        Assert.Contains("--tray-app", error.Message);
    }
 
    [Theory]
    [InlineData("Contents/MacOS/aspire-tray")]
    [InlineData("Contents/Info.plist")]
    [InlineData("Contents/Resources/Aspire.icns")]
    [InlineData("Contents/_CodeSignature/CodeResources")]
    public void MacLayoutRejectsIncompleteTray(string missingFile)
    {
        using var workspace = TemporaryWorkspace.Create(output);
        var source = MacTrayTestPayload.Create(workspace.Path);
        File.Delete(Path.Combine(source, missingFile));
        using var builder = new LayoutBuilder(Path.Combine(workspace.Path, "layout"), workspace.Path, "osx-arm64", "Debug", "test", false, source, null);
 
        var error = Assert.Throws<InvalidOperationException>(builder.CopyTray);
 
        Assert.Contains(Path.Combine(source, missingFile), error.Message);
    }
 
    [Fact]
    [UnsupportedOSPlatform("windows")]
    public void MacLayoutRejectsNonExecutableTray()
    {
        Assert.SkipWhen(OperatingSystem.IsWindows(), "Unix executable mode check.");
        using var workspace = TemporaryWorkspace.Create(output);
        var source = MacTrayTestPayload.Create(workspace.Path);
        File.SetUnixFileMode(Path.Combine(source, "Contents/MacOS/aspire-tray"), UnixFileMode.UserRead);
        using var builder = new LayoutBuilder(Path.Combine(workspace.Path, "layout"), workspace.Path, "osx-arm64", "Debug", "test", false, source, null);
 
        var error = Assert.Throws<InvalidOperationException>(builder.CopyTray);
 
        Assert.Contains("not executable", error.Message);
    }
 
    [Fact]
    [UnsupportedOSPlatform("windows")]
    public async Task AppResourcesSignaturesAndExecutableModesSurviveCopyAndArchive()
    {
        Assert.SkipWhen(OperatingSystem.IsWindows(), "macOS bundles are assembled on Unix.");
        using var workspace = TemporaryWorkspace.Create(output);
        var source = MacTrayTestPayload.Create(workspace.Path);
        var layout = Path.Combine(workspace.Path, "osx-arm64");
        var destination = Path.Combine(layout, "tray", "Aspire Tray.app");
        using var builder = new LayoutBuilder(layout, workspace.Path, "osx-arm64", "Debug", "test", false, source, null);
 
        builder.CopyTray();
 
        var expectedFiles = Directory.GetFiles(source, "*", SearchOption.AllDirectories)
            .Select(file => Path.GetRelativePath(source, file)).Order().ToArray();
        Assert.Equal(expectedFiles, Directory.GetFiles(destination, "*", SearchOption.AllDirectories)
            .Select(file => Path.GetRelativePath(destination, file)).Order().ToArray());
        foreach (var file in expectedFiles)
        {
            Assert.Equal(File.ReadAllBytes(Path.Combine(source, file)), File.ReadAllBytes(Path.Combine(destination, file)));
            Assert.Equal(File.GetUnixFileMode(Path.Combine(source, file)), File.GetUnixFileMode(Path.Combine(destination, file)));
        }
 
        var archivePath = await builder.CreateArchiveAsync();
        await using var archive = File.OpenRead(archivePath);
        await using var gzip = new GZipStream(archive, CompressionMode.Decompress);
        await using var tar = new TarReader(gzip);
        var archivedFiles = new List<string>();
        while (await tar.GetNextEntryAsync() is { } entry)
        {
            if (entry.EntryType is not (TarEntryType.RegularFile or TarEntryType.V7RegularFile))
            {
                continue;
            }
            const string prefix = "osx-arm64/tray/Aspire Tray.app/";
            Assert.StartsWith(prefix, entry.Name);
            var relativePath = entry.Name[prefix.Length..];
            archivedFiles.Add(relativePath);
            Assert.Equal(File.GetUnixFileMode(Path.Combine(source, relativePath)), entry.Mode);
            using var content = new MemoryStream();
            await entry.DataStream!.CopyToAsync(content);
            Assert.Equal(File.ReadAllBytes(Path.Combine(source, relativePath)), content.ToArray());
        }
 
        Assert.Equal(expectedFiles, archivedFiles.Order());
    }
 
    [Fact]
    public void BundlePublishesTrayBeforeLayoutIndependentlyOfNativeCliSkip()
    {
        var project = LoadProject("eng/Bundle.proj");
        var build = Target(project, "Build");
        var dependencies = build.Attribute("DependsOnTargets")!.Value.Split(';', StringSplitOptions.TrimEntries);
        Assert.True(Array.IndexOf(dependencies, "_PublishNativeTray") < Array.IndexOf(dependencies, "_RunCreateLayout"));
        Assert.Equal("($(TargetRid.StartsWith('osx-')) or $(TargetRid.StartsWith('win-'))) and '$(SkipTrayBuild)' != 'true'", Target(project, "_PublishNativeTray").Attribute("Condition")!.Value);
        Assert.Equal("'$(SkipNativeBuild)' != 'true'", Target(project, "_PublishNativeCli").Attribute("Condition")!.Value);
        Assert.Contains("--tray-app \"$(TrayAppPath)\"", Target(project, "_RunCreateLayout").Value);
        Assert.Contains("--tray-windows \"$(WindowsTrayPath)\"", Target(project, "_RunCreateLayout").Value);
        var cliPublish = Target(project, "_PublishNativeCli");
        Assert.Equal("--output \"$(CliPublishDir)\"", Assert.Single(cliPublish.Descendants("_CliPublishOutputArg")).Value);
        Assert.Contains("$(_CliPublishOutputArg)", Assert.Single(cliPublish.Elements("Exec")).Attribute("Command")!.Value);
    }
 
    [Fact]
    public void WholeAppSigningBypassesArcadeAndRestoresWithoutPublishing()
    {
        var project = LoadProject("src/Aspire.Tray/Mac/Aspire.Tray.Mac.csproj");
        Assert.Equal("Publish", Target(project, "PackageTray").Attribute("AfterTargets")!.Value);
        var restore = Target(project, "RestoreSignedTray");
        Assert.Null(restore.Attribute("DependsOnTargets"));
        Assert.Contains(restore.Elements("Exec"), exec => exec.Attribute("Command")!.Value.StartsWith("ditto -xk", StringComparison.Ordinal));
        Assert.Contains(restore.Elements("Exec"), exec => exec.Attribute("Command")!.Value.Contains("anchor apple generic", StringComparison.Ordinal));
 
        var signing = LoadProject("eng/Signing.props");
        Assert.Equal([@"$(ArtifactsBinDir)Aspire.Tray.Windows\**\publish\aspire-tray.exe"],
            signing.Descendants("ItemsToSign")
                .Select(element => element.Attribute("Include")!.Value)
                .Where(include => include.Contains("Aspire.Tray.", StringComparison.Ordinal)));
        var traySigning = LoadProject("eng/pipelines/SignTray.proj");
        var input = Assert.Single(traySigning.Descendants("_TrayToSign"));
        Assert.Equal("$(TraySigningArchive)", input.Attribute("Include")!.Value);
        Assert.Equal("MacDeveloperHarden", input.Element("Authenticode")!.Value);
        var notarization = Assert.Single(traySigning.Descendants("_TrayToNotarize"));
        Assert.Equal(input.Attribute("Include")!.Value, notarization.Attribute("Include")!.Value);
        Assert.Equal("8020", notarization.Element("Authenticode")!.Value);
        Assert.Equal("dotnet", notarization.Element("MacAppName")!.Value);
        var signTray = Target(traySigning, "SignTray");
        Assert.Equal("ValidateTraySigning", signTray.Attribute("DependsOnTargets")!.Value);
        Assert.Equal(["@(_TrayToSign)", "@(_TrayToNotarize)"],
            signTray.Elements("SignFiles").Select(task => task.Attribute("Files")!.Value));
 
        var pipeline = File.ReadAllText(Path.Combine(RepoRoot.Path, "eng/pipelines/templates/build_sign_native.yml"));
        var prepare = pipeline.IndexOf("/t:PrepareTraySigning", StringComparison.Ordinal);
        var sign = pipeline.IndexOf("SignManaged.binlog", StringComparison.Ordinal);
        var signApp = pipeline.IndexOf("/t:SignTray", StringComparison.Ordinal);
        var restoreApp = pipeline.IndexOf("/t:RestoreSignedTray", StringComparison.Ordinal);
        var layout = pipeline.IndexOf("/t:_RestoreDcpPackage;_RunCreateLayout", StringComparison.Ordinal);
        Assert.True(prepare >= 0 && prepare < sign && sign < signApp && signApp < restoreApp && restoreApp < layout);
        Assert.Contains("/p:SkipTrayBuild=true", pipeline);
    }
 
    [Fact]
    [RequiresTools(["pwsh", "ditto"])]
    [SupportedOSPlatform("macos")]
    public async Task MacSigningTransportContainsAppDirectoryAndPreservesExecutableModes()
    {
        Assert.SkipUnless(OperatingSystem.IsMacOS(), "macOS signing transport uses ditto.");
        using var workspace = TemporaryWorkspace.Create(output);
        var source = MacTrayTestPayload.Create(workspace.Path);
        var archivePath = Path.Combine(workspace.Path, "signing", "AspireTray.zip");
        var scriptPath = Path.Combine(workspace.Path, "prepare-signing.ps1");
        File.WriteAllText(scriptPath, """
            param([string]$RepositoryRoot, [string]$AppPath, [string]$ArchivePath)
            & "$RepositoryRoot/dotnet.sh" msbuild "$RepositoryRoot/src/Aspire.Tray/Mac/Aspire.Tray.Mac.csproj" `
                /t:PrepareTraySigning /p:RuntimeIdentifier=osx-arm64 `
                "/p:TrayAppPath=$AppPath" "/p:TraySigningArchive=$ArchivePath" /nologo
            exit $LASTEXITCODE
            """);
        using var command = new PowerShellCommand(scriptPath, output)
            .WithWorkingDirectory(RepoRoot.Path)
            .WithTimeout(TimeSpan.FromMinutes(2));
 
        var result = await command.ExecuteAsync(
            "-RepositoryRoot", $"\"{RepoRoot.Path}\"",
            "-AppPath", $"\"{source}\"",
            "-ArchivePath", $"\"{archivePath}\"");
 
        result.EnsureSuccessful();
        using var archive = ZipFile.OpenRead(archivePath);
        var files = archive.Entries.Where(entry => entry.Name.Length > 0 && !entry.FullName.StartsWith("__MACOSX/", StringComparison.Ordinal)).ToArray();
        var expectedFiles = Directory.GetFiles(source, "*", SearchOption.AllDirectories)
            .Select(file => $"{Path.GetFileName(source)}/{Path.GetRelativePath(source, file)}").Order().ToArray();
        Assert.Equal(expectedFiles, files.Select(entry => entry.FullName).Order());
        foreach (var entry in files)
        {
            var file = Path.Combine(Path.GetDirectoryName(source)!, entry.FullName);
            using var content = new MemoryStream();
            using var entryStream = entry.Open();
            await entryStream.CopyToAsync(content);
            Assert.Equal(File.ReadAllBytes(file), content.ToArray());
            Assert.Equal(File.GetUnixFileMode(file), (UnixFileMode)((entry.ExternalAttributes >> 16) & 0x1FF));
        }
    }
 
    [Fact]
    public void NativePayloadVerificationRunsAfterLayoutAndSharedTestsUseCentralMatrix()
    {
        var project = LoadProject("eng/Bundle.proj");
        var verification = Target(project, "_VerifyNativeTrayArchive");
        Assert.Equal("_RunCreateLayout", verification.Attribute("AfterTargets")!.Value);
        Assert.Equal("$(TargetRid.StartsWith('osx-'))", verification.Attribute("Condition")!.Value);
        var command = Assert.Single(verification.Elements("Exec")).Attribute("Command")!.Value;
        Assert.Contains("verify-tray-payload.sh", command);
        Assert.Contains("aspire-$(BundleVersion)-$(TargetRid).tar.gz", command);
        Assert.Contains("\"$(RequireMacTraySignature)\"", command);
 
        const string testProjectPath = "tests/Aspire.Tray.Tests/Aspire.Tray.Tests.csproj";
        var solution = LoadProject("Aspire.slnx");
        Assert.Contains(solution.Descendants("Project"), entry => entry.Attribute("Path")?.Value == testProjectPath);
        var testProject = LoadProject(testProjectPath);
        Assert.Equal("true", Assert.Single(testProject.Descendants("RunOnGithubActionsMacOS")).Value);
    }
 
    [Theory]
    [InlineData("Aspire.Tray.Mac", true)]
    [InlineData("Aspire.Tray.Windows", true)]
    public void NativeTrayChangesSelectSharedAndPackagingTests(string project, bool bundled)
    {
        var selector = new TestSelector(
            Path.Combine(RepoRoot.Path, "eng/github-ci/test-trigger-map.yml"),
            new HashSet<string>(["Aspire.Tray.Tests", "Infrastructure.Tests", "Aspire.Cli.EndToEnd.Tests"], StringComparer.Ordinal),
            new HashSet<string>(StringComparer.Ordinal),
            affectedTestProjectNames: new HashSet<string>(StringComparer.Ordinal));
 
        var result = selector.Select([], [project], new SelectorOptions());
 
        Assert.False(result.SelectsAll);
        Assert.Equal(bundled
                ? ["Aspire.Cli.EndToEnd.Tests", "Aspire.Tray.Tests", "Infrastructure.Tests"]
                : new[] { "Aspire.Tray.Tests", "Infrastructure.Tests" },
            result.TestProjects.Order(StringComparer.Ordinal));
        Assert.Equal(bundled
                ? ["job:cli-starter-validation", "job:extension-e2e", "job:homebrew-installer", "job:winget-installer"]
                : Array.Empty<string>(),
            result.Jobs.Order(StringComparer.Ordinal));
    }
 
    [Fact]
    public void BundleUsesProductTrayProjectAndOutputDirectory()
    {
        var project = LoadProject("eng/Bundle.proj");
        Assert.Equal(@"$(RepoRoot)src\Aspire.Tray\Mac\Aspire.Tray.Mac.csproj", Assert.Single(project.Descendants("TrayProjectPath")).Value);
        Assert.Equal(@"$(ArtifactsDir)bin\Aspire.Tray.Mac\$(Configuration)\net10.0\$(TargetRid)\app\Aspire Tray.app", Assert.Single(project.Descendants("TrayAppPath")).Value);
        Assert.All(Target(project, "_PublishNativeTray").Elements("Exec"),
            exec => Assert.Contains("$(_OfficialBuildIdArg)", exec.Attribute("Command")!.Value));
    }
 
    [Theory]
    [InlineData("win-x64")]
    [InlineData("win-arm64")]
    public async Task WindowsLayoutCopiesOnlyNativePayloadAndPreservesArchiveBytes(string rid)
    {
        using var workspace = TemporaryWorkspace.Create(output);
        var source = Path.Combine(workspace.Path, "publish with spaces");
        WindowsTrayTestPayload.Create(source, rid);
        File.WriteAllText(Path.Combine(source, "smoke.stdout.log"), "not runtime content");
        var layout = Path.Combine(workspace.Path, rid);
        using var builder = new LayoutBuilder(layout, workspace.Path, rid, "Debug", "test", false, null, source);
 
        builder.CopyTray();
 
        var archivePath = await builder.CreateArchiveAsync();
        await using var archive = File.OpenRead(archivePath);
        await using var gzip = new GZipStream(archive, CompressionMode.Decompress);
        await using var tar = new TarReader(gzip);
        var files = new List<string>();
        while (await tar.GetNextEntryAsync() is { } entry)
        {
            if (entry.EntryType is not (TarEntryType.RegularFile or TarEntryType.V7RegularFile))
            {
                continue;
            }
            files.Add(entry.Name);
            using var content = new MemoryStream();
            await entry.DataStream!.CopyToAsync(content);
            Assert.Equal(File.ReadAllBytes(Path.Combine(source, Path.GetFileName(entry.Name))), content.ToArray());
        }
        Assert.Equal(new[] { $"{rid}/tray/Aspire.ico", $"{rid}/tray/aspire-tray.exe" }.Order(), files.Order());
    }
 
    [Theory]
    [InlineData("missing-input")]
    [InlineData("missing-icon")]
    [InlineData("missing-executable")]
    [InlineData("wrong-rid")]
    [InlineData("unsupported-rid")]
    [InlineData("managed")]
    public void WindowsLayoutRejectsInvalidPublishOutput(string scenario)
    {
        using var workspace = TemporaryWorkspace.Create(output);
        var source = Path.Combine(workspace.Path, "publish");
        WindowsTrayTestPayload.Create(source, scenario == "wrong-rid" ? "win-arm64" : "win-x64");
        if (scenario == "missing-icon")
        {
            File.Delete(Path.Combine(source, "Aspire.ico"));
        }
        if (scenario == "missing-executable")
        {
            File.Delete(Path.Combine(source, "aspire-tray.exe"));
        }
        if (scenario == "managed")
        {
            File.Copy(typeof(TrayBundleTests).Assembly.Location, Path.Combine(source, "aspire-tray.exe"), overwrite: true);
        }
        using var builder = new LayoutBuilder(Path.Combine(workspace.Path, "layout"), workspace.Path,
            scenario == "unsupported-rid" ? "win-x86" : "win-x64", "Debug", "test", false, null,
            scenario == "missing-input" ? null : source);
 
        if (scenario == "missing-input")
        {
            Assert.Equal("The Windows bundle requires --tray-windows pointing to the pre-built native tray publish directory.",
                Assert.Throws<InvalidOperationException>(builder.CopyTray).Message);
        }
        else
        {
            Assert.Throws<InvalidDataException>(builder.CopyTray);
        }
    }
 
    [Fact]
    public void WindowsSigningAndArchiveVerificationUsePublishedPayloadBeforeEmbedding()
    {
        var signing = LoadProject("eng/Signing.props");
        Assert.Equal("Microsoft400", Assert.Single(signing.Descendants("FileSignInfo"),
            element => element.Attribute("Include")?.Value == "aspire-tray.exe").Attribute("CertificateName")!.Value);
        Assert.Contains(signing.Descendants("ItemsToSign"),
            element => element.Attribute("Include")?.Value == @"$(ArtifactsBinDir)Aspire.Tray.Windows\**\publish\aspire-tray.exe");
        var bundle = LoadProject("eng/Bundle.proj");
        var verify = Target(bundle, "_VerifyWindowsTrayArchive");
        Assert.Equal("_RunCreateLayout", verify.Attribute("AfterTargets")!.Value);
        Assert.Equal("$(TargetRid.StartsWith('win-'))", verify.Attribute("Condition")!.Value);
        Assert.All(verify.Elements("Exec"),
            exec => Assert.Contains("verify-windows-tray-payload.ps1", exec.Attribute("Command")!.Value));
        var pipeline = File.ReadAllText(Path.Combine(RepoRoot.Path, "eng/pipelines/templates/build_sign_native.yml"));
        Assert.Contains("if in(parameters.agentOs, 'macos', 'windows')", pipeline);
        Assert.Contains("/p:RequireWindowsTraySignature=${{ parameters.codeSign }}", pipeline);
        Assert.True(pipeline.IndexOf("/t:_PublishNativeTray", StringComparison.Ordinal) <
            pipeline.IndexOf("SignManaged.binlog", StringComparison.Ordinal));
    }
 
    [Fact]
    public void WindowsNativeSmokeUsesMatchingPublishedCliAndExplicitBoundedDuration()
    {
        var workflow = File.ReadAllText(Path.Combine(RepoRoot.Path, ".github/workflows/build-cli-native-archives.yml"));
        Assert.Contains("- name: Check Windows tray desktop availability\n        id: windows_tray_desktop\n        if: runner.os == 'Windows'", workflow);
        Assert.Contains("[Environment]::UserInteractive -and $session -ne 0 -and $explorer.Count -gt 0", workflow);
        Assert.Contains("FindWindow(\"Shell_TrayWnd\", null)", workflow);
        Assert.Contains("OpenInputDesktop(0, false, 0x0001)", workflow);
        Assert.Contains("available=$($available.ToString().ToLowerInvariant())", workflow);
        Assert.Contains("Write-Output \"::warning::$notice\"", workflow);
        Assert.Contains(">> $env:GITHUB_STEP_SUMMARY", workflow);
        Assert.Contains("""
                  - name: Report skipped Windows ARM64 tray smoke
                    if: runner.os == 'Windows' && matrix.targets.rids == 'win-arm64'
                    shell: pwsh
                    run: |
                      $notice = 'Windows ARM64 native tray UI smoke is temporarily disabled on GitHub-hosted runners: notification icon registration fails in native ARM64 and emulated x64 SDK controls despite a responsive Explorer. The root cause remains unresolved. Native publishing and payload verification remain enabled; native UI behavior is not validated by this job.'
                      Write-Output "::warning::$notice"
                      "### Windows ARM64 tray UI validation`n`n$notice" >> $env:GITHUB_STEP_SUMMARY
            """, workflow);
        Assert.Contains("- name: Smoke Windows native tray\n        if: runner.os == 'Windows' && matrix.targets.rids != 'win-arm64' && steps.windows_tray_desktop.outputs.available == 'true'", workflow);
        Assert.Contains("-SkipPublish -CliPath $cli -SmokeSeconds 60", workflow);
        Assert.Contains("$cli = Join-Path $scratch 'cli/aspire.exe'", workflow);
        Assert.Contains("-PublishDirectory (Join-Path $scratch \"$rid/tray\")", workflow);
        Assert.Contains("eng/scripts/tray-registration-control/run.ps1 -Architecture ($rid -replace '^win-', '') -CompareArchitectures:($rid -eq 'win-arm64')", workflow);
        Assert.Contains("artifacts/bundle/aspire-ci-bundlepayload-$rid.tar.gz", workflow);
        var yaml = new YamlStream();
        yaml.Load(new StringReader(workflow));
        var root = Assert.IsType<YamlMappingNode>(yaml.Documents[0].RootNode);
        var jobs = Assert.IsType<YamlMappingNode>(root.Children[new YamlScalarNode("jobs")]);
        var job = Assert.IsType<YamlMappingNode>(jobs.Children[new YamlScalarNode("build_cli_archives")]);
        var steps = Assert.IsType<YamlSequenceNode>(job.Children[new YamlScalarNode("steps")]);
        var smoke = Assert.Single(steps.Children.OfType<YamlMappingNode>(),
            step => step.Children.TryGetValue(new YamlScalarNode("name"), out var name) && name.ToString() == "Smoke Windows native tray");
        var environment = Assert.IsType<YamlMappingNode>(smoke.Children[new YamlScalarNode("env")]);
        Assert.Equal("${{ matrix.targets.rids }}", environment.Children[new YamlScalarNode("TRAY_RID")].ToString());
        Assert.Equal("${{ inputs.configuration }}", environment.Children[new YamlScalarNode("TRAY_CONFIGURATION")].ToString());
        var script = smoke.Children[new YamlScalarNode("run")].ToString();
        Assert.Equal(-1, script.IndexOf("${{", StringComparison.Ordinal));
        Assert.Contains("$rid = $env:TRAY_RID", script);
        Assert.Contains("-Configuration $env:TRAY_CONFIGURATION", script);
        var publish = File.ReadAllText(Path.Combine(RepoRoot.Path, "src/Aspire.Tray/Windows/publish.ps1"));
        Assert.Contains("$startInfo.ArgumentList.Add($argument)", publish);
        Assert.Contains("$process.StandardOutput.ReadToEndAsync()", publish);
        Assert.Contains("$process.StandardError.ReadToEndAsync()", publish);
        Assert.Contains("$process.WaitForExit(($SmokeSeconds + 30) * 1000)", publish);
        Assert.Contains("tools/CreateLayout/verify-windows-tray-payload.ps1", publish);
        Assert.Contains("-PublishDirectory $output -Rid \"win-$Architecture\"", publish);
    }
 
    [Theory]
    [RequiresTools(["pwsh", "tar"])]
    [InlineData("win-x64", "valid", 0)]
    [InlineData("win-arm64", "valid", 0)]
    [InlineData("win-x64", "wrong-rid", 1)]
    [InlineData("win-x64", "missing-icon", 1)]
    [InlineData("win-x64", "changed-icon", 1)]
    [InlineData("win-x64", "extra-file", 1)]
    [InlineData("win-x64", "managed", 1)]
    public async Task WindowsArchiveVerifierChecksActualPayload(string rid, string scenario, int expectedExitCode)
    {
        using var workspace = TemporaryWorkspace.Create(output);
        var source = Path.Combine(workspace.Path, "source");
        WindowsTrayTestPayload.Create(source, rid);
        File.Copy(Path.Combine(RepoRoot.Path, "src/Shared/Aspire.ico"), Path.Combine(source, "Aspire.ico"), overwrite: true);
        var layout = Path.Combine(workspace.Path, rid);
        using var builder = new LayoutBuilder(layout, workspace.Path, rid, "Debug", "test", false, null, source);
        builder.CopyTray();
        var tray = Path.Combine(layout, "tray");
        switch (scenario)
        {
            case "wrong-rid":
                WindowsTrayTestPayload.Create(tray, "win-arm64");
                File.Copy(Path.Combine(source, "Aspire.ico"), Path.Combine(tray, "Aspire.ico"), overwrite: true);
                break;
            case "missing-icon":
                File.Delete(Path.Combine(tray, "Aspire.ico"));
                break;
            case "changed-icon":
                File.WriteAllText(Path.Combine(tray, "Aspire.ico"), "wrong icon");
                break;
            case "extra-file":
                File.WriteAllText(Path.Combine(tray, "smoke.log"), "unexpected runtime content");
                break;
            case "managed":
                File.Copy(typeof(TrayBundleTests).Assembly.Location, Path.Combine(tray, "aspire-tray.exe"), overwrite: true);
                break;
        }
        var archive = await builder.CreateArchiveAsync();
        using var command = new PowerShellCommand(Path.Combine(RepoRoot.Path, "tools/CreateLayout/verify-windows-tray-payload.ps1"), output)
            .WithTimeout(TimeSpan.FromMinutes(1));
 
        var result = await command.ExecuteAsync("-Archive", $"\"{archive}\"", "-Rid", rid, "-RequireSignature", "false");
 
        result.EnsureExitCode(expectedExitCode);
        if (expectedExitCode == 0)
        {
            Assert.Contains($"Verified {rid} Windows tray payload", result.Output);
        }
    }
 
    private static XDocument LoadProject(string path) => XDocument.Load(Path.Combine(RepoRoot.Path, path));
 
    private static XElement Target(XDocument project, string name)
        => Assert.Single(project.Descendants("Target"), target => target.Attribute("Name")?.Value == name);
}