// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using Aspire.Deployment.EndToEnd.Tests.Helpers;
using Hex1b.Automation;
using Xunit;
namespace Aspire.Deployment.EndToEnd.Tests;
/// <summary>
/// End-to-end tests for deploying TypeScript AppHosts that publish Azure Container App jobs.
/// </summary>
public sealed class TypeScriptAzureContainerAppJobDeploymentTests(ITestOutputHelper output)
{
private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(35);
[Fact]
public async Task DeployTypeScriptContainerAppJobsToAzureContainerApps()
{
using var cts = new CancellationTokenSource(s_testTimeout);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
cts.Token, TestContext.Current.CancellationToken);
var cancellationToken = linkedCts.Token;
await DeployTypeScriptContainerAppJobsToAzureContainerAppsCore(cancellationToken);
}
private async Task DeployTypeScriptContainerAppJobsToAzureContainerAppsCore(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-aca-jobs");
output.WriteLine($"Test: {nameof(DeployTypeScriptContainerAppJobsToAzureContainerApps)}");
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.RunCommandAsync("aspire init --language typescript --non-interactive", counter, TimeSpan.FromMinutes(2));
await AddPackageAsync(auto, counter, "Aspire.Hosting.Azure.AppContainers");
await AddPackageAsync(auto, counter, "Aspire.Hosting.Azure.Provisioning.AppContainers");
WriteContainerAppJobsAppHost(workspace);
await auto.RunCommandAsync($"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(25));
await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
await auto.RunCommandAsync(BuildJobVerificationCommand(resourceGroupName), counter, TimeSpan.FromMinutes(5));
await auto.TypeAsync("exit");
await auto.EnterAsync();
await pendingRun;
var duration = DateTime.UtcNow - startTime;
DeploymentReporter.ReportDeploymentSuccess(
nameof(DeployTypeScriptContainerAppJobsToAzureContainerApps),
resourceGroupName,
new Dictionary<string, string>(),
duration);
}
catch (Exception ex)
{
var duration = DateTime.UtcNow - startTime;
output.WriteLine($"Test failed after {duration}: {ex.Message}");
DeploymentReporter.ReportDeploymentFailure(
nameof(DeployTypeScriptContainerAppJobsToAzureContainerApps),
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 WriteContainerAppJobsAppHost(TemporaryWorkspace workspace)
{
// `aspire init --language typescript` creates apphost.mts (since PR #16984), not
// apphost.ts. Overwrite that file with the test's custom AppHost; otherwise
// `aspire deploy` runs against the empty default apphost.mts and no Azure
// resources get provisioned. The mts variant imports the generated SDK as
// `aspire.mjs` (not `aspire.js`, which is the legacy apphost.ts shape).
File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.mts"), """
import { createBuilder } from './.aspire/modules/aspire.mjs';
const builder = await createBuilder();
const env = await builder.addAzureContainerAppEnvironment('env');
await (await builder.addContainer('manual-job', 'mcr.microsoft.com/azurelinux/base/core:3.0'))
.withComputeEnvironment(env)
.publishAsAzureContainerAppJob({
configure: async (infrastructure, job) => {
const provisionedJob = await infrastructure.getContainerAppJobByIdentifier(await job.bicepIdentifier.get());
const configuration = await provisionedJob.configuration.get();
await configuration.replicaTimeout.set(300);
await configuration.replicaRetryLimit.set(1);
}
});
await (await builder.addContainer('scheduled-job', 'mcr.microsoft.com/azurelinux/base/core:3.0'))
.withComputeEnvironment(env)
.publishAsScheduledAzureContainerAppJob('0 0 * * *');
await builder.build().run();
""");
}
private static string BuildJobVerificationCommand(string resourceGroupName)
{
return
$"RG_NAME=\"{resourceGroupName}\" && " +
"echo \"Resource group: $RG_NAME\" && " +
"if ! az group show -n \"$RG_NAME\" &>/dev/null; then echo \"Resource group not found\"; exit 1; fi && " +
"az containerapp job list -g \"$RG_NAME\" --query \"[].{name:name,trigger:properties.configuration.triggerType,cron:properties.configuration.scheduleTriggerConfig.cronExpression}\" -o table && " +
"manual_trigger=$(az containerapp job list -g \"$RG_NAME\" --query \"[?contains(name, 'manual-job')].properties.configuration.triggerType | [0]\" -o tsv) && " +
"scheduled_trigger=$(az containerapp job list -g \"$RG_NAME\" --query \"[?contains(name, 'scheduled-job')].properties.configuration.triggerType | [0]\" -o tsv) && " +
"scheduled_cron=$(az containerapp job list -g \"$RG_NAME\" --query \"[?contains(name, 'scheduled-job')].properties.configuration.scheduleTriggerConfig.cronExpression | [0]\" -o tsv) && " +
"manual_timeout=$(az containerapp job list -g \"$RG_NAME\" --query \"[?contains(name, 'manual-job')].properties.configuration.replicaTimeout | [0]\" -o tsv) && " +
"manual_retries=$(az containerapp job list -g \"$RG_NAME\" --query \"[?contains(name, 'manual-job')].properties.configuration.replicaRetryLimit | [0]\" -o tsv) && " +
"if [ \"$manual_trigger\" != \"Manual\" ]; then echo \"manual-job trigger was '$manual_trigger', expected Manual\"; exit 1; fi && " +
"if [ \"$scheduled_trigger\" != \"Schedule\" ]; then echo \"scheduled-job trigger was '$scheduled_trigger', expected Schedule\"; exit 1; fi && " +
"if [ \"$manual_timeout\" != \"300\" ]; then echo \"manual-job timeout was '$manual_timeout', expected 300\"; exit 1; fi && " +
"if [ \"$manual_retries\" != \"1\" ]; then echo \"manual-job retries was '$manual_retries', expected 1\"; exit 1; fi && " +
"if [ \"$scheduled_cron\" != \"0 0 * * *\" ]; then echo \"scheduled-job cron was '$scheduled_cron', expected 0 0 * * *\"; 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}");
}
}
}