mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
392 lines
16 KiB
JavaScript
392 lines
16 KiB
JavaScript
// Tests for lane-p1-tabs — src/renderer/session-log-view.js.
|
|
//
|
|
// The Log tab is the Chat pane's full-history event log for the ACTIVE
|
|
// session: a sessionEvents replay merged with live events, filtered by
|
|
// type-chips + text search, each row expandable with a { } inspector badge.
|
|
//
|
|
// Pure helpers (normalizeLogEntry / mergeLiveEntry / distinctTypes /
|
|
// summarizeEntry / pageSlice) run with no DOM. The controller
|
|
// (renderSessionLog / ingestLiveEvent) is exercised against a hand-rolled DOM
|
|
// shim + fake window.dsh.sessionEvents + a spy inspector — the same no-jsdom
|
|
// approach as inspector-drawer.test.js / chat-triple-view.test.js.
|
|
|
|
'use strict'
|
|
|
|
const test = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
|
|
// session-log-view.js reads window.DevtoolsModel / window.__dshInspector /
|
|
// window.dsh at call time, and attaches its API to window on load. Provide a
|
|
// window before requiring so the load-time `window.__dshSessionLogView =`
|
|
// assignment has a home, then read the module back off it. Also require
|
|
// DevtoolsModel so filterEntries composes exactly like the devtools panel.
|
|
const DevtoolsModel = require('../src/renderer/devtools-model.js')
|
|
global.window = global.window || {}
|
|
global.window.DevtoolsModel = DevtoolsModel
|
|
const logView = require('../src/renderer/session-log-view.js')
|
|
|
|
// ─── pure: normalizeLogEntry ───────────────────────────────────────────────
|
|
|
|
test('normalizeLogEntry: seq becomes the id; missing type → (unknown)', () => {
|
|
const e = logView.normalizeLogEntry({ type: 'user/message', seq: 7, time: 100, data: { text: 'hi' } }, -1)
|
|
assert.equal(e.id, 7)
|
|
assert.equal(e.seq, 7)
|
|
assert.equal(e.type, 'user/message')
|
|
assert.equal(e.time, 100)
|
|
assert.equal(e.event.data.text, 'hi')
|
|
})
|
|
|
|
test('normalizeLogEntry: seq-less event falls back to the supplied id', () => {
|
|
const e = logView.normalizeLogEntry({ type: 'chunk/delta', data: {} }, -5)
|
|
assert.equal(e.seq, null)
|
|
assert.equal(e.id, -5)
|
|
assert.equal(e.type, 'chunk/delta')
|
|
})
|
|
|
|
// ─── pure: mergeLiveEntry ───────────────────────────────────────────────────
|
|
|
|
test('mergeLiveEntry: appends a new seq in ascending order', () => {
|
|
const list = [
|
|
logView.normalizeLogEntry({ type: 'a', seq: 1 }),
|
|
logView.normalizeLogEntry({ type: 'b', seq: 3 }),
|
|
]
|
|
logView.mergeLiveEntry(list, logView.normalizeLogEntry({ type: 'c', seq: 2 }))
|
|
assert.deepEqual(list.map((e) => e.seq), [1, 2, 3])
|
|
})
|
|
|
|
test('mergeLiveEntry: a duplicate seq replaces in place (fuller payload)', () => {
|
|
const list = [logView.normalizeLogEntry({ type: 'assistant/message', seq: 5, data: { text: 'par' } })]
|
|
logView.mergeLiveEntry(list, logView.normalizeLogEntry({ type: 'assistant/message', seq: 5, data: { text: 'partial→full' } }))
|
|
assert.equal(list.length, 1, 'no duplicate row for the same seq')
|
|
assert.equal(list[0].event.data.text, 'partial→full')
|
|
})
|
|
|
|
test('mergeLiveEntry: seq-less event always appends', () => {
|
|
const list = [logView.normalizeLogEntry({ type: 'a', seq: 1 })]
|
|
logView.mergeLiveEntry(list, logView.normalizeLogEntry({ type: 'delta' }, -1))
|
|
logView.mergeLiveEntry(list, logView.normalizeLogEntry({ type: 'delta' }, -2))
|
|
assert.equal(list.length, 3)
|
|
})
|
|
|
|
// ─── pure: distinctTypes / summarizeEntry / pageSlice ───────────────────────
|
|
|
|
test('distinctTypes: sorted unique type list', () => {
|
|
const list = ['turn/start', 'user/message', 'turn/start', 'tool/call'].map(
|
|
(t, i) => logView.normalizeLogEntry({ type: t, seq: i }))
|
|
assert.deepEqual(logView.distinctTypes(list), ['tool/call', 'turn/start', 'user/message'])
|
|
})
|
|
|
|
test('summarizeEntry: prefers text, then content array, then stopReason', () => {
|
|
assert.equal(
|
|
logView.summarizeEntry(logView.normalizeLogEntry({ type: 'assistant/message', seq: 1, data: { text: 'hello world' } })),
|
|
'hello world')
|
|
assert.equal(
|
|
logView.summarizeEntry(logView.normalizeLogEntry({
|
|
type: 'user/message', seq: 2, data: { content: [{ type: 'text', text: 'multi' }, { type: 'text', text: 'part' }] },
|
|
})),
|
|
'multi part')
|
|
assert.equal(
|
|
logView.summarizeEntry(logView.normalizeLogEntry({ type: 'turn/end', seq: 3, data: { stopReason: 'cancelled' } })),
|
|
'cancelled')
|
|
})
|
|
|
|
test('summarizeEntry: truncates long text with an ellipsis', () => {
|
|
const s = logView.summarizeEntry(logView.normalizeLogEntry({ type: 'assistant/message', seq: 1, data: { text: 'x'.repeat(120) } }))
|
|
assert.ok(s.length <= 80)
|
|
assert.ok(s.endsWith('…'))
|
|
})
|
|
|
|
test('pageSlice: caps at PAGE and reports hasMore', () => {
|
|
const big = Array.from({ length: 450 }, (_, i) => logView.normalizeLogEntry({ type: 't', seq: i }))
|
|
const first = logView.pageSlice(big, logView.PAGE)
|
|
assert.equal(first.rows.length, logView.PAGE)
|
|
assert.equal(first.hasMore, true)
|
|
assert.equal(first.total, 450)
|
|
const second = logView.pageSlice(big, logView.PAGE * 3)
|
|
assert.equal(second.rows.length, 450)
|
|
assert.equal(second.hasMore, false)
|
|
})
|
|
|
|
// ─── controller: seed events (in-memory cache) ──────────────────────────────
|
|
|
|
test('renderSessionLog: seedEvents paint immediately (live-only session)', async () => {
|
|
const doc = makeDoc()
|
|
const c = container(doc)
|
|
const savedDsh = global.window.dsh
|
|
// Bridge returns nothing — mimics a daemon that hasn't persisted this live
|
|
// session. The seed must still render.
|
|
global.window.dsh = { sessionEvents: async () => ({ events: [] }) }
|
|
try {
|
|
logView.renderSessionLog(c, {
|
|
sessionId: 's-seed',
|
|
seedEvents: [
|
|
{ type: 'user/message', seq: 1, data: { text: 'live' } },
|
|
{ type: 'turn/start', seq: 2, data: {} },
|
|
],
|
|
})
|
|
await new Promise((r) => setTimeout(r, 5))
|
|
assert.equal(c.querySelectorAll('session-log-row').length, 2, 'seed rows survive an empty wire walk')
|
|
} finally {
|
|
global.window.dsh = savedDsh
|
|
}
|
|
})
|
|
|
|
test('renderSessionLog: a larger wire walk supersedes the seed', async () => {
|
|
const doc = makeDoc()
|
|
const c = container(doc)
|
|
const savedDsh = global.window.dsh
|
|
const events = [
|
|
{ type: 'user/message', seq: 1, data: {} },
|
|
{ type: 'turn/start', seq: 2, data: {} },
|
|
{ type: 'assistant/message', seq: 3, data: {} },
|
|
{ type: 'turn/end', seq: 4, data: {} },
|
|
]
|
|
global.window.dsh = { sessionEvents: makeSessionEventsBridge(events) }
|
|
try {
|
|
logView.renderSessionLog(c, {
|
|
sessionId: 's-supersede',
|
|
seedEvents: [{ type: 'user/message', seq: 1, data: {} }], // stale 1-event cache
|
|
})
|
|
await new Promise((r) => setTimeout(r, 5))
|
|
assert.equal(c.querySelectorAll('session-log-row').length, 4, 'wire walk (4) beats seed (1)')
|
|
} finally {
|
|
global.window.dsh = savedDsh
|
|
}
|
|
})
|
|
|
|
// ─── DOM shim ──────────────────────────────────────────────────────────────
|
|
//
|
|
// Minimal element with the surface session-log-view.js touches: className,
|
|
// dataset, textContent (clearing), appendChild, addEventListener + a manual
|
|
// `_fire`, querySelectorAll by single class, hidden, type/placeholder/value.
|
|
|
|
function makeDoc() {
|
|
function el(tag) {
|
|
const node = {
|
|
tagName: String(tag).toUpperCase(),
|
|
className: '',
|
|
_text: '',
|
|
placeholder: '',
|
|
type: '',
|
|
value: '',
|
|
hidden: false,
|
|
open: false,
|
|
dataset: {},
|
|
_children: [],
|
|
_listeners: {},
|
|
// Real DOM clears children when textContent is assigned; the controller
|
|
// relies on `el.textContent = ''` to reset a pane before repaint.
|
|
get textContent() { return this._text || this._children.map((c) => c.textContent || '').join('') },
|
|
set textContent(v) { this._text = String(v); this._children.length = 0 },
|
|
classList: {
|
|
_s: new Set(),
|
|
add(...c) { for (const x of c) this._s.add(x) },
|
|
remove(...c) { for (const x of c) this._s.delete(x) },
|
|
contains(c) { return this._s.has(c) },
|
|
toggle(c, f) { const h = this._s.has(c); const on = f === undefined ? !h : f; if (on) this._s.add(c); else this._s.delete(c); return on },
|
|
},
|
|
get firstChild() { return this._children[0] || null },
|
|
appendChild(c) { this._children.push(c); c.parentNode = node; return c },
|
|
removeChild(c) { const i = this._children.indexOf(c); if (i >= 0) this._children.splice(i, 1); return c },
|
|
setAttribute(k, v) { this.dataset[k] = v; if (k === 'class') this.className = String(v) },
|
|
getAttribute(k) { return this.dataset[k] },
|
|
addEventListener(evt, fn) { (this._listeners[evt] ||= []).push(fn) },
|
|
_fire(evt, arg) { for (const fn of (this._listeners[evt] || [])) fn(arg || { stopPropagation() {}, preventDefault() {} }) },
|
|
querySelectorAll(sel) {
|
|
const cls = sel.replace(/^\./, '')
|
|
const out = []
|
|
const walk = (n) => {
|
|
for (const c of (n._children || [])) {
|
|
if (typeof c.className === 'string' && c.className.split(/\s+/).includes(cls)) out.push(c)
|
|
walk(c)
|
|
}
|
|
}
|
|
walk(node)
|
|
return out
|
|
},
|
|
}
|
|
return node
|
|
}
|
|
return { createElement: el, createElementNS: (_ns, t) => el(t) }
|
|
}
|
|
|
|
function container(doc) {
|
|
const c = doc.createElement('div')
|
|
c.ownerDocument = doc
|
|
return c
|
|
}
|
|
|
|
// A fake window.dsh.sessionEvents that serves a fixed event list. First call
|
|
// (no seq) returns a metadata listing; windowed calls return slices.
|
|
function makeSessionEventsBridge(events) {
|
|
const sorted = events.slice().sort((a, b) => a.seq - b.seq)
|
|
return async function (sessionId, opts = {}) {
|
|
if (opts.seq === undefined) {
|
|
return { events: sorted.map((e) => ({ seq: e.seq, type: e.type })) }
|
|
}
|
|
const before = opts.before || 50
|
|
const end = opts.seq
|
|
const start = Math.max(0, end - before + 1)
|
|
const slice = sorted.filter((e) => e.seq >= start && e.seq <= end)
|
|
return { events: slice, startSeq: slice.length ? slice[0].seq : start }
|
|
}
|
|
}
|
|
|
|
// ─── controller: history replay ─────────────────────────────────────────────
|
|
|
|
test('renderSessionLog: replays full history and paints rows + chips', async () => {
|
|
const doc = makeDoc()
|
|
const c = container(doc)
|
|
const events = [
|
|
{ type: 'user/message', seq: 1, data: { text: 'go' } },
|
|
{ type: 'turn/start', seq: 2, data: {} },
|
|
{ type: 'assistant/message', seq: 3, data: { text: 'ok' } },
|
|
{ type: 'turn/end', seq: 4, data: { stopReason: 'end_turn' } },
|
|
]
|
|
const savedDsh = global.window.dsh
|
|
global.window.dsh = { sessionEvents: makeSessionEventsBridge(events) }
|
|
try {
|
|
logView.renderSessionLog(c, { sessionId: 's1' })
|
|
// loadHistory is async (awaits the bridge); let microtasks settle.
|
|
await new Promise((r) => setTimeout(r, 5))
|
|
const rows = c.querySelectorAll('session-log-row')
|
|
assert.equal(rows.length, 4, 'all four events render as rows')
|
|
const chips = c.querySelectorAll('session-log-chip')
|
|
assert.equal(chips.length, 4, 'one chip per distinct type')
|
|
} finally {
|
|
global.window.dsh = savedDsh
|
|
}
|
|
})
|
|
|
|
test('renderSessionLog: empty state when the bridge returns nothing', async () => {
|
|
const doc = makeDoc()
|
|
const c = container(doc)
|
|
const savedDsh = global.window.dsh
|
|
global.window.dsh = { sessionEvents: async () => ({ events: [] }) }
|
|
try {
|
|
logView.renderSessionLog(c, { sessionId: 's-empty' })
|
|
await new Promise((r) => setTimeout(r, 5))
|
|
const empty = c.querySelectorAll('session-log-empty')
|
|
assert.equal(empty.length, 1)
|
|
} finally {
|
|
global.window.dsh = savedDsh
|
|
}
|
|
})
|
|
|
|
// ─── controller: type-chip filter ───────────────────────────────────────────
|
|
|
|
test('type-chip toggle filters rows to the selected type', async () => {
|
|
const doc = makeDoc()
|
|
const c = container(doc)
|
|
const events = [
|
|
{ type: 'user/message', seq: 1, data: {} },
|
|
{ type: 'tool/call', seq: 2, data: { name: 'read' } },
|
|
{ type: 'tool/call', seq: 3, data: { name: 'write' } },
|
|
{ type: 'turn/end', seq: 4, data: {} },
|
|
]
|
|
const savedDsh = global.window.dsh
|
|
global.window.dsh = { sessionEvents: makeSessionEventsBridge(events) }
|
|
try {
|
|
logView.renderSessionLog(c, { sessionId: 's2' })
|
|
await new Promise((r) => setTimeout(r, 5))
|
|
assert.equal(c.querySelectorAll('session-log-row').length, 4)
|
|
// Click the tool/call chip.
|
|
const chips = c.querySelectorAll('session-log-chip')
|
|
const toolChip = chips.find((ch) => ch.dataset.type === 'tool/call')
|
|
assert.ok(toolChip, 'tool/call chip present')
|
|
toolChip._fire('click')
|
|
const rows = c.querySelectorAll('session-log-row')
|
|
assert.equal(rows.length, 2, 'only tool/call rows remain')
|
|
for (const r of rows) assert.equal(r.dataset.type, 'tool/call')
|
|
} finally {
|
|
global.window.dsh = savedDsh
|
|
}
|
|
})
|
|
|
|
// ─── controller: text search ────────────────────────────────────────────────
|
|
|
|
test('text search narrows rows by payload substring', async () => {
|
|
const doc = makeDoc()
|
|
const c = container(doc)
|
|
const events = [
|
|
{ type: 'user/message', seq: 1, data: { text: 'deploy the thing' } },
|
|
{ type: 'assistant/message', seq: 2, data: { text: 'rolling back' } },
|
|
]
|
|
const savedDsh = global.window.dsh
|
|
global.window.dsh = { sessionEvents: makeSessionEventsBridge(events) }
|
|
try {
|
|
logView.renderSessionLog(c, { sessionId: 's3' })
|
|
await new Promise((r) => setTimeout(r, 5))
|
|
const search = c.querySelectorAll('session-log-search')[0]
|
|
assert.ok(search, 'search input present')
|
|
search.value = 'deploy'
|
|
search._fire('input')
|
|
const rows = c.querySelectorAll('session-log-row')
|
|
assert.equal(rows.length, 1)
|
|
assert.equal(rows[0].dataset.seq, '1')
|
|
} finally {
|
|
global.window.dsh = savedDsh
|
|
}
|
|
})
|
|
|
|
// ─── controller: live merge ─────────────────────────────────────────────────
|
|
|
|
test('ingestLiveEvent: a live event for the shown session appends a row', async () => {
|
|
const doc = makeDoc()
|
|
const c = container(doc)
|
|
const savedDsh = global.window.dsh
|
|
global.window.dsh = { sessionEvents: makeSessionEventsBridge([
|
|
{ type: 'user/message', seq: 1, data: {} },
|
|
]) }
|
|
try {
|
|
logView.renderSessionLog(c, { sessionId: 's4' })
|
|
await new Promise((r) => setTimeout(r, 5))
|
|
assert.equal(c.querySelectorAll('session-log-row').length, 1)
|
|
logView.ingestLiveEvent(c, 's4', { type: 'turn/start', seq: 2, data: {} })
|
|
assert.equal(c.querySelectorAll('session-log-row').length, 2)
|
|
// An event for a DIFFERENT session is ignored.
|
|
logView.ingestLiveEvent(c, 's-other', { type: 'turn/end', seq: 3, data: {} })
|
|
assert.equal(c.querySelectorAll('session-log-row').length, 2)
|
|
} finally {
|
|
global.window.dsh = savedDsh
|
|
}
|
|
})
|
|
|
|
// ─── controller: inspector anchoring ────────────────────────────────────────
|
|
|
|
test('row { } badge opens the inspector anchored to that event', async () => {
|
|
const doc = makeDoc()
|
|
const c = container(doc)
|
|
const opened = []
|
|
const savedInsp = global.window.__dshInspector
|
|
const savedDsh = global.window.dsh
|
|
global.window.__dshInspector = {
|
|
// Mirror the real attachInspectBadge: resolve the target at click time
|
|
// and call open with it.
|
|
attachInspectBadge(host, getTarget) {
|
|
const btn = doc.createElement('button')
|
|
btn.className = 'inspect-badge'
|
|
btn.addEventListener('click', () => { const t = getTarget(); opened.push(t) })
|
|
host.appendChild(btn)
|
|
return btn
|
|
},
|
|
open(t) { opened.push(t) },
|
|
}
|
|
global.window.dsh = { sessionEvents: makeSessionEventsBridge([
|
|
{ type: 'assistant/message', seq: 9, data: { text: 'inspect me' } },
|
|
]) }
|
|
try {
|
|
logView.renderSessionLog(c, { sessionId: 's5' })
|
|
await new Promise((r) => setTimeout(r, 5))
|
|
const badge = c.querySelectorAll('inspect-badge')[0]
|
|
assert.ok(badge, 'inspect badge attached to the row')
|
|
badge._fire('click')
|
|
assert.equal(opened.length, 1)
|
|
assert.equal(opened[0].event.seq, 9, 'inspector anchored to the row event')
|
|
assert.equal(opened[0].event.data.text, 'inspect me')
|
|
} finally {
|
|
global.window.__dshInspector = savedInsp
|
|
global.window.dsh = savedDsh
|
|
}
|
|
})
|