File: AddViteAppTests.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 ASPIRECERTIFICATES001 // Type is for evaluation purposes only
#pragma warning disable ASPIREPIPELINES001 // Type is for evaluation purposes only
#pragma warning disable ASPIREJAVASCRIPT001 // Type is for evaluation purposes only
 
using System.Runtime.CompilerServices;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Tests.Utils;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Hosting.JavaScript.Tests;
 
public class AddViteAppTests(ITestOutputHelper outputHelper)
{
    [Fact]
    public async Task VerifyDefaultDockerfile()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        // Create vite directory to ensure manifest generates correct relative build context path
        var viteDir = Path.Combine(workspace.Path, "vite");
        Directory.CreateDirectory(viteDir);
 
        // Create a lock file so npm ci is used in the Dockerfile
        File.WriteAllText(Path.Combine(viteDir, "package-lock.json"), "empty");
 
        var nodeApp = builder.AddViteApp("vite", viteDir)
            .WithNpm(install: true);
 
        var manifest = await ManifestUtils.GetManifest(nodeApp.Resource, workspace.Path);
 
        var expectedManifest = $$"""
            {
              "type": "container.v1",
              "build": {
                "context": "vite",
                "dockerfile": "vite.Dockerfile",
                "buildOnly": true
              },
              "env": {
                "NODE_ENV": "production",
                "PORT": "{vite.bindings.http.targetPort}"
              },
              "bindings": {
                "http": {
                  "scheme": "http",
                  "protocol": "tcp",
                  "transport": "http",
                  "targetPort": 8000
                }
              }
            }
            """;
        Assert.Equal(expectedManifest, manifest.ToString());
 
        var dockerfilePath = Path.Combine(workspace.Path, "vite.Dockerfile");
        var dockerfileContents = File.ReadAllText(dockerfilePath);
        var expectedDockerfile = $$"""
            FROM node:22-slim
            WORKDIR /app
            COPY package*.json ./
            RUN --mount=type=cache,target=/root/.npm npm ci
            COPY . .
            RUN npm run build

            """.Replace("\r\n", "\n");
        Assert.Equal(expectedDockerfile, dockerfileContents);
 
        var dockerBuildAnnotation = nodeApp.Resource.Annotations.OfType<DockerfileBuildAnnotation>().Single();
        Assert.False(dockerBuildAnnotation.HasEntrypoint);
 
        var containerFilesSource = nodeApp.Resource.Annotations.OfType<ContainerFilesSourceAnnotation>().Single();
        Assert.Equal("/app/dist", containerFilesSource.SourcePath);
    }
 
    [Fact]
    public async Task VerifyDockerfileWhenPublishedAsStaticWebsite()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var viteDir = Path.Combine(workspace.Path, "vite");
        Directory.CreateDirectory(viteDir);
        File.WriteAllText(Path.Combine(viteDir, "package-lock.json"), "empty");
 
        var nodeApp = builder.AddViteApp("vite", viteDir)
            .WithNpm(install: true)
            .PublishAsStaticWebsite();
 
        await ManifestUtils.GetManifest(nodeApp.Resource, workspace.Path);
 
        var dockerfilePath = Path.Combine(workspace.Path, "vite.Dockerfile");
        await Verify(File.ReadAllText(dockerfilePath));
 
        var dockerBuildAnnotation = nodeApp.Resource.Annotations.OfType<DockerfileBuildAnnotation>().Single();
        Assert.True(dockerBuildAnnotation.HasEntrypoint);
 
        var containerFilesSource = nodeApp.Resource.Annotations.OfType<ContainerFilesSourceAnnotation>().Single();
        Assert.Equal("/app/dist", containerFilesSource.SourcePath);
    }
 
    [Fact]
    public async Task VerifyDockerfileWhenPublishedAsStaticWebsiteWithApiProxy()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var viteDir = Path.Combine(workspace.Path, "vite");
        Directory.CreateDirectory(viteDir);
        File.WriteAllText(Path.Combine(viteDir, "package-lock.json"), "empty");
 
        var apiDir = Path.Combine(workspace.Path, "api");
        Directory.CreateDirectory(apiDir);
        File.WriteAllText(Path.Combine(apiDir, "package-lock.json"), "empty");
 
        var api = builder.AddNodeApp("api", apiDir, "server.js")
            .WithHttpEndpoint(name: "http", targetPort: 3001);
 
        var nodeApp = builder.AddViteApp("vite", viteDir)
            .WithNpm(install: true)
            .PublishAsStaticWebsite("/api", api);
 
        await ManifestUtils.GetManifest(nodeApp.Resource, workspace.Path);
 
        var dockerfilePath = Path.Combine(workspace.Path, "vite.Dockerfile");
        await Verify(File.ReadAllText(dockerfilePath));
    }
 
    [Fact]
    public async Task PublishAsStaticWebsiteSetsYarpEnvironmentVariables()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var viteDir = Path.Combine(workspace.Path, "vite");
        Directory.CreateDirectory(viteDir);
        File.WriteAllText(Path.Combine(viteDir, "package-lock.json"), "empty");
 
        var nodeApp = builder.AddViteApp("vite", viteDir)
            .WithNpm(install: true)
            .PublishAsStaticWebsite();
 
        var env = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(nodeApp.Resource, DistributedApplicationOperation.Publish);
 
        Assert.Equal("true", env["YARP_ENABLE_STATIC_FILES"]);
        // PORT is set by the endpoint — verify it's present
        Assert.True(env.ContainsKey("PORT"), "PORT should be set by the HTTP endpoint");
    }
 
    [Fact]
    public async Task PublishAsStaticWebsiteWithApiProxySetsReverseProxyEnvironmentVariables()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var viteDir = Path.Combine(workspace.Path, "vite");
        Directory.CreateDirectory(viteDir);
        File.WriteAllText(Path.Combine(viteDir, "package-lock.json"), "empty");
 
        var apiDir = Path.Combine(workspace.Path, "api");
        Directory.CreateDirectory(apiDir);
        File.WriteAllText(Path.Combine(apiDir, "package-lock.json"), "empty");
 
        var api = builder.AddNodeApp("api", apiDir, "server.js")
            .WithHttpEndpoint(name: "http", targetPort: 3001);
 
        var nodeApp = builder.AddViteApp("vite", viteDir)
            .WithNpm(install: true)
            .PublishAsStaticWebsite("/api", api);
 
        var env = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(nodeApp.Resource, DistributedApplicationOperation.Publish);
 
        Assert.Equal("true", env["YARP_ENABLE_STATIC_FILES"]);
        Assert.Equal("api", env["REVERSEPROXY__ROUTES__api__CLUSTERID"]);
        Assert.Equal("/api/{**catch-all}", env["REVERSEPROXY__ROUTES__api__MATCH__PATH"]);
        Assert.False(env.ContainsKey("REVERSEPROXY__ROUTES__api__TRANSFORMS__0__PATHREMOVEPREFIX"), "StripPrefix defaults to false");
        Assert.True(env.ContainsKey("REVERSEPROXY__CLUSTERS__api__DESTINATIONS__destination1__ADDRESS"));
    }
 
    [Fact]
    public async Task VerifyDockerfileWhenPublishedAsStaticWebsiteWithCustomOutputPath()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "angular");
        Directory.CreateDirectory(appDir);
        File.WriteAllText(Path.Combine(appDir, "package-lock.json"), "empty");
 
        var nodeApp = builder.AddViteApp("angular", appDir)
            .WithNpm(install: true)
            .PublishAsStaticWebsite(o => o.OutputPath = "dist/browser");
 
        await ManifestUtils.GetManifest(nodeApp.Resource, workspace.Path);
 
        var dockerfilePath = Path.Combine(workspace.Path, "angular.Dockerfile");
        await Verify(File.ReadAllText(dockerfilePath));
 
        var dockerBuildAnnotation = nodeApp.Resource.Annotations.OfType<DockerfileBuildAnnotation>().Single();
        Assert.True(dockerBuildAnnotation.HasEntrypoint);
 
        var containerFilesSource = nodeApp.Resource.Annotations.OfType<ContainerFilesSourceAnnotation>().Single();
        Assert.Equal("/app/dist/browser", containerFilesSource.SourcePath);
    }
 
    [Fact]
    public async Task VerifyDockerfileWhenPublishedAsNodeServer()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "vite");
        Directory.CreateDirectory(appDir);
        File.WriteAllText(Path.Combine(appDir, "package-lock.json"), "empty");
 
        var nodeApp = builder.AddViteApp("vite", appDir)
            .WithNpm(install: true)
            .PublishAsNodeServer(".output/server/index.mjs", ".output");
 
        await ManifestUtils.GetManifest(nodeApp.Resource, workspace.Path);
 
        var dockerfilePath = Path.Combine(workspace.Path, "vite.Dockerfile");
        await Verify(File.ReadAllText(dockerfilePath));
 
        var containerFilesSource = nodeApp.Resource.Annotations.OfType<ContainerFilesSourceAnnotation>().Single();
        Assert.Equal("/app/.output", containerFilesSource.SourcePath);
    }
 
    [Fact]
    public async Task VerifyDockerfileWhenPublishedAsNextStandalone()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var nextDir = Path.Combine(workspace.Path, "nextjs");
        Directory.CreateDirectory(nextDir);
        File.WriteAllText(Path.Combine(nextDir, "package-lock.json"), "empty");
 
        var nodeApp = builder.AddNextJsApp("nextjs", nextDir)
            .WithNpm(install: true);
 
        await ManifestUtils.GetManifest(nodeApp.Resource, workspace.Path);
 
        var dockerfilePath = Path.Combine(workspace.Path, "nextjs.Dockerfile");
        await Verify(File.ReadAllText(dockerfilePath));
 
        Assert.Empty(nodeApp.Resource.Annotations.OfType<ContainerFilesSourceAnnotation>());
    }
 
    [Fact]
    public async Task VerifyDockerfileWhenNextJsAppUsesPnpm()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var nextDir = Path.Combine(workspace.Path, "nextjs");
        Directory.CreateDirectory(nextDir);
        File.WriteAllText(Path.Combine(nextDir, "pnpm-lock.yaml"), "");
 
        var nodeApp = builder.AddNextJsApp("nextjs", nextDir)
            .WithPnpm(install: true);
 
        await ManifestUtils.GetManifest(nodeApp.Resource, workspace.Path);
 
        var dockerfilePath = Path.Combine(workspace.Path, "nextjs.Dockerfile");
        await Verify(File.ReadAllText(dockerfilePath));
    }
 
    [Fact]
    public async Task VerifyDockerfileWhenPackageScriptUsesPnpm()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "nuxt");
        Directory.CreateDirectory(appDir);
        File.WriteAllText(Path.Combine(appDir, "pnpm-lock.yaml"), "");
 
        var nodeApp = builder.AddViteApp("nuxt", appDir)
            .WithPnpm(install: true)
            .PublishAsPackageScript("start");
 
        await ManifestUtils.GetManifest(nodeApp.Resource, workspace.Path);
 
        var dockerfilePath = Path.Combine(workspace.Path, "nuxt.Dockerfile");
        await Verify(File.ReadAllText(dockerfilePath));
    }
 
    [Fact]
    public async Task VerifyDockerfileWhenPackageScriptUsesBun()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var appDir = Path.Combine(workspace.Path, "nuxt");
        Directory.CreateDirectory(appDir);
        File.WriteAllText(Path.Combine(appDir, "bun.lock"), "");
 
        var nodeApp = builder.AddViteApp("nuxt", appDir)
            .WithBun(install: true)
            .PublishAsPackageScript("start");
 
        await ManifestUtils.GetManifest(nodeApp.Resource, workspace.Path);
 
        var dockerfilePath = Path.Combine(workspace.Path, "nuxt.Dockerfile");
        await Verify(File.ReadAllText(dockerfilePath));
    }
 
    [Fact]
    public async Task VerifyDockerfileWithNodeVersionFromNvmrc()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        // Create an .nvmrc file
        File.WriteAllText(Path.Combine(workspace.Path, ".nvmrc"), "18.20.0");
 
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
        var nodeApp = builder.AddViteApp("vite", workspace.Path)
            .WithNpm();
 
        var manifest = await ManifestUtils.GetManifest(nodeApp.Resource, workspace.Path);
 
        var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "vite.Dockerfile"));
 
        // Should detect version 18 from .nvmrc
        Assert.Contains("FROM node:18-slim", dockerfileContents);
    }
 
    [Fact]
    public async Task VerifyDockerfileWithNodeVersionFromNodeVersion()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        // Create a .node-version file
        File.WriteAllText(Path.Combine(workspace.Path, ".node-version"), "v21.5.0");
 
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
        var nodeApp = builder.AddViteApp("vite", workspace.Path)
            .WithNpm();
 
        var manifest = await ManifestUtils.GetManifest(nodeApp.Resource, workspace.Path);
 
        var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "vite.Dockerfile"));
 
        // Should detect version 21 from .node-version
        Assert.Contains("FROM node:21-slim", dockerfileContents);
    }
 
    [Fact]
    public async Task VerifyDockerfileWithNodeVersionFromToolVersions()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        // Create a .tool-versions file
        var toolVersions = """
            ruby 3.2.0
            nodejs 19.8.1
            python 3.11.0
            """;
        File.WriteAllText(Path.Combine(workspace.Path, ".tool-versions"), toolVersions);
 
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
        var nodeApp = builder.AddViteApp("vite", workspace.Path)
            .WithNpm();
 
        var manifest = await ManifestUtils.GetManifest(nodeApp.Resource, workspace.Path);
 
        var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "vite.Dockerfile"));
 
        // Should detect version 19 from .tool-versions
        Assert.Contains("FROM node:19-slim", dockerfileContents);
    }
 
    [Fact]
    public async Task VerifyDockerfileWithNodeVersionFromToolVersionsUsingTabs()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        // Create a .tool-versions file using tabs between the tool name and version
        var toolVersions = string.Join(Environment.NewLine,
        [
            "ruby 3.2.0",
            "nodejs\t19.8.1",
            "python 3.11.0"
        ]);
        File.WriteAllText(Path.Combine(workspace.Path, ".tool-versions"), toolVersions);
 
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
        var nodeApp = builder.AddViteApp("vite", workspace.Path)
            .WithNpm();
 
        var manifest = await ManifestUtils.GetManifest(nodeApp.Resource, workspace.Path);
 
        var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "vite.Dockerfile"));
 
        Assert.Contains("FROM node:19-slim", dockerfileContents);
    }
 
    [Fact]
    public async Task VerifyDockerfileIgnoresPackageJsonEnginesWhenNoPinnedVersionExists()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        var packageJson = """
            {
              "name": "test-vite",
              "engines": {
                "node": "18.x"
              }
            }
            """;
        File.WriteAllText(Path.Combine(workspace.Path, "package.json"), packageJson);
 
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
        var nodeApp = builder.AddViteApp("vite", workspace.Path)
            .WithNpm();
 
        var manifest = await ManifestUtils.GetManifest(nodeApp.Resource, workspace.Path);
 
        var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "vite.Dockerfile"));
 
        Assert.Contains("FROM node:22-slim", dockerfileContents);
    }
 
    [Fact]
    public async Task VerifyDockerfileDefaultsTo22WhenNoVersionFound()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        // Don't create any version files - should default to 22
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
        var nodeApp = builder.AddViteApp("vite", workspace.Path)
            .WithNpm();
 
        var manifest = await ManifestUtils.GetManifest(nodeApp.Resource, workspace.Path);
 
        var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "vite.Dockerfile"));
 
        // Should default to version 22
        Assert.Contains("FROM node:22-slim", dockerfileContents);
    }
 
    [Theory]
    [InlineData("18", "node:18-slim")]
    [InlineData("v20.1.0", "node:20-slim")]
    [InlineData(">=18.12", "node:18-slim")]
    [InlineData("^16.0.0", "node:16-slim")]
    [InlineData("~19.5.0", "node:19-slim")]
    public async Task VerifyDockerfileHandlesVariousVersionFormats(string versionString, string expectedImage)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        File.WriteAllText(Path.Combine(workspace.Path, ".nvmrc"), versionString);
 
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
        var nodeApp = builder.AddViteApp("vite", workspace.Path)
            .WithNpm();
 
        var manifest = await ManifestUtils.GetManifest(nodeApp.Resource, workspace.Path);
 
        var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "vite.Dockerfile"));
 
        Assert.Contains($"FROM {expectedImage}", dockerfileContents);
    }
 
    [Fact]
    public async Task VerifyCustomBaseImage()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: workspace.Path).WithResourceCleanUp(true);
 
        var customImage = "node:22-myspecialimage";
        var nodeApp = builder.AddViteApp("vite", workspace.Path)
            .WithNpm(install: true)
            .WithDockerfileBaseImage(buildImage: customImage);
 
        var manifest = await ManifestUtils.GetManifest(nodeApp.Resource, workspace.Path);
 
        // Verify the manifest structure
        Assert.Equal("container.v1", manifest["type"]?.ToString());
 
        // Verify the Dockerfile contains the custom base image
        var dockerfileContents = File.ReadAllText(Path.Combine(workspace.Path, "vite.Dockerfile"));
        Assert.Contains($"FROM {customImage}", dockerfileContents);
    }
 
    [Fact]
    public void AddViteApp_WithViteConfigPath_AppliesConfigArgument()
    {
        var builder = DistributedApplication.CreateBuilder();
 
        var viteApp = builder.AddViteApp("test-app", "./test-app")
            .WithViteConfig("custom.vite.config.js");
 
        using var app = builder.Build();
 
        var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
        var nodeResource = Assert.Single(appModel.Resources.OfType<ViteAppResource>());
 
        // Get the first command line args annotation (WithDebugSupport adds additional callbacks)
        var commandLineArgsAnnotation = nodeResource.Annotations.OfType<CommandLineArgsCallbackAnnotation>().First();
        var args = new List<object>();
        var context = new CommandLineArgsCallbackContext(args, nodeResource);
        commandLineArgsAnnotation.Callback(context);
 
        // Should include --config argument
        Assert.Contains("--config", args);
        var configIndex = args.IndexOf("--config");
        Assert.True(configIndex >= 0 && configIndex + 1 < args.Count);
        Assert.Equal("custom.vite.config.js", args[configIndex + 1]);
    }
 
    [Fact]
    public void AddViteApp_WithoutViteConfigPath_DoesNotApplyConfigArgument()
    {
        var builder = DistributedApplication.CreateBuilder();
 
        var viteApp = builder.AddViteApp("test-app", "./test-app");
 
        using var app = builder.Build();
 
        var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
        var nodeResource = Assert.Single(appModel.Resources.OfType<ViteAppResource>());
 
        // Get the first command line args annotation (WithDebugSupport adds additional callbacks)
        var commandLineArgsAnnotation = nodeResource.Annotations.OfType<CommandLineArgsCallbackAnnotation>().First();
        var args = new List<object>();
        var context = new CommandLineArgsCallbackContext(args, nodeResource);
        commandLineArgsAnnotation.Callback(context);
 
        // Should NOT include --config argument in base args
        Assert.DoesNotContain("--config", args);
    }
 
    [Fact]
    public async Task AddViteApp_ServerAuthCertConfig_WithExistingConfigArgument_ReplacesConfigPath()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        // Create node_modules directory for wrapper config generation
        Directory.CreateDirectory(Path.Combine(workspace.Path, "node_modules"));
 
        // Create a vite config file
        var viteConfigPath = Path.Combine(workspace.Path, "vite.config.js");
        File.WriteAllText(viteConfigPath, "export default {}");
 
        var builder = DistributedApplication.CreateBuilder();
        var viteApp = builder.AddViteApp("test-app", workspace.Path)
            .WithViteConfig("vite.config.js");
 
        using var app = builder.Build();
 
        var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
        var nodeResource = Assert.Single(appModel.Resources.OfType<ViteAppResource>());
 
        // Get the HttpsCertificateConfigurationCallbackAnnotation
        var certConfigAnnotation = nodeResource.Annotations
            .OfType<HttpsCertificateConfigurationCallbackAnnotation>()
            .Single();
 
        // Set up a context to invoke the callback with an existing --config argument
        var args = new List<object> { "run", "dev", "--", "--port", "3000", "--config", "vite.config.js" };
        var env = new Dictionary<string, object>();
 
        var context = new HttpsCertificateConfigurationCallbackAnnotationContext
        {
            ExecutionContext = new DistributedApplicationExecutionContext(new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run) { Services = app.Services }),
            Resource = nodeResource,
            Arguments = args,
            EnvironmentVariables = env,
            CertificatePath = ReferenceExpression.Create($"cert.pem"),
            KeyPath = ReferenceExpression.Create($"key.pem"),
            CertificateWithKeyPath = ReferenceExpression.Create($"cert-with-key.pem"),
            PfxPath = ReferenceExpression.Create($"cert.pfx"),
            Password = null,
            CancellationToken = CancellationToken.None
        };
 
        // Invoke the callback
        await certConfigAnnotation.Callback(context);
 
        // Verify the existing --config was replaced with the Aspire wrapper path
        var configIndex = args.IndexOf("--config");
        Assert.True(configIndex >= 0);
        Assert.True(configIndex + 1 < args.Count);
        var newConfigPath = args[configIndex + 1] as string;
        Assert.NotNull(newConfigPath);
        Assert.Contains("aspire.", newConfigPath);
        Assert.Contains(Path.Combine("node_modules", ".aspire"), newConfigPath);
 
        // Verify environment variables were set
        Assert.Contains("TLS_CONFIG_PFX", env.Keys);
        Assert.IsType<ReferenceExpression>(env["TLS_CONFIG_PFX"]);
    }
 
    [Fact]
    public async Task AddViteApp_ServerAuthCertConfig_WithoutExistingConfigArgument_DetectsDefaultConfig()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        // Create node_modules directory for wrapper config generation
        Directory.CreateDirectory(Path.Combine(workspace.Path, "node_modules"));
 
        // Create a default vite config file that would be auto-detected
        var viteConfigPath = Path.Combine(workspace.Path, "vite.config.js");
        File.WriteAllText(viteConfigPath, "export default {}");
 
        var builder = DistributedApplication.CreateBuilder();
        var viteApp = builder.AddViteApp("test-app", workspace.Path);
 
        using var app = builder.Build();
 
        var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
        var nodeResource = Assert.Single(appModel.Resources.OfType<ViteAppResource>());
 
        // Get the HttpsCertificateConfigurationCallbackAnnotation
        var certConfigAnnotation = nodeResource.Annotations
            .OfType<HttpsCertificateConfigurationCallbackAnnotation>()
            .Single();
 
        // Set up a context without --config argument
        var args = new List<object> { "run", "dev", "--", "--port", "3000" };
        var env = new Dictionary<string, object>();
 
        var context = new HttpsCertificateConfigurationCallbackAnnotationContext
        {
            ExecutionContext = new DistributedApplicationExecutionContext(new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run) { Services = app.Services }),
            Resource = nodeResource,
            Arguments = args,
            EnvironmentVariables = env,
            CertificatePath = ReferenceExpression.Create($"cert.pem"),
            KeyPath = ReferenceExpression.Create($"key.pem"),
            CertificateWithKeyPath = ReferenceExpression.Create($"cert-with-key.pem"),
            PfxPath = ReferenceExpression.Create($"cert.pfx"),
            Password = null,
            CancellationToken = CancellationToken.None
        };
 
        // Invoke the callback
        await certConfigAnnotation.Callback(context);
 
        // Verify a --config was added with Aspire-specific path
        var configIndex = args.IndexOf("--config");
        Assert.True(configIndex >= 0);
        Assert.True(configIndex + 1 < args.Count);
        var newConfigPath = args[configIndex + 1] as string;
        Assert.NotNull(newConfigPath);
        Assert.Contains("aspire.vite.config.js", newConfigPath);
 
        // Verify environment variables were set
        Assert.Contains("TLS_CONFIG_PFX", env.Keys);
    }
 
    [Fact]
    public async Task AddViteApp_ServerAuthCertConfig_WithMissingConfigFile_DoesNotAddConfigArgument()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        // Don't create any vite config file
        var builder = DistributedApplication.CreateBuilder();
        var viteApp = builder.AddViteApp("test-app", workspace.Path);
 
        using var app = builder.Build();
 
        var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
        var nodeResource = Assert.Single(appModel.Resources.OfType<ViteAppResource>());
 
        // Get the HttpsCertificateConfigurationCallbackAnnotation
        var certConfigAnnotation = nodeResource.Annotations
            .OfType<HttpsCertificateConfigurationCallbackAnnotation>()
            .Single();
 
        // Set up a context without --config argument
        var args = new List<object> { "run", "dev", "--", "--port", "3000" };
        var env = new Dictionary<string, object>();
 
        var context = new HttpsCertificateConfigurationCallbackAnnotationContext
        {
            ExecutionContext = new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run),
            Resource = nodeResource,
            Arguments = args,
            EnvironmentVariables = env,
            CertificatePath = ReferenceExpression.Create($"cert.pem"),
            KeyPath = ReferenceExpression.Create($"key.pem"),
            CertificateWithKeyPath = ReferenceExpression.Create($"cert-with-key.pem"),
            PfxPath = ReferenceExpression.Create($"cert.pfx"),
            Password = null,
            CancellationToken = CancellationToken.None
        };
 
        // Invoke the callback
        await certConfigAnnotation.Callback(context);
 
        // Verify no --config was added
        Assert.DoesNotContain("--config", args);
 
        // Environment variables should NOT be set
        Assert.Empty(env);
    }
 
    [Fact]
    public async Task AddViteApp_ServerAuthCertConfig_WithMissingNodeModules_PreservesConfigArgument()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        var viteConfigPath = Path.Combine(workspace.Path, "vite.config.js");
        File.WriteAllText(viteConfigPath, "export default {}");
 
        var builder = DistributedApplication.CreateBuilder();
        builder.AddViteApp("test-app", workspace.Path);
 
        using var app = builder.Build();
 
        var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
        var nodeResource = Assert.Single(appModel.Resources.OfType<ViteAppResource>());
        var certConfigAnnotation = nodeResource.Annotations
            .OfType<HttpsCertificateConfigurationCallbackAnnotation>()
            .Single();
 
        var args = new List<object> { "run", "dev", "--", "--port", "3000", "--config", viteConfigPath };
        var env = new Dictionary<string, object>();
 
        var context = new HttpsCertificateConfigurationCallbackAnnotationContext
        {
            ExecutionContext = new DistributedApplicationExecutionContext(new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run) { Services = app.Services }),
            Resource = nodeResource,
            Arguments = args,
            EnvironmentVariables = env,
            CertificatePath = ReferenceExpression.Create($"cert.pem"),
            KeyPath = ReferenceExpression.Create($"key.pem"),
            CertificateWithKeyPath = ReferenceExpression.Create($"cert-with-key.pem"),
            PfxPath = ReferenceExpression.Create($"cert.pfx"),
            Password = null,
            CancellationToken = CancellationToken.None
        };
 
        await certConfigAnnotation.Callback(context);
 
        Assert.Equal(["run", "dev", "--", "--port", "3000", "--config", viteConfigPath], args);
        Assert.Empty(env);
    }
 
    [Fact]
    public async Task AddViteApp_ServerAuthCertConfig_WithPassword_SetsPasswordEnvironmentVariable()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        // Create node_modules directory for wrapper config generation
        Directory.CreateDirectory(Path.Combine(workspace.Path, "node_modules"));
 
        // Create a vite config file
        var viteConfigPath = Path.Combine(workspace.Path, "vite.config.js");
        File.WriteAllText(viteConfigPath, "export default {}");
 
        var builder = DistributedApplication.CreateBuilder();
        var viteApp = builder.AddViteApp("test-app", workspace.Path);
 
        using var app = builder.Build();
 
        var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
        var nodeResource = Assert.Single(appModel.Resources.OfType<ViteAppResource>());
 
        // Get the HttpsCertificateConfigurationCallbackAnnotation
        var certConfigAnnotation = nodeResource.Annotations
            .OfType<HttpsCertificateConfigurationCallbackAnnotation>()
            .Single();
 
        // Set up a context with a password
        var args = new List<object> { "run", "dev", "--", "--port", "3000" };
        var env = new Dictionary<string, object>();
 
        // Create a mock password provider
        var password = new TestValueProvider("test-password");
 
        var context = new HttpsCertificateConfigurationCallbackAnnotationContext
        {
            ExecutionContext = new DistributedApplicationExecutionContext(new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run) { Services = app.Services }),
            Resource = nodeResource,
            Arguments = args,
            EnvironmentVariables = env,
            CertificatePath = ReferenceExpression.Create($"cert.pem"),
            KeyPath = ReferenceExpression.Create($"key.pem"),
            CertificateWithKeyPath = ReferenceExpression.Create($"cert-with-key.pem"),
            PfxPath = ReferenceExpression.Create($"cert.pfx"),
            Password = password,
            CancellationToken = CancellationToken.None
        };
 
        // Invoke the callback
        await certConfigAnnotation.Callback(context);
 
        // Verify both PFX and password environment variables were set
        Assert.Contains("TLS_CONFIG_PFX", env.Keys);
        Assert.Contains("TLS_CONFIG_PASSWORD", env.Keys);
        Assert.Equal(password, env["TLS_CONFIG_PASSWORD"]);
    }
 
    [Fact]
    public async Task AddViteApp_ServerAuthCertConfig_WritesWrapperToNearestNodeModules()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        // Simulate a hoisted monorepo layout: node_modules is at the repo root, not in the app directory
        var repoRoot = Path.Combine(workspace.Path, "repo");
        var appDir = Path.Combine(repoRoot, "packages", "frontend");
        Directory.CreateDirectory(appDir);
        Directory.CreateDirectory(Path.Combine(repoRoot, "node_modules"));
 
        // Create a vite config file in the app directory
        var viteConfigPath = Path.Combine(appDir, "vite.config.ts");
        File.WriteAllText(viteConfigPath, "export default {}");
 
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
        builder.AddViteApp("test-app", appDir)
            .WithHttpsDeveloperCertificate();
        var appHostId = builder.Configuration["AppHost:Sha256"]![..10].ToLowerInvariant();
 
        using var app = builder.Build();
 
        // Execute the before-start hooks which triggers SubscribeHttpsEndpointsUpdate (endpoint scheme change)
        await ExecuteBeforeStartHooksAsync(app, CancellationToken.None);
 
        var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
        var viteResource = Assert.Single(appModel.Resources.OfType<ViteAppResource>());
 
        // Now invoke the cert config callback which generates the wrapper file
        var certConfigAnnotation = viteResource.Annotations
            .OfType<HttpsCertificateConfigurationCallbackAnnotation>()
            .Single();
 
        var args = new List<object> { "run", "dev", "--", "--port", "3000" };
        var env = new Dictionary<string, object>();
 
        var context = new HttpsCertificateConfigurationCallbackAnnotationContext
        {
            ExecutionContext = new DistributedApplicationExecutionContext(new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run) { Services = app.Services }),
            Resource = viteResource,
            Arguments = args,
            EnvironmentVariables = env,
            CertificatePath = ReferenceExpression.Create($"cert.pem"),
            KeyPath = ReferenceExpression.Create($"key.pem"),
            CertificateWithKeyPath = ReferenceExpression.Create($"cert-with-key.pem"),
            PfxPath = ReferenceExpression.Create($"cert.pfx"),
            Password = null,
            CancellationToken = CancellationToken.None
        };
 
        await certConfigAnnotation.Callback(context);
 
        // Verify the wrapper was written under the hoisted node_modules/.aspire (repo root, not app dir)
        var expectedDir = Path.Combine(repoRoot, "node_modules", ".aspire", appHostId, "test-app");
        Assert.True(Directory.Exists(expectedDir), $"Expected .aspire directory at {expectedDir}");
 
        var wrapperFiles = Directory.GetFiles(expectedDir, "aspire.vite.config.ts");
        Assert.Single(wrapperFiles);
 
        // Verify the --config argument points to the wrapper in the hoisted location
        var configIndex = args.IndexOf("--config");
        Assert.True(configIndex >= 0);
        var configPath = args[configIndex + 1] as string;
        Assert.NotNull(configPath);
        Assert.StartsWith(expectedDir, configPath);
 
        // Verify wrapper content
        var wrapperContent = File.ReadAllText(wrapperFiles[0]);
 
        Assert.Contains("import config from '../../../../packages/frontend/vite.config.ts'", wrapperContent);
 
        // The console.log line should contain properly escaped backslashes for JavaScript
        var absoluteConfigPath = Path.GetFullPath(viteConfigPath);
        var expectedEscapedPath = absoluteConfigPath.Replace("\\", "\\\\");
        Assert.Contains($"Found original Vite configuration at \"{expectedEscapedPath}\"", wrapperContent);
    }
 
    [Fact]
    public async Task AddViteApp_ServerAuthCertConfig_SharedNodeModules_WritesResourceSpecificWrappers()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        var repoRoot = Path.Combine(workspace.Path, "repo");
        var firstAppDirectory = Path.Combine(repoRoot, "packages", "frontend-a");
        var secondAppDirectory = Path.Combine(repoRoot, "packages", "frontend-b");
        Directory.CreateDirectory(firstAppDirectory);
        Directory.CreateDirectory(secondAppDirectory);
        Directory.CreateDirectory(Path.Combine(repoRoot, "node_modules"));
 
        var firstConfigPath = Path.Combine(firstAppDirectory, "vite.config.ts");
        var secondConfigPath = Path.Combine(secondAppDirectory, "vite.config.ts");
        File.WriteAllText(firstConfigPath, "export default { app: 'a' }");
        File.WriteAllText(secondConfigPath, "export default { app: 'b' }");
 
        var builder = DistributedApplication.CreateBuilder();
        builder.AddViteApp("frontend-a", firstAppDirectory);
        builder.AddViteApp("frontend-b", secondAppDirectory);
        var appHostId = builder.Configuration["AppHost:Sha256"]![..10].ToLowerInvariant();
 
        using var app = builder.Build();
 
        var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
        var resources = appModel.Resources.OfType<ViteAppResource>().ToDictionary(resource => resource.Name);
        var configPaths = new Dictionary<string, string>();
 
        foreach (var resourceName in resources.Keys)
        {
            var resource = resources[resourceName];
            var args = new List<object> { "run", "dev", "--", "--port", "3000" };
            var context = new HttpsCertificateConfigurationCallbackAnnotationContext
            {
                ExecutionContext = new DistributedApplicationExecutionContext(new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run) { Services = app.Services }),
                Resource = resource,
                Arguments = args,
                EnvironmentVariables = new Dictionary<string, object>(),
                CertificatePath = ReferenceExpression.Create($"cert.pem"),
                KeyPath = ReferenceExpression.Create($"key.pem"),
                CertificateWithKeyPath = ReferenceExpression.Create($"cert-with-key.pem"),
                PfxPath = ReferenceExpression.Create($"cert.pfx"),
                Password = null,
                CancellationToken = CancellationToken.None
            };
 
            var certConfigAnnotation = resource.Annotations
                .OfType<HttpsCertificateConfigurationCallbackAnnotation>()
                .Single();
            await certConfigAnnotation.Callback(context);
 
            var configIndex = args.IndexOf("--config");
            configPaths[resourceName] = Assert.IsType<string>(args[configIndex + 1]);
        }
 
        var firstWrapperPath = Path.Combine(repoRoot, "node_modules", ".aspire", appHostId, "frontend-a", "aspire.vite.config.ts");
        var secondWrapperPath = Path.Combine(repoRoot, "node_modules", ".aspire", appHostId, "frontend-b", "aspire.vite.config.ts");
        Assert.Equal(firstWrapperPath, configPaths["frontend-a"]);
        Assert.Equal(secondWrapperPath, configPaths["frontend-b"]);
        Assert.Contains(Path.GetFullPath(firstConfigPath).Replace("\\", "\\\\"), File.ReadAllText(firstWrapperPath));
        Assert.Contains(Path.GetFullPath(secondConfigPath).Replace("\\", "\\\\"), File.ReadAllText(secondWrapperPath));
    }
 
    [Fact]
    public async Task AddViteApp_ServerAuthCertConfig_SharedNodeModules_WritesAppHostSpecificWrappers()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        var repoRoot = Path.Combine(workspace.Path, "repo");
        var firstAppDirectory = Path.Combine(repoRoot, "apps", "first");
        var secondAppDirectory = Path.Combine(repoRoot, "apps", "second");
        Directory.CreateDirectory(firstAppDirectory);
        Directory.CreateDirectory(secondAppDirectory);
        Directory.CreateDirectory(Path.Combine(repoRoot, "node_modules"));
 
        var firstConfigPath = Path.Combine(firstAppDirectory, "vite.config.ts");
        var secondConfigPath = Path.Combine(secondAppDirectory, "vite.config.ts");
        File.WriteAllText(firstConfigPath, "export default { app: 'first' }");
        File.WriteAllText(secondConfigPath, "export default { app: 'second' }");
 
        const string firstAppHostSha = "1111111111111111";
        const string secondAppHostSha = "2222222222222222";
 
        using var firstBuilder = TestDistributedApplicationBuilder.Create($"AppHostSha={firstAppHostSha}");
        using var secondBuilder = TestDistributedApplicationBuilder.Create($"AppHostSha={secondAppHostSha}");
        firstBuilder.AddViteApp("frontend", firstAppDirectory);
        secondBuilder.AddViteApp("frontend", secondAppDirectory);
 
        using var firstApp = firstBuilder.Build();
        using var secondApp = secondBuilder.Build();
 
        var firstWrapperPath = await GenerateViteWrapperAsync(firstApp);
        var secondWrapperPath = await GenerateViteWrapperAsync(secondApp);
 
        var expectedFirstWrapperPath = Path.Combine(repoRoot, "node_modules", ".aspire", firstAppHostSha[..10], "frontend", "aspire.vite.config.ts");
        var expectedSecondWrapperPath = Path.Combine(repoRoot, "node_modules", ".aspire", secondAppHostSha[..10], "frontend", "aspire.vite.config.ts");
        Assert.Equal(expectedFirstWrapperPath, firstWrapperPath);
        Assert.Equal(expectedSecondWrapperPath, secondWrapperPath);
        Assert.Contains(Path.GetFullPath(firstConfigPath).Replace("\\", "\\\\"), File.ReadAllText(firstWrapperPath));
        Assert.Contains(Path.GetFullPath(secondConfigPath).Replace("\\", "\\\\"), File.ReadAllText(secondWrapperPath));
    }
 
    [Theory]
    [InlineData("vite.config.js")]
    [InlineData("vite.config.mjs")]
    [InlineData("vite.config.ts")]
    [InlineData("vite.config.cjs")]
    [InlineData("vite.config.mts")]
    [InlineData("vite.config.cts")]
    public async Task AddViteApp_ServerAuthCertConfig_DetectsAllDefaultConfigFileFormats(string configFileName)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        // Create node_modules directory for wrapper config generation
        Directory.CreateDirectory(Path.Combine(workspace.Path, "node_modules"));
 
        // Create the specific config file format
        var viteConfigPath = Path.Combine(workspace.Path, configFileName);
        File.WriteAllText(viteConfigPath, "export default {}");
 
        var builder = DistributedApplication.CreateBuilder();
        var viteApp = builder.AddViteApp("test-app", workspace.Path);
 
        using var app = builder.Build();
 
        var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
        var nodeResource = Assert.Single(appModel.Resources.OfType<ViteAppResource>());
 
        // Get the HttpsCertificateConfigurationCallbackAnnotation
        var certConfigAnnotation = nodeResource.Annotations
            .OfType<HttpsCertificateConfigurationCallbackAnnotation>()
            .Single();
 
        // Set up a context without --config argument
        var args = new List<object> { "run", "dev", "--", "--port", "3000" };
        var env = new Dictionary<string, object>();
 
        var context = new HttpsCertificateConfigurationCallbackAnnotationContext
        {
            ExecutionContext = new DistributedApplicationExecutionContext(new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run) { Services = app.Services }),
            Resource = nodeResource,
            Arguments = args,
            EnvironmentVariables = env,
            CertificatePath = ReferenceExpression.Create($"cert.pem"),
            KeyPath = ReferenceExpression.Create($"key.pem"),
            CertificateWithKeyPath = ReferenceExpression.Create($"cert-with-key.pem"),
            PfxPath = ReferenceExpression.Create($"cert.pfx"),
            Password = null,
            CancellationToken = CancellationToken.None
        };
 
        // Invoke the callback
        await certConfigAnnotation.Callback(context);
 
        // Verify the specific config file was detected and wrapped
        var configIndex = args.IndexOf("--config");
        Assert.True(configIndex >= 0);
        var newConfigPath = args[configIndex + 1] as string;
        Assert.NotNull(newConfigPath);
        Assert.Contains($"aspire.{configFileName}", newConfigPath);
    }
 
    [Fact]
    public void NextJsAppHasBuildValidationStep()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
 
        var nextDir = Path.Combine(workspace.Path, "nextjs");
        Directory.CreateDirectory(nextDir);
        File.WriteAllText(Path.Combine(nextDir, "package-lock.json"), "empty");
 
        var nodeApp = builder.AddNextJsApp("nextjs", nextDir);
 
        var stepAnnotations = nodeApp.Resource.Annotations.OfType<PipelineStepAnnotation>().ToList();
        Assert.NotEmpty(stepAnnotations);
    }
 
    [Fact]
    public void DisableBuildValidationAddsSuppressAnnotation()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
 
        var nextDir = Path.Combine(workspace.Path, "nextjs");
        Directory.CreateDirectory(nextDir);
        File.WriteAllText(Path.Combine(nextDir, "package-lock.json"), "empty");
 
        var nodeApp = builder.AddNextJsApp("nextjs", nextDir)
            .DisableBuildValidation();
 
        Assert.True(nodeApp.Resource.TryGetLastAnnotation<SuppressPublishValidationAnnotation>(out _));
    }
 
    [Fact]
    public async Task NextJsStandaloneCheckFailsInPipelineWhenMissing()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: null).WithResourceCleanUp(true);
        builder.Services.AddSingleton<IPipelineActivityReporter, NullPublishingActivityReporter>();
 
        var nextDir = Path.Combine(workspace.Path, "nextjs");
        Directory.CreateDirectory(nextDir);
        File.WriteAllText(Path.Combine(nextDir, "package-lock.json"), "empty");
        File.WriteAllText(Path.Combine(nextDir, "next.config.ts"), "const nextConfig = {}; export default nextConfig;");
 
        builder.AddNextJsApp("nextjs", nextDir);
 
        var app = builder.Build();
        var pipeline = new DistributedApplicationPipeline();
        var context = new PipelineContext(
            app.Services.GetRequiredService<DistributedApplicationModel>(),
            app.Services.GetRequiredService<DistributedApplicationExecutionContext>(),
            app.Services,
            app.Services.GetRequiredService<ILogger<AddViteAppTests>>(),
            CancellationToken.None);
 
        // Pipeline throws AggregateException when multiple steps fail
        var ex = await Assert.ThrowsAnyAsync<Exception>(() => pipeline.ExecuteAsync(context));
        // Verify our standalone check step fired with the right message
        Assert.Contains("output: \"standalone\"", ex.ToString());
    }
 
    private static async Task<string> GenerateViteWrapperAsync(DistributedApplication app)
    {
        var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
        var resource = Assert.Single(appModel.Resources.OfType<ViteAppResource>());
        var args = new List<object> { "run", "dev", "--", "--port", "3000" };
        var context = new HttpsCertificateConfigurationCallbackAnnotationContext
        {
            ExecutionContext = new DistributedApplicationExecutionContext(new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run) { Services = app.Services }),
            Resource = resource,
            Arguments = args,
            EnvironmentVariables = new Dictionary<string, object>(),
            CertificatePath = ReferenceExpression.Create($"cert.pem"),
            KeyPath = ReferenceExpression.Create($"key.pem"),
            CertificateWithKeyPath = ReferenceExpression.Create($"cert-with-key.pem"),
            PfxPath = ReferenceExpression.Create($"cert.pfx"),
            Password = null,
            CancellationToken = CancellationToken.None
        };
 
        var certConfigAnnotation = resource.Annotations
            .OfType<HttpsCertificateConfigurationCallbackAnnotation>()
            .Single();
        await certConfigAnnotation.Callback(context);
 
        var configIndex = args.IndexOf("--config");
        return Assert.IsType<string>(args[configIndex + 1]);
    }
 
    // Helper class for testing IValueProvider
    [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "ExecuteBeforeStartHooksAsync")]
    private static extern Task ExecuteBeforeStartHooksAsync(DistributedApplication app, CancellationToken cancellationToken);
 
    private sealed class TestValueProvider : IValueProvider
    {
        private readonly string _value;
 
        public TestValueProvider(string value)
        {
            _value = value;
        }
 
        public ValueTask<string?> GetValueAsync(CancellationToken cancellationToken = default)
        {
            return new ValueTask<string?>(_value);
        }
    }
}