Commands & Testing
Nine scripts, no task runner, no test framework. Everything is either Astro’s own CLI, ESLint, Prettier, or one small Node file.
| Command | What it does |
|---|---|
pnpm install |
Install dependencies |
pnpm dev |
Dev server at localhost:4321 |
pnpm build |
Production build to dist/ |
pnpm preview |
Serve the production build locally |
pnpm check |
astro check — type-checks .astro and .ts |
pnpm lint |
ESLint, including the Astro JSX accessibility rules |
pnpm format |
eslint --fix, then Prettier over everything |
pnpm test |
Every check — scripts/*.test.mjs plus src/**/*.test.ts and *.selfcheck.ts |
pnpm deploy:cf |
astro build && wrangler deploy -c dist/server/wrangler.json |
The full pre-flight is four of them:
pnpm lint && pnpm check && pnpm build && pnpm test
pnpm build is the real check
More correctness is enforced at build time than anywhere else, and three classes of failure surface only there.
Content schemas. Zod validates every entry in blog, authors and customers at dev and build. Bad frontmatter fails with the entry named — a story missing one of its exactly-three metrics, a post with no heroImage.
Link integrity. The finly:link-integrity hook runs on astro:build:done, walks the emitted HTML, collects every href="/…", and fails naming anything that points at a path the build did not produce:
1 link(s) in the built HTML point at a path this build did not produce:
/product/bill-pay/ — drawn by 4 page(s), e.g. /product/index.html
Build the page, retarget the link, or drop it — every link this site draws resolves.
It reads the output, not the config, which is what makes it complete: it covers nav rows, the CTA button on 120 pages, footer rows, hrefs written in content frontmatter, and hrefs a helper rewrote on the way out. Its “what exists” set comes from the output directory rather than the page list, so endpoints and assets count as destinations too.
Content invariants the compiler cannot see. officesFor throws when a careers advert names a city that is not a declared office. groupByDepartment and categoryOf throw on an unknown id rather than dropping the entry. imageFor throws when a config entry has no matching static import. Each names the entry and, where relevant, the calling component.
A clean build is therefore the strongest single signal in the repository.
pnpm test — discovery, not a list
scripts/test.mjs walks two trees and runs everything it finds:
scripts/*.test.mjs plain node — repo tooling
src/**/*.test.ts --experimental-strip-types
src/**/*.selfcheck.ts --experimental-strip-types
Fifteen check files pass today. The output is one block per file, then a tally:
--- scripts/theme-contrast.test.mjs ---
All 45 theme contrast pairs pass WCAG AA.
--- src/js/roleFacts.test.ts ---
ok — roleFacts: 24 assertions passed
…
15/15 check file(s) passed.
Discovery rather than a list, because a list is the thing that goes stale. That is not hypothetical here: thirteen checks had been written and then left unreachable, because the runner globbed only scripts/. They cost maintenance and returned no signal. A check dropped beside new logic is now picked up without editing the runner, which is the only way the house rule — non-trivial logic leaves one runnable check behind — stays true by default.
There is no framework. Each file is a plain module using node:assert/strict, printing a line and exiting non-zero on failure. No fixtures, no config, nothing to register.
The constraint on where a check can live
This is the one thing to understand before writing a check, because it explains several file splits that otherwise look arbitrary.
Checks run under plain Node with type stripping, which resolves neither the @js/* path aliases nor astro:* imports. One value import from either turns a useful assertion into ERR_MODULE_NOT_FOUND.
So the modules that carry checks are deliberately import-clean: relative specifiers with extensions, import type for anything from the config layer (a type-only import is erased before Node sees a specifier), and no astro:* at all. That is why these exist as separate files:
@js/contact— split from the Astro action wrapper insrc/actions/contact.ts@js/schema— the JSON-LD builders, split fromBaseHead@js/postFacts,roleFacts,integrationFacts,productFacts— split from their route-aware*Utilssiblingsui/count-up/format.ts,ui/password/strength.ts,ui/nav-highlight/geometry.ts— split from their.astrocallers
Each one says so at the top of the file.
A check that needs the Astro runtime does not belong here. It belongs in a build hook — finly:link-integrity is the worked example — where it can read real content and real output.
Note the import type detail, because it bites: import { type Foo } from "…" keeps the statement and fails to resolve the extensionless path, while import type { Foo } from "…" is erased outright. The house style is the second form for exactly this reason.
What each check covers
| File | Asserts |
|---|---|
scripts/theme-contrast.test.mjs |
all 45 semantic colour pairs clear WCAG AA in both themes |
src/js/contact.test.ts |
the schema, both spam gates, escaping, and the Resend call with a stubbed fetch |
src/js/schema.selfcheck.ts |
every JSON-LD builder’s shape and @id cross-references |
src/js/postFacts.test.ts |
reading time, initials, pageRange, related ordering |
src/js/roleFacts.test.ts |
department grouping, the count templates, salary parsing, officesFor |
src/js/integrationFacts.test.ts |
category grouping, the meta row, defaults and overrides |
src/js/productFacts.test.ts |
the ledger total against its printed lines |
src/js/nav.test.ts |
isCurrentPath prefix matching, resolveHeaderGroups |
src/js/template.test.ts |
{placeholder} filling |
src/components/ui/_reveal.test.ts |
the at-rest / armed / released state machine |
src/components/ui/_sequence.test.ts |
the stagger ladder, plus three cross-file guards |
src/components/ui/_zoom.test.ts |
zoomSizes |
src/components/ui/count-up/format.test.ts |
formatCount, shared by server and browser |
src/components/ui/nav-highlight/geometry.test.ts |
the pill box arithmetic |
src/components/ui/password/strength.test.ts |
scorePassword |
The theme-contrast check is the one most likely to fail on your changes, because it resolves the real token graph — Tailwind’s palette, then the theme file’s aliases, then the :root and .dark blocks — so a rebrand that drops a pair below AA is caught immediately. See Colors & Theming.
pnpm lint, check and format
pnpm lint runs ESLint with typescript-eslint, eslint-plugin-astro, eslint-plugin-jsx-a11y and eslint-plugin-simple-import-sort. The accessibility rules are part of it rather than an optional extra.
pnpm check runs astro check, which type-checks .astro files as well as .ts — that is where a wrong <Icon name> or a missing config field surfaces.
pnpm format runs eslint --fix and then Prettier, with prettier-plugin-astro and prettier-plugin-tailwindcss. Let the plugin sort class lists. Hand-ordering them produces a diff on the next format run, and the Chip primitive’s history is the cautionary tale: two copies of one config differed only in class order, because one had been hand-edited past the sorter, which is what hid the fact that they had genuinely drifted.
Writing a check
Drop it beside the logic:
// src/js/myFacts.test.ts
import assert from "node:assert/strict";
import { myFact } from "./myFacts.ts"; // relative, WITH the extension
assert.equal(myFact(3), "three");
console.log("myFacts.test.ts — all assertions passed");
The runner finds it. Keep the module under test free of astro:* and @js/* value imports, or move the pure part out into a module that is.
If the thing you want to assert needs real content or real build output, write a build hook in astro.config.mjs instead — that is the other half of the same rule.