// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Text.Json.Nodes;
using System.Xml.Linq;
using Aspire.TestUtilities;
using Xunit;
namespace Infrastructure.Tests;
public sealed class NpmCliPackageTests : IDisposable
{
private const string PackageName = "@microsoft/aspire-cli";
private const string PackageVersion = "13.4.0-test.1";
private static readonly RidPackageExpectation[] s_supportedRids =
[
new("win-x64", "aspire.exe", ["win32"], ["x64"], null),
new("win-arm64", "aspire.exe", ["win32"], ["arm64"], null),
new("linux-x64", "aspire", ["linux"], ["x64"], ["glibc"]),
new("linux-arm64", "aspire", ["linux"], ["arm64"], ["glibc"]),
new("linux-musl-x64", "aspire", ["linux"], ["x64"], ["musl"]),
new("osx-x64", "aspire", ["darwin"], ["x64"], null),
new("osx-arm64", "aspire", ["darwin"], ["arm64"], null)
];
private readonly TemporaryWorkspace _workspace;
private readonly ITestOutputHelper _output;
private readonly string _repoRoot = RepoRoot.Path;
private readonly string _packScriptPath;
public NpmCliPackageTests(ITestOutputHelper output)
{
_output = output;
_workspace = TemporaryWorkspace.Create(output);
_packScriptPath = Path.Combine(_repoRoot, "eng", "scripts", "pack-cli-npm-package.ps1");
}
public void Dispose() => _workspace.Dispose();
[Fact]
public async Task LauncherDetectsMuslArm64AndThrowsUnsupported()
{
var launcher = await ReadRepoFileAsync("eng/clipack/npm/aspire.js");
// libc-mismatched binaries crash at exec with cryptic dynamic-linker
// errors. Previously the launcher only checked musl for x64 and silently
// fell through to the glibc-linked `linux-arm64` binary on Alpine arm64.
// Assert the musl-arm64 case throws a friendly "Unsupported platform"
// error rather than silently resolving to a binary that will crash.
Assert.Contains("if (arch === 'arm64' && musl)", launcher);
Assert.Contains("Unsupported platform: ${platform} musl ${arch}", launcher);
}
[Fact]
public async Task LauncherRejectsNonRegularCachedBinary()
{
var launcher = await ReadRepoFileAsync("eng/clipack/npm/aspire.js");
// The native binary is copied from the (signed) node_modules source into
// a writable cache before exec. `needsCopy` previously trusted any cache
// entry that matched the source size/mtime, so a same-user process could
// swap the cache entry for a symlink to attacker content and the launcher
// would exec it. Verify the staleness check lstat's the target and only
// trusts regular files, forcing a fresh copy otherwise.
Assert.Contains("const target = fs.lstatSync(targetPath);", launcher);
Assert.Contains("if (!target.isFile()) {", launcher);
}
[Fact]
public async Task LauncherForwardsTerminatingSignalsToChild()
{
var launcher = await ReadRepoFileAsync("eng/clipack/npm/aspire.js");
// Programmatic `kill <wrapper-pid>` was orphaning the native CLI because
// the launcher only handled child exit and never forwarded inbound
// signals to the child. Especially bad for `aspire run` which keeps an
// AppHost alive. Verify termination signals are wired up and that we use
// `once` so a second signal can still kill the wrapper if the child
// ignores the first.
Assert.Contains("process.once(signal", launcher);
Assert.Contains("child.kill(signal)", launcher);
}
[Fact]
public async Task LauncherUsesPlatformSpecificSignalListToAvoidWindowsCrash()
{
var launcher = await ReadRepoFileAsync("eng/clipack/npm/aspire.js");
// SIGQUIT is POSIX-only: on Windows `process.once('SIGQUIT', ...)` throws
// `uv_signal_start EINVAL`, which (because the child is already spawned)
// would crash the launcher and orphan the running CLI on every Windows
// invocation. Verify the signal list is built per platform so SIGQUIT is
// never registered on Windows, and that each registration is guarded so an
// unexpected platform/libuv mismatch can never crash the launcher.
Assert.Contains("process.platform === 'win32'", launcher);
Assert.Contains("? ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGBREAK']", launcher);
Assert.Contains(": ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGQUIT']", launcher);
}
[Fact]
public async Task LauncherRegistersSignalHandlersBeforeSpawningChild()
{
var launcher = await ReadRepoFileAsync("eng/clipack/npm/aspire.js");
// A signal delivered in the window between spawn and handler registration
// could terminate the wrapper and orphan the child - the exact failure the
// forwarding is meant to prevent. Verify `child` is declared before the
// signal loop and assigned by `childProcess.spawn` afterwards.
var declarationIndex = launcher.IndexOf("let child;", System.StringComparison.Ordinal);
var spawnIndex = launcher.IndexOf("child = childProcess.spawn(", System.StringComparison.Ordinal);
Assert.True(declarationIndex >= 0, "Expected `let child;` declaration in launcher.");
Assert.True(spawnIndex >= 0, "Expected `child = childProcess.spawn(` assignment in launcher.");
Assert.True(declarationIndex < spawnIndex, "Signal handlers must be registered before the child is spawned.");
}
[Fact]
public async Task LauncherDetectsMuslViaFilesystemFallbackWhenLddMissing()
{
var launcher = await ReadRepoFileAsync("eng/clipack/npm/aspire.js");
// On minimal Alpine images `ldd` can be absent, in which case the old code
// wrongly assumed glibc and resolved a binary whose dynamic linker is
// missing (crashing at exec). Verify the launcher falls back to probing
// for the musl dynamic linker (/lib/ld-musl-*.so) when ldd gives no
// recognizable banner.
Assert.Contains("ld-musl-", launcher);
Assert.Contains("readdirSync", launcher);
}
[Fact]
public async Task LauncherCreatesCacheDirectoryOwnerOnly()
{
var launcher = await ReadRepoFileAsync("eng/clipack/npm/aspire.js");
// The native binary cache may land in a shared location (an
// ASPIRE_NPM_CACHE_DIR override, or the os.tmpdir() fallback when no home
// directory is available). Create it owner-only so another local user
// cannot pre-create or read the cached executable.
Assert.Contains("recursive: true, mode: 0o700", launcher);
}
[Fact]
public async Task LauncherLoadsRidPackageMapInsideErrorHandler()
{
var launcher = await ReadRepoFileAsync("eng/clipack/npm/aspire.js");
// A missing/corrupt aspire-package-map.json used to throw raw
// ENOENT/SyntaxError with a Node stack at module load — bypassing the
// launcher's top-level try/catch that produces friendly errors. Verify
// the map is loaded lazily inside main() and that read/parse failures
// produce a "corrupted ... Reinstall" message rather than a stack.
Assert.Contains("if (ridPackageNames === null)", launcher);
Assert.Contains("ridPackageNames = loadRidPackageNames()", launcher);
Assert.Contains("Aspire CLI installation is corrupted", launcher);
Assert.Contains("Reinstall @microsoft/aspire-cli", launcher);
}
[Fact]
[RequiresTools(["node"])]
public async Task LauncherPostinstallCheckSucceedsWhenNativePackageIsInstalled()
{
var pointerPackageRoot = await CreateFakeNpmInstallAsync(includeRidPackages: true);
using var cmd = new NodeCommand(_output)
.WithTimeout(TimeSpan.FromSeconds(30));
var result = await cmd.ExecuteScriptAsync(
Path.Combine(pointerPackageRoot, "bin", "aspire.js"),
"--npm-postinstall-check");
result.EnsureSuccessful();
}
[Fact]
[RequiresTools(["node"])]
public async Task LauncherPostinstallCheckExplainsDisabledOptionalDependenciesOnSupportedPlatform()
{
var pointerPackageRoot = await CreateFakeNpmInstallAsync(includeRidPackages: false);
var testScriptPath = Path.Combine(_workspace.Path, $"{Path.GetRandomFileName()}.js");
await File.WriteAllTextAsync(
testScriptPath,
"""
const launcher = require(process.argv[2]);
try {
launcher.__testing.runNpmPostinstallCheck(
{ name: process.argv[3], version: process.argv[4] },
() => process.argv[5]);
console.error('Expected postinstall check to fail.');
process.exit(1);
} catch (error) {
console.log(error.message);
}
""");
using var cmd = new NodeCommand(_output)
.WithTimeout(TimeSpan.FromSeconds(30));
var result = await cmd.ExecuteScriptAsync(
testScriptPath,
Path.Combine(pointerPackageRoot, "bin", "aspire.js"),
PackageName,
PackageVersion,
"linux-x64");
result.EnsureSuccessful();
Assert.Contains("without disabling npm optional dependencies", result.Output);
Assert.Contains("--omit=optional", result.Output);
Assert.Contains("--no-optional", result.Output);
Assert.Contains("npm_config_optional=false", result.Output);
}
[Fact]
[RequiresTools(["node"])]
public async Task LauncherPostinstallCheckSkipsUnsupportedPlatform()
{
var pointerPackageRoot = await CreateFakeNpmInstallAsync(includeRidPackages: false);
var testScriptPath = Path.Combine(_workspace.Path, $"{Path.GetRandomFileName()}.js");
await File.WriteAllTextAsync(
testScriptPath,
"""
const launcher = require(process.argv[2]);
launcher.__testing.runNpmPostinstallCheck(
{ name: process.argv[3], version: process.argv[4] },
() => launcher.__testing.detectRid('linux', 'arm64', true));
""");
using var cmd = new NodeCommand(_output)
.WithTimeout(TimeSpan.FromSeconds(30));
var result = await cmd.ExecuteScriptAsync(
testScriptPath,
Path.Combine(pointerPackageRoot, "bin", "aspire.js"),
PackageName,
PackageVersion);
result.EnsureSuccessful();
}
[Fact]
[RequiresTools(["pwsh", "npm"])]
public async Task PackScriptGeneratesPointerPackageMetadataMapAndReadme()
{
var package = await PackCliNpmPackageAsync("linux-musl-x64");
var packageJson = ReadJsonObject(Path.Combine(package.PointerPackageRoot, "package.json"));
Assert.Equal(PackageName, GetString(packageJson, "name"));
Assert.Equal(PackageVersion, GetString(packageJson, "version"));
Assert.Equal("The Aspire CLI lets you build, run, manage, and deploy distributed applications in a terminal.", GetString(packageJson, "description"));
Assert.Equal("https://aspire.dev", GetString(packageJson, "homepage"));
Assert.Equal(
["aspire", "typescript", "dotnet", "apphost", "polyglot", "distributed-applications", "code-first", "orchestration", "observability", "opentelemetry", "local-development"],
GetStringArray(packageJson["keywords"]));
Assert.Equal(">=20", GetString(GetObject(packageJson, "engines"), "node"));
Assert.Equal("node bin/aspire.js --npm-postinstall-check", GetString(GetObject(packageJson, "scripts"), "postinstall"));
Assert.Equal(
s_supportedRids.ToDictionary(rid => $"{PackageName}-{rid.Rid}", _ => PackageVersion, StringComparer.Ordinal),
GetStringMap(GetObject(packageJson, "optionalDependencies")));
Assert.Equal(["bin", "README.md"], GetStringArray(packageJson["files"]));
var packageMap = ReadJsonObject(Path.Combine(package.PointerPackageRoot, "bin", "aspire-package-map.json"));
Assert.Equal(
s_supportedRids.ToDictionary(rid => rid.Rid, rid => $"{PackageName}-{rid.Rid}", StringComparer.Ordinal),
GetStringMap(packageMap));
var readme = await File.ReadAllTextAsync(Path.Combine(package.PointerPackageRoot, "README.md"));
Assert.Equal(
await RenderTemplateAsync(
"eng/scripts/pack-cli-npm-package.pointer.README.md",
("PACKAGE_NAME", PackageName),
("VERSION", PackageVersion)),
readme);
Assert.Contains("Use it to create, run, publish, and deploy Aspire AppHosts from a terminal.", readme);
Assert.Contains("This package requires Node.js 20 or later.", readme);
Assert.Contains("Supported platforms:", readme);
Assert.Contains($"npm install -g {PackageName}", readme);
Assert.Contains($"npm install -g {PackageName}@latest", readme);
Assert.Contains("The native platform packages are installed through npm optional dependencies.", readme);
Assert.Contains("installation fails because the launcher cannot find the native CLI binary", readme);
Assert.Contains("If you run `aspire update --self` from an npm install, the CLI points you back to this npm update command.", readme);
Assert.Contains($"npm uninstall -g {PackageName}", readme);
Assert.Contains("## Troubleshooting", readme);
Assert.Contains("Optional dependencies disabled", readme);
Assert.Contains("If installation fails or the launcher says the native package was not installed", readme);
Assert.Contains("the `npm_config_optional=false` environment variable", readme);
Assert.Contains("New app:", readme);
Assert.Contains("Existing repo:", readme);
Assert.Contains("Dashboard only:", readme);
Assert.Contains("PATH cannot find `aspire`", readme);
Assert.Contains("Supported platforms and architectures", readme);
Assert.Contains("## Useful commands", readme);
Assert.Contains("`aspire deploy` deploys an AppHost to its supported deployment targets.", readme);
Assert.Contains("Aspire CLI command reference", readme);
Assert.Contains("TypeScript AppHost (`apphost.mts`)", readme);
Assert.Contains("import { createBuilder } from './.aspire/modules/aspire.mjs';", readme);
Assert.Contains("aspire dashboard run", readme);
Assert.Contains("Browse Aspire samples", readme);
Assert.Contains("## Release notes", readme);
Assert.Contains($"This package contains Aspire CLI version `{PackageVersion}`.", readme);
Assert.Contains("[Aspire releases](https://github.com/microsoft/aspire/releases)", readme);
Assert.DoesNotContain("https://github.com/microsoft/aspire/releases/tag/v", readme);
Assert.DoesNotContain("apphost.ts", readme);
Assert.DoesNotContain("./.aspire/modules/aspire.js", readme);
Assert.DoesNotContain("__PACKAGE_NAME__", readme);
Assert.DoesNotContain("__VERSION__", readme);
// The C# AppHost example was intentionally removed; the npm README is TypeScript-only.
Assert.DoesNotContain("apphost.cs", readme);
Assert.DoesNotContain("```csharp", readme);
}
[Fact]
public async Task PointerPackageReadmeSupportedPlatformTextMatchesSupportedRidMatrix()
{
var readme = await RenderTemplateAsync(
"eng/scripts/pack-cli-npm-package.pointer.README.md",
("PACKAGE_NAME", PackageName),
("VERSION", PackageVersion));
var supportedPlatformText = GetExpectedSupportedPlatformText();
Assert.Contains($"Supported platforms: {supportedPlatformText}.", readme);
Assert.Contains($"The npm package currently ships native binaries for {supportedPlatformText}. Other platforms are not supported by this package.", readme);
}
[Theory]
[MemberData(nameof(GetSupportedRidData))]
[RequiresTools(["pwsh", "npm"])]
public async Task PackScriptGeneratesRidPackageMetadataAndReadme(RidPackageExpectation expectation)
{
var package = await PackCliNpmPackageAsync(expectation.Rid);
var packageJson = ReadJsonObject(Path.Combine(package.RidPackageRoot, "package.json"));
Assert.Equal($"{PackageName}-{expectation.Rid}", GetString(packageJson, "name"));
Assert.Equal(PackageVersion, GetString(packageJson, "version"));
Assert.Equal($"Native Aspire CLI binary for {expectation.Rid}.", GetString(packageJson, "description"));
Assert.Equal(expectation.Os, GetStringArray(packageJson["os"]));
Assert.Equal(expectation.Cpu, GetStringArray(packageJson["cpu"]));
Assert.Equal(["bin", "README.md"], GetStringArray(packageJson["files"]));
if (expectation.Libc is null)
{
Assert.False(packageJson.ContainsKey("libc"));
}
else
{
Assert.Equal(expectation.Libc, GetStringArray(packageJson["libc"]));
}
Assert.True(File.Exists(Path.Combine(package.RidPackageRoot, "bin", expectation.BinaryName)));
var readme = await File.ReadAllTextAsync(Path.Combine(package.RidPackageRoot, "README.md"));
Assert.Equal(
await RenderTemplateAsync(
"eng/scripts/pack-cli-npm-package.rid.README.md",
("RID_PACKAGE_NAME", $"{PackageName}-{expectation.Rid}"),
("RID", expectation.Rid),
("PACKAGE_NAME", PackageName)),
readme);
Assert.Contains($"Native Aspire CLI binary for `{expectation.Rid}`.", readme);
Assert.Contains($"This package is installed as an optional dependency of `{PackageName}`.", readme);
Assert.DoesNotContain("__RID__", readme);
Assert.DoesNotContain("__PACKAGE_NAME__", readme);
}
[Fact]
public async Task NpmInstallValidationJobsUseExplicitJobsSharedStepsTemplateAndCentralArtifactNames()
{
var commonVariables = await ReadRepoFileAsync("eng/pipelines/common-variables.yml");
var buildPipeline = await ReadRepoFileAsync("eng/pipelines/azure-pipelines.yml");
var releasePipeline = await ReadRepoFileAsync("eng/pipelines/release-publish-nuget.yml");
Assert.Contains("NPM_VALIDATION_SUMMARY_WIN_X64_ARTIFACT", commonVariables);
Assert.Contains("NPM_VALIDATION_SUMMARY_LINUX_X64_ARTIFACT", commonVariables);
Assert.Contains("NPM_VALIDATION_SUMMARY_OSX_ARTIFACT", commonVariables);
Assert.Equal(3, CountOccurrences(buildPipeline, "template: /eng/pipelines/templates/npm-cli-install-validation-steps.yml@self"));
Assert.DoesNotContain("template: /eng/pipelines/templates/npm-cli-install-validation-job.yml@self", buildPipeline);
Assert.Contains("job: NpmInstall_Windows_x64", buildPipeline);
Assert.Contains("job: NpmInstall_Linux_x64", buildPipeline);
Assert.Contains("job: NpmInstall_macOS", buildPipeline);
Assert.DoesNotContain("validationSummaryArtifactName: npm-validation-summary-win-x64", buildPipeline);
Assert.DoesNotContain("validationSummaryArtifactName: npm-validation-summary-linux-x64", buildPipeline);
Assert.DoesNotContain("validationSummaryArtifactName: npm-validation-summary-osx", buildPipeline);
Assert.DoesNotContain("artifact: npm-validation-summary-win-x64", releasePipeline);
Assert.DoesNotContain("artifact: npm-validation-summary-linux-x64", releasePipeline);
Assert.DoesNotContain("artifact: npm-validation-summary-osx", releasePipeline);
Assert.DoesNotContain("artifact: $(NPM_VALIDATION_SUMMARY_WIN_X64_ARTIFACT)", releasePipeline);
Assert.DoesNotContain("artifact: $(NPM_VALIDATION_SUMMARY_LINUX_X64_ARTIFACT)", releasePipeline);
Assert.DoesNotContain("artifact: $(NPM_VALIDATION_SUMMARY_OSX_ARTIFACT)", releasePipeline);
Assert.Equal(3, CountOccurrences(releasePipeline, "task: DownloadBuildArtifacts@0"));
Assert.Equal(3, CountOccurrences(releasePipeline, "pipeline: $(SourceBuildPipeline)"));
Assert.Equal(3, CountOccurrences(releasePipeline, "buildId: $(SourceBuildId)"));
Assert.Equal(3, CountOccurrences(releasePipeline, "downloadPath: '$(Pipeline.Workspace)/aspire-build'"));
Assert.Contains("SourceBuildPipeline: microsoft-aspire", releasePipeline);
Assert.Contains("artifactName: $(NPM_VALIDATION_SUMMARY_WIN_X64_ARTIFACT)", releasePipeline);
Assert.Contains("artifactName: $(NPM_VALIDATION_SUMMARY_LINUX_X64_ARTIFACT)", releasePipeline);
Assert.Contains("artifactName: $(NPM_VALIDATION_SUMMARY_OSX_ARTIFACT)", releasePipeline);
Assert.Contains("$(NPM_VALIDATION_SUMMARY_WIN_X64_ARTIFACT)", releasePipeline);
Assert.Contains("$(NPM_VALIDATION_SUMMARY_LINUX_X64_ARTIFACT)", releasePipeline);
Assert.Contains("$(NPM_VALIDATION_SUMMARY_OSX_ARTIFACT)", releasePipeline);
}
[Fact]
public async Task PolyglotTypeScriptToolchainUsesInternalNpmRegistry()
{
var dockerfile = await ReadRepoFileAsync(".github/workflows/polyglot-validation/Dockerfile.typescript");
Assert.Contains("ARG NPM_REGISTRY=https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/", dockerfile);
Assert.Contains("npm install --global --force --registry \"${NPM_REGISTRY}\"", dockerfile);
Assert.Contains("pnpm@10.0.0", dockerfile);
Assert.Contains("@yarnpkg/cli-dist@4.14.1", dockerfile);
Assert.DoesNotContain("corepack prepare", dockerfile);
}
[Fact]
public async Task RunTestsInstallsAzureFunctionsCoreToolsFromPinnedGitHubRelease()
{
var workflow = await ReadRepoFileAsync(".github/workflows/run-tests.yml");
Assert.DoesNotContain("npm i -g azure-functions-core-tools@4", workflow);
Assert.Contains("core_tools_version='4.12.1'", workflow);
Assert.Contains("https://github.com/Azure/azure-functions-core-tools/releases/download/${core_tools_version}/Azure.Functions.Cli.linux-x64.${core_tools_version}.zip", workflow);
Assert.Contains("sha256sum --check -", workflow);
Assert.Contains("func --version", workflow);
}
[Fact]
public async Task NpmSigningScopeCoversNestedTarballPayloads()
{
var signingProps = XDocument.Parse(await ReadRepoFileAsync("eng/Signing.props"));
AssertScopedSigningRule(signingProps, "FileExtensionSignInfo", ".tgz", "LinuxSign500180PGP");
AssertScopedSigningRule(signingProps, "FileSignInfo", "aspire.js", "MicrosoftDotNet500");
// The native npm packages are built from already-signed native archives.
// The main Windows build should only produce the detached npm tarball
// signature; it must still provide scoped rules for nested native
// executables because Arcade resolves nested file certificates inside
// the ItemsToSign collision scope.
AssertScopedSigningRule(signingProps, "FileSignInfo", "aspire.exe", "None");
AssertScopedSigningRule(signingProps, "FileSignInfo", "aspire", "None");
}
[Fact]
public async Task ReleasePipelinePreflightsScheduledNpmPackagesBeforePublishing()
{
var releasePipeline = await ReadRepoFileAsync("eng/pipelines/release-publish-nuget.yml");
var preflightIndex = releasePipeline.IndexOf("Verify npm Packages Are Not Already Published", System.StringComparison.Ordinal);
var publishIndex = releasePipeline.IndexOf("template: MicroBuild.Publish.yml@MicroBuildTemplate", System.StringComparison.Ordinal);
Assert.True(preflightIndex >= 0, "Expected an already-published npm package preflight.");
Assert.True(publishIndex >= 0, "Expected npm MicroBuild publish template usage.");
Assert.True(preflightIndex < publishIndex, "Already-published npm package preflight must run before MicroBuild publish.");
Assert.Contains("SkipNpmRidPublish", releasePipeline);
Assert.Contains("SkipNpmPointerPublish", releasePipeline);
Assert.Contains("npm view $packageSpec version", releasePipeline);
Assert.Contains("already exists on npm", releasePipeline);
Assert.Contains("Set SkipNpmRidPublish=true", releasePipeline);
Assert.Contains("Set SkipNpmPointerPublish=true", releasePipeline);
}
[Fact]
public async Task ReleasePipelineGuardsNpmLatestDistTagAgainstServicingDowngrade()
{
var releasePipeline = await ReadRepoFileAsync("eng/pipelines/release-publish-nuget.yml");
Assert.DoesNotContain("AllowNpmLatestDistTagMove", releasePipeline);
Assert.Contains("npm view @microsoft/aspire-cli@latest version", releasePipeline);
Assert.Contains("would move the npm latest dist-tag backward", releasePipeline);
Assert.Contains("Set SkipNpmRidPublish=true and SkipNpmPointerPublish=true for older servicing releases", releasePipeline);
}
[Fact]
public async Task ReleasePipelineUsesEffectiveNpmOwnersAndApproversFromSingleSource()
{
var commonVariables = await ReadRepoFileAsync("eng/pipelines/common-variables.yml");
var releasePipeline = await ReadRepoFileAsync("eng/pipelines/release-publish-nuget.yml");
Assert.DoesNotContain("NPM_PUBLISH_REQUIRED_OWNERS", commonVariables);
Assert.Contains("NPM_PUBLISH_REQUIRED_OWNERS", releasePipeline);
Assert.DoesNotContain("NPM_PUBLISH_REQUIRED_APPROVERS", commonVariables);
Assert.DoesNotContain("NPM_PUBLISH_REQUIRED_APPROVERS", releasePipeline);
Assert.DoesNotContain("requiredNpmApprovers", releasePipeline);
Assert.Contains("default: 'joperezr'", releasePipeline);
Assert.Contains("default: 'adamratzman'", releasePipeline);
Assert.Contains("NpmPublishOwnersEffective", releasePipeline);
Assert.Contains("NpmPublishApproversEffective", releasePipeline);
Assert.Contains("owners: '$(NpmPublishOwnersEffective)'", releasePipeline);
Assert.Contains("approvers: '$(NpmPublishApproversEffective)'", releasePipeline);
Assert.DoesNotContain("$requiredNpmOwners = @('joperezr', 'ankj')", releasePipeline);
Assert.DoesNotContain("owners: '${{ parameters.NpmPublishOwners }}'", releasePipeline);
Assert.DoesNotContain("approvers: '${{ parameters.NpmPublishApprovers }}'", releasePipeline);
}
private Task<string> ReadRepoFileAsync(string relativePath)
=> File.ReadAllTextAsync(Path.Combine(_repoRoot, relativePath.Replace('/', Path.DirectorySeparatorChar)));
private async Task<string> CreateFakeNpmInstallAsync(bool includeRidPackages)
{
var testRoot = Path.Combine(_workspace.Path, Path.GetRandomFileName());
var nodeModulesRoot = Path.Combine(testRoot, "node_modules");
var pointerPackageRoot = Path.Combine(nodeModulesRoot, "@microsoft", "aspire-cli");
var pointerBinRoot = Path.Combine(pointerPackageRoot, "bin");
Directory.CreateDirectory(pointerBinRoot);
File.Copy(
Path.Combine(_repoRoot, "eng", "clipack", "npm", "aspire.js"),
Path.Combine(pointerBinRoot, "aspire.js"));
await File.WriteAllTextAsync(
Path.Combine(pointerPackageRoot, "package.json"),
$$"""
{
"name": "{{PackageName}}",
"version": "{{PackageVersion}}"
}
""");
var packageMap = new JsonObject();
foreach (var rid in s_supportedRids)
{
packageMap[rid.Rid] = $"{PackageName}-{rid.Rid}";
}
await File.WriteAllTextAsync(Path.Combine(pointerBinRoot, "aspire-package-map.json"), packageMap.ToJsonString());
if (includeRidPackages)
{
foreach (var rid in s_supportedRids)
{
var ridPackageRoot = Path.Combine(nodeModulesRoot, "@microsoft", $"aspire-cli-{rid.Rid}");
var ridBinRoot = Path.Combine(ridPackageRoot, "bin");
Directory.CreateDirectory(ridBinRoot);
await File.WriteAllTextAsync(
Path.Combine(ridPackageRoot, "package.json"),
$$"""
{
"name": "{{PackageName}}-{{rid.Rid}}",
"version": "{{PackageVersion}}"
}
""");
await File.WriteAllTextAsync(Path.Combine(ridBinRoot, "aspire"), "native binary stub");
await File.WriteAllTextAsync(Path.Combine(ridBinRoot, "aspire.exe"), "native binary stub");
}
}
return pointerPackageRoot;
}
public static TheoryData<RidPackageExpectation> GetSupportedRidData()
{
var data = new TheoryData<RidPackageExpectation>();
foreach (var rid in s_supportedRids)
{
data.Add(rid);
}
return data;
}
private async Task<PackedNpmPackage> PackCliNpmPackageAsync(string rid)
{
var testRoot = Path.Combine(_workspace.Path, Path.GetRandomFileName());
var stagingRoot = Path.Combine(testRoot, "staging");
var outputPath = Path.Combine(testRoot, "output");
var nativeBinaryPath = Path.Combine(testRoot, "native-aspire-stub");
Directory.CreateDirectory(testRoot);
await File.WriteAllTextAsync(nativeBinaryPath, "native binary stub");
using var cmd = new PowerShellCommand(_packScriptPath, _output)
.WithTimeout(TimeSpan.FromMinutes(2));
var result = await cmd.ExecuteAsync(
"-Rid", rid,
"-Version", PackageVersion,
"-NativeBinaryPath", $"\"{nativeBinaryPath}\"",
"-OutputPath", $"\"{outputPath}\"",
"-StagingRoot", $"\"{stagingRoot}\"",
"-PackageName", PackageName);
result.EnsureSuccessful();
Assert.Equal(2, Directory.GetFiles(outputPath, "*.tgz").Length);
return new PackedNpmPackage(
Path.Combine(stagingRoot, "rid"),
Path.Combine(stagingRoot, "pointer"));
}
private async Task<string> RenderTemplateAsync(string templateRelativePath, params (string Name, string Value)[] values)
{
var template = await ReadRepoFileAsync(templateRelativePath);
foreach (var (name, value) in values)
{
template = template.Replace($"__{name}__", value, System.StringComparison.Ordinal);
}
return template;
}
private static string GetExpectedSupportedPlatformText()
{
var platformGroups = new[]
{
new PlatformDescription("win32", null, "Windows", null),
new PlatformDescription("darwin", null, "macOS", null),
new PlatformDescription("linux", "glibc", "Linux", "with glibc"),
new PlatformDescription("linux", "musl", "Linux", "with musl/Alpine")
};
var describedRids = new HashSet<string>(StringComparer.Ordinal);
var descriptions = new List<string>();
foreach (var group in platformGroups)
{
var matchingRids = s_supportedRids
.Where(rid => rid.Os.Contains(group.Os, StringComparer.Ordinal) && HasLibc(rid, group.Libc))
.ToArray();
if (matchingRids.Length == 0)
{
continue;
}
foreach (var rid in matchingRids)
{
describedRids.Add(rid.Rid);
}
var cpuText = string.Join(
'/',
matchingRids
.SelectMany(rid => rid.Cpu)
.Distinct(StringComparer.Ordinal)
.OrderBy(GetCpuDisplayOrder)
.Select(FormatCpuName));
descriptions.Add(group.LibcDescription is null ? $"{group.Name} {cpuText}" : $"{group.Name} {cpuText} {group.LibcDescription}");
}
Assert.Equal(
s_supportedRids.Select(rid => rid.Rid).Order(StringComparer.Ordinal),
describedRids.Order(StringComparer.Ordinal));
return JoinDescriptions(descriptions);
}
private static bool HasLibc(RidPackageExpectation rid, string? libc)
{
return libc is null
? rid.Libc is null
: rid.Libc?.Contains(libc, StringComparer.Ordinal) == true;
}
private static string FormatCpuName(string cpu)
{
return cpu switch
{
"arm64" => "Arm64",
_ => cpu
};
}
private static int GetCpuDisplayOrder(string cpu)
{
return cpu switch
{
"x64" => 0,
"arm64" => 1,
_ => 2
};
}
private static string JoinDescriptions(IReadOnlyList<string> descriptions)
{
return descriptions.Count switch
{
0 => throw new InvalidOperationException("Expected at least one supported npm CLI platform."),
1 => descriptions[0],
2 => $"{descriptions[0]} and {descriptions[1]}",
_ => $"{string.Join(", ", descriptions.Take(descriptions.Count - 1))}, and {descriptions[^1]}"
};
}
private static JsonObject ReadJsonObject(string path)
{
var json = File.ReadAllText(path);
return JsonNode.Parse(json)?.AsObject()
?? throw new InvalidOperationException($"Failed to parse JSON object from {path}");
}
private static JsonObject GetObject(JsonObject jsonObject, string propertyName)
{
return jsonObject[propertyName]?.AsObject()
?? throw new InvalidOperationException($"Missing JSON object property '{propertyName}'.");
}
private static string GetString(JsonObject jsonObject, string propertyName)
{
return jsonObject[propertyName]?.GetValue<string>()
?? throw new InvalidOperationException($"Missing JSON string property '{propertyName}'.");
}
private static string[] GetStringArray(JsonNode? jsonNode)
{
return jsonNode?.AsArray().Select(value => value?.GetValue<string>() ?? throw new InvalidOperationException("JSON array contains a null value.")).ToArray()
?? throw new InvalidOperationException("Missing JSON string array.");
}
private static Dictionary<string, string> GetStringMap(JsonObject jsonObject)
{
return jsonObject.ToDictionary(
property => property.Key,
property => property.Value?.GetValue<string>() ?? throw new InvalidOperationException($"JSON property '{property.Key}' is not a string."),
StringComparer.Ordinal);
}
private static int CountOccurrences(string value, string substring)
{
var count = 0;
var index = 0;
while ((index = value.IndexOf(substring, index, System.StringComparison.Ordinal)) >= 0)
{
count++;
index += substring.Length;
}
return count;
}
private static void AssertScopedSigningRule(XDocument document, string elementName, string include, string certificateName)
{
var matchingRules = document
.Descendants(elementName)
.Where(element =>
(string?)element.Attribute("CollisionPriorityId") == "AspireCliNpmPackage" &&
((string?)element.Attribute("Include") == include || (string?)element.Attribute("Update") == include) &&
(string?)element.Attribute("CertificateName") == certificateName)
.ToArray();
Assert.True(
matchingRules.Length == 1,
$"Expected exactly one {elementName} for '{include}' using '{certificateName}' in the AspireCliNpmPackage signing scope, but found {matchingRules.Length}.");
}
public sealed record RidPackageExpectation(string Rid, string BinaryName, string[] Os, string[] Cpu, string[]? Libc);
private sealed record PlatformDescription(string Os, string? Libc, string Name, string? LibcDescription);
private sealed record PackedNpmPackage(string RidPackageRoot, string PointerPackageRoot);
}