Status: built. The Vitest + PGlite harness described here exists and works (
vitest.config.ts, thecreateTestDb()helper insrc/lib/server/test/db.ts, hundreds of*.test.tsfiles, in-process PGlite Postgres). Schema setup usespushSchemafromdrizzle-kit/api— no migration files, nodrizzle/directory, no schema-gen step. One piece remains not built: the rootAGENTS.mdfile (and any CI). Treat that as plan; everything else reflects current code.
This document describes the testing approach — what exists, why, and how.
The target is an AI agent testing loop: implement → run tests → parse output → fix failures → repeat. Claude Code does this natively. The infrastructure's job is to give the agent clear, structured feedback so that loop terminates quickly.
Container Execution Model
Nothing is installed on the host machine except Podman. Claude Code runs on the host but all tools (Bun, Vitest, Biome, node_modules) live inside the v10r container. Every command must go through podman exec.
HOST (Podman + Claude Code only)
│
│ edits files at /home/ad/dev/velociraptor/
│ runs: podman exec v10r <cmd>
│
└──► CONTAINER v10r (WORKDIR /app)
├── Bun runtime
├── node_modules (named volume)
├── Source at /app/ (bind mount ← host)
├── Vitest runs here
├── PGlite runs here (in-process)
└── Biome runs here
Bind mount (.:/app in compose.yaml): file edits on the host are instantly visible in the container. No copy step.
Exit codes pass through: podman exec returns whatever the inner command returns. Hooks work correctly — a failing test exits non-zero.
No path translation needed: Vitest and Biome run from the project root against src/. Host paths never enter the commands.
Container must be running: podman exec fails if v10r isn't up. If you're coding, the dev server is running — this is always true in practice.
No wrapper scripts: Use podman exec v10r <cmd> directly. Explicit is better.
Test Strategy
Two tools. No more.
| Tool | Purpose |
|---|---|
| Vitest 4.x | All automated tests — unit, integration, Svelte components |
| Claude Chrome extension | Browser interaction, E2E validation |
Why Vitest and not Bun test
Every module in $lib/server/ eventually imports $env/static/private or $env/dynamic/private. These are SvelteKit virtual modules — Vite resolves them at build time. Bun test has no Vite plugin system and cannot resolve them. Tests fail immediately on import (oven-sh/bun#5541, oven-sh/bun#10712 — both still open as of early 2026). Additionally, PGlite itself produces out-of-bounds WASM errors with bun test (oven-sh/bun#15032).
Vitest with the @sveltejs/kit/vite plugin resolves $lib/*, $env/*, and all SvelteKit aliases automatically. Same tool handles both server-side unit/integration tests and Svelte component tests.
Vitest 4.x: Use the projects field (not workspace, deprecated in Vitest 3.2) for multi-project configuration. Browser Mode is now stable in Vitest 4.0 but not needed for this project's initial phases.
Why no Playwright
The Claude Chrome extension handles browser interaction. Playwright would duplicate this capability and add configuration overhead for zero benefit. vitest-browser-svelte (real browser component testing via Playwright) is the future direction but requires ~300MB Chromium in the container — not justified until jsdom-based tests hit real limitations.
Why vi.mock and not dependency injection
Every query and mutation module imports db as a module-level singleton (import { db } from '../index'). Converting to DI would change 30+ function signatures across 8+ files plus all call sites — high cost, zero production benefit. The multi-client core architecture explicitly chose module-level imports over DI containers (zero-cost imports vs 50–200ms cold start on serverless). vi.mock('$lib/server/db', ...) is a one-line swap at a clean module boundary.
Directory Structure
Tests live next to source (co-located). A test file for service.ts sits at service.test.ts.
src/
lib/
server/
errors/
index.ts
index.test.ts ← unit test
rawrag/
rank.ts
rank.test.ts ← unit test (pure logic, highest value)
chunk.ts
chunk.test.ts ← unit test (pure logic)
db/
notifications/
queries.ts
queries.test.ts ← integration test (PGlite)
notifications/
service.ts
service.test.ts ← integration test (PGlite)
test/ ← shared test utilities (not a test runner target)
db.ts ← PGlite setup + schema push (drizzle-kit/api)
fixtures.ts ← test data factories
components/
primitives/
button/
Button.svelte
Button.test.ts ← component test
Database Test Isolation
PGlite (unit and integration tests)
PGlite runs WASM Postgres in-process inside the v10r container. No external DB, no network, no shared state. Each test file gets its own database instance — parallel-safe by default. PGlite installs as an npm devDependency into the named node_modules volume — no host installation needed. Target version: 0.3.14+ (PostgreSQL 17.4 based).
Schema sync via pushSchema: The harness calls pushSchema(schema, db) from drizzle-kit/api. This compiles the live TypeScript schema into DDL in-process — no migration files, no drizzle/ directory, no codegen step. It matches the project's push-only workflow (drizzle-kit push against the live Neon DB), so test and production schemas come from the same source with nothing to regenerate.
pushSchema returns { statementsToExecute }. The harness executes those statements manually rather than calling the bundled apply helper — apply fails on this project's unqualified enum references. The execution order matters:
- Run every
CREATE SCHEMAstatement first. - Set
search_pathso unqualified enum references resolve. - Run the remaining DDL.
This project defines 14 custom PostgreSQL schemas (admin, ai, analytics, app, auth, blog, dbops, desk, feedback, image, jobs, notifications, rag, showcase) plus public, with schema-scoped enums and cross-schema foreign keys. The schema-first + search_path ordering is what makes those resolve under PGlite, which starts with only public. The search_path set in createTestDb() lists 11 of the 14 — it omits dbops, feedback, and image (a known gap; those three define enums too).
Rejected: migrate with generated files. The migrate function from drizzle-orm/pglite/migrator needs SQL migration files. The project is push-only — there is no drizzle/ directory and no drizzle-kit generate step. Adding one solely for tests would split the schema source and create drift between the test schema and the live DB. pushSchema reads the same TypeScript schema the app uses, so no sync step exists to forget.
Driver difference: Production uses drizzle-orm/neon-serverless. Tests use drizzle-orm/pglite. Same query API, different connection. Swap via vi.mock.
pgvector: PGlite supports pgvector via @electric-sql/pglite/vector. Must be loaded explicitly via the extensions option — without it, any column using vector(1536) fails with "type vector does not exist." The CREATE EXTENSION must run before any DDL that references the vector type.
Custom schemas: PGlite starts with only public. pushSchema emits CREATE SCHEMA statements for every custom schema; the harness runs them first (before any table DDL), then sets search_path across them (currently 11 of the 14 — dbops, feedback, image omitted) so unqualified enum references resolve.
Cross-schema foreign keys: Almost every schema references auth.user.id. PGlite tolerates forward references within a single exec batch, but the auth schema and its tables must exist before dependent tables are created — pushSchema orders the returned statements so they do. Schemas cannot be loaded in true isolation; when bootstrapping new test coverage, expand incrementally:
| Phase | Schemas | Why |
|---|---|---|
| First | jobs |
Standalone, zero cross-schema FKs — validates the PGlite + pushSchema pipeline |
| Then | auth + notifications |
Cross-schema FKs, real business queries |
| Then | auth + ai |
Another cross-schema domain |
| Last | auth + rag |
Adds pgvector, custom types, post-push SQL |
Out-of-schema DDL: src/lib/server/db/rag/setup.ts contains raw SQL for a generated tsvector column (search_vector), an HNSW index (chunk_embedding_hnsw_idx), a GIN index on search_vector, and a seed row in embedding_model. These live outside Drizzle schema definitions and are not emitted by pushSchema. They require a separate client.exec(sql) after the push — only when testing RAG-specific queries.
Index support: PGlite supports GiST (for range types), GIN (for tsvector/jsonb), and B-tree indexes natively — no extensions needed. HNSW indexes (pgvector) are theoretically supported but unverified in WASM at scale — functional correctness is expected, not performance parity with native Postgres.
Snapshot/restore (future optimization): PGlite's dumpDataDir/loadDataDir API can cut test init time by creating the schema once and restoring per suite instead of re-running the push. The API is documented and stable, but the pattern is experimental at scale. Consider this if the test suite grows slow.
The real helper lives at src/lib/server/test/db.ts:
// src/lib/server/test/db.ts
import { PGlite } from '@electric-sql/pglite';
import { vector } from '@electric-sql/pglite/vector';
import { pushSchema } from 'drizzle-kit/api';
import { drizzle } from 'drizzle-orm/pglite';
import * as schema from '$lib/server/db/schema';
export async function createTestDb() {
const client = new PGlite({ extensions: { vector } });
await client.exec('CREATE EXTENSION IF NOT EXISTS vector');
const db = drizzle(client, { schema });
// Get DDL from drizzle-kit push. Don't call apply — it fails on unqualified enums.
const { statementsToExecute } = await pushSchema(schema, db as any);
// Create schemas first, then set search_path, then run the rest.
const schemaStmts = statementsToExecute.filter((s) => s.startsWith('CREATE SCHEMA'));
const otherStmts = statementsToExecute.filter((s) => !s.startsWith('CREATE SCHEMA'));
for (const stmt of schemaStmts) await client.exec(stmt);
await client.exec(
'SET search_path TO public, admin, ai, analytics, app, auth, blog, desk, jobs, notifications, rag, showcase',
);
for (const stmt of otherStmts) await client.exec(stmt);
return { db, client };
}
The db as any cast is needed because PGlite's db type doesn't match pushSchema's strict PgDatabase generic. Setting search_path before the table DDL is the load-bearing step — without it, the schema-scoped enums in unqualified column definitions fail to resolve.
Inject into tests via vi.mock:
// In any test file that needs the DB
vi.mock('$lib/server/db', async () => {
const { createTestDb } = await import('$lib/server/test/db');
const { db } = await createTestDb();
return { db };
});
Cleanup: Each test file creates one PGlite instance (via vi.mock factory, called once per module). Vitest runs files in parallel across workers — this is safe. Use afterAll to shut down the client:
// In test files using PGlite
let testClient: PGlite;
vi.mock('$lib/server/db', async () => {
const { createTestDb } = await import('$lib/server/test/db');
const { db, client } = await createTestDb();
testClient = client;
return { db };
});
afterAll(async () => {
await testClient?.close();
});
Neo4j (unit and integration tests)
Mock the cypher() function. It's a single import — one mock covers all graph calls.
vi.mock('$lib/server/graph', () => ({
cypher: vi.fn().mockResolvedValue({ records: [] }),
}));
For tests that need specific graph responses, configure the mock per test:
vi.mocked(cypher).mockResolvedValueOnce({
records: [{ get: (key: string) => mockData[key] }],
});
Test Aura instance: only if graph features require E2E validation. Not needed for unit or integration coverage.
Test Data Factories
Factories produce valid test objects with sensible defaults. Override only what the test cares about.
// src/lib/server/test/fixtures.ts
import type { InferInsertModel } from 'drizzle-orm';
import type { user } from '$lib/server/db/schema/auth/_better-auth';
import type { notifications } from '$lib/server/db/schema/notifications/notifications';
type UserInsert = InferInsertModel<typeof user>;
type NotificationInsert = InferInsertModel<typeof notifications>;
export function makeUser(overrides?: Partial<UserInsert>): UserInsert {
const id = overrides?.id ?? crypto.randomUUID();
return {
id,
name: `Test User ${id.slice(0, 6)}`,
email: `${id.slice(0, 8)}@test.local`,
emailVerified: false,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
export function makeNotification(overrides?: Partial<NotificationInsert>): NotificationInsert {
return {
id: crypto.randomUUID(),
userId: 'must-be-set',
type: 'system',
messageKey: 'notif_system',
messageParams: {},
isRead: false,
createdAt: new Date(),
...overrides,
};
}
Claude Code Hooks
Hook commands run on the host machine (where Claude Code runs). Since all tools live in the container, every command uses podman exec v10r.
PostToolUse — lint only
Runs after every Edit or Write. Fast (sub-second). Biome only — no tests.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [{
"type": "command",
"command": "podman exec v10r bun biome check --write --reporter json src/"
}]
}
]
}
}
No path translation needed — Biome runs against src/ from the container's /app workdir.
Why not run tests here: Running full tests after every edit causes thrashing. The agent gets failure reports for partially-complete work and starts fixing intermediate states before the feature is done.
Stop hook — quality gate
Runs when the agent declares completion. Uses a shell script that reads Claude Code's hook input JSON, checks for infinite loop prevention, and runs validation.
{
"hooks": {
"Stop": [
{
"hooks": [{
"type": "command",
"command": ".claude/hooks/stop-gate.sh"
}]
}
]
}
}
#!/bin/bash
# .claude/hooks/stop-gate.sh
# Read hook input from stdin
INPUT=$(cat)
STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active')
# CRITICAL: prevent infinite loop. When stop_hook_active is true,
# we are already in forced-continuation from a previous block.
# Allow the agent to stop to avoid looping forever.
if [ "$STOP_HOOK_ACTIVE" = "true" ]; then
exit 0
fi
# Run full validation inside the container.
# Uses bash (not sh) for pipefail support — without it,
# piping to tail swallows the exit code from vitest.
OUTPUT=$(podman exec v10r bash -c 'set -o pipefail; bun run check && bun biome check . && bun vitest run 2>&1 | tail -80' 2>&1)
EXIT_CODE=$?
if [ $EXIT_CODE -ne 0 ]; then
# Block the stop — feed failure output back to the agent
echo "{\"decision\": \"block\", \"reason\": \"Validation failed:\\n${OUTPUT}\"}"
exit 0
fi
# All checks passed — allow stop
exit 0
Why bash -c not sh -c: POSIX sh does not support set -o pipefail. Without pipefail, vitest run 2>&1 | tail -80 returns tail's exit code (always 0), silently masking test failures. Using bash -c ensures the pipeline returns vitest's exit code.
Why stop_hook_active check: Claude Code sets stop_hook_active: true in the hook input when the agent is already in forced-continuation from a previous block. Without this check, the agent loops infinitely: fail → block → retry → fail → block → ... This is a confirmed issue (claude-code#10205).
Why JSON output (not exit code 2): Exit code 2 with stderr is supposed to block the stop, but has a known bug in plugin-installed hooks (claude-code#10412). JSON {"decision": "block", "reason": "..."} on stdout with exit 0 is the reliable approach.
Risk: If pre-existing failures exist, the agent loops trying to fix them (until stopped by stop_hook_active). Fix all pre-existing failures before enabling this hook.
Container down: If v10r isn't running, podman exec fails and the output contains "no such container" — a clear signal, not a confusing test failure.
Machine-readable output
All commands prefixed with podman exec v10r when run from the host.
| Tool | Format | Agent benefit |
|---|---|---|
bun vitest run |
Console (default) | File paths, line numbers, diff output |
bun biome check --reporter json |
Structured JSON | File path, line number, fix suggestions |
bun vitest run --reporter=junit --outputFile=./junit.xml |
JUnit XML | CI integration |
Configuration Files
vitest.config.ts
import { defineConfig } from 'vitest/config';
import { sveltekit } from '@sveltejs/kit/vite';
export default defineConfig({
plugins: [sveltekit()],
test: {
include: ['src/**/*.test.ts', 'src/**/*.svelte.test.ts'],
environment: 'node',
globals: true,
testTimeout: 15_000,
setupFiles: ['src/lib/server/test/vitest.setup.ts'],
passWithNoTests: true,
},
});
The sveltekit() plugin resolves all SvelteKit virtual modules — $lib/*, $env/*, $app/*. Without it, every server-side test fails on import.
testTimeout: 15_000: PGlite WASM startup + schema push can take 3–5 seconds per test file. The default 5000ms timeout causes false failures. Set higher globally, keep individual tests fast.
setupFiles: Runs once per worker (per test file). Used for global mocking.
Vitest 4.x note: The workspace field is deprecated since Vitest 3.2. Use projects for multi-project configuration if needed later. The simple single-project config above works for the initial phases.
vitest.setup.ts
// src/lib/server/test/vitest.setup.ts
import { vi } from 'vitest';
// Prevent job schedulers from starting. Both check globalThis
// sentinels before starting setInterval loops. Without this,
// any transitive import of hooks.server.ts starts real schedulers.
globalThis.__v10r_scheduler = 'test';
globalThis.__v10r_delivery_scheduler = 'test';
// $env/dynamic/private resolves to an empty object in Vitest because
// no SvelteKit adapter calls server.init(). Redirect to process.env
// so that values from .env.test are available.
vi.mock('$env/dynamic/private', () => ({
env: new Proxy({}, { get: (_target, prop: string) => process.env[prop] }),
}));
// $env/static/private — same redirect, for modules that read it at load time
vi.mock(
'$env/static/private',
() => new Proxy({}, { get: (_target, prop: string) => process.env[prop] }),
);
// $app/environment — needed if any import chain touches schedulers or SSR guards
vi.mock('$app/environment', () => ({
building: false,
browser: false,
dev: true,
version: 'test',
}));
Why $env/dynamic/private mock: $env/static/private works via the sveltekit() plugin (reads .env at Vite startup). But $env/dynamic/private depends on adapter initialization that never happens during vitest run. Without this mock, any module importing env from $env/dynamic/private gets an empty object. Known open issue: sveltejs/kit#9564.
Why scheduler sentinels: hooks.server.ts imports $lib/server/jobs/scheduler and $lib/server/jobs/delivery-scheduler as side effects. Both start setInterval loops unless a globalThis sentinel is set. The sentinels are the cleanest containment — no need to mock the platform module.
Why $app/environment mock: Both schedulers check building from $app/environment. Other modules (sidebar.svelte.ts) check browser. Providing deterministic values prevents runtime detection logic from firing.
.env.test
# Fake values for $env/static/private — only needed when tests import
# modules that read these at module load time. Pure logic tests
# (rank.ts, chunk.ts, errors/) don't need any of these.
NEON_DATABASE_URL_PROD=postgresql://test:test@localhost/test
BETTER_AUTH_SECRET=a-test-secret-that-is-at-least-32-chars
BETTER_AUTH_URL=http://localhost:5173
GITHUB_CLIENT_ID=test
GITHUB_CLIENT_SECRET=test
GOOGLE_CLIENT_ID=test
GOOGLE_CLIENT_SECRET=test
NEO4J_URI=neo4j+s://test.databases.neo4j.io
NEO4J_USERNAME=neo4j
NEO4J_PASSWORD=test
Why this is needed: $lib/server/auth/index.ts throws at module load if BETTER_AUTH_SECRET is shorter than 32 characters. $lib/server/db/index.ts reads NEON_DATABASE_URL_PROD at module load. The sveltekit() plugin reads .env.test automatically for $env/static/private values. These are never used for real connections — PGlite replaces the DB, and auth is mocked.
package.json scripts
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"validate": "bun run check && biome ci . && bun run test && bun run i18n:check-missing && bun run content:check"
}
}
validate is the quality gate command. From the host: podman exec v10r bun run validate.
There is no schema-gen step. createTestDb() builds the schema in-process via pushSchema, reading the same TypeScript schema the app uses. The live Neon database is managed via drizzle-kit push (db:push); tests need nothing regenerated after a schema change.
Implementation Phases
Phase 0 — Foundation
- Add
vitesttodevDependenciesinpackage.json - Create
vitest.config.tswithsveltekit()plugin,testTimeout, andsetupFiles - Create
src/lib/server/test/vitest.setup.ts— global mocks and scheduler sentinels - Create
.env.testwith fake values for$env/static/privatemodules - Add test scripts to
package.json(test,test:watch) - Restart container so deps install:
podman compose down && podman compose up -d - Ensure
.svelte-kit/types exist — thesveltekit()plugin needs them. The dev server creates them automatically on startup (svelte-kit sync). If running tests before the dev server has started, runpodman exec v10r bun run checkfirst (which triggerssvelte-kit sync).
No tests yet. Verify from host: podman exec v10r bun run test exits cleanly with "no test files found."
Phase 1 — Pure logic tests
Start with modules that have zero external dependencies — no DB, no mocks, maximum signal. These validate the full pipeline (host → container → Vitest → results) while testing the highest-value algorithmic code.
Priority order:
$lib/server/rawrag/rank.ts— RRF algorithm, deduplication, fusion. Four functions, deterministic math, highest ROI. The ranking logic is non-trivial and could easily have off-by-one errors.$lib/server/rawrag/chunk.ts— text splitting with overlap and hierarchy. Only needscrypto.subtle(available in Bun). Complex enough to have real bugs.$lib/server/errors/index.ts—ServerErrorclass,toStatus()mapping, JSON serialization. Pure logic, validates the pipeline.$lib/schemas/showcase/— Valibot schema rules. Test withv.safeParse(). TherealtimeSchemapassword-confirm cross-field validation is the most interesting.
// src/lib/server/errors/index.test.ts
import { describe, expect, it } from 'vitest';
import { ServerError } from './index';
describe('ServerError', () => {
it('sets kind and message', () => {
const err = new ServerError('db', 'something failed');
expect(err.kind).toBe('db');
expect(err.message).toBe('something failed');
});
it('is an Error instance', () => {
expect(new ServerError('ai', 'provider unavailable')).toBeInstanceOf(Error);
});
it('maps to 500 by default', () => {
const err = new ServerError('unknown', 'test');
expect(err.toStatus()).toBe(500);
});
it('serializes to JSON', () => {
const err = new ServerError('db', 'connection lost', 'ECONNREFUSED');
const json = err.toJSON();
expect(json).toEqual({
name: 'ServerError',
kind: 'db',
message: 'connection lost',
code: 'ECONNREFUSED',
status: 500,
});
});
});
Goal: verify the full pipeline works end-to-end. Claude Code (host) runs podman exec v10r bun run test → Vitest resolves $lib paths inside container → results flow back to host.
Phase 2 — DB tests
- Add
@electric-sql/pglitetodevDependenciesinpackage.json(drizzle-ormanddrizzle-kitare already dependencies) - Restart container:
podman compose down && podman compose up -d - Create
src/lib/server/test/db.ts— PGlite setup withpushSchema(create schemas first, setsearch_path, run the rest) - Create
src/lib/server/test/fixtures.ts— data factories - Validate incrementally — start with the
jobsschema (standalone, zero cross-schema FKs) to prove the pipeline, thenauth+notificationsfor real business logic - Write first DB test for notification queries
If pushSchema fails:
- Enums don't resolve: confirm
search_pathis set across all custom schemas before the table DDL runs, and thatCREATE SCHEMAstatements run first. This is the ordering increateTestDb(). - It hangs or errors on
apply: don't call the bundledapplyhelper — execute the returnedstatementsToExecuteyourself, as the harness does. - Unsupported types under PGlite: load the needed extension via the
extensionsoption (e.g.vector) before running DDL that references the type.
// src/lib/server/db/notifications/queries.test.ts
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { makeUser, makeNotification } from '$lib/server/test/fixtures';
vi.mock('$lib/server/db', async () => {
const { createTestDb } = await import('$lib/server/test/db');
const { db } = await createTestDb();
return { db };
});
describe('getNotifications', () => {
it('returns notifications for user', async () => {
const { getNotifications } = await import('./queries');
const user = makeUser();
// seed test data, then assert
const results = await getNotifications(user.id, 10, 0);
expect(results).toBeInstanceOf(Array);
});
});
Phase 3 — Stop hook
- Create
.claude/hooks/stop-gate.sh(see Stop hook section above) - Make it executable:
chmod +x .claude/hooks/stop-gate.sh - Add Stop hook to
.claude/settings.local.json - Install
jqin the container (needed by the hook script to parse JSON input) — or parse withgrep/sedifjqis unavailable
Every agent session must pass podman exec v10r bun run validate before completing.
Prerequisite: Phase 1 and Phase 2 tests pass cleanly. Never enable the Stop hook with failing tests.
Phase 4 — Expand coverage
Priority order based on codebase analysis (highest value first):
Data access layer (primary test boundary):
$lib/server/db/notifications/mutations.ts— race condition ingetOrCreateSettings(), IDOR protection viauserIdscoping inmarkAsRead(). High bug potential.$lib/server/db/notifications/queries.ts— core CRUD with auth scoping.$lib/server/db/ai/mutations.ts— multi-step operations with ownership verification.$lib/server/db/errors.ts—classifyDbError(),classifyCode(),safeDbMessage().
Orchestration layer:
$lib/server/notifications/service.ts—NotificationService.send()coordinates DB insert, SSE push, and external routing. Requires mocking SSE and router in addition to the DB.$lib/server/rawrag/index.ts— 261 lines of pure orchestration over embedding, three retrieval tiers, and ranking. Highly testable with mocked tier functions, no DB needed.
Auth and security:
$lib/server/auth/guards.ts—requireAuth,requireAdmin(throwing, pages) andguardApiUser(returning, endpoints). TakesApp.Localsas a plain argument — mock the object, assert redirect/error behavior.hooks.server.ts— security middleware (headers, CSRF, auth, rate limiting). Isolate as a separate concern: it has side-effect imports (schedulers), Redis connection, and feature logging that must be fully mocked. Mockauth.api.getSession,@upstash/ratelimit, constructRequestEventobjects. 515 lines of critical security logic.
Load functions:
- Form showcase load functions (
forms/basics/contact,forms/validation/server, etc.) — import only fromsveltekit-superformsand$lib/schemas/. No DB, easiest load function tests. Callload()directly with a mock event object.
Components:
Alert.svelte— self-contained, internal$state(true)for visibility, dismiss flow. Good first component test.- Primitives (
Switch,Toggle,Progress) — prop-to-DOM behavior, validates custom spacing tokens.
State machines (high value, tested as factories):
createRawragTrace()fromrawrag-trace.svelte.ts— streaming annotation processing, cursor tracking, step status transitions. Pure factory, no external imports.createDockState()fromdock.state.svelte.ts— tab activation, panel close, split creation. Pure factory.
What NOT to test (thin adapters):
- Route handlers (
+page.server.ts,+server.ts) — they are 15–25 line adapters that parse params and call domain functions. If the domain functions work, the routes work. Testing them requires constructingRequestEventobjects with high ceremony for low signal. - Structural components (
Card.svelte,BackLink.svelte,Kbd.svelte) — pure slots/snippets, no logic. - Canvas/WebGL components (
NetworkGraph.svelte,Scene.svelte) — impossible in jsdom.
Side-effect containment: The globalThis sentinels in vitest.setup.ts prevent scheduler startup. No earlier test should import anything that chains to hooks.server.ts. The vi.mock('$lib/server/db', ...) pattern is safe because query/mutation modules only import from '../index' (the DB client) — they do not chain through hooks.
Svelte 5 state testing note: All .svelte.ts state files in this project use the context factory pattern (createToastState(), createDockState(), etc.), not module-level $state singletons. This means the flushSync() requirement for external reactive state does not apply — call the factory directly in tests.
Phase 5 — AGENTS.md and CI
- Write
AGENTS.mdat project root (see template below) - Add
.github/workflows/test.ymlrunningvitest runandbiome checkon PRs
AGENTS.md Template
Place at project root. All AI coding agents read this file alongside CLAUDE.md.
# AGENTS.md
Supplementary instructions for AI coding agents working in this project.
Read CLAUDE.md first. This file adds testing-specific guidance.
## Container-First Execution
Nothing is installed on the host except Podman. All commands run inside the `v10r` container:
podman exec v10r <command>
Source is bind-mounted (host `.` → container `/app`). File edits are instantly visible.
The container must be running (`podman compose up -d`).
## Test Commands
| From host | What it does |
|-----------|-------------|
| `podman exec v10r bun run test` | Run all tests once |
| `podman exec v10r bun run test:watch` | Watch mode during development |
| `podman exec v10r bun run validate` | Full quality gate before declaring done |
| `podman exec v10r bun vitest run src/lib/server/errors/` | Run tests for one module |
| `podman exec v10r bun biome check --write src/lib/server/notifications/service.ts` | Lint and fix one file |
## Test Patterns
**Co-location:** Tests live next to source. `service.ts` → `service.test.ts`.
**DB isolation:** Use PGlite for any test touching the database.
Mock the DB module with `vi.mock('$lib/server/db', ...)` — see `src/lib/server/test/db.ts`.
Schema is built in-process via `pushSchema` from `drizzle-kit/api`. No migration files, no schema-gen step.
**Neo4j:** Mock `cypher()` for all unit and integration tests.
Only use Test Aura if the test cannot be written any other way.
**Factories:** Use `src/lib/server/test/fixtures.ts` for test data.
Override only what the test cares about.
**Schema changes:** None needed for tests. `createTestDb()` reads the live schema via `pushSchema`,
so test schemas track `src/lib/server/db/schema/` automatically — no regeneration step.
## Boundaries
- **Never** run commands on the host — always `podman exec v10r`
- **Never** add migration files or a `drizzle/` directory for tests — the project is push-only; the harness builds schema in-process via `pushSchema`
- **Never** commit `.env` or `.env.local` (`.env.test` is safe to commit — it contains only fake values)
- **Never** delete or skip a failing test — fix the code or fix the test
- **Never** use `bun test` as the test runner — use `vitest` only
- **Never** install packages on the host — add to `package.json`, restart container
- **Never** import `hooks.server.ts` in unit/integration tests — it starts real schedulers and Redis connections
Known Issues and Gotchas
| Issue | Impact | Mitigation |
|---|---|---|
vi.stubEnv() does not affect $env modules |
Env var mocking broken | Mock $env/dynamic/private in vitest.setup.ts (sveltejs/kit#9564) |
$lib/server/auth throws if BETTER_AUTH_SECRET < 32 chars |
Module load crash in tests | .env.test with a 32+ char fake secret |
Job schedulers start on import of hooks.server.ts |
Real setInterval loops in tests |
globalThis.__v10r_scheduler = 'test' sentinel in setup file |
| RAG setup SQL lives outside Drizzle schema | pushSchema doesn't create tsvector, HNSW, GIN |
Separate client.exec(sql) after the push, for RAG tests only |
pushSchema apply helper fails on unqualified enums |
Schema setup throws | Execute the returned statementsToExecute manually; don't call apply |
| Schema-scoped enums fail to resolve under PGlite | type ... does not exist during table DDL |
CREATE SCHEMA statements first, then SET search_path across all custom schemas, then the rest |
Stop hook exit 2 ignored in plugin hooks |
Hook doesn't block agent | Use JSON output {"decision": "block"} with exit 0 (claude-code#10412) |
| Stop hook infinite loop | Agent never terminates | Check stop_hook_active field in hook input (claude-code#10205) |
POSIX sh lacks pipefail |
Pipe to tail masks exit codes |
Use bash -c with set -o pipefail |
| PGlite WASM startup + schema push ~200ms–3s | Default 5s test timeout too short | Set testTimeout: 15_000 in vitest config |
Named node_modules volume persists across recreations |
Stale packages after removal | Use podman compose down -v for clean installs |
| Bun test cannot resolve SvelteKit virtual modules | Tests fail on import | Use Vitest only (oven-sh/bun#5541, oven-sh/bun#10712) |
| PGlite + Bun test: WASM out-of-bounds errors | PGlite crashes under Bun test runner | Use Vitest + Node (oven-sh/bun#15032) |
Vitest workspace field deprecated |
Warning in Vitest 3.2+ | Use projects field instead |
Rejected Approaches
| Approach | Why rejected |
|---|---|
migrate from drizzle-orm/pglite/migrator with generated files |
Needs a drizzle/ directory and a drizzle-kit generate step. The project is push-only — adding generated files solely for tests splits the schema source and invites drift. pushSchema reads the same TypeScript schema the app uses |
| Dependency injection for DB swap | 30+ function signatures change, all call sites affected, zero production benefit. Architecture explicitly chose module imports over DI |
| Testcontainers (real Postgres in Docker) | Container-startup latency per test run, adds Docker daemon dependency inside Podman container. PGlite is faster and sufficient |
| pg-mem | TypeScript reimplementation of Postgres, not real Postgres. No timezone support, approximate numerics, no Drizzle adapter |
| Playwright for component tests | Adds ~300MB Chromium to container. jsdom via @testing-library/svelte is sufficient for current component complexity |
| Neon Testing library (branch-per-test) | Real cloud Postgres branches per test file. Production parity but adds network latency, API key dependency, cost at scale. No independent practitioner reports yet — revisit in 6 months |
References
Official documentation
| Source | Relevance |
|---|---|
| Svelte Testing | Official Vitest + @sveltejs/kit/vite setup |
| Drizzle + PGlite connect | Official Drizzle/PGlite connection docs |
| Drizzle migrate docs | migrate function — the generated-files path this project rejected (push-only) |
| PGlite API | dumpDataDir, loadDataDir, exec, query, close |
| PGlite Extensions | pgvector, btree_gist, pg_trgm extension loading |
| PGlite ORM support | Drizzle listed as officially supported |
| PGlite Benchmarks | Query performance data |
| Vitest 4.0 release | Browser Mode now stable, projects field |
| Claude Code Hooks | PostToolUse and Stop hook configuration |
| Biome CLI | --reporter json flag, --write flag |
Community implementations
| Source | Relevance |
|---|---|
| PGlite + Drizzle tutorial | PGlite test setup with Drizzle ORM (team serving 2.1M users) |
| rphlmr/drizzle-vitest-pg | Reference implementation: Drizzle + PGlite + Vitest |
| 1300 tests in 25s benchmark | PGlite performance at scale |
| PGlite snapshot pattern | Snapshot/restore for faster tests (experimental) |
| sveltest.dev | vitest-browser-svelte patterns reference |
Issues and discussions
| Source | Relevance |
|---|---|
| sveltejs/kit#9564 | vi.stubEnv vs $env modules — still open |
| sveltejs/kit#8180 | $env/dynamic/private in Vitest — resolved |
| drizzle-orm#4531 | pushSchema apply prompt behavior — avoided by executing statementsToExecute manually |
| drizzle-orm#1181 | pgSchema push edge cases with custom schemas |
| drizzle-orm#4205 | pushSchema community usage and gotchas |
| oven-sh/bun#5541 | Bun test cannot resolve $app/environment |
| oven-sh/bun#10712 | Bun test cannot handle $env/dynamic/* |
| oven-sh/bun#15032 | Bun bundler + PGlite WASM errors |
| testing-library/svelte#284 | Svelte 5 support status and gotchas |
| claude-code#10205 | Stop hook infinite loop issue |
| claude-code#10412 | Stop hook exit code bug |
Architecture and patterns
| Source | Relevance |
|---|---|
| AGENTS.md Standard | AGENTS.md file format and purpose |
| AGENTS.md: lessons from 2500+ repos | What makes AGENTS.md files useful |
| Self-Improving Coding Agents | Agent testing loops, structured feedback |
| Neon Testing library | Branch-per-test with real Neon Postgres — future option |