/** * uat-api/execute.ts — Run the planned API calls and emit `api-results.json`. * * Per role: one login (token cached), then every planned call with the Bearer + * X-Tenant-Slug headers, measured by timedFetch (status / duration / bytes). * Anonymous calls go out bare. A role whose login fails keeps its calls in the * results as skips (reason login_failed) — the report stays complete. Calls run * through a small concurrency pool but land in the results array at their planned * index, so the artifact is deterministically ordered. */ import { mkdirSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { bodyExcerptOf, timedFetch, type FetchLike } from '../lib/http.js'; import { authHeaders, login } from '../lib/auth-client.js'; import { ApiRunFileSchema, type ApiRunFile, type ApiRunResult, aggregateApi, type ApiAggregate, } from '../lib/run-results.js'; import { assertCall, planApiCalls, type PlannedCall } from './plan-calls.js'; import type { ApiRunContext } from './validate.js'; export interface ApiExecuteDeps { fetchImpl?: FetchLike; nowIso?: () => string; } export interface ApiExecuteOutcome { success: boolean; allPassed: boolean; aggregate: ApiAggregate; resultsFileRel: string; results: ApiRunResult[]; errors: string[]; warnings: string[]; } /** Tiny promise pool preserving result slots. */ async function runPool(items: readonly T[], worker: (item: T, index: number) => Promise, size: number): Promise { let next = 0; const lanes = Array.from({ length: Math.max(1, size) }, async () => { while (next < items.length) { const index = next++; await worker(items[index], index); } }); await Promise.all(lanes); } export async function executeApiRun(ctx: ApiRunContext, deps: ApiExecuteDeps = {}): Promise { const nowIso = deps.nowIso ?? ((): string => new Date().toISOString()); const warnings: string[] = []; const errors: string[] = []; const startedAt = nowIso(); const calls = planApiCalls(ctx.plan, { roles: ctx.spec.roles, includeWriteProbes: ctx.spec.includeWriteProbes, }); // One login per authenticated role (sequential — trivial volume). const tokens = new Map(); const loginFailed = new Map(); const rolesInCalls = [...new Set(calls.map((c) => c.role))]; for (const role of rolesInCalls) { if (role === 'anonymous') continue; const cred = ctx.users?.users.find((u) => u.role === role); if (!cred) { loginFailed.set(role, 'no_credentials'); warnings.push(`Role "${role}" has no entry in uat-users.json — its calls are skipped. Re-run /uat provision.`); continue; } const result = await login( { apiUrl: ctx.apiUrl, timeoutMs: ctx.spec.timeoutMs, fetchImpl: deps.fetchImpl }, cred.email, cred.password, ); if (!result.ok || !result.token) { loginFailed.set(role, 'login_failed'); errors.push(`Login failed for role "${role}" (${cred.email}): ${result.error ?? result.status}`); continue; } tokens.set(role, result.token); } const tenantSlug = ctx.users?.tenant.slug; const results: ApiRunResult[] = new Array(calls.length); await runPool( calls, async (call: PlannedCall, index: number) => { const base: ApiRunResult = { id: call.endpointId, method: call.method, route: call.route, role: call.role, mode: call.mode, expected: call.expected, actual: null, ok: false, executed: false, durationMs: 0, sizeBytes: 0, ...(call.permission ? { permission: call.permission } : {}), ...(call.permissionSource ? { permissionSource: call.permissionSource } : {}), ...(call.ungated ? { ungated: true } : {}), ...(call.controller ? { controller: call.controller } : {}), }; if (!call.execute) { results[index] = { ...base, reason: call.reason }; return; } const skipReason = call.anonymous ? undefined : loginFailed.get(call.role); if (skipReason) { results[index] = { ...base, reason: skipReason }; return; } const token = call.anonymous ? null : (tokens.get(call.role) ?? null); const headers: Record = authHeaders(token, call.anonymous ? undefined : tenantSlug); if (call.body !== undefined) headers['Content-Type'] = 'application/json'; const res = await timedFetch( `${ctx.apiUrl}${call.route.startsWith('/') ? '' : '/'}${call.route}`, { method: call.method, headers, ...(call.body !== undefined ? { body: call.body } : {}) }, { timeoutMs: ctx.spec.timeoutMs, fetchImpl: deps.fetchImpl }, ); if (!res.ok) { results[index] = { ...base, executed: true, actual: 0, ok: false, durationMs: res.durationMs, error: res.error ?? 'network error', }; return; } const verdict = assertCall(call, res.status); // Failed assertions keep a bounded body excerpt — the report's diagnostic. const excerpt = verdict.ok ? undefined : bodyExcerptOf(res.bodyText, res.json); results[index] = { ...base, executed: true, actual: res.status, ok: verdict.ok, ...(verdict.note ? { note: verdict.note } : {}), durationMs: res.durationMs, sizeBytes: res.sizeBytes, ...(excerpt ? { bodyExcerpt: excerpt } : {}), }; }, ctx.spec.concurrency, ); const aggregate = aggregateApi(results); const file: ApiRunFile = ApiRunFileSchema.parse({ kind: 'uat-api', meta: { runId: ctx.runId, application: ctx.application, planPath: ctx.planRelPath, planSignature: ctx.plan.meta.source_signature, apiUrl: ctx.apiUrl, roles: rolesInCalls, startedAt, finishedAt: nowIso(), }, results, }); const resultsFileRel = `${ctx.runDirRel}/api-results.json`; const abs = join(ctx.projectRoot, resultsFileRel); mkdirSync(dirname(abs), { recursive: true }); writeFileSync(abs, `${JSON.stringify(file, null, 2)}\n`, 'utf-8'); return { success: errors.length === 0, allPassed: aggregate.failed === 0 && errors.length === 0, aggregate, resultsFileRel, results, errors, warnings, }; }