Testing
modernGraphTool uses Vitest with Playwright browser mode. Specs are co-located next to the
code they cover as *.spec.ts.
Running tests
| Command | What it does |
|---|---|
npm run test | Full suite — both the client and server projects |
npm run test:coverage | Full suite + v8 coverage report into coverage/ |
npm run test:smoke | Boots the built dist/ in a browser (run build first) |
The two projects
The filename decides which project runs a spec:
*.svelte.spec.ts→ theclientproject, in real Chromium via Playwright- everything else → the
serverproject, in node
Most specs drive stores and services directly. Component specs mount with render() from
vitest-browser-svelte and query through page.getBy* from vitest/browser.
Layer-specific testing traps live next to the code, in per-directory AGENTS.md files — component
and boot-test traps in src/lib/components/, d3/rAF traps in src/lib/graph/, and the fake-device
fixtures in src/lib/device-peq/. They are kept there rather than here so they stay accurate as
the code moves.
Coverage
The configuration lives in vite.config.ts under test.coverage, and two settings there are
load-bearing:
- The
includeglob covering all ofsrcis what makes files that no test imports count at all. Without it the ~25device-peq/handlersandconnectorsmodules vanish from the denominator entirely rather than showing as the 0% they were, which made the reported number about 10 points optimistic. (Vitest 4 removed the oldcoverage.allflag —includenow does that job, and passingallis a type error.) excludeomits the Paraglide output.src/lib/paraglide/is generated by the Paraglide Vite plugin and is gitignored; counting it added ~3800 machine-written statements — a third of the total — and dragged the figure well below what the hand-written source actually is.src/lib/types/**(type-only) andsrc/routes/**(shells covered by the boot tests) are excluded for the same "not meaningfully coverable" reason.
thresholds is a ratchet, not a targetIt holds the measured numbers as of the last improvement. Raise it when coverage goes up; never lower it to turn a red run green. CI runs coverage on the Linux leg only, and it blocks — a PR that falls below the ratchet has removed coverage that used to exist.
Smoke test
npm run build && npm run test:smoke
The unit suite mounts components against source, so nothing in it ever loads what the build emits.
An adapter-static misconfiguration, a defaults/ file the Vite plugin stopped copying, an asset-URL
regression, or a worker that fails to resolve once bundled will all ship a blank page with the whole
suite green.
scripts/smoke-dist.js serves dist/ over real HTTP — including the SPA fallback that .htaccess
provides — boots it in Playwright chromium, and fails on a console error, an uncaught exception, a
4xx, a graph that never draws, or a ?share= link that lands no curve.
Two things to keep in mind when extending it:
- A 200 does not prove a file exists. The SPA fallback answers anything not on disk with
index.html, on the real Apache host as much as here, so asset checks compare the body against the HTML shell rather than trusting the status. - It runs in the
buildCI job, afternpm run build, because it needs the built output.
Continuous integration
.github/workflows/ci.yml runs on every PR and every push to main: lint → check → test on
both ubuntu-latest and windows-latest, plus a build of the app, the CDN distribution and the docs
site on Linux.
The Windows leg is not redundant. Git for Windows sets core.autocrlf=true in its system config, so
before .gitattributes pinned every text file to eol=lf, the same commit was Prettier-clean on
macOS and dirty on Windows. The Working tree must be clean step guards against that regressing:
it fails if a checkout plus install leaves anything modified, or if CRLF ever reaches the index.
npm run lint must exit 0. ESLint warnings (mostly device-peq protocol constants kept for
reference) do not fail the build. Rule overrides live in eslint.config.js and each carries a
comment explaining why — notably no-useless-assignment is off for *.svelte because it misreads
$bindable() prop defaults, and no-explicit-any is off under docs/ because the config migration
tool parses arbitrary operator-authored config.