Letter Punk uses Node’s built-in test runner (node:test + node:assert/strict). No test framework dependency is installed — this keeps the project’s zero-bundler, minimal-tooling posture intact.
npm test
This runs node --test, which auto-discovers every file under test/.
test/gameLogic.test.js — gameplay rules engine (public/modules/gameLogic.js)test/dictionaryValidator.test.js — dictionary loading and validation (public/modules/dictionaryValidator.js)test/buildLogic.test.js — chain-break detection (public/modules/buildLogic.js), partial coverage — see “Not covered yet”test/shareLink.test.js — shareable-link encode/decode (public/modules/shareLink.js)public/modules/package.json and test/package.json — each set {"type": "module"}, scoped only to their own directory. This lets Node resolve the existing import/export syntax in public/modules/*.js correctly without changing the root package.json, which must stay CommonJS for scripts/generate-daily-puzzles.js and public/util/compile-dict.js (both use require).gameLogic.js and dictionaryValidator.js are both written as pure, dependency-injected factories (createGameEngine(options), createDictionaryValidator(options)) with no hard-coded DOM, fetch, or window access baked into their construction. Every test builds a small in-memory harness: a fixed test board, a mocked validateWord/fetchImpl/ptrieFactory, and plain callback arrays that record onStateChange/onMessage/onWordResult events for assertions. No network calls, no browser globals, no real dictionary files are touched.
One exception: dictionaryValidator.js’s API-fallback path reads window.location.href directly (a hard dependency on running in a browser). The one test exercising that path stubs a minimal globalThis.window for its duration and tears it down in t.after() — this is a test-environment workaround, not a source change.
gameLogic.js (createGameEngine):
x2): one repeat allowed, a second repeat rejectedvalidateWordremoveLastToken (single-character delete): nothing-to-undo, direct back-up on an already-empty builder, back-up from a locked lone starter, and generic mid-word popclearTokens (delete-word): already-clear, direct found-word removal on an empty builder, wiping an in-progress first word, the post-acceptance reset-to-starter, and the second-press remove-found-wordplayerCharacterCount was already computed at that point but silently never included in the message text)letterUsageCounts in the snapshot: reuse across accepted words plus the word in progress, including the auto-reseeded starting-letter token after a word is accepted (drives the decorative per-tile xN badge in boardRenderer.js, which has no direct test coverage of its own — see “Not covered yet”)submitWord()’s “solved” branch sets tokens=[] and returns before the usual auto-reseed runs, so the builder is genuinely empty afterward, not holding a seeded starting letter. A regression test locks in the fix for a real bug this surfaced: starterLocked used to stay true from before solving, so typing a letter of a further word and pressing Undo Letter would incorrectly back into (un-accept) the word that just completed the board instead of simply deleting that one letterjustCompleted on the word-result event: true only for the word that first covers the whole board, false for solved (which stays true) on every further word submitted afterward while it remains covered — this is the signal the completion celebration (steam vent plus an abbreviated ball-bearing pass, public/modules/steamVentEasterEgg.js / public/modules/pipeEasterEgg.js) uses so it fires once per completion rather than replaying on every word added during continued playrunningCharacterCount in the snapshot: a live tally of accepted-word letters plus the word in progress, including the auto-reseeded starter token (drives the “Accepted words” panel’s live letter-count stat)freeChainMode option, setFreeChainMode/isFreeChainMode, snapshot.freeChainMode): getRequiredStartingLetter() returns null whenever the mode is active, which is the single choke point every other behavior already reads from — so one covered change (no auto-seed, no starting-letter rejection, words acceptable in any order) is really the whole feature. Also covers switching the mode mid-puzzle in both directions: turning it on discards whatever’s mid-typed in the builder and drops the requirement immediately; turning it back off re-seeds the builder with the correct required starting letter; and toggling to the same value is a verified no-op (no state change emitted, in-progress word left untouched)dictionaryValidator.js (createDictionaryValidator):
clearCache() (dictionary fetches are cached independently and are not re-fetched by clearCache())findCompanionWord returns its full valid-candidate list sorted shortest to longest (not a single random pick — callers, e.g. pickBalancedCompanion in public/app.js, choose from it), excluding blocklisted wordsgetValidationSourceLabel and summarizeValidationSources helpersbuildLogic.js (partial — see “Not covered yet”):
findChainBreaks: reports every word in a sequence that doesn’t start with the previous word’s last lettershareLink.js (encodeShareHash/decodeShareHash):
The multi-word backspace path is worth calling out: after accepting two words, deleting back through the second word’s letters resets an internal starterLocked flag, so continued deletes fully empty the builder while the first word’s required starting letter is still active — typing the wrong letter at that point correctly triggers “This word must start with X.” That interaction isn’t obvious from reading appendToken or removeLastToken in isolation; test/gameLogic.test.js traces it step by step so a future refactor can’t silently break it.
boardRenderer.js — SVG/DOM rendering; would need a DOM environment (e.g. jsdom) to test meaningfully, which is a bigger tooling addition than the pure-logic modules above.buildLogic.js beyond findChainBreaks — wordsFromSolutionInput and generateBoardFromSolutionWords (the letter-to-side layout solver) are untested. The layout solver is a good next candidate: it’s pure and has real edge cases (infeasible adjacency graphs should fail cleanly, not throw).puzzleFetcher.js, historyManager.js, analyticsClient.js — not yet covered.app.js — no direct test coverage at all; anything that only lives there (pickBalancedCompanion’s percentile-outward search, copyShareLink, share-link hydration, modal wiring) is verified manually via headless-Chrome runs during development, not by the automated suite. This gap produced a real, user-reported bug: copyShareLink read the module-level canonicalWords directly, which is only ever populated by custom-board generation or by loading a shared link — normal catalog/daily-puzzle navigation never touches it. Sharing a daily puzzle (including a self-discovered alternate solve that differs from the catalog’s official pair) silently dropped the canonical reference, so the recipient’s — or the same player’s own — final submission got no character-count comparison at all. Fixed with a shared getActiveCanonicalWords() helper that both getActiveCanonicalCharacterCount and copyShareLink now call, falling back to the catalog entry’s canonicalSolution when the in-memory canonicalWords is empty. Verified against the actual live daily-puzzles catalog in a real browser, not just a mock.isFreeChainModeEnabled, setFreeChainPreference, setFreeChainSessionOverride/clearFreeChainSessionOverride, and the findChainBreaks(progressWords).length > 0 auto-detect in hydrateSharedPuzzle) is also app.js-only and manually verified, not automated. The engine-level behavior it drives (setFreeChainMode, getRequiredStartingLetter) is fully covered — see above.startArcadeMode/stopArcadeMode, the idle-warning and idle-restart timer pair, captureGameForLaterRestore/restoreSavedGame, and the &idleWarnSec=/&idleResetSec= URL overrides) is entirely app.js-only and manually verified via headless-Chrome runs with the relevant timing constants temporarily shortened, not covered by the automated suite. See docs/development.md for the full behavior these drive.Follow the harness pattern already in test/gameLogic.test.js and test/dictionaryValidator.test.js: inject mocks for anything that would otherwise touch the network, the DOM, or window, and assert against the returned snapshot/result objects and recorded callback events rather than internal state.