// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using Aspire.Cli.EndToEnd.Tests.Helpers;
using Hex1b.Automation;
using Xunit;
namespace Aspire.Cli.EndToEnd.Tests;
/// <summary>
/// End-to-end test for JavaScript publish methods. Uses checked-in fixture apps for all four
/// publish patterns (StaticWebsite, NodeServer, PackageScript, AddNextJsApp), deploys them in a
/// single apphost via aspire deploy, and verifies all Docker images build successfully.
/// </summary>
public sealed class JavaScriptPublishTests(ITestOutputHelper output)
{
private static readonly string s_fixturesDir = Path.Combine(
CliE2ETestHelpers.GetRepoRoot(),
"tests", "Aspire.Cli.EndToEnd.Tests", "Fixtures", "JsPublish");
[Fact]
[CaptureWorkspaceOnFailure]
public async Task AllPublishMethodsBuildDockerImages()
{
var repoRoot = CliE2ETestHelpers.GetRepoRoot();
var strategy = CliInstallStrategy.Detect(output.WriteLine);
using var workspace = TemporaryWorkspace.Create(output);
var localChannel = CliE2ETestHelpers.PrepareLocalChannel(repoRoot, strategy,
["Aspire.Hosting.CodeGeneration.TypeScript.", "Aspire.Hosting.JavaScript.", "Aspire.Hosting.Docker."]);
using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace);
var counter = new SequenceCounter();
var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken);
await auto.PrepareDockerEnvironmentAsync(counter, workspace);
await auto.InstallAspireCliAsync(strategy, counter);
// Create TS AppHost and add packages
await auto.TypeAsync("aspire init");
await auto.EnterAsync();
await auto.WaitUntilTextAsync("Which language would you like to use?", timeout: TimeSpan.FromSeconds(30));
await auto.DownAsync();
await auto.WaitUntilTextAsync("> TypeScript (Node.js)", timeout: TimeSpan.FromSeconds(5));
await auto.EnterAsync();
await auto.WaitUntilTextAsync("Created apphost.mts", timeout: TimeSpan.FromMinutes(2));
await auto.DeclineAgentInitPromptAsync(counter);
if (localChannel is not null)
{
CliE2ETestHelpers.WriteLocalChannelSettings(workspace.WorkspaceRoot.FullName, localChannel.SdkVersion);
}
await auto.TypeAsync("aspire add Aspire.Hosting.JavaScript");
await auto.EnterAsync();
await auto.WaitForAspireAddCompletionAsync(counter, TimeSpan.FromSeconds(180));
await auto.TypeAsync("aspire add Aspire.Hosting.Docker");
await auto.EnterAsync();
await auto.WaitForAspireAddCompletionAsync(counter, TimeSpan.FromSeconds(180));
// Copy checked-in fixture apps and write the apphost
CopyFixtures(workspace);
WriteAppHost(workspace);
// Deploy
await auto.TypeAsync("unset ASPIRE_PLAYGROUND");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
await auto.TypeAsync("aspire deploy --non-interactive");
await auto.EnterAsync();
await auto.WaitForPipelineSuccessAsync(timeout: TimeSpan.FromMinutes(5));
await auto.WaitForSuccessPromptAsync(counter);
// Wait for services and verify — verify.sh captures diagnostics first, then asserts
await auto.TypeAsync("bash verify.sh");
await auto.EnterAsync();
await auto.WaitUntilTextAsync("ALL_OK", timeout: TimeSpan.FromSeconds(90));
await auto.WaitForSuccessPromptAsync(counter);
// Clean up
await auto.TypeAsync("docker ps -q --filter label=com.docker.compose.project | xargs -r docker rm -f 2>/dev/null || true");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
}
[Fact]
[CaptureWorkspaceOnFailure]
public async Task JavaScriptHostingApisRunFromTypeScriptAppHost()
{
var repoRoot = CliE2ETestHelpers.GetRepoRoot();
var strategy = CliInstallStrategy.Detect(output.WriteLine);
using var workspace = TemporaryWorkspace.Create(output);
var localChannel = CliE2ETestHelpers.PrepareLocalChannel(repoRoot, strategy,
["Aspire.Hosting.CodeGeneration.TypeScript.", "Aspire.Hosting.JavaScript."]);
using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, variant: CliE2ETestHelpers.DockerfileVariant.Polyglot, workspace: workspace);
var counter = new SequenceCounter();
var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken);
try
{
await auto.PrepareDockerEnvironmentAsync(counter, workspace);
await auto.InstallAspireCliAsync(strategy, counter);
await auto.RunCommandAsync("aspire init --language typescript --non-interactive", counter, TimeSpan.FromMinutes(2));
if (localChannel is not null)
{
CliE2ETestHelpers.WriteLocalChannelSettings(workspace.WorkspaceRoot.FullName, localChannel.SdkVersion);
}
await auto.TypeAsync("aspire add Aspire.Hosting.JavaScript");
await auto.EnterAsync();
await auto.WaitForAspireAddCompletionAsync(counter, TimeSpan.FromMinutes(2));
WriteRuntimeFixtures(workspace);
WriteRuntimeAppHost(workspace);
WriteRuntimeVerificationScript(workspace);
await auto.RunCommandAsync("unset ASPIRE_PLAYGROUND", counter);
await auto.RunCommandAsync("aspire run > aspire-run.log 2>&1 & echo $! > aspire-run.pid", counter);
await auto.RunCommandAsync("bash verify-runtime.sh", counter, TimeSpan.FromMinutes(2));
}
finally
{
try
{
await auto.RunCommandAsync("if [ -f aspire-run.pid ]; then kill \"$(cat aspire-run.pid)\" 2>/dev/null || true; wait \"$(cat aspire-run.pid)\" 2>/dev/null || true; fi", counter, TimeSpan.FromMinutes(1));
}
catch
{
// Best effort. A failure before aspire run writes its PID leaves no process to stop.
}
}
}
private static void WriteAppHost(TemporaryWorkspace workspace)
{
var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.mts");
File.WriteAllText(appHostPath, """
import { createBuilder } from './.aspire/modules/aspire.mjs';
const builder = await createBuilder();
await builder.addDockerComposeEnvironment('compose');
const api = await builder.addNodeApp('api', './api', 'server.js')
.withHttpEndpoint({ port: 3001, env: 'PORT' })
.withExternalHttpEndpoints();
await builder.addViteApp('staticsite', './staticsite')
.withHttpEndpoint({ name: 'http', targetPort: 5000 })
.publishAsStaticWebsite({ apiPath: '/api', apiTarget: api })
.withExternalHttpEndpoints();
await builder.addViteApp('nodeserver', './nodeserver')
.publishAsNodeServer('build/server.js', { outputPath: 'build' })
.withExternalHttpEndpoints();
await builder.addViteApp('npmscript', './npmscript')
.publishAsPackageScript({ scriptName: 'start' })
.withExternalHttpEndpoints();
await builder.addNextJsApp('nextjs', './nextjs')
.withExternalHttpEndpoints();
await builder.build().run();
""");
}
private static void WriteRuntimeAppHost(TemporaryWorkspace workspace)
{
var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.mts");
File.WriteAllText(appHostPath, $$"""
import { createBuilder } from './.aspire/modules/aspire.mjs';
const builder = await createBuilder();
const nodeNpm = await builder.addNodeApp('node-npm', './node-npm', 'server.js');
await nodeNpm.withNpm({ install: false });
await nodeNpm.withRunScript('start');
await nodeNpm.withHttpEndpoint({ name: 'http', env: 'PORT' });
const javaScriptPnpm = await builder.addJavaScriptApp('javascript-pnpm', './javascript-pnpm', { runScriptName: 'dev' });
await javaScriptPnpm.withPnpm({ install: false });
const viteYarn = await builder.addViteApp('vite-yarn', './vite-yarn', { runScriptName: 'dev' });
await viteYarn.withYarn({ install: false });
const nextBun = await builder.addNextJsApp('next-bun', './next-bun', { runScriptName: 'dev' });
await nextBun.disableBuildValidation();
await nextBun.withBun({ install: false });
await builder.build().run();
""");
}
private static void WriteRuntimeFixtures(TemporaryWorkspace workspace)
{
WriteRuntimeApp(workspace, "node-npm", "start");
WriteRuntimeApp(workspace, "javascript-pnpm", "dev");
WriteRuntimeApp(workspace, "vite-yarn", "dev", packageManager: "yarn@4.14.1");
WriteRuntimeApp(workspace, "next-bun", "dev");
}
private static void WriteRuntimeApp(TemporaryWorkspace workspace, string appName, string scriptName, string? packageManager = null)
{
var appDir = Path.Combine(workspace.WorkspaceRoot.FullName, appName);
Directory.CreateDirectory(appDir);
var packageManagerProperty = packageManager is not null ? $"""
"packageManager": "{packageManager}",
""" : string.Empty;
File.WriteAllText(Path.Combine(appDir, "package.json"), $$"""
{
"name": "{{appName}}",
"private": true,
{{packageManagerProperty}}
"scripts": {
"{{scriptName}}": "node server.js"
}
}
""");
if (packageManager?.StartsWith("yarn@", StringComparison.OrdinalIgnoreCase) == true)
{
// Yarn 2+ requires a project-local lockfile with the workspace package entry before
// it will run scripts, even when Aspire is configured not to run package install.
File.WriteAllText(Path.Combine(appDir, "yarn.lock"), $$"""
# This file is generated by running "yarn install" inside your project.
# Manual changes might be lost - proceed with caution!
__metadata:
version: 9
cacheKey: 10c0
"{{appName}}@workspace:.":
version: 0.0.0-use.local
resolution: "{{appName}}@workspace:."
languageName: unknown
linkType: soft
""");
}
File.WriteAllText(Path.Combine(appDir, "server.js"), $$"""
const http = require('http');
const fs = require('fs');
const portArgumentIndex = process.argv.findIndex(arg => arg === '--port' || arg === '-p');
const fallbackPorts = {
'node-npm': 3001,
'javascript-pnpm': 3002,
'vite-yarn': 3003,
'next-bun': 3004,
};
const port = process.env.PORT || (portArgumentIndex >= 0 ? process.argv[portArgumentIndex + 1] : undefined) || fallbackPorts['{{appName}}'];
http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ app: '{{appName}}', ok: true }));
}).listen(port, '0.0.0.0', () => {
fs.writeFileSync('ready-port', port.toString());
console.log('{{appName}} listening on ' + port);
});
""");
}
private static void WriteRuntimeVerificationScript(TemporaryWorkspace workspace)
{
File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "verify-runtime.sh"), $$"""
#!/usr/bin/env bash
set -euo pipefail
check_endpoint() {
local name="$1"
local port_file="${name}/ready-port"
for i in $(seq 1 30); do
if [ ! -f "${port_file}" ]; then
sleep 1
continue
fi
local port
port="$(cat "${port_file}")"
if curl -sf --max-time 5 "http://localhost:${port}/" | grep -q "\"app\":\"${name}\""; then
echo "${name}_OK"
return 0
fi
sleep 1
done
echo "${name}_FAIL"
echo "===== aspire-run.log ====="
cat aspire-run.log || true
echo "===== end aspire-run.log ====="
echo "===== runtime fixture files ====="
for file in package.json node-npm/package.json node-npm/ready-port javascript-pnpm/package.json javascript-pnpm/ready-port vite-yarn/package.json vite-yarn/yarn.lock vite-yarn/ready-port next-bun/package.json next-bun/ready-port; do
if [ -f "$file" ]; then
echo "--- $file"
cat "$file"
fi
done
echo "===== end runtime fixture files ====="
return 1
}
check_endpoint "node-npm"
check_endpoint "javascript-pnpm"
check_endpoint "vite-yarn"
check_endpoint "next-bun"
echo "RUNTIME_ALL_OK"
""");
}
private static void CopyFixtures(TemporaryWorkspace workspace)
{
// Copy root-level files (e.g. verify.sh)
foreach (var file in Directory.GetFiles(s_fixturesDir))
{
File.Copy(file, Path.Combine(workspace.WorkspaceRoot.FullName, Path.GetFileName(file)));
}
// Copy subdirectories (app fixtures)
foreach (var fixtureDir in Directory.GetDirectories(s_fixturesDir))
{
var targetDir = Path.Combine(workspace.WorkspaceRoot.FullName, Path.GetFileName(fixtureDir));
TestDirectoryHelpers.CopyDirectory(fixtureDir, targetDir);
}
}
}