Files
deepseek-harness/examples/desktop/test/devtools-model.test.js
T

312 lines
12 KiB
JavaScript
Raw Normal View History

// Devtools event-log model unit tests. Runs under `node --test`, no DOM.
//
// The model exports a small ring buffer + preset/type/text filter pipeline
// used by devtools-panel.js. Everything under test is pure — no timers, no
// notifications, no DOM. See devtools-model.js header for the design contract.
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
function load() {
const p = require.resolve('../src/renderer/devtools-model.js')
delete require.cache[p]
return require('../src/renderer/devtools-model.js')
}
// -- ring buffer -------------------------------------------------------------
test('createBuffer: defaults to cap 500 and empty entries', () => {
const M = load()
const b = M.createBuffer()
assert.equal(b.cap, 500)
assert.deepEqual(b.entries, [])
assert.equal(b.nextId, 1)
})
test('createBuffer: honours explicit cap; rejects garbage', () => {
const M = load()
assert.equal(M.createBuffer(10).cap, 10)
assert.equal(M.createBuffer(0).cap, 500, 'zero falls back to default')
assert.equal(M.createBuffer(-5).cap, 500, 'negative falls back to default')
assert.equal(M.createBuffer('nope').cap, 500, 'non-number falls back')
assert.equal(M.createBuffer(3.7).cap, 3, 'floor is applied')
})
test('addEvent: stamps monotonic id, normalises type/time/seq', () => {
const M = load()
const b = M.createBuffer(5)
const e1 = M.addEvent(b, {
sessionId: 'sid-1',
event: { type: 'hook/invoked', time: 1000, seq: 4, data: { x: 1 } },
})
const e2 = M.addEvent(b, {
sessionId: 'sid-2',
event: { type: 'hook/result' }, // no time/seq
})
assert.equal(e1.id, 1)
assert.equal(e1.sessionId, 'sid-1')
assert.equal(e1.type, 'hook/invoked')
assert.equal(e1.time, 1000)
assert.equal(e1.seq, 4)
assert.equal(e2.id, 2)
assert.equal(e2.seq, null)
assert.equal(typeof e2.time, 'number', 'time defaults to now')
assert.equal(b.entries.length, 2)
})
test('addEvent: defaults for missing/garbage input never throw', () => {
const M = load()
const b = M.createBuffer()
const e = M.addEvent(b, {})
assert.equal(e.sessionId, '')
assert.equal(e.type, '(unknown)')
const e2 = M.addEvent(b, null)
assert.equal(e2.type, '(unknown)')
const e3 = M.addEvent(b, { sessionId: 42, event: { type: null } })
assert.equal(e3.sessionId, '')
assert.equal(e3.type, '(unknown)')
})
test('addEvent: ring buffer evicts oldest at cap', () => {
const M = load()
const b = M.createBuffer(3)
M.addEvent(b, { sessionId: 's', event: { type: 'a' } })
M.addEvent(b, { sessionId: 's', event: { type: 'b' } })
M.addEvent(b, { sessionId: 's', event: { type: 'c' } })
M.addEvent(b, { sessionId: 's', event: { type: 'd' } })
M.addEvent(b, { sessionId: 's', event: { type: 'e' } })
const types = M.getAll(b).map((e) => e.type)
assert.deepEqual(types, ['c', 'd', 'e'])
// Ids stay monotonic through eviction — a UI can rely on them for keys.
const ids = M.getAll(b).map((e) => e.id)
assert.deepEqual(ids, [3, 4, 5])
})
test('clearBuffer: empties entries; nextId keeps counting', () => {
const M = load()
const b = M.createBuffer()
M.addEvent(b, { sessionId: 's', event: { type: 'a' } })
M.addEvent(b, { sessionId: 's', event: { type: 'b' } })
M.clearBuffer(b)
assert.equal(b.entries.length, 0)
const e = M.addEvent(b, { sessionId: 's', event: { type: 'c' } })
assert.equal(e.id, 3, 'nextId survives clear so ids stay unique')
})
test('getAll: returns a fresh array; mutating it does not affect the buffer', () => {
const M = load()
const b = M.createBuffer()
M.addEvent(b, { sessionId: 's', event: { type: 'a' } })
const snap = M.getAll(b)
snap.push({ hostile: true })
assert.equal(b.entries.length, 1)
})
// -- pattern matching --------------------------------------------------------
test('matchesPattern: exact and prefix-star patterns', () => {
const M = load()
assert.equal(M.matchesPattern('hook/invoked', 'hook/invoked'), true)
assert.equal(M.matchesPattern('hook/invoked', 'hook/*'), true)
assert.equal(M.matchesPattern('hook/result', 'hook/*'), true)
assert.equal(M.matchesPattern('approval/asked', 'hook/*'), false)
assert.equal(M.matchesPattern('hook', 'hook/*'), false, 'no trailing slash → no match')
assert.equal(M.matchesPattern('foo/bar', 'foo/bar'), true)
assert.equal(M.matchesPattern('foo/barbaz', 'foo/bar'), false, 'exact mode is strict')
})
test('matchesPattern: bad input never throws, returns false', () => {
const M = load()
assert.equal(M.matchesPattern(null, 'hook/*'), false)
assert.equal(M.matchesPattern('x', null), false)
})
test('matchesPreset: All matches anything; unknown preset falls through to All', () => {
const M = load()
assert.equal(M.matchesPreset('anything', 'All'), true)
assert.equal(M.matchesPreset('anything', 'DoesNotExist'), true)
})
test('matchesPreset: Approvals covers approval/* and permission/*', () => {
const M = load()
assert.equal(M.matchesPreset('approval/asked', 'Approvals'), true)
assert.equal(M.matchesPreset('approval/decided', 'Approvals'), true)
assert.equal(M.matchesPreset('permission/preset', 'Approvals'), true)
assert.equal(M.matchesPreset('hook/invoked', 'Approvals'), false)
})
test('matchesPreset: Hooks covers hook/*; Requests covers request/header{,-delta}', () => {
const M = load()
assert.equal(M.matchesPreset('hook/invoked', 'Hooks'), true)
assert.equal(M.matchesPreset('hook/result', 'Hooks'), true)
assert.equal(M.matchesPreset('approval/asked', 'Hooks'), false)
assert.equal(M.matchesPreset('request/header', 'Requests'), true)
assert.equal(M.matchesPreset('request/header-delta', 'Requests'), true)
assert.equal(M.matchesPreset('request/other', 'Requests'), false, 'not blanket request/*')
})
// -- filterEntries -----------------------------------------------------------
function seed(M, tuples) {
const b = M.createBuffer()
for (const [sid, type, extra] of tuples) {
M.addEvent(b, { sessionId: sid, event: Object.assign({ type }, extra || {}) })
}
return b
}
test('filterEntries: no filters returns everything in insertion order', () => {
const M = load()
const b = seed(M, [
['s1', 'a'], ['s1', 'b'], ['s2', 'c'],
])
const out = M.filterEntries(M.getAll(b))
assert.deepEqual(out.map((e) => e.type), ['a', 'b', 'c'])
})
test('filterEntries: preset filter restricts to the preset patterns', () => {
const M = load()
const b = seed(M, [
['s', 'hook/invoked'], ['s', 'approval/asked'],
['s', 'tool/call'], ['s', 'hook/result'],
])
const hooks = M.filterEntries(M.getAll(b), { preset: 'Hooks' })
assert.deepEqual(hooks.map((e) => e.type), ['hook/invoked', 'hook/result'])
const approvals = M.filterEntries(M.getAll(b), { preset: 'Approvals' })
assert.deepEqual(approvals.map((e) => e.type), ['approval/asked'])
})
test('filterEntries: type set narrows further (AND with preset)', () => {
const M = load()
const b = seed(M, [
['s', 'hook/invoked'], ['s', 'hook/result'], ['s', 'approval/asked'],
])
const out = M.filterEntries(M.getAll(b), {
preset: 'Hooks',
types: new Set(['hook/result']),
})
assert.deepEqual(out.map((e) => e.type), ['hook/result'])
})
test('filterEntries: empty type Set is no restriction (regression: 0 / N drawer)', () => {
// Regression: the controller keeps `state.typeFilter` as a Set that starts
// empty and grows as the user clicks chips. Passing that empty Set through
// filterEntries must NOT filter out every entry — otherwise the drawer
// shows "0 / 181 · No events match the current filter" the moment it opens,
// which was the observed round-4 devtools-drawer bug. Guard against
// regression on both preset variants.
const M = load()
const b = seed(M, [
['s', 'user/message'], ['s', 'assistant/message'], ['s', 'turn/end'],
])
const withEmptySet = M.filterEntries(M.getAll(b), {
preset: 'All',
types: new Set(),
text: '',
})
assert.equal(withEmptySet.length, 3)
const withoutOpts = M.filterEntries(M.getAll(b))
assert.equal(withoutOpts.length, 3)
})
test('filterEntries: empty type Array is also no restriction', () => {
const M = load()
const b = seed(M, [['s', 'a'], ['s', 'b']])
assert.equal(M.filterEntries(M.getAll(b), { types: [] }).length, 2)
})
test('filterEntries: type set accepts arrays too', () => {
const M = load()
const b = seed(M, [
['s', 'a'], ['s', 'b'], ['s', 'c'],
])
const out = M.filterEntries(M.getAll(b), { types: ['a', 'c'] })
assert.deepEqual(out.map((e) => e.type), ['a', 'c'])
})
test('filterEntries: text search hits type, sessionId, and serialised payload', () => {
const M = load()
const b = seed(M, [
['abc-session', 'hook/invoked', { data: { matcher: 'PreToolUse' } }],
['def-session', 'tool/call', { data: { name: 'bash' } }],
['xyz-session', 'approval/asked', { data: { reason: 'sensitive read' } }],
])
// Type hit.
assert.equal(M.filterEntries(M.getAll(b), { text: 'hook' }).length, 1)
// Session hit.
assert.equal(M.filterEntries(M.getAll(b), { text: 'xyz' }).length, 1)
// Payload hit (case-insensitive).
assert.equal(M.filterEntries(M.getAll(b), { text: 'BASH' }).length, 1)
// Payload deep hit.
assert.equal(M.filterEntries(M.getAll(b), { text: 'sensitive' }).length, 1)
})
test('filterEntries: text search of empty/whitespace is a no-op', () => {
const M = load()
const b = seed(M, [['s', 'a'], ['s', 'b']])
assert.equal(M.filterEntries(M.getAll(b), { text: '' }).length, 2)
assert.equal(M.filterEntries(M.getAll(b), { text: ' ' }).length, 2)
})
test('filterEntries: circular event payload does not crash the text filter', () => {
const M = load()
const b = M.createBuffer()
const circ = { type: 'x' }
circ.self = circ
M.addEvent(b, { sessionId: 's', event: circ })
// Type search still works; payload search silently skips the JSON path.
assert.equal(M.filterEntries(M.getAll(b), { text: 'x' }).length, 1)
assert.equal(M.filterEntries(M.getAll(b), { text: 'noSuchToken' }).length, 0)
})
// -- collectTypes ------------------------------------------------------------
test('collectTypes: returns sorted unique types', () => {
const M = load()
const b = seed(M, [
['s', 'zeta'], ['s', 'alpha'], ['s', 'zeta'], ['s', 'beta'],
])
assert.deepEqual(M.collectTypes(M.getAll(b)), ['alpha', 'beta', 'zeta'])
assert.deepEqual(M.collectTypes([]), [])
})
// -- formatting --------------------------------------------------------------
test('formatJSON: pretty-prints; falls back on circular', () => {
const M = load()
assert.equal(M.formatJSON({ a: 1 }), '{\n "a": 1\n}')
const circ = { a: 1 }
circ.self = circ
const out = M.formatJSON(circ)
assert.equal(typeof out, 'string')
assert.notEqual(out, '') // some string, not a crash
})
test('formatTime: HH:MM:SS.mmm padded; garbage yields dashes', () => {
const M = load()
// 1 Jan 1970 00:00:01.234 UTC — asserting shape not zone-specific digits.
const out = M.formatTime(1234)
assert.match(out, /^\d{2}:\d{2}:\d{2}\.\d{3}$/)
assert.equal(M.formatTime(NaN), '--:--:--')
assert.equal(M.formatTime('nope'), '--:--:--')
})
// -- module surface ----------------------------------------------------------
test('module surface: exports the documented api', () => {
const M = load()
const keys = [
'DEFAULT_CAP', 'PRESETS',
'createBuffer', 'addEvent', 'clearBuffer', 'getAll',
'filterEntries', 'matchesPreset', 'matchesPattern', 'collectTypes',
'formatJSON', 'formatTime', 'normalizeEntry',
]
for (const k of keys) assert.ok(k in M, `missing export: ${k}`)
assert.equal(M.DEFAULT_CAP, 500)
// Preset names are UI-facing; assert them so a rename breaks the test on
// purpose (the controller relies on them by name).
assert.deepEqual(Object.keys(M.PRESETS).sort(), ['All', 'Approvals', 'Hooks', 'Requests'])
})