Files
blog/tests/support/pocketbase.ts
T
davide 33b49c5dbb Add hand-rolled English support (/en/*) alongside the Italian site
PocketBase gains optional, manually-authored English fields on articles
and categories (same record, same slug), and the frontend serves an /en
counterpart of every dynamic route via thin page wrappers around shared
view components — no i18n library, consistent with the project's existing
minimalism stance for a two-locale site with ~20 UI strings.

An article/category with no translation 404s cleanly on its own /en
detail page and is filtered out of /en listings, and the header's
language-switch link falls back to the English blog index rather than a
dead link; resolving that requires a global route middleware, since the
layout's header renders before the page content in document order and so
can't react to state a page component sets during its own async setup.
2026-09-11 13:53:17 +02:00

171 lines
5.6 KiB
TypeScript

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<string> {
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<number> {
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<void> {
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<void>
}
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<TestPocketbase> {
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<never>((_, 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 })
},
}
}