File: Migrations\TypeScriptAppHostMigrationTests.cs
Web Access
Project: src\tests\Aspire.Cli.Tests\Aspire.Cli.Tests.csproj (Aspire.Cli.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.Nodes;
using Aspire.Cli.Migrations;
using Aspire.Cli.Projects;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Microsoft.Extensions.Logging.Abstractions;
 
namespace Aspire.Cli.Tests.Migrations;
 
public class TypeScriptAppHostMigrationTests(ITestOutputHelper outputHelper)
{
    private static readonly LanguageInfo s_typeScriptLanguage = new(
        LanguageId: new LanguageId(KnownLanguageId.TypeScript),
        DisplayName: "TypeScript (Node.js)",
        PackageName: "Aspire.Hosting.CodeGeneration.TypeScript",
        DetectionPatterns: ["apphost.mts", "apphost.ts"],
        CodeGenerator: "TypeScript",
        AppHostFileName: "apphost.mts");
 
    private static TypeScriptAppHostMigration CreateMigration(TemporaryWorkspace workspace)
    {
        return new TypeScriptAppHostMigration(
            new NoProjectFileProjectLocator(),
            new TestLanguageDiscovery(s_typeScriptLanguage),
            new TestAppHostProjectFactory(),
            new TestInteractionService(),
            workspace.CreateExecutionContext(),
            NullLogger<TypeScriptAppHostMigration>.Instance);
    }
 
    [Fact]
    public void Order_Is100()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        Assert.Equal(100, CreateMigration(workspace).Order);
    }
 
    [Fact]
    public void Id_IsTypeScriptAppHostMts()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        Assert.Equal("typescript-apphost-mts", CreateMigration(workspace).Id);
    }
 
    [Fact]
    public async Task DetectAsync_WithLegacyAppHost_ReturnsDescriptor()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts");
        await File.WriteAllTextAsync(appHostPath, "import { createBuilder } from './.modules/aspire.js';");
 
        var descriptor = await CreateMigration(workspace).DetectAsync(MigrationContext.CurrentDirectory, CancellationToken.None);
 
        Assert.NotNull(descriptor);
        Assert.Contains("apphost.ts", descriptor.Detail);
        Assert.NotNull(descriptor.Metadata);
        Assert.Equal(KnownLanguageId.TypeScript, descriptor.Metadata["language"]!.GetValue<string>());
        Assert.Equal(appHostPath, descriptor.Metadata["appHostPath"]!.GetValue<string>());
    }
 
    [Fact]
    public async Task DetectAsync_WithModernAppHost_ReturnsNull()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        await File.WriteAllTextAsync(
            Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.mts"),
            "import { createBuilder } from './.aspire/modules/aspire.mjs';");
 
        var descriptor = await CreateMigration(workspace).DetectAsync(MigrationContext.CurrentDirectory, CancellationToken.None);
 
        Assert.Null(descriptor);
    }
 
    [Fact]
    public async Task DetectAsync_WithBothAppHosts_ReturnsNull()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        await File.WriteAllTextAsync(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts"), "// legacy");
        await File.WriteAllTextAsync(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.mts"), "// modern");
 
        var descriptor = await CreateMigration(workspace).DetectAsync(MigrationContext.CurrentDirectory, CancellationToken.None);
 
        Assert.Null(descriptor);
    }
 
    [Fact]
    public async Task DetectAsync_WithNonTypeScriptAppHost_ReturnsNull()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        await File.WriteAllTextAsync(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs"), "// csharp");
 
        var descriptor = await CreateMigration(workspace).DetectAsync(MigrationContext.CurrentDirectory, CancellationToken.None);
 
        Assert.Null(descriptor);
    }
 
    [Fact]
    public async Task DetectAsync_WithSelectedLegacyAppHostOutsideWorkingDirectory_ReturnsDescriptor()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "external-apphost"));
        await WriteLegacyLayoutAsync(appHostDirectory);
        var appHostPath = Path.Combine(appHostDirectory.FullName, "apphost.ts");
 
        var descriptor = await CreateMigration(workspace).DetectAsync(new MigrationContext(new FileInfo(appHostPath)), CancellationToken.None);
 
        Assert.NotNull(descriptor);
        Assert.Equal(appHostPath, descriptor.Metadata!["appHostPath"]!.GetValue<string>());
    }
 
    private const string LegacyAppHostContent =
        """
        import { createBuilder } from './.modules/aspire.js';
 
        const builder = createBuilder();
        await builder.build();
        """;
 
    private const string ExpectedModernAppHostContent =
        """
        import { createBuilder } from './.aspire/modules/aspire.mjs';
 
        const builder = createBuilder();
        await builder.build();
        """;
 
    private const string LegacyEslintConfigContent =
        """
        export default [
          {
            files: ['apphost.ts']
          }
        ];
        """;
 
    private const string ExpectedModernEslintConfigContent =
        """
        export default [
          {
            files: ['apphost.mts']
          }
        ];
        """;
 
    private static async Task WriteLegacyLayoutAsync(DirectoryInfo root, string? tsConfigContent = null)
    {
        await File.WriteAllTextAsync(Path.Combine(root.FullName, "apphost.ts"), LegacyAppHostContent);
        await File.WriteAllTextAsync(
            Path.Combine(root.FullName, "aspire.config.json"),
            """
            {
              "appHost": {
                "path": "apphost.ts"
              }
            }
            """);
        await File.WriteAllTextAsync(
            Path.Combine(root.FullName, "tsconfig.apphost.json"),
            tsConfigContent ??
            """
            {
              "include": [ "apphost.ts", ".modules/aspire.ts", ".modules/base.ts", ".modules/transport.ts", "src/**/*.ts", "lib/foo.ts" ]
            }
            """);
        await File.WriteAllTextAsync(
            Path.Combine(root.FullName, "package.json"),
            """
            {
              "type": "module",
              "scripts": {
                "aspire:build": "tsc -p tsconfig.apphost.json",
                "aspire:lint": "eslint apphost.ts"
              }
            }
            """);
        await File.WriteAllTextAsync(Path.Combine(root.FullName, "eslint.config.mjs"), LegacyEslintConfigContent);
 
        var modulesDir = Directory.CreateDirectory(Path.Combine(root.FullName, ".modules"));
        await File.WriteAllTextAsync(Path.Combine(modulesDir.FullName, "aspire.ts"), "// generated");
    }
 
    private static async Task AssertMigratedMetadataAsync(DirectoryInfo root, string[] expectedIncludes)
    {
        var config = JsonNode.Parse(await File.ReadAllTextAsync(Path.Combine(root.FullName, "aspire.config.json")))!;
        Assert.Equal("apphost.mts", config["appHost"]!["path"]!.GetValue<string>());
 
        var tsconfig = JsonNode.Parse(await File.ReadAllTextAsync(Path.Combine(root.FullName, "tsconfig.apphost.json")))!;
        var includes = tsconfig["include"]!.AsArray().Select(n => n!.GetValue<string>()).ToArray();
        Assert.Equal(expectedIncludes, includes);
 
        var packageJson = JsonNode.Parse(await File.ReadAllTextAsync(Path.Combine(root.FullName, "package.json")))!;
        var scripts = packageJson["scripts"]!;
        Assert.Equal("tsc -p tsconfig.apphost.json", scripts["aspire:build"]!.GetValue<string>());
        Assert.Equal("eslint apphost.mts", scripts["aspire:lint"]!.GetValue<string>());
 
        var eslintConfig = await File.ReadAllTextAsync(Path.Combine(root.FullName, "eslint.config.mjs"));
        Assert.Equal(ExpectedModernEslintConfigContent, eslintConfig);
    }
 
    [Fact]
    public async Task ApplyAsync_WithLegacyAppHost_MigratesToMts()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var root = workspace.WorkspaceRoot;
        await WriteLegacyLayoutAsync(root);
 
        await CreateMigration(workspace).ApplyAsync(MigrationContext.CurrentDirectory, CancellationToken.None);
 
        Assert.False(File.Exists(Path.Combine(root.FullName, "apphost.ts")));
        Assert.False(Directory.Exists(Path.Combine(root.FullName, ".modules")));
 
        var modernContent = await File.ReadAllTextAsync(Path.Combine(root.FullName, "apphost.mts"));
        Assert.Equal(ExpectedModernAppHostContent, modernContent);
 
        await AssertMigratedMetadataAsync(
            root,
            new[] { "apphost.mts", ".aspire/modules/aspire.mts", ".aspire/modules/base.mts", ".aspire/modules/transport.mts", "src/**/*.ts", "lib/foo.ts" });
    }
 
    [Fact]
    public async Task ApplyAsync_WithSelectedLegacyAppHostOutsideWorkingDirectory_MigratesSelectedAppHost()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "external-apphost"));
        await WriteLegacyLayoutAsync(appHostDirectory);
 
        await CreateMigration(workspace).ApplyAsync(new MigrationContext(new FileInfo(Path.Combine(appHostDirectory.FullName, "apphost.ts"))), CancellationToken.None);
 
        Assert.False(File.Exists(Path.Combine(appHostDirectory.FullName, "apphost.ts")));
        Assert.False(Directory.Exists(Path.Combine(appHostDirectory.FullName, ".modules")));
        Assert.Equal(ExpectedModernAppHostContent, await File.ReadAllTextAsync(Path.Combine(appHostDirectory.FullName, "apphost.mts")));
 
        await AssertMigratedMetadataAsync(
            appHostDirectory,
            new[] { "apphost.mts", ".aspire/modules/aspire.mts", ".aspire/modules/base.mts", ".aspire/modules/transport.mts", "src/**/*.ts", "lib/foo.ts" });
    }
 
    [Fact]
    public async Task ApplyAsync_WithJsoncTsConfig_RewritesIncludes()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var root = workspace.WorkspaceRoot;
        await WriteLegacyLayoutAsync(
            root,
            """
            {
              "include": [
                "apphost.ts",
                ".modules/aspire.ts",
                ".modules/aspire.d.ts",
                "src/**/*.ts", // user code remains TypeScript
              ],
            }
            """);
 
        await CreateMigration(workspace).ApplyAsync(MigrationContext.CurrentDirectory, CancellationToken.None);
 
        await AssertMigratedMetadataAsync(
            root,
            new[] { "apphost.mts", ".aspire/modules/aspire.mts", ".aspire/modules/aspire.d.ts", "src/**/*.ts" });
    }
 
    [Fact]
    public async Task ApplyAsync_RunTwice_SecondRunIsNoOp()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var root = workspace.WorkspaceRoot;
        await WriteLegacyLayoutAsync(root);
 
        var migration = CreateMigration(workspace);
        await migration.ApplyAsync(MigrationContext.CurrentDirectory, CancellationToken.None);
 
        var migratedContent = await File.ReadAllTextAsync(Path.Combine(root.FullName, "apphost.mts"));
 
        await migration.ApplyAsync(MigrationContext.CurrentDirectory, CancellationToken.None);
 
        Assert.Equal(migratedContent, await File.ReadAllTextAsync(Path.Combine(root.FullName, "apphost.mts")));
    }
}