test(web): shrink the assembled jsdom lane to one built-bundle boot smoke

The seven behavior-pinning snapshots (workspace-flow, slash-flow,
todo-display, session-title, session-actions, terminal-card,
code-mode-fixture) asserted per-package behavior through the whole
built app: every assertion now lives in the owning package's suite at
equal strength (SlotTestRuntime benches assemble the same plugin apply
over the production slot machinery), while the assembled layer kept
re-failing on unrelated churn at 10s-timeout debugging cost.

What only this lane can prove is the artifact plane: lib/client.js
bundles loading through the ModuleLoader path, staged boot activation
across the inject layers, per-plugin CSS injection, and a journey
reaching chat content over the fixture transport. built-boot.snapshot.ts
pins exactly that, and nothing else — behavior assertions belong to the
package suites.
This commit is contained in:
imccyu
2026-07-29 23:45:39 +08:00
parent 3980de695e
commit 4d1ab81f05
9 changed files with 43 additions and 1585 deletions

View File

@@ -1,6 +1,15 @@
// @vitest-environment jsdom
// Session row actions in the assembled fixture app: Rename opens the
// browser-owned dialog and settles the title from the unary response.
// The built-bundle boot smoke: the ONE assembled-jsdom test that loads the
// 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 { join } from 'node:path'
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-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-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', 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-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 => [
@@ -42,16 +60,11 @@ 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(() => {
@@ -60,7 +73,6 @@ afterEach(() => {
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 = ''
@@ -68,9 +80,12 @@ afterEach(() => {
vi.unstubAllGlobals()
})
async function bootApp(): Promise<void> {
const root = document.querySelector<HTMLElement>('#root')
if (root === null) throw new Error('snapshot root missing')
it('boots the built plugin graph and renders a fixture session end to end', async () => {
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) => {
@@ -82,49 +97,21 @@ async function bootApp(): Promise<void> {
void entry.run()
unmount = () => { entry.dispose() }
})
await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
}
/** The session row element carrying the given visible label. */
function rowOf(label: string): HTMLElement {
const tree = screen.getByRole('tree', { name: 'Sessions' })
const row = within(tree).getByText(label).closest<HTMLElement>('[role="treeitem"]')
if (row === null) throw new Error(`session row "${label}" missing`)
return row
}
// The sidebar renders from the boot graph: every inject layer activated.
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
await within(tree).findByText('4 sessions')
/** Open the row's ... menu and click one action. The anchor button is
* CSS-hover-revealed (real stylesheets are injected in this assembled run,
* so role queries filter it as hidden); target it directly. */
function pickRowAction(label: string, action: string): void {
const anchor = rowOf(label).querySelector<HTMLElement>(`button[aria-label="Session actions for ${label}"]`)
if (anchor === null) throw new Error(`row menu anchor for "${label}" missing`)
fireEvent.click(anchor)
fireEvent.click(screen.getByRole('menuitem', { name: action, hidden: true }))
}
// Opening a session reaches chat content through the fixture transport.
fireEvent.click(await within(tree).findByText('Fixture 历史会话'))
await waitFor(() => {
expect(document.querySelector('[data-sample="bash-global"]')).not.toBeNull()
}, { timeout: 10_000 })
it('renames a session through the row-menu dialog; the row settles from the unary response', async () => {
await bootApp()
const sourceLabel = 'Fixture 历史会话'
await screen.findByText(sourceLabel)
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')
// Every bundle injected its plugin-owned style tag (the loader's CSS path).
const styleOwners = [...document.head.querySelectorAll('style[data-plugin]')]
.map(style => style.getAttribute('data-plugin'))
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)
}
})

View File

@@ -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,
],
}
`)
})

View File

@@ -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')
})

View File

@@ -1,195 +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 serves the session's wire command catalog (sessions are always
// agent-backed — no draft/materialized split), 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, connectWorkspace unlock, /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)
// '/' 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,
sessionListed: visibleText(within(tree).getByText('1 session').closest('[role="treeitem"]')!),
}).toMatchInlineSnapshot(`
{
"composerSurvivedConversion": true,
"menuHadCompact": true,
"menuHadEcho": true,
"sessionListed": "nova1 session",
}
`)
})

View File

@@ -1,14 +0,0 @@
[
{
"label": "fixture4 sessions"
},
{
"label": "New Sessionnow"
},
{
"label": "分叉 实验记录now"
},
{
"label": "fixture2min"
}
]

View File

@@ -1,12 +0,0 @@
{
"initial": {
"sidebar": "Fixture 历史会话",
"breadcrumb": "Fixture 历史会话",
"documentTitle": "Fixture 历史会话 — DeepSeek Harness"
},
"revised": {
"sidebar": "Fixture 修订标题",
"breadcrumb": "Fixture 修订标题",
"documentTitle": "Fixture 修订标题 — DeepSeek Harness"
}
}

View File

@@ -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": "收起输出",
}
`)
})

View File

@@ -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,
}
`)
})

View File

@@ -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",
}
`)
})