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:
@@ -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,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",
|
|
||||||
}
|
|
||||||
`)
|
|
||||||
})
|
|
||||||
@@ -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",
|
|
||||||
}
|
|
||||||
`)
|
|
||||||
})
|
|
||||||
Reference in New Issue
Block a user