676 lines
24 KiB
TypeScript
676 lines
24 KiB
TypeScript
/**
|
|
* Headless Chromium + CDP End-to-End Verification for Battle.net "Create New Account" (`play.html`).
|
|
*
|
|
* Verifies the complete multi-stage workflow in a real headless Chromium browser:
|
|
* 1. Clicking `CREATE NEW ACCOUNT` on `bnet_login` -> `bnet_terms_of_use`
|
|
* 2. Scrolling Terms of Use and clicking `AGREE` -> `bnet_create_account`
|
|
* 3. Triggering and dismissing client-side validation error popup (`PopUpOkCancel2.dc6` + `btn-popup-small.png`)
|
|
* 4. 3-field `Tab` cycling (`accountName` -> `accountPassword` -> `accountVerifyPassword`) and `0x6fa52630` character filtering
|
|
* 5. Advancing through `bnet_please_read`, handling server-side `SID_CREATEACCOUNT2` rejection (`0x04 ALREADY_EXISTS` -> `5239`),
|
|
* and completing account creation into `char_select` with `d2_last_bnet_account` persisted in `localStorage`
|
|
* and zero `console.error` / `pageerror` events.
|
|
*/
|
|
|
|
import assert from 'node:assert/strict'
|
|
import { spawn } from 'node:child_process'
|
|
import { createHash } from 'node:crypto'
|
|
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { dirname, join, resolve } from 'node:path'
|
|
import { describe, it } from 'vitest'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { createServer, type ViteDevServer } from 'vite'
|
|
|
|
import { BncsCreateAccountStatus, type EndpointKind } from '../src/netproto/index.ts'
|
|
import { createFakeRealm, type FakeRealmOptions } from './netproto/fake-realm-servers.ts'
|
|
|
|
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 sleep(ms: number): Promise<void> {
|
|
return new Promise((r) => setTimeout(r, ms))
|
|
}
|
|
|
|
function encodeWsBinaryFrame(payload: Buffer): Buffer {
|
|
const len = payload.length
|
|
if (len < 126) {
|
|
const header = Buffer.allocUnsafe(2)
|
|
header[0] = 0x82
|
|
header[1] = len
|
|
return Buffer.concat([header, payload])
|
|
}
|
|
if (len < 65536) {
|
|
const header = Buffer.allocUnsafe(4)
|
|
header[0] = 0x82
|
|
header[1] = 126
|
|
header.writeUInt16BE(len, 2)
|
|
return Buffer.concat([header, payload])
|
|
}
|
|
const header = Buffer.allocUnsafe(10)
|
|
header[0] = 0x82
|
|
header[1] = 127
|
|
header.writeBigUInt64BE(BigInt(len), 2)
|
|
return Buffer.concat([header, payload])
|
|
}
|
|
|
|
function attachLocalD2NetWsBridge(
|
|
viteServer: ViteDevServer,
|
|
getOptions: () => FakeRealmOptions,
|
|
): void {
|
|
viteServer.httpServer?.on('upgrade', (req, socket, head) => {
|
|
const url = req.url ?? ''
|
|
if (!url.startsWith('/d2net/')) return
|
|
|
|
let kind: EndpointKind = 'bnet'
|
|
let targetPort = 6112
|
|
if (url.startsWith('/d2net/realm')) {
|
|
kind = 'realm'
|
|
targetPort = 6113
|
|
} else if (url.startsWith('/d2net/game')) {
|
|
kind = 'game'
|
|
targetPort = 4000
|
|
}
|
|
|
|
const key = req.headers['sec-websocket-key']
|
|
if (typeof key !== 'string') {
|
|
socket.destroy()
|
|
return
|
|
}
|
|
const accept = createHash('sha1')
|
|
.update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
|
|
.digest('base64')
|
|
|
|
const realm = createFakeRealm(getOptions())
|
|
void realm.resolver.open(kind, '127.0.0.1', targetPort).then((stream) => {
|
|
let wsBuf: Buffer = head && head.length > 0 ? Buffer.from(head) : Buffer.alloc(0)
|
|
|
|
stream.onData((chunk) => {
|
|
if (!socket.destroyed) {
|
|
socket.write(encodeWsBinaryFrame(Buffer.from(chunk)))
|
|
}
|
|
})
|
|
|
|
stream.onClose(() => {
|
|
if (!socket.destroyed) {
|
|
socket.end(Buffer.from([0x88, 0x00]))
|
|
}
|
|
})
|
|
|
|
const pumpWsToStream = (): void => {
|
|
while (wsBuf.length >= 2) {
|
|
const b0 = wsBuf[0]!
|
|
const b1 = wsBuf[1]!
|
|
const opcode = b0 & 0x0f
|
|
const masked = (b1 & 0x80) !== 0
|
|
let payloadLen = b1 & 0x7f
|
|
let offset = 2
|
|
|
|
if (payloadLen === 126) {
|
|
if (wsBuf.length < 4) return
|
|
payloadLen = wsBuf.readUInt16BE(2)
|
|
offset = 4
|
|
} else if (payloadLen === 127) {
|
|
if (wsBuf.length < 10) return
|
|
payloadLen = Number(wsBuf.readBigUInt64BE(2))
|
|
offset = 10
|
|
}
|
|
|
|
const maskBytes = masked ? 4 : 0
|
|
if (wsBuf.length < offset + maskBytes + payloadLen) return
|
|
|
|
const maskKey = masked ? wsBuf.subarray(offset, offset + 4) : null
|
|
offset += maskBytes
|
|
const payload = Buffer.from(wsBuf.subarray(offset, offset + payloadLen))
|
|
wsBuf = wsBuf.subarray(offset + payloadLen)
|
|
|
|
if (maskKey) {
|
|
for (let i = 0; i < payload.length; i++) {
|
|
payload[i] = payload[i]! ^ maskKey[i & 3]!
|
|
}
|
|
}
|
|
|
|
if (opcode === 0x08) {
|
|
void stream.close({ kind: 'remote' })
|
|
socket.end()
|
|
return
|
|
}
|
|
if (opcode === 0x01 || opcode === 0x02 || opcode === 0x00) {
|
|
stream.write(new Uint8Array(payload))
|
|
}
|
|
}
|
|
}
|
|
|
|
socket.write(
|
|
'HTTP/1.1 101 Switching Protocols\r\n' +
|
|
'Upgrade: websocket\r\n' +
|
|
'Connection: Upgrade\r\n' +
|
|
`Sec-WebSocket-Accept: ${accept}\r\n\r\n`,
|
|
)
|
|
pumpWsToStream()
|
|
|
|
socket.on('data', (chunk: Buffer) => {
|
|
wsBuf = Buffer.concat([wsBuf, chunk])
|
|
pumpWsToStream()
|
|
})
|
|
|
|
socket.on('close', () => {
|
|
void stream.close({ kind: 'local' })
|
|
})
|
|
socket.on('error', (err: Error) => {
|
|
void stream.close({ kind: 'error', error: err })
|
|
})
|
|
})
|
|
})
|
|
}
|
|
|
|
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[] = []
|
|
|
|
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')) {
|
|
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))
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
close(): void {
|
|
try {
|
|
this.ws.close()
|
|
} catch {
|
|
// Ignore close error
|
|
}
|
|
}
|
|
}
|
|
|
|
describe('Headless Chromium E2E: Battle.net Create New Account flow on play.html', () => {
|
|
it('completes CREATE NEW ACCOUNT -> Terms of Use -> Create Account (validation popup + 3 fields) -> Please Read -> char_select with 0 errors', async () => {
|
|
const chromeBin = findChromeBinary()
|
|
let fakeRealmOptions: FakeRealmOptions = {
|
|
characters: 0,
|
|
createAccountStatus: BncsCreateAccountStatus.OK,
|
|
}
|
|
|
|
let viteServer: ViteDevServer | null = null
|
|
const userDataDir = mkdtempSync(join(tmpdir(), 'd2-e2e-create-account-'))
|
|
let chromeProc: ReturnType<typeof spawn> | null = null
|
|
let cdp: CdpSession | null = null
|
|
|
|
try {
|
|
viteServer = await createServer({
|
|
root: ROOT_DIR,
|
|
plugins: [
|
|
{
|
|
name: 'd2-e2e-strip-d2net-proxy',
|
|
configResolved(config) {
|
|
if (config.server.proxy) {
|
|
delete (config.server.proxy as Record<string, unknown>)['/d2net']
|
|
}
|
|
},
|
|
},
|
|
],
|
|
server: {
|
|
host: '127.0.0.1',
|
|
port: 0,
|
|
strictPort: false,
|
|
},
|
|
logLevel: 'error',
|
|
})
|
|
attachLocalD2NetWsBridge(viteServer, () => fakeRealmOptions)
|
|
await viteServer.listen()
|
|
|
|
const addr = viteServer.httpServer?.address()
|
|
const port = typeof addr === 'object' && addr ? addr.port : 0
|
|
assert.ok(port > 0, 'Programmatic Vite server must bind an ephemeral port')
|
|
|
|
const targetUrl = `http://127.0.0.1:${port}/play.html`
|
|
|
|
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',
|
|
'--disable-component-extensions-with-background-pages',
|
|
'--no-first-run',
|
|
'--no-default-browser-check',
|
|
'--force-device-scale-factor=1',
|
|
'--window-size=1280,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(100)
|
|
try {
|
|
const raw = readFileSync(join(userDataDir, 'DevToolsActivePort'), 'utf8')
|
|
const firstLine = raw.trim().split('\n')[0]
|
|
if (firstLine) {
|
|
cdpPort = firstLine
|
|
break
|
|
}
|
|
} catch {
|
|
// Wait for DevToolsActivePort
|
|
}
|
|
}
|
|
assert.ok(cdpPort, 'Headless Chrome must write DevToolsActivePort')
|
|
|
|
let pageTarget: { readonly url: string; readonly webSocketDebuggerUrl: string } | null = null
|
|
for (let i = 0; i < 100; i++) {
|
|
await sleep(100)
|
|
try {
|
|
const res = await fetch(`http://127.0.0.1:${cdpPort}/json`)
|
|
const list = (await res.json()) as {
|
|
readonly type: string
|
|
readonly url: string
|
|
readonly webSocketDebuggerUrl: string
|
|
}[]
|
|
const pages = list.filter((p) => p.type === 'page')
|
|
if (pages.length > 0 && pages[0]) {
|
|
pageTarget = pages[0]
|
|
break
|
|
}
|
|
} catch {
|
|
// Keep polling
|
|
}
|
|
}
|
|
assert.ok(pageTarget, 'Must attach to headless Chrome page target')
|
|
|
|
cdp = await CdpSession.connect(pageTarget.webSocketDebuggerUrl)
|
|
// Point SettingsStore at our local Vite /d2net WebSocket bridge before play.html boots
|
|
await cdp.send('Page.addScriptToEvaluateOnNewDocument', {
|
|
source: `
|
|
localStorage.setItem('d2web.settings.v1', JSON.stringify({
|
|
version: 1,
|
|
serverHost: '127.0.0.1:${port}'
|
|
}));
|
|
localStorage.removeItem('d2_last_bnet_account');
|
|
`,
|
|
})
|
|
await cdp.send('Page.navigate', { url: targetUrl })
|
|
|
|
// Wait for window.__d2Play.ready === true and frontendRenderer assets loaded
|
|
let ready = false
|
|
for (let i = 0; i < 150; i++) {
|
|
const state = await cdp.evaluate<{ ready: boolean; hasManifest: boolean } | null>(
|
|
`window.__d2Play ? { ready: Boolean(window.__d2Play.ready), hasManifest: Boolean(window.__d2Play.frontendRenderer?.manifest) } : null`,
|
|
)
|
|
if (state?.ready && state?.hasManifest) {
|
|
ready = true
|
|
break
|
|
}
|
|
await sleep(150)
|
|
}
|
|
assert.equal(ready, true, 'window.__d2Play.ready and frontendRenderer.manifest must load')
|
|
|
|
// Step 1: Click BATTLE.NET on main_menu -> advance connecting ticks -> click CREATE NEW ACCOUNT on bnet_login
|
|
const step1 = await cdp.evaluate<{
|
|
stageAfterBnetClick: string
|
|
stageOnLogin: string
|
|
stageAfterCreateNewClick: string
|
|
termsScrollLineInitial: number
|
|
nonBlackPixels: number
|
|
}>(`(() => {
|
|
const app = window.__d2Play;
|
|
const fr = app.frontendRenderer;
|
|
const st = app.flowState;
|
|
|
|
// Click BATTLE.NET button (x=400, y=348)
|
|
fr.handleMouseDown(400, 348, performance.now());
|
|
fr.handleMouseUp(400, 348, performance.now());
|
|
const stageAfterBnetClick = st.stage;
|
|
|
|
// Advance connecting ticks into bnet_login
|
|
for (let i = 0; i < 20; i++) {
|
|
app.renderFrame(performance.now() + i * 40);
|
|
}
|
|
const stageOnLogin = st.stage;
|
|
|
|
// Click CREATE NEW ACCOUNT button on bnet_login (x=400, y=525)
|
|
fr.handleMouseDown(400, 525, performance.now());
|
|
fr.handleMouseUp(400, 525, performance.now());
|
|
app.renderFrame(performance.now() + 1000);
|
|
|
|
const bnetCanvas = document.getElementById('bnet-canvas');
|
|
const ctx = bnetCanvas.getContext('2d');
|
|
const img = ctx.getImageData(0, 0, bnetCanvas.width, bnetCanvas.height).data;
|
|
let nonBlackPixels = 0;
|
|
for (let i = 0; i < img.length; i += 16) {
|
|
if (img[i] > 8 || img[i + 1] > 8 || img[i + 2] > 8) nonBlackPixels++;
|
|
}
|
|
|
|
return {
|
|
stageAfterBnetClick,
|
|
stageOnLogin,
|
|
stageAfterCreateNewClick: st.stage,
|
|
termsScrollLineInitial: st.termsScrollLine,
|
|
nonBlackPixels,
|
|
};
|
|
})()`)
|
|
|
|
assert.equal(step1.stageAfterBnetClick, 'bnet_connecting')
|
|
assert.equal(step1.stageOnLogin, 'bnet_login')
|
|
assert.equal(step1.stageAfterCreateNewClick, 'bnet_terms_of_use')
|
|
assert.equal(step1.termsScrollLineInitial, 0)
|
|
assert.ok(step1.nonBlackPixels > 5000, 'bnet_terms_of_use canvas must render non-black pixels')
|
|
|
|
// Step 2: Scroll Terms of Use (via wheel and scrollbar down-arrow) and click AGREE -> bnet_create_account
|
|
const step2 = await cdp.evaluate<{
|
|
scrollAfterWheel: number
|
|
scrollAfterDownArrowClick: number
|
|
stageAfterAgree: string
|
|
activeInput: string
|
|
}>(`(() => {
|
|
const app = window.__d2Play;
|
|
const fr = app.frontendRenderer;
|
|
const st = app.flowState;
|
|
|
|
fr.handleWheel(120);
|
|
const scrollAfterWheel = st.termsScrollLine;
|
|
|
|
// Click scrollbar down arrow at (x=633, y=429)
|
|
fr.handleMouseDown(633, 429, performance.now());
|
|
fr.handleMouseUp(633, 429, performance.now());
|
|
const scrollAfterDownArrowClick = st.termsScrollLine;
|
|
|
|
// Click AGREE button at (x=589, y=495)
|
|
fr.handleMouseDown(589, 495, performance.now());
|
|
fr.handleMouseUp(589, 495, performance.now());
|
|
app.renderFrame(performance.now() + 2000);
|
|
|
|
return {
|
|
scrollAfterWheel,
|
|
scrollAfterDownArrowClick,
|
|
stageAfterAgree: st.stage,
|
|
activeInput: st.activeInput,
|
|
};
|
|
})()`)
|
|
|
|
assert.equal(step2.scrollAfterWheel, 3)
|
|
assert.equal(step2.scrollAfterDownArrowClick, 4)
|
|
assert.equal(step2.stageAfterAgree, 'bnet_create_account')
|
|
assert.equal(step2.activeInput, 'accountName')
|
|
|
|
// Step 3: Trigger client-side validation error popup (accountName < 2 chars -> 5216) and dismiss it
|
|
const step3 = await cdp.evaluate<{
|
|
stageDuringPopup: string
|
|
popupMessage: string | null
|
|
popupDismissedAfterClick: boolean
|
|
}>(`(() => {
|
|
const app = window.__d2Play;
|
|
const fr = app.frontendRenderer;
|
|
const st = app.flowState;
|
|
|
|
// Type 'a' (1 char) and illegal '#' (filtered out by 0x6fa52630)
|
|
fr.handleKeyDown(new KeyboardEvent('keydown', { key: 'a' }));
|
|
fr.handleKeyDown(new KeyboardEvent('keydown', { key: '#' }));
|
|
|
|
// Click OK at (x=691, y=554)
|
|
fr.handleMouseDown(691, 554, performance.now());
|
|
fr.handleMouseUp(691, 554, performance.now());
|
|
app.renderFrame(performance.now() + 3000);
|
|
|
|
const stageDuringPopup = st.stage;
|
|
const popupMessage = st.popupModal ? st.popupModal.message : null;
|
|
|
|
// Dismiss popup by clicking its OK button at (x=399, y=321)
|
|
fr.handleMouseDown(399, 321, performance.now());
|
|
fr.handleMouseUp(399, 321, performance.now());
|
|
app.renderFrame(performance.now() + 3100);
|
|
|
|
return {
|
|
stageDuringPopup,
|
|
popupMessage,
|
|
popupDismissedAfterClick: st.popupModal === null,
|
|
};
|
|
})()`)
|
|
|
|
assert.equal(step3.stageDuringPopup, 'bnet_create_account')
|
|
assert.equal(step3.popupMessage, 'Account name must be at least 2 characters.')
|
|
assert.equal(step3.popupDismissedAfterClick, true)
|
|
|
|
// Step 4: Test server-side rejection (ALREADY_EXISTS 0x04 -> 5239 with title 5249) returning to bnet_create_account
|
|
fakeRealmOptions = {
|
|
characters: 0,
|
|
createAccountStatus: BncsCreateAccountStatus.ALREADY_EXISTS,
|
|
createAccountSuggestion: 'Hero_2',
|
|
}
|
|
|
|
const step4 = await cdp.evaluate<{
|
|
stageOnPleaseRead: string
|
|
stageAfterRejectedCreate: string
|
|
preservedAccountName: string
|
|
popupTitle: string | null | undefined
|
|
popupMessage: string | null
|
|
disconnectBannerHidden: boolean
|
|
}>(`(async () => {
|
|
const app = window.__d2Play;
|
|
const fr = app.frontendRenderer;
|
|
const st = app.flowState;
|
|
|
|
// Clear 'a' and type 'NewHero_1', Tab -> 'Secret123', Tab -> 'Secret123'
|
|
fr.handleKeyDown(new KeyboardEvent('keydown', { key: 'Backspace' }));
|
|
for (const ch of 'NewHero_1') {
|
|
fr.handleKeyDown(new KeyboardEvent('keydown', { key: ch }));
|
|
}
|
|
fr.handleKeyDown(new KeyboardEvent('keydown', { key: 'Tab' }));
|
|
for (const ch of 'Secret123') {
|
|
fr.handleKeyDown(new KeyboardEvent('keydown', { key: ch }));
|
|
}
|
|
fr.handleKeyDown(new KeyboardEvent('keydown', { key: 'Tab' }));
|
|
for (const ch of 'Secret123') {
|
|
fr.handleKeyDown(new KeyboardEvent('keydown', { key: ch }));
|
|
}
|
|
|
|
// Click OK on bnet_create_account (x=691, y=554) -> advances to bnet_please_read
|
|
fr.handleMouseDown(691, 554, performance.now());
|
|
fr.handleMouseUp(691, 554, performance.now());
|
|
const stageOnPleaseRead = st.stage;
|
|
|
|
// Click OK on bnet_please_read (x=589, y=495)
|
|
fr.handleMouseDown(589, 495, performance.now());
|
|
fr.handleMouseUp(589, 495, performance.now());
|
|
|
|
// Wait for async SID_CREATEACCOUNT2 rejection to settle
|
|
for (let i = 0; i < 50; i++) {
|
|
if (st.stage === 'bnet_create_account' && st.popupModal) break;
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
}
|
|
app.renderFrame(performance.now() + 4000);
|
|
|
|
const banner = document.getElementById('d2-disconnect-banner');
|
|
const res = {
|
|
stageOnPleaseRead,
|
|
stageAfterRejectedCreate: st.stage,
|
|
preservedAccountName: st.accountName,
|
|
popupTitle: st.popupModal?.title,
|
|
popupMessage: st.popupModal ? st.popupModal.message : null,
|
|
disconnectBannerHidden: Boolean(!banner || banner.hidden),
|
|
};
|
|
|
|
// Dismiss the server error modal with Enter
|
|
fr.handleKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }));
|
|
return res;
|
|
})()`)
|
|
|
|
assert.equal(step4.stageOnPleaseRead, 'bnet_please_read')
|
|
assert.equal(step4.stageAfterRejectedCreate, 'bnet_create_account')
|
|
assert.equal(step4.preservedAccountName, 'NewHero_1')
|
|
assert.equal(step4.popupTitle, 'Unable to Create Account')
|
|
assert.equal(
|
|
step4.popupMessage?.includes('already exists'),
|
|
true,
|
|
`Expected 5239 message to include "already exists", got: ${String(step4.popupMessage)}`,
|
|
)
|
|
assert.equal(step4.disconnectBannerHidden, true)
|
|
|
|
// Step 5: Allow account creation to succeed -> advance through bnet_please_read -> land on char_select
|
|
fakeRealmOptions = {
|
|
characters: 0,
|
|
createAccountStatus: BncsCreateAccountStatus.OK,
|
|
}
|
|
|
|
const step5 = await cdp.evaluate<{
|
|
stageOnPleaseRead: string
|
|
finalStage: string
|
|
rosterCount: number
|
|
savedAccountInLocalStorage: string | null
|
|
}>(`(async () => {
|
|
const app = window.__d2Play;
|
|
const fr = app.frontendRenderer;
|
|
const st = app.flowState;
|
|
|
|
// Re-enter password & verifyPassword (accountName 'NewHero_1' was preserved)
|
|
fr.handleKeyDown(new KeyboardEvent('keydown', { key: 'Tab' }));
|
|
for (const ch of 'Secret123') {
|
|
fr.handleKeyDown(new KeyboardEvent('keydown', { key: ch }));
|
|
}
|
|
fr.handleKeyDown(new KeyboardEvent('keydown', { key: 'Tab' }));
|
|
for (const ch of 'Secret123') {
|
|
fr.handleKeyDown(new KeyboardEvent('keydown', { key: ch }));
|
|
}
|
|
|
|
// Click OK on bnet_create_account (x=691, y=554) -> bnet_please_read
|
|
fr.handleMouseDown(691, 554, performance.now());
|
|
fr.handleMouseUp(691, 554, performance.now());
|
|
const stageOnPleaseRead = st.stage;
|
|
|
|
// Click OK on bnet_please_read (x=589, y=495) -> triggers createAccountAndLogin
|
|
fr.handleMouseDown(589, 495, performance.now());
|
|
fr.handleMouseUp(589, 495, performance.now());
|
|
|
|
for (let i = 0; i < 80; i++) {
|
|
if (st.stage === 'char_select') break;
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
}
|
|
app.renderFrame(performance.now() + 5000);
|
|
|
|
return {
|
|
stageOnPleaseRead,
|
|
finalStage: st.stage,
|
|
rosterCount: st.roster.length,
|
|
savedAccountInLocalStorage: localStorage.getItem('d2_last_bnet_account'),
|
|
};
|
|
})()`)
|
|
|
|
assert.equal(step5.stageOnPleaseRead, 'bnet_please_read')
|
|
assert.equal(step5.finalStage, 'char_select')
|
|
assert.equal(step5.rosterCount, 0)
|
|
assert.equal(step5.savedAccountInLocalStorage, 'NewHero_1')
|
|
assert.deepEqual(cdp.consoleErrors, [], 'Zero console.error events allowed')
|
|
assert.deepEqual(cdp.pageExceptions, [], 'Zero uncaught pageerror events allowed')
|
|
} finally {
|
|
cdp?.close()
|
|
if (chromeProc && !chromeProc.killed) {
|
|
chromeProc.kill('SIGKILL')
|
|
}
|
|
if (viteServer) {
|
|
await viteServer.close()
|
|
}
|
|
try {
|
|
rmSync(userDataDir, { recursive: true, force: true })
|
|
} catch {
|
|
// Ignore cleanup errors
|
|
}
|
|
}
|
|
}, 60_000)
|
|
})
|