|
// 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
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Tests.Utils;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.DependencyInjection;
namespace Aspire.Hosting.JavaScript.Tests;
public class AddNodeAppTests
{
[Fact]
public async Task VerifyManifest()
{
using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
var workingDirectory = AppContext.BaseDirectory;
var nodeApp = builder.AddNodeApp("nodeapp", workingDirectory, "..\\foo\\app.js")
.WithHttpEndpoint(port: 5031, env: "PORT");
var manifest = await ManifestUtils.GetManifest(nodeApp.Resource);
var expectedManifest = $$"""
{
"type": "executable.v0",
"workingDirectory": ".",
"command": "node",
"args": [
"..\\foo\\app.js"
],
"env": {
"NODE_ENV": "{{builder.Environment.EnvironmentName.ToLowerInvariant()}}",
"PORT": "{nodeapp.bindings.http.targetPort}"
},
"bindings": {
"http": {
"scheme": "http",
"protocol": "tcp",
"transport": "http",
"port": 5031,
"targetPort": 8000
}
}
}
""";
Assert.Equal(expectedManifest, manifest.ToString());
#pragma warning disable CS0618 // Type or member is obsolete
var npmApp = builder.AddJavaScriptApp("npmapp", workingDirectory)
.WithHttpEndpoint(port: 5032, env: "PORT");
#pragma warning restore CS0618 // Type or member is obsolete
manifest = await ManifestUtils.GetManifest(npmApp.Resource);
expectedManifest = $$"""
{
"type": "executable.v0",
"workingDirectory": ".",
"command": "npm",
"args": [
"run",
"dev"
],
"env": {
"NODE_ENV": "{{builder.Environment.EnvironmentName.ToLowerInvariant()}}",
"PORT": "{npmapp.bindings.http.targetPort}"
},
"bindings": {
"http": {
"scheme": "http",
"protocol": "tcp",
"transport": "http",
"port": 5032,
"targetPort": 8000
}
}
}
""";
Assert.Equal(expectedManifest, manifest.ToString());
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task VerifyDockerfile(bool includePackageJson)
{
using var tempDir = new TempDirectory();
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: tempDir.Path).WithResourceCleanUp(true);
var appDir = Path.Combine(tempDir.Path, "js");
Directory.CreateDirectory(appDir);
if (includePackageJson)
{
File.WriteAllText(Path.Combine(appDir, "package.json"), "{}");
File.WriteAllText(Path.Combine(appDir, "package-lock.json"), "{}");
}
var nodeApp = builder.AddNodeApp("js", appDir, "app.js");
await ManifestUtils.GetManifest(nodeApp.Resource, tempDir.Path);
var dockerfilePath = Path.Combine(tempDir.Path, "js.Dockerfile");
var dockerfileContents = File.ReadAllText(dockerfilePath);
var expectedDockerfile = includePackageJson ?
"""
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
FROM node:22-alpine AS runtime
WORKDIR /app
COPY --from=build /app /app
ENV NODE_ENV=production
USER node
ENTRYPOINT ["node","app.js"]
""".Replace("\r\n", "\n") :
"""
FROM node:22-alpine AS build
WORKDIR /app
COPY . .
FROM node:22-alpine AS runtime
WORKDIR /app
COPY --from=build /app /app
ENV NODE_ENV=production
USER node
ENTRYPOINT ["node","app.js"]
""".Replace("\r\n", "\n");
Assert.Equal(expectedDockerfile, dockerfileContents);
var dockerBuildAnnotation = nodeApp.Resource.Annotations.OfType<DockerfileBuildAnnotation>().Single();
Assert.True(dockerBuildAnnotation.HasEntrypoint);
Assert.Empty(nodeApp.Resource.Annotations.OfType<ContainerFilesSourceAnnotation>());
}
[Fact]
public async Task VerifyDockerfileWithBuildScript()
{
using var tempDir = new TempDirectory();
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: tempDir.Path).WithResourceCleanUp(true);
var appDir = Path.Combine(tempDir.Path, "js");
Directory.CreateDirectory(appDir);
File.WriteAllText(Path.Combine(appDir, "package.json"), "{}");
var nodeApp = builder.AddNodeApp("js", appDir, "app.js")
.WithAnnotation(new JavaScriptPackageManagerAnnotation("mypm", runScriptCommand: null, cacheMount: null)
{
PackageFilesPatterns = { new CopyFilePattern("package*.json", "./") }
})
.WithAnnotation(new JavaScriptInstallCommandAnnotation(["myinstall"]))
.WithBuildScript("mybuild");
await ManifestUtils.GetManifest(nodeApp.Resource, tempDir.Path);
var dockerfilePath = Path.Combine(tempDir.Path, "js.Dockerfile");
var dockerfileContents = File.ReadAllText(dockerfilePath);
var expectedDockerfile = $"""
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN mypm myinstall
COPY . .
RUN mypm mybuild
FROM node:22-alpine AS runtime
WORKDIR /app
COPY --from=build /app /app
ENV NODE_ENV=production
USER node
ENTRYPOINT ["node","app.js"]
""".Replace("\r\n", "\n");
Assert.Equal(expectedDockerfile, dockerfileContents);
}
[Fact]
public async Task VerifyDockerfileWithCustomBaseImage()
{
using var tempDir = new TempDirectory();
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: tempDir.Path).WithResourceCleanUp(true);
var appDir = Path.Combine(tempDir.Path, "js");
Directory.CreateDirectory(appDir);
File.WriteAllText(Path.Combine(appDir, "package.json"), "{}");
var customBuildImage = "node:22-mySpecialBuildImage";
var customRuntimeImage = "node:22-mySpecialRuntimeImage";
var nodeApp = builder.AddNodeApp("js", appDir, "app.js")
.WithNpm(install: true)
.WithDockerfileBaseImage(customBuildImage, customRuntimeImage);
await ManifestUtils.GetManifest(nodeApp.Resource, tempDir.Path);
// Verify the Dockerfile contains the custom base image
var dockerfileContents = File.ReadAllText(Path.Combine(tempDir.Path, "js.Dockerfile"));
Assert.Contains($"FROM {customBuildImage}", dockerfileContents);
Assert.Contains($"FROM {customRuntimeImage}", dockerfileContents);
}
[Fact]
public void AddNodeApp_DoesNotAddNpmWhenNoPackageJson()
{
var tempDir = new TempDirectory();
File.WriteAllText(Path.Combine(tempDir.Path, "app.js"), "{}");
var builder = DistributedApplication.CreateBuilder();
builder.AddNodeApp("nodeapp", tempDir.Path, "app.js");
using var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
// Verify the JavaScriptApp resource exists
var nodeResource = Assert.Single(appModel.Resources.OfType<NodeAppResource>());
// Verify the package manager annotation
Assert.False(nodeResource.TryGetLastAnnotation<JavaScriptPackageManagerAnnotation>(out _));
// Verify the install command annotation
Assert.False(nodeResource.TryGetLastAnnotation<JavaScriptInstallCommandAnnotation>(out _));
// Verify the JavaScriptInstallerResource resource does not exist
Assert.Empty(appModel.Resources.OfType<JavaScriptInstallerResource>());
}
[Fact]
public void AddNodeApp_AddsNpmWhenPackageJsonExists()
{
var tempDir = new TempDirectory();
File.WriteAllText(Path.Combine(tempDir.Path, "package.json"), "{}");
var builder = DistributedApplication.CreateBuilder();
builder.AddNodeApp("nodeapp", tempDir.Path, "app.js");
using var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
// Verify the JavaScriptApp resource exists
var nodeResource = Assert.Single(appModel.Resources.OfType<NodeAppResource>());
// Verify the package manager annotation
Assert.True(nodeResource.TryGetLastAnnotation<JavaScriptPackageManagerAnnotation>(out var packageManager));
Assert.Equal("npm", packageManager.ExecutableName);
Assert.Equal("run", packageManager.ScriptCommand);
// Verify the install command annotation
Assert.True(nodeResource.TryGetLastAnnotation<JavaScriptInstallCommandAnnotation>(out var installAnnotation));
Assert.Equal(["install"], installAnnotation.Args);
// Verify the JavaScriptInstallerResource resource exists
var installerResources = Assert.Single(appModel.Resources.OfType<JavaScriptInstallerResource>());
Assert.NotNull(installerResources);
}
[Fact]
public async Task WithRunScript_SetsCustomRunCommand()
{
var builder = DistributedApplication.CreateBuilder();
builder.AddNodeApp("nodeapp", ".", "app.js")
.WithYarn()
.WithRunScript("start", ["--my-arg1"]);
using var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
// Verify the JavaScriptApp resource exists
var nodeResource = Assert.Single(appModel.Resources.OfType<NodeAppResource>());
var args = await ArgumentEvaluator.GetArgumentListAsync(nodeResource);
Assert.Collection(args,
arg => Assert.Equal("run", arg),
arg => Assert.Equal("start", arg),
arg => Assert.Equal("--my-arg1", arg));
}
[Fact]
public async Task VerifyNodeAppWithContainerFilesGeneratesCorrectDockerfile()
{
using var sourceDir = new TempDirectory();
using var outputDir = new TempDirectory();
var appDirectory = sourceDir.Path;
// Create a simple Node.js app
var packageJsonContent = """
{
"name": "test-app",
"version": "1.0.0",
"scripts": {
"start": "node app.js"
}
}
""";
var appContent = """
console.log('Hello from Node.js!');
""";
File.WriteAllText(Path.Combine(appDirectory, "package.json"), packageJsonContent);
File.WriteAllText(Path.Combine(appDirectory, "app.js"), appContent);
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.Path, step: "publish-manifest");
var nodeApp = builder.AddNodeApp("nodeapp", appDirectory, "app.js");
// Create a source container that provides files
var sourceFiles = builder.AddResource(new MyFilesContainer("source", "exe", "."))
.PublishAsDockerFile(c =>
{
c.WithDockerfileBuilder(".", dockerfileContext =>
{
dockerfileContext.Builder.From("scratch");
})
.WithImageTag("source-tag");
})
.WithAnnotation(new ContainerFilesSourceAnnotation() { SourcePath = "/app/dist" });
// Configure NodeApp to consume the source files using the proper PublishWithContainerFiles API
nodeApp.PublishWithContainerFiles(sourceFiles, "./static");
var app = builder.Build();
await app.RunAsync();
// Verify that Dockerfile was generated for the NodeApp
var nodeDockerfilePath = Path.Combine(outputDir.Path, "nodeapp.Dockerfile");
Assert.True(File.Exists(nodeDockerfilePath), "Dockerfile should be generated for NodeApp");
var dockerfileContent = File.ReadAllText(nodeDockerfilePath);
await Verify(dockerfileContent);
}
[Fact]
public async Task VerifyNodeAppWithContainerFilesFromResourceWithDashesGeneratesCorrectDockerfile()
{
using var sourceDir = new TempDirectory();
using var outputDir = new TempDirectory();
var appDirectory = sourceDir.Path;
// Create a simple Node.js app
var packageJsonContent = """
{
"name": "test-app",
"version": "1.0.0",
"scripts": {
"start": "node app.js"
}
}
""";
var appContent = """
console.log('Hello from Node.js!');
""";
File.WriteAllText(Path.Combine(appDirectory, "package.json"), packageJsonContent);
File.WriteAllText(Path.Combine(appDirectory, "app.js"), appContent);
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.Path, step: "publish-manifest");
var nodeApp = builder.AddNodeApp("nodeapp", appDirectory, "app.js");
// Create a source container with dashes in the name
var sourceFiles = builder.AddResource(new MyFilesContainer("static-dev", "exe", "."))
.PublishAsDockerFile(c =>
{
c.WithDockerfileBuilder(".", dockerfileContext =>
{
dockerfileContext.Builder.From("scratch");
})
.WithImageTag("static-dev-tag");
})
.WithAnnotation(new ContainerFilesSourceAnnotation() { SourcePath = "/app/dist" });
// Configure NodeApp to consume the source files using the proper PublishWithContainerFiles API
nodeApp.PublishWithContainerFiles(sourceFiles, "./static");
var app = builder.Build();
await app.RunAsync();
// Verify that Dockerfile was generated for the NodeApp
var nodeDockerfilePath = Path.Combine(outputDir.Path, "nodeapp.Dockerfile");
Assert.True(File.Exists(nodeDockerfilePath), "Dockerfile should be generated for NodeApp");
var dockerfileContent = File.ReadAllText(nodeDockerfilePath);
await Verify(dockerfileContent);
}
private sealed class MyFilesContainer(string name, string command, string workingDirectory)
: ExecutableResource(name, command, workingDirectory), IResourceWithContainerFiles;
}
|