Files
blog/tests/support/pocketbase.ts
T
davide 91181f2170 Add unit, integration and e2e test suites
tests/{unit,integration,e2e}/, at the repo root (not under frontend/,
since e2e exercises the frontend and PocketBase together) — all
against real code rather than mocks:

- unit/: Vitest (nuxt environment) — pure functions in server/utils
  and shared/utils, plus components/composables via
  @nuxt/test-utils/runtime's mountSuspended.
- integration/: Vitest (node environment) — @nuxt/test-utils/e2e's
  setup() builds and runs the real Nitro server against a real
  ephemeral PocketBase instance, exercising the actual /content/* and
  /admin routes end to end.
- e2e/: Playwright, a real browser against the built app and another
  ephemeral PocketBase, covering the user-facing flows (home, blog
  archive, article detail incl. JSON-LD, category filter, 404s, the
  /admin redirect).

The Vitest/Playwright tooling (and node_modules) stays in frontend/,
the repo's only npm project; its configs just point at ../tests/.
Playwright resolves packages from the node_modules nearest each spec
file, so `npm run test:e2e` first symlinks tests/node_modules to
frontend/node_modules (pretest:e2e, idempotent, gitignored).

tests/support/pocketbase.ts is the shared piece behind integration
and e2e: it downloads the same pinned PocketBase binary
pocketbase/Dockerfile uses, starts it against the real
pocketbase/pb_migrations/ (not a copy), and seeds it from
tests/support/fixtures.ts — so these tests run against the exact
schema and API rules that ship to production, catching the class of
bug that only shows up when PocketBase actually enforces them (e.g. a
wrong filter/fields query string silently returning nothing, or too
much).

CLAUDE.md and docs/frontend.md document the new npm scripts and the
single-test commands for each layer.
2026-09-11 11:48:43 +02:00

166 lines
5.4 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
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 ?? '',
}),
})
articles.push((await response.json()) as TestArticle)
}
return {
url,
categories,
articles,
async stop() {
proc.kill()
rmSync(dataDir, { recursive: true, force: true })
},
}
}