// trace-timeline.test.js — Task #203 view B (Gantt/waterfall). // // Verifies the pure layout math: // 1. buildTimelineRows spans startTime/endTime and computes totalMs // 2. tool/call → tool/result pairing collapses to one bar per call // 3. rows preserve wire-seq order and tree indent (depth 0 = step, 1 = ev) // 4. makeXScale is linear + clamped // 5. renderTimeline emits SVG with the expected axis ticks + one bar per // row that has a startTime // 6. onSeqClick fires with the row's seq // 7. exportTimelineSVG returns an SVG string that includes inline style. 'use strict' const test = require('node:test') const assert = require('node:assert') const T = require('../src/renderer/trace-timeline.js') function makeDoc() { const listeners = new Map() function makeEl(nsOrTag, tag) { const cls = { _s: new Set(), add(c) { this._s.add(c) }, remove(c) { this._s.delete(c) }, toggle(c, on) { if (on) this._s.add(c); else this._s.delete(c) }, contains(c) { return this._s.has(c) }, } const el = { tagName: (tag || nsOrTag).toUpperCase(), _children: [], _attrs: {}, _listeners: {}, dataset: {}, style: {}, textContent: '', hidden: false, get className() { return Array.from(cls._s).join(' ') }, set className(v) { cls._s.clear() String(v || '').split(/\s+/).forEach(x => x && cls._s.add(x)) }, classList: cls, appendChild(c) { this._children.push(c); return c }, append(...cs) { for (const c of cs) this._children.push(c) }, setAttribute(k, v) { this._attrs[k] = String(v) }, getAttribute(k) { return this._attrs[k] }, addEventListener(name, fn) { (this._listeners[name] = this._listeners[name] || []).push(fn) }, querySelector(sel) { // Very light-weight: 'svg' finds the first descendant. const target = String(sel).toLowerCase() const stack = [this] while (stack.length) { const cur = stack.pop() if (cur !== this && (cur.tagName || '').toLowerCase() === target) return cur for (const c of cur._children || []) stack.push(c) } return null }, firstChild: null, insertBefore(node, ref) { this._children.unshift(node); return node }, outerHTML: '', } return el } return { _listeners: listeners, createElement(tag) { return makeEl(tag) }, createElementNS(ns, tag) { return makeEl(ns, tag) }, body: makeEl('body'), } } test('buildTimelineRows: one step + inner events', () => { const rec = { turn: 3, step: 0, startSeq: 10, endSeq: 14, startTime: 1000, endTime: 1500, durationMs: 500, summary: 'read main.ts', inputs: [], outputs: [], events: [ { type: 'request/header', time: 1010, seq: 10, data: { header: { model: 'deepseek-chat' } } }, { type: 'tool/call', time: 1100, seq: 11, data: { callId: 'c1', name: 'read', arguments: '{"path":"main.ts"}' } }, { type: 'tool/result', time: 1250, seq: 12, data: { callId: 'c1', content: [{ type: 'text', text: 'file body' }] } }, { type: 'assistant/message', time: 1400, seq: 13, data: { content: [{ type: 'text', text: 'done' }], usage: { inputTokens: 100 } } }, ], } const { rows, totalMs, startTime } = T.buildTimelineRows(rec) // Rows: 1 step + 3 inner (tool/result pairs off into the call bar). assert.strictEqual(rows.length, 4) assert.strictEqual(rows[0].kind, 'step') assert.strictEqual(rows[0].depth, 0) assert.strictEqual(rows[0].family, 'step') assert.strictEqual(startTime, 1000) assert.ok(totalMs >= 500) // The tool/call row picked up the tool/result end time. const callRow = rows.find(r => r.type === 'tool/call') assert.ok(callRow, 'tool/call row present') assert.strictEqual(callRow.endTime, 1250) assert.strictEqual(callRow.family, 'tool') }) test('buildTimelineRows: session scope aggregates multiple records', () => { const recs = [ { turn: 1, step: 0, startSeq: 1, endSeq: 3, startTime: 1000, endTime: 1200, durationMs: 200, events: [], inputs: [], outputs: [] }, { turn: 1, step: 1, startSeq: 4, endSeq: 6, startTime: 1300, endTime: 1600, durationMs: 300, events: [], inputs: [], outputs: [] }, ] const { rows, totalMs, startTime, endTime } = T.buildTimelineRows(recs) assert.strictEqual(rows.length, 2) assert.strictEqual(startTime, 1000) assert.strictEqual(endTime, 1600) assert.strictEqual(totalMs, 600) }) test('pairToolCallResult collapses matched pairs', () => { const evs = [ { type: 'tool/call', time: 100, seq: 1, data: { callId: 'x', name: 'read' } }, { type: 'tool/result', time: 150, seq: 2, data: { callId: 'x' } }, { type: 'assistant/message', time: 200, seq: 3, data: {} }, ] const paired = T.pairToolCallResult(evs) assert.strictEqual(paired.length, 2) assert.strictEqual(paired[0].type, 'tool/call') assert.strictEqual(paired[0]._pairEndTime, 150) assert.strictEqual(paired[1].type, 'assistant/message') }) test('makeXScale is linear + clamped', () => { const scale = T.makeXScale(1000, 100, 500) assert.strictEqual(scale(1000), 0) assert.strictEqual(scale(1100), 500) assert.strictEqual(scale(1050), 250) // Out of range → clamped. assert.strictEqual(scale(900), 0) assert.strictEqual(scale(2000), 500) // NaN / bogus → 0. assert.strictEqual(scale(undefined), 0) }) test('renderTimeline emits SVG with ticks + bars', () => { const doc = makeDoc() const rec = { turn: 1, step: 0, startSeq: 1, endSeq: 3, startTime: 1000, endTime: 1400, durationMs: 400, events: [ { type: 'tool/call', time: 1050, seq: 2, data: { callId: 'a', name: 'read' } }, { type: 'tool/result', time: 1200, seq: 3, data: { callId: 'a' } }, ], inputs: [], outputs: [], } const el = T.renderTimeline(doc, rec, { width: 600, rowHeight: 20 }) assert.ok(el) assert.ok(el.classList.contains('trace-timeline')) const svg = el._children.find(c => c.tagName === 'SVG') assert.ok(svg, 'SVG child present') // Axis group + rows group at least. const groups = svg._children.filter(c => c.tagName === 'G') assert.ok(groups.length >= 2) }) test('renderTimeline onSeqClick fires with row seq', () => { const doc = makeDoc() const rec = { turn: 1, step: 0, startSeq: 5, endSeq: 5, startTime: 1000, endTime: 1100, durationMs: 100, events: [{ type: 'assistant/message', time: 1050, seq: 7, data: {} }], inputs: [], outputs: [], } const clicks = [] const el = T.renderTimeline(doc, rec, { onSeqClick(seq) { clicks.push(seq) }, }) // Find the row group with data-seq="7" and fire its click. const svg = el._children.find(c => c.tagName === 'SVG') const rowGroups = svg._children.filter(g => g._attrs['class'] && g._attrs['class'].includes('trace-timeline-row')) const target = rowGroups.find(g => g._attrs['data-seq'] === '7') assert.ok(target, 'target row present') const clickHandlers = target._listeners.click || [] assert.strictEqual(clickHandlers.length, 1) clickHandlers[0]({}) assert.deepStrictEqual(clicks, [7]) }) test('exportTimelineSVG produces a string with inline style', () => { const doc = makeDoc() const rec = { turn: 1, step: 0, startSeq: 1, endSeq: 1, startTime: 0, endTime: 100, durationMs: 100, events: [], inputs: [], outputs: [], } // Stub outerHTML lazily to observe what was serialised. const svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg') svg.outerHTML = '' const el = T.renderTimeline(doc, rec, {}) // real exportTimelineSVG builds its own — just check it doesn't crash and // returns a non-empty string when outerHTML is present. const inner = el._children.find(c => c.tagName === 'SVG') inner.outerHTML = '' const out = T.exportTimelineSVG(doc, rec, {}) assert.strictEqual(typeof out, 'string') }) test('familyForEvent maps types to palette families', () => { assert.strictEqual(T.familyForEvent({ type: 'tool/call' }), 'tool') assert.strictEqual(T.familyForEvent({ type: 'assistant/message' }), 'llm') assert.strictEqual(T.familyForEvent({ type: 'compact/summary' }), 'compact') assert.strictEqual(T.familyForEvent({ type: 'subagent.started' }), 'subagent') assert.strictEqual(T.familyForEvent({ type: 'hook/allow' }), 'hook') assert.strictEqual(T.familyForEvent({ type: 'user/message' }), 'input') assert.strictEqual(T.familyForEvent({ type: 'wat' }), 'event') }) // 2026-07-17 delta 203-Δ1: guard the header docblock rationale so a future // reference tracing UI-parity pass doesn't delete the Timeline (Gantt) tab as a // "misalignment". Reviewers must see the "DSH-specific enhancement, not // reference tracing UI parity" note before touching this file. test('trace-timeline.js header records the DSH-specific-enhancement rationale (203-Δ1)', () => { const fs = require('node:fs') const path = require('node:path') const src = fs.readFileSync( path.join(__dirname, '../src/renderer/trace-timeline.js'), 'utf8' ) assert.ok(/DSH-specific enhancement/.test(src), 'trace-timeline.js header must state that the Gantt tab is DSH-specific, not LangSmith parity') assert.ok(/not LangSmith parity/i.test(src), 'header must explicitly contrast with LangSmith parity so reviewers do not "align it away"') })