import { execFileSync, spawn } from 'node:child_process' import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { createServer } from 'node:net' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { FIXTURE_ARTICLES, FIXTURE_CATEGORIES } from './fixtures' // Keep in sync with pocketbase/Dockerfile's PB_VERSION build arg. const PB_VERSION = '0.40.3' const REPO_ROOT = fileURLToPath(new URL('../../', import.meta.url)) const MIGRATIONS_DIR = join(REPO_ROOT, 'pocketbase', 'pb_migrations') const BIN_DIR = join(REPO_ROOT, '.cache', 'pocketbase') const BIN_PATH = join(BIN_DIR, 'pocketbase') const TEST_ADMIN_EMAIL = 'test-admin@example.com' const TEST_ADMIN_PASSWORD = 'test-password-not-for-production' async function ensureBinary(): Promise { if (existsSync(BIN_PATH)) return BIN_PATH mkdirSync(BIN_DIR, { recursive: true }) const platform = process.platform === 'darwin' ? 'darwin' : 'linux' const arch = process.arch === 'arm64' ? 'arm64' : 'amd64' const downloadUrl = `https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_${platform}_${arch}.zip` const response = await fetch(downloadUrl) if (!response.ok) { throw new Error(`Failed to download PocketBase test binary: ${response.status} ${downloadUrl}`) } const zipPath = join(BIN_DIR, 'pocketbase.zip') writeFileSync(zipPath, Buffer.from(await response.arrayBuffer())) execFileSync('unzip', ['-o', zipPath, 'pocketbase', '-d', BIN_DIR]) rmSync(zipPath) execFileSync('chmod', ['+x', BIN_PATH]) return BIN_PATH } function findFreePort(): Promise { return new Promise((resolve, reject) => { const server = createServer() server.unref() server.on('error', reject) server.listen(0, '127.0.0.1', () => { const address = server.address() if (address && typeof address === 'object') { server.close(() => resolve(address.port)) } else { reject(new Error('Could not determine a free port')) } }) }) } async function waitForHealth(url: string, timeoutMs = 15000): Promise { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { try { const response = await fetch(`${url}/api/health`) if (response.ok) return } catch { // Not listening yet. } await new Promise((resolve) => setTimeout(resolve, 200)) } throw new Error(`PocketBase did not become healthy within ${timeoutMs}ms`) } export interface TestArticle { id: string slug: string publishedAt: string } export interface TestCategory { id: string name: string slug: string } export interface TestPocketbase { url: string /** Superuser auth token, for tests that need to create/inspect records beyond the seeded fixtures. */ token: string categories: TestCategory[] articles: TestArticle[] stop(): Promise } export interface StartOptions { /** Fixed port, for setups (e2e) that need it known ahead of time. Random by default. */ port?: number } /** * Spawns a real, disposable PocketBase instance — using `pocketbase/pb_migrations/` * directly, so tests run against the exact schema/rules that ship to production — * then seeds it with FIXTURE_CATEGORIES/FIXTURE_ARTICLES. */ export async function startTestPocketbase(options: StartOptions = {}): Promise { const bin = await ensureBinary() const dataDir = mkdtempSync(join(tmpdir(), 'pb-test-')) const port = options.port ?? (await findFreePort()) const url = `http://127.0.0.1:${port}` const proc = spawn( bin, ['serve', `--http=127.0.0.1:${port}`, `--dir=${dataDir}`, `--migrationsDir=${MIGRATIONS_DIR}`], { stdio: 'ignore' } ) const crashed = new Promise((_, reject) => { proc.on('exit', (code) => reject(new Error(`PocketBase exited early with code ${code}`))) }) await Promise.race([waitForHealth(url), crashed]) execFileSync(bin, ['superuser', 'upsert', TEST_ADMIN_EMAIL, TEST_ADMIN_PASSWORD, `--dir=${dataDir}`]) const authResponse = await fetch(`${url}/api/collections/_superusers/auth-with-password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identity: TEST_ADMIN_EMAIL, password: TEST_ADMIN_PASSWORD }), }) const { token } = (await authResponse.json()) as { token: string } const categories: TestCategory[] = [] for (const category of FIXTURE_CATEGORIES) { const response = await fetch(`${url}/api/collections/categories/records`, { method: 'POST', headers: { Authorization: token, 'Content-Type': 'application/json' }, body: JSON.stringify(category), }) categories.push((await response.json()) as TestCategory) } const articles: TestArticle[] = [] for (const article of FIXTURE_ARTICLES) { const category = categories.find((c) => c.slug === article.category) const response = await fetch(`${url}/api/collections/articles/records`, { method: 'POST', headers: { Authorization: token, 'Content-Type': 'application/json' }, body: JSON.stringify({ title: article.title, slug: article.slug, content: article.content, category: category?.id, publishedAt: article.publishedAt ?? '', authorName: article.authorName ?? '', titleEn: article.titleEn ?? '', contentEn: article.contentEn ?? '', }), }) articles.push((await response.json()) as TestArticle) } return { url, token, categories, articles, async stop() { proc.kill() rmSync(dataDir, { recursive: true, force: true }) }, } }