#!/usr/bin/env node "use strict"; const assert = require("node:assert/strict"); const fs = require("node:fs"); const path = require("node:path"); const { chromium } = require("playwright"); const baseURL = process.env.ABG_PROTOTYPE_URL || "http://127.0.0.1:8766"; const outputDir = process.env.ABG_PROTOTYPE_EVIDENCE || "/tmp/aetherbound-prototype-review"; const browserPath = process.env.ABG_BROWSER_PATH || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; async function clickView(page, view) { await page.locator(`[data-view="${view}"]`).click(); await page.locator("main h1").waitFor({ state: "visible" }); } async function noHorizontalOverflow(page, label) { const dimensions = await page.evaluate(() => ({ scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth, })); assert.ok( dimensions.scrollWidth <= dimensions.clientWidth + 1, `${label} overflow: ${JSON.stringify(dimensions)}`, ); } async function waitForMobileNavClosed(page) { await page.waitForFunction(() => { const sidebar = document.querySelector("#sidebar"); if (!sidebar || sidebar.classList.contains("is-open")) return false; return sidebar.getBoundingClientRect().right <= 0.5; }); } async function main() { fs.mkdirSync(outputDir, { recursive: true }); const browser = await chromium.launch({ headless: true, executablePath: browserPath }); try { const errors = []; const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 1 }); page.on("console", (message) => { if (message.type() === "error") errors.push(`console: ${message.text()}`); }); page.on("pageerror", (error) => errors.push(`pageerror: ${error.message}`)); await page.goto(baseURL, { waitUntil: "networkidle" }); assert.equal(await page.locator("#loading").count(), 0, "loading state must clear"); assert.equal(await page.locator(".nav-item").count(), 11, "primary view count"); await noHorizontalOverflow(page, "desktop overview"); await page.screenshot({ path: path.join(outputDir, "desktop-overview.png"), fullPage: true }); for (const view of [ "screens", "flow", "chapters", "characters", "professions", "enemies", "items", "records", "production", "readiness", ]) { await clickView(page, view); assert.ok(await page.locator("main h1").isVisible(), `${view} heading`); } await clickView(page, "screens"); assert.equal(await page.locator(".screen-index button").count(), 82, "screen inventory count"); const ids = await page.locator(".screen-index button span").allTextContents(); assert.equal(new Set(ids).size, 82, "screen IDs must be unique"); assert.equal(await page.locator('[data-coverage="dedicated"]').count(), 1, "dedicated page count"); assert.equal(await page.locator('[data-coverage="family"]').count(), 40, "shared-family page count"); assert.equal(await page.locator('[data-coverage="missing"]').count(), 41, "missing page count"); await page.locator('[data-screen-id="SYS-001"]').first().click(); assert.ok(await page.locator('[data-stage-id="SYS-001"] .boot-stage').isVisible(), "dedicated boot page"); assert.equal(await page.locator('[data-stage-id="SYS-001"] button').count(), 0, "boot has no player action"); assert.equal(await page.locator('[data-stage-id="SYS-001"]').getByText(/Cache|Saved|none/).count(), 0, "boot excludes unrelated HUD"); await page.screenshot({ path: path.join(outputDir, "desktop-boot-screen.png"), fullPage: true }); await page.locator('[data-screen-id="SYS-004"]').first().click(); assert.ok(await page.locator('[data-stage-id="SYS-004"] .prototype-missing').isVisible(), "title gap is explicit"); await page.screenshot({ path: path.join(outputDir, "desktop-title-gap.png"), fullPage: true }); await page.locator('[data-screen-id="BAT-002"]').first().click(); assert.ok(await page.locator('[data-stage-id="BAT-002"]').isVisible(), "battle screen stage"); assert.ok(await page.locator(".battle-telegraph").isVisible(), "battle telegraph"); await page.screenshot({ path: path.join(outputDir, "desktop-battle-screen.png"), fullPage: true }); await clickView(page, "flow"); assert.equal(await page.locator(".flow-steps button").count(), 17, "flow step count"); for (let index = 0; index < 17; index += 1) { assert.equal(await page.locator(".flow-steps button.is-active").count(), 1, `active flow step ${index}`); await page.locator(".flow-controls [data-flow-next]").click(); } assert.ok(await page.locator('[data-stage-id="SYS-004"]').isVisible(), "flow wraps to title"); await clickView(page, "chapters"); assert.equal(await page.locator(".chapter-tabs button").count(), 8, "chapter count"); const firstChapterArt = await page.locator(".chapter-art img").getAttribute("src"); await page.locator('[data-chapter="7"]').click(); assert.match(await page.locator(".chapter-copy h2").textContent(), /A Concord of Many/); const lastChapterArt = await page.locator(".chapter-art img").getAttribute("src"); assert.notEqual(lastChapterArt, firstChapterArt, "chapter art must change from CH-01 to CH-08"); assert.match(lastChapterArt, /(?:ch_ch08\.webp|^data:image\/webp;base64,)/); await clickView(page, "items"); await page.locator('[data-catalog-search="items"]').fill("Aetherbound Guildcoat"); assert.equal(await page.locator(".catalog-entry").count(), 1, "item search result count"); assert.equal(await page.locator(".catalog-entry strong").textContent(), "Aetherbound Guildcoat"); await page.setViewportSize({ width: 1024, height: 768 }); await page.reload({ waitUntil: "networkidle" }); await clickView(page, "screens"); await page.locator('[data-screen-id="BAT-002"]').first().click(); const tabletStageBox = await page.locator(".game-stage").boundingBox(); assert.ok( tabletStageBox && tabletStageBox.x >= 0 && tabletStageBox.x + tabletStageBox.width <= 1024.5, `tablet stage box ${JSON.stringify(tabletStageBox)}`, ); await noHorizontalOverflow(page, "tablet screens"); await page.screenshot({ path: path.join(outputDir, "tablet-battle-screen.png"), fullPage: true }); await page.setViewportSize({ width: 390, height: 844 }); await page.reload({ waitUntil: "networkidle" }); await page.locator("#open-nav").click(); assert.ok((await page.locator("#sidebar").getAttribute("class")).includes("is-open"), "mobile nav opens"); await page.locator('[data-view="screens"]').click(); assert.ok(!(await page.locator("#sidebar").getAttribute("class")).includes("is-open"), "mobile nav closes"); await waitForMobileNavClosed(page); const stageBox = await page.locator(".game-stage").boundingBox(); assert.ok(stageBox && stageBox.x >= 0 && stageBox.x + stageBox.width <= 390.5, `mobile stage box ${JSON.stringify(stageBox)}`); await noHorizontalOverflow(page, "phone portrait screens"); await page.screenshot({ path: path.join(outputDir, "phone-portrait-screens.png"), fullPage: true }); await page.setViewportSize({ width: 844, height: 390 }); await page.reload({ waitUntil: "networkidle" }); await page.locator("#open-nav").click(); await page.locator('[data-view="flow"]').click(); await waitForMobileNavClosed(page); await noHorizontalOverflow(page, "phone landscape flow"); assert.ok(await page.locator(".game-stage").isVisible(), "landscape stage visible"); await page.screenshot({ path: path.join(outputDir, "phone-landscape-flow.png"), fullPage: true }); assert.deepEqual(errors, [], `browser errors: ${errors.join(" | ")}`); console.log("ABG_PROTOTYPE_BROWSER_OK views=11 screens=82 flow_steps=17 chapters=8 responsive=desktop,tablet,phone_portrait,phone_landscape"); console.log(`evidence=${outputDir}`); } finally { await browser.close(); } } main().catch((error) => { console.error(error.stack || error); process.exitCode = 1; });