321 assertions, no test framework: why pure logic must import nothing
My test suite is `node scripts/selftest.ts` — no framework, no dependencies. The price is that every module it touches must have zero relative imports, and that price bought a codebase shape.
This is how the suite runs:
npm run test
# = node scripts/selftest.ts
No Vitest, no Jest, no config, no test dependencies. 321 assertions, executed directly. The reason it works and the price it charges are the same thing: every module the suite touches must have zero relative imports.
Why zero imports is non-negotiable
Node can run TypeScript directly now — but what it does is type stripping, not module resolution:
import { parse } from './parse'; // ✗ ERR_MODULE_NOT_FOUND
import { parse } from './parse.ts'; // ✓ extension required
My own test file can afford to write .ts on every import. But if the modules under test imported each other, they would each need that extension — while also being bundled by Astro and Vite, where a .ts extension is a different kind of trouble.
So the rule collapsed into its most memorable form:
games/*/logic/*,tools/*/logic/*,lib/i18n.ts,lib/detect.ts— these modules import nothing.
What that buys
① Test infrastructure that needs no upkeep. No framework means no version bumps, no config, no plugin compatibility. Adding a game means adding a logic/ directory and a few assertions to selftest.ts.
② Assertions are just code. No describe/it nesting, just a small function:
function eq(name: string, actual: unknown, expected: unknown) {
if (Object.is(actual, expected)) { passed++; return; }
failed++;
console.log('FAIL: ' + name + '\n expected ' + expected + '\n actual ' + actual);
}
Output is one line: passed: 321, failed: 0. On failure it prints expected and actual.
③ Feedback fast enough to gate on. The whole suite runs in under a second, so it can sit in front of the build rather than waiting on CI.
The price is deliberate duplication
Zero imports means you cannot extract a shared module. A real example: bytesToHex, a three-line function, exists once in the hash tool and once in the codec tool. Pulling it into lib/bytes.ts would make two logic/ directories import something, and the suite dies instantly.
Duplication over coupling — here it is not a slogan, it is what the toolchain forces.
Two copies of a three-line function is a price I will pay. What actually needs watching is duplication of rules, like “which strings count as a language tag”. That cannot be copy-paste. It needs a compile-time guard:
// lib/lang.ts
// Compile-time guard: the locales detect.ts can resolve must equal i18n.ts's
// Locale exactly. If either side drifts, these two lines fail to compile.
export const DETECTABLE_IS_LOCALE: readonly Locale[] = DETECTABLE;
export const LOCALE_IS_DETECTABLE: DetectLocale[] = [...LOCALES];
detect.ts imports nothing, so it declares its own list of resolvable locales; i18n.ts imports nothing, so it declares Locale. Assigning each to the other makes any drift a compile error in both directions. It is the only way I know to have duplication and consistency at the same time.
The real payoff is a codebase shape
The most valuable effect is not testing at all. It is that the rule forces a layering:
- The suite runs in plain Node: no DOM, no canvas, no
localStorage. - Therefore code that touches those things cannot sink into
logic/; it stays inApp.vue. - Therefore every game and tool is, by construction, pure logic plus a thin shell.
That layering was always the intent, but “should” usually loses to “just write it together”. With a zero-dependency suite it becomes something the tooling blocks: put canvas logic in logic/ and the next run explodes.
What it cannot test
Being honest about the boundary:
| Covered | Not covered |
|---|---|
| Game rules, scoring, collision | What canvas actually draws |
| Every pure function in the tools | Clipboard, drag-and-drop, file upload |
| Language detection, timezone fallback | Theme switching, ripple, scroll highlighting |
| Waka text parsing, duration maths | Layout, responsive behaviour, anything visual |
The DOM half I do not automate. Instead I keep it thin: the logic is tested to death inside logic/, and the shell only wires events to it and paints the result. The remaining surface is small enough to confirm by clicking through it once.
The best way to handle what you cannot test is to leave it nothing worth testing.
Roughly, the 321 assertions cover the rules of five games, the functions of ten tools, locale and first-visit detection, and the parser for the coding-activity block on my GitHub profile. They all run in one node call because what they depend on is: nothing.

Comments
…