Commands & Testing
Eight scripts, no task runner, no test framework. Everything is either Astro’s own CLI or one small Node file.
| Command | Does |
|---|---|
pnpm dev |
Dev server at localhost:4321 |
pnpm build |
Production build to dist/ |
pnpm preview |
Serve the built output |
pnpm check |
astro check — types across .astro and .ts |
pnpm lint |
ESLint |
pnpm format |
eslint --fix, then Prettier |
pnpm test |
Every *.test.ts under src/ |
pnpm astro |
The Astro CLI directly |
The verification chain
Run all four after any non-trivial change:
pnpm lint && pnpm check && pnpm build && pnpm test
They catch different things, and none of them is redundant.
pnpm lint runs ESLint over the whole project — the TypeScript recommended set, the Astro plugin, jsx-a11y for .astro files, and simple-import-sort. Note that no-explicit-any and no-unused-vars are set to warn rather than error. That is a deliberate relaxation because this is a buyer-facing template, where a commented-out demo should not fail a build for someone learning the codebase. It is not the bar for your own application code — keep any out and delete unused symbols.
pnpm check runs astro check, which is the only thing that type-checks inside .astro frontmatter and templates. ESLint does not do this. On a clean checkout it reports 271 files with zero errors, warnings and hints.
pnpm build is the real check. Content-schema violations, missing image references, broken collection references and config typos all surface here rather than in the browser. A clean build is the strongest single signal the theme gives you.
pnpm test runs the self-checks. See below.
pnpm format
"format": "eslint . --fix && prettier -w \"**/*\" --ignore-unknown --cache"
ESLint first (so import order is fixed), then Prettier over everything it recognises. Class ordering is handled by prettier-plugin-tailwindcss — let it sort and never hand-order class lists. If you find yourself fighting it, the answer is a tv() config, not a manual ordering.
The test runner
scripts/test.mjs is about forty lines and has no configuration:
const tests = readdirSync(src, { recursive: true })
.filter((file) => file.endsWith(".test.ts"))
.map((file) => path.join("src", file))
.sort();
if (tests.length === 0) {
console.error("No checks found under src/ — expected at least one *.test.ts file.");
process.exit(1);
}
It finds every *.test.ts under src/ and runs each with Node’s --experimental-strip-types. One convention, one path: name a file <thing>.test.ts and put it next to the code it covers. That is the entire registration step.
Two design decisions in it are worth understanding.
Zero checks is a failure, not a pass. The whole point of discovery is that it cannot quietly stop finding the checks it is supposed to run. A runner that passes on an empty set is a runner that will eventually pass on an empty set for the wrong reason.
The flag is passed explicitly even though type stripping is on by default from Node 23.6, so the runner works on the 22.13 floor in engines rather than only on newer releases.
There is no framework, no describe, no fixtures. A check is a file that asserts and exits non-zero on failure — node:assert/strict is the whole toolkit:
import assert from "node:assert/strict";
import { rotateAfter } from "./rotate.ts";
assert.deepEqual(rotateAfter(["a", "b", "c", "d"], 2, 2), ["d", "a"]);
assert.deepEqual(rotateAfter(["a", "b"], 0, 5), ["b"]);
console.log("rotate.test.ts — ok");
Run one directly while iterating:
node --experimental-strip-types src/js/rotate.test.ts
What is checked, and why those things
Eleven check files ship:
| Check | Covers |
|---|---|
src/js/nav.test.ts |
active-link prefix matching |
src/js/rotate.test.ts |
the related-cases wrap-around |
src/js/schema.test.ts |
the JSON-LD builders |
ui/_listbox.test.ts |
filter and active-descendant helpers |
ui/carousel/scroll.test.ts |
the infinite-loop arithmetic |
ui/count-up/count.test.ts |
the eased counter and its clamp |
ui/nav-highlight/geometry.test.ts |
pill placement on both axes |
ui/pagination/window.test.ts |
which page numbers a pager shows |
ui/password/strength.test.ts |
the password scoring rules |
ui/tabs/roving.test.ts |
roving focus |
ui/text-reveal/lines.test.ts |
grouping words into rendered lines |
The selection is not arbitrary. The rule is that non-trivial logic leaves exactly one runnable check behind — the smallest thing that fails if the logic breaks. Trivial one-liners need none.
There is a second, structural rule that explains where several of these modules live: a module that imports astro:content or a path alias cannot be checked under bare Node, because neither exists there. So logic that could be silently wrong gets moved into a dependency-free module next to its check.
That is why rotateAfter lives in src/js/rotate.ts rather than beside its only caller in work.ts, why the pagination window lives beside the primitive rather than in blog.ts, and why nav.ts is kept free of config imports — a single one would fail the whole self-check with a module-resolution error instead of a useful assertion.
The failure modes those checks guard against share a character: things a build never catches. An off-by-one in the pager quietly stops linking to the last page. A missing clamp in the counter lands on to - 1 instead of to. A wrap that returns the anchor puts a case study in its own related rail. None of them throws.
Writing your own check
- Put the logic in a module that imports nothing — no path aliases, no
astro:content. - Write
<thing>.test.tsbeside it. - Import
node:assert/strict, assert, and log a line on success. pnpm test.
If the logic you want to check cannot be extracted that way, that is usually a signal it should be. Both existing examples in src/js/ were moved for exactly this reason.
What is not checked
There are no component-render tests, no browser tests and no snapshot tests. The theme’s position is that astro check plus a clean build covers the class of error those would find, and that the remaining risk is visual — which is what the dev catalog is for.
The visual check is /examples/ui. Open it in dev, eyeball every primitive in light and dark using the header toggle, and — for the motion panel — with and without the OS “reduce motion” setting. A missing token shows up instantly as an un-themed element.
That check has one trap worth knowing: a page that mounts no ThemeToggle is dark-only, so light-mode bugs on it are invisible. The 404 shipped with a light-mode-white illustration for a while for exactly this reason. Grep the artwork rather than trusting the screenshot.
A note on the TypeScript version
typescript is held at 6.x on purpose, so pnpm outdated will always show it behind. TypeScript 7 is blocked by peers rather than preference: @astrojs/check accepts ^5 || ^6, and typescript-eslint accepts >=4.8.4 <6.1.0. Bumping breaks pnpm check and pnpm lint together.
Re-check when both ship TypeScript 7 support. Every other dependency tracks latest.