832 lines
32 KiB
TypeScript
832 lines
32 KiB
TypeScript
#!/usr/bin/env npx tsx
|
|
/**
|
|
* Headless Chrome CDP End-to-End Browser Verification for Kill Statistics:
|
|
* - Verifies `play.html` toolbar entry (`#d2-toolbar-killstats` -> `stats.html`, `target="_blank"`)
|
|
* - Drives `OnlineSession` + `KillTracker` in `play.html` with simulated D2GS `ServerEvent`s:
|
|
* - Solo kills: Andariel (`classId: 156`, double death event `0x08`+`0x09`), Pindleskin (`classId: 440`, `superUniqueId: 52`), Fallen (`classId: 19`)
|
|
* - Ignored non-hostile deaths: Mercenary (`0x81`), player summon (`ownerId`), friendly `Align !== 0` (`classId: 363`)
|
|
* - Party kill: `RosterUpdate` (`0x5B`) + Fallen (`classId: 19`)
|
|
* - Opens `stats.html` in a second tab in the same browser context:
|
|
* - Verifies local `@font-face` (`D2Exocet`, `D2Formal436`) and isolated `100vh` scroll containers
|
|
* - Verifies Account -> Character tree, Difficulty Summary Cards, 86-row Boss/SuperUnique Board, and Monster Type Table
|
|
* - Verifies Solo-Only toggle (`4` -> `3` -> `4`) and `中 / EN` language toggle
|
|
* - Verifies real-time cross-tab `BroadcastChannel('d2web-kill-stats')` auto-refresh (`4` -> `5` without reload)
|
|
* - Verifies G-counter JSON export + idempotent re-import via `DOM.setFileInputFiles`
|
|
* - Verifies tampered JSON (`soloCount > count`) rejection with `[role="alert"]` error banner
|
|
* - Verifies Character Deletion (`window.confirm` dialog) and empty state rendering
|
|
* - Verifies zero console errors and zero uncaught page exceptions across both tabs
|
|
*/
|
|
|
|
import { spawn } from 'node:child_process'
|
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { dirname, join, resolve } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { createServer, type ViteDevServer } from 'vite'
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
const ROOT_DIR = resolve(__dirname, '..')
|
|
|
|
const CHROME_CANDIDATES = [
|
|
join(process.env.HOME ?? '', '.cache/ms-playwright/chromium-1228/chrome-linux64/chrome'),
|
|
'/root/.cache/ms-playwright/chromium-1223/chrome-linux64/chrome',
|
|
'/usr/bin/google-chrome',
|
|
]
|
|
|
|
function findChromeBinary(): string {
|
|
for (const cand of CHROME_CANDIDATES) {
|
|
if (cand && existsSync(cand)) return cand
|
|
}
|
|
throw new Error(`No headless Chrome binary found in candidates: ${CHROME_CANDIDATES.join(', ')}`)
|
|
}
|
|
|
|
function parseOutDir(): string {
|
|
const args = process.argv.slice(2)
|
|
for (let i = 0; i < args.length; i++) {
|
|
const arg = args[i]!
|
|
if (arg === '--out' && args[i + 1]) return resolve(args[i + 1]!)
|
|
if (arg.startsWith('--out=')) return resolve(arg.slice('--out='.length))
|
|
}
|
|
return resolve(ROOT_DIR, 'scratch/verify-kill-stats')
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((r) => setTimeout(r, ms))
|
|
}
|
|
|
|
function assert(cond: unknown, message: string): asserts cond {
|
|
if (!cond) {
|
|
throw new Error(`Assertion failed: ${message}`)
|
|
}
|
|
}
|
|
|
|
class CdpSession {
|
|
private readonly ws: WebSocket
|
|
private msgId = 0
|
|
private readonly pending = new Map<
|
|
number,
|
|
{ readonly resolve: (val: any) => void; readonly reject: (err: Error) => void }
|
|
>()
|
|
readonly consoleErrors: string[] = []
|
|
readonly pageExceptions: string[] = []
|
|
readonly dialogsOpened: Array<{ readonly type: string; readonly message: string }> = []
|
|
autoAcceptDialogs = true
|
|
|
|
private constructor(ws: WebSocket) {
|
|
this.ws = ws
|
|
this.ws.onmessage = (ev) => {
|
|
const msg = JSON.parse(String(ev.data))
|
|
if (typeof msg.id === 'number') {
|
|
const entry = this.pending.get(msg.id)
|
|
if (entry) {
|
|
this.pending.delete(msg.id)
|
|
if (msg.error) {
|
|
entry.reject(new Error(msg.error.message || JSON.stringify(msg.error)))
|
|
} else {
|
|
entry.resolve(msg.result)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
if (msg.method === 'Runtime.consoleAPICalled') {
|
|
const type = msg.params?.type
|
|
if (type === 'error') {
|
|
const text = (msg.params?.args ?? [])
|
|
.map((a: any) => a.value ?? a.description ?? '')
|
|
.join(' ')
|
|
if (!text.includes('favicon.ico') && !text.includes('404')) {
|
|
this.consoleErrors.push(text)
|
|
}
|
|
}
|
|
} else if (msg.method === 'Runtime.exceptionThrown') {
|
|
const details = msg.params?.exceptionDetails
|
|
const desc =
|
|
details?.exception?.description ?? details?.text ?? JSON.stringify(details)
|
|
this.pageExceptions.push(String(desc))
|
|
} else if (msg.method === 'Page.javascriptDialogOpening') {
|
|
this.dialogsOpened.push({
|
|
type: String(msg.params?.type ?? ''),
|
|
message: String(msg.params?.message ?? ''),
|
|
})
|
|
void this.send('Page.handleJavaScriptDialog', { accept: this.autoAcceptDialogs })
|
|
}
|
|
}
|
|
}
|
|
|
|
static async connect(wsUrl: string): Promise<CdpSession> {
|
|
const ws = new WebSocket(wsUrl)
|
|
await new Promise<void>((resolve, reject) => {
|
|
ws.onopen = () => resolve()
|
|
ws.onerror = (err) => reject(new Error(`CDP WebSocket error: ${String(err)}`))
|
|
})
|
|
const cdp = new CdpSession(ws)
|
|
await cdp.send('Runtime.enable')
|
|
await cdp.send('Page.enable')
|
|
return cdp
|
|
}
|
|
|
|
send(method: string, params: Record<string, unknown> = {}): Promise<any> {
|
|
const id = ++this.msgId
|
|
return new Promise((resolve, reject) => {
|
|
this.pending.set(id, { resolve, reject })
|
|
this.ws.send(JSON.stringify({ id, method, params }))
|
|
})
|
|
}
|
|
|
|
async evaluate<T = any>(expression: string): Promise<T> {
|
|
const res = await this.send('Runtime.evaluate', {
|
|
expression,
|
|
returnByValue: true,
|
|
awaitPromise: true,
|
|
})
|
|
if (res?.exceptionDetails) {
|
|
const desc =
|
|
res.exceptionDetails.exception?.description ??
|
|
res.exceptionDetails.text ??
|
|
JSON.stringify(res.exceptionDetails)
|
|
throw new Error(`CDP evaluate exception: ${String(desc)}`)
|
|
}
|
|
return res?.result?.value as T
|
|
}
|
|
|
|
async setFileInputFiles(selector: string, filePaths: readonly string[]): Promise<void> {
|
|
await this.send('DOM.enable')
|
|
const { root } = await this.send('DOM.getDocument', { depth: -1 })
|
|
const { nodeId } = await this.send('DOM.querySelector', {
|
|
nodeId: root.nodeId,
|
|
selector,
|
|
})
|
|
if (!nodeId) {
|
|
throw new Error(`DOM.querySelector failed to find file input: ${selector}`)
|
|
}
|
|
await this.send('DOM.setFileInputFiles', {
|
|
nodeId,
|
|
files: [...filePaths],
|
|
})
|
|
await this.evaluate(`(() => {
|
|
const el = document.querySelector(${JSON.stringify(selector)});
|
|
if (el) el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
})()`)
|
|
}
|
|
|
|
async captureScreenshot(outPath: string): Promise<number> {
|
|
const shot = await this.send('Page.captureScreenshot', { format: 'png' })
|
|
const data = shot?.data
|
|
if (typeof data !== 'string' || data.length === 0) {
|
|
throw new Error('Failed to capture PNG screenshot via CDP')
|
|
}
|
|
const buf = Buffer.from(data, 'base64')
|
|
mkdirSync(dirname(outPath), { recursive: true })
|
|
writeFileSync(outPath, buf)
|
|
return buf.byteLength
|
|
}
|
|
|
|
async close(): Promise<void> {
|
|
try {
|
|
this.ws.close()
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
}
|
|
|
|
async function openNewPageTarget(
|
|
cdpPort: string,
|
|
targetUrl: string,
|
|
): Promise<{ readonly id: string; readonly webSocketDebuggerUrl: string }> {
|
|
const res = await fetch(
|
|
`http://127.0.0.1:${cdpPort}/json/new?${encodeURIComponent(targetUrl)}`,
|
|
{ method: 'PUT' },
|
|
)
|
|
if (!res.ok) {
|
|
throw new Error(`Failed to open new tab for ${targetUrl}: HTTP ${res.status}`)
|
|
}
|
|
return (await res.json()) as { id: string; webSocketDebuggerUrl: string }
|
|
}
|
|
|
|
async function run(): Promise<void> {
|
|
const chromeBin = findChromeBinary()
|
|
const outDir = parseOutDir()
|
|
mkdirSync(outDir, { recursive: true })
|
|
|
|
const publicDir = process.env.D2W_PUBLIC_DIR
|
|
? resolve(process.env.D2W_PUBLIC_DIR)
|
|
: resolve(ROOT_DIR, 'public')
|
|
const tilesIndexPath = join(publicDir, 'act-packs/tiles/index.json')
|
|
if (!existsSync(tilesIndexPath)) {
|
|
throw new Error(
|
|
`Missing required packed tiles at ${tilesIndexPath}. Set D2W_PUBLIC_DIR=/usr/local/google/home/taodao/diablo2-web/public`,
|
|
)
|
|
}
|
|
|
|
let viteServer: ViteDevServer | null = null
|
|
const userDataDir = mkdtempSync(join(tmpdir(), 'd2-verify-kill-stats-'))
|
|
const tmpImportDir = mkdtempSync(join(tmpdir(), 'd2-verify-kill-stats-import-'))
|
|
let chromeProc: ReturnType<typeof spawn> | null = null
|
|
let playCdp: CdpSession | null = null
|
|
let statsCdp: CdpSession | null = null
|
|
|
|
try {
|
|
viteServer = await createServer({
|
|
root: ROOT_DIR,
|
|
publicDir,
|
|
server: {
|
|
host: '127.0.0.1',
|
|
port: 0,
|
|
strictPort: false,
|
|
watch: null,
|
|
},
|
|
logLevel: 'error',
|
|
})
|
|
await viteServer.listen()
|
|
const addr = viteServer.httpServer?.address()
|
|
const port = typeof addr === 'object' && addr ? addr.port : 0
|
|
if (!port) throw new Error('Failed to start Vite server')
|
|
|
|
const playUrl = `http://127.0.0.1:${port}/play.html`
|
|
const statsUrl = `http://127.0.0.1:${port}/stats.html`
|
|
console.log(`[verify-kill-stats] Vite server listening at http://127.0.0.1:${port}`)
|
|
|
|
chromeProc = spawn(
|
|
chromeBin,
|
|
[
|
|
'--headless=new',
|
|
'--no-sandbox',
|
|
'--disable-dev-shm-usage',
|
|
'--disable-gpu-sandbox',
|
|
'--use-angle=swiftshader',
|
|
'--use-gl=angle',
|
|
'--enable-unsafe-swiftshader',
|
|
'--ignore-gpu-blocklist',
|
|
'--disable-background-timer-throttling',
|
|
'--disable-renderer-backgrounding',
|
|
'--disable-backgrounding-occluded-windows',
|
|
'--no-proxy-server',
|
|
'--password-store=basic',
|
|
'--disable-extensions',
|
|
'--no-first-run',
|
|
'--no-default-browser-check',
|
|
'--force-device-scale-factor=1',
|
|
'--window-size=1440,960',
|
|
`--user-data-dir=${userDataDir}`,
|
|
'--remote-debugging-port=0',
|
|
'about:blank',
|
|
],
|
|
{ stdio: ['ignore', 'ignore', 'pipe'] },
|
|
)
|
|
|
|
let cdpPort: string | null = null
|
|
for (let i = 0; i < 100; i++) {
|
|
await sleep(150)
|
|
try {
|
|
const raw = readFileSync(join(userDataDir, 'DevToolsActivePort'), 'utf8')
|
|
const firstLine = raw.trim().split('\n')[0]
|
|
if (firstLine) {
|
|
cdpPort = firstLine
|
|
break
|
|
}
|
|
} catch {
|
|
// wait
|
|
}
|
|
}
|
|
if (!cdpPort) throw new Error('Chrome failed to start DevToolsActivePort')
|
|
|
|
let pageTarget: { readonly url: string; readonly webSocketDebuggerUrl: string } | null = null
|
|
for (let i = 0; i < 120; i++) {
|
|
await sleep(150)
|
|
try {
|
|
const res = await fetch(`http://127.0.0.1:${cdpPort}/json`)
|
|
const list = (await res.json()) as any[]
|
|
const pages = list.filter((p) => p.type === 'page')
|
|
if (pages.length > 0 && pages[0]) {
|
|
pageTarget = pages[0]
|
|
break
|
|
}
|
|
} catch {
|
|
// wait
|
|
}
|
|
}
|
|
if (!pageTarget) throw new Error('Failed to attach to page target in headless Chrome')
|
|
|
|
playCdp = await CdpSession.connect(pageTarget.webSocketDebuggerUrl)
|
|
await playCdp.send('Page.navigate', { url: playUrl })
|
|
|
|
// 1. Wait for play.html boot and verify #d2-toolbar-killstats
|
|
for (let i = 0; i < 120; i++) {
|
|
await sleep(200)
|
|
const ready = await playCdp.evaluate<boolean>('Boolean(window.__d2PlayApp && window.__d2PlayApp.ready)')
|
|
if (ready) break
|
|
}
|
|
const playReady = await playCdp.evaluate<boolean>('Boolean(window.__d2PlayApp && window.__d2PlayApp.ready)')
|
|
assert(playReady, 'play.html __d2PlayApp failed to reach ready=true')
|
|
|
|
const toolbarLink = await playCdp.evaluate<{
|
|
exists: boolean
|
|
href: string
|
|
target: string
|
|
text: string
|
|
}>(`(() => {
|
|
const el = document.getElementById('d2-toolbar-killstats');
|
|
return {
|
|
exists: Boolean(el),
|
|
href: el?.getAttribute('href') ?? '',
|
|
target: el?.getAttribute('target') ?? '',
|
|
text: el?.textContent?.trim() ?? '',
|
|
};
|
|
})()`)
|
|
assert(toolbarLink.exists, '#d2-toolbar-killstats must exist in play.html toolbar')
|
|
assert(toolbarLink.href.endsWith('stats.html'), `Expected href ending with stats.html, got ${toolbarLink.href}`)
|
|
assert(toolbarLink.target === '_blank', `Expected target="_blank", got ${toolbarLink.target}`)
|
|
console.log(`[verify-kill-stats] Verified play.html toolbar link: href="${toolbarLink.href}" target="${toolbarLink.target}" text="${toolbarLink.text}"`)
|
|
|
|
// 2. Drive OnlineSession + KillTracker in play.html with simulated ServerEvents
|
|
await playCdp.evaluate(`(async () => {
|
|
const app = window.__d2PlayApp;
|
|
const session = app.session;
|
|
session.loginHostValue = 'www.laiseek.xyz';
|
|
session.usernameValue = 'myacct';
|
|
session.selectedRealmValue = { title: 'D2CS', description: 'Realm' };
|
|
session.selectedCharValue = 'HeroSorc';
|
|
session.charactersValue = [
|
|
{
|
|
name: 'HeroSorc',
|
|
charClass: 1,
|
|
level: 85,
|
|
hardcore: true,
|
|
expansion: true,
|
|
ladder: true,
|
|
rawFlags: 0,
|
|
act: 5,
|
|
},
|
|
];
|
|
|
|
let emitServerEvent = null;
|
|
const fakeAdapter = {
|
|
isConnected: true,
|
|
stats: { packetsIn: 0, packetsOut: 0, bytesIn: 0, bytesOut: 0, rttMs: 12, unknownPackets: 0 },
|
|
connect: async () => {},
|
|
disconnect: () => {},
|
|
send: () => {},
|
|
onEvent: (cb) => {
|
|
emitServerEvent = cb;
|
|
return () => { emitServerEvent = null; };
|
|
},
|
|
};
|
|
session.attachGameServer(fakeAdapter);
|
|
window.__d2EmitTestServerEvent = (ev) => emitServerEvent && emitServerEvent(ev);
|
|
|
|
const emit = (ev) => emitServerEvent(ev);
|
|
|
|
// GameFlags: Hell difficulty (2)
|
|
emit({ type: 'GameFlags', difficulty: 2, unknown1: 0, hardcore: true, expansion: true, ladder: true });
|
|
// Local player self assignment (id = 1)
|
|
emit({
|
|
type: 'UnitAssign',
|
|
isSelf: true,
|
|
unit: {
|
|
unitType: 'player',
|
|
id: 1,
|
|
classId: 1,
|
|
name: 'HeroSorc',
|
|
x: 5000,
|
|
y: 5000,
|
|
mode: 1,
|
|
lifePct: 128,
|
|
},
|
|
});
|
|
|
|
// Solo Kill #1: Andariel (classId 156, boss) with both MONMODE_DEATH (0) and MONMODE_DEAD (12) -> must count ONCE
|
|
emit({
|
|
type: 'UnitAssign',
|
|
unit: {
|
|
unitType: 'monster',
|
|
id: 101,
|
|
classId: 156,
|
|
x: 5010,
|
|
y: 5010,
|
|
mode: 1,
|
|
lifePct: 128,
|
|
isBoss: true,
|
|
isSuperUnique: false,
|
|
superUniqueId: 0,
|
|
},
|
|
});
|
|
emit({ type: 'UnitMode', unitType: 'monster', id: 101, mode: 0, x: 5010, y: 5010, lifePct: 0 });
|
|
emit({ type: 'UnitMode', unitType: 'monster', id: 101, mode: 12, x: 5010, y: 5010, lifePct: 0 });
|
|
|
|
// Solo Kill #2: Pindleskin (classId 440, superUniqueId 52)
|
|
emit({
|
|
type: 'UnitAssign',
|
|
unit: {
|
|
unitType: 'monster',
|
|
id: 102,
|
|
classId: 440,
|
|
x: 5020,
|
|
y: 5020,
|
|
mode: 1,
|
|
lifePct: 128,
|
|
isBoss: true,
|
|
isSuperUnique: true,
|
|
superUniqueId: 52,
|
|
},
|
|
});
|
|
emit({ type: 'UnitMode', unitType: 'monster', id: 102, mode: 12, x: 5020, y: 5020, lifePct: 0 });
|
|
|
|
// Solo Kill #3: Fallen (classId 19 = fallen1)
|
|
emit({
|
|
type: 'UnitAssign',
|
|
unit: {
|
|
unitType: 'monster',
|
|
id: 103,
|
|
classId: 19,
|
|
x: 5030,
|
|
y: 5030,
|
|
mode: 1,
|
|
lifePct: 128,
|
|
isBoss: false,
|
|
isSuperUnique: false,
|
|
superUniqueId: 0,
|
|
},
|
|
});
|
|
emit({ type: 'UnitMode', unitType: 'monster', id: 103, mode: 12, x: 5030, y: 5030, lifePct: 0 });
|
|
|
|
// Ignored #1: Mercenary (0x81 Merc assign)
|
|
emit({ type: 'Merc', kind: 'assign', ownerId: 1, mercId: 201, classId: 271, nameId: 1 });
|
|
emit({
|
|
type: 'UnitAssign',
|
|
unit: {
|
|
unitType: 'monster',
|
|
id: 201,
|
|
classId: 271,
|
|
x: 5005,
|
|
y: 5005,
|
|
mode: 1,
|
|
lifePct: 128,
|
|
},
|
|
});
|
|
emit({ type: 'UnitMode', unitType: 'monster', id: 201, mode: 12, x: 5005, y: 5005, lifePct: 0 });
|
|
|
|
// Ignored #2: Player summon (ownerId = 1)
|
|
emit({
|
|
type: 'UnitAssign',
|
|
unit: {
|
|
unitType: 'monster',
|
|
id: 202,
|
|
classId: 19,
|
|
x: 5006,
|
|
y: 5006,
|
|
mode: 1,
|
|
lifePct: 128,
|
|
ownerId: 1,
|
|
},
|
|
});
|
|
emit({ type: 'UnitMode', unitType: 'monster', id: 202, mode: 12, x: 5006, y: 5006, lifePct: 0 });
|
|
|
|
// Ignored #3: Friendly Align !== 0 unit (classId 363 = necroskeleton)
|
|
emit({
|
|
type: 'UnitAssign',
|
|
unit: {
|
|
unitType: 'monster',
|
|
id: 203,
|
|
classId: 363,
|
|
x: 5007,
|
|
y: 5007,
|
|
mode: 1,
|
|
lifePct: 128,
|
|
},
|
|
});
|
|
emit({ type: 'UnitMode', unitType: 'monster', id: 203, mode: 12, x: 5007, y: 5007, lifePct: 0 });
|
|
|
|
// Party Kill #4: Add another player to roster, then kill Fallen (id 104, classId 19)
|
|
emit({
|
|
type: 'Party',
|
|
kind: 'roster',
|
|
unitId: 2,
|
|
charName: 'PartyPaladin',
|
|
charClass: 3,
|
|
level: 80,
|
|
partyId: 1,
|
|
});
|
|
emit({
|
|
type: 'UnitAssign',
|
|
unit: {
|
|
unitType: 'monster',
|
|
id: 104,
|
|
classId: 19,
|
|
x: 5040,
|
|
y: 5040,
|
|
mode: 1,
|
|
lifePct: 128,
|
|
},
|
|
});
|
|
emit({ type: 'UnitMode', unitType: 'monster', id: 104, mode: 12, x: 5040, y: 5040, lifePct: 0 });
|
|
|
|
await app.killStats.flushNow();
|
|
})()`)
|
|
|
|
// 3. Open stats.html in a second tab in the same browser context
|
|
const statsTarget = await openNewPageTarget(cdpPort, statsUrl)
|
|
statsCdp = await CdpSession.connect(statsTarget.webSocketDebuggerUrl)
|
|
|
|
for (let i = 0; i < 100; i++) {
|
|
await sleep(150)
|
|
const ready = await statsCdp.evaluate<boolean>('Boolean(window.__d2StatsApp && window.__d2StatsApp.ready)')
|
|
if (ready) break
|
|
}
|
|
const statsReady = await statsCdp.evaluate<boolean>('Boolean(window.__d2StatsApp && window.__d2StatsApp.ready)')
|
|
assert(statsReady, 'stats.html __d2StatsApp failed to reach ready=true')
|
|
|
|
// 4. Verify isolated scroll layout, sidebar tree, summary cards, Boss/SU board, and Monster Type table
|
|
const initialSnap = await statsCdp.evaluate<{
|
|
outerOverflow: string
|
|
sidebarOverflowY: string
|
|
mainOverflowY: string
|
|
sidebarText: string
|
|
hasHeroSorc: boolean
|
|
valNormal: string
|
|
valNightmare: string
|
|
valHell: string
|
|
valTotal: string
|
|
andarielTotal: string
|
|
pindleskinTotal: string
|
|
fallenTotal: string
|
|
}>(`(() => {
|
|
const outer = document.querySelector('.d2-stats-shell') || document.getElementById('d2-stats-app');
|
|
const sidebar = document.querySelector('.d2-stats__sidebar');
|
|
const main = document.querySelector('.d2-stats__main');
|
|
const andarielRow = document.querySelector('tr[data-canonical-id="andariel"]');
|
|
const pindleskinRow = document.querySelector('tr[data-canonical-id="Pindleskin"]');
|
|
const fallenRow = document.querySelector('tr[data-monster-hcidx="19"]');
|
|
return {
|
|
outerOverflow: outer ? getComputedStyle(outer).overflow : '',
|
|
sidebarOverflowY: sidebar ? getComputedStyle(sidebar).overflowY : '',
|
|
mainOverflowY: main ? getComputedStyle(main).overflowY : '',
|
|
sidebarText: sidebar?.textContent ?? '',
|
|
hasHeroSorc: Boolean(document.querySelector('.d2-stats__char-btn[data-char-name="HeroSorc"]')),
|
|
valNormal: document.getElementById('d2-stats-val-normal')?.textContent?.trim() ?? '',
|
|
valNightmare: document.getElementById('d2-stats-val-nightmare')?.textContent?.trim() ?? '',
|
|
valHell: document.getElementById('d2-stats-val-hell')?.textContent?.trim() ?? '',
|
|
valTotal: document.getElementById('d2-stats-val-total')?.textContent?.trim() ?? '',
|
|
andarielTotal: andarielRow?.querySelector('td[data-col="total"]')?.textContent?.trim() ?? '',
|
|
pindleskinTotal: pindleskinRow?.querySelector('td[data-col="total"]')?.textContent?.trim() ?? '',
|
|
fallenTotal: fallenRow?.querySelector('td[data-col="total"]')?.textContent?.trim() ?? '',
|
|
};
|
|
})()`)
|
|
|
|
assert(initialSnap.outerOverflow === 'hidden', `Expected .d2-stats overflow: hidden, got ${initialSnap.outerOverflow}`)
|
|
assert(initialSnap.sidebarOverflowY === 'auto', `Expected .d2-stats__sidebar overflow-y: auto, got ${initialSnap.sidebarOverflowY}`)
|
|
assert(initialSnap.mainOverflowY === 'auto', `Expected .d2-stats__main overflow-y: auto, got ${initialSnap.mainOverflowY}`)
|
|
assert(initialSnap.sidebarText.includes('myacct'), `Sidebar must include account name myacct: ${initialSnap.sidebarText}`)
|
|
assert(initialSnap.hasHeroSorc, 'Sidebar must include character button for HeroSorc')
|
|
assert(initialSnap.valNormal === '0', `Expected Normal=0, got ${initialSnap.valNormal}`)
|
|
assert(initialSnap.valNightmare === '0', `Expected Nightmare=0, got ${initialSnap.valNightmare}`)
|
|
assert(initialSnap.valHell === '4', `Expected Hell=4, got ${initialSnap.valHell}`)
|
|
assert(initialSnap.valTotal === '4', `Expected Total=4, got ${initialSnap.valTotal}`)
|
|
assert(initialSnap.andarielTotal === '1', `Expected Andariel total=1, got ${initialSnap.andarielTotal}`)
|
|
assert(initialSnap.pindleskinTotal === '1', `Expected Pindleskin total=1, got ${initialSnap.pindleskinTotal}`)
|
|
assert(initialSnap.fallenTotal === '2', `Expected Fallen (hcIdx=19) total=2, got ${initialSnap.fallenTotal}`)
|
|
|
|
const shot1 = join(outDir, '01-stats-overview-zh.png')
|
|
const shot1Bytes = await statsCdp.captureScreenshot(shot1)
|
|
console.log(`[verify-kill-stats] Captured ${shot1} (${shot1Bytes} bytes) — Total=4 (Andariel=1, Pindleskin=1, Fallen=2)`)
|
|
|
|
// 5. Toggle Solo-Only filter -> expect Total = 3 (Fallen = 1)
|
|
await statsCdp.evaluate(`(() => {
|
|
const cb = document.getElementById('d2-stats-solo-only');
|
|
cb.click();
|
|
})()`)
|
|
const soloSnap = await statsCdp.evaluate<{ valTotal: string; fallenTotal: string }>(`(() => {
|
|
const fallenRow = document.querySelector('tr[data-monster-hcidx="19"]');
|
|
return {
|
|
valTotal: document.getElementById('d2-stats-val-total')?.textContent?.trim() ?? '',
|
|
fallenTotal: fallenRow?.querySelector('td[data-col="total"]')?.textContent?.trim() ?? '',
|
|
};
|
|
})()`)
|
|
assert(soloSnap.valTotal === '3', `Expected Solo-Only Total=3, got ${soloSnap.valTotal}`)
|
|
assert(soloSnap.fallenTotal === '1', `Expected Solo-Only Fallen=1, got ${soloSnap.fallenTotal}`)
|
|
|
|
const shot2 = join(outDir, '02-stats-solo-only.png')
|
|
const shot2Bytes = await statsCdp.captureScreenshot(shot2)
|
|
console.log(`[verify-kill-stats] Captured ${shot2} (${shot2Bytes} bytes) — Solo-Only Total=3`)
|
|
|
|
// Uncheck Solo-Only back to All Kills
|
|
await statsCdp.evaluate(`(() => {
|
|
const cb = document.getElementById('d2-stats-solo-only');
|
|
cb.click();
|
|
})()`)
|
|
|
|
// 6. Toggle Language (zh -> en -> zh)
|
|
await statsCdp.evaluate(`(() => {
|
|
const btn = document.getElementById('d2-stats-lang-toggle');
|
|
btn.click();
|
|
})()`)
|
|
const enSnap = await statsCdp.evaluate<{
|
|
titleText: string
|
|
andarielName: string
|
|
pindleskinName: string
|
|
}>(`(() => {
|
|
const andarielRow = document.querySelector('tr[data-canonical-id="andariel"]');
|
|
const pindleskinRow = document.querySelector('tr[data-canonical-id="Pindleskin"]');
|
|
return {
|
|
titleText: document.getElementById('d2-stats-page-title')?.textContent ?? '',
|
|
andarielName: andarielRow?.textContent ?? '',
|
|
pindleskinName: pindleskinRow?.textContent ?? '',
|
|
};
|
|
})()`)
|
|
assert(enSnap.titleText.includes('Kill Statistics'), `Expected English title, got ${enSnap.titleText}`)
|
|
assert(enSnap.andarielName.includes('Andariel'), `Expected English Andariel row, got ${enSnap.andarielName}`)
|
|
assert(enSnap.pindleskinName.includes('Pindleskin'), `Expected English Pindleskin row, got ${enSnap.pindleskinName}`)
|
|
|
|
const shot3 = join(outDir, '03-stats-overview-en.png')
|
|
const shot3Bytes = await statsCdp.captureScreenshot(shot3)
|
|
console.log(`[verify-kill-stats] Captured ${shot3} (${shot3Bytes} bytes) — English locale verified`)
|
|
|
|
// Toggle back to Chinese
|
|
await statsCdp.evaluate(`(() => {
|
|
const btn = document.getElementById('d2-stats-lang-toggle');
|
|
btn.click();
|
|
})()`)
|
|
|
|
// 7. Real-time cross-tab BroadcastChannel update: emit 1 more Fallen kill in playCdp + flushNow()
|
|
await playCdp.evaluate(`(async () => {
|
|
const emit = window.__d2EmitTestServerEvent;
|
|
emit({
|
|
type: 'UnitAssign',
|
|
unit: {
|
|
unitType: 'monster',
|
|
id: 105,
|
|
classId: 19,
|
|
x: 5050,
|
|
y: 5050,
|
|
mode: 1,
|
|
lifePct: 128,
|
|
},
|
|
});
|
|
emit({ type: 'UnitMode', unitType: 'monster', id: 105, mode: 12, x: 5050, y: 5050, lifePct: 0 });
|
|
await window.__d2PlayApp.killStats.flushNow();
|
|
})()`)
|
|
|
|
let liveTotal = ''
|
|
for (let i = 0; i < 30; i++) {
|
|
await sleep(100)
|
|
liveTotal = await statsCdp.evaluate<string>(
|
|
`document.getElementById('d2-stats-val-total')?.textContent?.trim() ?? ''`,
|
|
)
|
|
if (liveTotal === '5') break
|
|
}
|
|
assert(liveTotal === '5', `Expected live BroadcastChannel update to Total=5 without reload, got ${liveTotal}`)
|
|
|
|
const shot4 = join(outDir, '04-stats-live-broadcast-update.png')
|
|
const shot4Bytes = await statsCdp.captureScreenshot(shot4)
|
|
console.log(`[verify-kill-stats] Captured ${shot4} (${shot4Bytes} bytes) — Live BroadcastChannel auto-refresh Total=5`)
|
|
|
|
// 8. Export JSON & idempotent re-import via DOM.setFileInputFiles
|
|
await statsCdp.evaluate(`(() => {
|
|
const btn = document.getElementById('d2-stats-export-btn');
|
|
btn.click();
|
|
})()`)
|
|
|
|
let exportedJson = ''
|
|
for (let i = 0; i < 20; i++) {
|
|
await sleep(100)
|
|
exportedJson = await statsCdp.evaluate<string>(
|
|
`String(window.__d2LastExportedKillStatsJson ?? '')`,
|
|
)
|
|
if (exportedJson.length > 10) break
|
|
}
|
|
assert(exportedJson.includes('"format": "d2web-kill-stats"'), 'Exported JSON must contain d2web-kill-stats format')
|
|
|
|
const validExportPath = join(tmpImportDir, 'valid-kill-stats.json')
|
|
writeFileSync(validExportPath, exportedJson, 'utf8')
|
|
await statsCdp.setFileInputFiles('#d2-stats-import-input', [validExportPath])
|
|
await sleep(300)
|
|
|
|
const afterIdempotentImport = await statsCdp.evaluate<{
|
|
valTotal: string
|
|
infoVisible: boolean
|
|
infoText: string
|
|
errorVisible: boolean
|
|
}>(`(() => {
|
|
const info = document.getElementById('d2-stats-info-banner');
|
|
const err = document.getElementById('d2-stats-error-banner');
|
|
return {
|
|
valTotal: document.getElementById('d2-stats-val-total')?.textContent?.trim() ?? '',
|
|
infoVisible: Boolean(info && !info.hidden),
|
|
infoText: info?.textContent ?? '',
|
|
errorVisible: Boolean(err && !err.hidden),
|
|
};
|
|
})()`)
|
|
assert(
|
|
afterIdempotentImport.valTotal === '5',
|
|
`Idempotent re-import must keep Total=5, got ${afterIdempotentImport.valTotal}`,
|
|
)
|
|
assert(afterIdempotentImport.infoVisible, 'Info banner must be visible after valid import')
|
|
assert(!afterIdempotentImport.errorVisible, 'Error banner must be hidden after valid import')
|
|
console.log(`[verify-kill-stats] Idempotent JSON re-import verified: "${afterIdempotentImport.infoText}"`)
|
|
|
|
// 9. Tampered JSON import rejection (soloCount > count) -> [role="alert"] error banner
|
|
const tamperedObj = JSON.parse(exportedJson)
|
|
tamperedObj.tallies[0].soloCount = 999
|
|
const tamperedExportPath = join(tmpImportDir, 'tampered-kill-stats.json')
|
|
writeFileSync(tamperedExportPath, JSON.stringify(tamperedObj, null, 2), 'utf8')
|
|
|
|
await statsCdp.setFileInputFiles('#d2-stats-import-input', [tamperedExportPath])
|
|
await sleep(300)
|
|
|
|
const afterTamperedImport = await statsCdp.evaluate<{
|
|
errorVisible: boolean
|
|
errorRole: string
|
|
errorText: string
|
|
valTotal: string
|
|
}>(`(() => {
|
|
const err = document.getElementById('d2-stats-error-banner');
|
|
return {
|
|
errorVisible: Boolean(err && !err.hidden),
|
|
errorRole: err?.getAttribute('role') ?? '',
|
|
errorText: err?.textContent ?? '',
|
|
valTotal: document.getElementById('d2-stats-val-total')?.textContent?.trim() ?? '',
|
|
};
|
|
})()`)
|
|
assert(afterTamperedImport.errorVisible, 'Error banner must be visible on tampered JSON import')
|
|
assert(afterTamperedImport.errorRole === 'alert', `Expected role="alert", got ${afterTamperedImport.errorRole}`)
|
|
assert(
|
|
afterTamperedImport.errorText.includes('soloCount'),
|
|
`Expected error message to mention soloCount, got: ${afterTamperedImport.errorText}`,
|
|
)
|
|
assert(afterTamperedImport.valTotal === '5', 'Tampered import must not corrupt existing Total=5')
|
|
|
|
const shot5 = join(outDir, '05-stats-tampered-import-error.png')
|
|
const shot5Bytes = await statsCdp.captureScreenshot(shot5)
|
|
console.log(`[verify-kill-stats] Captured ${shot5} (${shot5Bytes} bytes) — Tampered JSON rejected with role="alert"`)
|
|
|
|
// 10. Select HeroSorc character and click Delete Character (auto-accepting confirm dialog)
|
|
await statsCdp.evaluate(`(() => {
|
|
const charBtn = document.querySelector('.d2-stats__char-btn[data-char-name="HeroSorc"]');
|
|
charBtn?.click();
|
|
})()`)
|
|
await sleep(150)
|
|
await statsCdp.evaluate(`(() => {
|
|
const delBtn = document.getElementById('d2-stats-delete-char-btn');
|
|
delBtn?.click();
|
|
})()`)
|
|
await sleep(400)
|
|
|
|
const afterDeleteSnap = await statsCdp.evaluate<{
|
|
valTotal: string
|
|
emptyVisible: boolean
|
|
hasHeroSorc: boolean
|
|
}>(`(() => {
|
|
const empty = document.getElementById('d2-stats-empty-state');
|
|
return {
|
|
valTotal: document.getElementById('d2-stats-val-total')?.textContent?.trim() ?? '',
|
|
emptyVisible: Boolean(empty && !empty.hidden),
|
|
hasHeroSorc: Boolean(document.querySelector('.d2-stats__char-btn[data-char-name="HeroSorc"]')),
|
|
};
|
|
})()`)
|
|
assert(afterDeleteSnap.valTotal === '0', `Expected Total=0 after deleting character, got ${afterDeleteSnap.valTotal}`)
|
|
assert(afterDeleteSnap.emptyVisible, 'Empty state must be visible after deleting character')
|
|
assert(!afterDeleteSnap.hasHeroSorc, 'HeroSorc button must be removed after deletion')
|
|
assert(statsCdp.dialogsOpened.length >= 1, 'Expected window.confirm dialog to be opened on delete')
|
|
|
|
const shot6 = join(outDir, '06-stats-deleted-empty-state.png')
|
|
const shot6Bytes = await statsCdp.captureScreenshot(shot6)
|
|
console.log(`[verify-kill-stats] Captured ${shot6} (${shot6Bytes} bytes) — Character deleted & empty state verified`)
|
|
|
|
// 11. Assert zero console errors and zero uncaught page exceptions across both tabs
|
|
assert(
|
|
playCdp.consoleErrors.length === 0,
|
|
`play.html had console errors: ${JSON.stringify(playCdp.consoleErrors)}`,
|
|
)
|
|
assert(
|
|
playCdp.pageExceptions.length === 0,
|
|
`play.html had page exceptions: ${JSON.stringify(playCdp.pageExceptions)}`,
|
|
)
|
|
assert(
|
|
statsCdp.consoleErrors.length === 0,
|
|
`stats.html had console errors: ${JSON.stringify(statsCdp.consoleErrors)}`,
|
|
)
|
|
assert(
|
|
statsCdp.pageExceptions.length === 0,
|
|
`stats.html had page exceptions: ${JSON.stringify(statsCdp.pageExceptions)}`,
|
|
)
|
|
|
|
console.log('[verify-kill-stats] ALL E2E BROWSER VERIFICATION CHECKS PASSED (0 console errors, 0 exceptions)')
|
|
} finally {
|
|
await statsCdp?.close()
|
|
await playCdp?.close()
|
|
if (chromeProc) {
|
|
chromeProc.kill('SIGKILL')
|
|
}
|
|
if (viteServer) {
|
|
await viteServer.close()
|
|
}
|
|
rmSync(userDataDir, { recursive: true, force: true })
|
|
rmSync(tmpImportDir, { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
run().catch((err) => {
|
|
console.error('[verify-kill-stats] FAILED:', err)
|
|
process.exit(1)
|
|
})
|