diablo2-web/tests/e2e-bnet-create-account.tes...

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 'node:test'
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
}
}
})
})