1694 lines
45 KiB
JavaScript
1694 lines
45 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { spawn, spawnSync } from 'node:child_process';
|
|
import { mkdirSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { chromium } from 'playwright';
|
|
import {
|
|
desktopBrowserContextOptions,
|
|
desktopBrowserDeviceScaleFactor,
|
|
desktopBrowserViewport,
|
|
desktopBrowserViewportBounds
|
|
} from './desktop-browser-viewport.mjs';
|
|
|
|
const sourceBattleId = 'first-battle-zhuo-commandery';
|
|
const visitId = 'first-pursuit-scout-tent';
|
|
const renderer =
|
|
process.env.VERIFY_CAMPAIGN_OBJECTIVE_JOURNAL_RENDERER;
|
|
const rendererNames = ['canvas', 'webgl'];
|
|
const forbiddenFutureTerms = [
|
|
'제갈량',
|
|
'형주 붕괴와 이릉',
|
|
'백제성',
|
|
'맹획',
|
|
'오장원'
|
|
];
|
|
|
|
if (!renderer) {
|
|
for (const requestedRenderer of rendererNames) {
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[fileURLToPath(import.meta.url)],
|
|
{
|
|
cwd: process.cwd(),
|
|
env: {
|
|
...process.env,
|
|
VERIFY_CAMPAIGN_OBJECTIVE_JOURNAL_RENDERER:
|
|
requestedRenderer
|
|
},
|
|
stdio: 'inherit'
|
|
}
|
|
);
|
|
if (result.status !== 0) {
|
|
process.exit(result.status ?? 1);
|
|
}
|
|
}
|
|
process.exit(0);
|
|
}
|
|
|
|
assert(
|
|
rendererNames.includes(renderer),
|
|
`Unsupported campaign objective journal renderer "${renderer}".`
|
|
);
|
|
|
|
const baseUrl =
|
|
process.env.VERIFY_CAMPAIGN_OBJECTIVE_JOURNAL_URL ??
|
|
'http://127.0.0.1:41808/heros_web/';
|
|
const targetUrl = withDebugOptions(baseUrl, renderer);
|
|
const localSeedUrl = withDebugOptions(
|
|
'http://127.0.0.1:41808/heros_web/',
|
|
renderer
|
|
);
|
|
const screenshotPath =
|
|
`dist/verification-campaign-objective-journal-${renderer}-1920x1080.png`;
|
|
|
|
let serverProcess;
|
|
let seedServerProcess;
|
|
let browser;
|
|
|
|
try {
|
|
mkdirSync('dist', { recursive: true });
|
|
serverProcess = await ensureLocalServer(targetUrl);
|
|
browser = await chromium.launch({
|
|
headless:
|
|
process.env.VERIFY_CAMPAIGN_OBJECTIVE_JOURNAL_HEADLESS !==
|
|
'0',
|
|
args:
|
|
renderer === 'webgl'
|
|
? [
|
|
'--use-angle=swiftshader',
|
|
'--enable-unsafe-swiftshader'
|
|
]
|
|
: []
|
|
});
|
|
const context = await browser.newContext(desktopBrowserContextOptions);
|
|
const page = await context.newPage();
|
|
page.setDefaultTimeout(30000);
|
|
|
|
const pageErrors = [];
|
|
const consoleErrors = [];
|
|
page.on('pageerror', (error) =>
|
|
pageErrors.push(error.stack ?? error.message)
|
|
);
|
|
page.on('console', (message) => {
|
|
if (message.type() === 'error') {
|
|
consoleErrors.push(message.text());
|
|
}
|
|
});
|
|
|
|
await page.addInitScript(() => {
|
|
const marker =
|
|
'heros-web:qa:campaign-objective-journal-storage-cleared';
|
|
if (window.sessionStorage.getItem(marker) === '1') {
|
|
return;
|
|
}
|
|
window.localStorage.clear();
|
|
window.sessionStorage.setItem(marker, '1');
|
|
});
|
|
await page.goto(targetUrl, {
|
|
waitUntil: 'domcontentloaded',
|
|
timeout: 90000
|
|
});
|
|
await waitForDebugApi(page);
|
|
let seeded;
|
|
if (isLocalUrl(targetUrl)) {
|
|
seeded = await seedFirstVictoryCamp(page);
|
|
} else {
|
|
const seedBridge = await createFirstVictorySeedBridge(
|
|
browser,
|
|
localSeedUrl
|
|
);
|
|
seedServerProcess = seedBridge.serverProcess;
|
|
seeded = seedBridge.seeded;
|
|
await page.evaluate((campaignSaves) => {
|
|
for (const [key, value] of Object.entries(campaignSaves)) {
|
|
window.localStorage.setItem(key, value);
|
|
}
|
|
}, seedBridge.campaignSaves);
|
|
await page.reload({
|
|
waitUntil: 'domcontentloaded',
|
|
timeout: 90000
|
|
});
|
|
await waitForDebugApi(page);
|
|
}
|
|
assert.deepEqual(
|
|
seeded,
|
|
{
|
|
step: 'first-camp',
|
|
latestBattleId: sourceBattleId,
|
|
reportBattleId: sourceBattleId,
|
|
reportOutcome: 'victory',
|
|
settlementOutcome: 'victory',
|
|
pendingAftermathBattleId: null
|
|
},
|
|
`${renderer}: failed to seed the first-victory camp.`
|
|
);
|
|
|
|
await page.evaluate(async () => {
|
|
await window.__HEROS_DEBUG__.goToCamp();
|
|
});
|
|
await suppressFirstPursuitAutoEntryForJournalQa(page);
|
|
await waitForCampJournalReady(page);
|
|
|
|
const runtime = await readRuntimeProbe(page, 'CampScene');
|
|
assertRuntimeBaseline(runtime, renderer);
|
|
|
|
const savesBeforeCampJournal = await readCampaignSaves(page);
|
|
const openedBy = await openCampJournalWithPointer(page);
|
|
const firstOpen = await readSceneJournal(
|
|
page,
|
|
'CampScene'
|
|
);
|
|
const firstContract = assertJournalContract(
|
|
firstOpen,
|
|
`${renderer} first-victory camp`
|
|
);
|
|
assertFirstVictoryMeaning(firstOpen.snapshot, firstOpen.view);
|
|
|
|
const screenshot = await page.screenshot({
|
|
path: screenshotPath,
|
|
fullPage: true
|
|
});
|
|
assert(
|
|
screenshot.byteLength >= 100_000,
|
|
`Expected a painted ${renderer} campaign objective journal ` +
|
|
`screenshot: ${screenshot.byteLength} bytes.`
|
|
);
|
|
|
|
await page.keyboard.press('Escape');
|
|
await waitForJournalVisibility(page, 'CampScene', false);
|
|
|
|
await page.keyboard.press('j');
|
|
await waitForJournalVisibility(page, 'CampScene', true);
|
|
const keyboardOpen = await readSceneJournal(
|
|
page,
|
|
'CampScene'
|
|
);
|
|
const keyboardContract = assertJournalContract(
|
|
keyboardOpen,
|
|
`${renderer} keyboard-opened camp`
|
|
);
|
|
assert.deepEqual(
|
|
stableJournalContent(keyboardOpen),
|
|
stableJournalContent(firstOpen),
|
|
`${renderer}: objective journal content changed after Escape and J.`
|
|
);
|
|
assert.equal(
|
|
keyboardContract.objectCount,
|
|
firstContract.objectCount,
|
|
`${renderer}: objective journal object count changed after reopen.`
|
|
);
|
|
|
|
await page.keyboard.press('j');
|
|
await waitForJournalVisibility(page, 'CampScene', false);
|
|
await page.keyboard.press('j');
|
|
await waitForJournalVisibility(page, 'CampScene', true);
|
|
const keyboardReopen = await readSceneJournal(
|
|
page,
|
|
'CampScene'
|
|
);
|
|
const reopenedContract = assertJournalContract(
|
|
keyboardReopen,
|
|
`${renderer} keyboard-reopened camp`
|
|
);
|
|
assert.deepEqual(
|
|
stableJournalContent(keyboardReopen),
|
|
stableJournalContent(firstOpen),
|
|
`${renderer}: objective journal content was not stable across ` +
|
|
'repeated J toggles.'
|
|
);
|
|
assert.equal(
|
|
reopenedContract.objectCount,
|
|
firstContract.objectCount,
|
|
`${renderer}: repeated J toggles created duplicate display objects.`
|
|
);
|
|
assert.deepEqual(
|
|
reopenedContract.cardKeys,
|
|
firstContract.cardKeys,
|
|
`${renderer}: repeated J toggles duplicated or reordered cards.`
|
|
);
|
|
|
|
await page.keyboard.press('Escape');
|
|
await waitForJournalVisibility(page, 'CampScene', false);
|
|
assert.deepEqual(
|
|
await readCampaignSaves(page),
|
|
savesBeforeCampJournal,
|
|
`${renderer}: merely viewing the objective journal changed a save.`
|
|
);
|
|
|
|
const explorationResult =
|
|
await verifyExplorationJournalIfAvailable(page);
|
|
const modalIsolationResult =
|
|
await verifyModalInputIsolation(page);
|
|
|
|
assert.deepEqual(
|
|
pageErrors,
|
|
[],
|
|
`${renderer}: unexpected page errors: ${pageErrors.join(' | ')}`
|
|
);
|
|
assert.deepEqual(
|
|
consoleErrors,
|
|
[],
|
|
`${renderer}: unexpected console errors: ` +
|
|
consoleErrors.join(' | ')
|
|
);
|
|
|
|
console.log(
|
|
`Verified ${renderer} campaign objective journal from the ` +
|
|
`${openedBy} at ${desktopBrowserViewport.width}x` +
|
|
`${desktopBrowserViewport.height}, 100% zoom, DPR ` +
|
|
`${desktopBrowserDeviceScaleFactor}; exploration ` +
|
|
`${explorationResult}; modal input isolation ` +
|
|
`${modalIsolationResult}; captured ${screenshotPath}.`
|
|
);
|
|
} finally {
|
|
await browser?.close();
|
|
await stopServerProcess(serverProcess);
|
|
await stopServerProcess(seedServerProcess);
|
|
}
|
|
|
|
function withDebugOptions(url, requestedRenderer) {
|
|
const parsed = new URL(url);
|
|
parsed.searchParams.set('debug', '1');
|
|
parsed.searchParams.set('renderer', requestedRenderer);
|
|
return parsed.toString();
|
|
}
|
|
|
|
function isLocalUrl(url) {
|
|
return ['localhost', '127.0.0.1', '0.0.0.0'].includes(
|
|
new URL(url).hostname
|
|
);
|
|
}
|
|
|
|
async function waitForDebugApi(page) {
|
|
await page.waitForFunction(
|
|
() =>
|
|
document.querySelector('canvas') !== null &&
|
|
window.__HEROS_GAME__ !== undefined &&
|
|
window.__HEROS_DEBUG__ !== undefined,
|
|
undefined,
|
|
{ timeout: 90000 }
|
|
);
|
|
}
|
|
|
|
async function suppressFirstPursuitAutoEntryForJournalQa(page) {
|
|
let result;
|
|
try {
|
|
const handle = await page.waitForFunction(
|
|
() => {
|
|
const debug = window.__HEROS_DEBUG__;
|
|
const camp = debug?.camp?.();
|
|
if (
|
|
!debug?.activeScenes?.().includes('CampScene') ||
|
|
camp?.firstPursuitAutoEntry?.cueVisible !== true
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
const loop = window.__HEROS_GAME__?.loop;
|
|
const scene = debug.scene('CampScene');
|
|
if (
|
|
!loop ||
|
|
typeof loop.sleep !== 'function' ||
|
|
typeof loop.wake !== 'function' ||
|
|
typeof scene?.debugSuppressFirstPursuitAutoEntry !==
|
|
'function'
|
|
) {
|
|
return {
|
|
suppressed: false,
|
|
reason: 'Required debug suppression controls are unavailable.'
|
|
};
|
|
}
|
|
|
|
loop.sleep();
|
|
try {
|
|
return {
|
|
suppressed:
|
|
scene.debugSuppressFirstPursuitAutoEntry(),
|
|
reason: null
|
|
};
|
|
} finally {
|
|
loop.wake();
|
|
}
|
|
},
|
|
undefined,
|
|
{ timeout: 90000 }
|
|
);
|
|
result = await handle.jsonValue();
|
|
} catch (error) {
|
|
const diagnostic = await page.evaluate(() => ({
|
|
activeScenes:
|
|
window.__HEROS_DEBUG__?.activeScenes?.() ?? [],
|
|
camp: window.__HEROS_DEBUG__?.camp?.() ?? null
|
|
}));
|
|
throw new Error(
|
|
`First-pursuit automatic entry could not be held for ` +
|
|
`campaign journal QA: ${JSON.stringify(diagnostic)}`,
|
|
{ cause: error }
|
|
);
|
|
}
|
|
|
|
assert.equal(
|
|
result?.suppressed,
|
|
true,
|
|
`${renderer}: failed to hold the first-victory camp for ` +
|
|
`campaign journal QA: ${JSON.stringify(result)}`
|
|
);
|
|
await page.waitForFunction(
|
|
() => {
|
|
const debug = window.__HEROS_DEBUG__;
|
|
const autoEntry =
|
|
debug?.camp?.()?.firstPursuitAutoEntry;
|
|
return (
|
|
debug?.activeScenes?.().includes('CampScene') &&
|
|
autoEntry?.cueVisible === false &&
|
|
autoEntry?.suppressedForDebug === true
|
|
);
|
|
},
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
}
|
|
|
|
async function seedFirstVictoryCamp(page) {
|
|
return page.evaluate(async (battleId) => {
|
|
const campaignModule = await import(
|
|
'/heros_web/src/game/state/campaignState.ts'
|
|
);
|
|
const { battleScenarios } = await import(
|
|
'/heros_web/src/game/data/battles.ts'
|
|
);
|
|
const scenario = battleScenarios[battleId];
|
|
if (!scenario) {
|
|
throw new Error(`Missing battle scenario ${battleId}.`);
|
|
}
|
|
|
|
campaignModule.resetCampaignState();
|
|
campaignModule.setFirstBattleReport({
|
|
battleId: scenario.id,
|
|
battleTitle: scenario.title,
|
|
outcome: 'victory',
|
|
turnNumber: 6,
|
|
rewardGold: scenario.baseVictoryGold,
|
|
defeatedEnemies: scenario.units.filter(
|
|
(unit) => unit.faction === 'enemy'
|
|
).length,
|
|
totalEnemies: scenario.units.filter(
|
|
(unit) => unit.faction === 'enemy'
|
|
).length,
|
|
objectives: [],
|
|
units: scenario.units,
|
|
bonds: scenario.bonds.map((bond) => ({
|
|
...bond,
|
|
battleExp: 0
|
|
})),
|
|
itemRewards: [],
|
|
completedCampDialogues: [],
|
|
completedCampVisits: [],
|
|
createdAt: '2026-07-28T00:00:00.000Z'
|
|
});
|
|
campaignModule.completeCampaignAftermath(scenario.id);
|
|
const campaign = campaignModule.getCampaignState();
|
|
return {
|
|
step: campaign.step,
|
|
latestBattleId: campaign.latestBattleId ?? null,
|
|
reportBattleId:
|
|
campaign.firstBattleReport?.battleId ?? null,
|
|
reportOutcome:
|
|
campaign.firstBattleReport?.outcome ?? null,
|
|
settlementOutcome:
|
|
campaign.battleHistory[battleId]?.outcome ?? null,
|
|
pendingAftermathBattleId:
|
|
campaign.pendingAftermathBattleId ?? null
|
|
};
|
|
}, sourceBattleId);
|
|
}
|
|
|
|
async function createFirstVictorySeedBridge(activeBrowser, seedUrl) {
|
|
const localServerProcess = await ensureLocalServer(seedUrl);
|
|
const context = await activeBrowser.newContext(
|
|
desktopBrowserContextOptions
|
|
);
|
|
try {
|
|
const page = await context.newPage();
|
|
page.setDefaultTimeout(30000);
|
|
await page.addInitScript(() => window.localStorage.clear());
|
|
await page.goto(seedUrl, {
|
|
waitUntil: 'domcontentloaded',
|
|
timeout: 90000
|
|
});
|
|
await waitForDebugApi(page);
|
|
const seeded = await seedFirstVictoryCamp(page);
|
|
const campaignSaves = await readCampaignSaves(page);
|
|
assert(
|
|
Object.keys(campaignSaves).length > 0,
|
|
'Local seed bridge did not create a campaign save.'
|
|
);
|
|
return {
|
|
campaignSaves,
|
|
seeded,
|
|
serverProcess: localServerProcess
|
|
};
|
|
} catch (error) {
|
|
await stopServerProcess(localServerProcess);
|
|
throw error;
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
}
|
|
|
|
async function waitForCampJournalReady(page) {
|
|
try {
|
|
await page.waitForFunction(
|
|
() => {
|
|
const debug = window.__HEROS_DEBUG__;
|
|
const camp = debug?.camp?.();
|
|
const journal = camp?.campaignObjectiveJournal;
|
|
const progressTab = camp?.campTabs?.find(
|
|
(tab) => tab.id === 'progress'
|
|
);
|
|
return (
|
|
debug?.activeScenes?.().includes('CampScene') &&
|
|
camp?.campaign?.step === 'first-camp' &&
|
|
journal?.snapshot?.current &&
|
|
journal?.snapshot?.recentCompleted &&
|
|
journal?.snapshot?.nextClue &&
|
|
journal?.view &&
|
|
progressTab?.interactive === true &&
|
|
progressTab?.bounds
|
|
);
|
|
},
|
|
undefined,
|
|
{ timeout: 90000 }
|
|
);
|
|
} catch (error) {
|
|
const diagnostic = await page.evaluate(() => ({
|
|
activeScenes:
|
|
window.__HEROS_DEBUG__?.activeScenes?.() ?? [],
|
|
camp: window.__HEROS_DEBUG__?.camp?.() ?? null
|
|
}));
|
|
throw new Error(
|
|
`Campaign objective journal did not become ready in ` +
|
|
`CampScene: ${JSON.stringify(diagnostic)}`,
|
|
{ cause: error }
|
|
);
|
|
}
|
|
await afterTwoFrames(page);
|
|
}
|
|
|
|
async function openCampJournalWithPointer(page) {
|
|
const candidates = await page.evaluate(() => {
|
|
const camp = window.__HEROS_DEBUG__?.camp?.();
|
|
const view = camp?.campaignObjectiveJournal?.view;
|
|
const progressTab = camp?.campTabs?.find(
|
|
(tab) => tab.id === 'progress'
|
|
);
|
|
const bounds = (candidate) =>
|
|
candidate?.bounds ?? candidate ?? null;
|
|
const proposed = [
|
|
{
|
|
id: 'progress tab',
|
|
bounds:
|
|
progressTab?.interactive === false
|
|
? null
|
|
: bounds(progressTab)
|
|
},
|
|
{
|
|
id: 'journal trigger',
|
|
bounds:
|
|
bounds(view?.triggerBounds) ??
|
|
bounds(view?.toggleButtonBounds) ??
|
|
bounds(view?.openButtonBounds) ??
|
|
bounds(view?.buttonBounds) ??
|
|
bounds(view?.trigger) ??
|
|
bounds(view?.toggleButton) ??
|
|
bounds(view?.openButton)
|
|
}
|
|
].filter((candidate) => candidate.bounds);
|
|
return proposed.filter(
|
|
(candidate, index, entries) =>
|
|
entries.findIndex(
|
|
(entry) =>
|
|
JSON.stringify(entry.bounds) ===
|
|
JSON.stringify(candidate.bounds)
|
|
) === index
|
|
);
|
|
});
|
|
|
|
assert(
|
|
candidates.length > 0,
|
|
`${renderer}: no interactive progress tab or journal trigger ` +
|
|
'was exposed.'
|
|
);
|
|
for (const candidate of candidates) {
|
|
assertBoundsContained(
|
|
assertFiniteBounds(
|
|
candidate.bounds,
|
|
`${renderer} ${candidate.id}`
|
|
),
|
|
desktopBrowserViewportBounds,
|
|
`${renderer} ${candidate.id} in viewport`
|
|
);
|
|
await clickNamedSceneBounds(
|
|
page,
|
|
'CampScene',
|
|
candidate.bounds
|
|
);
|
|
try {
|
|
await waitForJournalVisibility(
|
|
page,
|
|
'CampScene',
|
|
true,
|
|
3500
|
|
);
|
|
return candidate.id;
|
|
} catch {
|
|
// Some campaign builds keep the legacy progress tab and add a
|
|
// separate trigger. Try the next real pointer target.
|
|
}
|
|
}
|
|
|
|
const diagnostic = await page.evaluate(() =>
|
|
window.__HEROS_DEBUG__?.camp?.()
|
|
);
|
|
throw new Error(
|
|
`${renderer}: pointer targets did not open the objective ` +
|
|
`journal: ${JSON.stringify({ candidates, diagnostic })}`
|
|
);
|
|
}
|
|
|
|
async function waitForJournalVisibility(
|
|
page,
|
|
sceneKey,
|
|
expected,
|
|
timeout = 30000
|
|
) {
|
|
await page.waitForFunction(
|
|
({ key, visible }) => {
|
|
const debug = window.__HEROS_DEBUG__;
|
|
const sceneState =
|
|
key === 'CampScene'
|
|
? debug?.camp?.()
|
|
: debug?.scene(key)?.getDebugState?.();
|
|
const view = sceneState?.campaignObjectiveJournal?.view;
|
|
const actual =
|
|
typeof view?.visible === 'boolean'
|
|
? view.visible
|
|
: typeof view?.open === 'boolean'
|
|
? view.open
|
|
: typeof view?.isOpen === 'boolean'
|
|
? view.isOpen
|
|
: typeof view?.panelVisible === 'boolean'
|
|
? view.panelVisible
|
|
: typeof view?.panel?.visible === 'boolean'
|
|
? view.panel.visible
|
|
: null;
|
|
return actual === visible;
|
|
},
|
|
{ key: sceneKey, visible: expected },
|
|
{ timeout }
|
|
);
|
|
await afterTwoFrames(page);
|
|
}
|
|
|
|
async function readSceneJournal(page, sceneKey) {
|
|
const journal = await page.evaluate((key) => {
|
|
const debug = window.__HEROS_DEBUG__;
|
|
const sceneState =
|
|
key === 'CampScene'
|
|
? debug?.camp?.()
|
|
: debug?.scene(key)?.getDebugState?.();
|
|
return sceneState?.campaignObjectiveJournal ?? null;
|
|
}, sceneKey);
|
|
assert(
|
|
journal?.snapshot && journal?.view,
|
|
`${renderer}: missing ${sceneKey} campaignObjectiveJournal ` +
|
|
`debug contract: ${JSON.stringify(journal)}`
|
|
);
|
|
return journal;
|
|
}
|
|
|
|
function assertJournalContract(journal, label) {
|
|
const { snapshot, view } = journal;
|
|
assert(
|
|
isJournalOpen(view),
|
|
`${label}: debug view did not report an open journal.`
|
|
);
|
|
['current', 'recentCompleted', 'nextClue'].forEach((key) => {
|
|
assertMeaningfulEntry(snapshot?.[key], `${label} ${key}`);
|
|
});
|
|
|
|
const panel = assertFiniteBounds(
|
|
findBounds(view, [
|
|
'panelBounds',
|
|
'overlayBounds',
|
|
'dialogBounds',
|
|
'bounds'
|
|
]) ?? findNestedBounds(view.panel),
|
|
`${label} panel`
|
|
);
|
|
assertBoundsContained(
|
|
panel,
|
|
desktopBrowserViewportBounds,
|
|
`${label} panel in viewport`
|
|
);
|
|
|
|
const buttons = collectButtons(view);
|
|
assert(
|
|
buttons.length > 0,
|
|
`${label}: the view must expose at least one button bound.`
|
|
);
|
|
buttons.forEach((button, index) => {
|
|
const bounds = assertFiniteBounds(
|
|
button.bounds,
|
|
`${label} button ${button.id ?? index + 1}`
|
|
);
|
|
assertBoundsContained(
|
|
bounds,
|
|
desktopBrowserViewportBounds,
|
|
`${label} button ${button.id ?? index + 1} in viewport`
|
|
);
|
|
if (
|
|
button.id !== 'trigger' &&
|
|
button.id !== 'toggle' &&
|
|
boundsCenterInside(bounds, panel)
|
|
) {
|
|
assertBoundsContained(
|
|
bounds,
|
|
panel,
|
|
`${label} button ${button.id ?? index + 1} in panel`
|
|
);
|
|
}
|
|
});
|
|
|
|
const cards = collectCards(view);
|
|
assert.equal(
|
|
cards.length,
|
|
3,
|
|
`${label}: expected current, recent-completed, and next-clue ` +
|
|
`cards: ${JSON.stringify(cards)}`
|
|
);
|
|
const cardBounds = cards.map((card, index) => {
|
|
const bounds = assertFiniteBounds(
|
|
card.bounds,
|
|
`${label} card ${card.id ?? index + 1}`
|
|
);
|
|
assertBoundsContained(
|
|
bounds,
|
|
panel,
|
|
`${label} card ${card.id ?? index + 1} in panel`
|
|
);
|
|
assertBoundsContained(
|
|
bounds,
|
|
desktopBrowserViewportBounds,
|
|
`${label} card ${card.id ?? index + 1} in viewport`
|
|
);
|
|
const titleBounds = assertFiniteBounds(
|
|
findBounds(card.source, [
|
|
'titleBounds',
|
|
'headingBounds',
|
|
'labelBounds'
|
|
]),
|
|
`${label} card ${card.id ?? index + 1} title`
|
|
);
|
|
const bodyBounds = assertFiniteBounds(
|
|
findBounds(card.source, [
|
|
'bodyBounds',
|
|
'detailBounds',
|
|
'descriptionBounds',
|
|
'textBounds'
|
|
]),
|
|
`${label} card ${card.id ?? index + 1} body`
|
|
);
|
|
[titleBounds, bodyBounds].forEach((textBounds, textIndex) => {
|
|
assertBoundsContained(
|
|
textBounds,
|
|
bounds,
|
|
`${label} card ${card.id ?? index + 1} ` +
|
|
`${textIndex === 0 ? 'title' : 'body'} in card`
|
|
);
|
|
assertBoundsContained(
|
|
textBounds,
|
|
panel,
|
|
`${label} card ${card.id ?? index + 1} text in panel`
|
|
);
|
|
});
|
|
return bounds;
|
|
});
|
|
assertPairwiseNonOverlapping(cardBounds, `${label} cards`);
|
|
|
|
const visibleText = collectVisibleText(view);
|
|
const joinedVisibleText = visibleText.join('\n');
|
|
assert(
|
|
/◆\s*진행/.test(joinedVisibleText),
|
|
`${label}: visible text is missing "◆ 진행": ` +
|
|
JSON.stringify(visibleText)
|
|
);
|
|
assert(
|
|
/✓\s*완료/.test(joinedVisibleText),
|
|
`${label}: visible text is missing "✓ 완료": ` +
|
|
JSON.stringify(visibleText)
|
|
);
|
|
assert(
|
|
/\?\s*실마리/.test(joinedVisibleText),
|
|
`${label}: visible text is missing "? 실마리": ` +
|
|
JSON.stringify(visibleText)
|
|
);
|
|
|
|
const publicText = [
|
|
joinedVisibleText,
|
|
...['current', 'recentCompleted', 'nextClue'].map((key) =>
|
|
collectStrings(snapshot[key]).join('\n')
|
|
)
|
|
].join('\n');
|
|
forbiddenFutureTerms.forEach((term) => {
|
|
assert(
|
|
!publicText.includes(term),
|
|
`${label}: unrevealed future term "${term}" leaked into ` +
|
|
`the visible journal: ${publicText}`
|
|
);
|
|
});
|
|
|
|
const cardKeys = cards.map(
|
|
(card, index) => card.id ?? `card-${index + 1}`
|
|
);
|
|
assert.equal(
|
|
new Set(cardKeys).size,
|
|
cardKeys.length,
|
|
`${label}: duplicate card identities were exposed: ` +
|
|
JSON.stringify(cardKeys)
|
|
);
|
|
|
|
const objectCount = readObjectCount(view, cards, buttons);
|
|
assert(
|
|
Number.isInteger(objectCount) && objectCount > 0,
|
|
`${label}: expected a finite positive display-object count.`
|
|
);
|
|
return { cardKeys, objectCount };
|
|
}
|
|
|
|
function assertFirstVictoryMeaning(snapshot, view) {
|
|
const currentText = collectStrings(snapshot.current).join(' ');
|
|
const recentText = collectStrings(
|
|
snapshot.recentCompleted
|
|
).join(' ');
|
|
const nextText = collectStrings(snapshot.nextClue).join(' ');
|
|
const visibleText = collectVisibleText(view).join(' ');
|
|
|
|
assert(
|
|
/정찰막|둘러보기/.test(currentText),
|
|
`${renderer}: first-camp current objective should direct the ` +
|
|
`player to the scout tent: ${currentText}`
|
|
);
|
|
assert(
|
|
/탁현의 전투|한석 선봉/.test(recentText),
|
|
`${renderer}: first-camp recent completion should recount the ` +
|
|
`first victory: ${recentText}`
|
|
);
|
|
assert(
|
|
nextText.trim().length >= 2,
|
|
`${renderer}: first-camp next clue must be meaningful.`
|
|
);
|
|
assert.notEqual(
|
|
normalizeWhitespace(nextText),
|
|
normalizeWhitespace(currentText),
|
|
`${renderer}: next clue must not duplicate the current action.`
|
|
);
|
|
assert.notEqual(
|
|
normalizeWhitespace(nextText),
|
|
normalizeWhitespace(recentText),
|
|
`${renderer}: next clue must not duplicate the recent result.`
|
|
);
|
|
assert(
|
|
/정찰막|둘러보기/.test(visibleText) &&
|
|
/탁현의 전투|한석 선봉/.test(visibleText),
|
|
`${renderer}: meaningful first-camp journal content was not ` +
|
|
`rendered visibly: ${visibleText}`
|
|
);
|
|
}
|
|
|
|
async function verifyExplorationJournalIfAvailable(page) {
|
|
const available = await page.evaluate(
|
|
() =>
|
|
typeof window.__HEROS_DEBUG__
|
|
?.goToCampVisitExploration === 'function'
|
|
);
|
|
if (!available) {
|
|
return 'skipped (debug navigation unavailable)';
|
|
}
|
|
|
|
await page.evaluate(async (requestedVisitId) => {
|
|
await window.__HEROS_DEBUG__.goToCampVisitExploration(
|
|
requestedVisitId
|
|
);
|
|
}, visitId);
|
|
await page.waitForFunction(
|
|
() => {
|
|
const debug = window.__HEROS_DEBUG__;
|
|
const state = debug
|
|
?.scene('CampVisitExplorationScene')
|
|
?.getDebugState?.();
|
|
return (
|
|
debug
|
|
?.activeScenes?.()
|
|
.includes('CampVisitExplorationScene') &&
|
|
state?.ready === true
|
|
);
|
|
},
|
|
undefined,
|
|
{ timeout: 90000 }
|
|
);
|
|
await afterTwoFrames(page);
|
|
|
|
const state = await page.evaluate(() =>
|
|
window.__HEROS_DEBUG__
|
|
?.scene('CampVisitExplorationScene')
|
|
?.getDebugState?.()
|
|
);
|
|
if (!state?.campaignObjectiveJournal?.view) {
|
|
return 'skipped (exploration journal contract unavailable)';
|
|
}
|
|
|
|
const savesBefore = await readCampaignSaves(page);
|
|
await page.keyboard.press('j');
|
|
await waitForJournalVisibility(
|
|
page,
|
|
'CampVisitExplorationScene',
|
|
true
|
|
);
|
|
const openJournal = await readSceneJournal(
|
|
page,
|
|
'CampVisitExplorationScene'
|
|
);
|
|
assertJournalContract(
|
|
openJournal,
|
|
`${renderer} scout-tent exploration`
|
|
);
|
|
await page.keyboard.press('Escape');
|
|
await waitForJournalVisibility(
|
|
page,
|
|
'CampVisitExplorationScene',
|
|
false
|
|
);
|
|
assert.deepEqual(
|
|
await readCampaignSaves(page),
|
|
savesBefore,
|
|
`${renderer}: viewing the exploration journal changed a save.`
|
|
);
|
|
return 'verified';
|
|
}
|
|
|
|
async function verifyModalInputIsolation(page) {
|
|
if (renderer !== 'canvas') {
|
|
return 'covered in canvas';
|
|
}
|
|
|
|
const verifiedScenes = [];
|
|
const visitScene = await page.evaluate(() => {
|
|
const scene = window.__HEROS_DEBUG__
|
|
?.scene('CampVisitExplorationScene');
|
|
if (!scene || typeof scene.openChoicePanel !== 'function') {
|
|
return false;
|
|
}
|
|
scene.openChoicePanel();
|
|
return true;
|
|
});
|
|
if (visitScene) {
|
|
await page.waitForFunction(
|
|
() => {
|
|
const state = window.__HEROS_DEBUG__
|
|
?.scene('CampVisitExplorationScene')
|
|
?.getDebugState?.();
|
|
return state?.choice?.open === true &&
|
|
state?.choice?.ready === true;
|
|
}
|
|
);
|
|
const before = await page.evaluate(() => {
|
|
const state = window.__HEROS_DEBUG__
|
|
?.scene('CampVisitExplorationScene')
|
|
?.getDebugState?.();
|
|
return {
|
|
choiceOpen: state?.choice?.open ?? false,
|
|
choiceId: state?.visit?.choiceId ?? null,
|
|
completed: state?.visit?.completed ?? false,
|
|
dialogueActive: state?.dialogue?.active ?? false
|
|
};
|
|
});
|
|
const savesBefore = await readCampaignSaves(page);
|
|
await page.keyboard.press('j');
|
|
await waitForJournalVisibility(
|
|
page,
|
|
'CampVisitExplorationScene',
|
|
true
|
|
);
|
|
await page.keyboard.press('2');
|
|
await afterTwoFrames(page);
|
|
const after = await page.evaluate(() => {
|
|
const state = window.__HEROS_DEBUG__
|
|
?.scene('CampVisitExplorationScene')
|
|
?.getDebugState?.();
|
|
return {
|
|
choiceOpen: state?.choice?.open ?? false,
|
|
choiceId: state?.visit?.choiceId ?? null,
|
|
completed: state?.visit?.completed ?? false,
|
|
dialogueActive: state?.dialogue?.active ?? false,
|
|
journalVisible:
|
|
state?.campaignObjectiveJournal?.view?.visible ?? false
|
|
};
|
|
});
|
|
assert.deepEqual(
|
|
{
|
|
choiceOpen: after.choiceOpen,
|
|
choiceId: after.choiceId,
|
|
completed: after.completed,
|
|
dialogueActive: after.dialogueActive
|
|
},
|
|
before,
|
|
'canvas: journal leaked numeric input into the exploration choice.'
|
|
);
|
|
assert.equal(
|
|
after.journalVisible,
|
|
true,
|
|
'canvas: exploration journal closed while blocking a choice.'
|
|
);
|
|
assert.deepEqual(
|
|
await readCampaignSaves(page),
|
|
savesBefore,
|
|
'canvas: blocked exploration choice changed a save.'
|
|
);
|
|
await page.keyboard.press('Escape');
|
|
await waitForJournalVisibility(
|
|
page,
|
|
'CampVisitExplorationScene',
|
|
false
|
|
);
|
|
verifiedScenes.push('exploration');
|
|
}
|
|
|
|
const militiaAvailable = await page.evaluate(
|
|
() => typeof window.__HEROS_DEBUG__?.goToMilitiaCamp === 'function'
|
|
);
|
|
if (militiaAvailable) {
|
|
await page.evaluate(async () => {
|
|
await window.__HEROS_DEBUG__.goToMilitiaCamp();
|
|
});
|
|
await page.waitForFunction(
|
|
() => {
|
|
const debug = window.__HEROS_DEBUG__;
|
|
return debug?.activeScenes?.().includes(
|
|
'PrologueMilitiaCampScene'
|
|
) && debug?.militiaCamp?.()?.ready === true;
|
|
},
|
|
undefined,
|
|
{ timeout: 90000 }
|
|
);
|
|
await page.evaluate(() => {
|
|
const scene = window.__HEROS_DEBUG__
|
|
?.scene('PrologueMilitiaCampScene');
|
|
for (let index = 0; index < 20 && scene?.dialogueState; index += 1) {
|
|
scene.advanceDialogue();
|
|
}
|
|
scene?.openFirstCommandChoice?.();
|
|
});
|
|
await page.waitForFunction(
|
|
() => {
|
|
const choice = window.__HEROS_DEBUG__
|
|
?.militiaCamp?.()?.commandChoice;
|
|
return choice?.open === true && choice?.ready === true;
|
|
}
|
|
);
|
|
const before = await page.evaluate(() => {
|
|
const state = window.__HEROS_DEBUG__?.militiaCamp?.();
|
|
return {
|
|
open: state?.commandChoice?.open ?? false,
|
|
pendingId: state?.commandChoice?.pendingId ?? null,
|
|
selectedId: state?.commandChoice?.selectedId ?? null,
|
|
dialogueActive: state?.dialogue?.active ?? false
|
|
};
|
|
});
|
|
const savesBefore = await readCampaignSaves(page);
|
|
await page.keyboard.press('j');
|
|
await waitForJournalVisibility(
|
|
page,
|
|
'PrologueMilitiaCampScene',
|
|
true
|
|
);
|
|
await page.keyboard.press('2');
|
|
await page.keyboard.press('Enter');
|
|
await afterTwoFrames(page);
|
|
const after = await page.evaluate(() => {
|
|
const state = window.__HEROS_DEBUG__?.militiaCamp?.();
|
|
return {
|
|
open: state?.commandChoice?.open ?? false,
|
|
pendingId: state?.commandChoice?.pendingId ?? null,
|
|
selectedId: state?.commandChoice?.selectedId ?? null,
|
|
dialogueActive: state?.dialogue?.active ?? false,
|
|
journalVisible:
|
|
state?.campaignObjectiveJournal?.view?.visible ?? false
|
|
};
|
|
});
|
|
assert.deepEqual(
|
|
{
|
|
open: after.open,
|
|
pendingId: after.pendingId,
|
|
selectedId: after.selectedId,
|
|
dialogueActive: after.dialogueActive
|
|
},
|
|
before,
|
|
'canvas: journal leaked 2/Enter into the militia command choice.'
|
|
);
|
|
assert.equal(
|
|
after.journalVisible,
|
|
true,
|
|
'canvas: militia journal closed while blocking a command choice.'
|
|
);
|
|
assert.deepEqual(
|
|
await readCampaignSaves(page),
|
|
savesBefore,
|
|
'canvas: blocked militia command input changed a save.'
|
|
);
|
|
await page.keyboard.press('Escape');
|
|
await waitForJournalVisibility(
|
|
page,
|
|
'PrologueMilitiaCampScene',
|
|
false
|
|
);
|
|
verifiedScenes.push('militia command');
|
|
}
|
|
|
|
const cityAvailable = await page.evaluate(
|
|
() => typeof window.__HEROS_DEBUG__?.goToCityStay === 'function'
|
|
);
|
|
if (cityAvailable) {
|
|
await page.evaluate(async () => {
|
|
await window.__HEROS_DEBUG__.goToCityStay('xuzhou');
|
|
});
|
|
await page.waitForFunction(
|
|
() => {
|
|
const debug = window.__HEROS_DEBUG__;
|
|
return debug?.activeScenes?.().includes('CityStayScene') &&
|
|
debug?.cityStay?.()?.ready === true;
|
|
},
|
|
undefined,
|
|
{ timeout: 90000 }
|
|
);
|
|
await page.evaluate(() => {
|
|
const scene = window.__HEROS_DEBUG__?.scene('CityStayScene');
|
|
for (let index = 0; index < 20 && scene?.dialogueState; index += 1) {
|
|
scene.advanceDialogue();
|
|
}
|
|
scene?.openChoicePanel?.();
|
|
});
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.cityStay?.()?.choice?.open === true
|
|
);
|
|
const before = await page.evaluate(() => {
|
|
const state = window.__HEROS_DEBUG__?.cityStay?.();
|
|
return {
|
|
choiceOpen: state?.choice?.open ?? false,
|
|
dialogueActive: state?.dialogue?.active ?? false,
|
|
campaign: state?.campaign ?? null
|
|
};
|
|
});
|
|
const savesBefore = await readCampaignSaves(page);
|
|
await page.keyboard.press('j');
|
|
await waitForJournalVisibility(page, 'CityStayScene', true);
|
|
await page.keyboard.press('2');
|
|
await afterTwoFrames(page);
|
|
const after = await page.evaluate(() => {
|
|
const state = window.__HEROS_DEBUG__?.cityStay?.();
|
|
return {
|
|
choiceOpen: state?.choice?.open ?? false,
|
|
dialogueActive: state?.dialogue?.active ?? false,
|
|
campaign: state?.campaign ?? null,
|
|
journalVisible:
|
|
state?.campaignObjectiveJournal?.view?.visible ?? false
|
|
};
|
|
});
|
|
assert.deepEqual(
|
|
{
|
|
choiceOpen: after.choiceOpen,
|
|
dialogueActive: after.dialogueActive,
|
|
campaign: after.campaign
|
|
},
|
|
before,
|
|
'canvas: journal leaked numeric input into the city choice.'
|
|
);
|
|
assert.equal(
|
|
after.journalVisible,
|
|
true,
|
|
'canvas: city journal closed while blocking a dialogue choice.'
|
|
);
|
|
assert.deepEqual(
|
|
await readCampaignSaves(page),
|
|
savesBefore,
|
|
'canvas: blocked city choice changed a save.'
|
|
);
|
|
await page.keyboard.press('Escape');
|
|
await waitForJournalVisibility(page, 'CityStayScene', false);
|
|
verifiedScenes.push('city dialogue');
|
|
}
|
|
|
|
return verifiedScenes.length > 0
|
|
? `verified (${verifiedScenes.join(', ')})`
|
|
: 'skipped (debug navigation unavailable)';
|
|
}
|
|
|
|
function isJournalOpen(view) {
|
|
if (typeof view?.visible === 'boolean') {
|
|
return view.visible;
|
|
}
|
|
if (typeof view?.open === 'boolean') {
|
|
return view.open;
|
|
}
|
|
if (typeof view?.isOpen === 'boolean') {
|
|
return view.isOpen;
|
|
}
|
|
if (typeof view?.panelVisible === 'boolean') {
|
|
return view.panelVisible;
|
|
}
|
|
return view?.panel?.visible === true;
|
|
}
|
|
|
|
function findBounds(source, keys) {
|
|
for (const key of keys) {
|
|
const value = source?.[key];
|
|
const bounds = findNestedBounds(value);
|
|
if (bounds) {
|
|
return bounds;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function findNestedBounds(value) {
|
|
if (!value || typeof value !== 'object') {
|
|
return null;
|
|
}
|
|
if (
|
|
Number.isFinite(value.x) &&
|
|
Number.isFinite(value.y) &&
|
|
Number.isFinite(value.width) &&
|
|
Number.isFinite(value.height)
|
|
) {
|
|
return {
|
|
x: value.x,
|
|
y: value.y,
|
|
width: value.width,
|
|
height: value.height
|
|
};
|
|
}
|
|
return findNestedBounds(value.bounds);
|
|
}
|
|
|
|
function collectButtons(view) {
|
|
const buttons = [];
|
|
const append = (id, candidate) => {
|
|
const bounds = findNestedBounds(candidate);
|
|
if (!bounds) {
|
|
return;
|
|
}
|
|
const signature = JSON.stringify(bounds);
|
|
if (
|
|
buttons.some(
|
|
(button) => JSON.stringify(button.bounds) === signature
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
buttons.push({ id, bounds });
|
|
};
|
|
|
|
[
|
|
['trigger', view.triggerBounds],
|
|
['toggle', view.toggleButtonBounds],
|
|
['open', view.openButtonBounds],
|
|
['button', view.buttonBounds],
|
|
['close', view.closeButtonBounds],
|
|
['trigger', view.trigger],
|
|
['toggle', view.toggleButton],
|
|
['open', view.openButton],
|
|
['close', view.closeButton]
|
|
].forEach(([id, candidate]) => append(id, candidate));
|
|
for (const collectionKey of [
|
|
'buttons',
|
|
'actions',
|
|
'controls'
|
|
]) {
|
|
(view?.[collectionKey] ?? []).forEach(
|
|
(button, index) =>
|
|
append(
|
|
button?.id ??
|
|
button?.role ??
|
|
`${collectionKey}-${index + 1}`,
|
|
button
|
|
)
|
|
);
|
|
}
|
|
return buttons;
|
|
}
|
|
|
|
function collectCards(view) {
|
|
const cards = [];
|
|
const append = (id, source) => {
|
|
const bounds = findNestedBounds(source);
|
|
if (!bounds) {
|
|
return;
|
|
}
|
|
const signature = JSON.stringify(bounds);
|
|
if (
|
|
cards.some(
|
|
(card) => JSON.stringify(card.bounds) === signature
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
cards.push({
|
|
id: source?.id ?? source?.role ?? id,
|
|
bounds,
|
|
source
|
|
});
|
|
};
|
|
|
|
for (const collectionKey of ['cards', 'sections', 'entries']) {
|
|
(view?.[collectionKey] ?? []).forEach((card, index) =>
|
|
append(
|
|
card?.id ??
|
|
card?.role ??
|
|
`${collectionKey}-${index + 1}`,
|
|
card
|
|
)
|
|
);
|
|
}
|
|
[
|
|
['current', view.currentCard ?? view.current],
|
|
[
|
|
'recentCompleted',
|
|
view.recentCompletedCard ?? view.recentCompleted
|
|
],
|
|
['nextClue', view.nextClueCard ?? view.nextClue]
|
|
].forEach(([id, card]) => append(id, card));
|
|
return cards;
|
|
}
|
|
|
|
function collectVisibleText(view) {
|
|
if (Array.isArray(view?.visibleText)) {
|
|
return view.visibleText
|
|
.flatMap((value) =>
|
|
typeof value === 'string'
|
|
? [value]
|
|
: collectStrings(value)
|
|
)
|
|
.filter(Boolean);
|
|
}
|
|
if (typeof view?.visibleText === 'string') {
|
|
return [view.visibleText];
|
|
}
|
|
return [
|
|
...collectStrings(view?.title),
|
|
...collectStrings(view?.cards),
|
|
...collectStrings(view?.sections)
|
|
];
|
|
}
|
|
|
|
function collectStrings(value, seen = new Set()) {
|
|
if (typeof value === 'string') {
|
|
return value.trim() ? [value.trim()] : [];
|
|
}
|
|
if (
|
|
value === null ||
|
|
value === undefined ||
|
|
typeof value !== 'object' ||
|
|
seen.has(value)
|
|
) {
|
|
return [];
|
|
}
|
|
seen.add(value);
|
|
if (Array.isArray(value)) {
|
|
return value.flatMap((entry) =>
|
|
collectStrings(entry, seen)
|
|
);
|
|
}
|
|
return Object.entries(value).flatMap(([key, entry]) =>
|
|
/bounds|texture|color|font|id$/i.test(key)
|
|
? []
|
|
: collectStrings(entry, seen)
|
|
);
|
|
}
|
|
|
|
function assertMeaningfulEntry(entry, label) {
|
|
const strings = collectStrings(entry);
|
|
assert(
|
|
entry &&
|
|
strings.some(
|
|
(value) =>
|
|
normalizeWhitespace(value).length >= 2 &&
|
|
!['current', 'completed', 'clue'].includes(
|
|
value.toLowerCase()
|
|
)
|
|
),
|
|
`${label} must contain meaningful player-facing text: ` +
|
|
JSON.stringify(entry)
|
|
);
|
|
}
|
|
|
|
function readObjectCount(view, cards, buttons) {
|
|
const explicit = [
|
|
view?.objectCount,
|
|
view?.displayObjectCount,
|
|
view?.childCount,
|
|
view?.objects?.length,
|
|
view?.displayObjects?.length
|
|
].find(
|
|
(value) => Number.isInteger(value) && value > 0
|
|
);
|
|
return explicit ?? cards.length + buttons.length + 1;
|
|
}
|
|
|
|
function stableJournalContent(journal) {
|
|
return {
|
|
snapshot: stripPresentationState(journal.snapshot),
|
|
visibleText: collectVisibleText(journal.view).map(
|
|
normalizeWhitespace
|
|
),
|
|
cards: collectCards(journal.view).map((card) => ({
|
|
id: card.id,
|
|
content: stripPresentationState(card.source)
|
|
}))
|
|
};
|
|
}
|
|
|
|
function stripPresentationState(value) {
|
|
if (Array.isArray(value)) {
|
|
return value.map(stripPresentationState);
|
|
}
|
|
if (!value || typeof value !== 'object') {
|
|
return value;
|
|
}
|
|
return Object.fromEntries(
|
|
Object.entries(value)
|
|
.filter(
|
|
([key]) =>
|
|
!/bounds|^(x|y|width|height|visible|open|isOpen|panelVisible|interactive|hovered|focused|objectCount|displayObjectCount|childCount)$/i.test(
|
|
key
|
|
)
|
|
)
|
|
.map(([key, entry]) => [
|
|
key,
|
|
stripPresentationState(entry)
|
|
])
|
|
);
|
|
}
|
|
|
|
function normalizeWhitespace(value) {
|
|
return String(value).replace(/\s+/g, ' ').trim();
|
|
}
|
|
|
|
function assertFiniteBounds(bounds, label) {
|
|
assert(
|
|
bounds &&
|
|
Number.isFinite(bounds.x) &&
|
|
Number.isFinite(bounds.y) &&
|
|
Number.isFinite(bounds.width) &&
|
|
Number.isFinite(bounds.height) &&
|
|
bounds.width > 0 &&
|
|
bounds.height > 0,
|
|
`Expected finite positive bounds for ${label}: ` +
|
|
JSON.stringify(bounds)
|
|
);
|
|
return bounds;
|
|
}
|
|
|
|
function assertBoundsContained(inner, outer, label) {
|
|
const tolerance = 1;
|
|
assert(
|
|
inner.x >= outer.x - tolerance &&
|
|
inner.y >= outer.y - tolerance &&
|
|
inner.x + inner.width <=
|
|
outer.x + outer.width + tolerance &&
|
|
inner.y + inner.height <=
|
|
outer.y + outer.height + tolerance,
|
|
`${label} escaped its container: ` +
|
|
JSON.stringify({ inner, outer })
|
|
);
|
|
}
|
|
|
|
function boundsCenterInside(inner, outer) {
|
|
const x = inner.x + inner.width / 2;
|
|
const y = inner.y + inner.height / 2;
|
|
return (
|
|
x >= outer.x &&
|
|
y >= outer.y &&
|
|
x <= outer.x + outer.width &&
|
|
y <= outer.y + outer.height
|
|
);
|
|
}
|
|
|
|
function boundsOverlap(left, right) {
|
|
const tolerance = 1;
|
|
return (
|
|
left.x < right.x + right.width - tolerance &&
|
|
left.x + left.width > right.x + tolerance &&
|
|
left.y < right.y + right.height - tolerance &&
|
|
left.y + left.height > right.y + tolerance
|
|
);
|
|
}
|
|
|
|
function assertPairwiseNonOverlapping(bounds, label) {
|
|
for (let leftIndex = 0; leftIndex < bounds.length; leftIndex += 1) {
|
|
for (
|
|
let rightIndex = leftIndex + 1;
|
|
rightIndex < bounds.length;
|
|
rightIndex += 1
|
|
) {
|
|
assert(
|
|
!boundsOverlap(bounds[leftIndex], bounds[rightIndex]),
|
|
`${label} ${leftIndex + 1} and ${rightIndex + 1} ` +
|
|
`overlap: ${JSON.stringify({
|
|
left: bounds[leftIndex],
|
|
right: bounds[rightIndex]
|
|
})}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function clickNamedSceneBounds(
|
|
page,
|
|
sceneKey,
|
|
bounds
|
|
) {
|
|
const point = await page.evaluate(
|
|
({ key, requestedBounds }) => {
|
|
const scene = window.__HEROS_DEBUG__?.scene(key);
|
|
const canvasBounds =
|
|
document.querySelector('canvas')?.getBoundingClientRect();
|
|
if (!scene || !canvasBounds) {
|
|
return null;
|
|
}
|
|
return {
|
|
x:
|
|
canvasBounds.left +
|
|
(requestedBounds.x +
|
|
requestedBounds.width / 2) *
|
|
canvasBounds.width /
|
|
scene.scale.width,
|
|
y:
|
|
canvasBounds.top +
|
|
(requestedBounds.y +
|
|
requestedBounds.height / 2) *
|
|
canvasBounds.height /
|
|
scene.scale.height
|
|
};
|
|
},
|
|
{ key: sceneKey, requestedBounds: bounds }
|
|
);
|
|
assert(
|
|
point &&
|
|
Number.isFinite(point.x) &&
|
|
Number.isFinite(point.y),
|
|
`Unable to map ${sceneKey} bounds ${JSON.stringify(bounds)}.`
|
|
);
|
|
await page.mouse.click(point.x, point.y);
|
|
}
|
|
|
|
async function readCampaignSaves(page) {
|
|
return page.evaluate(() =>
|
|
Object.fromEntries(
|
|
Object.keys(window.localStorage)
|
|
.filter((key) =>
|
|
key.startsWith('heros-web:campaign-state')
|
|
)
|
|
.sort()
|
|
.map((key) => [
|
|
key,
|
|
window.localStorage.getItem(key)
|
|
])
|
|
)
|
|
);
|
|
}
|
|
|
|
async function afterTwoFrames(page) {
|
|
await page.evaluate(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
requestAnimationFrame(() =>
|
|
requestAnimationFrame(resolve)
|
|
);
|
|
})
|
|
);
|
|
}
|
|
|
|
async function readRuntimeProbe(page, sceneKey) {
|
|
return page.evaluate((key) => {
|
|
const game = window.__HEROS_GAME__;
|
|
const scene = game?.scene.getScene(key);
|
|
const canvas = document.querySelector('canvas');
|
|
const canvasBounds = canvas?.getBoundingClientRect();
|
|
return {
|
|
viewport: {
|
|
width: window.innerWidth,
|
|
height: window.innerHeight,
|
|
dpr: window.devicePixelRatio,
|
|
zoom: window.visualViewport?.scale ?? 1
|
|
},
|
|
canvas: canvas
|
|
? {
|
|
width: canvas.width,
|
|
height: canvas.height,
|
|
bounds: canvasBounds
|
|
? {
|
|
x: canvasBounds.x,
|
|
y: canvasBounds.y,
|
|
width: canvasBounds.width,
|
|
height: canvasBounds.height
|
|
}
|
|
: null
|
|
}
|
|
: null,
|
|
renderer: {
|
|
requested: new URLSearchParams(
|
|
window.location.search
|
|
).get('renderer'),
|
|
type: game?.renderer?.type ?? null,
|
|
name: game?.renderer?.constructor?.name ?? null
|
|
},
|
|
scene: scene
|
|
? {
|
|
width: scene.scale.width,
|
|
height: scene.scale.height
|
|
}
|
|
: null
|
|
};
|
|
}, sceneKey);
|
|
}
|
|
|
|
function assertRuntimeBaseline(runtime, requestedRenderer) {
|
|
assert.deepEqual(runtime.viewport, {
|
|
width: desktopBrowserViewport.width,
|
|
height: desktopBrowserViewport.height,
|
|
dpr: desktopBrowserDeviceScaleFactor,
|
|
zoom: 1
|
|
});
|
|
assert.deepEqual(runtime.canvas, {
|
|
width: desktopBrowserViewport.width,
|
|
height: desktopBrowserViewport.height,
|
|
bounds: {
|
|
x: 0,
|
|
y: 0,
|
|
width: desktopBrowserViewport.width,
|
|
height: desktopBrowserViewport.height
|
|
}
|
|
});
|
|
assert.deepEqual(runtime.scene, {
|
|
width: desktopBrowserViewport.width,
|
|
height: desktopBrowserViewport.height
|
|
});
|
|
assert.equal(runtime.renderer.requested, requestedRenderer);
|
|
assert.equal(
|
|
runtime.renderer.type,
|
|
requestedRenderer === 'canvas' ? 1 : 2,
|
|
`Expected ${requestedRenderer} renderer: ` +
|
|
JSON.stringify(runtime.renderer)
|
|
);
|
|
}
|
|
|
|
async function ensureLocalServer(url) {
|
|
if (await canReach(url)) {
|
|
return undefined;
|
|
}
|
|
|
|
const parsed = new URL(url);
|
|
if (
|
|
!['localhost', '127.0.0.1', '0.0.0.0'].includes(
|
|
parsed.hostname
|
|
)
|
|
) {
|
|
throw new Error(`No server responded at ${url}`);
|
|
}
|
|
|
|
const stderr = [];
|
|
const child = spawn(
|
|
process.execPath,
|
|
[
|
|
'node_modules/vite/bin/vite.js',
|
|
'--host',
|
|
'127.0.0.1',
|
|
'--port',
|
|
parsed.port || '41808',
|
|
'--strictPort'
|
|
],
|
|
{
|
|
cwd: process.cwd(),
|
|
env: process.env,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
windowsHide: true
|
|
}
|
|
);
|
|
child.stderr.on('data', (chunk) =>
|
|
stderr.push(chunk.toString())
|
|
);
|
|
child.stdout.on('data', () => {});
|
|
|
|
for (let attempt = 0; attempt < 120; attempt += 1) {
|
|
if (await canReach(url)) {
|
|
return child;
|
|
}
|
|
await delay(250);
|
|
}
|
|
|
|
await stopServerProcess(child);
|
|
throw new Error(
|
|
`Vite server did not start at ${url}\n${stderr.join('')}`
|
|
);
|
|
}
|
|
|
|
async function canReach(url) {
|
|
try {
|
|
const response = await fetch(url, {
|
|
signal: AbortSignal.timeout(1000)
|
|
});
|
|
return response.ok;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function delay(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
async function stopServerProcess(child) {
|
|
if (!child || child.exitCode !== null) {
|
|
return;
|
|
}
|
|
const exited = new Promise((resolve) =>
|
|
child.once('exit', resolve)
|
|
);
|
|
child.kill();
|
|
await Promise.race([exited, delay(5000)]);
|
|
if (child.exitCode === null) {
|
|
child.kill('SIGKILL');
|
|
await Promise.race([exited, delay(2000)]);
|
|
}
|
|
}
|