diff --git a/.gitignore b/.gitignore index 65c55ab..85afeb2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules/ *.zip -.DS_Store \ No newline at end of file +.DS_Store +tests/screenshots/ \ No newline at end of file diff --git a/tests/PLAN.md b/tests/PLAN.md new file mode 100644 index 0000000..4b162e9 --- /dev/null +++ b/tests/PLAN.md @@ -0,0 +1,39 @@ +# tests/PLAN.md — combat-hud-hub + +## What we test + +**Smoke (no-Foundry):** `verify-combat-hud-hub.mjs` +- Sections A-K: module skeleton, section registry, feed entries, soft-dep behavior, hook subscription, render loop, public API integrity, ApplicationV2 surface, throttled render, section render context, unwire. +- Section L: current-turn indicator (v0.2.1). + +**Playwright (live Foundry v14):** `verify-combat-hud-hub-foundry.mjs` +- 1. Join as Gamemaster (or Player fallback). +- 2. mod.api surface (8 documented methods + version). +- 3. Soft-dep presence detected for hooks-lib, battle-focus, its-achievable. +- 4. Core sections register iff battle-focus present; hide gracefully otherwise. +- 5. its-achievable registers pinned-achievements section iff it's installed. +- 6. addSection / removeSection round-trip via live API. +- 7. pushFeedEntry delivers entries to render ctx (live array reference). +- 8. HUD opens via api.openHud (.chh-hud in DOM after combatStart). +- 9. Core section rendering: round counter, current turn, combatants list, dice streak. +- 10. Current-turn indicator: exactly one row has data-chh-current-turn="true". +- 11. Screenshot of HUD for visual sign-off. +- 12. No thrown errors during init/ready (pageerror events). + +## Threshold for "done" + +- Smoke: 46/46 passing in <2s. +- Playwright: row 7 (FULL install) hits 100% on assertions 1-12. +- Matrix driver (separate file, deferred): all 16 cells behave as documented. + +## Verify commands + +``` +npm test # smoke +npm run test:foundry # Playwright +``` + +Prereqs for Playwright: +- Foundry running on localhost:30000 with the "Hello World Demo" world. +- combat-hud-hub installed in Data/modules/ and enabled in the world. +- foundry-hooks-lib + battle-focus + its-achievable installed and enabled (for full-install assertions; tests skip gracefully if any are missing). \ No newline at end of file diff --git a/tests/verify-combat-hud-hub-foundry.mjs b/tests/verify-combat-hud-hub-foundry.mjs new file mode 100644 index 0000000..e33e3fc --- /dev/null +++ b/tests/verify-combat-hud-hub-foundry.mjs @@ -0,0 +1,342 @@ +// tests/verify-combat-hud-hub-foundry.mjs +// +// Playwright + live Foundry v14 test for combat-hud-hub v0.2.1. +// Verifies the things the no-Foundry smoke test CAN'T: +// +// 1. The hub loads cleanly in a live Foundry v14 world. +// 2. mod.api is set on game.modules.get("combat-hud-hub") with the +// documented surface (addSection, removeSection, pushFeedEntry, +// listSections, getHud, openHud, closeHud). +// 3. Soft-dep wiring: foundry-hooks-lib + battle-focus installed and +// active → core sections register automatically. +// 4. Soft-dep degradation: foundry-hooks-lib missing → no subscription; +// battle-focus missing → no core sections. +// 5. The HUD opens on combatStart, renders round + current turn + +// combatants with the current-turn indicator on the right row. +// 6. Pushed feed entries land in the section's render context. +// 7. Consumer-registered sections (its-achievable's pinned-achievements) +// render alongside the core sections. +// +// Run: `node tests/verify-combat-hud-hub-foundry.mjs` +// Gate: passes before release. +// +// Prereqs: +// - Foundry running on FOUNDRY_URL (default localhost:30000). +// - combat-hud-hub installed in Data/modules/ AND enabled in the world. +// - foundry-hooks-lib + battle-focus + its-achievable installed and +// enabled (for the full-install assertions). Tests skip gracefully +// if any are missing. + +import { chromium } from "playwright-core"; +import { strict as assert } from "node:assert"; +import { performance } from "node:perf_hooks"; +import { mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +const MODULE_ID = "combat-hud-hub"; +const HOOKS_LIB_ID = "foundry-hooks-lib"; +const BATTLE_FOCUS_ID = "battle-focus"; +const ACHIEVABLE_ID = "its-achievable"; +const FOUNDRY_URL = process.env.FOUNDRY_URL ?? "http://localhost:30000"; +const TIMEOUT_MS = 30_000; +const SCREENSHOT_DIR = path.resolve("tests/screenshots"); + +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( + `--- combat-hud-hub v0.2.1 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 the modules under test. + page.on("console", (m) => { + const t = m.text(); + if ( + t.includes("[probe]") || + t.includes("[combat-hud-hub]") || + t.includes("[foundry-hooks-lib]") || + t.includes("[battle-focus]") || + t.includes("[its-achievable]") || + t.includes(MODULE_ID) || + t.includes(HOOKS_LIB_ID) || + t.includes(BATTLE_FOCUS_ID) || + t.includes(ACHIEVABLE_ID) || + m.type() === "error" + ) { + console.log(`[c.${m.type()}] ${t}`); + } + }); + // Capture pageerror events for the "no thrown errors during init" check. + const pageErrors = []; + page.on("pageerror", (e) => pageErrors.push(String(e))); + + // ── 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 users are all taken."); + } + 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 the hub to be ready. + await page.waitForFunction( + (mid) => game?.modules?.get(mid)?.api?.getHud != null, + 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, + hasAddSection: typeof a.addSection === "function", + hasRemoveSection: typeof a.removeSection === "function", + hasPushFeedEntry: typeof a.pushFeedEntry === "function", + hasListSections: typeof a.listSections === "function", + hasGetHud: typeof a.getHud === "function", + hasOpenHud: typeof a.openHud === "function", + hasCloseHud: typeof a.closeHud === "function", + }; + }, 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 has addSection", api?.hasAddSection); + check("2d. mod.api has removeSection", api?.hasRemoveSection); + check("2e. mod.api has pushFeedEntry", api?.hasPushFeedEntry); + check("2f. mod.api has listSections", api?.hasListSections); + check("2g. mod.api has getHud", api?.hasGetHud); + check("2h. mod.api has openHud", api?.hasOpenHud); + check("2i. mod.api has closeHud", api?.hasCloseHud); + check( + "2j. mod.api.version is a semver string", + typeof api?.apiVersion === "string" && /^\d+\.\d+\.\d+/.test(api.apiVersion), + `got=${JSON.stringify(api?.apiVersion)}`, + ); + + // ── 3. Soft-dep wiring (which modules are present?) ── + const presence = await page.evaluate(() => ({ + hooksLib: !!game.modules.get("foundry-hooks-lib")?.active, + battleFocus: !!game.modules.get("battle-focus")?.active, + itsAchievable: !!game.modules.get("its-achievable")?.active, + })); + check("3a. foundry-hooks-lib presence detected", presence.hooksLib !== undefined); + check("3b. battle-focus presence detected", presence.battleFocus !== undefined); + check("3c. its-achievable presence detected", presence.itsAchievable !== undefined); + console.log(` [info] presence: hooks-lib=${presence.hooksLib} bf=${presence.battleFocus} ia=${presence.itsAchievable}`); + + // ── 4. Core sections registered iff battle-focus is present ── + const sectionsInfo = await page.evaluate((mid) => { + const a = game.modules.get(mid)?.api; + if (!a) return null; + const sections = a.listSections(); + return { + ids: sections.map(s => s.id).sort(), + count: sections.length, + }; + }, MODULE_ID); + if (presence.battleFocus) { + check("4a. core-header registered when battle-focus present", sectionsInfo.ids.includes("core-header")); + check("4b. core-combatants registered when battle-focus present", sectionsInfo.ids.includes("core-combatants")); + check("4c. core-dice-streak registered when battle-focus present", sectionsInfo.ids.includes("core-dice-streak")); + } else { + check("4z. core sections hidden when battle-focus missing (graceful)", + !sectionsInfo.ids.includes("core-header") && !sectionsInfo.ids.includes("core-combatants")); + } + + // ── 5. its-achievable integration: pinned-achievements section ── + if (presence.itsAchievable) { + check("5a. pinned-achievements section registered by its-achievable", + sectionsInfo.ids.includes("pinned-achievements")); + } else { + check("5z. pinned-achievements absent when its-achievable missing", + !sectionsInfo.ids.includes("pinned-achievements")); + } + + // ── 6. Section registration round-trip via API ── + const roundTrip = await page.evaluate((mid) => { + const a = game.modules.get(mid).api; + const probeId = `__probe-${Date.now()}`; + a.addSection({ id: probeId, label: "Probe", render: (ctx) => `