#!/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 root = path.resolve(__dirname, "../.."); const baseURL = process.env.ABG_PROTOTYPE_URL || "http://127.0.0.1:8766"; const outputDir = process.env.ABG_PROTOTYPE_EVIDENCE || path.join(root, "prototype/gallery/evidence/p8-4"); const browserPath = process.env.ABG_BROWSER_PATH || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; async function screenshot(page, name) { const destination = path.join(outputDir, name); await page.screenshot({ path: destination, fullPage: true }); return destination; } async function noHorizontalOverflow(page, label) { const metrics = await page.evaluate(() => ({ scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth, })); assert.ok(metrics.scrollWidth <= metrics.clientWidth + 1, `${label}: ${JSON.stringify(metrics)}`); } async function stageFits(page, label) { const metrics = await page.locator(".game-stage").evaluate((node) => { const rect = node.getBoundingClientRect(); return { left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom, width: innerWidth, height: innerHeight }; }); assert.ok(metrics.left >= -0.5 && metrics.right <= metrics.width + 0.5, `${label} horizontal stage fit: ${JSON.stringify(metrics)}`); assert.ok(metrics.top >= -0.5 && metrics.bottom <= metrics.height + 0.5, `${label} vertical stage fit: ${JSON.stringify(metrics)}`); const action = await page.locator(".experience-stage-actions").evaluate((node) => { const a = node.getBoundingClientRect(); const s = node.closest(".game-stage").getBoundingClientRect(); return { aLeft: a.left, aRight: a.right, aTop: a.top, aBottom: a.bottom, sLeft: s.left, sRight: s.right, sTop: s.top, sBottom: s.bottom }; }); assert.ok(action.aLeft >= action.sLeft && action.aRight <= action.sRight && action.aTop >= action.sTop && action.aBottom <= action.sBottom, `${label} action fit: ${JSON.stringify(action)}`); } async function openPackage(page) { await page.goto(`${baseURL}?package=p8-4#playtest`, { waitUntil: "networkidle" }); await page.locator('[data-p84-state="diagnosis"]').waitFor(); assert.equal(await page.locator(".p84-progress li").count(), 5, "five visible P8.4 steps"); assert.match(await page.locator(".p84-diagnosis").textContent(), /队伍赢了,但 Ilya 没有回来/); assert.match(await page.locator(".p84-cause-replay").textContent(), /首个决定性分歧/); assert.match(await page.locator(".p84-cause-replay").textContent(), /EQ-014|寂静后甲/); assert.match(await page.locator(".p84-result-totals").textContent(), /12 → 46/); const order = await page.evaluate(() => { const cause = document.querySelector(".p84-cause-replay").getBoundingClientRect(); const totals = document.querySelector(".p84-result-totals").getBoundingClientRect(); return { causeLeft: cause.left, totalsLeft: totals.left }; }); assert.ok(order.causeLeft < order.totalsLeft, "causal diagnosis precedes totals in reading order"); } async function settleCasualty(page) { await page.locator("[data-p84-casualty-review]").click(); assert.match(await page.locator(".p84-casualty").textContent(), /min\(14, 10 \+ floor\(14 \/ 2\)\) = 14/); assert.match(await page.locator(".p84-casualty").textContent(), /EQ-014 Quiet Rearplate → 库存/); assert.match(await page.locator(".p84-casualty").textContent(), /Company 4 → 3/); await page.locator("[data-p84-back-diagnosis]").click(); assert.equal(await page.locator('[data-p84-state="diagnosis"]').count(), 1, "casualty review is reversible"); await page.locator("[data-p84-casualty-review]").click(); await page.locator("[data-p84-casualty-commit]").click(); assert.equal(await page.locator("text=CASUALTY-P84-001").count(), 1, "one casualty receipt"); assert.equal(await page.locator("[data-company-unit]").count(), 3, "three survivors remain"); assert.equal(await page.locator("[data-company-vacancy]").count(), 1, "vacancy remains explicit"); assert.match(await page.locator(".p84-receipt").textContent(), /0 → 14/); assert.match(await page.locator(".p84-receipt").textContent(), /EQ-014/); assert.match(await page.locator(".p84-receipt").textContent(), /1 \/ 1/); } async function chooseArtifactReward(page) { await page.locator("[data-p84-open-reward]").click(); assert.equal(await page.locator("[data-p84-reward]").count(), 3, "elite draft has three outer cards"); assert.equal(await page.locator("[data-p84-reward].is-selected").count(), 0, "reward has no preselection"); assert.ok(await page.locator("[data-p84-reward-next]").isDisabled(), "reward review is disabled before selection"); await page.locator('[data-p84-reward="artifact"]').click(); await page.locator("[data-p84-reward-next]").click(); assert.equal(await page.locator("[data-p84-artifact]").count(), 3, "Artifact card expands to three fixed IDs"); assert.equal(await page.locator("[data-p84-artifact].is-selected").count(), 0, "Artifact has no preselection"); assert.deepEqual(await page.locator("[data-p84-artifact]").evaluateAll((nodes) => nodes.map((node) => node.dataset.p84Artifact)), ["AR-021", "AR-031", "AR-043"]); await page.locator('[data-p84-artifact="AR-043"]').click(); await page.locator("[data-p84-artifact-back]").click(); assert.equal(await page.locator('[data-p84-reward="artifact"].is-selected').count(), 1, "back preserves the outer card"); await page.locator("[data-p84-reward-next]").click(); assert.equal(await page.locator('[data-p84-artifact="AR-043"].is-selected').count(), 1, "back preserves nested Artifact choice"); await page.locator("[data-p84-artifact-review]").click(); assert.match(await page.locator(".p84-review").textContent(), /AR-043/); await page.locator("[data-p84-reward-revise]").click(); assert.equal(await page.locator('[data-p84-artifact="AR-043"].is-selected').count(), 1, "reward review is reversible"); await page.locator("[data-p84-artifact-review]").click(); await page.locator("[data-p84-reward-commit]").click(); assert.equal(await page.locator("text=REWARD-P84-001").count(), 1, "one reward receipt"); assert.match(await page.locator(".p84-receipt").textContent(), /AR-043/); assert.match(await page.locator(".p84-receipt").textContent(), /金币\s*46/); assert.match(await page.locator(".p84-receipt").textContent(), /凭证\s*14/); assert.match(await page.locator(".p84-receipt").textContent(), /1 \/ 1/); } async function recoverWithCredit(page) { await page.locator("[data-p84-open-recovery]").click(); assert.equal(await page.locator("[data-p84-recovery]").count(), 3, "three recovery responses"); assert.equal(await page.locator("[data-p84-recovery].is-selected").count(), 0, "recovery has no preselection"); assert.match(await page.locator(".p84-recovery").textContent(), /不会自动装备退回的EQ-014/); await page.locator('[data-p84-recovery="credit"]').click(); await page.locator("[data-p84-recovery-review]").click(); assert.match(await page.locator(".p84-review").textContent(), /46 → 46/); assert.match(await page.locator(".p84-review").textContent(), /14 → 0/); assert.match(await page.locator(".p84-review").textContent(), /3 → 4/); await page.locator("[data-p84-recovery-revise]").click(); assert.equal(await page.locator('[data-p84-recovery="credit"].is-selected').count(), 1, "recovery review is reversible"); await page.locator("[data-p84-recovery-review]").click(); await page.locator("[data-p84-recovery-commit]").click(); assert.equal(await page.locator("text=RECOVERY-P84-001").count(), 1, "one recovery receipt"); assert.equal(await page.locator("[data-company-unit]").count(), 4, "replacement restores Company to four"); assert.equal(await page.locator('[data-company-unit="RCV-P84-01"]').count(), 1, "fixed recovery Recruit joins once"); assert.match(await page.locator(".p84-receipt").textContent(), /金币\s*46/); assert.match(await page.locator(".p84-receipt").textContent(), /凭证\s*0/); assert.match(await page.locator(".p84-receipt").textContent(), /EQ-014.*仍在库存/s); await page.locator("[data-p84-open-market]").click(); assert.match(await page.locator(".p84-next-market").textContent(), /第2轮集市已准备/); assert.match(await page.locator(".p84-market-ledger").textContent(), /金币\s*46/); assert.match(await page.locator(".p84-market-ledger").textContent(), /凭证\s*0/); assert.match(await page.locator(".p84-market-ledger").textContent(), /AR-043/); assert.match(await page.locator(".p84-next-market").textContent(), /P8\.5 尚未开启/); } async function mainPath(browser, evidence) { const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 1 }); await openPackage(page); evidence.push(await screenshot(page, "desktop-diagnosis.png")); await settleCasualty(page); evidence.push(await screenshot(page, "desktop-casualty-receipt.png")); await chooseArtifactReward(page); evidence.push(await screenshot(page, "desktop-reward-receipt.png")); await recoverWithCredit(page); evidence.push(await screenshot(page, "desktop-next-market.png")); await noHorizontalOverflow(page, "desktop next Market"); await stageFits(page, "desktop next Market"); await page.close(); } async function counterfactual(page, reward, recovery, expected) { await openPackage(page); await page.locator("[data-p84-casualty-review]").click(); await page.locator("[data-p84-casualty-commit]").click(); await page.locator("[data-p84-open-reward]").click(); if (reward === "decline") await page.locator("[data-p84-reward-decline]").click(); else { await page.locator(`[data-p84-reward="${reward}"]`).click(); await page.locator("[data-p84-reward-next]").click(); } await page.locator("[data-p84-reward-commit]").click(); await page.locator("[data-p84-open-recovery]").click(); await page.locator(`[data-p84-recovery="${recovery}"]`).click(); await page.locator("[data-p84-recovery-review]").click(); await page.locator("[data-p84-recovery-commit]").click(); await page.locator("[data-p84-open-market]").click(); const text = await page.locator(".p84-next-market").textContent(); assert.match(text, new RegExp(`金币\\s*${expected.coin}`)); assert.match(text, new RegExp(`凭证\\s*${expected.credit}`)); assert.equal(await page.locator("[data-company-unit]").count(), expected.company); assert.equal(await page.locator("[data-company-vacancy]").count(), expected.company === 3 ? 1 : 0); } async function responsiveAudit(browser, evidence) { for (const fixture of [ { name: "phone-landscape-recovery.png", viewport: { width: 844, height: 390 }, step: "recovery" }, { name: "phone-portrait-diagnosis.png", viewport: { width: 390, height: 844 }, step: "diagnosis" }, ]) { const page = await browser.newPage({ viewport: fixture.viewport, deviceScaleFactor: 1 }); await openPackage(page); if (fixture.step === "recovery") { await page.locator("[data-p84-casualty-review]").click(); await page.locator("[data-p84-casualty-commit]").click(); await page.locator("[data-p84-open-reward]").click(); await page.locator('[data-p84-reward="equipment"]').click(); await page.locator("[data-p84-reward-next]").click(); await page.locator("[data-p84-reward-commit]").click(); await page.locator("[data-p84-open-recovery]").click(); } await noHorizontalOverflow(page, fixture.name); await stageFits(page, fixture.name); evidence.push(await screenshot(page, fixture.name)); await page.close(); } } async function bundleAudit(browser) { const bundle = path.join(root, "prototype/gallery/aetherbound-guild-prototype.html"); assert.ok(fs.statSync(bundle).size > 1_000_000, "Gallery bundle contains embedded media"); const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); const requests = []; page.on("request", (request) => requests.push(request.url())); await page.goto(`file://${bundle}?package=p8-4#playtest`, { waitUntil: "load" }); assert.equal(await page.locator('[data-p84-state="diagnosis"]').count(), 1, "offline bundle opens P8.4"); assert.equal(requests.filter((url) => /^https?:/.test(url)).length, 0, "offline bundle makes no network requests"); await page.close(); } async function main() { fs.mkdirSync(outputDir, { recursive: true }); const browser = await chromium.launch({ headless: true, executablePath: browserPath }); const evidence = []; try { await mainPath(browser, evidence); const coinPage = await browser.newPage({ viewport: { width: 1280, height: 800 } }); await counterfactual(coinPage, "coin", "coin", { coin: 45, credit: 14, company: 4 }); await coinPage.close(); const shortPage = await browser.newPage({ viewport: { width: 1280, height: 800 } }); await counterfactual(shortPage, "decline", "short", { coin: 46, credit: 14, company: 3 }); await shortPage.close(); await responsiveAudit(browser, evidence); await bundleAudit(browser); } finally { await browser.close(); } fs.writeFileSync(path.join(outputDir, "screenshots.txt"), `${evidence.map((entry) => path.basename(entry)).join("\n")}\n`); console.log("ABG_P8_4_BROWSER_OK result=diagnosis_first battle_coin=34 coin=12,46 casualty=Ilya credit=14 item_return=EQ-014 reward_cards=3 artifact_ids=3 reward_commits=1 recovery_options=3 recovery_commits=1 next_market=dynamic counterfactuals=coin_coin,decline_short responsive=desktop,phone_landscape,phone_portrait bundle=offline p8_5=locked"); } main().catch((error) => { console.error(error.stack || error); process.exitCode = 1; });