Files
dsh-context/tests/client/components/contextView.fileActivity.spec.ts
bowenliang123 abce91a6a6 feat(client): move file-op values and step stamps to the row's right edge
Expanded file rows now end in one right-aligned meta group: the read window,
the line delta, the failure dot and the stamp+time. The turn/step stamp is the
Context Browser's own (`stepStampOf`, reusing browser.stepAt/stepAtTip): the
first request that carried the op's result node, merged with the op time, or
the time alone when no logged request carries the op yet. Nested Code-Mode ops
scope through the shared `surfaceSeqOf` helper, and the meta group wraps onto
its own right-aligned line on phone-width cards.
2026-10-19 10:39:29 +08:00

378 lines
17 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createElement as h } from 'react'
import assert from 'node:assert/strict'
import { describe, test, vi } from 'vitest'
import { DICT_EN } from '../../../src/client/i18n'
import type { UseChatLike } from '../../../src/client/services'
import type { ContextTimeline } from '../../../src/shared/types'
import { TestClientCtx } from '../helpers/harness'
import { click, flush, hover, mount, query, queryAll, text, unhover, until } from '../helpers/kit'
import { buttonByText, makeView, projectionsFor, T0, timeline } from './contextViewHarness'
describe('ContextView — file activity card', () => {
// Chart hover previews the browser/file activity after a short rest; a rapid sweep keeps only the trailing
// value (contextView's hover debounce), so tests wait for the trailing update before asserting.
const rest = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 200))
/** Six steps with two file ops: a live read (seq 3) and an edit (seq 5) compacted away before seq 6 dispatched. */
function fileTimeline(): ContextTimeline {
const req = (seq: number, time: number, extra: Record<string, unknown> = {}) =>
({ seq, time, system: 10, tools: 20, user: 10, inject: 0, assistant: 20, tool: 10, total: 70, ...extra })
return timeline({
requests: [
req(2, T0 + 1000, { turn: 1, step: 1 }),
req(4, T0 + 3000, { turn: 1, step: 2 }),
req(6, T0 + 5000), // two turn-less mid-session steps form their own aggregate
req(7, T0 + 6000),
req(8, T0 + 7000, { turn: 2, step: 1 }), // a single-step turn, not the last bar
req(9, T0 + 8000, { turn: 3, step: 1 }),
],
nodes: [
{ seq: 1, cat: 'user', tokens: 10, text: 'hi', time: T0 + 500 },
{ seq: 2, cat: 'assistant', tokens: 20, text: 'r1', time: T0 + 1000 },
{ seq: 3, cat: 'tool', tokens: 30, tool: 'read', time: T0 + 2000 },
{ seq: 4, cat: 'assistant', tokens: 60, text: 'r2', time: T0 + 3000 },
{ seq: 6, cat: 'assistant', tokens: 80, text: 'r3', time: T0 + 5000 },
{ seq: 7, cat: 'assistant', tokens: 80, text: 'r3b', time: T0 + 6000 },
{ seq: 8, cat: 'assistant', tokens: 80, text: 'r4', time: T0 + 7000 },
{ seq: 9, cat: 'assistant', tokens: 80, text: 'r5', time: T0 + 8000 },
],
archive: [
{ seq: 5, cat: 'tool', tokens: 30, tool: 'edit', gone: 6, time: T0 + 4000 },
],
})
}
const fileConv = [
{ kind: 'tool', seq: 3, call: { name: 'read', argsRaw: JSON.stringify({ file_path: '/src/a.ts' }) } },
{ kind: 'tool', seq: 5, call: { name: 'edit', argsRaw: JSON.stringify({ file_path: '/src/a.ts', old_string: 'a\nb', new_string: 'a' }) } },
]
async function mountFiles(sessionId: string) {
const View = makeView(new TestClientCtx())
const m = await mount(h(View, {
sessionId,
useProjection: projectionsFor(fileTimeline()),
useChat: (sel =>
sel({
legacy: { nodes: fileConv } })) as UseChatLike,
}))
const card = queryAll(m.container, '.lc-card').find(c => text(c).includes(DICT_EN['files.title']))
assert.ok(card !== undefined)
return { m, card }
}
test('follows the chart pick: the exclusive next-step bound', async () => {
const { m, card } = await mountFiles('sv-files-scope')
assert.ok(text(card).includes('+1') && text(card).includes('−2'))
// Hovering the FIRST bar bounds the fold at the second request: the edit drops out.
await hover(query(m.container, '.lc-bar[data-seq="2"]'))
await rest()
assert.equal(queryAll(card, '.lc-fa-meta-delta').length, 0)
assert.equal(queryAll(card, '.lc-fa-row').length, 1)
assert.ok(!text(card).includes('+1'))
// The turn-less mid step still bounds at the next request: both the read and the edit stay.
await hover(query(m.container, '.lc-bar[data-seq="6"]'))
await rest()
assert.ok(text(card).includes('+1'))
await unhover(query(m.container, '.lc-chart'))
assert.ok(text(card).includes('−2'))
await m.unmount()
})
test('a rapid second hover keeps only the trailing preview', async () => {
const { m, card } = await mountFiles('sv-files-trailing')
// Freeze the hover clock across both moves: the first previews immediately, the second can only trail.
const frozen = performance.now()
const clock = vi.spyOn(performance, 'now').mockReturnValue(frozen)
await hover(query(m.container, '.lc-bar[data-seq="2"]'))
await hover(query(m.container, '.lc-bar[data-seq="4"]'))
await hover(query(m.container, '.lc-bar[data-seq="6"]'))
clock.mockRestore()
// Poll rather than sleep a fixed margin: the trailing timer may land late under a loaded coverage run.
await until(() => text(card).includes('+1'), 'the trailing hover lands on the last bar')
await m.unmount()
})
test('DNA mode waits for the pointer to rest before updating the preview', async () => {
const { m, card } = await mountFiles('sv-files-dna-hover')
await click(buttonByText(m.container, DICT_EN['trend.dna']))
await hover(query(m.container, '.lc-bar[data-seq="2"]'))
await new Promise(resolve => setTimeout(resolve, 80))
// The chart lights its own bar immediately; the cross-card preview stays on the latest scope.
assert.ok(text(card).includes('−2'))
await new Promise(resolve => setTimeout(resolve, 300))
assert.ok(!text(card).includes('−2'))
await m.unmount()
})
test('op clicks reveal the result in the browser', async () => {
const { m, card } = await mountFiles('sv-files-locate')
const pick = query<HTMLSelectElement>(m.container, 'select.lc-br-pick')
// Expand the file row: the compacted edit (seq 5) has no viewing step and
// its click is a no-op; the read (seq 3) reveals in step 4's surface.
await click(query(card, '.lc-fa-row'))
const ops = queryAll(card, '.lc-fa-op')
assert.equal(ops.length, 2)
// The Context Browser's own stamp: the read result (seq 3) is first carried by the seq-4 request (Turn 1 · Step 2);
// the compacted edit's carrying request is turn-less, so its row prints no stamp.
assert.ok(text(query(ops[1], '.lc-fa-op-time')).includes('T1 S2'))
assert.ok(/^\d{2}:\d{2}:\d{2}$/.test(text(query(ops[0], '.lc-fa-op-time'))))
await click(ops[0]) // edit, gone = 6: unlocatable
assert.equal(pick.value, 'live')
await click(ops[1]) // read at seq 3
assert.equal(pick.value, '4')
assert.ok(text(query(m.container, '.lc-br-elem-on')).includes('/src/a.ts'))
await m.unmount()
})
test('a nested PTC op reveals on its parent run_code result, not on its dispatch seq', async () => {
const ptcTimeline = timeline({
requests: [
{ seq: 2, turn: 1, step: 1, time: T0 + 1000, system: 10, tools: 20, user: 10, inject: 0, assistant: 20, tool: 10, total: 70 },
{ seq: 4, turn: 1, step: 2, time: T0 + 3000, system: 10, tools: 20, user: 10, inject: 0, assistant: 20, tool: 10, total: 70 },
],
nodes: [
{ seq: 2, cat: 'assistant', tokens: 20, text: 'r1', time: T0 + 1000 },
{ seq: 3, cat: 'tool', tokens: 30, tool: 'run_code', time: T0 + 2000 },
{ seq: 4, cat: 'assistant', tokens: 60, text: 'r2', time: T0 + 3000 },
],
})
const ptcConv = [
{
kind: 'tool-result',
seq: 3,
call: { name: 'run_code', argsRaw: JSON.stringify({ code: 'await tools.edit(…)', description: 'Fix the failing test' }) },
subCalls: [
{
kind: 'tool-result',
seq: 2,
time: T0 + 1500,
call: { name: 'edit', argsRaw: JSON.stringify({ file_path: '/src/a.ts', old_string: 'a\nb', new_string: 'a' }) },
isError: false,
subCalls: [],
},
],
},
]
const View = makeView(new TestClientCtx())
const m = await mount(h(View, {
sessionId: 'sv-ptc-locate',
useProjection: projectionsFor(ptcTimeline),
useChat: (sel =>
sel({
legacy: { nodes: ptcConv } })) as UseChatLike,
}))
const card = queryAll(m.container, '.lc-card').find(c => text(c).includes(DICT_EN['files.title']))
assert.ok(card !== undefined)
await click(query(card, '.lc-fa-row'))
// The op is attributed to the nested tool, and says why (its program).
assert.ok(text(card).includes('edit'))
assert.ok(text(card).includes('Fix the failing test'))
// The click lands on the parent run_code node (seq 3 → step 4's surface).
await click(query(card, '.lc-fa-op'))
const pick = query<HTMLSelectElement>(m.container, 'select.lc-br-pick')
assert.equal(pick.value, '4')
assert.ok(text(query(m.container, '.lc-br-elem-on')).includes('Fix the failing test'))
await m.unmount()
})
test('the session workspace relativizes row paths; the host opener opens the resolved file', async () => {
const opened: string[] = []
const calls: string[] = []
const ctx = new TestClientCtx({
services: {
sessions: { list: { getSnapshot: () => ({ byId: { 'sv-files-open': { cwd: '/repo' } } }) } },
connection: {
isLoopback: true,
rpc: {
call: (_channel: string, endpoint: string, payload: unknown) => {
if (endpoint === 'session/canOpenWorkspacePath') return Promise.resolve({ ok: true, value: true })
calls.push(endpoint)
opened.push((payload as { args: { request: { path: string } } }).args.request.path)
return Promise.resolve({ ok: true, value: { opened: true } })
},
},
},
},
})
const conv = [
{ kind: 'tool', seq: 3, call: { name: 'read', argsRaw: JSON.stringify({ file_path: '/repo/src/a.ts' }) } },
]
const View = makeView(ctx)
const m = await mount(h(View, {
sessionId: 'sv-files-open',
useProjection: projectionsFor(fileTimeline()),
useChat: (sel =>
sel({
legacy: { nodes: conv } })) as UseChatLike,
}))
// The capability probe is an RPC round-trip: the answer lands in state on the next flush.
await flush()
const card = queryAll(m.container, '.lc-card').find(c => text(c).includes(DICT_EN['files.title']))
assert.ok(card !== undefined)
const row = query(card, '.lc-fa-row')
assert.ok(row.querySelector('.lc-fa-path em')?.textContent === 'src/')
const name = query(row, '.lc-fa-file')
assert.equal(name.getAttribute('title'), DICT_EN['files.open'])
await click(name)
assert.deepEqual(opened, ['/repo/src/a.ts'])
assert.deepEqual(calls, ['session/openWorkspacePath'])
await m.unmount()
})
test('the Sidebar preview leads the name click; a refusal falls through to the system opener', async () => {
const previewed: string[] = []
const opened: string[] = []
const ctx = new TestClientCtx({
services: {
sessions: { list: { getSnapshot: () => ({ byId: { 'sv-preview': { cwd: '/repo' } } }) } },
connection: {
isLoopback: true,
rpc: {
call: (_channel: string, endpoint: string, payload: unknown) => {
if (endpoint === 'session/canOpenWorkspacePath') return Promise.resolve({ ok: true, value: true })
opened.push((payload as { args: { request: { path: string } } }).args.request.path)
return Promise.resolve({ ok: true, value: { opened: true } })
},
},
},
sidebarRight: { openResource: (address: string) => { previewed.push(address) } },
},
})
const conv = [
{ kind: 'tool', seq: 3, call: { name: 'read', argsRaw: JSON.stringify({ file_path: '/repo/src/a.ts' }) } },
]
const View = makeView(ctx)
const m = await mount(h(View, {
sessionId: 'sv-preview',
useProjection: projectionsFor(fileTimeline()),
useChat: (sel =>
sel({
legacy: { nodes: conv } })) as UseChatLike,
}))
await flush()
const card = queryAll(m.container, '.lc-card').find(c => text(c).includes(DICT_EN['files.title']))
assert.ok(card !== undefined)
const name = query(query(card, '.lc-fa-row'), '.lc-fa-file')
assert.equal(name.getAttribute('title'), DICT_EN['files.preview'])
await click(name)
assert.deepEqual(previewed, ['dsh-resource://file/session/sv-preview/src/a.ts'])
assert.deepEqual(opened, [])
await m.unmount()
})
test('a Sidebar that refuses the address falls back to the system opener', async () => {
const opened: string[] = []
const ctx = new TestClientCtx({
services: {
sessions: { list: { getSnapshot: () => ({ byId: { 'sv-preview-no': { cwd: '/repo' } } }) } },
connection: {
isLoopback: true,
rpc: {
call: (_channel: string, endpoint: string, payload: unknown) => {
if (endpoint === 'session/canOpenWorkspacePath') return Promise.resolve({ ok: true, value: true })
opened.push((payload as { args: { request: { path: string } } }).args.request.path)
return Promise.resolve({ ok: true, value: { opened: true } })
},
},
},
// No preview type claims the address (or no surface is mounted): the throw
// is the face's own wiring-error report, and the card must fall back.
sidebarRight: { openResource: () => { throw new Error('no registered tab type claims it') } },
},
})
const conv = [
{ kind: 'tool', seq: 3, call: { name: 'read', argsRaw: JSON.stringify({ file_path: '/repo/src/a.ts' }) } },
]
const View = makeView(ctx)
const m = await mount(h(View, {
sessionId: 'sv-preview-no',
useProjection: projectionsFor(fileTimeline()),
useChat: (sel =>
sel({
legacy: { nodes: conv } })) as UseChatLike,
}))
await flush()
const card = queryAll(m.container, '.lc-card').find(c => text(c).includes(DICT_EN['files.title']))
assert.ok(card !== undefined)
await click(query(query(card, '.lc-fa-row'), '.lc-fa-file'))
assert.deepEqual(opened, ['/repo/src/a.ts'])
await m.unmount()
})
test('an unencodable path skips the preview and still opens on the system', async () => {
// Parsing resilience: a lone surrogate in a log path is unrepresentable in a
// resource address (the encoder throws), so the preview opener is never
// asked for one address and the system opener still takes the resolved path.
const opened: string[] = []
const previewed: string[] = []
const ctx = new TestClientCtx({
services: {
sessions: { list: { getSnapshot: () => ({ byId: { 'sv-preview-bad': { cwd: '/repo' } } }) } },
connection: {
isLoopback: true,
rpc: {
call: (_channel: string, endpoint: string, payload: unknown) => {
if (endpoint === 'session/canOpenWorkspacePath') return Promise.resolve({ ok: true, value: true })
opened.push((payload as { args: { request: { path: string } } }).args.request.path)
return Promise.resolve({ ok: true, value: { opened: true } })
},
},
},
sidebarRight: { openResource: (address: string) => { previewed.push(address) } },
},
})
const conv = [
{ kind: 'tool', seq: 3, call: { name: 'read', argsRaw: JSON.stringify({ file_path: '\uD800' }) } },
]
const View = makeView(ctx)
const m = await mount(h(View, {
sessionId: 'sv-preview-bad',
useProjection: projectionsFor(fileTimeline()),
useChat: (sel =>
sel({
legacy: { nodes: conv } })) as UseChatLike,
}))
await flush()
const card = queryAll(m.container, '.lc-card').find(c => text(c).includes(DICT_EN['files.title']))
assert.ok(card !== undefined)
await click(query(query(card, '.lc-fa-row'), '.lc-fa-file'))
assert.deepEqual(previewed, [])
assert.deepEqual(opened, ['/repo/\uD800'])
await m.unmount()
})
test('a capability probe that settles after unmount drops its answer', async () => {
let resolveProbe!: (value: unknown) => void
const ctx = new TestClientCtx({
services: {
connection: {
isLoopback: true,
rpc: { call: () => new Promise(resolve => { resolveProbe = resolve }) },
},
},
})
const View = makeView(ctx)
const m = await mount(h(View, {
sessionId: 'sv-open-late',
useProjection: projectionsFor(fileTimeline()),
}))
await m.unmount()
// The late "yes" arrives on a dead view: the stale answer is dropped whole.
resolveProbe({ ok: true, value: true })
await new Promise(resolve => setTimeout(resolve, 0))
})
test('a ctx without service access degrades the card wiring, not the view', async () => {
const View = makeView({} as unknown as TestClientCtx)
// No session id: the view stays on its loading screen; the workspace and
// opener wiring (both read ctx before the early return) resolve to nothing.
const m = await mount(h(View, { sessionId: '' }))
assert.ok(text(m.container).includes(DICT_EN.loading))
await m.unmount()
})
})