File: Npm\AspireJsLauncherTests.cs
Web Access
Project: src\tests\Aspire.Cli.Tests\Aspire.Cli.Tests.csproj (Aspire.Cli.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Text.RegularExpressions;
using Aspire.TestUtilities;
using Microsoft.AspNetCore.InternalTesting;
 
namespace Aspire.Cli.Tests.Npm;
 
[RequiresTools(["node"])]
public class AspireJsLauncherTests(ITestOutputHelper outputHelper)
{
    [Fact]
    public void LauncherFailsWhenRidPackageVersionMismatchesPointerPackageVersion()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var pointerVersion = "1.2.3";
        var ridPackageVersion = "1.2.4";
 
        var rid = GetCurrentRid();
        var ridPackageName = $"@microsoft/aspire-cli-{rid}";
        var layout = CreateFakeNpmLayout(workspace.Path, pointerVersion, rid, ridPackageName, ridPackageVersion);
 
        var result = RunLauncher(layout.LauncherScript, layout.CacheDir, [layout.ProbeScript, "--version"]);
 
        Assert.NotEqual(0, result.ExitCode);
        Assert.Contains("version", result.StdErr, StringComparison.OrdinalIgnoreCase);
        Assert.Contains(ridPackageName, result.StdErr);
        Assert.Contains(pointerVersion, result.StdErr);
        Assert.Contains(ridPackageVersion, result.StdErr);
    }
 
    [Fact]
    public void LauncherFailsWithRepairGuidanceWhenRidPackageJsonIsInvalid()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var pointerVersion = "1.2.3";
        var rid = GetCurrentRid();
        var ridPackageName = $"@microsoft/aspire-cli-{rid}";
        var layout = CreateFakeNpmLayout(workspace.Path, pointerVersion, rid, ridPackageName, pointerVersion);
        File.WriteAllText(layout.RidPackageJsonPath, "{ not valid json");
 
        var result = RunLauncher(layout.LauncherScript, layout.CacheDir, [layout.ProbeScript, "--version"]);
 
        Assert.NotEqual(0, result.ExitCode);
        Assert.Contains("Aspire CLI installation is corrupted", result.StdErr);
        Assert.Contains(ridPackageName, result.StdErr);
        Assert.Contains("package.json", result.StdErr);
        Assert.Contains("Reinstall @microsoft/aspire-cli", result.StdErr);
    }
 
    [Fact]
    public void LauncherCleansUpTempFileWhenChmodFails()
    {
        Assert.SkipUnless(!RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "chmod is POSIX-only");
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var pointerVersion = "1.0.0";
        var rid = GetCurrentRid();
        var ridPackageName = $"@microsoft/aspire-cli-{rid}";
        var layout = CreateFakeNpmLayout(workspace.Path, pointerVersion, rid, ridPackageName, pointerVersion);
 
        var monkeyPatchScript = Path.Combine(workspace.Path, "patch-chmod.js");
        File.WriteAllText(monkeyPatchScript, """
            const fs = require('fs');
            const originalChmodSync = fs.chmodSync;
            fs.chmodSync = function(path, mode) {
                if (path.includes('.tmp')) {
                    throw new Error('Simulated chmod failure');
                }
                return originalChmodSync.apply(this, arguments);
            };
            """);
 
        var result = RunLauncher(layout.LauncherScript, layout.CacheDir, [layout.ProbeScript, "--version"], requiredScript: monkeyPatchScript);
 
        Assert.NotEqual(0, result.ExitCode);
        Assert.Contains("Simulated chmod failure", result.StdErr);
        var tmpFiles = Directory.GetFiles(layout.CacheDir, "*.tmp", SearchOption.AllDirectories);
        Assert.Empty(tmpFiles);
    }
 
    [Fact]
    public void LauncherCleansUpTempFileWhenCopyFailsAfterCreatingTempFile()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var pointerVersion = "1.0.0";
        var rid = GetCurrentRid();
        var ridPackageName = $"@microsoft/aspire-cli-{rid}";
        var layout = CreateFakeNpmLayout(workspace.Path, pointerVersion, rid, ridPackageName, pointerVersion);
 
        var monkeyPatchScript = Path.Combine(workspace.Path, "patch-copy.js");
        File.WriteAllText(monkeyPatchScript, """
            const fs = require('fs');
            const originalCopyFileSync = fs.copyFileSync;
            fs.copyFileSync = function(source, destination, mode) {
                originalCopyFileSync.apply(this, arguments);
                if (String(destination).includes('.tmp')) {
                    throw new Error('Simulated copy failure after temp file creation');
                }
            };
            """);
 
        var result = RunLauncher(layout.LauncherScript, layout.CacheDir, [layout.ProbeScript, "--version"], requiredScript: monkeyPatchScript);
 
        Assert.NotEqual(0, result.ExitCode);
        Assert.Contains("Simulated copy failure", result.StdErr);
        var tmpFiles = Directory.GetFiles(layout.CacheDir, "*.tmp", SearchOption.AllDirectories);
        Assert.Empty(tmpFiles);
    }
 
    [Fact]
    public void LauncherCopiesBinaryToCacheAndSetsEnvironmentVariables()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var pointerVersion = "2.0.0";
        var rid = GetCurrentRid();
        var ridPackageName = $"@microsoft/aspire-cli-{rid}";
        var layout = CreateFakeNpmLayout(workspace.Path, pointerVersion, rid, ridPackageName, pointerVersion);
 
        var result = RunLauncher(layout.LauncherScript, layout.CacheDir, [layout.ProbeScript, "--test-env"]);
 
        Assert.Equal(0, result.ExitCode);
 
        var output = JsonDocument.Parse(result.StdOut);
        Assert.Equal("@microsoft/aspire-cli", output.RootElement.GetProperty("ASPIRE_NPM_PACKAGE").GetString());
        Assert.Equal(pointerVersion, output.RootElement.GetProperty("ASPIRE_NPM_PACKAGE_VERSION").GetString());
        Assert.Equal(rid, output.RootElement.GetProperty("ASPIRE_NPM_PACKAGE_RID").GetString());
 
        var args = output.RootElement.GetProperty("args");
        Assert.Equal(1, args.GetArrayLength());
        Assert.Equal("--test-env", args[0].GetString());
 
        var binaryName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "aspire.exe" : "aspire";
        var cachedBinaryPath = Path.Combine(layout.CacheDir, pointerVersion, rid, "bin", binaryName);
        Assert.True(File.Exists(cachedBinaryPath), $"Expected cached binary at {cachedBinaryPath}");
    }
 
    [Fact]
    public void LauncherSupportsAllRidsDefinedInPackScript()
    {
        var scriptPath = Path.Combine(GetRepoRoot(), "eng", "scripts", "pack-cli-npm-package.ps1");
        var supportedRids = GetSupportedRidsFromPackScript(scriptPath);
        var launcherScriptPath = Path.Combine(GetRepoRoot(), "eng", "clipack", "npm", "aspire.js");
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var probeScript = Path.Combine(workspace.Path, "detect-rids.js");
        File.WriteAllText(probeScript, $$"""
            const launcher = require({{JsonSerializer.Serialize(launcherScriptPath)}});
            const cases = {{JsonSerializer.Serialize(supportedRids.Select(CreateRidProbeCase).ToArray())}};
            const detected = cases.map(testCase => launcher.__testing.detectRid(testCase.Platform, testCase.Arch, testCase.Musl));
            console.log(JSON.stringify(detected));
            """);
 
        var result = RunNodeScript(probeScript);
 
        Assert.Equal(0, result.ExitCode);
        var detectedRids = JsonSerializer.Deserialize<string[]>(result.StdOut) ?? [];
        Assert.Equal(supportedRids.Order(StringComparer.Ordinal).ToArray(), detectedRids.Order(StringComparer.Ordinal).ToArray());
    }
 
    private string GetCurrentRid()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var launcherScriptPath = Path.Combine(GetRepoRoot(), "eng", "clipack", "npm", "aspire.js");
        var probeScript = Path.Combine(workspace.Path, "detect-current-rid.js");
 
        // Derive the fixture RID from the Node launcher itself. The Node runtime
        // architecture and libc detection can differ from the .NET test host.
        File.WriteAllText(probeScript, $$"""
            const launcher = require({{JsonSerializer.Serialize(launcherScriptPath)}});
            console.log(launcher.__testing.detectRid());
            """);
 
        var result = RunNodeScript(probeScript);
 
        Assert.Equal(0, result.ExitCode);
        return result.StdOut.Trim();
    }
 
    private static string[] GetSupportedRidsFromPackScript(string scriptPath)
    {
        var scriptContent = File.ReadAllText(scriptPath);
        var supportedRidsMatch = Regex.Match(scriptContent, @"\$supportedRids\s*=\s*@\((?<rids>[^)]*)\)", RegexOptions.Singleline);
        Assert.True(supportedRidsMatch.Success, "Could not find $supportedRids in pack-cli-npm-package.ps1.");
 
        return Regex.Matches(supportedRidsMatch.Groups["rids"].Value, @"'(?<rid>[^']+)'")
            .Select(match => match.Groups["rid"].Value)
            .Order(StringComparer.Ordinal)
            .ToArray();
    }
 
    private static RidProbeCase CreateRidProbeCase(string rid)
    {
        if (rid.StartsWith("win-", StringComparison.Ordinal))
        {
            return new RidProbeCase("win32", rid["win-".Length..], false);
        }
 
        if (rid.StartsWith("osx-", StringComparison.Ordinal))
        {
            return new RidProbeCase("darwin", rid["osx-".Length..], false);
        }
 
        if (rid.StartsWith("linux-musl-", StringComparison.Ordinal))
        {
            return new RidProbeCase("linux", rid["linux-musl-".Length..], true);
        }
 
        if (rid.StartsWith("linux-", StringComparison.Ordinal))
        {
            return new RidProbeCase("linux", rid["linux-".Length..], false);
        }
 
        throw new InvalidOperationException($"Unsupported RID shape in pack script: {rid}");
    }
 
    private static FakeNpmLayout CreateFakeNpmLayout(
        string rootPath,
        string pointerVersion,
        string rid,
        string ridPackageName,
        string ridPackageVersion)
    {
        var pointerDir = Path.Combine(rootPath, "pointer");
        var pointerBinDir = Path.Combine(pointerDir, "bin");
        var nodeModulesDir = Path.Combine(rootPath, "node_modules");
        var ridPackageDir = Path.Combine(nodeModulesDir, ridPackageName.Replace("/", Path.DirectorySeparatorChar.ToString()));
        var ridBinDir = Path.Combine(ridPackageDir, "bin");
        var cacheDir = Path.Combine(rootPath, "cache");
 
        Directory.CreateDirectory(pointerBinDir);
        Directory.CreateDirectory(ridBinDir);
        Directory.CreateDirectory(cacheDir);
 
        // Create pointer package.json
        var pointerPackageJson = new
        {
            name = "@microsoft/aspire-cli",
            version = pointerVersion
        };
        File.WriteAllText(
            Path.Combine(pointerDir, "package.json"),
            JsonSerializer.Serialize(pointerPackageJson, new JsonSerializerOptions { WriteIndented = true }));
 
        // Copy the real launcher script
        var realLauncherPath = Path.Combine(GetRepoRoot(), "eng", "clipack", "npm", "aspire.js");
        var launcherScript = Path.Combine(pointerBinDir, "aspire.js");
        File.Copy(realLauncherPath, launcherScript);
 
        // Create aspire-package-map.json
        var packageMap = new Dictionary<string, string> { [rid] = ridPackageName };
        File.WriteAllText(
            Path.Combine(pointerBinDir, "aspire-package-map.json"),
            JsonSerializer.Serialize(packageMap));
 
        // Create RID package.json
        var ridPackageJson = new
        {
            name = ridPackageName,
            version = ridPackageVersion
        };
        File.WriteAllText(
            Path.Combine(ridPackageDir, "package.json"),
            JsonSerializer.Serialize(ridPackageJson, new JsonSerializerOptions { WriteIndented = true }));
 
        var binaryName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "aspire.exe" : "aspire";
        var binaryPath = Path.Combine(ridBinDir, binaryName);
        CreateFakeNativeBinary(binaryPath);
 
        var probeScript = Path.Combine(rootPath, "probe-native.js");
        File.WriteAllText(probeScript, """
            const output = {
                ASPIRE_NPM_PACKAGE: process.env.ASPIRE_NPM_PACKAGE,
                ASPIRE_NPM_PACKAGE_VERSION: process.env.ASPIRE_NPM_PACKAGE_VERSION,
                ASPIRE_NPM_PACKAGE_RID: process.env.ASPIRE_NPM_PACKAGE_RID,
                args: process.argv.slice(2)
            };
            console.log(JSON.stringify(output));
            """);
 
        return new FakeNpmLayout(launcherScript, cacheDir, probeScript, Path.Combine(ridPackageDir, "package.json"));
    }
 
    private static void CreateFakeNativeBinary(string binaryPath)
    {
        if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
        {
            File.Copy(GetNodeExecutablePath(), binaryPath);
            return;
        }
 
        // Keep the fake native binary as a small script on Unix. Some macOS CI
        // Node builds abort when copied and executed from a different path/name,
        // which obscures what this fixture actually needs to validate: launcher
        // copy/cache behavior plus environment and argv forwarding.
        File.WriteAllText(binaryPath, """
            #!/bin/sh
            exec node "$@"
            """);
 
        var result = RunProcess(new ProcessStartInfo
        {
            FileName = "chmod",
            ArgumentList = { "+x", binaryPath },
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            UseShellExecute = false
        });
 
        Assert.Equal(0, result.ExitCode);
    }
 
    private static ProcessResult RunLauncher(string launcherScript, string cacheDir, string[] args, string? requiredScript = null)
    {
        var psi = new ProcessStartInfo
        {
            FileName = "node",
            WorkingDirectory = Path.GetDirectoryName(launcherScript),
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            UseShellExecute = false
        };
        if (requiredScript is not null)
        {
            psi.ArgumentList.Add("--require");
            psi.ArgumentList.Add(requiredScript);
        }
        psi.ArgumentList.Add(launcherScript);
        foreach (var arg in args)
        {
            psi.ArgumentList.Add(arg);
        }
        psi.Environment["ASPIRE_NPM_CACHE_DIR"] = cacheDir;
 
        return RunProcess(psi);
    }
 
    private static ProcessResult RunNodeScript(string scriptPath)
    {
        var psi = new ProcessStartInfo
        {
            FileName = "node",
            WorkingDirectory = Path.GetDirectoryName(scriptPath),
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            UseShellExecute = false
        };
        psi.ArgumentList.Add(scriptPath);
 
        return RunProcess(psi);
    }
 
    private static string GetNodeExecutablePath()
    {
        var psi = new ProcessStartInfo
        {
            FileName = "node",
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            UseShellExecute = false
        };
        psi.ArgumentList.Add("-p");
        psi.ArgumentList.Add("process.execPath");
 
        var result = RunProcess(psi);
 
        Assert.True(result.ExitCode == 0, result.StdErr);
        return result.StdOut.Trim();
    }
 
    private static ProcessResult RunProcess(ProcessStartInfo psi)
    {
        using var process = Process.Start(psi)!;
        var stdoutTask = process.StandardOutput.ReadToEndAsync();
        var stderrTask = process.StandardError.ReadToEndAsync();
 
        using var timeout = AsyncTestHelpers.CreateDefaultTimeoutTokenSource(TestConstants.LongTimeoutDuration);
        try
        {
            process.WaitForExitAsync(timeout.Token).GetAwaiter().GetResult();
        }
        catch (OperationCanceledException)
        {
            try
            {
                process.Kill(entireProcessTree: true);
            }
            catch (InvalidOperationException)
            {
            }
 
            throw new TimeoutException($"Process '{psi.FileName}' did not exit within the timeout.");
        }
 
        var stdout = stdoutTask.GetAwaiter().GetResult();
        var stderr = stderrTask.GetAwaiter().GetResult();
 
        return new ProcessResult(process.ExitCode, stdout, stderr);
    }
 
    private static string GetRepoRoot()
    {
        var current = Directory.GetCurrentDirectory();
        while (current is not null && !File.Exists(Path.Combine(current, "Aspire.slnx")))
        {
            current = Directory.GetParent(current)?.FullName;
        }
        return current ?? throw new InvalidOperationException("Could not find repository root");
    }
 
    private sealed record FakeNpmLayout(string LauncherScript, string CacheDir, string ProbeScript, string RidPackageJsonPath);
    private sealed record RidProbeCase(string Platform, string Arch, bool Musl);
    private sealed record ProcessResult(int ExitCode, string StdOut, string StdErr);
}