Files
deepseek-harness/examples/desktop/test/tool-cards.test.js
ZiyaZhang e8f5c0b51b 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.
2026-07-18 12:59:34 -07:00

712 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Tool render-intent cards unit tests. Runs under `node --test`, no Electron.
//
// Shares the DOM-shim approach with widgets.test.js — see that file's
// comments for why we hand-roll a `document` instead of pulling in jsdom.
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
function makeShim() {
function make(tagName) {
const el = {
tagName: String(tagName).toUpperCase(),
children: [],
attrs: {},
style: {},
dataset: {},
classList: {
_s: new Set(),
add(...names) { for (const n of names) this._s.add(n) },
remove(...names) { for (const n of names) this._s.delete(n) },
contains(n) { return this._s.has(n) },
toggle(n) { if (this._s.has(n)) this._s.delete(n); else this._s.add(n) },
},
_text: '',
get textContent() { return this._text },
set textContent(v) { this._text = String(v); this.children = [] },
set className(v) { this._className = String(v); for (const c of String(v).split(/\s+/)) this.classList.add(c) },
get className() { return this._className || '' },
setAttribute(k, v) { this.attrs[k] = String(v) },
appendChild(c) { this.children.push(c); return c },
append(...cs) { for (const c of cs) this.children.push(c) },
addEventListener() { /* no-op */ },
querySelector(sel) {
// Only supports simple class selectors we use ('.card-code-dispatch').
const cls = String(sel).replace(/^\./, '')
function search(node) {
if (!node || !node.children) return null
for (const c of node.children) {
if (c.classList && c.classList.contains(cls)) return c
const hit = search(c)
if (hit) return hit
}
return null
}
return search(this)
},
}
return el
}
const doc = {
createElement: (t) => make(t),
createTextNode: (t) => ({ nodeType: 3, textContent: String(t) }),
}
return { doc }
}
function loadCards() {
const p = require.resolve('../src/renderer/tool-cards.js')
delete require.cache[p]
return require('../src/renderer/tool-cards.js')
}
function walk(node, pred, out = []) {
if (!node || !node.tagName) return out
if (pred(node)) out.push(node)
for (const c of node.children || []) walk(c, pred, out)
return out
}
// ----- families --------------------------------------------------------------
test('toolFamilyFor: bash/edit/read/web_search/skill/subagent/todo_write/cordis_*/run_code all map', () => {
global.document = makeShim().doc
const { toolFamilyFor } = loadCards()
assert.equal(toolFamilyFor('bash').className, 'family-bash')
assert.equal(toolFamilyFor('edit').className, 'family-fs')
assert.equal(toolFamilyFor('read').className, 'family-fs')
assert.equal(toolFamilyFor('write').className, 'family-fs')
assert.equal(toolFamilyFor('web_search').className, 'family-web')
assert.equal(toolFamilyFor('web_fetch').className, 'family-web')
assert.equal(toolFamilyFor('skill').className, 'family-skill')
assert.equal(toolFamilyFor('subagent').className, 'family-subagent')
assert.equal(toolFamilyFor('todo_write').className, 'family-todo')
assert.equal(toolFamilyFor('cordis_inspect').className, 'family-cordis')
assert.equal(toolFamilyFor('cordis_mount').className, 'family-cordis')
assert.equal(toolFamilyFor('run_code').className, 'family-code')
})
test('toolFamilyFor: unknown tool name returns null (generic fall-through)', () => {
global.document = makeShim().doc
const { toolFamilyFor } = loadCards()
assert.equal(toolFamilyFor('made_up_tool'), null)
assert.equal(toolFamilyFor(undefined), null)
assert.equal(toolFamilyFor(''), null)
})
// ----- terminal card ---------------------------------------------------------
test('terminal card: stamps `data-tool-card-family="bash"` so QA probes can find it', () => {
// Round-visual N1 (2026-07-16). Terminal cards are dispatched only from
// bash-family tool/results, so the hook is hard-coded to 'bash'. See the
// matching hook on the parent `.tool-block` inside renderer.js
// appendToolCall — probes that walk into the card node directly still get
// the family without climbing back up the DOM.
global.document = makeShim().doc
const { renderTerminalCard } = loadCards()
const el = renderTerminalCard({ card: 'terminal', output: 'ok', exitCode: 0 })
assert.equal(el.attrs['data-tool-card-family'], 'bash')
})
test('diff card: stamps `data-tool-card-family="fs"` so QA probes can find it', () => {
// Round-visual N1: diff cards fire only from fs-family (read/write/edit)
// tool/results. Same rationale as the terminal-card hook.
global.document = makeShim().doc
const { renderDiffCard } = loadCards()
const el = renderDiffCard({ card: 'diff', diffs: [] })
assert.equal(el.attrs['data-tool-card-family'], 'fs')
})
test('terminal card: renders output verbatim in a <pre> with an exit-0 badge', () => {
global.document = makeShim().doc
const { renderTerminalCard } = loadCards()
const el = renderTerminalCard({ card: 'terminal', output: 'hello\nworld', exitCode: 0 })
const pres = walk(el, (n) => n.tagName === 'PRE')
assert.equal(pres.length, 1)
assert.equal(pres[0]._text, 'hello\nworld')
const badges = walk(el, (n) => n.classList && n.classList.contains('card-badge'))
assert.equal(badges.length, 1)
assert.match(badges[0]._text, /exit 0/)
assert.ok(badges[0].classList.contains('ok'))
})
test('terminal card: non-zero exit code uses the err class + shows the code', () => {
global.document = makeShim().doc
const { renderTerminalCard } = loadCards()
const el = renderTerminalCard({ card: 'terminal', output: 'boom', exitCode: 137 })
const badge = walk(el, (n) => n.classList && n.classList.contains('card-badge'))[0]
assert.ok(badge.classList.contains('err'))
assert.equal(badge._text, 'exit 137')
})
test('terminal card: signal chip alongside missing exitCode', () => {
global.document = makeShim().doc
const { renderTerminalCard } = loadCards()
const el = renderTerminalCard({ card: 'terminal', output: '', signal: 'SIGTERM' })
const sig = walk(el, (n) => n.classList && n.classList.contains('sig'))
assert.equal(sig.length, 1)
assert.equal(sig[0]._text, 'SIGTERM')
})
test('terminal card: missing output renders a placeholder, not an empty pre', () => {
global.document = makeShim().doc
const { renderTerminalCard } = loadCards()
const el = renderTerminalCard({ card: 'terminal', exitCode: 0 })
const pre = walk(el, (n) => n.tagName === 'PRE')[0]
assert.equal(pre._text, '(no output)')
})
// ----- diff card -------------------------------------------------------------
test('diff card: new-file (oldText === null) renders every line as an addition', () => {
global.document = makeShim().doc
const { renderDiffCard } = loadCards()
const el = renderDiffCard({
card: 'diff',
title: 'Write foo.txt',
diffs: [{ path: 'foo.txt', oldText: null, newText: 'a\nb\nc' }],
})
const adds = walk(el, (n) => n.classList && n.classList.contains('add') && n.classList.contains('card-diff-line'))
assert.equal(adds.length, 3)
const dels = walk(el, (n) => n.classList && n.classList.contains('del') && n.classList.contains('card-diff-line'))
assert.equal(dels.length, 0)
const newBadge = walk(el, (n) => n.classList && n.classList.contains('ok') && n._text === 'new')
assert.equal(newBadge.length, 1)
})
test('diff card: single-line replace shows one del + one add + surrounding context', () => {
global.document = makeShim().doc
const { renderDiffCard } = loadCards()
const el = renderDiffCard({
card: 'diff',
diffs: [{ path: 'x.txt', oldText: 'alpha\nbeta\ngamma', newText: 'alpha\nBETA\ngamma' }],
})
const rows = walk(el, (n) => n.classList && n.classList.contains('card-diff-line'))
const kinds = rows.map((r) => (r.classList.contains('add') ? 'add' : r.classList.contains('del') ? 'del' : 'ctx'))
// LCS: ctx alpha, del beta, add BETA, ctx gamma
assert.deepEqual(kinds, ['ctx', 'del', 'add', 'ctx'])
})
test('diff card: empty diffs list renders a "(no changes)" placeholder', () => {
global.document = makeShim().doc
const { renderDiffCard } = loadCards()
const el = renderDiffCard({ card: 'diff', diffs: [] })
const empties = walk(el, (n) => n.classList && n.classList.contains('card-diff-empty'))
assert.equal(empties.length, 1)
})
test('diff card: title is shown when present, omitted when absent', () => {
global.document = makeShim().doc
const { renderDiffCard } = loadCards()
const withTitle = renderDiffCard({ card: 'diff', title: 'Edit foo', diffs: [] })
const titles = walk(withTitle, (n) => n.classList && n.classList.contains('card-diff-title'))
assert.equal(titles.length, 1)
assert.equal(titles[0]._text, 'Edit foo')
const noTitle = renderDiffCard({ card: 'diff', diffs: [] })
const emptyTitles = walk(noTitle, (n) => n.classList && n.classList.contains('card-diff-title'))
assert.equal(emptyTitles.length, 0)
})
test('diffLines: order and kinds match the classic LCS backtrack', () => {
global.document = makeShim().doc
const { diffLines } = loadCards()
const out = diffLines(['a', 'b', 'c'], ['a', 'x', 'c'])
assert.deepEqual(out, [
{ kind: 'ctx', text: 'a' },
{ kind: 'del', text: 'b' },
{ kind: 'add', text: 'x' },
{ kind: 'ctx', text: 'c' },
])
assert.deepEqual(diffLines([], ['a']), [{ kind: 'add', text: 'a' }])
assert.deepEqual(diffLines(['a'], []), [{ kind: 'del', text: 'a' }])
assert.deepEqual(diffLines([], []), [])
})
// ----- code-dispatch fan-out -------------------------------------------------
test('appendCodeDispatch: first call clears placeholder + creates the list header', () => {
global.document = makeShim().doc
const { appendCodeDispatch } = loadCards()
const box = document.createElement('div')
box.textContent = '…'
appendCodeDispatch(box, { name: 'bash', subCallId: 'sc1', isError: false, resultSummary: 'ok' })
const header = walk(box, (n) => n.classList && n.classList.contains('card-code-dispatch-header'))
assert.equal(header.length, 1)
const rows = walk(box, (n) => n.classList && n.classList.contains('card-code-dispatch-row'))
assert.equal(rows.length, 1)
assert.ok(rows[0].classList.contains('ok'))
const name = walk(rows[0], (n) => n.classList && n.classList.contains('card-code-dispatch-name'))[0]
assert.equal(name._text, 'bash')
})
test('appendCodeDispatch: second call reuses the same list (no double header)', () => {
global.document = makeShim().doc
const { appendCodeDispatch } = loadCards()
const box = document.createElement('div')
appendCodeDispatch(box, { name: 'read', subCallId: 'a', isError: false, resultSummary: 'ok' })
appendCodeDispatch(box, { name: 'edit', subCallId: 'b', isError: true, resultSummary: 'no match' })
const headers = walk(box, (n) => n.classList && n.classList.contains('card-code-dispatch-header'))
assert.equal(headers.length, 1, 'header should not duplicate')
const rows = walk(box, (n) => n.classList && n.classList.contains('card-code-dispatch-row'))
assert.equal(rows.length, 2)
assert.ok(rows[1].classList.contains('err'))
})
// ----- durationMs pill (Ticket D) -------------------------------------------
test('formatDurationLabel: sub-second stays in ms, 1s < X < 60s uses N.Ns', () => {
global.document = makeShim().doc
const { formatDurationLabel } = loadCards()
assert.equal(formatDurationLabel(0), '0ms')
assert.equal(formatDurationLabel(37), '37ms')
assert.equal(formatDurationLabel(999), '999ms')
assert.equal(formatDurationLabel(1000), '1.0s')
assert.equal(formatDurationLabel(1500), '1.5s')
assert.equal(formatDurationLabel(59900), '59.9s')
})
test('formatDurationLabel: minute + hour scales use compound labels', () => {
global.document = makeShim().doc
const { formatDurationLabel } = loadCards()
assert.equal(formatDurationLabel(60_000), '1m00s')
assert.equal(formatDurationLabel(75_000), '1m15s')
assert.equal(formatDurationLabel(59 * 60_000 + 30_000), '59m30s')
assert.equal(formatDurationLabel(60 * 60_000), '1h00m')
assert.equal(formatDurationLabel(3 * 60 * 60_000 + 7 * 60_000), '3h07m')
})
test('formatDurationLabel: rejects non-finite / negative to empty string', () => {
global.document = makeShim().doc
const { formatDurationLabel } = loadCards()
assert.equal(formatDurationLabel(undefined), '')
assert.equal(formatDurationLabel(null), '')
assert.equal(formatDurationLabel(NaN), '')
assert.equal(formatDurationLabel(-1), '')
assert.equal(formatDurationLabel('37'), '')
})
test('durationFromToolResult: reads data.durationMs, then meta.durationMs, else null', () => {
global.document = makeShim().doc
const { durationFromToolResult } = loadCards()
assert.equal(durationFromToolResult({ durationMs: 42 }), 42)
assert.equal(durationFromToolResult({ meta: { durationMs: 88 } }), 88)
// Top-level wins when both present — matches audit doc: data.durationMs is
// the canonical source; meta.durationMs is only a nesting fallback.
assert.equal(durationFromToolResult({ durationMs: 10, meta: { durationMs: 999 } }), 10)
assert.equal(durationFromToolResult({}), null)
assert.equal(durationFromToolResult(null), null)
assert.equal(durationFromToolResult({ durationMs: -5 }), null)
assert.equal(durationFromToolResult({ meta: { durationMs: NaN } }), null)
})
// Shim `closest` + a `summary` child so applyToolDuration can drive the
// browser lookup path the renderer uses. The shim's element factory doesn't
// wire a `.closest`, so we hang one on the details node explicitly.
function makeToolBlockShim() {
const { doc } = makeShim()
global.document = doc
const details = doc.createElement('details')
details.classList.add('tool-block')
const summary = doc.createElement('summary')
details.appendChild(summary)
// Support the two-step lookup path used by the renderer (`closest`
// finds the enclosing block; `querySelector('summary')` finds the row).
details.closest = (sel) => (sel === '.tool-block' ? details : null)
details.querySelector = (sel) => (sel === 'summary' ? summary : null)
return { details, summary }
}
test('applyToolDuration: appends a .tool-duration pill with the formatted label', () => {
const { applyToolDuration } = loadCards()
const { details, summary } = makeToolBlockShim()
const pill = applyToolDuration(details, 1500)
assert.ok(pill, 'pill returned')
assert.ok(summary.children.includes(pill))
assert.equal(pill._text, '1.5s')
assert.ok(pill.classList.contains('tool-duration'))
assert.equal(pill.attrs.title, 'tool ran for 1.5s')
})
test('applyToolDuration: idempotent — second call updates label, no duplicate pill', () => {
const { applyToolDuration } = loadCards()
const { details, summary } = makeToolBlockShim()
// Wire summary.querySelector so the second call finds the existing pill.
const originalQS = summary.querySelector
summary.querySelector = (sel) => {
if (sel === '.tool-duration') {
return summary.children.find((c) => c.classList && c.classList.contains('tool-duration')) || null
}
return originalQS ? originalQS.call(summary, sel) : null
}
applyToolDuration(details, 250)
applyToolDuration(details, 60_000)
const pills = summary.children.filter((c) => c.classList && c.classList.contains('tool-duration'))
assert.equal(pills.length, 1, 'pill should update in place')
assert.equal(pills[0]._text, '1m00s')
})
test('applyToolDuration: no-op on missing / bad inputs — no pill inserted', () => {
const { applyToolDuration } = loadCards()
const { details, summary } = makeToolBlockShim()
assert.equal(applyToolDuration(details, undefined), null)
assert.equal(applyToolDuration(details, -1), null)
assert.equal(applyToolDuration(null, 100), null)
assert.equal(summary.children.length, 0)
})
// ----- hunkize (Ticket #139 / clause 1.5 v2) --------------------------------
test('hunkize: identical files (all ctx) → no hunks', () => {
global.document = makeShim().doc
const { hunkize } = loadCards()
const lines = [
{ kind: 'ctx', text: 'a' },
{ kind: 'ctx', text: 'b' },
{ kind: 'ctx', text: 'c' },
]
assert.deepEqual(hunkize(lines, 3), [])
assert.deepEqual(hunkize([], 3), [])
})
test('hunkize: single change gets ctx head + tail; header uses canonical shape', () => {
global.document = makeShim().doc
const { hunkize } = loadCards()
const lines = [
{ kind: 'ctx', text: 'a' },
{ kind: 'ctx', text: 'b' },
{ kind: 'del', text: 'old' },
{ kind: 'add', text: 'new' },
{ kind: 'ctx', text: 'c' },
{ kind: 'ctx', text: 'd' },
]
const hunks = hunkize(lines, 2)
assert.equal(hunks.length, 1)
assert.equal(hunks[0].lines.length, 6)
// old side: a,b,old,c,d = 5 rows starting at line 1
// new side: a,b,new,c,d = 5 rows starting at line 1
assert.equal(hunks[0].header, '@@ -1,5 +1,5 @@')
})
test('hunkize: two clusters far apart split into two hunks with fresh headers', () => {
global.document = makeShim().doc
const { hunkize } = loadCards()
// Build: 3 ctx, 1 add, 10 ctx (gap > 2*ctx=6), 1 del, 3 ctx
const lines = []
for (let i = 0; i < 3; i++) lines.push({ kind: 'ctx', text: `h${i}` })
lines.push({ kind: 'add', text: 'A' })
for (let i = 0; i < 10; i++) lines.push({ kind: 'ctx', text: `m${i}` })
lines.push({ kind: 'del', text: 'D' })
for (let i = 0; i < 3; i++) lines.push({ kind: 'ctx', text: `t${i}` })
const hunks = hunkize(lines, 3)
assert.equal(hunks.length, 2, 'gap > 2*ctx → split')
// First hunk: h0,h1,h2,A,m0,m1,m2 = 7 rows; old=6 (no A), new=7
assert.equal(hunks[0].header, '@@ -1,6 +1,7 @@')
// Second hunk starts after the 10-ctx gap. Old:new counter positions:
// before hunk1: old=0,new=0. After hunk1's 7 rows (6 old, 7 new): old=6,new=7.
// Then hunk2 head-skip walks `end`=7 → next cluster start. We're
// asserting the header exists + shape parses; exact numbers depend on
// the internal counter which we already exercise via the first hunk.
assert.match(hunks[1].header, /^@@ -\d+,\d+ \+\d+,\d+ @@$/)
})
test('hunkize: two changes close together stay in one hunk', () => {
global.document = makeShim().doc
const { hunkize } = loadCards()
const lines = [
{ kind: 'ctx', text: 'a' },
{ kind: 'add', text: 'A' },
{ kind: 'ctx', text: 'b' },
{ kind: 'ctx', text: 'c' },
{ kind: 'del', text: 'D' },
{ kind: 'ctx', text: 'd' },
]
// ctx=3 → gap between A(idx1) and D(idx4) is 2 rows ≤ 2*3 → same hunk.
const hunks = hunkize(lines, 3)
assert.equal(hunks.length, 1)
})
// ----- diff card CodeSandbox layout -----------------------------------------
test('diff card: single file → no file tree, pane inline', () => {
global.document = makeShim().doc
const { renderDiffCard } = loadCards()
const el = renderDiffCard({
card: 'diff',
diffs: [{ path: 'a.js', oldText: 'x', newText: 'y' }],
})
const trees = walk(el, (n) => n.classList && n.classList.contains('card-diff-tree'))
assert.equal(trees.length, 0, 'no tree for 1 file')
const panes = walk(el, (n) => n.classList && n.classList.contains('card-diff-pane'))
assert.equal(panes.length, 1)
})
test('diff card: multi-file renders file tree + pane (default first file active)', () => {
global.document = makeShim().doc
const { renderDiffCard } = loadCards()
const el = renderDiffCard({
card: 'diff',
diffs: [
{ path: 'a.js', oldText: 'x', newText: 'y' },
{ path: 'b.js', oldText: null, newText: 'new file' },
{ path: 'c.js', oldText: '1\n2', newText: '1\n2\n3' },
],
})
const trees = walk(el, (n) => n.classList && n.classList.contains('card-diff-tree'))
assert.equal(trees.length, 1)
const rows = walk(el, (n) => n.classList && n.classList.contains('card-diff-tree-row'))
assert.equal(rows.length, 3, '3 tree rows')
const active = rows.filter((r) => r.classList.contains('active'))
assert.equal(active.length, 1)
assert.strictEqual(active[0], rows[0], 'first row starts active')
// File paths shown in tree
const paths = walk(el, (n) => n.classList && n.classList.contains('card-diff-tree-path')).map((n) => n._text)
assert.deepEqual(paths, ['a.js', 'b.js', 'c.js'])
// Counts: new file gets 'new'; others get +N M
const counts = walk(el, (n) => n.classList && n.classList.contains('card-diff-tree-count')).map((n) => n._text)
assert.equal(counts[1], 'new')
assert.match(counts[0], /^\+\d+ \d+$/)
})
test('diff card: hunk cap — 25 hunks renders 20 + reveal button for the tail', () => {
global.document = makeShim().doc
const { renderDiffCard, HUNK_CAP } = loadCards()
// Build oldText/newText that produce many distant hunks: change every 20 lines
// (gap way bigger than 2*ctx=6) so hunkize splits them.
const N = 25
const oldLines = []
const newLines = []
for (let i = 0; i < N; i++) {
// Each block: 10 identical ctx rows, then 1 changed row.
for (let j = 0; j < 10; j++) {
oldLines.push(`p${i}-${j}`)
newLines.push(`p${i}-${j}`)
}
oldLines.push(`OLD-${i}`)
newLines.push(`NEW-${i}`)
}
const el = renderDiffCard({
card: 'diff',
diffs: [{ path: 'big.txt', oldText: oldLines.join('\n'), newText: newLines.join('\n') }],
})
const hunkDetails = walk(el, (n) => n.classList && n.classList.contains('card-diff-hunk'))
assert.equal(hunkDetails.length, HUNK_CAP, `first ${HUNK_CAP} hunks rendered`)
const reveal = walk(el, (n) => n.classList && n.classList.contains('card-diff-hunk-more'))
assert.equal(reveal.length, 1)
assert.match(reveal[0]._text, /Show 5 more hunks/)
})
test('diff card: first hunk opens by default, rest closed', () => {
global.document = makeShim().doc
const { renderDiffCard } = loadCards()
// Two well-separated changes → two hunks.
const oldLines = ['a', 'b', 'c', 'X', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'Y', 'n']
const newLines = ['a', 'b', 'c', 'X-mod', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'Y-mod', 'n']
const el = renderDiffCard({
card: 'diff',
diffs: [{ path: 'multi.txt', oldText: oldLines.join('\n'), newText: newLines.join('\n') }],
})
const hunkDetails = walk(el, (n) => n.classList && n.classList.contains('card-diff-hunk'))
assert.equal(hunkDetails.length, 2, 'expected 2 hunks')
assert.ok('open' in hunkDetails[0].attrs, 'first hunk open by default')
assert.ok(!('open' in hunkDetails[1].attrs), 'second hunk closed')
})
// ----- JSON badge + drawer --------------------------------------------------
test('renderJsonBadge: returns a "{ }" button that fires onClick without bubbling', () => {
global.document = makeShim().doc
const { renderJsonBadge } = loadCards()
let clicked = 0
const btn = renderJsonBadge(() => { clicked++ })
assert.equal(btn.tagName, 'BUTTON')
assert.equal(btn._text, '{ }')
assert.ok(btn.classList.contains('tool-json-badge'))
// Simulate a click: the badge attaches its own listener via addEventListener;
// the shim's addEventListener is a no-op, so we invoke the stored handler
// directly. Verifying onClick fires + stopPropagation is invoked.
let stopped = 0, prevented = 0
const fakeEvent = {
stopPropagation() { stopped++ },
preventDefault() { prevented++ },
}
// The badge wraps the caller's onClick; we can't reach it via the shim.
// Instead verify the wrapper by re-wiring the addEventListener to capture.
// Reload with a document that records handlers.
const doc = makeShim().doc
global.document = doc
const { renderJsonBadge: rjb2 } = loadCards()
let handlers = []
const origMake = doc.createElement
doc.createElement = (t) => {
const el = origMake(t)
el.addEventListener = (evt, fn) => { if (evt === 'click') handlers.push(fn) }
return el
}
const btn2 = rjb2(() => { clicked++ })
assert.equal(handlers.length, 1, 'click handler registered')
handlers[0](fakeEvent)
assert.equal(clicked, 1)
assert.equal(stopped, 1, 'propagation stopped')
assert.equal(prevented, 1, 'default prevented')
// Cleanup so later tests get a fresh document.
global.document = undefined
})
// Shim a minimal drawer + getElementById for openJsonDrawer / closeJsonDrawer.
function makeDrawerShim() {
const { doc } = makeShim()
global.document = doc
const drawer = doc.createElement('aside')
drawer.id = 'tool-json-drawer'
const title = doc.createElement('div')
title.className = 'tool-json-drawer-title'
drawer.appendChild(title)
const callPre = doc.createElement('pre')
callPre.className = 'tool-json-pane call'
callPre.attrs['data-json-pane'] = 'call'
drawer.appendChild(callPre)
const resultPre = doc.createElement('pre')
resultPre.className = 'tool-json-pane result'
resultPre.attrs['data-json-pane'] = 'result'
drawer.appendChild(resultPre)
// classList tracking
drawer.setAttribute = (k, v) => { drawer.attrs[k] = String(v) }
// querySelector: handle .class and [data-json-pane="x"]
drawer.querySelector = (sel) => {
if (sel === '.tool-json-drawer-title') return title
const m = /^\[data-json-pane="(\w+)"\]$/.exec(sel)
if (m) return m[1] === 'call' ? callPre : resultPre
return null
}
doc.getElementById = (id) => (id === 'tool-json-drawer' ? drawer : null)
// Record document-level listeners so we can assert cleanup.
const listeners = { keydown: [] }
doc.addEventListener = (evt, fn) => { (listeners[evt] || (listeners[evt] = [])).push(fn) }
doc.removeEventListener = (evt, fn) => {
const arr = listeners[evt]
if (!arr) return
const i = arr.indexOf(fn)
if (i >= 0) arr.splice(i, 1)
}
return { doc, drawer, title, callPre, resultPre, listeners }
}
test('openJsonDrawer: fills title + call + result and adds `open` class', () => {
const { drawer, title, callPre, resultPre } = makeDrawerShim()
const { openJsonDrawer } = loadCards()
const ret = openJsonDrawer({
title: 'tool: bash',
call: '{"command":"ls"}',
result: { ok: true, content: [{ type: 'text', text: 'a\nb' }] },
})
assert.strictEqual(ret, drawer)
assert.equal(title._text, 'tool: bash')
assert.match(callPre._text, /"command": "ls"/)
assert.match(resultPre._text, /"ok": true/)
assert.ok(drawer.classList.contains('open'))
assert.equal(drawer.attrs['aria-hidden'], 'false')
})
test('openJsonDrawer: missing payloads render explicit placeholders (no crash)', () => {
const { callPre, resultPre } = makeDrawerShim()
const { openJsonDrawer } = loadCards()
openJsonDrawer({ title: 't', call: null, result: undefined })
assert.equal(callPre._text, '(no call payload captured)')
assert.equal(resultPre._text, '(result pending)')
})
test('closeJsonDrawer: removes `open`, aria-hidden true, unbinds keydown', () => {
const { drawer, listeners } = makeDrawerShim()
const { openJsonDrawer, closeJsonDrawer } = loadCards()
openJsonDrawer({ title: 't', call: {}, result: {} })
assert.equal((listeners.keydown || []).length, 1, 'esc handler bound on open')
closeJsonDrawer()
assert.ok(!drawer.classList.contains('open'))
assert.equal(drawer.attrs['aria-hidden'], 'true')
assert.equal((listeners.keydown || []).length, 0, 'esc handler unbound on close')
})
test('openJsonDrawer: missing drawer element in DOM returns null (guard)', () => {
const { doc } = makeShim()
global.document = doc
doc.getElementById = () => null
const { openJsonDrawer, closeJsonDrawer } = loadCards()
assert.equal(openJsonDrawer({ title: 't' }), null)
// closeJsonDrawer should be a no-op too, not throw.
closeJsonDrawer()
})
// 2026-07-18 P0 hotfix (payload-controls drawer overlap): the earlier
// buildOrRefreshDrawerControls inserted the util's controls cluster as a
// bare child of `.tool-json-section` and relied on `float:right +
// margin-top:-18px` to shove it onto the <summary> baseline — which
// overlapped the summary's meta annotation
// `(content · meta · isError · error · durationMs)` (the actual P0 the
// user hit). The hotfix wraps controls in a
// `.tool-json-section-controls` strip that sits between summary and pre
// as its own flex row. This test locks the new structural invariant so
// nobody re-introduces the direct-child insertion.
test('openJsonDrawer: drawer controls sit in .tool-json-section-controls strip (not as bare section child)', () => {
const { drawer, callPre, resultPre } = makeDrawerShim()
const pcApi = require('../src/renderer/payload-controls.js')
global.window = { __dshPayloadControls: pcApi }
// Give the pre nodes a synthetic .tool-json-section closest() so
// buildOrRefreshDrawerControls can locate the section wrapper.
const doc = global.document
function wireSection(section, preNode) {
section.className = 'tool-json-section'
section.children = []
section.appendChild = (n) => { section.children.push(n); n.parentNode = section; return n }
section.appendChild(preNode)
preNode.closest = (sel) => (sel === '.tool-json-section' ? section : null)
section.querySelector = (sel) => {
if (sel.startsWith('.tool-json-section-controls')) {
return section.children.find(c => (c.className || '').includes('tool-json-section-controls')) || null
}
return null
}
section.insertBefore = (n, ref) => {
const idx = section.children.indexOf(ref)
section.children.splice(idx >= 0 ? idx : section.children.length, 0, n)
n.parentNode = section
}
// matches the removeChild contract our dedupe path calls into
const origRemoveChild = (c) => { const i = section.children.indexOf(c); if (i >= 0) section.children.splice(i, 1) }
section.removeChild = origRemoveChild
}
const callSection = doc.createElement('details')
wireSection(callSection, callPre)
const resultSection = doc.createElement('details')
wireSection(resultSection, resultPre)
const { openJsonDrawer } = loadCards()
openJsonDrawer({ title: 't', call: { name: 'bash' }, result: { content: 'ok' } })
// Contract 1: the controls live in a `.tool-json-section-controls` strip.
const callStrip = callSection.children.find(c => (c.className || '').includes('tool-json-section-controls'))
const resultStrip = resultSection.children.find(c => (c.className || '').includes('tool-json-section-controls'))
assert.ok(callStrip, 'call section grew a controls strip wrapper')
assert.ok(resultStrip, 'result section grew a controls strip wrapper')
// Contract 2: NO bare `.payload-controls` child directly under the section
// (would revert to the float:right + margin-top:-18px overlap regime).
const bareCallCtrl = callSection.children.find(c => (c.className || '').includes('payload-controls') && !(c.className || '').includes('tool-json-section-controls'))
const bareResultCtrl = resultSection.children.find(c => (c.className || '').includes('payload-controls') && !(c.className || '').includes('tool-json-section-controls'))
assert.equal(bareCallCtrl, undefined, 'no bare payload-controls under call section')
assert.equal(bareResultCtrl, undefined, 'no bare payload-controls under result section')
// Contract 3: strip carries `data-drawer-controls` marker so idempotent
// re-open tears down the prior strip cleanly.
assert.equal(callStrip.attrs['data-drawer-controls'], 'call')
assert.equal(resultStrip.attrs['data-drawer-controls'], 'result')
// Contract 4: re-open replaces the strip in place — no duplicate.
openJsonDrawer({ title: 't', call: { name: 'edit' }, result: { content: 'ok2' } })
const callStrips = resultSection.children.filter(c => (c.className || '').includes('tool-json-section-controls'))
assert.ok(callStrips.length <= 1, `re-open must not stack strips (got ${callStrips.length})`)
delete global.window
})