File: Projects\GuestRuntimeTests.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.Collections.Concurrent;
using System.Diagnostics;
using Aspire.Cli.Diagnostics;
using Aspire.Cli.DotNet;
using Aspire.Cli.Projects;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Aspire.Cli.Utils;
using Aspire.Tests;
using Aspire.TypeSystem;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
 
namespace Aspire.Cli.Tests.Projects;
 
public class GuestRuntimeTests(ITestOutputHelper outputHelper)
{
    private readonly ILoggerFactory _loggerFactory = LoggerFactory.Create(builder => builder.AddXunit(outputHelper));
 
    private ProcessGuestLauncher CreateLauncher(
        FileLoggerProvider? fileLoggerProvider = null,
        Func<string, string?>? commandResolver = null)
        => new(
            "test",
            _loggerFactory.CreateLogger<ProcessGuestLauncher>(),
            fileLoggerProvider: fileLoggerProvider,
            commandResolver: commandResolver ?? PathLookupHelper.FindFullPathFromPath,
            processExecutionFactory: new ProcessExecutionFactory(new TestEnvironment(), NullLogger<ProcessExecutionFactory>.Instance));
 
    private GuestRuntime CreateRuntime(
        RuntimeSpec? spec = null,
        Func<string, string?>? commandResolver = null,
        ProfilingTelemetry? profilingTelemetry = null)
    {
        return new GuestRuntime(
            spec ?? CreateTestSpec(),
            _loggerFactory.CreateLogger<GuestRuntime>(),
            commandResolver ?? PathLookupHelper.FindFullPathFromPath,
            new TestEnvironment(),
            profilingTelemetry ?? new ProfilingTelemetry(new ConfigurationBuilder().Build()));
    }
 
    private static RuntimeSpec CreateTestSpec(
        CommandSpec? execute = null,
        CommandSpec? watchExecute = null,
        CommandSpec? publishExecute = null,
        CommandSpec? installDependencies = null,
        CommandSpec[]? preExecute = null)
    {
        return new RuntimeSpec
        {
            Language = "test/runtime",
            DisplayName = "Test Runtime",
            CodeGenLanguage = "Test",
            DetectionPatterns = ["apphost.test"],
            Execute = execute ?? new CommandSpec
            {
                Command = "test-cmd",
                Args = ["{appHostFile}"]
            },
            WatchExecute = watchExecute,
            PublishExecute = publishExecute,
            InstallDependencies = installDependencies,
            PreExecute = preExecute
        };
    }
 
    private static RuntimeSpec CreateTypeScriptRuntimeSpec()
    {
        return CreateTestSpec(
            execute: new CommandSpec
            {
                Command = "npx",
                Args = ["--no-install", "tsx", "--tsconfig", "tsconfig.apphost.json", "{appHostFile}"]
            },
            preExecute:
            [
                new CommandSpec
                {
                    Command = "npx",
                    Args = ["--no-install", "tsc", "--noEmit", "-p", "tsconfig.apphost.json"]
                }
            ]);
    }
 
    [Fact]
    public void Language_ReturnsSpecLanguage()
    {
        var runtime = CreateRuntime();
 
        Assert.Equal("test/runtime", runtime.Language);
    }
 
    [Fact]
    public void DisplayName_ReturnsSpecDisplayName()
    {
        var runtime = CreateRuntime();
 
        Assert.Equal("Test Runtime", runtime.DisplayName);
    }
 
    [Fact]
    public void CreateDefaultLauncher_ReturnsProcessGuestLauncher()
    {
        var runtime = CreateRuntime();
 
        var launcher = runtime.CreateDefaultLauncher();
 
        Assert.IsType<ProcessGuestLauncher>(launcher);
    }
 
    [Fact]
    public async Task RunAsync_UsesExecuteSpec()
    {
        var spec = CreateTestSpec(execute: new CommandSpec
        {
            Command = "my-runner",
            Args = ["{appHostFile}"]
        });
        var runtime = CreateRuntime(spec);
        var launcher = new RecordingLauncher();
        var appHostFile = new FileInfo("/tmp/apphost.ts");
        var directory = new DirectoryInfo("/tmp");
        var envVars = new Dictionary<string, string>();
 
        await runtime.RunAsync(appHostFile, directory, envVars, watchMode: false, launcher, CancellationToken.None);
 
        Assert.Equal("my-runner", launcher.LastCommand);
        Assert.Contains(appHostFile.FullName, launcher.LastArgs);
    }
 
    [Fact]
    public async Task RunAsync_WatchMode_UsesWatchExecuteSpec()
    {
        var spec = CreateTestSpec(
            execute: new CommandSpec { Command = "run-cmd", Args = ["{appHostFile}"] },
            watchExecute: new CommandSpec { Command = "watch-cmd", Args = ["--watch", "{appHostFile}"] }
        );
        var runtime = CreateRuntime(spec);
        var launcher = new RecordingLauncher();
        var appHostFile = new FileInfo("/tmp/apphost.ts");
        var directory = new DirectoryInfo("/tmp");
 
        await runtime.RunAsync(appHostFile, directory, new Dictionary<string, string>(), watchMode: true, launcher, CancellationToken.None);
 
        Assert.Equal("watch-cmd", launcher.LastCommand);
        Assert.Contains("--watch", launcher.LastArgs);
    }
 
    [Fact]
    public async Task RunAsync_WatchModeWithWatchExecute_SkipsPreExecute()
    {
        var spec = CreateTestSpec(
            execute: new CommandSpec { Command = "run-cmd", Args = ["{appHostFile}"] },
            watchExecute: new CommandSpec { Command = "watch-cmd", Args = ["--watch", "{appHostFile}"] },
            preExecute:
            [
                new CommandSpec { Command = "typecheck-cmd", Args = ["--noEmit"] }
            ]);
        var runtime = CreateRuntime(spec);
        var launcher = new RecordingLauncher();
        var appHostFile = new FileInfo("/tmp/apphost.ts");
        var directory = new DirectoryInfo("/tmp");
 
        var (exitCode, _) = await runtime.RunAsync(appHostFile, directory, new Dictionary<string, string>(), watchMode: true, launcher, CancellationToken.None);
 
        Assert.Equal(0, exitCode);
        var call = Assert.Single(launcher.Calls);
        Assert.Equal("watch-cmd", call.Command);
    }
 
    [Fact]
    public async Task RunAsync_RunsPreExecuteBeforeExecute()
    {
        var spec = CreateTestSpec(
            execute: new CommandSpec { Command = "run-cmd", Args = ["{appHostFile}"] },
            preExecute:
            [
                new CommandSpec { Command = "typecheck-cmd", Args = ["--project", "{appHostDir}"] }
            ]);
        var runtime = CreateRuntime(spec);
        var launcher = new RecordingLauncher();
        var appHostFile = new FileInfo("/tmp/apphost.ts");
        var directory = new DirectoryInfo("/tmp");
 
        await runtime.RunAsync(appHostFile, directory, new Dictionary<string, string>(), watchMode: false, launcher, CancellationToken.None);
 
        Assert.Equal(2, launcher.Calls.Count);
        Assert.Equal("typecheck-cmd", launcher.Calls[0].Command);
        Assert.Equal(["--project", directory.FullName], launcher.Calls[0].Args);
        Assert.Equal("run-cmd", launcher.Calls[1].Command);
    }
 
    [Fact]
    public async Task RunAsync_NoBuildSkipsTypeScriptTscAndRunsAppHost()
    {
        var spec = CreateTypeScriptRuntimeSpec();
        var runtime = CreateRuntime(spec);
        var launcher = new RecordingLauncher();
        var appHostFile = new FileInfo("/tmp/apphost.ts");
        var directory = new DirectoryInfo("/tmp");
 
        var (exitCode, _) = await runtime.RunAsync(
            appHostFile,
            directory,
            new Dictionary<string, string>(),
            watchMode: false,
            launcher,
            CancellationToken.None,
            noBuild: true);
 
        Assert.Equal(0, exitCode);
        var call = Assert.Single(launcher.Calls);
        Assert.Equal("npx", call.Command);
        Assert.Equal(["--no-install", "tsx", "--tsconfig", "tsconfig.apphost.json", appHostFile.FullName], call.Args);
    }
 
    [Fact]
    public async Task RunAsync_CallsAfterAppHostLaunchedAfterPreExecute()
    {
        var spec = CreateTestSpec(
            execute: new CommandSpec { Command = "run-cmd", Args = ["{appHostFile}"] },
            preExecute:
            [
                new CommandSpec { Command = "typecheck-cmd", Args = ["--project", "{appHostDir}"] }
            ]);
        var runtime = CreateRuntime(spec);
        var launcher = new RecordingLauncher();
        var appHostFile = new FileInfo("/tmp/apphost.ts");
        var directory = new DirectoryInfo("/tmp");
        var afterAppHostLaunchedCalls = 0;
 
        await runtime.RunAsync(
            appHostFile,
            directory,
            new Dictionary<string, string>(),
            watchMode: false,
            launcher,
            CancellationToken.None,
            afterAppHostLaunchedAsync: () =>
            {
                afterAppHostLaunchedCalls++;
                Assert.Equal(2, launcher.Calls.Count);
                Assert.Equal("run-cmd", launcher.Calls[1].Command);
                return Task.CompletedTask;
            });
 
        Assert.Equal(1, afterAppHostLaunchedCalls);
        Assert.Equal(2, launcher.Calls.Count);
        Assert.Equal("run-cmd", launcher.Calls[1].Command);
    }
 
    [Fact]
    public async Task RunAsync_ProfilingTelemetryRecordsGuestCommandPhasesAndArgs()
    {
        var stoppedActivities = new ConcurrentBag<Activity>();
        using var profilingTelemetry = CreateProfilingTelemetry(
            (ProfilingTelemetry.EnvironmentVariables.Enabled, "true"),
            (ProfilingTelemetry.EnvironmentVariables.SessionId, "session-1"));
        using var listener = ActivityListenerHelper.Create(profilingTelemetry.ActivitySource, onActivityStopped: stoppedActivities.Add);
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var spec = CreateTestSpec(
            execute: new CommandSpec
            {
                Command = "npx",
                Args = ["tsx", "--tsconfig", "tsconfig.apphost.json", "{appHostFile}"]
            },
            preExecute:
            [
                new CommandSpec
                {
                    Command = "npx",
                    Args = ["tsc", "--noEmit", "-p", "tsconfig.apphost.json"]
                }
            ]);
        var runtime = CreateRuntime(spec, profilingTelemetry: profilingTelemetry);
        var launcher = new RecordingLauncher();
        var directory = new DirectoryInfo(workspace.Path);
        var appHostFile = new FileInfo(Path.Combine(directory.FullName, "apphost.ts"));
 
        await runtime.RunAsync(appHostFile, directory, new Dictionary<string, string>(), watchMode: false, launcher, CancellationToken.None);
 
        var guestActivities = stoppedActivities
            .Where(activity => activity.OperationName == ProfilingTelemetry.Activities.Process &&
                activity.GetTagItem(ProfilingTelemetry.Tags.ProfilingSessionId) as string == "session-1" &&
                activity.GetTagItem(ProfilingTelemetry.Tags.GuestCommandPhase) is not null)
            .OrderBy(activity => activity.StartTimeUtc)
            .ToArray();
 
        Assert.Collection(
            guestActivities,
            preExecuteActivity =>
            {
                Assert.Equal(ProfilingTelemetry.Values.GuestCommandPhasePreExecute, preExecuteActivity.GetTagItem(ProfilingTelemetry.Tags.GuestCommandPhase));
                Assert.Equal("process npx", preExecuteActivity.DisplayName);
                Assert.Equal("npx", preExecuteActivity.GetTagItem(ProfilingTelemetry.Tags.GuestCommand));
                Assert.Equal(new[] { "tsc", "--noEmit", "-p", "tsconfig.apphost.json" }, Assert.IsType<string[]>(preExecuteActivity.GetTagItem(ProfilingTelemetry.Tags.ProcessCommandArgs)));
                Assert.Equal(4, preExecuteActivity.GetTagItem(ProfilingTelemetry.Tags.ProcessCommandArgsCount));
                Assert.Equal(0, preExecuteActivity.GetTagItem(TelemetryConstants.Tags.ProcessExitCode));
            },
            executeActivity =>
            {
                Assert.Equal(ProfilingTelemetry.Values.GuestCommandPhaseExecute, executeActivity.GetTagItem(ProfilingTelemetry.Tags.GuestCommandPhase));
                Assert.Equal("process npx", executeActivity.DisplayName);
                Assert.Equal("npx", executeActivity.GetTagItem(ProfilingTelemetry.Tags.GuestCommand));
                Assert.Equal(new[] { "tsx", "--tsconfig", "tsconfig.apphost.json", appHostFile.FullName }, Assert.IsType<string[]>(executeActivity.GetTagItem(ProfilingTelemetry.Tags.ProcessCommandArgs)));
                Assert.Equal(4, executeActivity.GetTagItem(ProfilingTelemetry.Tags.ProcessCommandArgsCount));
                Assert.Equal(0, executeActivity.GetTagItem(TelemetryConstants.Tags.ProcessExitCode));
            });
    }
 
    [Fact]
    public async Task RunAsync_WhenPreExecuteFails_DoesNotExecute()
    {
        var spec = CreateTestSpec(
            execute: new CommandSpec { Command = "run-cmd", Args = ["{appHostFile}"] },
            preExecute:
            [
                new CommandSpec { Command = "typecheck-cmd", Args = ["--noEmit"] }
            ]);
        var runtime = CreateRuntime(spec);
        var launcher = new RecordingLauncher();
        launcher.ExitCodes.Enqueue(2);
        var appHostFile = new FileInfo("/tmp/apphost.ts");
        var directory = new DirectoryInfo("/tmp");
        var afterAppHostLaunchedCalled = false;
 
        var (exitCode, _) = await runtime.RunAsync(
            appHostFile,
            directory,
            new Dictionary<string, string>(),
            watchMode: false,
            launcher,
            CancellationToken.None,
            afterAppHostLaunchedAsync: () =>
            {
                afterAppHostLaunchedCalled = true;
                return Task.CompletedTask;
            });
 
        Assert.Equal(2, exitCode);
        Assert.False(afterAppHostLaunchedCalled);
        var call = Assert.Single(launcher.Calls);
        Assert.Equal("typecheck-cmd", call.Command);
    }
 
    [Fact]
    public async Task RunAsync_WhenExecuteCommandCannotResolve_DoesNotCallAfterAppHostLaunched()
    {
        var spec = CreateTestSpec(execute: new CommandSpec { Command = "missing-cmd", Args = ["{appHostFile}"] });
        var runtime = CreateRuntime(spec, commandResolver: _ => null);
        var launcher = runtime.CreateDefaultLauncher();
        var appHostFile = new FileInfo("/tmp/apphost.ts");
        var directory = new DirectoryInfo("/tmp");
        var afterAppHostLaunchedCalled = false;
 
        var (exitCode, _) = await runtime.RunAsync(
            appHostFile,
            directory,
            new Dictionary<string, string>(),
            watchMode: false,
            launcher,
            CancellationToken.None,
            afterAppHostLaunchedAsync: () =>
            {
                afterAppHostLaunchedCalled = true;
                return Task.CompletedTask;
            });
 
        Assert.Equal(-1, exitCode);
        Assert.False(afterAppHostLaunchedCalled);
    }
 
    [Fact]
    public async Task RunAsync_WatchModeWithoutWatchSpec_FallsBackToExecute()
    {
        var spec = CreateTestSpec(execute: new CommandSpec { Command = "run-cmd", Args = ["{appHostFile}"] });
        var runtime = CreateRuntime(spec);
        var launcher = new RecordingLauncher();
        var appHostFile = new FileInfo("/tmp/apphost.ts");
        var directory = new DirectoryInfo("/tmp");
 
        await runtime.RunAsync(appHostFile, directory, new Dictionary<string, string>(), watchMode: true, launcher, CancellationToken.None);
 
        Assert.Equal("run-cmd", launcher.LastCommand);
    }
 
    [Fact]
    public async Task PublishAsync_UsesPublishExecuteSpec()
    {
        var spec = CreateTestSpec(
            execute: new CommandSpec { Command = "run-cmd", Args = ["{appHostFile}"] },
            publishExecute: new CommandSpec { Command = "publish-cmd", Args = ["{appHostFile}", "{args}"] }
        );
        var runtime = CreateRuntime(spec);
        var launcher = new RecordingLauncher();
        var appHostFile = new FileInfo("/tmp/apphost.ts");
        var directory = new DirectoryInfo("/tmp");
 
        await runtime.PublishAsync(appHostFile, directory, new Dictionary<string, string>(), ["--output", "/out"], launcher, cancellationToken: CancellationToken.None);
 
        Assert.Equal("publish-cmd", launcher.LastCommand);
        Assert.Contains(launcher.LastArgs, a => a.Contains("--output") && a.Contains("/out"));
    }
 
    [Fact]
    public async Task PublishAsync_RunsPreExecuteBeforePublishExecute()
    {
        var spec = CreateTestSpec(
            execute: new CommandSpec { Command = "run-cmd", Args = ["{appHostFile}"] },
            publishExecute: new CommandSpec { Command = "publish-cmd", Args = ["{appHostFile}", "{args}"] },
            preExecute:
            [
                new CommandSpec { Command = "typecheck-cmd", Args = ["--project", "{appHostDir}"] }
            ]);
        var runtime = CreateRuntime(spec);
        var launcher = new RecordingLauncher();
        var appHostFile = new FileInfo("/tmp/apphost.ts");
        var directory = new DirectoryInfo("/tmp");
 
        await runtime.PublishAsync(appHostFile, directory, new Dictionary<string, string>(), ["--output", "/out"], launcher, cancellationToken: CancellationToken.None);
 
        Assert.Equal(2, launcher.Calls.Count);
        Assert.Equal("typecheck-cmd", launcher.Calls[0].Command);
        Assert.Equal("publish-cmd", launcher.Calls[1].Command);
    }
 
    [Fact]
    public async Task PublishAsync_CallsAfterAppHostLaunchedAfterPreExecute()
    {
        var spec = CreateTestSpec(
            execute: new CommandSpec { Command = "run-cmd", Args = ["{appHostFile}"] },
            publishExecute: new CommandSpec { Command = "publish-cmd", Args = ["{appHostFile}", "{args}"] },
            preExecute:
            [
                new CommandSpec { Command = "typecheck-cmd", Args = ["--project", "{appHostDir}"] }
            ]);
        var runtime = CreateRuntime(spec);
        var launcher = new RecordingLauncher();
        var appHostFile = new FileInfo("/tmp/apphost.ts");
        var directory = new DirectoryInfo("/tmp");
        var afterAppHostLaunchedCalls = 0;
 
        await runtime.PublishAsync(
            appHostFile,
            directory,
            new Dictionary<string, string>(),
            ["--output", "/out"],
            launcher,
            afterAppHostLaunchedAsync: () =>
            {
                afterAppHostLaunchedCalls++;
                Assert.Equal(2, launcher.Calls.Count);
                Assert.Equal("publish-cmd", launcher.Calls[1].Command);
                return Task.CompletedTask;
            },
            cancellationToken: CancellationToken.None);
 
        Assert.Equal(1, afterAppHostLaunchedCalls);
        Assert.Equal(2, launcher.Calls.Count);
        Assert.Equal("publish-cmd", launcher.Calls[1].Command);
    }
 
    [Fact]
    public async Task PublishAsync_NoBuildSkipsTypeScriptTscAndRunsAppHost()
    {
        var spec = CreateTypeScriptRuntimeSpec();
        var runtime = CreateRuntime(spec);
        var launcher = new RecordingLauncher();
        var appHostFile = new FileInfo("/tmp/apphost.ts");
        var directory = new DirectoryInfo("/tmp");
 
        var (exitCode, _) = await runtime.PublishAsync(
            appHostFile,
            directory,
            new Dictionary<string, string>(),
            ["--operation", "publish"],
            launcher,
            noBuild: true,
            cancellationToken: CancellationToken.None);
 
        Assert.Equal(0, exitCode);
        var call = Assert.Single(launcher.Calls);
        Assert.Equal("npx", call.Command);
        Assert.Equal(["--no-install", "tsx", "--tsconfig", "tsconfig.apphost.json", appHostFile.FullName, "--operation", "publish"], call.Args);
    }
 
    [Fact]
    public async Task PublishAsync_WithoutPublishSpec_FallsBackToExecute()
    {
        var spec = CreateTestSpec(execute: new CommandSpec { Command = "run-cmd", Args = ["{appHostFile}"] });
        var runtime = CreateRuntime(spec);
        var launcher = new RecordingLauncher();
        var appHostFile = new FileInfo("/tmp/apphost.ts");
        var directory = new DirectoryInfo("/tmp");
 
        await runtime.PublishAsync(appHostFile, directory, new Dictionary<string, string>(), null, launcher, cancellationToken: CancellationToken.None);
 
        Assert.Equal("run-cmd", launcher.LastCommand);
    }
 
    [Fact]
    public async Task RunAsync_MergesSpecEnvironmentVariables()
    {
        var spec = CreateTestSpec(execute: new CommandSpec
        {
            Command = "test-cmd",
            Args = ["{appHostFile}"],
            EnvironmentVariables = new Dictionary<string, string> { ["SPEC_VAR"] = "spec_value" }
        });
        var runtime = CreateRuntime(spec);
        var launcher = new RecordingLauncher();
        var appHostFile = new FileInfo("/tmp/apphost.ts");
        var directory = new DirectoryInfo("/tmp");
        var envVars = new Dictionary<string, string> { ["CALLER_VAR"] = "caller_value" };
 
        await runtime.RunAsync(appHostFile, directory, envVars, watchMode: false, launcher, CancellationToken.None);
 
        Assert.Equal("caller_value", launcher.LastEnvironmentVariables["CALLER_VAR"]);
        Assert.Equal("spec_value", launcher.LastEnvironmentVariables["SPEC_VAR"]);
    }
 
    [Fact]
    public async Task RunAsync_SpecEnvironmentVariables_TakePrecedence()
    {
        var spec = CreateTestSpec(execute: new CommandSpec
        {
            Command = "test-cmd",
            Args = ["{appHostFile}"],
            EnvironmentVariables = new Dictionary<string, string> { ["SHARED_VAR"] = "from_spec" }
        });
        var runtime = CreateRuntime(spec);
        var launcher = new RecordingLauncher();
        var appHostFile = new FileInfo("/tmp/apphost.ts");
        var directory = new DirectoryInfo("/tmp");
        var envVars = new Dictionary<string, string> { ["SHARED_VAR"] = "from_caller" };
 
        await runtime.RunAsync(appHostFile, directory, envVars, watchMode: false, launcher, CancellationToken.None);
 
        Assert.Equal("from_spec", launcher.LastEnvironmentVariables["SHARED_VAR"]);
    }
 
    [Fact]
    public async Task RunAsync_ReplacesAppHostFilePlaceholder()
    {
        var spec = CreateTestSpec(execute: new CommandSpec
        {
            Command = "npx",
            Args = ["tsx", "{appHostFile}"]
        });
        var runtime = CreateRuntime(spec);
        var launcher = new RecordingLauncher();
        var appHostFile = new FileInfo("/home/user/project/apphost.ts");
        var directory = new DirectoryInfo("/home/user/project");
 
        await runtime.RunAsync(appHostFile, directory, new Dictionary<string, string>(), watchMode: false, launcher, CancellationToken.None);
 
        Assert.Equal("npx", launcher.LastCommand);
        Assert.Equal(new[] { "tsx", appHostFile.FullName }, launcher.LastArgs);
    }
 
    [Fact]
    public async Task RunAsync_ReplacesAppHostDirPlaceholder()
    {
        var spec = CreateTestSpec(execute: new CommandSpec
        {
            Command = "test-cmd",
            Args = ["--dir", "{appHostDir}"]
        });
        var runtime = CreateRuntime(spec);
        var launcher = new RecordingLauncher();
        var appHostFile = new FileInfo("/home/user/project/apphost.ts");
        var directory = new DirectoryInfo("/home/user/project");
 
        await runtime.RunAsync(appHostFile, directory, new Dictionary<string, string>(), watchMode: false, launcher, CancellationToken.None);
 
        Assert.Equal(new[] { "--dir", directory.FullName }, launcher.LastArgs);
    }
 
    [Fact]
    public async Task PublishAsync_AdditionalArgsAppendedWhenNoPlaceholder()
    {
        var spec = CreateTestSpec(execute: new CommandSpec
        {
            Command = "test-cmd",
            Args = ["{appHostFile}"]
        });
        var runtime = CreateRuntime(spec);
        var launcher = new RecordingLauncher();
        var appHostFile = new FileInfo("/tmp/apphost.ts");
        var directory = new DirectoryInfo("/tmp");
 
        await runtime.PublishAsync(appHostFile, directory, new Dictionary<string, string>(), ["--extra", "arg"], launcher, cancellationToken: CancellationToken.None);
 
        Assert.Equal(appHostFile.FullName, launcher.LastArgs[0]);
        Assert.Equal("--extra", launcher.LastArgs[1]);
        Assert.Equal("arg", launcher.LastArgs[2]);
    }
 
    [Fact]
    public async Task RunAsync_EmptyPlaceholderReplacementsAreSkipped()
    {
        var spec = CreateTestSpec(execute: new CommandSpec
        {
            Command = "test-cmd",
            Args = ["{args}"]
        });
        var runtime = CreateRuntime(spec);
        var launcher = new RecordingLauncher();
        var appHostFile = new FileInfo("/tmp/apphost.ts");
        var directory = new DirectoryInfo("/tmp");
 
        await runtime.RunAsync(appHostFile, directory, new Dictionary<string, string>(), watchMode: false, launcher, CancellationToken.None);
 
        Assert.Empty(launcher.LastArgs);
    }
 
    [Fact]
    public void ExtensionLaunchCapability_ReturnsSpecValue()
    {
        var spec = new RuntimeSpec
        {
            Language = "test/runtime",
            DisplayName = "Test Runtime",
            CodeGenLanguage = "Test",
            DetectionPatterns = ["apphost.test"],
            Execute = new CommandSpec { Command = "test-cmd", Args = ["{appHostFile}"] },
            ExtensionLaunchCapability = "node"
        };
        var runtime = CreateRuntime(spec);
 
        Assert.Equal("node", runtime.ExtensionLaunchCapability);
    }
 
    [Fact]
    public void ExtensionLaunchCapability_DefaultsToNull()
    {
        var runtime = CreateRuntime();
 
        Assert.Null(runtime.ExtensionLaunchCapability);
    }
 
    [Fact]
    public async Task InstallDependenciesAsync_WithNoSpec_ReturnsZero()
    {
        var spec = CreateTestSpec();
        var runtime = CreateRuntime(spec);
 
        var (exitCode, output) = await runtime.InstallDependenciesAsync(new DirectoryInfo("/tmp"), CancellationToken.None);
 
        Assert.Equal(0, exitCode);
        Assert.Empty(output.GetLines());
    }
 
    [Fact]
    public async Task InstallDependenciesAsync_WhenNpmIsMissing_ReturnsNodeInstallMessage()
    {
        var runtime = CreateRuntime(
            new RuntimeSpec
            {
                Language = KnownLanguageId.TypeScript,
                DisplayName = "TypeScript (Node.js)",
                CodeGenLanguage = "typescript",
                DetectionPatterns = ["apphost.ts"],
                Execute = new CommandSpec { Command = "npx", Args = ["tsx", "{appHostFile}"] },
                InstallDependencies = new CommandSpec { Command = "npm", Args = ["install"] }
            },
            commandResolver: _ => null);
 
        var (exitCode, output) = await runtime.InstallDependenciesAsync(new DirectoryInfo(Path.GetTempPath()), CancellationToken.None);
 
        Assert.Equal(-1, exitCode);
        Assert.Collection(
            output.GetLines(),
            line =>
            {
                Assert.Equal(OutputLineStream.StdErr, line.Stream);
                Assert.Equal("npm is not installed or not found in PATH. Please install Node.js and try again.", line.Line);
            });
    }
 
    [Fact]
    public async Task RunAsync_WhenNpxIsMissing_ReturnsNodeInstallMessage()
    {
        var runtime = CreateRuntime(
            new RuntimeSpec
            {
                Language = KnownLanguageId.TypeScript,
                DisplayName = "TypeScript (Node.js)",
                CodeGenLanguage = "typescript",
                DetectionPatterns = ["apphost.ts"],
                Execute = new CommandSpec { Command = "npx", Args = ["tsx", "{appHostFile}"] }
            },
            commandResolver: _ => null);
 
        var appHostFile = new FileInfo(Path.Combine(Path.GetTempPath(), "apphost.ts"));
        var (exitCode, output) = await runtime.RunAsync(
            appHostFile,
            appHostFile.Directory!,
            new Dictionary<string, string>(),
            watchMode: false,
            runtime.CreateDefaultLauncher(),
            CancellationToken.None);
 
        Assert.Equal(-1, exitCode);
        var resolvedOutput = Assert.IsType<OutputCollector>(output);
        Assert.Collection(
            resolvedOutput.GetLines(),
            line =>
            {
                Assert.Equal(OutputLineStream.StdErr, line.Stream);
                Assert.Equal("npx is not installed or not found in PATH. Please install Node.js and try again.", line.Line);
            });
    }
 
    [Fact]
    public async Task ProcessGuestLauncher_WritesOutputToLogFile()
    {
        var logFilePath = Path.Combine(Path.GetTempPath(), $"guest-output-test-{Guid.NewGuid()}.log");
 
        try
        {
            using var fileLoggerProvider = new FileLoggerProvider(logFilePath, new TestStartupErrorWriter());
 
            var launcher = CreateLauncher(
                fileLoggerProvider: fileLoggerProvider,
                commandResolver: cmd => cmd == "dotnet" ? "dotnet" : null);
 
            var (exitCode, output) = await launcher.LaunchAsync(
                "dotnet",
                ["--version"],
                new DirectoryInfo(Path.GetTempPath()),
                new Dictionary<string, string>(),
                afterLaunchAsync: null,
                options: null,
                CancellationToken.None);
 
            Assert.Equal(0, exitCode);
            Assert.NotNull(output);
 
            // OutputCollector should have captured stdout
            var lines = output.GetLines().ToArray();
            Assert.NotEmpty(lines);
 
            // Dispose the provider to flush all pending writes
            fileLoggerProvider.Dispose();
 
            // Verify the log file was written and contains the output
            Assert.True(File.Exists(logFilePath), "Log file should exist");
            var logContents = await File.ReadAllTextAsync(logFilePath);
            Assert.Contains("[AppHost]", logContents);
 
            // The dotnet --version output should appear in the log
            var stdoutLine = lines.First(l => l.Stream == OutputLineStream.StdOut);
            Assert.Contains(stdoutLine.Line, logContents);
        }
        finally
        {
            if (File.Exists(logFilePath))
            {
                File.Delete(logFilePath);
            }
        }
    }
 
    [Fact]
    public async Task ProcessGuestLauncher_AnnotatesAmbientGuestProfilingActivity()
    {
        var stoppedActivities = new ConcurrentBag<Activity>();
        using var profilingTelemetry = CreateProfilingTelemetry(
            (ProfilingTelemetry.EnvironmentVariables.Enabled, "true"),
            (ProfilingTelemetry.EnvironmentVariables.SessionId, "session-1"));
        using var listener = ActivityListenerHelper.Create(profilingTelemetry.ActivitySource, onActivityStopped: stoppedActivities.Add);
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var launcher = CreateLauncher(
            commandResolver: cmd => cmd == "dotnet" ? "dotnet" : null);
 
        using (profilingTelemetry.StartGuestExecuteCommand(
            "test/runtime",
            "Test Runtime",
            "dotnet",
            ["--version"],
            new DirectoryInfo(workspace.Path),
            ProfilingTelemetry.Values.GuestCommandPhaseExecute))
        {
            var (exitCode, output) = await launcher.LaunchAsync(
                "dotnet",
                ["--version"],
                new DirectoryInfo(workspace.Path),
                new Dictionary<string, string>(),
                afterLaunchAsync: null,
                options: null,
                CancellationToken.None);
 
            Assert.Equal(0, exitCode);
            Assert.NotNull(output);
            Assert.Contains(output.GetLines(), line => line.Stream == OutputLineStream.StdOut);
        }
 
        var activity = Assert.Single(stoppedActivities, activity =>
            activity.OperationName == ProfilingTelemetry.Activities.Process &&
            activity.GetTagItem(ProfilingTelemetry.Tags.ProfilingSessionId) as string == "session-1" &&
            activity.GetTagItem(ProfilingTelemetry.Tags.GuestCommand) as string == "dotnet");
        Assert.Equal("process dotnet", activity.DisplayName);
        Assert.Equal("dotnet", activity.GetTagItem(TelemetryConstants.Tags.ProcessExecutablePath));
        Assert.Equal(new[] { "--version" }, Assert.IsType<string[]>(activity.GetTagItem(ProfilingTelemetry.Tags.ProcessCommandArgs)));
        Assert.Equal(1, activity.GetTagItem(ProfilingTelemetry.Tags.ProcessCommandArgsCount));
        Assert.Equal(0, activity.GetTagItem(TelemetryConstants.Tags.ProcessExitCode));
        Assert.True((int)activity.GetTagItem(TelemetryConstants.Tags.ProcessPid)! > 0);
        Assert.Contains(activity.Events, @event => @event.Name == ProfilingTelemetry.Events.GuestProcessResolveStart);
        Assert.Contains(activity.Events, @event => @event.Name == ProfilingTelemetry.Events.GuestProcessResolved);
        Assert.Contains(activity.Events, @event => @event.Name == ProfilingTelemetry.Events.GuestProcessStart);
        Assert.Contains(activity.Events, @event => @event.Name == ProfilingTelemetry.Events.GuestProcessStarted);
        Assert.Contains(activity.Events, @event => @event.Name == ProfilingTelemetry.Events.GuestFirstStdout);
        Assert.Contains(activity.Events, @event => @event.Name == ProfilingTelemetry.Events.GuestProcessExited);
    }
 
    [Fact]
    public async Task ProcessGuestLauncher_ClosesChildStdinSoReadsObserveEof()
    {
        // Regression coverage for https://github.com/microsoft/aspire/issues/16791.
        // Before this fix, the shared process launcher allowed isolated Unix guest processes
        // to inherit the parent CLI's TTY, so a child process (e.g. `npm install` postinstall
        // scripts on macOS) could block forever while reading stdin and make `aspire new` for
        // the TypeScript starter appear to stall.
        var launcher = CreateLauncher();
 
        var tempDirectory = Directory.CreateTempSubdirectory("aspire-guest-stdin-");
        try
        {
            string command;
            string[] args;
            if (OperatingSystem.IsWindows())
            {
                // `set /p` reads from process stdin. Do not add a local `<nul` redirection here:
                // that would make the command observe EOF even if ProcessGuestLauncher regressed.
                command = "cmd.exe";
                args = ["/c", "set /p line= & if defined line (echo got-input) else (echo eof)"];
            }
            else
            {
                // `read` returns non-zero on EOF; the script prints `eof` and exits.
                command = "sh";
                args = ["-c", "if read line; then echo got-input; else echo eof; fi"];
            }
 
            using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
            var stopwatch = Stopwatch.StartNew();
 
            var (exitCode, output) = await launcher.LaunchAsync(
                command,
                args,
                tempDirectory,
                new Dictionary<string, string>(),
                afterLaunchAsync: null,
                options: null,
                cancellationToken: cts.Token);
 
            stopwatch.Stop();
 
            Assert.False(cts.IsCancellationRequested,
                $"Child process did not exit on its own within 10s - stdin may not have been closed. Elapsed: {stopwatch.Elapsed}.");
            Assert.Equal(0, exitCode);
            var lines = output?.GetLines().Select(l => l.Line).ToArray() ?? [];
            Assert.Contains(lines, l => l.Contains("eof", StringComparison.OrdinalIgnoreCase));
            Assert.DoesNotContain(lines, l => l.Contains("got-input", StringComparison.OrdinalIgnoreCase));
        }
        finally
        {
            tempDirectory.Delete(recursive: true);
        }
    }
 
    [Fact]
    public async Task ProcessGuestLauncher_KillsProcessAndReturnsOnCancellation()
    {
        // Regression coverage for the AppHost system teardown path: when the AppHost server's
        // backchannel fails or the user cancels the run, GuestAppHostProject cancels a CTS that's
        // passed to this launcher. The launcher must kill the guest process tree (rather than
        // leaving it running) and drain output, otherwise pendingRun never completes and the CLI
        // appears to hang while it waits for the AppHost system to exit.
        var launcher = CreateLauncher();
 
        // Use a long-running cross-platform command. We pick something the OS resolves through PATH
        // so the launcher's CommandPathResolver succeeds without any fake.
        string command;
        string[] args;
        if (OperatingSystem.IsWindows())
        {
            // ping with a long count keeps the process alive for ~60 seconds; the kill needs to
            // actually terminate the process tree (cmd.exe -> ping.exe) for this to return.
            command = "cmd.exe";
            args = ["/c", "ping", "-n", "60", "127.0.0.1"];
        }
        else
        {
            command = "sleep";
            args = ["60"];
        }
 
        using var cts = new CancellationTokenSource();
        var launchTask = launcher.LaunchAsync(
            command,
            args,
            new DirectoryInfo(Path.GetTempPath()),
            new Dictionary<string, string>(),
            afterLaunchAsync: null,
            options: null,
            cts.Token);
 
        // Give the process a moment to actually start before cancelling so we exercise the
        // kill-after-running path, not the cancel-before-start short-circuit.
        await Task.Delay(500);
 
        var stopwatch = Stopwatch.StartNew();
        cts.Cancel();
 
        var (exitCode, _) = await launchTask;
        stopwatch.Stop();
 
        // The killed process should report a non-zero exit code. Different platforms report this
        // differently (SIGKILL maps to 137 on Linux/macOS; cmd.exe and ping return their own
        // process-tree-termination codes on Windows), so we only assert "not zero".
        Assert.NotEqual(0, exitCode);
 
        // Most importantly, the launcher must return quickly after cancellation. Before this fix
        // it just propagated the OperationCanceledException without killing the process, so the
        // caller-owned `using var process = new Process { ... }` only disposed the handle - the
        // OS process kept running until the underlying command finished on its own. We give a
        // generous slack here so the test isn't flaky under load, but it should still be well
        // under the 60s the command would run for if not killed.
        Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(15),
            $"Expected ProcessGuestLauncher to return within 15s of cancellation but it took {stopwatch.Elapsed}.");
    }
 
    [Fact]
    public async Task RunAsync_CreatesMissingMigrationFiles()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var tempDir = workspace.Path;
 
        var migrationFileName = "tsconfig.apphost.json";
        var migrationContent = """{ "compilerOptions": { "target": "ES2022" } }""";
 
        var spec = new RuntimeSpec
        {
            Language = "test/runtime",
            DisplayName = "Test Runtime",
            CodeGenLanguage = "Test",
            DetectionPatterns = ["apphost.test"],
            Execute = new CommandSpec
            {
                Command = "test-cmd",
                Args = ["--tsconfig", migrationFileName, "{appHostFile}"]
            },
            MigrationFiles = new Dictionary<string, string>
            {
                [migrationFileName] = migrationContent
            }
        };
 
        var runtime = CreateRuntime(spec);
        var launcher = new RecordingLauncher();
        var appHostFile = new FileInfo(Path.Combine(tempDir, "apphost.ts"));
        var directory = new DirectoryInfo(tempDir);
 
        // File should not exist before run
        var migrationFilePath = Path.Combine(tempDir, migrationFileName);
        Assert.False(File.Exists(migrationFilePath));
 
        await runtime.RunAsync(appHostFile, directory, new Dictionary<string, string>(), watchMode: false, launcher, CancellationToken.None);
 
        // File should be created after run
        Assert.True(File.Exists(migrationFilePath));
        var writtenContent = await File.ReadAllTextAsync(migrationFilePath);
        Assert.Equal(migrationContent, writtenContent);
    }
 
    [Fact]
    public async Task RunAsync_DoesNotOverwriteExistingMigrationFiles()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var tempDir = workspace.Path;
 
        var migrationFileName = "tsconfig.apphost.json";
        var migrationContent = """{ "compilerOptions": { "target": "ES2022" } }""";
        var existingContent = """{ "compilerOptions": { "target": "ES2020" } }""";
 
        // Pre-create the file with different content
        var migrationFilePath = Path.Combine(tempDir, migrationFileName);
        await File.WriteAllTextAsync(migrationFilePath, existingContent);
 
        var spec = new RuntimeSpec
        {
            Language = "test/runtime",
            DisplayName = "Test Runtime",
            CodeGenLanguage = "Test",
            DetectionPatterns = ["apphost.test"],
            Execute = new CommandSpec
            {
                Command = "test-cmd",
                Args = ["--tsconfig", migrationFileName, "{appHostFile}"]
            },
            MigrationFiles = new Dictionary<string, string>
            {
                [migrationFileName] = migrationContent
            }
        };
 
        var runtime = CreateRuntime(spec);
        var launcher = new RecordingLauncher();
        var appHostFile = new FileInfo(Path.Combine(tempDir, "apphost.ts"));
        var directory = new DirectoryInfo(tempDir);
 
        await runtime.RunAsync(appHostFile, directory, new Dictionary<string, string>(), watchMode: false, launcher, CancellationToken.None);
 
        // File should NOT be overwritten
        var writtenContent = await File.ReadAllTextAsync(migrationFilePath);
        Assert.Equal(existingContent, writtenContent);
    }
 
    [Fact]
    public async Task RunAsync_NoMigrationFiles_ExecutesNormally()
    {
        var spec = CreateTestSpec(execute: new CommandSpec
        {
            Command = "test-cmd",
            Args = ["{appHostFile}"]
        });
        var runtime = CreateRuntime(spec);
        var launcher = new RecordingLauncher();
        var appHostFile = new FileInfo("/tmp/apphost.ts");
        var directory = new DirectoryInfo("/tmp");
 
        await runtime.RunAsync(appHostFile, directory, new Dictionary<string, string>(), watchMode: false, launcher, CancellationToken.None);
 
        Assert.Equal("test-cmd", launcher.LastCommand);
    }
 
    [Fact]
    public async Task RunAsync_WhenPreExecuteHasNoStamp_RunsCommandAndWritesStamp()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = CreateUpToDateWorkspace(workspace.WorkspaceRoot);
 
        var runtime = CreateRuntime(CreateUpToDateSpec());
        var launcher = new RecordingLauncher();
 
        await runtime.RunAsync(appHostFile, workspace.WorkspaceRoot, new Dictionary<string, string>(), watchMode: false, launcher, CancellationToken.None);
 
        Assert.Contains(launcher.Calls, call => call.Command == "javac");
        Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "classes", ".aspire-compile-stamp")));
    }
 
    [Fact]
    public async Task RunAsync_WhenStampIsNewerThanInputs_SkipsPreExecute()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = CreateUpToDateWorkspace(workspace.WorkspaceRoot);
        WriteStamp(workspace.WorkspaceRoot, DateTime.UtcNow.AddMinutes(1));
 
        var runtime = CreateRuntime(CreateUpToDateSpec());
        var launcher = new RecordingLauncher();
 
        await runtime.RunAsync(appHostFile, workspace.WorkspaceRoot, new Dictionary<string, string>(), watchMode: false, launcher, CancellationToken.None);
 
        Assert.DoesNotContain(launcher.Calls, call => call.Command == "javac");
        Assert.Contains(launcher.Calls, call => call.Command == "java");
    }
 
    [Theory]
    [InlineData("AppHost.java")]
    [InlineData("Helper.java")]
    [InlineData(".aspire/modules/com/example/Generated.java")]
    [InlineData("src/main/java/com/example/Service.java")]
    public async Task RunAsync_WhenAnyJavaInputIsNewerThanStamp_RunsPreExecute(string relativeInput)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = CreateUpToDateWorkspace(workspace.WorkspaceRoot);
        WriteStamp(workspace.WorkspaceRoot, DateTime.UtcNow);
 
        var input = Path.Combine(workspace.WorkspaceRoot.FullName, relativeInput.Replace('/', Path.DirectorySeparatorChar));
        File.SetLastWriteTimeUtc(input, DateTime.UtcNow.AddMinutes(1));
 
        var runtime = CreateRuntime(CreateUpToDateSpec());
        var launcher = new RecordingLauncher();
 
        await runtime.RunAsync(appHostFile, workspace.WorkspaceRoot, new Dictionary<string, string>(), watchMode: false, launcher, CancellationToken.None);
 
        Assert.Contains(launcher.Calls, call => call.Command == "javac");
    }
 
    [Fact]
    public async Task RunAsync_UpToDateCheckIgnoresFilesOutsideTheDeclaredExtensions()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = CreateUpToDateWorkspace(workspace.WorkspaceRoot);
 
        // A class file is an output of the compile, not an input to it. Treating it as an input would
        // make the check permanently stale: every compile rewrites these and would invalidate itself.
        // The file is created before the stamp because its *appearance* is a change to the directory,
        // which the check does notice; what must not register is the rewrite of one already there.
        var classFile = Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.class");
        File.WriteAllText(classFile, "");
        WriteStamp(workspace.WorkspaceRoot, DateTime.UtcNow);
        File.SetLastWriteTimeUtc(classFile, DateTime.UtcNow.AddMinutes(1));
 
        var runtime = CreateRuntime(CreateUpToDateSpec());
        var launcher = new RecordingLauncher();
 
        await runtime.RunAsync(appHostFile, workspace.WorkspaceRoot, new Dictionary<string, string>(), watchMode: false, launcher, CancellationToken.None);
 
        Assert.DoesNotContain(launcher.Calls, call => call.Command == "javac");
    }
 
    [Theory]
    [InlineData("Helper.java")]
    [InlineData(".aspire/modules/com/example/Generated.java")]
    [InlineData("src/main/java/com/example/Service.java")]
    public async Task RunAsync_WhenAJavaInputIsDeleted_RunsPreExecute(string relativeInput)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = CreateUpToDateWorkspace(workspace.WorkspaceRoot);
        BackdateWorkspace(workspace.WorkspaceRoot, DateTime.UtcNow.AddMinutes(-2));
        WriteStamp(workspace.WorkspaceRoot, DateTime.UtcNow.AddMinutes(-1));
 
        // Deleting a source leaves every surviving input older than the stamp, so a check that only
        // compares file timestamps sees nothing at all. The class compiled from the deleted source is
        // still in the output directory and still on the runtime classpath, so the AppHost goes on
        // running against a type its own sources no longer define.
        File.Delete(Path.Combine(workspace.WorkspaceRoot.FullName, relativeInput.Replace('/', Path.DirectorySeparatorChar)));
 
        var runtime = CreateRuntime(CreateUpToDateSpec());
        var launcher = new RecordingLauncher();
 
        await runtime.RunAsync(appHostFile, workspace.WorkspaceRoot, new Dictionary<string, string>(), watchMode: false, launcher, CancellationToken.None);
 
        Assert.Contains(launcher.Calls, call => call.Command == "javac");
    }
 
    [Fact]
    public async Task RunAsync_WhenAnExplicitlyNamedInputChanges_RunsPreExecuteRegardlessOfItsExtension()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = CreateUpToDateWorkspace(workspace.WorkspaceRoot);
        var pom = Path.Combine(workspace.WorkspaceRoot.FullName, "pom.xml");
        File.WriteAllText(pom, "<project/>");
        WriteStamp(workspace.WorkspaceRoot, DateTime.UtcNow);
 
        // The extension filter exists to keep a directory scan from picking up a command's own
        // outputs. A file the spec names outright is not a scan result - it was declared as an input
        // on purpose, and for Java that is how a changed pom.xml or build.gradle reaches the check.
        File.SetLastWriteTimeUtc(pom, DateTime.UtcNow.AddMinutes(1));
 
        var runtime = CreateRuntime(CreateUpToDateSpec(extraInputs: ["pom.xml"]));
        var launcher = new RecordingLauncher();
 
        await runtime.RunAsync(appHostFile, workspace.WorkspaceRoot, new Dictionary<string, string>(), watchMode: false, launcher, CancellationToken.None);
 
        Assert.Contains(launcher.Calls, call => call.Command == "javac");
    }
 
    [Fact]
    public async Task RunAsync_WhenTheStagedDependencySetChanges_RunsPreExecute()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = CreateUpToDateWorkspace(workspace.WorkspaceRoot);
        var dependencies = Path.Combine(workspace.WorkspaceRoot.FullName, "target", "dependency");
        Directory.CreateDirectory(dependencies);
        File.WriteAllText(Path.Combine(dependencies, "guava-32.0.0.jar"), "");
 
        // Age the workspace before stamping so the restage below is genuinely newer. Stamping at
        // DateTime.UtcNow leaves the staged directory and the stamp within one clock tick on Windows,
        // where UtcNow advances in ~15ms steps, and the directory timestamp never compares greater.
        BackdateWorkspace(workspace.WorkspaceRoot, DateTime.UtcNow.AddMinutes(-2));
        WriteStamp(workspace.WorkspaceRoot, DateTime.UtcNow.AddMinutes(-1));
 
        // Bumping a dependency stages a differently-named JAR. Nothing under the source roots changes,
        // so without the staged set as an input the AppHost keeps running bytecode compiled against
        // the API of the version that is no longer on the classpath.
        File.Delete(Path.Combine(dependencies, "guava-32.0.0.jar"));
        File.WriteAllText(Path.Combine(dependencies, "guava-33.0.0.jar"), "");
 
        var runtime = CreateRuntime(CreateUpToDateSpec(extraInputs: [Path.Combine("target", "dependency")]));
        var launcher = new RecordingLauncher();
 
        await runtime.RunAsync(appHostFile, workspace.WorkspaceRoot, new Dictionary<string, string>(), watchMode: false, launcher, CancellationToken.None);
 
        Assert.Contains(launcher.Calls, call => call.Command == "javac");
    }
 
    [Fact]
    public async Task RunAsync_WhenAStagedDependencyIsRestagedInPlace_SkipsPreExecute()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = CreateUpToDateWorkspace(workspace.WorkspaceRoot);
        var dependencies = Path.Combine(workspace.WorkspaceRoot.FullName, "target", "dependency");
        Directory.CreateDirectory(dependencies);
        var jar = Path.Combine(dependencies, "guava-32.0.0.jar");
        File.WriteAllText(jar, "");
        WriteStamp(workspace.WorkspaceRoot, DateTime.UtcNow);
 
        // Dependency staging runs on every launch, so the JARs themselves can be rewritten with fresh
        // timestamps without the resolved set having changed. Reacting to that would recompile on
        // every single launch, which is the cost this check exists to avoid.
        File.SetLastWriteTimeUtc(jar, DateTime.UtcNow.AddMinutes(1));
 
        var runtime = CreateRuntime(CreateUpToDateSpec(extraInputs: [Path.Combine("target", "dependency")]));
        var launcher = new RecordingLauncher();
 
        await runtime.RunAsync(appHostFile, workspace.WorkspaceRoot, new Dictionary<string, string>(), watchMode: false, launcher, CancellationToken.None);
 
        Assert.DoesNotContain(launcher.Calls, call => call.Command == "javac");
    }
 
    [Fact]
    public async Task RunAsync_UpToDateCheckDoesNotRecurseIntoNonRecursiveInputs()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = CreateUpToDateWorkspace(workspace.WorkspaceRoot);
 
        // A directory input without the recursive marker is scanned top-level only, so churn deeper
        // inside it neither invalidates the command nor has to be walked. The tree is in place before
        // the stamp, as it would be in a real workspace; what must not register is the churn *inside*
        // it afterwards.
        var unrelated = Path.Combine(workspace.WorkspaceRoot.FullName, "vendor", "nested", "Vendored.java");
        Directory.CreateDirectory(Path.GetDirectoryName(unrelated)!);
        File.WriteAllText(unrelated, "");
        WriteStamp(workspace.WorkspaceRoot, DateTime.UtcNow);
        File.SetLastWriteTimeUtc(unrelated, DateTime.UtcNow.AddMinutes(1));
 
        var spec = CreateTestSpec(
            execute: new CommandSpec { Command = "java", Args = ["AppHost"] },
            preExecute:
            [
                new CommandSpec
                {
                    Command = "javac",
                    Args = ["-d", "classes", "{appHostFile}"],
                    UpToDateCheck = new CommandUpToDateCheck
                    {
                        Inputs = ["{appHostFile}", "vendor"],
                        FileExtensions = [".java"],
                        StampFile = Path.Combine("classes", ".aspire-compile-stamp")
                    }
                }
            ]);
 
        var runtime = CreateRuntime(spec);
        var launcher = new RecordingLauncher();
 
        await runtime.RunAsync(appHostFile, workspace.WorkspaceRoot, new Dictionary<string, string>(), watchMode: false, launcher, CancellationToken.None);
 
        Assert.DoesNotContain(launcher.Calls, call => call.Command == "javac");
    }
 
    [Fact]
    public async Task RunAsync_UpToDateCheckSeesAnEditToANestedPackageUnderTheAppHostDirectory()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = CreateUpToDateWorkspace(workspace.WorkspaceRoot);
 
        // javac is given no -classpath and no -sourcepath, so its source path defaults to the user
        // class path, which defaults to the current directory. A helper class in a package beside the
        // AppHost is therefore compiled implicitly, and its .class lands in the output directory the
        // AppHost runs from. Rewriting that file in place moves neither the AppHost directory's mtime
        // nor any top-level file, so a check that does not descend keeps stale bytecode.
        var nested = Path.Combine(workspace.WorkspaceRoot.FullName, "config", "Resources.java");
        Directory.CreateDirectory(Path.GetDirectoryName(nested)!);
        File.WriteAllText(nested, "class Resources { }");
        WriteStamp(workspace.WorkspaceRoot, DateTime.UtcNow);
        File.SetLastWriteTimeUtc(nested, DateTime.UtcNow.AddMinutes(1));
 
        var runtime = CreateRuntime(CreateUpToDateSpec());
        var launcher = new RecordingLauncher();
 
        await runtime.RunAsync(appHostFile, workspace.WorkspaceRoot, new Dictionary<string, string>(), watchMode: false, launcher, CancellationToken.None);
 
        Assert.Contains(launcher.Calls, call => call.Command == "javac");
    }
 
    [Fact]
    public async Task RunAsync_UpToDateCheckIgnoresChurnInDirectoriesThatCannotHoldJavaPackages()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = CreateUpToDateWorkspace(workspace.WorkspaceRoot);
 
        // The output directory holds the stamp itself, and a dependency or tooling directory is not a
        // source root, so neither may drag the compile back out of date once it has settled.
        var churn = new[]
        {
            Path.Combine(workspace.WorkspaceRoot.FullName, "classes", "config", "Resources.class"),
            Path.Combine(workspace.WorkspaceRoot.FullName, "node_modules", "vendor", "Vendored.java"),
            Path.Combine(workspace.WorkspaceRoot.FullName, ".gradle", "caches", "Cached.java")
        };
 
        foreach (var path in churn)
        {
            Directory.CreateDirectory(Path.GetDirectoryName(path)!);
            File.WriteAllText(path, "");
        }
 
        // Age the workspace before stamping. Stamping at DateTime.UtcNow leaves the real inputs and
        // the stamp within one clock tick on Windows, where UtcNow advances in ~15ms steps, so an
        // input can compare newer than the stamp and fail the check for a reason this test is not about.
        BackdateWorkspace(workspace.WorkspaceRoot, DateTime.UtcNow.AddMinutes(-2));
        WriteStamp(workspace.WorkspaceRoot, DateTime.UtcNow.AddMinutes(-1));
 
        foreach (var path in churn)
        {
            File.SetLastWriteTimeUtc(path, DateTime.UtcNow.AddMinutes(1));
 
            // Directories need Directory.SetLastWriteTimeUtc: the File overload opens the path without
            // FILE_FLAG_BACKUP_SEMANTICS, which Windows refuses for a directory handle.
            Directory.SetLastWriteTimeUtc(Path.GetDirectoryName(path)!, DateTime.UtcNow.AddMinutes(1));
        }
 
        var runtime = CreateRuntime(CreateUpToDateSpec());
        var launcher = new RecordingLauncher();
 
        await runtime.RunAsync(appHostFile, workspace.WorkspaceRoot, new Dictionary<string, string>(), watchMode: false, launcher, CancellationToken.None);
 
        Assert.DoesNotContain(launcher.Calls, call => call.Command == "javac");
    }
 
    [Fact]
    [SkipOnPlatform(TestPlatforms.Windows, "Directory permissions cannot be revoked this way on Windows, and root ignores them on Unix.")]
    public async Task RunAsync_UpToDateCheckTreatsAnUnreadableInputTreeAsOutOfDateInsteadOfThrowing()
    {
        Assert.SkipWhen(Environment.GetEnvironmentVariable("USER") == "root", "root bypasses directory permissions, so the traversal never fails.");
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = CreateUpToDateWorkspace(workspace.WorkspaceRoot);
        WriteStamp(workspace.WorkspaceRoot, DateTime.UtcNow.AddMinutes(1));
 
        // A recursive input containing a directory this user cannot traverse. EnumerateFiles is lazy,
        // so the UnauthorizedAccessException is raised while the foreach pulls from the enumerator --
        // enumerating outside the guarding try let it escape and abort AppHost startup entirely.
        var unreadable = Path.Combine(workspace.WorkspaceRoot.FullName, "src", "main", "java", "locked");
        Directory.CreateDirectory(unreadable);
        File.WriteAllText(Path.Combine(unreadable, "Hidden.java"), "class Hidden { }");
        SetUnixFileModeForTest(unreadable, UnixFileMode.None);
 
        try
        {
            var runtime = CreateRuntime(CreateUpToDateSpec());
            var launcher = new RecordingLauncher();
 
            await runtime.RunAsync(appHostFile, workspace.WorkspaceRoot, new Dictionary<string, string>(), watchMode: false, launcher, CancellationToken.None);
 
            // Falling back to running the compile is the safe answer: an unreadable tree cannot be
            // proven unchanged.
            Assert.Contains(launcher.Calls, call => call.Command == "javac");
        }
        finally
        {
            // Restore traversal so TemporaryWorkspace can delete the tree.
            SetUnixFileModeForTest(unreadable, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
        }
    }
 
    private static void SetUnixFileModeForTest(string path, UnixFileMode mode)
    {
        // The caller guards on platform, but the analyzer cannot see through [SkipOnPlatform].
#pragma warning disable CA1416
        File.SetUnixFileMode(path, mode);
#pragma warning restore CA1416
    }
 
    [Fact]
    public async Task RunAsync_WhenPreExecuteFails_DoesNotWriteStamp()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = CreateUpToDateWorkspace(workspace.WorkspaceRoot);
 
        var runtime = CreateRuntime(CreateUpToDateSpec());
        var launcher = new RecordingLauncher();
        launcher.ExitCodes.Enqueue(1);
 
        await runtime.RunAsync(appHostFile, workspace.WorkspaceRoot, new Dictionary<string, string>(), watchMode: false, launcher, CancellationToken.None);
 
        Assert.False(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "classes", ".aspire-compile-stamp")));
    }
 
    private static RuntimeSpec CreateUpToDateSpec(string[]? extraInputs = null)
    {
        return CreateTestSpec(
            execute: new CommandSpec { Command = "java", Args = ["AppHost"] },
            preExecute:
            [
                new CommandSpec
                {
                    Command = "javac",
                    Args = ["-d", "classes", "{appHostFile}"],
                    UpToDateCheck = new CommandUpToDateCheck
                    {
                        Inputs = ["{appHostFile}", "./**", ".aspire/modules/**", "src/main/java/**", .. extraInputs ?? []],
                        FileExtensions = [".java"],
                        StampFile = Path.Combine("classes", ".aspire-compile-stamp")
                    }
                }
            ]);
    }
 
    private static FileInfo CreateUpToDateWorkspace(DirectoryInfo root)
    {
        var appHostFile = new FileInfo(Path.Combine(root.FullName, "AppHost.java"));
        File.WriteAllText(appHostFile.FullName, "class AppHost { }");
        File.WriteAllText(Path.Combine(root.FullName, "Helper.java"), "class Helper { }");
 
        var generated = Path.Combine(root.FullName, ".aspire", "modules", "com", "example");
        Directory.CreateDirectory(generated);
        File.WriteAllText(Path.Combine(generated, "Generated.java"), "class Generated { }");
 
        var sources = Path.Combine(root.FullName, "src", "main", "java", "com", "example");
        Directory.CreateDirectory(sources);
        File.WriteAllText(Path.Combine(sources, "Service.java"), "class Service { }");
 
        Directory.CreateDirectory(Path.Combine(root.FullName, "classes"));
 
        return appHostFile;
    }
 
    /// <summary>
    /// Ages every file and directory in the workspace so a later stamp can postdate all of them, which
    /// is what a workspace looks like after a successful compile.
    /// </summary>
    private static void BackdateWorkspace(DirectoryInfo root, DateTime timestampUtc)
    {
        foreach (var file in Directory.EnumerateFiles(root.FullName, "*", SearchOption.AllDirectories))
        {
            File.SetLastWriteTimeUtc(file, timestampUtc);
        }
 
        // Directories come second: creating the files above moved their parents' timestamps.
        foreach (var directory in Directory.EnumerateDirectories(root.FullName, "*", SearchOption.AllDirectories))
        {
            Directory.SetLastWriteTimeUtc(directory, timestampUtc);
        }
 
        Directory.SetLastWriteTimeUtc(root.FullName, timestampUtc);
    }
 
    private static void WriteStamp(DirectoryInfo root, DateTime timestampUtc)
    {
        var stamp = Path.Combine(root.FullName, "classes", ".aspire-compile-stamp");
        Directory.CreateDirectory(Path.GetDirectoryName(stamp)!);
        File.WriteAllText(stamp, "");
        File.SetLastWriteTimeUtc(stamp, timestampUtc);
    }
 
    private sealed class RecordingLauncher : IGuestProcessLauncher
    {
        public List<(string Command, string[] Args)> Calls { get; } = [];
        public Queue<int> ExitCodes { get; } = [];
        public string LastCommand { get; private set; } = string.Empty;
        public string[] LastArgs { get; private set; } = [];
        public DirectoryInfo? LastWorkingDirectory { get; private set; }
        public IDictionary<string, string> LastEnvironmentVariables { get; private set; } = new Dictionary<string, string>();
 
        public async Task<(int ExitCode, OutputCollector? Output)> LaunchAsync(
            string command,
            string[] args,
            DirectoryInfo workingDirectory,
            IDictionary<string, string> environmentVariables,
            Func<Task>? afterLaunchAsync,
            GuestLaunchOptions? options,
            CancellationToken cancellationToken)
        {
            Calls.Add((command, args));
            LastCommand = command;
            LastArgs = args;
            LastWorkingDirectory = workingDirectory;
            LastEnvironmentVariables = new Dictionary<string, string>(environmentVariables);
            if (afterLaunchAsync is not null)
            {
                await afterLaunchAsync().ConfigureAwait(false);
            }
 
            var exitCode = ExitCodes.Count > 0 ? ExitCodes.Dequeue() : 0;
            return (exitCode, new OutputCollector());
        }
    }
 
    private static ProfilingTelemetry CreateProfilingTelemetry(params (string Key, string? Value)[] values)
    {
        var configuration = new ConfigurationBuilder()
            .AddInMemoryCollection(values.Select(value => new KeyValuePair<string, string?>(value.Key, value.Value)))
            .Build();
        return new ProfilingTelemetry(configuration);
    }
}