// scripts/qa-cdp-shoot-final-wave.mjs — final-wave reshoot driver.
//
// Batch shots proving the eight-step merge onto test-real: every new page,
// the tri-view (Tree/Timeline/Graph + detail-pane), the chat empty launcher
// four-card, the Bench post-fix layout, and a §7 centered-card verification.
//
// Runs against a fresh DSH_QA=1 Electron on --remote-debugging-port=9224
// with a scratch --user-data-dir; the driver never launches Electron itself
// (the QA agent boots it in shell), just drives.
//
// Usage:
// node scripts/qa-cdp-shoot-final-wave.mjs 9224 docs/demo-shots/final-wave
import { writeFileSync, mkdirSync } from 'node:fs'
import { resolve } from 'node:path'
const [,, portArg, outdirArg] = process.argv
const port = portArg || '9224'
const outdir = outdirArg || 'docs/demo-shots/final-wave'
mkdirSync(outdir, { recursive: true })
// ---------------- CDP plumbing ----------------
async function cdp() {
const listRes = await fetch(`http://localhost:${port}/json/list`)
const targets = await listRes.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((r, x) => { ws.onopen = r; ws.onerror = (e) => x(e) })
let id = 1
const pending = new Map()
const consoleEntries = []
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.method === 'Runtime.consoleAPICalled') {
// Capture console entries so we can grep for SyntaxError in the
// console-probe assertion at the tail of the run.
const text = (msg.params?.args || [])
.map((a) => a?.value ?? a?.description ?? '')
.join(' ')
consoleEntries.push({ level: msg.params?.type, text })
return
}
if (msg.method === 'Runtime.exceptionThrown') {
const desc = msg.params?.exceptionDetails?.exception?.description
|| msg.params?.exceptionDetails?.text || ''
consoleEntries.push({ level: 'exception', text: desc })
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 = {}, timeoutMs = 60000) => new Promise((ok, err) => {
const _id = id++
const t = setTimeout(() => { pending.delete(_id); err(new Error(`cdp timeout: ${m}`)) }, timeoutMs)
pending.set(_id, [(v) => { clearTimeout(t); ok(v) }, (e) => { clearTimeout(t); err(e) }])
ws.send(JSON.stringify({ id: _id, method: m, params: p }))
})
const evjs = async (js) => {
const r = await call('Runtime.evaluate', { expression: js, returnByValue: true, awaitPromise: true })
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text)
return r.result?.value
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
await call('Runtime.enable')
return { call, evjs, sleep, consoleEntries, close: () => ws.close() }
}
async function shoot(c, name) {
const r = await c.call('Page.captureScreenshot', { format: 'png' })
const p = resolve(outdir, name + '.png')
writeFileSync(p, Buffer.from(r.data, 'base64'))
console.log(' wrote', p)
}
// Close the devtools right-drawer if it's open. The QA harness pops it up
// on boot (Alt+D toggle default-visible when DSH_QA=1); every base-page
// shot needs it collapsed so the page fills the frame.
async function closeDevtools(c) {
await c.evjs(`(function(){
const btn = document.querySelector('.devtools-drawer .devtools-close')
if (btn) { btn.click(); return { closed: true } }
// fallback: hide the drawer directly
const d = document.querySelector('.devtools-drawer')
if (d && !d.hidden) { d.hidden = true; return { closed: 'hidden' } }
return { closed: false }
})()`)
}
// Open devtools drawer (for the trace tri-view sequence where the "Full
// trace" button lives inside the drawer head).
async function openDevtools(c) {
await c.evjs(`(function(){
const d = document.querySelector('.devtools-drawer')
if (d && d.hidden) {
// Find the show button in the debug bar
const t = document.querySelector('[data-devtools-toggle], .devtools-toggle, button[title*="Devtools" i]')
if (t) t.click()
else d.hidden = false
return { opened: true }
}
return { opened: !!d }
})()`)
}
// ---------------- shot recipes ----------------
// Each entry: { name, prep(c), waitMs }
// prep may switch tabs, click subtabs, seed a session, etc.
const RECIPES = [
// 1. Context page
{ name: '01-context-page', waitMs: 400, prep: async (c) => {
await c.evjs(`window.__dshTabs.switchTo('context')`)
} },
// 2. Hub page
{ name: '02-hub-page', waitMs: 400, prep: async (c) => {
await c.evjs(`window.__dshTabs.switchTo('hub')`)
} },
// 3. Bench page — post-fix layout (f1efa1c closed the two unclosed blocks
// that had swallowed the bench grid rules; this shot is the "layout OK now"
// proof).
{ name: '03-bench-page', waitMs: 500, prep: async (c) => {
await c.evjs(`window.__dshTabs.switchTo('bench')`)
} },
// 4. Rubrics catalog
{ name: '04-rubrics-page', waitMs: 400, prep: async (c) => {
await c.evjs(`window.__dshTabs.switchTo('rubrics')`)
} },
// 5. Runtimes page
{ name: '05-runtimes-page', waitMs: 400, prep: async (c) => {
await c.evjs(`window.__dshTabs.switchTo('runtimes')`)
} },
// 6. Settings
{ name: '06-settings-page', waitMs: 400, prep: async (c) => {
await c.evjs(`window.__dshTabs.switchTo('growth')`)
// Growth might not exist as 'settings' pane; try both. Fall back to
// whatever data-tab='settings' resolves to via nav btn click.
await c.evjs(`
(function(){
const b = document.querySelector('.tab-btn[data-tab="settings"]')
if (b) b.click()
})()
`)
} },
// 7. Session Tree
{ name: '07-session-tree', waitMs: 500, prep: async (c) => {
await c.evjs(`window.__dshTabs.switchTo('tree')`)
} },
// 8. Chat empty-state launcher four cards. Rebuild by fetching the
// real index.html and grafting its .empty-welcome block into the
// stream — the daemon typically auto-selects a prior session on boot
// and populates the stream, wiping the static template. Fetching
// preserves the actual SVG icons, tint classes, and copy without us
// reinventing them here.
{ name: '08-chat-empty-launcher', waitMs: 600, prep: async (c) => {
await c.evjs(`window.__dshTabs.switchTo('chat')`)
await c.evjs("(async function(){\n" +
" var stream = document.getElementById('stream')\n" +
" if (!stream) return { ok: false, msg: 'no stream' }\n" +
" var res = await fetch(window.location.href.split('#')[0])\n" +
" var html = await res.text()\n" +
" var m = html.match(/
\\s*<\\/div>\\s*<\\/section>/)\n" +
" if (!m) return { ok: false, msg: 'template not found' }\n" +
" // Strip the trailing we captured to find the block boundary.\n" +
" var block = m[0].replace(/<\\/section>$/, '').trim()\n" +
" stream.innerHTML = block\n" +
" return { ok: true }\n" +
"})()")
} },
]
// The tri-view detail shots run after the base pages. They seed a QA
// session, jump to Full trace via the devtools drawer contract, and then
// switch across tabs.
async function seedQaSession(c) {
// Prefer __dshLoadSampleTrace — mints a session AND replays a rich
// multi-turn fixture (~2.1+2.2+2.3+2.5+2.6 concatenated) so the Full
// trace overlay opens with real steps, not "0 steps".
return c.evjs(`(async () => {
if (typeof window.__dshLoadSampleTrace === 'function') {
await window.__dshLoadSampleTrace()
return { via: 'loadSampleTrace' }
}
if (typeof window.__dshQaSeedSession === 'function') {
const r = await window.__dshQaSeedSession()
return { via: 'seed', id: (r && r.id) || null }
}
throw new Error('no seed helper (DSH_QA=1 not set?)')
})()`)
}
async function openFullTrace(c) {
// Two options:
// 1) devtools drawer -> "Full trace" button opens a session-scope
// overlay (Tree/Timeline/Graph over the devtools event log). This
// is empty when we replay through onSessionEvent because the
// devtools drawer subscribes to a separate wire.
// 2) Turn-footer per-turn tri-view drawer — this is where the Tree
// view actually mounts with real records. ``
// is closed by default; open it programmatically.
// Prefer path 2, fall back to path 1 for the empty-state proof.
return c.evjs("(async () => {\n" +
" // Path 2: open the last turn's trace drawer inline.\n" +
" const drawers = document.querySelectorAll('details.turn-trace-drawer')\n" +
" if (drawers.length > 0) {\n" +
" const d = drawers[drawers.length - 1]\n" +
" d.open = true\n" +
" // Scroll it into view so the shot frames the tri-view, not empty scrollback.\n" +
" d.scrollIntoView({ block: 'center' })\n" +
" await new Promise(r => setTimeout(r, 400))\n" +
" return { via: 'turn-drawer', drawers: drawers.length }\n" +
" }\n" +
" // Path 1: fallback — devtools full-trace overlay (empty in this demo)\n" +
" const btns = Array.from(document.querySelectorAll('button, a, [role=button]'))\n" +
" const hit = btns.find(b => /full\\s*trace/i.test(b.textContent || ''))\n" +
" if (hit) { hit.click(); await new Promise(r => setTimeout(r, 300)); return { via: 'button' } }\n" +
" return { via: 'none' }\n" +
"})()")
}
async function switchTriviewTab(c, tab) {
// Only touch the tri-view inside the currently-open turn-trace-drawer.
// Multiple turn drawers can carry their own chip sets; we opened the
// last one, so scope to `details.turn-trace-drawer[open]`.
const js =
"(function(){\n" +
" var target = " + JSON.stringify(tab) + ";\n" +
" var scope = document.querySelector('details.turn-trace-drawer[open]') || document\n" +
" var btn = scope.querySelector('.trace-tri-chips .trace-tri-chip.chip-' + target)\n" +
" if (btn) { btn.click(); return { clicked: true, via: 'chip' } }\n" +
" var btn2 = scope.querySelector('.trace-tri-chip[data-view=\"' + target + '\"]')\n" +
" if (btn2) { btn2.click(); return { clicked: true, via: 'data-view' } }\n" +
" return { clicked: false, scoped: (scope !== document) }\n" +
"})()"
return c.evjs(js)
}
// ---------------- driver ----------------
async function main() {
const c = await cdp()
await c.call('Page.enable')
await c.evjs(`document.title`) // handshake
console.log('CDP handshake OK')
// First pass: base pages (drawer closed).
await closeDevtools(c)
await c.sleep(200)
for (const rec of RECIPES) {
console.log('shot:', rec.name)
try {
await rec.prep(c)
// The Chat pane opens the devtools drawer on entry in DSH_QA mode
// (qa-harness auto-opens); close after each prep so page-only shots
// stay clean.
await closeDevtools(c)
} catch (e) {
console.log(' prep err:', e.message)
}
await c.sleep(rec.waitMs)
await shoot(c, rec.name)
}
// Second pass: tri-view detail shots. Seed with real events first, then
// open the devtools drawer so the Full-trace button is reachable.
console.log('seeding QA session for tri-view shots...')
try {
// Back to chat pane where seeding wires stream to the visible pane.
await c.evjs(`window.__dshTabs.switchTo('chat')`)
await c.sleep(200)
const seedRes = await seedQaSession(c)
console.log(' seed:', JSON.stringify(seedRes))
await c.sleep(800)
// Bonus: populated chat with the sample-session fixture in place.
// Devtools drawer stays closed for this shot.
await closeDevtools(c)
await c.sleep(200)
await shoot(c, '13-chat-populated')
// Keep devtools closed for the tri-view sequence — tri-view lives
// inside a per-turn drawer beneath the turn footer, not
// inside the devtools drawer.
const openRes = await openFullTrace(c)
console.log(' openFullTrace:', JSON.stringify(openRes))
await c.sleep(600)
// 09 tree view (default after open) with inline time bar + model chip + pill row
await shoot(c, '09-triview-tree')
// 10 timeline tab (Gantt-shaped)
await switchTriviewTab(c, 'timeline')
await c.sleep(400)
await shoot(c, '10-triview-timeline')
// 11 graph tab
await switchTriviewTab(c, 'graph')
await c.sleep(400)
await shoot(c, '11-triview-graph')
// Back to tree, expand a step to see the four detail-pane tabs.
await switchTriviewTab(c, 'tree')
await c.sleep(300)
// Click the first expandable step summary in the tree to open the detail pane.
await c.evjs("(function(){\n" +
" // Tree lives inside the tri-view; the trace card rows are \n" +
" // summaries built by renderer.js. Grab the first summary in the\n" +
" // active panel and click it to expand.\n" +
" var panel = document.querySelector('.trace-tri-panel.panel-tree:not([hidden])')\n" +
" var scope = panel || document\n" +
" var summary = scope.querySelector('details > summary')\n" +
" if (summary) { summary.click(); return { clicked: 'summary' } }\n" +
" var row = scope.querySelector('.trace-event-row')\n" +
" if (row) { row.click(); return { clicked: 'row' } }\n" +
" return { clicked: null }\n" +
"})()")
await c.sleep(500)
await shoot(c, '12-triview-detail-pane')
// 14 §7 verification — mount the centered-card family (approval,
// workflow, subagent, question, terminal, diff) into the stream by
// firing the QA Debug buttons. If ANY of them render centered
// (not full-width) the shot exposes it; the CSS static gate should
// already prevent it, this is the render-time cross-check.
await c.evjs(`window.__dshTabs.switchTo('chat')`)
await c.sleep(200)
await c.evjs("(function(){\n" +
" var ids = ['mock-approval','mock-question','mock-card-terminal','mock-card-diff','mock-workflow','mock-recall','mock-compact-summary']\n" +
" var fired = []\n" +
" for (var i = 0; i < ids.length; i++) {\n" +
" var b = document.getElementById(ids[i])\n" +
" if (b) { b.click(); fired.push(ids[i]) }\n" +
" }\n" +
" return { fired: fired }\n" +
"})()")
await c.sleep(500)
await closeDevtools(c)
await c.sleep(200)
await c.evjs("(function(){ var s = document.getElementById('stream'); if (s) s.scrollTop = 0 })()")
await c.sleep(200)
await shoot(c, '14-section7-tool-cards')
} catch (e) {
console.log(' tri-view sequence err:', e.message)
}
// Console-probe assertion — no SyntaxError should have surfaced.
await c.sleep(200)
const syntaxHits = c.consoleEntries.filter(e => /SyntaxError/i.test(e.text))
const jsonPath = resolve(outdir, 'console-report.json')
writeFileSync(jsonPath, JSON.stringify({
port,
outdir,
totalConsoleEntries: c.consoleEntries.length,
syntaxErrors: syntaxHits,
exceptions: c.consoleEntries.filter(e => e.level === 'exception'),
}, null, 2))
console.log('console report:', jsonPath, 'syntaxErrors=' + syntaxHits.length)
c.close()
}
main().catch((e) => { console.error(e); process.exit(1) })