442 lines
18 KiB
TypeScript
442 lines
18 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/reviewer_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>
|
|
externalUrls: string[]
|
|
}
|
|
|
|
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()
|
|
})
|
|
|
|
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 = <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('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<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, 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`)
|
|
await evalJs(`(() => {
|
|
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,
|
|
};
|
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Alt', code: 'AltLeft', bubbles: true }));
|
|
inst.render(performance.now(), false);
|
|
})()`)
|
|
await sleep(300)
|
|
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;
|
|
|
|
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 {
|
|
document.dispatchEvent(new KeyboardEvent('keyup', { key: 'Alt', code: 'AltLeft', bubbles: true }));
|
|
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 bilingual CJK + Exocet/Formal 436 rendering ──
|
|
await withChromePage(`${baseUrl}/acts-zh.html?act=1`, 'acts-zh.html', 9253, async ({ evalJs, saveScreenshot, waitForGameReady, externalUrls }) => {
|
|
await waitForGameReady()
|
|
await evalJs(`(() => {
|
|
const inst = window.__d2webHudInstance;
|
|
if (inst.leftPanel !== 'char') inst.toggleLeftPanel('char');
|
|
if (inst.rightPanel !== 'inv') inst.toggleRightPanel('inv');
|
|
inst.inventory.handleMouseMove(561, 93);
|
|
inst.render(performance.now(), false);
|
|
})()`)
|
|
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)
|
|
})
|