mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-28 12:49:03 +00:00
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.
This commit is contained in:
@@ -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<string> {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-test-'));
|
||||
@@ -23,19 +30,29 @@ export async function setupTestDb(): Promise<string> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
@@ -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<void> {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user