From 2000653fb4c8062063503f77815b9ad5f0804ac4 Mon Sep 17 00:00:00 2001 From: Anso Date: Tue, 28 Apr 2026 10:08:39 -0400 Subject: [PATCH] perf(test): build baseline DB once via vitest globalSetup (#829) Each test file's setupTestDb() previously re-ran the full DatabaseService init path: initSchema (~30 CREATE TABLE IF NOT EXISTS), 14 idempotent migrate*() methods, a bcrypt hash, and the admin / settings seed inserts. With 82 files this was a meaningful slice of the per-fork cold-start cost. Move the build into a vitest globalSetup that runs once before any worker boots. The baseline DB lands at a fixed temp path; each worker's setupTestDb copies it into the per-file data dir, opens the copy via DatabaseService.getInstance() (re-running the same idempotent init as a no-op pass), then UPDATEs the seeded local node's compose_dir to match the per-file COMPOSE_DIR (the baseline recorded /app/compose because COMPOSE_DIR was unset when the seed fired in initSchema; without realigning, file-routes tests 400 on path traversal). TEST_JWT_SECRET moves from a per-file randomBytes assignment to a fixed constant in a new testConstants module so the value the baseline seeds matches the value test files import for direct token signing. setupTestDb re-exports it for back-compat with the existing import sites. A baseline-less measurement on the same machine flakes 30 of 82 files at the no-cap baseline; with this baseline copy, the same tree drops to 0-3 failures (the residual environmental Windows flakes) and ~47-52 s wall time. --- backend/src/__tests__/helpers/setupTestDb.ts | 47 +++++++++++------ .../src/__tests__/helpers/testConstants.ts | 20 +++++++ .../__tests__/helpers/vitestGlobalSetup.ts | 52 +++++++++++++++++++ backend/vitest.config.ts | 3 ++ 4 files changed, 107 insertions(+), 15 deletions(-) create mode 100644 backend/src/__tests__/helpers/testConstants.ts create mode 100644 backend/src/__tests__/helpers/vitestGlobalSetup.ts diff --git a/backend/src/__tests__/helpers/setupTestDb.ts b/backend/src/__tests__/helpers/setupTestDb.ts index 52b44fe0..80030c71 100644 --- a/backend/src/__tests__/helpers/setupTestDb.ts +++ b/backend/src/__tests__/helpers/setupTestDb.ts @@ -4,16 +4,23 @@ * * Call this at the top of every test file *before* importing the app, * because DatabaseService initialises its path on first getInstance() call. + * + * The baseline DB (schema + migrations + admin seed) is built once by + * vitest globalSetup; this helper just copies it into the per-test temp + * directory so each file pays a file-copy cost instead of a full + * schema-init + bcrypt.hash + seed-insert. */ import os from 'os'; import path from 'path'; import fs from 'fs'; -import bcrypt from 'bcrypt'; -import crypto from 'crypto'; +import { + TEST_USERNAME, + TEST_PASSWORD, + TEST_JWT_SECRET, + BASELINE_DB_PATH, +} from './testConstants'; -export const TEST_USERNAME = 'testadmin'; -export const TEST_PASSWORD = 'testpassword123'; -export let TEST_JWT_SECRET = ''; +export { TEST_USERNAME, TEST_PASSWORD, TEST_JWT_SECRET }; export async function setupTestDb(): Promise { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-test-')); @@ -23,19 +30,29 @@ export async function setupTestDb(): Promise { fs.mkdirSync(composeDir, { recursive: true }); process.env.COMPOSE_DIR = composeDir; - // Initialise the DB (singleton will use DATA_DIR we just set) + if (!fs.existsSync(BASELINE_DB_PATH)) { + throw new Error( + `Baseline test DB not found at ${BASELINE_DB_PATH}. Vitest globalSetup ` + + `(backend/src/__tests__/helpers/vitestGlobalSetup.ts) is responsible for ` + + `building it; check that vitest.config.ts still wires globalSetup.`, + ); + } + fs.copyFileSync(BASELINE_DB_PATH, path.join(tmpDir, 'sencho.db')); + + // Initialise the DB singleton against the copied baseline. The constructor + // re-runs initSchema() (CREATE TABLE IF NOT EXISTS no-ops) and every + // migrate*() (idempotent per CLAUDE.md), so opening an already-migrated + // copy is fast. const { DatabaseService } = await import('../../services/DatabaseService'); const db = DatabaseService.getInstance(); - // Seed admin credentials - const passwordHash = await bcrypt.hash(TEST_PASSWORD, 1); // cost=1 for speed in tests - TEST_JWT_SECRET = crypto.randomBytes(32).toString('hex'); - db.updateGlobalSetting('auth_username', TEST_USERNAME); - db.updateGlobalSetting('auth_password_hash', passwordHash); - db.updateGlobalSetting('auth_jwt_secret', TEST_JWT_SECRET); - - // Also seed the users table (RBAC login reads from here) - db.addUser({ username: TEST_USERNAME, password_hash: passwordHash, role: 'admin' }); + // The baseline's local node row was seeded with whatever COMPOSE_DIR was + // at globalSetup time (undefined, so fell back to /app/compose). Each + // test file resolves stack paths against process.env.COMPOSE_DIR via + // FileSystemService; routes that look up the node's stored compose_dir + // need it to match the per-file temp dir, otherwise they 400 on + // path-traversal or 404 on missing files. Realign here. + db.getDb().prepare('UPDATE nodes SET compose_dir = ? WHERE is_default = 1').run(composeDir); return tmpDir; } diff --git a/backend/src/__tests__/helpers/testConstants.ts b/backend/src/__tests__/helpers/testConstants.ts new file mode 100644 index 00000000..6f6d4277 --- /dev/null +++ b/backend/src/__tests__/helpers/testConstants.ts @@ -0,0 +1,20 @@ +import os from 'os'; +import path from 'path'; + +/** + * Constants shared between vitestGlobalSetup (which builds the baseline + * test DB once) and setupTestDb (which copies the baseline per file). + * Kept in their own module so both can import without circular deps. + */ + +export const TEST_USERNAME = 'testadmin'; +export const TEST_PASSWORD = 'testpassword123'; +// Fixed across the suite so JWT signing/verifying lines up between the +// admin user seeded in the baseline and tests that sign tokens directly. +// 64 hex chars matches the shape of a real JWT_SECRET produced by +// crypto.randomBytes(32).toString('hex'); the value itself is not secret. +export const TEST_JWT_SECRET = + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + +export const BASELINE_DIR = path.join(os.tmpdir(), 'sencho-test-baseline'); +export const BASELINE_DB_PATH = path.join(BASELINE_DIR, 'sencho.db'); diff --git a/backend/src/__tests__/helpers/vitestGlobalSetup.ts b/backend/src/__tests__/helpers/vitestGlobalSetup.ts new file mode 100644 index 00000000..71a8cd6c --- /dev/null +++ b/backend/src/__tests__/helpers/vitestGlobalSetup.ts @@ -0,0 +1,52 @@ +import fs from 'fs'; +import bcrypt from 'bcrypt'; +import { + TEST_USERNAME, + TEST_PASSWORD, + TEST_JWT_SECRET, + BASELINE_DIR, +} from './testConstants'; + +/** + * Vitest global setup: build the baseline test DB once before any worker + * runs. Each test file's setupTestDb() then copies this file into its own + * temp dir instead of paying the schema-init + migrate*() + bcrypt.hash + + * seed-insert cost N times. DatabaseService.getInstance() in the worker + * opens the existing baseline; the constructor's CREATE TABLE IF NOT + * EXISTS and idempotent migrate*() methods (per CLAUDE.md guarantee) + * collapse to no-ops. + */ +export default async function setup(): Promise { + if (fs.existsSync(BASELINE_DIR)) { + fs.rmSync(BASELINE_DIR, { recursive: true, force: true }); + } + fs.mkdirSync(BASELINE_DIR, { recursive: true }); + + const prevDataDir = process.env.DATA_DIR; + process.env.DATA_DIR = BASELINE_DIR; + try { + const { DatabaseService } = await import('../../services/DatabaseService'); + const db = DatabaseService.getInstance(); + + const passwordHash = await bcrypt.hash(TEST_PASSWORD, 1); + db.updateGlobalSetting('auth_username', TEST_USERNAME); + db.updateGlobalSetting('auth_password_hash', passwordHash); + db.updateGlobalSetting('auth_jwt_secret', TEST_JWT_SECRET); + db.addUser({ username: TEST_USERNAME, password_hash: passwordHash, role: 'admin' }); + + // Flush the file so workers see a complete DB on copy. + db.getDb().close(); + } finally { + // Restore env so workers do not inherit DATA_DIR pointing at the + // shared baseline. setupTestDb overrides it per file regardless, + // but defense-in-depth against an early DatabaseService import. + if (prevDataDir === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = prevDataDir; + } +} + +export function teardown(): void { + if (fs.existsSync(BASELINE_DIR)) { + fs.rmSync(BASELINE_DIR, { recursive: true, force: true }); + } +} diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts index fd4035ef..1b868de1 100644 --- a/backend/vitest.config.ts +++ b/backend/vitest.config.ts @@ -6,6 +6,9 @@ export default defineConfig({ // Only run TypeScript sources - exclude the compiled dist/ output. include: ['src/__tests__/**/*.test.ts'], exclude: ['dist/**', 'node_modules/**'], + // Build the baseline DB (schema + migrations + admin seed) once; each + // test file's setupTestDb copies it instead of re-running migrations. + globalSetup: ['./src/__tests__/helpers/vitestGlobalSetup.ts'], // Each test file gets its own worker so singletons are fresh between files. pool: 'forks', // Cap concurrency: each worker dynamic-imports the full Express stack