Files
dsh-remote/deploy/e2e-ui.mjs
T
lyuksovannyy 8872e73e76 dsh-remote 0.1.0: master/slave remote control for DSH
- authenticated WebSocket link (dependency-free RFC6455 + stream mux), pairing
  codes, per-device keys (hashed), revoke/remove
- /r/<device>/ proxy of the full DSH UI (HTTP, Remote WebSocket, SSE) with
  per-device cookie minting; slave->slave relay through the master
- Devices sidebar panel (all devices' sessions, new session on device),
  Remote devices settings page, finished-session toasts
- plugin + config sync master->slave: registry bundles via pluginManager,
  link: plugins as tarballs, cordis.patch.yml into a managed bundle layer
  (!!js preserved), allow/deny, opt-in per-secret credential sync
- deploy helpers + e2e probes, unit tests
2026-10-11 08:23:19 +00:00

74 lines
4.8 KiB
JavaScript

#!/usr/bin/env node
// E2E UI check with headless Chromium over the DevTools protocol (no playwright dep).
// node deploy/e2e-ui.mjs <chrome-binary> <out-dir> [port]
// Loads the local DSH (cookie minted from the browser-session secret), opens the Devices
// panel, screenshots it, then opens /r/<first remote device>/ and screenshots that.
import { spawn } from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { cookieName, mintCookie } from '../lib/auth-cookie.mjs'
const [chrome, outDir, port = '3081'] = process.argv.slice(2)
const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
const secret = fs.readFileSync(path.join(home, '.credentials.yaml'), 'utf8').match(/browser-session:[\s\S]*?secret:\s*([A-Za-z0-9_-]{43})/)[1]
const authority = `127.0.0.1:${port}`
const cookieVal = mintCookie(authority, secret, 1).split('=').slice(1).join('=')
fs.mkdirSync(outDir, { recursive: true })
const prof = fs.mkdtempSync(path.join(os.tmpdir(), 'dshr-chrome-'))
const proc = spawn(chrome, ['--headless=new', '--no-sandbox', '--disable-gpu', `--user-data-dir=${prof}`, '--remote-debugging-port=0', '--window-size=1500,950', 'about:blank'], { stdio: ['ignore', 'ignore', 'pipe'] })
const wsUrl = await new Promise((resolve, reject) => {
let buf = ''
proc.stderr.on('data', (d) => { buf += d; const m = buf.match(/DevTools listening on (ws:\/\/\S+)/); if (m) resolve(m[1]) })
setTimeout(() => reject(new Error('chrome did not start')), 15000)
})
const list = await (await fetch(wsUrl.replace(/^ws/, 'http').replace(/\/devtools\/browser\/.*/, '/json/list'))).json()
const page = list.find((t) => t.type === 'page')
const ws = new WebSocket(page.webSocketDebuggerUrl)
await new Promise((r) => ws.addEventListener('open', r))
let id = 0
const pending = new Map()
const logs = []
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) }
if (m.method === 'Runtime.consoleAPICalled') logs.push(`${m.params.type}: ${m.params.args.map((a) => a.value ?? a.description ?? '').join(' ')}`.slice(0, 300))
if (m.method === 'Runtime.exceptionThrown') logs.push(`EXC: ${m.params.exceptionDetails?.exception?.description || m.params.exceptionDetails?.text}`.slice(0, Number(process.env.EXC_LEN || 400)))
})
const send = (method, params = {}) => new Promise((resolve) => { const i = ++id; pending.set(i, resolve); ws.send(JSON.stringify({ id: i, method, params })) })
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const evalJs = async (expr) => (await send('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true })).result?.result?.value
const shot = async (name) => {
const r = await send('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(path.join(outDir, name), Buffer.from(r.result.data, 'base64'))
console.log('screenshot', path.join(outDir, name))
}
await send('Page.enable'); await send('Runtime.enable')
await send('Network.enable')
await send('Network.setCookie', { name: cookieName(authority), value: cookieVal, domain: '127.0.0.1', path: '/', httpOnly: true })
await send('Page.navigate', { url: `http://${authority}/` })
await sleep(9000)
// Click the Devices panel entry (its icon carries data-dsh-panel-entry).
const clicked = await evalJs(`(() => { const svg = document.querySelector('[data-dsh-panel-entry="dsh-remote-devices"]'); if (!svg) return 'no-entry'; (svg.closest('button,a,[role=button]') || svg).click(); return 'clicked' })()`)
console.log('devices entry:', clicked)
await sleep(3500)
console.log('panel text:', String(await evalJs(`(document.querySelector('[data-dsh-plugin="dsh-remote"]')?.innerText || 'NO PANEL').slice(0, 600)`)).replace(/\n+/g, ' | '))
await shot('devices-panel.png')
const localErrors = logs.filter((l) => /EXC/.test(l)).length
console.log('local page exceptions:', localErrors, logs.filter((l) => /EXC/.test(l)).map((l) => l.slice(0, 90)).join(' || '))
logs.length = 0
const remote = process.env.REMOTE_DEVICE || 'ai-pc'
const remoteSession = process.env.REMOTE_SESSION || ''
await send('Page.navigate', { url: `http://${authority}/r/${remote}/${remoteSession ? `#/session/${remoteSession}` : ''}` })
await sleep(remoteSession ? 14000 : 10000)
console.log('remote title/banner:', await evalJs(`document.title + ' :: ' + (document.querySelector('a[title*="another device"]')?.innerText || 'no banner') + ' :: hash=' + location.hash`))
if (remoteSession) {
console.log('remote session view:', String(await evalJs(`document.body.innerText.slice(0, 500)`)).replace(/\n+/g, ' | '))
}
await shot('remote-ui.png')
console.log('console:', logs.filter((l) => /error|EXC|warn/i.test(l)).slice(0, 12).join('\n '))
ws.close(); proc.kill('SIGKILL'); fs.rmSync(prof, { recursive: true, force: true })
process.exit(0)