File: TypeScriptReusablePackageTests.cs
Web Access
Project: src\tests\Aspire.Cli.EndToEnd.Tests\Aspire.Cli.EndToEnd.Tests.csproj (Aspire.Cli.EndToEnd.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.Text.Json;
using Aspire.Cli.EndToEnd.Tests.Helpers;
using Hex1b.Automation;
using Xunit;
 
namespace Aspire.Cli.EndToEnd.Tests;
 
/// <summary>
/// End-to-end tests for reusable TypeScript helper packages that consume generated Aspire SDK types.
/// </summary>
public sealed class TypeScriptReusablePackageTests(ITestOutputHelper output)
{
    [Fact]
    public async Task RestoreSupportsConfigOnlyHelperPackageAndCrossPackageTypes()
    {
        var repoRoot = CliE2ETestHelpers.GetRepoRoot();
        var strategy = CliInstallStrategy.Detect(output.WriteLine);
        var workspace = TemporaryWorkspace.Create(output);
 
        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);
 
        await auto.PrepareDockerEnvironmentAsync(counter, workspace);
        await auto.InstallAspireCliAsync(strategy, counter);
 
        var appDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "app"));
        var helperDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "packages", "aspire-commands"));
        var helperSourceDirectory = helperDirectory.CreateSubdirectory("src");
 
        await auto.TypeAsync($"cd {CliE2ETestHelpers.ToContainerPath(appDirectory.FullName, workspace)}");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter);
 
        // The main app is a normal TypeScript AppHost created by the CLI.
        await auto.TypeAsync("aspire init --language typescript --non-interactive");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter);
 
        var appAppHostPath = Path.Combine(appDirectory.FullName, "apphost.mts");
        Assert.True(File.Exists(appAppHostPath), $"Expected the CLI-created app to contain {appAppHostPath}.");
 
        await auto.TypeAsync("aspire add Aspire.Hosting.Redis");
        await auto.EnterAsync();
        await auto.WaitUntilTextAsync("The package Aspire.Hosting.", timeout: TimeSpan.FromMinutes(2));
        await auto.WaitForSuccessPromptAsync(counter);
 
        var sdkVersion = GetSdkVersion(appDirectory);
        WriteHelperPackageFiles(helperDirectory, helperSourceDirectory, sdkVersion);
        RewriteAppHostFiles(appDirectory);
 
        // The helper package is intentionally config-only: it has aspire.config.json but no apphost.mts.
        var helperAppHostPath = Path.Combine(helperDirectory.FullName, "apphost.mts");
        Assert.False(File.Exists(helperAppHostPath), $"Config-only helper package unexpectedly contained {helperAppHostPath}.");
 
        await auto.TypeAsync($"cd {CliE2ETestHelpers.ToContainerPath(helperDirectory.FullName, workspace)}");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter);
 
        await auto.TypeAsync("aspire restore");
        await auto.EnterAsync();
        await auto.WaitUntilTextAsync("SDK code restored successfully", timeout: TimeSpan.FromMinutes(3));
        await auto.WaitForSuccessPromptAsync(counter);
 
        var helperModulesDirectory = Path.Combine(helperDirectory.FullName, ".aspire", "modules");
        Assert.True(Directory.Exists(helperModulesDirectory), $".aspire/modules directory was not created for helper package at {helperModulesDirectory}");
        Assert.Contains("addRedis", File.ReadAllText(Path.Combine(helperModulesDirectory, "aspire.mts")));
 
        await auto.TypeAsync("npx tsc --noEmit");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
 
        await auto.TypeAsync($"cd {CliE2ETestHelpers.ToContainerPath(appDirectory.FullName, workspace)}");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter);
 
        await auto.TypeAsync("aspire restore");
        await auto.EnterAsync();
        await auto.WaitUntilTextAsync("SDK code restored successfully", timeout: TimeSpan.FromMinutes(3));
        await auto.WaitForSuccessPromptAsync(counter);
 
        await auto.TypeAsync("npx tsc --noEmit");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
    }
 
    private static string GetSdkVersion(DirectoryInfo appDirectory)
    {
        var configPath = Path.Combine(appDirectory.FullName, "aspire.config.json");
        using var doc = JsonDocument.Parse(File.ReadAllText(configPath));
        return doc.RootElement.GetProperty("sdk").GetProperty("version").GetString()
            ?? throw new InvalidOperationException("Expected aspire.config.json to contain sdk.version.");
    }
 
    private static void RewriteAppHostFiles(DirectoryInfo appDirectory)
    {
        File.WriteAllText(Path.Combine(appDirectory.FullName, "apphost.mts"), """
            import { createBuilder, refExpr } from './.aspire/modules/aspire.mjs';
            import { configureRedis } from '../packages/aspire-commands/src/index.js';
 
            const builder = await createBuilder();
            const redis = await builder.addRedis("cache");
 
            await configureRedis(redis, refExpr`redis://${redis}`);
 
            await builder.build().run();
            """);
 
        File.WriteAllText(Path.Combine(appDirectory.FullName, "tsconfig.json"), """
            {
              "compilerOptions": {
                "target": "ES2022",
                "module": "NodeNext",
                "moduleResolution": "NodeNext",
                "esModuleInterop": true,
                "forceConsistentCasingInFileNames": true,
                "strict": true,
                "skipLibCheck": true,
                "outDir": "./dist",
                "rootDir": ".."
              },
              "include": [
                "apphost.mts",
                ".aspire/modules/**/*.mts",
                "../packages/aspire-commands/src/**/*.ts",
                "../packages/aspire-commands/.aspire/modules/**/*.mts"
              ],
              "exclude": [
                "node_modules"
              ]
            }
            """);
    }
 
    private static void WriteHelperPackageFiles(DirectoryInfo helperDirectory, DirectoryInfo helperSourceDirectory, string sdkVersion)
    {
        File.WriteAllText(Path.Combine(helperDirectory.FullName, "aspire.config.json"), $$"""
            {
              "appHost": {
                "language": "typescript/nodejs"
              },
              "sdk": {
                "version": "{{sdkVersion}}"
              },
              "packages": {
                "Aspire.Hosting.Redis": ""
              }
            }
            """);
 
        File.WriteAllText(Path.Combine(helperDirectory.FullName, "package.json"), """
            {
              "name": "@issue15507/aspire-commands",
              "private": true,
              "type": "module",
              "dependencies": {
                "vscode-jsonrpc": "^8.2.0"
              },
              "devDependencies": {
                "@types/node": "^20.0.0",
                "tsx": "^4.19.0",
                "typescript": "^5.3.0"
              }
            }
            """);
 
        File.WriteAllText(Path.Combine(helperDirectory.FullName, "tsconfig.json"), """
            {
              "compilerOptions": {
                "target": "ES2022",
                "module": "NodeNext",
                "moduleResolution": "NodeNext",
                "esModuleInterop": true,
                "forceConsistentCasingInFileNames": true,
                "strict": true,
                "skipLibCheck": true,
                "outDir": "./dist",
                "rootDir": "."
              },
              "include": [
                "src/**/*.ts",
                ".aspire/modules/**/*.mts"
              ],
              "exclude": [
                "node_modules"
              ]
            }
            """);
 
        File.WriteAllText(Path.Combine(helperSourceDirectory.FullName, "index.ts"), """
            import type {
                ExecuteCommandContext,
                ExecuteCommandResult,
                RedisResource,
                ReferenceExpression
            } from '../.aspire/modules/aspire.mjs';
 
            export async function configureRedis(
                redis: RedisResource,
                prefix: ReferenceExpression
            ): Promise<RedisResource> {
                await redis.withConnectionProperty("prefix", prefix);
 
                return await redis.withCommand(
                    "flush",
                    "Flush Redis cache",
                    async (context: ExecuteCommandContext): Promise<ExecuteCommandResult> => ({
                        success: (await context.resourceName()).length > 0
                    }));
            }
            """);
    }
}