File: BunAppFixture.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.
 
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="BunAppResource"/> instances:
/// one running a script file directly (<c>bun server.ts</c>) and one via a package-manager script
/// (<c>bun run start</c>).
/// </summary>
public class BunAppFixture(IMessageSink diagnosticMessageSink) : IAsyncLifetime
{
    private IDistributedApplicationTestingBuilder? _builder;
    private DistributedApplication? _app;
    private string? _bunAppPath;
 
    public DistributedApplication App => _app ?? throw new InvalidOperationException("DistributedApplication is not initialized.");
 
    public IResourceBuilder<BunAppResource>? BunAppBuilder { get; private set; }
    public IResourceBuilder<BunAppResource>? BunScriptBuilder { get; private set; }
 
    public async ValueTask InitializeAsync()
    {
        _builder = TestDistributedApplicationBuilder.Create()
            .WithTestAndResourceLogging(new TestOutputWrapper(diagnosticMessageSink));
 
        _bunAppPath = CreateBunApp();
 
        BunAppBuilder = _builder.AddBunApp("bunapp", _bunAppPath, "server.ts")
            .WithHttpEndpoint(env: "PORT")
            .WithHttpHealthCheck("/", endpointName: "http");
 
        BunScriptBuilder = _builder.AddBunApp("bunscript", _bunAppPath, "server.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 (_bunAppPath is not null)
        {
            try
            {
                Directory.Delete(_bunAppPath, recursive: true);
            }
            catch
            {
                // Don't fail the test if we can't clean up the temporary folder
            }
        }
    }
 
    private static string CreateBunApp()
    {
        var tempDir = Directory.CreateTempSubdirectory("aspire-bun-tests").FullName;
 
        // Minimal Bun HTTP server. Distinguishes between direct (`bun server.ts`) and
        // script (`bun run start`) invocations via `npm_lifecycle_event`, which Bun sets
        // for script-based invocations: https://bun.com/docs/cli/run
        File.WriteAllText(Path.Combine(tempDir, "server.ts"),
            """
            const port = Number(process.env.PORT ?? 3000);
            const isScriptRun = process.env.npm_lifecycle_event !== undefined;
            const greeting = isScriptRun ? "Hello from bun script!" : "Hello from bun!";
 
            Bun.serve({
                port,
                fetch() {
                    return new Response(greeting, {
                        headers: { "Content-Type": "text/plain" },
                    });
                },
            });
 
            console.log(`Bun server listening on ${port}`);
            """);
 
        File.WriteAllText(Path.Combine(tempDir, "package.json"),
            """
            {
              "name": "bun-fixture",
              "version": "1.0.0",
              "type": "module",
              "private": true,
              "scripts": {
                "start": "bun server.ts"
              }
            }
            """);
 
        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.
        await Task.WhenAll(
            App.ResourceNotifications.WaitForResourceHealthyAsync(BunAppBuilder!.Resource.Name, cancellationToken),
            App.ResourceNotifications.WaitForResourceHealthyAsync(BunScriptBuilder!.Resource.Name, cancellationToken));
    }
 
    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)));
        }
    }
}