File: EmulatedLocalReleaseBuildTests.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.IO.Compression;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Aspire.Cli.EndToEnd.Tests.Helpers;
using Aspire.Cli.Resources;
using Hex1b.Automation;
using Xunit;
 
namespace Aspire.Cli.EndToEnd.Tests;
 
/// <summary>
/// End-to-end tests for the <b>all-local future release</b> workflow: a developer builds a
/// <em>stable-shaped</em> archive (CLI + packages + templates) for a version that does not exist
/// anywhere yet — e.g. a future <c>13.5.0</c> — entirely from local source via
/// <c>localhive --version 13.5.0</c>, then drives <c>aspire new</c> and <c>aspire add</c> so that
/// every template and package is resolved from that local build instead of nuget.org.
///
/// This differs from <see cref="EmulatedReleasedBuildTests"/> (which emulates the <em>already
/// shipped</em> latest stable version, resolving from nuget.org) in that the emulated version is
/// only present in the local hive. Because the version does not exist on nuget.org, a successful
/// resolve against it is itself proof that the CLI consulted the local packages — exercising the
/// <c>ASPIRE_CLI_PACKAGES</c> hive override under a <c>stable</c> identity (the product fix that
/// makes the override honored under any emulated channel name, not just <c>local</c>).
///
/// This class contains the <b>all-local stable</b> row of the AppHost-language × channel matrix, with
/// one scaffolding test per AppHost language (C# and TypeScript), plus focused TypeScript coverage for
/// archive-backed identity emulation and package-scoped source composition. The TypeScript-focused
/// tests also run against the PR archive used by normal GitHub CI, so those regression guards do not
/// depend on the opt-in stable archive. We keep the language tests separate because C# and TypeScript
/// AppHosts scaffold through different code paths and have diverged in behavior before.
///
/// <para>
/// <b>Gating / cost:</b> the two scaffolding tests only run when the CLI was installed from a local
/// hive archive (<see cref="CliInstallMode.LocalHive"/>) and that build produced a stable-shaped
/// package (no pre-release suffix). The archive-backed identity and package-scoped source-composition
/// tests additionally run with the <see cref="CliInstallMode.LocalArchive"/> supplied by normal PR CI.
/// The stable-release matrix activates in the deliberate local workflow:
/// <code>
/// ./localhive.sh --version 13.5.0 -o /tmp/aspire-localrelease -r linux-arm64 --archive
/// ASPIRE_E2E_ARCHIVE=/tmp/aspire-localrelease.tar.gz \
///   dotnet test tests/Aspire.Cli.EndToEnd.Tests/Aspire.Cli.EndToEnd.Tests.csproj \
///   -- --filter-class "*.EmulatedLocalReleaseBuildTests"
/// </code>
/// </para>
///
/// Each test class runs as a separate CI job for parallelization.
/// </summary>
public sealed class EmulatedLocalReleaseBuildTests(ITestOutputHelper output)
{
    /// <summary>
    /// SCENARIO: a developer-built <b>future stable release</b> (e.g. <c>13.5.0</c>) of the CLI,
    /// scaffolding the C# <c>aspire-starter</c> template entirely from the local build.
    ///
    /// Validates the "everything local" promise for C#: <c>aspire new</c> resolves the template from
    /// the local hive (not nuget.org), pins the AppHost SDK to the local stable version, and — because
    /// a stable build's packages live on the ambient default source — drops <b>no</b> per-project
    /// <c>NuGet.config</c>. Then <c>aspire add redis</c> resolves <c>Aspire.Hosting.Redis</c> at the
    /// local stable version; since that version exists only in the local hive, resolving it proves the
    /// CLI consulted <c>ASPIRE_CLI_PACKAGES</c> rather than nuget.org.
    /// </summary>
    [CaptureWorkspaceOnFailure]
    [Fact]
    public async Task EmulatedLocalReleaseScaffoldsCSharpStarterFromLocalPackages()
    {
        var repoRoot = CliE2ETestHelpers.GetRepoRoot();
        var strategy = CliInstallStrategy.Detect(output.WriteLine);
        var localStableVersion = RequireLocalStableArchiveOrSkip(repoRoot, strategy);
 
        var workspace = TemporaryWorkspace.Create(output);
 
        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, TestContext.Current.CancellationToken);
 
        await auto.PrepareDockerEnvironmentAsync(counter, workspace);
        await InstallLocalHiveWithoutLocalChannelAsync(auto, counter);
        await ApplyEmulatedLocalReleaseIdentityAsync(auto, counter, localStableVersion);
 
        const string projectName = "EmulatedLocalStarter";
        await auto.AspireNewAsync(projectName, counter, template: AspireTemplate.Starter, useRedisCache: false);
 
        var projectDir = new DirectoryInfo(Path.Combine(workspace.WorkspaceRoot.FullName, projectName));
 
        // A stable build's packages all live on the ambient default source, so the CLI must not pin a
        // custom feed via NuGet.config — even though resolution here is actually local. The hive is
        // surfaced through ASPIRE_CLI_PACKAGES, not a per-project feed pin.
        AssertNoNuGetConfig(projectDir);
 
        // The local stable version exists only in the hive, so an exact match pins the AppHost to
        // it — proving `aspire new` resolved the template from the local build rather than nuget.org.
        var appHostCsproj = Path.Combine(projectDir.FullName, $"{projectName}.AppHost", $"{projectName}.AppHost.csproj");
        var appHostVersion = CliE2ETestHelpers.GetAppHostVersion(File.ReadAllText(appHostCsproj));
        output.WriteLine($"Generated AppHost version: {appHostVersion}");
        Assert.Equal(localStableVersion, appHostVersion);
 
        // MSBuild restore reads NuGet sources, not ASPIRE_CLI_PACKAGES.
        await RegisterLocalHiveAsAmbientNuGetSourceAsync(auto, counter);
 
        await auto.RunCommandAsync($"cd {projectName}/{projectName}.AppHost", counter);
        await AddIntegrationInteractivelyAsync(auto, counter, "redis");
 
        // The added Redis integration must be the local stable version. That version is absent from
        // nuget.org, so resolving it confirms `aspire add` consulted the local hive. We match whichever
        // Aspire Redis integration the interactive picker selected (e.g. Aspire.Hosting.Redis or
        // Aspire.Hosting.Azure.Redis) rather than hard-coding one, since the exact selection is not the
        // point of this test — proving the version came from the local build is.
        var (addedRedisPackage, addedRedisVersion) = GetAddedAspireRedisPackageReferenceFromCsproj(appHostCsproj);
        output.WriteLine($"Added Redis integration: {addedRedisPackage} {addedRedisVersion}");
        Assert.Equal(localStableVersion, addedRedisVersion);
    }
 
    /// <summary>
    /// SCENARIO: a developer-built <b>future stable release</b> (e.g. <c>13.5.0</c>) of the CLI,
    /// scaffolding the TypeScript <c>aspire-ts-starter</c> (Express/React) template entirely from the
    /// local build.
    ///
    /// This is the TypeScript half of the all-local stable row; it mirrors the C# test because the two
    /// AppHosts scaffold through different code paths and have historically diverged. The TS starter
    /// records its SDK version in <c>aspire.config.json</c> (not a csproj) and drops no
    /// <c>NuGet.config</c>; instead the polyglot path discovers <c>Aspire.*</c> packages through the
    /// hive surfaced by <c>ASPIRE_CLI_PACKAGES</c>. The test asserts the recorded SDK version equals
    /// the local stable version (present only in the hive) and that <c>aspire add redis</c> resolves
    /// the same local stable version — proving local resolution for the TypeScript path too.
    /// </summary>
    [CaptureWorkspaceOnFailure]
    [Fact]
    public async Task EmulatedLocalReleaseScaffoldsTypeScriptStarterFromLocalPackages()
    {
        var repoRoot = CliE2ETestHelpers.GetRepoRoot();
        var strategy = CliInstallStrategy.Detect(output.WriteLine);
        var localStableVersion = RequireLocalStableArchiveOrSkip(repoRoot, strategy);
 
        var workspace = TemporaryWorkspace.Create(output);
 
        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, TestContext.Current.CancellationToken);
 
        await auto.PrepareDockerEnvironmentAsync(counter, workspace);
        await InstallLocalHiveWithoutLocalChannelAsync(auto, counter);
        await ApplyEmulatedLocalReleaseIdentityAsync(auto, counter, localStableVersion);
 
        const string projectName = "EmulatedLocalTsStarter";
        await auto.AspireNewAsync(projectName, counter, template: AspireTemplate.ExpressReact);
 
        var projectDir = new DirectoryInfo(Path.Combine(workspace.WorkspaceRoot.FullName, projectName));
        var configPath = Path.Combine(projectDir.FullName, "aspire.config.json");
 
        // The TypeScript polyglot path does not drop a project NuGet.config (a stable build's packages
        // live on the ambient default source); it instead relies on the hive via ASPIRE_CLI_PACKAGES.
        // Asserting this for TS too is the point of the matrix — C# and TS diverge here.
        AssertNoNuGetConfig(projectDir);
 
        // The TS starter records its SDK version in aspire.config.json. The local stable version exists
        // only in the hive, so an exact match proves `aspire new` resolved from the local build.
        var sdkVersion = GetSdkVersionFromAspireConfig(configPath);
        output.WriteLine($"Generated TypeScript SDK version: {sdkVersion}");
        Assert.Equal(localStableVersion, sdkVersion);
 
        await RegisterLocalHiveAsAmbientNuGetSourceAsync(auto, counter);
 
        await auto.RunCommandAsync($"cd {projectName}", counter);
        await AddIntegrationInteractivelyAsync(auto, counter, "redis");
 
        // The polyglot path records added integrations in aspire.config.json's `packages` map. The
        // resolved Redis version must be the local stable version (absent from nuget.org), confirming
        // `aspire add` consulted the local hive for the TypeScript path. We match whichever Aspire Redis
        // integration the picker selected rather than hard-coding one.
        var (addedRedisPackage, addedRedisVersion) = GetAddedAspireRedisPackageFromAspireConfig(configPath);
        output.WriteLine($"Added Redis integration: {addedRedisPackage} {addedRedisVersion}");
        Assert.Equal(localStableVersion, addedRedisVersion);
    }
 
    /// <summary>
    /// Verifies that the package hive shipped with a PR CI archive can back each channel identity
    /// used to validate a build before publication. The identity version remains the archive's exact
    /// package version because PR archives do not contain stable-shaped package aliases. The
    /// TypeScript AppHost deliberately has no persisted channel and the hive is not registered as an
    /// ambient NuGet source, so both initialization and the subsequent cold <c>aspire add</c> must
    /// resolve through <c>ASPIRE_CLI_PACKAGES</c>.
    /// </summary>
    [CaptureWorkspaceOnFailure]
    [Theory]
    [InlineData("local")]
    [InlineData("daily")]
    [InlineData("staging")]
    [InlineData("stable")]
    public async Task ArchivePackagesBackEmulatedTypeScriptIdentity(string identityChannel)
    {
        const string packageId = "Aspire.Hosting.Redis";
 
        var repoRoot = CliE2ETestHelpers.GetRepoRoot();
        var strategy = CliInstallStrategy.Detect(output.WriteLine);
        var archiveContext = RequireArchivePackagesOrSkip(repoRoot, strategy);
        var workspace = TemporaryWorkspace.Create(output);
 
        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,
            TestContext.Current.CancellationToken);
 
        await auto.PrepareDockerEnvironmentAsync(counter, workspace);
        await InstallArchiveWithoutLocalChannelAsync(auto, counter, strategy);
        await ApplyArchiveBackedIdentityAsync(
            auto,
            counter,
            identityChannel,
            archiveContext.PackageVersion,
            archiveContext.HiveLabel);
 
        // Each theory row gets an empty package cache. The archive hive remains invocation-scoped so
        // success proves the emulated identity honored ASPIRE_CLI_PACKAGES rather than an ambient feed.
        await auto.RunCommandAsync("export NUGET_PACKAGES=\"$PWD/.nuget-packages\"", counter);
        await auto.RunCommandAsync(
            $"if dotnet nuget list source | grep -F \"$HOME/.aspire/hives/{archiveContext.HiveLabel}/packages\"; then " +
            "echo 'The archive hive must not be registered as an ambient source.' >&2; exit 1; fi",
            counter);
 
        await auto.TypeAsync("aspire init --language typescript --non-interactive --suppress-agent-init");
        await auto.EnterAsync();
        await auto.WaitUntilTextAsync("Created apphost.mts", timeout: TimeSpan.FromMinutes(2));
        await auto.WaitForSuccessPromptAsync(counter);
 
        var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.config.json");
        Assert.Equal(archiveContext.PackageVersion, GetSdkVersionFromAspireConfig(configPath));
 
        // Remove the channel persisted by initialization so `add` must resolve from the running CLI
        // identity. Clearing the cache then makes this the no-`--source` control for the scoped-source
        // test below: only the archive-backed identity source can satisfy the cold restore.
        RemoveChannelFromAspireConfig(configPath);
        await auto.RunCommandAsync(
            "rm -rf \"$NUGET_PACKAGES\" && mkdir -p \"$NUGET_PACKAGES\"",
            counter);
 
        await auto.TypeAsync(
            $"aspire add {packageId} --version {archiveContext.PackageVersion} --non-interactive");
        await auto.EnterAsync();
        await auto.WaitForAspireAddSuccessAsync(counter, TimeSpan.FromMinutes(3));
 
        Assert.Equal(
            archiveContext.PackageVersion,
            GetPackageVersionFromAspireConfig(configPath, packageId));
 
        var redisMetadata =
            $".nuget-packages/aspire.hosting.redis/{archiveContext.PackageVersion}/.nupkg.metadata";
        var hostingMetadata =
            $".nuget-packages/aspire.hosting/{archiveContext.PackageVersion}/.nupkg.metadata";
        await auto.RunCommandAsync(
            $"test -f {redisMetadata} && test -f {hostingMetadata} && " +
            $"python3 -c 'import json,os; " +
            $"redis=json.load(open(\"{redisMetadata}\")); hosting=json.load(open(\"{hostingMetadata}\")); " +
            $"expected=os.path.realpath(os.path.expandvars(\"$HOME/.aspire/hives/{archiveContext.HiveLabel}/packages\")); " +
            "assert os.path.realpath(redis[\"source\"]) == expected; " +
            "assert os.path.realpath(hosting[\"source\"]) == expected'",
            counter);
    }
 
    /// <summary>
    /// Verifies that a package-scoped <c>aspire add --source</c> appends the selected feed instead of
    /// replacing the matching identity feed. The explicit source contains only a third-party
    /// integration package; its exact <c>Aspire.Hosting</c> dependency exists only in the installed
    /// identity hive. The hive is deliberately not registered in ambient NuGet configuration, so a
    /// cold restore can succeed only when the CLI composes both invocation-scoped sources.
    /// </summary>
    [CaptureWorkspaceOnFailure]
    [Fact]
    public async Task PackageScopedSourceAppendsIdentitySourceForTypeScriptRestore()
    {
        const string packageId = "CommunityToolkit.Aspire.Hosting.SourceAppendProbe";
        const string packageVersion = "1.0.0";
 
        var repoRoot = CliE2ETestHelpers.GetRepoRoot();
        var strategy = CliInstallStrategy.Detect(output.WriteLine);
        var restoreContext = RequirePackageSourceAppendArchiveOrSkip(repoRoot, strategy);
        var workspace = TemporaryWorkspace.Create(output);
        CreateThirdPartyIntegrationPackage(
            Path.Combine(workspace.WorkspaceRoot.FullName, "explicit-feed"),
            packageId,
            packageVersion,
            restoreContext.PackageVersion);
 
        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,
            TestContext.Current.CancellationToken);
 
        await auto.PrepareDockerEnvironmentAsync(counter, workspace);
        await InstallArchiveWithoutLocalChannelAsync(auto, counter, strategy);
        if (strategy.Mode == CliInstallMode.LocalHive)
        {
            await ApplyEmulatedLocalReleaseIdentityAsync(
                auto,
                counter,
                restoreContext.PackageVersion);
        }
 
        // Use an empty per-test package cache so a globally cached Aspire.Hosting cannot conceal
        // loss of the identity-local source. The identity hive must remain invocation-scoped: unlike
        // the C# AppHost tests above, this polyglot path does not need it registered with NuGet.
        await auto.RunCommandAsync("export NUGET_PACKAGES=\"$PWD/.nuget-packages\"", counter);
        await auto.RunCommandAsync(
            $"if dotnet nuget list source | grep -F \"$HOME/.aspire/hives/{restoreContext.HiveLabel}/packages\"; then " +
            "echo 'The identity hive must not be registered as an ambient source.' >&2; exit 1; fi",
            counter);
 
        await auto.TypeAsync("aspire init --language typescript --non-interactive --suppress-agent-init");
        await auto.EnterAsync();
        await auto.WaitUntilTextAsync("Created apphost.mts", timeout: TimeSpan.FromMinutes(2));
        await auto.WaitForSuccessPromptAsync(counter);
 
        var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.config.json");
        Assert.Equal(restoreContext.PackageVersion, GetSdkVersionFromAspireConfig(configPath));
 
        // PR archive creation persists its channel in aspire.config.json. Remove that project-level
        // selection so the add operation must rediscover the matching source from the running CLI
        // identity, reproducing the path that package-scoped --source previously short-circuited.
        RemoveChannelFromAspireConfig(configPath);
 
        // Initialization restores the polyglot SDK and therefore warms NUGET_PACKAGES. Remove those
        // assets while leaving the workspace manifest in place so add must reject stale paths and
        // perform a genuinely cold restore from the composed explicit and identity sources.
        await auto.RunCommandAsync(
            "rm -rf \"$NUGET_PACKAGES\" && mkdir -p \"$NUGET_PACKAGES\"",
            counter);
 
        await auto.TypeAsync(
            $"aspire add {packageId} --version {packageVersion} --source explicit-feed --non-interactive");
        await auto.EnterAsync();
        await auto.WaitForAspireAddSuccessAsync(counter, TimeSpan.FromMinutes(3));
 
        Assert.Equal(packageVersion, GetPackageVersionFromAspireConfig(configPath, packageId));
 
        var packageCacheId = packageId.ToLowerInvariant();
        var sourceMetadata = $".nuget-packages/{packageCacheId}/{packageVersion}/.nupkg.metadata";
        var hostingMetadata = $".nuget-packages/aspire.hosting/{restoreContext.PackageVersion}/.nupkg.metadata";
        await auto.RunCommandAsync(
            $"test \"$(find explicit-feed -maxdepth 1 -name '*.nupkg' | wc -l)\" -eq 1 && " +
            $"test -f {sourceMetadata} && test -f {hostingMetadata} && " +
            $"python3 -c 'import json,os; " +
            $"selected=json.load(open(\"{sourceMetadata}\")); hosting=json.load(open(\"{hostingMetadata}\")); " +
            "assert os.path.realpath(selected[\"source\"]) == os.path.realpath(\"explicit-feed\"); " +
            $"assert os.path.realpath(hosting[\"source\"]) == os.path.realpath(os.path.expandvars(\"$HOME/.aspire/hives/{restoreContext.HiveLabel}/packages\"))'",
            counter);
    }
 
    private static ArchivePackageContext RequireArchivePackagesOrSkip(
        string repoRoot,
        CliInstallStrategy strategy)
    {
        Assert.SkipUnless(
            strategy.Mode is CliInstallMode.LocalHive or CliInstallMode.LocalArchive,
            $"Archive-backed identity tests require a local hive or PR archive; current mode is {strategy.Mode}.");
 
        if (strategy.Mode == CliInstallMode.LocalArchive)
        {
            Assert.SkipWhen(
                strategy.ExpectedVersion is null,
                "The PR archive did not contain an Aspire.Cli pointer package from which to determine the identity version.");
 
            return new(strategy.ExpectedVersion!, strategy.LocalArchiveHiveLabel);
        }
 
        var localChannel = CliE2ETestHelpers.PrepareLocalChannel(
            repoRoot,
            strategy,
            ["Aspire.Hosting.Redis."]);
        Assert.SkipWhen(
            localChannel is null,
            "The local hive archive did not contain the packages required by the archive-backed identity tests.");
 
        return new(localChannel!.SdkVersion, "local");
    }
 
    private static ArchivePackageContext RequirePackageSourceAppendArchiveOrSkip(
        string repoRoot,
        CliInstallStrategy strategy)
    {
        if (strategy.Mode == CliInstallMode.LocalArchive)
        {
            return RequireArchivePackagesOrSkip(repoRoot, strategy);
        }
 
        Assert.SkipUnless(
            strategy.Mode == CliInstallMode.LocalHive,
            $"Package-scoped source composition requires a local hive or PR archive; current mode is {strategy.Mode}.");
 
        var localStableVersion = RequireLocalStableArchiveOrSkip(repoRoot, strategy);
        return new(localStableVersion, "local");
    }
 
    /// <summary>
    /// Asserts the preconditions for an all-local emulated-release run and returns the local stable
    /// version, or skips the test. The run requires (1) the CLI installed from a local hive archive and
    /// (2) that archive built with a stable-shaped version (via <c>localhive --version X.Y.Z</c>). In
    /// every other configuration there is no future-release build to emulate, so the test skips.
    /// </summary>
    private static string RequireLocalStableArchiveOrSkip(string repoRoot, CliInstallStrategy strategy)
    {
        Assert.SkipUnless(
            strategy.Mode == CliInstallMode.LocalHive,
            $"All-local emulated-release tests require a local hive archive (CliInstallMode.LocalHive); current mode is {strategy.Mode}. " +
            "Build one with `localhive --version <X.Y.Z> --archive` and point ASPIRE_E2E_ARCHIVE at it.");
 
        var localStableVersion = CliE2ETestHelpers.TryGetLocalStableAspireVersion(repoRoot);
        Assert.SkipWhen(
            localStableVersion is null,
            "No stable-shaped Aspire packages were found under artifacts/packages/**/Shipping. " +
            "Build a stable-shaped archive with `localhive --version <X.Y.Z> --archive` to run this test.");
 
        return localStableVersion!;
    }
 
    /// <summary>
    /// Extracts the local hive archive and sources the CLI environment, but deliberately does NOT run
    /// the local-hive configuration step. That step pins <c>channel local</c> globally, which would
    /// defeat the whole point of this test: we want the CLI to run under an emulated <c>stable</c>
    /// identity while still resolving packages from the local hive (via <c>ASPIRE_CLI_PACKAGES</c>),
    /// exactly as a future shipped release would behave once its packages are public.
    /// </summary>
    private static async Task InstallLocalHiveWithoutLocalChannelAsync(Hex1bTerminalAutomator auto, SequenceCounter counter)
    {
        // The host archive is mounted read-only at this fixed path for LocalHive mode (see
        // CliInstallStrategy.ConfigureContainer). Extracting it lays the CLI down under ~/.aspire/bin
        // and the packages under ~/.aspire/hives/local/packages.
        await auto.ExtractLocalHiveArchiveAsync("/tmp/aspire-localhive.tar.gz", counter);
        await auto.SourceAspireCliEnvironmentAsync(counter);
    }
 
    private static Task InstallArchiveWithoutLocalChannelAsync(
        Hex1bTerminalAutomator auto,
        SequenceCounter counter,
        CliInstallStrategy strategy)
    {
        return strategy.Mode == CliInstallMode.LocalHive
            ? InstallLocalHiveWithoutLocalChannelAsync(auto, counter)
            : auto.InstallAspireCliAsync(strategy, counter);
    }
 
    private static async Task ApplyArchiveBackedIdentityAsync(
        Hex1bTerminalAutomator auto,
        SequenceCounter counter,
        string identityChannel,
        string identityVersion,
        string hiveLabel)
    {
        await auto.RunCommandAsync(
            $"export ASPIRE_CLI_CHANNEL={identityChannel} " +
            $"ASPIRE_CLI_VERSION={identityVersion} " +
            $"ASPIRE_CLI_PACKAGES=\"$HOME/.aspire/hives/{hiveLabel}/packages\"; " +
            (string.Equals(identityChannel, "staging", StringComparison.Ordinal)
                ? "export ASPIRE_CLI_COMMIT=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
                : "unset ASPIRE_CLI_COMMIT"),
            counter);
 
        await auto.TypeAsync("aspire --version");
        await auto.EnterAsync();
        await auto.WaitUntilAsync(
            s => s.ContainsText(identityVersion) && s.ContainsText("emulating identity"),
            timeout: TimeSpan.FromSeconds(60),
            description: $"aspire --version reporting emulated {identityChannel} identity '{identityVersion}' with archive packages");
        await auto.WaitForSuccessPromptAsync(counter);
    }
 
    /// <summary>
    /// Exports the identity-override environment variables that make the just-installed CLI report and
    /// behave as a future stable release whose packages live only in the local hive, then proves the
    /// override is live before any scaffolding runs.
    /// </summary>
    private static async Task ApplyEmulatedLocalReleaseIdentityAsync(Hex1bTerminalAutomator auto, SequenceCounter counter, string localStableVersion)
    {
        // ASPIRE_CLI_CHANNEL=stable + ASPIRE_CLI_VERSION coerce the identity to a shipped-release shape.
        // ASPIRE_CLI_PACKAGES points the Aspire* feed at the extracted hive's flat package directory so
        // template and integration resolution come from the local build. Together these emulate a
        // future release that has not been published anywhere yet. (The product fix being validated:
        // ASPIRE_CLI_PACKAGES is honored under the `stable` channel, not just `local`.)
        await auto.RunCommandAsync("export ASPIRE_CLI_CHANNEL=stable", counter);
        await auto.RunCommandAsync($"export ASPIRE_CLI_VERSION={localStableVersion}", counter);
        await auto.RunCommandAsync("export ASPIRE_CLI_PACKAGES=$HOME/.aspire/hives/local/packages", counter);
 
        // `aspire --version` reports the resolved identity version (honoring ASPIRE_CLI_VERSION) and the
        // emulation notice fires on stderr while an override is active. Seeing both confirms the override
        // path — not the physical build — is in effect before we depend on it.
        await auto.TypeAsync("aspire --version");
        await auto.EnterAsync();
        await auto.WaitUntilAsync(
            s => s.ContainsText(localStableVersion) && s.ContainsText("emulating identity"),
            timeout: TimeSpan.FromSeconds(60),
            description: $"aspire --version reporting emulated local-release identity '{localStableVersion}' with override notice");
        await auto.WaitForSuccessPromptAsync(counter);
    }
 
    /// <summary>
    /// Registers the extracted local hive's flat package directory as an ambient NuGet source. This is
    /// required for buildability of <c>aspire add</c>/<c>aspire run</c> in the all-local stable case:
    /// MSBuild resolves the apphost's <c>Aspire.AppHost.Sdk</c> project SDK <em>before</em> restore via
    /// the NuGet-based SDK resolver, which reads only nuget.config sources and ignores
    /// <c>ASPIRE_CLI_PACKAGES</c>. Without the published packages on nuget.org, the local hive must be a
    /// real NuGet source for the SDK (and the integration's transitive packages) to restore.
    /// </summary>
    private static async Task RegisterLocalHiveAsAmbientNuGetSourceAsync(Hex1bTerminalAutomator auto, SequenceCounter counter)
    {
        // Writes to the user-level NuGet.config (~/.nuget/NuGet.config), NOT the project directory, so
        // the per-project "no NuGet.config" assertions above remain valid.
        await auto.RunCommandAsync(
            "dotnet nuget add source \"$HOME/.aspire/hives/local/packages\" --name local-aspire-release",
            counter,
            TimeSpan.FromSeconds(30));
    }
 
    /// <summary>
    /// Runs <c>aspire add</c> interactively, filters the integration list by <paramref name="filter"/>,
    /// accepts the default version if a version picker appears, and waits for the success message.
    /// Mirrors the proven interactive add flow used elsewhere in the E2E suite.
    /// </summary>
    private static async Task AddIntegrationInteractivelyAsync(Hex1bTerminalAutomator auto, SequenceCounter counter, string filter)
    {
        await auto.TypeAsync("aspire add");
        await auto.EnterAsync();
        await auto.WaitUntilTextAsync(AddCommandStrings.SelectAnIntegrationToAdd, timeout: TimeSpan.FromMinutes(1));
        await auto.TypeAsync(filter);
        await auto.EnterAsync();
 
        var waitingForVersionSelection = false;
        await auto.WaitUntilAsync(snapshot =>
        {
            waitingForVersionSelection = snapshot.ContainsText("Select a version of");
            return waitingForVersionSelection || snapshot.ContainsText("was added successfully.");
        }, timeout: TimeSpan.FromMinutes(2), description: "version prompt or add success");
 
        if (waitingForVersionSelection)
        {
            await auto.EnterAsync();
        }
 
        await auto.WaitUntilTextAsync("was added successfully.", timeout: TimeSpan.FromMinutes(2));
        await auto.WaitForSuccessPromptAsync(counter);
    }
 
    private static void CreateThirdPartyIntegrationPackage(
        string sourceDirectory,
        string packageId,
        string packageVersion,
        string hostingVersion)
    {
        Directory.CreateDirectory(sourceDirectory);
        var packagePath = Path.Combine(sourceDirectory, $"{packageId}.{packageVersion}.nupkg");
        using var archive = ZipFile.Open(packagePath, ZipArchiveMode.Create);
        var nuspec = archive.CreateEntry($"{packageId}.nuspec");
        using (var writer = new StreamWriter(nuspec.Open()))
        {
            writer.Write($"""
                <?xml version="1.0"?>
                <package>
                  <metadata>
                    <id>{packageId}</id>
                    <version>{packageVersion}</version>
                    <authors>Aspire</authors>
                    <description>Verifies package-scoped source composition.</description>
                    <tags>aspire integration hosting polyglot</tags>
                    <dependencies>
                      <group targetFramework="net10.0">
                        <dependency id="Aspire.Hosting" version="[{hostingVersion}]" />
                      </group>
                    </dependencies>
                  </metadata>
                </package>
                """);
        }
 
        archive.CreateEntry("lib/net10.0/_._");
    }
 
    private static void AssertNoNuGetConfig(DirectoryInfo projectDir)
    {
        // Match by file name with a case-insensitive comparison rather than relying on the glob
        // matcher, whose case sensitivity differs across host operating systems (the workspace is
        // read from the host: case-insensitive on macOS/Windows, case-sensitive on Linux CI).
        var nuGetConfigs = projectDir
            .EnumerateFiles("*", SearchOption.AllDirectories)
            .Where(f => string.Equals(f.Name, "NuGet.config", StringComparison.OrdinalIgnoreCase))
            .Select(f => f.FullName)
            .ToList();
 
        Assert.True(
            nuGetConfigs.Count == 0,
            $"Emulating an all-local stable build, 'aspire new' must not drop a NuGet.config (the hive is surfaced via ASPIRE_CLI_PACKAGES, not a per-project feed pin). Found: {string.Join(", ", nuGetConfigs)}");
    }
 
    private static (string Package, string Version) GetAddedAspireRedisPackageReferenceFromCsproj(string csprojPath)
    {
        if (!File.Exists(csprojPath))
        {
            throw new FileNotFoundException($"Expected AppHost project to exist: {csprojPath}", csprojPath);
        }
 
        // `aspire add` writes a PackageReference such as:
        //   <PackageReference Include="Aspire.Hosting.Redis" Version="13.5.0" />
        //   <PackageReference Include="Aspire.Hosting.Azure.Redis" Version="13.5.0" />
        // Match any Aspire.* Redis integration regardless of which one the interactive picker selected.
        // The Include and Version attributes can appear in either order, so handle both layouts.
        var content = File.ReadAllText(csprojPath);
        var match = Regex.Match(content, "Include=\"(?<id>Aspire\\.[^\"]*Redis[^\"]*)\"\\s+Version=\"(?<version>[^\"]+)\"");
        if (!match.Success)
        {
            match = Regex.Match(content, "Version=\"(?<version>[^\"]+)\"\\s+Include=\"(?<id>Aspire\\.[^\"]*Redis[^\"]*)\"");
        }
 
        return match.Success
            ? (match.Groups["id"].Value, match.Groups["version"].Value)
            : throw new InvalidOperationException($"Could not find an Aspire.* Redis PackageReference in {csprojPath}.");
    }
 
    private static string GetSdkVersionFromAspireConfig(string configPath)
    {
        if (!File.Exists(configPath))
        {
            throw new FileNotFoundException($"Expected aspire.config.json to exist: {configPath}", configPath);
        }
 
        using var document = JsonDocument.Parse(File.ReadAllText(configPath));
        if (document.RootElement.TryGetProperty("sdk", out var sdk) &&
            sdk.ValueKind == JsonValueKind.Object &&
            sdk.TryGetProperty("version", out var version) &&
            version.ValueKind == JsonValueKind.String &&
            version.GetString() is { Length: > 0 } sdkVersion)
        {
            return sdkVersion;
        }
 
        throw new InvalidOperationException($"Could not find sdk.version in {configPath}.");
    }
 
    private static (string Package, string Version) GetAddedAspireRedisPackageFromAspireConfig(string configPath)
    {
        if (!File.Exists(configPath))
        {
            throw new FileNotFoundException($"Expected aspire.config.json to exist: {configPath}", configPath);
        }
 
        // The polyglot path records added integrations as:
        //   { "packages": { "Aspire.Hosting.Redis": "13.5.0", ... } }
        // Match any Aspire.* Redis integration regardless of which one the interactive picker selected.
        using var document = JsonDocument.Parse(File.ReadAllText(configPath));
        if (document.RootElement.TryGetProperty("packages", out var packages) &&
            packages.ValueKind == JsonValueKind.Object)
        {
            foreach (var package in packages.EnumerateObject())
            {
                if (package.Name.StartsWith("Aspire.", StringComparison.OrdinalIgnoreCase) &&
                    package.Name.Contains("Redis", StringComparison.OrdinalIgnoreCase) &&
                    package.Value.ValueKind == JsonValueKind.String &&
                    package.Value.GetString() is { Length: > 0 } version)
                {
                    return (package.Name, version);
                }
            }
        }
 
        throw new InvalidOperationException($"Could not find an Aspire.* Redis entry in packages of {configPath}.");
    }
 
    private static string GetPackageVersionFromAspireConfig(string configPath, string packageId)
    {
        if (!File.Exists(configPath))
        {
            throw new FileNotFoundException($"Expected aspire.config.json to exist: {configPath}", configPath);
        }
 
        using var document = JsonDocument.Parse(File.ReadAllText(configPath));
        if (document.RootElement.TryGetProperty("packages", out var packages) &&
            packages.ValueKind == JsonValueKind.Object &&
            packages.TryGetProperty(packageId, out var packageVersion) &&
            packageVersion.ValueKind == JsonValueKind.String &&
            packageVersion.GetString() is { Length: > 0 } version)
        {
            return version;
        }
 
        throw new InvalidOperationException($"Could not find package '{packageId}' in {configPath}.");
    }
 
    private static void RemoveChannelFromAspireConfig(string configPath)
    {
        var config = JsonNode.Parse(File.ReadAllText(configPath))?.AsObject()
            ?? throw new InvalidOperationException($"Expected {configPath} to contain a JSON object.");
 
        config.Remove("channel");
        File.WriteAllText(configPath, config.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
    }
 
    private sealed record ArchivePackageContext(string PackageVersion, string HiveLabel);
}