diff --git a/tests/Aspire.Cli.EndToEnd.Tests/SelfUpdateChannelPersistenceTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/SelfUpdateChannelPersistenceTests.cs index f2b93f69fc8..7c8416363d5 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/SelfUpdateChannelPersistenceTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/SelfUpdateChannelPersistenceTests.cs @@ -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); @@ -33,32 +33,6 @@ 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 @@ -66,54 +40,67 @@ await auto.AspireNewCSharpEmptyAppHostAsync( 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-), 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+"; 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()); + 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()); + var updatedSidecar = ReadSidecar(Path.Combine(workspace.WorkspaceRoot.FullName, "updated-install-sidecar.json")); + Assert.Equal("staging", updatedSidecar["channel"]?.GetValue()); + Assert.Equal("script", updatedSidecar["source"]?.GetValue()); + Assert.Equal(initialSidecar["packages"]?.GetValue(), updatedSidecar["packages"]?.GetValue()); + Assert.Equal("keep", updatedSidecar["e2eMarker"]?.GetValue()); + 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}.");