test(integration): add real-stack integration test suite with PGlite
- PGlite (in-memory WASM Postgres) hosted in a child process, exposed via TCP socket; real migrations applied with prisma migrate deploy - Vitest harness (vitest.integration.config.ts, global-setup spawns PGlite + built Nuxt server, per-test TRUNCATE, factories, cookie-jar API client); no mocks, zero production code changes - 50 tests across auth, hunt, team, submit, review, showcase, diashow incl. SuperJSON envelope check; *.itest.ts keeps bun test untouched - CI: parallel unit + integration jobs in .github/workflows/test.yml - tests/ covered by nuxt typecheck via tests/tsconfig.json reference
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { createServer } from 'node:net';
|
||||
import type { TestProject } from 'vitest/node';
|
||||
|
||||
// Fixed test-only secret, >= 32 chars as required by nuxt-auth-utils.
|
||||
const SESSION_PASSWORD = 'integration-tests-only-not-for-prod-use!';
|
||||
|
||||
async function freePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer();
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === 'string') {
|
||||
reject(new Error('Could not determine a free port'));
|
||||
return;
|
||||
}
|
||||
server.close(() => resolve(address.port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function waitForLine(child: ChildProcess, pattern: RegExp): Promise<string[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buffer = '';
|
||||
const onData = (chunk: Buffer) => {
|
||||
buffer += chunk.toString();
|
||||
for (const line of buffer.split('\n')) {
|
||||
const match = line.match(pattern);
|
||||
if (match) {
|
||||
child.stdout?.off('data', onData);
|
||||
resolve(match.slice(1));
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
child.stdout?.on('data', onData);
|
||||
child.on('exit', (code) =>
|
||||
reject(new Error(`Child exited with code ${code} before being ready`))
|
||||
);
|
||||
child.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function run(command: string, args: string[], env: NodeJS.ProcessEnv = {}) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, ...env }
|
||||
});
|
||||
child.on('exit', (code) =>
|
||||
code === 0
|
||||
? resolve()
|
||||
: reject(new Error(`${command} ${args.join(' ')} exited with ${code}`))
|
||||
);
|
||||
child.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForHealth(baseUrl: string, timeoutMs = 60_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: unknown = new Error('server never became healthy');
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/health`);
|
||||
if (res.ok) return;
|
||||
lastError = new Error(`health responded with ${res.status}`);
|
||||
} catch (e) {
|
||||
lastError = e;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
function kill(child: ChildProcess) {
|
||||
return new Promise<void>((resolve) => {
|
||||
if (child.exitCode !== null) return resolve();
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL');
|
||||
}, 3_000);
|
||||
child.on('exit', () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
child.kill('SIGTERM');
|
||||
});
|
||||
}
|
||||
|
||||
export default async function globalSetup(project: TestProject) {
|
||||
// 1. Boot in-memory PGlite behind a Postgres wire protocol socket.
|
||||
// It runs in a child process: teardown under Bun exits with code 99
|
||||
// (Emscripten), and isolating it keeps the vitest process clean.
|
||||
const dbHost = spawn('node', ['tests/integration/pglite-host.mjs'], {
|
||||
stdio: ['ignore', 'pipe', 'inherit']
|
||||
});
|
||||
const [databaseUrl] = await waitForLine(dbHost, /^PGLITE_READY (.+)$/);
|
||||
|
||||
// 2. Apply all migrations against the socket (proven path from Phase 0).
|
||||
await run('bunx', ['prisma', 'migrate', 'deploy'], {
|
||||
DATABASE_URL: databaseUrl
|
||||
});
|
||||
|
||||
// 3. Build the Nuxt server once for all test files.
|
||||
if (process.env.SKIP_BUILD !== '1') {
|
||||
await run('bun', ['run', 'build']);
|
||||
}
|
||||
|
||||
// 4. Spawn the built server against the WASM database.
|
||||
const port = await freePort();
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
const server = spawn('node', ['.output/server/index.mjs'], {
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: 'production',
|
||||
PORT: String(port),
|
||||
DATABASE_URL: databaseUrl,
|
||||
NUXT_SESSION_PASSWORD: SESSION_PASSWORD
|
||||
}
|
||||
});
|
||||
server.on('error', (e) => {
|
||||
throw e;
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForHealth(baseUrl);
|
||||
} catch (e) {
|
||||
await kill(server);
|
||||
await kill(dbHost);
|
||||
throw e;
|
||||
}
|
||||
|
||||
project.provide('baseUrl', baseUrl);
|
||||
project.provide('databaseUrl', databaseUrl);
|
||||
|
||||
return async () => {
|
||||
await kill(server);
|
||||
await kill(dbHost);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user