feat(desktop): DSH Electron desktop shell — harness internals visualized
Minimal Electron shell over the DSH JSON-RPC runtime — a first-look at what a ChatGPT.app-style host on top of the DeepSeek Harness looks like, with the harness's normally-invisible internals (trace timeline, context surface, subagent tree, compaction, plugin registry, rubrics) brought forward as first-class UI surfaces so plugin authors and researchers can see what the agent is actually doing. Runs against three keyless-to-live profiles (stdio-echo works on master out of the box; daemon-echo / daemon-vibe-echo activate once the daemon-demo lands; stdio-deepseek and daemon-vibe hit the real DeepSeek API when you supply a key). HARNESS_DEV auto-resolves to the in-repo runtime when this shell ships under examples/desktop/, so a fresh clone launches without config; env DSH_DEV_ROOT overrides for custom layouts, and a sibling deepseek-harness-dev/ checkout is the original dev workflow. Cold-clone gate (P0 fixes for first-time-clone usability): - HARNESS_DEV: 3-candidate resolver (env → walk-up in-repo marker → sibling), unit-tested via mock fs so ordering is locked without needing either real layout on disk. - config yml leaves rewritten at assemble time so the sibling-clone paths (../../deepseek-harness-dev/examples/echo-agent/…) become the in-repo paths (../../echo-agent/…) in the released tree — source yml stays usable for local dev, released tree ships a working shape. - pnpm-workspace.yaml allowBuilds.electron = true (was placeholder). - missing-key card in stdio-deepseek offers a one-click switch to stdio-echo (the keyless profile that works on master) rather than daemon-echo (blocked on the not-yet-shipped daemon-demo). - assemble-oss-release.sh rewrites the source-side breadcrumb name 'dsh-desktop-demo' → 'dsh-desktop' for the released package.json. FOUC guard on the onboarding gate (41fc5df carried) keeps the first-launch splash from flashing before the runtime probe finishes. Test suite (1634 tests in source, 3990 in the runtime repo) covers resolver ordering, renderer classifiers, trace timeline shape, compaction diff rendering, rubric parity, and the missing-key onboarding paths.
This commit is contained in:
233
examples/desktop/scripts/assemble-oss-release.sh
Executable file
233
examples/desktop/scripts/assemble-oss-release.sh
Executable file
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env bash
|
||||
# Assemble a clean OSS release tree from the current HEAD.
|
||||
#
|
||||
# Reads: current git HEAD (via `git archive`).
|
||||
# Writes: a fresh directory (default: /tmp/dsh-oss-release) containing only
|
||||
# what the first plugin author / researcher who clones this repo
|
||||
# actually needs. Everything on the exclude list below is dropped.
|
||||
#
|
||||
# Run BEFORE the first public push. Re-run whenever the exclude list needs
|
||||
# to catch up with new internal review chatter.
|
||||
#
|
||||
# **Not covered by this script**: git-history mailmap rewrite for
|
||||
# @deepseek.com author emails (hygiene report §1-M1 / M1). That's a
|
||||
# destructive one-time op — wait for user green-light, then run
|
||||
# `git filter-repo --mailmap` before the first push. See docs/oss-review-
|
||||
# hygiene.md §6 for the checklist.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/assemble-oss-release.sh # → /tmp/dsh-oss-release
|
||||
# scripts/assemble-oss-release.sh /path/to/out # → custom out dir
|
||||
# DRY_RUN=1 scripts/assemble-oss-release.sh # print exclusions only
|
||||
#
|
||||
# Exit non-zero on: git error, output dir already populated, any residual
|
||||
# leak detected in the post-scrub verification grep.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
OUT_DIR="${1:-/tmp/dsh-oss-release}"
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
|
||||
HEAD_SHA="$(git -C "$REPO_ROOT" rev-parse --short HEAD)"
|
||||
|
||||
echo "[assemble-oss-release] source repo : $REPO_ROOT"
|
||||
echo "[assemble-oss-release] source HEAD : $HEAD_SHA"
|
||||
echo "[assemble-oss-release] output dir : $OUT_DIR"
|
||||
|
||||
if [ -d "$OUT_DIR" ] && [ "$(ls -A "$OUT_DIR" 2>/dev/null)" ]; then
|
||||
echo "[assemble-oss-release] ERROR: $OUT_DIR is not empty; refusing to overwrite" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ─── Exclude list (docs/oss-review-hygiene.md §4) ────────────────────────
|
||||
# Each entry is a path relative to the repo root. Kept as an array so the
|
||||
# dry-run mode can just print it.
|
||||
|
||||
EXCLUDES=(
|
||||
# §4.A — internal review / audit chatter
|
||||
"docs/arch-review-report.md"
|
||||
"docs/capability-frontend-audit.md"
|
||||
"docs/capability-ui-coverage.md"
|
||||
"docs/context-fork-intent.md"
|
||||
"docs/demo-clickability-audit.md"
|
||||
"docs/design-confirm-162.md"
|
||||
"docs/design-confirm-185-section-7.md"
|
||||
"docs/design-confirm-198-section-7.md"
|
||||
"docs/e2e-real-audit.md"
|
||||
"docs/field-viz-audit.md"
|
||||
"docs/oss-tree-ui-patterns.md"
|
||||
"docs/plugin-mcp-audit.md"
|
||||
"docs/preflight-passthrough.md"
|
||||
"docs/product-flow-review.md"
|
||||
"docs/product-ia-design.md"
|
||||
"docs/qa-walkthrough-report.md"
|
||||
"docs/qa-walkthrough-round2.md"
|
||||
"docs/qa-walkthrough-round3.md"
|
||||
"docs/qa-walkthrough-round3b.md"
|
||||
"docs/review-demo-labels.md"
|
||||
"docs/review-fresh-eyes.md"
|
||||
"docs/review-wire-live.md"
|
||||
"docs/stabilization-review.md"
|
||||
"docs/strategy-feature-list.md"
|
||||
"docs/viz-coverage-matrix.md"
|
||||
"docs/walkthrough-baseline.md"
|
||||
"docs/walkthrough-round-real-api.md"
|
||||
"docs/walkthrough-round-visual.md"
|
||||
"docs/widget-channel-design.md"
|
||||
"docs/launch-smoke-checklist.md"
|
||||
"docs/oss-review-hygiene.md"
|
||||
"docs/oss-review-redundancy.md"
|
||||
|
||||
# §4.B — screenshot archives (~90 MB)
|
||||
"docs/demo-shots"
|
||||
"docs/162-selfies"
|
||||
"docs/selfies"
|
||||
"docs/qa-round2-shots"
|
||||
"docs/qa-round3-shots"
|
||||
"docs/qa-round3-real-shots"
|
||||
"docs/qa-round3b-shots"
|
||||
"docs/qa-round3b-shots-r4final"
|
||||
"docs/qa-round4-shots"
|
||||
"docs/qa-round4-preverify"
|
||||
"docs/qa-round5-shots"
|
||||
"docs/qa-oss-survey-shots"
|
||||
"docs/walkthrough-round-real-api-shots"
|
||||
"docs/design-growth-v2"
|
||||
|
||||
# §4.C — internal ticket work
|
||||
"docs/tickets"
|
||||
"docs/ticket-c"
|
||||
"docs/upstream-rfc-pack"
|
||||
|
||||
# §4.D — design-refs (LangSmith reference material + internal codename URL)
|
||||
"docs/design-refs"
|
||||
|
||||
# §4.E — internal QA/probe tooling (contains hardcoded absolute paths)
|
||||
"docs/default-profile-real-v2-probe"
|
||||
"scripts/layout-overlap-scan.mjs"
|
||||
"scripts/qa-cdp-shoot-affordance.mjs"
|
||||
"scripts/interactive-sweep-v2.mjs"
|
||||
"scripts/showcase-12x12-verify.mjs"
|
||||
)
|
||||
|
||||
if [ "${DRY_RUN:-0}" = "1" ]; then
|
||||
echo "[assemble-oss-release] DRY_RUN=1 — printing exclusions and exiting"
|
||||
printf ' exclude: %s\n' "${EXCLUDES[@]}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ─── Stage 1: git archive → OUT_DIR ────────────────────────────────────
|
||||
mkdir -p "$OUT_DIR"
|
||||
echo "[assemble-oss-release] git archive HEAD → $OUT_DIR"
|
||||
(cd "$REPO_ROOT" && git archive HEAD) | tar -x -C "$OUT_DIR"
|
||||
|
||||
# ─── Stage 2: apply excludes ────────────────────────────────────────────
|
||||
echo "[assemble-oss-release] pruning ${#EXCLUDES[@]} exclude entries"
|
||||
for path in "${EXCLUDES[@]}"; do
|
||||
target="$OUT_DIR/$path"
|
||||
if [ -e "$target" ]; then
|
||||
rm -rf "$target"
|
||||
echo " removed: $path"
|
||||
fi
|
||||
done
|
||||
|
||||
# ─── Stage 2b: rewrite in-repo relative paths in cordis yml leaves ──────
|
||||
# P0-2 fix (2026-07-18). In this source repo the shell sits alongside a
|
||||
# sibling `deepseek-harness-dev/` checkout — the yml leaves import the
|
||||
# mock-llm/echo-tool ts files via `../../deepseek-harness-dev/examples/
|
||||
# echo-agent/…`. In the OFFICIAL repo layout (deepseek-harness with this
|
||||
# shell copied under examples/desktop/) those same ts files live at
|
||||
# `../../echo-agent/…` — no sibling, they're siblings-of-desktop inside
|
||||
# the monorepo. Rewriting at assemble time keeps the source yml usable
|
||||
# for local dev AND ships a working shape to the released tree.
|
||||
#
|
||||
# BSD sed (default on macOS) has no `-i ''`-vs-`-i` compat trick that
|
||||
# works both places, so we do stream-in / stream-out to a temp file per
|
||||
# leaf — portable, boring.
|
||||
echo "[assemble-oss-release] rewriting cordis yml relative paths (sibling-clone → in-repo)…"
|
||||
YML_LEAVES=(
|
||||
"config/echo-jsonrpc.yml"
|
||||
"config/daemon-echo.yml"
|
||||
"config/daemon-vibe.yml"
|
||||
)
|
||||
for leaf in "${YML_LEAVES[@]}"; do
|
||||
target="$OUT_DIR/$leaf"
|
||||
if [ ! -f "$target" ]; then
|
||||
echo "[assemble-oss-release] ERROR: yml leaf missing after archive: $leaf" >&2
|
||||
exit 3
|
||||
fi
|
||||
# Replace the sibling-clone prefix with the in-repo relative prefix.
|
||||
# From examples/desktop/config/ up two levels lands at examples/, so
|
||||
# `../../echo-agent/…` reaches examples/echo-agent/ — where the ts
|
||||
# files sit in the official repo. Fail loud if the replacement leaves
|
||||
# any residual `deepseek-harness-dev` reference inside the config leaf.
|
||||
tmp="$target.oss.tmp"
|
||||
sed 's#\.\./\.\./deepseek-harness-dev/examples/echo-agent/#../../echo-agent/#g' \
|
||||
"$target" > "$tmp"
|
||||
mv "$tmp" "$target"
|
||||
if grep -q 'deepseek-harness-dev' "$target"; then
|
||||
echo " LEAK: still references deepseek-harness-dev in $leaf" >&2
|
||||
exit 3
|
||||
fi
|
||||
echo " rewrote: $leaf"
|
||||
done
|
||||
|
||||
# ─── Stage 2c: rewrite package.json name for OSS release ────────────────
|
||||
# Source repo carries the dev name `dsh-desktop-demo` (proof-of-concept
|
||||
# breadcrumb, kept unchanged there). In the official repo layout under
|
||||
# examples/desktop/ the package publishes as `dsh-desktop` — the "demo"
|
||||
# suffix is a source-side breadcrumb, not a shipping name. Rewrite here
|
||||
# so the source repo stays legible for local dev while the released
|
||||
# tree ships the launch name. Fail loud if the sed didn't take.
|
||||
PKGJSON="$OUT_DIR/package.json"
|
||||
if [ ! -f "$PKGJSON" ]; then
|
||||
echo "[assemble-oss-release] ERROR: package.json missing after archive" >&2
|
||||
exit 3
|
||||
fi
|
||||
echo "[assemble-oss-release] rewriting package.json name (dsh-desktop-demo → dsh-desktop)…"
|
||||
tmp="$PKGJSON.oss.tmp"
|
||||
sed 's#"name": "dsh-desktop-demo"#"name": "dsh-desktop"#' "$PKGJSON" > "$tmp"
|
||||
mv "$tmp" "$PKGJSON"
|
||||
if ! grep -q '"name": "dsh-desktop"' "$PKGJSON"; then
|
||||
echo " LEAK: package.json name rewrite did not take" >&2
|
||||
exit 3
|
||||
fi
|
||||
if grep -q '"name": "dsh-desktop-demo"' "$PKGJSON"; then
|
||||
echo " LEAK: package.json still shows dev name" >&2
|
||||
exit 3
|
||||
fi
|
||||
echo " rewrote: package.json name → dsh-desktop"
|
||||
|
||||
# ─── Stage 3: verification grep ─────────────────────────────────────────
|
||||
# Any residual hit here means the exclude list has drifted — abort.
|
||||
echo "[assemble-oss-release] verifying scrub…"
|
||||
LEAK=0
|
||||
scan() {
|
||||
local pattern="$1" label="$2"
|
||||
# -r recursive, -I skip binaries, -l list-only. Only fail on **text**
|
||||
# hits — png bytes that happen to contain the ASCII string are ignored
|
||||
# via -I. This script itself contains the patterns literally, so skip
|
||||
# its own copy in the output tree.
|
||||
local hits
|
||||
hits="$(grep -rIl -E "$pattern" "$OUT_DIR" 2>/dev/null \
|
||||
| grep -v '/scripts/assemble-oss-release\.sh$' || true)"
|
||||
if [ -n "$hits" ]; then
|
||||
echo " LEAK [$label]:" >&2
|
||||
echo "$hits" | sed 's/^/ /' >&2
|
||||
LEAK=1
|
||||
fi
|
||||
}
|
||||
scan 'api-internal\.deepseek\.com' 'internal proxy hostname (H1)'
|
||||
scan 'yinghuo|high-flyer' 'internal codename (H2)'
|
||||
# Only flag UI-visible / source-of-truth carriers: rubric fixture
|
||||
# frontmatter ("description:" line) and the auto-inlined seed JS. Code
|
||||
# comments in src/renderer/*model.js are explicitly OK per hygiene §2.2.
|
||||
scan '^description:.*LangSmith' 'competitor-name in rubric frontmatter (2.1)'
|
||||
scan 'LangSmith FeedbackSchema.*primitive parity' 'competitor-name in UI seed (2.1)'
|
||||
|
||||
if [ "$LEAK" != "0" ]; then
|
||||
echo "[assemble-oss-release] FAIL: residual leak — update EXCLUDES and re-run" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "[assemble-oss-release] ok — clean tree at $OUT_DIR"
|
||||
echo "[assemble-oss-release] next: mailmap-rewrite the git history (hygiene §6 M1) before the first public push."
|
||||
177
examples/desktop/scripts/electron-e2e.driver.js
Normal file
177
examples/desktop/scripts/electron-e2e.driver.js
Normal file
@@ -0,0 +1,177 @@
|
||||
// Real-user E2E for the DSH desktop shell. Drives the running renderer via
|
||||
// CDP and asserts both bug fixes.
|
||||
//
|
||||
// Usage: npx electron . --enable-logging --remote-debugging-port=9222
|
||||
// node scripts/electron-e2e.driver.js
|
||||
//
|
||||
// Lives under scripts/ (not test/) so node --test's auto-discovery skips it
|
||||
// — it needs a live Electron instance and would otherwise fail the suite.
|
||||
|
||||
'use strict'
|
||||
|
||||
async function main() {
|
||||
const res = await fetch('http://localhost:9222/json/list')
|
||||
const targets = await res.json()
|
||||
const target = targets.find((t) => t.type === 'page' && (t.title || '').includes('DSH'))
|
||||
if (!target) throw new Error('no DSH page target — is Electron running with --remote-debugging-port=9222?')
|
||||
|
||||
const ws = new WebSocket(target.webSocketDebuggerUrl)
|
||||
await new Promise((r, x) => { ws.onopen = r; ws.onerror = x })
|
||||
|
||||
let nextId = 1
|
||||
const pending = new Map()
|
||||
ws.onmessage = (ev) => {
|
||||
const msg = JSON.parse(ev.data)
|
||||
if (msg.id != null && pending.has(msg.id)) {
|
||||
const [resolve, reject] = pending.get(msg.id)
|
||||
pending.delete(msg.id)
|
||||
if (msg.error) reject(new Error(msg.error.message))
|
||||
else resolve(msg.result)
|
||||
}
|
||||
}
|
||||
const call = (method, params = {}) => new Promise((resolve, reject) => {
|
||||
const id = nextId++
|
||||
pending.set(id, [resolve, reject])
|
||||
ws.send(JSON.stringify({ id, method, params }))
|
||||
})
|
||||
const evalJs = async (expr) => {
|
||||
const r = await call('Runtime.evaluate', {
|
||||
expression: expr, returnByValue: true, awaitPromise: true,
|
||||
})
|
||||
if (r.exceptionDetails) {
|
||||
throw new Error(r.exceptionDetails.exception?.description ||
|
||||
r.exceptionDetails.text || 'eval error')
|
||||
}
|
||||
return r.result && r.result.value
|
||||
}
|
||||
|
||||
const fails = []
|
||||
function assert(cond, msg) {
|
||||
if (!cond) { fails.push(msg); console.error(' FAIL', msg) }
|
||||
else console.log(' ok ', msg)
|
||||
}
|
||||
|
||||
// Wait for the renderer to expose the debug seam
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const ready = await evalJs(`!!(window.__dshRenderer && window.__dshRenderer.onSessionEvent)`)
|
||||
if (ready) break
|
||||
await new Promise((r) => setTimeout(r, 250))
|
||||
}
|
||||
|
||||
// Switch to Chat tab
|
||||
await evalJs(`window.__dshTabs && window.__dshTabs.switchTo && window.__dshTabs.switchTo('chat')`)
|
||||
|
||||
// ---- BUG 1 --------------------------------------------------------------
|
||||
// Feed a synthetic user/message with the buggy shape (object source) and
|
||||
// assert the rendered stream does NOT contain `[object Object]`. Also
|
||||
// assert a request/header event doesn't leak into the chat.
|
||||
const bug1 = await evalJs(`(() => {
|
||||
const R = window.__dshRenderer
|
||||
const sid = 'e2e-bug1-' + Date.now()
|
||||
R.ensureSession(sid)
|
||||
return R.selectSession(sid).then(() => {
|
||||
// Case (a): user/message with object source — historical bug
|
||||
R.onSessionEvent(sid, {
|
||||
type: 'user/message',
|
||||
seq: 1,
|
||||
time: Date.now(),
|
||||
data: {
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
content: [{ type: 'text', text: 'context restored from compact summary' }],
|
||||
},
|
||||
})
|
||||
// Case (b): a dev-only event that used to spam the chat
|
||||
R.onSessionEvent(sid, {
|
||||
type: 'request/header',
|
||||
seq: 2,
|
||||
time: Date.now(),
|
||||
data: { model: 'echo-1', tokens: 42 },
|
||||
})
|
||||
// Case (c): a normal user/message from the user (control)
|
||||
R.onSessionEvent(sid, {
|
||||
type: 'user/message',
|
||||
seq: 3,
|
||||
time: Date.now(),
|
||||
data: {
|
||||
source: 'user',
|
||||
content: [{ type: 'text', text: 'hello from real user test' }],
|
||||
},
|
||||
})
|
||||
return {
|
||||
text: R.getStreamText(),
|
||||
html: R.getStreamHtml(),
|
||||
}
|
||||
})
|
||||
})()`)
|
||||
|
||||
console.log('\n-- stream text after bug1 injection --')
|
||||
console.log(bug1.text.slice(0, 500))
|
||||
|
||||
assert(!bug1.text.includes('[object Object]'), 'bug1: no [object Object] in chat stream')
|
||||
assert(!bug1.text.includes('[[object Object]]'), 'bug1: no [[object Object]] wrapper')
|
||||
assert(bug1.text.includes('[plugin:compact]'), 'bug1: object source rendered as [plugin:compact]')
|
||||
assert(bug1.text.includes('context restored from compact summary'),
|
||||
'bug1: compact source content shown')
|
||||
assert(!bug1.text.includes('request/header'), 'bug1: request/header event NOT in chat')
|
||||
assert(bug1.text.includes('hello from real user test'),
|
||||
'bug1: normal user message still renders')
|
||||
|
||||
// ---- BUG 2 --------------------------------------------------------------
|
||||
// Fill session A with a live conversation. Switch to a new session B.
|
||||
// Switch back to A. Assert the conversation is still there — the daemon
|
||||
// hasn't persisted anything (we didn't call it), so this exclusively
|
||||
// exercises the in-memory cache path.
|
||||
const bug2 = await evalJs(`(async () => {
|
||||
const R = window.__dshRenderer
|
||||
const sidA = 'e2e-bug2-A-' + Date.now()
|
||||
const sidB = 'e2e-bug2-B-' + Date.now()
|
||||
R.ensureSession(sidA)
|
||||
R.ensureSession(sidB)
|
||||
await R.selectSession(sidA)
|
||||
// Simulate a full turn
|
||||
R.onSessionEvent(sidA, {
|
||||
type: 'user/message', seq: 1, time: Date.now(),
|
||||
data: { source: 'user', content: [{ type: 'text', text: 'draw a heat map SVG' }] },
|
||||
})
|
||||
R.onSessionEvent(sidA, {
|
||||
type: 'assistant/message', seq: 2, time: Date.now(),
|
||||
data: { content: [{ type: 'text', text: 'here is the SVG source…' }] },
|
||||
})
|
||||
R.onSessionEvent(sidA, {
|
||||
type: 'turn/end', seq: 3, time: Date.now(), data: { reason: { kind: 'end' } },
|
||||
})
|
||||
const beforeSwitch = R.getStreamText()
|
||||
|
||||
// Switch away, then back
|
||||
await R.selectSession(sidB)
|
||||
const afterSwitchAway = R.getStreamText()
|
||||
await R.selectSession(sidA)
|
||||
const afterSwitchBack = R.getStreamText()
|
||||
|
||||
return { beforeSwitch, afterSwitchAway, afterSwitchBack }
|
||||
})()`)
|
||||
|
||||
console.log('\n-- bug2: before/after switch --')
|
||||
console.log('before (in A):', bug2.beforeSwitch.slice(-200))
|
||||
console.log('after switch away (B):', bug2.afterSwitchAway.slice(-200))
|
||||
console.log('after switch back to A:', bug2.afterSwitchBack.slice(-300))
|
||||
|
||||
assert(bug2.beforeSwitch.includes('draw a heat map SVG'),
|
||||
'bug2: user message present in session A before switch')
|
||||
assert(bug2.beforeSwitch.includes('here is the SVG source'),
|
||||
'bug2: assistant reply present before switch')
|
||||
// After switch away, stream should be empty (B is fresh)
|
||||
assert(!bug2.afterSwitchAway.includes('heat map SVG'),
|
||||
'bug2: session B does not show A\'s history')
|
||||
// After switching back, both messages must be there
|
||||
assert(bug2.afterSwitchBack.includes('draw a heat map SVG'),
|
||||
'bug2: user message restored after switch-back')
|
||||
assert(bug2.afterSwitchBack.includes('here is the SVG source'),
|
||||
'bug2: assistant reply restored after switch-back')
|
||||
|
||||
ws.close()
|
||||
console.log(fails.length === 0 ? '\nall real-user E2E assertions passed.' : `\n${fails.length} failure(s).`)
|
||||
process.exit(fails.length === 0 ? 0 : 1)
|
||||
}
|
||||
|
||||
main().catch((err) => { console.error(err); process.exit(1) })
|
||||
180
examples/desktop/scripts/qa-cdp-drive-9223.mjs
Executable file
180
examples/desktop/scripts/qa-cdp-drive-9223.mjs
Executable file
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env node
|
||||
// CDP driver for the test-real lane, pinned to port 9223.
|
||||
//
|
||||
// This file is a sibling of scripts/qa-cdp-drive.mjs. The split is a
|
||||
// concurrency accommodation, not a design goal:
|
||||
//
|
||||
// 9222 — qa-walkthrough lane's Electron (surface walk-throughs)
|
||||
// 9223 — test-real lane's Electron (real-API and CDP-click passes)
|
||||
// 9224 — qa-functional lane's Electron
|
||||
//
|
||||
// Running several Electron instances on the same debug port would race, so
|
||||
// each lane owns a port and its own driver flavour. Everything else about
|
||||
// the two drivers is compatible; use qa-cdp-drive.mjs if you're touching
|
||||
// the 9222 instance, this one if you're on 9223.
|
||||
//
|
||||
// Prereqs: launch Electron with --remote-debugging-port=9223. Uses Node's
|
||||
// built-in WebSocket (no Origin header) so Chromium doesn't 403 the connect
|
||||
// the way a browser-origin WS would.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/qa-cdp-drive-9223.mjs state
|
||||
// node scripts/qa-cdp-drive-9223.mjs js '<expression>'
|
||||
// node scripts/qa-cdp-drive-9223.mjs switch <profile>
|
||||
// node scripts/qa-cdp-drive-9223.mjs newSession
|
||||
// node scripts/qa-cdp-drive-9223.mjs send '<prompt>'
|
||||
// node scripts/qa-cdp-drive-9223.mjs wait <ms>
|
||||
// node scripts/qa-cdp-drive-9223.mjs shot <path>
|
||||
// node scripts/qa-cdp-drive-9223.mjs stream
|
||||
// node scripts/qa-cdp-drive-9223.mjs switchTab <tab> # chat|tree|mission|growth|plugins|prs
|
||||
// node scripts/qa-cdp-drive-9223.mjs sessions
|
||||
// node scripts/qa-cdp-drive-9223.mjs select <sessionId>
|
||||
//
|
||||
// Everything runs through Runtime.evaluate awaitPromise:true; expressions
|
||||
// that need await should be wrapped as an IIFE, e.g.:
|
||||
// js '(async()=>({s:await window.dsh.listSessions()}))()'
|
||||
|
||||
import http from 'node:http'
|
||||
import fs from 'node:fs'
|
||||
|
||||
const PORT = process.env.CDP_PORT ? Number(process.env.CDP_PORT) : 9223
|
||||
|
||||
function listTargets() {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.get(`http://localhost:${PORT}/json/list`, (res) => {
|
||||
let b = ''
|
||||
res.on('data', (c) => (b += c))
|
||||
res.on('end', () => { try { resolve(JSON.parse(b)) } catch (e) { reject(e) } })
|
||||
}).on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
async function pickPage() {
|
||||
const t = await listTargets()
|
||||
const p = t.find((x) => x.title === 'DSH Desktop') || t.find((x) => x.type === 'page')
|
||||
if (!p) throw new Error('no DSH page in ' + JSON.stringify(t.map(x => x.title)))
|
||||
return p.webSocketDebuggerUrl
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
const url = await pickPage()
|
||||
const ws = new WebSocket(url)
|
||||
await new Promise((r, j) => { ws.onopen = () => r(); ws.onerror = (e) => j(e.message || 'ws err') })
|
||||
let seq = 0
|
||||
const pending = new Map()
|
||||
ws.onmessage = (ev) => {
|
||||
let msg
|
||||
try { msg = JSON.parse(ev.data) } catch { return }
|
||||
if (msg.id != null && pending.has(msg.id)) {
|
||||
const { resolve, reject } = pending.get(msg.id)
|
||||
pending.delete(msg.id)
|
||||
if (msg.error) reject(new Error(JSON.stringify(msg.error)))
|
||||
else resolve(msg.result)
|
||||
}
|
||||
}
|
||||
function send(method, params) {
|
||||
const id = ++seq
|
||||
return new Promise((resolve, reject) => {
|
||||
pending.set(id, { resolve, reject })
|
||||
ws.send(JSON.stringify({ id, method, params: params || {} }))
|
||||
})
|
||||
}
|
||||
async function evalExpr(expr, awaitProm = true) {
|
||||
const r = await send('Runtime.evaluate', {
|
||||
expression: expr,
|
||||
returnByValue: true,
|
||||
awaitPromise: awaitProm,
|
||||
timeout: 60000,
|
||||
})
|
||||
if (r.exceptionDetails) throw new Error(r.exceptionDetails.text + ': ' + JSON.stringify(r.exceptionDetails.exception && r.exceptionDetails.exception.description || ''))
|
||||
return r.result && r.result.value
|
||||
}
|
||||
async function screenshot(path) {
|
||||
const r = await send('Page.captureScreenshot', { format: 'png' })
|
||||
fs.writeFileSync(path, Buffer.from(r.data, 'base64'))
|
||||
return path
|
||||
}
|
||||
return { ws, evalExpr, screenshot, close: () => ws.close() }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [, , cmd, ...args] = process.argv
|
||||
const c = await connect()
|
||||
try {
|
||||
switch (cmd) {
|
||||
case 'state': {
|
||||
const s = await c.evalExpr('JSON.stringify(window.__dshRenderer && window.__dshRenderer.snapshotState && window.__dshRenderer.snapshotState() || {noSeam:true})')
|
||||
console.log(s)
|
||||
break
|
||||
}
|
||||
case 'stream': {
|
||||
const t = await c.evalExpr('window.__dshRenderer && window.__dshRenderer.getStreamText && window.__dshRenderer.getStreamText()')
|
||||
console.log(t)
|
||||
break
|
||||
}
|
||||
case 'streamHtml': {
|
||||
const t = await c.evalExpr('document.getElementById("stream") && document.getElementById("stream").innerHTML')
|
||||
console.log(t)
|
||||
break
|
||||
}
|
||||
case 'js': {
|
||||
const expr = args.join(' ')
|
||||
const v = await c.evalExpr(expr)
|
||||
console.log(typeof v === 'string' ? v : JSON.stringify(v, null, 2))
|
||||
break
|
||||
}
|
||||
case 'switch': {
|
||||
const p = args[0]
|
||||
const v = await c.evalExpr(`(async()=>{ await window.dsh.startRuntime(${JSON.stringify(p)}); return await window.dsh.runtimeStatus() })()`)
|
||||
console.log(JSON.stringify(v, null, 2))
|
||||
break
|
||||
}
|
||||
case 'newSession': {
|
||||
const v = await c.evalExpr('(async()=>await window.dsh.newSession())()')
|
||||
console.log(JSON.stringify(v, null, 2))
|
||||
break
|
||||
}
|
||||
case 'send': {
|
||||
const text = args.join(' ')
|
||||
const v = await c.evalExpr(`(async()=>{ const sid = window.__dshRenderer && window.__dshRenderer.getActiveSessionId && window.__dshRenderer.getActiveSessionId(); if (!sid) return {noSession:true}; return await window.dsh.sendPrompt(sid, ${JSON.stringify(text)}) })()`)
|
||||
console.log(JSON.stringify(v, null, 2))
|
||||
break
|
||||
}
|
||||
case 'wait': {
|
||||
await new Promise((r) => setTimeout(r, Number(args[0]) || 1000))
|
||||
console.log('waited', args[0])
|
||||
break
|
||||
}
|
||||
case 'shot': {
|
||||
const p = args[0] || `/tmp/shot-${Date.now()}.png`
|
||||
const out = await c.screenshot(p)
|
||||
console.log('saved', out)
|
||||
break
|
||||
}
|
||||
case 'switchTab': {
|
||||
const t = args[0]
|
||||
const v = await c.evalExpr(`window.__dshTabs && window.__dshTabs.switchTo && window.__dshTabs.switchTo(${JSON.stringify(t)})`)
|
||||
console.log('tab:', t, '->', JSON.stringify(v))
|
||||
break
|
||||
}
|
||||
case 'select': {
|
||||
const id = args[0]
|
||||
const v = await c.evalExpr(`window.__dshRenderer && window.__dshRenderer.selectSession && window.__dshRenderer.selectSession(${JSON.stringify(id)})`)
|
||||
console.log('select:', id, '->', JSON.stringify(v))
|
||||
break
|
||||
}
|
||||
case 'sessions': {
|
||||
const v = await c.evalExpr('JSON.stringify(Array.from((window.__dshRenderer && window.__dshRenderer.snapshotState && window.__dshRenderer.snapshotState().sessions || new Map()).entries()).map(([id,m])=>({id,title:m.title,running:m.running,cached:(m.cachedEvents||[]).length,live:!!m.live,persisted:!!m.persisted})))')
|
||||
console.log(v)
|
||||
break
|
||||
}
|
||||
default:
|
||||
console.error('cmd?', cmd)
|
||||
process.exitCode = 2
|
||||
}
|
||||
} finally {
|
||||
c.close()
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e.message || e); process.exit(1) })
|
||||
93
examples/desktop/scripts/qa-cdp-drive-9234.mjs
Normal file
93
examples/desktop/scripts/qa-cdp-drive-9234.mjs
Normal file
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env node
|
||||
// CDP driver for the bench lane — port 9234. Same shape as
|
||||
// scripts/qa-cdp-drive-9223.mjs but pinned to a different port so multiple
|
||||
// lanes' Electron instances can coexist.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/qa-cdp-drive-9234.mjs js '<expression>'
|
||||
// node scripts/qa-cdp-drive-9234.mjs switchTab bench
|
||||
// node scripts/qa-cdp-drive-9234.mjs shot <path>
|
||||
|
||||
import http from 'node:http'
|
||||
import fs from 'node:fs'
|
||||
|
||||
const PORT = 9234
|
||||
|
||||
function listTargets() {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.get(`http://localhost:${PORT}/json/list`, (res) => {
|
||||
let b = ''; res.on('data', c => b += c); res.on('end', () => { try { resolve(JSON.parse(b)) } catch (e) { reject(e) } })
|
||||
}).on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
async function pickPage() {
|
||||
const t = await listTargets()
|
||||
const p = t.find(x => x.title === 'DSH Desktop') || t.find(x => x.type === 'page')
|
||||
if (!p) throw new Error('no DSH page')
|
||||
return p.webSocketDebuggerUrl
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
const url = await pickPage()
|
||||
const ws = new WebSocket(url)
|
||||
await new Promise((r, j) => { ws.onopen = () => r(); ws.onerror = (e) => j(e.message) })
|
||||
let seq = 0
|
||||
const pending = new Map()
|
||||
ws.onmessage = (ev) => {
|
||||
let msg; try { msg = JSON.parse(ev.data) } catch { return }
|
||||
if (msg.id != null && pending.has(msg.id)) {
|
||||
const { resolve, reject } = pending.get(msg.id); pending.delete(msg.id)
|
||||
if (msg.error) reject(new Error(JSON.stringify(msg.error)))
|
||||
else resolve(msg.result)
|
||||
}
|
||||
}
|
||||
function send(method, params) {
|
||||
const id = ++seq
|
||||
return new Promise((resolve, reject) => {
|
||||
pending.set(id, { resolve, reject })
|
||||
ws.send(JSON.stringify({ id, method, params: params || {} }))
|
||||
})
|
||||
}
|
||||
return { send, ws }
|
||||
}
|
||||
|
||||
async function evalExpr(client, expr) {
|
||||
const r = await client.send('Runtime.evaluate', {
|
||||
expression: expr, awaitPromise: true, returnByValue: true,
|
||||
})
|
||||
if (r.exceptionDetails) throw new Error('eval failed: ' + JSON.stringify(r.exceptionDetails))
|
||||
return r.result && r.result.value
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [, , cmd, ...rest] = process.argv
|
||||
const client = await connect()
|
||||
try {
|
||||
if (cmd === 'js') {
|
||||
const val = await evalExpr(client, rest.join(' '))
|
||||
console.log(JSON.stringify(val, null, 2))
|
||||
} else if (cmd === 'switchTab') {
|
||||
await evalExpr(client, `window.__dshTabs.switchTo(${JSON.stringify(rest[0])}); true`)
|
||||
console.log('OK')
|
||||
} else if (cmd === 'shot') {
|
||||
const outPath = rest[0]
|
||||
await client.send('Page.enable')
|
||||
// Bring window forward before capture (macOS Electron sometimes hides on
|
||||
// background). window.reveal seam.
|
||||
try { await evalExpr(client, 'window.dsh && window.dsh.qa && window.dsh.qa.reveal && window.dsh.qa.reveal()') } catch {}
|
||||
const r = await client.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false })
|
||||
fs.writeFileSync(outPath, Buffer.from(r.data, 'base64'))
|
||||
console.log('wrote', outPath, fs.statSync(outPath).size, 'bytes')
|
||||
} else if (cmd === 'wait') {
|
||||
await new Promise(r => setTimeout(r, Number(rest[0] || 500)))
|
||||
console.log('ok')
|
||||
} else {
|
||||
console.log('usage: js <expr> | switchTab <name> | shot <path> | wait <ms>')
|
||||
}
|
||||
} finally {
|
||||
client.ws.close()
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(e => { console.error(e); process.exit(1) })
|
||||
54
examples/desktop/scripts/qa-cdp-drive.mjs
Normal file
54
examples/desktop/scripts/qa-cdp-drive.mjs
Normal file
@@ -0,0 +1,54 @@
|
||||
// Simple CDP driver for the running DSH Electron shell — used by QA
|
||||
// walkthrough passes (see docs/qa-walkthrough-round2.md) to drive the live
|
||||
// app from a shell script.
|
||||
//
|
||||
// Prereqs: launch Electron with --remote-debugging-port=9222. Uses Node's
|
||||
// built-in WebSocket (no Origin header) so Chromium doesn't 403 the connect
|
||||
// the way a browser-origin WS would. Same-family tool as test/electron-e2e.js.
|
||||
//
|
||||
// Usage: node test/qa-cdp-drive.mjs <cmd> [args...]
|
||||
// eval <js> — run js in page, print JSON result
|
||||
// click <selector> — document.querySelector(selector).click()
|
||||
// settab <tab-key> — call window.__dshTabs.switchTo(tab)
|
||||
// mtab <mission-key> — click .mission-subview-tab[data-mission-tab=key]
|
||||
// waitfor <selector> — poll up to 3s for the selector, print bool
|
||||
|
||||
const [,, cmd, ...rest] = process.argv
|
||||
|
||||
async function main() {
|
||||
const res = await fetch('http://localhost:9222/json/list')
|
||||
const targets = await res.json()
|
||||
const target = targets.find((t) => t.type === 'page')
|
||||
if (!target) throw new Error('no page target')
|
||||
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()
|
||||
ws.onmessage = (ev) => {
|
||||
const msg = JSON.parse(ev.data)
|
||||
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 = {}) => new Promise((ok, err) => {
|
||||
const _id = id++; pending.set(_id, [ok, err])
|
||||
ws.send(JSON.stringify({ id: _id, method: m, params: p }))
|
||||
})
|
||||
const ev = 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
|
||||
}
|
||||
|
||||
let out
|
||||
if (cmd === 'eval') out = await ev(rest.join(' '))
|
||||
else if (cmd === 'click') out = await ev(`(function(){const el=document.querySelector(${JSON.stringify(rest[0])}); if(!el) return 'NO_ELEMENT'; el.click(); return 'OK'})()`)
|
||||
else if (cmd === 'settab') out = await ev(`window.__dshTabs && window.__dshTabs.switchTo && window.__dshTabs.switchTo(${JSON.stringify(rest[0])})`)
|
||||
else if (cmd === 'mtab') out = await ev(`(function(){const el=document.querySelector('.mission-subview-tab[data-mission-tab='+${JSON.stringify(rest[0])}+']'); if(!el) return 'NO_MTAB'; el.click(); return 'OK'})()`)
|
||||
else if (cmd === 'waitfor') out = await ev(`(async()=>{for(let i=0;i<15;i++){if(document.querySelector(${JSON.stringify(rest[0])})) return true; await new Promise(r=>setTimeout(r,200))} return false})()`)
|
||||
else throw new Error('unknown cmd '+cmd)
|
||||
console.log(typeof out === 'string' ? out : JSON.stringify(out))
|
||||
ws.close()
|
||||
}
|
||||
main().catch((e) => { console.error(String(e)); process.exit(1) })
|
||||
117
examples/desktop/scripts/qa-cdp-shoot-14-context-empty.mjs
Normal file
117
examples/desktop/scripts/qa-cdp-shoot-14-context-empty.mjs
Normal file
@@ -0,0 +1,117 @@
|
||||
// Shot for task #14 — Context empty-state layout fix (user field report
|
||||
// 2026-07-17). Verifies the pane no longer shows: (a) a left column hole,
|
||||
// (b) an empty card floating in the right slot, or (c) an orphaned SDK
|
||||
// support card. After the fix the empty pane reads as a single-column
|
||||
// document: subtitle → empty card (page chrome, sole "no activity" copy) →
|
||||
// SDK support card, stacked full-width. See src/renderer/style.css
|
||||
// `.context-page-body.is-empty`, context-page.js `renderEmpty()`.
|
||||
//
|
||||
// Runs against an Electron shell launched with DSH_QA=1 and
|
||||
// --remote-debugging-port=$port (default 9241). Node built-in WebSocket
|
||||
// sends no Origin header, which Chromium accepts.
|
||||
|
||||
import { writeFileSync, mkdirSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const port = process.argv[2] || '9241'
|
||||
const outdir = process.argv[3] || 'docs/demo-shots'
|
||||
mkdirSync(outdir, { recursive: true })
|
||||
|
||||
async function main () {
|
||||
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((r, x) => { ws.onopen = r; ws.onerror = (e) => x(e) })
|
||||
|
||||
let id = 1
|
||||
const pending = new Map()
|
||||
ws.onmessage = (ev) => {
|
||||
const msg = JSON.parse(typeof ev.data === 'string' ? ev.data : String(ev.data))
|
||||
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 = 15000) => new Promise((ok, err) => {
|
||||
const _id = id++
|
||||
const timer = setTimeout(() => { pending.delete(_id); err(new Error('cdp timeout: ' + m)) }, timeoutMs)
|
||||
pending.set(_id, [(v) => { clearTimeout(timer); ok(v) }, (e) => { clearTimeout(timer); 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
|
||||
}
|
||||
|
||||
await call('Page.enable')
|
||||
await evj(`window.dshQa && window.dshQa.revealWindow ? await window.dshQa.revealWindow() : null`)
|
||||
await call('Emulation.setDeviceMetricsOverride', {
|
||||
width: 1440, height: 900, deviceScaleFactor: 2, mobile: false,
|
||||
})
|
||||
|
||||
// Dismiss onboarding if it's up.
|
||||
await evj(`(function(){
|
||||
const btns = Array.from(document.querySelectorAll('button'));
|
||||
const skip = btns.find(b => /skip and use defaults/i.test(b.textContent || ''));
|
||||
if (skip) { skip.click(); return 'dismissed'; }
|
||||
return 'no-onboarding';
|
||||
})()`)
|
||||
await new Promise((r) => setTimeout(r, 300))
|
||||
|
||||
// Hide devtools drawer if it stole the right edge.
|
||||
await evj(`(function(){
|
||||
const d = document.querySelector('.devtools-drawer');
|
||||
if (d) { d.style.display = 'none'; return 'hidden'; }
|
||||
return 'no-drawer';
|
||||
})()`)
|
||||
|
||||
// Switch to Context tab. Force through the seam so we don't rely on the
|
||||
// sidebar text — nav items may hide labels behind icons.
|
||||
await evj(`(function(){
|
||||
if (window.__dshTabs && window.__dshTabs.switchTo) {
|
||||
window.__dshTabs.switchTo('context');
|
||||
return 'via seam';
|
||||
}
|
||||
const el = document.querySelector('[data-tab="context"]');
|
||||
if (el) { el.click(); return 'via click'; }
|
||||
return 'no-target';
|
||||
})()`)
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
|
||||
// Empty by design — no session events. Verify the layout classes we care about.
|
||||
const inspect = await evj(`(function(){
|
||||
const body = document.querySelector('.context-page-body');
|
||||
const empty = document.getElementById('context-page-empty');
|
||||
const list = document.getElementById('context-page-list');
|
||||
const legend = document.querySelector('.context-page-legend-card');
|
||||
const sub = document.getElementById('context-page-subtitle');
|
||||
return {
|
||||
bodyClass: body ? body.className : null,
|
||||
isEmpty: !!(body && body.classList.contains('is-empty')),
|
||||
emptyHidden: empty ? empty.hidden : null,
|
||||
emptyRect: empty ? (({x,y,width,height}) => ({x:Math.round(x),y:Math.round(y),w:Math.round(width),h:Math.round(height)}))(empty.getBoundingClientRect()) : null,
|
||||
listHidden: list ? list.hidden : null,
|
||||
legendRect: legend ? (({x,y,width,height}) => ({x:Math.round(x),y:Math.round(y),w:Math.round(width),h:Math.round(height)}))(legend.getBoundingClientRect()) : null,
|
||||
subtitleText: sub ? sub.textContent : null,
|
||||
};
|
||||
})()`)
|
||||
console.error('layout ->', JSON.stringify(inspect))
|
||||
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
const shot = await call('Page.captureScreenshot', {
|
||||
format: 'png',
|
||||
clip: { x: 0, y: 0, width: 1440, height: 900, scale: 2 },
|
||||
}, 30000)
|
||||
const outPath = resolve(outdir, '14-context-empty-fixed.png')
|
||||
writeFileSync(outPath, Buffer.from(shot.data, 'base64'))
|
||||
console.log(outPath)
|
||||
ws.close()
|
||||
}
|
||||
main().catch((e) => { console.error(e); process.exit(1) })
|
||||
178
examples/desktop/scripts/qa-cdp-shoot-162.mjs
Normal file
178
examples/desktop/scripts/qa-cdp-shoot-162.mjs
Normal file
@@ -0,0 +1,178 @@
|
||||
// scripts/qa-cdp-shoot-162.mjs — #162 selfie driver.
|
||||
//
|
||||
// Boots the running Electron on --remote-debugging-port=<port>, seeds a
|
||||
// session via __dshQaSeedSession, plays fixture events through
|
||||
// playTraceFixture, optionally toggles folds/tabs to expose the target
|
||||
// L1/L2 surfaces, then captures a PNG via Page.captureScreenshot with
|
||||
// fromSurface:false + Emulation.setDeviceMetricsOverride so a hidden
|
||||
// window still renders.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/qa-cdp-shoot-162.mjs <port> <outdir>
|
||||
//
|
||||
// Design-confirm-162 §6 seven-shot pack:
|
||||
// 01-assistant-turn-container 2.1 fixture, sealed turn, full container
|
||||
// 02-reasoning-block-open 2.2 fixture, reasoning fold opened
|
||||
// 03-partial-tool-row-live 2.3 fixture at mid-stream (halt before tool/call sealing)
|
||||
// 04-turn-footer-fused-pill 2.1 fixture, turn/end fired, footer visible
|
||||
// 05-compact-diff-tab 2.5 fixture, Diff tab focused, preview rows visible
|
||||
// 06-subagent-inline-open 2.6 fixture, subagent-trace <details> opened
|
||||
// 07-reasoning-missing-compare 2.2-B fixture, both turns visible (A with fold, B without)
|
||||
//
|
||||
// All shots use one fresh session each — no cross-fixture bleed.
|
||||
|
||||
import { writeFileSync, mkdirSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const [,, portArg, outdir] = process.argv
|
||||
const port = portArg || '9226'
|
||||
if (!outdir) {
|
||||
console.error('usage: node scripts/qa-cdp-shoot-162.mjs <port> <outdir>')
|
||||
process.exit(1)
|
||||
}
|
||||
mkdirSync(outdir, { recursive: true })
|
||||
|
||||
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()
|
||||
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 = {}, timeoutMs = 20000) => 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))
|
||||
return { call, evjs, sleep, close: () => ws.close() }
|
||||
}
|
||||
|
||||
async function shoot(cdp, name, opts) {
|
||||
const { fixture, prep, wait = 400, hideDebugPanel = true } = opts
|
||||
// Fresh session + fixture replay in one call via the QA seam
|
||||
// (window.__dshQaPlayFixture, DSH_QA=1 gated). Returns
|
||||
// { sessionId, dispatched, total } — dispatched < total means some
|
||||
// events were skipped by onSessionEvent (usually _mock control rows
|
||||
// we deliberately want it to ignore).
|
||||
const played = await cdp.evjs(`(async () => {
|
||||
if (!window.__dshQaPlayFixture) return { err: 'no play seam' }
|
||||
return await window.__dshQaPlayFixture(${JSON.stringify(fixture)})
|
||||
})()`)
|
||||
console.error(`[${name}] play -> ${JSON.stringify(played)}`)
|
||||
if (!played || played.err) throw new Error(`play failed: ${JSON.stringify(played)}`)
|
||||
|
||||
await cdp.sleep(wait)
|
||||
if (typeof prep === 'function') {
|
||||
const prepJs = prep()
|
||||
if (prepJs) {
|
||||
const pr = await cdp.evjs(prepJs)
|
||||
console.error(`[${name}] prep -> ${JSON.stringify(pr)}`)
|
||||
await cdp.sleep(200)
|
||||
}
|
||||
}
|
||||
if (hideDebugPanel) {
|
||||
await cdp.evjs(`(function(){
|
||||
const p = document.querySelector('.debug-panel'); if (p) p.style.display='none'
|
||||
const d = document.querySelector('.devtools-drawer'); if (d) d.style.display='none'
|
||||
// Context Rail auto-opens on subagent notifications and stays open
|
||||
// across shots — hide it uniformly so the inline chat stream owns the
|
||||
// visible frame. Per-shot prep can re-show if it wants the rail visible.
|
||||
const rail = document.getElementById('context-rail'); if (rail) rail.hidden = true
|
||||
return 1
|
||||
})()`)
|
||||
}
|
||||
|
||||
const shot = await cdp.call('Page.captureScreenshot', {
|
||||
format: 'png',
|
||||
clip: { x: 0, y: 0, width: 1440, height: 900, scale: 1 },
|
||||
})
|
||||
const path = resolve(outdir, `${name}.png`)
|
||||
writeFileSync(path, Buffer.from(shot.data, 'base64'))
|
||||
console.log(path)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const c = await cdp()
|
||||
await c.call('Page.enable')
|
||||
const revealed = await c.evjs(`(async()=>{try{return window.dshQa && await window.dshQa.revealWindow()}catch(e){return {err: String(e)}}})()`)
|
||||
console.error(`reveal -> ${JSON.stringify(revealed)}`)
|
||||
await c.call('Emulation.setDeviceMetricsOverride', {
|
||||
width: 1440, height: 900, deviceScaleFactor: 1, mobile: false,
|
||||
})
|
||||
const chatTabRes = await c.evjs(`window.__dshTabs && window.__dshTabs.switchTo && window.__dshTabs.switchTo('chat')`)
|
||||
console.error(`tab chat -> ${JSON.stringify(chatTabRes)}`)
|
||||
await c.sleep(200)
|
||||
|
||||
try {
|
||||
await shoot(c, '01-assistant-turn-container', { fixture: '2.1-turn-trajectory-mixed.json', wait: 600 })
|
||||
await shoot(c, '02-reasoning-block-open', { fixture: '2.2-reasoning-interleaved.json',
|
||||
prep: () => `(function(){const rb = document.querySelector('.reasoning-block .reasoning-row'); if(!rb) return 'NO_RB'; rb.click(); return 'OK'})()`,
|
||||
wait: 500,
|
||||
})
|
||||
await shoot(c, '03-partial-tool-row-live', { fixture: '2.3-toolcall-delta-stream.json',
|
||||
// The full 2.3 fixture ends with tool/call sealing the partial row.
|
||||
// We snapshot mid-way by only dispatching the first 7 events (up to
|
||||
// the last argumentsDelta), leaving the row in its streaming state.
|
||||
// Since our dispatch runs the full array today, we accept the sealed
|
||||
// state and rely on the CSS pulse being visible in the still-open
|
||||
// second call. Follow-up: expose a mid-halt in the driver.
|
||||
wait: 500,
|
||||
})
|
||||
await shoot(c, '04-turn-footer-fused-pill', { fixture: '2.1-turn-trajectory-mixed.json',
|
||||
// Focus the last footer by scrolling it into view.
|
||||
prep: () => `(function(){const f = document.querySelectorAll('.assistant-turn .turn-footer'); const last = f[f.length-1]; if(!last) return 'NO_FOOTER'; last.scrollIntoView({block:'end'}); return 'OK'})()`,
|
||||
wait: 500,
|
||||
})
|
||||
await shoot(c, '05-compact-diff-tab', { fixture: '2.5-compact-before-after.json',
|
||||
// The compact-card is a <details> — open it first, then click the Diff tab.
|
||||
prep: () => `(function(){
|
||||
const d = document.querySelector('details.compact-card'); if (!d) return 'NO_CARD'
|
||||
d.open = true
|
||||
const btns = d.querySelectorAll('.compact-card-tab')
|
||||
for (const b of btns) { if (b.textContent === 'Diff') { b.click(); return 'OK' } }
|
||||
return 'NO_TAB(' + btns.length + ')'
|
||||
})()`,
|
||||
wait: 500,
|
||||
})
|
||||
await shoot(c, '06-subagent-inline-open', { fixture: '2.6-subagent-inline-trace.json',
|
||||
// Close the Context Rail if open (subagent event auto-opens it), then
|
||||
// open the .subagent-trace <details> so the audit trace body is visible.
|
||||
prep: () => `(function(){
|
||||
const rail = document.getElementById('context-rail'); if (rail) rail.hidden = true
|
||||
const t = document.querySelector('.subagent-trace')
|
||||
if (!t) return 'NO_TRACE'
|
||||
t.open = true
|
||||
t.scrollIntoView({block:'center'})
|
||||
return 'OK'
|
||||
})()`,
|
||||
wait: 600,
|
||||
})
|
||||
await shoot(c, '07-reasoning-missing-compare', { fixture: '2.2-B-reasoning-missing-comparison.json',
|
||||
// Open both reasoning folds (turn A has one; turn B renders zero).
|
||||
prep: () => `(function(){const rows = document.querySelectorAll('.reasoning-block .reasoning-row'); rows.forEach(r=>r.click()); return {folds: rows.length}})()`,
|
||||
wait: 500,
|
||||
})
|
||||
} finally {
|
||||
await c.call('Emulation.clearDeviceMetricsOverride').catch(() => {})
|
||||
c.close()
|
||||
}
|
||||
}
|
||||
main().catch((e) => { console.error(String(e)); process.exit(1) })
|
||||
395
examples/desktop/scripts/qa-cdp-shoot-203-triview.mjs
Normal file
395
examples/desktop/scripts/qa-cdp-shoot-203-triview.mjs
Normal file
@@ -0,0 +1,395 @@
|
||||
// scripts/qa-cdp-shoot-203-triview.mjs — task #203 selfie driver.
|
||||
//
|
||||
// Three shots proving the trace tri-view (Tree | Timeline | Graph):
|
||||
// triview-01 Timeline tab active, turn scope — Gantt bars over a
|
||||
// single step (2.1 fixture)
|
||||
// triview-02 Graph tab active, session scope — DAG with fan-out
|
||||
// (2.6 subagent fixture)
|
||||
// triview-03 Turn footer with view-toggle chips visible (Tree default)
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/qa-cdp-shoot-203-triview.mjs <port> <outdir>
|
||||
|
||||
import { writeFileSync, mkdirSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const [,, portArg, outdir] = process.argv
|
||||
const port = portArg || '9238'
|
||||
if (!outdir) {
|
||||
console.error('usage: node scripts/qa-cdp-shoot-203-triview.mjs <port> <outdir>')
|
||||
process.exit(1)
|
||||
}
|
||||
mkdirSync(outdir, { recursive: true })
|
||||
|
||||
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()
|
||||
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 = {}, 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))
|
||||
return { call, evjs, sleep, close: () => ws.close() }
|
||||
}
|
||||
|
||||
async function shoot(c, name, opts) {
|
||||
const { fixture, prep, wait = 500, hideDebug = true } = opts
|
||||
// The daemon isn't up in this env (tsx resolution fails at spawn), so we
|
||||
// can't use __dshQaPlayFixture (it calls newSession() which needs a
|
||||
// supervisor). Bypass by planting a synthetic active session id + streamEl
|
||||
// reset + direct onSessionEvent dispatch through window.__dshFixtureInject
|
||||
// (a QA-only helper the driver installs on the first shot).
|
||||
const played = await c.evjs(`(async () => {
|
||||
// Wait up to 5s for the renderer to plant an active session (daemon
|
||||
// may still be spawning). Re-resolve each attempt so a late boot is
|
||||
// caught even after the first install.
|
||||
let sid = null; let tries = 0
|
||||
for (let i = 0; i < 50 && !sid; i++) {
|
||||
tries++
|
||||
const chat = (window.__dshChat && window.__dshChat.getActiveSessionId) || null
|
||||
sid = chat ? chat() : null
|
||||
if (!sid) await new Promise(r => setTimeout(r, 100))
|
||||
}
|
||||
console.log('[__dshTriviewInject] wait ->', {tries, sid})
|
||||
if (!window.__dshTriviewInject) {
|
||||
window.__dshTriviewInject = async function (fname, activeSid) {
|
||||
try {
|
||||
const sid = activeSid || 'triview-shot-' + Date.now()
|
||||
// Reset stream to a clean slate.
|
||||
const streamEl = document.getElementById('stream')
|
||||
if (streamEl) streamEl.innerHTML = ''
|
||||
const url = new URL('../../fixtures/trace-samples/' + fname, window.location.href)
|
||||
const r = await fetch(url.href)
|
||||
if (!r.ok) return { err: 'fetch ' + r.status }
|
||||
const events = await r.json()
|
||||
// Route through the same dispatcher the real wire uses. We reach it
|
||||
// via the same window.__dshChat.selectSession seam if present, else
|
||||
// fall through to onSessionEvent via internal seam.
|
||||
const bag = (typeof window !== 'undefined') ? window : {}
|
||||
// Plant activeSessionId via the private renderer seam if exposed.
|
||||
if (bag.__dshRendererState) bag.__dshRendererState.activeSessionId = sid
|
||||
// Direct dispatch: renderer.js exposes the onSessionEvent function
|
||||
// as window.__dshOnSessionEvent when DSH_QA=1 (see fallback shim
|
||||
// below in case it's absent).
|
||||
const dispatch = bag.__dshOnSessionEvent
|
||||
if (typeof dispatch !== 'function') {
|
||||
return { err: 'no __dshOnSessionEvent seam — direct inject unavailable' }
|
||||
}
|
||||
for (const ev of events) dispatch(sid, ev)
|
||||
return { sid, count: events.length }
|
||||
} catch (e) { return { err: String(e) } }
|
||||
}
|
||||
}
|
||||
return await window.__dshTriviewInject(${JSON.stringify(fixture)}, sid)
|
||||
})()`)
|
||||
console.error(`[${name}] play -> ${JSON.stringify(played)}`)
|
||||
await c.sleep(wait)
|
||||
if (typeof prep === 'function') {
|
||||
const r = await c.evjs(prep())
|
||||
console.error(`[${name}] prep -> ${JSON.stringify(r)}`)
|
||||
await c.sleep(400)
|
||||
}
|
||||
if (hideDebug) {
|
||||
await c.evjs(`(function(){
|
||||
const p = document.querySelector('.debug-panel'); if (p) p.style.display='none'
|
||||
// Hide any right-column overlay so the wide tri-view fits — cover
|
||||
// every id/class we know can appear in the right column.
|
||||
for (const sel of ['#context-rail-drawer', '#context-rail', '.context-rail-drawer', '.context-rail', '.devtools-drawer', '#devtools-panel']) {
|
||||
const n = document.querySelector(sel)
|
||||
if (n) {
|
||||
n.hidden = true
|
||||
n.setAttribute('aria-hidden', 'true')
|
||||
n.style.display = 'none'
|
||||
}
|
||||
}
|
||||
return 1
|
||||
})()`)
|
||||
}
|
||||
const shot = await c.call('Page.captureScreenshot', {
|
||||
format: 'png',
|
||||
clip: { x: 0, y: 0, width: 1440, height: 900, scale: 1 },
|
||||
})
|
||||
const path = resolve(outdir, `${name}.png`)
|
||||
writeFileSync(path, Buffer.from(shot.data, 'base64'))
|
||||
console.log(path)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const c = await cdp()
|
||||
await c.call('Page.enable')
|
||||
const revealed = await c.evjs(`(async()=>{try{return window.dshQa && await window.dshQa.revealWindow()}catch(e){return {err: String(e)}}})()`)
|
||||
console.error(`reveal -> ${JSON.stringify(revealed)}`)
|
||||
await c.call('Emulation.setDeviceMetricsOverride', {
|
||||
width: 1440, height: 900, deviceScaleFactor: 1, mobile: false,
|
||||
})
|
||||
const chatSwitch = await c.evjs(`window.__dshTabs && window.__dshTabs.switchTo && window.__dshTabs.switchTo('chat')`)
|
||||
console.error(`tab chat -> ${JSON.stringify(chatSwitch)}`)
|
||||
await c.sleep(200)
|
||||
|
||||
// Dismiss onboarding overlay if present — it can eclipse trace UI.
|
||||
await c.evjs(`(function(){
|
||||
document.body.classList.add('onboarded')
|
||||
const ob = document.querySelector('#onboarding, .onboarding, [data-onboarding]')
|
||||
if (ob) ob.remove()
|
||||
// Hide the devtools drawer if it's open — it covers the right half
|
||||
// of the viewport and would mask the detail pane in our shots.
|
||||
for (const sel of ['#devtools-panel', '.devtools-drawer', '.devtools-panel-drawer', '[data-devtools-panel]']) {
|
||||
const dt = document.querySelector(sel)
|
||||
if (dt) { dt.hidden = true; dt.style.display = 'none' }
|
||||
}
|
||||
return 1
|
||||
})()`)
|
||||
|
||||
try {
|
||||
// triview-01: Timeline at TURN scope. Build the tri-view standalone
|
||||
// via the pure module because the daemon-echo → turn-trace-drawer
|
||||
// path is fragile in the offline harness. The tri-view we render is
|
||||
// the same component finishTurnContainer wraps — this is the L3
|
||||
// canvas the user will interact with.
|
||||
await shoot(c, 'triview-01', {
|
||||
fixture: '2.1-turn-trajectory-mixed.json',
|
||||
wait: 600,
|
||||
prep: () => `(async () => {
|
||||
const tri = window.__dshTraceTriView
|
||||
const agg = window.__dshTraceAgg
|
||||
if (!tri || !agg) return 'NO_MODS'
|
||||
const url = new URL('../../fixtures/trace-samples/2.1-turn-trajectory-mixed.json', window.location.href)
|
||||
const r = await fetch(url.href)
|
||||
const events = await r.json()
|
||||
const records = agg.aggregateSteps(events)
|
||||
// Stage a wide preview container inside the chat stream so the
|
||||
// shot centers on it. Clear the stream first for a clean frame.
|
||||
const s = document.getElementById('stream')
|
||||
if (s) s.innerHTML = ''
|
||||
const host = document.createElement('div')
|
||||
host.style.padding = '16px'
|
||||
host.style.maxWidth = '860px'
|
||||
host.style.margin = '20px auto'
|
||||
host.style.border = '1px solid rgba(0,0,0,0.09)'
|
||||
host.style.borderRadius = '8px'
|
||||
host.style.background = 'var(--surface, #fff)'
|
||||
const label = document.createElement('div')
|
||||
label.style.font = '600 13px system-ui, sans-serif'
|
||||
label.style.marginBottom = '10px'
|
||||
label.textContent = 'Turn footer trace drawer — tri-view (Timeline active)'
|
||||
host.appendChild(label)
|
||||
const view = tri.buildTriView(document, {
|
||||
records: records[0] || {},
|
||||
scope: 'turn',
|
||||
defaultView: 'timeline',
|
||||
onSeqClick: () => {},
|
||||
})
|
||||
host.appendChild(view)
|
||||
s.appendChild(host)
|
||||
return { records: records.length, viewOk: !!view }
|
||||
})()`,
|
||||
})
|
||||
|
||||
// triview-02: Graph at SESSION scope — same standalone approach,
|
||||
// over multiple aggregated steps from the mixed fixture. Shows the
|
||||
// fan-out edge (spawn_agent → subagent node) and the full graph
|
||||
// spine.
|
||||
await shoot(c, 'triview-02', {
|
||||
fixture: '2.6-subagent-inline-trace.json',
|
||||
wait: 600,
|
||||
prep: () => `(async () => {
|
||||
const tri = window.__dshTraceTriView
|
||||
const agg = window.__dshTraceAgg
|
||||
const url = new URL('../../fixtures/trace-samples/2.6-subagent-inline-trace.json', window.location.href)
|
||||
const r = await fetch(url.href)
|
||||
const events = await r.json()
|
||||
const records = agg.aggregateSteps(events)
|
||||
const s = document.getElementById('stream')
|
||||
if (s) s.innerHTML = ''
|
||||
const host = document.createElement('div')
|
||||
host.style.padding = '16px'
|
||||
host.style.maxWidth = '860px'
|
||||
host.style.margin = '20px auto'
|
||||
host.style.border = '1px solid rgba(0,0,0,0.09)'
|
||||
host.style.borderRadius = '8px'
|
||||
host.style.background = 'var(--surface, #fff)'
|
||||
const label = document.createElement('div')
|
||||
label.style.font = '600 13px system-ui, sans-serif'
|
||||
label.style.marginBottom = '10px'
|
||||
label.textContent = 'Full-session trace overlay — tri-view (Graph active, fan-out edge)'
|
||||
host.appendChild(label)
|
||||
const view = tri.buildTriView(document, {
|
||||
records,
|
||||
scope: 'session',
|
||||
defaultView: 'graph',
|
||||
onSeqClick: () => {},
|
||||
})
|
||||
host.appendChild(view)
|
||||
s.appendChild(host)
|
||||
return { records: records.length }
|
||||
})()`,
|
||||
})
|
||||
|
||||
// triview-03: All three chips visible with Tree active (default).
|
||||
// The Tree panel shows a stub explaining per-turn availability at
|
||||
// session scope; at turn scope it would carry the pre-rendered trace
|
||||
// card. This shot demonstrates the chip toggle affordance itself.
|
||||
await shoot(c, 'triview-03', {
|
||||
fixture: '1.1-trace-one-turn.json',
|
||||
wait: 500,
|
||||
prep: () => `(async () => {
|
||||
const tri = window.__dshTraceTriView
|
||||
const agg = window.__dshTraceAgg
|
||||
const url = new URL('../../fixtures/trace-samples/1.1-trace-one-turn.json', window.location.href)
|
||||
const r = await fetch(url.href)
|
||||
const events = await r.json()
|
||||
const records = agg.aggregateSteps(events)
|
||||
const s = document.getElementById('stream')
|
||||
if (s) s.innerHTML = ''
|
||||
const host = document.createElement('div')
|
||||
host.style.padding = '16px'
|
||||
host.style.maxWidth = '760px'
|
||||
host.style.margin = '20px auto'
|
||||
host.style.border = '1px solid rgba(0,0,0,0.09)'
|
||||
host.style.borderRadius = '8px'
|
||||
host.style.background = 'var(--surface, #fff)'
|
||||
const label = document.createElement('div')
|
||||
label.style.font = '600 13px system-ui, sans-serif'
|
||||
label.style.marginBottom = '10px'
|
||||
label.textContent = 'Tri-view chips (Tree | Timeline | Graph) with Export SVG affordance'
|
||||
host.appendChild(label)
|
||||
// Build a stand-in tree element so the Tree tab has content.
|
||||
const tree = document.createElement('div')
|
||||
tree.style.padding = '12px'
|
||||
tree.style.color = '#6b6b70'
|
||||
tree.style.font = '12px ui-monospace, monospace'
|
||||
tree.textContent = '(tree view: reuses the existing per-turn trace card — chips let the reader flip to Timeline or Graph without leaving the turn)'
|
||||
const view = tri.buildTriView(document, {
|
||||
treeEl: tree,
|
||||
records: records[0] || {},
|
||||
scope: 'turn',
|
||||
defaultView: 'tree',
|
||||
onSeqClick: () => {},
|
||||
})
|
||||
host.appendChild(view)
|
||||
s.appendChild(host)
|
||||
return { records: records.length }
|
||||
})()`,
|
||||
})
|
||||
|
||||
// triview-04: (task #215) span-tree — the per-turn trace card with
|
||||
// real start→end waterfall bars on each event row. Renders the
|
||||
// renderer.js path directly so the shot proves the tree-with-time is
|
||||
// integrated, not just an isolated module. Uses the 2.1 turn fixture
|
||||
// via the __dshOnSessionEvent seam.
|
||||
await shoot(c, 'triview-04', {
|
||||
fixture: '2.1-turn-trajectory-mixed.json',
|
||||
wait: 600,
|
||||
prep: () => `(async () => {
|
||||
// The renderer already produced trace-card DOM via the injected
|
||||
// events. Find the last trace-card and scroll it into view so the
|
||||
// shot centers on the inline span bars.
|
||||
const cards = document.querySelectorAll('.trace-card')
|
||||
const last = cards[cards.length - 1]
|
||||
if (last) {
|
||||
last.setAttribute('open', '')
|
||||
const pane = last.querySelector('.trace-pane-events')
|
||||
if (pane) pane.setAttribute('open', '')
|
||||
last.scrollIntoView({ block: 'center' })
|
||||
}
|
||||
// Enrich the label header so the shot is self-explanatory.
|
||||
const s = document.getElementById('stream')
|
||||
if (s && !s.querySelector('[data-triview-04-header]')) {
|
||||
const label = document.createElement('div')
|
||||
label.setAttribute('data-triview-04-header', '1')
|
||||
label.style.font = '600 13px system-ui, sans-serif'
|
||||
label.style.margin = '12px auto 8px auto'
|
||||
label.style.maxWidth = '760px'
|
||||
label.style.padding = '0 16px'
|
||||
label.textContent = 'Span-tree waterfall — each event row shows start→end alignment (#215)'
|
||||
s.insertBefore(label, s.firstChild)
|
||||
}
|
||||
return { cards: cards.length }
|
||||
})()`,
|
||||
})
|
||||
|
||||
// triview-05: (task #205) detail pane — tri-view stage with a step
|
||||
// selected, right-side pane open on the Output tab, tool_calls
|
||||
// rendered as KV blocks and arguments expandable.
|
||||
await shoot(c, 'triview-05', {
|
||||
fixture: '2.1-turn-trajectory-mixed.json',
|
||||
wait: 600,
|
||||
prep: () => `(async () => {
|
||||
const tri = window.__dshTraceTriView
|
||||
const agg = window.__dshTraceAgg
|
||||
if (!tri || !agg) return 'NO_MODS'
|
||||
const url = new URL('../../fixtures/trace-samples/2.1-turn-trajectory-mixed.json', window.location.href)
|
||||
const r = await fetch(url.href)
|
||||
const events = await r.json()
|
||||
const records = agg.aggregateSteps(events)
|
||||
const s = document.getElementById('stream')
|
||||
if (s) s.innerHTML = ''
|
||||
// Also collapse the devtools drawer so the right column is visible
|
||||
const dt = document.getElementById('devtools-panel')
|
||||
if (dt) dt.hidden = true
|
||||
const host = document.createElement('div')
|
||||
host.style.padding = '16px'
|
||||
host.style.maxWidth = '1180px'
|
||||
host.style.margin = '20px auto'
|
||||
host.style.border = '1px solid rgba(0,0,0,0.09)'
|
||||
host.style.borderRadius = '8px'
|
||||
host.style.background = 'var(--surface, #fff)'
|
||||
const label = document.createElement('div')
|
||||
label.style.font = '600 13px system-ui, sans-serif'
|
||||
label.style.marginBottom = '10px'
|
||||
label.textContent = 'Tri-view detail pane — node click opens Feedback / Input / Output / Attributes (#205)'
|
||||
host.appendChild(label)
|
||||
const view = tri.buildTriView(document, {
|
||||
records,
|
||||
scope: 'session',
|
||||
sessionId: 'triview-shot-session',
|
||||
defaultView: 'timeline',
|
||||
onSeqClick: () => {},
|
||||
})
|
||||
host.appendChild(view)
|
||||
s.appendChild(host)
|
||||
// Wait for lazy timeline build, then dispatch a click on a row
|
||||
// that has a real seq. SVG groups don't have .click().
|
||||
await new Promise((r) => setTimeout(r, 220))
|
||||
const rowsWithSeq = view.querySelectorAll('[data-seq]:not([data-seq=""])')
|
||||
// First row is the step header; the second (tool/call) is more
|
||||
// illustrative because it has real tool_call output content.
|
||||
let target = rowsWithSeq[0]
|
||||
for (const r of rowsWithSeq) {
|
||||
const sq = r.getAttribute('data-seq')
|
||||
if (sq && Number(sq) >= 3) { target = r; break }
|
||||
}
|
||||
if (target) target.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
// Give lazy panel build time to run.
|
||||
await new Promise((r) => setTimeout(r, 60))
|
||||
return { records: records.length, opened: !!target, hasDetail: view.classList.contains('has-detail') }
|
||||
})()`,
|
||||
})
|
||||
} finally {
|
||||
await c.call('Emulation.clearDeviceMetricsOverride').catch(() => {})
|
||||
c.close()
|
||||
}
|
||||
}
|
||||
main().catch((e) => { console.error(String(e)); process.exit(1) })
|
||||
168
examples/desktop/scripts/qa-cdp-shoot-225-tracing.mjs
Normal file
168
examples/desktop/scripts/qa-cdp-shoot-225-tracing.mjs
Normal file
@@ -0,0 +1,168 @@
|
||||
// scripts/qa-cdp-shoot-225-tracing.mjs — task #225 selfie driver.
|
||||
//
|
||||
// Two shots proving the Tracing page (LangSmith-style project runs table):
|
||||
// 225-01 Tracing tab active — full project runs table with seeded
|
||||
// multi-session data. Search box + Columns button visible.
|
||||
// 225-02 Row clicked -> tri-view drilldown (Timeline default at
|
||||
// session scope). Back-to-Tracing breadcrumb visible.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/qa-cdp-shoot-225-tracing.mjs <port> <outdir>
|
||||
|
||||
import { writeFileSync, mkdirSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const [,, portArg, outdir] = process.argv
|
||||
const port = portArg || '9241'
|
||||
if (!outdir) {
|
||||
console.error('usage: node scripts/qa-cdp-shoot-225-tracing.mjs <port> <outdir>')
|
||||
process.exit(1)
|
||||
}
|
||||
mkdirSync(outdir, { recursive: true })
|
||||
|
||||
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()
|
||||
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 = {}, 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))
|
||||
return { call, evjs, sleep, close: () => ws.close() }
|
||||
}
|
||||
|
||||
// Seed the renderer with N synthetic sessions by planting them directly
|
||||
// into __dshChat's state.sessions Map via a QA-only helper. Uses the same
|
||||
// discipline as qa-cdp-shoot-203-triview: no daemon required.
|
||||
//
|
||||
// Each session gets a fabricated event stream so the aggregator produces
|
||||
// realistic P50 / P99 / tokens / cost numbers. Fixtures live in
|
||||
// fixtures/trace-samples/ so we don't ship yet another copy.
|
||||
const SEED_SPEC = [
|
||||
{ id: 'sess-mixed', title: 'exploration · mixed_probe', fixture: '2.1-turn-trajectory-mixed.json' },
|
||||
{ id: 'sess-agent', title: 'agent_turn refactor loop', fixture: '2.6-subagent-inline-trace.json' },
|
||||
{ id: 'sess-single', title: 'single_turn_qa arithmetic', fixture: '1.1-trace-one-turn.json' },
|
||||
]
|
||||
|
||||
async function shoot(c, name, prep) {
|
||||
if (typeof prep === 'function') {
|
||||
const r = await c.evjs(prep())
|
||||
console.error('[' + name + '] prep ->', JSON.stringify(r))
|
||||
await c.sleep(400)
|
||||
}
|
||||
// Hide overlays / dev drawers that could occlude the table.
|
||||
await c.evjs(`(function(){
|
||||
const p = document.querySelector('.debug-panel'); if (p) p.style.display='none'
|
||||
for (const sel of ['#context-rail-drawer', '#context-rail', '.context-rail-drawer', '.context-rail', '.devtools-drawer', '#devtools-panel']) {
|
||||
const n = document.querySelector(sel)
|
||||
if (n) { n.hidden = true; n.setAttribute('aria-hidden', 'true'); n.style.display = 'none' }
|
||||
}
|
||||
const ob = document.querySelector('#onboarding, .onboarding, [data-onboarding]')
|
||||
if (ob) ob.remove()
|
||||
return 1
|
||||
})()`)
|
||||
const shot = await c.call('Page.captureScreenshot', {
|
||||
format: 'png',
|
||||
clip: { x: 0, y: 0, width: 1440, height: 900, scale: 1 },
|
||||
})
|
||||
const path = resolve(outdir, name + '.png')
|
||||
writeFileSync(path, Buffer.from(shot.data, 'base64'))
|
||||
console.log(path)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const c = await cdp()
|
||||
await c.call('Page.enable')
|
||||
const revealed = await c.evjs(`(async()=>{try{return window.dshQa && await window.dshQa.revealWindow()}catch(e){return {err: String(e)}}})()`)
|
||||
console.error('reveal ->', JSON.stringify(revealed))
|
||||
await c.call('Emulation.setDeviceMetricsOverride', {
|
||||
width: 1440, height: 900, deviceScaleFactor: 1, mobile: false,
|
||||
})
|
||||
|
||||
// 225-01: Tracing tab active with populated table.
|
||||
//
|
||||
// Sequence:
|
||||
// 1) switchTo('tracing') — flips the pane visible + calls show()
|
||||
// (which will find zero seeded rows in the offline env because
|
||||
// refreshSessionList only knows the daemon's persisted list).
|
||||
// 2) Seed 3 synthetic sessions with fixture events; rewrite times
|
||||
// onto a stagger so "Most Recent Run" reads sensibly.
|
||||
// 3) show() again — projection now finds the seeded events.
|
||||
//
|
||||
// The switchTo/seed ordering matters: seeding before switchTo would
|
||||
// be blown away by refreshSessionList inside switchTo.
|
||||
await shoot(c, '225-01-tracing-table', () => `(async () => {
|
||||
if (!window.__dshTabs || typeof window.__dshTabs.switchTo !== 'function') return { err: 'no tabs seam' }
|
||||
window.__dshTabs.switchTo('tracing')
|
||||
await new Promise(r => setTimeout(r, 400))
|
||||
const rs = window.__dshRendererState
|
||||
if (!rs) return { err: 'no state seam' }
|
||||
const specs = ${JSON.stringify(SEED_SPEC)}
|
||||
const now = Date.now()
|
||||
let offset = 0
|
||||
for (const spec of specs) {
|
||||
const url = new URL('../../fixtures/trace-samples/' + spec.fixture, window.location.href)
|
||||
const res = await fetch(url.href)
|
||||
if (!res.ok) return { err: 'fetch ' + spec.fixture + ': ' + res.status }
|
||||
const events = await res.json()
|
||||
const base = now - (offset * 5 * 60 * 1000)
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
if (events[i] && typeof events[i] === 'object') {
|
||||
events[i].time = base - (events.length - i) * 250
|
||||
}
|
||||
}
|
||||
rs.sessions.set(spec.id, {
|
||||
title: spec.title, running: false, lastEventTime: base,
|
||||
toolCalls: new Map(), header: null, live: true, persisted: true,
|
||||
forkMarkers: new Map(), contextTracker: null, recallCards: new Map(),
|
||||
hasUserMessage: true, eventCount: events.length, cachedEvents: events,
|
||||
})
|
||||
offset++
|
||||
}
|
||||
if (window.__dshTracingPage) window.__dshTracingPage.show()
|
||||
await new Promise(r => setTimeout(r, 250))
|
||||
return { rows: document.querySelectorAll('.tracing-page-row').length }
|
||||
})()`)
|
||||
|
||||
// 225-02: Row click -> tri-view drilldown.
|
||||
await shoot(c, '225-02-tracing-drilldown', () => `(async () => {
|
||||
// Ensure we're on the tracing tab first (previous shot left us there).
|
||||
const row = document.querySelector('.tracing-page-row')
|
||||
if (!row) return { err: 'no rows visible' }
|
||||
row.click()
|
||||
await new Promise(r => setTimeout(r, 400))
|
||||
// The tri-view mounts inside #tracing-page-detail and defaults to
|
||||
// Timeline at session scope. Wait for the SVG walk to settle.
|
||||
await new Promise(r => setTimeout(r, 400))
|
||||
return {
|
||||
breadcrumb: document.querySelector('#tracing-page-breadcrumb')?.hidden === false,
|
||||
detailHasView: !!document.querySelector('#tracing-page-detail .trace-tri-view'),
|
||||
}
|
||||
})()`)
|
||||
|
||||
c.close()
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1) })
|
||||
317
examples/desktop/scripts/qa-cdp-shoot-centered-fix.mjs
Normal file
317
examples/desktop/scripts/qa-cdp-shoot-centered-fix.mjs
Normal file
@@ -0,0 +1,317 @@
|
||||
// Reshoot script for task #221 (centered-card root fix). Reproduces the
|
||||
// t141 selfie set (19-approval / 21-exit-plan / 22-steer-chip+card /
|
||||
// 23-triggers-t2-t4-t5) via CDP against an Electron shell running with
|
||||
// DSH_QA=1, so we can verify the four in-stream card families now render
|
||||
// full-width and left-aligned. See docs/design-refs/density-layering-spec.md
|
||||
// §7 "centered-card ban" for the rule these shots prove.
|
||||
//
|
||||
// Approach: fire the real renderer entry points where they exist
|
||||
// (`__dshRenderer.showSteerCard`, `.maybeAppendTriggerCard`), and click
|
||||
// the shipped `mock-approval` debug button for the approval card. For the
|
||||
// exit-plan-mode card, whose builder is module-local, hand-mount a DOM
|
||||
// tree that matches renderer.js:3967-4050 class-for-class — the point of
|
||||
// the shot is the outer `.card.form.exit-plan-mode` box, which is what
|
||||
// the CSS rule under test governs.
|
||||
//
|
||||
// Runs on a spare CDP port (default 9237) so it doesn't collide with a
|
||||
// user-visible Electron instance. Node built-in WebSocket sends no Origin
|
||||
// header, which Chromium accepts (avoids the 403 gotcha browsers hit).
|
||||
|
||||
import { writeFileSync, mkdirSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const port = process.argv[2] || '9237'
|
||||
const outdir = process.argv[3] || 'docs/demo-shots/centered-fix-01'
|
||||
mkdirSync(outdir, { recursive: true })
|
||||
|
||||
async function main() {
|
||||
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((r, x) => { ws.onopen = r; ws.onerror = (e) => x(e) })
|
||||
|
||||
let id = 1
|
||||
const pending = new Map()
|
||||
ws.onmessage = (ev) => {
|
||||
const msg = JSON.parse(typeof ev.data === 'string' ? ev.data : String(ev.data))
|
||||
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 = 15000) => new Promise((ok, err) => {
|
||||
const _id = id++
|
||||
const timer = setTimeout(() => { pending.delete(_id); err(new Error('cdp timeout: ' + m)) }, timeoutMs)
|
||||
pending.set(_id, [(v) => { clearTimeout(timer); ok(v) }, (e) => { clearTimeout(timer); err(e) }])
|
||||
ws.send(JSON.stringify({ id: _id, method: m, params: p }))
|
||||
})
|
||||
// evj runs `expr` inside an IIFE with an implicit `return`. Wrap any
|
||||
// multi-statement expression yourself if you need `;`.
|
||||
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
|
||||
}
|
||||
|
||||
await call('Page.enable')
|
||||
const reveal = await evj(`window.dshQa && window.dshQa.revealWindow ? await window.dshQa.revealWindow() : null`)
|
||||
console.error('reveal ->', JSON.stringify(reveal))
|
||||
await call('Emulation.setDeviceMetricsOverride', {
|
||||
width: 1440, height: 900, deviceScaleFactor: 2, mobile: false,
|
||||
})
|
||||
|
||||
const shoot = async (name) => {
|
||||
// Settle longer to let post-mount layout finish; some cards (exit-plan
|
||||
// wrap, steer card status strip) trigger a second reflow after the
|
||||
// initial paint. 700ms is empirically enough on this machine.
|
||||
await new Promise((r) => setTimeout(r, 700))
|
||||
// Screenshot with a 30s timeout — the default 15s occasionally trips on
|
||||
// the second/third shot when the compositor is under load, and retry
|
||||
// once on timeout so a single flake doesn't lose the shot.
|
||||
let shot
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
shot = await call('Page.captureScreenshot', {
|
||||
format: 'png',
|
||||
clip: { x: 0, y: 0, width: 1440, height: 900, scale: 2 },
|
||||
}, 30000)
|
||||
break
|
||||
} catch (e) {
|
||||
if (attempt === 2) throw e
|
||||
console.error(` shoot ${name} attempt ${attempt + 1} failed: ${e.message}, retrying`)
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
}
|
||||
}
|
||||
const path = resolve(outdir, `${name}.png`)
|
||||
writeFileSync(path, Buffer.from(shot.data, 'base64'))
|
||||
console.log(path)
|
||||
}
|
||||
|
||||
// Dismiss the onboarding modal (first-boot) if it's up. It grays out the
|
||||
// whole app and none of the mock injectors reach the stream through it.
|
||||
// The modal's "Skip and use defaults" button dismisses without asking
|
||||
// the user to pick a profile.
|
||||
await evj(`(function(){
|
||||
const btns = Array.from(document.querySelectorAll('button'));
|
||||
const skip = btns.find(b => /skip and use defaults/i.test(b.textContent || ''));
|
||||
if (skip) { skip.click(); return 'onboarding dismissed'; }
|
||||
return 'no-onboarding';
|
||||
})()`).then(r => console.error('onboarding ->', r))
|
||||
await new Promise((r) => setTimeout(r, 400))
|
||||
|
||||
// Fresh boots land on whichever tab was last saved (often PRs). Force
|
||||
// chat via the tab seam if present, or by clicking the sidebar nav item.
|
||||
await evj(`(function(){
|
||||
if (window.__dshTabs && window.__dshTabs.switchTo) {
|
||||
window.__dshTabs.switchTo('chat');
|
||||
return 'via seam';
|
||||
}
|
||||
// Fallback: click the "Chat" nav-item in the observation group.
|
||||
const items = Array.from(document.querySelectorAll('.nav-item, [data-tab]'));
|
||||
for (const el of items) {
|
||||
if (/^chat$/i.test((el.textContent || '').trim()) || el.dataset.tab === 'chat') {
|
||||
el.click();
|
||||
return 'via click';
|
||||
}
|
||||
}
|
||||
return 'no-chat-target';
|
||||
})()`).then(r => console.error('switch chat ->', r))
|
||||
// Close the Devtools drawer if it's open — it obscures the right edge of
|
||||
// in-stream cards and is unrelated to the shot's intent. The real class
|
||||
// (per src/renderer/devtools-panel.js:88) is `.devtools-drawer`; we hide
|
||||
// it via style since there's no exposed close button on the drawer chrome.
|
||||
await evj(`(function(){
|
||||
const d = document.querySelector('.devtools-drawer');
|
||||
if (d) { d.style.display = 'none'; return 'hidden'; }
|
||||
return 'no-drawer';
|
||||
})()`).then(r => console.error('devtools close ->', r))
|
||||
await new Promise((r) => setTimeout(r, 300))
|
||||
|
||||
// Report what the stream looks like right now (before any injection).
|
||||
const preflight = await evj(`(function(){
|
||||
const s = document.querySelector('.stream');
|
||||
return {
|
||||
hasStream: !!s,
|
||||
streamWidth: s ? Math.round(s.getBoundingClientRect().width) : null,
|
||||
hasRenderer: !!window.__dshRenderer,
|
||||
showSteer: !!(window.__dshRenderer && window.__dshRenderer.showSteerCard),
|
||||
maybeTrigger: !!(window.__dshRenderer && window.__dshRenderer.maybeAppendTriggerCard),
|
||||
hasMockApproval: !!document.getElementById('mock-approval'),
|
||||
activeSession: window.__dshRenderer && window.__dshRenderer.getActiveSessionId && window.__dshRenderer.getActiveSessionId(),
|
||||
};
|
||||
})()`)
|
||||
console.error('preflight ->', JSON.stringify(preflight))
|
||||
|
||||
// Helper: wipe any leftover cards / plan wraps / steer chips from
|
||||
// the stream so successive shots don't stack. Kept as a callable expr.
|
||||
const clearExpr = `(function(){
|
||||
const s = document.querySelector('.stream');
|
||||
if (!s) return 'no-stream';
|
||||
for (const c of Array.from(s.querySelectorAll('.card, .exit-plan-mode-wrap, .steer-chip'))) c.remove();
|
||||
return 'cleared';
|
||||
})()`
|
||||
|
||||
// --- 19: approval card ----------------------------------------------------
|
||||
await evj(clearExpr)
|
||||
const clickRes = await evj(`(function(){
|
||||
const b = document.getElementById('mock-approval');
|
||||
if (!b) return 'no-button';
|
||||
b.click();
|
||||
return { present: !!document.querySelector('.card.approval') };
|
||||
})()`)
|
||||
console.error('19 mock-approval click ->', JSON.stringify(clickRes))
|
||||
await shoot('19-approval-waiting')
|
||||
|
||||
// --- 21: exit-plan-mode ---------------------------------------------------
|
||||
await evj(clearExpr)
|
||||
const planMount = await evj(`(function(){
|
||||
const stream = document.querySelector('.stream');
|
||||
if (!stream) return 'no-stream';
|
||||
// Mount the exact DOM the renderer builds at renderer.js:3967-4050.
|
||||
// The shot's job is to verify the outer .card.form.exit-plan-mode box
|
||||
// is full-width — that comes from the density-spec §7 rule under test.
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'exit-plan-mode-wrap';
|
||||
const el = document.createElement('div');
|
||||
el.className = 'card form exit-plan-mode';
|
||||
const h = document.createElement('h4');
|
||||
h.textContent = 'Exit plan mode?';
|
||||
el.appendChild(h);
|
||||
const desc = document.createElement('div');
|
||||
desc.className = 'label';
|
||||
desc.textContent = 'Review the plan below. Edit if needed, add a comment, then confirm to leave plan mode.';
|
||||
el.appendChild(desc);
|
||||
const planLabel = document.createElement('div');
|
||||
planLabel.className = 'exit-plan-mode-section-label';
|
||||
planLabel.textContent = 'PLAN';
|
||||
el.appendChild(planLabel);
|
||||
const planInput = document.createElement('textarea');
|
||||
planInput.className = 'exit-plan-mode-plan';
|
||||
planInput.rows = 5;
|
||||
planInput.value = '1. Read src/renderer/session-tree-page.js for the fork-tree render path.\\n2. Add a highlight to selected fork rows.\\n3. Update snapshot tests.\\n4. Reshoot selfie 01 and 02.';
|
||||
el.appendChild(planInput);
|
||||
const commentLabel = document.createElement('div');
|
||||
commentLabel.className = 'exit-plan-mode-section-label';
|
||||
commentLabel.textContent = 'COMMENT';
|
||||
el.appendChild(commentLabel);
|
||||
const commentInput = document.createElement('textarea');
|
||||
commentInput.className = 'exit-plan-mode-comment';
|
||||
commentInput.rows = 2;
|
||||
commentInput.placeholder = 'add a note before confirming';
|
||||
el.appendChild(commentInput);
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'actions';
|
||||
actions.style.marginTop = '10px';
|
||||
actions.innerHTML = '<button class="ghost small">Confirm</button><button class="ghost small">Skip</button>';
|
||||
el.appendChild(actions);
|
||||
wrap.appendChild(el);
|
||||
const rail = document.createElement('div');
|
||||
rail.className = 'plan-diff-rail';
|
||||
rail.innerHTML = "<div class='exit-plan-mode-section-label' style='padding:8px 12px'>PLAN PREVIEW</div>";
|
||||
wrap.appendChild(rail);
|
||||
stream.appendChild(wrap);
|
||||
return 'mounted';
|
||||
})()`)
|
||||
console.error('21 exit-plan mount ->', JSON.stringify(planMount))
|
||||
await shoot('21-exit-plan-mode')
|
||||
|
||||
// --- 22: steer chip + steer card -----------------------------------------
|
||||
await evj(clearExpr)
|
||||
const steerRes = await evj(`(function(){
|
||||
const R = window.__dshRenderer;
|
||||
if (!R) return 'no-renderer';
|
||||
const sid = R.getActiveSessionId && R.getActiveSessionId();
|
||||
const stream = document.querySelector('.stream');
|
||||
if (!stream) return 'no-stream';
|
||||
// Prior-turn steer chip (chat-stream row above the card).
|
||||
const chip = document.createElement('div');
|
||||
chip.className = 'steer-chip';
|
||||
chip.innerHTML = "<span class='steer-chip-label'>steer: read isolated-daemon spawn env</span>";
|
||||
stream.appendChild(chip);
|
||||
if (typeof R.showSteerCard !== 'function') return 'no-showSteerCard';
|
||||
R.showSteerCard({
|
||||
interruptId: 'mock-steer-' + Date.now(),
|
||||
sessionId: sid,
|
||||
spec: {
|
||||
title: 'Suggestion',
|
||||
message: 'This subagent has been idle for 45s. Nudge it to summarise progress?',
|
||||
suggestions: [
|
||||
{ label: 'Ask for a status update' },
|
||||
{ label: 'Cancel this subagent' },
|
||||
],
|
||||
},
|
||||
});
|
||||
return { chip: !!document.querySelector('.steer-chip'), card: !!document.querySelector('.card.steer') };
|
||||
})()`)
|
||||
console.error('22 steer mount ->', JSON.stringify(steerRes))
|
||||
await shoot('22-steer-chip-and-card')
|
||||
|
||||
// --- 23: trigger family t2 / t4 / t5 --------------------------------------
|
||||
// `maybeAppendTriggerCard` classifies via templateFromEvent + widget
|
||||
// registry — feeding it a synthetic dummy that doesn't match a template
|
||||
// produces zero cards, and the widget registry is dependency-injected
|
||||
// by the shell at boot. For a CSS-width shot we build the same DOM the
|
||||
// renderer would build (class-for-class match with renderer.js:3688-
|
||||
// 3703) so the outer .card.trigger-card box behaviour under test is
|
||||
// exercised on the real class chain.
|
||||
await evj(clearExpr)
|
||||
const trigRes = await evj(`(function(){
|
||||
const stream = document.querySelector('.stream');
|
||||
if (!stream) return 'no-stream';
|
||||
const mk = (kind, badge, title, body) => {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'card trigger-card trigger-' + kind;
|
||||
wrap.dataset.triggerKind = kind;
|
||||
const b = document.createElement('div');
|
||||
b.className = 'trigger-badge';
|
||||
b.textContent = badge;
|
||||
wrap.appendChild(b);
|
||||
const h = document.createElement('div');
|
||||
h.style.fontSize = '13px';
|
||||
h.style.fontWeight = '600';
|
||||
h.style.marginBottom = '4px';
|
||||
h.textContent = title;
|
||||
wrap.appendChild(h);
|
||||
const body_ = document.createElement('div');
|
||||
body_.style.fontSize = '12px';
|
||||
body_.style.color = 'var(--muted)';
|
||||
body_.textContent = body;
|
||||
wrap.appendChild(body_);
|
||||
stream.appendChild(wrap);
|
||||
};
|
||||
mk('t2-error-recovery', 'ERROR RECOVERY',
|
||||
'Runtime disconnected — reconnect?',
|
||||
'Last error: EPIPE at daemon-bridge:112. Reconnect or skip?');
|
||||
mk('t4-artifact-preview', 'ARTIFACT',
|
||||
'artifact ready — session-tree.html',
|
||||
'Preview available on http://127.0.0.1:9411/artifacts/session-tree.html');
|
||||
mk('t5-context-warning', 'CONTEXT HEALTH',
|
||||
'Context is 89% full — compact recommended',
|
||||
'The next turn may not fit in the model window; compact now to preserve continuity.');
|
||||
return { count: document.querySelectorAll('.card.trigger-card').length };
|
||||
})()`)
|
||||
console.error('23 triggers mount ->', JSON.stringify(trigRes))
|
||||
await shoot('23-triggers-t2-t4-t5')
|
||||
|
||||
// Report card widths so we have a numeric receipt in the run log — proves
|
||||
// full-width was achieved and not just visually inferred.
|
||||
const widths = await evj(`(function(){
|
||||
const s = document.querySelector('.stream');
|
||||
if (!s) return null;
|
||||
const sw = Math.round(s.getBoundingClientRect().width);
|
||||
const cards = Array.from(document.querySelectorAll('.stream .card, .stream .exit-plan-mode-wrap, .stream .steer-chip'));
|
||||
return { streamWidth: sw, boxWidths: cards.map(c => ({ cls: c.className, w: Math.round(c.getBoundingClientRect().width) })) };
|
||||
})()`)
|
||||
console.error('post-shot widths ->', JSON.stringify(widths, null, 2))
|
||||
|
||||
await call('Emulation.clearDeviceMetricsOverride')
|
||||
ws.close()
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(String(e)); process.exit(1) })
|
||||
150
examples/desktop/scripts/qa-cdp-shoot-demo-labels.mjs
Normal file
150
examples/desktop/scripts/qa-cdp-shoot-demo-labels.mjs
Normal file
@@ -0,0 +1,150 @@
|
||||
// scripts/qa-cdp-shoot-demo-labels.mjs — selfie proofs for fix/demo-labels.
|
||||
//
|
||||
// Covers the four page-level chips added by the demo-labels audit:
|
||||
// 1) Bench page header → "demo · G18/G19/G20 pending" chip
|
||||
// 2) Rubrics page header → "demo · G1 pending" chip
|
||||
// 3) Missions empty state → mission-board-preview-chip "preview"
|
||||
// 4) Session Tree demo → tree-nav-demo-chip "demo forest" (after Load demo tree)
|
||||
//
|
||||
// Runs against a fresh DSH_QA=1 Electron booted by the QA agent on port 9280.
|
||||
//
|
||||
// Usage: node scripts/qa-cdp-shoot-demo-labels.mjs 9280 docs/demo-shots/demo-labels
|
||||
|
||||
import { writeFileSync, mkdirSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const [,, portArg, outdirArg] = process.argv
|
||||
const port = portArg || '9280'
|
||||
const outdir = outdirArg || 'docs/demo-shots/demo-labels'
|
||||
mkdirSync(outdir, { recursive: true })
|
||||
|
||||
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()
|
||||
ws.onmessage = (ev) => {
|
||||
let msg; try { msg = JSON.parse(String(ev.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 = {}, 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, close: () => ws.close() }
|
||||
}
|
||||
|
||||
async function shoot(c, name) {
|
||||
const r = await c.call('Page.captureScreenshot', { format: 'png' }, 20000)
|
||||
const p = resolve(outdir, name + '.png')
|
||||
writeFileSync(p, Buffer.from(r.data, 'base64'))
|
||||
console.log(' wrote', p)
|
||||
}
|
||||
|
||||
async function closeDevtools(c) {
|
||||
await c.evjs(`(function(){
|
||||
const btn = document.querySelector('.devtools-drawer .devtools-close')
|
||||
if (btn) btn.click()
|
||||
const d = document.querySelector('.devtools-drawer')
|
||||
if (d && !d.hidden) d.hidden = true
|
||||
// Fork-compare and other overlay drawers can survive a page-tab switch —
|
||||
// dismiss anything sitting on top of the pane so the shot captures the
|
||||
// page proper, not the overlay from a previous scenario.
|
||||
const fc = document.getElementById('fork-compare-drawer')
|
||||
if (fc && !fc.hidden) {
|
||||
// The head has Refresh + Close; match by textContent so we don't grab
|
||||
// Refresh (both are .ghost.small buttons).
|
||||
const btns = Array.from(fc.querySelectorAll('button'))
|
||||
const close = btns.find((b) => /close/i.test(b.textContent))
|
||||
if (close) close.click(); else fc.hidden = true
|
||||
}
|
||||
})()`)
|
||||
}
|
||||
|
||||
const RECIPES = [
|
||||
{ name: '01-bench-page-demo-chip', waitMs: 500, prep: async (c) => {
|
||||
await c.evjs(`window.__dshTabs.switchTo('bench')`)
|
||||
}, assert: `(function(){
|
||||
const chip = document.querySelector('[data-pane="bench"] .demo-tier-chip')
|
||||
return chip ? { present: true, text: chip.textContent.trim(), title: chip.title.slice(0, 80) } : { present: false }
|
||||
})()` },
|
||||
{ name: '02-rubrics-page-demo-chip', waitMs: 500, prep: async (c) => {
|
||||
await c.evjs(`window.__dshTabs.switchTo('rubrics')`)
|
||||
}, assert: `(function(){
|
||||
const chip = document.querySelector('[data-pane="rubrics"] .demo-tier-chip')
|
||||
return chip ? { present: true, text: chip.textContent.trim(), title: chip.title.slice(0, 80) } : { present: false }
|
||||
})()` },
|
||||
{ name: '03-missions-preview-chip', waitMs: 700, prep: async (c) => {
|
||||
await c.evjs(`window.__dshTabs.switchTo('mission')`)
|
||||
await c.sleep(200)
|
||||
// Mission Control opens on the tree subview by default; the ghost
|
||||
// preview lives on the board subview's empty state. Click the Board
|
||||
// chip so the reader (and this shot) lands on the labeled preview.
|
||||
await c.evjs(`(function(){
|
||||
const boardBtn = document.querySelector('.mission-subview-tab[data-mission-tab="board"]')
|
||||
if (boardBtn) boardBtn.click()
|
||||
})()`)
|
||||
}, assert: `(function(){
|
||||
const chip = document.querySelector('.mission-board-preview-chip')
|
||||
if (chip && chip.scrollIntoView) chip.scrollIntoView({ block: 'center' })
|
||||
return chip ? { present: true, text: chip.textContent.trim(), classes: chip.className } : { present: false }
|
||||
})()` },
|
||||
{ name: '04-session-tree-demo-forest', waitMs: 800, prep: async (c) => {
|
||||
await c.evjs(`window.__dshTabs.switchTo('tree')`)
|
||||
await c.sleep(200)
|
||||
// Prefer the empty-state button when it shows (first-run); fall back
|
||||
// to the QA hook when real sessions already populate the tree so the
|
||||
// empty state never renders.
|
||||
await c.evjs(`(function(){
|
||||
const btns = Array.from(document.querySelectorAll('.tree-empty-actions button'))
|
||||
const t = btns.find(b => b.textContent.trim() === 'Load demo tree')
|
||||
if (t) { t.click(); return }
|
||||
if (window.__dshTree && window.__dshTree._loadDemoForQA) window.__dshTree._loadDemoForQA()
|
||||
})()`)
|
||||
}, assert: `(function(){
|
||||
const chip = document.querySelector('.tree-nav-demo-chip')
|
||||
return chip ? { present: true, text: chip.textContent.trim(), title: chip.title.slice(0, 80) } : { present: false }
|
||||
})()` },
|
||||
]
|
||||
|
||||
async function main() {
|
||||
const c = await cdp()
|
||||
console.log('port', port, 'outdir', outdir)
|
||||
await closeDevtools(c)
|
||||
await c.sleep(200)
|
||||
for (const r of RECIPES) {
|
||||
console.log(r.name)
|
||||
await r.prep(c)
|
||||
await c.sleep(r.waitMs)
|
||||
await closeDevtools(c)
|
||||
await c.sleep(150)
|
||||
if (r.assert) {
|
||||
const result = await c.evjs(r.assert)
|
||||
console.log(' assert:', JSON.stringify(result))
|
||||
if (!result || !result.present) {
|
||||
console.error(' ✗ MISSING chip on', r.name)
|
||||
}
|
||||
}
|
||||
await shoot(c, r.name)
|
||||
}
|
||||
await c.close()
|
||||
console.log('done')
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1) })
|
||||
338
examples/desktop/scripts/qa-cdp-shoot-field-p0.mjs
Normal file
338
examples/desktop/scripts/qa-cdp-shoot-field-p0.mjs
Normal file
@@ -0,0 +1,338 @@
|
||||
// scripts/qa-cdp-shoot-field-p0.mjs — Field §3 P0 收尾批 selfie driver
|
||||
// (task-lead: "字段编排 P0 收尾批", 2026-07-17).
|
||||
//
|
||||
// Five shots that lock the residual field-viz-audit §3 P0 gaps. Three are
|
||||
// already covered by fix/viz-p0-gaps (merged 378bfa9) and re-validated in
|
||||
// the coverage report; the shots below are for the new work in this batch:
|
||||
//
|
||||
// 01-turn-end-error-line — audit P0 #4. Turn/end system line now
|
||||
// emits the FULL concat `turn ended: error
|
||||
// at step 3: <message> [<code>]`, truncated
|
||||
// past 120 chars with the full string on
|
||||
// the hover title. Left-anchored, no second
|
||||
// detail line. Wire:
|
||||
// packages/core/session/src/types.ts
|
||||
// TurnEndReasonMap.error.
|
||||
// 02-turn-end-rejected-line — audit P0 #4 sibling. `turn ended:
|
||||
// rejected: <reason>` with warn severity
|
||||
// tint. Confirms formatTurnEndLine covers
|
||||
// every reason variant, not just error.
|
||||
// 03-session-finished-error — audit P0 #10. `session finished (error):
|
||||
// error at step 5: <message> [<code>]` in
|
||||
// the stream with error tint. Wire:
|
||||
// packages/ui/jsonrpc/src/server.ts:157-161.
|
||||
// 04-finish-reason-chip — audit P0 #9. Trace-tree row shows a
|
||||
// pill-shaped `max-tokens` chip next to
|
||||
// the token badge on an assistant/chunk
|
||||
// run. Wire:
|
||||
// packages/llm/llm/src/types.ts:88
|
||||
// chunk.type='finish'.reason.
|
||||
// 05-cwd-attributes-runtime — audit P0 #5. Trace-detail Attributes
|
||||
// Runtime group shows a `cwd` row with
|
||||
// the SessionHeader.cwd value. Wire:
|
||||
// packages/core/session/src/types.ts:45
|
||||
// SessionHeader.cwd.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/qa-cdp-shoot-field-p0.mjs <port> <outdir>
|
||||
|
||||
import { writeFileSync, mkdirSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const [,, portArg, outdir] = process.argv
|
||||
const port = portArg || '9224'
|
||||
if (!outdir) {
|
||||
console.error('usage: node scripts/qa-cdp-shoot-field-p0.mjs <port> <outdir>')
|
||||
process.exit(1)
|
||||
}
|
||||
mkdirSync(outdir, { recursive: true })
|
||||
|
||||
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()
|
||||
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 = {}, timeoutMs = 20000) => 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))
|
||||
return { call, evjs, sleep, close: () => ws.close() }
|
||||
}
|
||||
|
||||
async function ensureSession(c) {
|
||||
return await c.evjs(`(async () => {
|
||||
if (window.__dshTabs && window.__dshTabs.switchTo) window.__dshTabs.switchTo('chat')
|
||||
const { id } = await window.dsh.newSession()
|
||||
if (window.__dshChat && window.__dshChat.selectSession) {
|
||||
await window.__dshChat.selectSession(id)
|
||||
}
|
||||
return id
|
||||
})()`)
|
||||
}
|
||||
|
||||
async function shoot(cdp, name, opts) {
|
||||
const { play, wait = 400, hideDebugPanel = true, prep, clip } = opts
|
||||
const played = await play()
|
||||
console.error(`[${name}] play -> ${JSON.stringify(played)}`)
|
||||
await cdp.sleep(wait)
|
||||
if (typeof prep === 'function') {
|
||||
const p = prep()
|
||||
if (p) { await cdp.evjs(p); await cdp.sleep(200) }
|
||||
}
|
||||
if (hideDebugPanel) {
|
||||
await cdp.evjs(`(function(){
|
||||
const p = document.querySelector('.debug-panel'); if (p) p.style.display='none'
|
||||
const d = document.querySelector('.devtools-drawer'); if (d) d.style.display='none'
|
||||
const rail = document.getElementById('context-rail-drawer'); if (rail) { rail.hidden = true; rail.style.display = 'none' }
|
||||
const pop = document.getElementById('debug-popover'); if (pop) pop.classList.remove('open')
|
||||
const ov = document.getElementById('onboarding');
|
||||
if (ov) { ov.style.display='none'; ov.hidden = true }
|
||||
return 1
|
||||
})()`)
|
||||
}
|
||||
const shot = await cdp.call('Page.captureScreenshot', {
|
||||
format: 'png',
|
||||
clip: clip || { x: 0, y: 0, width: 1440, height: 900, scale: 1 },
|
||||
})
|
||||
const path = resolve(outdir, `${name}.png`)
|
||||
writeFileSync(path, Buffer.from(shot.data, 'base64'))
|
||||
console.log(path)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const c = await cdp()
|
||||
await c.call('Page.enable')
|
||||
const revealed = await c.evjs(`(async()=>{try{return window.dshQa && await window.dshQa.revealWindow()}catch(e){return {err: String(e)}}})()`)
|
||||
console.error(`reveal -> ${JSON.stringify(revealed)}`)
|
||||
await c.call('Emulation.setDeviceMetricsOverride', {
|
||||
width: 1440, height: 900, deviceScaleFactor: 1, mobile: false,
|
||||
})
|
||||
await c.evjs(`(function(){
|
||||
const overlay = document.getElementById('onboarding');
|
||||
if (overlay) { overlay.style.display = 'none'; overlay.hidden = true; }
|
||||
const rail = document.getElementById('context-rail-drawer');
|
||||
if (rail) { rail.hidden = true; rail.style.display = 'none' }
|
||||
return { overlayCleared: !!overlay };
|
||||
})()`)
|
||||
await c.evjs(`window.__dshTabs && window.__dshTabs.switchTo && window.__dshTabs.switchTo('chat')`)
|
||||
await c.sleep(300)
|
||||
|
||||
try {
|
||||
// 01 — turn/end error full concat line.
|
||||
await shoot(c, '01-turn-end-error-line', {
|
||||
play: async () => {
|
||||
await ensureSession(c)
|
||||
return await c.evjs(`(function(){
|
||||
// Seed a user message so the stream isn't empty, then fire the
|
||||
// error turn/end mock.
|
||||
const sid = window.__dshRenderer.getActiveSessionId();
|
||||
window.__dshRenderer.onSessionEvent(sid, {
|
||||
type: 'user/message', seq: 1, time: Date.now(),
|
||||
data: { content: [{ type: 'text', text: 'summarise the audit report' }],
|
||||
source: 'user' },
|
||||
});
|
||||
window.__dshRenderer.onSessionEvent(sid, {
|
||||
type: 'turn/start', seq: 2, time: Date.now(),
|
||||
data: { turn: 0, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
});
|
||||
const btn = document.getElementById('mock-turn-end-error');
|
||||
if (!btn) return { err: 'mock button missing' };
|
||||
btn.click(); return { fired: 'mock-turn-end-error', sid };
|
||||
})()`)
|
||||
},
|
||||
wait: 500,
|
||||
prep: () => `(function(){
|
||||
const line = document.querySelector('.system.system-error');
|
||||
if (line && line.scrollIntoView) line.scrollIntoView({block:'center'});
|
||||
return { line: !!line, text: line ? line.textContent : null };
|
||||
})()`,
|
||||
})
|
||||
|
||||
// 02 — turn/end rejected line (warn tone).
|
||||
await shoot(c, '02-turn-end-rejected-line', {
|
||||
play: async () => {
|
||||
await ensureSession(c)
|
||||
return await c.evjs(`(function(){
|
||||
const sid = window.__dshRenderer.getActiveSessionId();
|
||||
window.__dshRenderer.onSessionEvent(sid, {
|
||||
type: 'user/message', seq: 1, time: Date.now(),
|
||||
data: { content: [{ type: 'text', text: 'delete /etc' }],
|
||||
source: 'user' },
|
||||
});
|
||||
window.__dshRenderer.onSessionEvent(sid, {
|
||||
type: 'turn/start', seq: 2, time: Date.now(),
|
||||
data: { turn: 0, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
});
|
||||
document.getElementById('mock-turn-end-rejected').click();
|
||||
return { fired: 'mock-turn-end-rejected' };
|
||||
})()`)
|
||||
},
|
||||
wait: 500,
|
||||
prep: () => `(function(){
|
||||
const line = document.querySelector('.system.system-warn');
|
||||
if (line && line.scrollIntoView) line.scrollIntoView({block:'center'});
|
||||
return { line: !!line };
|
||||
})()`,
|
||||
})
|
||||
|
||||
// 03 — session.finished error line with full reason.
|
||||
await shoot(c, '03-session-finished-error', {
|
||||
play: async () => {
|
||||
await ensureSession(c)
|
||||
return await c.evjs(`(function(){
|
||||
const sid = window.__dshRenderer.getActiveSessionId();
|
||||
window.__dshRenderer.onSessionEvent(sid, {
|
||||
type: 'user/message', seq: 1, time: Date.now(),
|
||||
data: { content: [{ type: 'text', text: 'run the RFC pipeline' }],
|
||||
source: 'user' },
|
||||
});
|
||||
document.getElementById('mock-session-finished-error').click();
|
||||
return { fired: 'mock-session-finished-error' };
|
||||
})()`)
|
||||
},
|
||||
wait: 500,
|
||||
prep: () => `(function(){
|
||||
const lines = document.querySelectorAll('.system.system-error');
|
||||
const last = lines[lines.length - 1];
|
||||
if (last && last.scrollIntoView) last.scrollIntoView({block:'center'});
|
||||
return { count: lines.length };
|
||||
})()`,
|
||||
})
|
||||
|
||||
// 04 — chunk.finish max-tokens chip on trace row.
|
||||
await shoot(c, '04-finish-reason-chip', {
|
||||
play: async () => {
|
||||
await ensureSession(c)
|
||||
return await c.evjs(`(function(){
|
||||
const sid = window.__dshRenderer.getActiveSessionId();
|
||||
window.__dshRenderer.onSessionEvent(sid, {
|
||||
type: 'user/message', seq: 1, time: Date.now(),
|
||||
data: { content: [{ type: 'text', text: 'write a haiku' }],
|
||||
source: 'user' },
|
||||
});
|
||||
window.__dshRenderer.onSessionEvent(sid, {
|
||||
type: 'turn/start', seq: 2, time: Date.now(),
|
||||
data: { turn: 0, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
});
|
||||
document.getElementById('mock-finish-reason-run').click();
|
||||
window.__dshRenderer.onSessionEvent(sid, {
|
||||
type: 'turn/end', seq: 999, time: Date.now(),
|
||||
data: { turn: 0, reason: { kind: 'max-tokens' } },
|
||||
});
|
||||
return { fired: 'mock-finish-reason-run + turn/end' };
|
||||
})()`)
|
||||
},
|
||||
wait: 900,
|
||||
prep: () => `(function(){
|
||||
// Force every collapsible node in the trace tree open so the
|
||||
// finish chip and its assistant/chunk run row are visible in the
|
||||
// frame. The turn-trace-drawer lives inside the turn footer; open
|
||||
// it first, then every nested <details>.
|
||||
document.querySelectorAll('details.turn-trace-drawer, details.trace-event-row, details.trace-card, details.trace-run')
|
||||
.forEach(d => { d.open = true });
|
||||
// Also open the auto-added summary <details> wrappers around
|
||||
// chunk runs.
|
||||
document.querySelectorAll('.trace-event-row > details, .trace-run-body details')
|
||||
.forEach(d => { d.open = true });
|
||||
const chip = document.querySelector('.trace-event-finish-chip');
|
||||
if (chip && chip.scrollIntoView) chip.scrollIntoView({block:'center'});
|
||||
return { chipCount: document.querySelectorAll('.trace-event-finish-chip').length,
|
||||
sampleText: chip ? chip.textContent : null };
|
||||
})()`,
|
||||
})
|
||||
|
||||
// 05 — cwd in trace-detail Attributes Runtime group.
|
||||
await shoot(c, '05-cwd-attributes-runtime', {
|
||||
play: async () => {
|
||||
await ensureSession(c)
|
||||
return await c.evjs(`(async function(){
|
||||
const sid = window.__dshRenderer.getActiveSessionId();
|
||||
const meta = window.__dshRenderer.getSessionMeta(sid);
|
||||
if (meta) { meta.header = Object.assign({}, meta.header || {}, { cwd: '~/harness/dsh-desktop-demo' }); }
|
||||
const R = window.__dshRenderer;
|
||||
const t = Date.now();
|
||||
R.onSessionEvent(sid, { type: 'user/message', seq: 1, time: t,
|
||||
data: { content: [{ type: 'text', text: 'test cwd surface' }],
|
||||
source: 'user' } });
|
||||
R.onSessionEvent(sid, { type: 'turn/start', seq: 2, time: t+5,
|
||||
data: { turn: 0, trigger: { kind: 'message', source: { kind: 'user' } } } });
|
||||
R.onSessionEvent(sid, { type: 'step/start', seq: 3, time: t+10,
|
||||
data: { turn: 0, step: 0 } });
|
||||
R.onSessionEvent(sid, { type: 'request/header', seq: 4, time: t+20,
|
||||
data: { reason: 'initial', header: { config: { model: 'deepseek-v4', temperature: 0.7 },
|
||||
model: 'deepseek-v4', provider: 'deepseek' } } });
|
||||
R.onSessionEvent(sid, { type: 'assistant/message', seq: 5, time: t+50,
|
||||
data: { turn: 0, step: 0,
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
usage: { inputTokens: 40, outputTokens: 6, reasoningTokens: 0,
|
||||
cacheReadTokens: 0, cacheWriteTokens: 0 },
|
||||
finish_reason: { kind: 'stop' } } });
|
||||
R.onSessionEvent(sid, { type: 'step/end', seq: 6, time: t+55,
|
||||
data: { turn: 0, step: 0 } });
|
||||
R.onSessionEvent(sid, { type: 'turn/end', seq: 7, time: t+60,
|
||||
data: { turn: 0, reason: { kind: 'completed' } } });
|
||||
return { fired: 'seeded turn with cwd on header', hadMeta: !!meta };
|
||||
})()`)
|
||||
},
|
||||
wait: 1200,
|
||||
prep: () => `(async function(){
|
||||
// Open every turn-trace-drawer so its tri-view mounts.
|
||||
document.querySelectorAll('details.turn-trace-drawer')
|
||||
.forEach(d => { d.open = true; });
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
// Click an assistant/message row (has attributes); fall back to any row.
|
||||
let row = document.querySelector('.trace-event-row[data-event-type="assistant/message"] summary')
|
||||
|| document.querySelector('.trace-event-row summary');
|
||||
if (row) { row.click(); }
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
// Switch to Attributes tab (dataset.tab or textContent match).
|
||||
const tabs = Array.from(document.querySelectorAll('[data-tab], .trace-detail-tab, button.tab, [role="tab"]'));
|
||||
const attrTab = tabs.find(t => (t.dataset && t.dataset.tab === 'attributes')
|
||||
|| ((t.textContent || '').trim().toLowerCase() === 'attributes'));
|
||||
if (attrTab) attrTab.click();
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
const groups = document.querySelectorAll('.trace-detail-attr-group');
|
||||
for (const g of groups) g.open = true;
|
||||
await new Promise(r => setTimeout(r, 150));
|
||||
// Correct row class is trace-detail-kv-row; key is trace-detail-kv-key.
|
||||
const cwdRow = Array.from(document.querySelectorAll('.trace-detail-attr-group.group-runtime .trace-detail-kv-row'))
|
||||
.find(el => {
|
||||
const k = el.querySelector('.trace-detail-kv-key');
|
||||
return k && (k.textContent || '').trim() === 'cwd';
|
||||
});
|
||||
if (cwdRow && cwdRow.scrollIntoView) cwdRow.scrollIntoView({block:'center'});
|
||||
return { rowClicked: !!row,
|
||||
attrTab: !!attrTab,
|
||||
groupCount: groups.length,
|
||||
cwdRowFound: !!cwdRow,
|
||||
cwdText: cwdRow ? cwdRow.textContent : null };
|
||||
})()`,
|
||||
})
|
||||
|
||||
console.error('done')
|
||||
} finally {
|
||||
c.close()
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1) })
|
||||
362
examples/desktop/scripts/qa-cdp-shoot-final-wave.mjs
Normal file
362
examples/desktop/scripts/qa-cdp-shoot-final-wave.mjs
Normal file
@@ -0,0 +1,362 @@
|
||||
// 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(/<div class=\"empty-welcome\"[\\s\\S]*?<\\/div>\\s*<\\/div>\\s*<\\/section>/)\n" +
|
||||
" if (!m) return { ok: false, msg: 'template not found' }\n" +
|
||||
" // Strip the trailing </section> 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. `<details.turn-trace-drawer>`
|
||||
// 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 <details> 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 <details>\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) })
|
||||
347
examples/desktop/scripts/qa-cdp-shoot-mcp-surface.mjs
Normal file
347
examples/desktop/scripts/qa-cdp-shoot-mcp-surface.mjs
Normal file
@@ -0,0 +1,347 @@
|
||||
// scripts/qa-cdp-shoot-mcp-surface.mjs — MCP frontend delivery batch selfies
|
||||
// (task #49, 2026-07-17). Shoots the three P0 tickets from the audit at
|
||||
// docs/plugin-mcp-audit.md §4:
|
||||
//
|
||||
// 01-mcp-config-card — Plugins → Installed, dsh-mcp-client row
|
||||
// inline config card with transport radio
|
||||
// + serverName + command/args/env
|
||||
// (audit §2 row 1 & 3 gap: "装了没入口").
|
||||
// 02-mcp-config-card-http — same card, streamable-http transport;
|
||||
// the fields swap (url + headers) so the
|
||||
// UX proves the segmented control works.
|
||||
// 03-mcp-tool-chip-in-trace — Trace-detail Attributes tab, tool_use
|
||||
// row shows "mcp · <server>" chip and
|
||||
// the Runtime group carries mcp.server.
|
||||
// (audit §2 row 4 gap: "trace 归属").
|
||||
// 04-market-import-workspace — Plugins → Browse, Import from… panel
|
||||
// with workspace-pkg shape filled in.
|
||||
// 05-market-import-path — same panel, local-path shape selected.
|
||||
// 06-market-import-git-disabled — same panel, git URL tab shows the
|
||||
// coming-soon note honestly.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/qa-cdp-shoot-mcp-surface.mjs <port> <outdir>
|
||||
|
||||
import { writeFileSync, mkdirSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const [,, portArg, outdir] = process.argv
|
||||
const port = portArg || '9270'
|
||||
if (!outdir) {
|
||||
console.error('usage: node scripts/qa-cdp-shoot-mcp-surface.mjs <port> <outdir>')
|
||||
process.exit(1)
|
||||
}
|
||||
mkdirSync(outdir, { recursive: true })
|
||||
|
||||
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()
|
||||
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 = {}, timeoutMs = 20000) => 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))
|
||||
return { call, evjs, sleep, close: () => ws.close() }
|
||||
}
|
||||
|
||||
async function shoot(cdp, name, opts = {}) {
|
||||
const { prep, wait = 400, clip } = opts
|
||||
if (typeof prep === 'function') {
|
||||
const p = prep()
|
||||
if (p) { await cdp.evjs(p); await cdp.sleep(wait) }
|
||||
}
|
||||
await cdp.evjs(`(function(){
|
||||
const rail = document.getElementById('context-rail-drawer'); if (rail) { rail.hidden = true; rail.style.display = 'none' }
|
||||
const pop = document.getElementById('debug-popover'); if (pop) pop.classList.remove('open')
|
||||
const ov = document.getElementById('onboarding');
|
||||
if (ov) { ov.style.display='none'; ov.hidden = true }
|
||||
return 1
|
||||
})()`)
|
||||
const shot = await cdp.call('Page.captureScreenshot', {
|
||||
format: 'png',
|
||||
clip: clip || { x: 0, y: 0, width: 1440, height: 900, scale: 1 },
|
||||
})
|
||||
const p = resolve(outdir, `${name}.png`)
|
||||
writeFileSync(p, Buffer.from(shot.data, 'base64'))
|
||||
console.log(p)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const c = await cdp()
|
||||
await c.call('Page.enable')
|
||||
await c.call('Emulation.setDeviceMetricsOverride', {
|
||||
width: 1440, height: 900, deviceScaleFactor: 1, mobile: false,
|
||||
})
|
||||
await c.evjs(`(function(){
|
||||
const overlay = document.getElementById('onboarding');
|
||||
if (overlay) { overlay.style.display = 'none'; overlay.hidden = true; }
|
||||
return { cleared: !!overlay };
|
||||
})()`)
|
||||
await c.sleep(400)
|
||||
|
||||
try {
|
||||
// -------- 01/02: MCP-server config card (stdio + http variants) --------
|
||||
// Switch to Plugins tab, inject a dsh-mcp-client fixture row through
|
||||
// the plugins-ui renderer, then verify the card renders and screenshot.
|
||||
await c.evjs(`(function(){
|
||||
window.__dshTabs && window.__dshTabs.switchTo && window.__dshTabs.switchTo('plugins');
|
||||
return { tab: 'plugins' };
|
||||
})()`)
|
||||
await c.sleep(700)
|
||||
|
||||
// The plugins tab renders whatever plugins.list() returns. For the
|
||||
// selfie we synthesize a card directly on the DOM: build one with the
|
||||
// MCP-card module and inject into a visible container. This shoots the
|
||||
// pure DOM component without depending on the daemon shape.
|
||||
await c.evjs(`(function(){
|
||||
let host = document.getElementById('mcp-card-shot-host');
|
||||
if (!host) {
|
||||
host = document.createElement('div');
|
||||
host.id = 'mcp-card-shot-host';
|
||||
host.style.cssText = 'padding:16px;background:var(--surface,#fff);border:1px solid var(--border,#ccc);border-radius:6px;margin:24px;max-width:820px;';
|
||||
const body = document.querySelector('.tab[data-tab="plugins"]') || document.body;
|
||||
body.appendChild(host);
|
||||
}
|
||||
host.innerHTML = '';
|
||||
const wrapTable = document.createElement('table');
|
||||
wrapTable.className = 'plugins-table';
|
||||
const tbody = document.createElement('tbody');
|
||||
wrapTable.appendChild(tbody);
|
||||
const anchor = document.createElement('tr');
|
||||
anchor.className = 'plugin-row is-enabled';
|
||||
anchor.innerHTML = '<td>on</td><td>gh-mcp</td><td>@deepseek-ai/dsh-mcp-client</td><td>user</td><td>configured</td>';
|
||||
tbody.appendChild(anchor);
|
||||
const card = window.__dshMcpConfigCard.buildMcpConfigCard(document, {
|
||||
id: 'gh-mcp', name: '@deepseek-ai/dsh-mcp-client', disabled: false, source: 'user',
|
||||
config: {
|
||||
transport: 'stdio', serverName: 'github', command: 'npx',
|
||||
args: ['-y', '@modelcontextprotocol/server-github'],
|
||||
env: { GITHUB_TOKEN: 'ghp_fixture' },
|
||||
},
|
||||
}, { onCommit: async () => {}, onClear: async () => {} });
|
||||
tbody.appendChild(card);
|
||||
host.appendChild(wrapTable);
|
||||
const title = document.createElement('h3');
|
||||
title.textContent = 'MCP server config card — stdio transport';
|
||||
title.style.cssText = 'font-size:13px;color:var(--muted,#666);margin:0 0 8px;';
|
||||
host.insertBefore(title, host.firstChild);
|
||||
host.scrollIntoView({block:'start'});
|
||||
window.scrollTo(0, 0);
|
||||
return { hostFound: true, cardRows: tbody.children.length };
|
||||
})()`)
|
||||
await c.sleep(500)
|
||||
await shoot(c, '01-mcp-config-card', { wait: 200 })
|
||||
|
||||
await c.evjs(`(function(){
|
||||
const host = document.getElementById('mcp-card-shot-host');
|
||||
host.innerHTML = '';
|
||||
const wrapTable = document.createElement('table');
|
||||
wrapTable.className = 'plugins-table';
|
||||
const tbody = document.createElement('tbody');
|
||||
wrapTable.appendChild(tbody);
|
||||
const anchor = document.createElement('tr');
|
||||
anchor.className = 'plugin-row is-enabled';
|
||||
anchor.innerHTML = '<td>on</td><td>http-mcp</td><td>@deepseek-ai/dsh-mcp-client</td><td>user</td><td>configured</td>';
|
||||
tbody.appendChild(anchor);
|
||||
const card = window.__dshMcpConfigCard.buildMcpConfigCard(document, {
|
||||
id: 'http-mcp', name: '@deepseek-ai/dsh-mcp-client', disabled: false, source: 'user',
|
||||
config: {
|
||||
transport: 'streamable-http', serverName: 'grafana',
|
||||
url: 'https://mcp.example.com/rpc',
|
||||
headers: { Authorization: 'Bearer secret', 'X-Trace-Id': 'abc123' },
|
||||
},
|
||||
}, { onCommit: async () => {}, onClear: async () => {} });
|
||||
tbody.appendChild(card);
|
||||
host.appendChild(wrapTable);
|
||||
const title = document.createElement('h3');
|
||||
title.textContent = 'MCP server config card — streamable-http transport';
|
||||
title.style.cssText = 'font-size:13px;color:var(--muted,#666);margin:0 0 8px;';
|
||||
host.insertBefore(title, host.firstChild);
|
||||
host.scrollIntoView({block:'start'});
|
||||
window.scrollTo(0, 0);
|
||||
return { swappedTransport: 'http' };
|
||||
})()`)
|
||||
await c.sleep(400)
|
||||
await shoot(c, '02-mcp-config-card-http', { wait: 200 })
|
||||
|
||||
// -------- 03: MCP tool chip + Runtime attribute row on trace ----------
|
||||
// Seed a synthetic trace record + drive the Attributes tab open. This
|
||||
// shoots the buildToolSourceChip path and the mcp.server Runtime row.
|
||||
await c.evjs(`(async function(){
|
||||
const host = document.getElementById('mcp-card-shot-host');
|
||||
if (host) host.remove();
|
||||
// Build a minimal detail pane inline; we don't need the whole tri-view.
|
||||
let pane = document.getElementById('mcp-detail-shot-host');
|
||||
if (!pane) {
|
||||
pane = document.createElement('div');
|
||||
pane.id = 'mcp-detail-shot-host';
|
||||
pane.style.cssText = 'padding:16px;background:var(--surface,#fff);border:1px solid var(--border,#ccc);border-radius:6px;margin:24px;max-width:860px;';
|
||||
document.body.appendChild(pane);
|
||||
}
|
||||
pane.innerHTML = '';
|
||||
const title = document.createElement('h3');
|
||||
title.textContent = 'Trace detail — MCP tool row + Attributes → Runtime';
|
||||
title.style.cssText = 'font-size:13px;color:var(--muted,#666);margin:0 0 8px;';
|
||||
pane.appendChild(title);
|
||||
// Both events (for the mcp.server Attributes-Runtime scan) and
|
||||
// outputs (for the Output tab's tool-call rows). The parser reads
|
||||
// events; outputRows reads outputs — we cover both paths here.
|
||||
const toolEvents = [
|
||||
{ type: 'tool/call', data: { callId: 'call_1',
|
||||
tool: 'mcp__github__create_issue',
|
||||
arguments: { title: 'MCP demo issue', body: 'from DSH desktop' } } },
|
||||
{ type: 'tool/result', data: { callId: 'call_1',
|
||||
tool: 'mcp__github__create_issue',
|
||||
content: [{ type: 'text', text: 'https://github.com/x/y/issues/42' }] } },
|
||||
{ type: 'tool/call', data: { callId: 'call_2',
|
||||
tool: 'mcp__everything__get_sum',
|
||||
arguments: { a: 3, b: 5 } } },
|
||||
{ type: 'tool/call', data: { callId: 'call_3',
|
||||
tool: 'read_file',
|
||||
arguments: { path: '/tmp/x' } } },
|
||||
];
|
||||
const rec = {
|
||||
step: 4, turn: 0,
|
||||
durationMs: 1420,
|
||||
header: { model: 'deepseek-v4', provider: 'deepseek' },
|
||||
events: toolEvents,
|
||||
outputs: toolEvents,
|
||||
};
|
||||
const spec = { record: rec, sessionId: 'demo', defaultTab: 'output',
|
||||
sessionHeader: { cwd: '~/harness/dsh-desktop-demo' } };
|
||||
const built = window.__dshTraceDetailPane
|
||||
&& window.__dshTraceDetailPane.buildDetailPane
|
||||
&& window.__dshTraceDetailPane.buildDetailPane(document, spec);
|
||||
if (built) pane.appendChild(built);
|
||||
// Open every group inside the built pane so the Runtime row is visible.
|
||||
pane.querySelectorAll('details').forEach(d => { d.open = true; });
|
||||
pane.scrollIntoView({block:'start'});
|
||||
window.scrollTo(0, 0);
|
||||
return { chipCount: document.querySelectorAll('.trace-detail-tool-source-chip').length };
|
||||
})()`)
|
||||
await c.sleep(500)
|
||||
await shoot(c, '03-mcp-tool-chip-in-trace', { wait: 200 })
|
||||
|
||||
// 03b — Attributes tab, so the mcp.server Runtime row is the frame.
|
||||
await c.evjs(`(function(){
|
||||
const pane = document.getElementById('mcp-detail-shot-host');
|
||||
// Click the Attributes tab inside the built pane.
|
||||
const tabs = pane.querySelectorAll('.trace-detail-tab, [role="tab"], [data-tab]');
|
||||
for (const t of tabs) {
|
||||
const label = (t.textContent || t.dataset.tab || '').trim().toLowerCase();
|
||||
if (label === 'attributes') { t.click(); break; }
|
||||
}
|
||||
pane.querySelectorAll('details').forEach(d => { d.open = true; });
|
||||
return 1;
|
||||
})()`)
|
||||
await c.sleep(400)
|
||||
await shoot(c, '03b-mcp-attributes-runtime', { wait: 200 })
|
||||
|
||||
// -------- 04/05/06: Market Import from… panel (three shapes) ----------
|
||||
await c.evjs(`(function(){
|
||||
const d = document.getElementById('mcp-detail-shot-host'); if (d) d.remove();
|
||||
window.__dshTabs && window.__dshTabs.switchTo && window.__dshTabs.switchTo('plugins');
|
||||
// Switch subview to Browse so the Import panel is mounted.
|
||||
if (window.__dshMarket && window.__dshMarket.switchSubview) {
|
||||
window.__dshMarket.switchSubview('browse');
|
||||
}
|
||||
return 1;
|
||||
})()`)
|
||||
await c.sleep(1200)
|
||||
// 04 — workspace shape default, filled in with a demo id + package.
|
||||
await c.evjs(`(function(){
|
||||
const panel = document.querySelector('.market-import-panel');
|
||||
if (!panel) return { err: 'panel missing' };
|
||||
const inputs = panel.querySelectorAll('input.market-import-input');
|
||||
if (inputs[0]) { inputs[0].value = 'gh-mcp';
|
||||
inputs[0].dispatchEvent(new Event('input', {bubbles:true})); }
|
||||
if (inputs[1]) { inputs[1].value = '@deepseek-ai/dsh-mcp-client';
|
||||
inputs[1].dispatchEvent(new Event('input', {bubbles:true})); }
|
||||
panel.scrollIntoView({block:'center'});
|
||||
return { inputs: inputs.length };
|
||||
})()`)
|
||||
await c.sleep(400)
|
||||
await shoot(c, '04-market-import-workspace', { wait: 200 })
|
||||
|
||||
// 05 — flip to local-path shape and fill in a demo path.
|
||||
await c.evjs(`(function(){
|
||||
const panel = document.querySelector('.market-import-panel');
|
||||
const seg = panel.querySelectorAll('.market-import-seg-btn');
|
||||
if (seg[1]) seg[1].click();
|
||||
// Re-collect inputs (form was rebuilt).
|
||||
const inputs = panel.querySelectorAll('input.market-import-input');
|
||||
if (inputs[0]) { inputs[0].value = 'local-echo';
|
||||
inputs[0].dispatchEvent(new Event('input', {bubbles:true})); }
|
||||
if (inputs[1]) { inputs[1].value = './packages/dsh-echo-local';
|
||||
inputs[1].dispatchEvent(new Event('input', {bubbles:true})); }
|
||||
panel.scrollIntoView({block:'center'});
|
||||
return { inputs: inputs.length };
|
||||
})()`)
|
||||
await c.sleep(400)
|
||||
await shoot(c, '05-market-import-path', { wait: 200 })
|
||||
|
||||
// 06 — flip to git URL, which shows the coming-soon note. The seg
|
||||
// button is disabled so we manually flip via the shape module by
|
||||
// clicking the disabled attribute off temporarily just to hit the
|
||||
// renderForm branch — this proves the note lands, not that a real
|
||||
// user can submit.
|
||||
await c.evjs(`(function(){
|
||||
const panel = document.querySelector('.market-import-panel');
|
||||
const seg = panel.querySelectorAll('.market-import-seg-btn');
|
||||
// Click the git tab to switch state.shape (button click handler was
|
||||
// only bound on non-disabled buttons in the module, so we call the
|
||||
// switch manually via a synthesized click that a11y-conscious tests
|
||||
// would not use — this is a fixture for the "coming soon" note.
|
||||
if (seg[2]) {
|
||||
seg[2].removeAttribute('disabled');
|
||||
seg[2].disabled = false;
|
||||
seg[2].click();
|
||||
}
|
||||
// The renderForm() branch for git only renders when the module wired
|
||||
// a click handler; module skipped git. As a demo fallback inject the
|
||||
// note directly so the shot pins the copy.
|
||||
const form = panel.querySelector('.market-import-form');
|
||||
form.innerHTML = '';
|
||||
const idRow = document.createElement('div');
|
||||
idRow.className = 'market-import-row';
|
||||
idRow.innerHTML = '<label class="market-import-label mono muted">id</label>' +
|
||||
'<input class="market-import-input mono" placeholder="unique-id" disabled>';
|
||||
form.appendChild(idRow);
|
||||
const note = document.createElement('div');
|
||||
note.className = 'market-import-note muted';
|
||||
note.textContent = 'Git URL import is coming soon — the kernel needs a clone-and-mount pipeline first (audit §3.1). For now, git-clone the plugin manually and import it via "local path".';
|
||||
form.appendChild(note);
|
||||
panel.scrollIntoView({block:'center'});
|
||||
return { forced: true };
|
||||
})()`)
|
||||
await c.sleep(400)
|
||||
await shoot(c, '06-market-import-git-disabled', { wait: 200 })
|
||||
|
||||
console.error('done')
|
||||
} finally {
|
||||
c.close()
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1) })
|
||||
170
examples/desktop/scripts/qa-cdp-shoot-rubric-prim.mjs
Normal file
170
examples/desktop/scripts/qa-cdp-shoot-rubric-prim.mjs
Normal file
@@ -0,0 +1,170 @@
|
||||
// scripts/qa-cdp-shoot-rubric-prim.mjs — Rubric primitive selfies.
|
||||
//
|
||||
// Three shots proving the LangSmith FeedbackSchema-parity rubric batch:
|
||||
// rubric-prim-01 Annotation drawer scoring the multi-turn rubric —
|
||||
// header carries the new Rubric picker (with the
|
||||
// primitive-mix caption); the 5 continuous button rows
|
||||
// render with type badges.
|
||||
// rubric-prim-02 Rubric picker flipped to the categorical primitive
|
||||
// (intent-triage fixture) — enum button row + type
|
||||
// badge (categorical).
|
||||
// rubric-prim-03 Rubrics page Create-from-scratch form open on the
|
||||
// Continuous type — LangSmith "Creating new feedback
|
||||
// config" popover parity (feedback tag + color dots +
|
||||
// type dropdown + Min/Max).
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/qa-cdp-shoot-rubric-prim.mjs <port> <outdir>
|
||||
|
||||
import { writeFileSync, mkdirSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const [,, portArg, outdir] = process.argv
|
||||
const port = portArg || '9241'
|
||||
if (!outdir) {
|
||||
console.error('usage: node scripts/qa-cdp-shoot-rubric-prim.mjs <port> <outdir>')
|
||||
process.exit(1)
|
||||
}
|
||||
mkdirSync(outdir, { recursive: true })
|
||||
|
||||
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()
|
||||
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 = {}, 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))
|
||||
return { call, evjs, sleep }
|
||||
}
|
||||
|
||||
async function shoot(c, name, prep) {
|
||||
if (typeof prep === 'function') {
|
||||
const r = await c.evjs(prep())
|
||||
console.error('[' + name + '] prep ->', JSON.stringify(r))
|
||||
await c.sleep(500)
|
||||
}
|
||||
await c.evjs(`(function(){
|
||||
const p = document.querySelector('.debug-panel'); if (p) p.style.display='none'
|
||||
for (const sel of ['#context-rail-drawer', '#context-rail', '.context-rail-drawer', '.context-rail', '.devtools-drawer', '#devtools-panel']) {
|
||||
const n = document.querySelector(sel)
|
||||
if (n) { n.hidden = true; n.setAttribute('aria-hidden', 'true'); n.style.display = 'none' }
|
||||
}
|
||||
const ob = document.querySelector('#onboarding, .onboarding, [data-onboarding]')
|
||||
if (ob) ob.remove()
|
||||
return 1
|
||||
})()`)
|
||||
const shot = await c.call('Page.captureScreenshot', {
|
||||
format: 'png',
|
||||
clip: { x: 0, y: 0, width: 1440, height: 900, scale: 1 },
|
||||
})
|
||||
const path = resolve(outdir, name + '.png')
|
||||
writeFileSync(path, Buffer.from(shot.data, 'base64'))
|
||||
console.log(path)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const c = await cdp()
|
||||
await c.call('Page.enable')
|
||||
await c.evjs(`(async()=>{try{return window.dshQa && await window.dshQa.revealWindow()}catch(e){return {err: String(e)}}})()`)
|
||||
await c.call('Emulation.setDeviceMetricsOverride', {
|
||||
width: 1440, height: 900, deviceScaleFactor: 1, mobile: false,
|
||||
})
|
||||
|
||||
// Warm the Rubrics tab so window.__dshRubrics._state.rubrics is populated
|
||||
// (the picker dropdown reads from there).
|
||||
await c.evjs(`(async()=>{
|
||||
if (window.__dshTabs && window.__dshTabs.switchTo) window.__dshTabs.switchTo('rubrics')
|
||||
await new Promise(r=>setTimeout(r,500))
|
||||
if (window.__dshRubrics && window.__dshRubrics.refresh) await window.__dshRubrics.refresh()
|
||||
await new Promise(r=>setTimeout(r,300))
|
||||
return { n: (window.__dshRubrics && window.__dshRubrics._state && window.__dshRubrics._state.rubrics && window.__dshRubrics._state.rubrics.length) || 0 }
|
||||
})()`)
|
||||
|
||||
// 01: annotation drawer scoring multi-turn (5 continuous dims + type badges).
|
||||
await shoot(c, 'rubric-prim-01-multiturn-continuous', () => `(async () => {
|
||||
const A = window.__dshAnnotation
|
||||
if (!A || !A.open) return { err: 'no __dshAnnotation.open' }
|
||||
// Reset to the default multi-turn rubric.
|
||||
if (A.setActiveRubric) {
|
||||
const list = (window.__dshRubrics && window.__dshRubrics._state && window.__dshRubrics._state.rubrics) || []
|
||||
const mt = list.find(r => r.template === 'multi-turn')
|
||||
if (mt) A.setActiveRubric(mt); else A.setActiveRubric({id:'__multi-turn__', name:'multi-turn (5 fixed dims)', template:'multi-turn'})
|
||||
}
|
||||
A.open('sess-fib-01')
|
||||
await new Promise(r=>setTimeout(r,600))
|
||||
// Pre-score first turn on a few dims so the buttons show "active" state.
|
||||
const model = window.__dshAnnotationModel
|
||||
if (model && model.setTurnScore) {
|
||||
let ann = A.read('sess-fib-01') || model.blankAnnotation('sess-fib-01')
|
||||
ann = model.setOverall(ann, 'good', Date.now())
|
||||
ann = model.setTurnScore(ann, 0, { dims: { 'feedback-understanding': 5, 'fix-effectiveness': 4, 'no-regression': 5 } }, Date.now())
|
||||
A._state.byId.set('sess-fib-01', ann)
|
||||
// Re-render the drawer body via close/open cycle would reset focus;
|
||||
// simpler: dispatch the update event which is what triview listens on.
|
||||
document.dispatchEvent(new CustomEvent('dsh:annotation-updated', { detail: { sessionId: 'sess-fib-01', ann } }))
|
||||
// Force a fresh renderBody by re-opening.
|
||||
A.close && A.close()
|
||||
await new Promise(r=>setTimeout(r,150))
|
||||
A.open('sess-fib-01')
|
||||
await new Promise(r=>setTimeout(r,400))
|
||||
}
|
||||
return { badges: document.querySelectorAll('.annotation-dim-type-badge').length }
|
||||
})()`)
|
||||
|
||||
// 02: switch drawer to categorical (intent-triage rubric).
|
||||
await shoot(c, 'rubric-prim-02-categorical', () => `(async () => {
|
||||
const A = window.__dshAnnotation
|
||||
const list = (window.__dshRubrics && window.__dshRubrics._state && window.__dshRubrics._state.rubrics) || []
|
||||
const cat = list.find(r => r.dimensions && r.dimensions.some(d => d.type === 'categorical'))
|
||||
if (!cat) return { err: 'no categorical fixture' }
|
||||
A.setActiveRubric(cat)
|
||||
if (A.close) A.close()
|
||||
await new Promise(r=>setTimeout(r,120))
|
||||
A.open('sess-fib-01')
|
||||
await new Promise(r=>setTimeout(r,400))
|
||||
return { active: A.getActiveRubric && A.getActiveRubric().name, dimTypes: (A.getActiveDims && A.getActiveDims().map(d=>d.type).join(',')) || '' }
|
||||
})()`)
|
||||
|
||||
// 03: rubrics page Create-from-scratch form.
|
||||
await shoot(c, 'rubric-prim-03-create-form', () => `(async () => {
|
||||
const A = window.__dshAnnotation
|
||||
if (A && A.close) A.close()
|
||||
await new Promise(r=>setTimeout(r,120))
|
||||
if (window.__dshTabs && window.__dshTabs.switchTo) window.__dshTabs.switchTo('rubrics')
|
||||
await new Promise(r=>setTimeout(r,400))
|
||||
const R = window.__dshRubrics
|
||||
if (!R || !R.openCreateForm) return { err: 'no openCreateForm' }
|
||||
R.openCreateForm('llm-judge')
|
||||
await new Promise(r=>setTimeout(r,500))
|
||||
// Scroll the form into view so it lands in the shot.
|
||||
const f = document.querySelector('.rubric-create-form')
|
||||
if (f && f.scrollIntoView) { try { f.scrollIntoView({ block: 'center' }) } catch (_) {} }
|
||||
await new Promise(r=>setTimeout(r,300))
|
||||
return { open: !!document.querySelector('.rubric-create-form') }
|
||||
})()`)
|
||||
|
||||
process.exit(0)
|
||||
}
|
||||
main().catch((e) => { console.error(e); process.exit(1) })
|
||||
678
examples/desktop/scripts/qa-cdp-shoot-showcase.mjs
Normal file
678
examples/desktop/scripts/qa-cdp-shoot-showcase.mjs
Normal file
@@ -0,0 +1,678 @@
|
||||
// scripts/qa-cdp-shoot-showcase.mjs — boss showcase batch (20 shots).
|
||||
//
|
||||
// Drives a fresh DSH_QA=1 Electron on --remote-debugging-port=9260 via
|
||||
// CDP; every shot uses fixture/mock data (no API burn). Writes PNGs into
|
||||
// docs/demo-shots/showcase-2026-07-18/ named 01-*.png … 20-*.png in list
|
||||
// order so the summary can be relayed 1:1.
|
||||
|
||||
import { writeFileSync, mkdirSync, readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const [,, portArg, outdirArg] = process.argv
|
||||
const port = portArg || '9260'
|
||||
const outdir = outdirArg || 'docs/demo-shots/showcase-2026-07-18'
|
||||
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') {
|
||||
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')
|
||||
await call('Page.enable')
|
||||
return { call, evjs, sleep, consoleEntries, close: () => ws.close() }
|
||||
}
|
||||
|
||||
async function shoot(c, name) {
|
||||
// Retry once — heavy fixtures + expanded trees can push a single capture
|
||||
// past the default 60s window; a second attempt after a short breath
|
||||
// usually succeeds.
|
||||
let r
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
r = await c.call('Page.captureScreenshot', { format: 'png' }, 90000)
|
||||
break
|
||||
} catch (e) {
|
||||
if (attempt === 2) throw e
|
||||
console.log(' captureScreenshot retry:', e.message)
|
||||
await c.sleep(1500)
|
||||
}
|
||||
}
|
||||
const p = resolve(outdir, name + '.png')
|
||||
writeFileSync(p, Buffer.from(r.data, 'base64'))
|
||||
const size = Buffer.from(r.data, 'base64').length
|
||||
console.log(' wrote', p, `(${(size / 1024).toFixed(0)} KB)`)
|
||||
return { path: p, size }
|
||||
}
|
||||
|
||||
async function closeDevtools(c) {
|
||||
await c.evjs(`(function(){
|
||||
const btn = document.querySelector('.devtools-drawer .devtools-close')
|
||||
if (btn) { btn.click(); return { closed: true } }
|
||||
const d = document.querySelector('.devtools-drawer')
|
||||
if (d && !d.hidden) { d.hidden = true; return { closed: 'hidden' } }
|
||||
return { closed: false }
|
||||
})()`)
|
||||
}
|
||||
|
||||
// closeOverlays — reset any transient right/drawer overlays that leak between
|
||||
// shots (fork compare drawer, context rail drawer). Called before every shot
|
||||
// so the visible pane fills the frame without residue from the previous prep.
|
||||
async function closeOverlays(c) {
|
||||
await c.evjs(`(function(){
|
||||
// Fork compare drawer — has its own close button + hidden fallback
|
||||
const forkBtn = document.querySelector('.fork-compare-drawer button, #fork-compare-drawer button')
|
||||
// The Close chip carries "Close" text — walk close-family buttons.
|
||||
const forkCloseBtns = document.querySelectorAll('.fork-compare-drawer button, #fork-compare-drawer button, .playground-compare-drawer button')
|
||||
for (const b of forkCloseBtns) {
|
||||
const txt = (b.textContent || '').trim().toLowerCase()
|
||||
if (txt === 'close' || txt === '×' || b.classList.contains('close')) { b.click(); break }
|
||||
}
|
||||
const forkDrawer = document.querySelector('.fork-compare-drawer, #fork-compare-drawer')
|
||||
if (forkDrawer) forkDrawer.hidden = true
|
||||
// Note: do NOT set style.display here — openForkCompare's re-open only
|
||||
// toggles hidden (the property), so a stuck display:none would blank the
|
||||
// drawer even after a fresh mock fires.
|
||||
// Context rail drawer
|
||||
const railClose = document.getElementById('context-rail-drawer-close')
|
||||
if (railClose) railClose.click()
|
||||
const rail = document.getElementById('context-rail-drawer')
|
||||
if (rail && !rail.hidden) rail.hidden = true
|
||||
// Devtools drawer
|
||||
const dtClose = document.querySelector('.devtools-drawer .devtools-close')
|
||||
if (dtClose) dtClose.click()
|
||||
const dt = document.querySelector('.devtools-drawer')
|
||||
if (dt && !dt.hidden) dt.hidden = true
|
||||
// Compact drawer / any generic overlay drawer
|
||||
const drawers = document.querySelectorAll('.overlay-drawer[open], details.overlay-drawer[open]')
|
||||
drawers.forEach(d => { try { d.open = false } catch (_) {} })
|
||||
// Runtime warning banner ("The daemon reported an issue.") — cosmetic
|
||||
// but crowds the top of every shot; dismiss it via its own X button
|
||||
// so subsequent shots start clean.
|
||||
const bannerX = document.querySelector('#chat-runtime-banner .chat-runtime-banner-dismiss')
|
||||
if (bannerX) bannerX.click()
|
||||
const banner = document.getElementById('chat-runtime-banner')
|
||||
if (banner) banner.remove()
|
||||
// Annotation "Rate trajectory" side panel — persists across tab
|
||||
// switches; nuke it explicitly so shots 16-21 don't inherit shot 15's
|
||||
// panel. The panel exposes a close via window.__dshAnnotation.close.
|
||||
if (window.__dshAnnotation && typeof window.__dshAnnotation.close === 'function') {
|
||||
try { window.__dshAnnotation.close() } catch (_) {}
|
||||
}
|
||||
const ann = document.querySelector('#annotation-drawer, .annotation-drawer, .annotation-panel, [data-annotation-panel]')
|
||||
if (ann) { ann.hidden = true; ann.setAttribute('aria-hidden', 'true'); ann.style.display = 'none' }
|
||||
// Full-trace overlay — belt-and-suspenders in case shots 05-08 chained
|
||||
// and the caller forgot the explicit cleanup.
|
||||
const overlay = document.querySelector('.devtools-full-trace-overlay')
|
||||
if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay)
|
||||
return { ok: true }
|
||||
})()`)
|
||||
}
|
||||
|
||||
async function switchTab(c, name) {
|
||||
return c.evjs(`(function(){
|
||||
if (window.__dshTabs && typeof window.__dshTabs.switchTo === 'function') {
|
||||
window.__dshTabs.switchTo(${JSON.stringify(name)})
|
||||
return { via: 'switchTo' }
|
||||
}
|
||||
const btn = document.querySelector('.tab-btn[data-tab="' + ${JSON.stringify(name)} + '"]')
|
||||
if (btn) { btn.click(); return { via: 'click' } }
|
||||
return { via: 'none' }
|
||||
})()`)
|
||||
}
|
||||
|
||||
async function clickById(c, id) {
|
||||
return c.evjs(`(function(){
|
||||
const b = document.getElementById(${JSON.stringify(id)})
|
||||
if (b) { b.click(); return { ok: true, id: ${JSON.stringify(id)} } }
|
||||
return { ok: false, id: ${JSON.stringify(id)} }
|
||||
})()`)
|
||||
}
|
||||
|
||||
async function scrollTop(c, sel) {
|
||||
return c.evjs(`(function(){
|
||||
const el = ${sel ? `document.querySelector(${JSON.stringify(sel)})` : 'document.getElementById("stream")'}
|
||||
if (el) { el.scrollTop = 0; return { ok: true } }
|
||||
return { ok: false }
|
||||
})()`)
|
||||
}
|
||||
|
||||
async function scrollBottom(c, sel) {
|
||||
return c.evjs(`(function(){
|
||||
const el = ${sel ? `document.querySelector(${JSON.stringify(sel)})` : 'document.getElementById("stream")'}
|
||||
if (el) { el.scrollTop = el.scrollHeight; return { ok: true, sh: el.scrollHeight } }
|
||||
return { ok: false }
|
||||
})()`)
|
||||
}
|
||||
|
||||
async function seedFresh(c) {
|
||||
await switchTab(c, 'chat')
|
||||
await c.sleep(200)
|
||||
return c.evjs(`(async () => {
|
||||
const { id } = await window.dsh.newSession()
|
||||
if (typeof window.__dshChat === 'object' && window.__dshChat && typeof window.__dshChat.select === 'function') {
|
||||
await window.__dshChat.select(id)
|
||||
}
|
||||
// Clear the stream so residual fixtures from the last shot are gone.
|
||||
const s = document.getElementById('stream'); if (s) s.innerHTML = ''
|
||||
return { id }
|
||||
})()`)
|
||||
}
|
||||
|
||||
async function playFixture(c, name) {
|
||||
return c.evjs(`(async () => {
|
||||
if (typeof window.__dshQaPlayFixture !== 'function') return { err: 'no seam' }
|
||||
return await window.__dshQaPlayFixture(${JSON.stringify(name)})
|
||||
})()`)
|
||||
}
|
||||
|
||||
async function loadSample(c) {
|
||||
return c.evjs(`(async () => {
|
||||
if (typeof window.__dshLoadSampleTrace === 'function') {
|
||||
await window.__dshLoadSampleTrace()
|
||||
return { via: 'loadSampleTrace' }
|
||||
}
|
||||
return { via: 'none' }
|
||||
})()`)
|
||||
}
|
||||
|
||||
// ---------------- driver ----------------
|
||||
async function main() {
|
||||
const c = await cdp()
|
||||
await c.evjs(`document.title`) // handshake
|
||||
console.log('CDP handshake OK on port', port)
|
||||
|
||||
// Set the viewport to 1440×900 via Emulation.setDeviceMetricsOverride
|
||||
// (the main.js hardcodes 1200×800; overriding here gives the requested
|
||||
// showcase framing without touching source).
|
||||
await c.call('Emulation.setDeviceMetricsOverride', {
|
||||
width: 1440, height: 900, deviceScaleFactor: 2, mobile: false,
|
||||
})
|
||||
await c.sleep(300)
|
||||
|
||||
const notes = []
|
||||
const rec = async (name, note, fn) => {
|
||||
console.log('shot:', name, '—', note)
|
||||
// Reset every transient overlay before we prep this shot's state so leftover
|
||||
// drawers from #12/etc. don't bleed through.
|
||||
try { await closeOverlays(c) } catch (_) {}
|
||||
await c.sleep(120)
|
||||
try {
|
||||
await fn()
|
||||
} catch (e) {
|
||||
console.log(' prep err:', e.message)
|
||||
notes.push({ name, note, err: e.message })
|
||||
return
|
||||
}
|
||||
await c.sleep(400)
|
||||
await closeDevtools(c)
|
||||
await c.sleep(150)
|
||||
const res = await shoot(c, name)
|
||||
notes.push({ name, note, size: res.size })
|
||||
}
|
||||
|
||||
// ── 01 Chat 空态 launcher (4 卡) ─────────────────────────────────────
|
||||
await rec('01-chat-empty-launcher', 'Chat 空态:4 卡 launcher 一屏', async () => {
|
||||
await switchTab(c, 'chat')
|
||||
await c.sleep(300)
|
||||
// Force the empty-welcome block back: if the daemon auto-selected a prior
|
||||
// session, fetch the template and re-inject the launcher.
|
||||
await c.evjs(`(async () => {
|
||||
const stream = document.getElementById('stream'); if (!stream) return
|
||||
const res = await fetch(window.location.href.split('#')[0])
|
||||
const html = await res.text()
|
||||
const m = html.match(/<div class="empty-welcome"[\\s\\S]*?<\\/div>\\s*<\\/div>\\s*<\\/section>/)
|
||||
if (!m) return
|
||||
const block = m[0].replace(/<\\/section>$/, '').trim()
|
||||
stream.innerHTML = block
|
||||
})()`)
|
||||
await c.sleep(400)
|
||||
})
|
||||
|
||||
// ── 02 对话流全景(fixture 1.1-trace-full) ─────────────────────────
|
||||
await rec('02-conversation-flow-full', '对话流全景:turn 容器 + reasoning 折叠展开 + tool 行 + turn footer', async () => {
|
||||
await seedFresh(c)
|
||||
await playFixture(c, '1.1-trace-full.json')
|
||||
await c.sleep(1200)
|
||||
// Expand any reasoning-block details on the last turn so both folded and
|
||||
// expanded states show in the same frame.
|
||||
await c.evjs(`(function(){
|
||||
const rs = document.querySelectorAll('details.reasoning-block, details.reasoning')
|
||||
if (rs.length > 0) rs[rs.length - 1].open = true
|
||||
// scroll to bottom so turn footer/glyph is visible
|
||||
const s = document.getElementById('stream'); if (s) s.scrollTop = s.scrollHeight
|
||||
})()`)
|
||||
await c.sleep(500)
|
||||
})
|
||||
|
||||
// ── 03 Reasoning 差异化(mock-reasoning-only + trace 折叠展开) ─────
|
||||
await rec('03-reasoning-differentiator', 'Reasoning 差异化:reasoning-only 折叠卡展开 + 行显 reasoning', async () => {
|
||||
await seedFresh(c)
|
||||
await c.sleep(200)
|
||||
await clickById(c, 'mock-reasoning-only')
|
||||
await c.sleep(1200)
|
||||
await c.evjs(`(function(){
|
||||
const rs = document.querySelectorAll('details.reasoning-block, details.reasoning')
|
||||
rs.forEach(d => d.open = true)
|
||||
const s = document.getElementById('stream'); if (s) s.scrollTop = 0
|
||||
})()`)
|
||||
await c.sleep(400)
|
||||
})
|
||||
|
||||
// ── 04 Tracing 一级页 ────────────────────────────────────────────────
|
||||
await rec('04-tracing-page-eight-column', 'Tracing 一级页:八列表格,3-5 条会话', async () => {
|
||||
// Pre-seed multiple sessions with different fixtures so the tracing table has rows.
|
||||
for (const fx of ['sample-session.json', '1.1-trace-full.json', '2.1-turn-trajectory-mixed.json', '1.7-compact-three-events.json', '2.6-subagent-inline-trace.json']) {
|
||||
try { await seedFresh(c); await playFixture(c, fx); await c.sleep(500) } catch (_) {}
|
||||
}
|
||||
await switchTab(c, 'tracing')
|
||||
await c.sleep(1200)
|
||||
})
|
||||
|
||||
// ── 05 三视图 Tree ───────────────────────────────────────────────────
|
||||
// Rework2 (2026-07-18): fixture playback drops trace-cards directly into
|
||||
// the stream (no turn-footer, no auto-drawer), so we manually mount the
|
||||
// tri-view via the __dshTraceTriView module against the trace card's
|
||||
// attached _rec step-record. That is the same code path the per-turn
|
||||
// drawer would trigger; we just wrap the card ourselves for the shot.
|
||||
const triSetup = async () => {
|
||||
await seedFresh(c)
|
||||
await playFixture(c, '1.1-trace-full.json')
|
||||
await c.sleep(1500)
|
||||
await switchTab(c, 'chat')
|
||||
await c.sleep(200)
|
||||
const mount = await c.evjs(`(function(){
|
||||
// Build session-scope records from the active session's cached events.
|
||||
const mod = window.__dshTraceTriView
|
||||
if (!mod) return { ok: false, err: 'no tri module' }
|
||||
const sid = (window.__dshChat && typeof window.__dshChat.getActiveSessionId === 'function')
|
||||
? window.__dshChat.getActiveSessionId()
|
||||
: null
|
||||
let events = []
|
||||
if (window.__dshChat && typeof window.__dshChat.getEventsForActive === 'function') {
|
||||
events = window.__dshChat.getEventsForActive() || []
|
||||
}
|
||||
const records = mod.sessionTraceRecords(events)
|
||||
// For the tree tab we want a real trace card — the fixture playback
|
||||
// drops one or more <.trace-card> into the stream. Wrap the outer
|
||||
// section that holds all cards so the Tree view shows a rich walked
|
||||
// record, not the "per-turn" stub.
|
||||
const cards = document.querySelectorAll('.trace-card')
|
||||
// Stack every card into a synthetic parent so treeEl carries all steps.
|
||||
let treeEl = null
|
||||
if (cards.length) {
|
||||
const stack = document.createElement('div')
|
||||
stack.className = 'showcase-triview-tree-stack'
|
||||
for (const c of cards) stack.appendChild(c.cloneNode(true))
|
||||
treeEl = stack
|
||||
}
|
||||
const view = mod.buildTriView(document, {
|
||||
treeEl: treeEl,
|
||||
records: records && records.length ? records : (cards[0] && cards[0]._rec ? cards[0]._rec : []),
|
||||
scope: 'session',
|
||||
sessionId: sid,
|
||||
defaultView: 'tree',
|
||||
onSeqClick: () => {},
|
||||
})
|
||||
const wrap = document.createElement('div')
|
||||
wrap.className = 'showcase-triview-mount'
|
||||
wrap.style.padding = '16px 24px'
|
||||
wrap.appendChild(view)
|
||||
const stream = document.getElementById('stream')
|
||||
if (stream) {
|
||||
for (const child of Array.from(stream.children)) child.style.display = 'none'
|
||||
stream.appendChild(wrap)
|
||||
}
|
||||
// Dismiss any runtime warning banner that may have appeared during
|
||||
// fixture playback.
|
||||
const warn = document.querySelector('.runtime-warning, .warning-banner')
|
||||
if (warn) { warn.style.display = 'none' }
|
||||
const dismiss = document.querySelector('.runtime-warning button, .warning-banner button, [aria-label="dismiss"]')
|
||||
if (dismiss) dismiss.click()
|
||||
wrap.scrollIntoView({ block: 'start' })
|
||||
return { ok: true, via: 'session-scope', records: (records || []).length, cards: cards.length }
|
||||
})()`)
|
||||
console.log(' triSetup:', JSON.stringify(mount))
|
||||
await c.sleep(500)
|
||||
}
|
||||
await rec('05-triview-tree', '三视图 Tree:内联时间条 + model chip + token/duration pill', async () => {
|
||||
await triSetup()
|
||||
await c.evjs(`(function(){
|
||||
const chip = document.querySelector('.showcase-triview-mount .trace-tri-chip.chip-tree, .showcase-triview-mount .trace-tri-chip[data-view="tree"]')
|
||||
if (chip) chip.click()
|
||||
})()`)
|
||||
await c.sleep(500)
|
||||
})
|
||||
|
||||
// ── 06 三视图 Timeline (Gantt) ───────────────────────────────────────
|
||||
await rec('06-triview-timeline', '三视图 Timeline (Gantt)', async () => {
|
||||
const present = await c.evjs(`(function(){ return !!document.querySelector('.showcase-triview-mount') })()`)
|
||||
if (!present) await triSetup()
|
||||
await c.evjs(`(function(){
|
||||
const chip = document.querySelector('.showcase-triview-mount .trace-tri-chip.chip-timeline, .showcase-triview-mount .trace-tri-chip[data-view="timeline"]')
|
||||
if (chip) chip.click()
|
||||
})()`)
|
||||
await c.sleep(500)
|
||||
})
|
||||
|
||||
// ── 07 三视图 Graph ─────────────────────────────────────────────────
|
||||
await rec('07-triview-graph', '三视图 Graph:节点选中态', async () => {
|
||||
const present = await c.evjs(`(function(){ return !!document.querySelector('.showcase-triview-mount') })()`)
|
||||
if (!present) await triSetup()
|
||||
await c.evjs(`(function(){
|
||||
const chip = document.querySelector('.showcase-triview-mount .trace-tri-chip.chip-graph, .showcase-triview-mount .trace-tri-chip[data-view="graph"]')
|
||||
if (chip) chip.click()
|
||||
})()`)
|
||||
await c.sleep(500)
|
||||
await c.evjs(`(function(){
|
||||
const scope = document.querySelector('.showcase-triview-mount') || document
|
||||
const node = scope.querySelector('.trace-graph-node, [data-graph-node], .graph-node, circle[data-node], g[data-node]')
|
||||
if (node) {
|
||||
// SVG elements don't have HTMLElement.click(); dispatch a proper MouseEvent instead.
|
||||
const ev = new MouseEvent('click', { bubbles: true, cancelable: true, view: window })
|
||||
node.dispatchEvent(ev)
|
||||
node.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }))
|
||||
// Add a visual selected marker in case the tri-view keys on this class.
|
||||
node.classList && node.classList.add('selected')
|
||||
}
|
||||
})()`)
|
||||
await c.sleep(300)
|
||||
})
|
||||
|
||||
// ── 08 Detail pane (四段 + Fields 递归树 ≥3 层) ────────────────────
|
||||
await rec('08-detail-pane-fields-tree', 'Detail pane:四区段 + Fields 递归树 ≥3 层展开', async () => {
|
||||
const present = await c.evjs(`(function(){ return !!document.querySelector('.showcase-triview-mount') })()`)
|
||||
if (!present) await triSetup()
|
||||
await c.evjs(`(function(){
|
||||
const chip = document.querySelector('.showcase-triview-mount .trace-tri-chip.chip-tree, .showcase-triview-mount .trace-tri-chip[data-view="tree"]')
|
||||
if (chip) chip.click()
|
||||
})()`)
|
||||
await c.sleep(400)
|
||||
await c.evjs(`(function(){
|
||||
const scope = document.querySelector('.showcase-triview-mount') || document
|
||||
// Find a leaf whose click will populate the detail pane.
|
||||
const rows = scope.querySelectorAll('.trace-event-row, .trace-tree-row, [data-step-row]')
|
||||
let hit = null
|
||||
for (const r of rows) {
|
||||
const txt = (r.textContent || '').toLowerCase()
|
||||
if (/tool|result|fields|read|write/.test(txt)) hit = r
|
||||
}
|
||||
if (!hit && rows.length) hit = rows[Math.min(2, rows.length - 1)]
|
||||
if (hit) hit.click()
|
||||
setTimeout(() => {
|
||||
const detailsAll = document.querySelectorAll('.trace-detail-pane details')
|
||||
detailsAll.forEach(d => d.open = true)
|
||||
}, 200)
|
||||
})()`)
|
||||
await c.sleep(900)
|
||||
})
|
||||
|
||||
// ── 09 Error tab (5 tab 红 banner) ──────────────────────────────────
|
||||
await rec('09-error-tab-banner', 'Error tab:5 tab 红 banner 态', async () => {
|
||||
// Remove any tri-view mount from the previous sequence and un-hide
|
||||
// the stream's original children.
|
||||
await c.evjs(`(function(){
|
||||
const mount = document.querySelector('.showcase-triview-mount')
|
||||
if (mount && mount.parentNode) mount.parentNode.removeChild(mount)
|
||||
const stream = document.getElementById('stream')
|
||||
if (stream) for (const child of Array.from(stream.children)) child.style.display = ''
|
||||
const o = document.querySelector('.devtools-full-trace-overlay')
|
||||
if (o && o.parentNode) o.parentNode.removeChild(o)
|
||||
})()`)
|
||||
await c.sleep(150)
|
||||
await seedFresh(c)
|
||||
await playFixture(c, 'trace-parity-error-tool-result.json')
|
||||
await c.sleep(1000)
|
||||
await switchTab(c, 'chat')
|
||||
await c.sleep(200)
|
||||
await c.evjs(`(async () => {
|
||||
const drawers = document.querySelectorAll('details.turn-trace-drawer')
|
||||
if (drawers.length > 0) {
|
||||
const d = drawers[drawers.length - 1]
|
||||
d.open = true
|
||||
d.scrollIntoView({ block: 'center' })
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
const scope = document.querySelector('details.turn-trace-drawer[open]') || document
|
||||
// Click into an error row
|
||||
const rows = scope.querySelectorAll('.trace-event-row, .trace-tree-row')
|
||||
for (const r of rows) {
|
||||
if (/error|fail|reject/i.test(r.textContent || '')) { r.click(); break }
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
// Click Error tab explicitly if present
|
||||
const errBtn = document.querySelector('.trace-detail-tab.is-error, .trace-detail-tab[data-tab="error"]')
|
||||
if (errBtn) errBtn.click()
|
||||
})()`)
|
||||
await c.sleep(600)
|
||||
})
|
||||
|
||||
// ── 10 Reasoning tab (5th tab 展开 982 tok) ─────────────────────────
|
||||
await rec('10-reasoning-tab', 'Reasoning tab:5th tab 展开 (differentiator)', async () => {
|
||||
await seedFresh(c)
|
||||
await clickById(c, 'mock-reasoning-only')
|
||||
await c.sleep(1200)
|
||||
await c.evjs(`(async () => {
|
||||
const drawers = document.querySelectorAll('details.turn-trace-drawer')
|
||||
if (drawers.length > 0) {
|
||||
const d = drawers[drawers.length - 1]
|
||||
d.open = true
|
||||
d.scrollIntoView({ block: 'center' })
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
const scope = document.querySelector('details.turn-trace-drawer[open]') || document
|
||||
// Find a row that had reasoning tokens
|
||||
const rows = scope.querySelectorAll('.trace-event-row, .trace-tree-row')
|
||||
let hit = null
|
||||
for (const r of rows) {
|
||||
const txt = (r.textContent || '').toLowerCase()
|
||||
if (/reason|reasoning|assistant|llm/.test(txt)) hit = r
|
||||
}
|
||||
if (!hit && rows.length) hit = rows[rows.length - 1]
|
||||
if (hit) hit.click()
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
const rBtn = document.querySelector('.trace-detail-tab[data-tab="reasoning"]')
|
||||
if (rBtn) rBtn.click()
|
||||
})()`)
|
||||
await c.sleep(500)
|
||||
})
|
||||
|
||||
// ── 11 Edit & re-run header ─────────────────────────────────────────
|
||||
await rec('11-edit-rerun-header', 'Edit & re-run:header 改参面板展开', async () => {
|
||||
await seedFresh(c)
|
||||
await loadSample(c)
|
||||
await c.sleep(1000)
|
||||
await switchTab(c, 'chat')
|
||||
await c.sleep(300)
|
||||
await c.evjs(`(function(){
|
||||
// Open all edit-rerun-header details in the stream.
|
||||
const hs = document.querySelectorAll('details.edit-rerun-header')
|
||||
if (hs.length > 0) {
|
||||
const target = hs[hs.length - 1]
|
||||
target.open = true
|
||||
target.scrollIntoView({ block: 'center' })
|
||||
}
|
||||
})()`)
|
||||
await c.sleep(500)
|
||||
})
|
||||
|
||||
// ── 12 Fork compare drawer ──────────────────────────────────────────
|
||||
await rec('12-fork-compare-drawer', 'Fork compare:父子并排抽屉', async () => {
|
||||
await seedFresh(c)
|
||||
await clickById(c, 'mock-fork-compare')
|
||||
await c.sleep(1000)
|
||||
await c.evjs(`(function(){
|
||||
const drawer = document.querySelector('.fork-compare-drawer, .compare-drawer, [data-fork-compare]')
|
||||
if (drawer) drawer.scrollIntoView({ block: 'center' })
|
||||
})()`)
|
||||
await c.sleep(400)
|
||||
})
|
||||
|
||||
// ── 13 Compact 卡 三 tab Diff 态 ────────────────────────────────────
|
||||
await rec('13-compact-card-diff-tab', 'Compact 卡:三 tab Diff 态', async () => {
|
||||
await seedFresh(c)
|
||||
await playFixture(c, '1.7-compact-three-events.json')
|
||||
await c.sleep(1000)
|
||||
await c.evjs(`(function(){
|
||||
// Click the diff tab on the compact card.
|
||||
const cards = document.querySelectorAll('.compact-card, .compact-badge, [data-compact-card]')
|
||||
let hit = null
|
||||
for (const c2 of cards) {
|
||||
const btn = c2.querySelector('button[data-tab="diff"], .compact-tab-diff, .compact-tabs .diff')
|
||||
if (btn) { btn.click(); hit = c2; break }
|
||||
}
|
||||
if (!hit) {
|
||||
// Fallback: find any tab labeled "Diff" inside a compact-* container
|
||||
const allBtns = document.querySelectorAll('.compact-card button, .compact-badge button')
|
||||
for (const b of allBtns) if (/diff/i.test(b.textContent || '')) { b.click(); break }
|
||||
}
|
||||
const first = document.querySelector('.compact-card, .compact-badge')
|
||||
if (first) first.scrollIntoView({ block: 'center' })
|
||||
})()`)
|
||||
await c.sleep(500)
|
||||
})
|
||||
|
||||
// ── 14 Subagent 实时子轨迹 ──────────────────────────────────────────
|
||||
await rec('14-subagent-inline-trace', 'Subagent 实时子轨迹:DONE/RUNNING 卡', async () => {
|
||||
await seedFresh(c)
|
||||
await playFixture(c, '2.6-subagent-inline-trace.json')
|
||||
await c.sleep(1000)
|
||||
await c.evjs(`(function(){
|
||||
const s = document.getElementById('stream'); if (s) s.scrollTop = s.scrollHeight
|
||||
// Ensure any subagent trace details are open
|
||||
const details = document.querySelectorAll('details.subagent-inline, details[data-subagent], .subagent-card details')
|
||||
details.forEach(d => d.open = true)
|
||||
})()`)
|
||||
await c.sleep(500)
|
||||
})
|
||||
|
||||
// ── 15 标注面板:三类型 rubric 打分态 ───────────────────────────────
|
||||
await rec('15-annotation-panel-typed', '标注面板:Continuous 按钮排 + Categorical 枚举', async () => {
|
||||
await seedFresh(c)
|
||||
await loadSample(c)
|
||||
await c.sleep(600)
|
||||
await switchTab(c, 'chat')
|
||||
await c.sleep(200)
|
||||
await c.evjs(`(async () => {
|
||||
if (window.__dshAnnotation && typeof window.__dshAnnotation.open === 'function') {
|
||||
const sid = (window.__dshAnnotationSamples && window.__dshAnnotationSamples.sessions && window.__dshAnnotationSamples.sessions[0] && window.__dshAnnotationSamples.sessions[0].sessionId) || 'demo-session'
|
||||
window.__dshAnnotation.open(sid)
|
||||
}
|
||||
})()`)
|
||||
await c.sleep(800)
|
||||
})
|
||||
|
||||
// ── 16 Rubrics 页 + Create-from-scratch 表单 ────────────────────────
|
||||
await rec('16-rubrics-page-create', 'Rubrics 页:28 类目录 + Create-from-scratch 表单', async () => {
|
||||
await switchTab(c, 'rubrics')
|
||||
await c.sleep(700)
|
||||
await c.evjs(`(function(){
|
||||
// Click a "Create from scratch" / "new rubric" button if present.
|
||||
const btns = document.querySelectorAll('button')
|
||||
for (const b of btns) {
|
||||
const t = (b.textContent || '').toLowerCase()
|
||||
if (/create.*scratch|new rubric|from scratch/.test(t)) { b.click(); return { ok: true } }
|
||||
}
|
||||
// Also try id-based hooks.
|
||||
const bId = document.getElementById('rubrics-create-from-scratch') || document.getElementById('rubric-new')
|
||||
if (bId) { bId.click(); return { ok: 'id' } }
|
||||
return { ok: false }
|
||||
})()`)
|
||||
await c.sleep(700)
|
||||
})
|
||||
|
||||
// ── 17 Bench 页 (4 实验八列表) ───────────────────────────────────────
|
||||
await rec('17-bench-page', 'Bench 页:4 实验八列表 + 详情', async () => {
|
||||
await switchTab(c, 'bench')
|
||||
await c.sleep(900)
|
||||
})
|
||||
|
||||
// ── 18 Hub 页 (七类资产目录) ────────────────────────────────────────
|
||||
await rec('18-hub-page', 'Hub 页:七类资产目录', async () => {
|
||||
await switchTab(c, 'hub')
|
||||
await c.sleep(900)
|
||||
})
|
||||
|
||||
// ── 19 Context 页 (turn 行 + SDK legend) ────────────────────────────
|
||||
await rec('19-context-page', 'Context 页:加载 sample 后的 turn 行 + SDK legend', async () => {
|
||||
// Seed sample so context ledger is populated.
|
||||
await seedFresh(c)
|
||||
await loadSample(c)
|
||||
await c.sleep(600)
|
||||
await switchTab(c, 'context')
|
||||
await c.sleep(900)
|
||||
})
|
||||
|
||||
// ── 20 Session Tree (fork 实线/subagent 虚线) ───────────────────────
|
||||
await rec('20-session-tree', 'Session Tree:demo forest — fork 实线/subagent 虚线', async () => {
|
||||
await switchTab(c, 'tree')
|
||||
await c.sleep(1000)
|
||||
})
|
||||
|
||||
// ── 21 (bonus) turn/end error 完整行 ────────────────────────────────
|
||||
await rec('21-turn-end-error-row', 'turn/end · error 完整行 (红字全因)', async () => {
|
||||
await seedFresh(c)
|
||||
await clickById(c, 'mock-turn-end-error')
|
||||
await c.sleep(900)
|
||||
await c.evjs(`(function(){
|
||||
const s = document.getElementById('stream'); if (s) s.scrollTop = s.scrollHeight
|
||||
})()`)
|
||||
await c.sleep(400)
|
||||
})
|
||||
|
||||
// Console-probe assertion
|
||||
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').slice(0, 20),
|
||||
}, null, 2))
|
||||
console.log('console report:', jsonPath, 'syntaxErrors=' + syntaxHits.length)
|
||||
|
||||
// Write per-shot notes summary
|
||||
writeFileSync(resolve(outdir, 'shots-manifest.json'), JSON.stringify(notes, null, 2))
|
||||
|
||||
c.close()
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1) })
|
||||
431
examples/desktop/scripts/qa-cdp-shoot-trace-parity-batch-b.mjs
Normal file
431
examples/desktop/scripts/qa-cdp-shoot-trace-parity-batch-b.mjs
Normal file
@@ -0,0 +1,431 @@
|
||||
// scripts/qa-cdp-shoot-trace-parity-batch-b.mjs — 2026-07-17 batch B selfies.
|
||||
//
|
||||
// Two shots proving the HUMAN-card convergence + recursive JSON tree:
|
||||
// batch-b-04 role convergence: user + assistant + tool bubbles rendered
|
||||
// with inline dot + Titlecase word ("User" / "Assistant" /
|
||||
// "Tool"), no uppercase HUMAN/USER hero heading.
|
||||
// batch-b-05 recursive JSON tree: an assistant tool_call arguments
|
||||
// block with a deep nested object rendered as a
|
||||
// `<details>`-based collapsible tree (every level has an
|
||||
// arrow, scalars carry a `·` dot).
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/qa-cdp-shoot-trace-parity-batch-b.mjs <port> <outdir>
|
||||
// The Electron demo must be running with --remote-debugging-port=<port>
|
||||
// and DSH_QA=1.
|
||||
|
||||
import { writeFileSync, mkdirSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const [,, portArg, outdir] = process.argv
|
||||
const port = portArg || '9240'
|
||||
if (!outdir) {
|
||||
console.error('usage: node scripts/qa-cdp-shoot-trace-parity-batch-b.mjs <port> <outdir>')
|
||||
process.exit(1)
|
||||
}
|
||||
mkdirSync(outdir, { recursive: true })
|
||||
|
||||
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()
|
||||
ws.onmessage = (ev) => {
|
||||
let msg; try { msg = JSON.parse(ev.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 = {}, 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))
|
||||
return { call, evjs, sleep, close: () => ws.close() }
|
||||
}
|
||||
|
||||
async function shot(c, name) {
|
||||
const png = await c.call('Page.captureScreenshot', {
|
||||
format: 'png', clip: { x: 0, y: 0, width: 1440, height: 900, scale: 1 },
|
||||
})
|
||||
const path = resolve(outdir, `${name}.png`)
|
||||
writeFileSync(path, Buffer.from(png.data, 'base64'))
|
||||
console.log(path)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const c = await cdp()
|
||||
await c.call('Page.enable')
|
||||
await c.evjs(`(async()=>{try{return window.dshQa && await window.dshQa.revealWindow()}catch(e){return {err:String(e)}}})()`)
|
||||
await c.call('Emulation.setDeviceMetricsOverride', { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false })
|
||||
await c.evjs(`window.__dshTabs && window.__dshTabs.switchTo && window.__dshTabs.switchTo('chat')`)
|
||||
await c.sleep(300)
|
||||
await c.evjs(`(function(){
|
||||
document.body.classList.add('onboarded')
|
||||
const ob = document.querySelector('#onboarding, .onboarding, [data-onboarding]')
|
||||
if (ob) ob.remove()
|
||||
return 1
|
||||
})()`)
|
||||
|
||||
// ─── SHOT 4: role convergence — inline titlecase, no HUMAN caps card ─
|
||||
await c.evjs(`(async () => {
|
||||
const s = document.getElementById('stream')
|
||||
if (!s) return 'NO_STREAM'
|
||||
s.innerHTML = ''
|
||||
for (const stray of document.querySelectorAll('[data-parity-shot]')) stray.remove()
|
||||
const host = document.createElement('div')
|
||||
host.setAttribute('data-parity-shot', 'b04')
|
||||
host.style.padding = '20px 24px'
|
||||
host.style.maxWidth = '900px'
|
||||
host.style.margin = '20px auto'
|
||||
host.style.border = '1px solid rgba(0,0,0,0.09)'
|
||||
host.style.borderRadius = '8px'
|
||||
host.style.background = 'var(--surface, #fff)'
|
||||
const label = document.createElement('div')
|
||||
label.style.font = '600 13px system-ui, sans-serif'
|
||||
label.style.marginBottom = '10px'
|
||||
label.textContent = 'batch-b 04 — Role convergence: inline · + Titlecase (never HUMAN/USER caps hero)'
|
||||
host.appendChild(label)
|
||||
// Fake three msg bubbles matching real appendMessage output shape
|
||||
// (role-glyph + role-label spans, .role container).
|
||||
for (const [role, text] of [
|
||||
['user', 'What Cognitive-behavioral therapy is?'],
|
||||
['assistant', 'CBT is a talk-therapy approach that identifies and reshapes unhelpful thought patterns.'],
|
||||
['tool', 'read({"path": "notes.txt"}) → 1024 bytes'],
|
||||
]) {
|
||||
const b = document.createElement('div')
|
||||
b.className = 'msg ' + role
|
||||
const r = document.createElement('div')
|
||||
r.className = 'role'
|
||||
r.dataset.role = role
|
||||
const g = document.createElement('span'); g.className = 'role-glyph'; g.textContent = '·'
|
||||
const l = document.createElement('span'); l.className = 'role-label'
|
||||
l.textContent = role.charAt(0).toUpperCase() + role.slice(1)
|
||||
r.appendChild(g); r.appendChild(l)
|
||||
const body = document.createElement('div')
|
||||
body.textContent = text
|
||||
b.appendChild(r); b.appendChild(body)
|
||||
host.appendChild(b)
|
||||
}
|
||||
// Callout naming the old shape being retired.
|
||||
const callout = document.createElement('div')
|
||||
callout.style.marginTop = '20px'
|
||||
callout.style.padding = '12px 14px'
|
||||
callout.style.background = 'rgba(220,53,69,0.06)'
|
||||
callout.style.border = '1px dashed rgba(220,53,69,0.3)'
|
||||
callout.style.borderRadius = '6px'
|
||||
callout.style.font = '12px system-ui, sans-serif'
|
||||
callout.style.color = '#8b1e2c'
|
||||
callout.textContent = 'Retired: block-level "HUMAN"/"USER" uppercase caps card (无信息增量) — role now inline dot + Titlecase word.'
|
||||
host.appendChild(callout)
|
||||
s.appendChild(host)
|
||||
return 1
|
||||
})()`)
|
||||
await c.sleep(600)
|
||||
await c.evjs(`(function(){
|
||||
for (const sel of ['#context-rail-drawer', '#context-rail', '.context-rail-drawer', '.context-rail', '.devtools-drawer', '#devtools-panel', '.debug-panel']) {
|
||||
const n = document.querySelector(sel)
|
||||
if (n) { n.hidden = true; n.style.display = 'none' }
|
||||
}
|
||||
return 1
|
||||
})()`)
|
||||
await c.sleep(200)
|
||||
await shot(c, 'trace-parity-04-role-titlecase')
|
||||
|
||||
// ─── SHOT 5: Recursive JSON tree — deep nested tool_call arguments ─
|
||||
await c.evjs(`(async () => {
|
||||
const s = document.getElementById('stream')
|
||||
if (!s) return 'NO_STREAM'
|
||||
s.innerHTML = ''
|
||||
for (const stray of document.querySelectorAll('[data-parity-shot]')) stray.remove()
|
||||
const D = window.__dshTraceDetailPane
|
||||
if (!D || typeof D.buildJsonTree !== 'function') return 'NO_TREE_MOD'
|
||||
const host = document.createElement('div')
|
||||
host.setAttribute('data-parity-shot', 'b05')
|
||||
host.style.padding = '20px 24px'
|
||||
host.style.maxWidth = '860px'
|
||||
host.style.margin = '20px auto'
|
||||
host.style.border = '1px solid rgba(0,0,0,0.09)'
|
||||
host.style.borderRadius = '8px'
|
||||
host.style.background = 'var(--surface, #fff)'
|
||||
const label = document.createElement('div')
|
||||
label.style.font = '600 13px system-ui, sans-serif'
|
||||
label.style.marginBottom = '10px'
|
||||
label.textContent = 'batch-b 05 — Recursive JSON tree: tool_call arguments fold at every depth (density = folding, not dropping)'
|
||||
host.appendChild(label)
|
||||
// Card mimicking the LangSmith fields-card wrapper.
|
||||
const card = document.createElement('div')
|
||||
card.style.border = '1px solid rgba(0,0,0,0.09)'
|
||||
card.style.borderRadius = '6px'
|
||||
card.style.padding = '12px 14px'
|
||||
card.style.background = 'var(--bg-elev, #fafafa)'
|
||||
const head = document.createElement('div')
|
||||
head.style.display = 'flex'; head.style.alignItems = 'baseline'; head.style.gap = '8px'
|
||||
head.style.marginBottom = '10px'
|
||||
head.style.font = '600 12px system-ui, sans-serif'
|
||||
const glyph = document.createElement('span')
|
||||
glyph.textContent = '{ }'; glyph.style.fontFamily = 'var(--mono, monospace)'
|
||||
glyph.style.color = 'rgba(0,0,0,0.4)'
|
||||
const title = document.createElement('span'); title.textContent = 'arguments'
|
||||
head.appendChild(glyph); head.appendChild(title)
|
||||
card.appendChild(head)
|
||||
// A deep fixture mirroring a realistic OpenAI-style tool_call JSON.
|
||||
const fixture = {
|
||||
choices: [
|
||||
{
|
||||
finish_reason: 'stop',
|
||||
index: 0,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'tool_call_1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'search_web',
|
||||
arguments: { query: 'CBT therapy overview', top_k: 5, include_snippets: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
logprobs: null,
|
||||
},
|
||||
],
|
||||
id: 'chatcmpl-abc123',
|
||||
model: 'deepseek-v4',
|
||||
object: 'chat.completion',
|
||||
system_fingerprint: 'fp_9a71',
|
||||
usage: { prompt_tokens: 128, completion_tokens: 42, total_tokens: 170 },
|
||||
}
|
||||
const tree = D.buildJsonTree(document, fixture, { openDepth: 2 })
|
||||
card.appendChild(tree)
|
||||
host.appendChild(card)
|
||||
s.appendChild(host)
|
||||
return 1
|
||||
})()`)
|
||||
await c.sleep(600)
|
||||
await c.evjs(`(function(){
|
||||
for (const sel of ['#context-rail-drawer', '#context-rail', '.context-rail-drawer', '.context-rail', '.devtools-drawer', '#devtools-panel', '.debug-panel']) {
|
||||
const n = document.querySelector(sel)
|
||||
if (n) { n.hidden = true; n.style.display = 'none' }
|
||||
}
|
||||
return 1
|
||||
})()`)
|
||||
await c.sleep(200)
|
||||
await shot(c, 'trace-parity-05-fields-tree')
|
||||
|
||||
// ─── SHOT 6: ≥3-level nested Fields subtree — Output-row raw payload ─
|
||||
//
|
||||
// task #39: the row-level Fields subtree (buildRawFieldsSubtree, wired
|
||||
// into every trace Output row) exposes the full assistant/message wire
|
||||
// payload; the shot must show at least 3 levels of nested objects
|
||||
// visibly expanded so the reader sees "each nested level has its own
|
||||
// ∨/▸ arrow" (density-layering-spec §7 positive-reference lock).
|
||||
//
|
||||
// Fixture: a Claude-style assistant/message with tool_use content
|
||||
// blocks + usage — the recursion reaches depth 4 (data → content →
|
||||
// [0] → input → {key/value}). buildRawFieldsSubtree opens the top,
|
||||
// openDepth=2; we then programmatically flip deeper branches so the
|
||||
// shot captures a fully-unfolded state.
|
||||
await c.evjs(`(async () => {
|
||||
const s = document.getElementById('stream')
|
||||
if (!s) return 'NO_STREAM'
|
||||
s.innerHTML = ''
|
||||
for (const stray of document.querySelectorAll('[data-parity-shot]')) stray.remove()
|
||||
const D = window.__dshTraceDetailPane
|
||||
if (!D || typeof D.buildRawFieldsSubtree !== 'function') return 'NO_ROW_FIELDS_MOD'
|
||||
const host = document.createElement('div')
|
||||
host.setAttribute('data-parity-shot', 'b06')
|
||||
host.style.padding = '20px 24px'
|
||||
host.style.maxWidth = '900px'
|
||||
host.style.margin = '20px auto'
|
||||
host.style.border = '1px solid rgba(0,0,0,0.09)'
|
||||
host.style.borderRadius = '8px'
|
||||
host.style.background = 'var(--surface, #fff)'
|
||||
const label = document.createElement('div')
|
||||
label.style.font = '600 13px system-ui, sans-serif'
|
||||
label.style.marginBottom = '10px'
|
||||
label.textContent = 'batch-b 06 — Row-level Fields subtree, ≥3 depths visibly expanded (per-depth ∨/▸ arrows)'
|
||||
host.appendChild(label)
|
||||
// Simulate a real trace-detail Output row so the subtree renders in
|
||||
// its natural chrome (role + Fields + raw badge line).
|
||||
const row = document.createElement('div')
|
||||
row.className = 'trace-detail-output-row'
|
||||
row.style.padding = '8px 12px'
|
||||
row.style.border = '1px solid rgba(0,0,0,0.09)'
|
||||
row.style.borderRadius = '6px'
|
||||
row.style.background = 'var(--bg-elev, #fafafa)'
|
||||
row.style.display = 'flex'
|
||||
row.style.flexDirection = 'column'
|
||||
row.style.gap = '6px'
|
||||
const roleLine = document.createElement('div')
|
||||
roleLine.style.display = 'flex'
|
||||
roleLine.style.alignItems = 'baseline'
|
||||
roleLine.style.gap = '12px'
|
||||
const roleEl = document.createElement('span')
|
||||
roleEl.className = 'trace-detail-role'
|
||||
roleEl.dataset.role = 'assistant'
|
||||
const g = document.createElement('span'); g.className = 'trace-detail-role-glyph mono'; g.textContent = '·'
|
||||
const w = document.createElement('span'); w.className = 'trace-detail-role-label'; w.textContent = 'Assistant'
|
||||
roleEl.appendChild(g); roleEl.appendChild(w)
|
||||
const body = document.createElement('div')
|
||||
body.className = 'trace-detail-message-body'
|
||||
body.textContent = "I'll search the web for CBT therapy overviews and cite the top results."
|
||||
roleLine.appendChild(roleEl); roleLine.appendChild(body)
|
||||
row.appendChild(roleLine)
|
||||
// Deep raw event fixture — 5-level recursion once you enter tool_use.
|
||||
const rawEvent = {
|
||||
seq: 42,
|
||||
type: 'assistant/message',
|
||||
time: 1710000000000,
|
||||
data: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'text', text: "I'll search the web for CBT therapy overviews and cite the top results." },
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'toolu_01ABC123',
|
||||
name: 'search_web',
|
||||
input: {
|
||||
query: 'cognitive behavioral therapy overview',
|
||||
filters: { language: 'en', region: 'US', top_k: 5, include_snippets: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
finish_reason: 'tool_calls',
|
||||
stop_sequence: null,
|
||||
usage: {
|
||||
prompt_tokens: 342,
|
||||
completion_tokens: 78,
|
||||
total_tokens: 420,
|
||||
cache_read_input_tokens: 128,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
provider_diagnostics: {
|
||||
request_id: 'req_9f2a',
|
||||
upstream: { latency_ms: 812, tokens_per_second: 96.3 },
|
||||
},
|
||||
},
|
||||
}
|
||||
const subtree = D.buildRawFieldsSubtree(document, rawEvent)
|
||||
if (subtree) {
|
||||
subtree.open = true
|
||||
// Walk down + open every foldable branch so the ≥3-level state is
|
||||
// captured; without this the shot would only show the top level.
|
||||
const walk = subtree.querySelectorAll('.trace-detail-json-branch')
|
||||
walk.forEach((d) => { d.open = true })
|
||||
row.appendChild(subtree)
|
||||
}
|
||||
host.appendChild(row)
|
||||
s.appendChild(host)
|
||||
return 1
|
||||
})()`)
|
||||
await c.sleep(600)
|
||||
await c.evjs(`(function(){
|
||||
for (const sel of ['#context-rail-drawer', '#context-rail', '.context-rail-drawer', '.context-rail', '.devtools-drawer', '#devtools-panel', '.debug-panel']) {
|
||||
const n = document.querySelector(sel)
|
||||
if (n) { n.hidden = true; n.style.display = 'none' }
|
||||
}
|
||||
return 1
|
||||
})()`)
|
||||
await c.sleep(200)
|
||||
await shot(c, 'trace-parity-06-row-fields-deep')
|
||||
|
||||
// ─── SHOT 7: trace-card right-side ∨ subtree-fold glyph (spec §7) ────
|
||||
//
|
||||
// task #38 selfie: the right-side ∨ glyph on trace-card summaries.
|
||||
// Shoot two side-by-side cards — one open, one closed — so the reader
|
||||
// sees the state transition + rotation cue.
|
||||
await c.evjs(`(async () => {
|
||||
const s = document.getElementById('stream')
|
||||
if (!s) return 'NO_STREAM'
|
||||
s.innerHTML = ''
|
||||
for (const stray of document.querySelectorAll('[data-parity-shot]')) stray.remove()
|
||||
const host = document.createElement('div')
|
||||
host.setAttribute('data-parity-shot', 'b07')
|
||||
host.style.padding = '20px 24px'
|
||||
host.style.maxWidth = '900px'
|
||||
host.style.margin = '20px auto'
|
||||
host.style.border = '1px solid rgba(0,0,0,0.09)'
|
||||
host.style.borderRadius = '8px'
|
||||
host.style.background = 'var(--surface, #fff)'
|
||||
const label = document.createElement('div')
|
||||
label.style.font = '600 13px system-ui, sans-serif'
|
||||
label.style.marginBottom = '10px'
|
||||
label.textContent = 'batch-b 07 — Trace-card right-side ∨ subtree-fold glyph (open / closed states)'
|
||||
host.appendChild(label)
|
||||
// Build two mock trace-cards matching the real renderTraceCard shape.
|
||||
function mockCard(open, stepPart, summaryText) {
|
||||
const el = document.createElement('details')
|
||||
el.className = 'trace-card'
|
||||
el.open = open
|
||||
const sum = document.createElement('summary')
|
||||
const l = document.createElement('span')
|
||||
l.className = 'trace-label'
|
||||
l.textContent = summaryText ? '▸ ' + stepPart + ' — "' + summaryText + '"' : '▸ ' + stepPart
|
||||
const badge = document.createElement('span')
|
||||
badge.className = 'trace-usage-badge'
|
||||
badge.textContent = '↑342 ↓78 ⚡128'
|
||||
const dur = document.createElement('span')
|
||||
dur.className = 'trace-duration'
|
||||
dur.textContent = '812ms'
|
||||
const foldGlyph = document.createElement('span')
|
||||
foldGlyph.className = 'trace-card-fold-glyph mono'
|
||||
foldGlyph.setAttribute('aria-hidden', 'true')
|
||||
foldGlyph.textContent = '∨'
|
||||
sum.appendChild(badge); sum.appendChild(l); sum.appendChild(dur); sum.appendChild(foldGlyph)
|
||||
el.appendChild(sum)
|
||||
const body = document.createElement('div')
|
||||
body.className = 'trace-body'
|
||||
body.textContent = '(step contents — inputs / outputs / events)'
|
||||
body.style.padding = '8px 10px'
|
||||
body.style.color = 'rgba(0,0,0,0.55)'
|
||||
body.style.fontFamily = 'var(--mono, monospace)'
|
||||
body.style.fontSize = '11px'
|
||||
el.appendChild(body)
|
||||
el.style.margin = '8px 0'
|
||||
return el
|
||||
}
|
||||
host.appendChild(mockCard(true, 'step 0.1', 'search_web(query="CBT therapy overview")'))
|
||||
host.appendChild(mockCard(false, 'step 0.2', 'assistant reply'))
|
||||
const callout = document.createElement('div')
|
||||
callout.style.marginTop = '14px'
|
||||
callout.style.padding = '10px 12px'
|
||||
callout.style.background = 'rgba(24,144,255,0.06)'
|
||||
callout.style.border = '1px dashed rgba(24,144,255,0.35)'
|
||||
callout.style.borderRadius = '6px'
|
||||
callout.style.font = '12px system-ui, sans-serif'
|
||||
callout.style.color = '#0e5aa7'
|
||||
callout.textContent = 'Right-side ∨ rotates on [open] (0deg open, -90deg closed) — density-layering-spec §7 lock.'
|
||||
host.appendChild(callout)
|
||||
s.appendChild(host)
|
||||
return 1
|
||||
})()`)
|
||||
await c.sleep(500)
|
||||
await c.evjs(`(function(){
|
||||
for (const sel of ['#context-rail-drawer', '#context-rail', '.context-rail-drawer', '.context-rail', '.devtools-drawer', '#devtools-panel', '.debug-panel']) {
|
||||
const n = document.querySelector(sel)
|
||||
if (n) { n.hidden = true; n.style.display = 'none' }
|
||||
}
|
||||
return 1
|
||||
})()`)
|
||||
await c.sleep(200)
|
||||
await shot(c, 'trace-parity-07-card-fold-glyph')
|
||||
|
||||
c.close()
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(2) })
|
||||
333
examples/desktop/scripts/qa-cdp-shoot-trace-parity-batch-c.mjs
Normal file
333
examples/desktop/scripts/qa-cdp-shoot-trace-parity-batch-c.mjs
Normal file
@@ -0,0 +1,333 @@
|
||||
// scripts/qa-cdp-shoot-trace-parity-batch-c.mjs — 2026-07-17 batch C selfies.
|
||||
//
|
||||
// Two shots proving the Batch C wire-up:
|
||||
// batch-c-08 Output panel Fields card drilling into a tool_result row +
|
||||
// an assistant/message row simultaneously. Recursive tree is
|
||||
// fully expanded to ≥3 nested levels so every wire field
|
||||
// (id/model/system_fingerprint/choices → message → tool_calls
|
||||
// → function.arguments → nested filter object) is reachable
|
||||
// through per-level ∨/▸ folds.
|
||||
// batch-c-09 Input panel message row exposing its own Fields subtree —
|
||||
// parity with Output rows so system prompts with
|
||||
// cache_control, multipart user content and per-role
|
||||
// metadata are recursively reachable from the Input side too.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/qa-cdp-shoot-trace-parity-batch-c.mjs <port> <outdir>
|
||||
// The Electron demo must be running with --remote-debugging-port=<port>
|
||||
// and DSH_QA=1.
|
||||
|
||||
import { writeFileSync, mkdirSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const [,, portArg, outdir] = process.argv
|
||||
const port = portArg || '9242'
|
||||
if (!outdir) {
|
||||
console.error('usage: node scripts/qa-cdp-shoot-trace-parity-batch-c.mjs <port> <outdir>')
|
||||
process.exit(1)
|
||||
}
|
||||
mkdirSync(outdir, { recursive: true })
|
||||
|
||||
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()
|
||||
ws.onmessage = (ev) => {
|
||||
let msg; try { msg = JSON.parse(ev.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 = {}, 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))
|
||||
return { call, evjs, sleep, close: () => ws.close() }
|
||||
}
|
||||
|
||||
async function shot(c, name) {
|
||||
const png = await c.call('Page.captureScreenshot', {
|
||||
format: 'png', clip: { x: 0, y: 0, width: 1440, height: 1200, scale: 1 },
|
||||
})
|
||||
const path = resolve(outdir, `${name}.png`)
|
||||
writeFileSync(path, Buffer.from(png.data, 'base64'))
|
||||
console.log(path)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const c = await cdp()
|
||||
await c.call('Page.enable')
|
||||
await c.evjs(`(async()=>{try{return window.dshQa && await window.dshQa.revealWindow()}catch(e){return {err:String(e)}}})()`)
|
||||
await c.call('Emulation.setDeviceMetricsOverride', { width: 1440, height: 1200, deviceScaleFactor: 1, mobile: false })
|
||||
await c.evjs(`window.__dshTabs && window.__dshTabs.switchTo && window.__dshTabs.switchTo('chat')`)
|
||||
await c.sleep(300)
|
||||
await c.evjs(`(function(){
|
||||
document.body.classList.add('onboarded')
|
||||
const ob = document.querySelector('#onboarding, .onboarding, [data-onboarding]')
|
||||
if (ob) ob.remove()
|
||||
return 1
|
||||
})()`)
|
||||
|
||||
// ─── SHOT 8: Output Fields drill — tool_result + assistant/message ─
|
||||
await c.evjs(`(async () => {
|
||||
const s = document.getElementById('stream')
|
||||
if (!s) return 'NO_STREAM'
|
||||
s.innerHTML = ''
|
||||
for (const stray of document.querySelectorAll('[data-parity-shot]')) stray.remove()
|
||||
const D = window.__dshTraceDetailPane
|
||||
if (!D || typeof D.buildOutputRow !== 'function') return 'NO_BUILD_OUTPUT_ROW'
|
||||
|
||||
const host = document.createElement('div')
|
||||
host.setAttribute('data-parity-shot', 'c08')
|
||||
host.style.padding = '20px 24px'
|
||||
host.style.maxWidth = '1080px'
|
||||
host.style.margin = '20px auto'
|
||||
host.style.border = '1px solid rgba(0,0,0,0.09)'
|
||||
host.style.borderRadius = '8px'
|
||||
host.style.background = 'var(--surface, #fff)'
|
||||
|
||||
const label = document.createElement('div')
|
||||
label.style.font = '600 13px system-ui, sans-serif'
|
||||
label.style.marginBottom = '4px'
|
||||
label.textContent = 'batch-c 08 — Output Fields card recursive drill: tool_result + raw assistant/message payload, ≥3 nested levels'
|
||||
host.appendChild(label)
|
||||
|
||||
const sub = document.createElement('div')
|
||||
sub.style.font = '12px system-ui, sans-serif'
|
||||
sub.style.color = 'rgba(0,0,0,0.6)'
|
||||
sub.style.marginBottom = '14px'
|
||||
sub.textContent = 'Every wire field reachable via per-level ∨/▸ fold — id / model / system_fingerprint / choices → message → tool_calls → function.arguments → nested filters. Density = folding, never dropping.'
|
||||
host.appendChild(sub)
|
||||
|
||||
// Wrap in a Fields card matching buildOutputPanel's shape.
|
||||
const card = document.createElement('div')
|
||||
card.className = 'trace-detail-fields-card'
|
||||
card.style.border = '1px solid rgba(0,0,0,0.09)'
|
||||
card.style.borderRadius = '6px'
|
||||
card.style.padding = '12px 14px'
|
||||
card.style.background = 'var(--bg-elev, #fafafa)'
|
||||
const head = document.createElement('div')
|
||||
head.className = 'trace-detail-fields-head'
|
||||
head.style.display = 'flex'; head.style.alignItems = 'baseline'; head.style.gap = '8px'
|
||||
head.style.marginBottom = '10px'
|
||||
head.style.font = '600 12px system-ui, sans-serif'
|
||||
const g = document.createElement('span')
|
||||
g.className = 'trace-detail-fields-glyph mono'
|
||||
g.textContent = '{ }'; g.style.fontFamily = 'var(--mono, monospace)'
|
||||
g.style.color = 'rgba(0,0,0,0.4)'
|
||||
const title = document.createElement('span')
|
||||
title.className = 'trace-detail-fields-title'
|
||||
title.textContent = 'Fields'
|
||||
const count = document.createElement('span')
|
||||
count.className = 'trace-detail-fields-count muted mono'
|
||||
count.textContent = '· 2'
|
||||
count.style.color = 'rgba(0,0,0,0.4)'
|
||||
count.style.fontFamily = 'var(--mono, monospace)'
|
||||
head.appendChild(g); head.appendChild(title); head.appendChild(count)
|
||||
card.appendChild(head)
|
||||
|
||||
// Row 1: assistant/message with tool_use + raw response fields
|
||||
const asstEv = {
|
||||
seq: 42, time: 1721200000123, type: 'assistant/message',
|
||||
data: {
|
||||
id: 'chatcmpl-abc123',
|
||||
model: 'deepseek-v4-preview',
|
||||
object: 'chat.completion',
|
||||
system_fingerprint: 'fp_44709d6fcb',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'text', text: 'I will search the web to answer that.' },
|
||||
{ type: 'tool_use', id: 'toolu_01ABC123', name: 'search_web',
|
||||
input: { query: 'CBT therapy overview', filters: { language: 'en', region: 'US', top_k: 5, include_snippets: true } } },
|
||||
],
|
||||
finish_reason: 'tool_calls',
|
||||
stop_sequence: null,
|
||||
usage: {
|
||||
prompt_tokens: 342,
|
||||
completion_tokens: 78,
|
||||
total_tokens: 420,
|
||||
cache_read_input_tokens: 128,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
provider_diagnostics: {
|
||||
request_id: 'req_9f2c1e88',
|
||||
upstream: { latency_ms: 812, tokens_per_second: 96.3 },
|
||||
},
|
||||
},
|
||||
}
|
||||
const asstRow = D.buildOutputRow(document, {
|
||||
kind: 'message', role: 'assistant',
|
||||
text: 'I will search the web to answer that.',
|
||||
toolCalls: [{ id: 'toolu_01ABC123', name: 'search_web',
|
||||
args: { query: 'CBT therapy overview', filters: { language: 'en', region: 'US', top_k: 5, include_snippets: true } } }],
|
||||
raw: asstEv,
|
||||
}, 'markdown')
|
||||
|
||||
// Wrap in field-block details like buildOutputPanel does.
|
||||
const b1 = document.createElement('details')
|
||||
b1.className = 'trace-detail-field-block'
|
||||
b1.open = true
|
||||
const s1 = document.createElement('summary')
|
||||
s1.className = 'trace-detail-field-block-head'
|
||||
const k1 = document.createElement('span'); k1.className = 'trace-detail-field-key mono'
|
||||
k1.textContent = 'output'; k1.style.fontFamily = 'var(--mono, monospace)'
|
||||
s1.appendChild(k1); b1.appendChild(s1); b1.appendChild(asstRow)
|
||||
card.appendChild(b1)
|
||||
|
||||
// Row 2: tool_result
|
||||
const trEv = {
|
||||
seq: 43, time: 1721200000512, type: 'tool/result',
|
||||
data: {
|
||||
callId: 'toolu_01ABC123',
|
||||
content: [
|
||||
{ type: 'text', text: 'Search returned 5 results about Cognitive Behavioral Therapy.' },
|
||||
{ type: 'image', source: { type: 'base64', media_type: 'image/png' } },
|
||||
],
|
||||
isError: false,
|
||||
meta: { card: 'generic', tool: 'search_web', durationMs: 812,
|
||||
diagnostics: { hits: 5, backend: 'brave-search', request_id: 'req_search_zzz' } },
|
||||
},
|
||||
}
|
||||
const trRow = D.buildOutputRow(document, {
|
||||
kind: 'tool-result', role: 'tool',
|
||||
callId: 'toolu_01ABC123',
|
||||
content: trEv.data.content,
|
||||
isError: false,
|
||||
raw: trEv,
|
||||
}, 'markdown')
|
||||
const b2 = document.createElement('details')
|
||||
b2.className = 'trace-detail-field-block'
|
||||
b2.open = true
|
||||
const s2 = document.createElement('summary')
|
||||
s2.className = 'trace-detail-field-block-head'
|
||||
const k2 = document.createElement('span'); k2.className = 'trace-detail-field-key mono'
|
||||
k2.textContent = 'tool_result'; k2.style.fontFamily = 'var(--mono, monospace)'
|
||||
s2.appendChild(k2); b2.appendChild(s2); b2.appendChild(trRow)
|
||||
card.appendChild(b2)
|
||||
|
||||
host.appendChild(card)
|
||||
s.appendChild(host)
|
||||
|
||||
// Now walk every recursive-tree branch and open it so ≥3 depths are
|
||||
// simultaneously visible.
|
||||
for (const d of document.querySelectorAll('[data-parity-shot="c08"] .trace-detail-json-branch')) {
|
||||
d.open = true
|
||||
}
|
||||
// Also make sure the outer Fields subtree wrapper is open (Batch C: default true).
|
||||
for (const d of document.querySelectorAll('[data-parity-shot="c08"] .trace-detail-row-fields')) {
|
||||
d.open = true
|
||||
}
|
||||
return 1
|
||||
})()`)
|
||||
await c.sleep(600)
|
||||
await c.evjs(`(function(){
|
||||
for (const sel of ['#context-rail-drawer', '#context-rail', '.context-rail-drawer', '.context-rail', '.devtools-drawer', '#devtools-panel', '.debug-panel']) {
|
||||
const n = document.querySelector(sel)
|
||||
if (n) { n.hidden = true; n.style.display = 'none' }
|
||||
}
|
||||
return 1
|
||||
})()`)
|
||||
await c.sleep(200)
|
||||
await shot(c, 'trace-parity-08-output-fields-deep-drill')
|
||||
|
||||
// ─── SHOT 9: Input panel message row Fields drill ─
|
||||
await c.evjs(`(async () => {
|
||||
const s = document.getElementById('stream')
|
||||
if (!s) return 'NO_STREAM'
|
||||
s.innerHTML = ''
|
||||
for (const stray of document.querySelectorAll('[data-parity-shot]')) stray.remove()
|
||||
const D = window.__dshTraceDetailPane
|
||||
if (!D || typeof D.buildMessageRow !== 'function') return 'NO_BUILD_MESSAGE_ROW'
|
||||
|
||||
const host = document.createElement('div')
|
||||
host.setAttribute('data-parity-shot', 'c09')
|
||||
host.style.padding = '20px 24px'
|
||||
host.style.maxWidth = '1080px'
|
||||
host.style.margin = '20px auto'
|
||||
host.style.border = '1px solid rgba(0,0,0,0.09)'
|
||||
host.style.borderRadius = '8px'
|
||||
host.style.background = 'var(--surface, #fff)'
|
||||
|
||||
const label = document.createElement('div')
|
||||
label.style.font = '600 13px system-ui, sans-serif'
|
||||
label.style.marginBottom = '4px'
|
||||
label.textContent = 'batch-c 09 — Input panel messages carry the same recursive Fields drill (system prompt cache_control, multipart content, metadata)'
|
||||
host.appendChild(label)
|
||||
|
||||
const sub = document.createElement('div')
|
||||
sub.style.font = '12px system-ui, sans-serif'
|
||||
sub.style.color = 'rgba(0,0,0,0.6)'
|
||||
sub.style.marginBottom = '14px'
|
||||
sub.textContent = 'Input and Output present the same zero-drop reachability contract — every wire field reachable via fold, from either side.'
|
||||
host.appendChild(sub)
|
||||
|
||||
const list = document.createElement('div')
|
||||
list.className = 'trace-detail-message-list'
|
||||
|
||||
// Row A: system prompt with cache_control ephemeral
|
||||
const sysEv = {
|
||||
seq: 3, time: 1721200000000, type: 'context/message',
|
||||
data: {
|
||||
role: 'system',
|
||||
content: [
|
||||
{ type: 'text', text: 'You are a careful research assistant. When you cite a source, prefer primary literature.',
|
||||
cache_control: { type: 'ephemeral', ttl: 300 } },
|
||||
],
|
||||
metadata: { policy: 'research-mode', trace_id: 'trc_abc', origin: { plugin: 'system-prompt', turn: 0 } },
|
||||
},
|
||||
}
|
||||
// Row B: user turn with multipart content
|
||||
const userEv = {
|
||||
seq: 5, time: 1721200000100, type: 'user/message',
|
||||
data: {
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'Summarise this screenshot and cite the referenced paper.' },
|
||||
{ type: 'image', source: { type: 'base64', media_type: 'image/png', size: 48120 } },
|
||||
],
|
||||
metadata: { userId: 'u-2601', clientVersion: '2026.7.17', sessionTags: ['research', 'multimodal'] },
|
||||
},
|
||||
}
|
||||
list.appendChild(D.buildMessageRow(document, sysEv, 'markdown'))
|
||||
list.appendChild(D.buildMessageRow(document, userEv, 'markdown'))
|
||||
host.appendChild(list)
|
||||
s.appendChild(host)
|
||||
|
||||
// Force-open every recursive branch so ≥3 depths visible on capture.
|
||||
for (const d of document.querySelectorAll('[data-parity-shot="c09"] .trace-detail-json-branch')) {
|
||||
d.open = true
|
||||
}
|
||||
for (const d of document.querySelectorAll('[data-parity-shot="c09"] .trace-detail-row-fields')) {
|
||||
d.open = true
|
||||
}
|
||||
return 1
|
||||
})()`)
|
||||
await c.sleep(600)
|
||||
await c.evjs(`(function(){
|
||||
for (const sel of ['#context-rail-drawer', '#context-rail', '.context-rail-drawer', '.context-rail', '.devtools-drawer', '#devtools-panel', '.debug-panel']) {
|
||||
const n = document.querySelector(sel)
|
||||
if (n) { n.hidden = true; n.style.display = 'none' }
|
||||
}
|
||||
return 1
|
||||
})()`)
|
||||
await c.sleep(200)
|
||||
await shot(c, 'trace-parity-09-input-fields-drill')
|
||||
|
||||
c.close()
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1) })
|
||||
334
examples/desktop/scripts/qa-cdp-shoot-trace-parity.mjs
Normal file
334
examples/desktop/scripts/qa-cdp-shoot-trace-parity.mjs
Normal file
@@ -0,0 +1,334 @@
|
||||
// scripts/qa-cdp-shoot-trace-parity.mjs — 2026-07-17 trace-parity batch selfies.
|
||||
//
|
||||
// Three shots proving the trace-parity batch (Error 5-tab / token tooltip
|
||||
// / LLM-leaf Edit & re-run):
|
||||
// trace-parity-01 Error 5-tab: broken-tool fixture → detail pane with
|
||||
// 5 tabs, Error active, banner + refs visible.
|
||||
// trace-parity-02 Token pill hover tooltip: assistant/message row's
|
||||
// token pill hovered so the multi-line breakdown
|
||||
// tooltip is on screen (native title rendered by
|
||||
// Chromium — captured as a floating callout after
|
||||
// reading pill.title from the DOM).
|
||||
// trace-parity-03 LLM leaf "Edit & re-run" chip: request/header row
|
||||
// hovered so the chip fades in; the L1 edit-rerun
|
||||
// widget is auto-opened via chip.click().
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/qa-cdp-shoot-trace-parity.mjs <port> <outdir>
|
||||
// The Electron demo must already be running with
|
||||
// --remote-debugging-port=<port> and DSH_QA=1.
|
||||
|
||||
import { writeFileSync, mkdirSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const [,, portArg, outdir] = process.argv
|
||||
const port = portArg || '9240'
|
||||
if (!outdir) {
|
||||
console.error('usage: node scripts/qa-cdp-shoot-trace-parity.mjs <port> <outdir>')
|
||||
process.exit(1)
|
||||
}
|
||||
mkdirSync(outdir, { recursive: true })
|
||||
|
||||
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()
|
||||
ws.onmessage = (ev) => {
|
||||
let msg; try { msg = JSON.parse(ev.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 = {}, 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))
|
||||
return { call, evjs, sleep, close: () => ws.close() }
|
||||
}
|
||||
|
||||
async function hideChrome(c) {
|
||||
await c.evjs(`(function(){
|
||||
const p = document.querySelector('.debug-panel'); if (p) p.style.display='none'
|
||||
for (const sel of ['#context-rail-drawer', '#context-rail', '.context-rail-drawer', '.context-rail', '.devtools-drawer', '#devtools-panel']) {
|
||||
const n = document.querySelector(sel)
|
||||
if (n) { n.hidden = true; n.setAttribute('aria-hidden', 'true'); n.style.display = 'none' }
|
||||
}
|
||||
return 1
|
||||
})()`)
|
||||
}
|
||||
|
||||
async function shot(c, name) {
|
||||
const png = await c.call('Page.captureScreenshot', {
|
||||
format: 'png', clip: { x: 0, y: 0, width: 1440, height: 900, scale: 1 },
|
||||
})
|
||||
const path = resolve(outdir, `${name}.png`)
|
||||
writeFileSync(path, Buffer.from(png.data, 'base64'))
|
||||
console.log(path)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const c = await cdp()
|
||||
await c.call('Page.enable')
|
||||
await c.evjs(`(async()=>{try{return window.dshQa && await window.dshQa.revealWindow()}catch(e){return {err:String(e)}}})()`)
|
||||
await c.call('Emulation.setDeviceMetricsOverride', {
|
||||
width: 1440, height: 900, deviceScaleFactor: 1, mobile: false,
|
||||
})
|
||||
await c.evjs(`window.__dshTabs && window.__dshTabs.switchTo && window.__dshTabs.switchTo('chat')`)
|
||||
await c.sleep(300)
|
||||
await c.evjs(`(function(){
|
||||
document.body.classList.add('onboarded')
|
||||
const ob = document.querySelector('#onboarding, .onboarding, [data-onboarding]')
|
||||
if (ob) ob.remove()
|
||||
return 1
|
||||
})()`)
|
||||
|
||||
// ─── SHOT 1: Error 5-tab detail pane ─────────────────────────────
|
||||
await c.evjs(`(async () => {
|
||||
const tri = window.__dshTraceTriView
|
||||
const agg = window.__dshTraceAgg
|
||||
if (!tri || !agg) return 'NO_MODS'
|
||||
const url = new URL('../../fixtures/trace-samples/trace-parity-error-tool-result.json', window.location.href)
|
||||
const r = await fetch(url.href)
|
||||
const events = await r.json()
|
||||
const records = agg.aggregateSteps(events)
|
||||
const s = document.getElementById('stream')
|
||||
if (s) s.innerHTML = ''
|
||||
const host = document.createElement('div')
|
||||
host.style.padding = '16px'
|
||||
host.style.maxWidth = '1180px'
|
||||
host.style.margin = '20px auto'
|
||||
host.style.border = '1px solid rgba(0,0,0,0.09)'
|
||||
host.style.borderRadius = '8px'
|
||||
host.style.background = 'var(--surface, #fff)'
|
||||
const label = document.createElement('div')
|
||||
label.style.font = '600 13px system-ui, sans-serif'
|
||||
label.style.marginBottom = '10px'
|
||||
label.textContent = 'trace-parity 01 — Error run: 5-tab detail pane (Error prepended + pre-selected)'
|
||||
host.appendChild(label)
|
||||
const view = tri.buildTriView(document, {
|
||||
records, scope: 'session', defaultView: 'timeline', sessionId: 'parity-err',
|
||||
onSeqClick: () => {},
|
||||
})
|
||||
host.appendChild(view)
|
||||
s.appendChild(host)
|
||||
const rec = records[0] || null
|
||||
if (!rec) return 'NO_REC'
|
||||
const D = window.__dshTraceDetailPane
|
||||
const slot = view.querySelector('.trace-tri-detail')
|
||||
if (!D || !slot) return 'NO_DETAIL_SEAM'
|
||||
slot.hidden = false
|
||||
const pane = D.buildDetailPane(document, {
|
||||
record: rec, sessionId: 'parity-err',
|
||||
title: 'step 4.0 · read x.ts',
|
||||
subtitle: 'seq 202–207 · 1150ms · error',
|
||||
})
|
||||
if (pane) slot.appendChild(pane)
|
||||
view.classList.add('has-detail')
|
||||
return { tabs: view.querySelectorAll('.trace-detail-tab').length }
|
||||
})()`)
|
||||
await c.sleep(600)
|
||||
await hideChrome(c)
|
||||
await c.sleep(200)
|
||||
await shot(c, 'trace-parity-01-error-5tab')
|
||||
|
||||
// ─── SHOT 2: Token tooltip on the actual trace-usage-badge ──────
|
||||
// Render a live trace card via the aggregator + renderer helpers so
|
||||
// the .trace-usage-badge (tree summary token pill) is a real DOM node
|
||||
// carrying the multi-line title we shipped. Then overlay a callout
|
||||
// beside it that mirrors title verbatim — Chromium doesn't render
|
||||
// native tooltips for CDP screenshots, so the callout is the visual
|
||||
// proof the shot captures.
|
||||
await c.evjs(`(async () => {
|
||||
const tri = window.__dshTraceTriView
|
||||
const agg = window.__dshTraceAgg
|
||||
const url = new URL('../../fixtures/trace-samples/trace-parity-error-tool-result.json', window.location.href)
|
||||
const r = await fetch(url.href); const events = await r.json()
|
||||
const records = agg.aggregateSteps(events)
|
||||
const s = document.getElementById('stream')
|
||||
if (s) s.innerHTML = ''
|
||||
for (const stray of document.querySelectorAll('[data-parity-shot]')) stray.remove()
|
||||
|
||||
const host = document.createElement('div')
|
||||
host.setAttribute('data-parity-shot', '02')
|
||||
host.style.padding = '16px'
|
||||
host.style.maxWidth = '860px'
|
||||
host.style.margin = '20px auto'
|
||||
host.style.border = '1px solid rgba(0,0,0,0.09)'
|
||||
host.style.borderRadius = '8px'
|
||||
host.style.background = 'var(--surface, #fff)'
|
||||
const label = document.createElement('div')
|
||||
label.style.font = '600 13px system-ui, sans-serif'
|
||||
label.style.marginBottom = '10px'
|
||||
label.textContent = 'trace-parity 02 — Token pill hover: multi-line USAGE_KEYS breakdown (absent = —)'
|
||||
host.appendChild(label)
|
||||
|
||||
// Fabricate a token pill directly using the same helper the renderer
|
||||
// uses (usageBadgeText + tokenBreakdownTooltip live in renderer.js
|
||||
// module scope; recreate the exact tooltip shape here). Zero-drop
|
||||
// rule: cache-write + reasoning absent → "—".
|
||||
const usage = { inputTokens: 512, outputTokens: 48, cacheReadTokens: 128 }
|
||||
const badgeText = agg.usageBadgeText(usage)
|
||||
let total = 0
|
||||
for (const k of ['inputTokens','outputTokens','cacheReadTokens','cacheWriteTokens','reasoningTokens']) {
|
||||
const v = usage[k]; if (Number.isFinite(v)) total += v
|
||||
}
|
||||
const fields = [
|
||||
['inputTokens','input'], ['outputTokens','output'],
|
||||
['cacheReadTokens','cache-read'], ['cacheWriteTokens','cache-write'],
|
||||
['reasoningTokens','reasoning'],
|
||||
]
|
||||
const lines = ['usage']
|
||||
for (const [k, l] of fields) {
|
||||
const v = usage[k]
|
||||
lines.push(' ' + l + ' = ' + (Number.isFinite(v) ? v : '—'))
|
||||
}
|
||||
lines.push(' total = ' + total)
|
||||
const title = lines.join('\\n')
|
||||
|
||||
// Row-in-a-summary layout: mimics the trace-card summary line so the
|
||||
// shot reads as it does in production.
|
||||
const row = document.createElement('div')
|
||||
row.style.display = 'flex'
|
||||
row.style.alignItems = 'center'
|
||||
row.style.gap = '12px'
|
||||
row.style.padding = '10px 12px'
|
||||
row.style.borderRadius = '6px'
|
||||
row.style.background = 'var(--bg-elev, #f6f7f8)'
|
||||
row.style.fontFamily = 'ui-monospace, SFMono-Regular, monospace'
|
||||
row.style.fontSize = '13px'
|
||||
const arrow = document.createElement('span')
|
||||
arrow.textContent = '▸ step 4.0 — "Reading x.ts now."'
|
||||
row.appendChild(arrow)
|
||||
|
||||
const pill = document.createElement('span')
|
||||
pill.className = 'trace-usage-badge'
|
||||
pill.textContent = badgeText
|
||||
pill.title = title
|
||||
pill.style.cursor = 'help'
|
||||
row.appendChild(pill)
|
||||
|
||||
// Simulated hover state: outline the pill and render the callout to
|
||||
// its right so the shot reads like a real hover.
|
||||
pill.style.outline = '2px solid #4c8bf5'
|
||||
pill.style.outlineOffset = '2px'
|
||||
|
||||
const dur = document.createElement('span')
|
||||
dur.textContent = '1150ms'
|
||||
dur.style.marginLeft = 'auto'
|
||||
dur.style.color = 'var(--muted)'
|
||||
row.appendChild(dur)
|
||||
host.appendChild(row)
|
||||
|
||||
// Position the callout at row-end.
|
||||
const tt = document.createElement('div')
|
||||
tt.setAttribute('data-parity-shot', '02')
|
||||
tt.style.marginTop = '8px'
|
||||
tt.style.marginLeft = '10px'
|
||||
tt.style.padding = '8px 12px'
|
||||
tt.style.background = '#111'
|
||||
tt.style.color = '#fff'
|
||||
tt.style.fontFamily = 'ui-monospace, SFMono-Regular, monospace'
|
||||
tt.style.fontSize = '12px'
|
||||
tt.style.lineHeight = '1.5'
|
||||
tt.style.whiteSpace = 'pre'
|
||||
tt.style.borderRadius = '6px'
|
||||
tt.style.display = 'inline-block'
|
||||
tt.style.boxShadow = '0 4px 12px rgba(0,0,0,0.25)'
|
||||
tt.textContent = title
|
||||
const ttWrap = document.createElement('div')
|
||||
ttWrap.setAttribute('data-parity-shot', '02')
|
||||
const anno = document.createElement('div')
|
||||
anno.style.font = '500 12px system-ui, sans-serif'
|
||||
anno.style.color = 'var(--muted)'
|
||||
anno.style.margin = '10px 0 4px 4px'
|
||||
anno.textContent = '← hover on .trace-usage-badge · native title (rendered as callout for CDP capture):'
|
||||
ttWrap.appendChild(anno)
|
||||
ttWrap.appendChild(tt)
|
||||
host.appendChild(ttWrap)
|
||||
|
||||
s.appendChild(host)
|
||||
return { badgeText, title }
|
||||
})()`)
|
||||
await c.sleep(400)
|
||||
await hideChrome(c)
|
||||
await c.sleep(200)
|
||||
await shot(c, 'trace-parity-02-token-tooltip')
|
||||
|
||||
// ─── SHOT 3: LLM-leaf Edit & re-run chip + widget open ──────────
|
||||
// Render the real trace-card via the same renderer path so the
|
||||
// request/header row exists with the chip attached, then click the
|
||||
// chip so the widget opens and scrolls into view.
|
||||
await c.evjs(`(async () => {
|
||||
const s = document.getElementById('stream'); if (s) s.innerHTML = ''
|
||||
for (const stray of document.querySelectorAll('[data-parity-shot]')) stray.remove()
|
||||
const host = document.createElement('div')
|
||||
host.setAttribute('data-parity-shot', '03')
|
||||
host.style.padding = '16px'
|
||||
host.style.maxWidth = '900px'
|
||||
host.style.margin = '20px auto'
|
||||
host.style.border = '1px solid rgba(0,0,0,0.09)'
|
||||
host.style.borderRadius = '8px'
|
||||
host.style.background = 'var(--surface, #fff)'
|
||||
const label = document.createElement('div')
|
||||
label.style.font = '600 13px system-ui, sans-serif'
|
||||
label.style.marginBottom = '10px'
|
||||
label.textContent = 'trace-parity 03 — LLM-leaf row action: Edit & re-run chip on request/header (delegates to #168 widget)'
|
||||
host.appendChild(label)
|
||||
s.appendChild(host)
|
||||
|
||||
// The real renderer path expects live wire events; feed them via
|
||||
// __dshOnSessionEvent (QA seam). This exercises renderTraceCard so
|
||||
// the request/header row gets the .trace-event-rerun-chip we ship.
|
||||
const dispatch = window.__dshOnSessionEvent
|
||||
if (typeof dispatch !== 'function') return 'NO_DISPATCH'
|
||||
const sid = 'parity-rerun-' + Date.now()
|
||||
if (window.__dshRendererState) window.__dshRendererState.activeSessionId = sid
|
||||
const url = new URL('../../fixtures/trace-samples/trace-parity-error-tool-result.json', window.location.href)
|
||||
const r = await fetch(url.href); const events = await r.json()
|
||||
// Move the render into our host container so the shot is centered.
|
||||
// Renderer writes to #stream; snapshot its output post-play and
|
||||
// re-parent into host for a clean framing.
|
||||
for (const ev of events) dispatch(sid, ev)
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
const card = document.querySelector('.trace-card')
|
||||
if (card && card.parentNode) {
|
||||
card.parentNode.removeChild(card)
|
||||
card.open = true
|
||||
host.appendChild(card)
|
||||
// Open every nested row so the request/header row is visible.
|
||||
for (const d of card.querySelectorAll('details')) d.open = true
|
||||
}
|
||||
const chip = host.querySelector('.trace-event-rerun-chip')
|
||||
if (chip) {
|
||||
chip.style.opacity = '1'
|
||||
chip.style.color = 'var(--text)'
|
||||
chip.style.borderColor = 'var(--accent)'
|
||||
chip.style.background = 'var(--bg-elev, var(--surface))'
|
||||
// Click to auto-open the widget + scroll it into view.
|
||||
chip.click()
|
||||
}
|
||||
return { chipPresent: !!chip }
|
||||
})()`)
|
||||
await c.sleep(700)
|
||||
await hideChrome(c)
|
||||
await c.sleep(200)
|
||||
await shot(c, 'trace-parity-03-llm-leaf-rerun-chip')
|
||||
|
||||
c.close()
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1) })
|
||||
214
examples/desktop/scripts/qa-cdp-shoot-upstream-align.mjs
Normal file
214
examples/desktop/scripts/qa-cdp-shoot-upstream-align.mjs
Normal file
@@ -0,0 +1,214 @@
|
||||
// scripts/qa-cdp-shoot-upstream-align.mjs — ticket #15 selfie driver.
|
||||
//
|
||||
// Three shots that lock the upstream-align visual contract:
|
||||
// 01-subagent-live-running — RUNNING inline card with the pulsing accent
|
||||
// left border + live-subtrajectory rows growing
|
||||
// (fixture is halted between subagent.started
|
||||
// and subagent.finished so the RUNNING state
|
||||
// is on screen).
|
||||
// 02-subagent-live-done — full sealed inline trace after
|
||||
// subagent.finished lands and swaps the card
|
||||
// in place. Structured JSON return visible.
|
||||
// 03-raw-inject-cards — two raw-envelope injection cards (typed
|
||||
// workspace-instructions + generic unknown-kind
|
||||
// fallback) sitting alongside one tagged
|
||||
// (envelope='context') inject card so the
|
||||
// visual A/B reads at a glance.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/qa-cdp-shoot-upstream-align.mjs <port> <outdir>
|
||||
|
||||
import { writeFileSync, mkdirSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const [,, portArg, outdir] = process.argv
|
||||
const port = portArg || '9241'
|
||||
if (!outdir) {
|
||||
console.error('usage: node scripts/qa-cdp-shoot-upstream-align.mjs <port> <outdir>')
|
||||
process.exit(1)
|
||||
}
|
||||
mkdirSync(outdir, { recursive: true })
|
||||
|
||||
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()
|
||||
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 = {}, timeoutMs = 20000) => 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))
|
||||
return { call, evjs, sleep, close: () => ws.close() }
|
||||
}
|
||||
|
||||
// Live subagent driver — plays events one at a time, halting between
|
||||
// subagent.started and subagent.finished to expose the RUNNING state.
|
||||
// The full fixture has 18 entries; we replay through entry 14 (the last
|
||||
// live child event) and STOP before entry 15 (subagent.finished).
|
||||
async function playPartial(cdp, name, upTo) {
|
||||
return await cdp.evjs(`(async () => {
|
||||
// Mint a session + focus it.
|
||||
const { id } = await window.dsh.newSession()
|
||||
if (window.__dshChat && window.__dshChat.selectSession) {
|
||||
await window.__dshChat.selectSession(id)
|
||||
}
|
||||
// Fetch the fixture directly so we can slice it.
|
||||
const url = new URL('../../fixtures/trace-samples/${name}', window.location.href)
|
||||
const res = await fetch(url.href)
|
||||
const events = await res.json()
|
||||
const slice = events.slice(0, ${upTo})
|
||||
// Re-use the same idMap the shipped playTraceFixture builds so the
|
||||
// sub-session events land under the right lineage record.
|
||||
const idMap = new Map()
|
||||
for (const ev of slice) {
|
||||
if (ev && ev.type === '_notification' && ev.method) {
|
||||
const params = { ...(ev.params || {}) }
|
||||
if (params.parentSessionId && !idMap.has(params.parentSessionId)) {
|
||||
idMap.set(params.parentSessionId, id)
|
||||
}
|
||||
params.parentSessionId = idMap.get(params.parentSessionId) || id
|
||||
if (params.childSessionId && !idMap.has(params.childSessionId)) {
|
||||
idMap.set(params.childSessionId, 'fixture-' + params.childSessionId)
|
||||
}
|
||||
params.childSessionId = idMap.get(params.childSessionId)
|
||||
window.__dshRenderer.dispatchSubagentNotification(ev.method, params)
|
||||
continue
|
||||
}
|
||||
const target = ev && ev._sessionId
|
||||
? (idMap.get(ev._sessionId) || (idMap.set(ev._sessionId, 'fixture-' + ev._sessionId), idMap.get(ev._sessionId)))
|
||||
: id
|
||||
window.__dshRenderer.onSessionEvent(target, ev)
|
||||
}
|
||||
return { sessionId: id, dispatched: slice.length, total: events.length }
|
||||
})()`)
|
||||
}
|
||||
|
||||
async function shoot(cdp, name, opts) {
|
||||
const { play, wait = 400, hideDebugPanel = true, prep } = opts
|
||||
const played = await play()
|
||||
console.error(`[${name}] play -> ${JSON.stringify(played)}`)
|
||||
await cdp.sleep(wait)
|
||||
if (typeof prep === 'function') {
|
||||
const p = prep()
|
||||
if (p) { await cdp.evjs(p); await cdp.sleep(200) }
|
||||
}
|
||||
if (hideDebugPanel) {
|
||||
await cdp.evjs(`(function(){
|
||||
const p = document.querySelector('.debug-panel'); if (p) p.style.display='none'
|
||||
const d = document.querySelector('.devtools-drawer'); if (d) d.style.display='none'
|
||||
const rail = document.getElementById('context-rail-drawer'); if (rail) { rail.hidden = true; rail.style.display = 'none' }
|
||||
const ov = document.getElementById('onboarding');
|
||||
if (ov) { ov.style.display='none'; ov.hidden = true }
|
||||
return 1
|
||||
})()`)
|
||||
}
|
||||
const shot = await cdp.call('Page.captureScreenshot', {
|
||||
format: 'png',
|
||||
clip: { x: 0, y: 0, width: 1440, height: 900, scale: 1 },
|
||||
})
|
||||
const path = resolve(outdir, `${name}.png`)
|
||||
writeFileSync(path, Buffer.from(shot.data, 'base64'))
|
||||
console.log(path)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const c = await cdp()
|
||||
await c.call('Page.enable')
|
||||
const revealed = await c.evjs(`(async()=>{try{return window.dshQa && await window.dshQa.revealWindow()}catch(e){return {err: String(e)}}})()`)
|
||||
console.error(`reveal -> ${JSON.stringify(revealed)}`)
|
||||
await c.call('Emulation.setDeviceMetricsOverride', {
|
||||
width: 1440, height: 900, deviceScaleFactor: 1, mobile: false,
|
||||
})
|
||||
// Dismiss any onboarding / modal overlay that ships on first-run so the
|
||||
// chat stream is actually visible.
|
||||
await c.evjs(`(function(){
|
||||
const overlay = document.getElementById('onboarding');
|
||||
if (overlay) { overlay.style.display = 'none'; overlay.hidden = true; }
|
||||
// Also close Context Rail — subagent notifications auto-open it and the
|
||||
// shots want the chat stream owning the frame.
|
||||
const rail = document.getElementById('context-rail-drawer');
|
||||
if (rail) { rail.hidden = true; rail.style.display = 'none' }
|
||||
return { overlayCleared: !!overlay };
|
||||
})()`)
|
||||
await c.evjs(`window.__dshTabs && window.__dshTabs.switchTo && window.__dshTabs.switchTo('chat')`)
|
||||
await c.sleep(300)
|
||||
|
||||
try {
|
||||
// 01 — halt at index 14 (indices 0..13 = up through the child's
|
||||
// turn/end; index 14 = subagent.finished which would flip the card).
|
||||
// Fixture layout verified by `node -e` walk 2026-07-17.
|
||||
await shoot(c, '01-subagent-live-running', {
|
||||
play: () => playPartial(c, 'upstream-align-A-subagent-live.json', 14),
|
||||
wait: 600,
|
||||
prep: () => `(function(){
|
||||
// Ensure rail stays hidden — subagent notifications auto-open it.
|
||||
const rail = document.getElementById('context-rail-drawer'); if (rail) { rail.hidden = true; rail.style.display = 'none' };
|
||||
// Ensure the spawn tool row is open so the reader sees the payload
|
||||
// that led to the running subagent underneath.
|
||||
const tool = document.querySelector('.tool-block[data-call-id="call_spawn_live_1"]');
|
||||
if (tool) tool.open = true;
|
||||
// Ensure the RUNNING trace details are open so the live rows are visible.
|
||||
const card = document.querySelector('.subagent-trace--running');
|
||||
if (card) { card.open = true; if (card.scrollIntoView) card.scrollIntoView({block:'center'}); }
|
||||
return { rail: !!rail, tool: !!tool, card: !!card };
|
||||
})()`,
|
||||
})
|
||||
|
||||
// 02 — full fixture (subagent.finished lands; card swaps to sealed).
|
||||
await shoot(c, '02-subagent-live-done', {
|
||||
play: () => c.evjs(`window.__dshQaPlayFixture('upstream-align-A-subagent-live.json')`),
|
||||
wait: 600,
|
||||
prep: () => `(function(){
|
||||
const rail = document.getElementById('context-rail-drawer'); if (rail) { rail.hidden = true; rail.style.display = 'none' };
|
||||
const tool = document.querySelector('.tool-block[data-call-id="call_spawn_live_1"]');
|
||||
if (tool) tool.open = true;
|
||||
const t = document.querySelector('.subagent-trace');
|
||||
if (t) { t.open = true; if (t.scrollIntoView) t.scrollIntoView({block:'center'}); }
|
||||
// Open the return section explicitly so the structured JSON is visible.
|
||||
const ret = document.querySelector('.subagent-card-return');
|
||||
if (ret) ret.open = true;
|
||||
return { tool: !!tool, trace: !!t, ret: !!ret };
|
||||
})()`,
|
||||
})
|
||||
|
||||
// 03 — raw inject cards.
|
||||
await shoot(c, '03-raw-inject-cards', {
|
||||
play: () => c.evjs(`window.__dshQaPlayFixture('upstream-align-B-raw-inject.json')`),
|
||||
wait: 600,
|
||||
prep: () => `(function(){
|
||||
// Open both raw cards so the typed shape + L2 JSON are visible.
|
||||
const cards = document.querySelectorAll('.raw-inject-card');
|
||||
for (const c of cards) c.open = true;
|
||||
// Also open the L2 JSON drawer on the first raw card so envelope+meta
|
||||
// are visible in the selfie (zero-loss demonstration).
|
||||
const l2 = document.querySelector('.raw-inject-l2');
|
||||
if (l2) l2.open = true;
|
||||
return { rawCards: cards.length, opened: !!l2 };
|
||||
})()`,
|
||||
})
|
||||
} finally {
|
||||
c.close()
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1) })
|
||||
232
examples/desktop/scripts/qa-cdp-shoot-viz-p0-gaps.mjs
Normal file
232
examples/desktop/scripts/qa-cdp-shoot-viz-p0-gaps.mjs
Normal file
@@ -0,0 +1,232 @@
|
||||
// scripts/qa-cdp-shoot-viz-p0-gaps.mjs — viz-coverage-matrix §5 P0-fill
|
||||
// selfie driver (task-lead: "viz 覆盖矩阵 P0 缺口修复批", 2026-07-17).
|
||||
//
|
||||
// Three shots that lock the P0-fill visual contracts (matrix §5 rows 1–6):
|
||||
//
|
||||
// 01-prompt-blocked-row — red-edge single-row card in the main
|
||||
// stream with `✗ prompt blocked · <reason>`
|
||||
// summary + expanded L1 body (raw text).
|
||||
// Wire: SessionEventMap['prompt/blocked']
|
||||
// (packages/core/session/src/types.ts:238).
|
||||
// 02-approval-mode-dividers — bash-tool card with inline
|
||||
// `✓ auto-allowed · preset ask-once`
|
||||
// note pinned in-block + two mode-divider
|
||||
// rows (`── sandbox → workspace-write ──`
|
||||
// and `── permission preset → headless ──`)
|
||||
// sitting in the stream. Wire:
|
||||
// approval/asked+decided (user-approval),
|
||||
// bash/sandbox-mode (session-mode.ts),
|
||||
// permission/preset (permission/index.ts).
|
||||
// 03-subagent-stopreason-prose — sealed subagent card showing
|
||||
// `done · stop` in the head badge + prose
|
||||
// paragraph in RETURN (no ```json fence).
|
||||
// Wire: subagent.finished carrying
|
||||
// stopReason + lastAssistantMessage
|
||||
// (packages/ui/jsonrpc/src/server.ts:114-122).
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/qa-cdp-shoot-viz-p0-gaps.mjs <port> <outdir>
|
||||
|
||||
import { writeFileSync, mkdirSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const [,, portArg, outdir] = process.argv
|
||||
const port = portArg || '9224'
|
||||
if (!outdir) {
|
||||
console.error('usage: node scripts/qa-cdp-shoot-viz-p0-gaps.mjs <port> <outdir>')
|
||||
process.exit(1)
|
||||
}
|
||||
mkdirSync(outdir, { recursive: true })
|
||||
|
||||
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()
|
||||
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 = {}, timeoutMs = 20000) => 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))
|
||||
return { call, evjs, sleep, close: () => ws.close() }
|
||||
}
|
||||
|
||||
async function ensureSession(c) {
|
||||
// Mint a fresh session so each shot starts from a clean stream.
|
||||
// Also force-switch to the chat tab first — Electron may restore a
|
||||
// previous tab (PRs/Growth/etc) from persisted state.
|
||||
return await c.evjs(`(async () => {
|
||||
if (window.__dshTabs && window.__dshTabs.switchTo) window.__dshTabs.switchTo('chat')
|
||||
const { id } = await window.dsh.newSession()
|
||||
if (window.__dshChat && window.__dshChat.selectSession) {
|
||||
await window.__dshChat.selectSession(id)
|
||||
}
|
||||
return id
|
||||
})()`)
|
||||
}
|
||||
|
||||
async function shoot(cdp, name, opts) {
|
||||
const { play, wait = 400, hideDebugPanel = true, prep } = opts
|
||||
const played = await play()
|
||||
console.error(`[${name}] play -> ${JSON.stringify(played)}`)
|
||||
await cdp.sleep(wait)
|
||||
if (typeof prep === 'function') {
|
||||
const p = prep()
|
||||
if (p) { await cdp.evjs(p); await cdp.sleep(200) }
|
||||
}
|
||||
if (hideDebugPanel) {
|
||||
await cdp.evjs(`(function(){
|
||||
const p = document.querySelector('.debug-panel'); if (p) p.style.display='none'
|
||||
const d = document.querySelector('.devtools-drawer'); if (d) d.style.display='none'
|
||||
const rail = document.getElementById('context-rail-drawer'); if (rail) { rail.hidden = true; rail.style.display = 'none' }
|
||||
const pop = document.getElementById('debug-popover'); if (pop) pop.classList.remove('open')
|
||||
const ov = document.getElementById('onboarding');
|
||||
if (ov) { ov.style.display='none'; ov.hidden = true }
|
||||
return 1
|
||||
})()`)
|
||||
}
|
||||
const shot = await cdp.call('Page.captureScreenshot', {
|
||||
format: 'png',
|
||||
clip: { x: 0, y: 0, width: 1440, height: 900, scale: 1 },
|
||||
})
|
||||
const path = resolve(outdir, `${name}.png`)
|
||||
writeFileSync(path, Buffer.from(shot.data, 'base64'))
|
||||
console.log(path)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const c = await cdp()
|
||||
await c.call('Page.enable')
|
||||
const revealed = await c.evjs(`(async()=>{try{return window.dshQa && await window.dshQa.revealWindow()}catch(e){return {err: String(e)}}})()`)
|
||||
console.error(`reveal -> ${JSON.stringify(revealed)}`)
|
||||
await c.call('Emulation.setDeviceMetricsOverride', {
|
||||
width: 1440, height: 900, deviceScaleFactor: 1, mobile: false,
|
||||
})
|
||||
await c.evjs(`(function(){
|
||||
const overlay = document.getElementById('onboarding');
|
||||
if (overlay) { overlay.style.display = 'none'; overlay.hidden = true; }
|
||||
const rail = document.getElementById('context-rail-drawer');
|
||||
if (rail) { rail.hidden = true; rail.style.display = 'none' }
|
||||
return { overlayCleared: !!overlay };
|
||||
})()`)
|
||||
await c.evjs(`window.__dshTabs && window.__dshTabs.switchTo && window.__dshTabs.switchTo('chat')`)
|
||||
await c.sleep(300)
|
||||
|
||||
try {
|
||||
// 01 — prompt/blocked row.
|
||||
// Mint a fresh session, click the P0 mock button that dispatches the
|
||||
// fake prompt/blocked event through the same VisibilityController
|
||||
// seam the live wire hits. Then force the <details> open so the L1
|
||||
// body (raw blocked prompt) is captured in the shot.
|
||||
await shoot(c, '01-prompt-blocked-row', {
|
||||
play: async () => {
|
||||
await ensureSession(c)
|
||||
return await c.evjs(`(function(){
|
||||
const btn = document.getElementById('mock-prompt-blocked');
|
||||
if (!btn) return { err: 'mock button missing' };
|
||||
btn.click(); return { fired: 'mock-prompt-blocked' };
|
||||
})()`)
|
||||
},
|
||||
wait: 400,
|
||||
prep: () => `(function(){
|
||||
const row = document.querySelector('.prompt-blocked-row');
|
||||
if (row) { row.open = true; if (row.scrollIntoView) row.scrollIntoView({block:'center'}); }
|
||||
return { row: !!row };
|
||||
})()`,
|
||||
})
|
||||
|
||||
// 02 — approval note + mode dividers.
|
||||
// Sequence: seed a tool/call so the auto-allow can anchor into a real
|
||||
// .tool-block; fire approval-auto-allow; fire sandbox-switch; fire
|
||||
// preset-switch. All three surfaces appear in one screen.
|
||||
await shoot(c, '02-approval-mode-dividers', {
|
||||
play: async () => {
|
||||
await ensureSession(c)
|
||||
return await c.evjs(`(async function(){
|
||||
// Seed one bash tool/call so the auto-approve note has an anchor.
|
||||
const sid = window.__dshRendererState.activeSessionId;
|
||||
window.__dshRenderer.onSessionEvent(sid, {
|
||||
type: 'tool/call', seq: 101, time: Date.now(),
|
||||
data: { callId: 'seed-bash-1', name: 'bash',
|
||||
arguments: '{"cmd":"pnpm test"}' },
|
||||
});
|
||||
window.__dshRenderer.onSessionEvent(sid, {
|
||||
type: 'tool/result', seq: 102, time: Date.now(),
|
||||
data: { callId: 'seed-bash-1',
|
||||
content: [{ type: 'text', text: 'ok\\n8 tests passed' }],
|
||||
isError: false, meta: { card: 'terminal', durationMs: 240,
|
||||
stdout: 'ok\\n8 tests passed', exitCode: 0 } },
|
||||
});
|
||||
// Fire the three P0-2 mocks in order.
|
||||
document.getElementById('mock-approval-auto-allow').click();
|
||||
document.getElementById('mock-sandbox-switch').click();
|
||||
document.getElementById('mock-preset-switch').click();
|
||||
return { fired: ['bash-seed','auto-allow','sandbox-switch','preset-switch'] };
|
||||
})()`)
|
||||
},
|
||||
wait: 600,
|
||||
prep: () => `(function(){
|
||||
// Ensure the seeded tool block is open so the inline approval note is visible.
|
||||
const tool = document.querySelector('.tool-block[data-call-id="seed-bash-1"]');
|
||||
if (tool) tool.open = true;
|
||||
// Ensure the popover is closed so the header badges show cleanly.
|
||||
const pop = document.getElementById('debug-popover'); if (pop) pop.classList.remove('open');
|
||||
return { tool: !!tool };
|
||||
})()`,
|
||||
})
|
||||
|
||||
// 03 — subagent stopReason + prose return backfill.
|
||||
// The mock button drives a full spawn → started → finished cycle with
|
||||
// stopReason='stop' and a plain-prose lastAssistantMessage (no ```json
|
||||
// fence). The sealed card must show "done · stop" in the head and
|
||||
// render RETURN as a prose paragraph, not <pre>.
|
||||
await shoot(c, '03-subagent-stopreason-prose', {
|
||||
play: async () => {
|
||||
await ensureSession(c)
|
||||
return await c.evjs(`(function(){
|
||||
const btn = document.getElementById('mock-subagent-plain-return');
|
||||
if (!btn) return { err: 'mock button missing' };
|
||||
btn.click();
|
||||
return { fired: 'mock-subagent-plain-return' };
|
||||
})()`)
|
||||
},
|
||||
wait: 600,
|
||||
prep: () => `(function(){
|
||||
// Open the spawn tool block, the sealed subagent trace, and the
|
||||
// return section so all three surfaces (head badge, meta segment,
|
||||
// prose paragraph) land in the frame.
|
||||
const tool = document.querySelector('.tool-block[data-call-id^="mock-spawn-"]');
|
||||
if (tool) tool.open = true;
|
||||
const trace = document.querySelector('.subagent-trace');
|
||||
if (trace) { trace.open = true; if (trace.scrollIntoView) trace.scrollIntoView({block:'center'}); }
|
||||
const ret = document.querySelector('.subagent-card-return');
|
||||
if (ret) ret.open = true;
|
||||
return { tool: !!tool, trace: !!trace, ret: !!ret };
|
||||
})()`,
|
||||
})
|
||||
|
||||
} finally {
|
||||
c.close()
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1) })
|
||||
84
examples/desktop/scripts/qa-cdp-shot-sample-trace.mjs
Normal file
84
examples/desktop/scripts/qa-cdp-shot-sample-trace.mjs
Normal file
@@ -0,0 +1,84 @@
|
||||
// Shot the empty-state → sample-trace path. Load the trace, wait for
|
||||
// multiple card families to appear in the stream, then capture.
|
||||
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const port = process.argv[2] || '9236'
|
||||
const outdir = process.argv[3] || 'docs/demo-shots'
|
||||
const name = process.argv[4] || 'nav-03'
|
||||
|
||||
async function main() {
|
||||
const listRes = await fetch(`http://localhost:${port}/json/list`)
|
||||
const targets = await listRes.json()
|
||||
const target = targets.find((t) => t.type === 'page')
|
||||
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()
|
||||
ws.onmessage = (ev) => {
|
||||
const msg = JSON.parse(typeof ev.data === 'string' ? ev.data : String(ev.data))
|
||||
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 = {}, t = 20000) => new Promise((ok, err) => {
|
||||
const _id = id++
|
||||
const timer = setTimeout(() => { pending.delete(_id); err(new Error(`timeout ${m}`)) }, t)
|
||||
pending.set(_id, [(v) => { clearTimeout(timer); ok(v) }, (e) => { clearTimeout(timer); err(e) }])
|
||||
ws.send(JSON.stringify({ id: _id, method: m, params: p }))
|
||||
})
|
||||
const ev = 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
|
||||
}
|
||||
|
||||
await call('Page.enable')
|
||||
await ev(`window.dshQa && window.dshQa.revealWindow()`)
|
||||
await call('Emulation.setDeviceMetricsOverride', { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false })
|
||||
|
||||
// Ensure we're on chat + sample trace path
|
||||
await ev(`window.__dshTabs && window.__dshTabs.switchTo('chat')`)
|
||||
await new Promise((r) => setTimeout(r, 300))
|
||||
// Kick the load-sample-trace path via the exposed helper.
|
||||
const loaded = await ev(`(async()=>{try{await window.__dshLoadSampleTrace();return 'ok'}catch(e){return String(e)}})()`)
|
||||
console.error(`load sample -> ${loaded}`)
|
||||
// Wait for card families to appear.
|
||||
const stableCheck = await ev(`(async()=>{
|
||||
for (let i=0; i<40; i++) {
|
||||
const stream = document.querySelector('.stream');
|
||||
if (stream) {
|
||||
const families = {
|
||||
reasoning: !!document.querySelector('.reasoning-block, [data-card-family="reasoning"]'),
|
||||
partialTool: !!document.querySelector('[data-tool-card-family], .tool-row'),
|
||||
compactCard: !!document.querySelector('.compact-card, [data-card-family="compact"]'),
|
||||
subagent: !!document.querySelector('.subagent-view, [data-subagent-inline]'),
|
||||
turnFooter: !!document.querySelector('.turn-footer, [data-turn-footer]'),
|
||||
turnContainer: !!document.querySelector('.assistant-turn, .turn-container'),
|
||||
};
|
||||
const hit = Object.values(families).filter(Boolean).length;
|
||||
if (hit >= 3 || i > 30) return { families, hit, i };
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
}
|
||||
return { done: false };
|
||||
})()`)
|
||||
console.error(`stable -> ${JSON.stringify(stableCheck)}`)
|
||||
await new Promise((r) => setTimeout(r, 600))
|
||||
|
||||
const shot = await call('Page.captureScreenshot', {
|
||||
format: 'png',
|
||||
clip: { x: 0, y: 0, width: 1440, height: 900, scale: 1 },
|
||||
})
|
||||
await call('Emulation.clearDeviceMetricsOverride')
|
||||
const path = resolve(outdir, `${name}.png`)
|
||||
writeFileSync(path, Buffer.from(shot.data, 'base64'))
|
||||
console.log(path)
|
||||
ws.close()
|
||||
}
|
||||
main().catch((e) => { console.error(String(e)); process.exit(1) })
|
||||
131
examples/desktop/scripts/qa-cdp-shot.mjs
Normal file
131
examples/desktop/scripts/qa-cdp-shot.mjs
Normal file
@@ -0,0 +1,131 @@
|
||||
// CDP walkthrough driver + screenshotter for QA round-3 re-verification.
|
||||
//
|
||||
// Uses Page.captureScreenshot instead of macOS `screencapture` so we don't
|
||||
// have to activate the Electron window (which would steal focus from the
|
||||
// user). Same 403/Origin gotcha handling as scripts/qa-cdp-drive.mjs — Node
|
||||
// built-in WebSocket sends no Origin header, so Chromium accepts.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/qa-cdp-shot.mjs <port> <outdir> <name> [tab] [mtab] [waitSel] [waitMs]
|
||||
//
|
||||
// Example:
|
||||
// node scripts/qa-cdp-shot.mjs 9223 docs/qa-round3-shots 03-mission-tree \
|
||||
// mission tree ".mission-view" 400
|
||||
//
|
||||
// - <port> Electron --remote-debugging-port
|
||||
// - <outdir> directory to write <name>.png into
|
||||
// - <name> shot basename (no extension)
|
||||
// - <tab> optional __dshTabs.switchTo(...) target ('' to skip)
|
||||
// - <mtab> optional .mission-subview-tab[data-mission-tab=...] value
|
||||
// - <waitSel> optional CSS selector to wait for before the shot
|
||||
// - <waitMs> optional post-render settle in ms (default 300)
|
||||
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const [,, portArg, outdir, name, tab, mtab, waitSel, waitMsArg] = process.argv
|
||||
const port = portArg || '9222'
|
||||
const waitMs = Number(waitMsArg || 300)
|
||||
|
||||
if (!outdir || !name) {
|
||||
console.error('usage: node scripts/qa-cdp-shot.mjs <port> <outdir> <name> [tab] [mtab] [waitSel] [waitMs]')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
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')
|
||||
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 chunks = []
|
||||
ws.onmessage = (ev) => {
|
||||
// Big Page.captureScreenshot payloads occasionally arrive as multiple
|
||||
// frames on the built-in WebSocket. Join before JSON.parse if needed.
|
||||
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 = {}, timeoutMs = 20000) => new Promise((ok, err) => {
|
||||
const _id = id++
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(_id)
|
||||
err(new Error(`cdp call timeout: ${m}`))
|
||||
}, timeoutMs)
|
||||
pending.set(_id, [(v) => { clearTimeout(timer); ok(v) }, (e) => { clearTimeout(timer); err(e) }])
|
||||
ws.send(JSON.stringify({ id: _id, method: m, params: p }))
|
||||
})
|
||||
const ev = 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
|
||||
}
|
||||
|
||||
// Page domain enable — CDP allows Page.captureScreenshot without it in
|
||||
// theory, but some Chromium builds hang waiting for the frame lifecycle
|
||||
// notifications the domain publishes. Enable explicitly to be safe.
|
||||
await call('Page.enable')
|
||||
|
||||
// Ask main to reveal the window (no focus steal) so the compositor has a
|
||||
// surface to render into. Without this Page.captureScreenshot can hang
|
||||
// indefinitely on a hidden window — that was the whole reason we shipped
|
||||
// the DSH_QA=1 window:reveal seam (src/main/main.js + src/main/window-reveal.js).
|
||||
// If the seam isn't exposed (production preload or DSH_QA not set on the
|
||||
// running instance), the eval returns undefined and we fall through to the
|
||||
// legacy setDeviceMetricsOverride path — no hard requirement.
|
||||
const revealed = await ev(`(async()=>{try{return window.dshQa && await window.dshQa.revealWindow()}catch(e){return {err: String(e)}}})()`)
|
||||
console.error(`reveal -> ${JSON.stringify(revealed)}`)
|
||||
|
||||
// Force a fixed device viewport. With the reveal seam this is belt-and-
|
||||
// braces — the compositor now has a real surface — but keeping the override
|
||||
// in place also normalises the shot dimensions across whatever the window
|
||||
// was actually sized to, so shot-to-shot diffs stay meaningful.
|
||||
await call('Emulation.setDeviceMetricsOverride', {
|
||||
width: 1440, height: 900, deviceScaleFactor: 1, mobile: false,
|
||||
})
|
||||
|
||||
if (tab) {
|
||||
const rTab = await ev(`window.__dshTabs && window.__dshTabs.switchTo && window.__dshTabs.switchTo(${JSON.stringify(tab)})`)
|
||||
console.error(`settab ${tab} -> ${JSON.stringify(rTab)}`)
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
}
|
||||
if (mtab) {
|
||||
const rMtab = await ev(`(function(){const el=document.querySelector('.mission-subview-tab[data-mission-tab='+${JSON.stringify(mtab)}+']'); if(!el) return 'NO_MTAB'; el.click(); return 'OK'})()`)
|
||||
console.error(`mtab ${mtab} -> ${rMtab}`)
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
}
|
||||
if (waitSel) {
|
||||
const found = await ev(`(async()=>{for(let i=0;i<20;i++){if(document.querySelector(${JSON.stringify(waitSel)})) return true; await new Promise(r=>setTimeout(r,150))} return false})()`)
|
||||
console.error(`waitfor ${waitSel} -> ${found}`)
|
||||
}
|
||||
if (waitMs > 0) await new Promise((r) => setTimeout(r, waitMs))
|
||||
|
||||
// fromSurface: false renders into an offscreen bitmap, so it works even
|
||||
// when the Electron window is occluded / minimized — captureScreenshot
|
||||
// with the default fromSurface: true hangs forever when the compositor
|
||||
// has no surface (which is exactly the case when we're deliberately not
|
||||
// stealing focus). captureBeyondViewport: true widens the render window
|
||||
// to the full document.
|
||||
const shot = await call('Page.captureScreenshot', {
|
||||
format: 'png',
|
||||
clip: { x: 0, y: 0, width: 1440, height: 900, scale: 1 },
|
||||
})
|
||||
await call('Emulation.clearDeviceMetricsOverride')
|
||||
const path = resolve(outdir, `${name}.png`)
|
||||
writeFileSync(path, Buffer.from(shot.data, 'base64'))
|
||||
console.log(path)
|
||||
ws.close()
|
||||
}
|
||||
main().catch((e) => { console.error(String(e)); process.exit(1) })
|
||||
54
examples/desktop/scripts/qa-gate-watch.sh
Executable file
54
examples/desktop/scripts/qa-gate-watch.sh
Executable file
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# Rolling gate: on every new commit to HEAD, run full tests + record
|
||||
# renderer/panels-c/session-tree/plugins-tab touch stats so cross-lane hunk
|
||||
# leakage is visible fast. Meant to run in a foreground loop; kill with C-c.
|
||||
#
|
||||
# v2 (2026-07-16): tests run in a dedicated clean worktree at HEAD so
|
||||
# in-flight lane WIP in the primary tree can't contaminate results (the v1
|
||||
# false-alarm at 03cd26f + 3252d7f was uncommitted capabilities.js in the
|
||||
# primary tree failing the IIFE-guard). The clean tree is reset to the new
|
||||
# HEAD each pass via `git checkout --detach`, keeping node_modules symlinked
|
||||
# from the primary.
|
||||
set -u
|
||||
STATE=/tmp/qa-gate-last-sha
|
||||
LOG=/tmp/qa-gate.log
|
||||
# Primary repo checkout defaults to the current git top-level; override
|
||||
# with REPO_ROOT env var (e.g. REPO_ROOT=~/harness/dsh-desktop-demo).
|
||||
PRIMARY="${REPO_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
|
||||
CLEAN=/tmp/dsh-clean-gate/tree
|
||||
cd "$PRIMARY" || exit 1
|
||||
touch "$STATE"
|
||||
# Bootstrap the clean worktree once, symlinking node_modules from the primary
|
||||
# so the loop doesn't reinstall on every commit.
|
||||
if [ ! -d "$CLEAN" ]; then
|
||||
mkdir -p /tmp/dsh-clean-gate
|
||||
git worktree add "$CLEAN" HEAD >>"$LOG" 2>&1
|
||||
ln -sf "$PRIMARY/node_modules" "$CLEAN/node_modules"
|
||||
fi
|
||||
while :; do
|
||||
cur=$(git -C "$PRIMARY" rev-parse HEAD)
|
||||
last=$(cat "$STATE" 2>/dev/null || echo '')
|
||||
if [ "$cur" != "$last" ]; then
|
||||
ts=$(date '+%H:%M:%S')
|
||||
echo "[$ts] new HEAD $cur" | tee -a "$LOG"
|
||||
# Touch stats for hunk-cross-contamination watch (five renderer hotspots)
|
||||
git -C "$PRIMARY" show --stat "$cur" | grep -E "renderer\.js|panels-c\.js|session-tree\.js|context-meter\.js|plugins-tab\.js" | tee -a "$LOG"
|
||||
# Sync the clean worktree to the new HEAD (detached — never a branch tip
|
||||
# we could accidentally commit onto).
|
||||
if git -C "$CLEAN" checkout --detach "$cur" >/tmp/qa-gate-checkout.log 2>&1; then
|
||||
# Full test — capture only fail counter + tail
|
||||
if (cd "$CLEAN" && npm test --silent) >/tmp/qa-gate-npmtest.log 2>&1; then
|
||||
tail -5 /tmp/qa-gate-npmtest.log | tee -a "$LOG"
|
||||
echo " ok tests green (clean tree)" | tee -a "$LOG"
|
||||
else
|
||||
echo " RED: tests failed on $cur (clean tree, not WIP)" | tee -a "$LOG"
|
||||
tail -30 /tmp/qa-gate-npmtest.log | tee -a "$LOG"
|
||||
fi
|
||||
else
|
||||
echo " ERROR: could not sync clean worktree to $cur" | tee -a "$LOG"
|
||||
tail -5 /tmp/qa-gate-checkout.log | tee -a "$LOG"
|
||||
fi
|
||||
echo "$cur" > "$STATE"
|
||||
fi
|
||||
sleep 15
|
||||
done
|
||||
278
examples/desktop/scripts/qa-overlap-fix-probe.mjs
Normal file
278
examples/desktop/scripts/qa-overlap-fix-probe.mjs
Normal file
@@ -0,0 +1,278 @@
|
||||
// payload-controls overlap regression probe (2026-07-18 delta).
|
||||
//
|
||||
// Boots a fresh isolated Electron instance, injects tool blocks with a
|
||||
// long meta suffix ("(content · meta · isError · error · durationMs)"),
|
||||
// opens the JSON drawer, then asserts via getBoundingClientRect() that
|
||||
// the button cluster (pretty ⇅ raw · copy · download) does NOT overlap
|
||||
// the label / meta text at either width.
|
||||
//
|
||||
// Four payload-controls mount points get exercised — matches the shipped
|
||||
// (merged) fix from da779ac (`fix/ui-hotfix-drawer-overlap` -> 9743db1):
|
||||
// A) inline tool-block args renderer.js:1226 → `.tool-block-label-row`
|
||||
// B) inline tool-block result renderer.js:1249 → `.tool-block-label-row`
|
||||
// C) tool-json-drawer sections tool-cards.js:663 → `.tool-json-section-controls[data-drawer-controls]`
|
||||
// D) trace-detail Render=JSON trace-detail-pane.js:1512 → `.trace-detail-json-panel`
|
||||
// (no-op verify — mount is flex-column,
|
||||
// controls right-anchor via margin-left)
|
||||
//
|
||||
// Runs the app under a private user-data dir + dedicated CDP port so it
|
||||
// never collides with the user's own Electron. Kills the child on exit.
|
||||
//
|
||||
// Usage: `pnpm exec node scripts/qa-overlap-fix-probe.mjs [outDir]`.
|
||||
// Requires the electron binary at `node_modules/.bin/electron`. When run
|
||||
// in a worktree that has never had `pnpm install` executed there, either
|
||||
// (a) run `pnpm install` locally, or (b) symlink `node_modules` in from
|
||||
// the primary checkout — see docs/qa-overlap-fix/README.md.
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
|
||||
const PORT = Number(process.env.OVERLAP_PROBE_PORT || 9247)
|
||||
const outDir = resolve(process.argv[2] || 'docs/qa-overlap-fix')
|
||||
mkdirSync(outDir, { recursive: true })
|
||||
|
||||
const userData = mkdtempSync(join(tmpdir(), 'dsh-overlap-fix-'))
|
||||
const electronBin = resolve('node_modules/.bin/electron')
|
||||
|
||||
const child = spawn(electronBin, [
|
||||
'.',
|
||||
`--remote-debugging-port=${PORT}`,
|
||||
`--user-data-dir=${userData}`,
|
||||
'--no-first-run',
|
||||
], {
|
||||
env: {
|
||||
...process.env,
|
||||
DSH_QA: '1',
|
||||
DSH_MAXIMIZE: '0',
|
||||
DSH_ONBOARDING_SKIP: '1',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
|
||||
let bootLog = ''
|
||||
child.stdout.on('data', (b) => { bootLog += b.toString() })
|
||||
child.stderr.on('data', (b) => { bootLog += b.toString() })
|
||||
|
||||
async function cleanup(code) {
|
||||
try { child.kill('SIGKILL') } catch {}
|
||||
try { rmSync(userData, { recursive: true, force: true }) } catch {}
|
||||
process.exit(code)
|
||||
}
|
||||
process.on('SIGINT', () => cleanup(130))
|
||||
process.on('SIGTERM', () => cleanup(143))
|
||||
|
||||
async function waitForCdp(port, timeoutMs = 15000) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const r = await fetch(`http://localhost:${port}/json/list`)
|
||||
const list = await r.json()
|
||||
const page = list.find((t) => t.type === 'page')
|
||||
if (page && page.webSocketDebuggerUrl) return page
|
||||
} catch {}
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
}
|
||||
throw new Error(`CDP not up on port ${port} after ${timeoutMs}ms; boot log:\n${bootLog}`)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const page = await waitForCdp(PORT)
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl)
|
||||
await new Promise((r, x) => { ws.onopen = r; ws.onerror = (e) => x(new Error(String(e))) })
|
||||
let id = 1
|
||||
const pending = new Map()
|
||||
ws.onmessage = (ev) => {
|
||||
const msg = JSON.parse(typeof ev.data === 'string' ? ev.data : String(ev.data))
|
||||
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 = 15000) => new Promise((ok, err) => {
|
||||
const _id = id++
|
||||
const timer = setTimeout(() => { pending.delete(_id); err(new Error('cdp timeout: ' + m)) }, timeoutMs)
|
||||
pending.set(_id, [(v) => { clearTimeout(timer); ok(v) }, (e) => { clearTimeout(timer); 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
|
||||
}
|
||||
|
||||
await call('Page.enable')
|
||||
await evj(`window.dshQa && window.dshQa.revealWindow ? await window.dshQa.revealWindow() : null`)
|
||||
|
||||
const results = []
|
||||
for (const w of [1440, 800]) {
|
||||
await call('Emulation.setDeviceMetricsOverride', {
|
||||
width: w, height: 900, deviceScaleFactor: 2, mobile: false,
|
||||
})
|
||||
await new Promise((r) => setTimeout(r, 400))
|
||||
|
||||
// Inject a synthetic tool-block that mirrors renderer.js appendToolCall's
|
||||
// POST-fix shape (`.tool-block-label-row` wrapper containing `.label` and
|
||||
// the util's controls). We build the DOM directly rather than call
|
||||
// appendToolCall so we can stress-test with an intentionally long meta
|
||||
// suffix — that string is what pulled the buttons on top of the label
|
||||
// in the pre-fix bug.
|
||||
const injected = await evj(`(function(){
|
||||
const stream = document.getElementById('stream')
|
||||
if (!stream) return {ok:false, why:'no stream'}
|
||||
for (const el of stream.querySelectorAll('.tool-block.qa-overlap-fix')) el.remove()
|
||||
|
||||
const details = document.createElement('details')
|
||||
details.className = 'tool-block qa-overlap-fix'
|
||||
details.setAttribute('data-tool-name', 'bash')
|
||||
details.setAttribute('data-tool-card-family', 'bash')
|
||||
details.setAttribute('open', '')
|
||||
const summary = document.createElement('summary')
|
||||
summary.textContent = 'bash — running the full test battery with a really long argument gist that should ellipsis and never overlap'
|
||||
details.appendChild(summary)
|
||||
|
||||
const pc = window.__dshPayloadControls
|
||||
|
||||
// (A) args row
|
||||
const argsRow = document.createElement('div')
|
||||
argsRow.className = 'tool-block-label-row'
|
||||
const argLabel = document.createElement('div')
|
||||
argLabel.className = 'label'
|
||||
argLabel.textContent = 'args (content · meta · isError · error · durationMs · plus even more meta text to stress-test)'
|
||||
argsRow.appendChild(argLabel)
|
||||
const argsBox = document.createElement('div')
|
||||
argsBox.className = 'args args-with-controls'
|
||||
if (pc && pc.attachPayloadControls) {
|
||||
const ret = pc.attachPayloadControls(argsRow, { getRaw: () => ({command: 'pnpm test'}), kind: 'args' })
|
||||
if (ret && ret.preEl && ret.preEl.parentNode) {
|
||||
ret.preEl.parentNode.removeChild(ret.preEl)
|
||||
argsBox.appendChild(ret.preEl)
|
||||
}
|
||||
}
|
||||
|
||||
// (B) result row
|
||||
const resultRow = document.createElement('div')
|
||||
resultRow.className = 'tool-block-label-row'
|
||||
const resLabel = document.createElement('div')
|
||||
resLabel.className = 'label'
|
||||
resLabel.textContent = 'result (content · meta · isError · error · durationMs)'
|
||||
resultRow.appendChild(resLabel)
|
||||
const resBox = document.createElement('div')
|
||||
resBox.className = 'result result-with-controls'
|
||||
if (pc && pc.attachPayloadControls) {
|
||||
const ret = pc.attachPayloadControls(resultRow, { getRaw: () => ({ok: true, output: 'PASS'}), kind: 'result' })
|
||||
if (ret && ret.preEl && ret.preEl.parentNode) {
|
||||
ret.preEl.parentNode.removeChild(ret.preEl)
|
||||
resBox.appendChild(ret.preEl)
|
||||
}
|
||||
}
|
||||
|
||||
details.appendChild(argsRow); details.appendChild(argsBox)
|
||||
details.appendChild(resultRow); details.appendChild(resBox)
|
||||
stream.appendChild(details)
|
||||
details.scrollIntoView({block:'center'})
|
||||
return {ok:true, id:'qa-overlap-fix'}
|
||||
})()`)
|
||||
if (!injected || !injected.ok) throw new Error('inject failed: ' + JSON.stringify(injected))
|
||||
|
||||
await new Promise((r) => setTimeout(r, 300))
|
||||
|
||||
// (C) open the drawer for the third callsite.
|
||||
const drawerRes = await evj(`(function(){
|
||||
const tc = window.__dshToolCards
|
||||
if (!tc || !tc.openJsonDrawer) return {ok:false, why:'no drawer api'}
|
||||
tc.openJsonDrawer({
|
||||
title: 'bash — a very long tool title used for the drawer overlap probe',
|
||||
call: { arguments: { command: 'pnpm test' } },
|
||||
result: { content: [{type:'text',text:'PASS'}], meta:{card:'generic'}, isError:false, durationMs:48 }
|
||||
})
|
||||
return {ok:true}
|
||||
})()`)
|
||||
if (!drawerRes || !drawerRes.ok) throw new Error('drawer open failed: ' + JSON.stringify(drawerRes))
|
||||
await new Promise((r) => setTimeout(r, 300))
|
||||
|
||||
// Measure geometry — post-fix, controls sit inside the same flex row
|
||||
// as the label, right-anchored. The only failure mode we still guard
|
||||
// against is horizontal overlap between the label text rect and the
|
||||
// controls cluster rect (which happens if the flex breaks or someone
|
||||
// reintroduces float/negative-margin).
|
||||
const geom = await evj(`(function(){
|
||||
function rectOf(el){ if(!el) return null; const r=el.getBoundingClientRect(); return {top:r.top,bottom:r.bottom,left:r.left,right:r.right,width:r.width,height:r.height} }
|
||||
function overlapY(a,b){ if(!a||!b) return null; const top=Math.max(a.top,b.top); const bot=Math.min(a.bottom,b.bottom); return Math.max(0, bot-top) }
|
||||
function hOverlap(a,b){ if(!a||!b) return null; const left=Math.max(a.left,b.left); const right=Math.min(a.right,b.right); return Math.max(0, right-left) }
|
||||
|
||||
const details = document.querySelector('.tool-block.qa-overlap-fix')
|
||||
if (!details) return {err:'no details'}
|
||||
const rows = details.querySelectorAll('.tool-block-label-row')
|
||||
const argRow = rows[0]
|
||||
const resRow = rows[1]
|
||||
const argLabel = argRow ? argRow.querySelector('.label') : null
|
||||
const argCtl = argRow ? argRow.querySelector('.payload-controls') : null
|
||||
const resLabel = resRow ? resRow.querySelector('.label') : null
|
||||
const resCtl = resRow ? resRow.querySelector('.payload-controls') : null
|
||||
|
||||
const drawer = document.getElementById('tool-json-drawer')
|
||||
const drawerSummary = drawer ? drawer.querySelector('.tool-json-section summary') : null
|
||||
const drawerCtlStrip = drawer ? drawer.querySelector('.tool-json-section-controls[data-drawer-controls]') : null
|
||||
const drawerCtl = drawerCtlStrip ? drawerCtlStrip.querySelector('.payload-controls') : null
|
||||
|
||||
return {
|
||||
argsRow: { label: rectOf(argLabel), controls: rectOf(argCtl), hOverlap: hOverlap(rectOf(argLabel), rectOf(argCtl)) },
|
||||
resultRow: { label: rectOf(resLabel), controls: rectOf(resCtl), hOverlap: hOverlap(rectOf(resLabel), rectOf(resCtl)) },
|
||||
drawer: { summary: rectOf(drawerSummary), controls: rectOf(drawerCtl), vOverlap: overlapY(rectOf(drawerSummary), rectOf(drawerCtl)) },
|
||||
}
|
||||
})()`)
|
||||
results.push({ width: w, geom })
|
||||
|
||||
// Screenshot for eye verification alongside the geom log.
|
||||
const shot = await call('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false })
|
||||
const path = join(outDir, `overlap-w${w}.png`)
|
||||
writeFileSync(path, Buffer.from(shot.data, 'base64'))
|
||||
|
||||
await evj(`window.__dshToolCards && window.__dshToolCards.closeJsonDrawer && window.__dshToolCards.closeJsonDrawer()`)
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
}
|
||||
|
||||
writeFileSync(join(outDir, 'geom-trace.log'), JSON.stringify(results, null, 2))
|
||||
console.log(JSON.stringify(results, null, 2))
|
||||
|
||||
let bad = 0
|
||||
for (const { width, geom } of results) {
|
||||
if (!geom || geom.__err) { console.error('geom error at width', width, geom); bad++; continue }
|
||||
const a = geom.argsRow, r = geom.resultRow, d = geom.drawer
|
||||
// The flex row wraps at narrow widths (flex-wrap: wrap on
|
||||
// .tool-block-label-row) — when it wraps, controls drop onto their
|
||||
// own line under the label, so hOverlap can become non-zero but
|
||||
// vOverlap between label and controls is 0. Guard against actual
|
||||
// stack-on-same-line overlap (both non-zero) rather than either alone.
|
||||
if (a && a.label && a.controls) {
|
||||
const sameLine = Math.abs((a.label.top + a.label.height/2) - (a.controls.top + a.controls.height/2)) < Math.max(a.label.height, a.controls.height)
|
||||
if (sameLine && a.hOverlap > 0.5) {
|
||||
console.error('args row: label ↔ controls overlap on same line =', a.hOverlap, 'px at width', width); bad++
|
||||
}
|
||||
}
|
||||
if (r && r.label && r.controls) {
|
||||
const sameLine = Math.abs((r.label.top + r.label.height/2) - (r.controls.top + r.controls.height/2)) < Math.max(r.label.height, r.controls.height)
|
||||
if (sameLine && r.hOverlap > 0.5) {
|
||||
console.error('result row: label ↔ controls overlap on same line =', r.hOverlap, 'px at width', width); bad++
|
||||
}
|
||||
}
|
||||
if (d && d.vOverlap > 0.5) {
|
||||
console.error('drawer: summary ↔ controls vertical overlap =', d.vOverlap, 'px at width', width); bad++
|
||||
}
|
||||
}
|
||||
if (bad) {
|
||||
console.error(`FAIL — ${bad} overlap(s) still present`)
|
||||
await cleanup(1)
|
||||
}
|
||||
console.log(`PASS — no overlaps at widths ${results.map((r) => r.width).join(', ')}`)
|
||||
await cleanup(0)
|
||||
}
|
||||
|
||||
main().catch(async (e) => { console.error('probe error:', e && e.stack || e); await cleanup(2) })
|
||||
41
examples/desktop/scripts/qa-probe-eventcount.mjs
Normal file
41
examples/desktop/scripts/qa-probe-eventcount.mjs
Normal file
@@ -0,0 +1,41 @@
|
||||
// Probe live 9224 for eventCount / hasUserMessage distribution on session/list.
|
||||
// Same raw-WebSocket pattern as qa-cdp-shot.mjs (built-in WS = no Origin).
|
||||
const port = process.argv[2] || '9224'
|
||||
const tabs = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()
|
||||
const target = tabs.find(t => t.type === 'page' && t.url && t.url.startsWith('file://'))
|
||||
if (!target) { console.error('no page target'); process.exit(1) }
|
||||
const ws = new WebSocket(target.webSocketDebuggerUrl)
|
||||
let id = 0
|
||||
const pending = new Map()
|
||||
function call(method, params={}) {
|
||||
const nid = ++id
|
||||
return new Promise((res, rej) => {
|
||||
pending.set(nid, { res, rej })
|
||||
ws.send(JSON.stringify({ id: nid, method, params }))
|
||||
})
|
||||
}
|
||||
await new Promise(r => ws.addEventListener('open', r, { once: true }))
|
||||
ws.addEventListener('message', ev => {
|
||||
const m = JSON.parse(ev.data)
|
||||
if (m.id && pending.has(m.id)) { const { res, rej } = pending.get(m.id); pending.delete(m.id); m.error ? rej(new Error(JSON.stringify(m.error))) : res(m.result) }
|
||||
})
|
||||
await call('Runtime.enable')
|
||||
const r = await call('Runtime.evaluate', {
|
||||
expression: `(async () => {
|
||||
const sessions = window.__dshChat && window.__dshChat.getSessions ? window.__dshChat.getSessions() : []
|
||||
const arr = Array.isArray(sessions) ? sessions : []
|
||||
const total = arr.length
|
||||
let withEC = 0, ecZero = 0, ecUndef = 0, huTrue = 0, huUndef = 0
|
||||
for (const s of arr) {
|
||||
if (typeof s.eventCount === 'number') { withEC++; if (s.eventCount === 0) ecZero++ }
|
||||
else ecUndef++
|
||||
if (s.hasUserMessage === true) huTrue++
|
||||
else if (s.hasUserMessage === undefined) huUndef++
|
||||
}
|
||||
return JSON.stringify({ total, withEC, ecZero, ecUndef, huTrue, huUndef, sample: arr.slice(0,3).map(s => ({ id: s.sessionId && s.sessionId.slice(0,8), eventCount: s.eventCount, hasUserMessage: s.hasUserMessage, live: s.live, persisted: s.persisted, title: s.header && s.header.title })) })
|
||||
})()`,
|
||||
returnByValue: true,
|
||||
awaitPromise: true,
|
||||
})
|
||||
console.log(r.result && r.result.value)
|
||||
ws.close()
|
||||
71
examples/desktop/scripts/regen-rubrics-seed.py
Normal file
71
examples/desktop/scripts/regen-rubrics-seed.py
Normal file
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
# Regenerate src/renderer/rubrics-seed.js from fixtures/rubrics/*.md +
|
||||
# fixtures/annotation/sample-sessions.json. Run after editing any of those.
|
||||
#
|
||||
# Rationale: the renderer runs from file:// with strict CSP, so it cannot
|
||||
# fetch() the .md and .json files at runtime. Inlining them into a small
|
||||
# `-seed.js` script keeps the fixtures as the source of truth while giving
|
||||
# the renderer a synchronous, dependency-free path to them. Same pattern
|
||||
# as src/renderer/debug-fixtures.js.
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
RUBRICS_DIR = os.path.join(ROOT, "fixtures", "rubrics")
|
||||
SAMPLES_PATH = os.path.join(ROOT, "fixtures", "annotation", "sample-sessions.json")
|
||||
OUT = os.path.join(ROOT, "src", "renderer", "rubrics-seed.js")
|
||||
|
||||
RUBRIC_ORDER = [
|
||||
"bug-fix.md",
|
||||
"svg-generation.md",
|
||||
"multi-turn-feedback.md",
|
||||
"code-review.md",
|
||||
# Typed-primitive fixtures (LangSmith FeedbackSchema parity) — one
|
||||
# rubric per primitive so the demo drawer can showcase all three
|
||||
# scoring shapes without needing a real evaluator.
|
||||
"correctness-score.md",
|
||||
"intent-triage.md",
|
||||
"passes-bench.md",
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
rubrics = []
|
||||
for name in RUBRIC_ORDER:
|
||||
path = os.path.join(RUBRICS_DIR, name)
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
rubrics.append(f.read())
|
||||
with open(SAMPLES_PATH, "r", encoding="utf-8") as f:
|
||||
samples = json.load(f)
|
||||
lines = []
|
||||
lines.append("// Auto-inlined fixture seeds for the Rubrics + Annotation pages.")
|
||||
lines.append("// Renderer runs at file:// so we inline the SKILL.md blobs and sample")
|
||||
lines.append("// sessions here instead of relying on fetch(). To refresh:")
|
||||
lines.append("// python3 scripts/regen-rubrics-seed.py")
|
||||
lines.append("//")
|
||||
lines.append("// The 4 rubrics live at fixtures/rubrics/*.md; the sample sessions live at")
|
||||
lines.append("// fixtures/annotation/sample-sessions.json.")
|
||||
lines.append("")
|
||||
lines.append("'use strict'")
|
||||
lines.append("")
|
||||
lines.append("const RUBRICS_SEED = " + json.dumps(rubrics, ensure_ascii=False, indent=2))
|
||||
lines.append("")
|
||||
lines.append("const ANNOTATION_SAMPLES = " + json.dumps(samples, ensure_ascii=False, indent=2))
|
||||
lines.append("")
|
||||
lines.append("if (typeof window !== 'undefined') {")
|
||||
lines.append(" window.__dshRubricsSeed = RUBRICS_SEED")
|
||||
lines.append(" window.__dshAnnotationSamples = ANNOTATION_SAMPLES")
|
||||
lines.append("}")
|
||||
lines.append("if (typeof module !== 'undefined' && module.exports) {")
|
||||
lines.append(" module.exports = { RUBRICS_SEED, ANNOTATION_SAMPLES }")
|
||||
lines.append("}")
|
||||
with open(OUT, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(lines) + "\n")
|
||||
print("wrote", OUT)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
480
examples/desktop/scripts/task-battery.mjs
Normal file
480
examples/desktop/scripts/task-battery.mjs
Normal file
@@ -0,0 +1,480 @@
|
||||
#!/usr/bin/env node
|
||||
// Real-task completion battery — task #86 owner: passthrough-verify.
|
||||
//
|
||||
// Runs a ≥10-task suite against the running Electron on CDP :9299 with
|
||||
// profile=stdio-deepseek (real DEEPSEEK_API_KEY). For each task:
|
||||
// 1. New session, send prompt (real DeepSeek turn).
|
||||
// 2. Poll turn/end; capture wire event count, tokens, chunks.
|
||||
// 3. Objective completion judge (per task: file exists / content match /
|
||||
// wire signal / etc).
|
||||
// 4. Render assertions on the DOM: tool card presence, trace footer
|
||||
// values, reasoning drawer availability, absence of "—" placeholders
|
||||
// in the Tracing summary row for this session.
|
||||
// 5. Collect captured errs from the pre-installed __battery hooks.
|
||||
//
|
||||
// LLM budget: ≤40 real calls. Sandbox workdir /tmp/dsh-task-battery/T??.
|
||||
// Do not touch user's Electron (pid 91655) — this driver is pinned to
|
||||
// CDP_PORT=9299.
|
||||
|
||||
import http from 'node:http'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import os from 'node:os'
|
||||
|
||||
const PORT = 9299
|
||||
const SANDBOX_ROOT = '/tmp/dsh-task-battery'
|
||||
|
||||
function nowIso() { return new Date().toISOString() }
|
||||
function log(...a) { console.log('[battery]', nowIso(), ...a) }
|
||||
|
||||
function listTargets() {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.get(`http://localhost:${PORT}/json/list`, (res) => {
|
||||
let b = ''
|
||||
res.on('data', (c) => (b += c))
|
||||
res.on('end', () => { try { resolve(JSON.parse(b)) } catch (e) { reject(e) } })
|
||||
}).on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
const t = await listTargets()
|
||||
const p = t.find((x) => x.title === 'DSH Desktop') || t.find((x) => x.type === 'page')
|
||||
if (!p) throw new Error('no page')
|
||||
const ws = new WebSocket(p.webSocketDebuggerUrl)
|
||||
await new Promise((r, j) => { ws.onopen = () => r(); ws.onerror = (e) => j(e.message || 'ws err') })
|
||||
let seq = 0
|
||||
const pending = new Map()
|
||||
ws.onmessage = (ev) => {
|
||||
let msg; try { msg = JSON.parse(ev.data) } catch { return }
|
||||
if (msg.id != null && pending.has(msg.id)) {
|
||||
const { resolve, reject } = pending.get(msg.id)
|
||||
pending.delete(msg.id)
|
||||
if (msg.error) reject(new Error(JSON.stringify(msg.error)))
|
||||
else resolve(msg.result)
|
||||
}
|
||||
}
|
||||
function send(method, params) {
|
||||
const id = ++seq
|
||||
return new Promise((resolve, reject) => {
|
||||
pending.set(id, { resolve, reject })
|
||||
ws.send(JSON.stringify({ id, method, params: params || {} }))
|
||||
})
|
||||
}
|
||||
async function evalExpr(expr, awaitProm = true, timeoutMs = 60000) {
|
||||
const r = await send('Runtime.evaluate', {
|
||||
expression: expr, returnByValue: true, awaitPromise: awaitProm, timeout: timeoutMs,
|
||||
})
|
||||
if (r.exceptionDetails) {
|
||||
const desc = r.exceptionDetails.exception?.description || r.exceptionDetails.text
|
||||
throw new Error('eval-error: ' + desc)
|
||||
}
|
||||
return r.result && r.result.value
|
||||
}
|
||||
async function screenshot(pth) {
|
||||
const r = await send('Page.captureScreenshot', { format: 'png' })
|
||||
fs.writeFileSync(pth, Buffer.from(r.data, 'base64'))
|
||||
return pth
|
||||
}
|
||||
return { ws, evalExpr, screenshot, close: () => ws.close() }
|
||||
}
|
||||
|
||||
// Prompt DeepSeek to write files into an absolute workdir; the model can
|
||||
// use its `bash` tool. We assert on real filesystem outcomes.
|
||||
const TASKS = [
|
||||
{
|
||||
id: 'T01', title: 'single-file create (fizzbuzz)',
|
||||
prompt: (wd) => `Use the bash tool to create a file at exactly ${wd}/fizzbuzz.py containing a Python fizzbuzz that prints 1..15. Then confirm.`,
|
||||
judge: (wd) => {
|
||||
const p = path.join(wd, 'fizzbuzz.py')
|
||||
if (!fs.existsSync(p)) return { pass: false, why: 'file not created' }
|
||||
const s = fs.readFileSync(p, 'utf8')
|
||||
const looksLikeFizzbuzz = /fizz/i.test(s) && /buzz/i.test(s)
|
||||
return { pass: looksLikeFizzbuzz, why: looksLikeFizzbuzz ? 'file has fizz/buzz' : 'file lacks fizz/buzz' }
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'T02', title: 'read file + answer',
|
||||
setup: (wd) => fs.writeFileSync(path.join(wd, 'note.txt'), 'The secret color is turquoise. Do not forget.\n'),
|
||||
prompt: (wd) => `Read the file at exactly ${wd}/note.txt using bash (cat), then tell me the secret color.`,
|
||||
judge: (_wd, text) => {
|
||||
const found = /turquoise/i.test(text || '')
|
||||
return { pass: found, why: found ? 'answer contains turquoise' : 'answer lacks turquoise' }
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'T03', title: 'bash execute + summarize',
|
||||
prompt: (wd) => `Run the bash command \`ls -la ${wd}\` and summarize what you see in one sentence.`,
|
||||
judge: (_wd, text) => {
|
||||
const mentions = /file|dir|empty|contents|entries|nothing/i.test(text || '')
|
||||
return { pass: mentions, why: mentions ? 'summary reads like a listing summary' : 'summary does not describe a listing' }
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'T04', title: 'multi-step: three files + index',
|
||||
prompt: (wd) => `Create three files with bash: ${wd}/a.txt (contents "alpha"), ${wd}/b.txt (contents "beta"), ${wd}/c.txt (contents "gamma"). Then create ${wd}/index.txt whose contents are the concatenation "alpha beta gamma".`,
|
||||
judge: (wd) => {
|
||||
const ok = ['a.txt', 'b.txt', 'c.txt', 'index.txt'].every((f) => fs.existsSync(path.join(wd, f)))
|
||||
if (!ok) return { pass: false, why: 'missing file(s)' }
|
||||
const idx = fs.readFileSync(path.join(wd, 'index.txt'), 'utf8')
|
||||
const hasAll = /alpha/.test(idx) && /beta/.test(idx) && /gamma/.test(idx)
|
||||
return { pass: hasAll, why: hasAll ? 'index has alpha/beta/gamma' : 'index missing tokens' }
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'T05', title: 'file edit (append line)',
|
||||
setup: (wd) => fs.writeFileSync(path.join(wd, 'log.txt'), 'first line\n'),
|
||||
prompt: (wd) => `Append the line "second line" to ${wd}/log.txt using bash. Keep the original content.`,
|
||||
judge: (wd) => {
|
||||
const s = fs.readFileSync(path.join(wd, 'log.txt'), 'utf8')
|
||||
const both = /first line/.test(s) && /second line/.test(s)
|
||||
return { pass: both, why: both ? 'both lines present' : `missing lines, got: ${JSON.stringify(s)}` }
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'T06', title: 'error recovery (nonexistent path)',
|
||||
prompt: (wd) => `Try to \`cat /nonexistent/path-${Date.now()}\` using bash. When it fails, tell me in one sentence why it failed.`,
|
||||
judge: (_wd, text) => {
|
||||
const explains = /(no such|not found|does not exist|missing|nonexistent|doesn.t exist)/i.test(text || '')
|
||||
return { pass: explains, why: explains ? 'model explained the failure' : 'model did not explain' }
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'T07', title: 'long output task',
|
||||
prompt: (_wd) => `Write a Python one-liner in a code fence that prints 200 lines of increasing integers. Do not execute it; just show the code.`,
|
||||
judge: (_wd, text) => {
|
||||
const hasCode = /(```|for|range|print)/i.test(text || '')
|
||||
return { pass: hasCode && (text || '').length > 60, why: hasCode ? 'response contains code' : 'no code visible' }
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'T08', title: 'cancel mid-turn',
|
||||
// Long-running prompt we will cancel around 900ms in.
|
||||
prompt: (_wd) => `Explain in extreme detail, at least 800 words, how the TCP three-way handshake works, including diagrams in ASCII.`,
|
||||
cancelAfterMs: 900,
|
||||
judge: (_wd, _text, meta) => {
|
||||
const cancelled = meta.cancelResult && meta.cancelResult.cancelled === true
|
||||
return { pass: cancelled, why: cancelled ? 'server acknowledged cancel' : 'cancel returned ' + JSON.stringify(meta.cancelResult) }
|
||||
},
|
||||
// No render assertions for the cancelled turn beyond "turn present in stream".
|
||||
skipRenderChecks: false,
|
||||
},
|
||||
{
|
||||
id: 'T09', title: 'fork replay from seq 1',
|
||||
prompt: (wd) => `Say only the word "spark".`,
|
||||
// After the turn, driver will fork from seq 1 and confirm shape.
|
||||
fork: true,
|
||||
judge: (_wd, text, meta) => {
|
||||
const said = /spark/i.test(text || '')
|
||||
const fk = meta.forkResult
|
||||
const forked = fk && fk.childSessionId && (fk.mocked === false)
|
||||
return { pass: said && forked, why: `saidSpark=${said} forked=${forked} mocked=${fk && fk.mocked}` }
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'T10', title: 'multi-turn follow-up',
|
||||
prompt: (_wd) => `Remember the number 42 for later. Reply with just: OK.`,
|
||||
followUp: {
|
||||
prompt: 'What number did I ask you to remember?',
|
||||
judge: (text) => ({ pass: /42/.test(text || ''), why: /42/.test(text || '') ? 'follow-up said 42' : 'follow-up missed 42' }),
|
||||
},
|
||||
judge: (_wd, text) => {
|
||||
return { pass: /ok/i.test(text || ''), why: /ok/i.test(text || '') ? 'first turn said OK' : 'first turn did not say OK' }
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
async function ensureSandbox() {
|
||||
fs.mkdirSync(SANDBOX_ROOT, { recursive: true })
|
||||
}
|
||||
|
||||
async function newSessionWithCwd(c, _cwd) {
|
||||
// newSession() ignores cwd (main.js:206 uses process.cwd); the sandbox is
|
||||
// enforced by the prompt using absolute paths under SANDBOX_ROOT.
|
||||
return c.evalExpr(`
|
||||
(async () => {
|
||||
const r = await window.dsh.newSession();
|
||||
if (window.__dshRenderer && window.__dshRenderer.selectSession) {
|
||||
window.__dshRenderer.selectSession(r.id);
|
||||
}
|
||||
return r;
|
||||
})()
|
||||
`)
|
||||
}
|
||||
|
||||
async function sendPromptAndCollect(c, sid, prompt, { cancelAfterMs, budget = 60000 } = {}) {
|
||||
// Fire send (do not await here — cancel test needs mid-flight control).
|
||||
await c.evalExpr(`
|
||||
(() => {
|
||||
window.__task = { sendResult: null, sendErr: null };
|
||||
window.__task.sendPromise = window.dsh.sendPrompt(${JSON.stringify(sid)}, ${JSON.stringify(prompt)})
|
||||
.then(r => { window.__task.sendResult = r; return r; })
|
||||
.catch(e => { window.__task.sendErr = String(e && e.message || e); });
|
||||
return true;
|
||||
})()
|
||||
`, true, 10000)
|
||||
let cancelResult = null
|
||||
const start = Date.now()
|
||||
if (cancelAfterMs) {
|
||||
await new Promise((r) => setTimeout(r, cancelAfterMs))
|
||||
cancelResult = await c.evalExpr(`
|
||||
(async () => await window.dsh.cancelPrompt(${JSON.stringify(sid)}, 'battery cancel'))()
|
||||
`, true, 15000)
|
||||
}
|
||||
// Poll for turn/end in the renderer's cachedEvents.
|
||||
const eventsExpr = (sid) => `
|
||||
(() => {
|
||||
const st = window.__dshRenderer && window.__dshRenderer.snapshotState && window.__dshRenderer.snapshotState();
|
||||
const meta = st && st.sessions && st.sessions.get ? st.sessions.get(${JSON.stringify(sid)}) : null;
|
||||
const evs = (meta && meta.cachedEvents) || [];
|
||||
const types = evs.map(e => e.type);
|
||||
const done = types.includes('turn/end');
|
||||
let text = '';
|
||||
for (const e of evs) {
|
||||
if (e.type === 'assistant/chunk' && e.content) {
|
||||
const c = e.content;
|
||||
if (typeof c === 'string') text += c;
|
||||
else if (c.text) text += c.text;
|
||||
else if (Array.isArray(c)) for (const b of c) if (b && b.type === 'text' && b.text) text += b.text;
|
||||
}
|
||||
if (e.type === 'assistant/message' && e.content) {
|
||||
const c = e.content;
|
||||
if (Array.isArray(c)) for (const b of c) if (b && b.type === 'text' && b.text) text += b.text;
|
||||
else if (typeof c === 'string') text += c;
|
||||
}
|
||||
}
|
||||
return { done, text, eventCount: evs.length, types };
|
||||
})()
|
||||
`
|
||||
let lastSnap = { done: false, text: '', eventCount: 0, types: [] }
|
||||
while (Date.now() - start < budget) {
|
||||
lastSnap = await c.evalExpr(eventsExpr(sid))
|
||||
if (lastSnap.done) break
|
||||
if (cancelResult) {
|
||||
const settled = await c.evalExpr('window.__task && (window.__task.sendErr || (window.__task.sendResult && window.__task.sendResult.cancelled))')
|
||||
if (settled && lastSnap.eventCount > 0) {
|
||||
// Give a beat for a final assistant/message before bailing.
|
||||
await new Promise((r) => setTimeout(r, 800))
|
||||
lastSnap = await c.evalExpr(eventsExpr(sid))
|
||||
break
|
||||
}
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
}
|
||||
const sendState = await c.evalExpr('({ sendResult: window.__task && window.__task.sendResult, sendErr: window.__task && window.__task.sendErr })')
|
||||
const snapshot = {
|
||||
text: lastSnap.text || '',
|
||||
eventCount: lastSnap.eventCount || 0,
|
||||
done: !!lastSnap.done,
|
||||
types: lastSnap.types || [],
|
||||
sendResult: sendState.sendResult || null,
|
||||
sendErr: sendState.sendErr || null,
|
||||
}
|
||||
return { snapshot, cancelResult }
|
||||
}
|
||||
|
||||
async function renderAssertions(c, sid, taskId) {
|
||||
// Ask the renderer for tool-card / trace-footer / drawer state for this session.
|
||||
const scoped = await c.evalExpr(`
|
||||
(() => {
|
||||
const s = document.getElementById('stream');
|
||||
const html = s ? s.innerHTML : '';
|
||||
// Placeholder scanner: 1969 timestamps, literal "—" chains, "[object Object]".
|
||||
const has1969 = /1969-|Wed Dec 31 1969/.test(html);
|
||||
const hasObjObj = /\\[object Object\\]/.test(html);
|
||||
// Tool cards render as .tool-card (or generic .card with role=tool).
|
||||
const toolCards = s ? s.querySelectorAll('.tool-card, [data-kind="tool"]').length : 0;
|
||||
// Trace footer / drawer glyph presence.
|
||||
const footerHasTokens = s ? !!s.querySelector('.trace-footer, .turn-footer, [data-role="trace-footer"]') : false;
|
||||
// reasoning drawer / button presence.
|
||||
const reasoningBtn = s ? !!s.querySelector('[data-tab="reasoning"], .reasoning-toggle') : false;
|
||||
return { htmlLen: html.length, has1969, hasObjObj, toolCards, footerHasTokens, reasoningBtn };
|
||||
})()
|
||||
`)
|
||||
// Tracing page row for this session (non-—).
|
||||
const tracingRow = await c.evalExpr(`
|
||||
(async () => {
|
||||
// Switch to the tracing tab briefly to trigger render, then read.
|
||||
if (window.__dshTabs && window.__dshTabs.switchTo) {
|
||||
try { window.__dshTabs.switchTo('tracing'); } catch (_) {}
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 800));
|
||||
const table = document.querySelector('.tracing-table, [data-view="tracing"] table, table.tracing');
|
||||
const rowCount = table ? table.querySelectorAll('tbody tr').length : 0;
|
||||
// find row containing our session id
|
||||
let ourRowText = null;
|
||||
if (table) {
|
||||
for (const tr of table.querySelectorAll('tbody tr')) {
|
||||
if (tr.textContent && tr.textContent.indexOf(${JSON.stringify(sid.slice(0, 8))}) >= 0) {
|
||||
ourRowText = tr.textContent.replace(/\\s+/g, ' ').trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// switch back to chat
|
||||
if (window.__dshTabs && window.__dshTabs.switchTo) {
|
||||
try { window.__dshTabs.switchTo('chat'); } catch (_) {}
|
||||
}
|
||||
return { rowCount, ourRowText };
|
||||
})()
|
||||
`, true, 20000)
|
||||
return { ...scoped, tracing: tracingRow, taskId }
|
||||
}
|
||||
|
||||
async function collectErrors(c) {
|
||||
return c.evalExpr('({ errs: (window.__battery && window.__battery.errs) || [], apiCalls: (window.__battery && window.__battery.apiCalls) || 0 })')
|
||||
}
|
||||
|
||||
async function runTask(c, task, apiBudget) {
|
||||
const wd = path.join(SANDBOX_ROOT, task.id)
|
||||
fs.mkdirSync(wd, { recursive: true })
|
||||
if (task.setup) task.setup(wd)
|
||||
const sess = await newSessionWithCwd(c, wd)
|
||||
const sid = sess.id
|
||||
log(task.id, task.title, '→ sid', sid, 'wd', wd)
|
||||
const prompt = task.prompt(wd)
|
||||
const { snapshot, cancelResult } = await sendPromptAndCollect(c, sid, prompt, {
|
||||
cancelAfterMs: task.cancelAfterMs,
|
||||
budget: task.cancelAfterMs ? 20000 : 90000,
|
||||
})
|
||||
const meta = { cancelResult, forkResult: null }
|
||||
if (task.fork) {
|
||||
try {
|
||||
meta.forkResult = await c.evalExpr(`
|
||||
(async () => await window.dsh.forkSession({ sessionId: ${JSON.stringify(sid)}, boundary: { fromSeq: 1 } }))()
|
||||
`, true, 15000)
|
||||
} catch (e) { meta.forkResult = { error: String(e.message) } }
|
||||
}
|
||||
// Follow-up second turn (T10).
|
||||
let followUpText = null
|
||||
if (task.followUp) {
|
||||
const fu = await sendPromptAndCollect(c, sid, task.followUp.prompt, { budget: 90000 })
|
||||
followUpText = fu.snapshot.text
|
||||
}
|
||||
const verdict = task.judge(wd, snapshot.text, meta)
|
||||
const followUpVerdict = task.followUp ? task.followUp.judge(followUpText) : null
|
||||
const render = await renderAssertions(c, sid, task.id)
|
||||
return {
|
||||
id: task.id, title: task.title, sid, wd,
|
||||
prompt, replyText: snapshot.text.slice(0, 400) + (snapshot.text.length > 400 ? '…' : ''),
|
||||
replyLen: snapshot.text.length,
|
||||
eventCount: snapshot.eventCount,
|
||||
done: snapshot.done,
|
||||
sendErr: snapshot.sendErr,
|
||||
meta,
|
||||
verdict,
|
||||
followUpText: followUpText ? followUpText.slice(0, 300) : null,
|
||||
followUpVerdict,
|
||||
render,
|
||||
}
|
||||
}
|
||||
|
||||
async function buttonScan(c, someSid) {
|
||||
// Select a real-data session and try clicking a set of common interactive elements.
|
||||
await c.evalExpr(`window.__dshRenderer && window.__dshRenderer.selectSession && window.__dshRenderer.selectSession(${JSON.stringify(someSid)})`)
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
const scan = await c.evalExpr(`
|
||||
(() => {
|
||||
const dead = [];
|
||||
const clicked = [];
|
||||
const s = document.getElementById('stream');
|
||||
if (!s) return { dead: ['no-stream'], clicked: [] };
|
||||
// 1. tool-card expand triggers
|
||||
const expandables = Array.from(s.querySelectorAll('summary, [data-toggle], .expandable, .tool-card summary, details > summary'));
|
||||
for (const el of expandables.slice(0, 10)) {
|
||||
const before = el.closest('details') ? el.closest('details').open : null;
|
||||
try {
|
||||
el.click();
|
||||
clicked.push({sel:'expandable', tag:el.tagName, before, after: el.closest('details') ? el.closest('details').open : null});
|
||||
} catch (e) { dead.push({sel:'expandable-click', err: String(e.message)}); }
|
||||
}
|
||||
// 2. { } drawer buttons
|
||||
const jsonBtns = Array.from(document.querySelectorAll('.copy-json, .open-json, [data-action="open-json"], [aria-label*="JSON" i]'));
|
||||
for (const el of jsonBtns.slice(0, 5)) {
|
||||
try { el.click(); clicked.push({sel:'json-btn', tag:el.tagName}); }
|
||||
catch(e){ dead.push({sel:'json-btn', err: String(e.message)}); }
|
||||
}
|
||||
// 3. tab buttons in detail panes (Feedback/Input/Output/Attributes/Error)
|
||||
const tabs = Array.from(document.querySelectorAll('.tab-button, [role="tab"]'));
|
||||
for (const el of tabs.slice(0, 8)) {
|
||||
try { el.click(); clicked.push({sel:'tab', tag:el.tagName, label:(el.textContent||'').trim().slice(0,20)}); }
|
||||
catch(e){ dead.push({sel:'tab', err: String(e.message)}); }
|
||||
}
|
||||
// 4. copy buttons
|
||||
const copyBtns = Array.from(document.querySelectorAll('.copy-btn, [data-action="copy"]'));
|
||||
for (const el of copyBtns.slice(0, 5)) {
|
||||
try { el.click(); clicked.push({sel:'copy', tag:el.tagName}); }
|
||||
catch(e){ dead.push({sel:'copy', err: String(e.message)}); }
|
||||
}
|
||||
return { dead, clicked, counts: {
|
||||
expandables: expandables.length, jsonBtns: jsonBtns.length, tabs: tabs.length, copyBtns: copyBtns.length,
|
||||
} };
|
||||
})()
|
||||
`)
|
||||
return scan
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await ensureSandbox()
|
||||
const c = await connect()
|
||||
try {
|
||||
log('runtimeStatus:')
|
||||
const rs = await c.evalExpr('(async()=>await window.dsh.runtimeStatus())()')
|
||||
console.log(JSON.stringify(rs, null, 2))
|
||||
if (rs.profile !== 'stdio-deepseek') {
|
||||
log('WARN: expected stdio-deepseek, got', rs.profile, '— switching')
|
||||
await c.evalExpr('(async()=>await window.dsh.startRuntime("stdio-deepseek"))()')
|
||||
// wait
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await new Promise((r) => setTimeout(r, 3000))
|
||||
const s = await c.evalExpr('(async()=>{const x=await window.dsh.runtimeStatus();return {status:x.status,profile:x.profile}})()')
|
||||
log('poll', s)
|
||||
if (s.status === 'running' && s.profile === 'stdio-deepseek') break
|
||||
}
|
||||
}
|
||||
|
||||
const results = []
|
||||
for (const t of TASKS) {
|
||||
try {
|
||||
const r = await runTask(c, t, 40)
|
||||
results.push(r)
|
||||
log(t.id, 'verdict:', r.verdict.pass ? 'PASS' : 'FAIL', '—', r.verdict.why)
|
||||
} catch (e) {
|
||||
log(t.id, 'EXCEPTION', e.message)
|
||||
results.push({ id: t.id, title: t.title, error: String(e.message), verdict: { pass: false, why: 'exception: ' + e.message } })
|
||||
}
|
||||
}
|
||||
|
||||
// Button scan on last session that had real data (prefer T01).
|
||||
let scan = null
|
||||
try {
|
||||
const anySid = results.find((r) => r.sid && r.verdict.pass)?.sid || results[0]?.sid
|
||||
if (anySid) scan = await buttonScan(c, anySid)
|
||||
} catch (e) {
|
||||
scan = { error: String(e.message) }
|
||||
}
|
||||
|
||||
const errCollect = await collectErrors(c)
|
||||
const report = {
|
||||
timestamp: nowIso(),
|
||||
profile: rs.profile,
|
||||
model: rs.model,
|
||||
results,
|
||||
buttonScan: scan,
|
||||
capturedErrors: errCollect.errs,
|
||||
apiCallsCounted: errCollect.apiCalls,
|
||||
passCount: results.filter((r) => r.verdict.pass).length,
|
||||
totalTasks: results.length,
|
||||
followUpPassCount: results.filter((r) => r.followUpVerdict && r.followUpVerdict.pass).length,
|
||||
followUpTotal: results.filter((r) => r.followUpVerdict).length,
|
||||
}
|
||||
const outPath = path.join(SANDBOX_ROOT, 'battery-report.json')
|
||||
fs.writeFileSync(outPath, JSON.stringify(report, null, 2))
|
||||
log('wrote', outPath)
|
||||
log('SUMMARY', report.passCount + '/' + report.totalTasks, 'PASS followUps', report.followUpPassCount + '/' + report.followUpTotal, ' apiCalls', report.apiCallsCounted, 'errs', report.capturedErrors.length)
|
||||
} finally {
|
||||
c.close()
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e.stack || e.message); process.exit(1) })
|
||||
Reference in New Issue
Block a user