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 or one small Node file.

Command Runs
pnpm dev astro dev — the dev server on :4321
pnpm build astro build — production build to dist/
pnpm preview astro preview — serve the built output
pnpm astro the Astro CLI, for astro add and friends
pnpm check astro check — type-check .astro and .ts
pnpm lint ESLint across the repo
pnpm format eslint --fix, then Prettier
pnpm test every *.test.ts under src/
pnpm wiki:lint citation and link health for wiki/

The verification chain

pnpm lint && pnpm check && pnpm build && pnpm test && pnpm wiki:lint

Five commands, and each catches a different class of mistake. Run the whole thing before a deploy.

pnpm lint is ESLint with typescript-eslint, the Astro plugin and its JSX accessibility rules. Two configurations in it are deliberate and worth knowing before you trip over them:

  • astro/no-exports-from-components is switched off only for src/components/ui/** and src/components/svg/**, because the primitive contract requires exporting the tv() config. Everywhere else it stays on, which is why Sections/ puts anything exportable in a _sidecar.ts.
  • astro/jsx-a11y/no-noninteractive-tabindex allows region alongside tabpanel. A horizontally scrollable box needs tabindex="0" to be keyboard-reachable at all — in Chrome and Safari an overflow container is not focusable on its own, so everything past its right edge is unavailable without a pointer. The WAI fix is tabindex="0" plus role="region" plus a label, which is what the pricing comparison table does. Allowing it in the config rather than disabling the rule at the call site means the judgement does not have to be re-argued for the next scrollable table.

pnpm check runs astro check across .astro and .ts. tsconfig.json extends astro/tsconfigs/strict, so this is a strict pass. It is what catches an illegal <Icon name>, a prop that does not exist and a config value of the wrong shape.

pnpm build is the real check. Content-schema errors, config typos, broken reference()s, a missing logo file, a duplicate integration order, two posts claiming featured — all of them surface here rather than in the browser, and all of them name the offending entry.

pnpm test is described below.

pnpm wiki:lint does for the theme’s wiki/ knowledge base what the build does for the code.

The test runner

scripts/test.mjs, and it is about fifty lines:

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);
}

Discovery-based, so a check written next to the code it covers runs without being registered anywhere. Sorted, so failures always report in the same order. One convention, one path: a file named *.test.ts anywhere under src/.

Each file runs in its own Node process with --experimental-strip-types, which is why there is no framework, no config and no fixtures. A check is a plain module that asserts and throws.

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.

The --experimental-strip-types flag is passed explicitly even though type stripping is on by default from Node 23.6, so the runner works from 22.12 up. The engines floor is 22.13, set by pnpm 11 rather than by stripping — this flag is what keeps the runner working if that floor is ever lowered.

The twenty-six checks

src/components/ui/password/strength.test.ts

src/config/aboutData.test.ts        src/config/authData.test.ts
src/config/contactData.test.ts      src/config/homeData.test.ts
src/config/navData.test.ts          src/config/plannedRoutes.test.ts
src/config/pricingData.test.ts      src/config/productData.test.ts

src/js/blog.test.ts                 src/js/caseStudies.test.ts
src/js/contactEnquiry.test.ts       src/js/formGuards.test.ts
src/js/integrationRequest.test.ts   src/js/integrations.test.ts
src/js/listing.test.ts              src/js/nav.test.ts
src/js/newsletter.test.ts           src/js/orbit.test.ts
src/js/referenceCall.test.ts        src/js/resend.test.ts
src/js/rss.test.ts                  src/js/salesEnquiry.test.ts
src/js/schema.test.ts               src/js/signUp.test.ts
src/js/svg.test.ts

They fall into three groups.

Config checks verify what a type cannot: that an internal href points at a route which ships, that a logo stem has a file on disk, that a differentiator names a graphic the section can render, that no pricing tier is duplicated, that the site’s primary CTA still lands on /signup/, and that no planned route also has a real page.

Pure-function checks cover the helpers in @js/ — pagination arithmetic, related-entry selection, reading time, slugs, RSS escaping, JSON-LD shape, spam gates, every form schema, and the SVG id-namespacing that keeps inlined logos from clipping each other.

One primitive check covers scorePassword, the rule-based strength meter, because it is the only primitive with logic worth a check.

Note the split that decides where a check can live: anything importing astro:content cannot run under bare Node, which is why the content-layer readers sit in @js/collections.ts with no check beside them, and why every checked module imports nothing from astro:*.

Note also a convention that exists solely to make this work: every config file uses import type { … } rather than import { type … }. With only type bindings, the second form leaves the whole import statement in place after stripping, so Node tries to resolve an extensionless specifier and fails.

Reporting versus failing

Some checks print rather than throw, and the distinction is deliberate.

navData.test.ts fails outright on a malformed or duplicated href, but it prints the set of hrefs that have no page yet. Five *Data.test.ts files do the same. That is what lets the designed taxonomy ship ahead of its pages — the gap stays visible on every run without holding the suite red.

plannedRoutes.test.ts is the one that throws about those routes, and only for the contradiction: a route that is both redirected and built.

The three gates that fail closed

  1. Empty test discovery — zero checks found is a failure.
  2. pnpm wiki:lint — zero citations or zero pages is a failure.
  3. The production SITE_URL throw — a production deploy on the placeholder domain refuses to build.

Each of these exists because the silent version of it happened.

pnpm wiki:lint

The theme ships a wiki/ knowledge base covering every subsystem, and its value rests on one promise: every non-obvious claim is anchored to a path:line citation, so drift is detectable.

That promise broke silently once. When the SITE_URL gate added about twenty-one lines to the top of astro.config.mjs, six citations below the insertion point kept pointing at lines that still existed and still parsed — they just described different code. A bounds check cannot see that, and neither can a date check.

So the load-bearing rule is cite-anchor: the cited line must actually contain a symbol the surrounding prose names. That is what catches a citation that slid. The script also validates [[wiki-link]] targets and reciprocity, with the hub pages exempt.

If you delete the wiki, delete the script and the wiki:lint entry with it — a gate that fails closed on an empty set will fail on an empty directory, which is exactly what it is for.

pnpm format

Runs eslint --fix, then Prettier with prettier-plugin-astro and prettier-plugin-tailwindcss.

Let the Tailwind plugin sort your classes. Hand-ordering a class list is work that will be undone on the next format, and class order carries no meaning in Tailwind — with one exception worth knowing: when two conflicting utilities have equal specificity and tailwind-merge is not involved, emit order decides. That is a reason to avoid the conflict, not a reason to hand-sort.

Pinned versions

packageManager pins [email protected] and engines.node sets the 22.13 floor. pnpm-workspace.yaml adds two settings: allowBuilds denies post-install build scripts for @parcel/watcher, esbuild and sharp (none needs to compile, and denying them keeps installs fast and free of native toolchain requirements), and minimumReleaseAgeExclude pins the whole Tailwind version set — including every platform-specific @tailwindcss/oxide-* binary — to what the theme was built and tested against.

NEXT STEPTroubleshooting