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.
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
/** Shared seed data for integration and e2e tests against a real ephemeral PocketBase. */
|
||||
|
||||
export interface FixtureCategory {
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
export interface FixtureArticle {
|
||||
title: string
|
||||
slug: string
|
||||
content: string
|
||||
category: string
|
||||
/** `null` = draft (never returned by the public API). */
|
||||
publishedAt: string | null
|
||||
authorName?: string
|
||||
}
|
||||
|
||||
export const FIXTURE_CATEGORIES: FixtureCategory[] = [
|
||||
{ name: 'Economia', slug: 'economia' },
|
||||
{ name: 'Attualità', slug: 'attualita' },
|
||||
]
|
||||
|
||||
export const FIXTURE_ARTICLES: FixtureArticle[] = [
|
||||
{
|
||||
title: 'Articolo pubblicato',
|
||||
slug: 'articolo-pubblicato',
|
||||
content: '# Titolo\n\nCorpo **markdown** di prova con [un link](https://example.com).',
|
||||
category: 'economia',
|
||||
publishedAt: '2025-01-15 10:00:00',
|
||||
authorName: 'Redazione',
|
||||
},
|
||||
{
|
||||
title: 'Articolo in bozza',
|
||||
slug: 'articolo-bozza',
|
||||
content: 'Contenuto non ancora pubblicato.',
|
||||
category: 'economia',
|
||||
publishedAt: null,
|
||||
},
|
||||
{
|
||||
title: 'Secondo articolo pubblicato',
|
||||
slug: 'secondo-articolo',
|
||||
content: 'Un altro articolo pubblicato, in un\'altra categoria.',
|
||||
category: 'attualita',
|
||||
publishedAt: '2025-02-01 08:00:00',
|
||||
authorName: 'Redazione',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,165 @@
|
||||
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 })
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user