// 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);
}
}