Run API tests serially and support external targets without stale coverage logs

This commit is contained in:
Mike Cao committed 2026-09-06 20:11:15 -07:00
1 parent e18769e8cf
commit 4e47e350bf
3 files changed
+25 -6

No files matched your search

+3 -1
View File
@@ -20,8 +20,10 @@ export default defineConfig({
testDir: './tests/api', testDir: './tests/api',
testMatch: '**/*.spec.ts', testMatch: '**/*.spec.ts',
outputDir: 'test-results/api', outputDir: 'test-results/api',
// Specs share one seeded database and a few mutable globals (the shared users,
// the global 2FA requirement toggled by admin.spec.ts), so they must run serially.
fullyParallel: false, fullyParallel: false,
workers: process.env.API_WORKERS ? Number(process.env.API_WORKERS) : 1, workers: 1,
// Specs are deterministic and share coverage state; retries would only hide flakes. // Specs are deterministic and share coverage state; retries would only hide flakes.
retries: 0, retries: 0,
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
+18 -1
View File
@@ -20,6 +20,9 @@ const COMPOSE_FILE = 'docker-compose.test.yml';
const PROJECT_NAME = process.env.COMPOSE_PROJECT_NAME || 'umami-api-test'; const PROJECT_NAME = process.env.COMPOSE_PROJECT_NAME || 'umami-api-test';
const PORT = process.env.UMAMI_TEST_PORT || '3100'; const PORT = process.env.UMAMI_TEST_PORT || '3100';
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL || `http://localhost:${PORT}`; const BASE_URL = process.env.PLAYWRIGHT_BASE_URL || `http://localhost:${PORT}`;
// PLAYWRIGHT_BASE_URL selects an already-running server: no Compose stack is
// built, started, or torn down for it.
const EXTERNAL_TARGET = !!process.env.PLAYWRIGHT_BASE_URL;
const HEARTBEAT_TIMEOUT_MS = 180_000; const HEARTBEAT_TIMEOUT_MS = 180_000;
// Mirrors RUNTIME_DIR in tests/api/paths.ts (one state dir per target host). // Mirrors RUNTIME_DIR in tests/api/paths.ts (one state dir per target host).
const LOG_FILE = path.join( const LOG_FILE = path.join(
@@ -46,6 +49,8 @@ Environment:
UMAMI_TEST_PORT Host port for the app (default 3100) UMAMI_TEST_PORT Host port for the app (default 3100)
UMAMI_TEST_IMAGE Image tag to build/run (default umami-test:local) UMAMI_TEST_IMAGE Image tag to build/run (default umami-test:local)
COMPOSE_PROJECT_NAME Compose project name (default umami-api-test) COMPOSE_PROJECT_NAME Compose project name (default umami-api-test)
PLAYWRIGHT_BASE_URL Test an already-running server instead of starting the
Compose stack (pass --clickhouse if it stores analytics there)
API_SKIP_SEED=1 Reuse the previous seed when re-running against a kept stack API_SKIP_SEED=1 Reuse the previous seed when re-running against a kept stack
(each run seeds additively otherwise) (each run seeds additively otherwise)
`; `;
@@ -193,6 +198,14 @@ async function main() {
run('docker', composeArgs(['postgres', 'clickhouse'], ['down', '-v', '--remove-orphans'])); run('docker', composeArgs(['postgres', 'clickhouse'], ['down', '-v', '--remove-orphans']));
}; };
if (EXTERNAL_TARGET && (options.upOnly || options.down)) {
console.error(
'PLAYWRIGHT_BASE_URL selects an already-running server; --up-only and --down manage ' +
'the local Compose stack and cannot be combined with it.',
);
return 2;
}
if (options.down) { if (options.down) {
teardown(); teardown();
return 0; return 0;
@@ -226,6 +239,9 @@ async function main() {
process.on('SIGTERM', onSignal); process.on('SIGTERM', onSignal);
try { try {
if (EXTERNAL_TARGET) {
console.log(`Using the already-running server at ${BASE_URL} (PLAYWRIGHT_BASE_URL is set).`);
} else {
console.log(`Starting test stack (profile: ${profile}, port: ${PORT})...`); console.log(`Starting test stack (profile: ${profile}, port: ${PORT})...`);
const upArgs = ['up', '-d', '--wait', '--wait-timeout', '300']; const upArgs = ['up', '-d', '--wait', '--wait-timeout', '300'];
@@ -241,6 +257,7 @@ async function main() {
dumpLogs(); dumpLogs();
return 1; return 1;
} }
}
await waitForHeartbeat(BASE_URL, HEARTBEAT_TIMEOUT_MS); await waitForHeartbeat(BASE_URL, HEARTBEAT_TIMEOUT_MS);
@@ -272,7 +289,7 @@ async function main() {
process.env.API_COVERAGE ?? (options.grep || options.coverageReport ? 'report' : 'enforce'), process.env.API_COVERAGE ?? (options.grep || options.coverageReport ? 'report' : 'enforce'),
}); });
if (exitCode !== 0) { if (exitCode !== 0 && started) {
dumpLogs(); dumpLogs();
} }
+4 -4
View File
@@ -45,13 +45,13 @@ export default async function globalSetup(config: FullConfig) {
} }
// API_SKIP_SEED=1 reuses the seed from a previous run against a kept stack // API_SKIP_SEED=1 reuses the seed from a previous run against a kept stack
// (fast iteration on a single spec); coverage logs are then accumulated too. // (fast iteration on a single spec).
const skipSeed = !!process.env.API_SKIP_SEED && existsSync(SEED_FILE); const skipSeed = !!process.env.API_SKIP_SEED && existsSync(SEED_FILE);
if (!skipSeed) { // 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 }); rmSync(COVERAGE_DIR, { recursive: true, force: true });
}
mkdirSync(COVERAGE_DIR, { recursive: true }); mkdirSync(COVERAGE_DIR, { recursive: true });
console.log(`Waiting for ${baseURL}/api/heartbeat ...`); console.log(`Waiting for ${baseURL}/api/heartbeat ...`);