Files
deepseek-harness/examples/desktop/test/renderer-fork-errors.test.js
T

123 lines
5.6 KiB
JavaScript
Raw Normal View History

// Tests for P0-4: Fork button error-code classification + inflight-turn gating.
//
// The wire strips SessionForkError.code down to a JSON-RPC -32603 error whose
// `message` is preserved verbatim from packages/core/session/src/index.ts. The
// renderer classifies that message back into one of the four kernel codes so
// the system line can speak in replay-boundary language instead of raw error
// text.
//
// Intent red-line (2026-07-16 team-lead): fork wording NEVER says
// "copy" / "snapshot" / "duplicate current state" — a fork is a deterministic
// replay from a closed-turn boundary. The classifier's `humanMessage` field
// carries the user-facing phrasing and is asserted below.
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const { loadRenderer } = require('./renderer-harness.js')
test('classifyForkError → OPEN_TURN when message names an unclosed turn', async () => {
const { window } = await loadRenderer()
const { classifyForkError } = window.__dshRenderer
const raw = new Error('fork boundary 12 in session "abc" must be turn/end, got assistant/message')
const c = classifyForkError(raw)
assert.equal(c.code, 'OPEN_TURN')
// Human message stays in replay language — never "copy" / "snapshot".
assert.match(c.humanMessage, /closed[\s-]turn/i)
assert.doesNotMatch(c.humanMessage, /\b(copy|snapshot|duplicate)\b/i)
})
test('classifyForkError → INVALID_BOUNDARY for the three boundary-shape phrases', async () => {
const { window } = await loadRenderer()
const { classifyForkError } = window.__dshRenderer
const phrases = [
'fork boundary for session "x" must be a non-negative safe integer, got NaN',
'fork boundary 999 does not exist in session "x" (last seq: 42)',
'fork boundary 5 does not match a contiguous event seq in session "x"',
]
for (const p of phrases) {
const c = classifyForkError(new Error(p))
assert.equal(c.code, 'INVALID_BOUNDARY', p)
}
})
test('classifyForkError → SESSION_NOT_LIVE / SESSION_NOT_FOUND / SESSION_ALREADY_EXISTS', async () => {
const { window } = await loadRenderer()
const { classifyForkError } = window.__dshRenderer
assert.equal(
classifyForkError(new Error('session "abc" is not the live store instance')).code,
'SESSION_NOT_LIVE',
)
assert.equal(
classifyForkError(new Error('session "abc" not found')).code,
'SESSION_NOT_FOUND',
)
assert.equal(
classifyForkError(new Error('session "abc-fork-1" already exists')).code,
'SESSION_ALREADY_EXISTS',
)
})
test('classifyForkError → UNKNOWN when the message is not recognized', async () => {
const { window } = await loadRenderer()
const { classifyForkError } = window.__dshRenderer
const c = classifyForkError(new Error('something completely different'))
assert.equal(c.code, 'UNKNOWN')
// Falls through to the raw message so we don't lose diagnostic content.
assert.match(c.humanMessage, /something completely different/)
})
test('classifyForkError prefers explicit code on the error object over message parsing', async () => {
const { window } = await loadRenderer()
const { classifyForkError } = window.__dshRenderer
// If main.js pre-classified and stuck the code on the object, honor it.
const err = new Error('opaque wrapped message')
err.code = 'OPEN_TURN'
const c = classifyForkError(err)
assert.equal(c.code, 'OPEN_TURN')
})
test('classifyForkError handles thrown strings and undefined', async () => {
const { window } = await loadRenderer()
const { classifyForkError } = window.__dshRenderer
assert.equal(classifyForkError(undefined).code, 'UNKNOWN')
assert.equal(classifyForkError(null).code, 'UNKNOWN')
assert.equal(classifyForkError('OPEN_TURN somehow').code, 'UNKNOWN')
})
test('fork button is disabled + tooltip explains replay boundary when a turn is in flight', async () => {
const { renderer, window } = await loadRenderer()
renderer.ensureSession('s1', { title: 'sess', header: {} })
await renderer.selectSession('s1')
// Start a turn so inflightTurn flips true, then append an assistant bubble
// so a fork button gets attached to it.
renderer.onSessionEvent('s1', { type: 'turn/start', seq: 1 })
renderer.onSessionEvent('s1', { type: 'assistant/message', seq: 2, data: { content: 'hi' } })
const bubbles = window.document.querySelectorAll('.msg.assistant')
assert.ok(bubbles.length >= 1, 'expected an assistant bubble in the stream')
const btn = bubbles[0].querySelector('.fork-here')
assert.ok(btn, 'expected a fork-here button on the assistant bubble')
renderer.updateForkButtons()
assert.equal(btn.disabled, true)
assert.match(btn.title, /replay/i)
assert.match(btn.title, /closed[\s-]turn|current turn to (?:end|finish)/i)
assert.doesNotMatch(btn.title, /\b(copy|snapshot|duplicate)\b/i)
})
test('fork button re-enables + swaps tooltip back to boundary preview when the turn ends', async () => {
const { renderer, window } = await loadRenderer()
renderer.ensureSession('s1', { title: 'sess', header: {} })
await renderer.selectSession('s1')
renderer.onSessionEvent('s1', { type: 'turn/start', seq: 1 })
renderer.onSessionEvent('s1', { type: 'assistant/message', seq: 2, data: { content: 'hi' } })
renderer.onSessionEvent('s1', { type: 'turn/end', seq: 3 })
renderer.updateForkButtons()
const btn = window.document.querySelectorAll('.msg.assistant')[0].querySelector('.fork-here')
assert.equal(btn.disabled, false)
// Boundary-preview tooltip mentions the seq (turn/end re-stamped
// data-fork-seq); still no "copy" language.
assert.match(btn.title, /seq 3|turn boundary|replay/i)
assert.doesNotMatch(btn.title, /\b(copy|snapshot|duplicate)\b/i)
})