OverviewFilesCommitsIssuesDocsReleasesPeers

cthuloid / webui / test / browser-shared-control.mjs

113 lines · javascript ref:
// Manual browser test (Node + Chromium over CDP), not part of `npm test`:
// needs a fake proto-3 unit on :1339 and this server on :4052 with it included
// and connected. Usage: node test/browser-shared-control.mjs <dir-with-fake3-lines.txt>
// Two real Chromium browsers (separate profiles => separate session ids) drive
// the page with real mouse events, against a local server + fake proto-3 unit.
import { spawn } from 'node:child_process'
import { writeFileSync, mkdirSync, readFileSync } from 'node:fs'

const OUT = process.argv[2]
const URL = 'http://127.0.0.1:4052/'
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const results = []
const check = (name, ok, detail = '') => { results.push(ok); console.log((ok ? 'PASS ' : 'FAIL ') + name + (ok ? '' : '  ' + detail)) }

async function browser (port, label) {
  const prof = process.env.HOME + '/.cache/cthuloid-shots/prof-' + label + '-' + Date.now()
  mkdirSync(prof, { recursive: true })
  const proc = spawn('chromium', ['--headless=new', '--disable-gpu', '--remote-debugging-port=' + port,
    '--user-data-dir=' + prof, '--window-size=1100,1000', 'about:blank'], { stdio: 'ignore' })
  let target
  for (let i = 0; i < 50 && !target; i++) {
    await sleep(200)
    try { target = (await (await fetch('http://127.0.0.1:' + port + '/json')).json()).find((t) => t.type === 'page') } catch {}
  }
  const ws = new WebSocket(target.webSocketDebuggerUrl)
  await new Promise((r) => ws.addEventListener('open', r))
  let id = 0
  const pending = new Map()
  ws.addEventListener('message', (ev) => {
    const m = JSON.parse(ev.data)
    if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id) }
  })
  const send = (method, params = {}) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, method, params })) })
  const b = {
    label,
    send,
    eval: async (expr) => (await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true })).result.result.value,
    mouse: (type, x, y) => send('Input.dispatchMouseEvent', { type, x, y, button: 'left', buttons: type === 'mouseReleased' ? 0 : 1, clickCount: 1, pointerType: 'mouse' }),
    shot: async (name) => { const r = await send('Page.captureScreenshot', { format: 'png' }); writeFileSync(OUT + '/' + name, Buffer.from(r.result.data, 'base64')) },
    box: async (sel) => b.eval(`(() => { const e = [...document.querySelectorAll(${JSON.stringify(sel)})].find(x => x.offsetParent); if (!e) return null; const r = e.getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height } })()`),
    close: () => { ws.close(); proc.kill() }
  }
  await send('Page.enable')
  await send('Runtime.enable')
  await send('Page.navigate', { url: URL })
  await sleep(2000)
  return b
}

const card = '.unit:not(.off)'
const slot1 = `[...document.querySelectorAll('${card} .slot')][0]`

const alice = await browser(9341, 'alice')
const bob = await browser(9342, 'bob')
for (const [b, name] of [[alice, 'alice'], [bob, 'bob']]) {
  await b.eval(`(() => { const i = document.querySelector('#my-name'); i.value = '${name}'; i.dispatchEvent(new Event('change')); return 1 })()`)
}

// Alice opens slot 1's picker and starts dragging the wheel -- and keeps holding.
const sw = await alice.box(`${card} .swatch`)
await alice.mouse('mousePressed', sw.x + sw.w / 2, sw.y + sw.h / 2)
await alice.mouse('mouseReleased', sw.x + sw.w / 2, sw.y + sw.h / 2)
await sleep(300)
const wheel = await alice.box('.pk-wheel')
const cx = wheel.x + wheel.w / 2
const cy = wheel.y + wheel.h / 2
await alice.mouse('mousePressed', cx, cy)
for (let k = 1; k <= 8; k++) { await alice.mouse('mouseMoved', cx, cy - (wheel.h / 2 - 2) * k / 8); await sleep(40) }
await sleep(700)   // still pressed

const bobView = await bob.eval(`(() => { const r = ${slot1}; return { held: r.classList.contains('held-other'), tag: r.querySelector('.slot-holder').textContent, disabled: r.querySelector('.level').disabled } })()`)
check("bob sees slot 1 held by alice while she drags", bobView.held && bobView.tag === 'held by alice' && bobView.disabled, JSON.stringify(bobView))
const aliceView = await alice.eval(`(() => { const r = ${slot1}; return { held: r.classList.contains('held-other'), tag: r.querySelector('.slot-holder').textContent } })()`)
check('alice sees it as hers', !aliceView.held && aliceView.tag === 'you have this slot', JSON.stringify(aliceView))
await bob.shot('p2b-bob-greyed.png')

// Bob tries to drag the slot's brightness anyway: the control is disabled.
const lv = await bob.box(`${card} .level`)
await bob.mouse('mousePressed', lv.x + 5, lv.y + lv.h / 2)
await bob.mouse('mouseMoved', lv.x + lv.w - 5, lv.y + lv.h / 2)
await bob.mouse('mouseReleased', lv.x + lv.w - 5, lv.y + lv.h / 2)
await sleep(400)

// Alice lets go.
await alice.mouse('mouseReleased', cx, cy - wheel.h / 2 + 2)
await sleep(1000)
const bobAfter = await bob.eval(`(() => { const r = ${slot1}; return { held: r.classList.contains('held-other'), disabled: r.querySelector('.level').disabled } })()`)
check('after alice lets go, bob can use slot 1', !bobAfter.held && !bobAfter.disabled, JSON.stringify(bobAfter))

// Now bob drags slot 1's brightness: alice sees it held by bob.
await bob.mouse('mousePressed', lv.x + lv.w - 5, lv.y + lv.h / 2)
for (let k = 1; k <= 6; k++) { await bob.mouse('mouseMoved', lv.x + lv.w - 5 - (lv.w - 10) * k / 12, lv.y + lv.h / 2); await sleep(40) }
await sleep(600)
const aliceSees = await alice.eval(`(() => { const r = ${slot1}; return { held: r.classList.contains('held-other'), tag: r.querySelector('.slot-holder').textContent } })()`)
check('alice sees slot 1 held by bob while he drags', aliceSees.held && aliceSees.tag === 'held by bob', JSON.stringify(aliceSees))
await bob.mouse('mouseReleased', lv.x + lv.w / 2, lv.y + lv.h / 2)
await sleep(1200)

const lines = readFileSync(OUT + '/fake3-lines.txt', 'utf8').trim().split('\n').map((l) => l.split(' ').slice(1).join(' '))
const order = lines.filter((l) => /^(grant|release)/.test(l))
check('unit saw: alice grant, release, bob grant, release', JSON.stringify(order) === JSON.stringify(['grant 0 alice', 'release 0', 'grant 0 bob', 'release 0']), JSON.stringify(order))
const errs = lines.filter((l) => l.startsWith('ERR'))
check('no refused frames', errs.length === 0, JSON.stringify(errs))
const frames = lines.filter((l) => l.startsWith('slot 0'))
check('drag frames reached the unit', frames.length >= 2, String(frames.length))
const ownerAfter = await alice.eval(`(() => { const r = ${slot1}; return r.classList.contains('held-other') || r.querySelector('.slot-holder').hidden === false })()`)
check('slot 1 free again everywhere', ownerAfter === false)

alice.close()
bob.close()
console.log(results.every(Boolean) ? 'ALL PASS' : 'FAILURES')
process.exit(results.every(Boolean) ? 0 : 1)