diablo2-web/scripts/verify-ui-browser.ts

224 lines
8.3 KiB
TypeScript

/**
* Automated Headless Browser Verification & Visual Audit for Issue #27 (`acts.html`).
*
* Uses Vite dev server + `/usr/bin/google-chrome` over native WebSocket CDP (zero external deps).
*/
import { spawn } from 'node:child_process'
import { mkdirSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { createServer } from 'vite'
const ARTIFACT_DIR =
process.argv[2] ||
'/usr/local/google/home/taodao/.gemini/jetski/brain/aa680fb2-b60b-4fb6-86df-d401210969d8'
const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
async function main(): Promise<void> {
mkdirSync(ARTIFACT_DIR, { recursive: true })
const server = await createServer({
root: process.cwd(),
server: { port: 0, host: '127.0.0.1' },
})
await server.listen()
const address = server.httpServer?.address()
const port = typeof address === 'object' && address ? address.port : 5173
const baseUrl = `http://127.0.0.1:${port}`
console.log(`1. Vite dev server listening at ${baseUrl}`)
const debugPort = 9237
const chromeProc = spawn('/usr/bin/google-chrome', [
'--headless=new',
`--remote-debugging-port=${debugPort}`,
'--no-sandbox',
'--disable-dev-shm-usage',
'--enable-webgl',
'--ignore-gpu-blocklist',
'--use-gl=angle',
'--use-angle=swiftshader',
'--window-size=1280,840',
`${baseUrl}/acts.html?act=1`,
])
try {
let wsUrl: string | null = null
for (let i = 0; i < 60; i++) {
await sleep(300)
try {
const res = await fetch(`http://127.0.0.1:${debugPort}/json/list`)
const pages = (await res.json()) as { type: string; url: string; webSocketDebuggerUrl?: string }[]
const page = pages.find(p => p.type === 'page' && p.url.includes('acts.html'))
if (page?.webSocketDebuggerUrl) {
wsUrl = page.webSocketDebuggerUrl
break
}
} catch {
// Wait for Chrome CDP
}
}
if (!wsUrl) {
throw new Error('Failed to connect to Chrome CDP')
}
console.log('2. Connected to Chrome CDP WebSocket:', wsUrl)
const ws = new WebSocket(wsUrl)
await new Promise<void>(resolve => {
ws.onopen = () => resolve()
})
let idCounter = 1
const sendCommand = <T = any>(method: string, params: Record<string, unknown> = {}): Promise<T> =>
new Promise((resolve, reject) => {
const id = idCounter++
const handler = (event: MessageEvent) => {
const msg = JSON.parse(String(event.data))
if (msg.id === id) {
ws.removeEventListener('message', handler)
if (msg.error) reject(new Error(JSON.stringify(msg.error)))
else resolve(msg.result as T)
}
}
ws.addEventListener('message', handler)
ws.send(JSON.stringify({ id, method, params }))
})
const evalJs = async <T = any>(expression: string): Promise<T> => {
const res = await sendCommand<{ result: { value: T }; exceptionDetails?: any }>('Runtime.evaluate', {
expression,
awaitPromise: true,
returnByValue: true,
})
if (res.exceptionDetails) {
throw new Error(`JS exception: ${JSON.stringify(res.exceptionDetails)}`)
}
return res.result.value
}
const saveScreenshot = async (filename: string): Promise<void> => {
const shot = await sendCommand<{ data: string }>('Page.captureScreenshot', { format: 'png' })
writeFileSync(join(ARTIFACT_DIR, filename), Buffer.from(shot.data, 'base64'))
console.log(` Saved screenshot: ${filename}`)
}
await sendCommand('Runtime.enable')
await sendCommand('Page.enable')
// Wait for both ActScene and HudManager to become ready
for (let i = 0; i < 120; i++) {
const ready = await evalJs<boolean>(
`Boolean(window.__d2webAct?.ready && window.__d2webHud?.ready && window.__d2webHud?.assetsLoaded?.ctrlPnl)`,
)
if (ready) break
await sleep(300)
}
await sleep(600)
const initialHud = await evalJs<any>(`window.__d2webHud`)
console.log('3. Verified initial __d2webHud state:', JSON.stringify(initialHud))
if (!initialHud || initialHud.dc6DecodeFailures !== 0) {
throw new Error(`Expected 0 DC6 decode failures, got: ${JSON.stringify(initialHud)}`)
}
for (const [k, v] of Object.entries(initialHud.assetsLoaded)) {
if (!v) throw new Error(`Asset group failed to load: ${k}`)
}
// Screenshot 1: Base Town HUD
await saveScreenshot('hud_base_town.png')
// Screenshot 2: Left Character Sheet (`A`) + Right Inventory (`I`) + Hover Harlequin Crest
const dualState = await evalJs<any>(`(() => {
const inst = window.__d2webHudInstance;
inst.toggleLeftPanel('char');
inst.toggleRightPanel('inv');
inst.inventory.handleMouseMove(561, 93); // Hover Harlequin Crest in Helm slot
inst.render(performance.now(), false);
return window.__d2webHud;
})()`)
if (dualState.leftPanel !== 'char' || dualState.rightPanel !== 'inv') {
throw new Error(`Split panel assertion failed: ${JSON.stringify(dualState)}`)
}
await sleep(200)
await saveScreenshot('hud_char_and_inv.png')
// Verify Inventory Drag-and-Drop, Equipment Swap, Belt Drink, Stat & Skill Allocation
const interactionCheck = await evalJs<any>(`(() => {
const inst = window.__d2webHudInstance;
inst.inventory.clickEquipSlot('weapon1');
const pickedCode = inst.inventory.cursorItem?.code;
inst.inventory.clickGridCell(8, 0);
const swappedCode = inst.inventory.cursorItem?.code;
inst.inventory.clickEquipSlot('weapon1');
const finalWeapon1 = inst.inventory.equipped.weapon1?.code;
inst.hp = 900;
inst.drinkBeltSlot(0);
const afterBeltCount = inst.belt.countTotalPotions();
const healedHp = inst.hp;
inst.charSheet.allocateStat('vit');
inst.syncPublishedState();
return { pickedCode, swappedCode, finalWeapon1, afterBeltCount, healedHp, remainingStatPts: inst.state.statPoints };
})()`)
console.log('4. Verified HUD interactions via CDP:', JSON.stringify(interactionCheck))
if (
interactionCheck.pickedCode !== 'oba' ||
interactionCheck.swappedCode !== 'crs' ||
interactionCheck.finalWeapon1 !== 'crs' ||
interactionCheck.afterBeltCount !== 15 ||
interactionCheck.healedHp !== 1080
) {
throw new Error(`HUD interaction check failed: ${JSON.stringify(interactionCheck)}`)
}
// Screenshot 3: Left Quest Log (`Q`) + Right Skill Tree (`T`) + Hover Frozen Orb
const questSkillState = await evalJs<any>(`(() => {
const inst = window.__d2webHudInstance;
inst.toggleLeftPanel('quest');
inst.toggleRightPanel('skill');
inst.skillTree.handleMouseMove(445, 422); // Hover Frozen Orb
inst.render(performance.now(), false);
return window.__d2webHud;
})()`)
if (questSkillState.leftPanel !== 'quest' || questSkillState.rightPanel !== 'skill') {
throw new Error(`Quest/Skill panel assertion failed: ${JSON.stringify(questSkillState)}`)
}
await sleep(200)
await saveScreenshot('hud_quest_and_skills.png')
// Screenshot 4: Left Waypoint Menu (`V`) + 4x4 Pop-up Belt (`~`) + Right Skill Speedbar Popup + Monster Plaque
const wpBeltState = await evalJs<any>(`(() => {
const inst = window.__d2webHudInstance;
inst.toggleLeftPanel('waypoint');
inst.toggleRightPanel('skill'); // close right panel
inst.belt.lockedOpen = true;
inst.hotkeys.openPopup = 'right';
inst.hotkeys.hoveredPopupSkill = { side: 'right', skillId: 64 };
inst.targetMonster = {
name: 'Corpsefire (尸体发火)',
hp: 740,
maxHp: 980,
rank: 'superunique',
affixes: ['Spectral Hit · 特别命中', 'Undead · 不死族'],
};
inst.render(performance.now(), false);
return window.__d2webHud;
})()`)
if (wpBeltState.leftPanel !== 'waypoint' || !wpBeltState.beltExpanded) {
throw new Error(`Waypoint/Belt assertion failed: ${JSON.stringify(wpBeltState)}`)
}
await sleep(200)
await saveScreenshot('hud_waypoint_and_belt.png')
ws.close()
console.log('5. ALL Issue #27 Browser & CDP Assertions Passed 100%!')
} finally {
chromeProc.kill()
await server.close()
}
}
main().catch((err) => {
console.error('verify-ui-browser failed:', err)
process.exit(1)
})