Files
deepseek-harness/examples/desktop/scripts/qa-cdp-shoot-cordis-card.mjs
T

306 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// QA verification script for lane-cordis-card. Boots an isolated Electron on
// its own CDP port, drives the cordis dedicated card + the upgraded
// code-dispatch nested tree entirely through the renderer's
// `window.__dshRenderer.onSessionEvent` seam (no live model / no key), and
// captures three screenshots into docs/qa-cordis-card/:
//
// 01-mount-card.png — cordis_mount card: op glyph + id + kv block
// (id/name/state) + dyn-1 add-delta + source fold.
// 02-inspect-fields.png — cordis_inspect card: the six sections rendered
// through the reused Fields-tree widget.
// 03-code-dispatch-open.png — a run_code block with three fan-out sub-call
// rows, the first row expanded to show its args +
// result blocks, and the { } inspector badge.
//
// Isolation follows the 2026-07-18 postmortem baked into the sibling shoots
// (scripts/qa-cdp-shoot-msg-queue.mjs / nav-optional):
// 1. --user-data-dir=<tmp> isolates Chromium userdata.
// 2. DSH_DESKTOP_HOME=<tmp> isolates the main-process config root.
// 3. own CDP port (≥9310) so a lingering Electron helper from another
// lane's shoot can't hijack our DevTools endpoint.
//
// Why the seam and not a real Vibe run: the cordis card + code-dispatch tree
// are pure renderer concerns (parse the tool's own text / fan-out rows). We
// inject the REAL wire shapes from test/fixtures/cordis-wire-shapes.json
// through onSessionEvent — the exact path the live wire uses — so the render
// is deterministic with no daemon round-trip and no model key.
import { spawn } from 'node:child_process'
import { existsSync, mkdirSync, writeFileSync, readFileSync, rmSync, statSync } from 'node:fs'
import { resolve, join } from 'node:path'
import { setTimeout as sleep } from 'node:timers/promises'
import { tmpdir } from 'node:os'
const WORKTREE = resolve(process.env.DSH_WORKTREE || process.cwd())
const PARENT = resolve(process.env.DSH_REPO || '/Users/ziya/harness/dsh-desktop-demo')
const ELECTRON = join(PARENT, 'node_modules/.bin/electron')
const CDP_PORT = Number(process.env.DSH_CORDIS_PORT || 9310)
const OUTDIR = join(WORKTREE, 'docs/qa-cordis-card')
const FIX = JSON.parse(readFileSync(join(WORKTREE, 'test/fixtures/cordis-wire-shapes.json'), 'utf8'))
if (!existsSync(ELECTRON)) {
console.error(`electron binary not found at ${ELECTRON}`)
process.exit(2)
}
mkdirSync(OUTDIR, { recursive: true })
function seedHome(dshHome) {
const seedOverlay = [
'# QA cordis-card shoot seed overlay (tmp, per-run).',
'plugins:',
` - "@cordisjs/plugin-include":`,
` path: ${join(WORKTREE, 'config/daemon-echo.yml')}`,
'',
].join('\n')
writeFileSync(join(dshHome, 'user-overlay.cordis.yml'), seedOverlay)
writeFileSync(join(dshHome, 'config.json'), JSON.stringify({ role: 'coding', approvalMode: 'never' }, null, 2))
writeFileSync(join(dshHome, '.onboarded'), new Date().toISOString())
}
async function bootElectron(dshHome, userData, port) {
const child = spawn(ELECTRON, [
`--remote-debugging-port=${port}`,
`--user-data-dir=${userData}`,
'--disable-gpu',
'--no-sandbox',
'.',
], {
cwd: WORKTREE,
env: { ...process.env, DSH_DESKTOP_HOME: dshHome, DSH_MAXIMIZE: '1' },
stdio: ['ignore', 'pipe', 'pipe'],
})
const logs = []
child.stdout.on('data', d => logs.push(String(d)))
child.stderr.on('data', d => logs.push(String(d)))
for (let i = 0; i < 40; i++) {
await sleep(500)
try {
const r = await fetch(`http://localhost:${port}/json/list`)
if (r.ok) return { child, logs }
} catch {}
}
child.kill('SIGKILL')
console.error('electron CDP did not come up in 20s. logs:\n' + logs.join(''))
process.exit(3)
}
async function newCdp(port) {
const targets = await (await fetch(`http://localhost:${port}/json/list`)).json()
const target = targets.find(t => t.type === 'page')
if (!target) throw new Error('no page target on port ' + port)
const ws = new WebSocket(target.webSocketDebuggerUrl)
await new Promise((ok, err) => { ws.onopen = ok; ws.onerror = e => err(e) })
let id = 1
const pending = new Map()
ws.onmessage = ev => {
const data = typeof ev.data === 'string' ? ev.data : String(ev.data)
let msg
try { msg = JSON.parse(data) } catch { return }
if (msg.id != null && pending.has(msg.id)) {
const [ok, err] = pending.get(msg.id); pending.delete(msg.id)
if (msg.error) err(new Error(msg.error.message)); else ok(msg.result)
}
}
const call = (m, p = {}, ms = 15000) => new Promise((ok, err) => {
const _id = id++
const t = setTimeout(() => { pending.delete(_id); err(new Error('cdp timeout: ' + m)) }, ms)
pending.set(_id, [v => { clearTimeout(t); ok(v) }, e => { clearTimeout(t); err(e) }])
ws.send(JSON.stringify({ id: _id, method: m, params: p }))
})
const evj = async expr => {
const r = await call('Runtime.evaluate', {
expression: `(async()=>{try{return (${expr})}catch(e){return {__err:String(e)}}})()`,
returnByValue: true, awaitPromise: true,
})
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text)
return r.result?.value
}
return { ws, call, evj }
}
// Clip to the chat stream so the cards are unmistakably in frame.
async function shoot(call, evj, name) {
const clip = await evj(`
(() => {
const s = document.getElementById('stream')
if (!s) return null
const r = s.getBoundingClientRect()
return { x: r.x, y: Math.max(0, r.y), width: r.width, height: Math.min(r.height, 900), scale: 1 }
})()
`)
const shotArgs = { format: 'png', captureBeyondViewport: true }
if (clip) shotArgs.clip = clip
const shot = await call('Page.captureScreenshot', shotArgs, 30000)
if (!shot || !shot.data) throw new Error('captureScreenshot returned no data')
const outPath = join(OUTDIR, name)
writeFileSync(outPath, Buffer.from(shot.data, 'base64'))
const bytes = statSync(outPath).size
console.log(` wrote ${outPath} (${bytes} bytes)`)
if (bytes < 20000) throw new Error(`screenshot ${name} is only ${bytes} bytes (<20KB) — likely blank`)
return { path: outPath, bytes }
}
// Inject a call+result pair for a cordis tool through onSessionEvent.
function injectPairExpr(sid, pair) {
return `
(() => {
const R = window.__dshRenderer
R.onSessionEvent(${JSON.stringify(sid)}, ${JSON.stringify(pair.call)})
R.onSessionEvent(${JSON.stringify(sid)}, ${JSON.stringify(pair.result)})
return true
})()
`
}
// Open the tool-block that hosts a given cordis op + scroll it into view so
// the card body (not the collapsed summary) fills the shot. For inspect, also
// expand the first section in the reused Fields tree so the list reads richly.
async function openCordisBlock(evj, op) {
return evj(`
(() => {
const card = document.querySelector('.card-cordis[data-cordis-op="${op}"]')
if (!card) return { opened: false }
const block = card.closest ? card.closest('.tool-block') : null
if (block) block.open = true
// Expand the first Fields-tree section for inspect so it isn't all folded.
const firstBranch = card.querySelector('.card-cordis-tree .trace-detail-json-branch')
if (firstBranch) firstBranch.open = true
;(block || card).scrollIntoView({ block: 'center' })
return { opened: true }
})()
`)
}
async function main() {
const dshHome = join(tmpdir(), 'dsh-cordis-home')
const userData = join(tmpdir(), 'dsh-cordis-userdata')
for (const dir of [dshHome, userData]) {
try { rmSync(dir, { recursive: true, force: true }) } catch {}
mkdirSync(dir, { recursive: true })
}
seedHome(dshHome)
console.log(`booting on CDP :${CDP_PORT}`)
const { child } = await bootElectron(dshHome, userData, CDP_PORT)
try {
await sleep(1500)
const { call, evj } = await newCdp(CDP_PORT)
await call('Page.enable')
// Fresh session for the cordis cards.
const setup = await evj(`
(async () => {
const R = window.__dshRenderer
if (!R) return { __err: 'no __dshRenderer seam' }
if (!window.__dshCordisCard) return { __err: 'no __dshCordisCard module' }
R.ensureSession('qa-cordis', { title: 'Cordis card demo', header: {}, hasUserMessage: true })
await R.selectSession('qa-cordis')
return { active: R.getActiveSessionId() }
})()
`)
console.log(' setup:', JSON.stringify(setup))
if (setup && setup.__err) throw new Error(setup.__err)
// --- Shot 1: mount card -------------------------------------------------
const mountOk = await evj(injectPairExpr('qa-cordis', FIX.mount_ok))
if (mountOk && mountOk.__err) throw new Error(mountOk.__err)
const mountCheck = await evj(`
(() => {
const c = document.querySelector('.card-cordis[data-cordis-op="cordis_mount"]')
if (!c) return { found: false }
return {
found: true,
id: (c.querySelector('.card-cordis-id') || {}).textContent || '',
kvKeys: Array.from(c.querySelectorAll('.card-cordis-kv-key')).map(n => n.textContent),
delta: !!c.querySelector('.card-cordis-delta.add'),
codeFold: !!c.querySelector('.card-cordis-code'),
}
})()
`)
console.log(' mount card:', JSON.stringify(mountCheck))
if (!mountCheck.found || mountCheck.id !== 'dyn-1' || !mountCheck.delta) {
throw new Error('mount card missing expected shape: ' + JSON.stringify(mountCheck))
}
// Open the enclosing tool-block + scroll it into view so the card body
// (not just the collapsed summary) is unmistakably in frame.
await openCordisBlock(evj, 'cordis_mount')
await sleep(250)
const shot1 = await shoot(call, evj, '01-mount-card.png')
// --- Shot 2: inspect card with fields tree ------------------------------
const inspectOk = await evj(injectPairExpr('qa-cordis', FIX.inspect_all))
if (inspectOk && inspectOk.__err) throw new Error(inspectOk.__err)
const inspectCheck = await evj(`
(() => {
const c = document.querySelector('.card-cordis[data-cordis-op="cordis_inspect"]')
if (!c) return { found: false }
const tree = c.querySelector('.card-cordis-tree .trace-detail-json-tree')
return {
found: true,
hasTree: !!tree,
sections: tree ? Array.from(tree.querySelectorAll('.trace-detail-json-key')).map(n => n.textContent) : [],
}
})()
`)
console.log(' inspect card:', JSON.stringify(inspectCheck))
if (!inspectCheck.found || !inspectCheck.hasTree) {
throw new Error('inspect card missing fields tree: ' + JSON.stringify(inspectCheck))
}
await openCordisBlock(evj, 'cordis_inspect')
await sleep(250)
const shot2 = await shoot(call, evj, '02-inspect-fields.png')
// --- Shot 3: code-dispatch nested tree, first row expanded --------------
const cd = FIX.code_dispatch_run
const dispatchInject = await evj(`
(() => {
const R = window.__dshRenderer
const sid = 'qa-cordis'
// Order matches mock-fixtures.mockCodeDispatch: the parent call+result
// land first (the result populates the .result box), THEN each
// tool/code-dispatch appends into it. Firing dispatches before the
// result would let the generic tool/result branch overwrite the box.
R.onSessionEvent(sid, ${JSON.stringify(cd.parent_call)})
R.onSessionEvent(sid, ${JSON.stringify(cd.parent_result)})
${cd.dispatches.map(d => `R.onSessionEvent(sid, ${JSON.stringify(d)})`).join('\n ')}
// Expand the first sub-call row so the args + result blocks show.
const parent = document.querySelector('.tool-block[data-call-id="run-code-1"]')
const firstRow = parent && parent.querySelector('.card-code-dispatch-row')
if (firstRow) firstRow.open = true
return {
rows: parent ? parent.querySelectorAll('.card-code-dispatch-row').length : 0,
firstOpen: firstRow ? !!firstRow.open : false,
badges: parent ? parent.querySelectorAll('.card-code-dispatch-summary-line .inspect-badge').length : 0,
detailBlocks: firstRow ? firstRow.querySelectorAll('.card-code-dispatch-detail-block').length : 0,
}
})()
`)
console.log(' code-dispatch:', JSON.stringify(dispatchInject))
if (!dispatchInject || dispatchInject.rows !== 3 || !dispatchInject.firstOpen || dispatchInject.badges !== 3) {
throw new Error('code-dispatch tree missing expected shape: ' + JSON.stringify(dispatchInject))
}
// Scroll the run_code block into view before shooting.
await evj(`
(() => {
const p = document.querySelector('.tool-block[data-call-id="run-code-1"]')
if (p) { p.open = true; p.scrollIntoView({ block: 'center' }) }
return true
})()
`)
await sleep(300)
const shot3 = await shoot(call, evj, '03-code-dispatch-open.png')
console.log('\n--- SUMMARY ---')
console.log(`mount card: ${shot1.path} (${shot1.bytes} bytes)`)
console.log(`inspect fields: ${shot2.path} (${shot2.bytes} bytes)`)
console.log(`code-dispatch open:${shot3.path} (${shot3.bytes} bytes)`)
} finally {
try { child.kill('SIGKILL') } catch {}
for (let i = 0; i < 20; i++) {
await sleep(500)
try { await fetch(`http://localhost:${CDP_PORT}/json/list`) } catch { break }
}
}
}
main().catch(err => { console.error(err); process.exit(1) }).then(() => process.exit(0))