mirror of
https://github.com/kamask-dev/pchelpmaster.git
synced 2026-11-11 05:49:38 +00:00
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
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b7b32a0457
commit
5d5b2af318
+16
-12
@@ -4,13 +4,13 @@
|
|||||||
|
|
||||||
## Уровни тестов
|
## Уровни тестов
|
||||||
|
|
||||||
| Уровень | Что проверяет | Где лежит | Команда |
|
| Уровень | Что проверяет | Где лежит | Команда |
|
||||||
| --- | --- | --- | --- |
|
| --------------------- | -------------------------------------------------------------------------- | --------------------------------------------- | ----------------------- |
|
||||||
| **API** (e2e бэкенда) | схема, миграции, правила доступа, изоляция пользователей, хуки PocketBase | `tests/api/**/*.api.spec.ts` | `pnpm test:api` |
|
| **API** (e2e бэкенда) | схема, миграции, правила доступа, изоляция пользователей, хуки PocketBase | `tests/api/**/*.api.spec.ts` | `pnpm test:api` |
|
||||||
| **Unit** | доменные функции, компоненты и сервисы с фейками вместо PocketBase | рядом с кодом, `src/**/*.spec.ts` | `pnpm test` |
|
| **Unit** | доменные функции, компоненты и сервисы с фейками вместо PocketBase | рядом с кодом, `src/**/*.spec.ts` | `pnpm test` |
|
||||||
| **Integration** | PocketBase-реализации репозиториев и сервисов против настоящего PocketBase | рядом с кодом, `src/**/*.integration.spec.ts` | `pnpm test:integration` |
|
| **Integration** | PocketBase-реализации репозиториев и сервисов против настоящего PocketBase | рядом с кодом, `src/**/*.integration.spec.ts` | `pnpm test:integration` |
|
||||||
| **E2E** | пользовательские сценарии в браузере, SSR, доступность (axe) | `tests/e2e/**/*.e2e.ts` | `pnpm e2e` |
|
| **E2E** | пользовательские сценарии в браузере, SSR, доступность (axe) | `tests/e2e/**/*.e2e.ts` | `pnpm e2e` |
|
||||||
| **E2E prod** | сценарии на production-сборке, включая изоляцию данных при SSR | `tests/e2e/` (проект `ssr-prod`) | `pnpm e2e:prod` |
|
| **E2E prod** | сценарии на production-сборке, включая изоляцию данных при SSR | `tests/e2e/` (проект `ssr-prod`) | `pnpm e2e:prod` |
|
||||||
|
|
||||||
Команды появляются по мере того, как настраиваются соответствующие стенды.
|
Команды появляются по мере того, как настраиваются соответствующие стенды.
|
||||||
|
|
||||||
@@ -24,7 +24,9 @@ export function describeTagRepositoryContract(
|
|||||||
setup: () => Promise<{ repoA: TagRepository; repoB: TagRepository }>,
|
setup: () => Promise<{ repoA: TagRepository; repoB: TagRepository }>,
|
||||||
) {
|
) {
|
||||||
describe('контракт TagRepository', () => {
|
describe('контракт TagRepository', () => {
|
||||||
it('не показывает пользователю B метки пользователя A', async () => { /* ... */ });
|
it('не показывает пользователю B метки пользователя A', async () => {
|
||||||
|
/* ... */
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -35,13 +37,15 @@ export function describeTagRepositoryContract(
|
|||||||
|
|
||||||
API-, integration- и e2e-тесты работают с **отдельным временным экземпляром** PocketBase, а не с `pocketbase/pb_data`:
|
API-, integration- и e2e-тесты работают с **отдельным временным экземпляром** PocketBase, а не с `pocketbase/pb_data`:
|
||||||
|
|
||||||
1. Создаётся временная папка данных.
|
1. Создаётся временная папка данных (в системной temp-папке).
|
||||||
2. Создаётся суперпользователь (`pocketbase superuser upsert`) с данными из `.env` (`PB_SUPERUSER_EMAIL`, `PB_SUPERUSER_PASSWORD`).
|
2. Создаётся суперпользователь (`pocketbase superuser upsert`) со сгенерированными email и паролем: инстанс одноразовый, поэтому `.env` тестам не нужен.
|
||||||
3. Запускается `pocketbase serve` на свободном порту с `--dir <временная папка> --migrationsDir pocketbase/pb_migrations --hooksDir pocketbase/pb_hooks`; миграции применяются при старте.
|
3. Запускается `pocketbase serve` на свободном порту `127.0.0.1` с `--dir <временная папка> --migrationsDir pocketbase/pb_migrations --hooksDir pocketbase/pb_hooks --automigrate=false`; миграции применяются при старте.
|
||||||
4. Ожидание готовности по `GET /api/health`.
|
4. Ожидание готовности по `GET /api/health`.
|
||||||
5. После прогона процесс останавливается, папка удаляется.
|
5. После прогона процесс останавливается, папка удаляется.
|
||||||
|
|
||||||
Код запуска один на все уровни: `tests/support/pocketbase-server.ts`.
|
Код запуска один на все уровни: `tests/support/pocketbase-server.ts` (`startPocketBase(): Promise<{ url, dataDir, superuser, stop }>`). В API-тестах URL и суперпользователь доступны через `inject('pbUrl')` и `inject('pbSuperuser')`.
|
||||||
|
|
||||||
|
Стенд убирает за собой процесс и папку после успешного прогона, после падения (в том числе неудачного старта), по `SIGINT`/`SIGTERM` (Ctrl+C) и при выходе процесса. Предел: `SIGKILL` процесса-владельца перехватить нельзя — тогда PocketBase может продолжить работу, а папка `pchelpcrm-pb-*` остаться в temp.
|
||||||
|
|
||||||
## Правила написания тестов
|
## Правила написания тестов
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"build": "ng build",
|
"build": "ng build",
|
||||||
"watch": "ng build --watch --configuration development",
|
"watch": "ng build --watch --configuration development",
|
||||||
"test": "ng test",
|
"test": "ng test",
|
||||||
|
"test:api": "vitest run --config vitest.api.config.ts",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"serve:ssr:pchelpcrm": "node dist/pchelpcrm/server/server.mjs",
|
"serve:ssr:pchelpcrm": "node dist/pchelpcrm/server/server.mjs",
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { TestProject } from 'vitest/node';
|
||||||
|
import { startPocketBase } from '../support/pocketbase-server';
|
||||||
|
import '../support/clients';
|
||||||
|
|
||||||
|
export default async function setup(project: TestProject) {
|
||||||
|
const { url, dataDir, superuser, stop } = await startPocketBase();
|
||||||
|
project.provide('pbUrl', url);
|
||||||
|
project.provide('pbDataDir', dataDir);
|
||||||
|
project.provide('pbSuperuser', superuser);
|
||||||
|
return stop;
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { once } from 'node:events';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { describe, expect, inject, it, vi } from 'vitest';
|
||||||
|
import { asGuest, asSuperuser, asUser, createUser } from '../support/clients';
|
||||||
|
import { type PocketBaseServer, startPocketBase } from '../support/pocketbase-server';
|
||||||
|
|
||||||
|
const health = (url: string) => fetch(`${url}/api/health`);
|
||||||
|
const holdFixture = path.resolve('tests/support/fixtures/hold-pocketbase.ts');
|
||||||
|
|
||||||
|
describe('стенд API-тестов', () => {
|
||||||
|
it('отвечает 200 на GET /api/health', async () => {
|
||||||
|
const response = await health(inject('pbUrl'));
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asSuperuser() авторизован как суперпользователь', async () => {
|
||||||
|
const pb = await asSuperuser();
|
||||||
|
|
||||||
|
expect(pb.authStore.isValid).toBe(true);
|
||||||
|
expect(pb.authStore.isSuperuser).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asGuest() не авторизован', () => {
|
||||||
|
const pb = asGuest();
|
||||||
|
|
||||||
|
expect(pb.authStore.isValid).toBe(false);
|
||||||
|
expect(pb.authStore.record).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createUser() каждый раз создаёт нового пользователя с уникальным email', async () => {
|
||||||
|
const a = await createUser();
|
||||||
|
const b = await createUser();
|
||||||
|
|
||||||
|
expect(a.id).not.toBe(b.id);
|
||||||
|
expect(a.email).not.toBe(b.email);
|
||||||
|
expect(a.email).toMatch(/^user-.+@test\.local$/);
|
||||||
|
expect(b.email).toMatch(/^user-.+@test\.local$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createUser(overrides) применяет переданные поля', async () => {
|
||||||
|
const user = await createUser({ name: 'Иван' });
|
||||||
|
|
||||||
|
const record = await (await asSuperuser()).collection('users').getOne(user.id);
|
||||||
|
|
||||||
|
expect(record['name']).toBe('Иван');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asUser(user) авторизован именно этим пользователем', async () => {
|
||||||
|
const user = await createUser();
|
||||||
|
|
||||||
|
const pb = await asUser(user);
|
||||||
|
|
||||||
|
expect(pb.authStore.isValid).toBe(true);
|
||||||
|
expect(pb.authStore.isSuperuser).toBe(false);
|
||||||
|
expect(pb.authStore.record?.id).toBe(user.id);
|
||||||
|
expect(pb.authStore.record?.['email']).toBe(user.email);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('у каждого хелпера свой экземпляр SDK', async () => {
|
||||||
|
const superuser = await asSuperuser();
|
||||||
|
const guest = asGuest();
|
||||||
|
|
||||||
|
expect(guest).not.toBe(superuser);
|
||||||
|
expect(guest).not.toBe(asGuest());
|
||||||
|
expect(guest.authStore.isValid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('работает на отдельном временном PocketBase, а не на pocketbase/pb_data', async () => {
|
||||||
|
const url = inject('pbUrl');
|
||||||
|
const dataDir = inject('pbDataDir');
|
||||||
|
|
||||||
|
expect(url).not.toBe(process.env['PB_URL'] ?? 'http://127.0.0.1:8090');
|
||||||
|
expect(new URL(url).hostname).toBe('127.0.0.1');
|
||||||
|
expect(path.resolve(dataDir)).not.toBe(path.resolve('pocketbase/pb_data'));
|
||||||
|
expect(path.resolve(dataDir).startsWith(path.resolve(os.tmpdir()) + path.sep)).toBe(true);
|
||||||
|
expect(existsSync(dataDir)).toBe(true);
|
||||||
|
|
||||||
|
const user = await createUser();
|
||||||
|
const record = await (await asSuperuser()).collection('users').getOne(user.id);
|
||||||
|
expect(record.id).toBe(user.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('startPocketBase', { timeout: 30_000 }, () => {
|
||||||
|
it('stop() останавливает процесс и удаляет временную папку', async () => {
|
||||||
|
const server = await startPocketBase();
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect((await health(server.url)).status).toBe(200);
|
||||||
|
expect(existsSync(server.dataDir)).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await server.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(health(server.url)).rejects.toThrow();
|
||||||
|
expect(existsSync(server.dataDir)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['SIGINT', 'SIGTERM'] as const)(
|
||||||
|
'по %s процесс-владелец убирает за собой PocketBase и папку',
|
||||||
|
async (signal) => {
|
||||||
|
// Фикстура поднимает PocketBase, печатает в stdout JSON с url и dataDir и ждёт сигнала.
|
||||||
|
// Флаг подавляет предупреждение MODULE_TYPELESS_PACKAGE_JSON для .ts-файла.
|
||||||
|
const child = spawn(
|
||||||
|
process.execPath,
|
||||||
|
['--disable-warning=MODULE_TYPELESS_PACKAGE_JSON', holdFixture],
|
||||||
|
{
|
||||||
|
stdio: ['ignore', 'pipe', 'inherit'],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Если фикстура упала до вывода, тест падает сразу, а не ждёт таймаута.
|
||||||
|
const [chunk] = await Promise.race([
|
||||||
|
once(child.stdout, 'data'),
|
||||||
|
once(child, 'exit').then(() => {
|
||||||
|
throw new Error('фикстура завершилась, не запустив PocketBase');
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const { url, dataDir } = JSON.parse(String(chunk)) as {
|
||||||
|
url: string;
|
||||||
|
dataDir: string;
|
||||||
|
};
|
||||||
|
expect((await health(url)).status).toBe(200);
|
||||||
|
|
||||||
|
const exited = once(child, 'exit');
|
||||||
|
child.kill(signal);
|
||||||
|
await exited;
|
||||||
|
|
||||||
|
await expect(health(url)).rejects.toThrow();
|
||||||
|
expect(existsSync(dataDir)).toBe(false);
|
||||||
|
} finally {
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('повторный stop() не бросает ошибку', async () => {
|
||||||
|
const server = await startPocketBase();
|
||||||
|
await server.stop();
|
||||||
|
|
||||||
|
await expect(server.stop()).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('параллельные запуски получают разные порты и папки', async () => {
|
||||||
|
// allSettled, а не all: если один запуск упал, второй всё равно нужно остановить в finally.
|
||||||
|
const results = await Promise.allSettled([startPocketBase(), startPocketBase()]);
|
||||||
|
const servers = results
|
||||||
|
.filter((r): r is PromiseFulfilledResult<PocketBaseServer> => r.status === 'fulfilled')
|
||||||
|
.map((r) => r.value);
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(servers).toHaveLength(2);
|
||||||
|
const [a, b] = servers;
|
||||||
|
expect(a.url).not.toBe(b.url);
|
||||||
|
expect(a.dataDir).not.toBe(b.dataDir);
|
||||||
|
expect(a.superuser.email).not.toBe(b.superuser.email);
|
||||||
|
expect((await health(a.url)).status).toBe(200);
|
||||||
|
expect((await health(b.url)).status).toBe(200);
|
||||||
|
} finally {
|
||||||
|
await Promise.all(servers.map((s) => s.stop()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('без бинарника pocketbase в PATH падает с понятной ошибкой, а не по таймауту', async () => {
|
||||||
|
vi.stubEnv('PATH', '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect(startPocketBase()).rejects.toThrow(/pocketbase/i);
|
||||||
|
} finally {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// PocketBase SDK clients for tests; each helper returns its own instance with an in-memory auth store.
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import PocketBase, { BaseAuthStore } from 'pocketbase';
|
||||||
|
import { inject } from 'vitest';
|
||||||
|
|
||||||
|
declare module 'vitest' {
|
||||||
|
export interface ProvidedContext {
|
||||||
|
pbUrl: string;
|
||||||
|
pbDataDir: string;
|
||||||
|
pbSuperuser: { email: string; password: string };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asGuest(): PocketBase {
|
||||||
|
return new PocketBase(inject('pbUrl'), new BaseAuthStore());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function asSuperuser(): Promise<PocketBase> {
|
||||||
|
const { email, password } = inject('pbSuperuser');
|
||||||
|
const pb = asGuest();
|
||||||
|
await pb.collection('_superusers').authWithPassword(email, password);
|
||||||
|
return pb;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(overrides: Record<string, unknown> = {}): Promise<TestUser> {
|
||||||
|
const data = { email: `user-${randomUUID()}@test.local`, password: randomUUID(), ...overrides };
|
||||||
|
const record = await (
|
||||||
|
await asSuperuser()
|
||||||
|
)
|
||||||
|
.collection('users')
|
||||||
|
.create({ ...data, passwordConfirm: data.password });
|
||||||
|
return { id: record.id, email: String(data.email), password: String(data.password) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function asUser(user: TestUser): Promise<PocketBase> {
|
||||||
|
const pb = asGuest();
|
||||||
|
await pb.collection('users').authWithPassword(user.email, user.password);
|
||||||
|
return pb;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Starts PocketBase and holds it until the process gets a signal; used to test cleanup on Ctrl+C.
|
||||||
|
import { startPocketBase } from '../pocketbase-server.ts';
|
||||||
|
|
||||||
|
const { url, dataDir } = await startPocketBase();
|
||||||
|
console.log(JSON.stringify({ url, dataDir }));
|
||||||
|
setInterval(() => undefined, 1 << 30);
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
// Запускает временный 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ['tests/api/**/*.api.spec.ts'],
|
||||||
|
globalSetup: ['tests/api/global-setup.ts'],
|
||||||
|
// Headroom for starting a temporary PocketBase inside a test.
|
||||||
|
testTimeout: 20_000,
|
||||||
|
hookTimeout: 20_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user