Files
deepseek-harness/examples/desktop/test/inspector-drawer.test.js

515 lines
23 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.
// Tests for lane-p0-inspector — src/renderer/inspector-drawer.js.
//
// The inspector's projections (projectPretty / formatRaw / normalizeTab /
// kindForEvent) are pure and are the contract the three tabs render against;
// the DOM renderers (renderPretty / renderRaw / renderJson) take an injected
// `doc` so we exercise them with a hand-rolled shim (same approach as
// tool-cards.test.js / context-side-drawer.test.js — no jsdom).
//
// Static gates assert the index.html drawer scaffold + style.css geometry are
// present, since the browser wiring (open/close/setTab) resolves the drawer by
// id and toggles classes the CSS keys off.
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const inspector = require('../src/renderer/inspector-drawer.js')
// --- pure: normalizeTab / kindForEvent ------------------------------------
test('normalizeTab: known tabs pass through; unknown falls back to pretty', () => {
assert.equal(inspector.normalizeTab('pretty'), 'pretty')
assert.equal(inspector.normalizeTab('raw'), 'raw')
assert.equal(inspector.normalizeTab('json'), 'json')
assert.equal(inspector.normalizeTab('bogus'), 'pretty')
assert.equal(inspector.normalizeTab(undefined), 'pretty')
})
test('kindForEvent: maps each stream element type to its inspector kind', () => {
assert.equal(inspector.kindForEvent({ type: 'user/message' }), 'user')
assert.equal(inspector.kindForEvent({ type: 'assistant/message' }), 'assistant')
assert.equal(inspector.kindForEvent({ type: 'reasoning' }), 'reasoning')
assert.equal(inspector.kindForEvent({ type: 'tool/call' }), 'tool-call')
assert.equal(inspector.kindForEvent({ type: 'tool/result' }), 'tool-result')
assert.equal(inspector.kindForEvent({ type: 'context/message' }), 'context')
assert.equal(inspector.kindForEvent({ type: 'steering/message' }), 'context')
assert.equal(inspector.kindForEvent({ type: 'compact/summary' }), 'compact')
assert.equal(inspector.kindForEvent({ type: 'subagent/started' }), 'subagent')
assert.equal(inspector.kindForEvent({ type: 'subagent/finished' }), 'subagent')
assert.equal(inspector.kindForEvent({ type: 'dev/heartbeat' }), 'event')
assert.equal(inspector.kindForEvent({}), 'event')
})
// --- pure: projectPretty per event type -----------------------------------
test('projectPretty: user message projects text block + seq chip', () => {
const p = inspector.projectPretty({ type: 'user/message', seq: 4, data: { text: 'hello there' } })
assert.equal(p.kind, 'user')
assert.equal(p.title, 'User message')
assert.deepEqual(p.meta[0], { label: 'seq', value: '4' })
const textBlock = p.blocks.find((b) => b.label === 'text')
assert.ok(textBlock && textBlock.text === 'hello there')
})
test('projectPretty: user message folds content blocks when no raw text', () => {
const p = inspector.projectPretty({
type: 'user/message',
data: { content: [{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }] },
})
assert.equal(p.blocks.find((b) => b.label === 'text').text, 'ab')
})
test('projectPretty: assistant message surfaces usage as meta chips', () => {
const p = inspector.projectPretty({
type: 'assistant/message',
seq: 7,
data: { text: 'answer', usage: { inputTokens: 100, outputTokens: 20, totalTokens: 120 } },
})
assert.equal(p.title, 'Assistant message')
const labels = p.meta.map((m) => m.label)
assert.ok(labels.includes('input') && labels.includes('output') && labels.includes('total'))
assert.equal(p.meta.find((m) => m.label === 'output').value, '20')
})
test('projectPretty: usage dedupes across alias keys (input_tokens vs inputTokens)', () => {
const p = inspector.projectPretty({
type: 'assistant/message',
data: { text: 'x', usage: { inputTokens: 5, input_tokens: 5 } },
})
const inputs = p.meta.filter((m) => m.label === 'input')
assert.equal(inputs.length, 1, 'the input label appears once even when two alias keys are present')
})
test('projectPretty: reasoning projects the full thinking text', () => {
const p = inspector.projectPretty({ type: 'reasoning', data: { text: 'step by step' } })
assert.equal(p.title, 'Reasoning')
assert.equal(p.blocks[0].label, 'thinking')
assert.equal(p.blocks[0].text, 'step by step')
})
test('projectPretty: tool call shows name in title, args + result blocks (mono)', () => {
const p = inspector.projectPretty({
type: 'tool/call',
data: {
name: 'bash', callId: 'c1',
arguments: { cmd: 'ls' },
result: { content: 'file.txt', isError: false, durationMs: 12 },
},
})
assert.equal(p.title, 'Tool call · bash')
assert.ok(p.meta.some((m) => m.label === 'callId' && m.value === 'c1'))
assert.ok(p.meta.some((m) => m.label === 'isError' && m.value === 'false'))
assert.ok(p.meta.some((m) => m.label === 'durationMs' && m.value === '12'))
const args = p.blocks.find((b) => b.label === 'arguments')
assert.ok(args.mono && /"cmd": "ls"/.test(args.text))
const res = p.blocks.find((b) => b.label === 'result')
assert.ok(res.mono && res.text === 'file.txt')
})
test('projectPretty: tool call with no result yet marks result pending', () => {
const p = inspector.projectPretty({ type: 'tool/call', data: { name: 'read', arguments: {} } })
assert.equal(p.blocks.find((b) => b.label === 'result').text, '(result pending)')
})
test('projectPretty: tool result projects content + isError', () => {
const p = inspector.projectPretty({
type: 'tool/result',
data: { callId: 'c2', isError: true, content: 'boom' },
})
assert.equal(p.title, 'Tool result')
assert.ok(p.meta.some((m) => m.label === 'isError' && m.value === 'true'))
assert.equal(p.blocks.find((b) => b.label === 'content').text, 'boom')
})
test('projectPretty: context injection shows source + payload', () => {
const p = inspector.projectPretty({
type: 'context/message',
data: { source: { kind: 'plugin', plugin: 'skill' }, content: [{ type: 'text', text: 'loaded' }] },
})
assert.equal(p.title, 'Context injection')
assert.equal(p.meta.find((m) => m.label === 'source').value, 'plugin:skill')
assert.equal(p.blocks.find((b) => b.label === 'payload').text, 'loaded')
})
test('projectPretty: steering message titled distinctly from context', () => {
const p = inspector.projectPretty({ type: 'steering/message', data: { content: [{ type: 'text', text: 'go left' }] } })
assert.equal(p.title, 'Steering message')
})
test('projectPretty: compact projects the summary text + phase', () => {
const p = inspector.projectPretty({ type: 'compact/summary', data: { summary: 'kept the gist' } })
assert.equal(p.title, 'Compaction')
assert.equal(p.meta.find((m) => m.label === 'phase').value, 'summary')
assert.equal(p.blocks.find((b) => b.label === 'summary').text, 'kept the gist')
})
test('projectPretty: subagent projects status/stopReason + last assistant message', () => {
const p = inspector.projectPretty({
type: 'subagent/finished',
data: {
agentId: 'a1', status: 'ok', stopReason: 'end_turn',
lastAssistantMessage: [{ type: 'text', text: 'done' }],
},
})
assert.equal(p.title, 'Subagent')
assert.ok(p.meta.some((m) => m.label === 'status' && m.value === 'ok'))
assert.ok(p.meta.some((m) => m.label === 'stopReason' && m.value === 'end_turn'))
assert.equal(p.blocks.find((b) => b.label === 'result').text, 'done')
})
// --- pure: formatRaw -------------------------------------------------------
test('formatRaw: verbatim event → header (seq/type/time) + pretty JSON, not reconstructed', () => {
const raw = inspector.formatRaw({ type: 'user/message', seq: 3, time: 1234, data: { text: 'hi' } })
assert.equal(raw.header.seq, 3)
assert.equal(raw.header.type, 'user/message')
assert.equal(raw.header.time, 1234)
assert.equal(raw.reconstructed, false)
assert.equal(raw.note, '')
assert.ok(/"text": "hi"/.test(raw.json))
})
test('formatRaw: reconstructed record is flagged + noted, and the marker is stripped from JSON', () => {
const raw = inspector.formatRaw({
type: 'tool/call', data: { name: 'bash' }, __reconstructed: true,
})
assert.equal(raw.reconstructed, true)
assert.match(raw.note, /reconstructed/)
assert.ok(!/__reconstructed/.test(raw.json), 'internal marker must not leak into the verbatim JSON')
})
test('formatRaw: missing seq/time degrade to null header fields', () => {
const raw = inspector.formatRaw({ type: 'event', data: {} })
assert.equal(raw.header.seq, null)
assert.equal(raw.header.time, null)
assert.equal(raw.header.type, 'event')
})
// --- DOM shim --------------------------------------------------------------
function makeShim() {
function make(tagName) {
const el = {
tagName: String(tagName).toUpperCase(),
children: [],
attrs: {},
style: {},
dataset: {},
hidden: false,
_listeners: {},
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, force) {
const want = force === undefined ? !this._s.has(n) : !!force
if (want) this._s.add(n); else this._s.delete(n)
return want
},
},
_text: '',
get textContent() { return this._text },
set textContent(v) { this._text = String(v); this.children = [] },
set className(v) { this._className = String(v); this.classList._s = new Set(String(v).split(/\s+/).filter(Boolean)) },
get className() { return this._className || '' },
setAttribute(k, v) { this.attrs[k] = String(v) },
getAttribute(k) { return this.attrs[k] },
appendChild(c) { c.parentNode = this; this.children.push(c); return c },
append(...cs) { for (const c of cs) { c.parentNode = this; this.children.push(c) } },
addEventListener(k, fn) { (this._listeners[k] = this._listeners[k] || []).push(fn) },
dispatch(k, ev) { for (const fn of (this._listeners[k] || [])) fn(ev || {}) },
querySelector(sel) { return matchAll(this, sel)[0] || null },
querySelectorAll(sel) { const out = matchAll(this, sel); out.forEach = Array.prototype.forEach.bind(out); return out },
}
el.ownerDocument = null
return el
}
// Minimal selector matcher: '.class', '[data-panel="x"]', and
// '.class[data-panel="x"]' combined.
function matches(node, sel) {
if (!node || !node.tagName) return false
let rest = sel.trim()
// class
const classMatch = rest.match(/^\.([\w-]+)/)
if (classMatch) {
if (!node.classList.contains(classMatch[1])) return false
rest = rest.slice(classMatch[0].length)
}
// attribute [data-x="y"]
const attrMatch = rest.match(/^\[([\w-]+)="([^"]*)"\]/)
if (attrMatch) {
const key = attrMatch[1]
const want = attrMatch[2]
const got = key.startsWith('data-') ? node.dataset[dataKey(key)] : node.attrs[key]
if (String(got) !== want) return false
rest = rest.slice(attrMatch[0].length)
}
return rest.length === 0
}
function dataKey(attr) {
return attr.replace(/^data-/, '').replace(/-([a-z])/g, (_, c) => c.toUpperCase())
}
function matchAll(root, sel) {
const out = []
const stack = [...(root.children || [])]
while (stack.length) {
const n = stack.shift()
if (matches(n, sel)) out.push(n)
if (n && n.children) stack.push(...n.children)
}
return out
}
const doc = {
createElement: (t) => { const e = make(t); e.ownerDocument = doc; return e },
createTextNode: (t) => ({ nodeType: 3, textContent: String(t) }),
}
return { doc, make }
}
// --- DOM: renderPretty -----------------------------------------------------
test('renderPretty: writes title, meta chips, and block bodies into the host', () => {
const { doc } = makeShim()
const host = doc.createElement('div')
inspector.renderPretty(doc, host, {
title: 'Assistant message',
meta: [{ label: 'seq', value: '7' }, { label: 'output', value: '20' }],
blocks: [{ label: 'text', text: 'the answer' }],
})
const title = host.children.find((c) => c.className.includes('inspector-pretty-title'))
assert.equal(title.textContent, 'Assistant message')
const metaWrap = host.children.find((c) => c.className.includes('inspector-pretty-meta'))
assert.equal(metaWrap.children.length, 2)
const block = host.children.find((c) => c.tagName === 'SECTION')
assert.ok(block, 'a block section renders')
const body = block.children.find((c) => c.className.includes('inspector-pretty-block-body'))
assert.equal(body.textContent, 'the answer')
})
test('renderPretty: empty block text renders the "(empty)" placeholder', () => {
const { doc } = makeShim()
const host = doc.createElement('div')
inspector.renderPretty(doc, host, { title: 't', meta: [], blocks: [{ label: 'text', text: '' }] })
const body = host.querySelector('.inspector-pretty-block-body')
assert.equal(body.textContent, '(empty)')
})
// --- DOM: renderRaw --------------------------------------------------------
test('renderRaw: header line joins seq/type/time; <pre> holds the JSON; copy button present', () => {
const { doc } = makeShim()
const host = doc.createElement('div')
inspector.renderRaw(doc, host, inspector.formatRaw({ type: 'tool/call', seq: 9, time: 42, data: { name: 'bash' } }))
const label = host.querySelector('.inspector-raw-head-label')
assert.match(label.textContent, /seq 9/)
assert.match(label.textContent, /tool\/call/)
const pre = host.querySelector('.inspector-raw-pre')
assert.match(pre.textContent, /"name": "bash"/)
const copy = host.querySelector('.inspector-raw-copy')
assert.equal(copy.textContent, 'copy')
})
test('renderRaw: reconstructed record renders the "not a verbatim wire event" note', () => {
const { doc } = makeShim()
const host = doc.createElement('div')
inspector.renderRaw(doc, host, inspector.formatRaw({ type: 'reasoning', data: { text: 'x' }, __reconstructed: true }))
const note = host.querySelector('.inspector-raw-note')
assert.ok(note && /reconstructed/.test(note.textContent))
})
// --- DOM: renderJson (fields tree reuse) -----------------------------------
test('renderJson: falls back to a flat <pre> when the trace-detail fields tree is unavailable', () => {
const { doc } = makeShim()
const host = doc.createElement('div')
// No window.__dshTraceDetailPane in node → fallback path.
inspector.renderJson(doc, host, { type: 'user/message', data: { text: 'hi' }, __reconstructed: true })
const pre = host.querySelector('.inspector-raw-pre')
assert.ok(pre, 'fallback flat pre renders when buildJsonTree is absent')
assert.ok(!/__reconstructed/.test(pre.textContent), 'internal marker stripped in the JSON tab too')
})
// --- Static gates: index.html + style.css ---------------------------------
test('index.html: #inspector-drawer aside with three tabs + panels exists', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'src', 'renderer', 'index.html'), 'utf8')
assert.match(html, /id="inspector-drawer"/, 'the one drawer must exist so open()/close() can resolve it by id')
assert.match(html, /data-tab="pretty"/, 'Pretty tab button')
assert.match(html, /data-tab="raw"/, 'Raw tab button')
assert.match(html, /data-tab="json"/, 'JSON tab button')
assert.match(html, /data-panel="pretty"/, 'Pretty panel')
assert.match(html, /data-panel="raw"/, 'Raw panel')
assert.match(html, /data-panel="json"/, 'JSON panel')
assert.match(html, /id="inspector-drawer-close"/, 'close button target for the × / Escape bindings')
})
test('index.html: #stream-scroll-chip exists, hidden by default', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'src', 'renderer', 'index.html'), 'utf8')
assert.match(html, /id="stream-scroll-chip"[^>]*hidden/, 'the "back to bottom" chip starts hidden')
})
test('index.html: inspector-drawer.js is loaded AFTER trace-detail-pane.js (reuses its buildJsonTree)', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'src', 'renderer', 'index.html'), 'utf8')
const traceIdx = html.indexOf('trace-detail-pane.js')
const insIdx = html.indexOf('inspector-drawer.js')
assert.ok(traceIdx >= 0 && insIdx >= 0 && traceIdx < insIdx,
'inspector must load after trace-detail-pane so window.__dshTraceDetailPane.buildJsonTree is ready')
})
test('style.css: .inspector-drawer anchors right + slides via .open', () => {
const css = fs.readFileSync(path.join(__dirname, '..', 'src', 'renderer', 'style.css'), 'utf8')
const m = css.match(/\.inspector-drawer\s*\{[\s\S]+?\}/)
assert.ok(m, '.inspector-drawer rule missing')
assert.match(m[0], /right:\s*0/, 'drawer must anchor to the right edge')
assert.match(css, /\.inspector-drawer\.open\s*\{[^}]*translateX\(0\)/,
'.open must slide the drawer into view — open() adds this class')
assert.match(css, /\.stream-scroll-chip\[hidden\]\s*\{\s*display:\s*none/,
'the chip [hidden] attribute must collapse it')
})
// --- Drawer wiring: open() event anchoring + setTab() switching -----------
//
// open() / setTab() / renderActivePanel() guard on `typeof document`; we mint a
// global window+document with the same drawer scaffold index.html carries, load
// a FRESH copy of the module so its `isBrowser` closure sees them, then drive
// the state machine. This is the "event anchoring + tab switching" contract.
function buildDrawerDom(doc) {
const drawer = doc.createElement('aside')
drawer.id = 'inspector-drawer'
drawer.setAttribute('aria-hidden', 'true')
const title = doc.createElement('div'); title.className = 'inspector-drawer-title'; title.textContent = 'inspector'
drawer.appendChild(title)
for (const t of ['pretty', 'raw', 'json']) {
const tab = doc.createElement('button')
tab.className = 'inspector-tab' + (t === 'pretty' ? ' active' : '')
tab.dataset.tab = t
tab.setAttribute('aria-selected', t === 'pretty' ? 'true' : 'false')
drawer.appendChild(tab)
}
for (const p of ['pretty', 'raw', 'json']) {
const panel = doc.createElement('div')
panel.className = 'inspector-panel'
panel.dataset.panel = p
panel.hidden = p !== 'pretty'
drawer.appendChild(panel)
}
return drawer
}
function loadInspectorWithDom() {
const { doc } = makeShim()
const drawer = buildDrawerDom(doc)
const byId = { 'inspector-drawer': drawer }
doc.getElementById = (id) => byId[id] || null
doc.readyState = 'complete'
doc.addEventListener = () => {}
doc.removeEventListener = () => {}
const win = { document: doc }
global.window = win
global.document = doc
const p = require.resolve('../src/renderer/inspector-drawer.js')
delete require.cache[p]
const ins = require('../src/renderer/inspector-drawer.js')
return { ins, doc, drawer, byId }
}
function cleanupDom() {
delete global.window
delete global.document
const p = require.resolve('../src/renderer/inspector-drawer.js')
delete require.cache[p]
}
test('open(): anchors to the event, opens the drawer, renders the initial tab', () => {
const { ins, drawer } = loadInspectorWithDom()
try {
const ret = ins.open({ event: { type: 'user/message', seq: 2, data: { text: 'hi' } }, tab: 'pretty' })
assert.ok(ret, 'open returns the drawer node')
assert.equal(drawer.classList.contains('open'), true)
assert.equal(drawer.getAttribute('aria-hidden'), 'false')
const prettyPanel = drawer.querySelector('.inspector-panel[data-panel="pretty"]')
assert.equal(prettyPanel.hidden, false)
assert.ok(prettyPanel.children.length > 0, 'pretty panel got populated from the anchored event')
const title = drawer.querySelector('.inspector-drawer-title')
assert.equal(title.textContent, 'User message')
} finally { cleanupDom() }
})
test('open(): missing event is a no-op (returns null, drawer stays closed)', () => {
const { ins, drawer } = loadInspectorWithDom()
try {
assert.equal(ins.open({}), null)
assert.equal(drawer.classList.contains('open'), false)
} finally { cleanupDom() }
})
test('setTab(): switches active tab, toggles panel visibility + aria-selected', () => {
const { ins, drawer } = loadInspectorWithDom()
try {
ins.open({ event: { type: 'tool/call', data: { name: 'bash', arguments: { cmd: 'ls' } } }, tab: 'pretty' })
assert.equal(ins.setTab('raw'), 'raw')
const rawPanel = drawer.querySelector('.inspector-panel[data-panel="raw"]')
const prettyPanel = drawer.querySelector('.inspector-panel[data-panel="pretty"]')
assert.equal(rawPanel.hidden, false, 'raw panel shows')
assert.equal(prettyPanel.hidden, true, 'pretty panel hides')
const rawTab = drawer.querySelector('.inspector-tab[data-tab="raw"]')
assert.equal(rawTab.getAttribute('aria-selected'), 'true')
assert.equal(rawTab.classList.contains('active'), true)
assert.ok(rawPanel.querySelector('.inspector-raw-pre'), 'raw tab rendered its <pre>')
} finally { cleanupDom() }
})
test('setTab(): unknown tab name falls back to pretty', () => {
const { ins } = loadInspectorWithDom()
try {
ins.open({ event: { type: 'user/message', data: { text: 'x' } } })
assert.equal(ins.setTab('nope'), 'pretty')
} finally { cleanupDom() }
})
test('openFromDrawer(): call+result path reconstructs a combined tool/call record on the JSON tab', () => {
const { ins, drawer } = loadInspectorWithDom()
try {
ins.openFromDrawer({
title: 'tool: bash',
call: { callId: 'c9', name: 'bash', arguments: { cmd: 'ls' } },
result: { content: 'ok', isError: false },
})
assert.equal(drawer.classList.contains('open'), true)
// default tab for the drawer adapter is json
const jsonTab = drawer.querySelector('.inspector-tab[data-tab="json"]')
assert.equal(jsonTab.getAttribute('aria-selected'), 'true')
// switch to Raw and confirm the reconstructed note shows (combined record)
ins.setTab('raw')
const note = drawer.querySelector('.inspector-raw-note')
assert.ok(note && /reconstructed/.test(note.textContent),
'a combined call+result is labelled reconstructed, never sold as a verbatim wire event')
} finally { cleanupDom() }
})
test('openFromDrawer(): bare event path routes verbatim to the Raw tab', () => {
const { ins, drawer } = loadInspectorWithDom()
try {
ins.openFromDrawer({ title: 'user/message', event: { type: 'user/message', seq: 5, data: { text: 'hi' } }, tab: 'raw' })
const rawPanel = drawer.querySelector('.inspector-panel[data-panel="raw"]')
assert.equal(rawPanel.hidden, false)
assert.ok(!rawPanel.querySelector('.inspector-raw-note'), 'a verbatim event carries no reconstructed note')
} finally { cleanupDom() }
})
test('close(): removes the open class + marks aria-hidden', () => {
const { ins, drawer } = loadInspectorWithDom()
try {
ins.open({ event: { type: 'user/message', data: { text: 'x' } } })
ins.close()
assert.equal(drawer.classList.contains('open'), false)
assert.equal(drawer.getAttribute('aria-hidden'), 'true')
} finally { cleanupDom() }
})