v0.2.1: add Playwright test + tests/PLAN.md

- New tests/verify-combat-hud-hub-foundry.mjs (12 assertion sections).
  Covers live Foundry v14: API surface, soft-dep presence, section
  registry round-trip, pushFeedEntry, HUD open on combatStart, core
  section rendering, current-turn indicator, screenshot, no errors.
- New tests/PLAN.md (per-repo test plan).
- .gitignore: tests/screenshots/ excluded.
This commit is contained in:
2026-06-22 16:09:23 -04:00
parent f304db49cc
commit f752d15805
3 changed files with 383 additions and 1 deletions

3
.gitignore vendored
View File

@@ -1,3 +1,4 @@
node_modules/
*.zip
.DS_Store
.DS_Store
tests/screenshots/

39
tests/PLAN.md Normal file
View File

@@ -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).

View File

@@ -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) => `<div data-probe-id="${probeId}">probe-html</div>` });
const after = a.listSections().map(s => s.id);
a.removeSection(probeId);
const afterRemove = a.listSections().map(s => s.id);
return { added: after.includes(probeId), removed: !afterRemove.includes(probeId) };
}, MODULE_ID);
check("6a. addSection registers a section", roundTrip.added);
check("6b. removeSection unregisters a section", roundTrip.removed);
// ── 7. pushFeedEntry → render context receives feed ──
const feedTest = await page.evaluate((mid) => {
const a = game.modules.get(mid).api;
const probeId = `__feed-probe-${Date.now()}`;
let receivedFeed = null;
a.addSection({
id: probeId,
label: "Feed Probe",
render: (ctx) => { receivedFeed = ctx.feed ?? []; return ""; },
});
a.pushFeedEntry(probeId, { id: "x", name: "X" });
a.pushFeedEntry(probeId, { id: "y", name: "Y" });
const feed = receivedFeed;
a.removeSection(probeId);
return {
receivedCount: feed.length,
ids: feed.map(e => e.id),
};
}, MODULE_ID);
check("7a. pushFeedEntry delivers entries to render ctx", feedTest.receivedCount === 2);
check("7b. feed entries preserve order + ids",
feedTest.ids[0] === "x" && feedTest.ids[1] === "y");
// ── 8. HUD opens on combatStart ──
// We need an encounter. Look for an existing one or create a minimal one.
const hudOpens = await page.evaluate(async (mid) => {
const api = game.modules.get(mid).api;
const bfApi = game.modules.get("battle-focus")?.api;
if (!bfApi?.getActiveEncounter) return { ok: false, reason: "battle-focus not active" };
// If a combat is already running, use it. Otherwise we need to start one.
let enc = bfApi.getActiveEncounter();
if (!enc || !enc.isActive?.()) {
// Try to find a combat in the world and start it.
const combat = game.combats?.find?.(c => c) ?? game.combat;
if (combat && !combat.started) await combat.startCombat();
enc = bfApi.getActiveEncounter();
}
if (!enc) return { ok: false, reason: "no active encounter" };
// Open the HUD manually.
api.openHud();
// Wait briefly for the throttled render.
await new Promise(r => setTimeout(r, 1200));
const root = document.querySelector(".chh-hud");
const rendered = !!root;
return { ok: rendered, reason: rendered ? null : "no .chh-hud in DOM" };
}, MODULE_ID);
check("8a. HUD opens via api.openHud", hudOpens.ok, hudOpens.reason ?? "");
// ── 9. HUD renders core sections when open + battle-focus present ──
if (hudOpens.ok && presence.battleFocus) {
const render = await page.evaluate(() => {
const root = document.querySelector(".chh-hud");
if (!root) return null;
const roundEl = root.querySelector(".chh-section-header-round");
const turnEl = root.querySelector(".chh-section-header-turn");
const combatantsList = root.querySelector(".chh-section-combatants-list");
const streakEl = root.querySelector(".chh-section-dice-streak");
return {
roundText: roundEl?.textContent?.trim() ?? null,
turnText: turnEl?.textContent?.trim() ?? null,
combatantRowCount: combatantsList?.querySelectorAll(".chh-section-combatants-row").length ?? 0,
streakAttr: streakEl?.getAttribute("data-streak") ?? null,
};
});
check("9a. HUD renders round counter", render?.roundText && /Round \d+/.test(render.roundText),
`got=${JSON.stringify(render?.roundText)}`);
check("9b. HUD renders current turn", render?.turnText && render.turnText.length > 0,
`got=${JSON.stringify(render?.turnText)}`);
check("9c. HUD renders combatants list", render?.combatantRowCount >= 1,
`got=${render?.combatantRowCount} row(s)`);
check("9d. HUD renders dice streak", render?.streakAttr !== null,
`data-streak=${render?.streakAttr}`);
} else if (!presence.battleFocus) {
check("9z. core sections hidden without battle-focus (no row to check)", true);
} else {
check("9z. HUD didn't open; skipping render checks", false,
"see 8a for reason");
}
// ── 10. Current-turn indicator: exactly one row gets data-chh-current-turn ──
if (hudOpens.ok && presence.battleFocus) {
const indicator = await page.evaluate(() => {
const rows = document.querySelectorAll(".chh-section-combatants-row[data-chh-current-turn]");
return { count: rows.length, tokenIds: Array.from(rows).map(r => r.getAttribute("data-token-id")) };
});
check("10a. exactly one combatant row is marked as current turn",
indicator.count === 1,
`got=${indicator.count}`);
check("10b. the marked row has a data-token-id",
indicator.tokenIds[0] != null && indicator.tokenIds[0] !== "",
`got=${JSON.stringify(indicator.tokenIds)}`);
} else {
check("10z. skipping indicator check (HUD not open or no bf)", true);
}
// ── 11. Screenshot the HUD for visual sign-off ──
if (hudOpens.ok) {
try {
mkdirSync(SCREENSHOT_DIR, { recursive: true });
const shotPath = path.join(SCREENSHOT_DIR, "hud-full-install.png");
const hud = await page.$(".chh-hud");
if (hud) {
await hud.screenshot({ path: shotPath });
check("11a. HUD screenshot saved", true, `at ${shotPath}`);
} else {
check("11a. HUD screenshot saved", false, ".chh-hud not found at screenshot time");
}
} catch (e) {
check("11a. HUD screenshot saved", false, `error: ${e.message}`);
}
}
// ── 12. No thrown errors during init/ready ──
// We accept warnings (e.g. recordEncounterInSession is a pre-existing
// battle-focus warning, not an error).
const errorEvents = pageErrors.filter(e => !/Warning|deprecat/i.test(e));
check("12a. 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();
}
main().catch(async (e) => {
console.error("Test crashed:", e);
if (browser) await browser.close();
process.exit(1);
});