feat(desktop): unified right-side Inspector (Pretty/Raw/JSON) + at-bottom auto-follow fix
This commit is contained in:
514
examples/desktop/test/inspector-drawer.test.js
Normal file
514
examples/desktop/test/inspector-drawer.test.js
Normal file
@@ -0,0 +1,514 @@
|
||||
// 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() }
|
||||
})
|
||||
109
examples/desktop/test/stream-follow.test.js
Normal file
109
examples/desktop/test/stream-follow.test.js
Normal file
@@ -0,0 +1,109 @@
|
||||
// Tests for lane-p0-inspector — src/renderer/stream-follow.js.
|
||||
//
|
||||
// The module is a pure "should we auto-follow the streaming tail?" controller
|
||||
// with no DOM. We exercise the distance math, the near-bottom threshold, and
|
||||
// the stateful controller's pin/detach/repin transitions directly — these are
|
||||
// the exact decisions renderer.js's followStream()/onScroll wiring consumes.
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const sf = require('../src/renderer/stream-follow.js')
|
||||
|
||||
// --- distanceFromBottom / isNearBottom (pure math) ------------------------
|
||||
|
||||
test('distanceFromBottom: pinned to the very bottom reads 0', () => {
|
||||
assert.equal(sf.distanceFromBottom({ scrollTop: 900, scrollHeight: 1000, clientHeight: 100 }), 0)
|
||||
})
|
||||
|
||||
test('distanceFromBottom: scrolled up reports the gap', () => {
|
||||
assert.equal(sf.distanceFromBottom({ scrollTop: 500, scrollHeight: 1000, clientHeight: 100 }), 400)
|
||||
})
|
||||
|
||||
test('distanceFromBottom: clamps negative (sub-pixel rounding under zoom) to 0', () => {
|
||||
assert.equal(sf.distanceFromBottom({ scrollTop: 950, scrollHeight: 1000, clientHeight: 100 }), 0)
|
||||
})
|
||||
|
||||
test('distanceFromBottom: missing metrics → 0 (fail safe: treat as at-bottom)', () => {
|
||||
assert.equal(sf.distanceFromBottom(null), 0)
|
||||
})
|
||||
|
||||
test('isNearBottom: within threshold is near; beyond is not', () => {
|
||||
const m = (top) => ({ scrollTop: top, scrollHeight: 1000, clientHeight: 100 })
|
||||
assert.equal(sf.isNearBottom(m(870), 40), true, '30px from bottom ≤ 40px threshold')
|
||||
assert.equal(sf.isNearBottom(m(859), 40), false, '41px from bottom > 40px threshold')
|
||||
})
|
||||
|
||||
test('isNearBottom: default threshold is 40px', () => {
|
||||
assert.equal(sf.DEFAULT_THRESHOLD_PX, 40)
|
||||
const m = (top) => ({ scrollTop: top, scrollHeight: 1000, clientHeight: 100 })
|
||||
assert.equal(sf.isNearBottom(m(861)), true) // 39px
|
||||
assert.equal(sf.isNearBottom(m(855)), false) // 45px
|
||||
})
|
||||
|
||||
// --- createFollowController state machine ---------------------------------
|
||||
|
||||
test('controller: starts pinned by default', () => {
|
||||
const c = sf.createFollowController()
|
||||
assert.equal(c.isPinned(), true)
|
||||
})
|
||||
|
||||
test('controller: startPinned:false begins detached', () => {
|
||||
const c = sf.createFollowController({ startPinned: false })
|
||||
assert.equal(c.isPinned(), false)
|
||||
})
|
||||
|
||||
test('controller: onContent while pinned → follow, no chip', () => {
|
||||
const c = sf.createFollowController()
|
||||
const r = c.onContent()
|
||||
assert.deepEqual(r, { follow: true, showChip: false })
|
||||
})
|
||||
|
||||
test('controller: scroll up detaches; next content does NOT follow and shows chip', () => {
|
||||
const c = sf.createFollowController({ threshold: 40 })
|
||||
// reader scrolls up 400px
|
||||
const s = c.onScroll({ scrollTop: 500, scrollHeight: 1000, clientHeight: 100 })
|
||||
assert.equal(s.pinned, false)
|
||||
assert.equal(s.showChip, false, 'no new content yet, so no chip on the scroll itself')
|
||||
// new streamed content lands while detached
|
||||
const r = c.onContent()
|
||||
assert.equal(r.follow, false, 'must NOT yank a reader who scrolled up')
|
||||
assert.equal(r.showChip, true, 'chip appears because content arrived while detached')
|
||||
})
|
||||
|
||||
test('controller: onScroll back to bottom clears detached-content flag + chip', () => {
|
||||
const c = sf.createFollowController({ threshold: 40 })
|
||||
c.onScroll({ scrollTop: 500, scrollHeight: 1000, clientHeight: 100 }) // detach
|
||||
c.onContent() // detachedWithNew = true
|
||||
const back = c.onScroll({ scrollTop: 900, scrollHeight: 1000, clientHeight: 100 }) // re-pin
|
||||
assert.equal(back.pinned, true)
|
||||
assert.equal(back.showChip, false, 'returning to bottom hides the chip')
|
||||
})
|
||||
|
||||
test('controller: a scroll event while detached AND with pending content re-shows the chip', () => {
|
||||
const c = sf.createFollowController({ threshold: 40 })
|
||||
c.onScroll({ scrollTop: 500, scrollHeight: 1000, clientHeight: 100 }) // detach
|
||||
c.onContent() // detachedWithNew = true
|
||||
// reader nudges but stays detached (still 400px up)
|
||||
const s = c.onScroll({ scrollTop: 480, scrollHeight: 1000, clientHeight: 100 })
|
||||
assert.equal(s.pinned, false)
|
||||
assert.equal(s.showChip, true, 'still detached with pending content → chip stays')
|
||||
})
|
||||
|
||||
test('controller: repin() forces pinned + clears chip (chip click / deliberate jump)', () => {
|
||||
const c = sf.createFollowController({ threshold: 40 })
|
||||
c.onScroll({ scrollTop: 500, scrollHeight: 1000, clientHeight: 100 })
|
||||
c.onContent()
|
||||
const r = c.repin()
|
||||
assert.deepEqual(r, { pinned: true, showChip: false })
|
||||
assert.equal(c.isPinned(), true)
|
||||
// after repin, content follows again
|
||||
assert.equal(c.onContent().follow, true)
|
||||
})
|
||||
|
||||
test('controller: threshold is exposed for the wiring to mirror', () => {
|
||||
assert.equal(sf.createFollowController({ threshold: 25 }).threshold, 25)
|
||||
assert.equal(sf.createFollowController().threshold, 40)
|
||||
})
|
||||
Reference in New Issue
Block a user