Перейти до вмісту

Testing

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:

Terminal window
npm ci # once
npm run format # Prettier --write on src/, scripts/, test/, public/**/*.js, *.js
npm run lint # ESLint (flat config), public/ included; npm run lint:fix fixes what it can
npm test # node --test, NODE_ENV=test is set by CI; set it locally too
npm run test:watch # re-runs on change
npm run test:coverage # with node's built-in coverage table

On a machine where npm scripts are awkward (or to be explicit about the environment), the same loop is:

Terminal window
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 --test

CI (.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:

Terminal window
NODE_ENV=test node --test test/admin/news.test.js
NODE_ENV=test node --test --test-name-pattern="архів" test/admin/news.test.js

Markdown, 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.

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:

Terminal window
docker run --rm -v "$(pwd)":/app -w /app -e NODE_ENV=test node:20 sh -c "npm ci && npm test"
  • Runner: node:test (test, describe, before/after) with node:assert/strict. No Jest, no Mocha, no extra test-runner package.

  • HTTP: supertest against an app built in-process — never a listening server.

  • The app: buildTestApp(overrides) in test/helpers/buildTestApp.js assembles the real createApp() with a fresh in-memory SQLite database (createTestDb(), all migrations applied), a fake content repository, a no-op mailer and a silent logger. Pass db, content, mail, env, setupCode, backupScheduler or updateChecker to override. For tests that need the real public read model, pass content: 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) from test/helpers/adminWorld.js builds the whole world an admin test needs and cleans it up when t ends: 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 admin
    const { admin, editor } = await makeAdminWorld(t, { roles: ['admin', 'editor'] });
    const token = await csrf(agent, '/admin/pages'); // the _csrf of that form

    Options: roles (default ['admin']; [] logs nobody in — use anonymous() for a logged-out agent), names (display names by role), realContent: false for the fake repository, env (merged over TEST_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, app for anything else buildTestApp takes (setupCode, backupScheduler, mail, …), prefix for the temp dir. It also returns app, dataDir, fileStorage, users, agents (by role, also spread at the top level) and content; csrf(agent, url = '/admin') reads a fresh token from the form at url. Users are <role>@example.com with the exported PASSWORD. 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 use roles: [] and seedUser/loginAsAgent (test/helpers/seedUser.js, adminAgent.js) directly.

  • Permissions: test/admin/routeGuards.test.js walks the router stack the app builds and fails for any non-GET /admin route that is neither behind requireRole/requireCan (they mark the middleware they return with a guard property) nor listed in its EDITOR_ALLOWED with 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 in EDITOR_ALLOWED. permissions.test.js keeps the can() matrix in words.

  • Uploads: test/helpers/uploadRoutes.js finds every multer route in src/admin/routes/. multipartForms.test.js checks each goes through multipartRoute() and is registered for the CSRF middleware; multipartCsrf.test.js sends each a real file without a token (403, no row, no file, no temp copy left); multipartRoute.test.js covers the wrapper on its own; uploadAbuse.test.js covers oversized, empty, extra and oddly named files.

  • Clocks: code that reads the time takes it as a parameter — now in createContactRouter, createBackup and BackupScheduler — so a test passes a fake one instead of sleeping. For express-rate-limit windows, mock.timers.enable({ apis: ['Date'] }) from node:test moves its clock (test/searchRateLimitWindow.test.js); leave setTimeout real, 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 injected exit (test/createServer.test.js). Every script in scripts/ answers --help, and test/scripts/cli.test.js spawns each one, plus a backup → verify → restore round trip.

  • Temp directories: never fs.mkdtemp directly. tempDataDir(t, 'tabula-…-') from test/helpers/tmpDir.js creates one and removes it when the test ends; hand anything that keeps a file in it open (a file-backed Database) to closeWithDir(dir, db) so it is closed first — Windows cannot remove a directory with an open site.db. For a directory shared by a whole file, makeTempDir() in before() and removeTempDir() in after(). 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 an MZ header, built in memory with zlib — no binary fixture is committed; images are made with sharp the same way). Insert rows with SQL (in makeAdminWorld’s seed, 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.js pins the Content-Security-Policy header; test/cssCompat.test.js fails on brand colour literals outside tokens.css. An inline style or <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.js keeps the editor preview’s stylesheet list in step with head.ejs; test/assets.test.js checks that /site.css and /site.js contain every component file; test/cssUnused.test.js fails on a CSS class no template or script uses.

  • Neutral code: test/neutralTemplate.test.js fails if a fresh database renders another school’s words.

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.

Terminal window
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 finding
npm 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 on administracia, a published istoriya inside the «Про заклад» dropdown (so the section menu renders), an admin account; images are solid colours made with sharp, 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-mode set in localStorage before the first paint, as zir.js reads 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-practice rules are not selected. critical and serious findings fail the run; moderate and minor are 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»).

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:

3000/admin/setup
DATA_DIR=./data-scratch SESSION_SECRET=dev-only-secret-at-least-32-characters npm run dev

Delete 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.