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_CAT_PLAYTEST_DIR || 'playtest-artifacts/hunter-cat'); const staticDir = path.resolve('dist'); const contentDir = path.resolve('content'); const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-hunter-cat-')); 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-cat-')) { 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 ? { id: activePet.id, ownerActorId: activePet.ownerActorId, name: activePet.name, family: activePet.hunterPet.family, alive: activePet.health > 0, health: activePet.health, primary: { name: activePet.hunterPet.abilityName, spellId: activePet.hunterPet.spellId, rank: activePet.hunterPet.rank, }, rake: activePet.hunterPet.rake ? { spellId: activePet.hunterPet.rake.spellId, rank: activePet.hunterPet.rake.rank, requiredLevel: activePet.hunterPet.rake.requiredLevel, } : null, prowl: activePet.hunterPet.prowl ? { spellId: activePet.hunterPet.prowl.spellId, rank: activePet.hunterPet.prowl.rank, requiredLevel: activePet.hunterPet.prowl.requiredLevel, } : null, shellShieldSpellId: activePet.hunterPet.shellShield?.spellId ?? null, furiousHowlSpellId: activePet.hunterPet.furiousHowl?.spellId ?? null, command: activePet.hunterPetCommand ?? 'follow', reactMode: activePet.hunterPetReactMode ?? 'defensive', growlAutocast: Boolean(activePet.hunterPetGrowlAutocastEnabled), } : null, }, assetStatus: game.getState().assetStatus, }; }); } async function waitForGameReady(page) { await page.locator('.game-shell').waitFor({ timeout: 120_000 }); const isRenderedAndReady = async () => page.evaluate(async () => { const { game } = await window.hunterFamilyStores(); const overlay = document.querySelector('.loading-screen'); const style = overlay ? getComputedStyle(overlay) : null; const overlayVisible = Boolean(overlay && style?.display !== 'none' && style?.visibility !== 'hidden' && overlay.getBoundingClientRect().width > 0 && overlay.getBoundingClientRect().height > 0); const canvas = document.querySelector('canvas'); return ['ready', 'proxy'].includes(game.getState().assetStatus) && !overlayVisible && Boolean(canvas && canvas.width > 0 && canvas.height > 0 && canvas.dataset.engine); }); await page.waitForFunction(async () => { const { game } = await window.hunterFamilyStores(); const overlay = document.querySelector('.loading-screen'); const style = overlay ? getComputedStyle(overlay) : null; const overlayVisible = Boolean(overlay && style?.display !== 'none' && style?.visibility !== 'hidden' && overlay.getBoundingClientRect().width > 0 && overlay.getBoundingClientRect().height > 0); const canvas = document.querySelector('canvas'); return ['ready', 'proxy'].includes(game.getState().assetStatus) && !overlayVisible && Boolean(canvas && canvas.width > 0 && canvas.height > 0 && canvas.dataset.engine); }, null, { timeout: 180_000 }); await page.waitForTimeout(1_500); if (!await isRenderedAndReady()) { await page.waitForFunction(async () => { const { game } = await window.hunterFamilyStores(); const overlay = document.querySelector('.loading-screen'); const style = overlay ? getComputedStyle(overlay) : null; const overlayVisible = Boolean(overlay && style?.display !== 'none' && style?.visibility !== 'hidden' && overlay.getBoundingClientRect().width > 0 && overlay.getBoundingClientRect().height > 0); const canvas = document.querySelector('canvas'); return ['ready', 'proxy'].includes(game.getState().assetStatus) && !overlayVisible && Boolean(canvas && canvas.width > 0 && canvas.height > 0 && canvas.dataset.engine); }, null, { timeout: 180_000 }); } await page.locator('canvas').waitFor(); await page.locator('.loading-screen').waitFor({ state: 'hidden', timeout: 180_000 }).catch(() => undefined); } async function renderedReadiness(page) { return page.evaluate(async () => { const { game } = await window.hunterFamilyStores(); const overlay = document.querySelector('.loading-screen'); const style = overlay ? getComputedStyle(overlay) : null; const overlayVisible = Boolean(overlay && style?.display !== 'none' && style?.visibility !== 'hidden' && overlay.getBoundingClientRect().width > 0 && overlay.getBoundingClientRect().height > 0); const canvas = document.querySelector('canvas'); return { assetStatus: game.getState().assetStatus, dungeonIntroActive: game.getState().dungeonIntroActive, loadingScreenVisible: overlayVisible, canvasEngine: canvas?.dataset.engine ?? null, canvasSize: canvas ? { width: canvas.width, height: canvas.height } : null, }; }); } 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 enterWailingCaverns(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 page.locator('.dungeon-select-row').filter({ hasText: 'Wailing Caverns' }).click(); await page.locator('.dungeon-select-copy h2').getByText(/^Wailing Caverns$/, { exact: true }).waitFor(); await page.getByRole('button', { name: /Choose role/ }).click(); 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 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, 'Catguard'); 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 = ['Catguard', '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: 'Catguard', 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: 'Catguard' }).click(); await page.getByRole('button', { name: /Continue as Catguard/ }).click({ position: { x: 24, y: 30 } }); await page.locator('.main-menu-screen').waitFor(); await enterWailingCaverns(page); let selector = page.locator('.hunter-pet-controls select[aria-label="Pet family"]'); await selector.waitFor(); const bossIntro = page.locator('.dungeon-boss-intro'); const bossIntroObserved = await bossIntro.waitFor({ state: 'visible', timeout: 12_000 }).then(() => true, () => false); if (bossIntroObserved) await bossIntro.waitFor({ state: 'detached', timeout: 30_000 }); await waitForGameReady(page); 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-wailing-entry-combat-locked-desktop'); await page.setViewportSize({ width: 390, height: 844 }); await page.waitForTimeout(350); await capture(page, '02-wailing-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, [], 'Cat is selected only after the actual encounter has ended.'); const familyPointerTarget = await selector.evaluate((element) => { const bounds = element.getBoundingClientRect(); const hit = document.elementFromPoint(bounds.left + bounds.width / 2, bounds.top + bounds.height / 2); return { hitTag: hit?.tagName ?? null, hitClassName: hit?.className?.toString?.() ?? null, hitControls: Boolean(hit?.closest('.hunter-pet-controls') === element.closest('.hunter-pet-controls')) }; }); assert.equal(familyPointerTarget.hitControls, true, 'The visible family selector receives normal pointer hit testing.'); await selector.click(); await page.keyboard.press('End'); await page.keyboard.press('Enter'); await page.waitForFunction(async () => { const { shell, combat } = await window.hunterFamilyStores(); const profile = shell.getState().characters.find((character) => character.name === 'Catguard'); const pet = combat.getState().summons.find((summon) => summon.hunterPet && summon.ownerActorId === '__player__'); return profile?.hunterPetFamily === 'cat' && combat.getState().hunterPetFamily === 'cat' && pet?.name === 'Cat' && pet.hunterPet.family === 'cat' && pet.hunterPet.abilityName === 'Claw' && pet.hunterPet.rake?.spellId === 59881 && pet.hunterPet.prowl?.spellId === 24450 && !pet.hunterPet.furiousHowl && !pet.hunterPet.shellShield; }, null, { timeout: 10_000 }); let current = await storeSnapshot(page); assert.equal(current.selectedCharacter?.hunterPetFamily, 'cat'); assert.equal(current.combat.activePet?.id, '__player__:summon-1'); assert.equal(current.combat.activePet?.ownerActorId, '__player__'); assert.deepEqual(current.combat.activePet?.primary, { name: 'Claw', spellId: 16_828, rank: 2 }); assert.deepEqual(current.combat.activePet?.rake, { spellId: 59_881, rank: 1, requiredLevel: 1 }); assert.deepEqual(current.combat.activePet?.prowl, { spellId: 24_450, rank: 1, requiredLevel: 1 }); assert.equal(current.combat.activePet?.furiousHowlSpellId, null); assert.equal(current.combat.activePet?.shellShieldSpellId, null); await waitForGameReady(page); const renderReadyProof = await renderedReadiness(page); assert.ok(['ready', 'proxy'].includes(renderReadyProof.assetStatus), 'Dungeon assets are ready before visual capture.'); assert.equal(renderReadyProof.loadingScreenVisible, false, 'The actual loading screen is hidden before visual capture.'); assert.ok(renderReadyProof.canvasEngine, 'The rendered 3D canvas is present before visual capture.'); const canvasSize = renderReadyProof.canvasSize; 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-cat-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-cat-family-narrow'); const petControls = page.locator('.summon-frame--summon .hunter-pet-controls'); const petCommands = petControls.getByRole('group', { name: 'Pet command' }); const stayButton = petCommands.getByRole('button', { name: 'stay', exact: true }); const followButton = petCommands.getByRole('button', { name: 'follow', exact: true }); const attackButton = petCommands.getByRole('button', { name: 'attack', exact: true }); assert.equal(await petControls.count(), 1, 'The active player-owned Cat exposes its rendered controls.'); const commandPointerTarget = await petControls.evaluate((element) => { const button = element.querySelector('button[aria-pressed="false"]'); if (!button) return null; const bounds = button.getBoundingClientRect(); const hit = document.elementFromPoint(bounds.left + bounds.width / 2, bounds.top + bounds.height / 2); return { button: button.textContent?.trim(), hitTag: hit?.tagName ?? null, hitClassName: hit?.className?.toString?.() ?? null, hitControls: Boolean(hit?.closest('.hunter-pet-controls') === element) }; }); assert.equal(commandPointerTarget.hitControls, true, 'Visible command buttons receive normal pointer hit testing.'); const attackDisabledWithoutTarget = await attackButton.isDisabled(); assert.equal(attackDisabledWithoutTarget, true, 'Attack is visibly unavailable without a selected hostile target.'); await stayButton.click(); await page.waitForFunction(async () => { const { combat } = await window.hunterFamilyStores(); return combat.getState().summons.find((summon) => summon.hunterPet && summon.ownerActorId === '__player__')?.hunterPetCommand === 'stay'; }, null, { timeout: 5_000 }); await followButton.click(); await page.waitForFunction(async () => { const { combat } = await window.hunterFamilyStores(); return combat.getState().summons.find((summon) => summon.hunterPet && summon.ownerActorId === '__player__')?.hunterPetCommand === 'follow'; }, null, { timeout: 5_000 }); const reactSelector = petControls.getByLabel('Pet reaction'); await reactSelector.click(); await page.keyboard.press('End'); await page.keyboard.press('Enter'); await page.waitForFunction(async () => { const { combat } = await window.hunterFamilyStores(); return combat.getState().summons.find((summon) => summon.hunterPet && summon.ownerActorId === '__player__')?.hunterPetReactMode === 'passive'; }, null, { timeout: 5_000 }); await reactSelector.click(); await page.keyboard.press('ArrowUp'); await page.keyboard.press('Enter'); await page.waitForFunction(async () => { const { combat } = await window.hunterFamilyStores(); return combat.getState().summons.find((summon) => summon.hunterPet && summon.ownerActorId === '__player__')?.hunterPetReactMode === 'aggressive'; }, null, { timeout: 5_000 }); await reactSelector.click(); await page.keyboard.press('Home'); await page.keyboard.press('Enter'); await page.waitForFunction(async () => { const { combat } = await window.hunterFamilyStores(); return combat.getState().summons.find((summon) => summon.hunterPet && summon.ownerActorId === '__player__')?.hunterPetReactMode === 'defensive'; }, null, { timeout: 5_000 }); const growlToggle = petControls.locator('.hunter-pet-controls__growl input[type="checkbox"]'); await growlToggle.click(); await page.waitForFunction(async () => { const { combat } = await window.hunterFamilyStores(); return combat.getState().summons.find((summon) => summon.hunterPet && summon.ownerActorId === '__player__')?.hunterPetGrowlAutocastEnabled === true; }, null, { timeout: 5_000 }); await growlToggle.click(); await page.waitForFunction(async () => { const { combat } = await window.hunterFamilyStores(); return combat.getState().summons.find((summon) => summon.hunterPet && summon.ownerActorId === '__player__')?.hunterPetGrowlAutocastEnabled === false; }, null, { timeout: 5_000 }); const commandReactProof = { rendered: await petControls.count() === 1, pointerActivation: 'Stay, follow, reaction changes, and Growl autocast were exercised on the rendered controls after hit-testing them against the controls card.', pointerHitTest: commandPointerTarget, staySelected: await stayButton.getAttribute('aria-pressed'), followSelectedAfterRestore: await followButton.getAttribute('aria-pressed'), attackDisabledWithoutTarget, reactionValuesTested: ['passive', 'aggressive', 'defensive'], growlAutocastToggledAndRestored: true, restoredReaction: await reactSelector.inputValue(), }; // Try the ordinary Escape -> pause menu -> Spellbook route once. If it does // not open in this input context, stop instead of calling store actions. const spellRoute = { escapePressed: false, pauseOpened: false, spellbookOpened: false, callPet: 'not attempted' }; await page.setViewportSize({ width: 1440, height: 1000 }); await page.locator('.scene-shell').focus().catch(() => undefined); await page.keyboard.press('Escape').catch(() => undefined); spellRoute.escapePressed = true; spellRoute.pauseOpened = await page.locator('.pause-menu').isVisible().catch(() => false); if (spellRoute.pauseOpened) { await page.getByRole('button', { name: 'Spellbook', exact: true }).click(); const spellbook = page.locator('.spellbook-panel'); await spellbook.waitFor({ timeout: 5_000 }); spellRoute.spellbookOpened = true; const callPetAbility = spellbook.locator('.spellbook-ability').filter({ hasText: /^Call Pet/ }).first(); if (await callPetAbility.count()) { const label = await callPetAbility.getAttribute('aria-label'); if (label?.startsWith('Call Pet, unlocked')) { await callPetAbility.click(); await page.waitForTimeout(600); const afterCall = await storeSnapshot(page); spellRoute.callPet = { attemptedThroughRenderedSpellbook: true, abilityLabel: label, petId: afterCall.combat.activePet?.id ?? null, family: afterCall.combat.activePet?.family ?? null, overlayAfterClick: await page.locator('.spellbook-panel').isVisible().catch(() => false) ? 'spellbook' : 'closed', }; } else spellRoute.callPet = { attemptedThroughRenderedSpellbook: false, abilityLabel: label ?? null }; } else spellRoute.callPet = { attemptedThroughRenderedSpellbook: false, reason: 'No Call Pet entry in the rendered spellbook.' }; } else { spellRoute.callPet = { attemptedThroughRenderedSpellbook: false, reason: 'Escape did not expose the pause menu in this gameplay route.' }; } spellRoute.revivePet = 'Not attempted: the live pet remained alive; no ordinary UI route to a naturally dead pet occurred.'; 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 === 'Catguard')?.family, 'cat'); 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 the level-10 family controls and Cat skill ranks could be inspected. This is eligibility setup, not earned progression.', scope: 'Manual Hunter Cat family UI diagnostic only in disposable Wailing Caverns. Not a Hunter balance certification or Ready-party certification.', familySelectionProof: 'Cat 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 Wailing Caverns mobs were engaged and the rendered family selector was disabled; later it enabled only after the real encounter ended.' : 'The Cat run’s first observation came after the entrance pack had ended; this run did not capture an in-combat Cat-family lock state. Earlier Hunter family QA covered the lock with Wolf/Turtle.', callAndRevive: spellRoute, commandReactProof, catAbilityProof: 'The active player-owned pet runtime profile reports Claw 16828 rank 2, Rake 59881 rank 1, and Prowl 24450 rank 1 at level 10. It has neither Bite, Furious Howl, nor Shell Shield. This profile/readiness evidence does not prove live Rake or Prowl casts.', visual: { renderReadyProof, bossIntroObserved, screenshots: [ ...(entranceLock.disabled ? [ '01-wailing-entry-combat-locked-desktop.png', '02-wailing-entry-combat-locked-narrow.png', ] : []), '03-cat-family-desktop.png', '04-cat-family-narrow.png', ] }, entranceLock, outOfCombatMobs, narrowLayout, afterReload: afterReload, finalState: current, secondaryCharacter: afterReload.find((profile) => profile.name === 'Wolfguard'), pageErrors, consoleErrors, httpErrors, }, null, 2)}\n`); console.log(`Hunter Cat 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-wailing-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-wailing-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-wailing-entry-desktop-diagnostic.png', '02-wailing-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; });