/** * 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/reviewer_504_r2' const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) interface CdpSession { evalJs: (expression: string) => Promise saveScreenshot: (filename: string) => Promise waitForGameReady: () => Promise externalUrls: string[] } 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() }) const externalUrls: string[] = [] ws.addEventListener('message', (event: MessageEvent) => { try { const msg = JSON.parse(String(event.data)) if (msg.method === 'Network.requestWillBeSent') { const reqUrl = String(msg.params?.request?.url ?? '') if ( reqUrl && !reqUrl.startsWith('http://127.0.0.1:') && !reqUrl.startsWith('data:') && !reqUrl.startsWith('blob:') ) { externalUrls.push(reqUrl) } } } catch { // Ignore non-JSON CDP events } }) 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('Network.enable') await sendCommand('Runtime.enable') await sendCommand('Page.enable') const result = await fn({ evalJs, saveScreenshot, waitForGameReady, externalUrls }) 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, pixel-level Exocet O cross vs Formal 436 hollow O, & zero external requests ── await withChromePage(`${baseUrl}/index.html`, 'index.html', 9251, async ({ evalJs, saveScreenshot, externalUrls }) => { await sleep(500) const indexFonts = await evalJs<{ exocetLoaded: boolean formalLoaded: boolean h1FontFamily: string descFontFamily: string exocetOCenterAlpha: number formalOCenterAlpha: number }>(`(async () => { await document.fonts.ready; await Promise.all([ document.fonts.load('48px D2Exocet'), document.fonts.load('48px D2Formal436'), ]); const measureGlyphCenterAlpha = (fontSpec) => { const c = document.createElement('canvas'); c.width = 96; c.height = 96; const ctx = c.getContext('2d'); ctx.font = fontSpec; ctx.textBaseline = 'alphabetic'; ctx.fillStyle = '#ffffff'; ctx.fillText('O', 16, 68); const d = ctx.getImageData(0, 0, 96, 96).data; let minX = 96, maxX = 0, minY = 96, maxY = 0; for (let y = 0; y < 96; y++) { for (let x = 0; x < 96; x++) { if (d[(y * 96 + x) * 4 + 3] > 64) { if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y; } } } const cx = Math.round((minX + maxX) / 2); const cy = Math.round((minY + maxY) / 2); let maxCenterAlpha = 0; for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { const a = d[((cy + dy) * 96 + (cx + dx)) * 4 + 3]; if (a > maxCenterAlpha) maxCenterAlpha = a; } } return maxCenterAlpha; }; return { exocetLoaded: document.fonts.check('16px D2Exocet'), formalLoaded: document.fonts.check('14px D2Formal436'), h1FontFamily: getComputedStyle(document.querySelector('h1')).fontFamily, descFontFamily: getComputedStyle(document.querySelector('.lang-desc')).fontFamily, exocetOCenterAlpha: measureGlyphCenterAlpha('48px "D2Exocet"'), formalOCenterAlpha: measureGlyphCenterAlpha('48px "D2Formal436"'), }; })()`) console.log('2. Verified index.html WebFonts & O-glyph center pixels:', 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)}`) } if (indexFonts.exocetOCenterAlpha < 128 || indexFonts.formalOCenterAlpha !== 0) { throw new Error(`Expected D2Exocet 'O' center to have cross pixels (>128) and D2Formal436 'O' center to be hollow (0), got: ${JSON.stringify(indexFonts)}`) } if (externalUrls.length > 0) { throw new Error(`External network requests detected on index.html: ${JSON.stringify(externalUrls)}`) } await saveScreenshot('issue-504-index.png') }) // ── Phase B: Verify acts-en.html Canvas DC6 bitmap font blitting, 12 color codes, Area Banner, Ground Labels, Panels & Formal 436 Prose ── await withChromePage(`${baseUrl}/acts-en.html?act=1`, 'acts-en.html', 9252, async ({ evalJs, saveScreenshot, waitForGameReady, externalUrls }) => { await waitForGameReady() // 1. Capture Area Entry Banner (`Font30` Exocet) + Top-left `#hud` (`D2Exocet`) + Ground `Alt` Item Labels (`D2Exocet`) const groundLabelCheck = await evalJs<{ groundLabelsCount: number groundLabelFontFamily: string groundLabelTexts: string[] }>(`(async () => { const inst = window.__d2webHudInstance; inst.leftPanel = 'none'; inst.rightPanel = 'none'; inst.worldPanels.npcMenu = null; inst.worldPanels.areaBanner = { titleEn: 'Rogue Encampment', titleZh: '罗格营地', untilMs: performance.now() + 60000, }; if (inst.inventory.equipped?.helm) { inst.callbacks.onDropItemToGround?.({ ...inst.inventory.equipped.helm, id: 'verify-ground-helm' }); } if (inst.inventory.equipped?.weapon1) { inst.callbacks.onDropItemToGround?.({ ...inst.inventory.equipped.weapon1, id: 'verify-ground-weapon' }); } inst.showGroundLabels = true; await new Promise(r => setTimeout(r, 300)); const els = Array.from(document.querySelectorAll('.d2-ground-label')); return { groundLabelsCount: els.length, groundLabelFontFamily: els[0] ? getComputedStyle(els[0]).fontFamily : '', groundLabelTexts: els.map(el => el.textContent || ''), }; })()`) console.log(' Verified Alt ground labels:', JSON.stringify(groundLabelCheck)) if (groundLabelCheck.groundLabelsCount < 2 || !groundLabelCheck.groundLabelFontFamily.includes('D2Exocet')) { throw new Error(`Expected >=2 .d2-ground-label DOM elements using D2Exocet, got: ${JSON.stringify(groundLabelCheck)}`) } await saveScreenshot('issue-504-acts-en-area-banner.png') // 2. Verify DC6 bitmap blitting, DC6 'O' cross vs Formal 436 hollow 'O', and 12 distinct color codes const enCheck = await evalJs<{ exocetLoaded: boolean formalLoaded: boolean hudFontFamily: string controlsFontFamily: string dialogFontFamily: string dialogTitleFontFamily: string dc6FontLoaded: boolean dc6BlitCount: number fillTextCount: number dc6Font30OCenterAlpha: number dc6Font16OCenterAlpha: number dc6Formal12OCenterAlpha: number distinctColorCodesCount: number }>(`(async () => { await document.fonts.ready; await Promise.all([ document.fonts.load('16px D2Exocet'), document.fonts.load('14px D2Formal436'), ]); const inst = window.__d2webHudInstance; inst.showGroundLabels = false; const measureDc6GlyphCenterAlpha = (fontName) => { const c = document.createElement('canvas'); c.width = 64; c.height = 64; const ctx = c.getContext('2d'); inst.font.drawText(ctx, 'O', 10, 40, { font: fontName, color: 'white', shadow: false }); const d = ctx.getImageData(0, 0, 64, 64).data; let minX = 64, maxX = 0, minY = 64, maxY = 0; for (let y = 0; y < 64; y++) { for (let x = 0; x < 64; x++) { if (d[(y * 64 + x) * 4 + 3] > 128) { if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y; } } } const cx = Math.round((minX + maxX) / 2); const cy = Math.round((minY + maxY) / 2); let maxCenterAlpha = 0; for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { const a = d[((cy + dy) * 64 + (cx + dx)) * 4 + 3]; if (a > maxCenterAlpha) maxCenterAlpha = a; } } return maxCenterAlpha; }; const colorCodes = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', ':', ';']; const rgbSignatures = new Set(); for (const cc of colorCodes) { const c = document.createElement('canvas'); c.width = 32; c.height = 32; const ctx = c.getContext('2d'); inst.font.drawText(ctx, 'ÿc' + cc + 'I', 4, 24, { font: 'font16', shadow: false }); const d = ctx.getImageData(0, 0, 32, 32).data; let brightest = '0,0,0'; let maxSum = -1; for (let i = 0; i < d.length; i += 4) { if (d[i + 3] >= 180) { const sum = d[i] + d[i + 1] + d[i + 2]; if (sum > maxSum) { maxSum = sum; brightest = d[i] + ',' + d[i + 1] + ',' + d[i + 2]; } } } rgbSignatures.add(brightest); } 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('14px 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, dc6Font30OCenterAlpha: measureDc6GlyphCenterAlpha('font30'), dc6Font16OCenterAlpha: measureDc6GlyphCenterAlpha('font16'), dc6Formal12OCenterAlpha: measureDc6GlyphCenterAlpha('fontformal12'), distinctColorCodesCount: rgbSignatures.size, }; })()`) console.log('3. Verified acts-en.html Canvas DC6 blitting, O-glyph cross & 12 colors:', 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)}`) } if (enCheck.dc6Font30OCenterAlpha < 200 || enCheck.dc6Font16OCenterAlpha < 200 || enCheck.dc6Formal12OCenterAlpha !== 0) { throw new Error(`Expected DC6 font30/font16 'O' center to be solid cross (>200) and fontformal12 'O' center to be hollow (0), got: ${JSON.stringify(enCheck)}`) } if (enCheck.distinctColorCodesCount !== 12) { throw new Error(`Expected 12 distinct rendered RGB colors for ÿc0..ÿc;, got: ${enCheck.distinctColorCodesCount}`) } if (externalUrls.length > 0) { throw new Error(`External network requests detected on acts-en.html: ${JSON.stringify(externalUrls)}`) } await saveScreenshot('issue-504-acts-en-panels.png') // 3. Open Quest Log + Skill Tree + Canvas NPC Speech Dialog to verify Formal 436 (`fontformal12`) & Exocet in all panels await evalJs(`(() => { const inst = window.__d2webHudInstance; inst.inventory.hoveredItem = null; if (inst.leftPanel !== 'quest') inst.toggleLeftPanel('quest'); if (inst.rightPanel !== 'skill') inst.toggleRightPanel('skill'); inst.worldPanels.npcMenu = { npcId: 1, npcName: 'Akara', x: 400, y: 440, talking: true, dialogLines: [ 'I am Akara, High Priestess of the Sisterhood of the Sightless Eye.', 'Welcome to our camp, traveler. Please cleanse the Den of Evil.', ], }; inst.render(performance.now(), false); })()`) await sleep(300) await saveScreenshot('issue-504-acts-en-quests-skills-dialog.png') }) // ── Phase C: Verify acts-zh.html hybrid DC6 + CJK bilingual rendering ── await withChromePage(`${baseUrl}/acts-zh.html?act=1`, 'acts-zh.html', 9253, async ({ evalJs, saveScreenshot, waitForGameReady, externalUrls }) => { await waitForGameReady() const zhCheck = await evalJs<{ dc6BlitCount: number fillTextCount: number }>(`(() => { 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 { dc6BlitCount: blitCount, fillTextCount: fillCount, }; })()`) console.log('4. Verified acts-zh.html hybrid DC6 + CJK rendering:', JSON.stringify(zhCheck)) if (zhCheck.dc6BlitCount < 100 || zhCheck.fillTextCount < 10) { throw new Error(`Expected acts-zh.html hybrid rendering (>100 DC6 blits and >=10 CJK fillText calls), got: ${JSON.stringify(zhCheck)}`) } await sleep(300) if (externalUrls.length > 0) { throw new Error(`External network requests detected on acts-zh.html: ${JSON.stringify(externalUrls)}`) } await saveScreenshot('issue-504-acts-zh-bilingual.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) })