Files
dsh-context/tests/client/components/browserContentFetch.spec.ts
bowenliang123 9aaa11591a test(client): split the oversized specs into siblings with shared harnesses
browser.spec.ts (2360), trendChart.spec.ts (1629), contextView.spec.ts (1504), and
overview.spec.ts (1065) exceeded the 1000-line limit. Each splits along its existing
describe seams into same-directory siblings, with the fixtures that more than one
sibling needs extracted once into a shared harness.

The move is verbatim: test titles, describe blocks, and every assertion line are
preserved exactly (2061 tests before and after, no duplicate titles, no skips), and
the suite reports the same coverage.
2026-10-10 01:21:15 +08:00

249 lines
13 KiB
TypeScript

import { act, createElement as h } from 'react'
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import type { SurfaceNode } from '../../../src/shared/types'
import type { ConversationNodeLike } from '../../../src/client/services'
import { click, flush, mount, query, queryAll, surfaceNode, text, type Mounted } from '../helpers/kit'
import { Browser, catRow, elemRows, props, silenceFetchWarn, tl } from './browserHarness'
describe('ContextBrowser tool results', () => {
// The tail-status matrix: dsh settles failing commands as completed calls, so trailing markers are the failure signal.
const cases: { seq: number; tail: string; err: boolean; label: string }[] = [
{ seq: 80, tail: 'boom\n[exit code: 3]', err: true, label: 'Failed · exit 3' },
{ seq: 81, tail: '[killed by signal: SIGKILL]', err: true, label: 'Failed' },
{ seq: 82, tail: '[shell killed by signal: SIGTERM]', err: true, label: 'Failed' },
{ seq: 83, tail: '[shell exited: code 2]', err: true, label: 'Failed · exit 2' },
{ seq: 84, tail: '[shell exited: code 0]', err: false, label: 'OK' },
{ seq: 85, tail: '[shell exited]', err: false, label: 'OK' },
{ seq: 86, tail: 'oops [exit code: 7]\n[shell killed by signal: SIGTERM]', err: true, label: 'Failed · exit 7' },
{ seq: 87, tail: '[status: killed]', err: true, label: 'Failed' },
{ seq: 88, tail: '[status: failed, boom]', err: true, label: 'Failed' },
{ seq: 89, tail: 'all good', err: false, label: 'OK' },
{ seq: 90, tail: 'a quoted [exit code: 9] marker mid-text\nmore output', err: false, label: 'OK' },
]
const convNodes: ConversationNodeLike[] = cases.map(c => ({
kind: 'tool-result', seq: c.seq,
call: { name: 'bash', argsRaw: '{"description":"run it"}' },
content: [{ type: 'image', attachment: { attachmentId: 'x' + String(c.seq) } }, null, { type: 'text', text: c.tail }],
}))
convNodes.push(
// Fold-stamped failure without any marker.
{ kind: 'tool-result', seq: 91, call: { name: 'read', argsRaw: '{}' }, content: [{ type: 'text', text: 'denied' }], isError: true },
// No call record → no call card.
{ kind: 'tool-result', seq: 92, call: null, content: [{ type: 'text', text: 'orphan' }] },
// Content that is not an array → no body sections.
{ kind: 'tool-result', seq: 93, call: { name: 'bash', argsRaw: '' }, content: 'raw-text' as never },
)
const nodes: SurfaceNode[] = [
...cases.map(c => surfaceNode({ seq: c.seq, cat: 'tool', tokens: 9, tool: 'bash' })),
surfaceNode({ seq: 91, cat: 'tool', tokens: 9, tool: 'read' }),
surfaceNode({ seq: 92, cat: 'tool', tokens: 9, tool: 'bash' }),
surfaceNode({ seq: 93, cat: 'tool', tokens: 9, tool: 'bash' }),
// Fold-stamped err flag, no join.
surfaceNode({ seq: 94, cat: 'tool', tokens: 9, tool: 'write', err: true }),
// No join at all → placeholder preview; missing tool name → '?'.
surfaceNode({ seq: 95, cat: 'tool', tokens: 9, tool: 'bash' }),
surfaceNode({ seq: 96, cat: 'tool', tokens: 9 }),
// A joined, clean result: the OK-state expansion target below.
surfaceNode({ seq: 97, cat: 'tool', tokens: 9, tool: 'bash' }),
]
convNodes.push({ kind: 'tool-result', seq: 97, call: { name: 'bash', argsRaw: '{}' }, content: [{ type: 'text', text: 'skill body' }] })
const data = tl({
current: { system: 0, tools: 0, user: 0, inject: 0, skill: 0, assistant: 0, tool: 162, total: 162 },
nodes,
})
test('tail-status matrix drives the error dot; tags and previews render on collapsed rows', async () => {
const m = await mount(h(Browser, props({ data, convNodes })))
await click(catRow(m, 'tool'))
const rows = elemRows(m)
assert.equal(rows.length, nodes.length)
// Dot presence per case (collapsed rows): every marker failure + the two
// fold-stamped failures (isError, err flag) — nothing else.
const dots = queryAll(m.container, '.lc-br-err-dot')
assert.equal(dots.length, 9, 'one red dot per failing result')
// Newest-first order: rows[0] is seq 97 (joined clean result), then 96, 95, ...
assert.ok(text(rows[0]).includes('bash'))
assert.ok(text(rows[1]).includes('?'), 'missing tool name tags as ?')
assert.ok(text(rows[1]).includes('Tool Result'), 'no join → placeholder preview')
assert.ok(text(rows[2]).includes('Tool Result'))
assert.ok(text(rows[3]).includes('write'))
const byIdx = Object.fromEntries([97, 96, 95, 94, 93, 92, 91, 90, 89, 88, 87, 86, 85, 84, 83, 82, 81, 80].map((s, i) => [s, i]))
for (const c of cases) {
const hasDot = rows[byIdx[c.seq]].querySelector('.lc-br-err-dot') !== null
assert.equal(hasDot, c.err, `seq ${c.seq}: dot ${c.err ? 'shown' : 'hidden'} for tail ${JSON.stringify(c.tail)}`)
}
assert.ok(rows[byIdx[91]].querySelector('.lc-br-err-dot') !== null, 'isError stamps the dot')
assert.ok(rows[byIdx[94]].querySelector('.lc-br-err-dot') !== null, 'the fold err flag stamps the dot')
assert.ok(rows[byIdx[93]].querySelector('.lc-br-err-dot') === null, 'non-array content has no markers')
await click(rows[0])
const content = query(m.container, '.lc-br-content')
assert.ok(text(content).includes('OK'))
assert.ok(text(content).includes('skill body'))
await m.unmount()
})
test('clean results render the OK state; orphan results render no call card', async () => {
const m = await mount(h(Browser, props({ data, convNodes })))
await click(catRow(m, 'tool'))
const rows = elemRows(m)
// Newest first: index map by seq (97, 96, 95, 94, 93, 92, 91, 90..80).
const byIdx = Object.fromEntries([97, 96, 95, 94, 93, 92, 91, 90, 89, 88, 87, 86, 85, 84, 83, 82, 81, 80].map((s, i) => [s, i]))
const expand = async (seq: number) => {
await click(rows[byIdx[seq]])
return query(m.container, '.lc-br-content')
}
let content = await expand(89)
assert.ok(text(content).includes('OK'))
assert.ok(!queryAll(content, '.lc-br-err-dot').length)
await click(elemRows(m)[byIdx[89]])
// Exit-code failure with the pill.
content = await expand(80)
assert.ok(text(content).includes('Failed · exit 3'))
await click(elemRows(m)[byIdx[80]])
// Killed by signal: failure without an exit code.
content = await expand(81)
assert.ok(text(content).includes('Failed'))
assert.ok(!text(content).includes('exit'))
await click(elemRows(m)[byIdx[81]])
// Shell killed after a failing command: the command's code wins.
content = await expand(86)
assert.ok(text(content).includes('Failed · exit 7'))
await click(elemRows(m)[byIdx[86]])
// Orphan result: no call card, but the result body renders.
content = await expand(92)
assert.ok(!text(content).includes('→'), 'no call card without a call record')
assert.ok(text(content).includes('orphan'))
await click(elemRows(m)[byIdx[92]])
// Non-array content: call card only.
content = await expand(93)
assert.ok(text(content).includes('OK'))
assert.ok(!text(content).includes('raw-text'))
await m.unmount()
})
})
describe('ContextBrowser targeted content fetch', () => {
const data = tl({
current: { system: 0, tools: 0, user: 10, inject: 0, skill: 0, assistant: 0, tool: 0, total: 10 },
nodes: [surfaceNode({ seq: 5, tokens: 5, text: 'pageable' }), surfaceNode({ seq: 6, tokens: 5, text: 'also pageable' })],
})
const openFirstRow = async (m: Mounted) => {
await click(catRow(m, 'user'))
await click(elemRows(m)[1]) // seq 5 (newest first: 6, 5)
}
test('a missed join fetches the seq once, and the fetched body renders', async () => {
let calls = 0
let release: (() => void) | null = null
const fetchContent = async (seq: number) => {
calls += 1
return new Promise<void>((resolve) => { release = resolve })
.then(() => ({ kind: 'user', seq, content: [{ type: 'text', text: 'FULL BODY' }] }) as ConversationNodeLike)
}
const m = await mount(h(Browser, props({ data, convNodes: [], fetchContent })))
await openFirstRow(m)
assert.equal(calls, 1, 'one targeted read')
assert.ok(text(query(m.container, '.lc-br-content')).includes('Loading full content from older session history'), 'in-flight note')
await act(async () => { release?.() })
await flush()
assert.ok(text(query(m.container, '.lc-br-content')).includes('FULL BODY'))
// Close and reopen the same row: the fetched node is merged state — no re-read.
await click(elemRows(m)[1])
await click(elemRows(m)[1])
assert.ok(text(query(m.container, '.lc-br-content')).includes('FULL BODY'))
assert.equal(calls, 1, 'no second fetch for a merged seq')
await m.unmount()
})
test('a page without the seq reports it as absent from the session log', async () => {
let calls = 0
const fetchContent = async () => {
calls += 1
return null
}
const m = await mount(h(Browser, props({ data, convNodes: [], fetchContent })))
await openFirstRow(m)
await flush()
assert.equal(calls, 1)
assert.ok(text(query(m.container, '.lc-br-content')).includes('not in the session log anymore'))
await m.unmount()
})
test('a failed read arms the retry button; retrying succeeds', async () => {
silenceFetchWarn()
let calls = 0
const fetchContent = async (seq: number) => {
calls += 1
if (calls === 1) throw new Error('transport down')
return { kind: 'user', seq, content: [{ type: 'text', text: 'RETRY BODY' }] } as ConversationNodeLike
}
const m = await mount(h(Browser, props({ data, convNodes: [], fetchContent })))
await openFirstRow(m)
await flush()
const failed = query(m.container, '.lc-br-content')
assert.ok(text(failed).includes('Load failed'))
await click(query(failed, '.lc-br-retry'))
await flush()
assert.ok(text(query(m.container, '.lc-br-content')).includes('RETRY BODY'))
assert.equal(calls, 2)
await m.unmount()
})
test('without a fetcher (older host) an un-joined row keeps the static note', async () => {
const m = await mount(h(Browser, props({ data, convNodes: [] })))
await openFirstRow(m)
await flush()
assert.ok(text(query(m.container, '.lc-br-content')).includes('outside the loaded message window'))
await m.unmount()
})
test('a late fetch for an abandoned row never lands; the next row fetches its own seq', async () => {
silenceFetchWarn()
const deferreds: { resolve: (node: ConversationNodeLike | null) => void; reject: (reason: Error) => void }[] = []
const fetchedFor: number[] = []
const fetchContent = (seq: number): Promise<ConversationNodeLike | null> => {
fetchedFor.push(seq)
if (seq === 5) return new Promise((resolve, reject) => { deferreds.push({ resolve, reject }) })
return Promise.resolve({ kind: 'user', seq, content: [{ type: 'text', text: 'BODY ' + String(seq) }] } as ConversationNodeLike)
}
let snap: { nodes?: readonly ConversationNodeLike[] } = { nodes: [] }
const el = () => h(Browser, props({ data, convNodes: snap.nodes, fetchContent }))
const m = await mount(el())
await openFirstRow(m) // seq 5 hangs in flight
assert.equal(deferreds.length, 1)
await click(elemRows(m)[0]) // switch to seq 6 while seq 5 is pending
await flush()
assert.ok(text(query(m.container, '.lc-br-content')).includes('BODY 6'))
// The abandoned promise settles after the row was left — both outcomes are ignored.
await act(async () => {
deferreds[0]?.resolve({ kind: 'user', seq: 5, content: [{ type: 'text', text: 'STALE BODY' }] })
})
assert.ok(!text(m.container).includes('STALE BODY'), 'stale result ignored')
await click(elemRows(m)[1]) // seq 5 again: another deferred hangs
await click(elemRows(m)[0])
await flush()
await act(async () => {
deferreds[1]?.reject(new Error('gone'))
})
await flush()
assert.ok(text(query(m.container, '.lc-br-content')).includes('BODY 6'), 'stale rejection ignored')
await click(elemRows(m)[1])
await act(async () => {
deferreds[2]?.resolve({ kind: 'user', seq: 5, content: [{ type: 'text', text: 'BODY 5' }] })
})
await flush()
assert.ok(text(query(m.container, '.lc-br-content')).includes('BODY 5'))
assert.deepEqual(fetchedFor, [5, 6, 5, 5], 'one call per miss (seq 6 stays cached)')
// The conversation window catching up later wins over fetched state.
snap = { nodes: [{ kind: 'user', seq: 5, content: [{ type: 'text', text: 'WINDOW BODY' }] }] }
await m.update(el())
assert.ok(text(query(m.container, '.lc-br-content')).includes('WINDOW BODY'))
assert.ok(!text(m.container).includes('BODY 5'), 'the join takes precedence over the fetch cache')
await m.unmount()
})
})