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.
171 lines
5.6 KiB
TypeScript
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 })
|
|
},
|
|
}
|
|
}
|