Skip to content
AstroCraft Docs
On this theme

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 in src/actions/contact.ts
  • @js/schema — the JSON-LD builders, split from BaseHead
  • @js/postFacts, roleFacts, integrationFacts, productFacts — split from their route-aware *Utils siblings
  • ui/count-up/format.ts, ui/password/strength.ts, ui/nav-highlight/geometry.ts — split from their .astro callers

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.

NEXT STEPTroubleshooting