File: ChannelUpdateWorkflowTests.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.Json;
using Aspire.Cli.EndToEnd.Tests.Helpers;
using Hex1b.Automation;
using Xunit;
 
namespace Aspire.Cli.EndToEnd.Tests;
 
/// <summary>
/// End-to-end tests for the channel-update workflow.
///
/// The first test exercises the deep TypeScript path:
/// <list type="number">
///   <item><description>Create a TypeScript empty AppHost on the CLI's baked channel.</description></item>
///   <item><description><c>aspire add</c> a package and verify the recorded version is non-stable.</description></item>
///   <item><description><c>aspire start</c>, assert a wired resource exists, then <c>aspire stop</c>.</description></item>
///   <item><description><c>aspire update --channel stable</c> and verify that it previews
///     stable package updates without enqueuing an <c>aspire.config.json#channel</c> rewrite.</description></item>
///   <item><description>Decline the previewed updates, then verify the existing non-stable
///     channel and package versions are preserved.</description></item>
///   <item><description><c>aspire add</c> a second package and verify it still resolves to a non-stable version
///     because the project's configured channel was intentionally left alone.</description></item>
///   <item><description><c>aspire start</c> / <c>aspire stop</c> again to confirm the project still runs after the declined update.</description></item>
/// </list>
///
/// The remaining tests are lean regression guards for
/// <see href="https://github.com/microsoft/aspire/issues/17295"/>: each scaffolds an AppHost via one of
/// the four supported create paths (<c>aspire init</c> C#, <c>aspire new aspire-empty</c> C#,
/// <c>aspire init --language typescript</c>, plus the TypeScript <c>aspire new</c> case already
/// covered by the deep test above) and asserts that <c>aspire update --channel stable</c> does not
/// rewrite <c>aspire.config.json#channel</c>. Package-version assertions are intentionally scoped to
/// the polyglot deep test only: for C# projects, <c>aspire add</c> on PR/CI hives prefers the package
/// version that matches the running CLI build (the <c>VersionHelper.TryGetCurrentCliVersionMatch</c>
/// branch in <c>AddCommand</c>), which deliberately overrides the channel choice for build coherence
/// and would make a stable-version assertion flaky on C#.
/// </summary>
public sealed class ChannelUpdateWorkflowTests(ITestOutputHelper output)
{
    [Fact]
    [CaptureWorkspaceOnFailure]
    public async Task UpdateToStable_TypeScript_PreviewsStablePkgsAndKeepsChannel()
    {
        var repoRoot = CliE2ETestHelpers.GetRepoRoot();
        var strategy = CliInstallStrategy.Detect(output.WriteLine);
 
        // The workflow exercises switching FROM a non-stable channel TO stable. If the running CLI
        // was acquired as latest-GA (the default local-developer fallback in CliInstallStrategy.Detect),
        // the initial channel is already stable and `aspire update --channel stable` is a no-op, so
        // the prompts the test drives would never appear. Skip with a clear message instead of hanging.
        if (strategy.Mode == CliInstallMode.InstallScript && strategy.Quality is null && strategy.Version is null)
        {
            Assert.Skip(
                "This test exercises 'aspire update --channel stable' from a non-stable channel. " +
                "Run with ASPIRE_E2E_ARCHIVE (LocalHive), in CI (dev/staging quality), or against a " +
                "specific non-stable build so the initial channel is non-stable.");
        }
 
        var workspace = TemporaryWorkspace.Create(output);
 
        // Pre-stage the locally-built nupkgs (LocalHive runs) so the in-container `aspire add` /
        // `aspire update` resolve the integration packages from the local channel. For non-LocalHive
        // strategies this is a no-op and the CLI uses its baked channel + NuGet feeds.
        var localChannel = CliE2ETestHelpers.PrepareLocalChannel(repoRoot, strategy,
            ["Aspire.Hosting.CodeGeneration.TypeScript.", "Aspire.Hosting.Redis.", "Aspire.Hosting.PostgreSQL."]);
 
        // Polyglot variant + docker socket so the `cache` Redis container can come up during `aspire start`
        // and `aspire describe cache` has something to inspect.
        using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(
            repoRoot, strategy, output,
            variant: CliE2ETestHelpers.DockerfileVariant.Polyglot,
            mountDockerSocket: true,
            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 auto.InstallAspireCliAsync(strategy, counter);
 
        const string projectName = "ChannelUpdateTsApp";
        var projectPath = Path.Combine(workspace.WorkspaceRoot.FullName, projectName);
        var aspireConfigPath = Path.Combine(projectPath, "aspire.config.json");
 
        // Step 1: Create the empty TypeScript AppHost. No --channel is passed so the CLI uses its
        // baked channel (local / pr-N / dev / staging depending on the install strategy).
        await auto.AspireNewTypeScriptEmptyAppHostAsync(projectName, counter);
 
        // LocalHive strategy only: PrepareLocalChannel returned a real channel,
        // so write the per-project aspire.config.json to point at the in-repo
        // nupkg hive. Other strategies (script-installed CLI, pre-existing CLI)
        // return null and rely on the CLI's baked channel + ambient NuGet feeds.
        if (localChannel is not null)
        {
            CliE2ETestHelpers.WriteLocalChannelSettings(projectPath, localChannel.SdkVersion);
        }
 
        // Step 2: Capture the initial channel. Defense in depth — if the strategy gate above didn't
        // catch the "already stable" case (e.g. a hand-rolled build with channel baked to "stable"),
        // skip here so the test doesn't try to assert prerelease versions that won't exist.
        var initialChannel = ReadAspireConfigChannel(aspireConfigPath);
        if (string.IsNullOrEmpty(initialChannel) ||
            string.Equals(initialChannel, "stable", StringComparison.OrdinalIgnoreCase))
        {
            Assert.Skip(
                $"Initial aspire.config.json#channel was '{initialChannel ?? "<null>"}'. " +
                "This test requires a non-stable initial channel so 'aspire update --channel stable' can preview stable-channel updates.");
        }
 
        output.WriteLine($"Initial channel: {initialChannel}");
        var appHostRelativePath = ReadAspireConfigAppHostPath(aspireConfigPath);
        var appHostPath = Path.GetFullPath(Path.Combine(projectPath, appHostRelativePath));
        var generatedModuleImport = appHostRelativePath.EndsWith(".mts", StringComparison.OrdinalIgnoreCase)
            ? "./.aspire/modules/aspire.mjs"
            : "./.aspire/modules/aspire.js";
 
        await auto.RunCommandAsync($"cd {projectName}", counter);
 
        // Step 3: Add the first package on the non-stable channel. Don't pass --non-interactive — the
        // helper handles both direct success and the "based on NuGet.config" version picker that
        // can appear when more than one version is reachable.
        await auto.TypeAsync("aspire add Aspire.Hosting.Redis");
        await auto.EnterAsync();
        await auto.WaitForAspireAddSuccessAsync(counter, TimeSpan.FromMinutes(2));
 
        // Step 4: Verify the recorded version is non-stable. For polyglot, package versions live in
        // aspire.config.json#packages, keyed by the exact package ID (not the friendly name).
        var redisVersionBefore = ReadAspireConfigPackageVersion(aspireConfigPath, "Aspire.Hosting.Redis");
        output.WriteLine($"Redis version before update: {redisVersionBefore}");
        Assert.False(
            IsStableVersion(redisVersionBefore),
            $"Expected a non-stable Aspire.Hosting.Redis version before 'aspire update --channel stable', got '{redisVersionBefore}'.");
 
        // Step 5: Wire the cache resource into the scaffolded AppHost so the project has an actual resource to
        // start and describe. Without this, the empty AppHost has no resources and `aspire describe`
        // would have nothing to assert against.
        await File.WriteAllTextAsync(appHostPath,
            $$"""
            import { createBuilder } from '{{generatedModuleImport}}';
 
            const builder = await createBuilder();
            await builder.addRedis("cache");
            await builder.build().run();
            """);
 
        // Step 6: Sanity-check the project boots and the wired resource is visible.
        await auto.AspireStartAsync(counter);
        await auto.AssertResourcesExistAsync(counter, "cache");
        await auto.AspireStopAsync(counter);
 
        // Step 7: Suppress the post-update CLI self-update prompt (UpdateCommand.cs:243-265) so it
        // doesn't block waiting on "Update successful!". Pattern borrowed from CentralPackageManagementTests.
        await auto.TypeAsync("aspire config set features.updateNotificationsEnabled false -g");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter);
 
        try
        {
            // Step 8: Preview a stable-channel update. The stable channel is intentionally not
            // persisted, so decline the package updates here: applying stable package versions while
            // preserving the existing PR/local channel would leave the TypeScript SDK restore with
            // package versions that are not available from the preserved channel's source mapping.
            await PreviewStableUpdateAndDeclineAsync(auto, counter, expectedPackageInPlan: "Aspire.Hosting.Redis");
 
            // Step 9: Verify the existing channel and Redis package version were preserved.
            var channelAfter = ReadAspireConfigChannel(aspireConfigPath);
            Assert.Equal(initialChannel, channelAfter);
 
            var redisVersionAfter = ReadAspireConfigPackageVersion(aspireConfigPath, "Aspire.Hosting.Redis");
            output.WriteLine($"Redis version after declined update: {redisVersionAfter}");
            Assert.Equal(redisVersionBefore, redisVersionAfter);
 
            // Step 10: Add a second package after the declined update. Because the project channel is
            // preserved, the resolved version should still come from the non-stable channel. Note the
            // canonical capitalization: Aspire.Hosting.PostgreSQL.
            await auto.TypeAsync("aspire add Aspire.Hosting.PostgreSQL");
            await auto.EnterAsync();
            await auto.WaitForAspireAddSuccessAsync(counter, TimeSpan.FromMinutes(2));
 
            var postgresVersion = ReadAspireConfigPackageVersion(aspireConfigPath, "Aspire.Hosting.PostgreSQL");
            output.WriteLine($"PostgreSQL version (post-update add): {postgresVersion}");
            Assert.False(
                IsStableVersion(postgresVersion),
                $"Expected the post-update 'aspire add' to preserve the non-stable Aspire.Hosting.PostgreSQL channel, got '{postgresVersion}'.");
 
            // Step 11: Boot once more to confirm the project still runs after the declined update.
            // The AppHost still wires only the cache resource — PostgreSQL was added to config but not
            // wired in the AppHost, which is sufficient for the channel-preservation assertion.
            await auto.AspireStartAsync(counter);
            await auto.AssertResourcesExistAsync(counter, "cache");
            await auto.AspireStopAsync(counter);
        }
        finally
        {
            // Best-effort cleanup of the global feature flag we toggled above so other tests in the
            // same shared home aren't affected. Bound the wait so cleanup never burns the automator's
            // default timeout if the terminal is in an unexpected state, and accept either an OK or
            // an ERR prompt — we only care that the shell returned to a prompt.
            try
            {
                await auto.TypeAsync("aspire config delete features.updateNotificationsEnabled -g");
                await auto.EnterAsync();
                await auto.WaitForAnyPromptAsync(counter, TimeSpan.FromSeconds(30));
            }
            catch
            {
            }
        }
    }
 
    // ----------------------------------------------------------------------------------
    // Stable-channel persistence regression guards for https://github.com/microsoft/aspire/issues/17295
    //
    // These four cells cover the create-paths × language matrix:
    //   - C# `aspire init`             (single-file apphost.cs)        -> see test below
    //   - C# `aspire new aspire-empty` (project-mode .csproj)          -> see test below
    //   - TS `aspire init --language typescript --non-interactive`     -> see test below
    //   - TS `aspire new aspire-ts-empty` (project-mode)               -> already covered by the
    //                                                                     deep test above.
    //
    // Each test asserts the stable-channel invariant: `aspire update --channel stable` should not
    // enqueue an aspire.config.json#channel rewrite, so the existing channel value is preserved.
    // Package version assertions are intentionally not duplicated here — see the class docstring.
    // ----------------------------------------------------------------------------------
 
    [Fact]
    [CaptureWorkspaceOnFailure]
    public async Task UpdateToStable_CSharpSingleFileInit_KeepsConfigChannel()
    {
        var repoRoot = CliE2ETestHelpers.GetRepoRoot();
        var strategy = CliInstallStrategy.Detect(output.WriteLine);
        if (ShouldSkipForStableInitialChannel(strategy, out var skipReason))
        {
            Assert.Skip(skipReason);
        }
 
        var workspace = TemporaryWorkspace.Create(output);
        var localChannel = CliE2ETestHelpers.PrepareLocalChannel(repoRoot, strategy,
            ["Aspire.Hosting.AppHost."]);
 
        using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(
            repoRoot, strategy, output,
            variant: CliE2ETestHelpers.DockerfileVariant.DotNet,
            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 auto.InstallAspireCliAsync(strategy, counter);
 
        // `aspire init` scaffolds into the current working directory — give it a fresh subfolder so
        // the workspace root stays clean and the assertions target a well-known file path.
        const string projectName = "ChannelUpdateCsharpInitApp";
        var projectPath = Path.Combine(workspace.WorkspaceRoot.FullName, projectName);
        Directory.CreateDirectory(projectPath);
        await auto.RunCommandAsync($"cd {projectName}", counter);
 
        await auto.AspireInitAsync(counter);
 
        if (localChannel is not null)
        {
            CliE2ETestHelpers.WriteLocalChannelSettings(projectPath, localChannel.SdkVersion);
        }
 
        await RunStableChannelUpdateAndAssertChannelPreservedAsync(auto, counter, Path.Combine(projectPath, "aspire.config.json"));
    }
 
    [Fact]
    [CaptureWorkspaceOnFailure]
    public async Task UpdateToStable_CSharpEmptyAppHost_KeepsConfigChannel()
    {
        var repoRoot = CliE2ETestHelpers.GetRepoRoot();
        var strategy = CliInstallStrategy.Detect(output.WriteLine);
        if (ShouldSkipForStableInitialChannel(strategy, out var skipReason))
        {
            Assert.Skip(skipReason);
        }
 
        var workspace = TemporaryWorkspace.Create(output);
        var localChannel = CliE2ETestHelpers.PrepareLocalChannel(repoRoot, strategy,
            ["Aspire.Hosting.AppHost."]);
 
        using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(
            repoRoot, strategy, output,
            variant: CliE2ETestHelpers.DockerfileVariant.DotNet,
            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 auto.InstallAspireCliAsync(strategy, counter);
 
        const string projectName = "ChannelUpdateCsharpEmptyApp";
        var projectPath = Path.Combine(workspace.WorkspaceRoot.FullName, projectName);
 
        await auto.AspireNewCSharpEmptyAppHostAsync(projectName, counter);
 
        if (localChannel is not null)
        {
            CliE2ETestHelpers.WriteLocalChannelSettings(projectPath, localChannel.SdkVersion);
        }
 
        await auto.RunCommandAsync($"cd {projectName}", counter);
        await RunStableChannelUpdateAndAssertChannelPreservedAsync(auto, counter, Path.Combine(projectPath, "aspire.config.json"));
    }
 
    [Fact]
    [CaptureWorkspaceOnFailure]
    public async Task UpdateToStable_TypeScriptSingleFileInit_KeepsConfigChannel()
    {
        var repoRoot = CliE2ETestHelpers.GetRepoRoot();
        var strategy = CliInstallStrategy.Detect(output.WriteLine);
        if (ShouldSkipForStableInitialChannel(strategy, out var skipReason))
        {
            Assert.Skip(skipReason);
        }
 
        var workspace = TemporaryWorkspace.Create(output);
        var localChannel = CliE2ETestHelpers.PrepareLocalChannel(repoRoot, strategy,
            ["Aspire.Hosting.CodeGeneration.TypeScript."]);
 
        using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(
            repoRoot, strategy, output,
            variant: CliE2ETestHelpers.DockerfileVariant.Polyglot,
            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 auto.InstallAspireCliAsync(strategy, counter);
 
        // `aspire init` for TS scaffolds into the current directory — match the existing
        // TypeScriptCodegenValidationTests pattern so a fresh subfolder is the working dir.
        const string projectName = "ChannelUpdateTsInitApp";
        var projectPath = Path.Combine(workspace.WorkspaceRoot.FullName, projectName);
        Directory.CreateDirectory(projectPath);
        await auto.RunCommandAsync($"cd {projectName}", counter);
 
        await auto.TypeAsync("aspire init --language typescript --non-interactive");
        await auto.EnterAsync();
        await auto.WaitUntilTextAsync("Created apphost.mts", timeout: TimeSpan.FromMinutes(2));
        await auto.WaitForSuccessPromptAsync(counter);
 
        if (localChannel is not null)
        {
            CliE2ETestHelpers.WriteLocalChannelSettings(projectPath, localChannel.SdkVersion);
        }
 
        await RunStableChannelUpdateAndAssertChannelPreservedAsync(auto, counter, Path.Combine(projectPath, "aspire.config.json"));
    }
 
    /// <summary>
    /// Shared skip-gate for the channel-preservation regression tests: if the running CLI's baked channel
    /// is already <c>stable</c> (e.g. a local install-script run with no quality/version override),
    /// <c>aspire update --channel stable</c> would be a no-op and the prompts the test drives would
    /// never appear. Returns <c>true</c> together with a human-readable reason in that case.
    /// </summary>
    private static bool ShouldSkipForStableInitialChannel(CliInstallStrategy strategy, out string reason)
    {
        if (strategy.Mode == CliInstallMode.InstallScript && strategy.Quality is null && strategy.Version is null)
        {
            reason =
                "This test exercises 'aspire update --channel stable' from a non-stable channel. " +
                "Run with ASPIRE_E2E_ARCHIVE (LocalHive), in CI (dev/staging quality), or against a " +
                "specific non-stable build so the initial channel is non-stable.";
            return true;
        }
 
        reason = string.Empty;
        return false;
    }
 
    /// <summary>
    /// Shared body for the channel-preservation regression tests: snapshot the initial channel,
    /// suppress the post-update CLI self-update prompt, preview <c>aspire update --channel stable</c>,
    /// then assert that <c>aspire.config.json#channel</c> was left unchanged.
    /// Assumes the automator is positioned in the project directory (containing aspire.config.json).
    /// </summary>
    private static async Task RunStableChannelUpdateAndAssertChannelPreservedAsync(
        Hex1bTerminalAutomator auto,
        SequenceCounter counter,
        string aspireConfigPath)
    {
        var initialChannel = ReadAspireConfigChannel(aspireConfigPath);
        if (string.IsNullOrEmpty(initialChannel) ||
            string.Equals(initialChannel, "stable", StringComparison.OrdinalIgnoreCase))
        {
            Assert.Skip(
                $"Initial aspire.config.json#channel was '{initialChannel ?? "<null>"}'. " +
                "This test requires a non-stable initial channel so 'aspire update --channel stable' can preview stable-channel updates.");
        }
 
        // Suppress the post-update CLI self-update prompt so it doesn't block waiting on
        // "Update successful!". No cleanup needed — each test runs in its own Docker container.
        await auto.TypeAsync("aspire config set features.updateNotificationsEnabled false -g");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter);
 
        await PreviewStableUpdateAndDeclineAsync(auto, counter);
 
        var channelAfter = ReadAspireConfigChannel(aspireConfigPath);
        Assert.Equal(initialChannel, channelAfter);
    }
 
    private static async Task PreviewStableUpdateAndDeclineAsync(
        Hex1bTerminalAutomator auto,
        SequenceCounter counter,
        string? expectedPackageInPlan = null)
    {
        var updatePrompt = new CellPatternSearcher().Find("Perform updates?");
        var upToDateMessage = new CellPatternSearcher().Find("Project is up to date! (no updates necessary)");
        var channelUpdateLine = new CellPatternSearcher().Find("aspire.config.json#channel");
        var cliUpdatePrompt = new CellPatternSearcher().Find("Update the Aspire CLI now and re-run");
        var expectedPackageLine = expectedPackageInPlan is not null
            ? new CellPatternSearcher().Find(expectedPackageInPlan)
            : null;
 
        var sawChannelUpdateLine = false;
        var sawExpectedPackageLine = expectedPackageLine is null;
        var sawUpdatePrompt = false;
        var sawUpToDateMessage = false;
        var sawCliUpdatePrompt = false;
 
        await auto.TypeAsync("aspire update --channel stable --nuget-config-dir .");
        await auto.EnterAsync();
 
        async Task WaitForStableUpdatePreviewAsync(bool allowCliUpdatePrompt)
        {
            await auto.WaitUntilAsync(snapshot =>
            {
                sawChannelUpdateLine |= channelUpdateLine.Search(snapshot).Count > 0;
                if (expectedPackageLine is not null && expectedPackageLine.Search(snapshot).Count > 0)
                {
                    sawExpectedPackageLine = true;
                }
                sawUpdatePrompt |= updatePrompt.Search(snapshot).Count > 0;
                sawUpToDateMessage |= upToDateMessage.Search(snapshot).Count > 0;
                var foundCliUpdatePrompt = cliUpdatePrompt.Search(snapshot).Count > 0;
                sawCliUpdatePrompt |= foundCliUpdatePrompt;
 
                return sawUpdatePrompt || sawUpToDateMessage || (allowCliUpdatePrompt && foundCliUpdatePrompt);
            }, TimeSpan.FromMinutes(3), description: "waiting for stable update preview");
        }
 
        await WaitForStableUpdatePreviewAsync(allowCliUpdatePrompt: true);
 
        if (sawCliUpdatePrompt && !sawUpdatePrompt && !sawUpToDateMessage)
        {
            // Stable release versions sort higher than same-base PR prerelease versions
            // (for example, 13.4.3 > 13.4.3-pr.18093.g...). Decline the CLI self-update
            // prompt so the project update preview can continue. The prompt remains in the
            // terminal snapshot after the key is accepted, so the second wait must ignore it.
            await auto.TypeAsync("n");
            await WaitForStableUpdatePreviewAsync(allowCliUpdatePrompt: false);
        }
 
        Assert.False(sawChannelUpdateLine, "Stable channel updates should not enqueue an aspire.config.json#channel rewrite.");
        Assert.True(sawExpectedPackageLine, $"Expected the stable update preview to include '{expectedPackageInPlan}'.");
 
        if (sawUpdatePrompt)
        {
            // Type "n" to decline. Do NOT send Enter — the Spectre.Console [Y/n] confirmation
            // prompt accepts a single character. Sending Enter risks a race: if aspire update
            // returns from its line-reader on the "n" keystroke and tears down before the Enter
            // is dequeued, bash receives the Enter and executes a phantom blank command,
            // advancing CMDCOUNT and desyncing the test counter from the shell counter.
            // See .agents/skills/cli-e2e-testing/troubleshooting.md for the full failure pattern.
            await auto.TypeAsync("n");
        }
 
        await auto.WaitForSuccessPromptAsync(counter);
    }
 
    /// <summary>
    /// A NuGet semver is "stable" iff it carries no prerelease label (no <c>-</c>) and no build
    /// metadata (no <c>+</c>). Same heuristic used by <c>SmokeTests.LatestCliCanStartStableChannelAppHost</c>.
    /// </summary>
    private static bool IsStableVersion(string version) =>
        !version.Contains('-', StringComparison.Ordinal) &&
        !version.Contains('+', StringComparison.Ordinal);
 
    private static string? ReadAspireConfigChannel(string aspireConfigPath)
    {
        if (!File.Exists(aspireConfigPath))
        {
            throw new FileNotFoundException($"Expected aspire.config.json at: {aspireConfigPath}", aspireConfigPath);
        }
 
        using var doc = JsonDocument.Parse(File.ReadAllText(aspireConfigPath));
        return doc.RootElement.TryGetProperty("channel", out var channelElement) &&
               channelElement.ValueKind == JsonValueKind.String
            ? channelElement.GetString()
            : null;
    }
 
    private static string ReadAspireConfigAppHostPath(string aspireConfigPath)
    {
        if (!File.Exists(aspireConfigPath))
        {
            throw new FileNotFoundException($"Expected aspire.config.json at: {aspireConfigPath}", aspireConfigPath);
        }
 
        using var doc = JsonDocument.Parse(File.ReadAllText(aspireConfigPath));
        if (!doc.RootElement.TryGetProperty("appHost", out var appHost) ||
            appHost.ValueKind != JsonValueKind.Object)
        {
            throw new InvalidOperationException($"aspire.config.json has no 'appHost' object. Path: {aspireConfigPath}");
        }
 
        if (!appHost.TryGetProperty("path", out var pathElement) ||
            pathElement.ValueKind != JsonValueKind.String)
        {
            throw new InvalidOperationException($"aspire.config.json#appHost does not contain a string 'path' entry. Path: {aspireConfigPath}");
        }
 
        return pathElement.GetString()
            ?? throw new InvalidOperationException($"aspire.config.json#appHost.path was null. Path: {aspireConfigPath}");
    }
 
    private static string ReadAspireConfigPackageVersion(string aspireConfigPath, string packageId)
    {
        if (!File.Exists(aspireConfigPath))
        {
            throw new FileNotFoundException($"Expected aspire.config.json at: {aspireConfigPath}", aspireConfigPath);
        }
 
        using var doc = JsonDocument.Parse(File.ReadAllText(aspireConfigPath));
        if (!doc.RootElement.TryGetProperty("packages", out var packages) ||
            packages.ValueKind != JsonValueKind.Object)
        {
            throw new InvalidOperationException(
                $"aspire.config.json has no 'packages' object — was '{packageId}' actually added? Path: {aspireConfigPath}");
        }
 
        if (!packages.TryGetProperty(packageId, out var versionElement) ||
            versionElement.ValueKind != JsonValueKind.String)
        {
            throw new InvalidOperationException(
                $"aspire.config.json#packages does not contain a string entry for '{packageId}'. Path: {aspireConfigPath}");
        }
 
        return versionElement.GetString()
            ?? throw new InvalidOperationException(
                $"aspire.config.json#packages.{packageId} was null. Path: {aspireConfigPath}");
    }
}