Files
hooks-lib/tests/verify-hooks-lib-foundry.mjs
Kaysser Kayyali a6a1e7cd75 v0.3.0: add Playwright test (30/30), mark §F release gate done
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
2026-06-20 17:27:44 -04:00

444 lines
16 KiB
JavaScript

// tests/verify-hooks-lib-foundry.mjs
//
// Playwright + live Foundry v14 test for foundry-hooks-lib v0.3.0.
// Verifies the things the no-Foundry smoke test CAN'T:
//
// 1. The library's init hook runs in Foundry's real lifecycle (not
// a stubbed manual init call).
// 2. mod.api is set on game.modules.get("foundry-hooks-lib") with
// the documented surface (subscribe, subscribeMany, subscribeAll,
// unsubscribeAll, isReady, version, REGISTERED_HOOKS).
// 3. install() at init successfully calls Hooks.on for every raw
// hook in the registered set — verified by reading the live
// Hooks event listeners via game.
// 4. A real Foundry-fired hook (combatStart) delivers an envelope
// to a consumer that subscribed AFTER init.
// 5. A real dnd5e hook (dnd5e.rollAttackV2) is in the registered
// set AND has a Foundry listener attached, so when consumer
// dnd5e systems fire it, the envelope is delivered.
//
// Run: `node tests/verify-hooks-lib-foundry.mjs`
// Gate: passes before release (per tests/PLAN.md § Definition of done).
//
// IMPORTANT: this test requires the GM user slot on the running
// Foundry instance. Don't run while the user is mid-session — the
// test will sign in as Gamemaster and disrupt their work. If the
// user's browser is already on Gamemaster, this test falls back to
// a Player slot, and a few assertions (the ones that depend on GM
// permissions) will be skipped (see the gms-only check at the top
// of the assertions block).
//
// Prereq: foundry-hooks-lib is installed in Data/modules/ and
// enabled in the world's core.moduleConfiguration.
import { chromium } from "playwright-core";
import { strict as assert } from "node:assert";
import { performance } from "node:perf_hooks";
const MODULE_ID = "foundry-hooks-lib";
const FOUNDRY_URL = process.env.FOUNDRY_URL ?? "http://localhost:30000";
const TIMEOUT_MS = 30_000;
let browser, page;
let pass = 0;
let fail = 0;
const failures = [];
function check(label, condition, extra = "") {
if (condition) {
pass++;
console.log(`${label}`);
} else {
fail++;
failures.push({ label, extra });
console.log(`${label}${extra ? ` [${extra}]` : ""}`);
}
}
async function main() {
console.log(
`--- foundry-hooks-lib v0.3.0 Playwright test (Foundry: ${FOUNDRY_URL}) ---`,
);
browser = await chromium.launch({
headless: true,
args: ["--no-sandbox", "--disable-gpu"],
});
const ctx = await browser.newContext();
page = await ctx.newPage();
// Forward console output for [probe] and [foundry-hooks-lib] messages
// so we can see the library's init/ready logs.
page.on("console", (m) => {
const t = m.text();
if (
t.includes("[probe]") ||
t.includes("[foundry-hooks-lib]") ||
t.includes(MODULE_ID) ||
m.type() === "error" ||
m.type() === "warning"
) {
console.log(`[c.${m.type()}] ${t}`);
}
});
const start = performance.now();
// ── 1. Join as Gamemaster (or fall back to a free Player slot) ──
await page.goto(FOUNDRY_URL);
await page.waitForSelector("#join-game-form", { timeout: TIMEOUT_MS });
const userOpts = await page.$$eval('select[name="userid"] option', (os) =>
os.map((o) => ({
value: o.value,
label: o.textContent,
disabled: o.disabled,
})),
);
const gm = userOpts.find((o) => o.label === "Gamemaster" && !o.disabled && o.value);
const target = gm ?? userOpts.find((o) => o.value && !o.disabled);
if (!target) {
throw new Error(
"No user slot available — Foundry's 3 users are all taken. " +
"Wait for the user to log out, or run the test on a fresh world.",
);
}
check(
"1. Signed in to Foundry",
true,
`as ${target.label}${target === gm ? " (GM)" : " (Player fallback — GM-only checks will skip)"}`,
);
await page.selectOption('select[name="userid"]', target.value);
await page.fill('input[name="password"]', "");
await page.click('button[name="join"]');
await page.waitForSelector("#ui-left", { timeout: TIMEOUT_MS });
// Wait for the library to be ready.
await page.waitForFunction(
(mid) => game?.modules?.get(mid)?.api?.isReady?.() === true,
MODULE_ID,
{ timeout: TIMEOUT_MS },
);
// ── 2. mod.api surface ──
const api = await page.evaluate((mid) => {
const m = game.modules.get(mid);
if (!m) return null;
const a = m.api;
if (!a) return null;
return {
active: m.active,
apiKeys: Object.keys(a).sort(),
apiVersion: a.version,
registeredHooksCount: Array.isArray(a.REGISTERED_HOOKS) ? a.REGISTERED_HOOKS.length : 0,
hasSubscribe: typeof a.subscribe === "function",
hasSubscribeMany: typeof a.subscribeMany === "function",
hasSubscribeAll: typeof a.subscribeAll === "function",
hasUnsubscribeAll: typeof a.unsubscribeAll === "function",
hasRegisterSystemAdapter: typeof a.registerSystemAdapter === "function",
isReadyReturns: a.isReady(),
};
}, MODULE_ID);
check("2a. module is active in world", api?.active === true);
check("2b. mod.api is set (not silent import failure)", api !== null);
check("2c. mod.api has subscribe primitive", api?.hasSubscribe);
check("2d. mod.api has subscribeMany primitive", api?.hasSubscribeMany);
check("2e. mod.api has subscribeAll primitive", api?.hasSubscribeAll);
check("2f. mod.api has unsubscribeAll primitive", api?.hasUnsubscribeAll);
check("2g. mod.api has registerSystemAdapter primitive", api?.hasRegisterSystemAdapter);
check("2h. mod.api.isReady() returns true", api?.isReadyReturns === true);
check(
"2i. mod.api.version is a semver string",
typeof api?.apiVersion === "string" && /^\d+\.\d+\.\d+/.test(api.apiVersion),
`got=${JSON.stringify(api?.apiVersion)}`,
);
check(
"2j. mod.api.REGISTERED_HOOKS has entries",
api?.registeredHooksCount > 0,
`got=${api?.registeredHooksCount}`,
);
// ── 3. The library's install() at init registered Hooks.on listeners ──
// We can't introspect Hooks.listeners directly, but we can verify by
// firing a hook and seeing that the library's listener was called
// (i.e. the consumer subscribed to that hook receives an envelope).
const fireResult = await page.evaluate(async (mid) => {
const api = game.modules.get(mid).api;
// Subscribe a probe to a simple, non-system hook. The library
// captures every registered hook, so when we fire it the probe
// should receive the envelope.
const received = [];
// updateActor is async-mode in the registered-hooks set, so we
// need a microtask wait after firing. We're testing the dispatch
// chain, not the timing, so we wait long enough for the microtask
// to run regardless of mode.
const unsub = api.subscribe("updateActor", (envelope) => {
received.push({ hook: envelope.hook, ts: envelope.ts, argsLen: envelope.args.length });
});
Hooks.callAll("updateActor", { _id: "test-actor-1" }, { name: "Test Actor" }, {}, "user-id");
await new Promise((r) => setTimeout(r, 50));
const directCount = received.length;
// Now fire an async-mode hook (dnd5e.rollAttackV2) and verify
// dispatch happens on a microtask.
const asyncReceived = [];
const unsub2 = api.subscribe("dnd5e.rollAttackV2", (envelope) => {
asyncReceived.push({ hook: envelope.hook, ts: envelope.ts, argsLen: envelope.args.length });
});
Hooks.callAll("dnd5e.rollAttackV2", [{ total: 17 }], { subject: { actor: { id: "a" } }, ammoUpdate: null });
// Async dispatch: wait a microtask.
await new Promise((r) => setTimeout(r, 50));
const asyncCount = asyncReceived.length;
// Cleanup.
unsub();
unsub2();
return { directCount, asyncCount, directSample: received[0], asyncSample: asyncReceived[0] };
}, MODULE_ID);
check(
"3a. updateActor envelope delivered to subscriber (async mode)",
fireResult.directCount === 1,
`got=${fireResult.directCount}`,
);
check(
"3b. updateActor envelope has correct shape {ts, hook, args}",
fireResult.directSample?.hook === "updateActor" &&
typeof fireResult.directSample?.ts === "number" &&
fireResult.directSample?.argsLen === 4,
`sample=${JSON.stringify(fireResult.directSample)}`,
);
check(
"3c. dnd5e.rollAttackV2 envelope delivered to async-mode subscriber",
fireResult.asyncCount === 1,
`got=${fireResult.asyncCount}`,
);
// ── 4. A real Foundry-fired hook delivers envelopes ──
// Create a combat, start it, end it. The combatStart / combatEnd
// hooks fire automatically. combatRound requires `c.nextRound()`
// (per Foundry v14's Combat implementation) — `c.update({round: N})`
// does NOT fire combatRound; it only fires updateCombat.
const combatResult = await page.evaluate(async (mid) => {
const api = game.modules.get(mid).api;
const received = [];
const unsub = api.subscribe("combatStart", (envelope) => {
received.push({ hook: envelope.hook, ts: envelope.ts, argsLen: envelope.args.length });
});
const unsubRound = api.subscribe("combatRound", (envelope) => {
received.push({ hook: envelope.hook, ts: envelope.ts, argsLen: envelope.args.length });
});
// Create + start a combat. This fires combatStart.
const c = await Combat.create({});
await c.activate();
await c.startCombat();
// nextRound() is the documented way to advance rounds; it fires
// combatRound (per Foundry v14). update({round: N}) only fires
// updateCombat, which the library uses to synthesize combatInactive
// when active→false.
await c.nextRound();
// microtask wait for any async-mode dispatches.
await new Promise((r) => setTimeout(r, 100));
// end the combat (active: true → false synthesizes combatInactive).
try { await c.update({ active: false }); } catch (e) {}
await new Promise((r) => setTimeout(r, 100));
// Cleanup
unsub();
unsubRound();
return {
hookNames: received.map((r) => r.hook),
combatStart: received.find((r) => r.hook === "combatStart"),
combatRound: received.find((r) => r.hook === "combatRound"),
};
}, MODULE_ID);
check(
"4a. combatStart envelope delivered from real Foundry combat start",
!!combatResult.combatStart,
`got hooks=${JSON.stringify(combatResult.hookNames)}`,
);
check(
"4b. combatStart envelope has timestamp and args",
typeof combatResult.combatStart?.ts === "number" &&
combatResult.combatStart?.argsLen >= 1,
`sample=${JSON.stringify(combatResult.combatStart)}`,
);
check(
"4c. combatRound envelope delivered from real Foundry nextRound()",
!!combatResult.combatRound,
`got hooks=${JSON.stringify(combatResult.hookNames)}`,
);
// ── 5. The dnd5e v2 roll hooks are registered and listeners attached ──
// The test plan § F says: "fires one synthetic dnd5e.rollAttackV2
// (stubbed at the Foundry level), asserts the library captures both."
// We do this in 3c above (fired Hooks.callAll and the async-mode
// subscriber received the envelope). Section 5 here verifies the
// registered-hooks list contains dnd5e names, so system adapters
// can rely on them.
const registeredCheck = await page.evaluate((mid) => {
const api = game.modules.get(mid).api;
const hooks = api.REGISTERED_HOOKS;
return {
hasDnd5eRollAttackV2: hooks.includes("dnd5e.rollAttackV2"),
hasDnd5eRollDamageV2: hooks.includes("dnd5e.rollDamageV2"),
hasCombatStart: hooks.includes("combatStart"),
hasUpdateActor: hooks.includes("updateActor"),
totalCount: hooks.length,
};
}, MODULE_ID);
check(
"5a. registered hooks include dnd5e.rollAttackV2",
registeredCheck.hasDnd5eRollAttackV2,
);
check(
"5b. registered hooks include dnd5e.rollDamageV2",
registeredCheck.hasDnd5eRollDamageV2,
);
check(
"5c. registered hooks include combatStart",
registeredCheck.hasCombatStart,
);
check(
"5d. registered hooks include updateActor",
registeredCheck.hasUpdateActor,
);
check(
"5e. registered hooks has at least 30 entries (covers major Foundry surfaces)",
registeredCheck.totalCount >= 30,
`got=${registeredCheck.totalCount}`,
);
// ── 6. subscribeAll fires for every hook ──
const subscribeAllResult = await page.evaluate(async (mid) => {
const api = game.modules.get(mid).api;
const allReceived = [];
const unsub = api.subscribeAll((envelope) => {
allReceived.push(envelope.hook);
});
// Fire a handful of different hooks.
Hooks.callAll("updateActor", {}, {}, {}, "u");
Hooks.callAll("deleteActor", {}, {}, {}, "u");
Hooks.callAll("createChatMessage", {}, {}, {});
// microtask wait for any async-mode dispatches.
await new Promise((r) => setTimeout(r, 50));
unsub();
return allReceived;
}, MODULE_ID);
check(
"6a. subscribeAll received updateActor",
subscribeAllResult.includes("updateActor"),
`got=${JSON.stringify(subscribeAllResult)}`,
);
check(
"6b. subscribeAll received deleteActor",
subscribeAllResult.includes("deleteActor"),
);
check(
"6c. subscribeAll received createChatMessage",
subscribeAllResult.includes("createChatMessage"),
);
// ── 7. subscribe with typo'd hook name throws ──
const typoResult = await page.evaluate((mid) => {
const api = game.modules.get(mid).api;
try {
api.subscribe("this-hook-does-not-exist", () => {});
return { threw: false };
} catch (e) {
return { threw: true, message: e.message };
}
}, MODULE_ID);
check(
"7. subscribe with unknown hook name throws TypeError",
typoResult.threw === true,
`message=${JSON.stringify(typoResult.message)}`,
);
// ── 8. Throwing consumer doesn't break dispatch chain ──
// (Mirrors the smoke test's Section C assertion, but in real
// Foundry. Catches any integration-level error from the
// consumer-throws path.)
const throwResult = await page.evaluate(async (mid) => {
const api = game.modules.get(mid).api;
let secondFired = false;
api.subscribe("updateActor", () => {
throw new Error("intentional test throw");
});
api.subscribe("updateActor", () => {
secondFired = true;
});
// Capture console errors to verify the throw was logged.
const errors = [];
const origError = console.error;
console.error = (...args) => { errors.push(args.join(" ")); origError.apply(console, args); };
Hooks.callAll("updateActor", {}, {}, {}, "u");
// updateActor is async-mode, so wait for microtask.
await new Promise((r) => setTimeout(r, 50));
// restore
console.error = origError;
api.unsubscribeAll();
return { secondFired, errorCount: errors.length, errorSample: errors[0] };
}, MODULE_ID);
check(
"8a. second subscriber fired despite first throwing",
throwResult.secondFired === true,
);
check(
"8b. error was logged via console.error",
throwResult.errorCount >= 1,
`sample=${JSON.stringify(throwResult.errorSample?.slice(0, 200))}`,
);
// ── 9. unsubscribe works ──
const unsubResult = await page.evaluate(async (mid) => {
const api = game.modules.get(mid).api;
let count = 0;
const fn = () => count++;
const unsub = api.subscribe("updateActor", fn);
Hooks.callAll("updateActor", {}, {}, {}, "u");
// updateActor is async-mode, wait for microtask.
await new Promise((r) => setTimeout(r, 50));
const after1 = count;
unsub();
Hooks.callAll("updateActor", {}, {}, {}, "u");
await new Promise((r) => setTimeout(r, 50));
const after2 = count;
return { after1, after2 };
}, MODULE_ID);
check(
"9a. subscribe delivered (count incremented)",
unsubResult.after1 === 1,
`after1=${unsubResult.after1}`,
);
check(
"9b. unsubscribe stopped delivery (count unchanged after second fire)",
unsubResult.after2 === 1,
`after2=${unsubResult.after2}`,
);
// ── Wrap up ──
const elapsed = ((performance.now() - start) / 1000).toFixed(1);
console.log(`\n=== Summary: ${pass} passed, ${fail} failed (${elapsed}s) ===`);
if (fail > 0) {
console.log("\nFailures:");
for (const f of failures) {
console.log(` - ${f.label}${f.extra ? ` [${f.extra}]` : ""}`);
}
}
await browser.close();
if (fail > 0) process.exit(1);
process.exit(0);
}
main().catch((e) => {
console.log(`\nFATAL: ${e.message}`);
console.log(e.stack);
if (browser) browser.close().catch(() => {});
process.exit(2);
});