feat(desktop): workflow.event live consumption + inspector Feedback tab + upstream ledger L-5
This commit is contained in:
100
examples/desktop/test/feedback-annotation-model.test.js
Normal file
100
examples/desktop/test/feedback-annotation-model.test.js
Normal file
@@ -0,0 +1,100 @@
|
||||
// feedback-annotation-model.test.js — lane-wf-feedback item 2 (renderer model).
|
||||
//
|
||||
// The pure annotation model backs the inspector Feedback tab: identity keying,
|
||||
// forward-compatible record normalization, and the in-memory index that drives
|
||||
// the ✓ marker + prefill without an IPC round-trip.
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
|
||||
const model = require('../src/renderer/feedback-annotation-model.js')
|
||||
|
||||
test('keyFor: stable (sessionId, seq) key; rejects missing pieces', () => {
|
||||
assert.strictEqual(model.keyFor('s1', 7), 's1::7')
|
||||
assert.strictEqual(model.keyFor('s1', '7'), 's1::7')
|
||||
assert.strictEqual(model.keyFor('', 7), null)
|
||||
assert.strictEqual(model.keyFor('s1', NaN), null)
|
||||
assert.strictEqual(model.keyFor('s1', undefined), null)
|
||||
})
|
||||
|
||||
test('identityFor: pulls sessionId + seq from event + owning session', () => {
|
||||
assert.deepStrictEqual(model.identityFor({ seq: 4 }, 's1'), { sessionId: 's1', seq: 4 })
|
||||
assert.strictEqual(model.identityFor({ seq: 4 }, ''), null)
|
||||
assert.strictEqual(model.identityFor({}, 's1'), null)
|
||||
assert.strictEqual(model.identityFor(null, 's1'), null)
|
||||
})
|
||||
|
||||
test('normalize: builds the forward-compatible record shape', () => {
|
||||
const rec = model.normalize({ sessionId: 's1', seq: 7, verdict: 'up', note: ' good ', rubricDim: 'convergence', at: 111 })
|
||||
assert.deepStrictEqual(rec, { sessionId: 's1', seq: 7, verdict: 'up', note: 'good', rubricDim: 'convergence', at: 111 })
|
||||
})
|
||||
|
||||
test('normalize: verdict-only (no note) still stores; note-only stores with null verdict', () => {
|
||||
const v = model.normalize({ sessionId: 's', seq: 1, verdict: 'down', note: '' })
|
||||
assert.strictEqual(v.verdict, 'down')
|
||||
assert.strictEqual(v.note, '')
|
||||
const n = model.normalize({ sessionId: 's', seq: 1, note: 'just a note' })
|
||||
assert.strictEqual(n.verdict, null)
|
||||
assert.strictEqual(n.note, 'just a note')
|
||||
})
|
||||
|
||||
test('normalize: nothing to store (no verdict + empty note) → null (a clear)', () => {
|
||||
assert.strictEqual(model.normalize({ sessionId: 's', seq: 1, verdict: null, note: ' ' }), null)
|
||||
assert.strictEqual(model.normalize({ sessionId: 's', seq: 1 }), null)
|
||||
})
|
||||
|
||||
test('normalize: invalid verdict is coerced to null; bad rubricDim dropped', () => {
|
||||
const rec = model.normalize({ sessionId: 's', seq: 1, verdict: 'meh', note: 'x', rubricDim: ' ' })
|
||||
assert.strictEqual(rec.verdict, null)
|
||||
assert.strictEqual('rubricDim' in rec, false)
|
||||
})
|
||||
|
||||
test('normalize: no identity → null', () => {
|
||||
assert.strictEqual(model.normalize({ seq: 1, verdict: 'up' }), null)
|
||||
assert.strictEqual(model.normalize(null), null)
|
||||
})
|
||||
|
||||
test('index: put/get/has/remove round-trip', () => {
|
||||
const idx = model.createAnnotationIndex()
|
||||
assert.strictEqual(idx.has('s1', 7), false)
|
||||
const rec = idx.put({ sessionId: 's1', seq: 7, verdict: 'up', note: 'ok' })
|
||||
assert.strictEqual(rec.verdict, 'up')
|
||||
assert.strictEqual(idx.has('s1', 7), true)
|
||||
assert.strictEqual(idx.get('s1', 7).note, 'ok')
|
||||
assert.strictEqual(idx.size(), 1)
|
||||
assert.strictEqual(idx.remove('s1', 7), true)
|
||||
assert.strictEqual(idx.has('s1', 7), false)
|
||||
})
|
||||
|
||||
test('index: put with a clearing form drops the entry', () => {
|
||||
const idx = model.createAnnotationIndex()
|
||||
idx.put({ sessionId: 's1', seq: 7, verdict: 'up', note: 'ok' })
|
||||
assert.strictEqual(idx.has('s1', 7), true)
|
||||
const cleared = idx.put({ sessionId: 's1', seq: 7, verdict: null, note: '' })
|
||||
assert.strictEqual(cleared, null)
|
||||
assert.strictEqual(idx.has('s1', 7), false)
|
||||
})
|
||||
|
||||
test('index: hydrate replaces the whole set from a flat list', () => {
|
||||
const idx = model.createAnnotationIndex()
|
||||
idx.put({ sessionId: 'x', seq: 1, verdict: 'up', note: 'a' })
|
||||
idx.hydrate([
|
||||
{ sessionId: 's1', seq: 2, verdict: 'down', note: 'b' },
|
||||
{ sessionId: 's1', seq: 3, verdict: 'up', note: 'c' },
|
||||
{ bad: 'record' }, // ignored — no key
|
||||
])
|
||||
assert.strictEqual(idx.has('x', 1), false, 'prior entries cleared')
|
||||
assert.strictEqual(idx.size(), 2)
|
||||
assert.strictEqual(idx.get('s1', 2).note, 'b')
|
||||
})
|
||||
|
||||
test('index: two events in the same session are keyed independently', () => {
|
||||
const idx = model.createAnnotationIndex()
|
||||
idx.put({ sessionId: 's', seq: 1, verdict: 'up', note: 'first' })
|
||||
idx.put({ sessionId: 's', seq: 2, verdict: 'down', note: 'second' })
|
||||
assert.strictEqual(idx.get('s', 1).verdict, 'up')
|
||||
assert.strictEqual(idx.get('s', 2).verdict, 'down')
|
||||
assert.strictEqual(idx.size(), 2)
|
||||
})
|
||||
115
examples/desktop/test/feedback-annotations.test.js
Normal file
115
examples/desktop/test/feedback-annotations.test.js
Normal file
@@ -0,0 +1,115 @@
|
||||
// feedback-annotations.test.js — lane-wf-feedback item 2 (main-process store).
|
||||
//
|
||||
// Points DSH_DESKTOP_HOME at a per-test tmp dir so real ~/.dsh-desktop never
|
||||
// gets touched. Exercises the per-event annotation store's upsert / clear /
|
||||
// remove semantics + the persisted record shape (the RL seed).
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
|
||||
function withTmpHome(fn) {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-feedback-'))
|
||||
const prev = process.env.DSH_DESKTOP_HOME
|
||||
process.env.DSH_DESKTOP_HOME = home
|
||||
try { fn(home) }
|
||||
finally {
|
||||
if (prev == null) delete process.env.DSH_DESKTOP_HOME
|
||||
else process.env.DSH_DESKTOP_HOME = prev
|
||||
fs.rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function freshRequire() {
|
||||
delete require.cache[require.resolve('../src/main/feedback-annotations.js')]
|
||||
return require('../src/main/feedback-annotations.js')
|
||||
}
|
||||
|
||||
test('list: empty before any write', () => {
|
||||
withTmpHome(() => {
|
||||
const F = freshRequire()
|
||||
assert.deepEqual(F.list(), { ok: true, entries: [] })
|
||||
})
|
||||
})
|
||||
|
||||
test('upsert: writes a record and list reads it back with the RL-seed shape', () => {
|
||||
withTmpHome(() => {
|
||||
const F = freshRequire()
|
||||
const r = F.upsert({ sessionId: 's1', seq: 7, verdict: 'up', note: 'good turn', rubricDim: 'convergence' })
|
||||
assert.equal(r.ok, true)
|
||||
assert.equal(r.entry.sessionId, 's1')
|
||||
assert.equal(r.entry.seq, 7)
|
||||
assert.equal(r.entry.verdict, 'up')
|
||||
assert.equal(r.entry.note, 'good turn')
|
||||
assert.equal(r.entry.rubricDim, 'convergence')
|
||||
assert.equal(typeof r.entry.at, 'number')
|
||||
const { entries } = F.list()
|
||||
assert.equal(entries.length, 1)
|
||||
assert.equal(entries[0].sessionId, 's1')
|
||||
})
|
||||
})
|
||||
|
||||
test('upsert: re-annotating the same (sessionId, seq) overwrites in place', () => {
|
||||
withTmpHome(() => {
|
||||
const F = freshRequire()
|
||||
F.upsert({ sessionId: 's1', seq: 7, verdict: 'up', note: 'first' })
|
||||
F.upsert({ sessionId: 's1', seq: 7, verdict: 'down', note: 'revised' })
|
||||
const { entries } = F.list()
|
||||
assert.equal(entries.length, 1, 'still one record for the same key')
|
||||
assert.equal(entries[0].verdict, 'down')
|
||||
assert.equal(entries[0].note, 'revised')
|
||||
})
|
||||
})
|
||||
|
||||
test('upsert: distinct (sessionId, seq) pairs accumulate', () => {
|
||||
withTmpHome(() => {
|
||||
const F = freshRequire()
|
||||
F.upsert({ sessionId: 's1', seq: 1, verdict: 'up', note: 'a' })
|
||||
F.upsert({ sessionId: 's1', seq: 2, verdict: 'down', note: 'b' })
|
||||
F.upsert({ sessionId: 's2', seq: 1, verdict: 'up', note: 'c' })
|
||||
assert.equal(F.list().entries.length, 3)
|
||||
})
|
||||
})
|
||||
|
||||
test('upsert: an empty annotation clears an existing record', () => {
|
||||
withTmpHome(() => {
|
||||
const F = freshRequire()
|
||||
F.upsert({ sessionId: 's1', seq: 7, verdict: 'up', note: 'x' })
|
||||
const r = F.upsert({ sessionId: 's1', seq: 7, verdict: null, note: ' ' })
|
||||
assert.equal(r.ok, true)
|
||||
assert.equal(r.cleared, true)
|
||||
assert.equal(F.list().entries.length, 0)
|
||||
})
|
||||
})
|
||||
|
||||
test('upsert: missing sessionId/seq is rejected, no file written', () => {
|
||||
withTmpHome(() => {
|
||||
const F = freshRequire()
|
||||
assert.equal(F.upsert({ seq: 7, verdict: 'up' }).ok, false)
|
||||
assert.equal(F.upsert({ sessionId: 's1', verdict: 'up' }).ok, false)
|
||||
assert.equal(F.list().entries.length, 0)
|
||||
})
|
||||
})
|
||||
|
||||
test('remove: drops a record; removing a missing one is a no-op ok', () => {
|
||||
withTmpHome(() => {
|
||||
const F = freshRequire()
|
||||
F.upsert({ sessionId: 's1', seq: 7, verdict: 'up', note: 'x' })
|
||||
assert.deepEqual(F.remove({ sessionId: 's1', seq: 7 }), { ok: true, removed: true })
|
||||
assert.deepEqual(F.remove({ sessionId: 's1', seq: 7 }), { ok: true, removed: false })
|
||||
assert.equal(F.list().entries.length, 0)
|
||||
})
|
||||
})
|
||||
|
||||
test('annotationsPath lands under DSH_DESKTOP_HOME', () => {
|
||||
withTmpHome((home) => {
|
||||
const F = freshRequire()
|
||||
assert.equal(F.annotationsPath(), path.join(home, 'feedback-annotations.json'))
|
||||
F.upsert({ sessionId: 's1', seq: 7, verdict: 'up', note: 'x' })
|
||||
assert.equal(fs.existsSync(path.join(home, 'feedback-annotations.json')), true)
|
||||
})
|
||||
})
|
||||
@@ -25,6 +25,7 @@ test('normalizeTab: known tabs pass through; unknown falls back to pretty', () =
|
||||
assert.equal(inspector.normalizeTab('pretty'), 'pretty')
|
||||
assert.equal(inspector.normalizeTab('raw'), 'raw')
|
||||
assert.equal(inspector.normalizeTab('json'), 'json')
|
||||
assert.equal(inspector.normalizeTab('feedback'), 'feedback')
|
||||
assert.equal(inspector.normalizeTab('bogus'), 'pretty')
|
||||
assert.equal(inspector.normalizeTab(undefined), 'pretty')
|
||||
})
|
||||
@@ -342,9 +343,11 @@ test('index.html: #inspector-drawer aside with three tabs + panels exists', () =
|
||||
assert.match(html, /data-tab="pretty"/, 'Pretty tab button')
|
||||
assert.match(html, /data-tab="raw"/, 'Raw tab button')
|
||||
assert.match(html, /data-tab="json"/, 'JSON tab button')
|
||||
assert.match(html, /data-tab="feedback"/, 'Feedback tab button')
|
||||
assert.match(html, /data-panel="pretty"/, 'Pretty panel')
|
||||
assert.match(html, /data-panel="raw"/, 'Raw panel')
|
||||
assert.match(html, /data-panel="json"/, 'JSON panel')
|
||||
assert.match(html, /data-panel="feedback"/, 'Feedback panel')
|
||||
assert.match(html, /id="inspector-drawer-close"/, 'close button target for the × / Escape bindings')
|
||||
})
|
||||
|
||||
@@ -385,14 +388,14 @@ function buildDrawerDom(doc) {
|
||||
drawer.setAttribute('aria-hidden', 'true')
|
||||
const title = doc.createElement('div'); title.className = 'inspector-drawer-title'; title.textContent = 'inspector'
|
||||
drawer.appendChild(title)
|
||||
for (const t of ['pretty', 'raw', 'json']) {
|
||||
for (const t of ['pretty', 'raw', 'json', 'feedback']) {
|
||||
const tab = doc.createElement('button')
|
||||
tab.className = 'inspector-tab' + (t === 'pretty' ? ' active' : '')
|
||||
tab.dataset.tab = t
|
||||
tab.setAttribute('aria-selected', t === 'pretty' ? 'true' : 'false')
|
||||
drawer.appendChild(tab)
|
||||
}
|
||||
for (const p of ['pretty', 'raw', 'json']) {
|
||||
for (const p of ['pretty', 'raw', 'json', 'feedback']) {
|
||||
const panel = doc.createElement('div')
|
||||
panel.className = 'inspector-panel'
|
||||
panel.dataset.panel = p
|
||||
@@ -512,3 +515,113 @@ test('close(): removes the open class + marks aria-hidden', () => {
|
||||
assert.equal(drawer.getAttribute('aria-hidden'), 'true')
|
||||
} finally { cleanupDom() }
|
||||
})
|
||||
|
||||
// --- Feedback tab (lane-wf-feedback) --------------------------------------
|
||||
|
||||
test('renderFeedback: builds verdict thumbs, rubric select, note, and Save; injected dims populate the select', () => {
|
||||
const { doc } = makeShim()
|
||||
const host = doc.createElement('div')
|
||||
inspector.renderFeedback(doc, host, {
|
||||
event: { type: 'assistant/message', seq: 7 },
|
||||
sessionId: 'sess-1',
|
||||
dimensions: [{ id: 'convergence', label: 'Convergence' }, { id: 'no-regression', label: 'No regression' }],
|
||||
existing: null,
|
||||
onSave: () => {},
|
||||
})
|
||||
assert.ok(host.querySelector('.inspector-feedback'), 'feedback form root renders')
|
||||
assert.ok(host.querySelector('[aria-label="Thumbs up"]'), 'thumbs-up button')
|
||||
assert.ok(host.querySelector('[aria-label="Thumbs down"]'), 'thumbs-down button')
|
||||
const select = host.querySelector('.inspector-feedback-dim-select')
|
||||
assert.ok(select, 'rubric dimension select renders')
|
||||
// (none) + 2 injected dims = 3 options
|
||||
assert.equal(select.children.length, 3)
|
||||
assert.ok(host.querySelector('.inspector-feedback-note-input'), 'note textarea')
|
||||
assert.ok(host.querySelector('.inspector-feedback-save'), 'Save button')
|
||||
})
|
||||
|
||||
test('renderFeedback: an existing annotation prefills verdict, note, and rubric dim', () => {
|
||||
const { doc } = makeShim()
|
||||
const host = doc.createElement('div')
|
||||
inspector.renderFeedback(doc, host, {
|
||||
event: { type: 'assistant/message', seq: 7 },
|
||||
sessionId: 'sess-1',
|
||||
dimensions: [{ id: 'convergence', label: 'Convergence' }],
|
||||
existing: { sessionId: 'sess-1', seq: 7, verdict: 'up', note: 'good turn', rubricDim: 'convergence' },
|
||||
onSave: () => {},
|
||||
})
|
||||
const up = host.querySelector('[aria-label="Thumbs up"]')
|
||||
assert.equal(up.classList.contains('active'), true, 'thumbs-up reflects the stored verdict')
|
||||
const note = host.querySelector('.inspector-feedback-note-input')
|
||||
assert.equal(note.value, 'good turn')
|
||||
const select = host.querySelector('.inspector-feedback-dim-select')
|
||||
assert.equal(select.value, 'convergence')
|
||||
})
|
||||
|
||||
test('renderFeedback: Save collects the form and hands it to onSave', () => {
|
||||
const { doc } = makeShim()
|
||||
const host = doc.createElement('div')
|
||||
let captured = null
|
||||
inspector.renderFeedback(doc, host, {
|
||||
event: { type: 'assistant/message', seq: 12 },
|
||||
sessionId: 'sess-9',
|
||||
dimensions: [{ id: 'convergence', label: 'Convergence' }],
|
||||
existing: null,
|
||||
onSave: (form) => { captured = form; return { ok: true } },
|
||||
})
|
||||
// Toggle thumbs-up, type a note, pick a dim, then Save.
|
||||
host.querySelector('[aria-label="Thumbs up"]').dispatch('click')
|
||||
host.querySelector('.inspector-feedback-note-input').value = 'needs work'
|
||||
host.querySelector('.inspector-feedback-dim-select').value = 'convergence'
|
||||
host.querySelector('.inspector-feedback-save').dispatch('click')
|
||||
assert.ok(captured, 'onSave fired')
|
||||
assert.equal(captured.sessionId, 'sess-9')
|
||||
assert.equal(captured.seq, 12)
|
||||
assert.equal(captured.verdict, 'up')
|
||||
assert.equal(captured.note, 'needs work')
|
||||
assert.equal(captured.rubricDim, 'convergence')
|
||||
})
|
||||
|
||||
test('renderFeedback: a second click on the active verdict clears it (toggle to null)', () => {
|
||||
const { doc } = makeShim()
|
||||
const host = doc.createElement('div')
|
||||
let captured = null
|
||||
inspector.renderFeedback(doc, host, {
|
||||
event: { type: 'assistant/message', seq: 3 },
|
||||
sessionId: 's',
|
||||
dimensions: [],
|
||||
existing: { sessionId: 's', seq: 3, verdict: 'down', note: '' },
|
||||
onSave: (form) => { captured = form },
|
||||
})
|
||||
const down = host.querySelector('[aria-label="Thumbs down"]')
|
||||
assert.equal(down.classList.contains('active'), true)
|
||||
down.dispatch('click') // toggle off
|
||||
assert.equal(down.classList.contains('active'), false)
|
||||
host.querySelector('.inspector-feedback-save').dispatch('click')
|
||||
assert.equal(captured.verdict, null)
|
||||
})
|
||||
|
||||
test('open(): Feedback tab renders the annotation form anchored to the event', () => {
|
||||
const { ins, drawer } = loadInspectorWithDom()
|
||||
try {
|
||||
ins.open({ event: { type: 'assistant/message', seq: 7, data: { content: [] } }, tab: 'feedback', sessionId: 'sess-x' })
|
||||
const panel = drawer.querySelector('.inspector-panel[data-panel="feedback"]')
|
||||
assert.equal(panel.hidden, false, 'feedback panel shows')
|
||||
assert.ok(panel.querySelector('.inspector-feedback'), 'feedback form mounted')
|
||||
const feedbackTab = drawer.querySelector('.inspector-tab[data-tab="feedback"]')
|
||||
assert.equal(feedbackTab.getAttribute('aria-selected'), 'true')
|
||||
} finally { cleanupDom() }
|
||||
})
|
||||
|
||||
test('attachInspectBadge: stamps (sessionId, seq) on the badge for marker refresh', () => {
|
||||
const { ins, doc } = loadInspectorWithDom()
|
||||
try {
|
||||
const host = doc.createElement('div')
|
||||
const badge = ins.attachInspectBadge(host, () => ({
|
||||
event: { type: 'assistant/message', seq: 42 }, tab: 'pretty', sessionId: 'sess-7',
|
||||
}))
|
||||
assert.ok(badge, 'badge created')
|
||||
assert.equal(badge.getAttribute('data-annot-session'), 'sess-7')
|
||||
assert.equal(badge.getAttribute('data-annot-seq'), '42')
|
||||
} finally { cleanupDom() }
|
||||
})
|
||||
|
||||
|
||||
@@ -149,6 +149,15 @@ const NON_IIFE_ALLOWLIST = new Set([
|
||||
// require()s it so it must not be IIFE-wrapped. Sole top-level binding is
|
||||
// `function createMsgQueue`, unique across the shared scope.
|
||||
'msg-queue-model.js',
|
||||
// lane-wf-feedback (2026-07-20) two dual-exported pure models — same shape
|
||||
// as msg-queue-model.js. CommonJS require for node --test, window.__dsh*
|
||||
// for the renderer; neither is IIFE-wrapped.
|
||||
// workflow-live-model.js — folds on-wire workflow.event frames into
|
||||
// the aggregate buildWorkflowCard model.
|
||||
// feedback-annotation-model.js — per-event RL-annotation index behind the
|
||||
// inspector Feedback tab.
|
||||
'workflow-live-model.js',
|
||||
'feedback-annotation-model.js',
|
||||
])
|
||||
|
||||
function listRendererScripts() {
|
||||
|
||||
149
examples/desktop/test/workflow-live-model.test.js
Normal file
149
examples/desktop/test/workflow-live-model.test.js
Normal file
@@ -0,0 +1,149 @@
|
||||
// workflow-live-model.test.js — lane-wf-feedback item 1.
|
||||
//
|
||||
// Verifies the pure accumulator that folds the on-wire `workflow.event` train
|
||||
// (runtime commit dd29d8631) into the aggregate {name, kind:'seq', steps[]}
|
||||
// model workflow-view.buildWorkflowCard consumes.
|
||||
//
|
||||
// Covered:
|
||||
// 1. A full six-event run projects a linear seq card (agents → steps).
|
||||
// 2. agent-start → running; agent-end outcome=completed → done; failed → failed.
|
||||
// 3. Steps are ordered by engine `seq`, not arrival order.
|
||||
// 4. phase/log frames fold onto run state without inventing steps.
|
||||
// 5. Unknown kind + malformed frames are dropped (apply → null), no run made.
|
||||
// 6. The wire shape from the runtime's own server.spec fixture round-trips.
|
||||
// 7. toCard on an unknown run returns null; forget/clear drop runs.
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
|
||||
const { createWorkflowLiveModel, statusForOutcome } = require('../src/renderer/workflow-live-model.js')
|
||||
|
||||
// The exact emit shapes from packages/ui/jsonrpc/tests/server.spec.ts in the
|
||||
// runtime repo (the bridge's own test). runId 'run-42', one agent.
|
||||
const META = { name: 'test-flow', description: 'demo' }
|
||||
function frame(kind, payload) {
|
||||
const f = { kind, runId: 'run-42', meta: META }
|
||||
if (payload !== undefined) f.payload = payload
|
||||
return f
|
||||
}
|
||||
|
||||
test('folds the six-event run into a linear seq card', () => {
|
||||
const m = createWorkflowLiveModel()
|
||||
assert.strictEqual(m.apply(frame('workflow/start')), 'run-42')
|
||||
m.apply(frame('workflow/phase', 'Scan'))
|
||||
m.apply(frame('workflow/log', 'starting with 2 files'))
|
||||
m.apply(frame('workflow/agent-start', { seq: 1, label: 'read a.ts', phase: 'Scan', childId: 'child-1' }))
|
||||
m.apply(frame('workflow/agent-end', { seq: 1, label: 'read a.ts', phase: 'Scan', childId: 'child-1', outcome: 'completed' }))
|
||||
m.apply(frame('workflow/end', { stopReason: 'completed', agentsStarted: 1 }))
|
||||
|
||||
const card = m.toCard('run-42')
|
||||
assert.strictEqual(card.name, 'test-flow')
|
||||
assert.strictEqual(card.kind, 'seq')
|
||||
assert.strictEqual(card.steps.length, 1)
|
||||
assert.strictEqual(card.steps[0].id, 'child-1')
|
||||
assert.strictEqual(card.steps[0].name, 'read a.ts')
|
||||
assert.strictEqual(card.steps[0].status, 'done')
|
||||
assert.strictEqual(card._live, true)
|
||||
assert.strictEqual(card._done, true)
|
||||
assert.strictEqual(card._stopReason, 'completed')
|
||||
assert.strictEqual(card._phase, 'Scan')
|
||||
assert.deepStrictEqual(card._logs, ['starting with 2 files'])
|
||||
})
|
||||
|
||||
test('agent-start marks running until agent-end settles the status', () => {
|
||||
const m = createWorkflowLiveModel()
|
||||
m.apply(frame('workflow/agent-start', { seq: 1, label: 'step one', childId: 'c1' }))
|
||||
let card = m.toCard('run-42')
|
||||
assert.strictEqual(card.steps[0].status, 'running')
|
||||
|
||||
m.apply(frame('workflow/agent-end', { seq: 1, childId: 'c1', outcome: 'completed' }))
|
||||
card = m.toCard('run-42')
|
||||
assert.strictEqual(card.steps[0].status, 'done')
|
||||
assert.strictEqual(card.steps[0].output, 'completed')
|
||||
})
|
||||
|
||||
test('a failed outcome maps to a failed step', () => {
|
||||
const m = createWorkflowLiveModel()
|
||||
m.apply(frame('workflow/agent-start', { seq: 2, label: 'boom', childId: 'c2' }))
|
||||
m.apply(frame('workflow/agent-end', { seq: 2, childId: 'c2', outcome: 'failed' }))
|
||||
const card = m.toCard('run-42')
|
||||
assert.strictEqual(card.steps[0].status, 'failed')
|
||||
assert.strictEqual(statusForOutcome('failed'), 'failed')
|
||||
assert.strictEqual(statusForOutcome('aborted'), 'failed')
|
||||
assert.strictEqual(statusForOutcome('completed'), 'done')
|
||||
assert.strictEqual(statusForOutcome(undefined), 'done')
|
||||
})
|
||||
|
||||
test('steps are ordered by engine seq regardless of arrival order', () => {
|
||||
const m = createWorkflowLiveModel()
|
||||
m.apply(frame('workflow/agent-start', { seq: 3, label: 'third', childId: 'c3' }))
|
||||
m.apply(frame('workflow/agent-start', { seq: 1, label: 'first', childId: 'c1' }))
|
||||
m.apply(frame('workflow/agent-start', { seq: 2, label: 'second', childId: 'c2' }))
|
||||
const card = m.toCard('run-42')
|
||||
assert.deepStrictEqual(card.steps.map((s) => s.name), ['first', 'second', 'third'])
|
||||
})
|
||||
|
||||
test('phase and log frames fold onto run state without inventing steps', () => {
|
||||
const m = createWorkflowLiveModel()
|
||||
m.apply(frame('workflow/start'))
|
||||
m.apply(frame('workflow/phase', 'Plan'))
|
||||
m.apply(frame('workflow/log', 'line 1'))
|
||||
m.apply(frame('workflow/log', 'line 2'))
|
||||
const card = m.toCard('run-42')
|
||||
assert.strictEqual(card.steps.length, 0)
|
||||
assert.strictEqual(card._phase, 'Plan')
|
||||
assert.deepStrictEqual(card._logs, ['line 1', 'line 2'])
|
||||
})
|
||||
|
||||
test('phase accepts either a bare string or a { title } object', () => {
|
||||
const m = createWorkflowLiveModel()
|
||||
m.apply(frame('workflow/start'))
|
||||
m.apply(frame('workflow/phase', { title: 'Verify' }))
|
||||
assert.strictEqual(m.toCard('run-42')._phase, 'Verify')
|
||||
})
|
||||
|
||||
test('unknown kind and malformed frames are dropped', () => {
|
||||
const m = createWorkflowLiveModel()
|
||||
assert.strictEqual(m.apply({ kind: 'workflow/bogus', runId: 'x', meta: META }), null)
|
||||
assert.strictEqual(m.apply(null), null)
|
||||
assert.strictEqual(m.apply(undefined), null)
|
||||
assert.strictEqual(m.apply({ kind: 'workflow/start' }), null) // no runId
|
||||
assert.strictEqual(m.apply({ kind: 'workflow/start', runId: null }), null)
|
||||
assert.strictEqual(m.apply('not-an-object'), null)
|
||||
assert.strictEqual(m.runs.size, 0)
|
||||
})
|
||||
|
||||
test('agent frames with a non-finite seq are dropped', () => {
|
||||
const m = createWorkflowLiveModel()
|
||||
m.apply(frame('workflow/start'))
|
||||
m.apply(frame('workflow/agent-start', { label: 'no-seq', childId: 'c' }))
|
||||
m.apply(frame('workflow/agent-start', { seq: 'abc', label: 'bad-seq', childId: 'c2' }))
|
||||
assert.strictEqual(m.toCard('run-42').steps.length, 0)
|
||||
})
|
||||
|
||||
test('toCard on an unknown run is null; forget/clear drop runs', () => {
|
||||
const m = createWorkflowLiveModel()
|
||||
assert.strictEqual(m.toCard('missing'), null)
|
||||
m.apply(frame('workflow/start'))
|
||||
assert.strictEqual(m.hasRun('run-42'), true)
|
||||
m.forget('run-42')
|
||||
assert.strictEqual(m.hasRun('run-42'), false)
|
||||
m.apply(frame('workflow/start'))
|
||||
m.clear()
|
||||
assert.strictEqual(m.runs.size, 0)
|
||||
})
|
||||
|
||||
test('a run named only by runId falls back to runId as the card name', () => {
|
||||
const m = createWorkflowLiveModel()
|
||||
m.apply({ kind: 'workflow/start', runId: 'run-9' }) // no meta
|
||||
assert.strictEqual(m.toCard('run-9').name, 'run-9')
|
||||
})
|
||||
|
||||
test('a later frame backfills a name the start frame lacked', () => {
|
||||
const m = createWorkflowLiveModel()
|
||||
m.apply({ kind: 'workflow/start', runId: 'run-7' })
|
||||
m.apply({ kind: 'workflow/phase', runId: 'run-7', meta: { name: 'late-name', description: 'd' }, payload: 'P' })
|
||||
assert.strictEqual(m.toCard('run-7').name, 'late-name')
|
||||
})
|
||||
@@ -137,6 +137,17 @@ test('isMock flag adds the mock chip; without it, chip is absent', () => {
|
||||
assert.strictEqual(collectByClass(withoutMock, 'workflow-card-chip--mock').length, 0)
|
||||
})
|
||||
|
||||
test('isLive flag adds the live chip (not the mock chip); the two are exclusive', () => {
|
||||
const seq = loadFixture('1.6-workflow-seq.json')
|
||||
const live = view.buildWorkflowCard(makeDoc(), seq.workflow, { isLive: true })
|
||||
assert.strictEqual(collectByClass(live, 'workflow-card-chip--live').length, 1)
|
||||
assert.strictEqual(collectByClass(live, 'workflow-card-chip--mock').length, 0)
|
||||
// isMock wins when both are (wrongly) set — a card is never both live+mock.
|
||||
const both = view.buildWorkflowCard(makeDoc(), seq.workflow, { isMock: true, isLive: true })
|
||||
assert.strictEqual(collectByClass(both, 'workflow-card-chip--mock').length, 1)
|
||||
assert.strictEqual(collectByClass(both, 'workflow-card-chip--live').length, 0)
|
||||
})
|
||||
|
||||
test('showReplayBar mounts prev/next and clamps at ends', () => {
|
||||
const doc = makeDoc()
|
||||
const seq = loadFixture('1.6-workflow-seq.json')
|
||||
|
||||
Reference in New Issue
Block a user