diablo2-web/scripts/verify-issue-504-browser.ts

262 lines
10 KiB
TypeScript

/**
* Automated Headless Browser Verification & Visual Audit for Issue #504:
* Authentic Diablo II v1.13c Exocet (`D2Exocet`) & Formal 436 (`D2Formal436`)
* Typography across Canvas HUD (`D2FontRenderer` DC6 bitmap blitting) and DOM/CSS
* (`index.html`, `acts-en.html`, `acts.html`, `acts-zh.html`).
*/
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/diablo2-web/.agents/teamwork/implementer_504_r1'
const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
interface CdpSession {
evalJs: <T = any>(expression: string) => Promise<T>
saveScreenshot: (filename: string) => Promise<void>
waitForGameReady: () => Promise<void>
}
async function withChromePage<T>(
url: string,
matchSubstr: string,
debugPort: number,
fn: (session: CdpSession) => Promise<T>,
): Promise<T> {
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',
url,
])
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(matchSubstr))
if (page?.webSocketDebuggerUrl) {
wsUrl = page.webSocketDebuggerUrl
break
}
} catch {
// Wait for Chrome CDP
}
}
if (!wsUrl) {
throw new Error(`Failed to connect to Chrome CDP for ${url}`)
}
const ws = new WebSocket(wsUrl)
await new Promise<void>(resolve => {
ws.onopen = () => resolve()
})
let idCounter = 1
const sendCommand = <R = any>(method: string, params: Record<string, unknown> = {}): Promise<R> =>
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 R)
}
}
ws.addEventListener('message', handler)
ws.send(JSON.stringify({ id, method, params }))
})
const evalJs = async <R = any>(expression: string): Promise<R> => {
const res = await sendCommand<{ result: { value: R }; 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}`)
}
const waitForGameReady = async (): Promise<void> => {
for (let i = 0; i < 120; i++) {
const ready = await evalJs<boolean>(
`Boolean(window.__d2webAct?.ready && window.__d2webHud?.ready && window.__d2webHudInstance && window.__d2webHud?.assetsLoaded?.ctrlPnl)`,
)
if (ready) {
await sleep(500)
return
}
await sleep(300)
}
throw new Error(`Timed out waiting for game ready at ${url}`)
}
await sendCommand('Runtime.enable')
await sendCommand('Page.enable')
const result = await fn({ evalJs, saveScreenshot, waitForGameReady })
ws.close()
return result
} finally {
chromeProc.kill('SIGKILL')
await sleep(200)
}
}
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}`)
try {
// ── Phase A: Verify index.html WebFont loading & computed typography ──
await withChromePage(`${baseUrl}/index.html`, 'index.html', 9251, async ({ evalJs, saveScreenshot }) => {
await sleep(500)
const indexFonts = await evalJs<{
exocetLoaded: boolean
formalLoaded: boolean
h1FontFamily: string
descFontFamily: string
}>(`(async () => {
await document.fonts.ready;
await Promise.all([
document.fonts.load('16px D2Exocet'),
document.fonts.load('16px D2Formal436'),
]);
return {
exocetLoaded: document.fonts.check('16px D2Exocet'),
formalLoaded: document.fonts.check('16px D2Formal436'),
h1FontFamily: getComputedStyle(document.querySelector('h1')).fontFamily,
descFontFamily: getComputedStyle(document.querySelector('.lang-desc')).fontFamily,
};
})()`)
console.log('2. Verified index.html WebFonts:', JSON.stringify(indexFonts))
if (!indexFonts.exocetLoaded || !indexFonts.formalLoaded) {
throw new Error(`index.html failed to load D2Exocet or D2Formal436: ${JSON.stringify(indexFonts)}`)
}
if (!indexFonts.h1FontFamily.includes('D2Exocet') || !indexFonts.descFontFamily.includes('D2Formal436')) {
throw new Error(`index.html unexpected font-family: ${JSON.stringify(indexFonts)}`)
}
await saveScreenshot('issue-504-index.png')
})
// ── Phase B: Verify acts-en.html Canvas DC6 bitmap font blitting & DOM/CSS WebFonts ──
await withChromePage(`${baseUrl}/acts-en.html?act=1`, 'acts-en.html', 9252, async ({ evalJs, saveScreenshot, waitForGameReady }) => {
await waitForGameReady()
const enCheck = await evalJs<{
exocetLoaded: boolean
formalLoaded: boolean
hudFontFamily: string
controlsFontFamily: string
dialogFontFamily: string
dialogTitleFontFamily: string
dc6FontLoaded: boolean
dc6BlitCount: number
fillTextCount: number
}>(`(async () => {
await document.fonts.ready;
await Promise.all([
document.fonts.load('16px D2Exocet'),
document.fonts.load('16px D2Formal436'),
]);
const inst = window.__d2webHudInstance;
let blitCount = 0;
let fillCount = 0;
const origDrawImage = CanvasRenderingContext2D.prototype.drawImage;
const origFillText = CanvasRenderingContext2D.prototype.fillText;
CanvasRenderingContext2D.prototype.drawImage = function(...args) {
if (args.length === 9) blitCount++;
return origDrawImage.apply(this, args);
};
CanvasRenderingContext2D.prototype.fillText = function(...args) {
fillCount++;
return origFillText.apply(this, args);
};
try {
if (inst.leftPanel !== 'char') inst.toggleLeftPanel('char');
if (inst.rightPanel !== 'inv') inst.toggleRightPanel('inv');
inst.inventory.handleMouseMove(561, 93);
inst.render(performance.now(), false);
} finally {
CanvasRenderingContext2D.prototype.drawImage = origDrawImage;
CanvasRenderingContext2D.prototype.fillText = origFillText;
}
return {
exocetLoaded: document.fonts.check('16px D2Exocet'),
formalLoaded: document.fonts.check('16px D2Formal436'),
hudFontFamily: getComputedStyle(document.getElementById('hud')).fontFamily,
controlsFontFamily: getComputedStyle(document.getElementById('controls')).fontFamily,
dialogFontFamily: getComputedStyle(document.getElementById('dialog')).fontFamily,
dialogTitleFontFamily: getComputedStyle(document.getElementById('status')).fontFamily,
dc6FontLoaded: Boolean(inst.font?.loaded),
dc6BlitCount: blitCount,
fillTextCount: fillCount,
};
})()`)
console.log('3. Verified acts-en.html Canvas DC6 blitting & WebFonts:', JSON.stringify(enCheck))
if (!enCheck.exocetLoaded || !enCheck.formalLoaded || !enCheck.dc6FontLoaded) {
throw new Error(`acts-en.html font loading failed: ${JSON.stringify(enCheck)}`)
}
if (enCheck.dc6BlitCount < 100 || enCheck.fillTextCount !== 0) {
throw new Error(`Expected acts-en.html HUD to use DC6 bitmap blitting (>100 blits, 0 fillText), got: ${JSON.stringify(enCheck)}`)
}
await saveScreenshot('issue-504-acts-en-panels.png')
// Open Quest Log + Skill Tree + NPC Dialog to verify Formal 436 & Exocet in all panels
await evalJs(`(() => {
const inst = window.__d2webHudInstance;
inst.toggleLeftPanel('quest');
inst.toggleRightPanel('skill');
const dialog = document.getElementById('dialog');
if (dialog) {
dialog.innerHTML = '<div class="dialog-title">Akara · High Priestess of the Sisterhood</div><div class="dialog-body">I am Akara, High Priestess of the Sisterhood of the Sightless Eye. I welcome you, traveler, to our camp, but I am afraid I can offer you but poor shelter within these rickety walls.</div>';
dialog.hidden = false;
dialog.style.display = 'block';
}
inst.render(performance.now(), false);
})()`)
await sleep(300)
await saveScreenshot('issue-504-acts-en-quests-skills-dialog.png')
})
console.log('✅ All Issue #504 Headless Browser E2E Checks PASSED!')
} finally {
await server.close()
}
}
main().catch(err => {
console.error('❌ Issue #504 browser verification failed:', err)
process.exit(1)
})