diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Fixtures/JsPublish/api/package.json b/tests/Aspire.Cli.EndToEnd.Tests/Fixtures/JsPublish/api/package.json
index 418c1a88185..7d180647c58 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/Fixtures/JsPublish/api/package.json
+++ b/tests/Aspire.Cli.EndToEnd.Tests/Fixtures/JsPublish/api/package.json
@@ -1,5 +1,6 @@
{
"name": "api",
+ "version": "1.0.0",
"private": true,
"scripts": {
"dev": "node server.js",
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Fixtures/JsPublish/nextjs/package-lock.json b/tests/Aspire.Cli.EndToEnd.Tests/Fixtures/JsPublish/nextjs/package-lock.json
index 21e8c7fc0fa..c68b8cad749 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/Fixtures/JsPublish/nextjs/package-lock.json
+++ b/tests/Aspire.Cli.EndToEnd.Tests/Fixtures/JsPublish/nextjs/package-lock.json
@@ -5,6 +5,7 @@
"packages": {
"": {
"name": "nextjs",
+ "version": "1.0.0",
"dependencies": {
"next": "15.5.15",
"react": "^19.0.0",
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Fixtures/JsPublish/nextjs/package.json b/tests/Aspire.Cli.EndToEnd.Tests/Fixtures/JsPublish/nextjs/package.json
index 326e830f271..dfb77eaad51 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/Fixtures/JsPublish/nextjs/package.json
+++ b/tests/Aspire.Cli.EndToEnd.Tests/Fixtures/JsPublish/nextjs/package.json
@@ -1,5 +1,6 @@
{
"name": "nextjs",
+ "version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Fixtures/JsPublish/nodeserver/package.json b/tests/Aspire.Cli.EndToEnd.Tests/Fixtures/JsPublish/nodeserver/package.json
index 1e7d689f980..287934fbf83 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/Fixtures/JsPublish/nodeserver/package.json
+++ b/tests/Aspire.Cli.EndToEnd.Tests/Fixtures/JsPublish/nodeserver/package.json
@@ -1,5 +1,6 @@
{
"name": "nodeserver",
+ "version": "1.0.0",
"private": true,
"scripts": {
"dev": "node server.js",
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Fixtures/JsPublish/npmscript/package-lock.json b/tests/Aspire.Cli.EndToEnd.Tests/Fixtures/JsPublish/npmscript/package-lock.json
index cd50ac0bc93..3324c296e38 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/Fixtures/JsPublish/npmscript/package-lock.json
+++ b/tests/Aspire.Cli.EndToEnd.Tests/Fixtures/JsPublish/npmscript/package-lock.json
@@ -5,6 +5,7 @@
"packages": {
"": {
"name": "npmscript",
+ "version": "1.0.0",
"dependencies": {
"express": "^4.21.0"
}
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Fixtures/JsPublish/npmscript/package.json b/tests/Aspire.Cli.EndToEnd.Tests/Fixtures/JsPublish/npmscript/package.json
index 466b3f76509..cc195e0ddaf 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/Fixtures/JsPublish/npmscript/package.json
+++ b/tests/Aspire.Cli.EndToEnd.Tests/Fixtures/JsPublish/npmscript/package.json
@@ -1,5 +1,6 @@
{
"name": "npmscript",
+ "version": "1.0.0",
"private": true,
"scripts": {
"dev": "node server.js",
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Fixtures/JsPublish/staticsite/package.json b/tests/Aspire.Cli.EndToEnd.Tests/Fixtures/JsPublish/staticsite/package.json
index afe8603493e..78663c6115b 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/Fixtures/JsPublish/staticsite/package.json
+++ b/tests/Aspire.Cli.EndToEnd.Tests/Fixtures/JsPublish/staticsite/package.json
@@ -1,5 +1,6 @@
{
"name": "staticsite",
+ "version": "1.0.0",
"private": true,
"scripts": {
"dev": "npx serve dist",
diff --git a/tests/Aspire.Cli.EndToEnd.Tests/JavaScriptPublishTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/JavaScriptPublishTests.cs
index 82972ac3c16..349444ea377 100644
--- a/tests/Aspire.Cli.EndToEnd.Tests/JavaScriptPublishTests.cs
+++ b/tests/Aspire.Cli.EndToEnd.Tests/JavaScriptPublishTests.cs
@@ -3,7 +3,6 @@
using Aspire.Cli.EndToEnd.Tests.Helpers;
using Aspire.Cli.Tests.Utils;
-using Aspire.TestUtilities;
using Hex1b.Automation;
using Xunit;
@@ -22,12 +21,13 @@ public sealed class JavaScriptPublishTests(ITestOutputHelper output)
[Fact]
[CaptureWorkspaceOnFailure]
- [QuarantinedTest("https://github.com/microsoft/aspire/issues/16188")]
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);
@@ -48,13 +48,18 @@ public async Task AllPublishMethodsBuildDockerImages()
await auto.WaitUntilTextAsync("Created apphost.ts", 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.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForAspireAddCompletionAsync(counter, TimeSpan.FromSeconds(180));
await auto.TypeAsync("aspire add Aspire.Hosting.Docker");
await auto.EnterAsync();
- await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180));
+ await auto.WaitForAspireAddCompletionAsync(counter, TimeSpan.FromSeconds(180));
// Copy checked-in fixture apps and write the apphost
CopyFixtures(workspace);
@@ -86,6 +91,89 @@ public async Task AllPublishMethodsBuildDockerImages()
await pendingRun;
}
+ [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 pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
+ var counter = new SequenceCounter();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
+ var testBodyFailed = false;
+
+ try
+ {
+ await auto.PrepareDockerEnvironmentAsync(counter, workspace);
+ await auto.InstallAspireCliAsync(strategy, counter);
+
+ await auto.RunCommandFailFastAsync("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.RunCommandFailFastAsync("unset ASPIRE_PLAYGROUND", counter);
+
+ await auto.RunCommandFailFastAsync("aspire run > aspire-run.log 2>&1 & echo $! > aspire-run.pid", counter);
+ await auto.RunCommandFailFastAsync("bash verify-runtime.sh", counter, TimeSpan.FromMinutes(2));
+ }
+ catch
+ {
+ testBodyFailed = true;
+ throw;
+ }
+ 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.
+ }
+
+ try
+ {
+ await auto.CaptureAspireDiagnosticsAsync(counter, workspace);
+ }
+ catch
+ {
+ // Best effort diagnostics capture.
+ }
+
+ try
+ {
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
+ await pendingRun;
+ }
+ catch
+ {
+ if (!testBodyFailed)
+ {
+ throw;
+ }
+ }
+ }
+ }
+
private static void WriteAppHost(TemporaryWorkspace workspace)
{
var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts");
@@ -100,6 +188,7 @@ private static void WriteAppHost(TemporaryWorkspace workspace)
.withExternalHttpEndpoints();
await builder.addViteApp('staticsite', './staticsite')
+ .withHttpEndpoint({ name: 'http', targetPort: 5000 })
.publishAsStaticWebsite({ apiPath: '/api', apiTarget: api })
.withExternalHttpEndpoints();
@@ -118,6 +207,155 @@ await builder.addNextJsApp('nextjs', './nextjs')
""");
}
+ private static void WriteRuntimeAppHost(TemporaryWorkspace workspace)
+ {
+ var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts");
+ File.WriteAllText(appHostPath, $$"""
+ import { createBuilder } from './.modules/aspire.js';
+
+ 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)
diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/TypeScriptJavaScriptHostingDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/TypeScriptJavaScriptHostingDeploymentTests.cs
new file mode 100644
index 00000000000..873272ad8b5
--- /dev/null
+++ b/tests/Aspire.Deployment.EndToEnd.Tests/TypeScriptJavaScriptHostingDeploymentTests.cs
@@ -0,0 +1,224 @@
+// 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.Tests.Utils;
+using Aspire.Deployment.EndToEnd.Tests.Helpers;
+using Hex1b.Automation;
+using Xunit;
+
+namespace Aspire.Deployment.EndToEnd.Tests;
+
+///
+/// End-to-end tests for deploying TypeScript AppHosts that use Aspire.Hosting.JavaScript publish APIs.
+///
+public sealed class TypeScriptJavaScriptHostingDeploymentTests(ITestOutputHelper output)
+{
+ private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(40);
+
+ [Fact]
+ public async Task DeployTypeScriptStaticWebsiteWithNodeApiToAzureContainerApps()
+ {
+ using var cts = new CancellationTokenSource(s_testTimeout);
+ using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
+ cts.Token, TestContext.Current.CancellationToken);
+ var cancellationToken = linkedCts.Token;
+
+ await DeployTypeScriptStaticWebsiteWithNodeApiToAzureContainerAppsCore(cancellationToken);
+ }
+
+ private async Task DeployTypeScriptStaticWebsiteWithNodeApiToAzureContainerAppsCore(CancellationToken cancellationToken)
+ {
+ var subscriptionId = AzureAuthenticationHelpers.TryGetSubscriptionId();
+ if (string.IsNullOrEmpty(subscriptionId))
+ {
+ Assert.Skip("Azure subscription not configured. Set ASPIRE_DEPLOYMENT_TEST_SUBSCRIPTION.");
+ }
+
+ if (!AzureAuthenticationHelpers.IsAzureAuthAvailable())
+ {
+ if (DeploymentE2ETestHelpers.IsRunningInCI)
+ {
+ Assert.Fail("Azure authentication not available in CI. Check OIDC configuration.");
+ }
+
+ Assert.Skip("Azure authentication not available. Run 'az login' to authenticate.");
+ }
+
+ using var workspace = TemporaryWorkspace.Create(output);
+ var startTime = DateTime.UtcNow;
+ var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("ts-js-hosting");
+
+ output.WriteLine($"Test: {nameof(DeployTypeScriptStaticWebsiteWithNodeApiToAzureContainerApps)}");
+ output.WriteLine($"Resource Group: {resourceGroupName}");
+ output.WriteLine($"Subscription: {subscriptionId[..8]}...");
+ output.WriteLine($"Workspace: {workspace.WorkspaceRoot.FullName}");
+
+ try
+ {
+ using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal();
+ var pendingRun = terminal.RunAsync(cancellationToken);
+
+ var counter = new SequenceCounter();
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
+
+ await auto.PrepareEnvironmentAsync(workspace, counter);
+ await auto.InstallCurrentBuildAspireBundleAsync(counter, output);
+
+ await auto.RunCommandFailFastAsync("aspire init --language typescript --non-interactive", counter, TimeSpan.FromMinutes(2));
+
+ await AddPackageAsync(auto, counter, "Aspire.Hosting.JavaScript");
+ await AddPackageAsync(auto, counter, "Aspire.Hosting.Azure.AppContainers");
+
+ WriteStaticWebsiteWithNodeApiAppHost(workspace);
+
+ await auto.RunCommandFailFastAsync($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}", counter);
+
+ await auto.TypeAsync("aspire deploy --clear-cache");
+ await auto.EnterAsync();
+ await auto.WaitForPipelineSuccessAsync(timeout: TimeSpan.FromMinutes(30));
+ await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
+
+ await auto.RunCommandFailFastAsync(BuildEndpointVerificationCommand(resourceGroupName), counter, TimeSpan.FromMinutes(10));
+
+ await auto.TypeAsync("exit");
+ await auto.EnterAsync();
+ await pendingRun;
+
+ var duration = DateTime.UtcNow - startTime;
+ DeploymentReporter.ReportDeploymentSuccess(
+ nameof(DeployTypeScriptStaticWebsiteWithNodeApiToAzureContainerApps),
+ resourceGroupName,
+ new Dictionary(),
+ duration);
+ }
+ catch (Exception ex)
+ {
+ var duration = DateTime.UtcNow - startTime;
+ output.WriteLine($"Test failed after {duration}: {ex.Message}");
+
+ DeploymentReporter.ReportDeploymentFailure(
+ nameof(DeployTypeScriptStaticWebsiteWithNodeApiToAzureContainerApps),
+ resourceGroupName,
+ ex.Message,
+ ex.StackTrace);
+
+ throw;
+ }
+ finally
+ {
+ output.WriteLine($"Triggering cleanup of resource group: {resourceGroupName}");
+ TriggerCleanupResourceGroup(resourceGroupName, output);
+ DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: true, errorMessage: "Cleanup triggered (fire-and-forget)");
+ }
+ }
+
+ private static async Task AddPackageAsync(Hex1bTerminalAutomator auto, SequenceCounter counter, string packageName)
+ {
+ await auto.TypeAsync($"aspire add {packageName}");
+ await auto.EnterAsync();
+ await auto.WaitForAspireAddCompletionAsync(counter, TimeSpan.FromMinutes(3));
+ }
+
+ private static void WriteStaticWebsiteWithNodeApiAppHost(TemporaryWorkspace workspace)
+ {
+ var apiDir = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "api"));
+ File.WriteAllText(Path.Combine(apiDir.FullName, "package.json"), """
+ {
+ "name": "api",
+ "version": "1.0.0",
+ "private": true,
+ "scripts": {
+ "build": "echo 'no build needed'"
+ }
+ }
+ """);
+ File.WriteAllText(Path.Combine(apiDir.FullName, "server.js"), """
+ const http = require('http');
+ const port = process.env.PORT || 3000;
+
+ http.createServer((req, res) => {
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify([{ temperatureC: 22, summary: 'Warm' }]));
+ }).listen(port, '0.0.0.0');
+ """);
+
+ var staticSiteDir = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "staticsite"));
+ File.WriteAllText(Path.Combine(staticSiteDir.FullName, "package.json"), """
+ {
+ "name": "staticsite",
+ "version": "1.0.0",
+ "private": true,
+ "scripts": {
+ "build": "mkdir -p dist && cp index.html dist/index.html"
+ }
+ }
+ """);
+ File.WriteAllText(Path.Combine(staticSiteDir.FullName, "index.html"), """
+
+
+ Static Site
+ Weather
+
+ """);
+
+ File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts"), """
+ import { createBuilder } from './.modules/aspire.js';
+
+ const builder = await createBuilder();
+
+ // This environment selects Azure Container Apps as the deployment target for these app resources.
+ await builder.addAzureContainerAppEnvironment('env');
+
+ const api = await builder.addNodeApp('api', './api', 'server.js')
+ .withHttpEndpoint({ name: 'http', env: 'PORT' });
+
+ await builder.addJavaScriptApp('staticsite', './staticsite')
+ .withHttpEndpoint({ name: 'http', targetPort: 5000 })
+ .publishAsStaticWebsite({ apiPath: '/api', apiTarget: api })
+ .withExternalHttpEndpoints();
+
+ await builder.build().run();
+ """);
+ }
+
+ private static string BuildEndpointVerificationCommand(string resourceGroupName)
+ {
+ return
+ $"static_host=$(az containerapp list -g \"{resourceGroupName}\" --query \"[?contains(name, 'staticsite') && properties.configuration.ingress.external == \\`true\\`].properties.configuration.ingress.fqdn | [0]\" -o tsv) && " +
+ $"if [ -z \"$static_host\" ]; then echo \"No external staticsite endpoint found\"; az containerapp list -g \"{resourceGroupName}\" --query \"[].{{name:name,external:properties.configuration.ingress.external,fqdn:properties.configuration.ingress.fqdn}}\" -o table; exit 1; fi && " +
+ "echo \"Checking https://$static_host\" && " +
+ "ok=0 && " +
+ "for i in $(seq 1 30); do " +
+ "if curl -sf --max-time 5 \"https://$static_host/index.html\" | grep -q Weather && " +
+ "curl -sf --max-time 5 \"https://$static_host/api/weather\" | grep -q temperatureC; then " +
+ "ok=1; break; " +
+ "fi; " +
+ "sleep 5; " +
+ "done && " +
+ "if [ \"$ok\" -ne 1 ]; then echo \"Endpoint verification failed\"; exit 1; fi";
+ }
+
+ private static void TriggerCleanupResourceGroup(string resourceGroupName, ITestOutputHelper output)
+ {
+ using var process = new System.Diagnostics.Process
+ {
+ StartInfo = new System.Diagnostics.ProcessStartInfo
+ {
+ FileName = "az",
+ Arguments = $"group delete --name {resourceGroupName} --yes --no-wait",
+ UseShellExecute = false,
+ CreateNoWindow = true
+ }
+ };
+
+ try
+ {
+ process.Start();
+ output.WriteLine($"Cleanup triggered for resource group: {resourceGroupName}");
+ }
+ catch (Exception ex)
+ {
+ output.WriteLine($"Failed to trigger cleanup: {ex.Message}");
+ }
+ }
+}
diff --git a/tests/PolyglotAppHosts/Aspire.Hosting.JavaScript/TypeScript/apphost.ts b/tests/PolyglotAppHosts/Aspire.Hosting.JavaScript/TypeScript/apphost.ts
index b6604f990b0..47b693ea62f 100644
--- a/tests/PolyglotAppHosts/Aspire.Hosting.JavaScript/TypeScript/apphost.ts
+++ b/tests/PolyglotAppHosts/Aspire.Hosting.JavaScript/TypeScript/apphost.ts
@@ -28,4 +28,26 @@ const _viteAppName = await viteApp.name();
const _viteAppCommand = await viteApp.command();
const _viteAppWorkingDirectory = await viteApp.workingDirectory();
+const nextJsApp = await builder.addNextJsApp('nextjs-app', './nextjs-app', { runScriptName: 'dev' });
+await nextJsApp.disableBuildValidation();
+await nextJsApp.withNpm({ install: false, installCommand: 'ci' });
+const _nextJsAppName = await nextJsApp.name();
+const _nextJsAppCommand = await nextJsApp.command();
+const _nextJsAppWorkingDirectory = await nextJsApp.workingDirectory();
+
+const staticSiteApp = await builder.addJavaScriptApp('static-site-app', './static-site-app');
+await staticSiteApp.publishAsStaticWebsite({
+ apiPath: '/api',
+ apiTarget: nodeApp,
+ outputPath: 'dist',
+ stripPrefix: true,
+ targetEndpointName: 'http',
+});
+
+await builder.addJavaScriptApp('node-server-app', './node-server-app')
+ .publishAsNodeServer('server.js', { outputPath: 'build' });
+
+await builder.addJavaScriptApp('npm-script-app', './npm-script-app')
+ .publishAsNpmScript({ startScriptName: 'start', runScriptArguments: '-- --port $PORT' });
+
await builder.build().run();