| File: TypeScriptStarterSmokeTests.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 smoke tests for the TypeScript Express/React starter template (aspire-ts-starter). /// Validates that <c>aspire new</c> creates a working Express API + React frontend project, /// the resulting <c>aspire.config.json</c> agrees with the CLI's identity channel, and /// <c>aspire run</c> starts it successfully. /// <para> /// Class name ends in <c>SmokeTests</c> so the daily smoke workflow /// (<c>.github/workflows/tests-daily-smoke.yml</c>) picks it up via its /// <c>--filter-class "*SmokeTests"</c> filter — which exercises the CLI against the actual /// daily-channel build from <c>https://aka.ms/dotnet/9/aspire/daily</c>. This is the /// regression coverage that catches identity-channel resolution bugs invisible on PR builds /// (PR CLIs bake <c>pr-<N></c> as their identity, which always took the well-tested /// local-build branch even before the identity-channel selection was widened). /// </para> /// </summary> public sealed class TypeScriptStarterSmokeTests(ITestOutputHelper output) { [CaptureWorkspaceOnFailure] [Fact] public async Task CreateAndRunTypeScriptStarterProject() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); var strategy = CliInstallStrategy.Detect(output.WriteLine); var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, 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); // Step 1: Create project using aspire new, selecting the Express/React template. // Deliberately no `--channel` arg — exercises the implicit-channel path where the // resolved channel falls out of CliExecutionContext.IdentityChannel. await auto.AspireNewAsync("TsStarterApp", counter, template: AspireTemplate.ExpressReact); // Step 1.5: Verify starter creation also restored the generated TypeScript SDK. var projectRoot = Path.Combine(workspace.WorkspaceRoot.FullName, "TsStarterApp"); GitIgnoreAssertions.AssertContainsEntry(projectRoot, ".aspire/"); var modulesDir = Path.Combine(projectRoot, ".aspire/modules"); if (!Directory.Exists(modulesDir)) { throw new InvalidOperationException($".aspire/modules directory was not created at {modulesDir}"); } var aspireModulePath = Path.Combine(modulesDir, "aspire.mts"); if (!File.Exists(aspireModulePath)) { throw new InvalidOperationException($"Expected generated file not found: {aspireModulePath}"); } // Step 1.6: Regression guard for the daily-channel restore failure. The bug class // we're locking out: a CLI whose IdentityChannel is daily/staging would resolve // `Aspire.ProjectTemplates` from the Implicit (nuget.org) channel — yielding a // stable SDK version — but the template factories still pinned `channel` to the // CLI's identity channel. The pinned-channel PSM then routed Aspire.* to a feed // that has no stable, and restore failed with "Unable to find a stable package". // // After the fix, the channel persisted into `aspire.config.json` MUST agree with // the SDK version: if a non-stable channel is pinned, the SDK version must be a // prerelease (since channel-specific feeds only host prereleases). If channel is // unset or `stable`, the SDK version must be a stable release. Restore succeeding // above proves the two are mutually satisfiable, but explicitly asserting the // invariant here surfaces the mismatch directly if anything reseeds an // inconsistent pair in the future. AssertAspireConfigChannelConsistentWithSdkVersion(Path.Combine(projectRoot, "aspire.config.json")); // Step 2: Navigate into the project and start it. await auto.TypeAsync("cd TsStarterApp"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); await auto.RunCommandAsync("npm run build", counter, TimeSpan.FromMinutes(2)); await auto.AspireStartAsync(counter); await auto.AspireStopAsync(counter); } /// <summary> /// Reads <c>aspire.config.json</c> and asserts the SDK version is consistent with the /// channel pin. Non-stable channel ⇒ prerelease SDK; absent/stable channel ⇒ stable /// SDK. The invariant is the contract restore relies on: any consumer reading the /// per-project channel for PSM/feed selection must be able to find the requested SDK /// version through that channel. /// </summary> private static void AssertAspireConfigChannelConsistentWithSdkVersion(string configPath) { if (!File.Exists(configPath)) { throw new FileNotFoundException($"Expected aspire.config.json to exist at {configPath}.", configPath); } using var doc = JsonDocument.Parse(File.ReadAllText(configPath)); var root = doc.RootElement; if (!root.TryGetProperty("sdk", out var sdk) || !sdk.TryGetProperty("version", out var versionProp) || versionProp.ValueKind != JsonValueKind.String) { throw new InvalidOperationException($"aspire.config.json is missing 'sdk.version': {configPath}"); } var sdkVersion = versionProp.GetString()!; var sdkIsPrerelease = sdkVersion.Contains('-', StringComparison.Ordinal); var channel = root.TryGetProperty("channel", out var channelProp) && channelProp.ValueKind == JsonValueKind.String ? channelProp.GetString() : null; // The staging channel's quality is configurable (Stable / Prerelease / Both) — its // SHA-pinned feed can legitimately host either stable or prerelease packages, so // we can't enforce the prerelease ↔ channel invariant from the channel name alone. // Restore feasibility is still validated by the surrounding aspire-run flow. if (string.Equals(channel, "staging", StringComparison.OrdinalIgnoreCase)) { return; } // Stable channel (or no channel pin) → SDK must be stable. // Any non-stable channel (daily / pr-* / local) → SDK must be prerelease. var channelExpectsPrerelease = !string.IsNullOrEmpty(channel) && !string.Equals(channel, "stable", StringComparison.OrdinalIgnoreCase); if (channelExpectsPrerelease && !sdkIsPrerelease) { throw new InvalidOperationException( $"aspire.config.json pinned channel '{channel}' but SDK version '{sdkVersion}' is stable. " + "These cannot be mutually satisfied by restore (the channel feed only hosts prereleases). " + $"See {configPath}."); } if (!channelExpectsPrerelease && sdkIsPrerelease) { throw new InvalidOperationException( $"aspire.config.json has no non-stable channel pin (channel='{channel ?? "<unset>"}') but SDK version '{sdkVersion}' is a prerelease. " + "The Implicit/stable channel won't surface this version. " + $"See {configPath}."); } } }