File: TypeScriptLegacyAppHostTests.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 System.Text.Json.Nodes;
using Aspire.Cli.EndToEnd.Tests.Helpers;
using Hex1b.Automation;
using Xunit;
 
namespace Aspire.Cli.EndToEnd.Tests;
 
/// <summary>
/// End-to-end coverage for the legacy TypeScript AppHost compatibility path. TypeScript
/// projects scaffolded before the move to <c>apphost.mts</c> still ship an
/// <c>apphost.ts</c> that imports the generated SDK from <c>./.modules/aspire.js</c>.
/// When such a project is detected, the CLI must:
/// <list type="bullet">
///   <item><description>Write generated SDK files to the legacy <c>./.modules/</c> folder (not <c>./.aspire/modules/</c>).</description></item>
///   <item><description>Convert <c>.mts</c>/<c>.mjs</c> output back to <c>.ts</c>/<c>.js</c> so the existing import specifiers resolve.</description></item>
///   <item><description>Continue to run successfully via <c>aspire start</c>.</description></item>
/// </list>
/// See <c>GuestAppHostProject.ShouldEmitLegacyTypeScriptGeneratedFiles</c> and
/// <c>ConvertGeneratedFilesForLegacyTypeScriptAppHost</c>.
/// </summary>
public sealed class TypeScriptLegacyAppHostTests(ITestOutputHelper output)
{
    [Fact]
    [CaptureWorkspaceOnFailure]
    public async Task AspireAddAndStartWorkAgainstLegacyAppHostTs()
    {
        var repoRoot = CliE2ETestHelpers.GetRepoRoot();
        var strategy = CliInstallStrategy.Detect(output.WriteLine);
        var workspace = TemporaryWorkspace.Create(output);
 
        using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(
            repoRoot,
            strategy,
            output,
            variant: CliE2ETestHelpers.DockerfileVariant.DotNet,
            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: Bootstrap a modern TypeScript AppHost so all scaffolded files
        // (package.json, tsconfig.apphost.json, aspire.config.json) and the installed
        // Node toolchain are real. We then convert it in place to the legacy layout to
        // reproduce a project that an earlier CLI version originally created.
        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);
 
        ConvertScaffoldToLegacyAppHostTs(workspace.WorkspaceRoot.FullName);
 
        // Step 2: Add a new integration through `aspire add`. The CLI must persist the
        // package to aspire.config.json and then route the subsequent code generation
        // through the legacy compatibility path without disturbing the existing apphost.ts.
        await auto.TypeAsync("aspire add Aspire.Hosting.Redis");
        await auto.EnterAsync();
        await auto.WaitForAspireAddSuccessAsync(counter, TimeSpan.FromMinutes(2));
 
        // Step 3: Restore triggers the explicit code-generation step. For a legacy
        // apphost.ts (apphost.mts absent), generated files MUST land in `.modules/`
        // rather than `.aspire/modules/`. RestoreCommand calls BuildAndGenerateSdkAsync
        // with appHostFile: null, so ShouldEmitLegacyTypeScriptGeneratedFiles takes the
        // disk-scan branch and selects the legacy layout.
        await auto.TypeAsync("aspire restore");
        await auto.EnterAsync();
        await auto.WaitUntilTextAsync("SDK code restored successfully", timeout: TimeSpan.FromMinutes(3));
        await auto.WaitForSuccessPromptAsync(counter);
 
        AssertLegacyModulesLayout(workspace.WorkspaceRoot.FullName);
 
        // Step 4: Type-check apphost.ts against its tsconfig. This proves the rewritten
        // `.js` import specifiers in the generated files resolve correctly against the
        // legacy `.modules/` folder — the contract the conversion enforces.
        await auto.TypeAsync("npx --no-install tsc --noEmit -p tsconfig.apphost.json");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
 
        // Step 5: `aspire start` exercises apphost.ts at runtime — proving the generated
        // SDK is dynamically importable AND that addRedis (added via aspire add in step 2)
        // materializes as a real resource. RunAsync passes the explicit apphost.ts
        // FileInfo, so legacy detection takes the file-name branch this time.
        await auto.AspireStartAsync(counter);
        await auto.AssertResourcesExistAsync(counter, "cache");
        await auto.AspireStopAsync(counter);
    }
 
    /// <summary>
    /// Mutates a freshly-scaffolded modern TypeScript AppHost into the legacy layout
    /// (<c>apphost.ts</c> + <c>./.modules/aspire.js</c> imports) that pre-13.4 CLI
    /// versions produced. This is intentionally surgical — we only touch the files
    /// that the legacy detection in
    /// <c>GuestAppHostProject.ShouldEmitLegacyTypeScriptGeneratedFiles</c> reads, plus
    /// the tsconfig include set so <c>tsc --noEmit</c> can type-check the legacy layout.
    /// </summary>
    private static void ConvertScaffoldToLegacyAppHostTs(string projectRoot)
    {
        // Drop the modern apphost.mts and any pre-generated SDK files. Their presence
        // would defeat the legacy detection, which requires apphost.ts AND no apphost.mts
        // on disk (see ShouldEmitLegacyTypeScriptGeneratedFiles' file-existence check).
        var mtsPath = Path.Combine(projectRoot, "apphost.mts");
        if (File.Exists(mtsPath))
        {
            File.Delete(mtsPath);
        }
 
        var modernModulesDir = Path.Combine(projectRoot, ".aspire", "modules");
        if (Directory.Exists(modernModulesDir))
        {
            Directory.Delete(modernModulesDir, recursive: true);
        }
 
        // The legacy apphost.ts imports the generated SDK from ./.modules/aspire.js —
        // the exact import shape that pre-13.4 scaffolding produced and that this test
        // is validating continues to work end-to-end.
        File.WriteAllText(Path.Combine(projectRoot, "apphost.ts"), """
            import { createBuilder } from './.modules/aspire.js';
 
            const builder = await createBuilder();
            await builder.addRedis("cache");
            await builder.build().run();
            """);
 
        // Point aspire.config.json at apphost.ts. apphost.ts also matches the TypeScript
        // detection patterns, but `aspire run/start` reads appHost.path explicitly from
        // aspire.config.json — without this update the CLI would still look for
        // apphost.mts.
        var configPath = Path.Combine(projectRoot, "aspire.config.json");
        var configJson = JsonNode.Parse(File.ReadAllText(configPath))!.AsObject();
        var appHost = configJson["appHost"]!.AsObject();
        appHost["path"] = "apphost.ts";
        File.WriteAllText(
            configPath,
            configJson.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
 
        // The CLI's PreExecute step runs `tsc --noEmit -p tsconfig.apphost.json` against
        // whatever files the tsconfig includes. Replace the modern .mts entries with the
        // legacy .ts equivalents so the type-check actually sees the rewritten SDK.
        File.WriteAllText(Path.Combine(projectRoot, "tsconfig.apphost.json"), """
            {
              "compilerOptions": {
                "target": "ES2022",
                "module": "NodeNext",
                "moduleResolution": "NodeNext",
                "esModuleInterop": true,
                "forceConsistentCasingInFileNames": true,
                "strict": true,
                "skipLibCheck": true,
                "outDir": "./dist/apphost",
                "rootDir": "."
              },
              "include": [
                "apphost.ts",
                ".modules/aspire.ts",
                ".modules/base.ts",
                ".modules/transport.ts"
              ],
              "exclude": ["node_modules"]
            }
            """);
    }
 
    private static void AssertLegacyModulesLayout(string projectRoot)
    {
        var legacyModulesDir = Path.Combine(projectRoot, ".modules");
        if (!Directory.Exists(legacyModulesDir))
        {
            throw new InvalidOperationException(
                $"Legacy '.modules' directory was not created at {legacyModulesDir}. " +
                "The CLI is supposed to route generated SDK files to '.modules/' (not '.aspire/modules/') " +
                "when an apphost.ts (without an apphost.mts) is detected — see ShouldEmitLegacyTypeScriptGeneratedFiles.");
        }
 
        var modernModulesDir = Path.Combine(projectRoot, ".aspire", "modules");
        if (Directory.Exists(modernModulesDir))
        {
            throw new InvalidOperationException(
                $"Modern '.aspire/modules' directory was unexpectedly created at {modernModulesDir} for a legacy apphost.ts project. " +
                "This would silently leave the project's `./.modules/aspire.js` import unresolved.");
        }
 
        foreach (var expectedFile in new[] { "aspire.ts", "base.ts", "transport.ts" })
        {
            var filePath = Path.Combine(legacyModulesDir, expectedFile);
            if (!File.Exists(filePath))
            {
                throw new InvalidOperationException($"Expected generated file not found: {filePath}");
            }
 
            var content = File.ReadAllText(filePath);
            if (string.IsNullOrWhiteSpace(content))
            {
                throw new InvalidOperationException($"Generated file is empty: {filePath}");
            }
 
            // The legacy conversion rewrites .mjs import specifiers to .js so the
            // existing apphost.ts `./.modules/aspire.js` import (and the inter-module
            // imports inside the generated files) all resolve at runtime.
            if (content.Contains(".mjs", StringComparison.Ordinal))
            {
                throw new InvalidOperationException(
                    $"Generated file '{filePath}' still contains a '.mjs' import specifier — the legacy conversion " +
                    "should have rewritten these to '.js' to match how Node resolves the SDK from apphost.ts.");
            }
        }
 
        // None of the generated files should be emitted with .mts extensions in the
        // legacy folder — the conversion renames aspire/base/transport to .ts.
        foreach (var unexpectedFile in new[] { "aspire.mts", "base.mts", "transport.mts" })
        {
            var filePath = Path.Combine(legacyModulesDir, unexpectedFile);
            if (File.Exists(filePath))
            {
                throw new InvalidOperationException(
                    $"Unexpected modern file '{filePath}' was emitted into the legacy '.modules/' folder. " +
                    "The conversion path should have written the .ts equivalents only.");
            }
        }
 
        // The generated SDK must actually surface the Redis API that aspire add registered.
        // If addRedis is missing the apphost.ts call to builder.addRedis("cache") would
        // fail at runtime — and `aspire start` would never reach the dashboard URL.
        var aspireTs = File.ReadAllText(Path.Combine(legacyModulesDir, "aspire.ts"));
        if (!aspireTs.Contains("addRedis", StringComparison.Ordinal))
        {
            throw new InvalidOperationException(
                $"Generated '.modules/aspire.ts' does not expose addRedis after `aspire add Aspire.Hosting.Redis`. " +
                $"File path: {Path.Combine(legacyModulesDir, "aspire.ts")}.");
        }
    }
}