Files
pchelpmaster/tests/support/pocketbase-server.ts
T
kamaskandClaude Opus 5.5 5d5b2af318 test(api): add temporary pocketbase harness for api tests
`pnpm test:api` starts an isolated PocketBase with project migrations and
hooks in a temp dir, provides SDK client helpers and cleans up after the run,
on failure and on SIGINT/SIGTERM. The superuser is generated per instance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EvmtStx737hu8gXe15e55W
2026-10-26 17:40:36 +03:00

146 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Запускает временный PocketBase с миграциями и хуками проекта (см. docs/testing.md).
// Выполняется под чистым Node (tests/support/fixtures/hold-pocketbase.ts): только импорты node:* и стираемый TypeScript.
import { type ChildProcess, execFile, spawn } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { once } from 'node:events';
import { rmSync } from 'node:fs';
import { mkdtemp, rm } from 'node:fs/promises';
import { type AddressInfo, createServer } from 'node:net';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
import { promisify } from 'node:util';
const START_TIMEOUT_MS = 10_000;
const STOP_TIMEOUT_MS = 5_000;
const SIGNALS = ['SIGINT', 'SIGTERM'] as const;
/** Запущенный временный экземпляр PocketBase. */
export interface PocketBaseServer {
url: string;
dataDir: string;
superuser: { email: string; password: string };
/** Останавливает процесс и удаляет `dataDir`. Повторный вызов безопасен. */
stop(): Promise<void>;
}
/**
* Запускает одноразовый PocketBase на свободном порту и ждёт, пока он ответит на `/api/health`.
*
* Процесс и каталог данных удаляются при `stop()`, по `SIGINT`/`SIGTERM`, при выходе
* и при ошибке запуска.
*
* @throws Если `pocketbase` нет в PATH, не создался суперпользователь или сервер не ответил
* за {@link START_TIMEOUT_MS}. В сообщение ошибки включён вывод PocketBase.
*/
export async function startPocketBase(): Promise<PocketBaseServer> {
const superuser = { email: `superuser-${randomUUID()}@test.local`, password: randomUUID() };
const dataDir = await mkdtemp(join(tmpdir(), 'pchelpcrm-pb-'));
let child: ChildProcess | undefined;
let stopping: Promise<void> | undefined;
const onSignal = (signal: NodeJS.Signals) => {
void stop().then(() => process.kill(process.pid, signal));
};
// Обработчик `exit` не может ждать, поэтому здесь только синхронные вызовы.
const onExit = () => {
child?.kill('SIGKILL');
rmSync(dataDir, { recursive: true, force: true });
};
const stopProcess = async () => {
if (child?.pid !== undefined && child.exitCode === null && child.signalCode === null) {
const exited = once(child, 'exit');
child.kill('SIGTERM');
const timer = setTimeout(() => child?.kill('SIGKILL'), STOP_TIMEOUT_MS);
await exited;
clearTimeout(timer);
}
await rm(dataDir, { recursive: true, force: true });
// Снимаются последними: сигнал во время очистки не должен убить владельца раньше её конца.
for (const signal of SIGNALS) process.off(signal, onSignal);
process.off('exit', onExit);
};
// Очистка выполняется один раз: повторные вызовы получают тот же промис.
const stop = () => (stopping ??= stopProcess());
for (const signal of SIGNALS) process.on(signal, onSignal);
process.on('exit', onExit);
try {
await promisify(execFile)('pocketbase', [
'superuser',
'upsert',
superuser.email,
superuser.password,
'--dir',
dataDir,
]).catch((error: NodeJS.ErrnoException & { stdout?: string; stderr?: string }) => {
if (error.code === 'ENOENT') throw binaryNotFound(error);
throw new Error(`pocketbase superuser upsert failed:\n${error.stdout}${error.stderr}`);
});
const url = `http://127.0.0.1:${await freePort()}`;
let output = '';
let spawnError: Error | undefined;
const proc = spawn('pocketbase', [
'serve',
'--dir',
dataDir,
'--migrationsDir',
'pocketbase/pb_migrations',
'--hooksDir',
'pocketbase/pb_hooks',
'--http',
url.slice('http://'.length),
// Иначе изменения коллекций, сделанные тестами, попадут в pocketbase/pb_migrations.
'--automigrate=false',
]);
child = proc;
proc.stdout.on('data', (chunk: Buffer) => (output += chunk));
proc.stderr.on('data', (chunk: Buffer) => (output += chunk));
proc.on('error', (error: NodeJS.ErrnoException) => {
spawnError = error.code === 'ENOENT' ? binaryNotFound(error) : error;
});
for (const deadline = Date.now() + START_TIMEOUT_MS; ; await delay(100)) {
if (spawnError) throw spawnError;
if (proc.exitCode !== null || proc.signalCode !== null) {
throw new Error(`PocketBase exited before becoming ready:\n${output}`);
}
if (await isHealthy(url)) return { url, dataDir, superuser, stop };
if (Date.now() > deadline) {
throw new Error(
`PocketBase did not answer /api/health within ${START_TIMEOUT_MS} ms:\n${output}`,
);
}
}
} catch (error) {
await stop();
throw error;
}
}
function binaryNotFound(cause: Error): Error {
return new Error('pocketbase binary not found in PATH: install PocketBase and add it to PATH', {
cause,
});
}
// shortcut: порт может быть занят между close() и запуском pocketbase serve; при нестабильности добавить повтор при ошибке привязки.
async function freePort(): Promise<number> {
const server = createServer().listen(0, '127.0.0.1');
await once(server, 'listening');
const { port } = server.address() as AddressInfo;
server.close();
await once(server, 'close');
return port;
}
async function isHealthy(url: string): Promise<boolean> {
try {
return (await fetch(`${url}/api/health`, { signal: AbortSignal.timeout(1_000) })).ok;
} catch {
return false;
}
}