File: AddDenoAppTests.cs
Web Access
Project: src\tests\Aspire.Hosting.JavaScript.Tests\Aspire.Hosting.JavaScript.Tests.csproj (Aspire.Hosting.JavaScript.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#pragma warning disable ASPIREDOCKERFILEBUILDER001 // Type is for evaluation purposes only
#pragma warning disable ASPIREDENO001 // Type is for evaluation purposes only
#pragma warning disable ASPIREJAVASCRIPT001 // Type is for evaluation purposes only
 
using System.Diagnostics;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Tests.Utils;
using Aspire.Hosting.Utils;
using Aspire.TestUtilities;
using Microsoft.Extensions.DependencyInjection;
 
namespace Aspire.Hosting.JavaScript.Tests;
 
public class AddDenoAppTests(ITestOutputHelper outputHelper)
{
    [Fact]
    public async Task VerifyManifest()
    {
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
 
        var workingDirectory = AppContext.BaseDirectory;
        var denoApp = builder.AddDenoApp("denoapp", workingDirectory, "main.ts")
            .WithHttpEndpoint(port: 5033, env: "PORT");
        var manifest = await ManifestUtils.GetManifest(denoApp.Resource);
 
        await Verify(manifest.ToString());
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task ManifestDoesNotActivateDenoWithoutATargetOtlpEndpoint(bool configurePublish)
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish).WithResourceCleanUp(true);
        var denoApp = builder.AddDenoApp("denoapp", AppContext.BaseDirectory, "main.ts");
 
        if (configurePublish)
        {
            denoApp.WithDeno()
                .PublishAsPackageScript();
        }
 
        var exporter = Assert.Single(denoApp.Resource.Annotations.OfType<OtlpExporterAnnotation>());
        Assert.Equal(OtlpProtocol.HttpProtobuf, exporter.RequiredProtocol);
        Assert.IsAssignableFrom<IReadOnlyDictionary<string, string>>(exporter);
 
        var manifest = await ManifestUtils.GetManifest(denoApp.Resource);
 
        Assert.Null(manifest["env"]?["OTEL_DENO"]);
    }
 
    [Theory]
    [InlineData(true)]
    [InlineData(false)]
    public async Task VerifyDockerfile(bool includePackageJson)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
 
        if (includePackageJson)
        {
            File.WriteAllText(Path.Combine(appDir, "package.json"), "{}");
        }
 
        var denoApp = builder.AddDenoApp("js", appDir, "main.ts");
 
        await ManifestUtils.GetManifest(denoApp.Resource, workspace.Path);
 
        var dockerfilePath = Path.Combine(workspace.Path, "js.Dockerfile");
        var dockerfileContents = File.ReadAllText(dockerfilePath);
        await Verify(dockerfileContents);
 
        var dockerBuildAnnotation = denoApp.Resource.Annotations.OfType<DockerfileBuildAnnotation>().Single();
        Assert.True(dockerBuildAnnotation.HasEntrypoint);
 
        Assert.Empty(denoApp.Resource.Annotations.OfType<ContainerFilesSourceAnnotation>());
    }
 
    [Fact]
    public async Task VerifyDockerfileWithCustomBaseImage()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
        File.WriteAllText(Path.Combine(appDir, "package.json"), "{}");
 
        var customBuildImage = "denoland/deno:2.1-alpine";
        var customRuntimeImage = "denoland/deno:2.1-distroless";
        var denoApp = builder.AddDenoApp("js", appDir, "main.ts")
            .WithDockerfileBaseImage(customBuildImage, customRuntimeImage);
 
        await ManifestUtils.GetManifest(denoApp.Resource, workspace.Path);
 
        var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile"));
        await Verify(dockerfileContents);
        Assert.Equal("COPY --from=build /app /app", GetDockerfileLine(dockerfileContents, "COPY --from=build /app"));
        Assert.Equal("COPY --from=build /deno-dir /deno-dir", GetDockerfileLine(dockerfileContents, "COPY --from=build /deno-dir"));
    }
 
    [Fact]
    public async Task VerifyDockerfileEmitsPerDockerfileDockerignore()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
        File.WriteAllText(Path.Combine(appDir, "package.json"), "{}");
 
        var denoApp = builder.AddDenoApp("js", appDir, "main.ts");
 
        await ManifestUtils.GetManifest(denoApp.Resource, workspace.Path);
 
        // The default .dockerignore should be emitted alongside the published Dockerfile using
        // BuildKit's per-Dockerfile convention (<dockerfile-name>.dockerignore), not into the
        // user's source tree.
        var perDockerfileIgnorePath = Path.Combine(workspace.Path, "js.Dockerfile.dockerignore");
        Assert.True(File.Exists(perDockerfileIgnorePath), $"Expected per-Dockerfile dockerignore at {perDockerfileIgnorePath}");
        var ignoreContents = File.ReadAllText(perDockerfileIgnorePath);
        await Verify(ignoreContents);
 
        // The user's source tree must not be polluted with a generated .dockerignore.
        Assert.False(File.Exists(Path.Combine(appDir, ".dockerignore")), "Aspire should not write a .dockerignore into the user's source tree.");
 
        // The annotation should carry the default content so it can be inspected/overridden by users.
        // Deno can materialize node_modules for npm compatibility, so keep local dependency folders
        // out of the generated build context like the Bun/Node variants do.
        var dockerBuildAnnotation = denoApp.Resource.Annotations.OfType<DockerfileBuildAnnotation>().Single();
        Assert.Equal(ignoreContents, dockerBuildAnnotation.BuildContextIgnoreContent);
    }
 
    [Fact]
    public async Task VerifyDockerfile_PreCachesDependenciesAndShipsDenoDir()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
 
        var denoApp = builder.AddDenoApp("js", appDir, "main.ts");
 
        await ManifestUtils.GetManifest(denoApp.Resource, workspace.Path);
 
        var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile"));
 
        // The build stage must pre-cache direct run/serve entrypoints into DENO_DIR. Without a deno.lock,
        // plain `deno cache` is used.
        Assert.Equal("RUN deno cache main.ts", GetDockerfileLine(dockerfileContents, "RUN deno cache"));
        // DENO_DIR must be pinned deterministically in both stages...
        Assert.Collection(
            dockerfileContents.Split('\n').Select(line => line.TrimEnd('\r')).Where(line => line.StartsWith("ENV DENO_DIR", StringComparison.Ordinal)),
            line => Assert.Equal("ENV DENO_DIR=/deno-dir", line),
            line => Assert.Equal("ENV DENO_DIR=/deno-dir", line));
        // ...and the populated cache copied into the runtime stage.
        Assert.Equal(
            "COPY --from=build --chown=deno:deno /deno-dir /deno-dir",
            GetDockerfileLine(dockerfileContents, "COPY --from=build --chown=deno:deno /deno-dir"));
        // NODE_ENV must be set for Deno's npm-compatibility mode, mirroring the Bun publish block.
        Assert.Equal("ENV NODE_ENV=production", GetDockerfileLine(dockerfileContents, "ENV NODE_ENV"));
        // Runtime uses only the build-stage cache instead of re-fetching dependencies from the network.
        Assert.Equal(
            """ENTRYPOINT ["deno","run","--allow-net","--allow-env","--cached-only","main.ts"]""",
            GetDockerfileLine(dockerfileContents, "ENTRYPOINT"));
    }
 
    [Fact]
    public async Task VerifyDockerfile_UsesFrozenCacheWhenDenoLockExists()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
        // A committed lockfile means the build should fail fast on drift rather than silently re-resolve.
        File.WriteAllText(Path.Combine(appDir, "deno.lock"), "{}");
 
        var denoApp = builder.AddDenoApp("js", appDir, "main.ts");
 
        await ManifestUtils.GetManifest(denoApp.Resource, workspace.Path);
 
        var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile"));
 
        Assert.Equal("RUN deno cache --frozen main.ts", GetDockerfileLine(dockerfileContents, "RUN deno cache"));
    }
 
    [Fact]
    public async Task VerifyDockerfile_CacheUsesConfiguredResolutionAndLockFlags()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
        File.WriteAllText(Path.Combine(appDir, "custom.lock"), "{}");
 
        var denoApp = builder.AddDenoApp("js", appDir, "main.ts")
            .WithDenoConfig("deno.json")
            .WithDenoImportMap("import_map.json")
            .WithDenoLock("custom.lock")
            .WithDenoNodeModulesDir(DenoNodeModulesDirMode.Auto);
 
        await ManifestUtils.GetManifest(denoApp.Resource, workspace.Path);
 
        var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile"));
        Assert.Equal(
            "RUN deno cache --config deno.json --import-map import_map.json --lock custom.lock --node-modules-dir=auto --frozen main.ts",
            GetDockerfileLine(dockerfileContents, "RUN deno cache"));
    }
 
    [Fact]
    public async Task VerifyDockerfile_TaskEntrypointSkipsOpaqueTaskCache()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
        File.WriteAllText(Path.Combine(appDir, "custom.lock"), "{}");
 
        var denoApp = builder.AddDenoApp("js", appDir, "main.ts")
            .WithDenoTask("start")
            .WithDenoConfig("deno.json")
            .WithDenoImportMap("import_map.json")
            .WithDenoLock("custom.lock")
            .WithDenoNodeModulesDir(DenoNodeModulesDirMode.Auto)
            .WithDenoUnstable("sloppy-imports");
 
        await ManifestUtils.GetManifest(denoApp.Resource, workspace.Path);
 
        await Verify(File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile")));
    }
 
    [Fact]
    public async Task VerifyDockerfile_WithRunScriptUsesDenoTaskEntrypoint()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
        File.WriteAllText(Path.Combine(appDir, "deno.json"), """{"tasks":{"start":"deno run -A main.ts"}}""");
 
        var denoApp = builder.AddDenoApp("js", appDir, "main.ts")
            .WithRunScript("start", ["--my-arg"]);
 
        await ManifestUtils.GetManifest(denoApp.Resource, workspace.Path);
 
        var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile"));
        Assert.Equal("RUN mkdir -p /deno-dir", GetDockerfileLine(dockerfileContents, "RUN "));
        Assert.Equal("""ENTRYPOINT ["deno","task","start","--my-arg"]""", GetDockerfileLine(dockerfileContents, "ENTRYPOINT"));
    }
 
    [Fact]
    public async Task VerifyDockerfile_WithRunScriptAndDenoFlagsUsesDenoTaskEntrypoint()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
        File.WriteAllText(Path.Combine(appDir, "deno.json"), """{"tasks":{"start":"deno run -A main.ts"}}""");
        File.WriteAllText(Path.Combine(appDir, "deno.lock"), "{}");
 
        var denoApp = builder.AddDenoApp("js", appDir, "main.ts")
            .WithRunScript("start", ["--my-arg"])
            .WithDenoConfig("deno.json")
            .WithDenoImportMap("import_map.json")
            .WithDenoLock("deno.lock")
            .WithDenoNodeModulesDir(DenoNodeModulesDirMode.Auto);
 
        await ManifestUtils.GetManifest(denoApp.Resource, workspace.Path);
 
        var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile"));
        Assert.Equal("RUN mkdir -p /deno-dir", GetDockerfileLine(dockerfileContents, "RUN "));
        Assert.Equal(
            """ENTRYPOINT ["deno","task","--config","deno.json","--lock","deno.lock","--node-modules-dir=auto","start","--my-arg"]""",
            GetDockerfileLine(dockerfileContents, "ENTRYPOINT"));
    }
 
    [Fact]
    public async Task VerifyDockerfile_PublishAsPackageScriptWithDenoDoesNotRequireProductionInstallArgs()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
        File.WriteAllText(Path.Combine(appDir, "deno.json"), """{"tasks":{"build":"deno run -A build.ts","start":"deno run -A main.ts"}}""");
 
        var app = builder.AddJavaScriptApp("js", appDir)
            .WithDeno()
            .WithBuildScript("build")
            .PublishAsPackageScript("start", "-- --port $PORT");
 
        await ManifestUtils.GetManifest(app.Resource, workspace.Path);
 
        var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile"));
        await Verify(dockerfileContents);
        Assert.Equal("COPY --from=build --chown=deno:deno /app /app", GetDockerfileLine(dockerfileContents, "COPY --from=build --chown=deno:deno /app"));
        // Deno's dependency store must travel to the runtime stage or the container re-downloads on first run.
        Assert.Equal("COPY --from=build --chown=deno:deno /deno-dir /deno-dir", GetDockerfileLine(dockerfileContents, "COPY --from=build --chown=deno:deno /deno-dir"));
        Assert.Equal("USER deno", GetDockerfileLine(dockerfileContents, "USER"));
        Assert.Equal("""ENTRYPOINT ["sh","-c","exec deno task start -- --port $PORT"]""", GetDockerfileLine(dockerfileContents, "ENTRYPOINT"));
    }
 
    [Fact]
    public async Task VerifyDockerfile_AddDenoAppPackageScriptRunsInstallAndBuildAndOverridesLocalMode()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
        File.WriteAllText(
            Path.Combine(appDir, "deno.json"),
            """{"tasks":{"build prod":"deno run -A build.ts","start":"deno run -A main.ts"}}""");
 
        var app = builder.AddDenoApp("js", appDir, "main.ts")
            .WithDenoTask("local")
            .WithBuildScript("build prod")
            .PublishAsPackageScript("start", "--my-arg");
 
        var exporter = Assert.Single(app.Resource.Annotations.OfType<OtlpExporterAnnotation>());
        Assert.Equal(OtlpProtocol.HttpProtobuf, exporter.RequiredProtocol);
 
        await ManifestUtils.GetManifest(app.Resource, workspace.Path);
 
        var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile"));
        await Verify(dockerfileContents);
        Assert.Equal("RUN deno install", GetDockerfileLine(dockerfileContents, "RUN deno install"));
        Assert.Equal("RUN deno task 'build prod'", GetDockerfileLine(dockerfileContents, "RUN deno task"));
        Assert.Equal(
            """ENTRYPOINT ["deno","task","start","--my-arg"]""",
            GetDockerfileLine(dockerfileContents, "ENTRYPOINT"));
    }
 
    [Fact]
    public async Task VerifyDockerfile_AddDenoAppPublishAsStaticWebsiteThrows()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
 
        var app = builder.AddDenoApp("js", appDir, "main.ts")
            .PublishAsStaticWebsite();
 
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(
            () => ManifestUtils.GetManifest(app.Resource, workspace.Path));
 
        Assert.Equal(
            "Generated Deno Dockerfiles do not support PublishAsStaticWebsite. Use AddJavaScriptApp(...).WithDeno() or provide a custom Dockerfile.",
            exception.Message);
    }
 
    [Fact]
    public async Task VerifyDockerfile_AddDenoAppPublishAsNodeServerThrows()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
 
        var app = builder.AddDenoApp("js", appDir, "main.ts")
            .PublishAsNodeServer("server.js");
 
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(
            () => ManifestUtils.GetManifest(app.Resource, workspace.Path));
 
        Assert.Equal(
            "Generated Deno Dockerfiles do not support PublishAsNodeServer. Use AddJavaScriptApp(...).WithDeno() or provide a custom Dockerfile.",
            exception.Message);
    }
 
    [Fact]
    public async Task VerifyDockerfile_DenoPackageScriptCopiesReferencedMetadataBeforeInstall()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
        File.WriteAllText(Path.Combine(appDir, "deno.json"), """{"importMap":"import_map.json","tasks":{"start":"deno run main.ts"}}""");
        File.WriteAllText(Path.Combine(appDir, "import_map.json"), """{"imports":{}}""");
 
        var app = builder.AddDenoApp("js", appDir, "main.ts")
            .PublishAsPackageScript("start");
 
        await ManifestUtils.GetManifest(app.Resource, workspace.Path);
 
        var dockerfileLines = File.ReadAllLines(Path.Combine(workspace.Path, "js.Dockerfile"));
        var copySourceIndex = Array.IndexOf(dockerfileLines, "COPY . .");
        var installIndex = Array.IndexOf(dockerfileLines, "RUN deno install");
 
        Assert.True(copySourceIndex >= 0, string.Join(Environment.NewLine, dockerfileLines));
        Assert.True(installIndex > copySourceIndex, string.Join(Environment.NewLine, dockerfileLines));
    }
 
    [Fact]
    public async Task VerifyDockerfile_JavaScriptAppWithDenoCopiesReferencedMetadataBeforeInstall()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
        File.WriteAllText(Path.Combine(appDir, "deno.json"), """{"importMap":"import_map.json","tasks":{"start":"deno run main.ts"}}""");
        File.WriteAllText(Path.Combine(appDir, "import_map.json"), """{"imports":{}}""");
 
        var app = builder.AddJavaScriptApp("js", appDir)
            .WithDeno()
            .PublishAsPackageScript("start");
 
        await ManifestUtils.GetManifest(app.Resource, workspace.Path);
 
        var dockerfileLines = File.ReadAllLines(Path.Combine(workspace.Path, "js.Dockerfile"));
        var copySourceIndex = Array.IndexOf(dockerfileLines, "COPY . .");
        var installIndex = Array.IndexOf(dockerfileLines, "RUN deno install");
 
        Assert.True(copySourceIndex >= 0, string.Join(Environment.NewLine, dockerfileLines));
        Assert.True(installIndex > copySourceIndex, string.Join(Environment.NewLine, dockerfileLines));
    }
 
    [Fact]
    public async Task VerifyDockerfile_DefaultPublishPermissionsAreLeastPrivilege()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
 
        var app = builder.AddDenoApp("js", appDir, "main.ts");
        await ManifestUtils.GetManifest(app.Resource, workspace.Path);
 
        var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile"));
        Assert.Equal(
            """ENTRYPOINT ["deno","run","--allow-net","--allow-env","--cached-only","main.ts"]""",
            GetDockerfileLine(dockerfileContents, "ENTRYPOINT"));
    }
 
    [Fact]
    public async Task VerifyDockerfile_DenyOnlyPermissionsNarrowPublishDefaults()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
 
        var app = builder.AddDenoApp("js", appDir, "main.ts")
            .WithDenoDeny(DenoPermissionKind.Read, @"secrets\private");
        await ManifestUtils.GetManifest(app.Resource, workspace.Path);
 
        var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile"));
        Assert.Equal(
            """ENTRYPOINT ["deno","run","--allow-net","--allow-env","--deny-read=secrets/private","--cached-only","main.ts"]""",
            GetDockerfileLine(dockerfileContents, "ENTRYPOINT"));
    }
 
    [Fact]
    public async Task VerifyDockerfile_ExplicitPublishPermissionsArePreservedAndNormalized()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
 
        var app = builder.AddDenoApp("js", appDir, "main.ts")
            .WithDenoAllow(DenoPermissionKind.Read, @"data\read")
            .WithDenoDeny(DenoPermissionKind.Write, @"data\write")
            .WithDenoAllow(DenoPermissionKind.Ffi, @"native\lib");
        await ManifestUtils.GetManifest(app.Resource, workspace.Path);
 
        var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile"));
        Assert.Equal(
            """ENTRYPOINT ["deno","run","--allow-read=data/read","--deny-write=data/write","--allow-ffi=native/lib","--cached-only","main.ts"]""",
            GetDockerfileLine(dockerfileContents, "ENTRYPOINT"));
    }
 
    [Theory]
    [InlineData("--env-file")]
    [InlineData("--env-file=")]
    [InlineData("--env-file=.env.production")]
    [InlineData("--env-file=../.env")]
    [InlineData("--env-file=**")]
    [InlineData("--env-file=.env\n!.env")]
    public async Task VerifyDockerfile_EnvironmentFileIsRejected(string runtimeArg)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
 
        var app = builder.AddDenoApp("js", appDir, "main.ts")
            .WithDenoRuntimeArgs(runtimeArg);
 
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(
            () => ManifestUtils.GetManifest(app.Resource, workspace.Path));
        Assert.Equal(
            "Generated Deno Dockerfiles do not support '--env-file' because dotenv files can contain secrets that would be copied into the container image. Use Aspire environment variables or secret parameters, or provide a custom Dockerfile that handles the file securely.",
            exception.Message);
    }
 
    [Fact]
    public async Task VerifyDockerfile_EnvironmentFileIsAllowedWithCustomDockerfile()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
        File.WriteAllText(Path.Combine(appDir, "Dockerfile"), "FROM denoland/deno:2.9.0");
 
        var app = builder.AddDenoApp("js", appDir, "main.ts")
            .WithDenoRuntimeArgs("--env-file=.env.production");
 
        await ManifestUtils.GetManifest(app.Resource, workspace.Path);
 
        Assert.False(File.Exists(Path.Combine(workspace.Path, "js.Dockerfile")));
    }
 
    [Fact]
    public async Task VerifyDockerfile_CachePropagatesResolutionGoverningRuntimeArgs()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
        File.WriteAllText(Path.Combine(appDir, "main.ts"), "console.log(1);");
 
        var app = builder.AddDenoApp("js", appDir, "main.ts")
            // --cert is required to fetch a private HTTPS module and --no-remote forbids network resolution.
            // Both are honoured by `deno cache`, so dropping them either breaks `docker build` or silently
            // ignores the caller's policy at build time.
            .WithDenoRuntimeArgs("--allow-net", "--cert", "ca.pem", "--no-remote", "--vendor=true");
 
        await ManifestUtils.GetManifest(app.Resource, workspace.Path);
 
        var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile"));
        // --allow-net is a run-only permission that `deno cache` rejects outright, so it must not be forwarded.
        Assert.Equal(
            "RUN deno cache --cert ca.pem --no-remote --vendor=true main.ts",
            GetDockerfileLine(dockerfileContents, "RUN deno cache"));
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task VerifyDockerfile_CertificatePathsAreNormalizedForLinuxContainers(bool inlineValue)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
        File.WriteAllText(Path.Combine(appDir, "main.ts"), "console.log(1);");
 
        var app = builder.AddDenoApp("js", appDir, "main.ts");
        if (inlineValue)
        {
            app.WithDenoRuntimeArgs(@"--cert=certs\ca.pem");
        }
        else
        {
            app.WithDenoRuntimeArgs("--cert", @"certs\ca.pem");
        }
 
        await ManifestUtils.GetManifest(app.Resource, workspace.Path);
 
        var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile"));
        if (inlineValue)
        {
            Assert.Equal(
                "RUN deno cache --cert=certs/ca.pem main.ts",
                GetDockerfileLine(dockerfileContents, "RUN deno cache"));
            Assert.Equal(
                """ENTRYPOINT ["deno","run","--allow-net","--allow-env","--cached-only","--cert=certs/ca.pem","main.ts"]""",
                GetDockerfileLine(dockerfileContents, "ENTRYPOINT"));
        }
        else
        {
            Assert.Equal(
                "RUN deno cache --cert certs/ca.pem main.ts",
                GetDockerfileLine(dockerfileContents, "RUN deno cache"));
            Assert.Equal(
                """ENTRYPOINT ["deno","run","--allow-net","--allow-env","--cached-only","--cert","certs/ca.pem","main.ts"]""",
                GetDockerfileLine(dockerfileContents, "ENTRYPOINT"));
        }
    }
 
    [Fact]
    public async Task VerifyDockerfile_CacheOmitsRuntimeArgsDenoCacheRejects()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
        File.WriteAllText(Path.Combine(appDir, "main.ts"), "console.log(1);");
 
        var app = builder.AddDenoApp("js", appDir, "main.ts")
            .WithDenoRuntimeArgs("-A", "--allow-read", "/data", "--cached-only");
 
        await ManifestUtils.GetManifest(app.Resource, workspace.Path);
 
        // Forwarding these would fail the build: `deno cache -A` reports "error: unexpected argument".
        // "/data" is a bare value belonging to --allow-read and must not leak through as a module to cache.
        var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile"));
        Assert.Equal("RUN deno cache main.ts", GetDockerfileLine(dockerfileContents, "RUN deno cache"));
    }
 
    [Fact]
    public async Task VerifyDockerfile_CacheOmitsTrailingValueFlagThatWouldSwallowTheEntrypoint()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
        File.WriteAllText(Path.Combine(appDir, "main.ts"), "console.log(1);");
 
        var app = builder.AddDenoApp("js", appDir, "main.ts")
            .WithDenoRuntimeArgs("--lock");
 
        await ManifestUtils.GetManifest(app.Resource, workspace.Path);
 
        // `deno cache --lock main.ts` consumes main.ts as the lock file and then fails with
        // "the following required arguments were not provided: <file>...", so a bare trailing --lock is dropped.
        var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile"));
        Assert.Equal("RUN deno cache main.ts", GetDockerfileLine(dockerfileContents, "RUN deno cache"));
    }
 
    [Fact]
    public async Task VerifyDockerfile_BuildScriptWithSpacesIsQuotedForDeno()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
        File.WriteAllText(Path.Combine(appDir, "deno.json"), """{"tasks":{"build prod":"deno run -A build.ts","start":"deno run -A main.ts"}}""");
 
        var app = builder.AddJavaScriptApp("js", appDir)
            .WithDeno()
            .WithBuildScript("build prod")
            .PublishAsPackageScript("start");
 
        await ManifestUtils.GetManifest(app.Resource, workspace.Path);
 
        var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile"));
        // Without quoting the shell word-splits this into `deno task build` plus a stray `prod` argument.
        Assert.Equal("RUN deno task 'build prod'", GetDockerfileLine(dockerfileContents, "RUN deno task"));
    }
 
    [Fact]
    public async Task VerifyDockerfile_BuildScriptWithSpacesIsQuotedForNpm()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
        File.WriteAllText(Path.Combine(appDir, "package.json"), """{"scripts":{"build prod":"echo build","start":"node main.js"}}""");
 
        var app = builder.AddJavaScriptApp("js", appDir)
            .WithNpm()
            .WithBuildScript("build prod")
            .PublishAsPackageScript("start");
 
        await ManifestUtils.GetManifest(app.Resource, workspace.Path);
 
        // The build-script RUN line is shared by every package manager, so the quoting fix must hold off the Deno path too.
        var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile"));
        Assert.Equal("RUN npm run 'build prod'", GetDockerfileLine(dockerfileContents, "RUN npm run"));
    }
 
    [Fact]
    public async Task VerifyDockerfile_PublishAsPackageScriptWithCustomDenoRuntimeImagePreservesImageUser()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
        File.WriteAllText(Path.Combine(appDir, "deno.json"), """{"tasks":{"start":"deno run -A main.ts"}}""");
 
        var app = builder.AddJavaScriptApp("js", appDir)
            .WithDockerfileBaseImage("denoland/deno:2.9.0", "denoland/deno:2.1-distroless")
            .WithDeno()
            .PublishAsPackageScript("start");
 
        await ManifestUtils.GetManifest(app.Resource, workspace.Path);
 
        var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile"));
        Assert.Equal("COPY --from=build /app /app", GetDockerfileLine(dockerfileContents, "COPY --from=build /app"));
        Assert.Equal("COPY --from=build /deno-dir /deno-dir", GetDockerfileLine(dockerfileContents, "COPY --from=build /deno-dir"));
        Assert.DoesNotContain(dockerfileContents.Split(Environment.NewLine), line => line.StartsWith("USER ", StringComparison.Ordinal));
        // Distroless images have no /bin/sh, so the entrypoint must be exec form.
        Assert.Equal(
            """ENTRYPOINT ["deno","task","start"]""",
            GetDockerfileLine(dockerfileContents, "ENTRYPOINT"));
    }
 
    [Theory]
    [InlineData(true)]
    [InlineData(false)]
    public async Task VerifyDockerfile_RuntimeOnlyBaseImageOverrideStillBuildsWithDeno(bool denoBeforeOverride)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
        File.WriteAllText(Path.Combine(appDir, "deno.json"), """{"tasks":{"start":"deno run -A main.ts"}}""");
 
        // WithDockerfileBaseImage replaces the annotation, so a runtime-only override leaves BuildImage null in
        // either ordering. The build stage must still resolve to a Deno image or `deno install` would not exist.
        var app = builder.AddJavaScriptApp("js", appDir);
        if (denoBeforeOverride)
        {
            app.WithDeno(install: true).WithDockerfileBaseImage(runtimeImage: "denoland/deno:2.1-distroless");
        }
        else
        {
            app.WithDockerfileBaseImage(runtimeImage: "denoland/deno:2.1-distroless").WithDeno(install: true);
        }
 
        app.PublishAsPackageScript("start");
 
        await ManifestUtils.GetManifest(app.Resource, workspace.Path);
 
        var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile"));
        Assert.Equal("FROM denoland/deno:2.9.0 AS build", GetDockerfileLine(dockerfileContents, "FROM denoland/deno:2.9.0"));
        Assert.Equal("FROM denoland/deno:2.1-distroless AS runtime", GetDockerfileLine(dockerfileContents, "FROM denoland/deno:2.1-distroless"));
    }
 
    [Fact]
    public async Task VerifyDockerfile_PublishAsPackageScriptWithPlainArgumentsUsesExecForm()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
        File.WriteAllText(Path.Combine(appDir, "deno.json"), """{"tasks":{"start":"deno run -A main.ts"}}""");
 
        var app = builder.AddJavaScriptApp("js", appDir)
            .WithDeno()
            .PublishAsPackageScript("start", """-- --port 8080 --name "my app" """);
 
        await ManifestUtils.GetManifest(app.Resource, workspace.Path);
 
        var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile"));
        Assert.Equal(
            """ENTRYPOINT ["deno","task","start","--","--port","8080","--name","my app"]""",
            GetDockerfileLine(dockerfileContents, "ENTRYPOINT"));
    }
 
    // Every character the classifier treats as inert is baked into an exec-form ENTRYPOINT verbatim, so a
    // character that actually means something to `sh` would silently change the command. These two theories
    // pin the boundary; the shell-routed set was verified against a real /bin/sh.
    [Theory]
    [InlineData("--port 8080", new[] { "--port", "8080" })]
    [InlineData("--name 'my app'", new[] { "--name", "my app" })]
    [InlineData("""--path "/a b" """, new[] { "--path", "/a b" })]
    [InlineData("--flag=a,b", new[] { "--flag=a,b" })]
    [InlineData("--email a@b.co", new[] { "--email", "a@b.co" })]
    [InlineData("--pct 50%", new[] { "--pct", "50%" })]
    [InlineData("--not !x", new[] { "--not", "!x" })]
    [InlineData("--caret a^b", new[] { "--caret", "a^b" })]
    [InlineData("--plus a+b", new[] { "--plus", "a+b" })]
    public void BuildDenoPackageScriptEntrypoint_KeepsShellInertArgumentsInExecForm(string runScriptArguments, string[] expectedArgumentTokens)
    {
        var entrypoint = JavaScriptHostingExtensions.BuildDenoPackageScriptEntrypoint("deno", "task", "start", runScriptArguments);
 
        Assert.Equal(["deno", "task", "start", .. expectedArgumentTokens], entrypoint);
    }
 
    [Theory]
    // Expansion, substitution, operators, and redirection.
    [InlineData("--port $PORT")]
    [InlineData("--rev `git rev-parse HEAD`")]
    [InlineData("--a x|y")]
    [InlineData("--a x&y")]
    [InlineData("--a x;y")]
    [InlineData("--out >log")]
    [InlineData("--in <log")]
    [InlineData("--glob *.ts")]
    [InlineData("--glob ?.ts")]
    [InlineData("--home ~/app")]
    [InlineData("--sub (x)")]
    // Regressions the previous metacharacter denylist missed: bracket expressions and brace expansion glob,
    // '#' starts a comment that discards the rest of the line, and a newline separates commands.
    [InlineData("--glob [ab].ts")]
    [InlineData("--brace {a,b}.ts")]
    [InlineData("--tag #1")]
    [InlineData("--a x\ny")]
    public void BuildDenoPackageScriptEntrypoint_RoutesShellDependentArgumentsToShellForm(string runScriptArguments)
    {
        var entrypoint = JavaScriptHostingExtensions.BuildDenoPackageScriptEntrypoint("deno", "task", "start", runScriptArguments);
 
        Assert.Equal(["sh", "-c", $"exec deno task start {runScriptArguments}"], entrypoint);
    }
 
    [Theory]
    [InlineData("--name 'unterminated", "single")]
    [InlineData("--name \"unterminated", "double")]
    [InlineData("'", "single")]
    [InlineData("--flag=value --other 'still open", "single")]
    public void BuildDenoPackageScriptEntrypoint_ThrowsForUnterminatedQuote(string runScriptArguments, string quoteKind)
    {
        var exception = Assert.Throws<InvalidOperationException>(
            () => JavaScriptHostingExtensions.BuildDenoPackageScriptEntrypoint("deno", "task", "start", runScriptArguments));
 
        Assert.Equal(
            $"The Deno run script arguments '{runScriptArguments}' contain an unterminated {quoteKind} quote. Close the quote so the arguments can be parsed the way a shell would parse them.",
            exception.Message);
    }
 
    [Theory]
    [InlineData("--name 'closed'", new[] { "deno", "task", "start", "--name", "closed" })]
    [InlineData("--name \"closed\"", new[] { "deno", "task", "start", "--name", "closed" })]
    [InlineData("''", new[] { "deno", "task", "start", "" })]
    public void BuildDenoPackageScriptEntrypoint_AcceptsBalancedQuotes(string runScriptArguments, string[] expected)
    {
        var entrypoint = JavaScriptHostingExtensions.BuildDenoPackageScriptEntrypoint("deno", "task", "start", runScriptArguments);
 
        Assert.Equal(expected, entrypoint);
    }
 
    [Fact]
    public void BuildDenoPackageScriptEntrypoint_QuotesCommandComponentsInShellForm()
    {
        // Only runScriptArguments is meant to be shell-evaluated. A task name with a space would otherwise
        // word-split into `deno task build prod`, running the "build" task with a stray "prod" argument.
        var entrypoint = JavaScriptHostingExtensions.BuildDenoPackageScriptEntrypoint("deno", "task", "build prod", "-- --port $PORT");
 
        Assert.Equal(["sh", "-c", "exec deno task 'build prod' -- --port $PORT"], entrypoint);
    }
 
    [Fact]
    public async Task VerifyDockerfile_PublishAsPackageScriptPreservesPosixDoubleQuoteEscapes()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
        File.WriteAllText(Path.Combine(appDir, "deno.json"), """{"tasks":{"start":"deno run -A main.ts"}}""");
 
        // Inside double quotes POSIX only treats a backslash as an escape before $ ` " \ and newline, so
        // "\d+" must survive intact while "C:\\tmp" collapses to a single backslash.
        var app = builder.AddJavaScriptApp("js", appDir)
            .WithDeno()
            .PublishAsPackageScript("start", """-- --pattern "\d+" --path "C:\\tmp" --quote "say \"hi\"" """);
 
        await ManifestUtils.GetManifest(app.Resource, workspace.Path);
 
        var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile"));
        Assert.Equal(
            """ENTRYPOINT ["deno","task","start","--","--pattern","\\d+","--path","C:\\tmp","--quote","say \"hi\""]""",
            GetDockerfileLine(dockerfileContents, "ENTRYPOINT"));
    }
 
    [Fact]
    public async Task VerifyDockerfile_CacheUsesNoLockWhenConfigured()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
        File.WriteAllText(Path.Combine(appDir, "deno.lock"), "{}");
 
        var denoApp = builder.AddDenoApp("js", appDir, "main.ts")
            .WithDenoNoLock();
 
        await ManifestUtils.GetManifest(denoApp.Resource, workspace.Path);
 
        var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile"));
        Assert.Equal("RUN deno cache --no-lock main.ts", GetDockerfileLine(dockerfileContents, "RUN deno cache"));
    }
 
    [Fact]
    public async Task VerifyDockerfile_CacheDoesNotCombineRawNoLockWithFrozen()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
        File.WriteAllText(Path.Combine(appDir, "deno.lock"), "{}");
 
        var denoApp = builder.AddDenoApp("js", appDir, "main.ts")
            .WithDenoRuntimeArgs("--no-lock");
 
        await ManifestUtils.GetManifest(denoApp.Resource, workspace.Path);
 
        var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile"));
        Assert.Equal("RUN deno cache --no-lock main.ts", GetDockerfileLine(dockerfileContents, "RUN deno cache"));
    }
 
    [Fact]
    public async Task VerifyDockerfile_ManualNodeModulesDirThrows()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var denoApp = builder.AddDenoApp("denoapp", workspace.Path, "main.ts")
            .WithDenoNodeModulesDir(DenoNodeModulesDirMode.Manual);
 
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => ManifestUtils.GetManifest(denoApp.Resource, workspace.Path));
 
        Assert.Equal("The 'manual' node_modules mode is not supported by generated Deno Dockerfiles because node_modules is excluded from the build context. Use the 'auto' mode or provide a custom Dockerfile.", exception.Message);
    }
 
    [Fact]
    public async Task VerifyDockerfile_AlternatePackageManagerThrows()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var denoApp = builder.AddDenoApp("denoapp", workspace.Path, "main.ts")
            .WithRunScript("start")
            .WithNpm();
 
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => ManifestUtils.GetManifest(denoApp.Resource, workspace.Path));
 
        Assert.Equal("Generated Deno Dockerfiles do not support alternate package manager 'npm'. Use WithDeno() or provide a custom Dockerfile.", exception.Message);
    }
 
    [Fact]
    public async Task VerifyDockerfile_CacheUsesUnstableFlags()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
 
        var denoApp = builder.AddDenoApp("js", appDir, "main.ts")
            .WithDenoConfig("deno.json")
            .WithDenoUnstable("sloppy-imports");
 
        await ManifestUtils.GetManifest(denoApp.Resource, workspace.Path);
 
        var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile"));
        Assert.Equal(
            "RUN deno cache --config deno.json --unstable-sloppy-imports main.ts",
            GetDockerfileLine(dockerfileContents, "RUN deno cache"));
    }
 
    [Fact]
    public async Task VerifyDockerfile_CacheQuotesShellArguments()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
        File.WriteAllText(Path.Combine(appDir, "custom lock's.lock"), "{}");
 
        var denoApp = builder.AddDenoApp("js", appDir, "main file's.ts")
            .WithDenoConfig("deno config.json")
            .WithDenoImportMap("import map.json")
            .WithDenoLock("custom lock's.lock");
 
        await ManifestUtils.GetManifest(denoApp.Resource, workspace.Path);
 
        var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile"));
        Assert.Equal(
            """RUN deno cache --config 'deno config.json' --import-map 'import map.json' --lock 'custom lock'"'"'s.lock' --frozen 'main file'"'"'s.ts'""",
            GetDockerfileLine(dockerfileContents, "RUN deno cache"));
    }
 
    [Fact]
    public async Task VerifyDockerfile_EntrypointDoesNotIncludeDevelopmentFlags()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
 
        var denoApp = builder.AddDenoApp("js", appDir, "main.ts")
            .WithDenoWatch()
            .WithDenoInspect(DenoInspectMode.InspectWait, "127.0.0.1:9229");
 
        await ManifestUtils.GetManifest(denoApp.Resource, workspace.Path);
 
        var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile"));
        Assert.Equal("""ENTRYPOINT ["deno","run","--allow-net","--allow-env","--cached-only","main.ts"]""", GetDockerfileLine(dockerfileContents, "ENTRYPOINT"));
    }
 
    // --cached-only is an Aspire default, not a hard requirement. Deno accepts it alongside --reload without
    // error but --cached-only silently wins, so emitting both would turn the caller's explicit cache policy
    // into a no-op. Drop the default instead of overriding the caller.
    [Theory]
    [InlineData("--reload")]
    [InlineData("--reload=npm:chalk")]
    [InlineData("-r")]
    [InlineData("--cached-only")]
    public async Task VerifyDockerfile_EntrypointDropsManagedCachedOnlyWhenRuntimeArgsSelectACachePolicy(string cacheFlag)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
 
        var denoApp = builder.AddDenoApp("js", appDir, "main.ts")
            .WithDenoRuntimeArgs(cacheFlag);
 
        await ManifestUtils.GetManifest(denoApp.Resource, workspace.Path);
 
        var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile"));
        Assert.Equal($$"""ENTRYPOINT ["deno","run","--allow-net","--allow-env","{{cacheFlag}}","main.ts"]""", GetDockerfileLine(dockerfileContents, "ENTRYPOINT"));
    }
 
    [Fact]
    public async Task VerifyDockerfile_ServeEntrypointBindsEndpointTargetPort()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
 
        var denoApp = builder.AddDenoApp("js", appDir, "server.ts")
            .WithDenoServe()
            .WithHttpEndpoint(targetPort: 5173);
 
        await ManifestUtils.GetManifest(denoApp.Resource, workspace.Path);
 
        var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile"));
        Assert.Equal(
            """ENTRYPOINT ["deno","serve","--allow-net","--allow-env","--cached-only","--host","0.0.0.0","--port","5173","server.ts"]""",
            GetDockerfileLine(dockerfileContents, "ENTRYPOINT"));
    }
 
    [Fact]
    public async Task VerifyDockerfile_ServeEntrypointPreservesPreconfiguredEndpointTargetPort()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
 
        var denoApp = builder.AddDenoApp("js", appDir, "server.ts")
            .WithHttpEndpoint(targetPort: 5173)
            .WithDenoServe();
 
        await ManifestUtils.GetManifest(denoApp.Resource, workspace.Path);
 
        var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile"));
        Assert.Equal(
            """ENTRYPOINT ["deno","serve","--allow-net","--allow-env","--cached-only","--host","0.0.0.0","--port","5173","server.ts"]""",
            GetDockerfileLine(dockerfileContents, "ENTRYPOINT"));
    }
 
    [Fact]
    public async Task VerifyDockerfile_ServeDefaultEndpointPinsDenoDefaultPort()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
 
        var denoApp = builder.AddDenoApp("js", appDir, "server.ts")
            .WithDenoServe();
 
        var httpEndpoint = denoApp.Resource.GetEndpoint("http");
        Assert.Equal(8000, httpEndpoint.EndpointAnnotation.TargetPort);
 
        var manifest = await ManifestUtils.GetManifest(denoApp.Resource, workspace.Path);
        Assert.Equal(8000, manifest["bindings"]!["http"]!["targetPort"]!.GetValue<int>());
 
        var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile"));
        Assert.Equal(
            """ENTRYPOINT ["deno","serve","--allow-net","--allow-env","--cached-only","--host","0.0.0.0","--port","8000","server.ts"]""",
            GetDockerfileLine(dockerfileContents, "ENTRYPOINT"));
    }
 
    [Fact]
    public void WithDenoServe_DoesNotPinRunModeDefaultTargetPort()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
 
        var denoApp = builder.AddDenoApp("js", appDir, "server.ts")
            .WithDenoServe();
 
        Assert.Null(denoApp.Resource.GetEndpoint("http").EndpointAnnotation.TargetPort);
    }
 
    [Theory]
    [InlineData("task")]
    [InlineData("run")]
    public async Task WithDenoServe_WithdrawsHttpEndpointAndEnvironmentWhenAnotherModeWins(string finalMode)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run).WithResourceCleanUp(true);
        builder.Configuration["ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL"] = "http://localhost:4318";
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
 
        var denoApp = builder.AddDenoApp("js", appDir, "server.ts");
        var initialEnvironmentCallbackCount = denoApp.Resource.Annotations.OfType<EnvironmentCallbackAnnotation>().Count();
 
        denoApp.WithDenoServe();
        _ = finalMode == "task" ? denoApp.WithDenoTask("worker") : denoApp.WithDenoRun();
 
        // A non-serve mode does not bind HTTP, so leaving the endpoint behind would advertise a binding through
        // service discovery for a process that never listens, and would still inject PORT.
        Assert.Empty(denoApp.Resource.Annotations.OfType<EndpointAnnotation>());
        Assert.Equal(initialEnvironmentCallbackCount, denoApp.Resource.Annotations.OfType<EnvironmentCallbackAnnotation>().Count());
 
        var environment = await EnvironmentVariableEvaluator
            .GetEnvironmentVariablesAsync(denoApp.Resource, DistributedApplicationOperation.Run)
            .AsTask()
            .WaitAsync(TimeSpan.FromSeconds(5));
        Assert.Null(environment.GetValueOrDefault("PORT"));
    }
 
    [Fact]
    public async Task WithDenoServe_RestoresHttpEndpointAndEnvironmentWhenServeWinsAgain()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
 
        var denoApp = builder.AddDenoApp("js", appDir, "server.ts")
            .WithDenoServe()
            .WithDenoTask("worker")
            .WithDenoServe();
 
        var endpoint = Assert.Single(denoApp.Resource.Annotations.OfType<EndpointAnnotation>());
        Assert.Equal("http", endpoint.Name);
 
        var environment = await EnvironmentVariableEvaluator
            .GetEnvironmentVariablesAsync(denoApp.Resource, DistributedApplicationOperation.Publish)
            .AsTask()
            .WaitAsync(TimeSpan.FromSeconds(5));
        Assert.Equal(
            [
                new("NODE_ENV", "production"),
                new("PORT", "{js.bindings.http.targetPort}"),
            ],
            environment.OrderBy(pair => pair.Key));
    }
 
    [Fact]
    public void WithDenoTask_KeepsCallerConfiguredHttpEndpoint()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
 
        // The endpoint here is the caller's, not one WithDenoServe created, so switching modes must not remove it.
        var denoApp = builder.AddDenoApp("js", appDir, "server.ts")
            .WithHttpEndpoint(name: "api")
            .WithDenoTask("worker");
 
        var endpoint = Assert.Single(denoApp.Resource.Annotations.OfType<EndpointAnnotation>());
        Assert.Equal("api", endpoint.Name);
    }
 
    [Fact]
    public async Task WithDenoTask_RestoresCallerConfiguredSameNameHttpEndpoint()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run).WithResourceCleanUp(true);
        builder.Configuration["ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL"] = "http://localhost:4318";
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
 
        var denoApp = builder.AddDenoApp("js", appDir, "server.ts")
            .WithHttpEndpoint(targetPort: 4321, env: "APP_PORT");
        var callerEndpoint = denoApp.Resource.GetEndpoint("http").EndpointAnnotation;
        var initialEnvironmentCallbackCount = denoApp.Resource.Annotations.OfType<EnvironmentCallbackAnnotation>().Count();
 
        denoApp.WithDenoServe().WithDenoTask("worker");
 
        var endpoint = Assert.Single(denoApp.Resource.Annotations.OfType<EndpointAnnotation>());
        Assert.Same(callerEndpoint, endpoint);
        Assert.Equal(4321, endpoint.TargetPort);
        Assert.Equal(initialEnvironmentCallbackCount, denoApp.Resource.Annotations.OfType<EnvironmentCallbackAnnotation>().Count());
 
        var environment = await EnvironmentVariableEvaluator
            .GetEnvironmentVariablesAsync(denoApp.Resource, DistributedApplicationOperation.Run)
            .AsTask()
            .WaitAsync(TimeSpan.FromSeconds(5));
        Assert.Equal("4321", environment["APP_PORT"]);
        Assert.Null(environment.GetValueOrDefault("PORT"));
    }
 
    [Fact]
    public async Task VerifyDockerfile_ServeResourcesReuseProcessLocalDefaultTargetPort()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir1 = Path.Combine(workspace.Path, "js1");
        var appDir2 = Path.Combine(workspace.Path, "js2");
        Directory.CreateDirectory(appDir1);
        Directory.CreateDirectory(appDir2);
 
        _ = builder.AddDenoApp("js1", appDir1, "server.ts")
            .WithDenoServe();
        var denoApp2 = builder.AddDenoApp("js2", appDir2, "server.ts")
            .WithDenoServe();
 
        var httpEndpoint = denoApp2.Resource.GetEndpoint("http");
        Assert.Equal(8000, httpEndpoint.EndpointAnnotation.TargetPort);
 
        await ManifestUtils.GetManifest(denoApp2.Resource, workspace.Path);
 
        var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js2.Dockerfile"));
        Assert.Equal(
            """ENTRYPOINT ["deno","serve","--allow-net","--allow-env","--cached-only","--host","0.0.0.0","--port","8000","server.ts"]""",
            GetDockerfileLine(dockerfileContents, "ENTRYPOINT"));
    }
 
    [Fact]
    public void AddDenoApp_DoesNotAddDenoPackageManagerWhenNoManifest()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        File.WriteAllText(Path.Combine(workspace.Path, "main.ts"), "console.log('hi');");
 
        var builder = DistributedApplication.CreateBuilder();
 
        builder.AddDenoApp("denoapp", workspace.Path, "main.ts");
 
        using var app = builder.Build();
 
        var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
 
        var denoResource = Assert.Single(appModel.Resources.OfType<DenoAppResource>());
 
        // No package.json/deno.json: don't auto-configure Deno as a package manager and don't add an installer.
        Assert.False(denoResource.TryGetLastAnnotation<JavaScriptPackageManagerAnnotation>(out _));
        Assert.False(denoResource.TryGetLastAnnotation<JavaScriptInstallCommandAnnotation>(out _));
        Assert.Empty(appModel.Resources.OfType<JavaScriptInstallerResource>());
    }
 
    [Fact]
    public void AddDenoApp_AddsDenoPackageManagerWhenPackageJsonExists()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        File.WriteAllText(Path.Combine(workspace.Path, "package.json"), "{}");
 
        var builder = DistributedApplication.CreateBuilder();
 
        builder.AddDenoApp("denoapp", workspace.Path, "main.ts");
 
        using var app = builder.Build();
 
        var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
 
        var denoResource = Assert.Single(appModel.Resources.OfType<DenoAppResource>());
 
        Assert.True(denoResource.TryGetLastAnnotation<JavaScriptPackageManagerAnnotation>(out var packageManager));
        Assert.Equal("deno", packageManager.ExecutableName);
        Assert.Equal("task", packageManager.ScriptCommand);
 
        Assert.True(denoResource.TryGetLastAnnotation<JavaScriptInstallCommandAnnotation>(out var installAnnotation));
        Assert.Equal(["install"], installAnnotation.Args);
 
        // Deno caches dependencies on first run, so no installer resource is created by default.
        Assert.Empty(appModel.Resources.OfType<JavaScriptInstallerResource>());
    }
 
    [Fact]
    public void AddDenoApp_AddsDenoPackageManagerWhenDenoJsonExists()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        File.WriteAllText(Path.Combine(workspace.Path, "deno.json"), "{}");
 
        var builder = DistributedApplication.CreateBuilder();
 
        builder.AddDenoApp("denoapp", workspace.Path, "main.ts");
 
        using var app = builder.Build();
 
        var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
 
        var denoResource = Assert.Single(appModel.Resources.OfType<DenoAppResource>());
 
        Assert.True(denoResource.TryGetLastAnnotation<JavaScriptPackageManagerAnnotation>(out var packageManager));
        Assert.Equal("deno", packageManager.ExecutableName);
        Assert.Equal("task", packageManager.ScriptCommand);
        Assert.Empty(appModel.Resources.OfType<JavaScriptInstallerResource>());
    }
 
    [Fact]
    public async Task AddDenoApp_DirectFile_ProducesRunDashAArgs()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        File.WriteAllText(Path.Combine(workspace.Path, "main.ts"), "console.log('hi');");
 
        var builder = DistributedApplication.CreateBuilder();
        builder.AddDenoApp("denoapp", workspace.Path, "main.ts");
 
        using var app = builder.Build();
 
        var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
 
        var denoResource = Assert.Single(appModel.Resources.OfType<DenoAppResource>());
 
        var args = await ArgumentEvaluator.GetArgumentListAsync(denoResource);
 
        // Deno requires the `run` subcommand and, unlike Node/Bun, a permission grant (`-A`) to read
        // env vars and open sockets.
        Assert.Collection(args,
            arg => Assert.Equal("run", arg),
            arg => Assert.Equal("-A", arg),
            arg => Assert.Equal("main.ts", arg));
    }
 
    [Fact]
    public async Task WithRunScript_SetsCustomTaskCommand()
    {
        var builder = DistributedApplication.CreateBuilder();
 
        builder.AddDenoApp("denoapp", ".", "main.ts")
            .WithDeno()
            .WithRunScript("start", ["--my-arg1"]);
 
        using var app = builder.Build();
 
        var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
 
        var denoResource = Assert.Single(appModel.Resources.OfType<DenoAppResource>());
 
        var args = await ArgumentEvaluator.GetArgumentListAsync(denoResource);
 
        // Deno runs package scripts through its task runner (`deno task <name>`).
        Assert.Collection(args,
            arg => Assert.Equal("task", arg),
            arg => Assert.Equal("start", arg),
            arg => Assert.Equal("--my-arg1", arg));
    }
 
    [Fact]
    public async Task WithRunScript_ComposesWithDenoFlagsAsTaskCommand()
    {
        var builder = DistributedApplication.CreateBuilder();
 
        builder.AddDenoApp("denoapp", ".", "main.ts")
            .WithDeno()
            .WithRunScript("start", ["--my-arg1"])
            .WithDenoConfig("deno.json")
            .WithDenoImportMap("import_map.json")
            .WithDenoLock("deno.lock")
            .WithDenoAllow(DenoPermissionKind.Net, "localhost");
 
        using var app = builder.Build();
 
        var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
        var denoResource = Assert.Single(appModel.Resources.OfType<DenoAppResource>());
        var args = await ArgumentEvaluator.GetArgumentListAsync(denoResource);
 
        // WithRunScript is still a Deno task launch when additional WithDeno* flags are added.
        // Task mode keeps valid task-level resolution flags and leaves permissions/import maps to the task body.
        Assert.Collection(args,
            arg => Assert.Equal("task", arg),
            arg => Assert.Equal("--config", arg),
            arg => Assert.Equal("deno.json", arg),
            arg => Assert.Equal("--lock", arg),
            arg => Assert.Equal("deno.lock", arg),
            arg => Assert.Equal("start", arg),
            arg => Assert.Equal("--my-arg1", arg));
    }
 
    [Fact]
    public async Task WithRunScript_ExplicitDenoRunUsesEntrypoint()
    {
        var args = await GetDenoArgsAsync(d => d
            .WithDeno()
            .WithRunScript("start", ["--my-arg1"])
            .WithDenoRun()
            .WithDenoAllowAll(false)
            .WithDenoConfig("deno.json"));
 
        Assert.Collection(args,
            arg => Assert.Equal("run", arg),
            arg => Assert.Equal("--config", arg),
            arg => Assert.Equal("deno.json", arg),
            arg => Assert.Equal("main.ts", arg));
    }
 
    [Fact]
    public void WithDenoAllow_NullBuilderThrowsArgumentNullException()
    {
        IResourceBuilder<DenoAppResource> builder = null!;
 
        var exception = Assert.Throws<ArgumentNullException>(() => builder.WithDenoAllow(DenoPermissionKind.Net));
 
        Assert.Equal("builder", exception.ParamName);
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public void WithDenoPermission_RejectsUndefinedKind(bool deny)
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var denoApp = builder.AddDenoApp("denoapp", AppContext.BaseDirectory, "main.ts");
 
        var exception = Assert.Throws<ArgumentOutOfRangeException>(() =>
        {
            if (deny)
            {
                denoApp.WithDenoDeny((DenoPermissionKind)42);
            }
            else
            {
                denoApp.WithDenoAllow((DenoPermissionKind)42);
            }
        });
 
        Assert.Equal("kind", exception.ParamName);
        Assert.Equal(
            "The permission kind must be a defined DenoPermissionKind value. (Parameter 'kind')\nActual value was 42.",
            exception.Message.ReplaceLineEndings("\n"));
    }
 
    [Theory]
    [InlineData(false, null)]
    [InlineData(true, "")]
    public void WithDenoPermission_RejectsNullOrEmptyValues(bool deny, string? value)
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var denoApp = builder.AddDenoApp("denoapp", AppContext.BaseDirectory, "main.ts");
 
        var exception = Assert.Throws<ArgumentException>(() =>
        {
            if (deny)
            {
                denoApp.WithDenoDeny(DenoPermissionKind.Read, value!);
            }
            else
            {
                denoApp.WithDenoAllow(DenoPermissionKind.Read, value!);
            }
        });
 
        Assert.Equal(
            "Deno permission values cannot be null or empty. (Parameter 'values')",
            exception.Message);
    }
 
    [Fact]
    public async Task WithDenoPermission_PreservesWhitespaceValue()
    {
        var args = await GetDenoArgsAsync(d => d.WithDenoAllow(DenoPermissionKind.Read, " "));
 
        Assert.Equal(["run", "--allow-read= ", "main.ts"], args);
    }
 
    [Fact]
    public void WithDenoInstallFalseDoesNotCreateInstallerWhenNoneExists()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
 
        builder.AddDenoApp("denoapp", workspace.Path, "main.ts")
            .WithDeno(install: false);
 
        using var app = builder.Build();
        var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
 
        Assert.Empty(appModel.Resources.OfType<JavaScriptInstallerResource>());
    }
 
    [Fact]
    public void WithDenoInstallTrueCreatesInstaller()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
 
        var app = builder.AddDenoApp("denoapp", workspace.Path, "main.ts")
            .WithDeno(install: true);
 
        using var distributedApplication = builder.Build();
        var appModel = distributedApplication.Services.GetRequiredService<DistributedApplicationModel>();
        var installer = Assert.Single(appModel.Resources.OfType<JavaScriptInstallerResource>());
 
        Assert.False(installer.TryGetLastAnnotation<ExplicitStartupAnnotation>(out _));
        Assert.Contains(app.Resource.Annotations.OfType<WaitAnnotation>(), wait => wait.Resource == installer);
    }
 
    [Fact]
    public void WithDenoInstallFalseDisablesExistingInstaller()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
 
        var app = builder.AddJavaScriptApp("js", workspace.Path)
            .WithDeno(install: false);
 
        using var distributedApplication = builder.Build();
        var appModel = distributedApplication.Services.GetRequiredService<DistributedApplicationModel>();
        var installer = Assert.Single(appModel.Resources.OfType<JavaScriptInstallerResource>());
 
        Assert.True(installer.TryGetLastAnnotation<ExplicitStartupAnnotation>(out _));
        Assert.DoesNotContain(app.Resource.Annotations.OfType<WaitAnnotation>(), wait => wait.Resource == installer);
    }
 
    [Fact]
    public void WithDenoInstallTrueReEnablesDisabledInstaller()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
 
        var app = builder.AddJavaScriptApp("js", workspace.Path)
            .WithDeno(install: false)
            .WithDeno(install: true);
 
        using var distributedApplication = builder.Build();
        var appModel = distributedApplication.Services.GetRequiredService<DistributedApplicationModel>();
        var installer = Assert.Single(appModel.Resources.OfType<JavaScriptInstallerResource>());
 
        Assert.False(installer.TryGetLastAnnotation<ExplicitStartupAnnotation>(out _));
        Assert.Single(app.Resource.Annotations.OfType<WaitAnnotation>(), wait => wait.Resource == installer);
    }
 
    [Fact]
    public async Task DenoOptionsWithAlternatePackageManagerThrows()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
 
        builder.AddDenoApp("denoapp", workspace.Path, "main.ts")
            .WithRunScript("start")
            .WithNpm()
            .WithDenoWatch();
 
        using var app = builder.Build();
 
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => app.StartAsync());
        Assert.Equal(
            "Deno command-line options configured with the WithDeno* methods cannot be combined with package manager 'npm' on resource 'denoapp'. Remove the WithDeno* options or use WithDeno().",
            exception.Message);
    }
 
    [Fact]
    public async Task VerifyDockerfile_NormalizesHostPathSeparatorsForContainerPaths()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
 
        // Windows AppHosts configure nested paths with backslashes; the Linux build/runtime stages need POSIX form.
        var denoApp = builder.AddDenoApp("js", appDir, @"src\main.ts")
            .WithDenoConfig(@"config\deno.json");
 
        await ManifestUtils.GetManifest(denoApp.Resource, workspace.Path);
 
        var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile"));
        Assert.Equal(
            "RUN deno cache --config config/deno.json src/main.ts",
            GetDockerfileLine(dockerfileContents, "RUN deno cache"));
        Assert.Equal(
            """ENTRYPOINT ["deno","run","--allow-net","--allow-env","--config","config/deno.json","--cached-only","src/main.ts"]""",
            GetDockerfileLine(dockerfileContents, "ENTRYPOINT"));
    }
 
    [Fact]
    public async Task VerifyDockerfile_NormalizesPathsThatStayWithinBuildContext()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
 
        var denoApp = builder.AddDenoApp("js", appDir, @"src\..\main.ts")
            .WithDenoConfig("config/../deno.json")
            .WithDenoImportMap(@"maps\..\import_map.json")
            .WithDenoLock("locks/../deno.lock");
 
        await ManifestUtils.GetManifest(denoApp.Resource, workspace.Path);
 
        var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile"));
        Assert.Equal(
            "RUN deno cache --config deno.json --import-map import_map.json --lock deno.lock main.ts",
            GetDockerfileLine(dockerfileContents, "RUN deno cache"));
        Assert.Equal(
            """ENTRYPOINT ["deno","run","--allow-net","--allow-env","--config","deno.json","--import-map","import_map.json","--lock","deno.lock","--cached-only","main.ts"]""",
            GetDockerfileLine(dockerfileContents, "ENTRYPOINT"));
    }
 
    [Theory]
    [InlineData("-c", "config/../deno.json", "RUN deno cache -c deno.json main.ts", """ENTRYPOINT ["deno","run","--allow-net","--allow-env","--cached-only","-c","deno.json","main.ts"]""")]
    [InlineData("-c=config/../deno.json", null, "RUN deno cache -c=deno.json main.ts", """ENTRYPOINT ["deno","run","--allow-net","--allow-env","--cached-only","-c=deno.json","main.ts"]""")]
    public async Task VerifyDockerfile_NormalizesConfigAliasPath(string flag, string? value, string expectedCacheCommand, string expectedEntrypoint)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
        string[] runtimeArgs = value is null ? [flag] : [flag, value];
 
        var denoApp = builder.AddDenoApp("js", appDir, "main.ts")
            .WithDenoRuntimeArgs(runtimeArgs);
 
        await ManifestUtils.GetManifest(denoApp.Resource, workspace.Path);
 
        var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile"));
        Assert.Equal(expectedCacheCommand, GetDockerfileLine(dockerfileContents, "RUN deno cache"));
        Assert.Equal(expectedEntrypoint, GetDockerfileLine(dockerfileContents, "ENTRYPOINT"));
    }
 
    [Fact]
    public async Task VerifyDockerfile_PreservesRemoteImportMapUrl()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
 
        var denoApp = builder.AddDenoApp("js", appDir, "main.ts")
            .WithDenoImportMap("https://example.com/import_map.json");
 
        await ManifestUtils.GetManifest(denoApp.Resource, workspace.Path);
 
        var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile"));
        Assert.Equal(
            "RUN deno cache --import-map https://example.com/import_map.json main.ts",
            GetDockerfileLine(dockerfileContents, "RUN deno cache"));
        Assert.Equal(
            """ENTRYPOINT ["deno","run","--allow-net","--allow-env","--import-map","https://example.com/import_map.json","--cached-only","main.ts"]""",
            GetDockerfileLine(dockerfileContents, "ENTRYPOINT"));
    }
 
    [Theory]
    [InlineData("../shared/deno.json")]
    [InlineData("/etc/deno.json")]
    [InlineData("..")]
    // Traversal embedded mid-path still resolves outside the build context.
    [InlineData("config/../../outside.json")]
    // ToDenoContainerPath rewrites backslashes, so this becomes the absolute container path /tmp/deno.json
    // even though Path.IsPathRooted reports false for it on Linux and macOS.
    [InlineData("\\tmp\\deno.json")]
    [InlineData("\\\\server\\share\\deno.json")]
    [InlineData("C:\\temp\\deno.json")]
    [InlineData("C:/temp/deno.json")]
    [InlineData("C:temp\\deno.json")]
    public async Task VerifyDockerfile_RejectsConfigPathOutsideBuildContext(string configFile)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "js");
        Directory.CreateDirectory(appDir);
 
        var denoApp = builder.AddDenoApp("js", appDir, "main.ts")
            .WithDenoConfig(configFile);
 
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(
            () => ManifestUtils.GetManifest(denoApp.Resource, workspace.Path));
        Assert.Equal(
            $"The path '{configFile}' configured with WithDenoConfig is outside the Deno application directory, so it is not part of the generated Dockerfile's build context. Move the file inside the application directory or provide a custom Dockerfile.",
            exception.Message);
    }
 
    // Helper: build a Deno resource, apply the given flag configuration, and evaluate the emitted argument list.
    private static async Task<IReadOnlyList<string>> GetDenoArgsAsync(Action<IResourceBuilder<DenoAppResource>> configure, string entrypoint = "main.ts")
    {
        var builder = DistributedApplication.CreateBuilder();
        var deno = builder.AddDenoApp("denoapp", ".", entrypoint);
        configure(deno);
 
        using var app = builder.Build();
        var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
        var denoResource = Assert.Single(appModel.Resources.OfType<DenoAppResource>());
 
        return await ArgumentEvaluator.GetArgumentListAsync(denoResource);
    }
 
    [Theory]
    [InlineData("--port", "3000")]
    [InlineData("--port=3000", null)]
    [InlineData("--host", "127.0.0.1")]
    [InlineData("--host=127.0.0.1", null)]
    public async Task WithDenoServe_RuntimeArgsOverridingHostOrPort_Throws(string flag, string? value)
    {
        // Deno rejects a repeated --host/--port ("cannot be used multiple times"), and serve mode always emits
        // the managed pair, so the resource would fail to start with a raw Deno parser error.
        string[] runtimeArgs = value is null ? [flag] : [flag, value];
 
        var ex = await Assert.ThrowsAsync<InvalidOperationException>(
            () => GetDenoArgsAsync(d => d.WithDenoServe().WithDenoRuntimeArgs(runtimeArgs)));
 
        Assert.Equal(
            $"The argument '{flag}' cannot be configured with WithDenoRuntimeArgs because WithDenoServe already emits --host and --port from the resource's endpoint, and Deno rejects those arguments when they are combined. Configure the endpoint instead, for example WithHttpEndpoint(port: 5005).",
            ex.Message);
    }
 
    // Deno hard-errors when a single-occurrence option is repeated ("cannot be used multiple times") or when
    // mutually exclusive flags are combined ("cannot be used with"), verified on 2.9.0. Both are clap errors
    // that never mention Aspire, so every managed flag must be guarded, not just the serve endpoint pair.
    [Theory]
    [InlineData("--config", "other.json")]
    [InlineData("--config=other.json", null)]
    [InlineData("-c", "other.json")]
    [InlineData("-c=other.json", null)]
    [InlineData("--no-config", null)]
    [InlineData("--import-map", "other.json")]
    [InlineData("--import-map=other.json", null)]
    [InlineData("--lock", "other.lock")]
    [InlineData("--lock=other.lock", null)]
    [InlineData("--no-lock", null)]
    [InlineData("--node-modules-dir", "none")]
    [InlineData("--node-modules-dir=none", null)]
    [InlineData("--watch", null)]
    [InlineData("--watch-hmr", null)]
    [InlineData("--inspect", null)]
    [InlineData("--inspect=127.0.0.1:9230", null)]
    public async Task WithDenoRuntimeArgs_DuplicatingAManagedFlag_Throws(string flag, string? value)
    {
        string[] runtimeArgs = value is null ? [flag] : [flag, value];
 
        var ex = await Assert.ThrowsAsync<InvalidOperationException>(
            () => GetDenoArgsAsync(d => d
                .WithDenoConfig("deno.json")
                .WithDenoImportMap("import_map.json")
                .WithDenoLock("deno.lock")
                .WithDenoNodeModulesDir()
                .WithDenoWatch()
                .WithDenoInspect()
                .WithDenoRuntimeArgs(runtimeArgs)));
 
        Assert.StartsWith($"The argument '{flag}' cannot be configured with WithDenoRuntimeArgs because ", ex.Message, StringComparison.Ordinal);
        Assert.EndsWith(" instead.", ex.Message, StringComparison.Ordinal);
    }
 
    [Theory]
    [InlineData("--config", "other.json")]
    [InlineData("--config=other.json", null)]
    [InlineData("-c", "other.json")]
    [InlineData("-c=other.json", null)]
    [InlineData("--no-config", null)]
    public async Task WithDenoRuntimeArgs_ConflictingConfigSpellingsThrowWithActionableGuidance(string flag, string? value)
    {
        string[] runtimeArgs = value is null ? [flag] : [flag, value];
 
        var ex = await Assert.ThrowsAsync<InvalidOperationException>(
            () => GetDenoArgsAsync(d => d
                .WithDenoConfig("deno.json")
                .WithDenoRuntimeArgs(runtimeArgs)));
 
        Assert.Equal(
            $"The argument '{flag}' cannot be configured with WithDenoRuntimeArgs because WithDenoConfig already emits --config, and Deno rejects those arguments when they are combined. Pass the configuration file to WithDenoConfig instead.",
            ex.Message);
    }
 
    [Fact]
    public async Task WithDenoRuntimeArgs_LockAndNoLockAreMutuallyExclusiveWithTheManagedFlag()
    {
        var ex = await Assert.ThrowsAsync<InvalidOperationException>(
            () => GetDenoArgsAsync(d => d
                .WithDenoNoLock()
                .WithDenoRuntimeArgs("--lock", "other.lock")));
 
        Assert.Equal(
            "The argument '--lock' cannot be configured with WithDenoRuntimeArgs because WithDenoNoLock already emits --no-lock, and Deno rejects those arguments when they are combined. Configure locking with WithDenoNoLock instead.",
            ex.Message);
    }
 
    [Theory]
    // Repeatable options merge in Deno, so layering extra grants over the managed ones must keep working.
    [InlineData("--allow-read", "/var")]
    [InlineData("--allow-net", "example.com")]
    [InlineData("--parallel", null)]
    [InlineData("--v8-flags=--max-old-space-size=4096", null)]
    public async Task WithDenoRuntimeArgs_NonConflictingArgumentsArePreserved(string flag, string? value)
    {
        string[] runtimeArgs = value is null ? [flag] : [flag, value];
 
        var args = await GetDenoArgsAsync(d => d
            .WithDenoAllow(DenoPermissionKind.Read, "/etc/app")
            .WithDenoConfig("deno.json")
            .WithDenoRuntimeArgs(runtimeArgs));
 
        Assert.Equal(["run", "--allow-read=/etc/app", "--config", "deno.json", .. runtimeArgs, "main.ts"], args);
    }
 
    // Task mode resolves configuration from deno.json and never emits --import-map or the development-only
    // watch/inspect flags, so those must stay available through the escape hatch.
    [Fact]
    public async Task WithDenoTask_RuntimeArgsForFlagsTaskModeDoesNotEmitArePreserved()
    {
        var args = await GetDenoArgsAsync(d => d
            .WithDenoTask("start")
            .WithDenoWatch()
            .WithDenoRuntimeArgs("--import-map", "import_map.json"));
 
        Assert.Equal(["task", "--import-map", "import_map.json", "start"], args);
    }
 
    [Fact]
    public async Task WithDenoTask_RuntimeArgsDuplicatingAManagedTaskFlag_Throws()
    {
        var ex = await Assert.ThrowsAsync<InvalidOperationException>(
            () => GetDenoArgsAsync(d => d
                .WithDenoTask("start")
                .WithDenoConfig("deno.json")
                .WithDenoRuntimeArgs("--config", "other.json")));
 
        Assert.Equal(
            "The argument '--config' cannot be configured with WithDenoRuntimeArgs because WithDenoConfig already emits --config, and Deno rejects those arguments when they are combined. Pass the configuration file to WithDenoConfig instead.",
            ex.Message);
    }
 
    [Fact]
    public async Task WithDenoServe_UnrelatedRuntimeArgsArePreserved()
    {
        var args = await GetDenoArgsAsync(d => d
            .WithDenoServe()
            .WithHttpEndpoint(targetPort: 5173)
            .WithDenoRuntimeArgs("--parallel"), entrypoint: "server.ts");
 
        // Runtime args are appended after the managed --host/--port pair, before the entrypoint.
        Assert.Collection(args,
            a => Assert.Equal("serve", a),
            a => Assert.Equal("-A", a),
            a => Assert.Equal("--host", a),
            a => Assert.Equal("localhost", a),
            a => Assert.Equal("--port", a),
            a => Assert.Equal("5173", a),
            a => Assert.Equal("--parallel", a),
            a => Assert.Equal("server.ts", a));
    }
 
    [Fact]
    public async Task WithDenoWatch_HmrAndPlainAreMutuallyExclusive()
    {
        // Deno rejects "--watch-hmr" combined with "--watch", so only the last selection may be emitted.
        var hmrLast = await GetDenoArgsAsync(d => d.WithDenoWatch().WithDenoWatch(hmr: true));
 
        Assert.Collection(hmrLast,
            a => Assert.Equal("run", a),
            a => Assert.Equal("-A", a),
            a => Assert.Equal("--watch-hmr", a),
            a => Assert.Equal("main.ts", a));
 
        var plainLast = await GetDenoArgsAsync(d => d.WithDenoWatch(hmr: true).WithDenoWatch());
 
        Assert.Collection(plainLast,
            a => Assert.Equal("run", a),
            a => Assert.Equal("-A", a),
            a => Assert.Equal("--watch", a),
            a => Assert.Equal("main.ts", a));
    }
 
    [Fact]
    public async Task WithDenoPermission_MutatingTheCallerArrayAfterTheCallDoesNotChangeTheCommandLine()
    {
        // Permission values are only read when the command line is materialized, so holding the caller's
        // params array by reference would let a later mutation silently rewrite the launch arguments.
        var hosts = new[] { "localhost", "api.internal" };
 
        var args = await GetDenoArgsAsync(d =>
        {
            d.WithDenoAllow(DenoPermissionKind.Net, hosts);
            hosts[0] = "evil.example";
            hosts[1] = "attacker.example";
        });
 
        Assert.Equal(["run", "--allow-net=localhost,api.internal", "main.ts"], args);
    }
 
    [Fact]
    public async Task WithDenoAllowAll_False_DropsBlanketGrant()
    {
        // Least-privilege: explicitly opting out of -A must not emit any allow-all flag; only `run <script>`.
        var args = await GetDenoArgsAsync(d => d.WithDenoAllowAll(false));
 
        Assert.Collection(args,
            a => Assert.Equal("run", a),
            a => Assert.Equal("main.ts", a));
    }
 
    [Fact]
    public async Task WithDenoDenyOnly_DefaultRunKeepsBlanketGrant()
    {
        var args = await GetDenoArgsAsync(d => d.WithDenoDeny(DenoPermissionKind.Read, "/secrets"));
 
        Assert.Equal(["run", "-A", "--deny-read=/secrets", "main.ts"], args);
    }
 
    [Fact]
    public async Task WithDenoGranularPermissions_EmitInCanonicalOrderWithValues()
    {
        // Configured out of canonical order and across allow/deny to prove deterministic ordering
        // (net, read, write, run, env, import, sys, ffi; allow before deny) independent of call order.
        // Every granular permission API is exercised here: each one is a distinct one-line call into
        // AddDenoPermission, so a swapped deny flag or mistyped kind would otherwise ship unnoticed.
        var args = await GetDenoArgsAsync(d => d
            .WithDenoAllow(DenoPermissionKind.Env, "PORT", "HOME")
            .WithDenoDeny(DenoPermissionKind.Env, "SECRET")
            .WithDenoDeny(DenoPermissionKind.Import, "evil.example")
            .WithDenoDeny(DenoPermissionKind.Net, "evil.example")
            .WithDenoAllow(DenoPermissionKind.Net, "localhost:8080", "api.internal")
            .WithDenoAllow(DenoPermissionKind.Read, "/etc/app")
            .WithDenoDeny(DenoPermissionKind.Read, "/etc/shadow")
            .WithDenoAllow(DenoPermissionKind.Write, "/var/app")
            .WithDenoDeny(DenoPermissionKind.Write)
            .WithDenoAllow(DenoPermissionKind.Run, "git")
            .WithDenoDeny(DenoPermissionKind.Run, "curl")
            .WithDenoAllow(DenoPermissionKind.Import, "cdn.example")
            .WithDenoAllow(DenoPermissionKind.Sys)
            .WithDenoDeny(DenoPermissionKind.Sys, "hostname")
            .WithDenoAllow(DenoPermissionKind.Ffi, "./native.so")
            .WithDenoDeny(DenoPermissionKind.Ffi, "./blocked.so"));
 
        Assert.Collection(args,
            a => Assert.Equal("run", a),
            a => Assert.Equal("--allow-net=localhost:8080,api.internal", a),
            a => Assert.Equal("--deny-net=evil.example", a),
            a => Assert.Equal("--allow-read=/etc/app", a),
            a => Assert.Equal("--deny-read=/etc/shadow", a),
            a => Assert.Equal("--allow-write=/var/app", a),
            a => Assert.Equal("--deny-write", a),
            a => Assert.Equal("--allow-run=git", a),
            a => Assert.Equal("--deny-run=curl", a),
            a => Assert.Equal("--allow-env=PORT,HOME", a),
            a => Assert.Equal("--deny-env=SECRET", a),
            a => Assert.Equal("--allow-import=cdn.example", a),
            a => Assert.Equal("--deny-import=evil.example", a),
            a => Assert.Equal("--allow-sys", a),
            a => Assert.Equal("--deny-sys=hostname", a),
            a => Assert.Equal("--allow-ffi=./native.so", a),
            a => Assert.Equal("--deny-ffi=./blocked.so", a),
            a => Assert.Equal("main.ts", a));
    }
 
    [Fact]
    public async Task WithDenoAllowAll_True_KeepsDenyFlagsButDropsRedundantAllows()
    {
        // -A subsumes granular allows; a deny flag still narrows it and must be preserved.
        var args = await GetDenoArgsAsync(d => d
            .WithDenoAllowAll()
            .WithDenoAllow(DenoPermissionKind.Net, "localhost")
            .WithDenoDeny(DenoPermissionKind.Write, "/etc"));
 
        Assert.Collection(args,
            a => Assert.Equal("run", a),
            a => Assert.Equal("-A", a),
            a => Assert.Equal("--deny-write=/etc", a),
            a => Assert.Equal("main.ts", a));
    }
 
    [Fact]
    public async Task WithDenoResolutionFlags_EmitConfigImportMapLockNodeModulesDir()
    {
        var args = await GetDenoArgsAsync(d => d
            .WithDenoAllowAll(false)
            .WithDenoConfig("deno.json")
            .WithDenoImportMap("import_map.json")
            .WithDenoLock("deno.lock")
            .WithDenoNodeModulesDir(DenoNodeModulesDirMode.Auto));
 
        Assert.Collection(args,
            a => Assert.Equal("run", a),
            a => Assert.Equal("--config", a),
            a => Assert.Equal("deno.json", a),
            a => Assert.Equal("--import-map", a),
            a => Assert.Equal("import_map.json", a),
            a => Assert.Equal("--lock", a),
            a => Assert.Equal("deno.lock", a),
            a => Assert.Equal("--node-modules-dir=auto", a),
            a => Assert.Equal("main.ts", a));
    }
 
    [Fact]
    public void WithDenoNodeModulesDir_RejectsUndefinedMode()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var denoApp = builder.AddDenoApp("denoapp", AppContext.BaseDirectory, "main.ts");
 
        var exception = Assert.Throws<ArgumentOutOfRangeException>(
            () => denoApp.WithDenoNodeModulesDir((DenoNodeModulesDirMode)42));
 
        Assert.Equal("mode", exception.ParamName);
        Assert.Equal(
            "The node_modules mode must be a defined DenoNodeModulesDirMode value. (Parameter 'mode')\nActual value was 42.",
            exception.Message.ReplaceLineEndings("\n"));
    }
 
    [Fact]
    public async Task WithDenoNoLock_OverridesLockAndEmitsNoLock()
    {
        var args = await GetDenoArgsAsync(d => d
            .WithDenoAllowAll(false)
            .WithDenoLock("deno.lock")
            .WithDenoNoLock()
            .WithDenoNodeModulesDir());
 
        Assert.Collection(args,
            a => Assert.Equal("run", a),
            a => Assert.Equal("--no-lock", a),
            a => Assert.Equal("--node-modules-dir", a),
            a => Assert.Equal("main.ts", a));
    }
 
    [Fact]
    public async Task WithDenoUnstable_NormalizesBareAndQualifiedFeatures()
    {
        var args = await GetDenoArgsAsync(d => d
            .WithDenoAllowAll(false)
            .WithDenoUnstable("kv", "worker-options")
            .WithDenoUnstable("--unstable-sloppy-imports"));
 
        Assert.Collection(args,
            a => Assert.Equal("run", a),
            a => Assert.Equal("--unstable-kv", a),
            a => Assert.Equal("--unstable-worker-options", a),
            a => Assert.Equal("--unstable-sloppy-imports", a),
            a => Assert.Equal("main.ts", a));
    }
 
    [Fact]
    public void WithDenoUnstable_RejectsQualifiedNonUnstableFlags()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var denoApp = builder.AddDenoApp("denoapp", AppContext.BaseDirectory, "main.ts");
 
        var exception = Assert.Throws<ArgumentException>(() => denoApp.WithDenoUnstable("--allow-all"));
 
        Assert.Equal("features", exception.ParamName);
    }
 
    [Fact]
    public async Task WithDenoWatchAndInspect_EmitInRuntimeFlagPosition()
    {
        var args = await GetDenoArgsAsync(d => d
            .WithDenoAllowAll(false)
            .WithDenoWatch()
            .WithDenoInspect(DenoInspectMode.InspectBrk, "127.0.0.1:9229"));
 
        Assert.Collection(args,
            a => Assert.Equal("run", a),
            a => Assert.Equal("--watch", a),
            a => Assert.Equal("--inspect-brk=127.0.0.1:9229", a),
            a => Assert.Equal("main.ts", a));
    }
 
    [Fact]
    public async Task WithDenoInspect_EmitsPlainInspectFlag()
    {
        var args = await GetDenoArgsAsync(d => d
            .WithDenoAllowAll(false)
            .WithDenoInspect(hostPort: "127.0.0.1:9229"));
 
        Assert.Equal(["run", "--inspect=127.0.0.1:9229", "main.ts"], args);
    }
 
    [Fact]
    public void WithDenoInspect_RejectsUndefinedMode()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var denoApp = builder.AddDenoApp("denoapp", AppContext.BaseDirectory, "main.ts");
 
        var exception = Assert.Throws<ArgumentOutOfRangeException>(
            () => denoApp.WithDenoInspect((DenoInspectMode)42));
 
        Assert.Equal("mode", exception.ParamName);
        Assert.Equal(
            "The inspect mode must be a defined DenoInspectMode value. (Parameter 'mode')\nActual value was 42.",
            exception.Message.ReplaceLineEndings("\n"));
    }
 
    [Fact]
    public async Task WithDenoWatchHmr_EmitsWatchHmrFlag()
    {
        var args = await GetDenoArgsAsync(d => d
            .WithDenoAllowAll(false)
            .WithDenoWatch(hmr: true)
            .WithDenoInspect(DenoInspectMode.InspectWait));
 
        Assert.Collection(args,
            a => Assert.Equal("run", a),
            a => Assert.Equal("--watch-hmr", a),
            a => Assert.Equal("--inspect-wait", a),
            a => Assert.Equal("main.ts", a));
    }
 
    [Fact]
    public async Task WithDenoWatch_RepeatedCallsEmitOnlyLastWatchMode()
    {
        var watchArgs = await GetDenoArgsAsync(d => d
            .WithDenoAllowAll(false)
            .WithDenoWatch(hmr: true)
            .WithDenoWatch());
 
        Assert.Collection(watchArgs,
            a => Assert.Equal("run", a),
            a => Assert.Equal("--watch", a),
            a => Assert.Equal("main.ts", a));
 
        var hmrArgs = await GetDenoArgsAsync(d => d
            .WithDenoAllowAll(false)
            .WithDenoWatch()
            .WithDenoWatch(hmr: true));
 
        Assert.Collection(hmrArgs,
            a => Assert.Equal("run", a),
            a => Assert.Equal("--watch-hmr", a),
            a => Assert.Equal("main.ts", a));
    }
 
    [Fact]
    public async Task WithDenoScriptArgs_AreEmittedAfterEntrypoint()
    {
        var args = await GetDenoArgsAsync(d => d
            .WithDenoScriptArgs("--port", "5000", "serve"));
 
        // Default -A grant preserved; script args follow the entrypoint.
        Assert.Collection(args,
            a => Assert.Equal("run", a),
            a => Assert.Equal("-A", a),
            a => Assert.Equal("main.ts", a),
            a => Assert.Equal("--port", a),
            a => Assert.Equal("5000", a),
            a => Assert.Equal("serve", a));
    }
 
    [Fact]
    public async Task WithDenoServe_EmitsServeMode()
    {
        var args = await GetDenoArgsAsync(d => d
            .WithDenoServe()
            .WithHttpEndpoint(targetPort: 5173)
            .WithDenoAllow(DenoPermissionKind.Net, "0.0.0.0:8000")
            .WithDenoScriptArgs("--config-arg"), entrypoint: "server.ts");
 
        Assert.Collection(args,
            a => Assert.Equal("serve", a),
            a => Assert.Equal("--allow-net=0.0.0.0:8000", a),
            a => Assert.Equal("--host", a),
            a => Assert.Equal("localhost", a),
            a => Assert.Equal("--port", a),
            a => Assert.Equal("5173", a),
            a => Assert.Equal("server.ts", a),
            a => Assert.Equal("--config-arg", a));
    }
 
    [Fact]
    public async Task WithDenoServe_PreservesPreconfiguredEndpointTargetPort()
    {
        var args = await GetDenoArgsAsync(d =>
        {
            d.WithHttpEndpoint(targetPort: 5173);
            d.WithDenoServe();
        }, entrypoint: "server.ts");
 
        Assert.Collection(args,
            a => Assert.Equal("serve", a),
            a => Assert.Equal("-A", a),
            a => Assert.Equal("--host", a),
            a => Assert.Equal("localhost", a),
            a => Assert.Equal("--port", a),
            a => Assert.Equal("5173", a),
            a => Assert.Equal("server.ts", a));
    }
 
    [Fact]
    public async Task WithDenoTask_EmitsTaskModeAndIgnoresPermissionFlags()
    {
        var args = await GetDenoArgsAsync(d => d
            .WithDenoTask("dev")
            .WithDenoAllow(DenoPermissionKind.Net, "localhost") // permissions belong to the task; must not be emitted
            .WithDenoConfig("deno.json")
            .WithDenoImportMap("import_map.json") // Deno 2.5.6 rejects --import-map on `deno task`
            .WithDenoLock("deno.lock")
            .WithDenoNodeModulesDir(DenoNodeModulesDirMode.Auto)
            .WithDenoScriptArgs("--flag"));
 
        Assert.Collection(args,
            a => Assert.Equal("task", a),
            a => Assert.Equal("--config", a),
            a => Assert.Equal("deno.json", a),
            a => Assert.Equal("--lock", a),
            a => Assert.Equal("deno.lock", a),
            a => Assert.Equal("--node-modules-dir=auto", a),
            a => Assert.Equal("dev", a),
            a => Assert.Equal("--flag", a));
    }
 
    [Fact]
    public async Task WithDenoRuntimeArgs_EscapeHatch_InjectsBeforeEntrypoint()
    {
        // AddExecutable-replacement escape hatch: any flag not covered by a dedicated method can be injected raw
        // before the script, giving parity with AddExecutable("name", "deno", workdir, args...).
        var args = await GetDenoArgsAsync(d => d
            .WithDenoAllowAll(false)
            .WithDenoRuntimeArgs("--v8-flags=--max-old-space-size=4096", "--seed", "42"));
 
        Assert.Collection(args,
            a => Assert.Equal("run", a),
            a => Assert.Equal("--v8-flags=--max-old-space-size=4096", a),
            a => Assert.Equal("--seed", a),
            a => Assert.Equal("42", a),
            a => Assert.Equal("main.ts", a));
    }
 
    [Fact]
    public async Task AddDenoApp_ReplacesAddExecutable_FullPolyglotConfiguration()
    {
        // A NetScript-style configuration that previously required dropping back to
        // AddExecutable("gateway", "deno", workdir, "run", "--allow-net", ... , "main.ts", "--serve")
        // is now fully expressible through AddDenoApp + fluent flags, and yields an equivalent arg vector.
        var args = await GetDenoArgsAsync(d => d
            .WithDenoAllow(DenoPermissionKind.Net, "0.0.0.0:8000", "db:5432")
            .WithDenoAllow(DenoPermissionKind.Env, "PORT", "DATABASE_URL")
            .WithDenoAllow(DenoPermissionKind.Read, "./config")
            .WithDenoConfig("deno.json")
            .WithDenoUnstable("kv")
            .WithDenoScriptArgs("--serve"), entrypoint: "main.ts");
 
        Assert.Collection(args,
            a => Assert.Equal("run", a),
            a => Assert.Equal("--allow-net=0.0.0.0:8000,db:5432", a),
            a => Assert.Equal("--allow-read=./config", a),
            a => Assert.Equal("--allow-env=PORT,DATABASE_URL", a),
            a => Assert.Equal("--config", a),
            a => Assert.Equal("deno.json", a),
            a => Assert.Equal("--unstable-kv", a),
            a => Assert.Equal("main.ts", a),
            a => Assert.Equal("--serve", a));
    }
 
    [Fact]
    public void AddDenoApp_PermissionValueContainingComma_Throws()
    {
        // Deno splits permission values on commas with no escape syntax, so a comma-containing value would
        // silently become several permissions - denying the requested one and granting unrelated ones.
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        var builder = DistributedApplication.CreateBuilder();
        var deno = builder.AddDenoApp("deno", workspace.WorkspaceRoot.FullName, "main.ts");
 
        var ex = Assert.Throws<ArgumentException>(() => deno.WithDenoAllow(DenoPermissionKind.Read, "data,secret"));
        Assert.Equal(
            "The value 'data,secret' cannot contain a comma. Deno separates --allow-read values with commas and provides no way to escape them, so this value would be interpreted as multiple permissions. Pass each value as a separate argument. (Parameter 'values')",
            ex.Message);
 
        var denyEx = Assert.Throws<ArgumentException>(() => deno.WithDenoDeny(DenoPermissionKind.Net, "a.example,b.example"));
        Assert.Equal(
            "The value 'a.example,b.example' cannot contain a comma. Deno separates --deny-net values with commas and provides no way to escape them, so this value would be interpreted as multiple permissions. Pass each value as a separate argument. (Parameter 'values')",
            denyEx.Message);
 
        // Separate arguments remain the supported way to express multiple values.
        deno.WithDenoAllow(DenoPermissionKind.Read, "data", "secret");
    }
 
    [Fact]
    public void AddDenoApp_UsesDenoCommand()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        var builder = DistributedApplication.CreateBuilder();
        var denoApp = builder.AddDenoApp("denoapp", workspace.Path, "main.ts");
 
        Assert.Equal("deno", denoApp.Resource.Command);
    }
 
    [Fact]
    public void AddDenoApp_ThrowsForNullBuilder()
    {
        Assert.Throws<ArgumentNullException>(() =>
            JavaScriptHostingExtensions.AddDenoApp(null!, "denoapp", ".", "main.ts"));
    }
 
    [Fact]
    public void AddDenoApp_ThrowsForEmptyName()
    {
        var builder = DistributedApplication.CreateBuilder();
        Assert.Throws<ArgumentException>(() => builder.AddDenoApp("", ".", "main.ts"));
    }
 
    [Fact]
    public void AddDenoApp_ThrowsForEmptyScriptPath()
    {
        var builder = DistributedApplication.CreateBuilder();
        Assert.Throws<ArgumentException>(() => builder.AddDenoApp("denoapp", ".", ""));
    }
 
    [Theory]
    [InlineData("/tmp/main.ts")]
    [InlineData("../main.ts")]
    [InlineData("sub/../../main.ts")]
    [InlineData("\\tmp\\main.ts")]
    [InlineData("\\\\server\\share\\main.ts")]
    [InlineData("C:\\temp\\main.ts")]
    [InlineData("C:/temp/main.ts")]
    [InlineData("C:temp\\main.ts")]
    public void AddDenoApp_ThrowsForScriptPathOutsideAppDirectory(string scriptPath)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var builder = DistributedApplication.CreateBuilder();
 
        var exception = Assert.Throws<ArgumentException>(() => builder.AddDenoApp("denoapp", workspace.Path, scriptPath));
 
        Assert.Equal("scriptPath", exception.ParamName);
    }
 
    [Fact]
    [RequiresFeature(TestFeature.Docker | TestFeature.ContainerImageBuild)]
    [OuterloopTest("Builds and runs a Docker image to verify the generated Deno Dockerfile serves HTTP")]
    public async Task VerifyDenoDockerfileBuildsAndRunsHttpEndpoint()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "deno-app");
        Directory.CreateDirectory(appDir);
        await File.WriteAllTextAsync(Path.Combine(appDir, "main.ts"), """
            const port = Number(Deno.env.get("PORT") ?? "8000");
            Deno.serve({ hostname: "0.0.0.0", port }, () => new Response("deno runtime ok"));
            """, TestContext.Current.CancellationToken);
 
        var denoApp = builder.AddDenoApp("deno-app", appDir, "main.ts");
 
        await ManifestUtils.GetManifest(denoApp.Resource, workspace.Path);
 
        var dockerfilePath = Path.Combine(workspace.Path, "deno-app.Dockerfile");
        Assert.True(File.Exists(dockerfilePath), $"Dockerfile should exist at {dockerfilePath}");
 
        var imageName = $"aspire-deno-runtime-test-{Guid.NewGuid():N}";
        string? containerId = null;
 
        try
        {
            var buildResult = await RunDockerCommandAsync($"build --network=host -t {imageName} -f \"{dockerfilePath}\" .", appDir);
            Assert.True(buildResult.ExitCode == 0, $"Docker build failed with exit code {buildResult.ExitCode}.\nStdout: {buildResult.Stdout}\nStderr: {buildResult.Stderr}");
 
            var runResult = await RunDockerCommandAsync($"run --rm -d -e PORT=8000 -p 127.0.0.1::8000 {imageName}", appDir);
            Assert.True(runResult.ExitCode == 0, $"Docker run failed with exit code {runResult.ExitCode}.\nStdout: {runResult.Stdout}\nStderr: {runResult.Stderr}");
            containerId = runResult.Stdout.Trim();
 
            var portResult = await RunDockerCommandAsync($"port {containerId} 8000/tcp", appDir);
            Assert.True(portResult.ExitCode == 0, $"Docker port failed with exit code {portResult.ExitCode}.\nStdout: {portResult.Stdout}\nStderr: {portResult.Stderr}");
 
            await WaitForHttpTextAsync($"http://{portResult.Stdout.Trim()}", "deno runtime ok");
        }
        finally
        {
            if (!string.IsNullOrEmpty(containerId))
            {
                await RunDockerCommandAsync($"rm -f {containerId}", appDir);
            }
 
            await RunDockerCommandAsync($"rmi {imageName}", appDir);
        }
    }
 
    [Fact]
    public async Task AddDenoApp_ConfiguresCertificateTrustForAppendScope()
    {
        var builder = DistributedApplication.CreateBuilder();
        var denoApp = builder.AddDenoApp("denoapp", ".", "main.ts");
 
        Assert.True(denoApp.Resource.TryGetLastAnnotation<CertificateTrustConfigurationCallbackAnnotation>(out var annotation));
 
        var envVars = new Dictionary<string, object>();
        var bundle = ReferenceExpression.Create($"/etc/ssl/aspire/bundle.crt");
        var dirs = ReferenceExpression.Create($"/etc/ssl/aspire/certs");
        var ctx = new CertificateTrustConfigurationCallbackAnnotationContext
        {
            ExecutionContext = CreateRunExecutionContext(builder.Services),
            Resource = denoApp.Resource,
            Arguments = [],
            EnvironmentVariables = envVars,
            CertificateBundlePath = bundle,
            CertificateDirectoriesPath = dirs,
            Scope = CertificateTrustScope.Append,
            CancellationToken = default,
        };
 
        await annotation.Callback(ctx);
 
        // Deno loads an additional PEM certificate via DENO_CERT on top of its bundled Mozilla store.
        // Its native OTLP exporter is implemented in Rust and reads the OpenTelemetry certificate variable.
        Assert.Same(bundle, envVars["DENO_CERT"]);
        Assert.Same(bundle, envVars["OTEL_EXPORTER_OTLP_CERTIFICATE"]);
    }
 
    [Fact]
    public async Task AddDenoApp_ConfiguresCertificateTrustForOverrideScope()
    {
        var builder = DistributedApplication.CreateBuilder();
        var denoApp = builder.AddDenoApp("denoapp", ".", "main.ts");
 
        Assert.True(denoApp.Resource.TryGetLastAnnotation<CertificateTrustConfigurationCallbackAnnotation>(out var annotation));
 
        var envVars = new Dictionary<string, object>();
        var ctx = new CertificateTrustConfigurationCallbackAnnotationContext
        {
            ExecutionContext = CreateRunExecutionContext(builder.Services),
            Resource = denoApp.Resource,
            Arguments = [],
            EnvironmentVariables = envVars,
            CertificateBundlePath = ReferenceExpression.Create($"/etc/ssl/aspire/bundle.crt"),
            CertificateDirectoriesPath = ReferenceExpression.Create($"/etc/ssl/aspire/certs"),
            Scope = CertificateTrustScope.Override,
            CancellationToken = default,
        };
 
        await annotation.Callback(ctx);
 
        Assert.Same(ctx.CertificateBundlePath, envVars["DENO_CERT"]);
        Assert.Same(ctx.CertificateBundlePath, envVars["OTEL_EXPORTER_OTLP_CERTIFICATE"]);
        Assert.Equal("", envVars["DENO_TLS_CA_STORE"]);
    }
 
    [Fact]
    public async Task AddDenoApp_ConfiguresCertificateTrustForSystemScope()
    {
        var builder = DistributedApplication.CreateBuilder();
        var denoApp = builder.AddDenoApp("denoapp", ".", "main.ts");
 
        Assert.True(denoApp.Resource.TryGetLastAnnotation<CertificateTrustConfigurationCallbackAnnotation>(out var annotation));
 
        var envVars = new Dictionary<string, object>();
        var ctx = new CertificateTrustConfigurationCallbackAnnotationContext
        {
            ExecutionContext = CreateRunExecutionContext(builder.Services),
            Resource = denoApp.Resource,
            Arguments = [],
            EnvironmentVariables = envVars,
            CertificateBundlePath = ReferenceExpression.Create($"/etc/ssl/aspire/bundle.crt"),
            CertificateDirectoriesPath = ReferenceExpression.Create($"/etc/ssl/aspire/certs"),
            Scope = CertificateTrustScope.System,
            CancellationToken = default,
        };
 
        await annotation.Callback(ctx);
 
        Assert.Equal("system", envVars["DENO_TLS_CA_STORE"]);
        Assert.Same(ctx.CertificateBundlePath, envVars["DENO_CERT"]);
        Assert.Same(ctx.CertificateBundlePath, envVars["OTEL_EXPORTER_OTLP_CERTIFICATE"]);
    }
 
    [Fact]
    public async Task AddDenoApp_EnablesNativeOpenTelemetry()
    {
        var builder = DistributedApplication.CreateBuilder();
        builder.Configuration["ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL"] = "http://localhost:4318";
        var denoApp = builder.AddDenoApp("denoapp", ".", "main.ts");
 
        var env = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(denoApp.Resource, DistributedApplicationOperation.Run);
 
        // Deno's built-in OpenTelemetry integration is enabled with a single environment variable.
        Assert.Equal("true", env["OTEL_DENO"]);
        Assert.Equal("http://localhost:4318", env["OTEL_EXPORTER_OTLP_ENDPOINT"]);
        Assert.Equal("http/protobuf", env["OTEL_EXPORTER_OTLP_PROTOCOL"]);
    }
 
    [Fact]
    public async Task AddDenoApp_RemainsRunnableWithoutDashboardOrExternalOtlpEndpoint()
    {
        using var builder = TestDistributedApplicationBuilder.Create(
            options => options.DisableDashboard = true,
            outputHelper);
        var denoApp = builder.AddDenoApp("denoapp", ".", "main.ts");
 
        var environment = await EnvironmentVariableEvaluator
            .GetEnvironmentVariablesAsync(denoApp.Resource, DistributedApplicationOperation.Run);
 
        Assert.Equal(
            [new("NODE_ENV", "production")],
            environment.OrderBy(pair => pair.Key));
    }
 
    [Fact]
    public async Task AddDenoApp_DoesNotEnableNativeOpenTelemetryInPlainPublishMode()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var denoApp = builder.AddDenoApp("denoapp", ".", "main.ts");
 
        var environment = await EnvironmentVariableEvaluator
            .GetEnvironmentVariablesAsync(denoApp.Resource, DistributedApplicationOperation.Publish);
 
        Assert.False(environment.ContainsKey("OTEL_EXPORTER_OTLP_ENDPOINT"));
        Assert.False(environment.ContainsKey("OTEL_DENO"));
    }
 
#pragma warning disable ASPIREEXTENSION001 // Type is for evaluation purposes only
 
    [Fact]
    public void DenoApp_WithVSCodeDebugging_AddsSupportsDebuggingAnnotation()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        var denoApp = builder.AddDenoApp("denoapp", workspace.Path, "main.ts");
 
        var annotation = denoApp.Resource.Annotations.OfType<SupportsDebuggingAnnotation>().SingleOrDefault();
        Assert.NotNull(annotation);
        Assert.Equal("deno", annotation.LaunchConfigurationType);
    }
 
    [Fact]
    public void DenoApp_WithVSCodeDebugging_DoesNotAddAnnotationInPublishMode()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        var denoApp = builder.AddDenoApp("denoapp", workspace.Path, "main.ts");
 
        var annotation = denoApp.Resource.Annotations.OfType<SupportsDebuggingAnnotation>().SingleOrDefault();
        Assert.Null(annotation);
    }
 
    [Fact]
    public void DenoApp_WithRunScript_AddsSupportsDebuggingAnnotation()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        var denoApp = builder.AddDenoApp("denoapp", workspace.Path, "main.ts")
            .WithRunScript("dev");
 
        var annotation = denoApp.Resource.Annotations.OfType<SupportsDebuggingAnnotation>().SingleOrDefault();
        Assert.NotNull(annotation);
        Assert.Equal("deno", annotation.LaunchConfigurationType);
    }
 
    [Fact]
    public async Task DenoApp_DirectFile_ProducesDenoRuntimeExecutable()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        var denoApp = builder.AddDenoApp("denoapp", workspace.Path, "main.ts");
 
        var launchConfig = await CreateLaunchConfigurationAsync(denoApp.Resource);
 
        Assert.Equal("deno", launchConfig.Type);
        Assert.Equal("deno", launchConfig.RuntimeExecutable);
        Assert.Equal("direct", launchConfig.LaunchMethod);
        Assert.Equal(Path.GetFullPath("main.ts", workspace.Path), launchConfig.ScriptPath);
    }
 
    [Fact]
    public async Task DenoApp_WithRunScriptAndPackageManager_ProducesDenoRuntimeExecutable()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        // AddDenoApp automatically calls WithDeno() when a deno.json exists, which makes the run-script a
        // package-manager invocation (deno task dev).
        File.WriteAllText(Path.Combine(workspace.Path, "deno.json"), "{}");
 
        var denoApp = builder.AddDenoApp("denoapp", workspace.Path, "main.ts")
            .WithRunScript("dev");
 
        var launchConfig = await CreateLaunchConfigurationAsync(denoApp.Resource);
 
        Assert.Equal("deno", launchConfig.Type);
        Assert.Equal("deno", launchConfig.RuntimeExecutable);
        Assert.Equal("package-manager", launchConfig.LaunchMethod);
    }
 
    [Fact]
    public async Task DenoApp_WithRunScriptAndExplicitDenoRun_ProducesDirectLaunchMethod()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        File.WriteAllText(Path.Combine(workspace.Path, "deno.json"), "{}");
 
        var denoApp = builder.AddDenoApp("denoapp", workspace.Path, "main.ts")
            .WithRunScript("dev")
            .WithDenoRun();
 
        var launchConfig = await CreateLaunchConfigurationAsync(denoApp.Resource);
 
        Assert.Equal("deno", launchConfig.Type);
        Assert.Equal("deno", launchConfig.RuntimeExecutable);
        Assert.Equal("direct", launchConfig.LaunchMethod);
    }
 
    [Fact]
    public async Task DenoApp_WithRunScriptAndExplicitDenoServe_ProducesDirectLaunchMethod()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        File.WriteAllText(Path.Combine(workspace.Path, "deno.json"), "{}");
 
        var denoApp = builder.AddDenoApp("denoapp", workspace.Path, "main.ts")
            .WithRunScript("dev")
            .WithDenoServe();
 
        var launchConfig = await CreateLaunchConfigurationAsync(denoApp.Resource);
 
        Assert.Equal("deno", launchConfig.Type);
        Assert.Equal("deno", launchConfig.RuntimeExecutable);
        Assert.Equal("direct", launchConfig.LaunchMethod);
    }
 
    [Fact]
    public async Task DenoApp_WithDenoTask_ProducesPackageManagerLaunchMethod()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        var denoApp = builder.AddDenoApp("denoapp", workspace.Path, "main.ts")
            .WithDenoTask("dev");
 
        var launchConfig = await CreateLaunchConfigurationAsync(denoApp.Resource);
 
        Assert.Equal("deno", launchConfig.Type);
        Assert.Equal("deno", launchConfig.RuntimeExecutable);
        Assert.Equal("package-manager", launchConfig.LaunchMethod);
    }
 
    [Fact]
    public async Task DenoApp_WithRunScriptAndNpm_ProducesNodeLaunchConfiguration()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        var denoApp = builder.AddDenoApp("denoapp", workspace.Path, "main.ts")
            .WithRunScript("dev")
            .WithNpm();
 
        var launchConfig = await CreateLaunchConfigurationAsync(denoApp.Resource);
 
        Assert.Equal("node", launchConfig.Type);
        Assert.Equal("npm", launchConfig.RuntimeExecutable);
        Assert.Equal("package-manager", launchConfig.LaunchMethod);
    }
 
    [Fact]
    public async Task DenoApp_LaunchConfigurationHonorsCancellation()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var cancellationTokenSource = new CancellationTokenSource();
        cancellationTokenSource.Cancel();
 
        var denoApp = builder.AddDenoApp("denoapp", workspace.Path, "main.ts");
 
        await Assert.ThrowsAnyAsync<OperationCanceledException>(
            () => CreateLaunchConfigurationAsync(denoApp.Resource, cancellationTokenSource.Token));
    }
 
    private static async Task<JavaScriptLaunchConfiguration> CreateLaunchConfigurationAsync(
        IResource resource,
        CancellationToken cancellationToken = default)
    {
        var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext(
            resource,
            cancellationToken: cancellationToken);
        return Assert.IsType<JavaScriptLaunchConfiguration>(
            await LaunchConfigurationTestHelpers.InvokeLaunchConfigurationProducerAsync(resource, callbackContext));
    }
 
    private static DistributedApplicationExecutionContext CreateRunExecutionContext(IServiceCollection services) =>
        new(new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run)
        {
            Services = services.BuildServiceProvider()
        });
 
    private static string GetDockerfileLine(string dockerfileContents, string prefix)
        => dockerfileContents.Split('\n').Select(line => line.TrimEnd('\r')).Single(line => line.StartsWith(prefix, StringComparison.Ordinal));
 
    private static async Task<(int ExitCode, string Stdout, string Stderr)> RunDockerCommandAsync(string arguments, string workingDirectory)
    {
        var processStartInfo = new ProcessStartInfo
        {
            FileName = "docker",
            Arguments = arguments,
            WorkingDirectory = workingDirectory,
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            UseShellExecute = false,
            CreateNoWindow = true
        };
 
        using var process = Process.Start(processStartInfo);
        Assert.NotNull(process);
 
        // Read both streams concurrently so a full pipe cannot deadlock the Docker process.
        var stdoutTask = process.StandardOutput.ReadToEndAsync();
        var stderrTask = process.StandardError.ReadToEndAsync();
 
        try
        {
            await process.WaitForExitAsync(TestContext.Current.CancellationToken);
        }
        catch (OperationCanceledException) when (TestContext.Current.CancellationToken.IsCancellationRequested)
        {
            if (!process.HasExited)
            {
                try
                {
                    process.Kill(entireProcessTree: true);
                }
                catch (InvalidOperationException) when (process.HasExited)
                {
                    // The process exited between HasExited and Kill.
                }
            }
 
            await process.WaitForExitAsync(CancellationToken.None);
            await Task.WhenAll(stdoutTask, stderrTask);
            throw;
        }
 
        return (process.ExitCode, await stdoutTask, await stderrTask);
    }
 
    private static async Task WaitForHttpTextAsync(string url, string expectedText)
    {
        using var httpClient = new HttpClient();
        string? lastError = null;
 
        for (var attempt = 0; attempt < 120; attempt++)
        {
            try
            {
                var response = await httpClient.GetStringAsync(url, TestContext.Current.CancellationToken);
                if (response.Contains(expectedText, StringComparison.Ordinal))
                {
                    return;
                }
 
                lastError = $"Response did not contain '{expectedText}': {response}";
            }
            catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
            {
                lastError = ex.Message;
            }
 
            await Task.Delay(500, TestContext.Current.CancellationToken);
        }
 
        throw new TimeoutException($"Timed out waiting for {url} to return '{expectedText}'. Last error: {lastError ?? "<none>"}");
    }
 
#pragma warning restore ASPIREEXTENSION001 // Type is for evaluation purposes only
}