147 lines
6.5 KiB
JavaScript
147 lines
6.5 KiB
JavaScript
// N separate Chromium instances, one page each: the co-op check the reliable way.
|
|
// Usage: node cdp_many.mjs <chromeBin> <url0> <url1> [...] [--seconds N] [--shot prefix]
|
|
import { spawn } from 'node:child_process'
|
|
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
|
|
const args = process.argv.slice(2)
|
|
const chromeBin = args[0]
|
|
const urls = args.slice(1).filter((value) => value.startsWith('http'))
|
|
const arg = (name, fallback) => {
|
|
const i = process.argv.indexOf(name)
|
|
return i === -1 ? fallback : process.argv[i + 1]
|
|
}
|
|
const seconds = Number(arg('--seconds', '14'))
|
|
const shotPrefix = arg('--shot', null)
|
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
|
|
/** Launch Chromium on a URL and attach to its only tab. */
|
|
async function connect(url) {
|
|
const userDataDir = mkdtempSync(join(tmpdir(), 'cdp-pair-'))
|
|
const chrome = spawn(chromeBin, [
|
|
'--headless=new', '--no-sandbox', '--disable-dev-shm-usage',
|
|
'--use-angle=swiftshader', '--use-gl=angle', '--enable-unsafe-swiftshader',
|
|
'--window-size=1000,700',
|
|
`--user-data-dir=${userDataDir}`, '--remote-debugging-port=0', url,
|
|
], { stdio: ['ignore', 'ignore', 'pipe'] })
|
|
let stderr = ''
|
|
chrome.stderr.on('data', (d) => { stderr += d })
|
|
let port = null
|
|
for (let i = 0; i < 120 && !port; i++) {
|
|
await sleep(200)
|
|
try { port = readFileSync(join(userDataDir, 'DevToolsActivePort'), 'utf8').trim().split('\n')[0] } catch {}
|
|
}
|
|
if (!port) throw new Error(`no devtools port; ${stderr.slice(-300)}`)
|
|
let page = null
|
|
for (let i = 0; i < 40 && page === null; i++) {
|
|
await sleep(200)
|
|
try {
|
|
const list = await (await fetch(`http://127.0.0.1:${port}/json`)).json()
|
|
page = list.find((entry) => entry.type === 'page') ?? null
|
|
} catch {}
|
|
}
|
|
if (page === null) throw new Error(`no page target; ${stderr.slice(-300)}`)
|
|
const ws = new WebSocket(page.webSocketDebuggerUrl)
|
|
await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject })
|
|
let id = 0
|
|
const pending = new Map()
|
|
ws.onmessage = (event) => {
|
|
const message = JSON.parse(event.data)
|
|
if (message.id && pending.has(message.id)) { pending.get(message.id)(message); pending.delete(message.id) }
|
|
}
|
|
const send = (method, params = {}) => new Promise((resolve) => {
|
|
const msgId = ++id
|
|
pending.set(msgId, resolve)
|
|
ws.send(JSON.stringify({ id: msgId, method, params }))
|
|
})
|
|
await send('Runtime.enable')
|
|
await send('Page.enable')
|
|
return { send, ws, chrome, stderr: () => stderr }
|
|
}
|
|
|
|
const pages = await Promise.all(urls.map((url) => connect(url)))
|
|
// Collect page-level errors: an exception thrown inside a socket listener is
|
|
// invisible from the outside, and is exactly how a "no messages arrive" bug
|
|
// actually looks.
|
|
for (const page of pages) {
|
|
await page.send('Runtime.evaluate', { expression: `(() => { window.__errs = []; window.addEventListener('error', e => window.__errs.push(String(e.message)));
|
|
window.addEventListener('unhandledrejection', e => window.__errs.push('rejection: ' + String(e.reason)));
|
|
return 'ok'; })()`, returnByValue: true })
|
|
}
|
|
await sleep(4500)
|
|
|
|
const evaluate = async (page, expression, awaitPromise = false) => {
|
|
const result = await page.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise })
|
|
if (result.result?.exceptionDetails) return { __error: JSON.stringify(result.result.exceptionDetails).slice(0, 400) }
|
|
return result.result?.result?.value ?? null
|
|
}
|
|
|
|
console.log('where:', JSON.stringify(await Promise.all(pages.map(page => evaluate(page, 'location.href')))))
|
|
|
|
/** Press and hold a key, then release it. */
|
|
async function hold(page, code, key, vk, ms) {
|
|
const base = { code, key, windowsVirtualKeyCode: vk, nativeVirtualKeyCode: vk }
|
|
await page.send('Input.dispatchKeyEvent', { type: 'keyDown', ...base })
|
|
await sleep(ms)
|
|
await page.send('Input.dispatchKeyEvent', { type: 'keyUp', ...base })
|
|
}
|
|
|
|
const tap = async (page, code, key, vk) => {
|
|
const base = { code, key, windowsVirtualKeyCode: vk, nativeVirtualKeyCode: vk }
|
|
await page.send('Input.dispatchKeyEvent', { type: 'keyDown', ...base })
|
|
await sleep(700)
|
|
await page.send('Input.dispatchKeyEvent', { type: 'keyUp', ...base })
|
|
}
|
|
|
|
// Each page walks on its own axis, so a fighter that moves only under its own
|
|
// page's keys is visible in the final positions.
|
|
const KEYS = [['KeyD', 'd', 68], ['KeyS', 's', 83], ['KeyA', 'a', 65], ['KeyW', 'w', 87]]
|
|
const drive = (async () => {
|
|
for (let round = 0; round < 5; round += 1) {
|
|
await Promise.all(pages.map((page, index) => {
|
|
const [code, key, vk] = KEYS[index % KEYS.length]
|
|
return hold(page, code, key, vk, 800)
|
|
}))
|
|
await Promise.all(pages.map((page) => tap(page, 'Space', ' ', 32)))
|
|
await sleep(300)
|
|
}
|
|
})()
|
|
await Promise.race([drive, sleep(seconds * 1000)])
|
|
|
|
const states = []
|
|
for (const page of pages) {
|
|
const value = await evaluate(page, 'JSON.stringify(window.__d2webNet ?? null)')
|
|
states.push(typeof value === 'string' ? JSON.parse(value) : value)
|
|
}
|
|
|
|
if (shotPrefix !== null) {
|
|
for (let index = 0; index < pages.length; index += 1) {
|
|
const shot = await pages[index].send('Page.captureScreenshot', { format: 'png' })
|
|
const data = shot.result?.data
|
|
if (typeof data === 'string') writeFileSync(`${shotPrefix}-${index}.png`, Buffer.from(data, 'base64'))
|
|
}
|
|
}
|
|
|
|
// Pixel statistics, read straight from the framebuffer: a page whose world is
|
|
// correct but whose canvas is empty has failed in a way the numbers cannot see.
|
|
let pixels = []
|
|
for (const page of pages) {
|
|
const value = await evaluate(page, `(() => { const c = document.querySelector('#view'); const gl = c.getContext('webgl2');
|
|
const px = new Uint8Array(c.width * c.height * 4); gl.readPixels(0, 0, c.width, c.height, gl.RGBA, gl.UNSIGNED_BYTE, px);
|
|
let nonBlack = 0; const seen = new Set();
|
|
for (let i = 0; i < px.length; i += 4) { if (px[i] + px[i+1] + px[i+2] > 24) nonBlack++;
|
|
seen.add((px[i] >> 3) + ',' + (px[i+1] >> 3) + ',' + (px[i+2] >> 3)); }
|
|
return JSON.stringify({ nonBlack, total: c.width * c.height, distinctColours: seen.size, glError: gl.getError() }); })()`)
|
|
pixels.push(typeof value === 'string' ? JSON.parse(value) : value)
|
|
}
|
|
|
|
const errors = []
|
|
for (const page of pages) {
|
|
const value = await evaluate(page, 'JSON.stringify(window.__errs ?? null)')
|
|
errors.push(typeof value === 'string' ? JSON.parse(value) : value)
|
|
}
|
|
console.log(JSON.stringify({ states, pixels, errors }, null, 2))
|
|
for (const page of pages) { page.ws.close(); page.chrome.kill() }
|
|
process.exit(0)
|