Files
deepseek-harness/examples/desktop/test/chat-triple-view.test.js
T
ZiyaZhang 3b49a948e0 fix(desktop): session Graph node order — sort by seq + repair turn/user reversal, node label previews
Also picks up two files the previous sync batch missed
(test/artifact-compact-row.test.js, docs/qa-artifact-compact/).
2026-07-19 23:31:19 -07:00

376 lines
18 KiB
JavaScript

// Tests for feat/chat-triple-view: color tokens, side drawer, session graph.
// Covers:
// - style.css declares the three §7 turn edge tokens and applies them
// to the correct selectors
// - chat-side-drawer.deriveTurnRows / summarize / renderChatSideDrawer
// produce the right shape for a fixture event stream
// - chat-session-graph.deriveGraph node count == turn count, and fork /
// interrupt edges land on the correct edge kinds
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const drawer = require('../src/renderer/chat-side-drawer.js')
const graph = require('../src/renderer/chat-session-graph.js')
// -- Fixture: 3-turn session with one fork + one interruption --------------
function buildFixture() {
return [
{ type: 'user/message', seq: 1, data: { content: [{ type: 'text', text: 'hello there' }] } },
{ type: 'turn/start', seq: 2, data: { turnId: 't0', model: 'deepseek-r1' } },
{ type: 'assistant/message', seq: 3, data: { text: 'hi — how can I help?' } },
{ type: 'turn/end', seq: 4, data: { turnId: 't0', usage: { total_tokens: 128 }, durationMs: 500 } },
{ type: 'user/message', seq: 5, data: { content: [{ type: 'text', text: 'run a task' }] } },
{ type: 'turn/start', seq: 6, data: { turnId: 't1', model: 'deepseek-r1' } },
{ type: 'assistant/message', seq: 7, data: { text: 'sure, working on it' } },
{ type: 'turn/end', seq: 8, data: { turnId: 't1', usage: { total_tokens: 512 }, durationMs: 4200 } },
// fork off t1 into a child session
{ type: 'session/fork', seq: 9, data: { fromTurnId: 't1', childSessionId: 'child-abc' } },
{ type: 'user/message', seq: 10, data: { content: [{ type: 'text', text: 'wait, cancel that' }] } },
{ type: 'turn/start', seq: 11, data: { turnId: 't2', model: 'deepseek-r1' } },
{ type: 'user/interrupt', seq: 12, data: {} },
{ type: 'turn/end', seq: 13, data: { turnId: 't2', stopReason: 'cancelled' } },
]
}
// -- CSS token gate --------------------------------------------------------
test('style.css declares §7 turn edge tokens on :root', () => {
const css = fs.readFileSync(path.join(__dirname, '..', 'src', 'renderer', 'style.css'), 'utf8')
assert.match(css, /--turn-action-edge\s*:/, 'missing --turn-action-edge token')
assert.match(css, /--turn-output-edge\s*:/, 'missing --turn-output-edge token')
assert.match(css, /--turn-interrupt-marker\s*:/, 'missing --turn-interrupt-marker token')
})
test('style.css applies action edge to .assistant-turn border-left', () => {
const css = fs.readFileSync(path.join(__dirname, '..', 'src', 'renderer', 'style.css'), 'utf8')
// capture the .assistant-turn block up to the first close brace
const m = css.match(/\.assistant-turn\s*\{[^}]+\}/)
assert.ok(m, '.assistant-turn rule missing')
assert.match(m[0], /border-left:\s*2px\s+solid\s+var\(--turn-action-edge\)/,
'.assistant-turn should paint its left rail with --turn-action-edge')
})
test('style.css applies output edge to tool-result-row inside a turn', () => {
const css = fs.readFileSync(path.join(__dirname, '..', 'src', 'renderer', 'style.css'), 'utf8')
assert.match(css, /\.assistant-turn\s*>\s*\.turn-body\s*>\s*\.tool-result-row\s*\{[^}]*--turn-output-edge/,
'tool-result-row inside turn should reference --turn-output-edge')
})
test('style.css declares .turn-interrupt-marker with the interrupt color', () => {
const css = fs.readFileSync(path.join(__dirname, '..', 'src', 'renderer', 'style.css'), 'utf8')
assert.match(css, /\.turn-interrupt-marker\s*\{[^}]*var\(--turn-interrupt-marker\)/,
'.turn-interrupt-marker should paint with --turn-interrupt-marker')
})
// -- Side drawer pure functions --------------------------------------------
test('deriveTurnRows: builds user + turn rows in wire order', () => {
const rows = drawer.deriveTurnRows(buildFixture())
const kinds = rows.map((r) => r.kind)
assert.deepEqual(kinds, ['user', 'turn', 'user', 'turn', 'user', 'turn'])
const turns = rows.filter((r) => r.kind === 'turn')
assert.equal(turns.length, 3)
assert.equal(turns[0].turnId, 't0')
assert.equal(turns[0].tokens, 128)
assert.equal(turns[1].tokens, 512)
assert.equal(turns[2].interrupted, true, 't2 should be marked interrupted')
})
test('summarize: totals across turns', () => {
const rows = drawer.deriveTurnRows(buildFixture())
const s = drawer.summarize(rows)
assert.equal(s.turnCount, 3)
assert.equal(s.userCount, 3)
assert.equal(s.tokens, 640) // 128 + 512 + 0
assert.equal(s.interrupted, 1)
})
test('firstLine: strips + truncates', () => {
assert.equal(drawer.firstLine('short'), 'short')
assert.equal(drawer.firstLine('line 1\nline 2'), 'line 1')
const big = 'x'.repeat(200)
const trimmed = drawer.firstLine(big)
assert.ok(trimmed.length <= 80)
assert.ok(trimmed.endsWith('…'))
})
// -- Drawer render (DOM shim) ----------------------------------------------
function makeMiniDoc() {
function el(tag) {
return {
tagName: String(tag).toUpperCase(),
className: '',
textContent: '',
dataset: {},
_children: [],
_attrs: {},
_listeners: {},
classList: {
_cls: new Set(),
add(c) { this._cls.add(c) },
remove(c) { this._cls.delete(c) },
contains(c) { return this._cls.has(c) },
toggle(c, force) {
const has = this._cls.has(c)
const shouldOn = typeof force === 'boolean' ? force : !has
if (shouldOn) this._cls.add(c); else this._cls.delete(c)
},
},
appendChild(c) { this._children.push(c); return c },
append(...cs) { for (const c of cs) this._children.push(c); return this },
setAttribute(k, v) {
this._attrs[k] = v
// In real DOM, HTML elements' `class` attribute mirrors to className;
// SVG elements' does not, but for the purposes of these tests we
// don't care about that distinction — mirror so the filter works.
if (k === 'class') this.className = String(v)
},
addEventListener(evt, fn) { (this._listeners[evt] ||= []).push(fn) },
querySelectorAll(sel) {
const cls = sel.replace(/^\./, '')
const out = []
const walk = (n) => {
if (!n || !Array.isArray(n._children)) return
for (const c of n._children) {
if (c && typeof c.className === 'string' && c.className.split(/\s+/).includes(cls)) out.push(c)
walk(c)
}
}
walk(this)
return out
},
}
}
return { createElement: el, createElementNS: (ns, tag) => el(tag) }
}
test('renderChatSideDrawer: paints three sections', () => {
const doc = makeMiniDoc()
const container = doc.createElement('div')
container.ownerDocument = doc
drawer.renderChatSideDrawer(container, {
sessionId: 's-abc-123456',
events: buildFixture(),
})
const sections = container.querySelectorAll('.chat-side-drawer-section')
assert.equal(sections.length, 3, 'expected 3 sections: current / overview / history')
const historyItems = container.querySelectorAll('.chat-side-drawer-history-item')
// 3 user rows + 3 turns = 6
assert.equal(historyItems.length, 6)
})
// -- Session graph ---------------------------------------------------------
test('deriveGraph: node count matches user+turn+fork events', () => {
const g = graph.deriveGraph(buildFixture())
const turnNodes = g.nodes.filter((n) => n.kind === 'turn' || n.kind === 'interrupt')
const userNodes = g.nodes.filter((n) => n.kind === 'user')
const forkNodes = g.nodes.filter((n) => n.kind === 'fork')
assert.equal(turnNodes.length, 3, 'three turn nodes (interrupted still counts as turn)')
assert.equal(userNodes.length, 3)
assert.equal(forkNodes.length, 1)
})
test('deriveGraph: fork edge is dashed and interrupt edge is orange', () => {
const g = graph.deriveGraph(buildFixture())
const forkEdge = g.edges.find((e) => e.kind === 'fork')
assert.ok(forkEdge, 'fork edge missing')
const interruptEdge = g.edges.find((e) => e.kind === 'interrupt')
assert.ok(interruptEdge, 'interrupt edge missing')
// Interrupt edge terminates at an interrupt-kind node.
const target = g.nodes.find((n) => n.id === interruptEdge.to)
assert.equal(target.kind, 'interrupt')
})
test('deriveGraph: succession edges chain the main line', () => {
const g = graph.deriveGraph(buildFixture())
const successionEdges = g.edges.filter((e) => e.kind === 'succession')
// 6 main-line nodes ⇒ 5 chained edges; the one that lands on the
// interrupted t2 gets recoloured to `interrupt`, leaving 4 succession.
assert.equal(successionEdges.length, 4)
const interruptEdges = g.edges.filter((e) => e.kind === 'interrupt')
assert.equal(interruptEdges.length, 1)
})
test('layoutGraph: assigns fork a right-shifted column', () => {
const g = graph.deriveGraph(buildFixture())
const laid = graph.layoutGraph(g)
const forkNode = g.nodes.find((n) => n.kind === 'fork')
const parentEdge = g.edges.find((e) => e.to === forkNode.id)
const forkPos = laid.positions.get(forkNode.id)
const parentPos = laid.positions.get(parentEdge.from)
assert.ok(forkPos.x > parentPos.x, 'fork should be shifted right of its parent')
assert.equal(forkPos.y, parentPos.y, 'fork sits on the same row as its parent')
})
test('renderSessionGraph: draws SVG with node + edge groups', () => {
const doc = makeMiniDoc()
const container = doc.createElement('div')
container.ownerDocument = doc
graph.renderSessionGraph(container, { events: buildFixture() })
const svg = container._children[0]
assert.ok(svg, 'svg root missing')
assert.equal(svg.tagName, 'SVG')
// Count node <g> children (kind: graph-node) and edges (graph-edge)
const nodes = svg._children.filter((c) => typeof c.className === 'string' && c.className.startsWith('graph-node'))
const edges = svg._children.filter((c) => typeof c.className === 'string' && c.className.startsWith('graph-edge'))
assert.equal(nodes.length, 7, 'three turns + three users + one fork = 7 nodes')
assert.ok(edges.length >= 5, 'at least the five main-line succession edges + fork + interrupt')
})
test('renderSessionGraph: empty state when no events', () => {
const doc = makeMiniDoc()
const container = doc.createElement('div')
container.ownerDocument = doc
graph.renderSessionGraph(container, { events: [] })
const empty = container._children[0]
assert.equal(empty.className, 'chat-session-graph-empty')
})
// -- Session graph: event ordering & labels --------------------------------
// The runtime protocol serialises turn/start before its triggering
// user/message echo — the main stream masks this with an optimistic
// bubble + echo adoption dance (renderer.js ~L809), but the Graph view
// sees the raw wire order. These tests pin the reorder/repair pass in
// deriveGraph() so a wire-order fixture still draws the DAG in causal
// order, and pin the enriched node labels.
test('deriveGraph: sorts out-of-order events by evt.seq', () => {
const events = [
{ type: 'turn/start', seq: 6, data: { turnId: 't1' } },
{ type: 'user/message', seq: 1, data: { text: 'hello' } },
{ type: 'turn/end', seq: 8, data: { turnId: 't1' } },
{ type: 'turn/start', seq: 2, data: { turnId: 't0' } },
{ type: 'user/message', seq: 5, data: { text: 'again' } },
{ type: 'turn/end', seq: 4, data: { turnId: 't0' } },
]
const g = graph.deriveGraph(events)
const kinds = g.nodes.map((n) => n.kind)
// seq-sorted stream is: u1 t2 e4 u5 t6 e8 → nodes u, t, u, t
assert.deepEqual(kinds, ['user', 'turn', 'user', 'turn'])
})
test('deriveGraph: repairs (turn/start, user/message) reversal at close seq', () => {
// Wire-order bug: turn/start echoes before its triggering user/message.
// Both events share an adjacent seq window, so the repair pass must
// swap them so user precedes its turn in the DAG.
const events = [
{ type: 'turn/start', seq: 2, data: { turnId: 't0' } },
{ type: 'user/message', seq: 3, data: { text: 'do the thing' } },
{ type: 'assistant/message', seq: 4, data: { text: 'ok' } },
{ type: 'turn/end', seq: 5, data: { turnId: 't0' } },
]
const g = graph.deriveGraph(events)
assert.equal(g.nodes.length, 2)
assert.equal(g.nodes[0].kind, 'user', 'user node must come first')
assert.equal(g.nodes[1].kind, 'turn', 'turn node must follow user')
const succ = g.edges.find((e) => e.kind === 'succession')
assert.equal(succ.from, g.nodes[0].id)
assert.equal(succ.to, g.nodes[1].id)
})
test('deriveGraph: repairs pair even when seqs are identical', () => {
const events = [
{ type: 'turn/start', seq: 10, data: { turnId: 't0' } },
{ type: 'user/message', seq: 10, data: { text: 'same tick' } },
]
const g = graph.deriveGraph(events)
assert.deepEqual(g.nodes.map((n) => n.kind), ['user', 'turn'])
})
test('deriveGraph: preserves user AFTER turn on genuine barge-in (seq gap)', () => {
// Barge-in: user interrupts an in-flight turn much later than start.
// The gap (seq 20 vs 10) is too wide to be an echo — must stay
// ordered as the wire had it (turn then user), because chronology is
// real.
const events = [
{ type: 'turn/start', seq: 10, data: { turnId: 't0' } },
{ type: 'user/message', seq: 20, data: { text: 'wait cancel' } },
]
const g = graph.deriveGraph(events)
assert.deepEqual(g.nodes.map((n) => n.kind), ['turn', 'user'])
})
test('deriveGraph: user node label carries a truncated first-line preview', () => {
const events = [
{ type: 'user/message', seq: 1, data: { text: 'short one' } },
{ type: 'turn/start', seq: 2, data: { turnId: 't0' } },
{ type: 'user/message', seq: 3, data: { text: 'x'.repeat(80) } },
{ type: 'user/message', seq: 4, data: { text: 'line1\nline2\nline3' } },
]
const g = graph.deriveGraph(events)
const users = g.nodes.filter((n) => n.kind === 'user')
assert.equal(users.length, 3)
assert.equal(users[0].label, 'user · "short one"')
// Long-string label is truncated with an ellipsis (label = `user · "`
// (8) + up to 28 preview + `"` (1) = at most 37 chars).
assert.ok(users[1].label.endsWith('…"'), 'long text should be truncated with an ellipsis')
assert.ok(users[1].label.length <= 38, 'label must not exceed the 28-char preview cap + framing')
// Truncated preview exposes full text on hover via node.title.
assert.equal(users[1].title, 'x'.repeat(80))
// Only the first line is used; subsequent lines are dropped.
assert.equal(users[2].label, 'user · "line1"')
})
test('deriveGraph: turn node label appends stopReason when set', () => {
const events = [
{ type: 'turn/start', seq: 1, data: { turnId: 't0' } },
{ type: 'turn/end', seq: 2, data: { turnId: 't0', stopReason: 'end_turn' } },
{ type: 'turn/start', seq: 3, data: { turnId: 't1' } },
{ type: 'turn/end', seq: 4, data: { turnId: 't1' } },
{ type: 'turn/start', seq: 5, data: { turnId: 't2' } },
{ type: 'turn/end', seq: 6, data: { turnId: 't2', stopReason: 'cancelled' } },
]
const g = graph.deriveGraph(events)
const turns = g.nodes.filter((n) => n.kind === 'turn' || n.kind === 'interrupt')
assert.equal(turns[0].label, '#0 · end_turn')
assert.equal(turns[1].label, '#1', 'no stopReason → no suffix')
assert.equal(turns[2].kind, 'interrupt')
assert.match(turns[2].label, /^#2 · cancelled$/)
})
test('deriveGraph: reorder preserves fork parent + interrupt classification', () => {
// buildFixture()'s scenario with each (turn/start, user/message) pair
// swapped to simulate the wire-order bug. Fork/interrupt outcomes
// must land on the exact same edges after the repair pass.
const events = [
{ type: 'turn/start', seq: 2, data: { turnId: 't0' } },
{ type: 'user/message', seq: 1, data: { text: 'hello there' } },
{ type: 'assistant/message', seq: 3, data: { text: 'hi' } },
{ type: 'turn/end', seq: 4, data: { turnId: 't0' } },
{ type: 'turn/start', seq: 6, data: { turnId: 't1' } },
{ type: 'user/message', seq: 5, data: { text: 'run a task' } },
{ type: 'assistant/message', seq: 7, data: { text: 'sure' } },
{ type: 'turn/end', seq: 8, data: { turnId: 't1' } },
{ type: 'session/fork', seq: 9, data: { fromTurnId: 't1', childSessionId: 'child-abc' } },
{ type: 'turn/start', seq: 11, data: { turnId: 't2' } },
{ type: 'user/message', seq: 10, data: { text: 'wait, cancel' } },
{ type: 'user/interrupt', seq: 12, data: {} },
{ type: 'turn/end', seq: 13, data: { turnId: 't2', stopReason: 'cancelled' } },
]
const g = graph.deriveGraph(events)
const forkEdge = g.edges.find((e) => e.kind === 'fork')
assert.ok(forkEdge, 'fork edge missing after reorder')
const forkParent = g.nodes.find((n) => n.id === forkEdge.from)
assert.equal(forkParent.turnId, 't1')
const interruptNode = g.nodes.find((n) => n.kind === 'interrupt')
assert.ok(interruptNode, 'interrupt node missing after reorder')
assert.equal(interruptNode.turnId, 't2')
// First succession edge on the main line goes user → turn (repaired).
const first = g.edges.find((e) => e.kind === 'succession')
assert.equal(g.nodes.find((n) => n.id === first.from).kind, 'user')
assert.equal(g.nodes.find((n) => n.id === first.to).kind, 'turn')
})
test('renderSessionGraph: emits SVG <title> tooltip for truncated user labels', () => {
const doc = makeMiniDoc()
const container = doc.createElement('div')
container.ownerDocument = doc
graph.renderSessionGraph(container, {
events: [{ type: 'user/message', seq: 1, data: { text: 'y'.repeat(60) } }],
})
const svg = container._children[0]
const userG = svg._children.find((c) => typeof c.className === 'string' && c.className.includes('node-user'))
assert.ok(userG, 'user node group missing')
const titleEl = userG._children.find((c) => c.tagName === 'TITLE')
assert.ok(titleEl, 'expected <title> tooltip child on the user node')
assert.equal(titleEl.textContent, 'y'.repeat(60))
})