/** * tools/federation-harness/harness.ts * * Vitest-consumable helpers for the two-gateway federation harness. * * USAGE (in a vitest test file): * * import { bootHarness, tearDownHarness, serverA, serverB, seed } from * '../../tools/federation-harness/harness.js'; * * let handle: HarnessHandle; * * beforeAll(async () => { * handle = await bootHarness(); * }, 180_000); * * afterAll(async () => { * await tearDownHarness(handle); * }); * * test('variant A — list tasks', async () => { * const seedResult = await seed(handle, 'variantA'); * const a = serverA(handle); * const res = await fetch(`${a.baseUrl}/api/federation/list/tasks`, { * headers: { 'x-admin-key': a.adminKey }, * }); * expect(res.status).toBe(200); * }); * * ESM / NodeNext: all imports use .js extensions. */ import { execSync, execFileSync } from 'node:child_process'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { runSeed, type SeedResult } from './seed.js'; // ─── Types ─────────────────────────────────────────────────────────────────── export interface GatewayAccessor { /** Base URL reachable from the host machine, e.g. http://localhost:14001 */ baseUrl: string; /** Admin key for X-Admin-Key header */ adminKey: string; /** Internal Docker network hostname (for container-to-container calls) */ internalHostname: string; } export interface HarnessHandle { /** Server A accessor */ a: GatewayAccessor; /** Server B accessor */ b: GatewayAccessor; /** Absolute path to the docker-compose file */ composeFile: string; /** Whether this instance booted the stack (vs. reusing an existing one) */ ownedStack: boolean; /** Optional seed result if seed() was called */ seedResult?: SeedResult; } export type SeedScenario = 'variantA' | 'variantB' | 'variantC' | 'all'; // ─── Constants ──────────────────────────────────────────────────────────────── const __dirname = dirname(fileURLToPath(import.meta.url)); const COMPOSE_FILE = resolve(__dirname, 'docker-compose.two-gateways.yml'); const GATEWAY_A_URL = process.env['GATEWAY_A_URL'] ?? 'http://localhost:14001'; const GATEWAY_B_URL = process.env['GATEWAY_B_URL'] ?? 'http://localhost:14002'; const ADMIN_KEY_A = process.env['ADMIN_KEY_A'] ?? 'harness-admin-key-a'; const ADMIN_KEY_B = process.env['ADMIN_KEY_B'] ?? 'harness-admin-key-b'; const READINESS_TIMEOUT_MS = 180_000; const READINESS_POLL_MS = 3_000; // ─── Internal helpers ───────────────────────────────────────────────────────── async function isGatewayHealthy(baseUrl: string): Promise { try { const res = await fetch(`${baseUrl}/api/health`, { signal: AbortSignal.timeout(5_000) }); return res.ok; } catch { return false; } } async function waitForStack(handle: HarnessHandle): Promise { const deadline = Date.now() + READINESS_TIMEOUT_MS; const gateways: Array<{ label: string; url: string }> = [ { label: 'gateway-a', url: handle.a.baseUrl }, { label: 'gateway-b', url: handle.b.baseUrl }, ]; for (const gw of gateways) { process.stdout.write(`[harness] Waiting for ${gw.label}...`); while (Date.now() < deadline) { if (await isGatewayHealthy(gw.url)) { process.stdout.write(' ready\n'); break; } if (Date.now() + READINESS_POLL_MS > deadline) { throw new Error( `[harness] ${gw.label} did not become healthy within ${READINESS_TIMEOUT_MS}ms`, ); } await new Promise((r) => setTimeout(r, READINESS_POLL_MS)); process.stdout.write('.'); } } } function isStackRunning(): boolean { try { const output = execFileSync( 'docker', ['compose', '-f', COMPOSE_FILE, 'ps', '--format', 'json'], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }, ); if (!output.trim()) return false; // Parse JSON lines — each running service emits a JSON object per line const lines = output.trim().split('\n').filter(Boolean); const runningServices = lines.filter((line) => { try { const obj = JSON.parse(line) as { State?: string }; return obj.State === 'running'; } catch { return false; } }); // Expect at least gateway-a and gateway-b running return runningServices.length >= 2; } catch { return false; } } // ─── Public API ─────────────────────────────────────────────────────────────── /** * Boot the harness stack. * * Idempotent: if the stack is already running and both gateways are healthy, * this function reuses the existing stack and returns a handle with * `ownedStack: false`. Callers that set `ownedStack: false` should NOT call * `tearDownHarness` unless they explicitly want to tear down a pre-existing stack. * * If the stack is not running, it starts it with `docker compose up -d` and * waits for both gateways to pass their /api/health probe. */ export async function bootHarness(): Promise { const handle: HarnessHandle = { a: { baseUrl: GATEWAY_A_URL, adminKey: ADMIN_KEY_A, internalHostname: 'gateway-a', }, b: { baseUrl: GATEWAY_B_URL, adminKey: ADMIN_KEY_B, internalHostname: 'gateway-b', }, composeFile: COMPOSE_FILE, ownedStack: false, }; // Check if both gateways are already healthy const [aHealthy, bHealthy] = await Promise.all([ isGatewayHealthy(handle.a.baseUrl), isGatewayHealthy(handle.b.baseUrl), ]); if (aHealthy && bHealthy) { console.log('[harness] Stack already running — reusing existing stack.'); handle.ownedStack = false; return handle; } console.log('[harness] Starting federation harness stack...'); execSync(`docker compose -f "${COMPOSE_FILE}" up -d`, { stdio: 'inherit' }); handle.ownedStack = true; await waitForStack(handle); console.log('[harness] Stack is ready.'); return handle; } /** * Tear down the harness stack. * * Runs `docker compose down -v` to remove containers AND volumes (ephemeral state). * Only tears down if `handle.ownedStack` is true unless `force` is set. */ export async function tearDownHarness( handle: HarnessHandle, opts?: { force?: boolean }, ): Promise { if (!handle.ownedStack && !opts?.force) { console.log( '[harness] Stack not owned by this handle — skipping teardown (pass force: true to override).', ); return; } console.log('[harness] Tearing down federation harness stack...'); execSync(`docker compose -f "${handle.composeFile}" down -v`, { stdio: 'inherit' }); console.log('[harness] Stack torn down.'); } /** * Return the Server A accessor from a harness handle. * Convenience wrapper for test readability. */ export function serverA(handle: HarnessHandle): GatewayAccessor { return handle.a; } /** * Return the Server B accessor from a harness handle. * Convenience wrapper for test readability. */ export function serverB(handle: HarnessHandle): GatewayAccessor { return handle.b; } /** * Seed the harness with test data for one or more scenarios. * * @param handle The harness handle returned by bootHarness(). * @param scenario Which scope variants to provision: * 'variantA' | 'variantB' | 'variantC' | 'all' * * Returns a SeedResult with grant IDs and peer IDs for each variant, * which test assertions can reference. */ export async function seed( handle: HarnessHandle, scenario: SeedScenario = 'all', ): Promise { // For now all scenarios run the full seed — M3-11 can narrow this. // The seed script is idempotent in the sense that it creates new grants // each time; tests should start with a clean stack for isolation. void scenario; // narrowing deferred to M3-11 const result = await runSeed({ serverAUrl: handle.a.baseUrl, serverBUrl: handle.b.baseUrl, adminKeyA: handle.a.adminKey, adminKeyB: handle.b.adminKey, }); handle.seedResult = result; return result; }