Files
deepseek-harness/examples/desktop/scripts/qa-cdp-shoot-nav-optional.mjs
ZiyaZhang 52a2493495 feat(desktop): nav hiddenPages config + optional pages opt-in
hiddenPages three-state config (undefined default / [] show-all / [list] custom) with
Playground and Missions default-hidden. Nav is now opt-in via Settings > Optional pages
section with immediate toggle rendering (no restart).

Change surface: nav-config-model.js (new, 64 lines), main.js (+24), preload.js (+9),
renderer.js (+41), index.html (+27/-1), settings-page.js (+87), style.css (+49), and
test/nav-config-model.test.js (new 22 tests, +291). docs/qa-nav-optional/*.png × 3
capture the three states.

12 files, +846/-1.

Test count on mainline: 1806 total / 1806 pass / 0 fail.
2026-07-19 05:39:04 -07:00

256 lines
10 KiB
JavaScript

// QA verification script for lane-nav-optional. Boots an isolated
// Electron on CDP :9272, rewrites the shell config for three fixture
// cases (missing hiddenPages / empty [] / custom list), and takes one
// sidebar screenshot for each case into docs/qa-nav-optional/.
//
// Isolation follows the 2026-07-18 postmortem in
// scripts/qa-cdp-shoot-affordance.mjs:
// 1. --user-data-dir=<tmp> isolates Chromium userdata.
// 2. DSH_DESKTOP_HOME=<tmp> isolates main-process config root so we
// never write into ~/.dsh-desktop.
//
// Why three passes not one boot with hot reloads: the hidden-page filter
// reads config.json through IPC at renderer init; we could plumb a
// window.__dshNavFilter.apply() to re-read after we mutate the file,
// but the point of the shoot is to prove the boot-time filter honors
// the file as-shipped. So we relaunch three times with different
// on-disk configs — a clean-room reproduction of the three cases a
// researcher would actually hit.
import { spawn } from 'node:child_process'
import { existsSync, mkdirSync, writeFileSync, rmSync } 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')
// One CDP port per case — Electron helper processes can linger past the
// main process death and hold the debug port bound, which quietly forces
// case-2/3 to attach to case-1's DevTools endpoint. Isolating ports side-
// steps the whole race.
const CDP_PORT_BASE = Number(process.env.DSH_NAV_OPTIONAL_PORT || 9272)
const OUTDIR = join(WORKTREE, 'docs/qa-nav-optional')
if (!existsSync(ELECTRON)) {
console.error(`electron binary not found at ${ELECTRON}`)
process.exit(2)
}
mkdirSync(OUTDIR, { recursive: true })
// Three fixture cases per task spec:
// • case-1: config.json missing hiddenPages → default (playground+mission hidden)
// • case-2: hiddenPages = [] → all pages visible
// • case-3: hiddenPages = ["prs","growth"] → PRs + Growth hidden
const CASES = [
{
name: '01-default-hidden',
label: 'missing hiddenPages → default (playground+mission hidden)',
config: { role: 'coding', approvalMode: 'never' },
},
{
name: '02-empty-array',
label: 'hiddenPages=[] → everything visible',
config: { role: 'coding', approvalMode: 'never', hiddenPages: [] },
},
{
name: '03-custom-list',
label: 'hiddenPages=["prs","growth"] → PRs+Growth hidden',
config: { role: 'coding', approvalMode: 'never', hiddenPages: ['prs', 'growth'] },
},
]
function seedHome(dshHome, cfg) {
// Every fresh boot writes a minimal overlay + config.json + .onboarded
// sentinel so the first-run modal doesn't fire and rewrite our seed.
const seedOverlay = [
'# QA nav-optional-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(cfg, 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',
// Deliberately NOT setting DSH_QA=1: qa-harness.js §11 clicks every
// onboarding button on boot including the skip path, which triggers
// onboarding:apply and rewrites config.json without our seeded
// hiddenPages. See main.js:763 — `writeShellConfig({role,approvalMode,createdAt})`
// drops any other fields on the floor.
},
stdio: ['ignore', 'pipe', 'pipe'],
})
const logs = []
child.stdout.on('data', d => { const s = String(d); logs.push(s); if (s.includes('[nav:')) process.stdout.write('MAIN_STDOUT: ' + s) })
child.stderr.on('data', d => { const s = String(d); logs.push(s); if (s.includes('[nav:')) process.stdout.write('MAIN_STDERR: ' + s) })
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()
const chunks = []
ws.onmessage = ev => {
const data = typeof ev.data === 'string' ? ev.data : String(ev.data)
let msg
try { msg = JSON.parse(data) } catch { chunks.push(data); 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 }
}
async function runCase(kase, idx) {
const port = CDP_PORT_BASE + idx
const dshHome = join(tmpdir(), `dsh-nav-optional-home-${kase.name}`)
const userData = join(tmpdir(), `dsh-nav-optional-userdata-${kase.name}`)
for (const dir of [dshHome, userData]) {
try { rmSync(dir, { recursive: true, force: true }) } catch {}
mkdirSync(dir, { recursive: true })
}
seedHome(dshHome, kase.config)
console.log(`[${kase.name}] booting on port ${port}: ${kase.label}`)
const { child } = await bootElectron(dshHome, userData, port)
try {
// Give the renderer a beat to run applyNavHiddenPages.
await sleep(1500)
const { call, evj } = await newCdp(port)
await call('Page.enable')
// Diagnostics before assertion: prove the IPC path is reachable and
// the model resolves the fixture case we expect. Without this, a
// race between the applyNavHiddenPages await and the shot capture
// leaves us reading DOM that hasn't been filtered yet.
const diag = await evj(`
(async () => {
const M = window.__dshNavConfigModel || null
const raw = window.dsh && window.dsh.nav && typeof window.dsh.nav.getHiddenPages === 'function'
? await window.dsh.nav.getHiddenPages()
: '<no-ipc>'
const resolved = M ? M.resolveHiddenPages(raw || {}) : '<no-model>'
// Force-run the filter now so the DOM is definitely up-to-date
// before we assert. This is what the settings-page.js checkbox
// path calls on toggle too.
if (window.__dshNavFilter && typeof window.__dshNavFilter.apply === 'function') {
await window.__dshNavFilter.apply()
}
return { hasModel: !!M, ipcResult: raw, resolved }
})()
`)
console.log(` diag :`, JSON.stringify(diag))
// Assert the DOM matches expectation before we capture — cheap
// sanity so a broken build fails loud instead of shipping a bad shot.
const visible = await evj(`
Array.from(document.querySelectorAll('.sidebar-nav .tab-btn'))
.filter(b => !b.classList.contains('nav-item--hidden'))
.map(b => b.dataset.tab)
`)
const hidden = await evj(`
Array.from(document.querySelectorAll('.sidebar-nav .tab-btn'))
.filter(b => b.classList.contains('nav-item--hidden'))
.map(b => b.dataset.tab)
`)
console.log(` visible:`, visible)
console.log(` hidden :`, hidden)
// Screenshot: clip to the left sidebar so we get a tight frame of
// the nav filter effect (not a giant chat pane full of empty state).
const clip = await evj(`
(() => {
const s = document.querySelector('.sidebar')
if (!s) return null
const r = s.getBoundingClientRect()
return { x: r.x, y: r.y, width: r.width, height: r.height, 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, `${kase.name}.png`)
writeFileSync(outPath, Buffer.from(shot.data, 'base64'))
console.log(` wrote ${outPath}`)
return { visible, hidden, path: outPath }
} finally {
try { child.kill('SIGKILL') } catch {}
// Aggressively wait for the CDP port to actually free — Electron
// spawns a helper process (GPU / renderer) that can linger after the
// main process death, keeping the port bound. Poll until /json/list
// stops answering (or 10s cap).
for (let i = 0; i < 20; i++) {
await sleep(500)
try {
await fetch(`http://localhost:${port}/json/list`)
} catch {
break // port is free
}
}
}
}
async function main() {
const results = []
for (let i = 0; i < CASES.length; i++) {
const kase = CASES[i]
const r = await runCase(kase, i)
results.push({ name: kase.name, label: kase.label, ...r })
}
console.log('\n--- SUMMARY ---')
for (const r of results) {
console.log(`${r.name}: ${r.label}`)
console.log(` visible: ${JSON.stringify(r.visible)}`)
console.log(` hidden : ${JSON.stringify(r.hidden)}`)
console.log(` shot : ${r.path}`)
}
}
main().catch(err => { console.error(err); process.exit(1) })