/** * 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(resolve => setTimeout(resolve, ms)) interface CdpSession { evalJs: (expression: string) => Promise saveScreenshot: (filename: string) => Promise waitForGameReady: () => Promise } async function withChromePage( url: string, matchSubstr: string, debugPort: number, fn: (session: CdpSession) => Promise, ): Promise { 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(resolve => { ws.onopen = () => resolve() }) let idCounter = 1 const sendCommand = (method: string, params: Record = {}): Promise => 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 (expression: string): Promise => { 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 => { 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 => { for (let i = 0; i < 120; i++) { const ready = await evalJs( `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 { 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 = '
Akara · High Priestess of the Sisterhood
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.
'; 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) })