/** * 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 = { '.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(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 { 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) })