User directive: 'update the plan. v14 only'. Implementation: **scope change (Foundry v14 only):** - registered-hooks.js: renderChatInput entry drops the v13 renderChatLog name. Single subscription to the v14 name. - anti-corruption.js: combatRound arg-normalization no longer detects the v13 round-num position. v14's updateOptions.round is the only path. Removed the v13 comments from the other arg shapes (combatEnd, combatTurn). - module.json: compatibility.minimum is now 14 (was 13). verified stays 14. - package.json: version 0.3.0 -> 0.4.0 (semver-breaking: dropping v13 support is a breaking change for v13 consumers). - package.json description: 'Foundry VTT v14-only module' prefix. **test plan:** - tests/PLAN.md: v14-only scope documented at the top of the file and in Section E. Status line bumps 554 to 546 assertions (v13-only assertions dropped). Test files table re-scoped to v0.4.0. - tests/verify-hooks-lib.mjs: dropped the v13-only assertions (E.2 'renderChatLog in installed', E.3's 'both v13 and v14 produce' check, E.4's v13 round shape). Kept the v14-only assertions + added an inverse assertion: 'renderChatLog is NOT in installed raw hooks' to lock the v14-only scope. - tests/verify-hooks-lib.mjs: stub table at line ~520 drops renderChatLog (dead in production now). **doc updates:** - README.md: new 'v0.4.0 — Foundry v14 only' section explaining the change + migration note for v13 consumers. - docs/HOOK_CONTRACT.md: v0.3.0 header. §9 marks the v13 column as historical. §10 example uses the v14 shape only. **artifact:** - foundry-hooks-lib-0.4.0.zip built (82KB, 46 entries, inner version 0.4.0, inner compatibility.minimum 14). **verified:** - npm test: 546/546 assertions passed - npm run test:foundry: 30/30 assertions passed - npm run test:perf: 6/6 assertions passed (median 0.0003ms/fire) - battle-focus E2E (consumer): 125/125 still green - its-achievable smoke (consumer): 75/75 still green **consumer follow-up (separate commits in their own repos):** - battle-focus/module.json: relationships.requires[0].version bumped to ^0.4.0 - its-achievable/module.json: relationships.requires[0].minimum bumped to 0.4.0
12 KiB
hooks-lib test plan — v0.4.0
Scope: Foundry v14 only. v0.3.0 does not test v13 compatibility; the anti-corruption layer is reduced to v14-only normalization (no dual v13/v14 hook subscription). v0.2.0 supported both; v0.3.0 narrows to v14 because the only consumer (battle-focus) is v14.
Status: Implemented. Smoke (546/546) + Playwright (30/30) + perf (6/6) all green as of 2026-06-20. The § Definition of done release gate is met.
The smoke-test assertion count went from 554 to 546 when v0.4.0 dropped
the v13-only renderChatLog and v13-round-num assertions (8 fewer
v13-specific assertions). v0.3.0 was the rename + Playwright test;
v0.4.0 narrows to v14 only.
Drives: tests/verify-hooks-lib.mjs (no-Foundry smoke),
tests/verify-hooks-lib-foundry.mjs (Playwright against a live
Foundry v14 instance), and tests/perf.mjs (perf budget).
The v0.1.0 test file (tests/verify-hooks-lib.mjs, 20 assertions) is
out of scope for v0.3.0 — its assertions cover the curated-event
catalog shape that v0.3.0 replaces. v0.3.0 ships a fresh test file;
v0.1.0's stays in git history for reference but is gitignored from
the test runner.
What we test (must pass for "done")
Section A — Envelope shape
Every registered hook in HOOK_CONTRACT.md §6 produces an envelope with exactly the fields from §2:
{ ts: number, hook: string, args: array }
No extras, no missing. The test enumerates the registered hook set and fires each one with a stubbed Foundry, asserting the envelope shape per hook.
Assertions per registered hook:
envelope.tsis a number,>= 0, taken within the last secondenvelope.hookequals the registered hook name (or the normalized name in §9 for hooks with anti-corruption mappings)envelope.argsis an array with the arity documented in HOOK_ARG_SHAPES.md (or arity 0 for hook families with no args)
Why this matters: if a hook ever emits an extra field, downstream consumers (system adapters, audit logs, replay tools) might depend on it. Locking the shape to §2 keeps the contract honest.
Section B — Subscriber API
The four subscribe primitives from §3 work as documented.
subscribe(hookName, fn):
- Returns an unsubscribe function.
- Unsubscribed fn no longer fires.
- Multiple subscribes for the same hook stack in registration order.
- Subscribing with an unregistered hook name throws
TypeErrorsynchronously (typo protection).
subscribeMany({...}):
- Atomic: if any name is unregistered, the whole batch throws and no handlers are registered.
- Successful batch registers all handlers; the returned unsubscribe removes them all.
subscribeAll(fn):
- One handler receives every fire from every registered hook.
- Unsubscribed fn no longer fires.
unsubscribeAll():
- Removes every callback registered through any primitive.
Section C — Error containment
A throwing consumer callback does not break the dispatch chain.
Assertions:
- Register two callbacks; first throws, second runs.
- The error is logged via
console.errorwith the prefix[foundry-hooks-lib]and the hook name. - The library does NOT re-throw the error to Foundry's hook chain.
- 100 fires with a single throwing callback complete with the same total fires-to-other-callbacks count as 100 fires without.
Section D — Lifecycle
init registration:
- After the library's
initruns,listRegisteredHooks()returns every hook in §6. - No
game.systemreads during init (the test verifies the library doesn't crash whengame.systemis undefined during init).
ready adapter evaluation:
- With
game.system.id === "dnd5e"andgame.system.version === "5.2.5", a registered dnd5e adapter whosesystem.versionscovers 5.2.5 is loaded. - With
game.system.id === "dnd5e"and version outside the adapter's range, the adapter is NOT loaded; a warning is logged naming the adapter and the version mismatch. - With
game.system.id === "pf2e"and the adapter declaringsystem.id === "dnd5e", the adapter is NOT loaded; no warning (silent skip for non-matching system). - Throwing adapter factory is contained: the failure is logged, the library continues loading other adapters.
unregisterModule cleanup:
- After
unregisterModule("foundry-hooks-lib"),listRegisteredHooks()returns[]. - All consumer callbacks are removed (verified by re-registering init + asserting the registered hook set is empty).
- Foundry's
Hooks.offis called for every library-registered listener (verified by asserting that subsequent Foundry fires do NOT produce envelopes).
World change re-evaluation:
- Firing
readytwice on the same world produces the same active adapter set (idempotent). - Firing
readyagain with a differentgame.system.idre-runs the adapter match; non-matching adapters unload, matching ones load.
Section E — Anti-corruption
Hook rename mapping (§9): the library subscribes to the v14 hook
names and emits a stable envelope.hook name. Example mappings (v0.3.0):
- A Foundry v14 fire of
renderChatInputproduces an envelope withhook === "renderChatInput". - A Foundry v14
updateCombatwithactive: falseproduces an envelope withhook === "combatEnd"(normalized from the raw updateCombat with active→inactive transition). - A Foundry v14
dnd5e.rollAttackV2producesenvelope.hook === "dnd5e.rollAttackV2". (v1dnd5e.rollAttackis not supported in v0.3.0; v13 in general is out of scope per the file header.)
Arg normalization (§10):
- For every hook in §6 with documented arg-shape normalization,
firing with raw Foundry v14 args produces an
argsarray matching the documented shape. - The exact normalization shapes live in
docs/HOOK_ARG_SHAPES.md. v0.3.0 ships shapes for:combatStart,combatEnd,combatTurn,combatRound,preUpdateActor,updateActor,preUpdateToken,updateToken,dnd5e.rollAttackV2,dnd5e.rollDamageV2. The remaining hooks either have stable arity or no normalization.
Section F — Performance budget
Measured by tests/perf.mjs:
- Per-fire overhead: median <0.1ms per fire over 10k fires. Median because some fires do more work (e.g. consumer with many subscribers) — median captures the typical case.
- Memory: zero per-fire allocation beyond the envelope (verified by heap snapshot before/after a 10k-fire loop; heap delta < 1MB).
- Async dispatch: for async-dispatched hooks, the wrapper returns before consumer callbacks run (verified by registering a consumer that records its invocation time and asserting the wrapper's return time precedes it).
Section G — System adapter loading (deeper)
Beyond Section D's basic cases:
- An adapter registered with no
factoryis invalid;registerSystemAdapterthrows synchronously. - An adapter whose
factoryreturns a non-array is invalid; the library logs and marks the adapter failed. - A derived-event registered by a failed adapter does NOT fire (no partial registration on failure).
- Multiple adapters with the same
idare deduplicated; the second registration is a no-op with a warning. - The library does not call
factoryfor an adapter whose system doesn't match, even if called multiple times.
What we don't test (explicitly out of scope)
- Consumer-side domain logic. "When bob takes damage" is the consumer's test plan, not hooks-lib's. The library's tests assert that the envelope shape and subscriber API work; they do not assert that any specific consumer's interpretation is correct.
- Foundry's hook correctness. We trust Foundry fires the right args. If Foundry has a bug, we patch around it in the library, but we don't test Foundry.
- System-specific derived events. That's the adapter's test plan. hooks-lib's tests verify the adapter protocol (registration, matching, lifecycle) but not the adapter's content.
- Foundry v13 compatibility. v0.3.0 is Foundry v14 only. The
anti-corruption layer in v0.2.0 absorbed both v13 and v14 hook
renames; v0.3.0 narrows to v14. The dual-subscription
renderChatInput+renderChatLoglistener is now justrenderChatInput. Older versions of Foundry are "best effort" — not a CI gate, not in the test plan. - Real Foundry runtime for the smoke test. The no-Foundry smoke
test stubs
Hooks,game,ui. It runs in <2s. Real-Foundry verification is the Playwright test (§ Playwright run below).
Definition of done
A v0.3.0 release is "done" when:
- 100% of the "What we test" bullets have an assertion. No silent gaps. If we can't test something (e.g. a Foundry-version matrix we don't have installed), we either skip that bullet with an explicit comment in the test file OR we leave the bullet out of this plan.
npm testexits 0 in the no-Foundry smoke runner. Runs in <2s.npm run test:foundryexits 0 against a live Foundry v14 instance with a representative world (the battle-focus v0.6.xhello-world-demoworld works). Runs in <30s. v13 is not supported (see file header).npm run lintexits 0 (if we adopt a linter; otherwise skip).- Both pass on the Hermes shell (Windows, git-bash, Node 18+). CI portability is a stretch goal, not a gate.
- Performance budget holds (§ F). Median per-fire overhead measured in CI; if it regresses >0.1ms, the release is blocked until the regression is explained.
Test files (v0.4.0)
| File | Purpose |
|---|---|
tests/verify-hooks-lib.mjs |
No-Foundry smoke. Sections A–F (F's perf-measurement subset that doesn't require Foundry). Runs in <2s. v0.4.0 is v14-only — v13-specific assertions are dropped. |
tests/verify-hooks-lib-foundry.mjs |
Playwright against a live Foundry v14. Loads the library as a Foundry module, asserts mod.api.isReady() === true, fires synthetic updateActor + dnd5e.rollAttackV2 + creates a real combat, asserts envelopes flow through. 30 assertions. Runs in <15s. |
tests/perf.mjs |
The performance budget measurement. Runs in <5s. Called from the smoke runner or standalone. |
tests/test-helpers.mjs |
Shared stubs and Foundry helpers. The no-Foundry smoke stubs Hooks, game, ui. |
The smoke test is the gate for PRs. The Foundry test is the gate for releases. Performance test runs weekly or on demand.
Future turns (when this repo is no longer the focus)
- The v0.1.0
tests/verify-hooks-lib.mjsandtests/test-helpers.mjswill be archived (git mv intotests/archive/v0.1.0/) once v0.3.0's test files are stable. They stay in git history but not in the test runner. - When battle-focus migrates to hooks-lib v0.3.0, the
battle-focus test plan (separate
tests/PLAN.mdin that repo) will reference hooks-lib's plan for the contract assertions and add battle-focus-specific consumer assertions. - When its-achievable ships, its
tests/PLAN.mdwill reference hooks-lib's adapter protocol and add adapter-specific assertions.
Open questions for this plan
These are settled when v0.3.0 implementation starts, not before:
- Stub fidelity for the no-Foundry smoke test. How close does
the stub need to match Foundry's actual
Hooks.onsemantics (e.g.callAllreturn value, error propagation)? Pragmatic answer: match the contract §3.5's error containment, match the arity pass-through, and don't sweat the rest. - Performance measurement methodology. §F says "median <0.1ms
over 10k fires." In CI, that's
node --experimental-vm-moduleswith a synthetic hook loop. On real hardware. No reproducible benchmark — the budget is a soft gate. - How to test
unregisterModule. Foundry's unregister hook fires when a module is disabled via the UI. In the smoke test, we call the library's unregister function directly. In the Foundry test, we trigger it viagame.modules.get(id).active = false. Both paths are tested.
Push back on any of these or on the plan as a whole before the v0.3.0 implementation starts.