File: Projects\TypeScriptAppHostToolchainResolverTests.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 Aspire.Cli.Projects;
using Aspire.Cli.Tests.Utils;
using Aspire.TypeSystem;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Testing;
 
namespace Aspire.Cli.Tests.Projects;
 
public sealed class TypeScriptAppHostToolchainResolverTests(ITestOutputHelper outputHelper)
{
    [Fact]
    public void Resolve_WhenPackageManagerIsBun_ReturnsBun()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "package.json"), "{ \"packageManager\": \"bun@1.2.0\" }");
 
        var toolchain = TypeScriptAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot, new TestEnvironment(), logger: null);
 
        Assert.Equal(TypeScriptAppHostToolchain.Bun, toolchain);
    }
 
    [Fact]
    public void Resolve_WhenPnpmLockExists_ReturnsPnpm()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "package.json"), "{ \"name\": \"apphost\" }");
        File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "pnpm-lock.yaml"), "lockfileVersion: '9.0'");
 
        var toolchain = TypeScriptAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot, new TestEnvironment(), logger: null);
 
        Assert.Equal(TypeScriptAppHostToolchain.Pnpm, toolchain);
    }
 
    [Fact]
    public void Resolve_WhenPackageManagerIsYarnClassic_Throws()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var packageJsonPath = Path.Combine(workspace.WorkspaceRoot.FullName, "package.json");
        File.WriteAllText(packageJsonPath, "{ \"packageManager\": \"yarn@1.22.22\" }");
 
        var exception = Assert.Throws<YarnClassicNotSupportedException>(() => TypeScriptAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot, new TestEnvironment(), logger: null));
 
        Assert.Equal($"Yarn Classic is not supported for TypeScript AppHosts. Upgrade 'yarn@1.22.22' in {packageJsonPath} to Yarn 4 or later, or use npm, pnpm, or Bun.", exception.Message);
    }
 
    [Fact]
    public void Resolve_WhenPackageManagerIsModernYarn_ReturnsYarn()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "package.json"), "{ \"packageManager\": \"yarn@4.14.1\" }");
 
        var toolchain = TypeScriptAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot, new TestEnvironment(), logger: null);
 
        Assert.Equal(TypeScriptAppHostToolchain.Yarn, toolchain);
    }
 
    [Fact]
    public void Resolve_WhenYarnLockIsClassic_Throws()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "package.json"), "{ \"name\": \"apphost\" }");
        var yarnLockPath = Path.Combine(workspace.WorkspaceRoot.FullName, "yarn.lock");
        File.WriteAllText(yarnLockPath, "# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.\n# yarn lockfile v1\n");
 
        var exception = Assert.Throws<YarnClassicNotSupportedException>(() => TypeScriptAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot, new TestEnvironment(), logger: null));
 
        Assert.Equal($"Yarn Classic is not supported for TypeScript AppHosts. Upgrade the Yarn lockfile at {yarnLockPath} to Yarn 4 or later, or use npm, pnpm, or Bun.", exception.Message);
    }
 
    [Fact]
    public void Resolve_WhenParentYarnLockIsClassic_Throws()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("apps").CreateSubdirectory("apphost");
        var parentDirectory = appHostDirectory.Parent!;
        File.WriteAllText(Path.Combine(appHostDirectory.FullName, "package.json"), "{ \"name\": \"apphost\" }");
        File.WriteAllText(Path.Combine(parentDirectory.FullName, "package.json"), "{ \"name\": \"workspace\" }");
        var yarnLockPath = Path.Combine(parentDirectory.FullName, "yarn.lock");
        File.WriteAllText(yarnLockPath, "# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.\n# yarn lockfile v1\n");
 
        var exception = Assert.Throws<YarnClassicNotSupportedException>(() => TypeScriptAppHostToolchainResolver.Resolve(appHostDirectory, new TestEnvironment(), logger: null));
 
        Assert.Equal($"Yarn Classic is not supported for TypeScript AppHosts. Upgrade the Yarn lockfile at {yarnLockPath} to Yarn 4 or later, or use npm, pnpm, or Bun.", exception.Message);
    }
 
    [Fact]
    public void Resolve_WhenPackageLockExists_ReturnsNpm()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("apps").CreateSubdirectory("apphost");
        var parentDirectory = appHostDirectory.Parent!;
        File.WriteAllText(Path.Combine(parentDirectory.FullName, "package.json"), "{ \"name\": \"workspace\" }");
        File.WriteAllText(Path.Combine(parentDirectory.FullName, "yarn.lock"), string.Empty);
        File.WriteAllText(Path.Combine(appHostDirectory.FullName, "package.json"), "{ \"name\": \"apphost\" }");
        File.WriteAllText(Path.Combine(appHostDirectory.FullName, "package-lock.json"), "{}");
 
        var resolution = TypeScriptAppHostToolchainResolver.ResolveWithReason(appHostDirectory, new TestEnvironment());
 
        Assert.Equal(TypeScriptAppHostToolchain.Npm, resolution.Toolchain);
        Assert.Equal($"package-lock.json found in {appHostDirectory.FullName}", resolution.Reason);
    }
 
    [Fact]
    public void Resolve_WhenPackageLockAndYarnLockExistInSameDirectory_ReturnsYarn()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "package.json"), "{ \"name\": \"apphost\" }");
        File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "package-lock.json"), "{}");
        File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "yarn.lock"), string.Empty);
 
        var resolution = TypeScriptAppHostToolchainResolver.ResolveWithReason(workspace.WorkspaceRoot, new TestEnvironment());
 
        Assert.Equal(TypeScriptAppHostToolchain.Yarn, resolution.Toolchain);
        Assert.Equal($"yarn.lock found in {workspace.WorkspaceRoot.FullName}", resolution.Reason);
    }
 
    [Fact]
    public void Resolve_WhenYarnDirectoryExists_ReturnsNpm()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "package.json"), "{ \"name\": \"apphost\" }");
        Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, ".yarn"));
 
        var toolchain = TypeScriptAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot, new TestEnvironment(), logger: null);
 
        Assert.Equal(TypeScriptAppHostToolchain.Npm, toolchain);
    }
 
    [Fact]
    public void Resolve_WhenNothingConfigured_ReturnsNpm()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "package.json"), "{ \"name\": \"apphost\" }");
 
        var toolchain = TypeScriptAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot, new TestEnvironment(), logger: null);
 
        Assert.Equal(TypeScriptAppHostToolchain.Npm, toolchain);
    }
 
    [Fact]
    public void Resolve_WhenMarkerExists_LogsReason()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "package.json"), "{ \"name\": \"apphost\" }");
        File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "yarn.lock"), string.Empty);
 
        var sink = new TestSink();
        var logger = new TestLogger(nameof(TypeScriptAppHostToolchainResolverTests), sink, logLevel => logLevel == LogLevel.Debug);
 
        var toolchain = TypeScriptAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot, new TestEnvironment(), logger);
 
        Assert.Equal(TypeScriptAppHostToolchain.Yarn, toolchain);
        var write = Assert.Single(sink.Writes);
        Assert.Equal(LogLevel.Debug, write.LogLevel);
        Assert.Equal($"Selected TypeScript AppHost package manager 'yarn' because yarn.lock found in {workspace.WorkspaceRoot.FullName}.", write.Message);
    }
 
    [Fact]
    public void Resolve_WhenParentDirectoryDefinesToolchain_ReturnsParentToolchain()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var appHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("apps").CreateSubdirectory("apphost");
        File.WriteAllText(Path.Combine(appHostDirectory.Parent!.FullName, "package.json"), "{ \"packageManager\": \"pnpm@10.12.1\" }");
        File.WriteAllText(Path.Combine(appHostDirectory.FullName, "package.json"), "{ \"name\": \"apphost\" }");
 
        var toolchain = TypeScriptAppHostToolchainResolver.Resolve(appHostDirectory, new TestEnvironment(), logger: null);
 
        Assert.Equal(TypeScriptAppHostToolchain.Pnpm, toolchain);
    }
 
    [Fact]
    public void Resolve_WhenAppHostAndParentDefineDifferentToolchains_ReturnsAppHostToolchain()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var appHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("apps").CreateSubdirectory("apphost");
        File.WriteAllText(Path.Combine(appHostDirectory.Parent!.FullName, "package.json"), "{ \"packageManager\": \"pnpm@10.12.1\" }");
        File.WriteAllText(Path.Combine(appHostDirectory.FullName, "package.json"), "{ \"packageManager\": \"bun@1.2.0\" }");
 
        var resolution = TypeScriptAppHostToolchainResolver.ResolveWithReason(appHostDirectory, new TestEnvironment());
 
        Assert.Equal(TypeScriptAppHostToolchain.Bun, resolution.Toolchain);
        Assert.Equal($"packageManager 'bun@1.2.0' found in {Path.Combine(appHostDirectory.FullName, "package.json")}", resolution.Reason);
    }
 
    [Fact]
    public void Resolve_WhenAppHostLockFileAndParentPackageManagerDiffer_ReturnsAppHostLockFileToolchain()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var appHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("apps").CreateSubdirectory("apphost");
        File.WriteAllText(Path.Combine(appHostDirectory.Parent!.FullName, "package.json"), "{ \"packageManager\": \"yarn@4.14.1\" }");
        File.WriteAllText(Path.Combine(appHostDirectory.FullName, "package.json"), "{ \"name\": \"apphost\" }");
        File.WriteAllText(Path.Combine(appHostDirectory.FullName, "pnpm-lock.yaml"), "lockfileVersion: '9.0'");
 
        var resolution = TypeScriptAppHostToolchainResolver.ResolveWithReason(appHostDirectory, new TestEnvironment());
 
        Assert.Equal(TypeScriptAppHostToolchain.Pnpm, resolution.Toolchain);
        Assert.Equal($"pnpm-lock.yaml found in {appHostDirectory.FullName}", resolution.Reason);
    }
 
    [Fact]
    public void Resolve_WhenGrandparentDirectoryDefinesToolchain_ReturnsNpm()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "package.json"), "{ \"packageManager\": \"bun@1.2.0\" }");
 
        var appHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("apps").CreateSubdirectory("apphost");
 
        var toolchain = TypeScriptAppHostToolchainResolver.Resolve(appHostDirectory, new TestEnvironment(), logger: null);
 
        Assert.Equal(TypeScriptAppHostToolchain.Npm, toolchain);
    }
 
    [Fact]
    public void ShouldSearchParentDirectory_WhenDirectoryIsRoot_ReturnsFalse()
    {
        var directory = new DirectoryInfo(Path.GetPathRoot(Path.GetTempPath())!);
 
        var shouldSearch = TypeScriptAppHostToolchainResolver.ShouldSearchParentDirectory(directory, new TestEnvironment());
 
        Assert.False(shouldSearch);
    }
 
    [Fact]
    public void ShouldSearchParentDirectory_WhenDirectoryIsHome_ReturnsFalse()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var shouldSearch = TypeScriptAppHostToolchainResolver.ShouldSearchParentDirectory(
            workspace.WorkspaceRoot,
            new TestEnvironment(),
            homeDirectory: workspace.WorkspaceRoot.FullName);
 
        Assert.False(shouldSearch);
    }
 
    [Fact]
    public void ShouldSearchParentDirectory_WhenDirectoryIsHomeWithDifferentCasingOnWindows_ReturnsFalse()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var shouldSearch = TypeScriptAppHostToolchainResolver.ShouldSearchParentDirectory(
            workspace.WorkspaceRoot,
            TestEnvironment.CreateWindows(),
            homeDirectory: InvertCasing(workspace.WorkspaceRoot.FullName));
 
        Assert.False(shouldSearch);
    }
 
    [Fact]
    public void ShouldSearchParentDirectory_WhenDirectoryIsHomeWithDifferentCasingOnMacOS_ReturnsFalse()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var shouldSearch = TypeScriptAppHostToolchainResolver.ShouldSearchParentDirectory(
            workspace.WorkspaceRoot,
            TestEnvironment.CreateMacOS(),
            homeDirectory: InvertCasing(workspace.WorkspaceRoot.FullName));
 
        Assert.False(shouldSearch);
    }
 
    [Fact]
    public void ShouldSearchParentDirectory_WhenDirectoryIsHomeWithDifferentCasingOnLinux_ReturnsTrue()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var shouldSearch = TypeScriptAppHostToolchainResolver.ShouldSearchParentDirectory(
            workspace.WorkspaceRoot,
            TestEnvironment.CreateLinux(),
            homeDirectory: InvertCasing(workspace.WorkspaceRoot.FullName));
 
        Assert.True(shouldSearch);
    }
 
    [Fact]
    public void ApplyToRuntimeSpec_WhenBunSelected_UsesBunCommandsAndPreservesExtensionLaunch()
    {
        var baseRuntimeSpec = CreateBaseRuntimeSpec();
 
        var runtimeSpec = TypeScriptAppHostToolchainResolver.ApplyToRuntimeSpec(baseRuntimeSpec, TypeScriptAppHostToolchain.Bun);
 
        Assert.Equal("TypeScript (Bun)", runtimeSpec.DisplayName);
        Assert.NotNull(runtimeSpec.InstallDependencies);
        Assert.Equal("bun", runtimeSpec.InstallDependencies?.Command);
        Assert.Equal(["install"], runtimeSpec.InstallDependencies!.Args);
        var preExecute = Assert.Single(runtimeSpec.PreExecute!);
        Assert.Equal("bun", preExecute.Command);
        Assert.Equal(["run", "tsc", "--noEmit", "-p", "tsconfig.apphost.json"], preExecute.Args);
        Assert.Equal("bun", runtimeSpec.Execute.Command);
        Assert.Equal(["run", "{appHostFile}"], runtimeSpec.Execute.Args);
        Assert.NotNull(runtimeSpec.WatchExecute);
        Assert.Equal("bun", runtimeSpec.WatchExecute?.Command);
        Assert.Equal(
            [
                "run",
                "nodemon",
                "--signal", "SIGTERM",
                "--watch", ".",
                "--ext", "ts,mts",
                "--ignore", "node_modules/",
                "--ignore", ".aspire/modules/",
                "--exec", "bun run tsc --noEmit -p tsconfig.apphost.json && bun run \"{appHostFile}\""
            ],
            runtimeSpec.WatchExecute!.Args);
        Assert.Equal("node", runtimeSpec.ExtensionLaunchCapability);
        Assert.Equal("NODE_EXTRA_CA_CERTS", runtimeSpec.CertificateBundleEnvironmentVariable);
    }
 
    [Fact]
    public void ApplyToRuntimeSpec_WhenYarnSelected_UsesYarnExecCommands()
    {
        var baseRuntimeSpec = CreateBaseRuntimeSpec();
 
        var runtimeSpec = TypeScriptAppHostToolchainResolver.ApplyToRuntimeSpec(baseRuntimeSpec, TypeScriptAppHostToolchain.Yarn);
 
        var preExecute = Assert.Single(runtimeSpec.PreExecute!);
        Assert.Equal("yarn", preExecute.Command);
        Assert.Equal(["run", "tsc", "--noEmit", "-p", "tsconfig.apphost.json"], preExecute.Args);
        Assert.Equal("yarn", runtimeSpec.Execute.Command);
        Assert.Equal(["run", "tsx", "--tsconfig", "tsconfig.apphost.json", "{appHostFile}"], runtimeSpec.Execute.Args);
        Assert.Equal("yarn", runtimeSpec.WatchExecute?.Command);
        Assert.Contains("yarn run tsc --noEmit -p tsconfig.apphost.json && yarn run tsx --tsconfig tsconfig.apphost.json \"{appHostFile}\"", runtimeSpec.WatchExecute?.Args ?? []);
    }
 
    [Fact]
    public void ApplyToRuntimeSpec_WhenPnpmSelected_UsesPnpmTypeCheckCommands()
    {
        var baseRuntimeSpec = CreateBaseRuntimeSpec();
 
        var runtimeSpec = TypeScriptAppHostToolchainResolver.ApplyToRuntimeSpec(baseRuntimeSpec, TypeScriptAppHostToolchain.Pnpm);
 
        var installDependencies = Assert.IsType<CommandSpec>(runtimeSpec.InstallDependencies);
        Assert.Equal("pnpm", installDependencies.Command);
        Assert.Equal(["install", "--ignore-workspace"], installDependencies.Args);
        var preExecute = Assert.Single(runtimeSpec.PreExecute!);
        Assert.Equal("pnpm", preExecute.Command);
        Assert.Equal(["exec", "tsc", "--noEmit", "-p", "tsconfig.apphost.json"], preExecute.Args);
        Assert.Equal("pnpm", runtimeSpec.Execute.Command);
        Assert.Equal(["exec", "tsx", "--tsconfig", "tsconfig.apphost.json", "{appHostFile}"], runtimeSpec.Execute.Args);
        Assert.Equal("pnpm", runtimeSpec.WatchExecute?.Command);
        Assert.Contains("pnpm exec tsc --noEmit -p tsconfig.apphost.json && pnpm exec tsx --tsconfig tsconfig.apphost.json \"{appHostFile}\"", runtimeSpec.WatchExecute?.Args ?? []);
    }
 
    private static RuntimeSpec CreateBaseRuntimeSpec()
    {
        return new RuntimeSpec
        {
            Language = KnownLanguageId.TypeScript,
            DisplayName = "TypeScript (Node.js)",
            CodeGenLanguage = "TypeScript",
            DetectionPatterns = ["apphost.mts"],
            InstallDependencies = new CommandSpec
            {
                Command = "npm",
                Args = ["install"]
            },
            PreExecute =
            [
                new CommandSpec
                {
                    Command = "npx",
                    Args = ["--no-install", "tsc", "--noEmit", "-p", "tsconfig.apphost.json"]
                }
            ],
            Execute = new CommandSpec
            {
                Command = "npx",
                Args = ["--no-install", "tsx", "--tsconfig", "tsconfig.apphost.json", "{appHostFile}"]
            },
            WatchExecute = new CommandSpec
            {
                Command = "npx",
                Args = ["--no-install", "nodemon", "--exec", "npx --no-install tsx --tsconfig tsconfig.apphost.json {appHostFile}"]
            },
            ExtensionLaunchCapability = "node",
            CertificateBundleEnvironmentVariable = "NODE_EXTRA_CA_CERTS"
        };
    }
 
    private static string InvertCasing(string value)
    {
        return new string(value.Select(c => char.IsUpper(c) ? char.ToLowerInvariant(c) : char.ToUpperInvariant(c)).ToArray());
    }
}