// 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 AddBunAppTests(ITestOutputHelper outputHelper)
{
[Fact]
public async Task VerifyManifest()
{
using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true);
var workingDirectory = AppContext.BaseDirectory;
var bunApp = builder.AddBunApp("bunapp", workingDirectory, "server.ts")
.WithHttpEndpoint(port: 5033, env: "PORT");
var manifest = await ManifestUtils.GetManifest(bunApp.Resource);
await Verify(manifest.ToString());
}
[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 bunApp = builder.AddBunApp("js", appDir, "server.ts");
await ManifestUtils.GetManifest(bunApp.Resource, workspace.Path);
var dockerfilePath = Path.Combine(workspace.Path, "js.Dockerfile");
var dockerfileContents = File.ReadAllText(dockerfilePath);
await Verify(dockerfileContents);
var dockerBuildAnnotation = bunApp.Resource.Annotations.OfType<DockerfileBuildAnnotation>().Single();
Assert.True(dockerBuildAnnotation.HasEntrypoint);
Assert.Empty(bunApp.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 = "oven/bun:1.1-debian";
var customRuntimeImage = "oven/bun:1.1-distroless";
var bunApp = builder.AddBunApp("js", appDir, "server.ts")
.WithDockerfileBaseImage(customBuildImage, customRuntimeImage);
await ManifestUtils.GetManifest(bunApp.Resource, workspace.Path);
await Verify(File.ReadAllText(Path.Combine(workspace.Path, "js.Dockerfile")));
}
[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 bunApp = builder.AddBunApp("js", appDir, "server.ts");
await ManifestUtils.GetManifest(bunApp.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.
var dockerBuildAnnotation = bunApp.Resource.Annotations.OfType<DockerfileBuildAnnotation>().Single();
Assert.NotNull(dockerBuildAnnotation.BuildContextIgnoreContent);
Assert.Contains("node_modules", dockerBuildAnnotation.BuildContextIgnoreContent!);
}
[Fact]
public async Task VerifyDockerfileSkipsDockerignoreWhenUserAuthoredOneExists()
{
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 bunApp = builder.AddBunApp("js", appDir, "server.ts");
await ManifestUtils.GetManifest(bunApp.Resource, workspace.Path);
var perDockerfileIgnorePath = Path.Combine(workspace.Path, "js.Dockerfile.dockerignore");
Assert.True(File.Exists(perDockerfileIgnorePath));
// User has authored their own .dockerignore at the context root after a previous publish.
// BuildKit gives per-Dockerfile ignore files precedence, so Aspire must remove the stale
// generated sibling to honor the user's file on the next publish to the same output path.
var userIgnorePath = Path.Combine(appDir, ".dockerignore");
var userIgnoreContents = "# user-authored\nsecrets/\n";
File.WriteAllText(userIgnorePath, userIgnoreContents);
await ManifestUtils.GetManifest(bunApp.Resource, workspace.Path);
Assert.False(File.Exists(perDockerfileIgnorePath), "Aspire should not shadow a user-authored context-root .dockerignore.");
// User file is untouched.
Assert.Equal(userIgnoreContents, File.ReadAllText(userIgnorePath));
}
[Fact]
public void AddBunApp_DoesNotAddBunPackageManagerWhenNoPackageJson()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
File.WriteAllText(Path.Combine(workspace.Path, "server.ts"), "console.log('hi');");
var builder = DistributedApplication.CreateBuilder();
builder.AddBunApp("bunapp", workspace.Path, "server.ts");
using var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var bunResource = Assert.Single(appModel.Resources.OfType<BunAppResource>());
// No package.json: don't auto-configure Bun as a package manager and don't add an installer.
Assert.False(bunResource.TryGetLastAnnotation<JavaScriptPackageManagerAnnotation>(out _));
Assert.False(bunResource.TryGetLastAnnotation<JavaScriptInstallCommandAnnotation>(out _));
Assert.Empty(appModel.Resources.OfType<JavaScriptInstallerResource>());
}
[Fact]
public void AddBunApp_AddsBunPackageManagerWhenPackageJsonExists()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
File.WriteAllText(Path.Combine(workspace.Path, "package.json"), "{}");
var builder = DistributedApplication.CreateBuilder();
builder.AddBunApp("bunapp", workspace.Path, "server.ts");
using var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var bunResource = Assert.Single(appModel.Resources.OfType<BunAppResource>());
Assert.True(bunResource.TryGetLastAnnotation<JavaScriptPackageManagerAnnotation>(out var packageManager));
Assert.Equal("bun", packageManager.ExecutableName);
Assert.Equal("run", packageManager.ScriptCommand);
Assert.True(bunResource.TryGetLastAnnotation<JavaScriptInstallCommandAnnotation>(out var installAnnotation));
Assert.Equal(["install"], installAnnotation.Args);
var installerResource = Assert.Single(appModel.Resources.OfType<JavaScriptInstallerResource>());
Assert.NotNull(installerResource);
}
[Fact]
public async Task WithRunScript_SetsCustomRunCommand()
{
var builder = DistributedApplication.CreateBuilder();
builder.AddBunApp("bunapp", ".", "server.ts")
.WithBun()
.WithRunScript("start", ["--my-arg1"]);
using var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var bunResource = Assert.Single(appModel.Resources.OfType<BunAppResource>());
var args = await ArgumentEvaluator.GetArgumentListAsync(bunResource);
Assert.Collection(args,
arg => Assert.Equal("run", arg),
arg => Assert.Equal("start", arg),
arg => Assert.Equal("--my-arg1", arg));
}
[Fact]
public void AddBunApp_UsesBunCommand()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = DistributedApplication.CreateBuilder();
var bunApp = builder.AddBunApp("bunapp", workspace.Path, "server.ts");
Assert.Equal("bun", bunApp.Resource.Command);
}
[Fact]
public void AddBunApp_ThrowsForNullBuilder()
{
Assert.Throws<ArgumentNullException>(() =>
JavaScriptHostingExtensions.AddBunApp(null!, "bunapp", ".", "server.ts"));
}
[Fact]
public void AddBunApp_ThrowsForEmptyName()
{
var builder = DistributedApplication.CreateBuilder();
Assert.Throws<ArgumentException>(() => builder.AddBunApp("", ".", "server.ts"));
}
[Fact]
public void AddBunApp_ThrowsForEmptyScriptPath()
{
var builder = DistributedApplication.CreateBuilder();
Assert.Throws<ArgumentException>(() => builder.AddBunApp("bunapp", ".", ""));
}
[Fact]
public async Task AddBunApp_ConfiguresCertificateTrustForAppendScope()
{
var builder = DistributedApplication.CreateBuilder();
var bunApp = builder.AddBunApp("bunapp", ".", "server.ts");
Assert.True(bunApp.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 = new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run),
Resource = bunApp.Resource,
Arguments = [],
EnvironmentVariables = envVars,
CertificateBundlePath = bundle,
CertificateDirectoriesPath = dirs,
Scope = CertificateTrustScope.Append,
CancellationToken = default,
};
await annotation.Callback(ctx);
// Bun 1.3+ honors NODE_EXTRA_CA_CERTS for both fetch() and node:https/node:tls
// so the Append scope simply points Bun at the Aspire-provided bundle.
Assert.Same(bundle, envVars["NODE_EXTRA_CA_CERTS"]);
}
[Fact]
public async Task AddBunApp_ConfiguresCertificateTrustForOverrideScope()
{
var builder = DistributedApplication.CreateBuilder();
var bunApp = builder.AddBunApp("bunapp", ".", "server.ts");
Assert.True(bunApp.Resource.TryGetLastAnnotation<CertificateTrustConfigurationCallbackAnnotation>(out var annotation));
var envVars = new Dictionary<string, object>();
var ctx = new CertificateTrustConfigurationCallbackAnnotationContext
{
ExecutionContext = new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run),
Resource = bunApp.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);
// Override/System scopes route TLS verification through the OS trust store via the
// --use-openssl-ca flag, which Bun reads from NODE_OPTIONS for Node compatibility.
Assert.Equal("--use-openssl-ca", envVars["NODE_OPTIONS"]);
}
#pragma warning disable ASPIREEXTENSION001 // Type is for evaluation purposes only
[Fact]
public void BunApp_WithVSCodeDebugging_AddsSupportsDebuggingAnnotation()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
using var workspace = TemporaryWorkspace.Create(outputHelper);
var bunApp = builder.AddBunApp("bunapp", workspace.Path, "server.ts");
var annotation = bunApp.Resource.Annotations.OfType<SupportsDebuggingAnnotation>().SingleOrDefault();
Assert.NotNull(annotation);
Assert.Equal("bun", annotation.LaunchConfigurationType);
}
[Fact]
public void BunApp_WithVSCodeDebugging_DoesNotAddAnnotationInPublishMode()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
using var workspace = TemporaryWorkspace.Create(outputHelper);
var bunApp = builder.AddBunApp("bunapp", workspace.Path, "server.ts");
var annotation = bunApp.Resource.Annotations.OfType<SupportsDebuggingAnnotation>().SingleOrDefault();
Assert.Null(annotation);
}
[Fact]
public void BunApp_WithRunScript_AddsSupportsDebuggingAnnotation()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
using var workspace = TemporaryWorkspace.Create(outputHelper);
var bunApp = builder.AddBunApp("bunapp", workspace.Path, "server.ts")
.WithRunScript("dev");
var annotation = bunApp.Resource.Annotations.OfType<SupportsDebuggingAnnotation>().SingleOrDefault();
Assert.NotNull(annotation);
Assert.Equal("bun", annotation.LaunchConfigurationType);
}
[Fact]
public void BunApp_WithPackageJson_HasPackageManagerAnnotation()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
using var workspace = TemporaryWorkspace.Create(outputHelper);
// AddBunApp automatically calls WithBun() when a package.json exists.
File.WriteAllText(Path.Combine(workspace.Path, "package.json"), "{}");
var bunApp = builder.AddBunApp("bunapp", workspace.Path, "server.ts")
.WithRunScript("dev");
Assert.True(bunApp.Resource.TryGetLastAnnotation<JavaScriptPackageManagerAnnotation>(out var pmAnnotation));
Assert.Equal("bun", pmAnnotation.ExecutableName);
}
[Fact]
public async Task BunApp_DirectFile_ProducesBunRuntimeExecutable()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
using var workspace = TemporaryWorkspace.Create(outputHelper);
var bunApp = builder.AddBunApp("bunapp", workspace.Path, "server.ts");
var launchConfig = await CreateLaunchConfigurationAsync(bunApp.Resource);
Assert.Equal("bun", launchConfig.Type);
Assert.Equal("bun", launchConfig.RuntimeExecutable);
Assert.Equal("direct", launchConfig.LaunchMethod);
Assert.Equal(Path.GetFullPath("server.ts", workspace.Path), launchConfig.ScriptPath);
}
[Fact]
public async Task BunApp_WithRunScriptAndPackageManager_ProducesBunRuntimeExecutable()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
using var workspace = TemporaryWorkspace.Create(outputHelper);
// AddBunApp automatically calls WithBun() when a package.json exists, which makes the run-script a
// package-manager invocation (bun run dev).
File.WriteAllText(Path.Combine(workspace.Path, "package.json"), "{}");
var bunApp = builder.AddBunApp("bunapp", workspace.Path, "server.ts")
.WithRunScript("dev");
var launchConfig = await CreateLaunchConfigurationAsync(bunApp.Resource);
Assert.Equal("bun", launchConfig.Type);
Assert.Equal("bun", launchConfig.RuntimeExecutable);
Assert.Equal("package-manager", launchConfig.LaunchMethod);
}
private static async Task<JavaScriptLaunchConfiguration> CreateLaunchConfigurationAsync(IResource resource)
{
var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext(resource);
return Assert.IsType<JavaScriptLaunchConfiguration>(
await LaunchConfigurationTestHelpers.InvokeLaunchConfigurationProducerAsync(resource, callbackContext));
}
#pragma warning restore ASPIREEXTENSION001 // Type is for evaluation purposes only
}