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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
node_modules/
|
||||
*.zip
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
tests/screenshots/
|
||||
39
tests/PLAN.md
Normal file
39
tests/PLAN.md
Normal 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).
|
||||
342
tests/verify-combat-hud-hub-foundry.mjs
Normal file
342
tests/verify-combat-hud-hub-foundry.mjs
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user