fix: make SQLite migrations and e2e reproducible
This commit is contained in:
@@ -21,7 +21,9 @@ for (const name of previous) assert.equal(readFileSync(join(baseline, 'prisma/mi
|
||||
const Database = require(join(repo, 'node_modules/better-sqlite3'));
|
||||
const dir = mkdtempSync(join(tmpdir(), 'wealthledger-bank-durability-'));
|
||||
let file = join(dir, 'history.db');
|
||||
const env = { ...process.env, DATABASE_URL: `file:${file}`, RUST_LOG: 'info', NODE_ENV: 'development', LOG_LEVEL: 'error', FRONTEND_ORIGIN: 'http://localhost:5189', JWT_SECRET: 'fictional-bank-durability-secret-long-enough', TOKEN_ENCRYPTION_KEY: Buffer.from('0123456789abcdef0123456789abcdef').toString('base64') };
|
||||
const env = { ...process.env, DATABASE_URL: `file:${file}`, NODE_ENV: 'development', LOG_LEVEL: 'error', FRONTEND_ORIGIN: 'http://localhost:5189', JWT_SECRET: 'fictional-bank-durability-secret-long-enough', TOKEN_ENCRYPTION_KEY: Buffer.from('0123456789abcdef0123456789abcdef').toString('base64') };
|
||||
// Historical baseline config predates the Prisma RUST_LOG isolation.
|
||||
delete env.RUST_LOG;
|
||||
let child, origin, run = 0;
|
||||
const credentials = { email: 'durability@example.test', password: 'fictional password long enough' };
|
||||
const rub = value => ({ currency: 'RUB', value });
|
||||
|
||||
@@ -2,7 +2,6 @@ import { INestApplication } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Server } from 'node:http';
|
||||
import request from 'supertest';
|
||||
@@ -83,23 +82,6 @@ describe('Manual bank cards configured HTTP and real SQLite', () => {
|
||||
db = app.get(PrismaService);
|
||||
};
|
||||
beforeAll(async () => {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (
|
||||
!url ||
|
||||
!/^file:\/(?:private\/)?tmp\/bank-cards-[^/]+\/.+\.db$/.test(url)
|
||||
)
|
||||
throw new Error(
|
||||
'Pass a unique /tmp/bank-cards-*/test.db DATABASE_URL. No shared/user database is allowed.',
|
||||
);
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
['node_modules/prisma/build/index.js', 'migrate', 'deploy'],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, RUST_LOG: 'info' },
|
||||
stdio: 'pipe',
|
||||
},
|
||||
);
|
||||
await start();
|
||||
await db.user.createMany({
|
||||
data: [
|
||||
|
||||
+15
-2
@@ -1,4 +1,17 @@
|
||||
process.env.DATABASE_URL ??= 'file:/tmp/wealth-ledger-e2e.db';
|
||||
const { join, dirname, basename } = require('node:path');
|
||||
const { tmpdir } = require('node:os');
|
||||
const dir = process.env.WL_E2E_DIRECTORY;
|
||||
if (
|
||||
!dir ||
|
||||
dirname(dir) !== tmpdir() ||
|
||||
!basename(dir).startsWith('wealthledger-e2e-') ||
|
||||
process.env.DATABASE_URL !== `file:${join(dir, 'test.db')}`
|
||||
) {
|
||||
throw new Error(
|
||||
'Use npm run test:e2e or test:sqlite: an isolated runner database is required.',
|
||||
);
|
||||
}
|
||||
process.env.FRONTEND_ORIGIN = 'http://localhost:5173';
|
||||
process.env.JWT_SECRET = 'e2e-secret-that-is-at-least-thirty-two-characters';
|
||||
process.env.TOKEN_ENCRYPTION_KEY = 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=';
|
||||
process.env.TOKEN_ENCRYPTION_KEY =
|
||||
'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=';
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// Full fresh-DB proof, including concurrent runners and failure/signal cleanup.
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawn } = require('node:child_process');
|
||||
const {
|
||||
mkdtempSync,
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
existsSync,
|
||||
} = require('node:fs');
|
||||
const { tmpdir } = require('node:os');
|
||||
const { join, resolve } = require('node:path');
|
||||
const cwd = resolve(__dirname, '..');
|
||||
const evidence = mkdtempSync(join(tmpdir(), 'wealthledger-sqlite-regression-'));
|
||||
const sentinel = join(evidence, 'user.db');
|
||||
writeFileSync(sentinel, 'fictional user database: must never be opened');
|
||||
const before = readFileSync(sentinel);
|
||||
const databases = new Set();
|
||||
async function check(name, overrides = {}, signal) {
|
||||
const child = spawn(process.execPath, ['test/sqlite.cjs', '--all'], {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
DATABASE_URL: `file:${sentinel}`,
|
||||
RUST_LOG: 'warn',
|
||||
LOG_LEVEL: 'error',
|
||||
...overrides,
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let log = '';
|
||||
let sent = false;
|
||||
child.stdout.on('data', (chunk) => {
|
||||
log += chunk;
|
||||
if (signal && !sent && log.includes('SQLite ')) {
|
||||
sent = true;
|
||||
child.kill(signal);
|
||||
}
|
||||
});
|
||||
child.stderr.on('data', (chunk) => {
|
||||
log += chunk;
|
||||
});
|
||||
const code = await new Promise((resolve, reject) => {
|
||||
child.once('error', reject);
|
||||
child.once('close', resolve);
|
||||
});
|
||||
writeFileSync(join(evidence, `${name}.log`), log);
|
||||
const dirs = [...log.matchAll(/^SQLite .+: (.+)$/gm)].map(
|
||||
(match) => match[1],
|
||||
);
|
||||
assert.ok(dirs.length > 0, 'runner must allocate a database');
|
||||
for (const dir of dirs) {
|
||||
assert.ok(!databases.has(dir), 'runners must never share databases');
|
||||
databases.add(dir);
|
||||
assert.ok(!existsSync(dir), `runner must clean up ${dir}`);
|
||||
}
|
||||
assert.deepEqual(
|
||||
readFileSync(sentinel),
|
||||
before,
|
||||
'user database must be untouched',
|
||||
);
|
||||
const expected =
|
||||
signal === 'SIGINT'
|
||||
? 130
|
||||
: signal === 'SIGTERM'
|
||||
? 143
|
||||
: overrides.PRISMA_SCHEMA_ENGINE_BINARY
|
||||
? 1
|
||||
: 0;
|
||||
assert.equal(code, expected, `See ${name}.log`);
|
||||
console.log(`PASS ${name}: ${dirs.length} isolated databases`);
|
||||
}
|
||||
(async () => {
|
||||
console.log(`Evidence: ${evidence}`);
|
||||
for (let i = 1; i <= 3; i++) await check(`sequential-${i}`);
|
||||
const parallel = await Promise.allSettled([
|
||||
check('parallel-1'),
|
||||
check('parallel-2', { RUST_LOG: 'error' }),
|
||||
]);
|
||||
for (const result of parallel)
|
||||
if (result.status === 'rejected') throw result.reason;
|
||||
await check('migration-failure', {
|
||||
PRISMA_SCHEMA_ENGINE_BINARY: join(evidence, 'missing-engine'),
|
||||
});
|
||||
await check('sigint', {}, 'SIGINT');
|
||||
await check('sigterm', {}, 'SIGTERM');
|
||||
console.log(`PASS regression: ${evidence}`);
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
+86
-36
@@ -1,42 +1,92 @@
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { mkdtempSync, rmSync } = require('node:fs');
|
||||
const { spawn } = require('node:child_process');
|
||||
const { mkdtempSync, readdirSync, rmSync } = require('node:fs');
|
||||
const { tmpdir } = require('node:os');
|
||||
const { join, resolve } = require('node:path');
|
||||
|
||||
const cwd = resolve(__dirname, '..');
|
||||
for (const suite of ['app.e2e-spec.ts', 'bank-cards.e2e-spec.ts']) {
|
||||
const dir = mkdtempSync('/tmp/bank-cards-');
|
||||
const env = {
|
||||
...process.env,
|
||||
DATABASE_URL: `file:${join(dir, 'test.db')}`,
|
||||
RUST_LOG: 'info',
|
||||
NODE_ENV: 'development',
|
||||
NODE_OPTIONS: '--experimental-vm-modules',
|
||||
};
|
||||
const available = readdirSync(__dirname)
|
||||
.filter((name) => name.endsWith('.e2e-spec.ts'))
|
||||
.sort();
|
||||
const requested = process.argv.slice(2);
|
||||
const suites =
|
||||
requested.length === 0
|
||||
? ['app.e2e-spec.ts', 'bank-cards.e2e-spec.ts']
|
||||
: requested.length === 1 && requested[0] === '--all'
|
||||
? available
|
||||
: requested;
|
||||
if (suites.some((suite) => !available.includes(suite)))
|
||||
throw new Error('Unknown e2e suite');
|
||||
let child;
|
||||
let interrupted;
|
||||
let killTimer;
|
||||
function kill(signal) {
|
||||
if (!child) return;
|
||||
try {
|
||||
for (const args of [
|
||||
['node_modules/prisma/build/index.js', 'migrate', 'deploy'],
|
||||
[
|
||||
'node_modules/jest/bin/jest.js',
|
||||
'--config',
|
||||
'test/jest-e2e.json',
|
||||
'--runInBand',
|
||||
'--runTestsByPath',
|
||||
`test/${suite}`,
|
||||
],
|
||||
]) {
|
||||
const result = spawnSync(process.execPath, args, {
|
||||
cwd,
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) {
|
||||
process.exitCode = result.status ?? 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
process.kill(-child.pid, signal);
|
||||
} catch (error) {
|
||||
if (error.code !== 'ESRCH') throw error;
|
||||
}
|
||||
if (process.exitCode) break;
|
||||
}
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||
process.on(signal, () => {
|
||||
interrupted = signal;
|
||||
kill(signal);
|
||||
killTimer ??= setTimeout(() => kill('SIGKILL'), 5000);
|
||||
});
|
||||
}
|
||||
function run(args, env) {
|
||||
return new Promise((resolve, reject) => {
|
||||
child = spawn(process.execPath, args, {
|
||||
cwd,
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
detached: true,
|
||||
});
|
||||
child.once('error', reject);
|
||||
child.once('close', (code, signal) => {
|
||||
clearTimeout(killTimer);
|
||||
child = undefined;
|
||||
if (code !== 0 || signal)
|
||||
reject(new Error(`Child failed: ${signal ?? code}`));
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
(async () => {
|
||||
for (const suite of suites) {
|
||||
if (interrupted) throw new Error(interrupted);
|
||||
const dir = mkdtempSync(join(tmpdir(), 'wealthledger-e2e-'));
|
||||
const env = {
|
||||
...process.env,
|
||||
DATABASE_URL: `file:${join(dir, 'test.db')}`,
|
||||
WL_E2E_DIRECTORY: dir,
|
||||
NODE_ENV: 'development',
|
||||
NODE_OPTIONS: '--experimental-vm-modules',
|
||||
};
|
||||
console.log(`SQLite ${suite}: ${dir}`);
|
||||
try {
|
||||
await run(
|
||||
['node_modules/prisma/build/index.js', 'migrate', 'deploy'],
|
||||
env,
|
||||
);
|
||||
if (interrupted) throw new Error(interrupted);
|
||||
await run(
|
||||
[
|
||||
'node_modules/jest/bin/jest.js',
|
||||
'--config',
|
||||
'test/jest-e2e.json',
|
||||
'--runInBand',
|
||||
'--runTestsByPath',
|
||||
`test/${suite}`,
|
||||
],
|
||||
env,
|
||||
);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error.message);
|
||||
process.exitCode =
|
||||
interrupted === 'SIGINT' ? 130 : interrupted === 'SIGTERM' ? 143 : 1;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user