diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index f95a4a48d75..fa5ac480a40 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -39,7 +39,8 @@ on: required: false type: boolean default: false - # Controls whether to set CLI E2E environment variables (GH_TOKEN, GITHUB_PR_NUMBER, GITHUB_PR_HEAD_SHA) + # Controls whether to build and install the CLI from a native archive and set + # CLI E2E environment variables (ASPIRE_CLI_PATH_DIR, ASPIRE_CLI_VERSION, BUILT_NUGETS_PATH) requiresCliArchive: required: false type: boolean @@ -202,6 +203,69 @@ jobs: Write-Host "Merged $($nupkgs.Count) arch-specific nugets for RID=$env:RID into $dest" } + - name: Download CLI archive (Linux) + if: ${{ inputs.requiresCliArchive && runner.os == 'Linux' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: cli-native-archives-linux-x64 + path: ${{ github.workspace }}/cli-archive + + - name: Install CLI from archive (Linux) + if: ${{ inputs.requiresCliArchive && runner.os == 'Linux' }} + shell: bash + run: | + mkdir -p "$HOME/.aspire/bin" + + # Find the tar.gz archive (e.g., aspire-cli-linux-x64-13.3.0-preview.1.25234.2.tar.gz) + archive=$(find "${{ github.workspace }}/cli-archive" -name "aspire-cli-*.tar.gz" 2>/dev/null | head -1) + + if [ -z "$archive" ]; then + echo "ERROR: No aspire-cli-*.tar.gz found in ${{ github.workspace }}/cli-archive" + ls -la "${{ github.workspace }}/cli-archive/" || true + exit 1 + fi + + echo "Installing CLI from: $archive" + tar -xzf "$archive" -C "$HOME/.aspire/bin" + chmod +x "$HOME/.aspire/bin/aspire" + + # Verify installation (first run triggers self-extraction of embedded bundle). + # Capture and print the version to confirm the correct build was installed. + installed_version=$("$HOME/.aspire/bin/aspire" --version 2>&1) + if [ $? -ne 0 ]; then + echo "WARNING: 'aspire --version' exited non-zero. Output: $installed_version" + else + echo "Installed CLI version: $installed_version" + fi + + # Add to PATH for subsequent steps and tests + echo "$HOME/.aspire/bin" >> $GITHUB_PATH + + # Expose the install dir and commit SHA so tests can detect the pre-installed CLI + # and verify they are running the correct build. + echo "ASPIRE_CLI_PATH_DIR=$HOME/.aspire/bin" >> $GITHUB_ENV + echo "ASPIRE_CLI_VERSION=$installed_version" >> $GITHUB_ENV + + # Set up NuGet hive from built packages so 'aspire new' / 'aspire add' can resolve + # CI-built packages without going to nuget.org. + NUGETS_PATH="${{ github.workspace }}/artifacts/packages/Debug/Shipping" + if [ -d "$NUGETS_PATH" ]; then + HIVE_DIR="$HOME/.aspire/hives/ci/packages" + mkdir -p "$HIVE_DIR" + if cp "$NUGETS_PATH"/*.nupkg "$HIVE_DIR/" 2>/dev/null; then + pkg_count=$(find "$HIVE_DIR" -name "*.nupkg" | wc -l) + echo "Copied $pkg_count packages to NuGet hive at $HIVE_DIR" + else + echo "WARNING: No .nupkg files found in $NUGETS_PATH — NuGet hive will be empty" + fi + + # Configure the CLI channel to point to the local hive. + # Non-fatal: channel can be configured manually if this fails. + if ! "$HOME/.aspire/bin/aspire" config set channel ci --global --non-interactive 2>&1; then + echo "WARNING: Failed to set CLI channel to 'ci' — packages may be resolved from nuget.org" + fi + fi + - name: Install sdk for nuget based testing if: ${{ inputs.requiresTestSdk }} run: > @@ -328,10 +392,6 @@ jobs: TEST_LOG_PATH: ${{ github.workspace }}/artifacts/log/test-logs TestsRunningOutsideOfRepo: true TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX: 'netaspireci.azurecr.io' - # PR metadata and token for CLI E2E tests that download artifacts from a PR - GITHUB_PR_NUMBER: ${{ inputs.requiresCliArchive && github.event.pull_request.number || '' }} - GITHUB_PR_HEAD_SHA: ${{ inputs.requiresCliArchive && github.event.pull_request.head.sha || '' }} - GH_TOKEN: ${{ inputs.requiresCliArchive && github.token || '' }} run: | # Start heartbeat monitor in background ${{ github.workspace }}/${{ env.DOTNET_SCRIPT }} ${{ github.workspace }}/tools/scripts/Heartbeat.cs & @@ -371,10 +431,6 @@ jobs: TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX: 'netaspireci.azurecr.io' # Prevent VBCSCompiler from starting during tests. See #15832 UseSharedCompilation: false - # PR metadata and token for CLI E2E tests that download artifacts from a PR - GITHUB_PR_NUMBER: ${{ inputs.requiresCliArchive && github.event.pull_request.number || '' }} - GITHUB_PR_HEAD_SHA: ${{ inputs.requiresCliArchive && github.event.pull_request.head.sha || '' }} - GH_TOKEN: ${{ inputs.requiresCliArchive && github.token || '' }} run: | # Start heartbeat monitor in background (output goes to console directly) # Use 60s interval on Windows to reduce overhead on constrained 2-core runners @@ -420,10 +476,6 @@ jobs: NUGET_PACKAGES: ${{ github.workspace }}/.packages PLAYWRIGHT_INSTALLED: ${{ !inputs.enablePlaywrightInstall && 'false' || 'true' }} TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX: 'netaspireci.azurecr.io' - # PR metadata and token for CLI E2E tests that download artifacts from a PR - GITHUB_PR_NUMBER: ${{ inputs.requiresCliArchive && github.event.pull_request.number || '' }} - GITHUB_PR_HEAD_SHA: ${{ inputs.requiresCliArchive && github.event.pull_request.head.sha || '' }} - GH_TOKEN: ${{ inputs.requiresCliArchive && github.token || '' }} run: | # Start heartbeat monitor in background ${{ env.DOTNET_SCRIPT }} ${{ github.workspace }}/tools/scripts/Heartbeat.cs & @@ -470,10 +522,6 @@ jobs: # so any rebuild triggered by DCP's "dotnet run" should be minimal. # See https://github.com/microsoft/aspire/issues/15832 UseSharedCompilation: false - # PR metadata and token for CLI E2E tests that download artifacts from a PR - GITHUB_PR_NUMBER: ${{ inputs.requiresCliArchive && github.event.pull_request.number || '' }} - GITHUB_PR_HEAD_SHA: ${{ inputs.requiresCliArchive && github.event.pull_request.head.sha || '' }} - GH_TOKEN: ${{ inputs.requiresCliArchive && github.token || '' }} run: | # Start heartbeat monitor in background (output goes to console directly) # Use 60s interval on Windows to reduce overhead on constrained 2-core runners diff --git a/tests/Aspire.Cli.EndToEnd.Tests/DockerDeploymentTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/DockerDeploymentTests.cs index 5679a174255..947b7e17fed 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/DockerDeploymentTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/DockerDeploymentTests.cs @@ -19,15 +19,11 @@ public sealed class DockerDeploymentTests(ITestOutputHelper output) private const string ProjectName = "AspireDockerDeployTest"; [Fact] - [ActiveIssue("https://github.com/microsoft/aspire/issues/15930")] [QuarantinedTest("https://github.com/microsoft/aspire/issues/15882")] public async Task CreateAndDeployToDockerCompose() { using var workspace = TemporaryWorkspace.Create(output); - var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); - var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); - var isCI = CliE2ETestHelpers.IsRunningInCI; using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -38,11 +34,11 @@ public async Task CreateAndDeployToDockerCompose() // PrepareEnvironment await auto.PrepareEnvironmentAsync(workspace, counter); - if (isCI) + if (CliE2ETestHelpers.PreInstalledCliDir is not null) { - await auto.InstallAspireCliFromPullRequestAsync(prNumber, counter); + // CI: CLI was pre-installed by the workflow — just configure env vars and verify. await auto.SourceAspireCliEnvironmentAsync(counter); - await auto.VerifyAspireCliVersionAsync(commitSha, counter); + await auto.VerifyAspireCliVersionAsync(counter); } // Step 1: Create a new Aspire Starter App (no Redis cache) @@ -58,11 +54,12 @@ public async Task CreateAndDeployToDockerCompose() await auto.TypeAsync("aspire add Aspire.Hosting.Docker"); await auto.EnterAsync(); - // In CI, aspire add shows a version selection prompt (unlike aspire new which auto-selects when channel is set) - if (isCI) + // In CI, aspire add shows a version selection prompt + // (unlike aspire new which auto-selects when channel is set) + if (CliE2ETestHelpers.PreInstalledCliDir is not null) { await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60)); - await auto.EnterAsync(); // select first version (PR build) + await auto.EnterAsync(); // select first version } await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180)); @@ -142,15 +139,11 @@ public async Task CreateAndDeployToDockerCompose() } [Fact] - [ActiveIssue("https://github.com/microsoft/aspire/issues/15930")] [QuarantinedTest("https://github.com/microsoft/aspire/issues/15871")] public async Task CreateAndDeployToDockerComposeInteractive() { using var workspace = TemporaryWorkspace.Create(output); - var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); - var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); - var isCI = CliE2ETestHelpers.IsRunningInCI; using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -161,11 +154,11 @@ public async Task CreateAndDeployToDockerComposeInteractive() // PrepareEnvironment await auto.PrepareEnvironmentAsync(workspace, counter); - if (isCI) + if (CliE2ETestHelpers.PreInstalledCliDir is not null) { - await auto.InstallAspireCliFromPullRequestAsync(prNumber, counter); + // CI: CLI was pre-installed by the workflow — just configure env vars and verify. await auto.SourceAspireCliEnvironmentAsync(counter); - await auto.VerifyAspireCliVersionAsync(commitSha, counter); + await auto.VerifyAspireCliVersionAsync(counter); } // Step 1: Create a new Aspire Starter App (no Redis cache) @@ -181,11 +174,12 @@ public async Task CreateAndDeployToDockerComposeInteractive() await auto.TypeAsync("aspire add Aspire.Hosting.Docker"); await auto.EnterAsync(); - // In CI, aspire add shows a version selection prompt (unlike aspire new which auto-selects when channel is set) - if (isCI) + // In CI, aspire add shows a version selection prompt + // (unlike aspire new which auto-selects when channel is set) + if (CliE2ETestHelpers.PreInstalledCliDir is not null) { await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60)); - await auto.EnterAsync(); // select first version (PR build) + await auto.EnterAsync(); // select first version } await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180)); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs index 57f388cf504..407640ec878 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs @@ -64,13 +64,22 @@ internal static async Task InstallAspireCliInDockerAsync( { switch (installMode) { - case CliE2ETestHelpers.DockerInstallMode.SourceBuild: + case CliE2ETestHelpers.DockerInstallMode.PreInstalled: await auto.TypeAsync("mkdir -p ~/.aspire/bin && cp /opt/aspire-cli/aspire ~/.aspire/bin/aspire && chmod +x ~/.aspire/bin/aspire"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30)); await auto.TypeAsync("export PATH=~/.aspire/bin:$PATH"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); + + // If the CI workflow mounted built NuGet packages, configure a local hive so that + // 'aspire new' and 'aspire add' can resolve CI packages without going to nuget.org. + await auto.TypeAsync("if [ -d /built-nugets ]; then mkdir -p ~/.aspire/hives/ci/packages && cp /built-nugets/*.nupkg ~/.aspire/hives/ci/packages/ 2>/dev/null; fi"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60)); + await auto.TypeAsync("if [ -d ~/.aspire/hives/ci/packages ] && ls ~/.aspire/hives/ci/packages/*.nupkg 1>/dev/null 2>&1; then aspire config set channel ci --global --non-interactive; fi"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30)); break; case CliE2ETestHelpers.DockerInstallMode.GaRelease: @@ -82,63 +91,11 @@ internal static async Task InstallAspireCliInDockerAsync( await auto.WaitForSuccessPromptAsync(counter); break; - case CliE2ETestHelpers.DockerInstallMode.PullRequest: - var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); - await auto.TypeAsync($"/opt/aspire-scripts/get-aspire-cli-pr.sh {prNumber}"); - await auto.EnterAsync(); - await auto.WaitForSuccessPromptFailFastAsync(counter, TimeSpan.FromSeconds(300)); - await auto.TypeAsync("export PATH=~/.aspire/bin:~/.aspire:$PATH"); - await auto.EnterAsync(); - await auto.WaitForSuccessPromptAsync(counter); - break; - default: throw new ArgumentOutOfRangeException(nameof(installMode)); } } - /// - /// Prepares a non-Docker terminal environment with prompt counting and workspace navigation. - /// Used by tests that run with (bare bash, no Docker). - /// - internal static async Task PrepareEnvironmentAsync( - this Hex1bTerminalAutomator auto, - TemporaryWorkspace workspace, - SequenceCounter counter) - { - var waitingForInputPattern = new CellPatternSearcher() - .Find("b").RightUntil("$").Right(' ').Right(' '); - - await auto.WaitUntilAsync( - s => waitingForInputPattern.Search(s).Count > 0, - timeout: TimeSpan.FromSeconds(10), - description: "initial bash prompt"); - await auto.WaitAsync(500); - - const string promptSetup = "CMDCOUNT=0; PROMPT_COMMAND='s=$?;((CMDCOUNT++));PS1=\"[$CMDCOUNT $([ $s -eq 0 ] && echo OK || echo ERR:$s)] \\$ \"'"; - await auto.TypeAsync(promptSetup); - await auto.EnterAsync(); - await auto.WaitForSuccessPromptAsync(counter); - - await auto.TypeAsync($"cd {workspace.WorkspaceRoot.FullName}"); - await auto.EnterAsync(); - await auto.WaitForSuccessPromptAsync(counter); - } - - /// - /// Installs the Aspire CLI from PR build artifacts in a non-Docker environment. - /// - internal static async Task InstallAspireCliFromPullRequestAsync( - this Hex1bTerminalAutomator auto, - int prNumber, - SequenceCounter counter) - { - var command = $"curl -fsSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- {prNumber}"; - await auto.TypeAsync(command); - await auto.EnterAsync(); - await auto.WaitForSuccessPromptFailFastAsync(counter, TimeSpan.FromSeconds(300)); - } - /// /// Configures the PATH and environment variables for the Aspire CLI in a non-Docker environment. /// @@ -152,60 +109,51 @@ internal static async Task SourceAspireCliEnvironmentAsync( } /// - /// Verifies the installed Aspire CLI version matches the expected build. - /// Always checks the dynamic version prefix from eng/Versions.props. - /// For non-stabilized builds (all normal PR builds), also verifies the commit SHA suffix. + /// Verifies the installed Aspire CLI version matches the expected version from the + /// ASPIRE_CLI_VERSION environment variable. If the env var is not set (local dev), + /// this is a no-op. /// internal static async Task VerifyAspireCliVersionAsync( this Hex1bTerminalAutomator auto, - string commitSha, SequenceCounter counter) { - var versionPrefix = CliE2ETestHelpers.GetVersionPrefix(); - var isStabilized = CliE2ETestHelpers.IsStabilizedBuild(); - - await auto.TypeAsync("aspire --version"); - await auto.EnterAsync(); - - // Always verify the version prefix matches the branch's version (e.g., "13.3.0"). - await auto.WaitUntilTextAsync(versionPrefix, timeout: TimeSpan.FromSeconds(10)); - - // For non-stabilized builds (all PR CI builds), also verify the commit SHA suffix - // to uniquely identify the exact build. Stabilized builds (official releases only) - // produce versions without SHA suffixes, so we skip this check. - if (!isStabilized && commitSha.Length == 40) + var expectedVersion = CliE2ETestHelpers.ExpectedCliVersion; + if (expectedVersion is null) { - var shortCommitSha = commitSha[..8]; - var expectedVersionSuffix = $"g{shortCommitSha}"; - await auto.WaitUntilTextAsync(expectedVersionSuffix, timeout: TimeSpan.FromSeconds(10)); + // Local dev — skip version check. + return; } + await auto.TypeAsync("aspire --version"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync(expectedVersion, timeout: TimeSpan.FromSeconds(10)); await auto.WaitForSuccessPromptAsync(counter); } /// - /// Installs the Aspire CLI and bundle from PR build artifacts, using the PR head SHA to fetch the install script. + /// Prepares a non-Docker terminal environment with prompt counting and workspace navigation. + /// Used by tests that run with (bare bash, no Docker). /// - internal static async Task InstallAspireBundleFromPullRequestAsync( + internal static async Task PrepareEnvironmentAsync( this Hex1bTerminalAutomator auto, - int prNumber, + TemporaryWorkspace workspace, SequenceCounter counter) { - var command = $"ref=$(gh api repos/microsoft/aspire/pulls/{prNumber} --jq '.head.sha') && curl -fsSL https://raw.githubusercontent.com/microsoft/aspire/$ref/eng/scripts/get-aspire-cli-pr.sh | bash -s -- {prNumber}"; - await auto.TypeAsync(command); + var waitingForInputPattern = new CellPatternSearcher() + .Find("b").RightUntil("$").Right(' ').Right(' '); + + await auto.WaitUntilAsync( + s => waitingForInputPattern.Search(s).Count > 0, + timeout: TimeSpan.FromSeconds(10), + description: "initial bash prompt"); + await auto.WaitAsync(500); + + const string promptSetup = "CMDCOUNT=0; PROMPT_COMMAND='s=$?;((CMDCOUNT++));PS1=\"[$CMDCOUNT $([ $s -eq 0 ] && echo OK || echo ERR:$s)] \\$ \"'"; + await auto.TypeAsync(promptSetup); await auto.EnterAsync(); - await auto.WaitForSuccessPromptFailFastAsync(counter, TimeSpan.FromSeconds(300)); - } + await auto.WaitForSuccessPromptAsync(counter); - /// - /// Configures the PATH and environment variables for the Aspire CLI bundle in a non-Docker environment. - /// Unlike , this includes ~/.aspire in PATH for bundle tools. - /// - internal static async Task SourceAspireBundleEnvironmentAsync( - this Hex1bTerminalAutomator auto, - SequenceCounter counter) - { - await auto.TypeAsync("export PATH=~/.aspire/bin:~/.aspire:$PATH ASPIRE_PLAYGROUND=true TERM=xterm DOTNET_CLI_TELEMETRY_OPTOUT=true DOTNET_SKIP_FIRST_TIME_EXPERIENCE=true DOTNET_GENERATE_ASPNET_CERTIFICATE=false"); + await auto.TypeAsync($"cd {workspace.WorkspaceRoot.FullName}"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs index 4411c907212..a4f6fe80517 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs @@ -3,7 +3,6 @@ using System.Diagnostics; using System.Runtime.CompilerServices; -using System.Xml.Linq; using Aspire.Cli.Tests.Utils; using Hex1b; using Xunit; @@ -16,50 +15,21 @@ namespace Aspire.Cli.EndToEnd.Tests.Helpers; internal static class CliE2ETestHelpers { /// - /// Gets whether the tests are running in CI (GitHub Actions) vs locally. - /// When running locally, some commands are replaced with echo stubs. + /// Gets the expected version string from the ASPIRE_CLI_VERSION environment variable, + /// or when running locally (version check is skipped). /// - internal static bool IsRunningInCI => - !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_PR_NUMBER")) && - !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_PR_HEAD_SHA")); + internal static string? ExpectedCliVersion => + Environment.GetEnvironmentVariable("ASPIRE_CLI_VERSION") is { Length: > 0 } v ? v : null; /// - /// Gets the PR number from the GITHUB_PR_NUMBER environment variable. - /// When running locally (not in CI), returns a dummy value (0) for testing. + /// Gets the directory that contains the pre-installed Aspire CLI binary, or + /// if the CLI was not pre-installed by the workflow. + /// Set by the "Install CLI from archive" step in run-tests.yml when + /// requiresCliArchive is and there is no pull-request context + /// (e.g., scheduled quarantine runs). /// - /// The PR number, or 0 when running locally. - internal static int GetRequiredPrNumber() - { - var prNumberStr = Environment.GetEnvironmentVariable("GITHUB_PR_NUMBER"); - - if (string.IsNullOrEmpty(prNumberStr)) - { - // Running locally - return dummy value - return 0; - } - - Assert.True(int.TryParse(prNumberStr, out var prNumber), $"GITHUB_PR_NUMBER must be a valid integer, got: {prNumberStr}"); - return prNumber; - } - - /// - /// Gets the commit SHA from the GITHUB_PR_HEAD_SHA environment variable. - /// This is the actual PR head commit, not the merge commit (GITHUB_SHA). - /// When running locally (not in CI), returns a dummy value for testing. - /// - /// The commit SHA, or a dummy value when running locally. - internal static string GetRequiredCommitSha() - { - var commitSha = Environment.GetEnvironmentVariable("GITHUB_PR_HEAD_SHA"); - - if (string.IsNullOrEmpty(commitSha)) - { - // Running locally - return dummy value - return "local0000"; - } - - return commitSha; - } + internal static string? PreInstalledCliDir => + Environment.GetEnvironmentVariable("ASPIRE_CLI_PATH_DIR") is { Length: > 0 } v ? v : null; /// /// Gets the path for storing asciinema recordings that will be uploaded as CI artifacts. @@ -92,19 +62,15 @@ internal static Hex1bTerminal CreateTestTerminal(int width = 160, int height = 4 internal enum DockerInstallMode { /// - /// The CLI was built from source by the Dockerfile and is already on PATH. + /// The CLI binary is pre-installed on the host and mounted into the container. + /// Used for both local dev (./build.sh --bundle) and CI (PR, quarantine, outerloop). /// - SourceBuild, + PreInstalled, /// /// Install the latest GA release from aspire.dev. /// GaRelease, - - /// - /// Install from PR artifacts using the get-aspire-cli-pr.sh script. - /// - PullRequest, } /// @@ -139,28 +105,38 @@ internal enum DockerfileVariant /// The detected . internal static DockerInstallMode DetectDockerInstallMode(string repoRoot) { - if (IsRunningInCI) - { - return DockerInstallMode.PullRequest; - } - - // Check if a locally-built native AOT CLI binary exists (developer has run ./build.sh --bundle). + // Check if a pre-installed or locally-built CLI binary exists. var cliPublishDir = FindLocalCliBinary(repoRoot); if (cliPublishDir is not null) { - return DockerInstallMode.SourceBuild; + return DockerInstallMode.PreInstalled; } return DockerInstallMode.GaRelease; } /// - /// Finds the locally-built native AOT CLI publish directory. - /// Searches for the aspire binary under artifacts/bin/Aspire.Cli/*/net*/linux-x64/publish/. + /// Finds the CLI binary directory to use for Docker PreInstalled mode. + /// Checks, in order: + /// + /// The ASPIRE_CLI_PATH_DIR env var set by the "Install CLI from archive" workflow step. + /// The locally-built native AOT publish directory under artifacts/bin/Aspire.Cli/. + /// + /// Only Linux is supported (Docker-based E2E tests run on Linux only). /// - /// The publish directory path, or null if not found. + /// The directory containing the aspire binary, or if not found. internal static string? FindLocalCliBinary(string repoRoot) { + // Docker E2E tests only run on Linux; the binary is named 'aspire' (no .exe). + const string binaryName = "aspire"; + + // Prefer the pre-installed CLI binary dir set by the CI workflow. + var preInstalled = PreInstalledCliDir; + if (preInstalled is not null && File.Exists(Path.Combine(preInstalled, binaryName))) + { + return preInstalled; + } + var cliBaseDir = Path.Combine(repoRoot, "artifacts", "bin", "Aspire.Cli"); if (!Directory.Exists(cliBaseDir)) { @@ -168,7 +144,7 @@ internal static DockerInstallMode DetectDockerInstallMode(string repoRoot) } // Search for the native AOT binary under any config/TFM combination. - var matches = Directory.GetFiles(cliBaseDir, "aspire", SearchOption.AllDirectories) + var matches = Directory.GetFiles(cliBaseDir, binaryName, SearchOption.AllDirectories) .Where(f => f.Contains("linux-x64") && f.Contains("publish")) .ToArray(); @@ -257,33 +233,26 @@ internal static Hex1bTerminal CreateDockerTestTerminal( } // Always skip the expensive source build inside Docker. - // For SourceBuild mode, the CLI is installed from a mounted local bundle. - // For PullRequest/GaRelease, it's installed via scripts after container start. + // For PreInstalled mode, the CLI is installed from a mounted local binary. + // For GaRelease, it's installed via scripts after container start. c.BuildArgs["SKIP_SOURCE_BUILD"] = "true"; - if (installMode == DockerInstallMode.SourceBuild) + if (installMode == DockerInstallMode.PreInstalled) { - // Mount the locally-built native AOT CLI binary into the container. + // Mount the locally-built or CI-installed CLI binary into the container. var cliPublishDir = FindLocalCliBinary(repoRoot) - ?? throw new InvalidOperationException("SourceBuild mode detected but CLI binary not found"); + ?? throw new InvalidOperationException("PreInstalled mode detected but CLI binary not found"); c.Volumes.Add($"{cliPublishDir}:/opt/aspire-cli:ro"); output.WriteLine($" CLI binary: {cliPublishDir}"); - } - if (installMode == DockerInstallMode.PullRequest) - { - var ghToken = Environment.GetEnvironmentVariable("GH_TOKEN"); - if (!string.IsNullOrEmpty(ghToken)) + // Also mount the built NuGet packages so 'aspire new' / 'aspire add' / restore + // can resolve CI-built packages without going to nuget.org. + var builtNugetsPath = Environment.GetEnvironmentVariable("BUILT_NUGETS_PATH"); + if (!string.IsNullOrEmpty(builtNugetsPath) && Directory.Exists(builtNugetsPath)) { - c.Environment["GH_TOKEN"] = ghToken; + c.Volumes.Add($"{builtNugetsPath}:/built-nugets:ro"); + output.WriteLine($" Built NuGets: {builtNugetsPath}"); } - - var prNumber = Environment.GetEnvironmentVariable("GITHUB_PR_NUMBER") ?? ""; - var prSha = Environment.GetEnvironmentVariable("GITHUB_PR_HEAD_SHA") ?? ""; - c.Environment["GITHUB_PR_NUMBER"] = prNumber; - c.Environment["GITHUB_PR_HEAD_SHA"] = prSha; - output.WriteLine($" PR number: {prNumber}"); - output.WriteLine($" PR head SHA: {prSha}"); } }); @@ -378,53 +347,6 @@ internal static string ToContainerPath(string hostPath, TemporaryWorkspace works return $"/workspace/{workspace.WorkspaceRoot.Name}/" + relativePath.Replace('\\', '/'); } - /// - /// Reads the VersionPrefix (e.g., "13.3.0") from eng/Versions.props by parsing - /// the MajorVersion, MinorVersion, and PatchVersion MSBuild properties. - /// - internal static string GetVersionPrefix() - { - var repoRoot = GetRepoRoot(); - var versionsPropsPath = Path.Combine(repoRoot, "eng", "Versions.props"); - - var doc = XDocument.Load(versionsPropsPath); - var ns = doc.Root?.Name.Namespace ?? XNamespace.None; - - string? GetProperty(string name) => - doc.Descendants(ns + name).FirstOrDefault()?.Value; - - var major = GetProperty("MajorVersion") - ?? throw new InvalidOperationException("MajorVersion not found in eng/Versions.props"); - var minor = GetProperty("MinorVersion") - ?? throw new InvalidOperationException("MinorVersion not found in eng/Versions.props"); - var patch = GetProperty("PatchVersion") - ?? throw new InvalidOperationException("PatchVersion not found in eng/Versions.props"); - - return $"{major}.{minor}.{patch}"; - } - - /// - /// Checks whether the build is stabilized (StabilizePackageVersion=true in eng/Versions.props). - /// Stabilized builds produce version strings without commit SHA suffixes (e.g., "13.2.0" instead - /// of "13.2.0-preview.1.25175.1+g{sha}"). This is only true for official release builds, - /// never for normal PR CI builds. - /// - internal static bool IsStabilizedBuild() - { - var repoRoot = GetRepoRoot(); - var versionsPropsPath = Path.Combine(repoRoot, "eng", "Versions.props"); - - var doc = XDocument.Load(versionsPropsPath); - var ns = doc.Root?.Name.Namespace ?? XNamespace.None; - - // The default value in Versions.props uses a Condition to default to "false", - // so we read the element's text directly. - var stabilize = doc.Descendants(ns + "StabilizePackageVersion") - .FirstOrDefault()?.Value; - - return string.Equals(stabilize, "true", StringComparison.OrdinalIgnoreCase); - } - /// /// Copies a directory to testresults/workspaces/{testName}/{label} for CI artifact upload. /// Renames dot-prefixed directories to underscore-prefixed (upload-artifact skips hidden files). diff --git a/tests/Aspire.Cli.EndToEnd.Tests/JavaScriptPublishTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/JavaScriptPublishTests.cs index 194e81090da..fa26923c240 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/JavaScriptPublishTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/JavaScriptPublishTests.cs @@ -25,9 +25,6 @@ public async Task AllPublishMethodsBuildDockerImages() { using var workspace = TemporaryWorkspace.Create(output); - var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); - var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); - var isCI = CliE2ETestHelpers.IsRunningInCI; using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -36,11 +33,10 @@ public async Task AllPublishMethodsBuildDockerImages() await auto.PrepareEnvironmentAsync(workspace, counter); - if (isCI) + if (CliE2ETestHelpers.PreInstalledCliDir is not null) { - await auto.InstallAspireCliFromPullRequestAsync(prNumber, counter); await auto.SourceAspireCliEnvironmentAsync(counter); - await auto.VerifyAspireCliVersionAsync(commitSha, counter); + await auto.VerifyAspireCliVersionAsync(counter); } // Create TS AppHost and add packages diff --git a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs index 8aac2ef7af3..336034e1728 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs @@ -27,15 +27,11 @@ private static string GenerateUniqueClusterName() => $"{ClusterNamePrefix}-{Guid.NewGuid():N}"[..32]; // KinD cluster names max 32 chars [Fact] - [ActiveIssue("https://github.com/microsoft/aspire/issues/15930")] [QuarantinedTest("https://github.com/microsoft/aspire/issues/15870")] public async Task CreateAndPublishToKubernetes() { using var workspace = TemporaryWorkspace.Create(output); - var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); - var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); - var isCI = CliE2ETestHelpers.IsRunningInCI; var clusterName = GenerateUniqueClusterName(); output.WriteLine($"Using KinD version: {KindVersion}"); @@ -52,11 +48,11 @@ public async Task CreateAndPublishToKubernetes() // Prepare environment await auto.PrepareEnvironmentAsync(workspace, counter); - if (isCI) + if (CliE2ETestHelpers.PreInstalledCliDir is not null) { - await auto.InstallAspireCliFromPullRequestAsync(prNumber, counter); + // CI: CLI was pre-installed by the workflow — just configure env vars and verify. await auto.SourceAspireCliEnvironmentAsync(counter); - await auto.VerifyAspireCliVersionAsync(commitSha, counter); + await auto.VerifyAspireCliVersionAsync(counter); } try diff --git a/tests/Aspire.Cli.EndToEnd.Tests/PlaywrightCliInstallTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/PlaywrightCliInstallTests.cs index 32a52efa557..49387e7c575 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/PlaywrightCliInstallTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/PlaywrightCliInstallTests.cs @@ -73,10 +73,12 @@ await auto.WaitUntilAsync( await auto.TypeAsync(" "); // Toggle on Claude Code location await auto.EnterAsync(); - // Third prompt: skills. Accept defaults (Aspire, Playwright CLI, dotnet-inspect). + // Third prompt: skills. Aspire is pre-selected; explicitly select Playwright CLI too. await auto.WaitUntilAsync( s => s.ContainsText("skills should be installed"), timeout: TimeSpan.FromSeconds(30), description: "skill selection prompt"); + await auto.DownAsync(); // Move to playwright-cli + await auto.TypeAsync(" "); // Toggle it on await auto.EnterAsync(); // Wait for installation to complete (this downloads from npm, can take a while) @@ -158,10 +160,12 @@ await auto.WaitUntilAsync( await auto.TypeAsync(" "); // Toggle on Claude Code location await auto.EnterAsync(); - // Accept default skills, which include Playwright CLI. + // Select skills: Aspire is pre-selected; explicitly select Playwright CLI too. await auto.WaitUntilAsync( s => s.ContainsText("skills should be installed"), timeout: TimeSpan.FromSeconds(30), description: "skill selection prompt"); + await auto.DownAsync(); // Move to playwright-cli + await auto.TypeAsync(" "); // Toggle it on await auto.EnterAsync(); await auto.WaitUntilTextAsync("configuration complete", timeout: TimeSpan.FromMinutes(3)); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs index d5e6698291d..8f9900b19cf 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/ProjectReferenceTests.cs @@ -37,6 +37,7 @@ public async Task TypeScriptAppHostWithProjectReferenceIntegration() await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.VerifyAspireCliVersionAsync(counter); // Step 1: Create a TypeScript AppHost (so we get the SDK version in aspire.config.json) await auto.TypeAsync("aspire init --language typescript --non-interactive"); @@ -170,9 +171,11 @@ await auto.WaitUntilAsync(s => await auto.WaitForSuccessPromptAsync(counter); // Step 5: Wait for the custom resource to be up - await auto.TypeAsync("aspire wait my-svc --timeout 60"); + // Use a longer timeout for Docker-in-Docker where container image pull and startup + // can take considerably more than a minute. + await auto.TypeAsync("aspire wait my-svc --timeout 120"); await auto.EnterAsync(); - await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(90)); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(150)); // Step 6: Verify the resource appears in describe await auto.TypeAsync("aspire describe my-svc --format json > /tmp/my-svc-describe.json"); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptCodegenValidationTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptCodegenValidationTests.cs index ef8ff4add5f..0e479bc47aa 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptCodegenValidationTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptCodegenValidationTests.cs @@ -101,10 +101,6 @@ public async Task RunWithMissingAwaitShowsHelpfulError() { using var workspace = TemporaryWorkspace.Create(output); - var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); - var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); - var isCI = CliE2ETestHelpers.IsRunningInCI; - using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -114,11 +110,10 @@ public async Task RunWithMissingAwaitShowsHelpfulError() // PrepareEnvironment await auto.PrepareEnvironmentAsync(workspace, counter); - if (isCI) + if (CliE2ETestHelpers.PreInstalledCliDir is not null) { - await auto.InstallAspireBundleFromPullRequestAsync(prNumber, counter); - await auto.SourceAspireBundleEnvironmentAsync(counter); - await auto.VerifyAspireCliVersionAsync(commitSha, counter); + await auto.SourceAspireCliEnvironmentAsync(counter); + await auto.VerifyAspireCliVersionAsync(counter); } await auto.TypeAsync("aspire init --language typescript --non-interactive"); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/WaitCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/WaitCommandTests.cs index 5b0ad9be9be..5ba4e0bb6c8 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/WaitCommandTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/WaitCommandTests.cs @@ -37,6 +37,7 @@ public async Task CreateStartWaitAndStopAspireProject() // Install the Aspire CLI await auto.InstallAspireCliInDockerAsync(installMode, counter); + await auto.VerifyAspireCliVersionAsync(counter); // Create a new project using aspire new await auto.AspireNewAsync("AspireWaitApp", counter); @@ -46,8 +47,15 @@ public async Task CreateStartWaitAndStopAspireProject() await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); - // Start the AppHost in the background using aspire start - await auto.TypeAsync("aspire start"); + // Pre-build the project so that 'aspire start --no-build' can establish the backchannel + // quickly. In Docker-in-Docker environments the NuGet restore + build can exceed the + // 120-second backchannel timeout inside 'aspire start', causing a spurious timeout failure. + await auto.TypeAsync("dotnet build"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(5)); + + // Start the AppHost in the background using aspire start (skip build since we just built) + await auto.TypeAsync("aspire start --no-build"); await auto.EnterAsync(); await auto.WaitUntilTextAsync(RunCommandStrings.AppHostStartedSuccessfully, timeout: TimeSpan.FromMinutes(3)); await auto.WaitForSuccessPromptAsync(counter); diff --git a/tests/Shared/Hex1bAutomatorTestHelpers.cs b/tests/Shared/Hex1bAutomatorTestHelpers.cs index aad2b2a8522..039c62b197d 100644 --- a/tests/Shared/Hex1bAutomatorTestHelpers.cs +++ b/tests/Shared/Hex1bAutomatorTestHelpers.cs @@ -170,13 +170,39 @@ internal static async Task AspireNewAsync( { var templateTimeout = TimeSpan.FromSeconds(60); - // Step 1: Type aspire new and wait for the template list + // Step 1: Type aspire new and wait for the template list or a version picker. + // When a non-default channel (e.g. "ci") is configured the CLI shows a version + // selection prompt before the template list. await auto.TypeAsync("aspire new"); await auto.EnterAsync(); + + var waitingForTemplateList = new CellPatternSearcher().Find("> Starter App"); + var waitingForVersionPicker = new CellPatternSearcher().Find("Select a version of"); + var versionPickerShown = false; + await auto.WaitUntilAsync( - s => new CellPatternSearcher().Find("> Starter App").Search(s).Count > 0, + s => + { + if (waitingForVersionPicker.Search(s).Count > 0) + { + versionPickerShown = true; + return true; + } + return waitingForTemplateList.Search(s).Count > 0; + }, timeout: templateTimeout, - description: "template selection list (> Starter App)"); + description: "template selection list or version picker"); + + // If we landed on a version picker, accept the first (latest) version and then + // wait for the template list to appear. + if (versionPickerShown) + { + await auto.EnterAsync(); // Accept the first (pre-selected) version + await auto.WaitUntilAsync( + s => waitingForTemplateList.Search(s).Count > 0, + timeout: templateTimeout, + description: "template selection list (> Starter App) after version selection"); + } // Step 2: Navigate to and select the desired template switch (template)