From e5a3f8568119786e52a851a2a9376bfae5385c81 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 11:18:04 -0500 Subject: [PATCH 1/3] Test TypeScriptAppHostToolingCheck across all package managers Expands TypeScriptAppHostToolingCheckTests to cover npm, pnpm, Yarn, and Bun (previously only Bun was tested). For each toolchain, verifies that: - A Pass result is returned when all required CLIs are on PATH, with the result Message naming every required command. - A Fail result is returned for each missing CLI, carrying the toolchain's installation link from CommandPathResolver, the Fix string that names the installation display name, and Details produced by GetMissingCommandMessage. Also adds a dedicated theory for the npm toolchain (the only toolchain that requires two CLIs) covering npm-missing-only and npx-missing-only scenarios. Closes #17032 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../TypeScriptAppHostToolingCheckTests.cs | 81 ++++++++++++++++--- 1 file changed, 71 insertions(+), 10 deletions(-) diff --git a/tests/Aspire.Cli.Tests/Commands/TypeScriptAppHostToolingCheckTests.cs b/tests/Aspire.Cli.Tests/Commands/TypeScriptAppHostToolingCheckTests.cs index ebb9e1b3780..5cdcc34e81a 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] From ec059e8f53cec014c1e9c026b15606d3d5758d04 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 13:10:51 -0500 Subject: [PATCH 2/3] Surface Yarn Classic as a diagnostic in 'aspire doctor' Previously, when a TypeScript AppHost workspace used Yarn Classic (yarn@1.x or a yarn.lock with the v1 marker), TypeScriptAppHostToolchainResolver.Resolve would throw InvalidOperationException, the exception would bubble out of TypeScriptAppHostToolingCheck.CheckAsync uncaught, and EnvironmentChecker.CheckAllAsync would silently swallow it at Debug level. Net result: 'aspire doctor' produced no TypeScript AppHost output at all for these workspaces. This change catches the InvalidOperationException in TypeScriptAppHostToolingCheck and emits a Fail result instead, including: - Name: typescript-apphost-yarn-classic - Status: Fail - Message: 'TypeScript AppHost does not support Yarn Classic.' - Details: the resolver's user-facing exception text (mentions the specific offending packageManager value or lockfile path, depending on which signal triggered detection) - 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 Adds two tests in TypeScriptAppHostToolingCheckTests covering both detection signals (packageManager field and yarn.lock with v1 marker). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../TypeScriptAppHostToolingCheck.cs | 28 ++++++++++++- .../TypeScriptAppHostToolingCheckTests.cs | 42 +++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Cli/Utils/EnvironmentChecker/TypeScriptAppHostToolingCheck.cs b/src/Aspire.Cli/Utils/EnvironmentChecker/TypeScriptAppHostToolingCheck.cs index b6ccdadbce8..2e73ac760e8 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 (InvalidOperationException 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 5cdcc34e81a..ee243d62c37 100644 --- a/tests/Aspire.Cli.Tests/Commands/TypeScriptAppHostToolingCheckTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/TypeScriptAppHostToolingCheckTests.cs @@ -126,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"); From c1d540ed9d55009a3792c17e04d3a229e870109d Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 13:20:48 -0500 Subject: [PATCH 3/3] Narrow Yarn Classic catch to a dedicated exception type The previous commit caught plain InvalidOperationException from TypeScriptAppHostToolchainResolver.Resolve and treated every IOE as a Yarn Classic diagnostic. That binding is implicit: if a future contributor adds any other 'throw new InvalidOperationException(...)' inside Resolve (or anything it calls), the doctor check would silently surface it as 'TypeScript AppHost does not support Yarn Classic' with a Yarn install link - misleading users about the actual problem. Introduces YarnClassicNotSupportedException : InvalidOperationException, has the resolver throw that specific type from CreateYarnClassicNotSupportedException, and narrows the catch in TypeScriptAppHostToolingCheck.CheckAsync to the new type. Subclassing InvalidOperationException preserves the resolver's existing public throw contract (callers catching IOE still work). Updates the three existing 'Resolve_When*Yarn*Classic*_Throws' tests in TypeScriptAppHostToolchainResolverTests to assert on the specific exception type. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Projects/TypeScriptAppHostToolchainResolver.cs | 4 ++-- .../Projects/YarnClassicNotSupportedException.cs | 8 ++++++++ .../EnvironmentChecker/TypeScriptAppHostToolingCheck.cs | 2 +- .../Projects/TypeScriptAppHostToolchainResolverTests.cs | 6 +++--- 4 files changed, 14 insertions(+), 6 deletions(-) create mode 100644 src/Aspire.Cli/Projects/YarnClassicNotSupportedException.cs 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 2e73ac760e8..c7dab31ee71 100644 --- a/src/Aspire.Cli/Utils/EnvironmentChecker/TypeScriptAppHostToolingCheck.cs +++ b/src/Aspire.Cli/Utils/EnvironmentChecker/TypeScriptAppHostToolingCheck.cs @@ -53,7 +53,7 @@ public async Task> CheckAsync(Cancellation { toolchain = TypeScriptAppHostToolchainResolver.Resolve(appHostDirectory, _logger); } - catch (InvalidOperationException ex) + catch (YarnClassicNotSupportedException ex) { return [ 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); }