test(browser): cover the cold start, failure surface, and pushed takeover

Add useTimelineSource cases for the cold read rendering from its own slim
head, the retry after a failed/absent/headless cold read, and a pushed head
taking the shared per-session store back. Surface the retryable failure note
in the Context tab and /context modal specs, isolate the page-lifetime
detail stores between tests, and record the no-stall contract in
docs/compatibility.md.
This commit is contained in:
bowenliang123 committed 2026-09-29 17:10:36 +08:00
1 parent 864aa2603c
commit 67787685fe
4 files changed
+126 -4

No files matched your search

+1
View File
@@ -63,6 +63,7 @@ The plugin lives off data it does not own: the durable session log (event shapes
- **One bad record never takes down a view.** A malformed node, event, tool entry, or file op degrades to zero rows for that item — the card, the tab, and the session keep working.
- **Host-side projection folds are TOTAL.** The harness projection registry drives `apply` straight off the session/event bus with no error boundary of its own: one throwing fold stalls that unit's cells and its `session/projection` push feed, and the browser waits on "loading" forever. Unknown event types return the state unchanged; per-event processing is isolated so a malformed event is dropped whole (all-or-nothing, no partial state); and no `undefined`-valued property is ever materialized into persisted state — the plain-JSON precondition makes one such property fail EVERY projection-cache write for the session, breaking sessions in unrelated, far-away places.
- **Client-side parsing degrades visibly.** Delivered projection payloads are sanitized at the boundary (the `timelineOf` pattern: collections re-proved, scalars zeroed, whole-value absence stays `null` → loading screen); per-item work in any fold over join/log data is isolated (per-item guards, or a bounded catch where a hostile object may throw on property access); every async fetch resolves to data or a visible retryable state, never an unhandled rejection that leaves a spinner.
- **A session with no pushed `contextTimeline` value never stalls the view.** The projection store's `seed()` clears a key its baseline omits, and a checkpoint row can be missing or version-stale on a cold-observed session — so the Context tab or `/context` modal can sit with no value at all and no push on the way. The source opens the detail channel itself in that case (rev 0): the plugin's own `/api/dsh-context/detail` route folds the durable log on demand and serves the slim head beside the collections, the next pushed head takes the channel back per its own revision, and a settled-without-data read surfaces the retryable failure note (the same `DetailNote` the detail cards use) instead of an eternal spinner.
- **Every field is re-proved at runtime.** Structural narrowing over blind casts; optional chaining over non-null assertions; elements that fail the shape are skipped instead of throwing.
- **Every parser carries hostile fixtures next to its happy path**: wrong types, null/missing fields, null or primitive elements inside arrays, unpaired references, and objects that throw on property access. The repository's 100% per-file coverage bar applies to every guard branch — an untested guard is an unverified promise.
@@ -7,6 +7,7 @@ import { act, createElement as h, useSyncExternalStore } from 'react'
import assert from 'node:assert/strict'
import { afterEach, describe, test, vi } from 'vitest'
import { makeContextModal } from '../../../src/client/components/contextModal'
import { resetTimelineDetailStores } from '../../../src/client/timelineSource'
import { modalStoreOf, setPendingConsume, takePendingConsume } from '../../../src/client/modalStore'
import { watchHistoryFaces } from '../../../src/client/historyPage'
import { createContextSettings } from '../../../src/client/settings'
@@ -44,6 +45,10 @@ const OPEN = (): boolean => true
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
// The cold-start share (timelineSource.ts) is page-lifetime per session:
// drop it so one test's cold read never leaks its state into a later test
// that reuses the same session id.
resetTimelineDetailStores()
})
describe('ContextModal', () => {
@@ -82,6 +87,25 @@ describe('ContextModal', () => {
await m2.unmount()
})
test('a cold read that settles without data surfaces the retryable failure, not a stall', async () => {
vi.stubGlobal('fetch', () => Promise.resolve({ ok: false, status: 500, json: async () => null } as Response))
const ctx = new TestClientCtx({ services: { sessions: new TestSessions() } })
const ContextModal = makeContextModal(asClientCtx(ctx), kit, settings)
const m = await mount(h(ContextModal, {
sessionId: 'sm-cold-fail',
useContextModal: OPEN,
useProjection: () => undefined,
}))
await until(() => text(m.container).includes(DICT_EN['detail.loadFailed']), 'the failure note never surfaced')
assert.ok(!text(m.container).includes(DICT_EN.loading), 'no spinner remains once failed')
await act(async () => {
const hit = queryAll(m.container, 'button').find(b => text(b) === DICT_EN['detail.loadFailed'])
if (hit === undefined) throw new Error('retry button not found')
hit.click()
})
await m.unmount()
})
test('a corrupt timeline projection is sanitized and still renders the composition', async () => {
const ctx = new TestClientCtx({ services: { sessions: new TestSessions() } })
const ContextModal = makeContextModal(asClientCtx(ctx), kit, settings)
@@ -12,6 +12,7 @@ import { afterEach, beforeEach, describe, test, vi } from 'vitest'
import { makeContextView } from '../../../src/client/components/contextView'
import { watchHistoryFaces } from '../../../src/client/historyPage'
import { requestContextFocus, takeContextFocus } from '../../../src/client/viewFocus'
import { resetTimelineDetailStores } from '../../../src/client/timelineSource'
import { createContextSettings } from '../../../src/client/settings'
import type { SettingsScopeLike } from '../../../src/client/settings'
import { resetModelPrices, setModelPricesLoader } from '../../../src/client/modelPrices'
@@ -30,6 +31,10 @@ const kit = makeKit()
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
// The cold-start share (timelineSource.ts) is page-lifetime per session:
// drop it so one test's cold read never leaks its failed/ready state into
// a later test that reuses the same session id.
resetTimelineDetailStores()
})
@@ -135,6 +140,18 @@ describe('ContextView — projection guards', () => {
await m3.unmount()
})
test('a cold read that settles without data surfaces the retryable failure, not a stall', async () => {
vi.stubGlobal('fetch', () => Promise.resolve({ ok: false, status: 500, json: async () => null } as Response))
const View = makeView(new TestClientCtx())
const m = await mount(h(View, { sessionId: 'sv-cold-fail', useProjection: () => undefined }))
await until(() => text(m.container).includes(DICT_EN['detail.loadFailed']), 'the failure note never surfaced')
assert.ok(!text(m.container).includes(DICT_EN.loading), 'no spinner remains once failed')
await act(async () => {
buttonByText(m.container, DICT_EN['detail.loadFailed']).click()
})
await m.unmount()
})
test('renders the full tab without a session id (the agent card anchors nothing)', async () => {
const View = makeView(new TestClientCtx())
const m = await mount(h(View, { useProjection: projectionsFor(timeline()) }))
+84 -4
View File
@@ -400,10 +400,11 @@ describe('detailStoreOf', () => {
})
/** A probe component rendering the source's observable surface as text. */
function SourceProbe(props: { ctx: ClientCtx; sessionId: string; value: unknown }): ReactElement {
function SourceProbe(props: { ctx: ClientCtx; sessionId: string; value?: unknown; useProjection?: (key: string) => unknown }): ReactElement {
const useProjection = props.useProjection ?? ((key: string) => (key === 'contextTimeline' ? props.value : undefined))
const source = useTimelineSource(props.ctx, {
sessionId: props.sessionId,
useProjection: (key: string) => (key === 'contextTimeline' ? props.value : undefined),
useProjection,
})
const data = source.data
return h('div', null,
@@ -451,10 +452,89 @@ describe('useTimelineSource', () => {
await m.unmount()
})
test('no projection value keeps the loading surface', async () => {
const m = await mount(h(SourceProbe, { ctx: ctxWithCall(() => ({ body: null })), sessionId: 's1', value: undefined }))
test('the cold start: no pushed value opens the channel itself and renders from the read', async () => {
let calls = 0
const ctx = ctxWithCall(() => {
calls++
return { body: { ok: true, value: detail(2, { head: slimHead(2) }) } }
})
const m = await mount(h(SourceProbe, { ctx, sessionId: 's1', value: undefined }))
// The first paint waits on the cold read — then the read's own slim head
// stands in for the never-pushed projection value.
assert.equal(probeRead(m.container, 'state'), 'loading')
await until(() => probeRead(m.container, 'state') === 'ready', 'the cold read never landed')
assert.equal(probeRead(m.container, 'model'), 'm', 'the read head renders')
assert.equal(probeRead(m.container, 'rev'), '2')
assert.equal(probeRead(m.container, 'steps'), '1', 'the read collections merge')
assert.ok(calls >= 1)
await m.unmount()
})
test('a pushed head arriving after the cold read takes the channel back', async () => {
// The stub folds the served rev per the latest requested revision — the
// production route folds the requested session at its current watermark.
let servedRev = 1
vi.stubGlobal('fetch', async () => ({ ok: true, status: 200, json: async () => ({ ok: true, value: detail(servedRev, { head: slimHead(servedRev, { model: servedRev === 1 ? 'cold-m' : 'pushed-m' }) }) }) }))
const ctx = asClientCtx(new TestClientCtx())
// The probe reads `useProjection` through a mutable capture so an update
// rerender sees the freshly pushed head (the real seat reads per render).
let pushed: unknown = undefined
function PushProbe(): ReactElement {
return h(SourceProbe, {
ctx,
sessionId: 's1',
useProjection: (key: string) => (key === 'contextTimeline' ? pushed : undefined),
})
}
const m = await mount(h(PushProbe, null))
await until(() => probeRead(m.container, 'state') === 'ready', 'the cold read never landed')
assert.equal(probeRead(m.container, 'model'), 'cold-m')
// The pushed head outruns the cold read's revision: same per-session
// store, one trailing refetch, the pushed value renders.
servedRev = 2
pushed = slimHead(2, { model: 'pushed-m' })
await m.update(h(PushProbe, null))
assert.equal(probeRead(m.container, 'model'), 'pushed-m', 'the pushed head wins at once')
assert.equal(probeRead(m.container, 'rev'), '2', 'the pushed revision renders')
await until(() => detailStoreOf(ctx, 's1').getSnapshot().detail?.rev === 2, 'the pushed head never trailed the channel')
await m.unmount()
})
test('the cold read failing arms the retry affordance instead of stalling', async () => {
let online = false
const ctx = ctxWithCall(() => {
if (!online) throw new Error('offline')
return { body: { ok: true, value: detail(1, { head: slimHead(1) }) } }
})
const m = await mount(h(SourceProbe, { ctx, sessionId: 's1', value: undefined }))
await until(() => probeRead(m.container, 'state') === 'failed', 'the cold failure never surfaced')
assert.equal(probeRead(m.container, 'steps'), 'null')
online = true
await act(async () => {
;(m.container.querySelector('[data-k="retry"]') as HTMLElement).click()
})
await until(() => probeRead(m.container, 'state') === 'ready', 'the retry never recovered')
await m.unmount()
})
test('a cold payload without its head types the failure (nothing renderable, no stall)', async () => {
const ctx = ctxWithCall(() => ({ body: { ok: true, value: detail(1) } }))
const m = await mount(h(SourceProbe, { ctx, sessionId: 's1', value: undefined }))
await until(() => probeRead(m.container, 'state') === 'failed', 'the headless payload never surfaced')
await m.unmount()
})
test('an absent cold answer (the session left the live set) surfaces the failed note', async () => {
const ctx = ctxWithCall(() => ({ body: { ok: true, value: null } }))
const m = await mount(h(SourceProbe, { ctx, sessionId: 's1', value: undefined }))
await until(() => probeRead(m.container, 'state') === 'failed', 'absence never surfaced')
await m.unmount()
})
test('no session id to read for: the cold start types the failure, not an eternal spinner', async () => {
const ctx = ctxWithCall(() => ({ body: { ok: true, value: detail(1, { head: slimHead(1) }) } }))
const m = await mount(h(SourceProbe, { ctx, sessionId: '', value: undefined }))
await until(() => probeRead(m.container, 'state') === 'failed', 'the empty-id cold start never surfaced')
await m.unmount()
})