File: DenoAppFixture.cs
Web Access
Project: src\tests\Aspire.Hosting.JavaScript.Tests\Aspire.Hosting.JavaScript.Tests.csproj (Aspire.Hosting.JavaScript.Tests)
// 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 ASPIREDENO001 // Type is for evaluation purposes only
 
using System.Globalization;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Testing;
using Aspire.Hosting.Utils;
using Xunit.Sdk;
 
namespace Aspire.Hosting.JavaScript.Tests;
 
/// <summary>
/// Test fixture that boots an Aspire application with two <see cref="DenoAppResource"/> instances:
/// one running a script file directly (<c>deno run -A main.ts</c>) and one via a package-manager task
/// (<c>deno task start</c>).
/// </summary>
public sealed class DenoAppFixture(IMessageSink diagnosticMessageSink)
    : DenoAppFixtureBase(diagnosticMessageSink, enableDashboard: false, includeTaskApp: true);
 
/// <summary>
/// Test fixture that boots one Deno application and the dashboard for native telemetry validation.
/// </summary>
public sealed class DenoTelemetryFixture(IMessageSink diagnosticMessageSink)
    : DenoAppFixtureBase(diagnosticMessageSink, enableDashboard: true, includeTaskApp: false)
{
    public const string AspireDashboardResourceName = "aspire-dashboard";
    public const string DashboardApiKey = "DenoFunctionalTestsApiKey";
}
 
public abstract class DenoAppFixtureBase(
    IMessageSink diagnosticMessageSink,
    bool enableDashboard,
    bool includeTaskApp) : IAsyncLifetime
{
    private IDistributedApplicationTestingBuilder? _builder;
    private DistributedApplication? _app;
    private string? _denoAppPath;
 
    public DistributedApplication App => _app ?? throw new InvalidOperationException("DistributedApplication is not initialized.");
 
    public IResourceBuilder<DenoAppResource>? DenoAppBuilder { get; private set; }
    public IResourceBuilder<DenoAppResource>? DenoScriptBuilder { get; private set; }
 
    public async ValueTask InitializeAsync()
    {
        _builder = TestDistributedApplicationBuilder.Create(
            options =>
            {
                options.DisableDashboard = !enableDashboard;
                options.TrustDeveloperCertificate = false;
            },
            new TestOutputWrapper(diagnosticMessageSink));
        if (enableDashboard)
        {
            // Keep native telemetry validation independent of development-certificate trust.
            // Certificate environment mapping is covered separately by AddDenoAppTests.
            _builder.Configuration["ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL"] = "http://localhost:0";
            _builder.Configuration["AppHost:DashboardApiKey"] = DenoTelemetryFixture.DashboardApiKey;
        }
 
        _denoAppPath = CreateDenoApp();
 
        DenoAppBuilder = _builder.AddDenoApp("denoapp", _denoAppPath, "main.ts")
            .WithHttpEndpoint(env: "PORT")
            .WithHttpHealthCheck("/", endpointName: "http");
 
        if (includeTaskApp)
        {
            DenoScriptBuilder = _builder.AddDenoApp("denoscript", _denoAppPath, "main.ts")
                .WithRunScript("start")
                .WithHttpEndpoint(env: "PORT")
                .WithHttpHealthCheck("/", endpointName: "http");
        }
 
        _app = _builder.Build();
 
        using var startCts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
        await _app.StartAsync(startCts.Token);
 
        using var readinessCts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
        await WaitReadyStateAsync(readinessCts.Token);
    }
 
    public async ValueTask DisposeAsync()
    {
        _builder?.Dispose();
 
        if (_app is not null)
        {
            await _app.StopAsync();
            await _app.DisposeAsync();
        }
 
        if (_denoAppPath is not null)
        {
            try
            {
                Directory.Delete(_denoAppPath, recursive: true);
            }
            catch (IOException)
            {
                // A terminating Deno process can briefly retain a file handle on Windows.
            }
            catch (UnauthorizedAccessException)
            {
                // Cleanup should not mask the functional-test result.
            }
        }
    }
 
    private static string CreateDenoApp()
    {
        var tempDir = Directory.CreateTempSubdirectory("aspire-deno-tests").FullName;
 
        // Minimal Deno HTTP server. Distinguishes between direct (`deno run -A main.ts`) and
        // task (`deno task start`) invocations via the `--from-task` argument injected by the
        // deno.json task definition below.
        File.WriteAllText(Path.Combine(tempDir, "main.ts"),
            """
            const port = Number(Deno.env.get("PORT") ?? 3000);
            const isTaskRun = Deno.args.includes("--from-task");
            const greeting = isTaskRun ? "Hello from deno task!" : "Hello from deno!";
 
            Deno.serve({ port }, () => {
                console.log(`Deno telemetry request from ${isTaskRun ? "task" : "direct"} app`);
                return new Response(greeting, {
                    headers: { "Content-Type": "text/plain" },
                });
            });
 
            console.log(`Deno server listening on ${port}`);
            """);
 
        // deno.json defines the `start` task. AddDenoApp auto-detects deno.json and configures Deno as
        // the package manager, so `.WithRunScript("start")` runs `deno task start`.
        File.WriteAllText(Path.Combine(tempDir, "deno.json"),
            """
            {
              "tasks": {
                "start": "deno run -A main.ts --from-task"
              }
            }
            """);
 
        return tempDir;
    }
 
    private async Task WaitReadyStateAsync(CancellationToken cancellationToken)
    {
        // Wait for each resource in parallel — separate timeouts would compound startup time
        // and either resource being slow shouldn't starve the other.
        var readinessTasks = new List<Task>
        {
            App.ResourceNotifications.WaitForResourceHealthyAsync(DenoAppBuilder!.Resource.Name, cancellationToken)
        };
        if (enableDashboard)
        {
            readinessTasks.Add(App.ResourceNotifications.WaitForResourceAsync(
                DenoTelemetryFixture.AspireDashboardResourceName,
                KnownResourceStates.Running,
                cancellationToken));
        }
        if (DenoScriptBuilder is not null)
        {
            readinessTasks.Add(App.ResourceNotifications.WaitForResourceHealthyAsync(
                DenoScriptBuilder.Resource.Name,
                cancellationToken));
        }
 
        await Task.WhenAll(readinessTasks);
    }
 
    private sealed class TestOutputWrapper(IMessageSink messageSink) : ITestOutputHelper
    {
        public string Output => string.Empty;
 
        public void Write(string message)
        {
            messageSink.OnMessage(new DiagnosticMessage(message));
        }
 
        public void Write(string format, params object[] args)
        {
            messageSink.OnMessage(new DiagnosticMessage(string.Format(CultureInfo.CurrentCulture, format, args)));
        }
 
        public void WriteLine(string message)
        {
            messageSink.OnMessage(new DiagnosticMessage(message));
        }
 
        public void WriteLine(string format, params object[] args)
        {
            messageSink.OnMessage(new DiagnosticMessage(string.Format(CultureInfo.CurrentCulture, format, args)));
        }
    }
}