Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
}

Expand Down
8 changes: 8 additions & 0 deletions src/Aspire.Cli/Projects/YarnClassicNotSupportedException.cs
Original file line number Diff line number Diff line change
@@ -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)
{
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,33 @@ public async Task<IReadOnlyList<EnvironmentCheckResult>> 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<EnvironmentCheckResult>();

foreach (var command in TypeScriptAppHostToolchainResolver.GetRequiredCommands(toolchain))
Expand Down
123 changes: 113 additions & 10 deletions tests/Aspire.Cli.Tests/Commands/TypeScriptAppHostToolingCheckTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TypeScriptAppHostToolchain>(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<TypeScriptAppHostToolchain>(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]
Expand All @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<InvalidOperationException>(() => TypeScriptAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot, logger: null));
var exception = Assert.Throws<YarnClassicNotSupportedException>(() => 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);
}
Expand All @@ -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<InvalidOperationException>(() => TypeScriptAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot, logger: null));
var exception = Assert.Throws<YarnClassicNotSupportedException>(() => 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);
}
Expand All @@ -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<InvalidOperationException>(() => TypeScriptAppHostToolchainResolver.Resolve(appHostDirectory, logger: null));
var exception = Assert.Throws<YarnClassicNotSupportedException>(() => 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);
}
Expand Down
Loading