Minimal Electron shell over the DSH JSON-RPC runtime — a first-look at what a ChatGPT.app-style host on top of the DeepSeek Harness looks like, with the harness's normally-invisible internals (trace timeline, context surface, subagent tree, compaction, plugin registry, rubrics) brought forward as first-class UI surfaces so plugin authors and researchers can see what the agent is actually doing. Runs against three keyless-to-live profiles (stdio-echo works on master out of the box; daemon-echo / daemon-vibe-echo activate once the daemon-demo lands; stdio-deepseek and daemon-vibe hit the real DeepSeek API when you supply a key). HARNESS_DEV auto-resolves to the in-repo runtime when this shell ships under examples/desktop/, so a fresh clone launches without config; env DSH_DEV_ROOT overrides for custom layouts, and a sibling deepseek-harness-dev/ checkout is the original dev workflow. Cold-clone gate (P0 fixes for first-time-clone usability): - HARNESS_DEV: 3-candidate resolver (env → walk-up in-repo marker → sibling), unit-tested via mock fs so ordering is locked without needing either real layout on disk. - config yml leaves rewritten at assemble time so the sibling-clone paths (../../deepseek-harness-dev/examples/echo-agent/…) become the in-repo paths (../../echo-agent/…) in the released tree — source yml stays usable for local dev, released tree ships a working shape. - pnpm-workspace.yaml allowBuilds.electron = true (was placeholder). - missing-key card in stdio-deepseek offers a one-click switch to stdio-echo (the keyless profile that works on master) rather than daemon-echo (blocked on the not-yet-shipped daemon-demo). - assemble-oss-release.sh rewrites the source-side breadcrumb name 'dsh-desktop-demo' → 'dsh-desktop' for the released package.json. FOUC guard on the onboarding gate (41fc5df carried) keeps the first-launch splash from flashing before the runtime probe finishes. Test suite (1634 tests in source, 3990 in the runtime repo) covers resolver ordering, renderer classifiers, trace timeline shape, compaction diff rendering, rubric parity, and the missing-key onboarding paths.
312 lines
12 KiB
JavaScript
312 lines
12 KiB
JavaScript
// 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'])
|
|
})
|