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
444 lines
16 KiB
JavaScript
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);
|
|
});
|