// 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());
}
}