Verification scripts
Every script in tests/ - the exact command, what it checks, what a failure means, and which ones you must run before you commit.
Last updated 2026-08-06
Summary#
tests/ holds Node scripts, baseline files and a README. None of them run in CI. The blocking list is tests/release-gates.txt, and you run it yourself before every push:
node tests/run-gates.mjs # every check, reports every failure
node tests/run-gates.mjs --bail # stop at the first failureA push to main deploys whether or not these pass. Until 2026-09-14 the deploy workflow ran this list in a Run the release gates step and a failure stopped the deploy; the owner removed testing from GitHub Actions to save minutes, so the local run is now the only check.
tests/gates-wired.mjs keeps the list honest. Every script in tests/ must either be in the list or be declared as a deliberate exclusion, so a gate cannot be added and then quietly never run. A handful are not in the list because they need Playwright or a live deployment; those are declared, with the reason, in that file.
They are written against Node's standard library only, because the repository has no package.json and no node_modules. Run them with Node 24 from the repository root.
Overview#
The scripts fall into five groups.
| Group | Scripts | What they protect |
|---|---|---|
| Structural integrity | guard.mjs, chkblk.mjs | The inline scripts in the big HTML files still parse |
| Sync contracts | login-inline-sync.mjs, site-chrome-sync.mjs, kb-build.mjs | A generated copy still matches its source |
| Ratchets and audits | design-lint.mjs, theme-lint.mjs, contrast-audit.mjs | Design debt does not grow, theming stays on one system, contrast stays legible |
| Behavior | smoke.mjs, guide-rules.mjs, guide-wiring.mjs, login-verify.cjs | The product actually works end to end |
| Pipelines and tooling | i18n-*.mjs, kb-manifest.mjs, compare-repos.mjs, higgsfield-auth.mjs | Translation, documentation coverage, cross-repository drift, operator auth |
Three of them write files: login-inline-sync.mjs --write, site-chrome-sync.mjs --write, i18n-wire.mjs, i18n-generate.mjs --emit and kb-build.mjs --write. Each also has a read-only form that exits 1 on drift. The contract is always the same: edit the source, run the tool, commit both sides.
Pre-commit gates#
Run the ones that apply to what you changed.
| Gate | Command | When |
|---|---|---|
| Design ratchet | node tests/design-lint.mjs | Any UI change |
| Inline-script integrity | node tests/chkblk.mjs <file> | Any edit to an inline <script> |
| Login inline sync | node tests/login-inline-sync.mjs | Only if login.html changed |
| Site-chrome drift | node tests/site-chrome-sync.mjs | Any public page change |
| i18n coverage | node tests/i18n-verify.mjs | Before any UI feature is considered done |
| Knowledge base build | node tests/kb-build.mjs | Any change under docs/kb/ |
Full inventory#
| File | Bytes | One-line purpose | Writes files? |
|---|---|---|---|
README.md | 2,012 | Smoke-test documentation | - |
chkblk.mjs | 1,454 | Compile every inline <script> block | No |
compare-repos.mjs | 6,521 | Staging versus production drift | No |
contrast-audit.mjs | 16,703 | WCAG AA contrast audit | No |
design-lint-baseline.json | 1,144 | The ratchet's stored counts | Data |
design-lint.mjs | 7,883 | Design-system debt ratchet | --update-baseline |
guard.mjs | 5,413 | Closing-tag tripwire | No |
guide-rules.mjs | 16,124 | Unit tests for the recommendation rules | No |
guide-wiring.mjs | 10,550 | DOM wiring tests for the recommendations panel | No |
higgsfield-auth.mjs | 4,910 | One-time operator OAuth helper | Yes, .dev.vars |
i18n-extract.mjs | 22,416 | Scan the app for translatable strings | Yes, i18n/strings.en.json |
i18n-generate.mjs | 11,790 | Translate and emit the dictionary | Yes |
i18n-verify.mjs | 2,846 | Translation coverage gate | No |
i18n-wire.mjs | 3,283 | Inject the i18n runtime and switcher | Yes |
kb-build.mjs | 46,621 | Knowledge-base validator and builder | --write |
kb-manifest.mjs | 5,369 | Knowledge-base plan versus reality | No |
login-inline-sync.mjs | 4,169 | Keep the inlined /login equal to login.html | --write |
login-verify.cjs | 3,257 | Real-browser login test and screenshot | Yes, a PNG |
site-chrome-sync.mjs | 19,012 | Stamp the nav and footer into 21 public pages | --write |
smoke.mjs | 12,613 | Live end-to-end tool checks | No |
theme-lint.mjs | 5,420 | One-theme-system conformance | No |
Structural integrity#
guard.mjs — closing-tag tripwire#
node tests/guard.mjs # default targets
node tests/guard.mjs path/to/file.htmlDefault targets: dashboard.html, index.html, admin.html, login.html. Inside any inline <script> block it bans a literal </body, </html or </script. Exit 0 clean, 1 on any hit.
Why: this catches the class of bug that took production down on 2026-04-20. A literal closing-body or closing-script tag written inside a JavaScript template literal can be matched by any HTML rewriter doing string.replace('</body>', ...). The browser's script parser then terminates early and throws SyntaxError: Unexpected end of input, which breaks every inline-scripted feature on the page including authentication, leaving the whole site unclickable.
A failure means: split the string. The prescribed fix is printed: const CLOSE_BODY = '</' + 'body>'; and then use ${CLOSE_BODY}.
chkblk.mjs — inline <script> compile check#
node tests/chkblk.mjs # defaults to ../dashboard.html
node tests/chkblk.mjs dashboard.htmlUses vm.compileFunction in-process with no execution, so it catches unbalanced quotes, parentheses and braces introduced by an edit. It skips <script src=…> and application/json / application/ld+json blocks.
Output is Checked <n> <script> blocks in <FILE> — <k> broken. and it must report 0 broken. A failure lists block #<i> (line ~<line>) with the first line of the error and exits 1.
Sync contracts#
login-inline-sync.mjs — the inlined /login matches login.html#
node tests/login-inline-sync.mjs # check, exits 1 on drift
node tests/login-inline-sync.mjs --write # regenerate from login.html_worker.js intercepts GET /login and /login.html and serves an inlined const LOGIN_HTML = \...\` template literal instead of the static asset, added to dodge a Pages caching bug that served stale or zero-byte HTML. Nothing regenerates that copy, so editing login.html` alone has no effect in production. That drift once silently swallowed a full set of authentication fixes.
The script evaluates the template literal rather than string-comparing it, so escaping bugs surface as ❌ inlined LOGIN_HTML does not evaluate: <msg>. Comparison is line-ending agnostic. A pass prints ✅ LOGIN_HTML matches login.html (<n> bytes). A drift report gives both byte counts, the first differing character index, 70-character excerpts from each side, and ends with Edits to login.html are NOT live until you run: node tests/login-inline-sync.mjs --write. --write re-verifies after writing and aborts if it still mismatches. Commit both files.
site-chrome-sync.mjs — one navbar and one footer#
node tests/site-chrome-sync.mjs # drift check, exits 1 if stale
node tests/site-chrome-sync.mjs --write # apply the partials to the pages
node tests/site-chrome-sync.mjs --list # show the target setStamps partials/site-nav.html and partials/site-footer.html into 21 public pages between MV:NAV and MV:FOOTER marker comments. Per page it also fills <!--MV:SLOT name--> from partials/extras/, keeps <!--MV:IF lang--> only on pages that load js/mv-i18n-runtime.js, marks the current nav entry is-active, rewrites /#anchor to #anchor on index.html, and ensures /css/tokens.css, /css/site-chrome.css and /js/mv-site-chrome.js are linked.
Editing the chrome inside a page does nothing — the next sync overwrites it. Adding a public page means adding it to PAGES and running --write.
Why: the chrome had drifted into 20 distinct navbars and 12 distinct footers across 26 pages. On seven pages the "Free tools" nav link pointed at /free-tools/schema-validator instead of /free-tools, and the blog link was /blog on some pages and /blog.html on others. Detail in Centralized nav and footer.
kb-build.mjs — the knowledge-base gate#
node tests/kb-build.mjs # validate only, exit 1 on error — the gate
node tests/kb-build.mjs --write # validate, then regenerate help/
node tests/kb-build.mjs --stats # article and word counts per category
node tests/kb-build.mjs --list # every article id, category and file pathSource of truth is docs/kb/**/*.md; generated output is help/. The script carries its own front-matter parser, Markdown renderer and search-index builder, because the repository has no build step and no dependencies.
It validates front matter (id, title, summary, and a category that exists in its TAXONOMY), checks that every double-bracket cross-link and every related / seeAlso id resolves, and checks each article against the required section set for its type. Feature articles in the tools, billing and account categories are additionally checked against the full 17-section skeleton and warn if more than four sections are missing.
A failure means: a dead cross-link, a missing required section, a bad category, or malformed front matter. The build prints which file and which id. Never hand-edit help/ — the next --write wipes and regenerates it. help/ is not staged for deploy.
kb-manifest.mjs — plan versus reality#
node tests/kb-manifest.mjs # full report
node tests/kb-manifest.mjs --missing # just the ids still to write, one per line
node tests/kb-manifest.mjs --strict # exit 1 if anything planned is missingCompares docs/kb/_meta/manifest.json (the plan) with the articles that exist, in both directions:
| Result | Meaning | What to do |
|---|---|---|
MISSING | Planned, no file yet | Write it |
UNPLANNED | The file exists but is absent from the manifest | Add it to the manifest or delete it. An article nobody planned is an article nobody maintains |
MISMATCH | It exists, but its type disagrees with the plan | Fix one side |
It exits 1 with No manifest at <path> if the manifest is absent.
Ratchets and audits#
design-lint.mjs — the design-system ratchet#
node tests/design-lint.mjs # check against the baseline
node tests/design-lint.mjs --update-baseline # lock in a reduction
node tests/design-lint.mjs --summary # report without failing| Exit code | Meaning |
|---|---|
| 0 | No rule increased over the baseline, or --update-baseline / --summary |
| 1 | At least one file and rule increased. New design debt was introduced |
| 2 | Configuration error. A missing baseline prints ❌ config: no baseline found. Run: node tests/design-lint.mjs --update-baseline |
Scanned files: dashboard.html, chat-widget.js, css/app.css, css/legacy-mvx.css, css/mvr.css, css/mvr-premium.css. css/tokens.css is deliberately excluded, because it is where tokens are supposed to be defined.
| Rule | What it counts |
|---|---|
hardcoded-hex | Any #rrggbb or #rgb literal |
inline-style | Any style=" or style=' attribute |
emoji-in-ui | Any extended pictographic character |
offscale-radius | A border-radius px value outside the scale 6, 9, 12, 16, 20. Values of 999px and above are the pill idiom and are skipped |
left-accent-border | border-left: <n>px solid |
forked-token-def | In CSS only: a --brand, --radius*, --shadow-*, --space-<n> or --gradient definition outside tokens.css |
Pass prints ✅ PASS: no design-debt counts increased. The ratchet holds. Failure prints ❌ FAIL: new design debt was introduced (a tracked count went up):. A reduction prints ⬇ Debt reduced (nice — run --update-baseline to lock it in):.
A failure means: you added a hardcoded color, an inline style, an emoji, an off-scale radius, a left-accent border stripe, or a forked token definition. Fix it with a token or an .mvr-* component. Only re-baseline when you genuinely reduced debt. The rules themselves are law in docs/DESIGN-CONSTITUTION.md; The design system explains them.
theme-lint.mjs — one theme system#
node tests/theme-lint.mjs # report, exit 1 if any page fails
node tests/theme-lint.mjs --todo # just the remaining work
node tests/theme-lint.mjs --summary # never failsTwenty-nine pages, three checks each:
| Check | Requirement |
|---|---|
tokens | The page loads css/tokens.css |
boot | An inline <script> in <head> sets data-theme before the first stylesheet. An external file or a late tag means the page paints dark then snaps to light |
no-forks | No local color variable declarations. An alias such as --surface: var(--card) is allowed; a hardcoded value is a fork |
Output ends with <n>/<total> pages on the shared theme system. This was written before the migration on purpose, so it reports what is left rather than blocking work.
contrast-audit.mjs — WCAG AA#
node tests/contrast-audit.mjs # light mode, AA, exit 1 on fail
node tests/contrast-audit.mjs --theme dark
node tests/contrast-audit.mjs --all # both themes; this is what run-gates.mjs runs
node tests/contrast-audit.mjs --summary # never exits non-zero
node tests/contrast-audit.mjs --min 3.0 # override the thresholdWalks the element tree, finds the nearest opaque-background ancestor, composites translucent layers, then measures, because the naive check is wrong often enough to be useless. Twenty-two pages are audited: index, pricing, blog, blog-post, legal.html, four legal/* and thirteen free-tools/*.
The release gate list runs --all, so a failure in either theme fails node tests/run-gates.mjs. It ran light only until 2026-09-11, because dark carried 25 findings of its own and a gate that is red on day one blocks everyone. Both themes are clean now.
A clean run is not a statement about the site. Twenty-two pages is fewer than the thirty-four seo-lint.mjs gates as public: about, contact, legal/cookies, the four solutions/* and the six platform/* are measured by nothing, and neither are dashboard, admin or reports/*. free-tools/llms-txt-validator was in that blind spot until 2026-09-11 and was sitting on a live 4.48:1 miss when it was added, which is what being unmeasured looks like.
Declared limits, stated in the script: gradients are measured at their first color stop; elements with a background-image: url(...) are unmeasurable and skipped but counted; :hover, :focus, ::before and other state selectors are skipped; unparseable selectors are skipped and counted. A clean run means "nothing measurable fails", never "everything is fine".
Behavior#
smoke.mjs — live end-to-end tool checks#
BASE_URL=https://metricvaultai.com node tests/smoke.mjs
BASE_URL=https://metricvaultai.com node tests/smoke.mjs --tools=audience_overlap,tech_stack
BASE_URL=https://metricvaultai.com node tests/smoke.mjs --concurrency=8 --timeout-ms=90000
BASE_URL=https://preview-abc123.metricvaultai.pages.dev node tests/smoke.mjsDefaults: BASE_URL is https://metricvaultai.com, --concurrency is 4, --timeout-ms is 120000.
| Exit code | Meaning |
|---|---|
| 0 | All cases passed |
| 1 | One or more failed |
| 2 | A configuration or network problem before any test ran, including an empty --tools filter |
Forty-five cases: 35 premium-AI types posted to /api/premium-ai as {type, query}, and 10 real-data tools posted to /api/tools as {type, url}. Each case asserts that required fields are present (dotted paths supported) and that arrays meet a minimum length, for example domain_overview.topKeywords at least 3, keyword_magic.keywords at least 5, ai_visibility.platforms at least 3, tech_stack.technologies at least 1. Adding a tool means adding an entry to CASES.
Why: seventeen tools once looked "working" to users — the button clicked, the spinner showed — but silently threw a ReferenceError inside the renderer and never displayed data.
Warning: this hits the live deployment with no authentication header and burns real provider spend against real domains. Narrow it with --tools= unless you intend a full run.
guide-rules.mjs — the recommendation rules#
node tests/guide-rules.mjsLoads js/mv-guide-rules.js and tests every "Get recommendations" rule three ways: a realistic payload that should produce findings, a healthy payload that should produce none, and hostile inputs (null, wrong types, strings where numbers go, huge values) that must not throw and must not invent findings.
Shape assertions: the rule returns an array of at most 6 findings, each with a non-empty t, a sev of high, med or low, an area string and a numeric id, and the text must contain no undefined, NaN or [object. A stray undefined or NaN means a field was read that the payload did not have.
The two failure modes it guards: a rule reads a field the API never sends, so the button never appears and the tool looks like it was never wired up; or a rule fires on absent data, reading "0 issues" as "0 = bad".
guide-wiring.mjs — the panel wiring#
node tests/guide-wiring.mjsWhere guide-rules.mjs tests the rules in isolation, this pulls the real mvAttachGuide and mvBuildGuide out of dashboard.html, runs them against a minimal DOM shim, and checks that a button is inserted, that clicking it calls the AI with the right payload, and that a tool which already has a panel does not get a second one.
login-verify.cjs — real-browser login#
$env:MV_EMAIL='...'; $env:MV_PW='...'; node tests/login-verify.cjs [outPng] [gotoPath]Requires a local dev server on 127.0.0.1:8788 and Playwright installed globally or in node_modules. Credentials come from the environment only, so they never appear in the file or on a command line.
| Variable | Purpose |
|---|---|
MV_EMAIL, MV_PW | Required |
MV_BASE | Default http://127.0.0.1:8788 |
MV_PW_PKG | Path to the Playwright package |
Defaults: output tests/login-shot.png, target path /dashboard. It fills #signinEmail and #signinPassword, clicks #signinButton, waits for Supabase authentication, navigates, waits for the inline scripts to settle and screenshots at 1440x900. It prints LOGIN_OK:, final_url:, login_error_text:, the first 12 console_errors: and screenshot:. Exit 2 means MISSING MV_EMAIL / MV_PW env; exit 3 means CANNOT_LOAD_PLAYWRIGHT: set MV_PW_PKG to the playwright package path.
Pipelines and tooling#
The i18n pipeline#
Run all four in order whenever a UI string changed. Detail in Localization system.
node tests/i18n-extract.mjs
node tests/i18n-generate.mjs # needs ANTHROPIC_API_KEY in .dev.vars; resumable
node tests/i18n-generate.mjs --emit
node tests/i18n-verify.mjs # must print "all current UI strings are covered"| Script | Commands | Notes |
|---|---|---|
i18n-extract.mjs | no-arg writes i18n/strings.en.json · --report prints per-file counts and writes nothing · --list <file> dumps what one file yields | A regex scanner, not a DOM or AST parse, because there is no build step. admin.html is excluded: operator-only, English by design. A new page must be added to HTML_FILES; a new component script to JS_FILES |
i18n-generate.mjs | no-arg translates all languages · --lang es · --limit 20 for a cheap smoke test · --emit builds js/mv-i18n-dict.<lang>.js, one per language | Languages es, fr, pt, de, ru. Batches of 40 strings, 6 concurrent requests. Reads ANTHROPIC_API_KEY from the environment or .dev.vars, else throws No ANTHROPIC_API_KEY in env and no .dev.vars found. Writes after every batch and skips keys already present, because a run this size will be interrupted |
i18n-verify.mjs | no-arg | The gate. Re-runs the extractor's scan, diffs against the baseline, and per language reports covered/current (pct) missing-from-baseline=<n> layout-risk(long)=<n>. Exits 1 if any language has an uncovered string or the UI has new untranslated strings |
i18n-wire.mjs | no-arg writes the changes · --check exits 1 if any page still needs wiring | Idempotently inserts the language switcher after <div class="mv-nav-right"> and <script defer src="/js/mv-i18n-runtime.js"></script> before </body>. Excludes dashboard.html, the legal pages, admin.html and login.html, which have their own systems |
A verify failure means: a string is on screen that never reached the dictionary. Dictionary coverage is not the same as text swapping on screen, so also run a permissive loose scan and diff against i18n/dict.es.json; no visible English should remain except data, URLs, ids, domains or raw external-API values.
compare-repos.mjs — staging versus production drift#
node tests/compare-repos.mjs ../metricvaultai-seo-main
node tests/compare-repos.mjs <other-repo-dir> [this-repo-dir]Reports what matches, what differs, and what is missing on each side, separating application code (which should match) from infrastructure (which is meant to differ). Exit code 1 if any non-infrastructure file differs, else 0. Full explanation in Production and staging repos.
higgsfield-auth.mjs — operator OAuth helper, not a test#
node tests/higgsfield-auth.mjsOpens a browser sign-in against https://mcp.higgsfield.ai with a local callback on port 8792, then writes HIGGSFIELD_ACCESS_TOKEN, HIGGSFIELD_REFRESH_TOKEN, HIGGSFIELD_CLIENT_ID and AI_IMAGE_PROVIDER=higgsfield into ./.dev.vars.
Each run performs a fresh dynamic client registration so Metric Vault gets its own client_id and its own token family. That matters because Higgsfield rotates the refresh token on every renewal and has reuse detection: if two applications share one token family, whichever renews first invalidates the other permanently. For production, set the three values as Cloudflare Pages secrets instead. See Environment variables and secrets.
What is not covered#
Being straight about the gaps is more useful than implying coverage.
- No unit test harness for the worker.
_worker.jsis never imported and executed as a module. Well over a hundred gates do read it, and many lift a named function out of the source and run it against fixtures, but there is no harness that boots the worker and drives requests through it.smoke.mjsis the closest thing and it runs against a live deployment. - No accessibility audit beyond contrast.
contrast-audit.mjsmeasures color only. - No performance budget check.
See also
Was this article helpful?
Thanks — feedback noted for the docs team.