feat(desktop): workflow.event live consumption + inspector Feedback tab + upstream ledger L-5

This commit is contained in:
ZiyaZhang
2026-07-20 02:57:59 -07:00
parent fa640ec9fe
commit 60dfa8d504
21 changed files with 1875 additions and 31 deletions

View 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)
})

View 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)
})
})

View File

@@ -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() }
})

View File

@@ -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() {

View 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')
})

View File

@@ -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')