File: TypeScriptPolyglotTests.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.Diagnostics;
using System.Text.Json;
using System.Text.Json.Nodes;
using Aspire.Cli.EndToEnd.Tests.Helpers;
using Hex1b.Automation;
using Hex1b.Input;
using Xunit;
 
namespace Aspire.Cli.EndToEnd.Tests;
 
/// <summary>
/// End-to-end tests for Aspire CLI with TypeScript polyglot AppHost.
/// Tests creating a TypeScript-based AppHost and adding a Vite application.
/// </summary>
public sealed class TypeScriptPolyglotTests(ITestOutputHelper output)
{
    public static TheoryData<string> SupportedToolchains => new()
    {
        "npm",
        "bun",
        "yarn",
        "pnpm",
        "deno"
    };
 
    [Theory]
    [MemberData(nameof(SupportedToolchains))]
    [CaptureWorkspaceOnFailure]
    public async Task CreateTypeScriptAppHostWithViteApp_UsesConfiguredToolchain(string toolchain)
    {
        var repoRoot = CliE2ETestHelpers.GetRepoRoot();
        var strategy = CliInstallStrategy.Detect(output.WriteLine);
        var workspace = TemporaryWorkspace.Create(output);
        var localChannel = CliE2ETestHelpers.PrepareLocalChannel(repoRoot, strategy,
            ["Aspire.Hosting.CodeGeneration.TypeScript.", "Aspire.Hosting.JavaScript."]);
 
        // LocalHive strategy only: PrepareLocalChannel returned a real channel,
        // so pass --channel local explicitly to aspire init. Other strategies
        // (script-installed CLI, pre-existing CLI) return null and rely on the
        // CLI's baked channel + ambient NuGet feeds.
        var channelArgument = localChannel is not null ? " --channel local" : string.Empty;
 
        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);
 
        output.WriteLine($"Testing TypeScript AppHost toolchain: {toolchain}");
 
        // Step 1: Create TypeScript AppHost
        await auto.TypeAsync($"aspire init --language typescript --non-interactive{channelArgument}");
        await auto.EnterAsync();
        await auto.WaitUntilTextAsync("Created apphost.mts", timeout: TimeSpan.FromMinutes(2));
        await auto.DeclineAgentInitPromptAsync(counter);
 
        TypeScriptAppHostToolchainTestHelpers.SetPackageManager(workspace.WorkspaceRoot.FullName, toolchain, cleanInstallState: true);
 
        // 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(workspace.WorkspaceRoot.FullName, localChannel.SdkVersion);
        }
 
        // Step 2: Create a Vite app using npm create vite
        // Using --template vanilla-ts for a minimal TypeScript Vite app
        // Use -y to skip npm prompts and -- to pass args to create-vite
        // Use --no-interactive to skip vite's interactive prompts (rolldown, install now, etc.)
        await auto.TypeAsync(ViteTestHelpers.GetCreateCommand("viteapp"));
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
 
        var viteProjectRoot = Path.Combine(workspace.WorkspaceRoot.FullName, "viteapp");
        TypeScriptAppHostToolchainTestHelpers.SetPackageManager(viteProjectRoot, toolchain, cleanInstallState: true);
 
        // Step 3: Install Vite app dependencies
        await auto.TypeAsync($"cd viteapp && {TypeScriptAppHostToolchainTestHelpers.GetInstallCommand(toolchain)} && cd ..");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
 
        // Step 4: Add Aspire.Hosting.JavaScript package
        // When channel is set (CI) and there's only one channel with one version,
        // the version is auto-selected without prompting.
        await auto.TypeAsync("aspire add Aspire.Hosting.JavaScript");
        await auto.EnterAsync();
        await auto.WaitForAspireAddSuccessAsync(counter, TimeSpan.FromMinutes(2));
 
        // Step 5: Modify apphost.mts to add the Vite app
        var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.mts");
        var newContent = """
            // Aspire TypeScript AppHost
            // For more information, see: https://aspire.dev
 
            import { createBuilder } from './.aspire/modules/aspire.mjs';
 
            const builder = await createBuilder();
 
            // Add the Vite frontend application
            const viteApp = await builder.addViteApp("viteapp", "./viteapp");
 
            await builder.build().run();
            """;
 
        File.WriteAllText(appHostPath, newContent);
 
        // Step 6: Restore and type-check with the configured package manager before running.
        await auto.TypeAsync("aspire restore");
        await auto.EnterAsync();
        await auto.WaitUntilTextAsync("SDK code restored successfully", timeout: TimeSpan.FromMinutes(3));
        await auto.WaitForSuccessPromptAsync(counter);
 
        var appHostLockFilePath = Path.Combine(
            workspace.WorkspaceRoot.FullName,
            TypeScriptAppHostToolchainTestHelpers.GetLockFileName(toolchain));
        Assert.True(
            File.Exists(appHostLockFilePath),
            $"Expected {TypeScriptAppHostToolchainTestHelpers.GetDisplayName(toolchain)} restore to create '{appHostLockFilePath}'.");
 
        var viteLockFilePath = Path.Combine(
            viteProjectRoot,
            TypeScriptAppHostToolchainTestHelpers.GetLockFileName(toolchain));
        Assert.True(
            File.Exists(viteLockFilePath),
            $"Expected {TypeScriptAppHostToolchainTestHelpers.GetDisplayName(toolchain)} install to create '{viteLockFilePath}'.");
 
        await auto.TypeAsync(TypeScriptAppHostToolchainTestHelpers.GetTypeCheckCommand(toolchain, "tsconfig.apphost.json"));
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
 
        // Step 7: Run the apphost
        await auto.TypeAsync("aspire run");
        await auto.EnterAsync();
        await auto.WaitUntilAsync(s =>
        {
            if (s.ContainsText("Select an AppHost to use:"))
            {
                throw new InvalidOperationException(
                    "Unexpected apphost selection prompt detected! " +
                    "This indicates multiple apphosts were incorrectly detected.");
            }
 
            return s.ContainsText("Press CTRL+C to stop the AppHost and exit.");
        }, timeout: TimeSpan.FromMinutes(3), description: "Press CTRL+C message (aspire run started)");
 
        // Step 8: Stop the apphost
        await auto.Ctrl().KeyAsync(Hex1bKey.C);
        await auto.WaitForSuccessPromptAsync(counter);
    }
 
    [Fact]
    [CaptureWorkspaceOnFailure]
    public async Task TypeScriptAppHostWithVite_AllowsDifferentGuestPkgManager()
    {
        const string appHostToolchain = "pnpm";
        const string guestToolchain = "npm";
 
        var repoRoot = CliE2ETestHelpers.GetRepoRoot();
        var strategy = CliInstallStrategy.Detect(output.WriteLine);
        var workspace = TemporaryWorkspace.Create(output);
        var localChannel = CliE2ETestHelpers.PrepareLocalChannel(repoRoot, strategy,
            ["Aspire.Hosting.CodeGeneration.TypeScript.", "Aspire.Hosting.JavaScript."]);
        var channelArgument = localChannel is not null ? " --channel local" : string.Empty;
 
        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);
 
        await auto.TypeAsync($"aspire init --language typescript --non-interactive{channelArgument}");
        await auto.EnterAsync();
        await auto.WaitUntilTextAsync("Created apphost.mts", timeout: TimeSpan.FromMinutes(2));
        await auto.DeclineAgentInitPromptAsync(counter);
 
        TypeScriptAppHostToolchainTestHelpers.SetPackageManager(workspace.WorkspaceRoot.FullName, appHostToolchain, cleanInstallState: true);
 
        if (localChannel is not null)
        {
            CliE2ETestHelpers.WriteLocalChannelSettings(workspace.WorkspaceRoot.FullName, localChannel.SdkVersion);
        }
 
        await auto.TypeAsync(ViteTestHelpers.GetCreateCommand("viteapp"));
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
 
        var viteProjectRoot = Path.Combine(workspace.WorkspaceRoot.FullName, "viteapp");
        TypeScriptAppHostToolchainTestHelpers.SetPackageManager(viteProjectRoot, guestToolchain, cleanInstallState: true);
 
        await auto.TypeAsync($"cd viteapp && {TypeScriptAppHostToolchainTestHelpers.GetInstallCommand(guestToolchain)} && cd ..");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
 
        await auto.TypeAsync("aspire add Aspire.Hosting.JavaScript");
        await auto.EnterAsync();
        await auto.WaitForAspireAddSuccessAsync(counter, TimeSpan.FromMinutes(2));
 
        var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.mts");
        File.WriteAllText(appHostPath, """
            // Aspire TypeScript AppHost
            // For more information, see: https://aspire.dev
 
            import { createBuilder } from './.aspire/modules/aspire.mjs';
 
            const builder = await createBuilder();
 
            await builder.addViteApp("viteapp", "./viteapp");
 
            await builder.build().run();
            """);
 
        await auto.TypeAsync("aspire restore");
        await auto.EnterAsync();
        await auto.WaitUntilTextAsync("SDK code restored successfully", timeout: TimeSpan.FromMinutes(3));
        await auto.WaitForSuccessPromptAsync(counter);
 
        var appHostLockFilePath = Path.Combine(
            workspace.WorkspaceRoot.FullName,
            TypeScriptAppHostToolchainTestHelpers.GetLockFileName(appHostToolchain));
        Assert.True(
            File.Exists(appHostLockFilePath),
            $"Expected {TypeScriptAppHostToolchainTestHelpers.GetDisplayName(appHostToolchain)} restore to create '{appHostLockFilePath}'.");
 
        var guestLockFilePath = Path.Combine(
            viteProjectRoot,
            TypeScriptAppHostToolchainTestHelpers.GetLockFileName(guestToolchain));
        Assert.True(
            File.Exists(guestLockFilePath),
            $"Expected {TypeScriptAppHostToolchainTestHelpers.GetDisplayName(guestToolchain)} install to create '{guestLockFilePath}'.");
 
        await auto.TypeAsync(TypeScriptAppHostToolchainTestHelpers.GetTypeCheckCommand(appHostToolchain, "tsconfig.apphost.json"));
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
    }
 
    [Theory]
    [MemberData(nameof(SupportedToolchains))]
    [CaptureWorkspaceOnFailure]
    public async Task GeneratedAspireDevScript_StartsWatchMode_WithConfiguredToolchain(string toolchain)
    {
        var repoRoot = CliE2ETestHelpers.GetRepoRoot();
        var strategy = CliInstallStrategy.Detect(output.WriteLine);
        var workspace = TemporaryWorkspace.Create(output);
        var localChannel = CliE2ETestHelpers.PrepareLocalChannel(repoRoot, strategy,
            ["Aspire.Hosting.CodeGeneration.TypeScript."]);
 
        var channelArgument = localChannel is not null ? " --channel local" : string.Empty;
 
        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 auto.InstallAspireCliAsync(strategy, counter);
 
        await auto.TypeAsync($"aspire init --language typescript --non-interactive{channelArgument}");
        await auto.EnterAsync();
        await auto.WaitUntilTextAsync("Created apphost.mts", timeout: TimeSpan.FromMinutes(2));
        await auto.DeclineAgentInitPromptAsync(counter);
 
        TypeScriptAppHostToolchainTestHelpers.SetPackageManager(workspace.WorkspaceRoot.FullName, toolchain, cleanInstallState: true);
 
        if (localChannel is not null)
        {
            CliE2ETestHelpers.WriteLocalChannelSettings(workspace.WorkspaceRoot.FullName, localChannel.SdkVersion);
        }
 
        await auto.TypeAsync(TypeScriptAppHostToolchainTestHelpers.GetInstallCommand(toolchain));
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
 
        var lockFilePath = Path.Combine(
            workspace.WorkspaceRoot.FullName,
            TypeScriptAppHostToolchainTestHelpers.GetLockFileName(toolchain));
        Assert.True(
            File.Exists(lockFilePath),
            $"Expected {TypeScriptAppHostToolchainTestHelpers.GetDisplayName(toolchain)} install to create '{lockFilePath}'.");
 
        if (toolchain == "deno")
        {
            // Keep Deno and Node available for package tasks and local npm-package binaries, but
            // deliberately exclude npm. A successful build proves the convenience task runs both
            // ESLint and tsc without relying on npm lifecycle hooks.
            await auto.TypeAsync("""
                mkdir -p /tmp/deno-task-path &&
                ln -sf "$(command -v deno)" /tmp/deno-task-path/deno &&
                ln -sf "$(command -v node)" /tmp/deno-task-path/node &&
                PATH=/tmp/deno-task-path deno task build
                """.ReplaceLineEndings(" "));
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
        }
 
        var watchCommand = TypeScriptAppHostToolchainTestHelpers.GetRunScriptCommand(toolchain, "aspire:dev");
        if (toolchain == "deno")
        {
            watchCommand = "PATH=/tmp/deno-task-path deno task watch";
        }
 
        await auto.TypeAsync(watchCommand);
        await auto.EnterAsync();
        await auto.WaitUntilAsync(
            s => s.ContainsText(TypeScriptAppHostToolchainTestHelpers.GetWatchModeReadyText(toolchain)),
            timeout: TimeSpan.FromMinutes(2),
            description: $"{toolchain} watch mode to start");
 
        await auto.Ctrl().KeyAsync(Hex1bKey.C);
        await auto.WaitForAnyPromptAsync(counter);
    }
 
    [Fact]
    [CaptureWorkspaceOnFailure]
    public async Task AspireRun_WithDenoWatchMode_RestartsOnAppHostChange()
    {
        var repoRoot = CliE2ETestHelpers.GetRepoRoot();
        var strategy = CliInstallStrategy.Detect(output.WriteLine);
        var workspace = TemporaryWorkspace.Create(output);
        var localChannel = CliE2ETestHelpers.PrepareLocalChannel(repoRoot, strategy,
            ["Aspire.Hosting.CodeGeneration.TypeScript."]);
        var channelArgument = localChannel is not null ? " --channel local" : string.Empty;
 
        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 auto.InstallAspireCliAsync(strategy, counter);
 
        await auto.TypeAsync($"aspire init --language typescript --non-interactive{channelArgument}");
        await auto.EnterAsync();
        await auto.WaitUntilTextAsync("Created apphost.mts", timeout: TimeSpan.FromMinutes(2));
        await auto.DeclineAgentInitPromptAsync(counter);
 
        TypeScriptAppHostToolchainTestHelpers.SetPackageManager(workspace.WorkspaceRoot.FullName, "deno", cleanInstallState: true);
        if (localChannel is not null)
        {
            CliE2ETestHelpers.WriteLocalChannelSettings(workspace.WorkspaceRoot.FullName, localChannel.SdkVersion);
        }
 
        var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.config.json");
        var config = JsonNode.Parse(File.ReadAllText(configPath))!.AsObject();
        config["features"] = new JsonObject
        {
            ["defaultWatchEnabled"] = true
        };
        File.WriteAllText(configPath, config.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
 
        var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.mts");
        var restartCountPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".deno-watch-restart-count");
        var appHostContent = File.ReadAllText(appHostPath);
        File.WriteAllText(appHostPath, $$"""
            const restartCountUrl = new URL("./.deno-watch-restart-count", import.meta.url);
            let restartCount = 0;
            try {
                restartCount = Number(await Deno.readTextFile(restartCountUrl));
            } catch (error) {
                if (!(error instanceof Deno.errors.NotFound)) {
                    throw error;
                }
            }
            await Deno.writeTextFile(restartCountUrl, String(restartCount + 1));
 
            {{appHostContent}}
            """);
 
        await auto.TypeAsync("deno install");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
 
        await auto.TypeAsync("aspire run");
        await auto.EnterAsync();
        await auto.WaitUntilTextAsync("Press CTRL+C to stop the AppHost and exit.", timeout: TimeSpan.FromMinutes(3));
        Assert.Equal("1", File.ReadAllText(restartCountPath));
 
        File.AppendAllText(appHostPath, $"{Environment.NewLine}// Trigger Deno watch restart.{Environment.NewLine}");
        await WaitForFileContentAsync(restartCountPath, "2", TimeSpan.FromMinutes(2));
 
        await auto.Ctrl().KeyAsync(Hex1bKey.C);
        await auto.WaitForAnyPromptAsync(counter);
    }
 
    [Fact]
    public async Task InitTypeScriptAppHost_AugmentsExistingViteRepoInWorkspaceSubdirectory()
    {
        var repoRoot = CliE2ETestHelpers.GetRepoRoot();
        var strategy = CliInstallStrategy.Detect(output.WriteLine);
        var workspace = TemporaryWorkspace.Create(output);
        var localChannel = CliE2ETestHelpers.PrepareLocalChannel(repoRoot, strategy,
            ["Aspire.Hosting.CodeGeneration.TypeScript.", "Aspire.Hosting.JavaScript."]);
 
        // LocalHive strategy only: PrepareLocalChannel returned a real channel,
        // so pass --channel local explicitly to aspire init. Other strategies
        // (script-installed CLI, pre-existing CLI) return null and rely on the
        // CLI's baked channel + ambient NuGet feeds.
        var channelArgument = localChannel is not null ? " --channel local" : string.Empty;
 
        using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, variant: CliE2ETestHelpers.DockerfileVariant.DotNet, mountDockerSocket: true, workspace: workspace);
        string? originalDevScript = null;
        string? originalBuildScript = null;
        string? originalPreviewScript = null;
        string? originalPackageType = null;
        string? originalTsConfig = null;
 
        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);
        await auto.EnablePolyglotSupportAsync(counter);
 
        File.WriteAllText(
            Path.Combine(workspace.WorkspaceRoot.FullName, "package.json"),
            """
            {
              "name": "workspace-root",
              "private": true,
              "workspaces": [
                "packages/*"
              ]
            }
            """);
 
        // Create brownfield Vite project
        await auto.TypeAsync("mkdir -p packages/brownfield && cd packages/brownfield");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter);
 
        await auto.TypeAsync(ViteTestHelpers.GetCreateCommand("."));
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
 
        // Capture original package.json scripts and tsconfig before aspire init
        var projectRoot = Path.Combine(workspace.WorkspaceRoot.FullName, "packages", "brownfield");
        var packageJson = JsonNode.Parse(File.ReadAllText(Path.Combine(projectRoot, "package.json")))!.AsObject();
        var scripts = packageJson["scripts"]!.AsObject();
        originalDevScript = scripts["dev"]?.GetValue<string>();
        originalBuildScript = scripts["build"]?.GetValue<string>();
        originalPreviewScript = scripts["preview"]?.GetValue<string>();
        originalPackageType = packageJson["type"]?.GetValue<string>();
        originalTsConfig = File.ReadAllText(Path.Combine(projectRoot, "tsconfig.json"));
        scripts["aspire:start"] = "custom-apphost-start";
        File.WriteAllText(Path.Combine(projectRoot, "package.json"), packageJson.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
 
        // 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(projectRoot, localChannel.SdkVersion);
        }
 
        // Run aspire init in brownfield mode
        await auto.TypeAsync($"aspire init --language typescript --non-interactive{channelArgument}");
        await auto.EnterAsync();
        await auto.WaitUntilTextAsync("Created aspire-apphost/apphost.mts", timeout: TimeSpan.FromMinutes(2));
        await auto.DeclineAgentInitPromptAsync(counter);
 
        // Verify brownfield augmentation preserved existing config
        Assert.NotNull(originalDevScript);
        Assert.NotNull(originalBuildScript);
        Assert.NotNull(originalPreviewScript);
        Assert.NotNull(originalTsConfig);
 
        packageJson = JsonNode.Parse(File.ReadAllText(Path.Combine(projectRoot, "package.json")))!.AsObject();
        scripts = packageJson["scripts"]!.AsObject();
 
        Assert.Equal(originalDevScript, scripts["dev"]?.GetValue<string>());
        Assert.Equal(originalBuildScript, scripts["build"]?.GetValue<string>());
        Assert.Equal(originalPreviewScript, scripts["preview"]?.GetValue<string>());
        Assert.Equal("custom-apphost-start", scripts["aspire:start"]?.GetValue<string>());
        Assert.Equal("npm --prefix aspire-apphost run aspire:build", scripts["aspire:build"]?.GetValue<string>());
        Assert.Equal("npm --prefix aspire-apphost run aspire:dev", scripts["aspire:dev"]?.GetValue<string>());
        Assert.Equal(originalPackageType, packageJson["type"]?.GetValue<string>());
        Assert.False(scripts.ContainsKey("start"));
        var rootDependencies = packageJson["dependencies"]?.AsObject();
        var rootDevDependencies = packageJson["devDependencies"]?.AsObject();
        Assert.Null(rootDependencies?["vscode-jsonrpc"]);
        Assert.Null(rootDevDependencies?["vscode-jsonrpc"]);
        Assert.Null(rootDevDependencies?["nodemon"]);
        Assert.Null(rootDevDependencies?["tsx"]);
        Assert.Equal(originalTsConfig, File.ReadAllText(Path.Combine(projectRoot, "tsconfig.json")));
        Assert.False(File.Exists(Path.Combine(projectRoot, "tsconfig.apphost.json")));
        Assert.False(File.Exists(Path.Combine(projectRoot, "apphost.mts")));
 
        var appHostDirectory = Path.Combine(projectRoot, "aspire-apphost");
        var appHostPath = Path.Combine(appHostDirectory, "apphost.mts");
        Assert.True(File.Exists(appHostPath));
 
        var appHostPackageJson = JsonNode.Parse(File.ReadAllText(Path.Combine(appHostDirectory, "package.json")))!.AsObject();
        var appHostScripts = appHostPackageJson["scripts"]!.AsObject();
        var appHostDependencies = appHostPackageJson["dependencies"]!.AsObject();
        var appHostDevDependencies = appHostPackageJson["devDependencies"]!.AsObject();
        Assert.Equal("module", appHostPackageJson["type"]?.GetValue<string>());
        Assert.Equal("aspire-apphost", appHostPackageJson["name"]?.GetValue<string>());
        Assert.Equal("aspire run", appHostScripts["aspire:start"]?.GetValue<string>());
        Assert.NotNull(appHostDependencies["vscode-jsonrpc"]);
        Assert.NotNull(appHostDevDependencies["@types/node"]);
        Assert.NotNull(appHostDevDependencies["nodemon"]);
        Assert.NotNull(appHostDevDependencies["tsx"]);
        Assert.NotNull(appHostDevDependencies["typescript"]);
        Assert.True(File.Exists(Path.Combine(appHostDirectory, "tsconfig.apphost.json")));
 
        // Verify Aspire.Hosting.JavaScript was pre-added in config
        var configPath = Path.Combine(projectRoot, "aspire.config.json");
        var config = JsonNode.Parse(File.ReadAllText(configPath))!.AsObject();
        var appHost = config["appHost"]!.AsObject();
        Assert.Equal("aspire-apphost/apphost.mts", appHost["path"]?.GetValue<string>());
        var packagesNode = config["packages"];
        Assert.NotNull(packagesNode);
        var packages = packagesNode!.AsObject();
        Assert.NotNull(packages["Aspire.Hosting.JavaScript"]);
 
        // Modify apphost.mts to add the Vite app before running
        var newContent = """
            // Aspire TypeScript AppHost
            // For more information, see: https://aspire.dev
 
            import { createBuilder } from './.aspire/modules/aspire.mjs';
 
            const builder = await createBuilder();
 
            await builder.addViteApp("brownfield", "..");
 
            await builder.build().run();
            """;
 
        File.WriteAllText(appHostPath, newContent);
 
        // Run the apphost to verify it works
        await auto.TypeAsync("aspire run");
        await auto.EnterAsync();
        await auto.WaitUntilAsync(s =>
        {
            if (s.ContainsText("Select an AppHost to use:"))
            {
                throw new InvalidOperationException(
                    "Unexpected apphost selection prompt detected! " +
                    "This indicates multiple apphosts were incorrectly detected.");
            }
 
            return s.ContainsText("Press CTRL+C to stop the AppHost and exit.");
        }, timeout: TimeSpan.FromMinutes(3), description: "Press CTRL+C message (aspire run started)");
 
        await auto.Ctrl().KeyAsync(Hex1bKey.C);
        await auto.WaitForSuccessPromptAsync(counter);
    }
 
    private static async Task WaitForFileContentAsync(string path, string expectedContent, TimeSpan timeout)
    {
        var stopwatch = Stopwatch.StartNew();
        while (stopwatch.Elapsed < timeout)
        {
            if (File.Exists(path) &&
                string.Equals(File.ReadAllText(path), expectedContent, StringComparison.Ordinal))
            {
                return;
            }
 
            await Task.Delay(TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
        }
 
        var actualContent = File.Exists(path) ? File.ReadAllText(path) : "<missing>";
        throw new TimeoutException($"Timed out waiting for '{path}' to contain '{expectedContent}'. Actual content: '{actualContent}'.");
    }
}