forked from phenom/healer-man
451 lines
25 KiB
JavaScript
451 lines
25 KiB
JavaScript
const { DEFAULT_TEST_DUNGEON_TITLE, selectDefaultTestDungeon, openSelectedDungeonRoles } = require('./playtest-defaults.cjs');
|
||
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const os = require('node:os');
|
||
const path = require('node:path');
|
||
|
||
const directory = path.resolve(process.env.HUNTER_FAMILY_PLAYTEST_DIR || 'playtest-artifacts/hunter-families');
|
||
const staticDir = path.resolve('dist');
|
||
const contentDir = path.resolve('content');
|
||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-hunter-families-'));
|
||
function cleanupDataDir() {
|
||
const resolvedDataDir = path.resolve(dataDir);
|
||
const resolvedTempDir = path.resolve(os.tmpdir());
|
||
if (path.dirname(resolvedDataDir) !== resolvedTempDir
|
||
|| !path.basename(resolvedDataDir).startsWith('healer-man-hunter-families-')) {
|
||
throw new Error(`Refusing recursive cleanup outside the dedicated Hunter QA temp directory: ${resolvedDataDir}`);
|
||
}
|
||
fs.rmSync(resolvedDataDir, { recursive: true, force: true });
|
||
}
|
||
const assetDirectory = path.join(staticDir, 'assets');
|
||
if (!fs.existsSync(path.join(staticDir, 'index.html'))) {
|
||
cleanupDataDir();
|
||
throw new Error('A fresh production build is required before this playtest. Build dist, then rerun.');
|
||
}
|
||
const assets = fs.readdirSync(assetDirectory);
|
||
const mainModule = `/assets/${assets.find((file) => /^index-.*\.js$/.test(file))}`;
|
||
const combatModule = `/assets/${assets.find((file) => /^combatStore-.*\.js$/.test(file))}`;
|
||
if (mainModule.endsWith('undefined') || combatModule.endsWith('undefined')) {
|
||
cleanupDataDir();
|
||
throw new Error('Could not locate the production app and combat store modules in dist/assets.');
|
||
}
|
||
fs.mkdirSync(directory, { recursive: true });
|
||
|
||
async function storeSnapshot(page) {
|
||
return page.evaluate(async () => {
|
||
const { shell, combat, game } = await window.hunterFamilyStores();
|
||
const roster = shell.getState();
|
||
const runtime = combat.getState();
|
||
const activePet = runtime.summons.find((summon) => summon.hunterPet && summon.ownerActorId === '__player__');
|
||
const activeProfile = roster.characters.find((character) => character.id === roster.selectedCharacterId);
|
||
return {
|
||
phase: roster.phase,
|
||
selectedCharacter: activeProfile ? {
|
||
name: activeProfile.name,
|
||
classId: activeProfile.classId,
|
||
level: activeProfile.level,
|
||
specializationId: activeProfile.specializationId,
|
||
hunterPetFamily: activeProfile.hunterPetFamily ?? null,
|
||
} : null,
|
||
combat: {
|
||
classId: runtime.classId,
|
||
level: runtime.level,
|
||
family: runtime.hunterPetFamily,
|
||
activePet: activePet ? {
|
||
name: activePet.name,
|
||
family: activePet.hunterPet.family,
|
||
alive: activePet.health > 0,
|
||
health: activePet.health,
|
||
shellShieldSpellId: activePet.hunterPet.shellShield?.spellId ?? null,
|
||
furiousHowlSpellId: activePet.hunterPet.furiousHowl?.spellId ?? null,
|
||
} : null,
|
||
},
|
||
assetStatus: game.getState().assetStatus,
|
||
};
|
||
});
|
||
}
|
||
|
||
async function waitForGameReady(page) {
|
||
await page.locator('.game-shell').waitFor({ timeout: 120_000 });
|
||
const overlay = page.locator('.loading-screen');
|
||
let showedOverlay = await overlay.isVisible().catch(() => false);
|
||
if (!showedOverlay) {
|
||
showedOverlay = await overlay.waitFor({ state: 'visible', timeout: 8_000 }).then(() => true, () => false);
|
||
}
|
||
if (showedOverlay) await overlay.waitFor({ state: 'hidden', timeout: 180_000 });
|
||
await page.waitForFunction(async () => {
|
||
const { game } = await window.hunterFamilyStores();
|
||
return game.getState().assetStatus === 'ready';
|
||
}, null, { timeout: 180_000 });
|
||
await page.locator('canvas').waitFor();
|
||
await page.waitForTimeout(800);
|
||
}
|
||
|
||
async function createHunter(page, name) {
|
||
await page.locator('.character-select-screen').waitFor();
|
||
await page.locator('.roster-list').getByRole('button', { name: /Create character/ }).click();
|
||
await page.locator('.character-create-screen').waitFor();
|
||
await page.locator('.class-grid').getByRole('button', { name: /Hunter/ }).click();
|
||
await page.locator('.creator-specializations').getByRole('button', { name: /Marksmanship/ }).click();
|
||
await page.getByRole('button', { name: /Customize & name/ }).click();
|
||
await page.getByLabel('Character name', { exact: true }).fill(name);
|
||
await page.locator('.character-create-screen').getByRole('button', { name: /Create character/ }).click();
|
||
await page.getByRole('button', { name: new RegExp(`Continue as ${name}`) }).waitFor();
|
||
await page.getByRole('button', { name: new RegExp(`Continue as ${name}`) }).click({ position: { x: 24, y: 30 } });
|
||
await page.locator('.main-menu-screen').waitFor();
|
||
}
|
||
|
||
async function enterDefaultTestDungeon(page) {
|
||
await page.getByRole('button', { name: /Dungeons/ }).click();
|
||
await page.locator('.dungeon-select-screen').waitFor();
|
||
await page.locator('.dungeon-category-tab').filter({ hasText: 'WoW' }).click();
|
||
await selectDefaultTestDungeon(page);
|
||
await page.locator('.dungeon-select-copy h2').getByText(DEFAULT_TEST_DUNGEON_TITLE, { exact: true }).waitFor();
|
||
await openSelectedDungeonRoles(page);
|
||
await page.locator('.dungeon-role-option').filter({ hasText: /DPS/ }).first().click();
|
||
await waitForGameReady(page);
|
||
await page.locator('.party-frames').waitFor({ timeout: 180_000 });
|
||
await page.locator('.party-frame').nth(4).waitFor({ timeout: 180_000 });
|
||
}
|
||
|
||
async function activateSpellFromSpellbook(page, spellName) {
|
||
await page.locator('.scene-shell').focus();
|
||
await page.keyboard.press('Escape');
|
||
await page.locator('.pause-menu').waitFor({ timeout: 15_000 });
|
||
await page.getByRole('button', { name: 'Spellbook', exact: true }).click();
|
||
const spellbook = page.locator('.spellbook-panel');
|
||
await spellbook.waitFor();
|
||
const spell = spellbook.locator('.spellbook-ability').filter({ hasText: spellName }).first();
|
||
await spell.waitFor();
|
||
assert.match(await spell.getAttribute('aria-label'), new RegExp(`${spellName}, unlocked`), `${spellName} is unlocked at the seeded level`);
|
||
await spell.click();
|
||
await spellbook.waitFor({ state: 'detached' });
|
||
}
|
||
|
||
async function callPet(page, expectedFamily) {
|
||
await activateSpellFromSpellbook(page, 'Call Pet');
|
||
const selector = page.locator('.hunter-pet-controls select[aria-label="Pet family"]');
|
||
assert.equal(await selector.count(), 1, 'The player-controlled pet has one family selector.');
|
||
await selector.waitFor();
|
||
assert.equal(await selector.inputValue(), expectedFamily);
|
||
const current = await storeSnapshot(page);
|
||
assert.equal(current.combat.classId, 'hunter');
|
||
assert.ok(current.combat.level >= 10, 'Call Pet is unlocked by the documented level-10 test seed.');
|
||
assert.deepEqual(current.combat.activePet && {
|
||
name: current.combat.activePet.name,
|
||
family: current.combat.activePet.family,
|
||
}, { name: expectedFamily === 'turtle' ? 'Turtle' : 'Wolf', family: expectedFamily });
|
||
return selector;
|
||
}
|
||
|
||
async function capture(page, name, fullPage = false) {
|
||
await page.screenshot({ path: path.join(directory, `${name}.png`), fullPage });
|
||
console.log(`Captured ${name}`);
|
||
}
|
||
|
||
(async () => {
|
||
const { createGameServer } = await import('../server/server.mjs');
|
||
const runtime = createGameServer({ dataDir, staticDir, contentDir, enableShelvedModes: false });
|
||
await new Promise((resolve) => runtime.server.listen(0, '127.0.0.1', resolve));
|
||
const origin = `http://127.0.0.1:${runtime.server.address().port}`;
|
||
let browser;
|
||
let page;
|
||
const pageErrors = [];
|
||
const consoleErrors = [];
|
||
const httpErrors = [];
|
||
const recentRequests = [];
|
||
const keyEvents = [];
|
||
|
||
try {
|
||
browser = await chromium.launch({ channel: 'msedge', headless: true });
|
||
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, locale: 'en-US' });
|
||
page = await context.newPage();
|
||
page.setDefaultTimeout(90_000);
|
||
page.on('pageerror', (error) => pageErrors.push(error.message));
|
||
page.on('console', (message) => { if (message.type() === 'error') consoleErrors.push({ text: message.text(), location: message.location() }); });
|
||
page.on('request', (request) => {
|
||
recentRequests.push({ method: request.method(), url: request.url() });
|
||
if (recentRequests.length > 100) recentRequests.shift();
|
||
});
|
||
page.on('response', (response) => { if (response.status() >= 400) httpErrors.push({ status: response.status(), url: response.url() }); });
|
||
page.on('requestfailed', (request) => httpErrors.push({ status: 'failed', url: request.url(), failure: request.failure()?.errorText }));
|
||
await page.addInitScript(({ mainModule, combatModule }) => {
|
||
window.__hunterKeyEvents = [];
|
||
window.addEventListener('keydown', (event) => {
|
||
window.__hunterKeyEvents.push({ key: event.key, code: event.code, target: event.target?.nodeName ?? null });
|
||
}, true);
|
||
window.hunterFamilyStores = async () => {
|
||
const exports = [...Object.values(await import(mainModule)), ...Object.values(await import(combatModule))];
|
||
const stateOf = (value) => { try { return value?.getState?.(); } catch { return undefined; } };
|
||
return {
|
||
shell: exports.find((value) => stateOf(value) && 'characters' in stateOf(value) && 'selectedCharacterId' in stateOf(value)),
|
||
combat: exports.find((value) => stateOf(value) && 'castAbility' in stateOf(value) && 'summons' in stateOf(value)),
|
||
game: exports.find((value) => stateOf(value) && 'assetStatus' in stateOf(value) && 'openOverlay' in stateOf(value)),
|
||
simulation: exports.find((value) => stateOf(value) && 'sessionMode' in stateOf(value) && 'runState' in stateOf(value)),
|
||
biteSizedLayout: exports.find((value) => stateOf(value) && 'panelFocused' in stateOf(value) && 'editorMode' in stateOf(value)),
|
||
};
|
||
};
|
||
}, { mainModule, combatModule });
|
||
|
||
await page.goto(origin, { waitUntil: 'domcontentloaded', timeout: 120_000 });
|
||
await page.getByRole('button', { name: /Play offline/ }).click();
|
||
await page.locator('.character-select-screen').waitFor();
|
||
await createHunter(page, 'Shellguard');
|
||
await page.getByRole('button', { name: 'Change character', exact: true }).click();
|
||
await createHunter(page, 'Wolfguard');
|
||
|
||
// QA-only eligibility seed: after normal UI creation, advance both temporary
|
||
// local profiles to level 10 through the repository-backed progression API.
|
||
// Family selection and persistence are exercised only through the rendered UI.
|
||
const seededProfiles = await page.evaluate(async () => {
|
||
const { shell } = await window.hunterFamilyStores();
|
||
const names = ['Shellguard', 'Wolfguard'];
|
||
const seeded = [];
|
||
for (const name of names) {
|
||
const profile = shell.getState().characters.find((character) => character.name === name);
|
||
if (!profile) throw new Error(`Missing UI-created test character ${name}.`);
|
||
shell.setState({ activeCharacter: profile });
|
||
shell.getState().saveActiveProgression(10, 0, profile.talentRanks, profile.glyphIds);
|
||
shell.setState({ activeCharacter: null });
|
||
const saved = shell.getState().characters.find((character) => character.id === profile.id);
|
||
if (saved?.level !== 10) throw new Error(`${name} did not reach the level-10 QA fixture.`);
|
||
seeded.push({ name, level: saved.level, family: saved.hunterPetFamily ?? 'wolf' });
|
||
}
|
||
return seeded;
|
||
});
|
||
assert.deepEqual(seededProfiles, [
|
||
{ name: 'Shellguard', level: 10, family: 'wolf' },
|
||
{ name: 'Wolfguard', level: 10, family: 'wolf' },
|
||
]);
|
||
|
||
await page.getByRole('button', { name: 'Change character', exact: true }).click();
|
||
await page.locator('.character-select-screen').waitFor();
|
||
await page.locator('.roster-character').filter({ hasText: 'Shellguard' }).click();
|
||
await page.getByRole('button', { name: /Continue as Shellguard/ }).click({ position: { x: 24, y: 30 } });
|
||
await page.locator('.main-menu-screen').waitFor();
|
||
await enterDefaultTestDungeon(page);
|
||
|
||
let selector = page.locator('.hunter-pet-controls select[aria-label="Pet family"]');
|
||
await selector.waitFor();
|
||
await page.locator('.dungeon-boss-intro').waitFor({ state: 'detached', timeout: 30_000 });
|
||
await page.waitForTimeout(300);
|
||
const entranceLock = {
|
||
disabled: await selector.isDisabled(),
|
||
selected: await selector.inputValue(),
|
||
engagedMobs: await page.evaluate(async () => {
|
||
const { combat } = await window.hunterFamilyStores();
|
||
return Object.values(combat.getState().mobs)
|
||
.filter((mob) => mob.engaged && !mob.dead && !mob.despawned)
|
||
.map((mob) => ({ id: mob.id, name: mob.name }));
|
||
}),
|
||
};
|
||
assert.equal(entranceLock.selected, 'wolf', 'A new eligible Hunter enters with its default Wolf.');
|
||
if (entranceLock.disabled) {
|
||
await capture(page, '01-stockade-entry-combat-locked-desktop');
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await page.waitForTimeout(350);
|
||
await capture(page, '02-stockade-entry-combat-locked-narrow');
|
||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||
}
|
||
// Entrance mobs must die through the ordinary party simulation before the
|
||
// OOC family selector is used. This bounded wait never changes encounter state.
|
||
await page.waitForFunction(() => {
|
||
const select = document.querySelector('.hunter-pet-controls select[aria-label="Pet family"]');
|
||
return Boolean(select && !select.disabled);
|
||
}, null, { timeout: 60_000 });
|
||
assert.equal(await selector.isEnabled(), true, 'Family selection unlocks naturally when the entrance encounter ends.');
|
||
const outOfCombatMobs = await page.evaluate(async () => {
|
||
const { combat } = await window.hunterFamilyStores();
|
||
return Object.values(combat.getState().mobs)
|
||
.filter((mob) => mob.engaged && !mob.dead && !mob.despawned)
|
||
.map((mob) => ({ id: mob.id, name: mob.name }));
|
||
});
|
||
assert.deepEqual(outOfCombatMobs, [], 'Turtle is selected only after the actual encounter has ended.');
|
||
await selector.selectOption('turtle');
|
||
await page.waitForFunction(async () => {
|
||
const { shell, combat } = await window.hunterFamilyStores();
|
||
const profile = shell.getState().characters.find((character) => character.name === 'Shellguard');
|
||
const pet = combat.getState().summons.find((summon) => summon.hunterPet && summon.ownerActorId === '__player__');
|
||
return profile?.hunterPetFamily === 'turtle'
|
||
&& combat.getState().hunterPetFamily === 'turtle'
|
||
&& pet?.name === 'Turtle'
|
||
&& pet.hunterPet.family === 'turtle'
|
||
&& !pet.hunterPet.furiousHowl;
|
||
}, null, { timeout: 10_000 });
|
||
let current = await storeSnapshot(page);
|
||
assert.equal(current.selectedCharacter?.hunterPetFamily, 'turtle');
|
||
assert.equal(current.combat.activePet?.shellShieldSpellId, 26_064);
|
||
assert.equal(current.combat.activePet?.furiousHowlSpellId, null);
|
||
const canvasSize = await page.locator('canvas').first().evaluate((element) => ({ width: element.width, height: element.height }));
|
||
assert.ok(canvasSize.width > 0 && canvasSize.height > 0, 'The rendered dungeon has an active WebGL canvas.');
|
||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||
await capture(page, '03-turtle-family-desktop');
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await page.waitForTimeout(350);
|
||
const narrowLayout = await page.evaluate(() => {
|
||
const selector = document.querySelector('.hunter-pet-controls select[aria-label="Pet family"]');
|
||
const petFrame = selector?.closest('.summon-frame');
|
||
const partyFrames = document.querySelector('.party-frames');
|
||
const topRightHud = document.querySelector('.hud-top-right');
|
||
const rect = (element) => {
|
||
if (!element) return null;
|
||
const { top, right, bottom, left, width, height } = element.getBoundingClientRect();
|
||
return { top, right, bottom, left, width, height };
|
||
};
|
||
const overlaps = (first, second) => Boolean(first && second
|
||
&& first.left < second.right && first.right > second.left
|
||
&& first.top < second.bottom && first.bottom > second.top);
|
||
const controlRect = rect(selector);
|
||
const petRect = rect(petFrame);
|
||
const partyRect = rect(partyFrames);
|
||
const topRightRect = rect(topRightHud);
|
||
return {
|
||
width: innerWidth,
|
||
documentWidth: document.documentElement.scrollWidth,
|
||
horizontalOverflow: document.documentElement.scrollWidth > innerWidth + 1,
|
||
selectorVisibleInViewport: Boolean(controlRect && controlRect.width > 0 && controlRect.height > 0
|
||
&& controlRect.left >= 0 && controlRect.right <= innerWidth
|
||
&& controlRect.top >= 0 && controlRect.bottom <= innerHeight),
|
||
partyFrameRect: partyRect,
|
||
petFrameRect: petRect,
|
||
selectorRect: controlRect,
|
||
partyClearance: partyRect && petRect ? petRect.top - partyRect.bottom : null,
|
||
overlapsPartyFrames: overlaps(petRect, partyRect),
|
||
overlapsTopRightHud: overlaps(petRect, topRightRect),
|
||
};
|
||
});
|
||
assert.equal(narrowLayout.horizontalOverflow, false, 'The narrow HUD has no horizontal overflow.');
|
||
assert.equal(narrowLayout.selectorVisibleInViewport, true, 'The narrow family selector remains visible.');
|
||
assert.ok(narrowLayout.partyClearance >= 8, 'The player pet card sits below the party stack with clearance.');
|
||
assert.equal(narrowLayout.overlapsTopRightHud, false, 'The player pet card does not overlap buffs/debuffs or minimap HUD.');
|
||
await capture(page, '04-turtle-family-narrow');
|
||
|
||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||
await page.locator('.character-select-screen').waitFor();
|
||
let afterReload = await page.evaluate(async () => {
|
||
const { shell } = await window.hunterFamilyStores();
|
||
return shell.getState().characters.map((character) => ({
|
||
name: character.name,
|
||
family: character.hunterPetFamily ?? null,
|
||
level: character.level,
|
||
}));
|
||
});
|
||
assert.equal(afterReload.find((profile) => profile.name === 'Shellguard')?.family, 'turtle');
|
||
assert.equal(afterReload.find((profile) => profile.name === 'Wolfguard')?.family, 'wolf');
|
||
assert.deepEqual(pageErrors, [], 'No new browser page errors occurred.');
|
||
const unexpectedConsoleErrors = consoleErrors.filter((entry) => !entry.location.url.endsWith('/favicon.ico'));
|
||
assert.deepEqual(unexpectedConsoleErrors, [], 'No browser console errors beyond the local server’s missing favicon occurred.');
|
||
|
||
fs.writeFileSync(path.join(directory, 'results.json'), `${JSON.stringify({
|
||
passed: true,
|
||
server: 'ephemeral localhost server with disposable OS-temp data directory and isolated Playwright context',
|
||
characterSeed: 'Two Marksmanship Hunters created through the normal UI; both advanced to level 10 through shellStore.saveActiveProgression in the isolated QA fixture so sourced Call Pet (level 10) and Revive Pet (level 10) are available.',
|
||
scope: 'Manual Hunter family UI mechanics diagnostic only in disposable Cellblock Rush. Not a Hunter balance certification or Ready-party certification.',
|
||
familySelectionProof: 'Turtle was selected through the rendered Pet family select while the live runtime reported no engaged mobs; the selected character’s saved value was re-read after page reload, and a separate UI-created Hunter remained Wolf.',
|
||
combatLockProof: entranceLock.disabled
|
||
? 'At entry, live Cellblock Rush mobs were engaged and the rendered family selector was disabled; later it enabled only after the real encounter ended.'
|
||
: 'The rendered family selector was enabled at the first post-intro observation with no engaged mobs. A separate earlier diagnostic screenshot and failure.json record the selector disabled while the entrance mobs were engaged; there is no narrow combat-lock screenshot.',
|
||
callAndRevive: 'Spellbook Call Pet / Revive Pet activation was not covered; the Escape shortcut reached the focused scene but did not open the pause menu in this route. Neither spell was activated by state injection.',
|
||
visual: { canvasSize, screenshots: [
|
||
'01-stockade-entry-combat-locked-desktop.png',
|
||
'02-stockade-entry-combat-locked-narrow.png',
|
||
'03-turtle-family-desktop.png',
|
||
'04-turtle-family-narrow.png',
|
||
] },
|
||
entranceLock,
|
||
outOfCombatMobs,
|
||
narrowLayout,
|
||
afterReload: afterReload,
|
||
finalState: current,
|
||
pageErrors,
|
||
consoleErrors,
|
||
httpErrors,
|
||
}, null, 2)}\n`);
|
||
console.log(`Hunter family browser playtest passed. Evidence: ${directory}`);
|
||
} catch (error) {
|
||
if (page) {
|
||
await page.screenshot({ path: path.join(directory, 'failure.png'), fullPage: true }).catch(() => undefined);
|
||
console.log((await page.locator('body').innerText().catch(() => '')).slice(-2_000));
|
||
}
|
||
console.log('Browser page errors:', pageErrors);
|
||
console.log('Browser console errors:', consoleErrors);
|
||
console.log('HTTP errors:', httpErrors);
|
||
console.log('Recent requests:', recentRequests.slice(-30));
|
||
const diagnostic = await page?.evaluate(async () => {
|
||
const { shell, combat, game, simulation, biteSizedLayout } = await window.hunterFamilyStores();
|
||
const runtime = combat.getState();
|
||
const familySelector = document.querySelector('.hunter-pet-controls select[aria-label="Pet family"]');
|
||
return {
|
||
keyEvents: window.__hunterKeyEvents ?? [],
|
||
focusedElement: { tag: document.activeElement?.tagName, className: document.activeElement?.className },
|
||
game: { overlay: game.getState().overlay, dungeonIntroActive: game.getState().dungeonIntroActive, assetStatus: game.getState().assetStatus },
|
||
inputContext: {
|
||
sessionMode: simulation?.getState().sessionMode ?? null,
|
||
runState: simulation?.getState().runState ?? null,
|
||
biteSizedPanelFocused: biteSizedLayout?.getState().panelFocused ?? null,
|
||
biteSizedEditorMode: biteSizedLayout?.getState().editorMode ?? null,
|
||
},
|
||
sessionMode: window.localStorage.getItem('healer-man-dungeon-simulation'),
|
||
familySelector: familySelector ? {
|
||
value: familySelector.value,
|
||
disabled: familySelector.disabled,
|
||
visible: Boolean(familySelector.getClientRects().length),
|
||
} : null,
|
||
engagedMobs: Object.values(runtime.mobs).filter((mob) => mob.engaged && !mob.dead && !mob.despawned).map((mob) => ({ id: mob.id, name: mob.name })),
|
||
family: runtime.hunterPetFamily,
|
||
pets: runtime.summons.filter((summon) => summon.hunterPet && summon.ownerActorId === '__player__').map((summon) => ({ id: summon.id, kind: summon.kind, name: summon.name, family: summon.hunterPet?.family, alive: summon.health > 0 })),
|
||
profiles: shell.getState().characters.map((character) => ({ name: character.name, family: character.hunterPetFamily ?? null, level: character.level })),
|
||
};
|
||
}).catch((diagnosticError) => ({ error: diagnosticError.message }));
|
||
let narrowEvidence = null;
|
||
if (page) {
|
||
await page.screenshot({ path: path.join(directory, '01-stockade-entry-desktop-diagnostic.png'), fullPage: false }).catch(() => undefined);
|
||
await page.setViewportSize({ width: 390, height: 844 }).catch(() => undefined);
|
||
await page.waitForTimeout(350).catch(() => undefined);
|
||
narrowEvidence = await page.evaluate(() => {
|
||
const rect = (element) => {
|
||
if (!element) return null;
|
||
const { top, right, bottom, left, width, height } = element.getBoundingClientRect();
|
||
return { top, right, bottom, left, width, height };
|
||
};
|
||
const selector = document.querySelector('.hunter-pet-controls select[aria-label="Pet family"]');
|
||
const petFrame = selector?.closest('.summon-frame');
|
||
const partyFrames = document.querySelector('.party-frames');
|
||
const selectorRect = rect(selector);
|
||
return {
|
||
width: innerWidth,
|
||
documentWidth: document.documentElement.scrollWidth,
|
||
horizontalOverflow: document.documentElement.scrollWidth > innerWidth + 1,
|
||
selectorRect,
|
||
selectorVisibleInViewport: Boolean(selectorRect && selectorRect.width > 0 && selectorRect.height > 0
|
||
&& selectorRect.left >= 0 && selectorRect.right <= innerWidth
|
||
&& selectorRect.top >= 0 && selectorRect.bottom <= innerHeight),
|
||
partyFrameRect: rect(partyFrames),
|
||
petFrameRect: rect(petFrame),
|
||
partyClearance: partyFrames && petFrame ? petFrame.getBoundingClientRect().top - partyFrames.getBoundingClientRect().bottom : null,
|
||
};
|
||
}).catch(() => null);
|
||
await page.screenshot({ path: path.join(directory, '02-stockade-entry-narrow-diagnostic.png'), fullPage: false }).catch(() => undefined);
|
||
}
|
||
const failure = {
|
||
passed: false,
|
||
status: error.message.includes('party stack') ? 'failed_narrow_hud_layout_assertion' : 'failed_assertion',
|
||
error: { name: error.name, message: error.message },
|
||
diagnostic,
|
||
narrowEvidence,
|
||
screenshots: ['failure.png', '01-stockade-entry-desktop-diagnostic.png', '02-stockade-entry-narrow-diagnostic.png'],
|
||
consoleErrors,
|
||
httpErrors,
|
||
recentRequests: recentRequests.slice(-30),
|
||
};
|
||
fs.writeFileSync(path.join(directory, 'failure.json'), `${JSON.stringify(failure, null, 2)}\n`);
|
||
throw error;
|
||
} finally {
|
||
await browser?.close();
|
||
await new Promise((resolve) => runtime.server.close(resolve));
|
||
cleanupDataDir();
|
||
}
|
||
})().catch((error) => { console.error(error); process.exitCode = 1; });
|