Files
umami/tests/api/global-setup.ts

132 lines
4.1 KiB
TypeScript

/* eslint-disable no-console */
import { copyFileSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { type FullConfig, request } from '@playwright/test';
import { ApiClient } from './client';
import { disableCoverageRecording } from './coverage/recorder';
import {
assertDisposableTarget,
COVERAGE_DIR,
LOCAL_OPENAPI_FILE,
OPENAPI_FILE,
SEED_FILE,
} from './paths';
import { seedEnvironment } from './seed/setup';
const HEARTBEAT_TIMEOUT_MS = 120_000;
async function waitForHeartbeat(baseURL: string, timeoutMs: number) {
const deadline = Date.now() + timeoutMs;
let lastError = '';
while (Date.now() < deadline) {
try {
const response = await fetch(`${baseURL}/api/heartbeat`);
if (response.ok) {
return;
}
lastError = `HTTP ${response.status}`;
} catch (error) {
lastError = (error as Error).message;
}
await new Promise(resolve => setTimeout(resolve, 1000));
}
throw new Error(
`Timed out waiting for ${baseURL}/api/heartbeat (${lastError}). ` +
'Start the stack with `pnpm test:api:up` or run the full flow with `pnpm test:api`.',
);
}
/**
* The coverage oracle must describe the server actually under test, so fetch its
* generated OpenAPI document rather than assuming a local `pnpm build` ran.
* public/openapi.json is a gitignored build artifact and is usually absent in a
* checkout that only runs the Compose stack.
*/
async function fetchOpenApiDocument(baseURL: string) {
const url = `${baseURL}/openapi.json`;
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const document = await response.json();
if (!document?.paths) {
throw new Error('response has no "paths" object');
}
writeFileSync(OPENAPI_FILE, JSON.stringify(document));
return;
} catch (error) {
if (existsSync(LOCAL_OPENAPI_FILE)) {
console.warn(
`Could not fetch ${url} (${(error as Error).message}); ` +
`using the locally generated ${LOCAL_OPENAPI_FILE} as the coverage oracle instead.`,
);
copyFileSync(LOCAL_OPENAPI_FILE, OPENAPI_FILE);
return;
}
throw new Error(
`Could not fetch the OpenAPI document from ${url} (${(error as Error).message}) and no ` +
`local ${LOCAL_OPENAPI_FILE} exists. The server under test must serve its generated spec ` +
'(it is built by `pnpm build:openapi`), or generate it locally with `pnpm openapi:generate`.',
);
}
}
export default async function globalSetup(config: FullConfig) {
// Seeding traffic must not count towards endpoint coverage.
disableCoverageRecording();
const baseURL = config.projects[0]?.use?.baseURL;
if (!baseURL) {
throw new Error('playwright.api.config.ts must define use.baseURL');
}
assertDisposableTarget(baseURL);
// API_SKIP_SEED=1 reuses the seed from a previous run against a kept stack
// (fast iteration on a single spec).
const skipSeed = !!process.env.API_SKIP_SEED && existsSync(SEED_FILE);
// Coverage must reflect this run only: the per-process logs are append-only and
// the reporter merges every file in the directory, so stale logs from an earlier
// run would let removed endpoint calls still count as covered.
rmSync(COVERAGE_DIR, { recursive: true, force: true });
mkdirSync(COVERAGE_DIR, { recursive: true });
console.log(`Waiting for ${baseURL}/api/heartbeat ...`);
await waitForHeartbeat(baseURL, HEARTBEAT_TIMEOUT_MS);
await fetchOpenApiDocument(baseURL);
if (skipSeed) {
console.log(`Reusing seed state from ${SEED_FILE} (API_SKIP_SEED is set).`);
return;
}
const context = await request.newContext({ baseURL });
try {
const started = Date.now();
const state = await seedEnvironment(new ApiClient(context));
writeFileSync(SEED_FILE, JSON.stringify(state, null, 2));
console.log(
`Seeded ${state.db} environment in ${((Date.now() - started) / 1000).toFixed(1)}s ` +
`(${state.data.expectedPageviews} pageviews on ${state.website.domain})`,
);
} finally {
await context.dispose();
}
}