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)test/shareText.test.js — masked share-text formatting (public/modules/shareText.js)test/puzzleFetcher.test.js — daily puzzle catalog and navigation (public/modules/puzzleFetcher.js)test/historyManager.test.js — player stats and streak persistence (public/modules/historyManager.js)test/analyticsClient.test.js — fire-and-forget analytics events (public/modules/analyticsClient.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)hasNoStartEndOverlap, see docs/development.md): earned in Free Chain mode when no letter is both a start and an end, not earned there if the player reuses one anyway, never earned across a normal-chain multi-word solve since the chain rule forces the overlap by construction, and combines with a character-count title in the same message rather than displacing itisFullyChained/isEligibleForUnionPlumber, see docs/development.md): earned in Free Chain mode when the player voluntarily chains every word anyway, not earned for the identical word sequence in normal chain mode since the game already forces that structure there, requires at least two words (a solo full-board solve earns Solo Plumber instead, not this), is mutually exclusive with Solo Plumber in the same message, cannot be earned retroactively by switching Free Chain mode on after already solving in normal mode with no gameplay in between (a real bug in an earlier cut of this feature, since fixed), is not disqualified by an earlier word submitted outside Free Chain mode as long as the board was completed under Free Chain mode (Undo always works, so an earlier word’s mode is never a meaningful constraint — an even earlier, stricter cut of this feature got this backwards too), and is not retroactively revoked by switching out of Free Chain mode after the board is already completeletterUsageCounts in the snapshot: reuse across accepted words plus the word in progress, deliberately excluding the chain-required connector letter (a word’s first letter matching the previous word’s last letter in normal play, and the auto-reseeded starting-letter token that follows) so the count reflects genuine reuse rather than firing on every ordinary word transition; Free Chain mode has no such connector to exclude, so nothing is skipped there (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 letterremoveLatestFoundWord() (the path clearTokens/”Undo Word” falls into when the builder is already empty, which it always is right after solving) used to call seedNextWord() unconditionally after removing a word. Deleting a bonus word added after the board was already solved — which never changes solved status, since none of a bonus word’s letters can be load-bearing — still seeded a starting letter based on whichever word became newest, even though there was no “next word” to seed one for; a second Undo on that phantom seed then backed into a real prior word the player never asked to touch. Fixed by checking whether the board is still fully covered after the removal (mirroring submitWord’s own solved check) before deciding whether to reseed at all — verified to still reseed correctly on a further undo that genuinely un-solves the board, not just on the bonus-word casejustCompleted 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)getShareSummary() (see docs/development.md): word lengths and chain transitions in solve order for a chained Free Chain solve, that normal mode never credits Union Plumber even though isFullyChained would otherwise be true, a Solo Plumber case, a title combined with a character-count title in the same summary, that it’s safely callable before the board is solved, that completedInFreeChain reflects only the mode active at the moment the board was completed — true when completed under Free Chain mode regardless of an earlier word submitted outside it, false for a normal-mode solve, and unaffected by any mode change either before that moment (an earlier word) or after it (continued play, a later Settings change) — and that a words field (real words, uppercase, solve order) is omitted entirely by default and only present when explicitly requested via { includeWords: true } (see docs/development.md)isWordCountAtOrUnderCanonical, see docs/canonical-solution-rating.md): a real, user-reported issue this fixes — a title (and its contribution to the masked share’s Bonus +N) used to fire regardless of word count, so a longer solve could earn the identical praise a tight one did, and every solve with a known canonical reference earned some bonus, making the word “bonus” meaningless. Covered: a title fires normally when word count is at or under the canonical count (matches prior behavior exactly); a title is withheld — replaced by a plain fact-plus-encouragement message, “the reference solution does it in N — see if you can trim it down” — when word count exceeds canonical, even when the character count alone would have earned Vocabulary Wrangler; and getShareSummary()’s titles array omits the character-count title in that same case while still including Union Plumber, confirming the two axes are gated independently, not coupleddictionaryValidator.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):
resultSummary produces the exact same hash as before that field existed (locks in backward compatibility for every pre-existing link format); a populated resultSummary round-trips with no real letters ever appearing in the hash, including completedInFreeChain; a link with none decodes resultSummary to null; an empty titles array round-trips as [], not a missing field; completedInFreeChain defaults to false when omitted from the input, so older-shaped resultSummary objects still encode correctlyshareText.js (formatMaskedShareText):
LETTER_BLOCK per letter, bookended by start/end anchors that are always extra glyphs, never a stand-in for a letter — asserted for a multi-letter word, a two-letter word, and (no longer a special case, now that anchors and blocks don’t compete for the same slot) a single-letter wordBonus +N count line (+1 for one, +2 for both axes) with the specific title name(s) asserted absent from the output — see docs/development.md for why naming them would leak which side of canonical a solve landed on; the line is omitted entirely when nothing was earnedFree Chain badge appears when completedInFreeChain is true, combining with the bonus count on one line (Free Chain · Bonus +1) when both apply, appearing alone when only the mode applies, and absent entirely for a normal-mode solve even if a bonus was earnedbuildCountLine) is always present (unlike the badge line), correctly pluralizes both counts independently, sums letters across every word, and sits after the badge line but before an optional URL block——————————) and a “Play Letter Punk here:” blurb (buildUrlBlock), both omitted together with the URL itself when none is suppliedshareText.js (formatUnmaskedShareText, see docs/development.md):
Bonus +N — asserted both that the name appears and that no Bonus/count text does; the titles line is omitted entirely when nothing was earnedcanonicalWords is supplied (e.g. Efficiency Engineer (14 under the canonical 18), Dead Reckoner (matches the canonical 4)), falls back to the bare title name when no canonical words are known, and — confirming the detail logic is scoped correctly — leaves a non-character-count title (Solo/Union Plumber) untouched even when canonical words are suppliedCanonical: WORD, WORD line (uppercased regardless of input case) is appended when canonical words are known, omitted entirely otherwise, and sits immediately before the rule/blurb/URL block so the URL always stays the last lineformatMaskedShareText, sharing the same buildHeaderLine/buildCountLine/buildUrlBlock helpers so the two formatters can’t drift apart on anything but the row and the titles-line content itselfThe 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.
puzzleFetcher.js (createPuzzleFetcher): the one client-side module here with real date-dependent branching, made testable without mocking Date by having fixtures compute their own “today”/”yesterday”/”tomorrow” ids the same way the module does (isoDate(offsetDays) in the test file mirrors getTodayPuzzleId’s formatting), so the suite stays correct on whatever real day it runs.
loadDailyPuzzleCatalog: applies today’s entry when present; falls back to the nearest upcoming entry when today is missing from the catalog; falls back to the catalog’s earliest entry (not the most recent past one — a real, easy-to-assume-wrong branch in findInitialPuzzleIndex) when the whole catalog is in the past; sorts an out-of-order catalog by id before choosing; { applyBoard: false } loads catalog data without applying a board, leaving puzzleSource untouched, for the shared-link/?date= boot paths that need to apply their own board first; tries the static /data/daily-puzzles.json fallback URL only after the primary endpoint 404s; falls back to a random board when every candidate URL fails or the payload isn’t shaped like a catalogapplyCatalogPuzzle (via boardFromPuzzleEntry): converts a puzzle entry’s {top, right, bottom, left} board into the engine’s 4-side, uppercased array shapeplayPreviousPuzzle/playNextPuzzle: stop cleanly at both catalog boundaries rather than going out of rangeplayTodayPuzzle: returns to the remembered home index after navigating away, and reports a clear error when no catalog has ever loadedmarkCustomBoard/markRandomBoard: reset puzzleSource and clear activePuzzleIndex back to -1getYesterdayPuzzleData: returns the previous entry’s canonical words uppercased; null at the start of the catalog, off a catalog puzzle entirely, or when the previous entry has no recorded solutionisActiveCatalogPuzzleToday/getActiveCatalogDateParam: agree with each other across catalog, custom, and random sources — the same distinction the dated-share-link scheme (see docs/development.md) depends onplayPuzzleByDate: applies the matching entry for a valid compact date, and returns the two distinct { ok: false, error } shapes for a malformed date string vs. a well-formed one that isn’t in the cataloggetPreviousSolutionUiLabels/getNavigationState: the “Yesterday” vs. “Previous” label switch, and previousDisabled/nextDisabled/todayDisabled at each boundary, including the empty-catalog starting statehistoryManager.js (getPlayerStats/recordFinishedGame): the module reads/writes the bare global localStorage rather than an injected store, so the test file installs a minimal in-memory shim (globalThis.localStorage) in a beforeEach — real persistence within a test, not the silent no-op the module’s own try/catch would otherwise produce under plain Node.
getPlayerStats returns a fully-shaped default when nothing is stored, and normalizes corrupted JSON, a partial solveDistribution, and a non-array historyLog without crashingrecordFinishedGame: a first win starts a streak of 1; a loss leaves gamesWon unchanged, records a null word count, and resets currentStreak to 0 while leaving maxStreak alone; solveDistribution buckets by word count with 5+ words sharing one bucket; a repeated puzzleId is a no-op rather than double-countingrecordFinishedGame always compares lastPlayedDate against today’s actual “yesterday” (getRelativeDateString(-1), computed fresh on every call), not the day before whatever puzzleId was just recorded. A test locks this in explicitly: two calls using real yesterday/today ids extend the streak to 2; the same two calls made with an equal-sized gap in the puzzleId sequence but not aligned to real yesterday/today do not extend it. This means the function isn’t designed to correctly backfill an arbitrary historical streak — only to be called at roughly the time each puzzle is actually played.analyticsClient.js (trackPuzzleLoad/trackWordSubmit/trackGameSolved): the module calls the bare global fetch, so the test file stubs globalThis.fetch in a beforeEach to capture calls instead of letting the real fetch throw on the relative /api/event URL (which it would, outside a browser — the module’s own try/catch already absorbs that in production, but stubbing is also just the right way to verify what would have been sent).
trackX function posts the exact documented event/data shape, including defaulting a missing puzzleId/validationSource to '' and lowercasing the submitted wordfetch that throws synchronously doesn’t propagate out of a trackX call, and a fetch promise that rejects doesn’t surface as an unhandled rejection (its internal .catch(() => {}) is actually reached, not just present in the source)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).app.js — no direct test coverage at all; anything that only lives there (pickBalancedCompanion’s percentile-outward search, copyShareLink, the wireEvents event-listener attachments themselves) is verified manually via headless-Chrome runs during development, not by the automated suite. Share-link hydration/replay and arcade mode live in puzzleReplay.js/arcadeMode.js, settings/preferences in settings.js, and modal open/close/focus-trap logic in modalManager.js — see the dedicated bullets below. 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.setMessage’s display duration is also app.js-only: a fixed 4-second timeout regardless of message length, which was fine until Solo Plumber and Union Plumber (see below) could stack a second full sentence onto an already-long character-count title, easily running 250+ characters — not enough time to actually read it. Now scales duration by message length (MESSAGE_MIN_DISPLAY_MS/MESSAGE_MS_PER_CHARACTER/MESSAGE_MAX_DISPLAY_MS), floored at the original 4000ms so ordinary short messages are unaffected. Verified in a real browser: a short message still clears at ~4000ms, and the length-to-duration arithmetic was independently checked against realistic message lengths (244–283 characters → 12.2–14.15s).public/modules/settings.js’s createSettings — the four persisted toggles’ isX/setX/syncXToUi functions, the Free Chain session override, and the reduced-motion system-media-query fallback) is a DOM/localStorage-heavy factory module extracted from app.js, same category as the arcade/replay modules below — no automated coverage, manually verified via headless Chrome: toggling each Settings checkbox and confirming the matching localStorage key, re-opening Settings after a toggle to confirm all four checkboxes reflect persisted state (syncAllToUi), and a fresh page load in the same browser profile confirming all three persisted preferences (reduced motion’s body class included) survive a full reload.public/modules/modalManager.js’s createModalManager — open/close/focus for Help, Yesterday’s Puzzle, Settings, and Set Board, plus the shared getActiveModal/closeActiveModalIfAny/trapFocusInModal helpers) is another DOM-only factory module extracted from app.js — no automated coverage, manually verified via headless Chrome: the first-visit Help modal auto-opening and closing via its own button; the Settings → Set Board handoff (Settings closes, Set Board opens, focus lands on the first board input, confirming prepareBoardModal’s callback into app.js still runs); Escape closing whichever modal is actually open; and click-outside-the-modal closing Yesterday’s Puzzle.public/modules/puzzleProgress.js’s getSavedProgress/saveProgress/clearProgress, plus the app.js-level wiring — saveCatalogProgressIfApplicable, restoreCatalogProgress, resetCurrentPuzzle, the suppressProgressSave ordering guard — see docs/development.md) is localStorage-backed and app.js-only, manually verified via headless Chrome against the real, live daily-puzzle catalog. Verified end to end, in one continuous session against the same puzzle: typing a real word (VENTILATION) then navigating away and back restored it with “Welcome back — picked up right where you left off.”; completing the puzzle (NECROMANCY) then navigating away and back showed “Welcome back — this puzzle is already solved.” with both words present; the Reset button produced a blank board that stayed blank on the next visit (no restore); a fresh partial solve manually undone all the way back to blank behaved identically, confirming the saved record clears itself with no special-casing; a brand-new tab against the same browser profile (simulating closing and reopening the browser, not just clicking an arrow) restored an in-progress puzzle at boot; and a real shared-link hash (#p=...) loaded its custom board exactly as before, completely unaffected. Two real bugs were caught and fixed by this verification, not assumed away: a save/restore race (see suppressProgressSave) and a double-typed auto-seed letter on restore (see docs/development.md’s “Local puzzle progress” section) — the second of which turned out to be a pre-existing latent bug in arcadeMode.js’s own restoreSavedGame, fixed identically in both places once found.settings.isFreeChainModeEnabled/setFreeChainPreference/setFreeChainSessionOverride/clearFreeChainSessionOverride, and the findChainBreaks(progressWords).length > 0 auto-detect in hydrateSharedPuzzle, now in public/modules/puzzleReplay.js) is manually verified, not automated. The engine-level behavior it drives (setFreeChainMode, getRequiredStartingLetter) is fully covered — see above.public/modules/arcadeMode.js’s createArcadeMode — startArcadeMode/stopArcadeMode, the idle-warning and idle-restart timer pair, captureGameForLaterRestore/restoreSavedGame, and the &idleWarnSec=/&idleResetSec= URL overrides; public/modules/puzzleReplay.js’s createPuzzleReplay — replayProgressWords/playSolvedReplay/hydrateSharedPuzzle) are DOM/timer-heavy factory modules extracted from app.js, same category as boardRenderer.js/pipeEasterEgg.js above — no automated coverage, manually verified via headless-Chrome runs with the relevant timing constants temporarily shortened. See docs/development.md for the full behavior these drive.src/psaFeed.js) and client-side selection (public/modules/psaBanner.js) are also uncovered by the automated suite — verified manually against the real, live ICRC and WHO feeds via wrangler dev (confirming the server route returns correctly-parsed items) and headless Chrome (confirming render, dismiss, and seen-tracking). Both feed formats (Atom for ICRC, RSS 2.0 for WHO) were verified against genuine, current feed content, not fixtures. Hidden/experimental — see docs/development.md — so this also covers confirming #psaBanner=1 gates visibility correctly and a normal visit shows nothing.public/modules/campaignCard.js) is likewise app.js/client-only and manually verified — confirmed via headless Chrome (including a real screenshot) that it renders real content, sits in the lower-left of the pipe-artwork panel without colliding with or being wiped out by the pipe-bearing easter egg’s own DOM updates, and that the × dismiss behaves correctly (session-only; it has no Settings toggle by design).shareResult, describeShareTeaser, isBoardFullySolved, getActivePuzzleDateLabel, the shareIncludeLinkToggle preference, and the teaser-toast hookup in tryLoadSharedPuzzleFromHash) is app.js-only and manually verified via headless Chrome, not automated — see docs/development.md. Verified: the error message before solving; the exact masked, chain-linked clipboard text after a real solve (driven through a progress share-link so the words are genuinely accepted by the engine, not a mock); titles never leaking by name in either the copied text or the recipient’s teaser (Bonus +N only); the Free Chain badge appearing in both the copied text and the recipient’s teaser for a solve genuinely replayed under Free Chain mode (driven via a real progress link with the letter-punk.free-chain preference set beforehand, so the engine actually starts in that mode rather than being faked); the relocated Set Board/Copy Blank Link/Copy Progress Link controls resolving to unique element IDs after the old #shareModal wrapper (and the duplicate copyProgressLinkBtn ID it had been carrying) was removed, then a second relocation moving Copy Blank/Progress Link out of Settings and into the Set Board modal itself (confirmed via document.querySelector('#boardModal #copyBlankLinkBtn') etc. and a screenshot); and — at the time, since this is now the backward-compatibility path rather than something the current UI generates, see the blank-link bullet below — a masked share link loading a blank board with the one-time “beat their score” toast in place of the generic load message.buildBlankPuzzleShareUrl in app.js) when the include-link toggle is on, replacing the earlier behavior where Share attached a masked resultSummary+teaser link and Reveal Solution attached a real progress link that replayed the sender’s words on open. Verified with the toggle enabled beforehand (letter-punk.share-include-link set to 'on' in localStorage) on a real solve: both buttons produce byte-for-byte identical hashes with an empty progress segment and no resultSummary segment; opening the resulting link shows the plain “Loaded a shared puzzle. Route away.” message (not the completed-puzzle or teaser message) with Share correctly still hidden, confirming the board opens genuinely blank rather than solved or replayed. describeShareTeaser/the resultSummary-decode path are deliberately left in place, unexercised by current UI actions, purely so a link shared before this change still resolves correctly for whoever received it — see docs/development.md for the removal criteria if that stops being a concern.2026-07-15 catalog entry (REMEDIATION/NIBBLING, real dictionary words, no override needed) with the include-link toggle on, and confirmed Share’s copied text ended in exactly http://localhost:8787/ with no #p=... fragment at all. puzzleFetcher.isActiveCatalogPuzzleToday() (exposed on the fetcher’s public API for this; it already existed internally, used by getPreviousSolutionUiLabels()) drives the check — see docs/development.md for why this is deliberately stricter than getNavigationState().todayDisabled.?date=YYYYMMDD link. puzzleFetcher.js’s own playPuzzleByDate/getActiveCatalogDateParam are unit-tested (see above); the app.js-level link-generation and boot-time wiring around them (see docs/development.md for the design reasoning) is still app.js-only and manually verified via headless Chrome. Verified: navigated to the real 2026-07-14 catalog entry via Previous, solved it (RELEGATION/NECKTIES, real dictionary words), and confirmed Share’s link was exactly http://localhost:8787/?date=20260714; opened that link fresh in a new session and confirmed the correct board (ETOGINRACLKS) loaded with the status “Archive Puzzle - 2026-07-14” and the message “Loaded the puzzle for July 14”; then, critically, clicked Next from there and confirmed it advanced to “Daily Puzzle” (today’s, 2026-07-15) rather than being stuck on a disconnected custom board — the actual point of this scheme, not just a shorter URL. Also verified an out-of-catalog-range date (?date=19991231) falls back to today’s puzzle with an explanatory error message rather than stranding the recipient on the default random board, and re-confirmed the existing custom-board scenario is unaffected (still produces the full encoded #p=... link).revealSolution, renderShareActionsVisibility) is likewise app.js-only and manually verified, not automated — see docs/development.md. Verified via a real solve (board generated from known words via Set Board, then actually typed and submitted through real KeyboardEvents so the engine’s own auto-reseed behavior between words was exercised for real, not bypassed): the button stays hidden before solving and appears immediately after; clicking it copies the correct unmasked, chain-linked, real-word clipboard text with the earned title shown by real name and exact canonical detail (e.g. “Dead Reckoner (matches the canonical 14)”) plus a trailing Canonical: ... line listing the actual reference words, both sourced from the real getActiveCanonicalWords() for a board generated from known solution words; and — the specific risk this design was built to rule out — clicking the primary Share button afterward still produces the ordinary masked (block, Bonus +N) text, confirming the two actions share no state that could leak one into the other. Also confirmed, same session: for a real 4-letter word (ADGJ), the masked row (🟩🟦🟦🟦🟦🔗) and unmasked row (🟩ADGJ🔗) now have matching structure — 4 letter-representations plus 2 anchors in both — rather than the masked row’s anchors consuming a letter slot the way an earlier version did.renderShareActionsVisibility, replacing the earlier renderRevealSolutionVisibility that only covered Reveal Solution) is likewise manually verified: the toolbar no longer contains #shareBtn (confirmed via document.querySelector('.board-tool-buttons #shareBtn') returning nothing) while #shareBtn resolves inside the Accepted Words panel-card instead; both Share and Reveal Solution start hidden, appear together the instant the board is solved, and correctly disappear again when the board leaves the solved state — verified both via repeated Undo Word presses (confirming usedLetters, recomputed fresh from state.foundWords on every undo rather than tracked as a grow-only tally, correctly drives visibility back to hidden mid-experimentation) and via reapplying the same board through Set Board (a full resetGameForBoard), then reappear with fresh content once the board is solved again. The two buttons’ status feedback also correctly shares one message element (shareStatusMessage) rather than each having its own.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.