diff --git a/tests/verify-achievable-foundry.mjs b/tests/verify-achievable-foundry.mjs index 16086cf..4adee10 100644 --- a/tests/verify-achievable-foundry.mjs +++ b/tests/verify-achievable-foundry.mjs @@ -1,30 +1,31 @@ // tests/verify-achievable-foundry.mjs // -// Playwright + live Foundry v14 test for its-achievable v0.2.0. -// Verifies the wiring that the no-Foundry smoke test CAN'T: +// Playwright + live Foundry v14 test for its-achievable v0.3.0. +// Verifies the things the no-Foundry smoke test CAN'T: // // 1. its-achievable's main.js loads cleanly in a live Foundry v14. // 2. mod.api is set on game.modules.get("its-achievable") with the -// documented surface. -// 3. The HUD singleton is created. -// 4. wireHooks(hooksLib) is called at ready time, with the -// foundry-hooks-lib API. -// 5. The HUD's subscriptions to the foundry-hooks-lib envelope -// stream deliver real events when combat is started and -// attack/damage rolls are fired. -// 6. The HUD's getState() reflects the new event (dice streak -// updated, combatants list populated). +// documented surface, AND the HUD-related exports (getHud, +// openHud, closeHud, buildHudUpdatePayload) are removed (the +// HUD moved to combat-hud-hub in v0.3.0). +// 3. Soft-dep wiring: when combat-hud-hub is installed and active, +// its-achievable registers a "pinned-achievements" section on +// the hub at ready time. +// 4. The chatBubble listener pushes achievement entries into the +// hub's feed (the integration path: award → chatBubble → +// pushToHubFeed → hub.pushFeedEntry → chh feed). +// 5. When combat-hud-hub is NOT installed, ia falls back to +// popover-only mode without throwing. // // Run: `node tests/verify-achievable-foundry.mjs` -// Gate: passes before release (analogous to the hooks-lib -// Foundry-load test). // // Prereqs: -// - Foundry is running on FOUNDRY_URL (default localhost:30000). -// - its-achievable, foundry-hooks-lib, and battle-focus are all -// installed in Data/modules/ AND enabled in the world. -// - The world has at least one PC actor (Bard) and one NPC -// (Goblin) to seed combatants. +// - Foundry running on FOUNDRY_URL (default localhost:30000). +// - its-achievable, foundry-hooks-lib, and battle-focus installed +// in Data/modules/ AND enabled in the world. +// - combat-hud-hub ideally also installed (for the full hub +// integration assertions). The test skips those gracefully +// if it's missing. import { chromium } from "playwright-core"; import { strict as assert } from "node:assert"; @@ -33,6 +34,7 @@ import { performance } from "node:perf_hooks"; const MODULE_ID = "its-achievable"; const HOOKS_LIB_ID = "foundry-hooks-lib"; const BATTLE_FOCUS_ID = "battle-focus"; +const HUD_HUB_ID = "combat-hud-hub"; const FOUNDRY_URL = process.env.FOUNDRY_URL ?? "http://localhost:30000"; const TIMEOUT_MS = 30_000; @@ -54,7 +56,7 @@ function check(label, condition, extra = "") { async function main() { console.log( - `--- its-achievable v0.2.0 Playwright test (Foundry: ${FOUNDRY_URL}) ---`, + `--- its-achievable v0.3.0 Playwright test (Foundry: ${FOUNDRY_URL}) ---`, ); browser = await chromium.launch({ headless: true, @@ -63,23 +65,21 @@ async function main() { const ctx = await browser.newContext(); page = await ctx.newPage(); - // Forward console output for [probe], [its-achievable], and - // [battle-focus] messages so we can see the module init logs. page.on("console", (m) => { const t = m.text(); if ( t.includes("[probe]") || t.includes("[its-achievable]") || + t.includes("[combat-hud-hub]") || t.includes("[battle-focus]") || t.includes("[foundry-hooks-lib]") || - m.type() === "error" || - m.type() === "warning" + m.type() === "error" ) { console.log(`[c.${m.type()}] ${t}`); } }); - - const start = performance.now(); + const pageErrors = []; + page.on("pageerror", (e) => pageErrors.push(String(e))); // ── 1. Join as Gamemaster ── await page.goto(FOUNDRY_URL); @@ -95,37 +95,23 @@ async function main() { (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."); - } - check( - "1. Signed in to Foundry", - true, - `as ${target.label}${target === gm ? " (GM)" : " (Player fallback)"}`, - ); + if (!target) throw new Error("No user slot available."); + check("1. Signed in to Foundry", true, `as ${target.label}${target === gm ? " (GM)" : " (Player fallback)"}`); 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 all three modules to be ready. - await page.waitForFunction( - (mid) => game?.modules?.get(mid)?.api?.isReady?.() === true, - HOOKS_LIB_ID, - { timeout: TIMEOUT_MS }, - ); - await page.waitForFunction( - (mid) => game?.modules?.get(mid)?.api?.isReady?.() === true, - BATTLE_FOCUS_ID, - { timeout: TIMEOUT_MS }, - ); - await page.waitForFunction( - (mid) => game?.modules?.get(mid)?.api?.isReady?.() === true, - MODULE_ID, - { timeout: TIMEOUT_MS }, - ); + // Wait for ia + battle-focus to be ready. hooks-lib is also a hard prereq. + for (const mid of [HOOKS_LIB_ID, BATTLE_FOCUS_ID, MODULE_ID]) { + await page.waitForFunction( + (m) => game?.modules?.get(m)?.api?.isReady?.() === true, + mid, + { timeout: TIMEOUT_MS }, + ); + } - // ── 2. mod.api surface ── + // ── 2. mod.api surface — v0.3.0 shape ── const api = await page.evaluate((mid) => { const m = game.modules.get(mid); if (!m) return null; @@ -135,238 +121,172 @@ async function main() { active: m.active, apiKeys: Object.keys(a).sort(), apiVersion: a.version, + // Removed in v0.3.0: hasGetHud: typeof a.getHud === "function", - hasBuildHudUpdatePayload: typeof a.buildHudUpdatePayload === "function", hasOpenHud: typeof a.openHud === "function", hasCloseHud: typeof a.closeHud === "function", + hasBuildHudUpdatePayload: typeof a.buildHudUpdatePayload === "function", + // New in v0.3.0: + hasRegisterHubSection: typeof a.registerHubSection === "function", + hasPushToHubFeed: typeof a.pushToHubFeed === "function", + // Still present: + hasAwardAchievement: typeof a.awardAchievement === "function", + hasOpenCustomAchievementsApp: typeof a.openCustomAchievementsApp === "function", + hasEvaluateRulesForEvent: typeof a.evaluateRulesForEvent === "function", + hasRenderAchievementPopover: typeof a.renderAchievementPopover === "function", isReadyReturns: a.isReady(), }; }, MODULE_ID); check("2a. module is active in world", api?.active === true); check("2b. mod.api is set (no silent import failure)", api !== null); - check("2c. mod.api.version is 0.2.0", api?.apiVersion === "0.2.0", + check("2c. mod.api.version is 0.3.0", api?.apiVersion === "0.3.0", `got=${api?.apiVersion}`); - check("2d. mod.api.getHud is a function", api?.hasGetHud); - check("2e. mod.api.buildHudUpdatePayload is a function", api?.hasBuildHudUpdatePayload); - check("2f. mod.api.openHud is a function", api?.hasOpenHud); - check("2g. mod.api.closeHud is a function", api?.hasCloseHud); - check("2h. mod.api.isReady() returns true", api?.isReadyReturns === true); + check("2d. mod.api does NOT export getHud (HUD moved to combat-hud-hub)", + !api?.hasGetHud, `got=${api?.hasGetHud}`); + check("2e. mod.api does NOT export openHud", + !api?.hasOpenHud, `got=${api?.hasOpenHud}`); + check("2f. mod.api does NOT export closeHud", + !api?.hasCloseHud, `got=${api?.hasCloseHud}`); + check("2g. mod.api does NOT export buildHudUpdatePayload", + !api?.hasBuildHudUpdatePayload, `got=${api?.hasBuildHudUpdatePayload}`); + check("2h. mod.api exports registerHubSection (new in v0.3.0)", + api?.hasRegisterHubSection); + check("2i. mod.api exports pushToHubFeed (new in v0.3.0)", + api?.hasPushToHubFeed); + check("2j. mod.api still exports awardAchievement", api?.hasAwardAchievement); + check("2k. mod.api still exports openCustomAchievementsApp", + api?.hasOpenCustomAchievementsApp); + check("2l. mod.api still exports evaluateRulesForEvent", + api?.hasEvaluateRulesForEvent); + check("2m. mod.api still exports renderAchievementPopover", + api?.hasRenderAchievementPopover); + check("2n. mod.api.isReady() returns true", api?.isReadyReturns === true); - // ── 3. The HUD singleton is constructed ── - const hudState = await page.evaluate((mid) => { - const api = game.modules.get(mid).api; - const hud = api.getHud(); - if (!hud) return { ok: false }; - return { - ok: true, - hooksRegistered: hud._hooksRegistered === true, - isApp: typeof hud.open === "function" && typeof hud.close === "function", - initialState: hud.getState(), - }; - }, MODULE_ID); + // ── 3. Soft-dep presence + hub integration ── + const presence = await page.evaluate(() => ({ + hooksLib: !!game.modules.get("foundry-hooks-lib")?.active, + battleFocus: !!game.modules.get("battle-focus")?.active, + hudHub: !!game.modules.get("combat-hud-hub")?.active, + })); + console.log(` [info] presence: hooks-lib=${presence.hooksLib} bf=${presence.battleFocus} chh=${presence.hudHub}`); - check("3a. HUD singleton exists", hudState.ok); - check("3b. HUD is a Foundry ApplicationV2 (open + close methods)", - hudState.isApp); - check("3c. HUD has wired subscriptions (hooksRegistered === true)", - hudState.hooksRegistered, - `got=${hudState.hooksRegistered}`); - check("3d. HUD initial state has empty combatants list", - Array.isArray(hudState.initialState?.combatants) && - hudState.initialState.combatants.length === 0); - - // ── 4a. The HUD's openHud() path works (the auto-open the user - // noticed was missing). This is what the v0.2.0 auto-open - // on combatStart also exercises, but we test it directly - // so a regression in the open() call gets caught even - // before the combatStart envelope fires. ── - const openCheck = await page.evaluate(async (mid) => { - const api = game.modules.get(mid).api; - // Close first (in case a prior test left it open). - api.closeHud(); - await new Promise((r) => setTimeout(r, 100)); - const wasClosed = api.getHud().rendered === false; - // Now open via the API surface. open() returns a Promise that - // resolves after render completes. - await api.openHud(); - await new Promise((r) => setTimeout(r, 200)); - const hud = api.getHud(); - return { - wasClosed, - isRendered: hud.rendered === true, - hasElement: !!hud.element, - elementTag: hud.element?.tagName ?? null, - }; - }, MODULE_ID); - - check("4-pre. closeHud() then openHud() works (wasClosed=true, isRendered=true)", - openCheck.wasClosed && openCheck.isRendered, - `wasClosed=${openCheck.wasClosed} isRendered=${openCheck.isRendered} elementTag=${openCheck.elementTag}`, - ); - - // ── 4. The HUD receives events from the foundry-hooks-lib stream ── - // Run a small combat: pick any two actors in the world, place - // tokens for them on the canvas if needed, start combat, fire a - // synthetic attack roll via Hooks.callAll, verify the HUD got - // the event and updated its state. - const liveUpdate = await page.evaluate(async ({ iaMid, bfMid }) => { - const iaApi = game.modules.get(iaMid).api; - const bfApi = game.modules.get(bfMid).api; - const hud = iaApi.getHud(); - // Pick the first two actors in the world (any names work). - const actors = [...game.actors].slice(0, 2); - if (actors.length < 2) { - return { ok: false, reason: `only ${actors.length} actor(s) in world` }; - } - const [attacker, targetActor] = actors; - // Find or create a token document for each actor on the active - // scene. Tokens reference actors via actorId. - const scene = canvas.scene; - if (!scene) return { ok: false, reason: "no active scene" }; - async function ensureToken(actor) { - const existing = canvas.tokens.placeables.find( - (t) => t.document.actorId === actor.id, - ); - if (existing) return existing.document; - // Place a new token at a free coordinate. Use Foundry's - // document API: TokenDocument is created via actor.getTokenDocument - // (returns data), then we call canvas.scene.createEmbeddedDocuments - // to actually create it on the scene. - const docData = await actor.getTokenDocument({ x: 1000, y: 1000 }); - const created = await scene.createEmbeddedDocuments("Token", [docData]); - return created[0]; - } - const attackerTokDoc = await ensureToken(attacker); - const targetTokDoc = await ensureToken(targetActor); - // Create + start a combat with both as combatants. - const c = await Combat.create({}); - await c.createEmbeddedDocuments("Combatant", [ - { tokenId: attackerTokDoc.id, actorId: attacker.id, name: attacker.name }, - { tokenId: targetTokDoc.id, actorId: targetActor.id, name: targetActor.name }, - ]); - await c.activate(); - await c.startCombat(); - // microtask wait so the combatStart envelope is delivered - await new Promise((r) => setTimeout(r, 100)); - const stateAfterCombatStart = hud.getState(); - // Fire a synthetic attack roll. battle-focus's handleEvent picks - // it up via hooks-lib; its-achievable's HUD picks it up too. - const rolls = [ - { - total: 17, - formula: "1d20+5", - terms: [ - { faces: 20, constructor: { name: "Die" }, results: [{ result: 12 }] }, - ], - }, - ]; - // Find a weapon item on the attacker; if none, use null. - const weapon = attacker.items.find((i) => i.type === "weapon") - ?? attacker.items.find((i) => i.name?.toLowerCase().includes("sword")) - ?? attacker.items.first(); - Hooks.callAll( - "dnd5e.rollAttackV2", - rolls, - { - subject: { actor: attacker, item: weapon, target: targetTokDoc }, - ammoUpdate: null, - }, - ); - // dnd5e.rollAttackV2 is async-mode in hooks-lib; wait a microtask. - await new Promise((r) => setTimeout(r, 100)); - // Dice streak / last-dice state should reflect the d20. - const stateAfterAttack = hud.getState(); - // End combat. The encounter goes away. - try { await c.update({ active: false }); } catch (e) {} - await new Promise((r) => setTimeout(r, 100)); - // Cleanup the combat. - try { await c.delete(); } catch (e) {} - return { - ok: true, - actorNames: actors.map((a) => a.name), - afterCombatStart: { - combatants: stateAfterCombatStart?.combatants?.length ?? 0, - round: stateAfterCombatStart?.round ?? null, - isActive: stateAfterCombatStart?.isActive ?? null, - }, - afterAttack: { - diceStreak: stateAfterAttack?.diceStreak ?? null, - lastDiceValue: stateAfterAttack?.lastDiceValue ?? null, - combatants: stateAfterAttack?.combatants?.length ?? 0, - }, - }; - }, { iaMid: MODULE_ID, bfMid: BATTLE_FOCUS_ID }); - - // Note: 4a/4b check combatants after combatStart, but battle-focus - // creates the encounter on combatStart and adds combatants via - // createCombatant events. Foundry's createCombatant fires BEFORE - // combatStart (during c.createEmbeddedDocuments), and the - // encounter doesn't exist at that point — so the combatants - // map is empty at the moment combatStart fires. This is a - // pre-existing battle-focus ordering issue, not introduced by - // the v0.2.0 HUD wiring. The HUD wiring itself is correct: - // it receives the combatStart envelope and routes it through - // the translation layer. The followup dnd5e.rollAttackV2 - // (which fires AFTER combatStart) exercises the encounter - // path correctly. - check( - "4a. live combat-start envelope reaches the HUD", - liveUpdate.ok, - `actors=${JSON.stringify(liveUpdate.actorNames)} state=${JSON.stringify(liveUpdate.afterCombatStart)}`, - ); - check( - "4b. live combat-start envelope produces a HUD event", - liveUpdate.ok && liveUpdate.afterCombatStart !== null, - ); - check( - "4c. live dnd5e.rollAttackV2 envelope populates HUD combatants list (post-combatStart)", - liveUpdate.ok && liveUpdate.afterAttack?.combatants === 2, - `got combatants=${liveUpdate.afterAttack?.combatants}`, - ); - check( - "4d. live dnd5e.rollAttackV2 envelope updates the HUD's dice streak", - liveUpdate.ok && liveUpdate.afterAttack?.diceStreak === 1, - `got diceStreak=${liveUpdate.afterAttack?.diceStreak}, lastDice=${liveUpdate.afterAttack?.lastDiceValue}`, - ); - check( - "4e. live dnd5e.rollAttackV2 envelope records the d20 value", - liveUpdate.ok && liveUpdate.afterAttack?.lastDiceValue === 12, - `got=${liveUpdate.afterAttack?.lastDiceValue}`, - ); - - // ── 5. Foundry v14 chatMessage is no longer subscribed (v0.2.0 - // dropped the chatBubble handler? No — chatBubble is its own - // listener for the popover; verify it's still there.) ── - const chatBubbleCheck = await page.evaluate(() => { - // chatBubble is a Foundry v14 chat-rendering hook. its-achievable - // subscribes to it for the achievement popover near the chat - // input. We verify the registration is intact. - // Foundry doesn't expose listener introspection, so we just - // confirm the chatBubble hook is still fired by the game. - return typeof Hooks?.events?.chatBubble !== "undefined" - || Object.keys(Hooks?.events ?? {}).length > 0; - }, MODULE_ID); - check("5. Foundry Hooks.events map is populated", chatBubbleCheck); - - // ── 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}]` : ""}`); - } + // ── 4. Pinned-achievements section registered on the hub ── + let pinnedExists = false; + if (presence.hudHub) { + const pinned = await page.evaluate((chhMid) => { + const api = game.modules.get(chhMid)?.api; + if (!api) return { ok: false }; + const list = api.listSections(); + return { + ok: true, + ids: list.map(s => s.id), + }; + }, HUD_HUB_ID); + pinnedExists = pinned.ids?.includes("pinned-achievements"); + check("4a. pinned-achievements section registered on combat-hud-hub", + pinnedExists, `ids=${JSON.stringify(pinned?.ids)}`); + } else { + check("4z. combat-hud-hub not installed — section registration skipped", true); } + // ── 5. chatBubble → pushToHubFeed integration path ── + if (presence.hudHub) { + const chatBubbleTest = await page.evaluate(({ chhMid, iaMid }) => { + const chhApi = game.modules.get(chhMid).api; + const iaApi = game.modules.get(iaMid).api; + // Clear any existing feed entries from prior runs. + const beforeCount = (chhApi.getFeed?.("pinned-achievements") ?? []).length; + // Push a synthetic entry directly via pushToHubFeed (the + // chatBubble listener is hard to trigger in isolation). + iaApi.pushToHubFeed( + { id: "test-ach", name: "Test Achievement", icon: "🏆", description: "For testing" }, + "TestActor", + ); + const afterFeed = chhApi.getFeed?.("pinned-achievements") ?? []; + const lastEntry = afterFeed[afterFeed.length - 1]; + return { + beforeCount, + afterCount: afterFeed.length, + lastEntry, + }; + }, { chhMid: HUD_HUB_ID, iaMid: MODULE_ID }); + check("5a. pushToHubFeed adds an entry to the hub feed", + chatBubbleTest.afterCount > chatBubbleTest.beforeCount, + `before=${chatBubbleTest.beforeCount} after=${chatBubbleTest.afterCount}`); + check("5b. the pushed entry has the expected fields", + chatBubbleTest.lastEntry?.id === "test-ach" && + chatBubbleTest.lastEntry?.name === "Test Achievement" && + chatBubbleTest.lastEntry?.actorKey === "TestActor", + `entry=${JSON.stringify(chatBubbleTest.lastEntry)}`); + } else { + check("5z. combat-hud-hub not installed — chatBubble→feed path skipped", true); + } + + // ── 6. registerHubSection idempotency + graceful fallback ── + const regTest = await page.evaluate(({ iaMid, chhMid }) => { + const iaApi = game.modules.get(iaMid).api; + const chhApi = game.modules.get(chhMid)?.api; + if (!chhApi) return { result: "no-hub" }; + // First call should return true (hub present, section registered). + const first = iaApi.registerHubSection(); + const countAfterFirst = chhApi.listSections().filter(s => s.id === "pinned-achievements").length; + // Second call should also return true (idempotent: remove + re-add). + const second = iaApi.registerHubSection(); + const countAfterSecond = chhApi.listSections().filter(s => s.id === "pinned-achievements").length; + return { first, second, countAfterFirst, countAfterSecond }; + }, { iaMid: MODULE_ID, chhMid: HUD_HUB_ID }); + if (presence.hudHub) { + check("6a. registerHubSection() returns true when hub present", + regTest.first === true, `got=${regTest.first}`); + check("6b. registerHubSection() is idempotent (still one section after second call)", + regTest.countAfterSecond === 1, + `countAfterFirst=${regTest.countAfterFirst} countAfterSecond=${regTest.countAfterSecond}`); + } else { + check("6z. registerHubSection skipped (no hub)", true); + } + + // ── 7. awardAchievement still works (the core achievement API) ── + const awardTest = await page.evaluate(async (mid) => { + const api = game.modules.get(mid).api; + // Use the test actor; award first-blood idempotently. + const actorKey = "actor-test-ia"; + const beforeMap = api.getAchievementsByActor(); + const beforeHad = (beforeMap[actorKey] ?? []).some(a => a.id === "first-blood"); + const result = await api.awardAchievement(actorKey, "first-blood", "enc-test"); + const afterMap = api.getAchievementsByActor(); + const afterHas = (afterMap[actorKey] ?? []).some(a => a.id === "first-blood"); + // Idempotent: a second call should not duplicate. + await api.awardAchievement(actorKey, "first-blood", "enc-test"); + const finalCount = (api.getAchievementsByActor()[actorKey] ?? []).filter(a => a.id === "first-blood").length; + return { beforeHad, afterHas, finalCount, result }; + }, MODULE_ID); + check("7a. awardAchievement returns null (idempotent on re-award) or the new record", + awardTest.result === null || typeof awardTest.result === "object", + `got=${JSON.stringify(awardTest.result)}`); + check("7b. awardAchievement makes the achievement reachable via getAchievementsByActor", + awardTest.afterHas === true); + check("7c. awardAchievement is idempotent (re-award doesn't duplicate)", + awardTest.finalCount === 1, `finalCount=${awardTest.finalCount}`); + + // ── 8. No thrown errors during init/ready ── + const errorEvents = pageErrors.filter(e => !/Warning|deprecat/i.test(e)); + check("8a. no thrown errors during init/ready", + errorEvents.length === 0, + `got ${errorEvents.length} error(s): ${errorEvents.slice(0, 2).map(e => e.slice(0, 100)).join(" | ")}`); + + // ── Summary ── + console.log(`\n=== Summary: ${pass} passed, ${fail} failed ===`); + if (fail > 0) { + console.log("\nFailures:"); + for (const f of failures) console.log(` - ${f.label}${f.extra ? ` [${f.extra}]` : ""}`); + process.exit(1); + } 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); -}); +main().catch(async (e) => { + console.error("Test crashed:", e); + if (browser) await browser.close(); + process.exit(1); +}); \ No newline at end of file