Merge branch 'master' into codex/web-queue-actions
This commit is contained in:
@@ -1,6 +1,15 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
// Session row actions in the assembled fixture app: Rename opens the
|
// The built-bundle boot smoke: the ONE assembled-jsdom test that loads the
|
||||||
// browser-owned dialog and settles the title from the unary response.
|
// real `packages/client/*/lib/client.js` artifacts through AppWebEntry's
|
||||||
|
// ModuleLoader path (fetchBundle/executeBundle) and proves the boot graph
|
||||||
|
// assembles — staged activation across the immediately tier and the inject
|
||||||
|
// layers, per-plugin CSS injection, and a rendered journey reaching chat
|
||||||
|
// content from the keyless FixtureApiClient transport.
|
||||||
|
//
|
||||||
|
// Behavior assertions do NOT belong here: component and wiring behavior is
|
||||||
|
// pinned by the per-package suites (SlotTestRuntime benches over src), which
|
||||||
|
// this smoke's plugin set cannot influence — bundling, module-table
|
||||||
|
// resolution, and boot layering are the only failure modes left to it.
|
||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||||
@@ -16,9 +25,18 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
|
|||||||
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-slash', dir: 'ui-slash', url: '/plugins/ui-slash.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation'] },
|
{
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-command', dir: 'ui-command', url: '/plugins/ui-command.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash', '@deepseek-ai/dsh-client-ui-conversation'] },
|
id: '@deepseek-ai/dsh-client-ui-workspace',
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-workspace', dir: 'ui-workspace', url: '/plugins/ui-workspace.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation', '@deepseek-ai/dsh-client-ui-sidebar'] },
|
dir: 'ui-workspace',
|
||||||
|
url: '/plugins/ui-workspace.js',
|
||||||
|
rev: 'fx',
|
||||||
|
inject: [
|
||||||
|
'@deepseek-ai/dsh-client-runtime',
|
||||||
|
'@deepseek-ai/dsh-client-ui-conversation',
|
||||||
|
'@deepseek-ai/dsh-client-ui-sidebar',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
|
||||||
]
|
]
|
||||||
|
|
||||||
const bundles = new Map(PLUGINS.map(plugin => [
|
const bundles = new Map(PLUGINS.map(plugin => [
|
||||||
@@ -42,16 +60,11 @@ let unmount: (() => void) | undefined
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
history.replaceState(null, '', '/?fixture')
|
|
||||||
document.title = 'DeepSeek Harness'
|
document.title = 'DeepSeek Harness'
|
||||||
const root = document.createElement('div')
|
|
||||||
root.id = 'root'
|
|
||||||
document.body.appendChild(root)
|
|
||||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
||||||
setTimeout(() => { callback(0) }, 0) as unknown as number)
|
setTimeout(() => { callback(0) }, 0) as unknown as number)
|
||||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
|
vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
|
||||||
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
|
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -60,7 +73,6 @@ afterEach(() => {
|
|||||||
cleanup()
|
cleanup()
|
||||||
delete win.__DSH_BOOT__
|
delete win.__DSH_BOOT__
|
||||||
delete win.__ModuleLoader__
|
delete win.__ModuleLoader__
|
||||||
delete (globalThis as Record<string, unknown>).__fxTiming
|
|
||||||
document.body.innerHTML = ''
|
document.body.innerHTML = ''
|
||||||
document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
|
document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
|
||||||
document.title = ''
|
document.title = ''
|
||||||
@@ -68,9 +80,12 @@ afterEach(() => {
|
|||||||
vi.unstubAllGlobals()
|
vi.unstubAllGlobals()
|
||||||
})
|
})
|
||||||
|
|
||||||
async function bootApp(): Promise<void> {
|
it('boots the built plugin graph and renders a fixture session end to end', async () => {
|
||||||
const root = document.querySelector<HTMLElement>('#root')
|
history.replaceState(null, '', '/?fixture')
|
||||||
if (root === null) throw new Error('snapshot root missing')
|
const root = document.createElement('div')
|
||||||
|
root.id = 'root'
|
||||||
|
document.body.appendChild(root)
|
||||||
|
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
|
||||||
act(() => {
|
act(() => {
|
||||||
const entry = new AppWebEntry(root, {
|
const entry = new AppWebEntry(root, {
|
||||||
fetchBundle: (url) => {
|
fetchBundle: (url) => {
|
||||||
@@ -82,49 +97,21 @@ async function bootApp(): Promise<void> {
|
|||||||
void entry.run()
|
void entry.run()
|
||||||
unmount = () => { entry.dispose() }
|
unmount = () => { entry.dispose() }
|
||||||
})
|
})
|
||||||
await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The session row element carrying the given visible label. */
|
// The sidebar renders from the boot graph: every inject layer activated.
|
||||||
function rowOf(label: string): HTMLElement {
|
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
|
||||||
const tree = screen.getByRole('tree', { name: 'Sessions' })
|
await within(tree).findByText('4 sessions')
|
||||||
const row = within(tree).getByText(label).closest<HTMLElement>('[role="treeitem"]')
|
|
||||||
if (row === null) throw new Error(`session row "${label}" missing`)
|
|
||||||
return row
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Open the row's ... menu and click one action. The anchor button is
|
// Opening a session reaches chat content through the fixture transport.
|
||||||
* CSS-hover-revealed (real stylesheets are injected in this assembled run,
|
fireEvent.click(await within(tree).findByText('Fixture 历史会话'))
|
||||||
* so role queries filter it as hidden); target it directly. */
|
await waitFor(() => {
|
||||||
function pickRowAction(label: string, action: string): void {
|
expect(document.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||||
const anchor = rowOf(label).querySelector<HTMLElement>(`button[aria-label="Session actions for ${label}"]`)
|
}, { timeout: 10_000 })
|
||||||
if (anchor === null) throw new Error(`row menu anchor for "${label}" missing`)
|
|
||||||
fireEvent.click(anchor)
|
|
||||||
fireEvent.click(screen.getByRole('menuitem', { name: action, hidden: true }))
|
|
||||||
}
|
|
||||||
|
|
||||||
it('renames a session through the row-menu dialog; the row settles from the unary response', async () => {
|
// Every bundle injected its plugin-owned style tag (the loader's CSS path).
|
||||||
await bootApp()
|
const styleOwners = [...document.head.querySelectorAll('style[data-plugin]')]
|
||||||
const sourceLabel = 'Fixture 历史会话'
|
.map(style => style.getAttribute('data-plugin'))
|
||||||
await screen.findByText(sourceLabel)
|
for (const plugin of ['@deepseek-ai/dsh-client-ui-layout', '@deepseek-ai/dsh-client-ui-sidebar', '@deepseek-ai/dsh-client-ui-conversation']) {
|
||||||
|
expect(styleOwners).toContain(plugin)
|
||||||
pickRowAction(sourceLabel, 'Rename')
|
}
|
||||||
const input = await screen.findByLabelText('Session name')
|
|
||||||
expect((input as HTMLInputElement).value).toBe(sourceLabel)
|
|
||||||
fireEvent.change(input, { target: { value: ' 分叉 实验记录 ' } })
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Rename' }))
|
|
||||||
|
|
||||||
// Host-side normalization collapses whitespace; the dialog closes on
|
|
||||||
// acceptance and the row re-labels without any push-frame wait.
|
|
||||||
const renamed = '分叉 实验记录'
|
|
||||||
await waitFor(() => { expect(screen.queryByLabelText('Session name')).toBeNull() })
|
|
||||||
await screen.findByText(renamed)
|
|
||||||
const tree = screen.getByRole('tree', { name: 'Sessions' })
|
|
||||||
expect(within(tree).queryByText(sourceLabel)).toBeNull()
|
|
||||||
|
|
||||||
const rows = [...tree.querySelectorAll('[role="treeitem"]')].map(row => ({
|
|
||||||
label: row.textContent?.replace(/\s+/g, ' ').trim() ?? '',
|
|
||||||
}))
|
|
||||||
await expect(`${JSON.stringify(rows, null, 2)}\n`)
|
|
||||||
.toMatchFileSnapshot('./snapshots/session-actions/rename-rows.json')
|
|
||||||
})
|
})
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
// @vitest-environment jsdom
|
|
||||||
// Code Mode fixture snapshot over the BUILT client graph (the workspace-flow
|
|
||||||
// idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport).
|
|
||||||
// Opens the fixture history session and pins the run_code turn's rendering:
|
|
||||||
// the code-variant parent row titled by the model-authored description, its
|
|
||||||
// three always-visible nested sub-rows (bash through the sample registration,
|
|
||||||
// read through GenericToolCard, the failing read wearing the error state),
|
|
||||||
// the expanded program body, inert bash / file-link sub-row gestures,
|
|
||||||
// details-panel resolution of a sub-callId, and the Trajectory tab's sub-call
|
|
||||||
// cells and timing overview.
|
|
||||||
import { readFileSync } from 'node:fs'
|
|
||||||
import { join } from 'node:path'
|
|
||||||
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
|
||||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
|
||||||
import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
|
|
||||||
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
|
|
||||||
|
|
||||||
const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
|
|
||||||
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
|
||||||
{
|
|
||||||
id: '@deepseek-ai/dsh-client-ui-workspace',
|
|
||||||
dir: 'ui-workspace',
|
|
||||||
url: '/plugins/ui-workspace.js',
|
|
||||||
rev: 'fx',
|
|
||||||
inject: [
|
|
||||||
'@deepseek-ai/dsh-client-runtime',
|
|
||||||
'@deepseek-ai/dsh-client-ui-conversation',
|
|
||||||
'@deepseek-ai/dsh-client-ui-sidebar',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
|
|
||||||
]
|
|
||||||
|
|
||||||
const bundles = new Map(PLUGINS.map(plugin => [
|
|
||||||
plugin.url,
|
|
||||||
readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
|
|
||||||
]))
|
|
||||||
|
|
||||||
interface FixtureWindow extends Window {
|
|
||||||
__DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
|
|
||||||
__ModuleLoader__?: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
class ResizeObserverStub {
|
|
||||||
observe(): void {}
|
|
||||||
disconnect(): void {}
|
|
||||||
unobserve(): void {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const win = window as FixtureWindow
|
|
||||||
let unmount: (() => void) | undefined
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
localStorage.clear()
|
|
||||||
document.title = 'DeepSeek Harness'
|
|
||||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
|
||||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
|
||||||
setTimeout(() => { callback(0) }, 0) as unknown as number)
|
|
||||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
act(() => { unmount?.() })
|
|
||||||
unmount = undefined
|
|
||||||
cleanup()
|
|
||||||
delete win.__DSH_BOOT__
|
|
||||||
delete win.__ModuleLoader__
|
|
||||||
delete (globalThis as Record<string, unknown>).__fxTiming
|
|
||||||
document.body.innerHTML = ''
|
|
||||||
document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
|
|
||||||
document.title = ''
|
|
||||||
history.replaceState(null, '', '/')
|
|
||||||
vi.unstubAllGlobals()
|
|
||||||
})
|
|
||||||
|
|
||||||
/** Boot the complete built client graph against the populated fixture branch. */
|
|
||||||
function boot(): void {
|
|
||||||
history.replaceState(null, '', '/?fixture')
|
|
||||||
const root = document.createElement('div')
|
|
||||||
root.id = 'root'
|
|
||||||
document.body.appendChild(root)
|
|
||||||
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
|
|
||||||
act(() => {
|
|
||||||
const entry = new AppWebEntry(root, {
|
|
||||||
fetchBundle: (url) => {
|
|
||||||
const code = bundles.get(url)
|
|
||||||
return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
|
|
||||||
},
|
|
||||||
executeBundle: (code) => { (0, eval)(code) },
|
|
||||||
})
|
|
||||||
void entry.run()
|
|
||||||
unmount = () => { entry.dispose() }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Collapse decorative whitespace while preserving the text a user sees. */
|
|
||||||
function visibleText(element: Element): string {
|
|
||||||
return (element.textContent ?? '').replace(/\s+/g, ' ').trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Open the fixture history session (the alpha log carrying the run_code turn) and scroll to its tail. */
|
|
||||||
async function openFixtureSession(): Promise<void> {
|
|
||||||
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
|
|
||||||
const group = within(tree).getByText('4 sessions').closest<HTMLElement>('[role="treeitem"]')
|
|
||||||
if (group === null) throw new Error('fixture Workspace group missing')
|
|
||||||
if (group.getAttribute('aria-expanded') === 'false') {
|
|
||||||
fireEvent.click(within(group).getByText('fixture'))
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(within(tree).getByText('4 sessions').closest('[role="treeitem"]')?.getAttribute('aria-expanded')).toBe('true')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const session = await within(tree).findByText('Fixture 历史会话')
|
|
||||||
fireEvent.click(session)
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(document.querySelector('[data-variant="code"]')).not.toBeNull()
|
|
||||||
}, { timeout: 10_000 })
|
|
||||||
}
|
|
||||||
|
|
||||||
it('renders the fixture run_code turn: code parent row, nested sub-rows, error state', async () => {
|
|
||||||
boot()
|
|
||||||
await openFixtureSession()
|
|
||||||
|
|
||||||
const codeRoot = document.querySelector('[data-variant="code"]')
|
|
||||||
if (codeRoot === null) throw new Error('code-variant row missing')
|
|
||||||
const nest = codeRoot.closest('[class*="callRow"]')?.querySelector('[data-subcalls]')
|
|
||||||
if (nest === undefined || nest === null) throw new Error('sub-call nest missing under the code row')
|
|
||||||
|
|
||||||
expect({
|
|
||||||
parentRow: visibleText(codeRoot),
|
|
||||||
// The three sub-rows in dispatch order: bash rides the sample plugin's
|
|
||||||
// keyed registration (the same one a native top-level bash row uses),
|
|
||||||
// both reads ride GenericToolCard.
|
|
||||||
bashSample: nest.querySelector('[data-sample="bash-global"]') !== null,
|
|
||||||
subRows: [...nest.querySelectorAll(':scope > *')].map(visibleText),
|
|
||||||
errorSubRow: nest.querySelector('[data-state="error"]') !== null,
|
|
||||||
}).toMatchInlineSnapshot(`
|
|
||||||
{
|
|
||||||
"bashSample": true,
|
|
||||||
"errorSubRow": true,
|
|
||||||
"parentRow": "CodeRead the notes files and summarize",
|
|
||||||
"subRows": [
|
|
||||||
"BashList notes",
|
|
||||||
"Readnotes/demo.txt",
|
|
||||||
"Readnotes/missing.txt",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
`)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('expands the code row into the program body; sub-row clicks do not open details', async () => {
|
|
||||||
boot()
|
|
||||||
await openFixtureSession()
|
|
||||||
|
|
||||||
// Expand: the leading control reveals the program (shiki-tokenized: the
|
|
||||||
// text splits into styled spans inside one <pre class="shiki"> tree).
|
|
||||||
const codeRoot = document.querySelector('[data-variant="code"]')
|
|
||||||
if (codeRoot === null) throw new Error('code-variant row missing')
|
|
||||||
const toggle = codeRoot.querySelector('button[aria-expanded]')
|
|
||||||
if (toggle === null) throw new Error('code row expand control missing')
|
|
||||||
fireEvent.click(toggle)
|
|
||||||
await waitFor(() => {
|
|
||||||
// Scope to THIS row: the markdown fixture turn also renders shiki pres.
|
|
||||||
const pre = codeRoot.querySelector('pre.shiki')
|
|
||||||
if (pre === null || !(pre.textContent ?? '').includes('const listing = await tools.bash')) {
|
|
||||||
throw new Error('highlighted program body missing under the code row')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Tool rows no longer drive the details panel: bash is inert, file paths
|
|
||||||
// are host-open links (fixture openPath is a no-op success).
|
|
||||||
const nest = document.querySelector('[data-subcalls]')
|
|
||||||
if (nest === null) throw new Error('sub-call nest missing')
|
|
||||||
const bashRow = nest.querySelector('[data-sample="bash-global"]')
|
|
||||||
if (bashRow === null) throw new Error('bash sample sub-row missing')
|
|
||||||
const fileLink = nest.querySelector('button')
|
|
||||||
if (fileLink === null) throw new Error('file-path summary link missing on a read sub-row')
|
|
||||||
const frame = document.querySelector('[data-details-collapsed]')
|
|
||||||
if (frame === null) throw new Error('app frame missing')
|
|
||||||
expect(frame.getAttribute('data-details-collapsed')).toBe('true')
|
|
||||||
fireEvent.click(bashRow)
|
|
||||||
expect(frame.getAttribute('data-details-collapsed')).toBe('true')
|
|
||||||
fireEvent.click(fileLink)
|
|
||||||
expect(frame.getAttribute('data-details-collapsed')).toBe('true')
|
|
||||||
expect({
|
|
||||||
fileLink: visibleText(fileLink),
|
|
||||||
detailsCollapsed: frame.getAttribute('data-details-collapsed'),
|
|
||||||
}).toMatchInlineSnapshot(`
|
|
||||||
{
|
|
||||||
"detailsCollapsed": "true",
|
|
||||||
"fileLink": "notes/demo.txt",
|
|
||||||
}
|
|
||||||
`)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('trajectory surfaces run_code sub-calls in the ledger and timing overview', async () => {
|
|
||||||
boot()
|
|
||||||
await openFixtureSession()
|
|
||||||
|
|
||||||
// Switch to the trajectory tab (same slot ring the chat view registers in).
|
|
||||||
fireEvent.click(await screen.findByRole('tab', { name: 'Trajectory' }))
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(document.querySelector('[data-kind="subtool"]')).not.toBeNull()
|
|
||||||
}, { timeout: 10_000 })
|
|
||||||
const subCells = [...document.querySelectorAll('[data-kind="subtool"]')]
|
|
||||||
expect({
|
|
||||||
// Three Subtool cells nested under the run_code Tool cell in dispatch
|
|
||||||
// order, each paired with its result preview.
|
|
||||||
subCells: subCells.map(cell => visibleText(cell)),
|
|
||||||
}).toMatchInlineSnapshot(`
|
|
||||||
{
|
|
||||||
"subCells": [
|
|
||||||
"SUBTOOLbash{"command":"ls notes","description":"List notes"}→demo.txt new-demo.txt",
|
|
||||||
"SUBTOOLread{"path":"notes/demo.txt"}→hello fixture",
|
|
||||||
"SUBTOOLread{"path":"notes/missing.txt"}→error",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
`)
|
|
||||||
|
|
||||||
const timelineSubCalls = [...document.querySelectorAll('[data-timeline-span="subtool"]')]
|
|
||||||
expect({
|
|
||||||
count: timelineSubCalls.length,
|
|
||||||
measured: timelineSubCalls.map(span => span.getAttribute('title')?.endsWith(' · 800 ms')),
|
|
||||||
}).toMatchInlineSnapshot(`
|
|
||||||
{
|
|
||||||
"count": 3,
|
|
||||||
"measured": [
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
],
|
|
||||||
}
|
|
||||||
`)
|
|
||||||
})
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
// @vitest-environment jsdom
|
|
||||||
import { readFileSync } from 'node:fs'
|
|
||||||
import { join } from 'node:path'
|
|
||||||
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
|
||||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
|
||||||
import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
|
|
||||||
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
|
|
||||||
|
|
||||||
const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
|
|
||||||
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-settings', dir: 'ui-settings', url: '/plugins/ui-settings.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-sidebar'] },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-settings-general', dir: 'ui-settings-general', url: '/plugins/ui-settings-general.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings', '@deepseek-ai/dsh-client-locale'] },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-models', dir: 'ui-models', url: '/plugins/ui-models.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-slash', dir: 'ui-slash', url: '/plugins/ui-slash.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation'] },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-command', dir: 'ui-command', url: '/plugins/ui-command.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash', '@deepseek-ai/dsh-client-ui-conversation'] },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-model', dir: 'ui-model', url: '/plugins/ui-model.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-command'] },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-workspace', dir: 'ui-workspace', url: '/plugins/ui-workspace.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation', '@deepseek-ai/dsh-client-ui-sidebar'] },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
|
|
||||||
]
|
|
||||||
|
|
||||||
const bundles = new Map(PLUGINS.map(plugin => [
|
|
||||||
plugin.url,
|
|
||||||
readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
|
|
||||||
]))
|
|
||||||
|
|
||||||
interface FixtureTiming {
|
|
||||||
appendTitle(id: string, title: string): void
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FixtureWindow extends Window {
|
|
||||||
__DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
|
|
||||||
__ModuleLoader__?: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
class ResizeObserverStub {
|
|
||||||
observe(): void {}
|
|
||||||
disconnect(): void {}
|
|
||||||
unobserve(): void {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const win = window as FixtureWindow
|
|
||||||
let unmount: (() => void) | undefined
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
localStorage.clear()
|
|
||||||
history.replaceState(null, '', '/?fixture')
|
|
||||||
document.title = 'DeepSeek Harness'
|
|
||||||
const root = document.createElement('div')
|
|
||||||
root.id = 'root'
|
|
||||||
document.body.appendChild(root)
|
|
||||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
|
||||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
|
||||||
setTimeout(() => { callback(0) }, 0) as unknown as number)
|
|
||||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
|
|
||||||
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
act(() => { unmount?.() })
|
|
||||||
unmount = undefined
|
|
||||||
cleanup()
|
|
||||||
delete win.__DSH_BOOT__
|
|
||||||
delete win.__ModuleLoader__
|
|
||||||
delete (globalThis as Record<string, unknown>).__fxTiming
|
|
||||||
document.body.innerHTML = ''
|
|
||||||
document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
|
|
||||||
document.title = ''
|
|
||||||
history.replaceState(null, '', '/')
|
|
||||||
vi.unstubAllGlobals()
|
|
||||||
})
|
|
||||||
|
|
||||||
/** Read only the stable, user-facing title surfaces from the assembled app. */
|
|
||||||
function titleSurfaces(label: string): { sidebar: string; breadcrumb: string; documentTitle: string } {
|
|
||||||
const tree = screen.getByRole('tree', { name: 'Sessions' })
|
|
||||||
const sidebar = within(tree).getByText(label).textContent ?? ''
|
|
||||||
const breadcrumb = within(screen.getByRole('navigation', { name: 'Session hierarchy' }))
|
|
||||||
.getByRole('button', { name: label }).textContent ?? ''
|
|
||||||
return { sidebar, breadcrumb, documentTitle: document.title }
|
|
||||||
}
|
|
||||||
|
|
||||||
it('projects titles and routes the next turn through the selected model in the built fixture app', async () => {
|
|
||||||
const root = document.querySelector<HTMLElement>('#root')
|
|
||||||
if (root === null) throw new Error('snapshot root missing')
|
|
||||||
act(() => {
|
|
||||||
const entry = new AppWebEntry(root, {
|
|
||||||
fetchBundle: (url) => {
|
|
||||||
const code = bundles.get(url)
|
|
||||||
return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
|
|
||||||
},
|
|
||||||
executeBundle: (code) => { (0, eval)(code) },
|
|
||||||
})
|
|
||||||
void entry.run()
|
|
||||||
unmount = () => { entry.dispose() }
|
|
||||||
})
|
|
||||||
|
|
||||||
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
|
|
||||||
// The fixture Intent selects the workspace, so the current-group effect
|
|
||||||
// already expanded it; clicking the header would now collapse (the twist
|
|
||||||
// stays live since intent stopped forcing expansion).
|
|
||||||
await within(tree).findByText('4 sessions')
|
|
||||||
|
|
||||||
const initialLabel = 'Fixture 历史会话'
|
|
||||||
const initialRowLabel = await screen.findByText(initialLabel)
|
|
||||||
const initialRow = initialRowLabel.closest<HTMLElement>('[role="treeitem"]')
|
|
||||||
if (initialRow === null) throw new Error('fixture session row missing')
|
|
||||||
fireEvent.click(initialRow)
|
|
||||||
await waitFor(() => { expect(document.title).toBe(`${initialLabel} — DeepSeek Harness`) })
|
|
||||||
const initial = titleSurfaces(initialLabel)
|
|
||||||
|
|
||||||
const revisedLabel = 'Fixture 修订标题'
|
|
||||||
const timing = (globalThis as Record<string, unknown>).__fxTiming as FixtureTiming
|
|
||||||
act(() => { timing.appendTitle('fx-alpha', revisedLabel) })
|
|
||||||
await waitFor(() => { expect(document.title).toBe(`${revisedLabel} — DeepSeek Harness`) })
|
|
||||||
const revised = titleSurfaces(revisedLabel)
|
|
||||||
|
|
||||||
// fx-alpha carries the fixture's resident answerable approval, so the
|
|
||||||
// approval panel has taken over the composer (the real takeover behavior);
|
|
||||||
// answer it to restore the composer chrome before asserting the model seat.
|
|
||||||
fireEvent.click(await screen.findByRole('button', { name: '允许一次' }))
|
|
||||||
const modelTrigger = await screen.findByRole('button', {
|
|
||||||
name: '选择模型,当前 DeepSeek-V4-Flash,推理等级 High',
|
|
||||||
})
|
|
||||||
fireEvent.click(modelTrigger)
|
|
||||||
fireEvent.click(screen.getByRole('menuitem', { name: /Model/ }))
|
|
||||||
fireEvent.click(screen.getByRole('menuitemradio', { name: /GPT-5/ }))
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(modelTrigger.getAttribute('aria-label')).toBe('选择模型,当前 GPT-5,推理等级 Medium')
|
|
||||||
})
|
|
||||||
fireEvent.click(modelTrigger)
|
|
||||||
fireEvent.click(screen.getByRole('menuitem', { name: /Effort/ }))
|
|
||||||
fireEvent.click(screen.getByRole('menuitemradio', { name: 'Max' }))
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(modelTrigger.getAttribute('aria-label')).toBe('选择模型,当前 GPT-5,推理等级 Max')
|
|
||||||
})
|
|
||||||
|
|
||||||
// fx-alpha starts in the running state. Selecting above is intentionally
|
|
||||||
// allowed for the next turn; stop the fixture's resident run before sending
|
|
||||||
// the route-report prompt.
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Stop generating' }))
|
|
||||||
const composer = await screen.findByPlaceholderText('给智能体发消息')
|
|
||||||
fireEvent.change(composer, { target: { value: 'report model' } })
|
|
||||||
fireEvent.keyDown(composer, { key: 'Enter' })
|
|
||||||
await screen.findByText('当前模型:openai/gpt-5 · 推理等级:max', {}, { timeout: 10_000 })
|
|
||||||
|
|
||||||
await expect(`${JSON.stringify({ initial, revised }, null, 2)}\n`)
|
|
||||||
.toMatchFileSnapshot('./snapshots/session-title.json')
|
|
||||||
})
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
// @vitest-environment jsdom
|
|
||||||
// Assembled keyless snapshot of the slash/input/session convergence under the
|
|
||||||
// agent-parity model: the New Session view state locks the composer until a
|
|
||||||
// Workspace is picked (connectWorkspace materializes the full Session+Agent),
|
|
||||||
// the '/' menu renders the session's skill and wire command catalogs
|
|
||||||
// (sessions are always agent-backed — no draft/materialized split), a skill
|
|
||||||
// pick inserts its reference, a leadingInput command claims,
|
|
||||||
// submits over the wire, and notices its result, and the SAME composer
|
|
||||||
// textarea then carries the first plain send, whose ACCEPTANCE (not attempt)
|
|
||||||
// flips blank and surfaces the session in lists. This is the user-visible
|
|
||||||
// acceptance anchor — package mocks do not substitute for the assembled
|
|
||||||
// application transcript.
|
|
||||||
import { readFileSync } from 'node:fs'
|
|
||||||
import { join } from 'node:path'
|
|
||||||
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
|
||||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
|
||||||
import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
|
|
||||||
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
|
|
||||||
|
|
||||||
const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
|
|
||||||
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-slash', dir: 'ui-slash', url: '/plugins/ui-slash.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout', '@deepseek-ai/dsh-client-ui-slash'] },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-command', dir: 'ui-command', url: '/plugins/ui-command.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash', '@deepseek-ai/dsh-client-ui-conversation'] },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-skill', dir: 'ui-skill', url: '/plugins/ui-skill.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash'] },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-subagent', dir: 'ui-subagent', url: '/plugins/ui-subagent.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash'] },
|
|
||||||
{
|
|
||||||
id: '@deepseek-ai/dsh-client-ui-workspace',
|
|
||||||
dir: 'ui-workspace',
|
|
||||||
url: '/plugins/ui-workspace.js',
|
|
||||||
rev: 'fx',
|
|
||||||
inject: [
|
|
||||||
'@deepseek-ai/dsh-client-runtime',
|
|
||||||
'@deepseek-ai/dsh-client-ui-conversation',
|
|
||||||
'@deepseek-ai/dsh-client-ui-sidebar',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const bundles = new Map(PLUGINS.map(plugin => [
|
|
||||||
plugin.url,
|
|
||||||
readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
|
|
||||||
]))
|
|
||||||
|
|
||||||
interface FixtureWindow extends Window {
|
|
||||||
__DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
|
|
||||||
__ModuleLoader__?: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
class ResizeObserverStub {
|
|
||||||
observe(): void {}
|
|
||||||
disconnect(): void {}
|
|
||||||
unobserve(): void {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// jsdom has no scrollIntoView; the slash menu follows its highlighted option.
|
|
||||||
const scrollIntoView = vi.fn()
|
|
||||||
const win = window as FixtureWindow
|
|
||||||
let unmount: (() => void) | undefined
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
localStorage.clear()
|
|
||||||
document.title = 'DeepSeek Harness'
|
|
||||||
Element.prototype.scrollIntoView = scrollIntoView
|
|
||||||
scrollIntoView.mockClear()
|
|
||||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
|
||||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
|
||||||
setTimeout(() => { callback(0) }, 0) as unknown as number)
|
|
||||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
act(() => { unmount?.() })
|
|
||||||
unmount = undefined
|
|
||||||
cleanup()
|
|
||||||
delete win.__DSH_BOOT__
|
|
||||||
delete win.__ModuleLoader__
|
|
||||||
delete (globalThis as Record<string, unknown>).__fxTiming
|
|
||||||
document.body.innerHTML = ''
|
|
||||||
document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
|
|
||||||
document.title = ''
|
|
||||||
history.replaceState(null, '', '/')
|
|
||||||
Reflect.deleteProperty(Element.prototype, 'scrollIntoView')
|
|
||||||
vi.unstubAllGlobals()
|
|
||||||
})
|
|
||||||
|
|
||||||
/** Boot the complete built client graph against one keyless fixture branch. */
|
|
||||||
function boot(search: string): void {
|
|
||||||
history.replaceState(null, '', `/${search}`)
|
|
||||||
const root = document.createElement('div')
|
|
||||||
root.id = 'root'
|
|
||||||
document.body.appendChild(root)
|
|
||||||
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
|
|
||||||
act(() => {
|
|
||||||
const entry = new AppWebEntry(root, {
|
|
||||||
fetchBundle: (url) => {
|
|
||||||
const code = bundles.get(url)
|
|
||||||
return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
|
|
||||||
},
|
|
||||||
executeBundle: (code) => { (0, eval)(code) },
|
|
||||||
})
|
|
||||||
void entry.run()
|
|
||||||
unmount = () => { entry.dispose() }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Collapse decorative whitespace while preserving the text a user sees. */
|
|
||||||
function visibleText(element: Element): string {
|
|
||||||
return (element.textContent ?? '').replace(/\s+/g, ' ').trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Type into the machine-driven composer and let the change echo back. */
|
|
||||||
async function typeComposer(composer: HTMLTextAreaElement, value: string): Promise<void> {
|
|
||||||
fireEvent.change(composer, { target: { value } })
|
|
||||||
await waitFor(() => { expect(composer.value).toBe(value) })
|
|
||||||
}
|
|
||||||
|
|
||||||
it('locked view state, skill discovery, /echo claim chain, and blank-on-acceptance ride one resident composer', async () => {
|
|
||||||
boot('?fixture=empty')
|
|
||||||
|
|
||||||
// View state: no session entity — the composer renders locked; only the
|
|
||||||
// workspace picker is live.
|
|
||||||
const locked = await screen.findByPlaceholderText<HTMLTextAreaElement>(
|
|
||||||
'Choose a workspace to start', {}, { timeout: 10_000 },
|
|
||||||
)
|
|
||||||
expect(locked.disabled).toBe(true)
|
|
||||||
|
|
||||||
// Pick (create) a Workspace: connectWorkspace materializes the full
|
|
||||||
// Session+Agent and the provider swaps in the live blank-session hero.
|
|
||||||
fireEvent.click(screen.getAllByRole('button', { name: 'Choose workspace' })
|
|
||||||
.find(el => el.getAttribute('aria-haspopup') === 'menu')!)
|
|
||||||
fireEvent.click(await screen.findByRole('menuitem', { name: 'Create a new workspace' }))
|
|
||||||
const dialog = await screen.findByRole('dialog', { name: 'Create a new workspace' })
|
|
||||||
fireEvent.change(within(dialog).getByRole('textbox', { name: 'New workspace name' }), {
|
|
||||||
target: { value: 'nova' },
|
|
||||||
})
|
|
||||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Create workspace' }))
|
|
||||||
|
|
||||||
const composer = await screen.findByPlaceholderText<HTMLTextAreaElement>(
|
|
||||||
'Describe what you want to build', {}, { timeout: 10_000 },
|
|
||||||
)
|
|
||||||
expect(composer.disabled).toBe(false)
|
|
||||||
|
|
||||||
// The built skill plugin prewarms the fixture's session-addressed catalog;
|
|
||||||
// this pins client rendering and picking, while the real-host browser lane
|
|
||||||
// owns policy filtering. Picking inserts the literal reference into the
|
|
||||||
// resident composer.
|
|
||||||
await typeComposer(composer, '/fixture')
|
|
||||||
const skillMenu = await screen.findByRole('listbox', { name: 'Trigger suggestions' })
|
|
||||||
const skillOption = await within(skillMenu).findByRole('option', { name: /fixture-demo/ })
|
|
||||||
const skillMenuText = visibleText(skillMenu)
|
|
||||||
fireEvent.mouseDown(skillOption)
|
|
||||||
await waitFor(() => { expect(composer.value).toBe('/fixture-demo ') })
|
|
||||||
const pickedSkill = composer.value
|
|
||||||
await typeComposer(composer, '')
|
|
||||||
|
|
||||||
// '/' opens the menu with the session's wire command catalog (the session
|
|
||||||
// is agent-backed from birth — the catalog is the single-address list).
|
|
||||||
await typeComposer(composer, '/')
|
|
||||||
const menu = await screen.findByRole('listbox', { name: 'Trigger suggestions' })
|
|
||||||
await waitFor(() => { expect(visibleText(menu)).toContain('echo') })
|
|
||||||
const menuText = visibleText(menu)
|
|
||||||
|
|
||||||
// Pick /echo (leadingInput): the claim token lands in the same textarea.
|
|
||||||
fireEvent.mouseDown(screen.getByRole('option', { name: /echo/ }))
|
|
||||||
await waitFor(() => { expect(composer.value).toBe('/echo ') })
|
|
||||||
|
|
||||||
// Type args and submit: the claim executes over the wire and notices its
|
|
||||||
// result; the token is consumed and the draft returns to plain text.
|
|
||||||
await typeComposer(composer, '/echo hello parser')
|
|
||||||
fireEvent.keyDown(composer, { key: 'Enter' })
|
|
||||||
await screen.findByText('hello parser', {}, { timeout: 10_000 })
|
|
||||||
await waitFor(() => { expect(composer.value).toBe('') })
|
|
||||||
|
|
||||||
// Slash execution does not flip blank: the selected row remains New Session.
|
|
||||||
const tree = screen.getByRole('tree', { name: 'Sessions' })
|
|
||||||
expect(within(tree).getByText('1 session')).toBeDefined()
|
|
||||||
expect(within(tree).getByText('New Session')).toBeDefined()
|
|
||||||
|
|
||||||
// First plain send through the SAME textarea: acceptance logs the user
|
|
||||||
// message and converts the existing sidebar row out of blank.
|
|
||||||
const before = composer
|
|
||||||
await typeComposer(composer, 'build me a parser')
|
|
||||||
fireEvent.keyDown(composer, { key: 'Enter' })
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.queryByText("Let's start building")).toBeNull()
|
|
||||||
}, { timeout: 10_000 })
|
|
||||||
await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })
|
|
||||||
const after = document.querySelector('textarea')
|
|
||||||
|
|
||||||
expect({
|
|
||||||
menuHadEcho: menuText.includes('echo'),
|
|
||||||
menuHadCompact: menuText.includes('compact'),
|
|
||||||
composerSurvivedConversion: after === before,
|
|
||||||
skillMenuHadFixtureDemo: skillMenuText.includes('fixture-demo'),
|
|
||||||
skillPickInserted: pickedSkill,
|
|
||||||
sessionListed: visibleText(within(tree).getByText('1 session').closest('[role="treeitem"]')!),
|
|
||||||
}).toMatchInlineSnapshot(`
|
|
||||||
{
|
|
||||||
"composerSurvivedConversion": true,
|
|
||||||
"menuHadCompact": true,
|
|
||||||
"menuHadEcho": true,
|
|
||||||
"sessionListed": "nova1 session",
|
|
||||||
"skillMenuHadFixtureDemo": true,
|
|
||||||
"skillPickInserted": "/fixture-demo ",
|
|
||||||
}
|
|
||||||
`)
|
|
||||||
})
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"label": "fixture4 sessions"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "New Sessionnow"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "分叉 实验记录now"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "fixture2min"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"initial": {
|
|
||||||
"sidebar": "Fixture 历史会话",
|
|
||||||
"breadcrumb": "Fixture 历史会话",
|
|
||||||
"documentTitle": "Fixture 历史会话 — DeepSeek Harness"
|
|
||||||
},
|
|
||||||
"revised": {
|
|
||||||
"sidebar": "Fixture 修订标题",
|
|
||||||
"breadcrumb": "Fixture 修订标题",
|
|
||||||
"documentTitle": "Fixture 修订标题 — DeepSeek Harness"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,306 +0,0 @@
|
|||||||
// @vitest-environment jsdom
|
|
||||||
// Terminal card snapshot over the BUILT client graph (the code-mode-fixture
|
|
||||||
// idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport).
|
|
||||||
// Opens the fixture history session and pins the `card: 'terminal'` render
|
|
||||||
// intent at both of its conversation render sites, for both chat-row shapes:
|
|
||||||
// turn 60's `fx-bash` on the render-site fallback row (expand-gated body) and
|
|
||||||
// turn 65's `bash` on the keyed BashRow registration (resident body). Turn 65
|
|
||||||
// carries what turn 60's two clean prompt rows cannot — SGR runs resolved to
|
|
||||||
// --dsw-* tokens, output past the chat cap, a nested cwd, and a non-zero exit
|
|
||||||
// pill; turn 60 carries the multi-line command's per-line prompt rows.
|
|
||||||
//
|
|
||||||
// The details panel's Output section is NOT covered here: tool rows stopped
|
|
||||||
// being details-panel click targets, and nothing else in the assembled
|
|
||||||
// application opens that panel, so the surface cannot be driven end to end.
|
|
||||||
// Its terminal rendering stays pinned in ui-conversation's
|
|
||||||
// tests/terminal-card.spec.tsx, which mounts DetailsPanel with a selection
|
|
||||||
// directly.
|
|
||||||
import { readFileSync } from 'node:fs'
|
|
||||||
import { join } from 'node:path'
|
|
||||||
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
|
||||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
|
||||||
import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
|
|
||||||
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
|
|
||||||
|
|
||||||
const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
|
|
||||||
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
|
||||||
{
|
|
||||||
id: '@deepseek-ai/dsh-client-ui-workspace',
|
|
||||||
dir: 'ui-workspace',
|
|
||||||
url: '/plugins/ui-workspace.js',
|
|
||||||
rev: 'fx',
|
|
||||||
inject: [
|
|
||||||
'@deepseek-ai/dsh-client-runtime',
|
|
||||||
'@deepseek-ai/dsh-client-ui-conversation',
|
|
||||||
'@deepseek-ai/dsh-client-ui-sidebar',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const bundles = new Map(PLUGINS.map(plugin => [
|
|
||||||
plugin.url,
|
|
||||||
readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
|
|
||||||
]))
|
|
||||||
|
|
||||||
interface FixtureWindow extends Window {
|
|
||||||
__DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
|
|
||||||
__ModuleLoader__?: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
class ResizeObserverStub {
|
|
||||||
observe(): void {}
|
|
||||||
disconnect(): void {}
|
|
||||||
unobserve(): void {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const win = window as FixtureWindow
|
|
||||||
let unmount: (() => void) | undefined
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
localStorage.clear()
|
|
||||||
document.title = 'DeepSeek Harness'
|
|
||||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
|
||||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
|
||||||
setTimeout(() => { callback(0) }, 0) as unknown as number)
|
|
||||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
act(() => { unmount?.() })
|
|
||||||
unmount = undefined
|
|
||||||
cleanup()
|
|
||||||
delete win.__DSH_BOOT__
|
|
||||||
delete win.__ModuleLoader__
|
|
||||||
document.body.innerHTML = ''
|
|
||||||
document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
|
|
||||||
document.title = ''
|
|
||||||
history.replaceState(null, '', '/')
|
|
||||||
vi.unstubAllGlobals()
|
|
||||||
})
|
|
||||||
|
|
||||||
/** Boot the complete built client graph against the populated fixture branch. */
|
|
||||||
function boot(): void {
|
|
||||||
history.replaceState(null, '', '/?fixture')
|
|
||||||
const root = document.createElement('div')
|
|
||||||
root.id = 'root'
|
|
||||||
document.body.appendChild(root)
|
|
||||||
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
|
|
||||||
act(() => {
|
|
||||||
const entry = new AppWebEntry(root, {
|
|
||||||
fetchBundle: (url) => {
|
|
||||||
const code = bundles.get(url)
|
|
||||||
return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
|
|
||||||
},
|
|
||||||
executeBundle: (code) => { (0, eval)(code) },
|
|
||||||
})
|
|
||||||
void entry.run()
|
|
||||||
unmount = () => { entry.dispose() }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Collapse decorative whitespace while preserving the text a user sees. */
|
|
||||||
function visibleText(element: Element): string {
|
|
||||||
return (element.textContent ?? '').replace(/\s+/g, ' ').trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read one terminal card's user-visible state. Output lines keep their interior
|
|
||||||
* whitespace: holding column alignment is what this card exists for, so
|
|
||||||
* collapsing runs of spaces would hide the behavior under test.
|
|
||||||
*/
|
|
||||||
function readCard(card: Element) {
|
|
||||||
const status = card.querySelector('[class*="_status_"]')
|
|
||||||
const expander = card.querySelector('button[aria-expanded]')
|
|
||||||
return {
|
|
||||||
// One entry per command line: a multi-line command is one row per line.
|
|
||||||
prompt: [...card.querySelectorAll('[class*="_promptLine_"]')].map(row =>
|
|
||||||
`${row.querySelector('[class*="_cwd_"]')?.textContent ?? ''} ${row.querySelector('[class*="_command_"]')?.textContent ?? ''}`),
|
|
||||||
// Dots per prompt row: exactly one, on the first row — the exit status the
|
|
||||||
// view carries is the whole call's, so a dot per line would assert a
|
|
||||||
// per-line outcome bash does not report.
|
|
||||||
dotsPerPromptRow: [...card.querySelectorAll('[class*="_promptLine_"]')].map(row =>
|
|
||||||
row.querySelectorAll('[data-state]').length),
|
|
||||||
status: status === null ? null : status.textContent,
|
|
||||||
copy: card.querySelector('[class*="_copyButton_"]')?.textContent ?? null,
|
|
||||||
lines: [...card.querySelectorAll('[class*="_line_"]')].map(line => line.textContent),
|
|
||||||
expander: expander === null ? null : {
|
|
||||||
label: expander.getAttribute('aria-label'),
|
|
||||||
text: expander.textContent,
|
|
||||||
expanded: expander.getAttribute('aria-expanded'),
|
|
||||||
},
|
|
||||||
// The run-state dot at the head of the prompt line, by its StateDot state.
|
|
||||||
runState: card.querySelector('[class*="_runState_"][data-state]')?.getAttribute('data-state') ?? null,
|
|
||||||
runStateLabel: card.querySelector('[class*="_runStateLabel_"]')?.textContent ?? null,
|
|
||||||
// Every color the ANSI parser emits resolves through a --dsw-* token, so
|
|
||||||
// the card follows the theme instead of painting literal terminal rgb.
|
|
||||||
// Scoped to the output lines: the run-state dot is an inline-styled span
|
|
||||||
// too, and its geometry is not an ANSI-resolved color.
|
|
||||||
colors: [...new Set([...card.querySelectorAll('[class*="_line_"] span[style]')]
|
|
||||||
.map(span => span.getAttribute('style')))],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Open the fixture history session (the alpha log carrying both bash turns) and wait for its tail. */
|
|
||||||
async function openFixtureSession(): Promise<void> {
|
|
||||||
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
|
|
||||||
// Anchor on the expandable Workspace group row: the title and the blank
|
|
||||||
// session row can both read "fixture".
|
|
||||||
const group = (await within(tree).findAllByText('fixture'))
|
|
||||||
.map(el => el.closest<HTMLElement>('[role="treeitem"]'))
|
|
||||||
.find(el => el?.getAttribute('aria-expanded') !== null)
|
|
||||||
if (group === null || group === undefined) throw new Error('fixture Workspace group missing')
|
|
||||||
if (group.getAttribute('aria-expanded') === 'false') {
|
|
||||||
fireEvent.click(within(group).getByText('fixture'))
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(group.getAttribute('aria-expanded')).toBe('true')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
fireEvent.click(await within(tree).findByText('Fixture 历史会话'))
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(document.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
|
||||||
}, { timeout: 10_000 })
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The keyed BashRow of fixture turn 65 (the one carrying the ANSI sample). */
|
|
||||||
function keyedBashRow(): Element {
|
|
||||||
// Anchored on the BashRow wrapper (summary row + resident card), not on the
|
|
||||||
// summary row itself: the summary now shows the presenter's description (the
|
|
||||||
// contract's above-card text), so the command lives only in the card below it.
|
|
||||||
const row = [...document.querySelectorAll('[data-sample="bash-global"]')]
|
|
||||||
.map(node => node.parentElement)
|
|
||||||
.find((node): node is HTMLElement => node !== null && visibleText(node).includes('pnpm run check'))
|
|
||||||
if (row === undefined) throw new Error('keyed bash row for turn 65 missing')
|
|
||||||
return row
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The turn-60 fallback row, which reaches the terminal card through GenericToolCard/ToolRow. */
|
|
||||||
function fallbackBashRow(): Element {
|
|
||||||
const row = document.querySelector('[data-tool="fx-bash"]')
|
|
||||||
if (row === null) throw new Error('fx-bash fallback row missing')
|
|
||||||
return row
|
|
||||||
}
|
|
||||||
|
|
||||||
it('renders the keyed bash row with a resident terminal card', async () => {
|
|
||||||
boot()
|
|
||||||
await openFixtureSession()
|
|
||||||
|
|
||||||
const row = keyedBashRow()
|
|
||||||
const card = row.parentElement?.querySelector('[data-terminal]')
|
|
||||||
if (card === null || card === undefined) throw new Error('keyed bash row has no resident terminal card')
|
|
||||||
// The prompt shortens the nested cwd to its last segment, the exit pill comes
|
|
||||||
// from the sample's authored exit status (its body deliberately carries no
|
|
||||||
// `[exit code: N]` marker, since the real presenter consumes that one), ANSI
|
|
||||||
// runs land on theme tokens, and the chat cap (8) collapses the middle into a
|
|
||||||
// head/tail split with an expander between them.
|
|
||||||
expect(readCard(card)).toMatchInlineSnapshot(`
|
|
||||||
{
|
|
||||||
"colors": [
|
|
||||||
"font-weight: 700;",
|
|
||||||
"color: var(--dsw-alias-state-success-primary);",
|
|
||||||
"color: var(--dsw-alias-state-error-primary);",
|
|
||||||
],
|
|
||||||
"copy": "复制",
|
|
||||||
"dotsPerPromptRow": [
|
|
||||||
1,
|
|
||||||
],
|
|
||||||
"expander": {
|
|
||||||
"expanded": "false",
|
|
||||||
"label": "展开其余 13 行输出",
|
|
||||||
"text": "… 其余 13 行",
|
|
||||||
},
|
|
||||||
"lines": [
|
|
||||||
"Running 4 checks",
|
|
||||||
"✓ typecheck 1.82s",
|
|
||||||
"✓ lint 0.94s",
|
|
||||||
"✓ duplication 2.10s",
|
|
||||||
"StateDot.tsx 100% 100% 100% -",
|
|
||||||
"markdown/Markdown.tsx 100% 100% 100% -",
|
|
||||||
"",
|
|
||||||
"1 of 4 checks failed",
|
|
||||||
],
|
|
||||||
"prompt": [
|
|
||||||
"nested pnpm run check",
|
|
||||||
],
|
|
||||||
"runState": "error",
|
|
||||||
"runStateLabel": "失败",
|
|
||||||
"status": "退出码 1",
|
|
||||||
}
|
|
||||||
`)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('the fallback row reaches the same card through its expand control', async () => {
|
|
||||||
boot()
|
|
||||||
await openFixtureSession()
|
|
||||||
|
|
||||||
const row = fallbackBashRow()
|
|
||||||
expect(row.querySelector('[data-terminal]')).toBeNull()
|
|
||||||
const toggle = row.querySelector('button[aria-expanded]')
|
|
||||||
if (toggle === null) throw new Error('fallback row expand control missing')
|
|
||||||
fireEvent.click(toggle)
|
|
||||||
const card = await waitFor(() => {
|
|
||||||
const found = row.querySelector('[data-terminal]')
|
|
||||||
if (found === null) throw new Error('terminal card missing after expanding the fallback row')
|
|
||||||
return found
|
|
||||||
})
|
|
||||||
// Three plain lines under the cap: no ANSI spans, no exit pill, no expander.
|
|
||||||
expect(readCard(card)).toMatchInlineSnapshot(`
|
|
||||||
{
|
|
||||||
"colors": [],
|
|
||||||
"copy": "复制",
|
|
||||||
"dotsPerPromptRow": [
|
|
||||||
1,
|
|
||||||
0,
|
|
||||||
],
|
|
||||||
"expander": null,
|
|
||||||
"lines": [
|
|
||||||
"total 2",
|
|
||||||
"drwxr-xr-x fixture",
|
|
||||||
"-rw-r--r-- demo.txt",
|
|
||||||
],
|
|
||||||
"prompt": [
|
|
||||||
"fixture ls -la",
|
|
||||||
"$ echo done",
|
|
||||||
],
|
|
||||||
"runState": "done",
|
|
||||||
"runStateLabel": "已完成",
|
|
||||||
"status": null,
|
|
||||||
}
|
|
||||||
`)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('the chat card expands the collapsed middle in place, without opening the details panel', async () => {
|
|
||||||
boot()
|
|
||||||
await openFixtureSession()
|
|
||||||
|
|
||||||
const card = keyedBashRow().parentElement?.querySelector('[data-terminal]')
|
|
||||||
if (card === null || card === undefined) throw new Error('resident terminal card missing')
|
|
||||||
const expander = card.querySelector('button[aria-expanded]')
|
|
||||||
if (expander === null) throw new Error('height-cap expander missing')
|
|
||||||
const capped = card.querySelectorAll('[class*="_line_"]').length
|
|
||||||
|
|
||||||
fireEvent.click(expander)
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(card.querySelector('button[aria-expanded]')?.getAttribute('aria-expanded')).toBe('true')
|
|
||||||
})
|
|
||||||
expect({
|
|
||||||
cappedLines: capped,
|
|
||||||
expandedLines: card.querySelectorAll('[class*="_line_"]').length,
|
|
||||||
expanderLabel: card.querySelector('button[aria-expanded]')?.getAttribute('aria-label'),
|
|
||||||
// The card sits outside the summary row's click target, so toggling it
|
|
||||||
// left the details panel shut.
|
|
||||||
detailsOpen: screen.queryByText('Input') !== null,
|
|
||||||
}).toMatchInlineSnapshot(`
|
|
||||||
{
|
|
||||||
"cappedLines": 8,
|
|
||||||
"detailsOpen": false,
|
|
||||||
"expandedLines": 21,
|
|
||||||
"expanderLabel": "收起输出",
|
|
||||||
}
|
|
||||||
`)
|
|
||||||
})
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
// @vitest-environment jsdom
|
|
||||||
// Todo display snapshot over the BUILT client graph (the code-mode-fixture
|
|
||||||
// idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport).
|
|
||||||
// Opens the fixture history session and pins the todo_write turn's two
|
|
||||||
// surfaces: the dedicated TodoRow in the chat flow (keyed toolview, summary
|
|
||||||
// derived from the call args) and the TodoPanel plan strip riding the
|
|
||||||
// 'conversation.input.dock' slot (fed by the host `todos` projection via
|
|
||||||
// useProjection, seeded by the tail history page), including the collapse
|
|
||||||
// interaction and the next-turn clearance of the standing plan.
|
|
||||||
import { readFileSync } from 'node:fs'
|
|
||||||
import { join } from 'node:path'
|
|
||||||
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
|
||||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
|
||||||
import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
|
|
||||||
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
|
|
||||||
|
|
||||||
const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
|
|
||||||
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
|
||||||
{
|
|
||||||
id: '@deepseek-ai/dsh-client-ui-workspace',
|
|
||||||
dir: 'ui-workspace',
|
|
||||||
url: '/plugins/ui-workspace.js',
|
|
||||||
rev: 'fx',
|
|
||||||
inject: [
|
|
||||||
'@deepseek-ai/dsh-client-runtime',
|
|
||||||
'@deepseek-ai/dsh-client-ui-conversation',
|
|
||||||
'@deepseek-ai/dsh-client-ui-sidebar',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const bundles = new Map(PLUGINS.map(plugin => [
|
|
||||||
plugin.url,
|
|
||||||
readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
|
|
||||||
]))
|
|
||||||
|
|
||||||
interface FixtureWindow extends Window {
|
|
||||||
__DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
|
|
||||||
__ModuleLoader__?: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
class ResizeObserverStub {
|
|
||||||
observe(): void {}
|
|
||||||
disconnect(): void {}
|
|
||||||
unobserve(): void {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const win = window as FixtureWindow
|
|
||||||
let unmount: (() => void) | undefined
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
localStorage.clear()
|
|
||||||
document.title = 'DeepSeek Harness'
|
|
||||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
|
||||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
|
||||||
setTimeout(() => { callback(0) }, 0) as unknown as number)
|
|
||||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
act(() => { unmount?.() })
|
|
||||||
unmount = undefined
|
|
||||||
cleanup()
|
|
||||||
delete win.__DSH_BOOT__
|
|
||||||
delete win.__ModuleLoader__
|
|
||||||
delete (globalThis as Record<string, unknown>).__fxTiming
|
|
||||||
document.body.innerHTML = ''
|
|
||||||
document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
|
|
||||||
document.title = ''
|
|
||||||
history.replaceState(null, '', '/')
|
|
||||||
vi.unstubAllGlobals()
|
|
||||||
})
|
|
||||||
|
|
||||||
/** Boot the complete built client graph against the populated fixture branch. */
|
|
||||||
function boot(): void {
|
|
||||||
history.replaceState(null, '', '/?fixture')
|
|
||||||
const root = document.createElement('div')
|
|
||||||
root.id = 'root'
|
|
||||||
document.body.appendChild(root)
|
|
||||||
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
|
|
||||||
act(() => {
|
|
||||||
const entry = new AppWebEntry(root, {
|
|
||||||
fetchBundle: (url) => {
|
|
||||||
const code = bundles.get(url)
|
|
||||||
return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
|
|
||||||
},
|
|
||||||
executeBundle: (code) => { (0, eval)(code) },
|
|
||||||
})
|
|
||||||
void entry.run()
|
|
||||||
unmount = () => { entry.dispose() }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Collapse decorative whitespace while preserving the text a user sees. */
|
|
||||||
function visibleText(element: Element): string {
|
|
||||||
return (element.textContent ?? '').replace(/\s+/g, ' ').trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Open the fixture history session (the alpha log carrying the todo_write turn) and wait for its tail. */
|
|
||||||
async function openFixtureSession(): Promise<void> {
|
|
||||||
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
|
|
||||||
// Anchor on the expandable Workspace group row: the title and the blank
|
|
||||||
// session row can both read "fixture", and the session-count meta shifts
|
|
||||||
// when a blank session joins the group.
|
|
||||||
const group = (await within(tree).findAllByText('fixture'))
|
|
||||||
.map(el => el.closest<HTMLElement>('[role="treeitem"]'))
|
|
||||||
.find(el => el?.getAttribute('aria-expanded') !== null)
|
|
||||||
if (group === null || group === undefined) throw new Error('fixture Workspace group missing')
|
|
||||||
if (group.getAttribute('aria-expanded') === 'false') {
|
|
||||||
fireEvent.click(within(group).getByText('fixture'))
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(group.getAttribute('aria-expanded')).toBe('true')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const session = await within(tree).findByText('Fixture 历史会话')
|
|
||||||
fireEvent.click(session)
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(document.querySelector('[data-sample="todo-row"]')).not.toBeNull()
|
|
||||||
}, { timeout: 10_000 })
|
|
||||||
}
|
|
||||||
|
|
||||||
it('renders the todo_write turn: dedicated tool row + the dock plan strip', async () => {
|
|
||||||
boot()
|
|
||||||
await openFixtureSession()
|
|
||||||
|
|
||||||
const row = document.querySelector('[data-sample="todo-row"]')
|
|
||||||
if (row === null) throw new Error('todo row missing')
|
|
||||||
const panel = document.querySelector('[data-testid="todo-panel"]')
|
|
||||||
if (panel === null) throw new Error('todo panel missing from the input dock')
|
|
||||||
|
|
||||||
// Header spans are adjacent inline nodes; textContent joins "To-dos" +
|
|
||||||
// "1/3…" with no space (visual gap is CSS gap: 10px, not a text node).
|
|
||||||
expect({
|
|
||||||
row: visibleText(row),
|
|
||||||
rowState: row.getAttribute('data-state'),
|
|
||||||
panelHeader: visibleText(panel.querySelector('button') ?? panel),
|
|
||||||
panelItems: [...panel.querySelectorAll('li')].map(item => ({
|
|
||||||
status: item.getAttribute('data-status'),
|
|
||||||
text: visibleText(item),
|
|
||||||
})),
|
|
||||||
}).toMatchInlineSnapshot(`
|
|
||||||
{
|
|
||||||
"panelHeader": "To-dos1/3 tasks · 1 in progress",
|
|
||||||
"panelItems": [],
|
|
||||||
"row": "更新任务清单1/3 已完成 · 实现 fixture 样本",
|
|
||||||
"rowState": "ok",
|
|
||||||
}
|
|
||||||
`)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('expands the default-collapsed plan strip and restores its folded state', async () => {
|
|
||||||
boot()
|
|
||||||
await openFixtureSession()
|
|
||||||
|
|
||||||
const panel = document.querySelector('[data-testid="todo-panel"]')
|
|
||||||
if (panel === null) throw new Error('todo panel missing from the input dock')
|
|
||||||
const header = panel.querySelector('button')
|
|
||||||
if (header === null) throw new Error('todo panel header missing')
|
|
||||||
|
|
||||||
expect({
|
|
||||||
collapsedHeader: visibleText(header),
|
|
||||||
expanded: header.getAttribute('aria-expanded'),
|
|
||||||
listGone: panel.querySelector('ul') === null,
|
|
||||||
}).toMatchInlineSnapshot(`
|
|
||||||
{
|
|
||||||
"collapsedHeader": "To-dos1/3 tasks · 1 in progress",
|
|
||||||
"expanded": "false",
|
|
||||||
"listGone": true,
|
|
||||||
}
|
|
||||||
`)
|
|
||||||
|
|
||||||
fireEvent.click(header)
|
|
||||||
expect(panel.querySelectorAll('li')).toHaveLength(3)
|
|
||||||
expect(header.getAttribute('aria-expanded')).toBe('true')
|
|
||||||
|
|
||||||
fireEvent.click(header)
|
|
||||||
expect(panel.querySelector('ul')).toBeNull()
|
|
||||||
expect(header.getAttribute('aria-expanded')).toBe('false')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('hides the plan strip when the next turn starts', async () => {
|
|
||||||
boot()
|
|
||||||
await openFixtureSession()
|
|
||||||
expect(document.querySelector('[data-testid="todo-panel"]')).not.toBeNull()
|
|
||||||
|
|
||||||
const composer = await screen.findByPlaceholderText('给智能体发消息', {}, { timeout: 10_000 })
|
|
||||||
fireEvent.change(composer, { target: { value: '下一轮清空计划' } })
|
|
||||||
fireEvent.keyDown(composer, { key: 'Enter' })
|
|
||||||
|
|
||||||
await screen.findByText('下一轮清空计划', { exact: true }, { timeout: 10_000 })
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(document.querySelector('[data-testid="todo-panel"]')).toBeNull()
|
|
||||||
}, { timeout: 10_000 })
|
|
||||||
|
|
||||||
expect({
|
|
||||||
promptVisible: screen.getByText('下一轮清空计划', { exact: true }).textContent,
|
|
||||||
panelGone: document.querySelector('[data-testid="todo-panel"]') === null,
|
|
||||||
// Historical todo_write row stays in the flow; only the dock strip clears.
|
|
||||||
rowStillPresent: document.querySelector('[data-sample="todo-row"]') !== null,
|
|
||||||
}).toMatchInlineSnapshot(`
|
|
||||||
{
|
|
||||||
"panelGone": true,
|
|
||||||
"promptVisible": "下一轮清空计划",
|
|
||||||
"rowStillPresent": true,
|
|
||||||
}
|
|
||||||
`)
|
|
||||||
})
|
|
||||||
@@ -1,397 +0,0 @@
|
|||||||
// @vitest-environment jsdom
|
|
||||||
// Assembled keyless snapshots of the New Session flow under the agent-parity
|
|
||||||
// model: startup auto-connects the recent Workspace's blank session when one
|
|
||||||
// exists; without any Workspace the composer is locked in the pure view
|
|
||||||
// state until one is chosen. Picking one materializes the full Session+Agent
|
|
||||||
// (reuse-or-create of the workspace's blank session), the first ACCEPTED
|
|
||||||
// prompt flips blank and surfaces the session in lists, and failures leave
|
|
||||||
// no client-side transaction state: a failed attach keeps the view state
|
|
||||||
// locked, a rejected prompt keeps the session blank with the draft restored.
|
|
||||||
import { readFileSync } from 'node:fs'
|
|
||||||
import { join } from 'node:path'
|
|
||||||
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
|
||||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
|
||||||
import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
|
|
||||||
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
|
|
||||||
|
|
||||||
const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
|
|
||||||
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-settings', dir: 'ui-settings', url: '/plugins/ui-settings.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-sidebar'] },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-settings-general', dir: 'ui-settings-general', url: '/plugins/ui-settings-general.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings', '@deepseek-ai/dsh-client-locale'] },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-models', dir: 'ui-models', url: '/plugins/ui-models.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] },
|
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
|
||||||
{
|
|
||||||
id: '@deepseek-ai/dsh-client-ui-workspace',
|
|
||||||
dir: 'ui-workspace',
|
|
||||||
url: '/plugins/ui-workspace.js',
|
|
||||||
rev: 'fx',
|
|
||||||
inject: [
|
|
||||||
'@deepseek-ai/dsh-client-runtime',
|
|
||||||
'@deepseek-ai/dsh-client-ui-conversation',
|
|
||||||
'@deepseek-ai/dsh-client-ui-sidebar',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
|
|
||||||
// Dual-face host package: its browser half fills the directory-flow holes
|
|
||||||
// (the same composition row apps/cli mounts for the node-side backend).
|
|
||||||
{
|
|
||||||
id: '@deepseek-ai/dsh-host-directory-picker-browse',
|
|
||||||
dir: '../host/directory-picker-browse',
|
|
||||||
url: '/plugins/directory-picker-browse.js',
|
|
||||||
rev: 'fx',
|
|
||||||
inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-workspace', '@deepseek-ai/dsh-client-locale'],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const bundles = new Map(PLUGINS.map(plugin => [
|
|
||||||
plugin.url,
|
|
||||||
readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
|
|
||||||
]))
|
|
||||||
|
|
||||||
interface FixtureWindow extends Window {
|
|
||||||
__DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
|
|
||||||
__ModuleLoader__?: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
class ResizeObserverStub {
|
|
||||||
observe(): void {}
|
|
||||||
disconnect(): void {}
|
|
||||||
unobserve(): void {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const win = window as FixtureWindow
|
|
||||||
let unmount: (() => void) | undefined
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
localStorage.clear()
|
|
||||||
document.title = 'DeepSeek Harness'
|
|
||||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
|
||||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
|
||||||
setTimeout(() => { callback(0) }, 0) as unknown as number)
|
|
||||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
act(() => { unmount?.() })
|
|
||||||
unmount = undefined
|
|
||||||
cleanup()
|
|
||||||
delete win.__DSH_BOOT__
|
|
||||||
delete win.__ModuleLoader__
|
|
||||||
delete (globalThis as Record<string, unknown>).__fxTiming
|
|
||||||
document.body.innerHTML = ''
|
|
||||||
document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
|
|
||||||
document.title = ''
|
|
||||||
history.replaceState(null, '', '/')
|
|
||||||
vi.unstubAllGlobals()
|
|
||||||
})
|
|
||||||
|
|
||||||
/** Boot the complete built client graph against one keyless fixture branch. */
|
|
||||||
function boot(search: string): void {
|
|
||||||
history.replaceState(null, '', `/${search}`)
|
|
||||||
const root = document.createElement('div')
|
|
||||||
root.id = 'root'
|
|
||||||
document.body.appendChild(root)
|
|
||||||
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
|
|
||||||
act(() => {
|
|
||||||
const entry = new AppWebEntry(root, {
|
|
||||||
fetchBundle: (url) => {
|
|
||||||
const code = bundles.get(url)
|
|
||||||
return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
|
|
||||||
},
|
|
||||||
executeBundle: (code) => { (0, eval)(code) },
|
|
||||||
})
|
|
||||||
void entry.run()
|
|
||||||
unmount = () => { entry.dispose() }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Collapse decorative whitespace while preserving the text a user sees. */
|
|
||||||
function visibleText(element: Element): string {
|
|
||||||
return (element.textContent ?? '').replace(/\s+/g, ' ').trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Identify the interactive Workspace chip (view state or blank-session hero) by its menu contract. */
|
|
||||||
function workspaceChip(): HTMLElement {
|
|
||||||
const chip = screen.getAllByRole('button', { name: 'Choose workspace' })
|
|
||||||
.find(element => element.getAttribute('aria-haspopup') === 'menu')
|
|
||||||
if (chip === undefined) throw new Error('Workspace chip missing')
|
|
||||||
return chip
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The locked view-state composer (no session yet). */
|
|
||||||
async function findLockedComposer(): Promise<HTMLTextAreaElement> {
|
|
||||||
return await screen.findByPlaceholderText(
|
|
||||||
'Choose a workspace to start', {}, { timeout: 10_000 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The live blank-session hero composer (session materialized). */
|
|
||||||
async function findHeroComposer(): Promise<HTMLTextAreaElement> {
|
|
||||||
return await screen.findByPlaceholderText(
|
|
||||||
'Describe what you want to build', {}, { timeout: 10_000 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Edit the machine-owned controlled input and assert the same-tick echo. */
|
|
||||||
function setComposerText(composer: HTMLElement, value: string): void {
|
|
||||||
fireEvent.change(composer, { target: { value } })
|
|
||||||
expect((composer as HTMLTextAreaElement).value).toBe(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Drive the picker's create flow: chip → Create a new workspace → name dialog. */
|
|
||||||
async function createWorkspaceViaPicker(name: string): Promise<void> {
|
|
||||||
fireEvent.click(workspaceChip())
|
|
||||||
fireEvent.click(await screen.findByRole('menuitem', { name: 'Create a new workspace' }))
|
|
||||||
const dialog = await screen.findByRole('dialog', { name: 'Create a new workspace' })
|
|
||||||
fireEvent.change(within(dialog).getByRole('textbox', { name: 'New workspace name' }), {
|
|
||||||
target: { value: name },
|
|
||||||
})
|
|
||||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Create workspace' }))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Pick an existing Workspace row from the chip menu. */
|
|
||||||
async function pickWorkspace(title: string): Promise<void> {
|
|
||||||
fireEvent.click(workspaceChip())
|
|
||||||
fireEvent.click(await screen.findByRole('menuitem', { name: title }))
|
|
||||||
}
|
|
||||||
|
|
||||||
it('locks the composer in the New Session view state until a Workspace is chosen', async () => {
|
|
||||||
boot('?fixture=empty')
|
|
||||||
|
|
||||||
const composer = await findLockedComposer()
|
|
||||||
const tree = screen.getByRole('tree', { name: 'Sessions' })
|
|
||||||
|
|
||||||
expect({
|
|
||||||
headline: visibleText(screen.getByText("Let's start building")),
|
|
||||||
chip: visibleText(workspaceChip()),
|
|
||||||
composerDisabled: composer.disabled,
|
|
||||||
sendDisabled: screen.getByRole<HTMLButtonElement>('button', { name: 'Send message' }).disabled,
|
|
||||||
sidebar: visibleText(tree),
|
|
||||||
}).toMatchInlineSnapshot(`
|
|
||||||
{
|
|
||||||
"chip": "Choose workspace",
|
|
||||||
"composerDisabled": true,
|
|
||||||
"headline": "Let's start building",
|
|
||||||
"sendDisabled": true,
|
|
||||||
"sidebar": "No sessions yet",
|
|
||||||
}
|
|
||||||
`)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('adopts a directory through the composed in-app browse flow and lands in its blank session', async () => {
|
|
||||||
boot('?fixture=empty')
|
|
||||||
|
|
||||||
await findLockedComposer()
|
|
||||||
fireEvent.click(workspaceChip())
|
|
||||||
const menu = await screen.findByRole('menu')
|
|
||||||
// The composed flow package occupies the directory-flow hole, so the
|
|
||||||
// picking affordance is present (no advertised-kind read exists anymore).
|
|
||||||
expect(within(menu).getAllByRole('menuitem').map(item => visibleText(item)))
|
|
||||||
.toEqual(['Open local folder…', 'Create a new workspace'])
|
|
||||||
fireEvent.click(within(menu).getByRole('menuitem', { name: 'Open local folder…' }))
|
|
||||||
// The browse occupant renders the Select Workspace Directory dialog at the
|
|
||||||
// fixture home; select Documents, advance into project, and adopt it.
|
|
||||||
const dialog = await screen.findByRole('dialog', { name: '选择工作区目录' }, { timeout: 10_000 })
|
|
||||||
// Row targeting goes through the visible label text: listitem accessible-name
|
|
||||||
// computation differs across dom-accessibility-api environments, while the
|
|
||||||
// row's name span is stable (clicks bubble to the row button).
|
|
||||||
fireEvent.click(await within(dialog).findByText('Documents', {}, { timeout: 10_000 }))
|
|
||||||
fireEvent.click(await within(dialog).findByText('project', {}, { timeout: 10_000 }))
|
|
||||||
// Open disables while the selection's child listing is in flight; wait for
|
|
||||||
// the enabled state or the click lands on a dead button on slow runners.
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(within(dialog).getByRole<HTMLButtonElement>('button', { name: '打开' }).disabled).toBe(false)
|
|
||||||
}, { timeout: 10_000 })
|
|
||||||
fireEvent.click(within(dialog).getByRole('button', { name: '打开' }))
|
|
||||||
await findHeroComposer()
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(visibleText(screen.getByRole('tree', { name: 'Sessions' }))).toContain('project')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('selects the recent Workspace and opens its blank Session on first load', async () => {
|
|
||||||
boot('?fixture')
|
|
||||||
|
|
||||||
const composer = await findHeroComposer()
|
|
||||||
const tree = screen.getByRole('tree', { name: 'Sessions' })
|
|
||||||
await waitFor(() => { expect(within(tree).getByText('4 sessions')).toBeDefined() }, { timeout: 10_000 })
|
|
||||||
|
|
||||||
expect({
|
|
||||||
chip: visibleText(workspaceChip()),
|
|
||||||
composerDisabled: composer.disabled,
|
|
||||||
blankRow: within(tree).getByText('New Session').textContent,
|
|
||||||
}).toMatchInlineSnapshot(`
|
|
||||||
{
|
|
||||||
"blankRow": "New Session",
|
|
||||||
"chip": "fixture",
|
|
||||||
"composerDisabled": false,
|
|
||||||
}
|
|
||||||
`)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('creating a Workspace materializes and lists its selected blank Session', async () => {
|
|
||||||
boot('?fixture=empty')
|
|
||||||
|
|
||||||
await findLockedComposer()
|
|
||||||
await createWorkspaceViaPicker('nova')
|
|
||||||
|
|
||||||
// The pick connected the workspace: full Session+Agent exists, composer live.
|
|
||||||
const composer = await findHeroComposer()
|
|
||||||
const tree = screen.getByRole('tree', { name: 'Sessions' })
|
|
||||||
await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() })
|
|
||||||
expect(within(tree).getByText('New Session')).toBeDefined()
|
|
||||||
const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
|
|
||||||
if (group === null) throw new Error('created Workspace projection missing')
|
|
||||||
|
|
||||||
expect({
|
|
||||||
composerDisabled: composer.disabled,
|
|
||||||
chip: visibleText(workspaceChip()),
|
|
||||||
workspace: visibleText(group),
|
|
||||||
}).toMatchInlineSnapshot(`
|
|
||||||
{
|
|
||||||
"chip": "nova",
|
|
||||||
"composerDisabled": false,
|
|
||||||
"workspace": "nova1 session",
|
|
||||||
}
|
|
||||||
`)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('New Session reuses the Workspace blank session and converts the single visible row', async () => {
|
|
||||||
boot('?fixture=empty')
|
|
||||||
|
|
||||||
await findLockedComposer()
|
|
||||||
await createWorkspaceViaPicker('nova')
|
|
||||||
await findHeroComposer()
|
|
||||||
const tree = screen.getByRole('tree', { name: 'Sessions' })
|
|
||||||
await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })
|
|
||||||
|
|
||||||
// New Session resolves through the recent Workspace and reuses its blank
|
|
||||||
// session in place: no locked interlude, no second entity.
|
|
||||||
const newSessionButton = screen.getAllByRole('button', { name: 'New session' })
|
|
||||||
.find(button => visibleText(button) === 'New Session')
|
|
||||||
if (newSessionButton === undefined) throw new Error('New Session button missing')
|
|
||||||
fireEvent.click(newSessionButton)
|
|
||||||
const composer = await findHeroComposer()
|
|
||||||
await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })
|
|
||||||
|
|
||||||
setComposerText(composer, 'first light')
|
|
||||||
fireEvent.keyDown(composer, { key: 'Enter' })
|
|
||||||
|
|
||||||
// Conversion: the accepted prompt flips blank without adding a second row.
|
|
||||||
await screen.findByText('first light', { exact: true }, { timeout: 10_000 })
|
|
||||||
await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })
|
|
||||||
const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
|
|
||||||
if (group === null) throw new Error('converted Session projection missing')
|
|
||||||
|
|
||||||
expect({
|
|
||||||
workspace: visibleText(group),
|
|
||||||
promptVisible: screen.getByText('first light', { exact: true }).textContent,
|
|
||||||
}).toMatchInlineSnapshot(`
|
|
||||||
{
|
|
||||||
"promptVisible": "first light",
|
|
||||||
"workspace": "nova1 session",
|
|
||||||
}
|
|
||||||
`)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('a failed Workspace attach recovers by reusing the published blank session', async () => {
|
|
||||||
boot('?fixture&fixtureAttach=fail')
|
|
||||||
|
|
||||||
// The rejected startup connect surfaces the locked view state first: the
|
|
||||||
// failure leaves no client-side transaction state to unwind.
|
|
||||||
await findLockedComposer()
|
|
||||||
|
|
||||||
// The host published the session before rejecting attachment (blank, with
|
|
||||||
// the workspace cwd), so the next connect — retry or manual pick — reuses
|
|
||||||
// it instead of minting a duplicate, and the hero opens on it.
|
|
||||||
await pickWorkspace('fixture')
|
|
||||||
const composer = await findHeroComposer()
|
|
||||||
const tree = screen.getByRole('tree', { name: 'Sessions' })
|
|
||||||
const group = within(tree).getByText('3 sessions').closest('[role="treeitem"]')
|
|
||||||
if (group === null) throw new Error('fixture Workspace projection missing')
|
|
||||||
|
|
||||||
expect({
|
|
||||||
headline: visibleText(screen.getByText("Let's start building")),
|
|
||||||
composerDisabled: composer.disabled,
|
|
||||||
chip: visibleText(workspaceChip()),
|
|
||||||
workspace: visibleText(group),
|
|
||||||
}).toMatchInlineSnapshot(`
|
|
||||||
{
|
|
||||||
"chip": "fixture",
|
|
||||||
"composerDisabled": false,
|
|
||||||
"headline": "Let's start building",
|
|
||||||
"workspace": "fixture3 sessions",
|
|
||||||
}
|
|
||||||
`)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('a rejected first prompt keeps the session blank and the draft in the machine', async () => {
|
|
||||||
boot('?fixture=empty&fixturePrompt=reject')
|
|
||||||
|
|
||||||
await findLockedComposer()
|
|
||||||
await createWorkspaceViaPicker('nova')
|
|
||||||
const composer = await findHeroComposer()
|
|
||||||
|
|
||||||
setComposerText(composer, 'do not lose this')
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Send message' }))
|
|
||||||
|
|
||||||
const alert = await screen.findByRole('alert', {}, { timeout: 10_000 })
|
|
||||||
// Failure restore rides the machine (no pendingPrompt transaction): the
|
|
||||||
// draft returns to the same resident textarea one render later. The
|
|
||||||
// attempt flips the composer out of the hero (engaging = retry chrome),
|
|
||||||
// but acceptance never happened: the session row stays New Session.
|
|
||||||
const retained = await screen.findByDisplayValue('do not lose this')
|
|
||||||
const tree = screen.getByRole('tree', { name: 'Sessions' })
|
|
||||||
const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
|
|
||||||
if (group === null) throw new Error('rejected-send Workspace projection missing')
|
|
||||||
|
|
||||||
expect({
|
|
||||||
error: visibleText(alert),
|
|
||||||
prompt: (retained as HTMLTextAreaElement).value,
|
|
||||||
blankRow: within(tree).getByText('New Session').textContent,
|
|
||||||
workspace: visibleText(group),
|
|
||||||
}).toMatchInlineSnapshot(`
|
|
||||||
{
|
|
||||||
"blankRow": "New Session",
|
|
||||||
"error": "fixture: prompt rejected before acceptance (agent-busy)",
|
|
||||||
"prompt": "do not lose this",
|
|
||||||
"workspace": "nova1 session",
|
|
||||||
}
|
|
||||||
`)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('switching Workspace before the first message carries the draft to the new blank session', async () => {
|
|
||||||
boot('?fixture')
|
|
||||||
|
|
||||||
const composer = await findHeroComposer()
|
|
||||||
setComposerText(composer, 'carry me')
|
|
||||||
|
|
||||||
// Switch = session switch: the new workspace's blank session takes over,
|
|
||||||
// the typed draft moves machine-to-machine, the old blank stays hidden.
|
|
||||||
await createWorkspaceViaPicker('nova')
|
|
||||||
await waitFor(() => { expect(visibleText(workspaceChip())).toBe('nova') }, { timeout: 10_000 })
|
|
||||||
const carried = await screen.findByDisplayValue('carry me')
|
|
||||||
const tree = screen.getByRole('tree', { name: 'Sessions' })
|
|
||||||
const fixtureGroup = within(tree).getByText('3 sessions').closest('[role="treeitem"]')
|
|
||||||
const novaGroup = within(tree).getByText('1 session').closest('[role="treeitem"]')
|
|
||||||
if (fixtureGroup === null || novaGroup === null) throw new Error('Workspace projections missing after switch')
|
|
||||||
|
|
||||||
expect({
|
|
||||||
chip: visibleText(workspaceChip()),
|
|
||||||
prompt: (carried as HTMLTextAreaElement).value,
|
|
||||||
fixtureWorkspace: visibleText(fixtureGroup),
|
|
||||||
novaWorkspace: visibleText(novaGroup),
|
|
||||||
}).toMatchInlineSnapshot(`
|
|
||||||
{
|
|
||||||
"chip": "nova",
|
|
||||||
"fixtureWorkspace": "fixture3 sessions",
|
|
||||||
"novaWorkspace": "nova1 session",
|
|
||||||
"prompt": "carry me",
|
|
||||||
}
|
|
||||||
`)
|
|
||||||
})
|
|
||||||
@@ -286,3 +286,78 @@ describe('WorkspacesService', () => {
|
|||||||
await expect(workspaces.delete(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/)
|
await expect(workspaces.delete(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('startInitialSelection', () => {
|
||||||
|
function bench() {
|
||||||
|
const ctx = new Context()
|
||||||
|
const api = new FakeApiClient()
|
||||||
|
const sessions = new SessionsService(ctx, api)
|
||||||
|
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||||
|
return { api, sessions, workspaces }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('connects the recent Workspace blank session once baselines are ready and opens it', async () => {
|
||||||
|
const b = bench()
|
||||||
|
const stop = b.workspaces.startInitialSelection()
|
||||||
|
// Nothing happens before both baselines land.
|
||||||
|
expect(b.api.callsOf('session.create')).toHaveLength(0)
|
||||||
|
|
||||||
|
b.api.onWorkspaceList = () => Promise.resolve(ok({
|
||||||
|
items: [workspace('recent', [], '2026-01-02T00:00:00.000Z')] as never[],
|
||||||
|
}))
|
||||||
|
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-new') }))
|
||||||
|
await b.workspaces.refresh()
|
||||||
|
await b.sessions.refresh()
|
||||||
|
// Store notifications and the connect round trip are microtask-batched.
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
|
expect(b.api.callsOf('session.create')).toEqual([{ workspaceId: 'recent' }])
|
||||||
|
expect(b.sessions.list.getSnapshot().current).toBe('s-new')
|
||||||
|
stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stays idle when a session is already current or no recent Workspace exists', async () => {
|
||||||
|
const withCurrent = bench()
|
||||||
|
withCurrent.api.onList = () => Promise.resolve(ok({
|
||||||
|
items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }] as never[],
|
||||||
|
}))
|
||||||
|
await withCurrent.sessions.refresh()
|
||||||
|
withCurrent.sessions.open(sid('s1'))
|
||||||
|
withCurrent.api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('w1', [sid('s1')])] as never[] }))
|
||||||
|
const stopCurrent = withCurrent.workspaces.startInitialSelection()
|
||||||
|
await withCurrent.workspaces.refresh()
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
|
expect(withCurrent.api.callsOf('session.create')).toHaveLength(0)
|
||||||
|
stopCurrent()
|
||||||
|
|
||||||
|
const noRecent = bench()
|
||||||
|
const stopEmpty = noRecent.workspaces.startInitialSelection()
|
||||||
|
await noRecent.workspaces.refresh()
|
||||||
|
await noRecent.sessions.refresh()
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
|
expect(noRecent.api.callsOf('session.create')).toHaveLength(0)
|
||||||
|
expect(() => noRecent.workspaces.startInitialSelection()).toThrow(/already started/)
|
||||||
|
stopEmpty()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a failed connect returns to waiting and retries on the next list change', async () => {
|
||||||
|
const b = bench()
|
||||||
|
b.api.onWorkspaceList = () => Promise.resolve(ok({
|
||||||
|
items: [workspace('recent', [], '2026-01-02T00:00:00.000Z')] as never[],
|
||||||
|
}))
|
||||||
|
b.api.onCreate = () => Promise.resolve(err({ code: 'internal', message: 'attach exploded', details: {} }))
|
||||||
|
const stop = b.workspaces.startInitialSelection()
|
||||||
|
await b.workspaces.refresh()
|
||||||
|
await b.sessions.refresh()
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
|
expect(b.api.callsOf('session.create')).toHaveLength(1)
|
||||||
|
expect(b.sessions.list.getSnapshot().current).toBeUndefined()
|
||||||
|
|
||||||
|
// Recovery: the next workspace-list change re-runs the reconcile.
|
||||||
|
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-retry') }))
|
||||||
|
await b.workspaces.refresh()
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
|
expect(b.api.callsOf('session.create')).toHaveLength(2)
|
||||||
|
expect(b.sessions.list.getSnapshot().current).toBe('s-retry')
|
||||||
|
stop()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -245,6 +245,20 @@ export class TestSessions implements ISessions {
|
|||||||
await this.stabilize(() => { record.snapshot.update(mutate) })
|
await this.stabilize(() => { record.snapshot.update(mutate) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a session's list row (the wire-echo stand-in: title settles,
|
||||||
|
* running flips — components subscribed via useSessions re-render).
|
||||||
|
* @param id - session id.
|
||||||
|
* @param patch - summary fields to merge over the row.
|
||||||
|
*/
|
||||||
|
async updateSummary(id: string, patch: Partial<Omit<SessionSummary, 'id'>>): Promise<void> {
|
||||||
|
const record = this.require(id)
|
||||||
|
record.summary = { ...record.summary, ...patch }
|
||||||
|
await this.stabilize(() => {
|
||||||
|
this.list.update((draft) => { draft.byId[id as SessionId] = record.summary })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Switch the current selection (undefined = the no-session empty state).
|
* Switch the current selection (undefined = the no-session empty state).
|
||||||
* @param id - session id to select, or undefined to clear.
|
* @param id - session id to select, or undefined to clear.
|
||||||
|
|||||||
245
packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx
Normal file
245
packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
/**
|
||||||
|
* Assembly-level acceptance on SlotTestRuntime (real apply, real slot
|
||||||
|
* machinery, real renderer; data fed as fixtures) for surfaces that were
|
||||||
|
* previously pinned only by the assembled-app jsdom snapshots
|
||||||
|
* (apps/web/tests/{todo-display,terminal-card,slash-flow}.snapshot.ts):
|
||||||
|
*
|
||||||
|
* - the todo_write turn reaches BOTH surfaces through the product
|
||||||
|
* registrations (keyed toolview row in the flow, plan strip in the input
|
||||||
|
* dock via the 'todos' projection) and the strip follows projection
|
||||||
|
* retirement;
|
||||||
|
* - the bash keyed row carries its resident terminal card, and the fallback
|
||||||
|
* row reaches the same card through its expand control;
|
||||||
|
* - the resident composer textarea survives the blank→active conversion as
|
||||||
|
* the SAME DOM node (focus/IME continuity rides React reconciliation:
|
||||||
|
* component identity + tree position, which this assembled tree pins).
|
||||||
|
*
|
||||||
|
* Component-level behavior (collapse interaction, card model arms, summary
|
||||||
|
* derivations) lives in todo-panel.spec.tsx / terminal-card.spec.tsx; this
|
||||||
|
* suite only proves the assembled wiring.
|
||||||
|
*/
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { cleanup, fireEvent, waitFor, within } from '@testing-library/react'
|
||||||
|
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||||
|
import type { ISession, SessionId, TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
|
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||||
|
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
|
||||||
|
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||||
|
|
||||||
|
const SID = 's1' as SessionId
|
||||||
|
|
||||||
|
afterEach(cleanup)
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
const TODOS: TodoItem[] = [
|
||||||
|
{ content: '梳理需求', status: 'completed' },
|
||||||
|
{ content: '实现 fixture 样本', status: 'in_progress' },
|
||||||
|
{ content: '浏览器验收', status: 'pending' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const todoResult = (seq: number): ToolResultNode => ({
|
||||||
|
kind: 'tool-result', seq, time: seq * 1_000, callId: `todo-${seq}`,
|
||||||
|
call: { name: 'todo_write', argsRaw: JSON.stringify({ todos: TODOS }) },
|
||||||
|
callTime: seq * 1_000 - 500,
|
||||||
|
content: [], isError: false, callView: null, resultView: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const bashResult = (seq: number, callId: string, over?: Partial<ToolResultNode>): ToolResultNode => ({
|
||||||
|
kind: 'tool-result', seq, time: seq * 1_000, callId,
|
||||||
|
call: { name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}' },
|
||||||
|
callTime: seq * 1_000 - 500,
|
||||||
|
content: [{ type: 'text', text: 'total 2\ndemo.txt\n' }], isError: false,
|
||||||
|
callView: { card: 'terminal', title: 'ls -la', description: 'List files' },
|
||||||
|
resultView: { card: 'terminal', output: 'total 2\ndemo.txt\n', exitCode: 0 },
|
||||||
|
...over,
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Test-owned AppFrame role: declares and renders the resident conversation area. */
|
||||||
|
type AppRootProps = PropsRenderSlots<'conversation' | 'details'>
|
||||||
|
function AppRoot({ renderSlot }: AppRootProps) {
|
||||||
|
return <>{renderSlot('conversation', {})}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
const LAYOUT_CHILDREN = {
|
||||||
|
'conversation': { kind: 'single', scope: 'session-maybe' },
|
||||||
|
'details': { kind: 'single', scope: 'session' },
|
||||||
|
} as const
|
||||||
|
|
||||||
|
async function bench(nodes: ToolResultNode[], opts?: { blank?: boolean }) {
|
||||||
|
const runtime = await SlotTestRuntime.create()
|
||||||
|
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||||
|
runtime.provide('locale', new LocaleService(runtime.ctx))
|
||||||
|
await runtime.sessions.add({
|
||||||
|
id: SID,
|
||||||
|
summary: { title: 'S', displayTitle: 'S', cwd: '/proj' },
|
||||||
|
snapshot: {
|
||||||
|
nodes,
|
||||||
|
...(opts?.blank === true ? { blank: true, composerPhase: 'blank' as const } : {}),
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
loadOlder: vi.fn<ISession['loadOlder']>(),
|
||||||
|
prompt: vi.fn<ISession['prompt']>(async () => ({ ok: true, value: { accepted: true } })),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
|
||||||
|
await runtime.mount({ inject: [...inject], apply })
|
||||||
|
return runtime
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('todo_write assembly (product registrations, no outlet twins)', () => {
|
||||||
|
it('reaches the keyed toolview row and the dock plan strip, and the strip follows projection retirement', async () => {
|
||||||
|
const runtime = await bench([todoResult(3)])
|
||||||
|
// The dock strip reads the host-computed 'todos' projection.
|
||||||
|
runtime.sessions.behavior(SID).projections.set('todos', TODOS)
|
||||||
|
const view = runtime.renderRoot()
|
||||||
|
|
||||||
|
// Keyed toolview registration took the row (summary derived from args).
|
||||||
|
const row = view.container.querySelector('[data-sample="todo-row"]')
|
||||||
|
expect(row).not.toBeNull()
|
||||||
|
expect(row!.textContent).toContain('1/3 已完成 · 实现 fixture 样本')
|
||||||
|
|
||||||
|
// The plan strip sits in the input dock, fed by the projection
|
||||||
|
// (default-collapsed: the header summary shows; rows appear on expand).
|
||||||
|
const panel = view.container.querySelector('[data-testid="todo-panel"]')
|
||||||
|
expect(panel).not.toBeNull()
|
||||||
|
expect(panel!.textContent).toContain('1/3 tasks · 1 in progress')
|
||||||
|
fireEvent.click(panel!.querySelector('button')!)
|
||||||
|
expect([...panel!.querySelectorAll('li')].map(li => li.getAttribute('data-status')))
|
||||||
|
.toEqual(['completed', 'in_progress', 'pending'])
|
||||||
|
|
||||||
|
// Next turn retires the standing plan (host pushes null): the strip
|
||||||
|
// clears while the historical row stays in the flow.
|
||||||
|
await runtime.flush()
|
||||||
|
runtime.sessions.behavior(SID).projections.set('todos', null)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(view.container.querySelector('[data-testid="todo-panel"]')).toBeNull()
|
||||||
|
})
|
||||||
|
expect(view.container.querySelector('[data-sample="todo-row"]')).not.toBeNull()
|
||||||
|
await runtime.dispose()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('terminal card assembly', () => {
|
||||||
|
it('the keyed bash row carries a resident terminal card; the fallback row reaches one through expand', async () => {
|
||||||
|
const runtime = await bench([
|
||||||
|
bashResult(3, 'c-keyed'),
|
||||||
|
// An unregistered tool with terminal views: GenericToolCard fallback.
|
||||||
|
bashResult(4, 'c-fallback', { call: { name: 'fx-bash', argsRaw: '{"command":"ls -la"}' } }),
|
||||||
|
])
|
||||||
|
const view = runtime.renderRoot()
|
||||||
|
|
||||||
|
// Keyed BashRow renders the card residently (no expand gesture).
|
||||||
|
const keyed = view.container.querySelector('[data-sample="bash-global"]')?.parentElement
|
||||||
|
expect(keyed?.querySelector('[data-terminal]')).not.toBeNull()
|
||||||
|
|
||||||
|
// Fallback row: card appears only after its expand control.
|
||||||
|
const fallback = view.container.querySelector('[data-tool="fx-bash"]')
|
||||||
|
expect(fallback).not.toBeNull()
|
||||||
|
expect(fallback!.querySelector('[data-terminal]')).toBeNull()
|
||||||
|
fireEvent.click(fallback!.querySelector('button[aria-expanded]')!)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fallback!.querySelector('[data-terminal]')).not.toBeNull()
|
||||||
|
})
|
||||||
|
await runtime.dispose()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('resident composer', () => {
|
||||||
|
it('renders the locked view state while no session exists at all', async () => {
|
||||||
|
const runtime = await SlotTestRuntime.create()
|
||||||
|
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||||
|
runtime.provide('locale', new LocaleService(runtime.ctx))
|
||||||
|
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
|
||||||
|
await runtime.mount({ inject: [...inject], apply })
|
||||||
|
const view = runtime.renderRoot()
|
||||||
|
// No session entity: the inert twin renders (disabled textarea), and the
|
||||||
|
// workspace picker chip is the only live control.
|
||||||
|
const textarea = view.container.querySelector('textarea')
|
||||||
|
expect(textarea).not.toBeNull()
|
||||||
|
expect(textarea!.disabled).toBe(true)
|
||||||
|
expect(view.getByRole('button', { name: 'Choose workspace' })).toBeTruthy()
|
||||||
|
await runtime.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
it('the textarea survives the blank→active conversion as the same DOM node', async () => {
|
||||||
|
const runtime = await bench([], { blank: true })
|
||||||
|
// The hero renders the LIVE composer only when the blank session's
|
||||||
|
// workspace resolves a chip title; an ownerless blank session shows the
|
||||||
|
// disabled twin instead (deleted-workspace semantics).
|
||||||
|
await runtime.workspaces.update((draft) => {
|
||||||
|
draft.items = [{ workspaceId: 'w1', title: 'Proj', path: '/proj', sessionIds: [SID] }] as never
|
||||||
|
})
|
||||||
|
const view = runtime.renderRoot()
|
||||||
|
const hero = view.container.querySelector('textarea')
|
||||||
|
expect(hero).not.toBeNull()
|
||||||
|
expect(hero!.disabled).toBe(false)
|
||||||
|
|
||||||
|
// First acceptance: the session leaves blank and the composer docks.
|
||||||
|
await runtime.sessions.updateSnapshot(SID, (draft) => {
|
||||||
|
draft.blank = false
|
||||||
|
draft.composerPhase = 'active'
|
||||||
|
})
|
||||||
|
const docked = view.container.querySelector('textarea')
|
||||||
|
expect(docked).toBe(hero)
|
||||||
|
await runtime.dispose()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('prompt rejection through the assembled composer', () => {
|
||||||
|
it('renders the promptError alert strip and keeps the draft in the machine', async () => {
|
||||||
|
const runtime = await SlotTestRuntime.create()
|
||||||
|
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||||
|
runtime.provide('locale', new LocaleService(runtime.ctx))
|
||||||
|
const prompt = vi.fn<ISession['prompt']>(async () => ({
|
||||||
|
ok: false, error: { code: 'agent-busy', message: 'prompt rejected before acceptance', details: { reason: 'busy' } },
|
||||||
|
}))
|
||||||
|
await runtime.sessions.add({
|
||||||
|
id: SID,
|
||||||
|
summary: { title: 'S', displayTitle: 'S', cwd: '/proj' },
|
||||||
|
session: { prompt, loadOlder: vi.fn<ISession['loadOlder']>() },
|
||||||
|
})
|
||||||
|
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
|
||||||
|
await runtime.mount({ inject: [...inject], apply })
|
||||||
|
const view = runtime.renderRoot()
|
||||||
|
|
||||||
|
const composer = view.container.querySelector('textarea')!
|
||||||
|
fireEvent.change(composer, { target: { value: 'do not lose this' } })
|
||||||
|
fireEvent.keyDown(composer, { key: 'Enter' })
|
||||||
|
await waitFor(() => { expect(prompt).toHaveBeenCalledOnce() })
|
||||||
|
|
||||||
|
// The rejection lands in snapshot.promptError (the Session's own path);
|
||||||
|
// the fixture mirrors that hop — the assembled InputBar renders it.
|
||||||
|
await runtime.sessions.updateSnapshot(SID, (draft) => {
|
||||||
|
draft.promptError = {
|
||||||
|
op: 'send',
|
||||||
|
error: { code: 'agent-busy', message: 'prompt rejected before acceptance', details: { reason: 'busy' } },
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const alert = await view.findByRole('alert')
|
||||||
|
expect(alert.textContent).toContain('prompt rejected before acceptance (agent-busy)')
|
||||||
|
// Failure restore: the machine returned the draft to the same textarea.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect((view.container.querySelector('textarea'))!.value).toBe('do not lose this')
|
||||||
|
})
|
||||||
|
await runtime.dispose()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('title projection across assembled surfaces', () => {
|
||||||
|
it('one summary update re-labels the breadcrumb and document.title consumers together', async () => {
|
||||||
|
const runtime = await bench([])
|
||||||
|
const view = runtime.renderRoot()
|
||||||
|
// The strict session header breadcrumb reads useSessions ancestry.
|
||||||
|
const crumb = within(view.container.querySelector('[aria-label="Session hierarchy"]') as HTMLElement)
|
||||||
|
expect(crumb.getByText('S')).toBeTruthy()
|
||||||
|
|
||||||
|
await runtime.sessions.updateSummary(SID, { displayTitle: '修订标题', title: '修订标题' })
|
||||||
|
await waitFor(() => { expect(crumb.getByText('修订标题')).toBeTruthy() })
|
||||||
|
expect(crumb.queryByText('S')).toBeNull()
|
||||||
|
await runtime.dispose()
|
||||||
|
})
|
||||||
|
})
|
||||||
118
packages/client/ui-workspace/tests/rename-assembly.spec.tsx
Normal file
118
packages/client/ui-workspace/tests/rename-assembly.spec.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
/**
|
||||||
|
* The session-rename assembly chain on SlotTestRuntime (real apply, real
|
||||||
|
* WorkspaceBrowser occupying the sidebar hole): row menu → rename dialog →
|
||||||
|
* the injected renameSession hop (sessions.binding → ISession.rename) → on
|
||||||
|
* the accepted unary response the dialog closes and the row re-labels from
|
||||||
|
* the list state — no push-frame wait. Previously pinned only by the
|
||||||
|
* assembled-app snapshot (apps/web/tests/session-actions.snapshot.ts); the
|
||||||
|
* verb's wire behavior stays with the runtime package
|
||||||
|
* (session.spec.ts#rename), the dialog's own arms with rows.spec /
|
||||||
|
* workspace-browser.spec.
|
||||||
|
*/
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { cleanup, fireEvent, waitFor, within } from '@testing-library/react'
|
||||||
|
import type { ISession, SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
|
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||||
|
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
|
||||||
|
import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||||
|
|
||||||
|
const SID = 's1' as SessionId
|
||||||
|
|
||||||
|
afterEach(cleanup)
|
||||||
|
beforeEach(() => { localStorage.clear() })
|
||||||
|
|
||||||
|
/** Test-owned sidebar shell role: declares and renders the browsing region. */
|
||||||
|
type FrameProps = PropsRenderSlots<'sidebar.workspaces'>
|
||||||
|
function SidebarFrame({ renderSlot }: FrameProps) {
|
||||||
|
return <>{renderSlot('sidebar.workspaces', { wide: true, expandSidebar: () => {} })}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('session rename through the assembled browser', () => {
|
||||||
|
it('renames via the row menu: binding.session.rename fires, the dialog closes, the row re-labels from the list', async () => {
|
||||||
|
const runtime = await SlotTestRuntime.create()
|
||||||
|
const rename = vi.fn<ISession['rename']>(async title => ({
|
||||||
|
ok: true, value: { title: title.trim().replace(/\s+/g, ' '), seq: 7 },
|
||||||
|
}))
|
||||||
|
await runtime.sessions.add({
|
||||||
|
id: SID,
|
||||||
|
summary: { title: '旧标题', displayTitle: '旧标题', cwd: '/w/alpha' },
|
||||||
|
session: { rename },
|
||||||
|
})
|
||||||
|
await runtime.workspaces.update((draft) => {
|
||||||
|
draft.items = [{
|
||||||
|
workspaceId: 'w1' as WorkspaceId, title: 'alpha', path: '/w/alpha',
|
||||||
|
sessionIds: [SID], createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
}] as never
|
||||||
|
})
|
||||||
|
await runtime.root.declare(
|
||||||
|
{ 'sidebar.workspaces': { kind: 'single', scope: 'root' } } as never,
|
||||||
|
SidebarFrame as never,
|
||||||
|
)
|
||||||
|
await runtime.mount({ inject: [...inject], apply })
|
||||||
|
const view = runtime.renderRoot()
|
||||||
|
|
||||||
|
// The current session's group auto-expands; open the row's action menu.
|
||||||
|
const row = (await view.findByText('旧标题')).closest('[role="treeitem"]')!
|
||||||
|
fireEvent.click(within(row as HTMLElement).getByLabelText('Session actions for 旧标题'))
|
||||||
|
fireEvent.click(view.getByRole('menuitem', { name: 'Rename', hidden: true }))
|
||||||
|
|
||||||
|
// The dialog seeds from the current title; submit a padded value.
|
||||||
|
const input = await view.findByLabelText('Session name') as HTMLInputElement
|
||||||
|
expect(input.value).toBe('旧标题')
|
||||||
|
fireEvent.change(input, { target: { value: ' 分叉 实验记录 ' } })
|
||||||
|
fireEvent.click(view.getByRole('button', { name: 'Rename' }))
|
||||||
|
|
||||||
|
// The injected hop reached the session face with the edge-trimmed draft
|
||||||
|
// (the dialog trims edges; interior normalization is host-side).
|
||||||
|
await waitFor(() => { expect(rename).toHaveBeenCalledWith('分叉 实验记录') })
|
||||||
|
// Acceptance closes the dialog without any push-frame wait.
|
||||||
|
await waitFor(() => { expect(view.queryByLabelText('Session name')).toBeNull() })
|
||||||
|
// The manager lands the unary echo in the list store (its own package
|
||||||
|
// tests own that hop); the row re-labels from list state alone.
|
||||||
|
await runtime.sessions.updateSummary(SID, { displayTitle: '分叉 实验记录', title: '分叉 实验记录' })
|
||||||
|
await view.findByText('分叉 实验记录')
|
||||||
|
expect(view.queryByText('旧标题')).toBeNull()
|
||||||
|
await runtime.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a rejected rename keeps the dialog open with the error surfaced', async () => {
|
||||||
|
const runtime = await SlotTestRuntime.create()
|
||||||
|
const rename = vi.fn<ISession['rename']>(async () => ({
|
||||||
|
ok: false, error: { code: 'internal', message: 'title write failed', details: {} },
|
||||||
|
}))
|
||||||
|
await runtime.sessions.add({
|
||||||
|
id: SID,
|
||||||
|
summary: { title: '旧标题', displayTitle: '旧标题', cwd: '/w/alpha' },
|
||||||
|
session: { rename },
|
||||||
|
})
|
||||||
|
await runtime.workspaces.update((draft) => {
|
||||||
|
draft.items = [{
|
||||||
|
workspaceId: 'w1' as WorkspaceId, title: 'alpha', path: '/w/alpha',
|
||||||
|
sessionIds: [SID], createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
}] as never
|
||||||
|
})
|
||||||
|
await runtime.root.declare(
|
||||||
|
{ 'sidebar.workspaces': { kind: 'single', scope: 'root' } } as never,
|
||||||
|
SidebarFrame as never,
|
||||||
|
)
|
||||||
|
await runtime.mount({ inject: [...inject], apply })
|
||||||
|
const view = runtime.renderRoot()
|
||||||
|
await runtime.flush()
|
||||||
|
|
||||||
|
const row = (await view.findByText('旧标题')).closest('[role="treeitem"]')!
|
||||||
|
fireEvent.click(within(row as HTMLElement).getByLabelText('Session actions for 旧标题'))
|
||||||
|
fireEvent.click(view.getByRole('menuitem', { name: 'Rename', hidden: true }))
|
||||||
|
const input = await view.findByLabelText('Session name')
|
||||||
|
fireEvent.change(input, { target: { value: '新名' } })
|
||||||
|
fireEvent.click(view.getByRole('button', { name: 'Rename' }))
|
||||||
|
|
||||||
|
// Failure: the injected hop rethrows the business error; the dialog
|
||||||
|
// stays open with the alert and the row keeps its title.
|
||||||
|
const alert = await view.findByRole('alert')
|
||||||
|
expect(alert.textContent).toContain('title write failed')
|
||||||
|
expect(view.getByLabelText('Session name')).toBeTruthy()
|
||||||
|
expect(view.getByText('旧标题')).toBeTruthy()
|
||||||
|
await runtime.dispose()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user