Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 48 additions & 61 deletions tests/Aspire.Cli.EndToEnd.Tests/SelfUpdateChannelPersistenceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public sealed class SelfUpdateChannelPersistenceTests(ITestOutputHelper output)
{
[Fact]
[CaptureWorkspaceOnFailure]
public async Task SelfUpdateToStaging_RelaunchedCliUsesStagingForImplicitProjectUpdate()
public async Task SelfUpdateToStaging_PersistsChannelForRelaunchedCli()
{
var repoRoot = CliE2ETestHelpers.GetRepoRoot();
var strategy = CliInstallStrategy.Detect(output.WriteLine);
Expand All @@ -33,87 +33,74 @@ public async Task SelfUpdateToStaging_RelaunchedCliUsesStagingForImplicitProject
await auto.PrepareDockerEnvironmentAsync(counter, workspace);
await auto.InstallAspireCliAsync(strategy, counter);

const string projectName = "SelfUpdateChannelApp";
var projectPath = Path.Combine(workspace.WorkspaceRoot.FullName, projectName);
var configPath = Path.Combine(projectPath, "aspire.config.json");

// Model the reported scenario where the AppHost predates the self-update. Normalize any
// channel assigned by the current-source install so the later update must use CLI identity.
await auto.AspireNewCSharpEmptyAppHostAsync(
projectName,
counter,
timeout: TimeSpan.FromMinutes(5));

// The downloaded staging CLI can predate package-only AppHosts.
var appHostPath = Path.Combine(projectPath, "apphost.cs");
var appHostContent = File.ReadAllText(appHostPath);
var appHostVersion = CliE2ETestHelpers.GetAppHostVersion(appHostContent);
File.WriteAllText(appHostPath, $"#:sdk Aspire.AppHost.Sdk@{appHostVersion}\n" + string.Join('\n',
appHostContent.Split('\n').Where(line =>
!line.StartsWith("#:sdk Aspire.AppHost.Sdk@", StringComparison.Ordinal) &&
!line.StartsWith("#:package Aspire.Hosting.AppHost@", StringComparison.Ordinal) &&
!line.StartsWith("#:package Aspire.Hosting.Dotnet@", StringComparison.Ordinal))));

var createdConfig = ReadConfig(configPath);
createdConfig.Remove("channel");
File.WriteAllText(configPath, createdConfig.ToJsonString());
Assert.False(ReadConfig(configPath).ContainsKey("channel"));

if (strategy.Mode is CliInstallMode.LocalHive)
{
// LocalHive setup pins its original Aspire home to local. Remove that competing
// global setting before switching to the isolated script-style install.
await auto.RunCommandAsync("aspire config delete channel -g", counter);
}

// Copy the current build into a dedicated get-aspire-cli.sh-style prefix. This gives the
// self-update a realistic writable route without replacing the harness's original install.
//
// The sidecar's "packages" field pins Aspire package resolution to the harness's local
// hive. Without it the relaunched CLI derives its feed from the identity it just persisted
// (darc-pub-microsoft-aspire-<commit>), which stops carrying matching packages once that
// staging build is promoted to GA -- the exact failure reported in
// https://github.com/microsoft/aspire/issues/19708. InstallSidecarWriter.PrepareForSelfUpdate
// rewrites only channel/version/commit, so this field survives the self-update below.
// Local-hive setup exports identity overrides, which take precedence over the sidecar.
await auto.RunCommandAsync(
"unset ASPIRE_CLI_CHANNEL ASPIRE_CLI_VERSION ASPIRE_CLI_COMMIT ASPIRE_CLI_PACKAGES ASPIRE_CLI_NUGET_SERVICE_INDEX",
counter);

// Keep the writable install outside the captured workspace so failures don't upload
// the CLI and its backup. Copy only the sidecar into the workspace for assertions.
// Parse --version as e.g. "17.0.0-pr.123.gabcdef12+<sha>"; only seed a hex commit suffix.
// The packages override makes fresh invocations emit the effective-identity notice.
await auto.RunCommandAsync(
"install_root=$HOME/.aspire-self-update-e2e; " +
"package_path=$(find ~/.aspire/hives -type f -name 'Aspire.Hosting.*.nupkg' -print -quit); " +
"test -n \"$package_path\"; " +
"packages_dir=$(dirname \"$package_path\"); " +
"mkdir -p \"$install_root/bin\"; " +
"cp \"$(command -v aspire)\" \"$install_root/bin/aspire\"; " +
"chmod +x \"$install_root/bin/aspire\"; " +
"printf '{\"source\":\"script\",\"channel\":\"stable\",\"packages\":\"%s\"}\\n' \"$packages_dir\" > \"$install_root/bin/.aspire-install.json\"; " +
"export PATH=\"$install_root/bin:$PATH\" ASPIRE_CLI_TELEMETRY_OPTOUT=true; hash -r; " +
"package_path=$(find ~/.aspire/hives -type f -name 'Aspire.Hosting.*.nupkg' -print -quit) && " +
"test -n \"$package_path\" && " +
"packages_dir=$(dirname \"$package_path\") && " +
"cli_version=$(aspire --version 2>/dev/null) && " +
"mkdir -p \"$install_root/bin\" && " +
"cp \"$(command -v aspire)\" \"$install_root/bin/aspire\" && " +
"chmod +x \"$install_root/bin/aspire\" && " +
"jq -n --arg version \"${cli_version%%+*}\" --arg commit \"${cli_version#*+}\" --arg packages \"$packages_dir\" " +
"'{source:\"script\",channel:\"stable\",version:$version,packages:$packages,e2eMarker:\"keep\"} + " +
"(if ($commit | test(\"^[0-9a-fA-F]{8,64}$\")) then {commit:$commit} else {} end)' " +
"> \"$install_root/bin/.aspire-install.json\" && " +
"cp \"$install_root/bin/.aspire-install.json\" initial-install-sidecar.json && " +
"export PATH=\"$install_root/bin:$PATH\" ASPIRE_CLI_TELEMETRY_OPTOUT=true && hash -r && " +
"test \"$(command -v aspire)\" = \"$install_root/bin/aspire\"",
counter);

var initialSidecar = ReadSidecar(Path.Combine(workspace.WorkspaceRoot.FullName, "initial-install-sidecar.json"));
Assert.Equal("stable", initialSidecar["channel"]?.GetValue<string>());
Assert.NotNull(initialSidecar["version"]);
Assert.NotNull(initialSidecar["packages"]);

await auto.RunCommandAsync(
"aspire update --self --channel staging --non-interactive --yes",
counter,
timeout: TimeSpan.FromMinutes(10));

await auto.ClearScreenAsync(counter);

await auto.RunCommandAsync("aspire config set features.updateNotificationsEnabled false -g", counter);

// Relaunch from the replaced path without specifying a channel. Seeing staging land in the
// project config proves the new process resolved the identity persisted in the install
// sidecar. A second `aspire update --self` cannot be used to assert this: the preserved
// "packages" field synthesizes a local-hive channel that replaces the same-named built-in
// one (PackagingService.GetChannelsAsync), and a local hive has no CliDownloadBaseUrl, so
// self-download reports "Channel 'staging' does not support CLI downloads".
await auto.RunCommandAsync($"cd {projectName}", counter);
await auto.RunCommandAsync(
"aspire update --non-interactive --yes",
counter,
timeout: TimeSpan.FromMinutes(10));
"cp \"$install_root/bin/.aspire-install.json\" updated-install-sidecar.json",
counter);

var updatedConfig = ReadConfig(configPath);
Assert.Equal("staging", updatedConfig["channel"]?.GetValue<string>());
var updatedSidecar = ReadSidecar(Path.Combine(workspace.WorkspaceRoot.FullName, "updated-install-sidecar.json"));
Assert.Equal("staging", updatedSidecar["channel"]?.GetValue<string>());
Assert.Equal("script", updatedSidecar["source"]?.GetValue<string>());
Assert.Equal(initialSidecar["packages"]?.GetValue<string>(), updatedSidecar["packages"]?.GetValue<string>());
Assert.Equal("keep", updatedSidecar["e2eMarker"]?.GetValue<string>());
Assert.False(updatedSidecar.ContainsKey("version"));
Assert.False(updatedSidecar.ContainsKey("commit"));

// This invocation runs the downloaded CLI, not the current-source build. Its notice
// checks the effective identity without relying on C# updates, which preserve native
// NuGet policy. If the binary itself is stamped staging, only the sidecar assertions
// above distinguish persisted identity from its built-in channel.
await auto.RunCommandAsync(
"aspire --version > relaunched-version.txt 2> relaunched-identity.txt && " +
"cat relaunched-identity.txt && " +
"grep -F \"emulating identity 'staging'\" relaunched-identity.txt",
counter);
}

private static JsonObject ReadConfig(string path)
private static JsonObject ReadSidecar(string path)
{
return JsonNode.Parse(File.ReadAllText(path))?.AsObject()
?? throw new InvalidOperationException($"Unable to read {path}.");
Expand Down
Loading