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
 
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.DependencyInjection;
 
namespace Aspire.Hosting.JavaScript.Tests;
 
public class AddViteAppTests
{
    [Fact]
    public async Task VerifyDefaultDockerfile()
    {
        using var tempDir = new TestTempDirectory();
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: tempDir.Path).WithResourceCleanUp(true);
 
        // Create vite directory to ensure manifest generates correct relative build context path
        var viteDir = Path.Combine(tempDir.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, tempDir.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(tempDir.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 VerifyDockerfileWithNodeVersionFromPackageJson()
    {
        using var tempDir = new TestTempDirectory();
 
        // Create a package.json with engines.node specification
        var packageJson = """
            {
              "name": "test-vite",
              "engines": {
                "node": ">=20.12"
              }
            }
            """;
        File.WriteAllText(Path.Combine(tempDir.Path, "package.json"), packageJson);
 
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: tempDir.Path).WithResourceCleanUp(true);
        var nodeApp = builder.AddViteApp("vite", tempDir.Path)
            .WithNpm();
 
        var manifest = await ManifestUtils.GetManifest(nodeApp.Resource, tempDir.Path);
 
        var dockerfileContents = File.ReadAllText(Path.Combine(tempDir.Path, "vite.Dockerfile"));
 
        // Should detect version 20 from package.json
        Assert.Contains("FROM node:20-slim", dockerfileContents);
    }
 
    [Fact]
    public async Task VerifyDockerfileWithNodeVersionFromNvmrc()
    {
        using var tempDir = new TestTempDirectory();
 
        // Create an .nvmrc file
        File.WriteAllText(Path.Combine(tempDir.Path, ".nvmrc"), "18.20.0");
 
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: tempDir.Path).WithResourceCleanUp(true);
        var nodeApp = builder.AddViteApp("vite", tempDir.Path)
            .WithNpm();
 
        var manifest = await ManifestUtils.GetManifest(nodeApp.Resource, tempDir.Path);
 
        var dockerfileContents = File.ReadAllText(Path.Combine(tempDir.Path, "vite.Dockerfile"));
 
        // Should detect version 18 from .nvmrc
        Assert.Contains("FROM node:18-slim", dockerfileContents);
    }
 
    [Fact]
    public async Task VerifyDockerfileWithNodeVersionFromNodeVersion()
    {
        using var tempDir = new TestTempDirectory();
 
        // Create a .node-version file
        File.WriteAllText(Path.Combine(tempDir.Path, ".node-version"), "v21.5.0");
 
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: tempDir.Path).WithResourceCleanUp(true);
        var nodeApp = builder.AddViteApp("vite", tempDir.Path)
            .WithNpm();
 
        var manifest = await ManifestUtils.GetManifest(nodeApp.Resource, tempDir.Path);
 
        var dockerfileContents = File.ReadAllText(Path.Combine(tempDir.Path, "vite.Dockerfile"));
 
        // Should detect version 21 from .node-version
        Assert.Contains("FROM node:21-slim", dockerfileContents);
    }
 
    [Fact]
    public async Task VerifyDockerfileWithNodeVersionFromToolVersions()
    {
        using var tempDir = new TestTempDirectory();
 
        // Create a .tool-versions file
        var toolVersions = """
            ruby 3.2.0
            nodejs 19.8.1
            python 3.11.0
            """;
        File.WriteAllText(Path.Combine(tempDir.Path, ".tool-versions"), toolVersions);
 
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: tempDir.Path).WithResourceCleanUp(true);
        var nodeApp = builder.AddViteApp("vite", tempDir.Path)
            .WithNpm();
 
        var manifest = await ManifestUtils.GetManifest(nodeApp.Resource, tempDir.Path);
 
        var dockerfileContents = File.ReadAllText(Path.Combine(tempDir.Path, "vite.Dockerfile"));
 
        // Should detect version 19 from .tool-versions
        Assert.Contains("FROM node:19-slim", dockerfileContents);
    }
 
    [Fact]
    public async Task VerifyDockerfileDefaultsTo22WhenNoVersionFound()
    {
        using var tempDir = new TestTempDirectory();
 
        // Don't create any version files - should default to 22
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: tempDir.Path).WithResourceCleanUp(true);
        var nodeApp = builder.AddViteApp("vite", tempDir.Path)
            .WithNpm();
 
        var manifest = await ManifestUtils.GetManifest(nodeApp.Resource, tempDir.Path);
 
        var dockerfileContents = File.ReadAllText(Path.Combine(tempDir.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 tempDir = new TestTempDirectory();
 
        File.WriteAllText(Path.Combine(tempDir.Path, ".nvmrc"), versionString);
 
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: tempDir.Path).WithResourceCleanUp(true);
        var nodeApp = builder.AddViteApp("vite", tempDir.Path)
            .WithNpm();
 
        var manifest = await ManifestUtils.GetManifest(nodeApp.Resource, tempDir.Path);
 
        var dockerfileContents = File.ReadAllText(Path.Combine(tempDir.Path, "vite.Dockerfile"));
 
        Assert.Contains($"FROM {expectedImage}", dockerfileContents);
    }
 
    [Fact]
    public async Task VerifyCustomBaseImage()
    {
        using var tempDir = new TestTempDirectory();
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: tempDir.Path).WithResourceCleanUp(true);
 
        var customImage = "node:22-myspecialimage";
        var nodeApp = builder.AddViteApp("vite", tempDir.Path)
            .WithNpm(install: true)
            .WithDockerfileBaseImage(buildImage: customImage);
 
        var manifest = await ManifestUtils.GetManifest(nodeApp.Resource, tempDir.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(tempDir.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 command line args annotation to inspect the args callback
        var commandLineArgsAnnotation = nodeResource.Annotations.OfType<CommandLineArgsCallbackAnnotation>().Single();
        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 command line args annotation to inspect the args callback
        var commandLineArgsAnnotation = nodeResource.Annotations.OfType<CommandLineArgsCallbackAnnotation>().Single();
        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 tempDir = new TestTempDirectory();
 
        // Create node_modules/.bin directory for Aspire config generation
        var nodeModulesBinDir = Path.Combine(tempDir.Path, "node_modules", ".bin");
        Directory.CreateDirectory(nodeModulesBinDir);
 
        // Create a vite config file
        var viteConfigPath = Path.Combine(tempDir.Path, "vite.config.js");
        File.WriteAllText(viteConfigPath, "export default {}");
 
        var builder = DistributedApplication.CreateBuilder();
        var viteApp = builder.AddViteApp("test-app", tempDir.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(DistributedApplicationOperation.Run),
            Resource = nodeResource,
            Arguments = args,
            EnvironmentVariables = env,
            CertificatePath = ReferenceExpression.Create($"cert.pem"),
            KeyPath = ReferenceExpression.Create($"key.pem"),
            PfxPath = ReferenceExpression.Create($"cert.pfx"),
            Password = null,
            CancellationToken = CancellationToken.None
        };
 
        // Invoke the callback
        await certConfigAnnotation.Callback(context);
 
        // Verify a new --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.", newConfigPath);
        Assert.Contains("node_modules", 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 tempDir = new TestTempDirectory();
 
        // Create node_modules/.bin directory for Aspire config generation
        var nodeModulesBinDir = Path.Combine(tempDir.Path, "node_modules", ".bin");
        Directory.CreateDirectory(nodeModulesBinDir);
 
        // Create a default vite config file that would be auto-detected
        var viteConfigPath = Path.Combine(tempDir.Path, "vite.config.js");
        File.WriteAllText(viteConfigPath, "export default {}");
 
        var builder = DistributedApplication.CreateBuilder();
        var viteApp = builder.AddViteApp("test-app", tempDir.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 (simulating default behavior)
        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"),
            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 tempDir = new TestTempDirectory();
 
        // Don't create any vite config file
        var builder = DistributedApplication.CreateBuilder();
        var viteApp = builder.AddViteApp("test-app", tempDir.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"),
            PfxPath = ReferenceExpression.Create($"cert.pfx"),
            Password = null,
            CancellationToken = CancellationToken.None
        };
 
        // Invoke the callback
        await certConfigAnnotation.Callback(context);
 
        // Verify no --config was added since no default config file exists
        Assert.DoesNotContain("--config", args);
 
        // Environment variables should NOT be set if there was no config to wrap
        Assert.Empty(env);
    }
 
    [Fact]
    public async Task AddViteApp_ServerAuthCertConfig_WithPassword_SetsPasswordEnvironmentVariable()
    {
        using var tempDir = new TestTempDirectory();
 
        // Create node_modules/.bin directory for Aspire config generation
        var nodeModulesBinDir = Path.Combine(tempDir.Path, "node_modules", ".bin");
        Directory.CreateDirectory(nodeModulesBinDir);
 
        // Create a vite config file
        var viteConfigPath = Path.Combine(tempDir.Path, "vite.config.js");
        File.WriteAllText(viteConfigPath, "export default {}");
 
        var builder = DistributedApplication.CreateBuilder();
        var viteApp = builder.AddViteApp("test-app", tempDir.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(DistributedApplicationOperation.Run),
            Resource = nodeResource,
            Arguments = args,
            EnvironmentVariables = env,
            CertificatePath = ReferenceExpression.Create($"cert.pem"),
            KeyPath = ReferenceExpression.Create($"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"]);
    }
 
    [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 tempDir = new TestTempDirectory();
 
        // Create node_modules/.bin directory for Aspire config generation
        var nodeModulesBinDir = Path.Combine(tempDir.Path, "node_modules", ".bin");
        Directory.CreateDirectory(nodeModulesBinDir);
 
        // Create the specific config file format
        var viteConfigPath = Path.Combine(tempDir.Path, configFileName);
        File.WriteAllText(viteConfigPath, "export default {}");
 
        var builder = DistributedApplication.CreateBuilder();
        var viteApp = builder.AddViteApp("test-app", tempDir.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"),
            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);
    }
 
    // Helper class for testing IValueProvider
    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);
        }
    }
}