#!/usr/bin/env bun /** * Smart api test runner. * * Optimizations (CI / QUINN_REQUIRE_DB_TESTS): * - One TEMPLATE database with all test migrations applied once per run * - Per-file DBs cloned via CREATE DATABASE … TEMPLATE (~seconds, not ~20s migrate) * - Parallel workers (QUINN_TEST_WORKERS, default 4 on CI) * - QUINN_CI_FAST=1 runs a smoke subset on push/PR; workflow_dispatch runs full * * Off-LAN: probes DB latency and skips DB-dependent files when slow/unreachable. */ import { readFileSync } from 'node:fs'; import { Glob } from 'bun'; import postgres from 'postgres'; import { buildTestTemplate, dropTestDatabase, sweepOrphanTestDbs, DEFAULT_ADMIN_DB_URL, } from './test-template'; const TIMEOUT = process.env['QUINN_TEST_TIMEOUT'] ?? '60000'; const LATENCY_THRESHOLD_MS = Number(process.env['QUINN_DB_LATENCY_SKIP_MS'] ?? '250'); const DB_URL = process.env['QUINN_TEST_DB_URL'] ?? 'postgresql://quinn:devpassword@black.lan:25435/quinn_test'; const ADMIN_URL = process.env['QUINN_TEST_ADMIN_DB_URL'] ?? DB_URL.replace(/\/[^/]+$/, '/postgres'); const isCi = process.env['CI'] != null || process.env['GITHUB_ACTIONS'] != null || process.env['GITEA_ACTIONS'] != null; const forceRequire = isCi || process.env['QUINN_REQUIRE_DB_TESTS'] === '1'; const forceSkip = process.env['QUINN_SKIP_DB_TESTS'] === '1'; const ciFast = process.env['QUINN_CI_FAST'] === '1'; const workers = Math.max( 1, Number(process.env['QUINN_TEST_WORKERS'] ?? (isCi ? '4' : '1')), ); /** Push/PR smoke — full suite on workflow_dispatch (QUINN_CI_FULL=1 or no QUINN_CI_FAST). */ const CI_FAST_FILES = new Set([ 'src/__tests__/pg-migration.test.ts', 'src/__tests__/www-provider-config-shop.test.ts', 'src/__tests__/admin-content-drops.test.ts', 'src/__tests__/admin-gallery-items.test.ts', 'src/__tests__/vip-api.test.ts', 'src/__tests__/my-clients.test.ts', 'src/__tests__/server.test.ts', 'src/__tests__/public-contact.test.ts', 'src/__tests__/engine-drafts.test.ts', 'src/__tests__/short-links.test.ts', 'src/__tests__/www-blog.test.ts', 'src/__tests__/admin-lore-sections.test.ts', ]); /** Files referencing the DB harness are integration tests needing the live DB. */ const HARNESS_RE = /test-db|openTestDb|initTestDb|createTestApp|injectDb|activeTestTx|runMigrations/; function log(line: string): void { process.stdout.write(`${line}\n`); } async function probeDb(): Promise<{ reachable: boolean; rttMs: number | null }> { const sql = postgres(ADMIN_URL, { max: 1, connect_timeout: 4, idle_timeout: 1, onnotice: () => { /* quiet */ } }); try { await sql`SELECT 1`; const t0 = performance.now(); for (let i = 0; i < 3; i++) await sql`SELECT 1`; return { reachable: true, rttMs: Math.round((performance.now() - t0) / 3) }; } catch { return { reachable: false, rttMs: null }; } finally { await sql.end({ timeout: 2 }).catch(() => { /* ignore */ }); } } async function decide(): Promise<{ skipDb: boolean; reason: string }> { if (forceRequire) return { skipDb: false, reason: isCi ? 'CI — full suite required' : 'QUINN_REQUIRE_DB_TESTS=1' }; if (forceSkip) return { skipDb: true, reason: 'QUINN_SKIP_DB_TESTS=1' }; const { reachable, rttMs } = await probeDb(); if (!reachable) return { skipDb: true, reason: 'test DB unreachable' }; if (rttMs != null && rttMs > LATENCY_THRESHOLD_MS) { return { skipDb: true, reason: `DB round-trip ${rttMs}ms > ${LATENCY_THRESHOLD_MS}ms (not on the fast LAN path)` }; } return { skipDb: false, reason: `DB round-trip ${rttMs ?? 0}ms` }; } function isDbDependent(file: string): boolean { try { return HARNESS_RE.test(readFileSync(file, 'utf8')); } catch { return false; } } function chunkFiles(files: string[], n: number): string[][] { const chunks: string[][] = Array.from({ length: n }, () => []); for (let i = 0; i < files.length; i += 1) { chunks[i % n]!.push(files[i]!); } return chunks.filter((c) => c.length > 0); } async function runOneFile(file: string, childEnv: Record): Promise { log(`[run-tests] ▶ ${file}`); const proc = Bun.spawn(['bun', 'test', '--parallel=1', `--timeout=${TIMEOUT}`, file], { env: childEnv, stdout: 'inherit', stderr: 'inherit', stdin: 'inherit', }); const code = await proc.exited; if (code !== 0) log(`[run-tests] ✖ ${file} (exit ${code})`); return code; } async function runWorker(files: string[], childEnv: Record): Promise<{ exitCode: number; failed: string[] }> { let exitCode = 0; const failed: string[] = []; for (const file of files) { const code = await runOneFile(file, childEnv); if (code !== 0) { exitCode = code !== 0 ? code : exitCode; failed.push(file); } } return { exitCode: exitCode || (failed.length > 0 ? 1 : 0), failed }; } const { skipDb, reason } = await decide(); const allFiles = [...new Glob('src/**/*.test.ts').scanSync('.')].sort(); let filesToRun = skipDb ? allFiles.filter((f) => !isDbDependent(f)) : allFiles; if (ciFast && !skipDb) { const fast = filesToRun.filter((f) => CI_FAST_FILES.has(f)); log(`[run-tests] QUINN_CI_FAST=1 — running ${fast.length}/${filesToRun.length} smoke file(s)`); filesToRun = fast; } const skippedCount = allFiles.length - filesToRun.length; log(`[run-tests] DB tests ${skipDb ? 'SKIPPED' : 'enabled'} — ${reason}`); if (skipDb) { log(`[run-tests] skipping ${skippedCount} DB-dependent file(s); running ${filesToRun.length} DB-free file(s).`); log('[run-tests] run on LAN (black) or set QUINN_REQUIRE_DB_TESTS=1 to include them.'); } if (filesToRun.length === 0) { log('[run-tests] no files to run — nothing to do.'); process.exit(0); } const childEnv: Record = { ...(process.env as Record) }; if (skipDb) childEnv['QUINN_SKIP_DB_TESTS'] = '1'; else childEnv['QUINN_REQUIRE_DB_TESTS'] = '1'; let templateName: string | undefined; if (!skipDb) { const adminUrl = DEFAULT_ADMIN_DB_URL; const swept = await sweepOrphanTestDbs(adminUrl); if (swept > 0) log(`[run-tests] swept ${swept} orphan test database(s)`); log('[run-tests] building migration template (once per run)…'); const t0 = performance.now(); templateName = await buildTestTemplate(adminUrl); log(`[run-tests] template ${templateName} ready in ${Math.round(performance.now() - t0)}ms`); childEnv['QUINN_TEST_DB_TEMPLATE'] = templateName; childEnv['QUINN_TEST_SKIP_SWEEP'] = '1'; childEnv['QUINN_TEST_ADMIN_DB_URL'] = adminUrl; } log(`[run-tests] ${filesToRun.length} file(s), ${workers} worker(s)`); const chunks = chunkFiles(filesToRun, workers); let exitCode = 0; const failedFiles: string[] = []; try { const results = await Promise.all(chunks.map((chunk) => runWorker(chunk, childEnv))); for (const r of results) { if (r.exitCode !== 0) exitCode = r.exitCode; failedFiles.push(...r.failed); } } finally { if (templateName) { log(`[run-tests] dropping template ${templateName}`); await dropTestDatabase(templateName, childEnv['QUINN_TEST_ADMIN_DB_URL'] ?? ADMIN_URL).catch((err) => { log(`[run-tests] template drop failed: ${String(err)}`); }); } } if (failedFiles.length > 0) { log(`[run-tests] ${failedFiles.length} file(s) failed:`); for (const f of failedFiles) log(` - ${f}`); } process.exit(failedFiles.length > 0 ? (exitCode || 1) : exitCode);