The repository guards itself with a handful of commands. Most of them are fast, all of them run from the root, and CI runs the same ones on every push. This page lists them, says what each one catches, and how the tests are organised so you know where a new test goes.
At a glance
| Command | What it checks | In CI |
|---|---|---|
pnpm lint | Biome format and lint (biome check .) | yes |
pnpm check:i18n | catalogues in sync, keys exist, no hardcoded Dutch | yes |
pnpm check:actions | every workspace server action checks a permission | yes |
pnpm typecheck | tsc in every workspace, tests included | yes |
pnpm test | unit and database tests in every workspace | yes (after pnpm db:migrate) |
pnpm build | production builds | yes |
pnpm e2e | Playwright against a running dashboard | no, run locally |
pnpm backup:drill | backup and restore with the production scripts, locally | no |
pnpm lighthouse <url…> | Lighthouse scores of running sites | no |
pnpm release:dry | the npm packages build and pack as they would publish | no |
pnpm typecheck
turbo run typecheck runs each workspace's typecheck script (tsc -p tsconfig.json, no
emit). The tsconfigs include the tests ("include": ["src", "test"] in the packages,
**/*.ts in the Next apps), so a test that no longer matches the code fails here even before
it runs. The message catalogue types make a missing translation key a type error as well.
pnpm lint
biome check . with biome.json: formatting (two spaces, double quotes, semicolons, trailing
commas, line width 100), organised imports and lint rules, among them the a11y rule
noLabelWithoutControl as an error. pnpm lint:fix writes what can be fixed. CI fails on
anything Biome would change.
pnpm check:i18n
node tools/check-i18n.mjs reads apps/dashboard/messages/*.json and the sources in
apps/dashboard/src with the TypeScript parser. It fails on keys used through getT, useT or
getTranslator that do not exist, on keys or {variables} that differ between nl.json and
the other catalogues, on unbalanced braces, and on Dutch-looking copy left in JSX text,
label-like props, toasts, returned errors or sentences. // i18n-ignore: <reason> on the line
(or the line above) and // i18n-ignore-file are the escape hatches. Options: --dir to point
it at another app, --json for machine-readable output. Details in
Languages and design system.
pnpm check:actions
node tools/check-action-permissions.mjs reads every exported async function in
apps/dashboard/src/lib/actions/*.ts. An action whose body calls requireWorkspace(,
await setup(, await setupDeploy( or await setupWebhooks( must also contain a recognised
permission check (can(ctx, "…"), canTrash(ctx, …), the site setup helpers with a key,
ctx.isStaff / ctx.canManage / ctx.staffRole / ctx.agencyRole, or
findWorkspaceApp(ctx, …)), or the script names it and exits 1. See
Tenancy and permissions.
pnpm test
turbo run test runs the test script of every workspace that has one:
| Workspace | Script | Notes |
|---|---|---|
@kwisple/core | vitest run | pure |
@kwisple/cms | vitest run | pure; includes the English labels of the standard schema |
@kwisple/content | vitest run | the engine against Postgres (test/engine.test.ts, query.test.ts, …) |
@kwisple/analytics | vitest run | pure, agents, benchmark, and db.test.ts (rollup against Postgres) |
@kwisple/brand | vitest run | pure |
@kwisple/ui | vitest run | locale, png |
@kwisple/sdk | vitest run | client, CLI, Astro, apps, staging, SEO; fetch is injected |
create-kwisple-site | vitest run | scaffolding |
@kwisple/agent | vitest run | the agent service |
@kwisple/docs | vitest run | the developer documentation content |
@kwisple/dashboard | tsx --test test/*.test.ts && vitest run | two runners, below |
@kwisple/web, @kwisple/db, @kwisple/jobs, @kwisple/social and @kwisple/storage have
no test script; their code is covered through the packages and the dashboard tests that use it.
Run one workspace with a filter:
pnpm --filter @kwisple/content test
pnpm --filter @kwisple/dashboard test
pnpm --filter @kwisple/dashboard exec vitest run test/access # one folderThe dashboard's two runners
apps/dashboard/package.json runs tsx --test test/*.test.ts first, then vitest run.
| Runner | Files |
|---|---|
node:test through tsx | the top-level files test/*.test.ts: billing, dns-records, jobs-assets, oauth, pure, signature, webhooks |
Vitest (apps/dashboard/vitest.config.ts) | the folders test/access, agent, billing, brand, campaigns, cli, help, hosting, i18n, media, platform, preflight, requests, sites, social, studio, trash, website-review |
The Vitest config resolves @/ to src/, uses the node environment, a 30 s test and 60 s hook
timeout, and fileParallelism: false (database tests share one Postgres). A new folder under
test/ must be added to include in vitest.config.ts, or it never runs. New tests go into a
Vitest folder; the node:test files are the older ones.
Database-backed tests
Tests that need Postgres read the root .env themselves and use the real database with their
own fixtures:
import { config } from "dotenv";
config({ path: new URL("../../../../.env", import.meta.url).pathname });
const { db, sql } = await import("@kwisple/db");
const reachable = await db.execute(sql`select 1`).then(() => true).catch(() => false);
const tag = shortId(); // unique per run
const ids = { agency: newId("agency"), ws: newId("workspace"), owner: `usr_a_${tag}_o` };
const slug = `access-test-${tag}`;
beforeAll(async () => { if (!reachable) return; /* insert agency, workspace, users … */ });
afterAll(async () => { if (!reachable) return; /* delete workspace, agency, users */ });
describe.skipIf(!reachable)("access policy resolution", () => { /* … */ });The rules they follow:
- Skip when Postgres is unreachable. The dashboard's database tests probe with
select 1and wrap their suites indescribe.skipIf(!reachable), sopnpm teston a machine withoutpnpm infra:upstill passes the pure tests. The@kwisple/contentengine tests have no such skip: run them with the database up. - Own fixtures, tagged. Every run creates its own agency, workspace and users with ids and
slugs that contain a
shortId()tag, so tests never touch seed or real data and two runs never collide. - Clean up in
afterAll. Delete the workspace (its tenant rows go with it throughon delete cascade), then the agency and the users. - Dynamic imports after
dotenv. Modules that readDATABASE_URLat import are imported withawait import(...)after the.envis loaded.
CI starts a Postgres 17 service, runs pnpm db:migrate, then pnpm test, so database tests
run there for real.
pnpm e2e
Playwright tests of the core flows in e2e/:
pnpm build && pnpm preview # or pnpm dev, in another terminal
pnpm e2epnpm e2e type-checks e2e/ (tsc -p e2e/tsconfig.json) and runs
playwright test -c e2e/playwright.config.ts:
- Target:
http://localhost:4100, orE2E_BASE_URL. Global setup refuses to start when no dashboard answers on/login. - Fixtures: global setup runs
apps/dashboard/scripts/e2e-fixtures.ts setup, which creates a throw-away agency with an owner, a client with a custom-code website and a client member, and writes them toe2e/.fixtures.json. Teardown removes all of it. The script refuses to run withNODE_ENV=production. E2E_KEEP=1skips the teardown, so you can sign in as the fixture accounts and look around after a failure.- Projects:
desktop(Desktop Chrome, everything exceptphone) andphone(iPhone 13 viewport,phone.spec.ts), one worker, no retries,nl-BEand Europe/Brussels, trace and screenshot on failure ine2e/.results. - Specs (
e2e/tests/):auth,campaign,help,phone,requests,social,solo,website. Selectors use the message catalogue (msg("auth.form.email")ine2e/fixtures.ts), so a copy change does not break them.
Run it before a release and after changes to login, navigation or a core flow.
pnpm backup:drill
tools/backup-drill.sh rehearses the production backup locally with the same image and
scripts as production (infra/backup). It needs pnpm infra:up, reads .env without
printing it, and:
- builds the backup image;
- creates a scratch database
restore_checkand a throw-away bucket on the local RustFS; - runs
backup.sh(database dump and file copy) into that bucket; - runs
restore-check.sh, which restores the newest dump intorestore_check; - compares agency, workspace, user, entry and asset counts and the file count between live and restored;
- removes the bucket, the scratch database and the image again.
It exits 1 when the numbers differ. Run it after changes to infra/backup or big schema changes.
pnpm lighthouse
tools/lighthouse/run.mjs audits running production servers (it builds nothing):
pnpm lighthouse http://<slug>.sites.localhost:4101/ http://localhost:4102/
pnpm lighthouse --preset mobile --runs 5 --min perf=90 <url…>
pnpm lighthouse --config tools/lighthouse/urls.example.jsonEach URL is audited --runs times (default 3) per preset (mobile and desktop by default); the
median run by performance is kept and fails below the thresholds (defaults perf 95, a11y 100,
best practices 100, SEO 100). Lighthouse itself is pinned in the script. --out sets the
report folder, --chrome the browser binary.
Content tests: the handleiding and these docs
Both documentation sets are tested like code:
| Test | Runs in | Fails when |
|---|---|---|
apps/dashboard/test/help/manual.test.ts | pnpm --filter @kwisple/dashboard test | a Hulp page is missing from docs/handleiding/README.md, or a link between pages points at a page that does not exist |
apps/docs/test/content.test.ts | pnpm --filter @kwisple/docs test | a developer docs page lacks title, description, section, order or updated, its H1 differs from its title, it is not in docs/developers/README.md exactly once, a link points at a page or heading that does not exist, or it still contains a "to verify" marker left by a draft |
pnpm release:dry and pnpm release:pack
tools/release-dry.mjs handles the three packages meant for npm: @kwisple/cms,
@kwisple/sdk and create-kwisple-site.
pnpm release:drybuilds each package, stages it the waypnpm publishwould (withpublishConfigapplied,workspace:*replaced by real versions, devDependencies dropped) and runsnpm pack --dry-run. Nothing is published. It fails when a package is private.pnpm release:pack <dir>writes the real tarballs to<dir>instead. A site repository installs them from its ownvendor/folder until the packages are on npm, so repack after an SDK or CMS change that a site needs.
CI
.github/workflows/ci.yml runs on every push to main and on pull requests, on Ubuntu with
Node 22 and pnpm, with a Postgres 17 service and placeholder environment values:
pnpm install --frozen-lockfile
pnpm lint
pnpm check:i18n
pnpm check:actions
pnpm typecheck
pnpm db:migrate
pnpm test
pnpm buildStorage is not running in CI, so tests must not depend on a reachable bucket.
Before you commit
Run what CI runs, fastest first:
pnpm lint
pnpm check:i18n
pnpm check:actions
pnpm typecheck
pnpm test # with pnpm infra:up, so database tests run instead of skippingAdd, depending on what you touched:
| You changed | Also run |
|---|---|
a schema file in packages/db | pnpm db:generate (commit the migration), pnpm db:migrate |
| login, navigation or a core flow | pnpm build && pnpm preview, then pnpm e2e |
| a screen, button or flow | update docs/handleiding; the manual test checks the index and links |
docs/developers | pnpm --filter @kwisple/docs test |
packages/sdk or packages/cms | pnpm release:dry; pnpm release:pack <dir> for vendoring sites |
infra/backup | pnpm backup:drill |
| a hosted block or site rendering | pnpm lighthouse on a production build |