Testing
The loop
Section titled “The loop”Node 20 (.nvmrc; engines is >=20 <25, and better-sqlite3 is pinned to 12.x because
13.x segfaults on Node 20 — see Gotcha #1 in CLAUDE.md). From the repository root:
npm ci # oncenpm run format # Prettier --write on src/, scripts/, test/, public/**/*.js, *.jsnpm run lint # ESLint (flat config), public/ included; npm run lint:fix fixes what it cannpm test # node --test, NODE_ENV=test is set by CI; set it locally toonpm run test:watch # re-runs on changenpm run test:coverage # with node's built-in coverage tableOn a machine where npm scripts are awkward (or to be explicit about the environment), the same
loop is:
node node_modules/prettier/bin/prettier.cjs --write "src/**/*.js" "scripts/**/*.js" "test/**/*.js" "public/**/*.js" "*.js"node node_modules/eslint/bin/eslint.js .NODE_ENV=test node --testCI (.github/workflows/ci.yml, every PR and every push to develop/stage/prod) runs
npm run lint, npm run format:check, npm audit --omit=dev --audit-level=high and the suite
with a coverage floor (90 % lines, 75 % branches, scripts/check-coverage.js) on Node 20 with
NODE_ENV=test, plus an a11y job (after test) that runs the accessibility check below and a
docker job that builds the image and validates the compose file. The deploy and release
workflows run lint, format:check and the tests before building anything; a red test stops a
deploy.
The suite is 1 095 tests (2026-09-27) and is expected to be 100 % green on Windows as well as on Linux — a failure on a developer’s Windows machine is a bug, not a platform quirk. It takes about 100–160 s locally. No test leaves a directory behind in the system temp directory.
Run one file or one test:
NODE_ENV=test node --test test/admin/news.test.jsNODE_ENV=test node --test --test-name-pattern="архів" test/admin/news.test.jsMarkdown, EJS and CSS are not part of format:check (.prettierignore excludes
src/views/**/*.ejs and public/css/). The browser scripts in public/ are linted and formatted
like the rest: classic scripts (sourceType: 'script'), ES2020, with the browser globals they
use listed in eslint.config.js — a new one is added there on purpose. src/ has stricter rules
than tests and scripts (eqeqeq, no-var, prefer-const, unused variables are errors). EJS
has no linter: a template’s CSP violations show only in the browser console.
Native modules
Section titled “Native modules”better-sqlite3, argon2 and sharp ship prebuilt binaries for common platforms. If npm ci
falls back to compiling (node-gyp rebuild) and your machine has no C/C++ toolchain, run the
suite in the same image CI uses instead of fighting the toolchain:
docker run --rm -v "$(pwd)":/app -w /app -e NODE_ENV=test node:20 sh -c "npm ci && npm test"Conventions
Section titled “Conventions”-
Runner:
node:test(test,describe,before/after) withnode:assert/strict. No Jest, no Mocha, no extra test-runner package. -
HTTP:
supertestagainst an app built in-process — never a listening server. -
The app:
buildTestApp(overrides)intest/helpers/buildTestApp.jsassembles the realcreateApp()with a fresh in-memory SQLite database (createTestDb(), all migrations applied), a fake content repository, a no-op mailer and a silent logger. Passdb,content,mail,env,setupCode,backupSchedulerorupdateCheckerto override. For tests that need the real public read model, passcontent: new SqliteContentRepository({ db }). -
No timers, no network. A test app never starts the backup scheduler or the update checker unless the test injects a fake. External services (S3, GitHub, SMTP) are faked at their service boundary (
test/services/). -
Admin tests:
makeAdminWorld(t, options)fromtest/helpers/adminWorld.jsbuilds the whole world an admin test needs and cleans it up whentends: a temp data dir, a fresh migrated database, the app on the real content repository, and one logged-in supertest agent per role (a real login with a real CSRF token, cookies kept).const { db, agent, csrf } = await makeAdminWorld(t); // one adminconst { admin, editor } = await makeAdminWorld(t, { roles: ['admin', 'editor'] });const token = await csrf(agent, '/admin/pages'); // the _csrf of that formOptions:
roles(default['admin'];[]logs nobody in — useanonymous()for a logged-out agent),names(display names by role),realContent: falsefor the fake repository,env(merged overTEST_ENV),db(a database of the test’s own, e.g. migrated in steps),seed(db, dataDir)for rows that must exist before the app is built,appfor anything elsebuildTestApptakes (setupCode,backupScheduler,mail, …),prefixfor the temp dir. It also returnsapp,dataDir,fileStorage,users,agents(by role, also spread at the top level) andcontent;csrf(agent, url = '/admin')reads a fresh token from the form aturl. Users are<role>@example.comwith the exportedPASSWORD. A file that needs fixtures on top (a page, an album) wraps it in a small function named after what it adds —worldWithAlbum(t)— never a second copy of the login sequence. Tests of the login itself useroles: []andseedUser/loginAsAgent(test/helpers/seedUser.js,adminAgent.js) directly. -
Permissions:
test/admin/routeGuards.test.jswalks the router stack the app builds and fails for any non-GET/adminroute that is neither behindrequireRole/requireCan(they mark the middleware they return with aguardproperty) nor listed in itsEDITOR_ALLOWEDwith a reason; it then sends every guarded route an editor (403) and an admin (not 403). A new admin-only route needs no test of its own for that; a new route editors may use needs a line inEDITOR_ALLOWED.permissions.test.jskeeps thecan()matrix in words. -
Uploads:
test/helpers/uploadRoutes.jsfinds every multer route insrc/admin/routes/.multipartForms.test.jschecks each goes throughmultipartRoute()and is registered for the CSRF middleware;multipartCsrf.test.jssends each a real file without a token (403, no row, no file, no temp copy left);multipartRoute.test.jscovers the wrapper on its own;uploadAbuse.test.jscovers oversized, empty, extra and oddly named files. -
Clocks: code that reads the time takes it as a parameter —
nowincreateContactRouter,createBackupandBackupScheduler— so a test passes a fake one instead of sleeping. Forexpress-rate-limitwindows,mock.timers.enable({ apis: ['Date'] })fromnode:testmoves its clock (test/searchRateLimitWindow.test.js); leavesetTimeoutreal, supertest needs it. No test sleeps for more than a moment. -
The server and the CLIs:
src/lib/createServer.js(lock file, signal handlers, schedulers) is tested in-process with fake schedulers and an injectedexit(test/createServer.test.js). Every script inscripts/answers--help, andtest/scripts/cli.test.jsspawns each one, plus a backup → verify → restore round trip. -
Temp directories: never
fs.mkdtempdirectly.tempDataDir(t, 'tabula-…-')fromtest/helpers/tmpDir.jscreates one and removes it when the test ends; hand anything that keeps a file in it open (a file-backedDatabase) tocloseWithDir(dir, db)so it is closed first — Windows cannot remove a directory with an opensite.db. For a directory shared by a whole file,makeTempDir()inbefore()andremoveTempDir()inafter(). A run leaves nothing in the system temp directory. -
Fixtures:
test/helpers/fakeContent.js(menu and pages for the fake repository),seedAlbum.js,officeFixtures.js(minimal DOCX/XLSX/PPTX/ODT/ODS archives, a macro-enabled one, a plain ZIP and anMZheader, built in memory withzlib— no binary fixture is committed; images are made withsharpthe same way). Insert rows with SQL (inmakeAdminWorld’sseed, or after it) when you need more. -
Test names in this repository are Ukrainian sentences that state the behaviour (
'архівування ховає новину; розархівування повертає з тією самою датою'). Follow the file you are in. Comments are English. -
What to cover for an admin feature: the happy path, validation errors (status 400 and the Ukrainian message), the role matrix (an editor gets 403 where the action is admin-only), CSRF on a new POST route, soft delete (the row stays, lists and the public site stop showing it), and the public rendering — draft, hidden and recycled content must not leak.
-
CSP and CSS:
test/csp.test.jspins the Content-Security-Policy header;test/cssCompat.test.jsfails on brand colour literals outsidetokens.css. An inlinestyleor<script>is not caught by a test — the browser blocks it and reports only in the console, so check the console when you touch a view.test/editorPreviewStyles.test.jskeeps the editor preview’s stylesheet list in step withhead.ejs;test/assets.test.jschecks that/site.cssand/site.jscontain every component file;test/cssUnused.test.jsfails on a CSS class no template or script uses. -
Neutral code:
test/neutralTemplate.test.jsfails if a fresh database renders another school’s words.
Accessibility
Section titled “Accessibility”npm run test:a11y checks the rendered pages against WCAG 2.2 AA with
axe-core in Playwright’s Chromium (issue #266). It is a
separate command, never part of node --test: the suite stays browser-free.
npx playwright install chromium # once per Playwright version (~150 MB, in the user's cache)npm run test:a11y # about 30–40 s; exit code 1 on a blocking findingnpm run test:a11y -- --report # also writes test/a11y/report/report.json (git-ignored)What it does (test/a11y/check.js, CLI scripts/a11y-check.js):
- Creates a temp data dir, seeds it with
test/a11y/seed.js— the setup wizard’s «school» structure, a published post with a cover, a library document, an album with two photos, two invented people onadministracia, a publishedistoriyainside the «Про заклад» dropdown (so the section menu renders), an admin account; images are solid colours made withsharp, the PDF is a few bytes of text — and starts the app on it (createServer, random port, no schedulers, no mail). Everything is removed at the end, pass or fail. - Opens every page of
pagesToCheck(): home, a section page with the side menu, the news list, a post, the library, an album, the people page, search results, contacts — each in the default theme and in the low-vision mode (bvi-modeset inlocalStoragebefore the first paint, aszir.jsreads it) — then the admin login, and, signed in, the dashboard and the page editor (the admin panel has one look, so those are checked once). The page’s real CSP stays on. - Runs axe with the tags
wcag2a,wcag2aa,wcag21a,wcag21aa,wcag22aa—best-practicerules are not selected.criticalandseriousfindings fail the run;moderateandminorare printed and do not.
Reading a failure. The log has one line per page and theme; under a page with findings, one line per rule:
✗ Новина (/novyny/vyhadana-podiya) · версія для слабозорих ✗ serious link-in-text-block — Links must be distinguishable without relying on color (1 елемент): p > a[href$="contacty"]— impact, the axe rule id, what the rule asks, how many elements, and the first offending selector.
The rule id is what to search for (https://dequeuniversity.com/rules/axe/4.13/<rule-id> explains
it); --report lists every selector and the help URL. Reproduce locally with npm run test:a11y,
fix the template or the CSS, and name the rule id in the commit message. If a finding is a false
positive, disable that rule in DISABLED_RULES (test/a11y/check.js) with the reason as its
value — never a tag, never the whole check; test/a11y/summary.test.js refuses a rule disabled
without a reason.
Adding a page. A new page shape (a new page type, a new admin screen people use daily) gets an
entry in pagesToCheck(), and whatever content it needs to render its real layout goes into
seed.js — invented names only, and every seeded piece looked up before it is added, so seeding
twice changes nothing (the unit test checks). An empty state only proves the empty state.
Colour contrast is checked in both themes, but only for the default brand colours: a school that
picks its own on «Оформлення» gets the warning on that tab (contrastRatio()), not this check.
Manual screen-reader testing is not automated — it is a step of the
publishing checklist («8. Last look»).
Manual checks
Section titled “Manual checks”Tests do not cover the look of a page. For a UI change, run the app against a scratch data directory and look at it in a browser, at phone width too:
DATA_DIR=./data-scratch SESSION_SECRET=dev-only-secret-at-least-32-characters npm run devDelete data-scratch/ afterwards: only data-local/ and data-local-import/ are git-ignored,
so a scratch directory with another name shows up in git status.