File: RepositoryToolUpdateTests.cs
Web Access
Project: src\tests\Aspire.Cli.EndToEnd.Tests\Aspire.Cli.EndToEnd.Tests.csproj (Aspire.Cli.EndToEnd.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.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Aspire.Cli.EndToEnd.Tests.Helpers;
using Hex1b.Automation;
using Xunit;
 
namespace Aspire.Cli.EndToEnd.Tests;
 
public sealed class RepositoryToolUpdateTests(ITestOutputHelper output)
{
    private const string OriginalVersion = "0.0.0";
 
    [Fact]
    [CaptureWorkspaceOnFailure]
    public Task UpdateRepositoryTools_AcceptUpdatesPinsWithoutInstallingOrReplacingCli()
    {
        return UpdateRepositoryToolsAsync(accept: true);
    }
 
    [Fact]
    [CaptureWorkspaceOnFailure]
    public Task UpdateRepositoryTools_DeclineLeavesRepositoryAndCliUnchanged()
    {
        return UpdateRepositoryToolsAsync(accept: false);
    }
 
    private async Task UpdateRepositoryToolsAsync(bool accept)
    {
        var cancellationToken = TestContext.Current.CancellationToken;
        var repoRoot = CliE2ETestHelpers.GetRepoRoot();
        var strategy = CliInstallStrategy.Detect(output.WriteLine);
        if (strategy.Mode is not (CliInstallMode.LocalHive or CliInstallMode.PullRequest or CliInstallMode.LocalArchive))
        {
            Assert.Skip("Repository tool updates require a current source build. Run with ASPIRE_E2E_ARCHIVE or in pull request CI.");
        }
 
        var workspace = TemporaryWorkspace.Create(output);
        var repositoryPath = Path.Combine(workspace.WorkspaceRoot.FullName, "repository");
        var dotnetManifestPath = Path.Combine(repositoryPath, ".config", "dotnet-tools.json");
        var npmManifestPath = Path.Combine(repositoryPath, "package.json");
        var probeDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "package-manager-probes");
        var invocationLogPath = Path.Combine(workspace.WorkspaceRoot.FullName, "package-manager-invocations.log");
 
        Directory.CreateDirectory(Path.GetDirectoryName(dotnetManifestPath)!);
        Directory.CreateDirectory(Path.Combine(repositoryPath, ".git"));
        Directory.CreateDirectory(probeDirectory);
 
        var dotnetManifest = $$"""
            {
              "version": 1,
              "isRoot": true,
              "tools": {
                "aspire.cli": {
                  "version": "{{OriginalVersion}}",
                  "commands": ["aspire"],
                  "rollForward": true
                },
                "unrelated.tool": {
                  "version": "1.2.3",
                  "commands": ["unrelated"]
                }
              }
            }
            """;
        var npmManifest = $$"""
            {
              "name": "repository-tool-update",
              "private": true,
              "description": "Preserve unrelated repository metadata",
              "dependencies": {
                "@microsoft/aspire-cli": "{{OriginalVersion}}"
              },
              "devDependencies": {
                "@microsoft/aspire-cli": "^{{OriginalVersion}}"
              },
              "optionalDependencies": {
                "@microsoft/aspire-cli": "~{{OriginalVersion}}"
              },
              "scripts": {
                "preinstall": "node -e \"require('node:fs').writeFileSync('lifecycle-ran', 'preinstall')\"",
                "install": "node -e \"require('node:fs').writeFileSync('lifecycle-ran', 'install')\"",
                "postinstall": "node -e \"require('node:fs').writeFileSync('lifecycle-ran', 'postinstall')\""
              },
              "custom": {
                "keep": true
              }
            }
            """;
 
        await File.WriteAllTextAsync(dotnetManifestPath, dotnetManifest, cancellationToken);
        await File.WriteAllTextAsync(npmManifestPath, npmManifest, cancellationToken);
        var originalDotnetBytes = await File.ReadAllBytesAsync(dotnetManifestPath, cancellationToken);
        var originalNpmBytes = await File.ReadAllBytesAsync(npmManifestPath, cancellationToken);
 
        Dictionary<string, byte[]> lockFiles = new()
        {
            ["package-lock.json"] = Encoding.UTF8.GetBytes("{ \"name\": \"repository-tool-update\", \"lockfileVersion\": 3, \"packages\": {} }\r\n"),
            ["npm-shrinkwrap.json"] = Encoding.UTF8.GetBytes("{\n  \"name\": \"repository-tool-update\",\n  \"lockfileVersion\": 3,\n  \"packages\": {}\n}\n"),
            ["pnpm-lock.yaml"] = Encoding.UTF8.GetBytes("lockfileVersion: '9.0'\nimporters:\n  .: {}\n"),
            ["yarn.lock"] = Encoding.UTF8.GetBytes("# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.\n# yarn lockfile v1\n\n")
        };
        foreach (var (name, bytes) in lockFiles)
        {
            await File.WriteAllBytesAsync(Path.Combine(repositoryPath, name), bytes, cancellationToken);
        }
 
        // These transparent wrappers record real package-manager invocations, then delegate unchanged.
        // Metadata resolution must still use npm, but even an install with scripts disabled must fail the test.
        const string PackageManagerWrapper = """
            #!/bin/sh
            tool=${0##*/}
            printf '%s' "$tool" >> "$REPOSITORY_TOOL_UPDATE_LOG"
            for argument in "$@"; do
                printf ' %s' "$argument" >> "$REPOSITORY_TOOL_UPDATE_LOG"
            done
            printf '\n' >> "$REPOSITORY_TOOL_UPDATE_LOG"
            case "$tool" in
                npm) exec "$REPOSITORY_TOOL_UPDATE_NPM" "$@" ;;
                pnpm) exec "$REPOSITORY_TOOL_UPDATE_PNPM" "$@" ;;
                yarn) exec "$REPOSITORY_TOOL_UPDATE_YARN" "$@" ;;
                *) exit 125 ;;
            esac
            """;
        foreach (var tool in new[] { "npm", "pnpm", "yarn" })
        {
            await File.WriteAllTextAsync(
                Path.Combine(probeDirectory, tool),
                PackageManagerWrapper.ReplaceLineEndings("\n") + "\n",
                cancellationToken);
        }
 
        using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace);
        var counter = new SequenceCounter();
        var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
        await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, cancellationToken);
 
        await auto.PrepareDockerEnvironmentAsync(counter, workspace);
        await auto.InstallAspireCliAsync(strategy, counter);
        await auto.RunCommandAsync("aspire config set features.updateNotificationsEnabled false -g", counter);
        await auto.RunCommandAsync(
            "export REPOSITORY_TOOL_UPDATE_NPM=\"$(command -v npm)\" " +
            "REPOSITORY_TOOL_UPDATE_PNPM=\"$(command -v pnpm)\" " +
            "REPOSITORY_TOOL_UPDATE_YARN=\"$(command -v yarn)\" " +
            "REPOSITORY_TOOL_UPDATE_LOG=\"$ASPIRE_E2E_WORKSPACE/package-manager-invocations.log\"; " +
            "test -n \"$REPOSITORY_TOOL_UPDATE_NPM\" && " +
            "test -n \"$REPOSITORY_TOOL_UPDATE_PNPM\" && " +
            "test -n \"$REPOSITORY_TOOL_UPDATE_YARN\" && " +
            "chmod +x \"$ASPIRE_E2E_WORKSPACE\"/package-manager-probes/* && " +
            "export PATH=\"$ASPIRE_E2E_WORKSPACE/package-manager-probes:$PATH\" && " +
            "running_cli=$(readlink -f \"$(command -v aspire)\") && " +
            "cp \"$running_cli\" /tmp/aspire-before-repository-update && " +
            "cd \"$ASPIRE_E2E_WORKSPACE/repository\"",
            counter);
 
        var (dotnetVersion, npmVersion) = await GetLatestPublishedVersionsAsync(cancellationToken);
        output.WriteLine($"Expected stable CLI pins: NuGet={dotnetVersion}, npm={npmVersion}");
 
        await auto.ClearScreenAsync(counter);
        await auto.TypeAsync("aspire update --channel stable");
        await auto.EnterAsync();
        await auto.WaitUntilAsync(
            snapshot => snapshot.ContainsText("Perform updates?") &&
                snapshot.ContainsText("Aspire.Cli") &&
                snapshot.ContainsText("@microsoft/aspire-cli") &&
                snapshot.ContainsText(dotnetVersion) &&
                snapshot.ContainsText(npmVersion),
            timeout: TimeSpan.FromMinutes(3),
            description: "Repository tool updates and their shared confirmation prompt");
 
        Assert.Equal(originalDotnetBytes, await File.ReadAllBytesAsync(dotnetManifestPath, cancellationToken));
        Assert.Equal(originalNpmBytes, await File.ReadAllBytesAsync(npmManifestPath, cancellationToken));
 
        // Spectre accepts a single character; Enter afterwards could reach bash as another command.
        await auto.TypeAsync(accept ? "y" : "n");
        if (accept)
        {
            await auto.WaitUntilAsync(
                snapshot => snapshot.ContainsText("dotnet tool restore") &&
                    snapshot.ContainsText("Lockfiles were not changed.") &&
                    snapshot.ContainsText("npm install") &&
                    snapshot.ContainsText("The running CLI was not replaced."),
                description: "Restore/install guidance after manifest-only updates");
        }
        await auto.WaitForSuccessPromptAsync(counter);
 
        await auto.RunCommandAsync(
            "test \"$(readlink -f \"$(command -v aspire)\")\" = \"$running_cli\" && " +
            "cmp \"$running_cli\" /tmp/aspire-before-repository-update",
            counter);
 
        if (accept)
        {
            var expectedDotnet = JsonNode.Parse(dotnetManifest)!;
            expectedDotnet["tools"]!["aspire.cli"]!["version"] = dotnetVersion;
            var actualDotnet = await File.ReadAllTextAsync(dotnetManifestPath, cancellationToken);
            Assert.True(JsonNode.DeepEquals(expectedDotnet, JsonNode.Parse(actualDotnet)), actualDotnet);
 
            var expectedNpm = JsonNode.Parse(npmManifest)!;
            expectedNpm["dependencies"]!["@microsoft/aspire-cli"] = npmVersion;
            expectedNpm["devDependencies"]!["@microsoft/aspire-cli"] = "^" + npmVersion;
            expectedNpm["optionalDependencies"]!["@microsoft/aspire-cli"] = "~" + npmVersion;
            var actualNpm = await File.ReadAllTextAsync(npmManifestPath, cancellationToken);
            Assert.True(JsonNode.DeepEquals(expectedNpm, JsonNode.Parse(actualNpm)), actualNpm);
        }
        else
        {
            Assert.Equal(originalDotnetBytes, await File.ReadAllBytesAsync(dotnetManifestPath, cancellationToken));
            Assert.Equal(originalNpmBytes, await File.ReadAllBytesAsync(npmManifestPath, cancellationToken));
        }
 
        foreach (var (name, bytes) in lockFiles)
        {
            Assert.Equal(bytes, await File.ReadAllBytesAsync(Path.Combine(repositoryPath, name), cancellationToken));
        }
 
        Assert.Equal(
            ["npm view @microsoft/aspire-cli@latest version --registry https://registry.npmjs.org/"],
            await File.ReadAllLinesAsync(invocationLogPath, cancellationToken));
        Assert.False(File.Exists(Path.Combine(repositoryPath, "lifecycle-ran")));
        Assert.False(Directory.Exists(Path.Combine(repositoryPath, "node_modules")));
        Assert.False(File.Exists(Path.Combine(repositoryPath, "aspire.config.json")));
    }
 
    private static async Task<(string DotnetVersion, string NpmVersion)> GetLatestPublishedVersionsAsync(CancellationToken cancellationToken)
    {
        using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
        var nugetTask = httpClient.GetStringAsync("https://api.nuget.org/v3-flatcontainer/aspire.cli/index.json", cancellationToken);
        var npmTask = httpClient.GetStringAsync("https://registry.npmjs.org/@microsoft%2faspire-cli/latest", cancellationToken);
        await Task.WhenAll(nugetTask, npmTask);
 
        // NuGet returns {"versions":[...]}; npm's latest endpoint returns {"version":"..."}.
        // Resolve them independently because the two package registries can publish at different times.
        using var nugetMetadata = JsonDocument.Parse(await nugetTask);
        var dotnetVersion = nugetMetadata.RootElement.GetProperty("versions").EnumerateArray()
            .Select(version => version.GetString()!)
            .Where(version => Version.TryParse(version, out _))
            .MaxBy(Version.Parse);
        Assert.NotNull(dotnetVersion);
 
        using var npmMetadata = JsonDocument.Parse(await npmTask);
        var npmVersion = npmMetadata.RootElement.GetProperty("version").GetString();
        Assert.NotNull(npmVersion);
        Assert.True(Version.Parse(dotnetVersion) > Version.Parse(OriginalVersion));
        Assert.True(Version.Parse(npmVersion) > Version.Parse(OriginalVersion));
 
        return (dotnetVersion, npmVersion);
    }
}