diablo2-web/scripts/audit-skills-browser.ts

474 lines
21 KiB
TypeScript

/**
* Comprehensive Browser-based E2E Verification & Screenshot Audit for Diablo II Skill Calculator.
*/
import { spawn, type ChildProcess } from 'child_process'
import { createServer } from 'http'
import { readFileSync, existsSync, mkdirSync, writeFileSync, copyFileSync } from 'fs'
import { join, extname } from 'path'
async function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms))
}
const MIME_TYPES: Record<string, string> = {
'.html': 'text/html',
'.js': 'application/javascript',
'.css': 'text/css',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.json': 'application/json',
}
async function runAudit() {
console.log('=== Starting Diablo II Skill Calculator Browser Audit ===')
// 1. Start simple static server on port 8899 serving dist/
const PORT = 8899
const baseDir = join(process.cwd(), 'dist')
if (!existsSync(baseDir)) {
throw new Error('dist/ directory does not exist! Run npm run build first.')
}
const server = createServer((req, res) => {
let reqPath = req.url?.split('?')[0]?.split('#')[0] || '/'
if (reqPath === '/') reqPath = '/skills.html'
const filePath = join(baseDir, reqPath)
if (existsSync(filePath)) {
const ext = extname(filePath)
res.writeHead(200, { 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream' })
res.end(readFileSync(filePath))
} else {
// Check public/ folder for skill images
const publicPath = join(process.cwd(), 'public', reqPath)
if (existsSync(publicPath)) {
const ext = extname(publicPath)
res.writeHead(200, { 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream' })
res.end(readFileSync(publicPath))
} else {
res.writeHead(404)
res.end('Not found: ' + reqPath)
}
}
})
await new Promise<void>(resolve => server.listen(PORT, '127.0.0.1', () => resolve()))
console.log(`1. Local HTTP server listening at http://127.0.0.1:${PORT}/`)
// 2. Launch headless Chrome with CDP
console.log('2. Starting Chrome headless on port 9223...')
const chromeProc: ChildProcess = spawn('/usr/bin/google-chrome', [
'--headless=new',
'--remote-debugging-port=9223',
'--no-sandbox',
'--disable-gpu',
'--disable-extensions',
'--window-size=1280,900',
`http://127.0.0.1:${PORT}/skills.html`,
])
let wsUrl: string | null = null
for (let i = 0; i < 40; i++) {
await sleep(300)
try {
const res = await fetch('http://127.0.0.1:9223/json/list')
const pages = await res.json()
const page = pages.find((p: any) => p.type === 'page' && p.url.includes('skills.html'))
if (page && page.webSocketDebuggerUrl) {
wsUrl = page.webSocketDebuggerUrl
break
}
} catch {}
}
if (!wsUrl) {
chromeProc.kill()
server.close()
throw new Error('Failed to connect to Chrome headless on port 9223')
}
console.log('3. Connected to Chrome WebSocket:', wsUrl)
const ws = new WebSocket(wsUrl)
await new Promise(resolve => ws.onopen = resolve)
let idCounter = 1
function sendCommand(method: string, params: any = {}): Promise<any> {
return new Promise((resolve, reject) => {
const id = idCounter++
const handler = (event: any) => {
const msg = JSON.parse(event.data)
if (msg.id === id) {
ws.removeEventListener('message', handler)
if (msg.error) reject(msg.error)
else resolve(msg.result)
}
}
ws.addEventListener('message', handler)
ws.send(JSON.stringify({ id, method, params }))
})
}
await sendCommand('Runtime.enable')
await sendCommand('Page.enable')
await sleep(1500)
// Test 1: Check initial Amazon render
console.log('4. Auditing initial page render (Amazon)...')
const initialData = await sendCommand('Runtime.evaluate', {
expression: `(() => {
const titles = Array.from(document.querySelectorAll('.tree-title')).map(el => el.textContent.trim());
const sockets = document.querySelectorAll('.skill-socket');
const reqLvl = document.getElementById('val-req-level')?.textContent;
const spent = document.getElementById('val-points-spent')?.textContent;
return { titles, socketCount: sockets.length, reqLvl, spent };
})()`,
returnByValue: true,
})
console.log(' Initial render state:', initialData.result.value)
if (initialData.result.value.socketCount !== 30) {
throw new Error(`Expected 30 skill sockets for Amazon, found: ${initialData.result.value.socketCount}`)
}
if (initialData.result.value.titles.length !== 3) {
throw new Error(`Expected 3 tree titles, found: ${initialData.result.value.titles.length}`)
}
// Test 2: Left-click point allocation on Jab (id 10)
console.log('5. Auditing Left-click allocation on Jab (id 10)...')
const allocData = await sendCommand('Runtime.evaluate', {
expression: `(() => {
const jabSocket = document.querySelector('.skill-socket[data-skill-id="10"]');
if (!jabSocket) return { error: 'Jab socket not found' };
// Left click
jabSocket.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
const newJab = document.querySelector('.skill-socket[data-skill-id="10"]');
const badge = newJab?.querySelector('.skill-pts-badge')?.textContent;
const reqLvl = document.getElementById('val-req-level')?.textContent;
const spent = document.getElementById('val-points-spent')?.textContent;
return { badge, reqLvl, spent };
})()`,
returnByValue: true,
})
console.log(' After 1 click on Jab:', allocData.result.value)
if (allocData.result.value.badge !== '1') {
throw new Error(`Expected Jab badge to be 1, got ${allocData.result.value.badge}`)
}
// Shift+Click allocation (+5 points)
console.log('6. Auditing Shift+Left-click allocation on Jab (+5 points)...')
const shiftAllocData = await sendCommand('Runtime.evaluate', {
expression: `(() => {
const jabSocket = document.querySelector('.skill-socket[data-skill-id="10"]');
jabSocket.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, shiftKey: true }));
const newJab = document.querySelector('.skill-socket[data-skill-id="10"]');
const badge = newJab?.querySelector('.skill-pts-badge')?.textContent;
const spent = document.getElementById('val-points-spent')?.textContent;
return { badge, spent };
})()`,
returnByValue: true,
})
console.log(' After Shift+Click on Jab:', shiftAllocData.result.value)
if (shiftAllocData.result.value.badge !== '6') {
throw new Error(`Expected Jab badge to be 6, got ${shiftAllocData.result.value.badge}`)
}
// Max out Jab to 20 points
await sendCommand('Runtime.evaluate', {
expression: `(() => {
const jabSocket = document.querySelector('.skill-socket[data-skill-id="10"]');
for (let i = 0; i < 3; i++) {
jabSocket.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, shiftKey: true }));
}
})()`,
})
// Test 3: Prerequisite chain unlocking
console.log('7. Auditing Prerequisite chain: Power Strike (id 14) and Impale (id 19)...')
const prereqCheck = await sendCommand('Runtime.evaluate', {
expression: `(() => {
const psSocket = document.querySelector('.skill-socket[data-skill-id="14"]');
const impSocket = document.querySelector('.skill-socket[data-skill-id="19"]');
const psLocked = psSocket?.classList.contains('locked');
const impLocked = impSocket?.classList.contains('locked');
// Allocate 5 points to Power Strike
psSocket.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, shiftKey: true }));
const newPs = document.querySelector('.skill-socket[data-skill-id="14"]');
const psBadge = newPs?.querySelector('.skill-pts-badge')?.textContent;
return { psLocked, impLocked, psBadge };
})()`,
returnByValue: true,
})
console.log(' Prerequisite unlocking result:', prereqCheck.result.value)
if (prereqCheck.result.value.psLocked || prereqCheck.result.value.impLocked) {
throw new Error('Power Strike or Impale remained locked after allocating prerequisite Jab!')
}
if (prereqCheck.result.value.psBadge !== '5') {
throw new Error(`Expected Power Strike badge to be 5, got ${prereqCheck.result.value.psBadge}`)
}
// Test 4: Tooltip verification on Charged Strike (id 24)
console.log('8. Auditing Hover Tooltip on Charged Strike (id 24)...')
const tooltipCheck = await sendCommand('Runtime.evaluate', {
expression: `(() => {
const csSocket = document.querySelector('.skill-socket[data-skill-id="24"]');
const rect = csSocket.getBoundingClientRect();
csSocket.dispatchEvent(new MouseEvent('mouseenter', {
bubbles: true,
clientX: rect.left + 20,
clientY: rect.top + 20
}));
const tt = document.getElementById('skill-tooltip');
const display = tt?.style.display;
const title = tt?.querySelector('.tt-title')?.textContent;
const damage = tt?.querySelector('.tt-damage-main')?.textContent?.trim();
const synergies = Array.from(tt?.querySelectorAll('.tt-syn-item') || []).map(el => el.textContent.trim());
return { display, title, damage, synergies };
})()`,
returnByValue: true,
})
console.log(' Hover Tooltip data:', tooltipCheck.result.value)
if (tooltipCheck.result.value.display !== 'block') {
throw new Error('Tooltip was not displayed on mouseenter!')
}
if (!tooltipCheck.result.value.title?.includes('充能') && !tooltipCheck.result.value.title?.includes('Charged Strike')) {
throw new Error(`Unexpected tooltip title: ${tooltipCheck.result.value.title}`)
}
// Test 5: Right-click deallocation and Orphan Protection
console.log('9. Auditing Orphan Protection on Jab and Power Strike...')
const orphanCheck = await sendCommand('Runtime.evaluate', {
expression: `(() => {
// Allocate prerequisites for Charged Strike (24): Poison Javelin (15) -> Lightning Bolt (20) -> Charged Strike (24)
document.querySelector('.skill-socket[data-skill-id="15"]')?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
document.querySelector('.skill-socket[data-skill-id="20"]')?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
document.querySelector('.skill-socket[data-skill-id="24"]')?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
// Now attempt to right-click Power Strike (14) down to 0 points with shiftKey
document.querySelector('.skill-socket[data-skill-id="14"]')?.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, shiftKey: true }));
const newPs = document.querySelector('.skill-socket[data-skill-id="14"]');
const psBadge = newPs?.querySelector('.skill-pts-badge')?.textContent;
const toastText = document.getElementById('toast')?.textContent;
return { psBadge, toastText };
})()`,
returnByValue: true,
})
console.log(' Orphan protection result:', orphanCheck.result.value)
if (orphanCheck.result.value.psBadge === '0') {
throw new Error('Orphan protection failed! Power Strike was reduced to 0 while Charged Strike still depends on it!')
}
// Test 6: Class switcher to Sorceress, URL Hash, and bilingual toggle
console.log('10. Auditing Class Switcher to Sorceress (sor)...')
const sorCheck = await sendCommand('Runtime.evaluate', {
expression: `(() => {
const sorBtn = document.querySelector('.class-btn[data-class-code="sor"]');
sorBtn.click();
const titles = Array.from(document.querySelectorAll('.tree-title')).map(el => el.textContent.trim());
const hash = window.location.hash;
return { titles, hash };
})()`,
returnByValue: true,
})
console.log(' Sorceress tabs:', sorCheck.result.value)
if (!sorCheck.result.value.titles[0]?.includes('火焰法术') && !sorCheck.result.value.titles[0]?.includes('Fire Spells')) {
throw new Error('Failed to switch to Sorceress trees!')
}
// Test 7: Bilingual toggle
console.log('11. Auditing Language Toggle...')
const langCheck = await sendCommand('Runtime.evaluate', {
expression: `(() => {
const btnLang = document.getElementById('btn-lang');
btnLang.click(); // Switch to EN
const titlesEn = Array.from(document.querySelectorAll('.tree-title')).map(el => el.textContent.trim());
const respecText = document.getElementById('btn-respec')?.textContent;
return { titlesEn, respecText };
})()`,
returnByValue: true,
})
console.log(' English switch result:', langCheck.result.value)
if (!langCheck.result.value.titlesEn[0]?.includes('Fire Spells')) {
throw new Error(`Expected English tab 'Fire Spells', got: ${langCheck.result.value.titlesEn[0]}`)
}
// Test 8: Non-Damage Skills Tooltip Display Audit (Warmth, Raise Skeleton, Battle Orders, Fanaticism)
console.log('12. Auditing Non-Damage Skills (Warmth, Raise Skeleton, Battle Orders, Fanaticism)...')
const nonDamageAudit = await sendCommand('Runtime.evaluate', {
expression: `(() => {
document.getElementById('btn-lang').click(); // Back to Chinese
// 1. Warmth (37)
const warmthSocket = document.querySelector('.skill-socket[data-skill-id="37"]');
warmthSocket.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
const tt = document.getElementById('skill-tooltip');
const warmthHasDmg = Boolean(tt.querySelector('.tt-damage-box'));
const warmthEffects = Array.from(tt.querySelectorAll('.tt-effect-line')).map(e => e.textContent);
// 2. Switch to Necromancer (nec) for Raise Skeleton (70)
document.querySelector('.class-btn[data-class-code="nec"]').click();
const skelSocket = document.querySelector('.skill-socket[data-skill-id="70"]');
const smSocket = document.querySelector('.skill-socket[data-skill-id="69"]');
// Allocate 20 Raise Skeleton & 20 Skeleton Mastery
for (let i = 0; i < 4; i++) skelSocket.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, shiftKey: true }));
for (let i = 0; i < 4; i++) smSocket.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, shiftKey: true }));
const skelRect = skelSocket.getBoundingClientRect();
skelSocket.dispatchEvent(new MouseEvent('mouseenter', {
bubbles: true,
clientX: skelRect.left + 24,
clientY: skelRect.top + 24
}));
const skelHasDmg = Boolean(tt.querySelector('.tt-damage-box'));
const skelEffects = Array.from(tt.querySelectorAll('.tt-effect-line')).map(e => e.textContent);
// 3. Switch to Barbarian (bar) for Battle Orders (149)
document.querySelector('.class-btn[data-class-code="bar"]').click();
const boSocket = document.querySelector('.skill-socket[data-skill-id="149"]');
boSocket.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
const boHasDmg = Boolean(tt.querySelector('.tt-damage-box'));
const boEffects = Array.from(tt.querySelectorAll('.tt-effect-line')).map(e => e.textContent);
// 4. Switch to Paladin (pal) for Fanaticism (122)
document.querySelector('.class-btn[data-class-code="pal"]').click();
const fanSocket = document.querySelector('.skill-socket[data-skill-id="122"]');
fanSocket.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
const fanHasDmg = Boolean(tt.querySelector('.tt-damage-box'));
const fanEffects = Array.from(tt.querySelectorAll('.tt-effect-line')).map(e => e.textContent);
return {
warmth: { hasDmg: warmthHasDmg, effects: warmthEffects },
raiseSkeleton: { hasDmg: skelHasDmg, effects: skelEffects },
battleOrders: { hasDmg: boHasDmg, effects: boEffects },
fanaticism: { hasDmg: fanHasDmg, effects: fanEffects },
};
})()`,
returnByValue: true,
})
console.log(' Non-Damage Audit Results:', JSON.stringify(nonDamageAudit.result.value, null, 2))
const nd = nonDamageAudit.result.value
if (nd.warmth.hasDmg) throw new Error('Warmth MUST NOT display direct damage!')
if (!nd.warmth.effects.some((t: string) => t.includes('法力回復速度'))) {
throw new Error('Warmth missing mana recovery rate effect!')
}
if (nd.raiseSkeleton.hasDmg) throw new Error('Raise Skeleton MUST NOT display direct damage!')
if (!nd.raiseSkeleton.effects.some((t: string) => t.includes('骷髏總數'))) {
throw new Error('Raise Skeleton missing skeleton count effect!')
}
if (nd.battleOrders.hasDmg) throw new Error('Battle Orders MUST NOT display direct damage!')
if (!nd.battleOrders.effects.some((t: string) => t.includes('生命最大值'))) {
throw new Error('Battle Orders missing max life effect!')
}
if (nd.fanaticism.hasDmg) throw new Error('Fanaticism MUST NOT display direct damage!')
if (!nd.fanaticism.effects.some((t: string) => t.includes('攻擊速度'))) {
throw new Error('Fanaticism missing attack speed effect!')
}
console.log('✓ Non-Damage Skills Tooltip Audit Passed: No fake damage, all authentic mechanics displayed!')
// Capture Necromancer Raise Skeleton Screenshot
console.log('12.1 Capturing Necromancer Raise Skeleton Tooltip Screenshot...')
await sendCommand('Runtime.evaluate', {
expression: `(() => {
document.querySelector('.class-btn[data-class-code="nec"]').click();
const skelSocket = document.querySelector('.skill-socket[data-skill-id="70"]');
const smSocket = document.querySelector('.skill-socket[data-skill-id="69"]');
for (let i = 0; i < 4; i++) skelSocket.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, shiftKey: true }));
for (let i = 0; i < 4; i++) smSocket.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, shiftKey: true }));
const skelRect = skelSocket.getBoundingClientRect();
skelSocket.dispatchEvent(new MouseEvent('mouseenter', {
bubbles: true,
clientX: skelRect.left + 24,
clientY: skelRect.top + 24
}));
})()`,
})
await sleep(500)
const necShot = await sendCommand('Page.captureScreenshot', { format: 'png', quality: 100, fromSurface: true })
const necBuf = Buffer.from(necShot.data, 'base64')
const necPathLocal = join(process.cwd(), 'tests', 'skills_calculator_nec_audit.png')
const necPathArtifact = '/usr/local/google/home/taodao/.gemini/jetski/brain/d8366262-b464-4b0a-bff5-16918852fb1a/skills_calculator_nec_audit.png'
const necPathX20 = '/google/data/rw/users/ta/taodao/skills_calculator_nec_audit.png'
writeFileSync(necPathLocal, necBuf)
writeFileSync(necPathArtifact, necBuf)
try { copyFileSync(necPathLocal, necPathX20) } catch {}
console.log(`✓ Saved Necromancer screenshot to: ${necPathArtifact} and x20`)
// Switch back to Sorceress and allocate Fire Ball build for final screenshot
console.log('13. Configuring Fire Sorceress build for visual audit screenshot...')
await sendCommand('Runtime.evaluate', {
expression: `(() => {
document.querySelector('.class-btn[data-class-code="sor"]').click();
// Allocate 20 Fire Bolt (36)
const bolt = document.querySelector('.skill-socket[data-skill-id="36"]');
for (let i = 0; i < 4; i++) bolt.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, shiftKey: true }));
// Allocate 20 Fire Ball (47)
const ball = document.querySelector('.skill-socket[data-skill-id="47"]');
for (let i = 0; i < 4; i++) ball.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, shiftKey: true }));
// Allocate 20 Fire Mastery (61)
const fm = document.querySelector('.skill-socket[data-skill-id="61"]');
for (let i = 0; i < 4; i++) fm.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, shiftKey: true }));
// Add +3 All Skills
const incBtn = document.getElementById('btn-all-skills-inc');
incBtn.click(); incBtn.click(); incBtn.click();
// Trigger hover tooltip on Fire Ball (47)
const ballRect = ball.getBoundingClientRect();
ball.dispatchEvent(new MouseEvent('mouseenter', {
bubbles: true,
clientX: ballRect.left + 24,
clientY: ballRect.top + 24
}));
})()`,
})
await sleep(600)
// Capture screenshot of the full viewport
console.log('13. Capturing full-page screenshot via CDP...')
const screenshotResult = await sendCommand('Page.captureScreenshot', {
format: 'png',
quality: 100,
fromSurface: true,
})
const screenshotBuf = Buffer.from(screenshotResult.data, 'base64')
const localScreenshotPath = join(process.cwd(), 'tests', 'skills_calculator_audit.png')
const publicScreenshotPath = join(process.cwd(), 'public', 'skills_calculator_audit.png')
const x20Dir = '/google/data/rw/users/ta/taodao'
const x20Path = join(x20Dir, 'skills_calculator_audit.png')
const artifactPath = '/usr/local/google/home/taodao/.gemini/jetski/brain/d8366262-b464-4b0a-bff5-16918852fb1a/skills_calculator_audit.png'
writeFileSync(localScreenshotPath, screenshotBuf)
writeFileSync(publicScreenshotPath, screenshotBuf)
writeFileSync(artifactPath, screenshotBuf)
if (existsSync(x20Dir)) {
try {
copyFileSync(localScreenshotPath, x20Path)
console.log(`✓ Copied audit screenshot to x20: ${x20Path}`)
} catch (e) {
console.warn('Could not copy to x20:', e)
}
}
console.log(`✓ Saved screenshot to: ${localScreenshotPath}`)
console.log(`✓ Saved screenshot to: ${artifactPath}`)
// Cleanup
ws.close()
chromeProc.kill()
server.close()
console.log('=== All Browser Audit Tests Passed Successfully! ===')
}
runAudit().catch(err => {
console.error('Audit failed:', err)
process.exit(1)
})