Testing — what the suite actually checks
How to run the suite, and — more importantly — what it is actually asserting. Most of these tests read the source and make claims about it rather than exercisin
How to run the suite, and — more importantly — what it is actually asserting. Most of these tests read the source and make claims about it rather than exercising functions through an interface, and that is a deliberate design, not a limitation to apologise for.
Running the tests
npm test
package.json:10 defines "test": "node --test", and package.json:13 defines
"pretest": "npm run check:header". So npm test first runs
node scripts/sync-header.js --check and exits before a single test runs if any page's
generated header or footer is out of date. That is deliberate: a page edited without
npm run sync:header is a drift, and it should fail loudly before anything else has a
chance to look at it.
To run the tests directly, skipping the header check:
node --test # every *.test.js under test/
node --test test/forum.test.js # one file
node --test --test-name-pattern='the tool strip' # one test
The runner discovers files itself; there is no manifest to update when you add one.
The runtime is Node's built-in runner: node:test and node:assert. There is no
external test dependency. No Jest, no Mocha, no Vitest, no snapshot framework, no
coverage tool. package.json has a dependencies block and nothing else.
That is not minimalism for its own sake. Several tests boot a client script inside a
vm and inspect the tree it produced, and a framework that wanted to own the globals
would be in the way of exactly that. The same applies to the database tests: they
require lib/db.js against a temporary DB_PATH, so they exercise the real SQL
rather than a mock of it.
What the suite is actually testing
These are not unit tests. Most of them read the SOURCE and assert things about it.
test/header.test.jsasserts the header is byte-identical on every page — row 1 may differ only in which item carriesaria-current.test/ui-shape.test.jsandtest/ui-language.test.jsparse the client scripts and the markup rather than running a browser.test/dom-contract.test.jsextracts everygetElementById('…')a page script performs and asserts that id exists in that page's own markup (or is built by the script itself at render time).test/legal-pages.test.jscompares the claims in the generated/privacyand/imprintpages against the implementation, so a claim cannot silently stop being true.
That framing is the point. The properties worth protecting on this project are agreements between files: a status must exist in the lib, in the client and in the stylesheet; a class the renderer emits must be styled; a route the nav claims must be served; a value the privacy page names must be the value the code uses. Testing that means reading both sides and comparing them, which is what these tests do.
The counter-example is in the file: the profile page once asked for #vote-score while
the markup said #hv-score. getElementById returned null, the next line
dereferenced it, and because the page's init is a single async function the TypeError
unwound everything after the header. Nothing crashed visibly — the page just quietly
stopped halfway down.
So a test here often looks like two assert.match calls against a file's source. That
is not a shortcut around a missing harness. The property being protected is genuinely
between two files, and the cheapest honest way to assert an agreement is to read both
sides. Where the code has a real interface — the parsers, the writer, the forum data
layer, the session store — the tests do call it, against a real temporary SQLite file.
Shared helpers
test/helpers/md-dom.js — a minimal DOM good enough to boot
public/js/markdown-editor.js inside a vm. Its querySelector supports class
selectors, bare tag names and [attr] / [attr=value]; anything else returns false
rather than guessing. className and classList deliberately share one set, so a
class the editor adds is not reported as absent. It returns the resulting tree and the
requests the browser would send.
test/helpers/editor-dom.mjs — the ESM counterpart for public/js/editor.js. Same
idea for a much larger app; getElementById returns null for ids the page does not
have, exactly as a browser does.
There is no jsdom, no happy-dom and no Playwright. The tests run in plain node --test.
The test files
47 test files, in the groups below.
| Group | Files | What it protects |
|---|---|---|
| Access control and auth | 6 | Who may do what, and the shape of the OAuth round trip |
| The forum | 3 | The data layer and the three pages that sit on it |
| The guides engine and the docs API | 7 | The .md pipeline and the write path |
The markdown engines and the ::: blocks |
1 | Every container block, plus the sanitiser |
| The Profile Builder | 8 | Boot, draft, first run, composer round trip, preview, limits |
| The recorder and recordings | 3 | Capture, replay, storage and publish |
The .spcf format |
3 | The parser, the writer and the taxonomy |
| SEO, social cards and robots | 4 | Meta tags, PNGs, robots.txt, the front page |
| Headers, the shared UI and privacy | 12 | The generated chrome, the stylesheet contract, the language, the legal pages |
Access control and auth
| File | What it asserts |
|---|---|
admin-access.test.js |
The admin allowlist is parsed in exactly one place; every /api/admin route is behind requireAdmin; filing a report is open to users while moderating one is not; the admin pages refuse before emitting any HTML and come back afterwards; the header and the command palette only offer the panel to an admin session; the session is the only thing that grants admin. Also the moderator half: the moderator list is parsed once, moderation is derived server-side and cannot be claimed by the client, and every forum moderation route is behind requireModerator. |
auth.test.js |
The session cookie is named swiftpoint.sid; a non-admin is not sent round in circles by /admin; the admin panel sends a signed-out visitor through Discord and back; returnTo is one convention everywhere (a full browser path); the header sends the current page as the return destination; a destination that leaves the site, or that contains a header-splitting character, is refused. |
auth-roundtrip.test.js |
A full simulated OAuth round trip: the user comes back to where they were going, the base path is applied exactly once, no destination lands on the site root rather than the domain root, every destination a header link can produce survives, a hostile destination cannot carry the user off-site, the session is logged in afterwards, a replayed callback is rejected, and a non-admin asking for the panel is not redirected in a loop. |
staff.test.js |
Only an admin can change who is staff; the .env admin is the way back in and cannot be removed; the last admin cannot be removed or demoted; a name resolves only for people who have signed in; the same account cannot be added twice and promoting is explicit; the effective allowlist is the union of .env and the staff table, with .env winning. |
session-store.test.js |
A session round-trips, an unknown one reads as missing rather than as an error, destroy removes it, the cookie expiry survives serialisation, an expired session is treated as missing and cleaned up, a session survives a process restart, touch pushes the expiry back, and the store is actually wired into the server rather than left at the in-memory default. |
public-api-privacy.test.js |
The gallery and profile responses carry no Discord id; the uploader block is name, avatar and nothing else; ownership is answered by the server, not computed in the browser; no client compares two Discord ids any more; the features that used the id still work from the server answer; the admin panel keeps the identifiers it works with. |
The forum
| File | What it asserts |
|---|---|
forum.test.js |
The largest file in the suite. Reading needs no account and writing does; a thread in a support category is a ticket and one elsewhere is not; only a moderator moves a ticket and every move is recorded; an unknown status is refused rather than stored; moving a ticket into support gives it a status and out of support takes it away; a status change reaches the followers; a reply does not notify its own author; a locked thread stops replies from everybody but moderators; removing a post is a moderator's and removing the question is an admin's; a member can edit their own post and nobody else's; markdown is rendered on the server and cannot smuggle anything; the preview is the same renderer the post will be; only staff can wear a changelog badge; callouts are not collateral damage; the ticket queue is moderators' business only; search answers with both threads and posts; a category is created, renamed and cannot be deleted out from under its threads; changelog entries are siblings however they are written; updates carry what makes a list readable, are cleaned rather than trusted, are public to read and moderator-only to write, and a published update is announced; a reported forum post reaches the moderators with both ids; a long thread is a window and says so; a refused write is refused for the reason it was refused; moderation and writing are separate gates on every route. |
forum-pages.test.js |
The three pages exist and are in the sync list; they carry the shared header and footer and a sync changes nothing; the header marks the forum as current on all three; the footer links to the forum and the updates and the runtime builder agrees; the server serves exactly the URLs the nav, footer and pages claim; the forum and updates are in the sitemap; the ticket states and update kinds are the same on both sides of the wire; a member's profile lists the threads they opened; a page in the sitemap is indexable and says it once; the front page carries the updates and the open tickets server-rendered; a signed-out reader gets a page, never a gate; the forum pages load their scripts in the same order the others do. |
forum-render.test.js |
Renders the forum page, an empty forum, and a ticket. Asserts categories, the ticket and the sidebar all appear; an empty forum says so instead of showing a blank column; the shared helpers are loaded before the page script that needs them; a ticket renders its status, its note and the trail of who moved it; a reader gets no moderation controls and no queue; an anonymous reader is offered a sign-in, not a dead composer. |
The guides engine and the docs API
| File | What it asserts |
|---|---|
docs-api.test.js |
The folder routes are matched before the guide routes; every guides route is admin-only; the tree comes back with everything the panel draws; one guide comes back with its front matter and its body apart; saving, renaming and deleting work through the API; deleting a non-empty folder is 409 with a count, then succeeds; the root cannot be deleted; a traversal in a name never leaves the guides; the preview renders with the guides engine and not the forum one, accepts the field the shared editor actually sends, refuses an empty body, and still needs an admin. |
docs-write.test.js |
The file layer: nothing escapes the guides root, a name is slugified so a folder is always a URL segment, the tree is the shape of what is on disk, a folder and a guide land where the renderer will find them, creating never overwrites, saving keeps front matter keys the writer does not own, a save that changes nothing says so and does not write, saving a non-existent guide is a refusal, the body is capped so a paste cannot fill the disk, renaming moves the file and follows with the title, renaming a folder moves everything inside it, deleting a non-empty folder asks first and reports its contents, the root cannot be deleted or renamed, deleting a guide removes only that guide, the renderer reads a guide written here, and front matter survives a split/build round trip. |
docs-slug.test.js |
A guide resolves under any capitalisation and reports one canonical slug; an uppercase folder and a README resolve too; the route 301-redirects the non-canonical spelling; a folder is its own address rather than a second spelling of its index; a case-only difference never escapes the docs root; the published guides are all lowercase; the walkthrough guide is a real guide, not a bare embed; the old uppercase file name is gone. |
docs-tags.test.js |
A tag in the URL filters to guides carrying it; an unused tag shows an empty tree rather than the whole thing; tags are compared the way a reader compares words (case and separators); a guide with no tag is missing from a filter but not from the tree; the class the filter relies on is actually styled; tags are links, not buttons that poke a search box; the site lists its tags once, not twice; the guides editor offers the changelog blocks. |
docs-empty-folders.test.js |
A folder with no guides under it is not in the explorer, but one whose guides are a level down still is; the guides themselves are all still there; an active guide keeps its folder expanded; a tag filter still hides a folder with nothing matching; the admin still sees the empty folder so it can be cleaned up; the empty folder still reports zero guides; recordings inside a hidden folder still resolve; the real guides on disk still resolve through the shipped module. |
docs-player.test.js |
The live launch is public, hub-absolute and describes itself; it is not the admin recording link; the hub base is derived the same way as auth.js and chrome.js; the stepper reads the recorded focus target with the legacy shape as a fallback; the recorded target renders through escapeHtml; the guide page still loads the player and so does the forum; a published recording replays for a signed-out reader. |
docs-steps.test.js |
The ::: steps container embeds a recording and resolves its URL, and falls back to a visible warning when the recording is missing. |
docs-recordings-root.test.js |
There is one recordings directory and the embedding guide's folder is not an input: the same recording resolves to the same URL from a guide in profile-builder/, from one at the root of docs/ and from one three folders down; the old profile-builder/recordings/… source path, an absolute /docs/… address and a query string all normalise to it; a path that climbs out and a path that is not a recording are still refused; the step count is the same everywhere; and the resolver takes nothing but the path it was given. |
The markdown engines and the ::: blocks
| File | What it asserts |
|---|---|
blocks.test.js |
The largest container test. Every ::: block kind, its arguments, its defaults and its rendered output, plus the sanitiser: which HTML the markdown output may and may not contain, that callouts and steps survive it, and that the two renderers (guides and forum) stay in step where they are supposed to. |
The Profile Builder
| File | What it asserts |
|---|---|
editor-boot.test.js |
Boots public/js/editor.js in the stub DOM. The editor boots without throwing; the binding editor sits beside the views rather than replacing them; the split is a real split, sized and remembered; the editor's columns follow the pane, not the window; it says what you are editing; it opens for both kinds of selection; the grid is two columns; the layers rail is resizable, collapsible and remembers both, and the collapsed rail keeps the panel's actions; the status bar reports only state the editor already keeps; the diagnostics view has one way in; the redesign did not touch the document model; a control implies its own trigger, a selected control implies its trigger and the idle pane offers the rest; press stages appear only where a deep zone is possible; the sizes asked for are the sizes in the stylesheet; nothing is drawn on top of anything else. |
editor-draft.test.js |
The sph:editor:draft lifecycle: a draft from this device is adopted instead of a blank profile; with nothing stored the Builder opens blank and says its ways in; a profile opened in one session is still there in the next; a draft that cannot be read does not break the boot; a ?id= link opens the profile it names, not the draft; "New profile" is the way to throw a draft away; the draft is a local copy and never a second save path; publishing without a session keeps the work and offers the sign-in; coming back from Discord reopens the publish dialog still filled in; coming back signed in reopens the dialog it interrupted. |
editor-first-run.test.js |
The first-run panel: it offers four ways in and the buttons are wired; it says the thing that has to be said; it never stands in front of a deep link; it is shown every time and is not a gate; every entry point stays on this machine; the guide entry exists only when a guide is published; the guide list is the documentation, read from the guides themselves; the embeds are collected only when the recording is really there; the panel is styled as a dialog, not as a page. |
editor-flow.test.js |
Round-trips through the real document model: blank -> binding -> serialize -> parse; applySettings writes the full device-settings mapping; a macro loop, deep-click, tilt and wheel trigger all parse back; every composer kind serializes and re-parses. |
editor-preview.test.js |
The guides editor sends the field the guides endpoint reads, while the forum preview still sends body and that is the default name; previewData is read per render so a renamed guide is not stale; a slow render cannot overwrite a newer one; a preview request is aborted when the editor goes back to writing; the preview surface is styled by the engine that produced it. |
step-diff.test.js |
The parser exposes the mapping summarizers the diff needs; adding a binding produces a human step; identical documents yield no changes; a changed output on the same trigger is described; a profile rename is described; bindings without a name never emit empty chips. |
step-titles.test.js |
Step-title wording: an added binding reads as an action; a changed action reads as an action rather than as a diff; removals and renames read well and are left alone; an unknown change is never dropped; no published title leaks a fingerprint; the recording names itself as a guide, not as a test. |
limits.test.js |
The updates and guides editors allow 32000 characters; no editor promises more than the server keeps; the editor default covers a forum post and nothing longer; the editor reads its budget per instance rather than from a constant; the preview guard is at least as large as any surface it serves. |
The recorder and recordings
| File | What it asserts |
|---|---|
recorder.test.js |
The largest file by size. The full-frame flag for the two tools (it survives a body tag that has attributes, setting it twice updates rather than duplicates, a missing tag is an error, the route injects it through the helper rather than a literal replace, recording is gated on the server flag while replay is not). The record button's shape in the tool strip. The replay contract: a step finds its mouse button by its visible name; the editor exposes what replay needs to drive it; a step names the control it acted on and records the target as an identity rather than a position; nothing in a recording is a screenshot any more; an older step still replays, read from its title; a step records the screen and the value and the screen as it was before the step; the replay restores the recorded screen rather than inferring it; the replay fills the control in with the recorded value; the editor can report and restore its own state; one step, one screen, and nothing moves underneath it; a step can point at more than one control; the ring is placed where the element ends up, not where it was. And the publishing path: the slug is editable and the places that quote it follow it, a rename is its own request, the server moves the published file with the row, and a signed-out reader can replay a published recording. |
records.test.js |
The data layer: save upserts a .rec.json v1 document and slugifies sensibly; payloads that are not .rec.json documents are rejected; list and getWithStats expose the stored steps; publish writes the repo file while unpublish and remove clean up; slugFromPath derives the slug across forms. |
video-player.test.js |
The ::: video embed: the player asks the frame something first; a range is enforced, which needs that first probe; a chip re-points a frame that is not answering; a chip seeks in place without reloading once the player answers; clicking a chip inside a range lets it play on; a frame that never answers is given up on; a plain video is not polled forever. |
The .spcf format
| File | What it asserts |
|---|---|
spcf-format.test.js |
A blank profile round-trips through the parser; every writer output kind is parseable and describes its kind; non-official lift-off values still round-trip 1:1; all input action kinds parse through buttonMappings / tiltMappings; writer field templates match every kind seen in the real database; OLED pack/unpack round-trips; editing an existing node preserves unknown fields (base merge); fixGroupCounts keeps ItemCount in sync; joystick stick ranges (dead-zone trims) survive the round trip. |
profile-format.test.js |
There are profiles to check. Every stored profile survives a write and re-parse unchanged; no profile writes out something the parser rejects; every save path goes through the writer; the redesign added no document state. |
game-taxonomy.test.js |
Known spellings resolve to one canonical name and unrelated games are never merged; an unknown game is still allowed; a typed name still merges with the spelling the site already uses and is cleaned like every other upload field; every alias points at a game in the catalogue and no two catalogue entries collapse to the same key; the upload form and the Builder publish dialog read the same list; the gallery no longer filters by a second control; uploads and replacements both go through the taxonomy; the stored game is always one string; the migration is idempotent and only rewrites the game and its tag; every profile with a game has that game in its tags. |
SEO, social cards and robots
| File | What it asserts |
|---|---|
profile-seo.test.js |
A profile page ends up with exactly one description and it is its own; a user page does the same and the homepage keeps its own; every injecting route drops the static copy first; a profile page is indexable, canonical and named after itself; the homepage describes its gallery without duplicating the page; the gallery itself is a list of links; every public profile is in the sitemap. |
social-card.test.js |
Every card is a real 1200x630 PNG (rendered, not just declared); the four tools each have a card and an unknown one is not invented; the home card is rendered once and reused; no page asks a crawler to render an SVG; the cards are served from routes and the variants share one route; every page asks for the large card and declares its size; each page points at its own card; the profile page keeps its own card; the cards share one palette and it is the profile card's; a card is a content card, not a logo. |
robots.test.js |
The hub serves plain text, not the API's JSON 404; the hub policy matches the host root policy; public content is not blocked; no dead directives are left behind. |
front-page.test.js |
The gallery route is registered ahead of the profile route; the gallery page gets a base href like every other page; "Browse" means the gallery and the brand means home, and nothing means the gallery by writing / any more; the front page explains the mouse and the words are checkable; it links to the things it talks about; it states the community facts server-rendered; it offers the published guides without JavaScript; the two pages are not the same page twice; the gallery stylesheet is its own; both pages are in the sync list with the right active nav; the sitemap knows about both; the gallery URL is one place; the undocumented route is still a 404 and /profiles/hub is not. |
Headers, the shared UI and privacy
| File | What it asserts |
|---|---|
header.test.js |
Every static page carries the canonical header and row 1 is byte-identical on every page — only aria-current moves; every page links to the guides; the auth controls are present on every page and only one control starts visible, so signed-out is the safe default; the palette ships inert and is switched on by chrome.js; aria-current marks the active page and only that page; the nav has no duplicates and covers every section; the nav resolves the routes the server cannot name; every claimed route is one the server actually serves; the nav renders identically everywhere; every tool strip is written in the shared toolbar vocabulary and a page has exactly one; the OLED editor has no second bar of its own; the tool strip survives a header sync; the docs shell renders the same header; the header owns its own app-shell treatment; sticky offsets use the total header height. |
footer.test.js |
Every non-app-shell page carries the footer in its own markup and the disclaimer is in the markup, not only in the script; app shells do not get one; the runtime builder is a fallback, not the source, and it agrees with the generated footer; the footer links resolve to pages the server serves; applying it twice changes nothing; it escapes what it prints; it is announced as navigation and the note is not a link. |
ui-shape.test.js |
Parses the client scripts and the markup: a client script never asks for an absolute URL under the app prefix; the markdown toolbar is one row and the writing area is a writing area; a big modal is actually big; the update editor is a changelog editor rather than a title and a box; an update has a colour and an entry kind and both are fixed lists; the update fields are capped, cleaned and unknown values are dropped; the New Thread form is a real editor; the editor does the things a toolbar has to do; posts are shown as rendered HTML and it is the server's; the body sanitiser no longer eats the post; the changelog blocks are for the updates form and nowhere else; rendered markdown is styled rather than left as bare tags; writing an update is reachable from the header for staff; the OLED tool strip is two rows and loses nothing; the symbol palette is a palette and not a duplicate of the toolbar; a recorded walkthrough can be embedded in a post. |
ui-css-coverage.test.js |
The pages this work owns have no unstyled class at all, a class the server renders is styled too, and no page anywhere has a new unstyled class; the recorded gaps are still gaps and are not quietly growing; shared controls live in the shared layer rather than in a page sheet; a property is not set twice in two sheets to different values; every surface that shows rendered markdown styles all of it; headings are different sizes, in the preview as well as in the post; no stylesheet declares a selector the browser will throw away; the syntax highlighting highlight.js emits is actually coloured, and the theme uses the site tokens so the light theme gets one too. |
ui-language.test.js |
No client-facing string is German (the codebase's comments are, the UI is not); the gallery error path is English; every page declares English. |
mobile-header.test.js |
The narrow bar does not wrap into several rows; the nav falls back to icons and stays nameable; the command trigger goes and the shortcut stays; the things a phone needs are never hidden; the action cluster keeps its size so the sign-in control stays tappable; the whole header is never made horizontally scrollable; the account chip is not squashed into unreadability. |
script-order.test.js |
The auth helper is available to every page script and really does wait for the DOM itself; a page script never reads a missing auth helper as signed out; every client script parses; the admin page does not bounce to Discord on an unverifiable session; the server, not the page, is what authorises /admin. |
http-headers.test.js |
HSTS is sent on HTTPS and only on HTTPS, and it does not promise subdomains; the other hardening headers are untouched; no asset is served as immutable under a name that can change; CSS, JS and images are cached with a revalidation window; HTML revalidates and content does not outlive its owner; a route that sets its own cache header is left alone; a .spcf download is still served byte-for-byte as an attachment. |
legal-pages.test.js |
Both pages are routed, titled and canonical and carry the header and footer without JavaScript; every table the privacy page lists is a table that exists; the OAuth claim is the scope the code asks for; the cookie claim matches the session configuration; the retention claim matches what is actually pruned; every storage key it lists is one the client writes; the local-first promise is stated because the code keeps it; the analytics claim matches the implementation; nothing in either page invents a legal claim; the public API claim holds; the deletion claim matches what the UI offers; the page is in English and the hub is the only subject. |
notes-map.test.js |
Every guide is a node and every node is named; a label is short enough to read on a node; the layout is the reading order and is the same every time; the size of the map comes from the text, not the window; the map knows when it cannot be drawn and does not draw it anyway; every note is named on the canvas, not only on hover; the list carries every note as links with a link count; the connections panel says what a note links to in words; hovering shows the same; the same pair of notes is one line, not several; it is still a canvas and still CSP-safe; the graph data is built from the guides themselves. |
gallery-links.test.js |
The gallery card is a real link, not a div with a click handler; navigation is no longer re-implemented per key press; the vote buttons stay outside the link; the card still escapes everything it prints; the user dashboard card keeps its own link shape; both pages load the shared card kit before their own script. |
How to add a test
'use strict';
const test = require('node:test');
const assert = require('node:assert');
test('the thing is true', () => {
assert.strictEqual(something(), 'expected');
});
Test names in this suite are full sentences that state the invariant, not the function under test — "the tool strip survives a header sync", "a claimed route is one the server serves". A failure message is the first thing anybody reads, and on this project it usually has to stand in for a browser.
- Use
node:testandnode:assertonly. Adding a test framework would be a larger decision than it looks. - If the test needs a database, set
process.env.DB_PATHto a temporary file before requiringlib/db—lib/db.jsopens the database at require time.test/forum.test.jsandtest/staff.test.jsare the models for that;test/docs-write.test.jsshows thetest.afterhook that removes the temporary tree. - If the assertion is about a claim the site makes in words, assert it against the
implementation, not against a copy of the words.
test/legal-pages.test.jsis the model. - Put it in
test/, name it<area>.test.jsafter the module or the page it protects (dom-contract.test.js,legal-pages.test.js). - If you need to boot a client script, reuse
test/helpers/md-dom.jsortest/helpers/editor-dom.mjsrather than writing a new stub — the details that matter (sharedclassName/classList,nullfromgetElementById) are the reason they exist. - Run
npm test. Remember it runscheck:headerfirst, so a page edit withoutnpm run sync:headerfails the whole run before your test ever executes.
What is NOT covered
Being explicit about this matters as much as the list above.
- No browser is driven. Nothing tests actual rendering, CSS application, layout or real user interaction. The CSS tests check that a selector exists in a stylesheet, not that the page looks right.
- No integration test boots the server and makes real HTTP requests. The round-trip
tests (
auth-roundtrip,session-store) stub at the module boundary. A routing or middleware regression is caught by reading the code, not by a request. - No load or performance test. Nothing measures latency, memory or concurrency.
- No accessibility automation. There is no axe, no ARIA validator and no colour
contrast checker. The mobile-header and
ui-shapetests assert structural properties by reading markup; nothing evaluates them against a rule set. - The assertions that read source text are brittle by design. Reformatting
public/js/editor.js, renaming a class, or rewrapping arequirecan fail a test without changing behaviour. That is the accepted cost of this style; when it bites, fix the assertion rather than the code, and check that the behaviour really is the same.