diff --git a/src/Aspire.Cli/Projects/TypeScriptAppHostToolchainResolver.cs b/src/Aspire.Cli/Projects/TypeScriptAppHostToolchainResolver.cs index 9745273044f..aeb9451ea62 100644 --- a/src/Aspire.Cli/Projects/TypeScriptAppHostToolchainResolver.cs +++ b/src/Aspire.Cli/Projects/TypeScriptAppHostToolchainResolver.cs @@ -358,9 +358,9 @@ private static bool IsYarnClassicPackageManager(string packageManager) (version.Length == 1 || !char.IsAsciiDigit(version[1])); } - private static InvalidOperationException CreateYarnClassicNotSupportedException(string upgradeTarget) + private static YarnClassicNotSupportedException CreateYarnClassicNotSupportedException(string upgradeTarget) { - return new InvalidOperationException( + return new YarnClassicNotSupportedException( $"Yarn Classic is not supported for TypeScript AppHosts. Upgrade {upgradeTarget} to Yarn 4 or later, or use npm, pnpm, or Bun."); } diff --git a/src/Aspire.Cli/Projects/YarnClassicNotSupportedException.cs b/src/Aspire.Cli/Projects/YarnClassicNotSupportedException.cs new file mode 100644 index 00000000000..aa09932c5e7 --- /dev/null +++ b/src/Aspire.Cli/Projects/YarnClassicNotSupportedException.cs @@ -0,0 +1,8 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Cli.Projects; + +internal sealed class YarnClassicNotSupportedException(string message) : InvalidOperationException(message) +{ +} diff --git a/src/Aspire.Cli/Utils/EnvironmentChecker/TypeScriptAppHostToolingCheck.cs b/src/Aspire.Cli/Utils/EnvironmentChecker/TypeScriptAppHostToolingCheck.cs index b6ccdadbce8..c7dab31ee71 100644 --- a/src/Aspire.Cli/Utils/EnvironmentChecker/TypeScriptAppHostToolingCheck.cs +++ b/src/Aspire.Cli/Utils/EnvironmentChecker/TypeScriptAppHostToolingCheck.cs @@ -48,7 +48,33 @@ public async Task> CheckAsync(Cancellation return []; } - var toolchain = TypeScriptAppHostToolchainResolver.Resolve(appHostDirectory, _logger); + TypeScriptAppHostToolchain toolchain; + try + { + toolchain = TypeScriptAppHostToolchainResolver.Resolve(appHostDirectory, _logger); + } + catch (YarnClassicNotSupportedException ex) + { + return + [ + new EnvironmentCheckResult + { + Category = "environment", + Name = "typescript-apphost-yarn-classic", + Status = EnvironmentCheckStatus.Fail, + Message = "TypeScript AppHost does not support Yarn Classic.", + Details = ex.Message, + Fix = "Upgrade to Yarn 4 or later, or switch to npm, pnpm, or Bun, then rerun 'aspire doctor'.", + Link = "https://yarnpkg.com/getting-started/install", + Metadata = new JsonObject + { + ["language"] = KnownLanguageId.TypeScript, + ["appHostPath"] = appHostFile.FullName + } + } + ]; + } + var missingResults = new List(); foreach (var command in TypeScriptAppHostToolchainResolver.GetRequiredCommands(toolchain)) diff --git a/tests/Aspire.Cli.Tests/Commands/TypeScriptAppHostToolingCheckTests.cs b/tests/Aspire.Cli.Tests/Commands/TypeScriptAppHostToolingCheckTests.cs index ebb9e1b3780..ee243d62c37 100644 --- a/tests/Aspire.Cli.Tests/Commands/TypeScriptAppHostToolingCheckTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/TypeScriptAppHostToolingCheckTests.cs @@ -20,38 +20,99 @@ public sealed class TypeScriptAppHostToolingCheckTests(ITestOutputHelper outputH CodeGenerator: "TypeScript", AppHostFileName: "apphost.ts"); - [Fact] - public async Task CheckAsync_ReturnsPass_WhenConfiguredToolchainIsAvailable() + [Theory] + [InlineData("npm@10.5.0", nameof(TypeScriptAppHostToolchain.Npm))] + [InlineData("bun@1.2.0", nameof(TypeScriptAppHostToolchain.Bun))] + [InlineData("yarn@4.14.1", nameof(TypeScriptAppHostToolchain.Yarn))] + [InlineData("pnpm@10.12.1", nameof(TypeScriptAppHostToolchain.Pnpm))] + public async Task CheckAsync_ReturnsPass_WhenConfiguredToolchainIsAvailable(string packageManagerSpec, string toolchainName) { + var toolchain = Enum.Parse(toolchainName); + var requiredCommands = TypeScriptAppHostToolchainResolver.GetRequiredCommands(toolchain); + using var workspace = TemporaryWorkspace.Create(outputHelper); - var appHostFile = CreateTypeScriptAppHost(workspace, "{ \"packageManager\": \"bun@1.2.0\" }"); + var appHostFile = CreateTypeScriptAppHost(workspace, $"{{ \"packageManager\": \"{packageManagerSpec}\" }}"); var check = CreateCheck( workspace, appHostFile, - commandResolver: command => command.Equals("bun", StringComparison.OrdinalIgnoreCase) ? "/usr/bin/bun" : null); + commandResolver: command => requiredCommands.Contains(command, StringComparer.OrdinalIgnoreCase) ? $"/usr/bin/{command}" : null); var results = await check.CheckAsync().DefaultTimeout(); var result = Assert.Single(results); Assert.Equal(EnvironmentCheckStatus.Pass, result.Status); - Assert.Contains("bun", result.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal("typescript-apphost-tools", result.Name); + foreach (var command in requiredCommands) + { + Assert.Contains(command, result.Message, StringComparison.OrdinalIgnoreCase); + } } - [Fact] - public async Task CheckAsync_ReturnsFail_WhenConfiguredToolchainIsMissing() + [Theory] + [InlineData("npm@10.5.0", nameof(TypeScriptAppHostToolchain.Npm), "Node.js", "https://nodejs.org/en/download")] + [InlineData("bun@1.2.0", nameof(TypeScriptAppHostToolchain.Bun), "Bun", "https://bun.sh/docs/installation")] + [InlineData("yarn@4.14.1", nameof(TypeScriptAppHostToolchain.Yarn), "Yarn", "https://yarnpkg.com/getting-started/install")] + [InlineData("pnpm@10.12.1", nameof(TypeScriptAppHostToolchain.Pnpm), "pnpm", "https://pnpm.io/installation")] + public async Task CheckAsync_ReturnsFail_WhenConfiguredToolchainIsMissing( + string packageManagerSpec, + string toolchainName, + string installDisplayName, + string expectedInstallLink) { + var toolchain = Enum.Parse(toolchainName); + var requiredCommands = TypeScriptAppHostToolchainResolver.GetRequiredCommands(toolchain); + using var workspace = TemporaryWorkspace.Create(outputHelper); - var appHostFile = CreateTypeScriptAppHost(workspace, "{ \"packageManager\": \"bun@1.2.0\" }"); + var appHostFile = CreateTypeScriptAppHost(workspace, $"{{ \"packageManager\": \"{packageManagerSpec}\" }}"); var check = CreateCheck(workspace, appHostFile, commandResolver: _ => null); var results = await check.CheckAsync().DefaultTimeout(); + Assert.Equal(requiredCommands.Length, results.Count); + Assert.All(results, result => + { + Assert.Equal(EnvironmentCheckStatus.Fail, result.Status); + Assert.Equal("environment", result.Category); + Assert.Equal(expectedInstallLink, result.Link); + Assert.Equal( + $"Install {installDisplayName} tooling and rerun 'aspire doctor'.", + result.Fix); + Assert.NotNull(result.Details); + Assert.Contains(installDisplayName, result.Details!); + }); + + foreach (var command in requiredCommands) + { + var commandResult = Assert.Single(results, r => r.Name == $"typescript-apphost-{command}"); + Assert.Contains($"'{command}'", commandResult.Message, StringComparison.Ordinal); + Assert.Contains(command, commandResult.Details!, StringComparison.Ordinal); + } + } + + [Theory] + [InlineData("npm")] + [InlineData("npx")] + public async Task CheckAsync_ReturnsFailOnlyForMissingNpmCommand_WhenTheOtherIsPresent(string missingCommand) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var appHostFile = CreateTypeScriptAppHost(workspace, "{ \"packageManager\": \"npm@10.5.0\" }"); + + var check = CreateCheck( + workspace, + appHostFile, + commandResolver: command => command.Equals(missingCommand, StringComparison.OrdinalIgnoreCase) + ? null + : $"/usr/bin/{command}"); + + var results = await check.CheckAsync().DefaultTimeout(); + var result = Assert.Single(results); Assert.Equal(EnvironmentCheckStatus.Fail, result.Status); - Assert.Equal("https://bun.sh/docs/installation", result.Link); - Assert.Contains("'bun'", result.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal($"typescript-apphost-{missingCommand}", result.Name); + Assert.Equal("https://nodejs.org/en/download", result.Link); + Assert.Contains($"'{missingCommand}'", result.Message, StringComparison.Ordinal); } [Fact] @@ -65,6 +126,48 @@ public async Task CheckAsync_Skips_WhenNoTypeScriptAppHostExists() Assert.Empty(results); } + [Fact] + public async Task CheckAsync_ReturnsFail_WhenPackageManagerIsYarnClassic() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var appHostFile = CreateTypeScriptAppHost(workspace, "{ \"packageManager\": \"yarn@1.22.22\" }"); + + var check = CreateCheck(workspace, appHostFile, commandResolver: command => $"/usr/bin/{command}"); + + var results = await check.CheckAsync().DefaultTimeout(); + + var result = Assert.Single(results); + Assert.Equal(EnvironmentCheckStatus.Fail, result.Status); + Assert.Equal("typescript-apphost-yarn-classic", result.Name); + Assert.Equal("environment", result.Category); + Assert.Equal("https://yarnpkg.com/getting-started/install", result.Link); + Assert.Equal("TypeScript AppHost does not support Yarn Classic.", result.Message); + Assert.Contains("Yarn Classic is not supported", result.Details ?? string.Empty); + Assert.Contains("yarn@1.22.22", result.Details ?? string.Empty); + Assert.Contains("Yarn 4", result.Fix ?? string.Empty); + } + + [Fact] + public async Task CheckAsync_ReturnsFail_WhenYarnClassicLockFileIsPresent() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var appHostFile = CreateTypeScriptAppHost(workspace, "{ \"name\": \"apphost\" }"); + await File.WriteAllTextAsync( + Path.Combine(workspace.WorkspaceRoot.FullName, "yarn.lock"), + "# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.\n# yarn lockfile v1\n"); + + var check = CreateCheck(workspace, appHostFile, commandResolver: command => $"/usr/bin/{command}"); + + var results = await check.CheckAsync().DefaultTimeout(); + + var result = Assert.Single(results); + Assert.Equal(EnvironmentCheckStatus.Fail, result.Status); + Assert.Equal("typescript-apphost-yarn-classic", result.Name); + Assert.Equal("environment", result.Category); + Assert.Equal("https://yarnpkg.com/getting-started/install", result.Link); + Assert.Contains("yarn.lock", result.Details ?? string.Empty); + } + private static FileInfo CreateTypeScriptAppHost(TemporaryWorkspace workspace, string packageJsonContent) { var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts"); diff --git a/tests/Aspire.Cli.Tests/Projects/TypeScriptAppHostToolchainResolverTests.cs b/tests/Aspire.Cli.Tests/Projects/TypeScriptAppHostToolchainResolverTests.cs index c765b145d31..7836a4fc7c9 100644 --- a/tests/Aspire.Cli.Tests/Projects/TypeScriptAppHostToolchainResolverTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/TypeScriptAppHostToolchainResolverTests.cs @@ -41,7 +41,7 @@ public void Resolve_WhenPackageManagerIsYarnClassic_Throws() var packageJsonPath = Path.Combine(workspace.WorkspaceRoot.FullName, "package.json"); File.WriteAllText(packageJsonPath, "{ \"packageManager\": \"yarn@1.22.22\" }"); - var exception = Assert.Throws(() => TypeScriptAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot, logger: null)); + var exception = Assert.Throws(() => TypeScriptAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot, 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); } @@ -65,7 +65,7 @@ public void Resolve_WhenYarnLockIsClassic_Throws() 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(() => TypeScriptAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot, logger: null)); + var exception = Assert.Throws(() => TypeScriptAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot, 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); } @@ -81,7 +81,7 @@ public void Resolve_WhenParentYarnLockIsClassic_Throws() 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(() => TypeScriptAppHostToolchainResolver.Resolve(appHostDirectory, logger: null)); + var exception = Assert.Throws(() => TypeScriptAppHostToolchainResolver.Resolve(appHostDirectory, 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); }