Implements tests/PLAN.md § Playwright run (Section F's release
gate). 30 assertions in tests/verify-hooks-lib-foundry.mjs,
runs in ~10s against a live Foundry v14 instance.
What the Playwright test verifies that the no-Foundry smoke
test CAN'T:
- The library's init hook runs in Foundry's real lifecycle
(not a stubbed manual init call).
- mod.api is set on game.modules.get('foundry-hooks-lib') with
the documented surface.
- install() at init calls Hooks.on for every raw hook in the
registered set.
- A real Foundry-fired hook (combatStart, combatRound) delivers
an envelope to a consumer that subscribed AFTER init.
- A synthetic Hooks.callAll fire delivers envelopes to
subscribers (sync-mode hooks via direct dispatch,
async-mode hooks via microtask).
- subscribeAll receives envelopes from every registered hook.
- subscribe with an unknown hook name throws TypeError.
- A throwing consumer does NOT break the dispatch chain; the
second subscriber still fires; the error is logged via
console.error with the [foundry-hooks-lib] prefix.
- subscribe + unsubscribe correctly gate delivery.
**Bumps package.json to 0.3.0** (was 0.2.0 — version lagged
behind module.json's 0.3.0 from the rename commit).
**Adds test:foundry and test:all npm scripts.**
**Marks tests/PLAN.md status as Implemented** (was Proposed).
The Definition of done gate (npm run test:foundry exits 0 in
<30s) is now met.
**Adds playwright-core as a devDependency** (1 package, ~no
runtime impact since this repo's module doesn't depend on it at
runtime — it's a test-only dep).
**Final tallies:**
- npm test: 554/554 in ~0.4s (no-Foundry smoke)
- npm run test:foundry: 30/30 in ~10s (Playwright)
- npm run test:perf: 6/6 in ~5s (median 0.0003ms/fire)
- npm run test:all: all of the above
11 KiB
hooks-lib test plan — v0.3.0
Status: Implemented. Smoke (554/554) + Playwright (30/30) + perf (6/6) all green as of 2026-06-20. The § Definition of done release gate is met.
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):
- A Foundry v13 fire of
renderChatLogproduces an envelope withhook === "renderChatInput". - A Foundry v14 fire of
renderChatInputproduces the same. - A Foundry v13
combatEnd(when fired) AND a Foundry v14updateCombatwithactive: falseboth produce envelopes withhook === "combatEnd"(normalized).
Arg normalization (§10):
- For every hook in §6 with documented arg-shape normalization,
firing with raw Foundry args produces an
argsarray matching the documented shape, regardless of Foundry version. - 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-version compatibility beyond the latest two releases. v0.3.0 tests Foundry v13.351 and v14.364. Older versions are "best effort" — the anti-corruption layer should handle them, but no CI gate.
- 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 (3 actors, Bard-vs-Goblin fixture reused from battle-focus's test helper). Runs in <30s.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.3.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. Replaces v0.1.0's verify-hooks-lib.mjs. |
tests/verify-hooks-lib-foundry.mjs |
Playwright against a live Foundry. Loads the library as a Foundry module, asserts mod.api.isReady() === true, fires one synthetic updateActor and one dnd5e.rollAttackV2 (stubbed at the Foundry level), asserts the library captures both. Runs in <30s. |
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. Updates the v0.1.0 helper to expose the new Foundry hook stubbing. |
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.