From 0cf7420e52e39efd26570b9ca13b6bf699bcfcd2 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 6 Aug 2026 12:26:27 +0800 Subject: [PATCH] test(web): pin the parallel todo plan in the assembled application The `+N` active count rides ToolRow's non-shrinking `summarySuffix` slot, but only jsdom package suites covered it: the ACP snapshots render no web surface and the built-boot smoke asserts no todo row. Add `apps/web/tests/todo-row.snapshot.ts`, which boots the built client bundles against the keyless fixture transport and records `summary`, `suffix`, and the plan strip's header as separate fields, so folding the count back into the summary string changes the expected output. The three assembled-jsdom files now share `apps/web/tests/assembled-boot.ts` instead of each carrying its own copy of the boot entry list, bundle map, jsdom stubs, and mount call. Also: name the policy branch in each `allowParallelInProgress` test title so no case asserting `true` sits under a `false` describe, reword the stale cap comment in todo-panel.spec.tsx, and record the plan strip's real header format in the Agent Note (per-status counts, not `/ tasks`). --- ...-07-26-todo-parallel-in-progress.i18n.yaml | 4 +- .../2026-07-26-todo-parallel-in-progress.md | 4 +- ...2026-07-26-todo-parallel-in-progress.zh.md | 4 +- apps/web/tests/assembled-boot.ts | 126 ++++++++++++++++++ apps/web/tests/built-boot.snapshot.ts | 92 +------------ apps/web/tests/search-card.snapshot.ts | 103 +------------- .../todo-row/parallel-plan.expected.txt | 9 ++ apps/web/tests/todo-row.snapshot.ts | 72 ++++++++++ .../ui-conversation/tests/todo-panel.spec.tsx | 2 +- .../todo/tool-todo/tests/tool-todo.spec.ts | 8 +- 10 files changed, 230 insertions(+), 194 deletions(-) create mode 100644 apps/web/tests/assembled-boot.ts create mode 100644 apps/web/tests/snapshots/todo-row/parallel-plan.expected.txt create mode 100644 apps/web/tests/todo-row.snapshot.ts diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml index 5efe7a5816..9dd6946ab5 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md -2026-07-26-todo-parallel-in-progress.md: 310763977862cf7b170a8901d636ce824fca304d -2026-07-26-todo-parallel-in-progress.zh.md: e00c8357cac1dbc9bb82388b3e25304f96e80b3b +2026-07-26-todo-parallel-in-progress.md: b380ee86154f8436416126725a7bb486f6dc052d +2026-07-26-todo-parallel-in-progress.zh.md: f007cc5645df211adc8e391f72ad2d90666e9c0f diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md index 3107639778..b380ee8615 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md @@ -37,7 +37,7 @@ The durable-log invariant deliberately does NOT follow the flag. A log written w ## The display surfaces are part of the change -Lifting the cap makes a list shape reachable that no renderer had ever received, so this branch stacks on the [web todo display](2026-07-23-web-todo-display.md) rather than landing beside it: both change `tool-todo`, and the GUI is where a parallel plan becomes visible. Two web sites derived their one-line summary with `todos.find(t => t.status === 'in_progress')` — the collapsed plan-strip header and the `todo_write` row — and under the old cap that `find` was total, since at most one item could match. With several active it silently dropped every active item but the first: a four-item plan with three running tasks collapsed to the name of one, and the row read `0/8 已完成 · ` while seven others were in flight. The expanded list was always correct (it maps every item), which is why neither PR's tests caught it — only the collapsed header and the row lost information. The panel redesign in [#740](https://github.com/deepseek-harness/deepseek-harness/pull/740) has since replaced the collapsed header's named hint with a `/ tasks · in progress` count, which reports parallel work correctly and needs no name to truncate; the row is the one site this branch still had to fix. +Lifting the cap makes a list shape reachable that no renderer had ever received, so this branch stacks on the [web todo display](2026-07-23-web-todo-display.md) rather than landing beside it: both change `tool-todo`, and the GUI is where a parallel plan becomes visible. Two web sites derived their one-line summary with `todos.find(t => t.status === 'in_progress')` — the collapsed plan-strip header and the `todo_write` row — and under the old cap that `find` was total, since at most one item could match. With several active it silently dropped every active item but the first: a four-item plan with three running tasks collapsed to the name of one, and the row read `0/8 已完成 · ` while seven others were in flight. The expanded list was always correct (it maps every item), which is why neither PR's tests caught it — only the collapsed header and the row lost information. The panel redesign in [#740](https://github.com/deepseek-harness/deepseek-harness/pull/740) has since replaced the collapsed header's named hint with `·`-joined per-status counts (localized, `1 completed · 2 in progress · 1 pending`, zero-count segments omitted), which reports parallel work correctly and needs no name to truncate; the row is the one site this branch still had to fix. The row takes `planSummary` in `toolviews/plan-summary.ts`. It names the first active item and counts the rest, so the row reports how many tasks are running instead of implying one. Naming every active item was rejected: the row is a single line, and an unbounded join would overflow it — the count degrades predictably where a list does not. The derivation sits inside the toolviews domain rather than in `contract/`, the inter-domain face: the panel computes its own counts inline and shares nothing with the row, so a contract module would declare a sharing relationship that no longer exists. @@ -47,4 +47,4 @@ The row takes `planSummary` in `toolviews/plan-summary.ts`. It names the first a ## Consequences -A todo list can now faithfully mirror parallel execution, and every UI renders several active markers at once: the TUI's per-status prefix needed no change, the plan strip's header counts the active items, and the row needed the derivation above. A composition that sets `allowParallelInProgress: true` no longer rejects a formerly-invalid snapshot shape; one that sets `false` keeps the old rejection, and the durable-log invariant accepts both. The model-facing description changed, which re-recorded the tool-catalog page and every snapshot sidecar carrying the todo schema. No count is recorded here: the set grows with every pinning scenario that lands, and the two point-in-time censuses this note previously carried were both stale within days. The operative rule is that a branch changing the tool description must refresh whichever sidecars landed after it branched — including the numbered `tool-schemas..expected.json` files pinning a subagent class, whose schemas the parent scenario does not cover — and `pnpm run test:snapshot:refresh` does it keylessly over the whole corpus. The web fixture's todo sample now runs two items `in_progress`, so both fixture-driven surfaces render a parallel plan — `packages/client/ui-conversation/tests/todo-panel.spec.tsx` pins the row summary and the plan strip, and the ACP `todo-write` scenario records a three-todo plan with two active — and each would fail again if its derivation returned to single-active. +A todo list can now faithfully mirror parallel execution, and every UI renders several active markers at once: the TUI's per-status prefix needed no change, the plan strip's header counts the active items, and the row needed the derivation above. A composition that sets `allowParallelInProgress: true` no longer rejects a formerly-invalid snapshot shape; one that sets `false` keeps the old rejection, and the durable-log invariant accepts both. The model-facing description changed, which re-recorded the tool-catalog page and every snapshot sidecar carrying the todo schema. No count is recorded here: the set grows with every pinning scenario that lands, and the two point-in-time censuses this note previously carried were both stale within days. The operative rule is that a branch changing the tool description must refresh whichever sidecars landed after it branched — including the numbered `tool-schemas..expected.json` files pinning a subagent class, whose schemas the parent scenario does not cover — and `pnpm run test:snapshot:refresh` does it keylessly over the whole corpus. The web fixture's todo sample now runs two items `in_progress`, so both fixture-driven surfaces render a parallel plan. `packages/client/ui-conversation/tests/todo-panel.spec.tsx` pins the row summary and the plan strip over src, the ACP `todo-write` scenario records a three-todo plan with two active, and `apps/web/tests/todo-row.snapshot.ts` pins both surfaces in the assembled application — booted from the built `packages/client/*/lib/client.js` bundles, so it is the one place the keyed registration and the bundled wiring are under test. That last file records `summary`, `suffix`, and the strip's header as separate fields, so folding the `+N` count back into the summary string changes the expected output even though the concatenated text would read the same. diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md index e00c8357ca..f007cc5645 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md @@ -37,7 +37,7 @@ Status: implemented ## 展示面是本次改动的一部分 -解除上限使一种此前任何渲染器都不曾收到的列表形状变得可达,因此本分支 stack(栈叠)在 [web todo 展示](2026-07-23-web-todo-display.md)之上,而不是与之并行落地:两者都改 `tool-todo`,而 GUI 正是并行计划变得可见的地方。web 有两处用 `todos.find(t => t.status === 'in_progress')` 推导单行摘要——折叠态的计划横条表头与 `todo_write` 工具行——在旧上限下这个 `find` 是完备的,因为最多只能有一个条目匹配。一旦有多个活跃项,它会静默丢掉除第一个之外的全部活跃条目:一个四条目、三个任务在跑的计划折叠后只显示其中一个的名字,工具行读作 `0/8 已完成 · <一个任务>`,而另外七个仍在进行。展开态的列表始终正确(它遍历每个条目),这也是两个 PR 的测试都没抓到它的原因——只有折叠表头与工具行丢失了信息。其后 [#740](https://github.com/deepseek-harness/deepseek-harness/pull/740) 的面板重做已把折叠表头的具名提示换成 `/ tasks · in progress` 计数,它能正确报告并行工作,且不需要任何可被截断的名字;工具行才是本分支仍需修的那一处。 +解除上限使一种此前任何渲染器都不曾收到的列表形状变得可达,因此本分支 stack(栈叠)在 [web todo 展示](2026-07-23-web-todo-display.md)之上,而不是与之并行落地:两者都改 `tool-todo`,而 GUI 正是并行计划变得可见的地方。web 有两处用 `todos.find(t => t.status === 'in_progress')` 推导单行摘要——折叠态的计划横条表头与 `todo_write` 工具行——在旧上限下这个 `find` 是完备的,因为最多只能有一个条目匹配。一旦有多个活跃项,它会静默丢掉除第一个之外的全部活跃条目:一个四条目、三个任务在跑的计划折叠后只显示其中一个的名字,工具行读作 `0/8 已完成 · <一个任务>`,而另外七个仍在进行。展开态的列表始终正确(它遍历每个条目),这也是两个 PR 的测试都没抓到它的原因——只有折叠表头与工具行丢失了信息。其后 [#740](https://github.com/deepseek-harness/deepseek-harness/pull/740) 的面板重做已把折叠表头的具名提示换成以 `·` 连接的各状态计数(本地化后形如 `1 已完成 · 2 进行中 · 1 待处理`,计数为零的段落省略),它能正确报告并行工作,且不需要任何可被截断的名字;工具行才是本分支仍需修的那一处。 工具行改用 `toolviews/plan-summary.ts` 中的 `planSummary`。它给出第一个活跃条目,并计数其余活跃项,因此工具行报告的是有多少任务在跑,而不是暗示只有一个。列出全部活跃条目被否决了:工具行是单行,无上界的拼接会溢出——在列表做不到的地方,计数能够可预测地降级。该推导放在 toolviews 域内而非 `contract/`(域间共享面):面板自行内联计算其计数,与工具行不共享任何东西,因此放进 contract 会声明一种已不存在的共享关系。 @@ -47,4 +47,4 @@ Status: implemented ## 后果 -现在 todo 列表可以忠实反映并行执行,并且每个 UI 都能一次渲染多个活跃标记:TUI 按状态区分的前缀无需改动,计划横条的表头会计数活跃条目,工具行则需要上述推导。设置 `allowParallelInProgress: true` 的组合不再拒绝一种此前无效的快照形状;设置为 `false` 的组合仍保留旧的拒绝行为,而持久日志不变式两者都接受。面向模型的描述发生了变化,这重新记录了 tool-catalog 页面以及每个带有 todo schema 的快照 sidecar。此处不记录数量:该集合会随每个新落地的 pin 场景增长,而本 Note 先前记过的两次点时刻计数都在几天内失实。有效规则是:改动工具描述的分支必须刷新它分叉之后落地的那些 sidecar —— 包括固定 subagent 类工具的编号文件 `tool-schemas..expected.json`,其 schema 不被父场景覆盖 —— `pnpm run test:snapshot:refresh` 可以无 key 地对整个语料完成刷新。web fixture 的 todo 样本现在有两个条目处于 `in_progress`,因此两个由 fixture 驱动的展示面渲染的都是并行计划——`packages/client/ui-conversation/tests/todo-panel.spec.tsx` 固定工具行摘要与计划横条,ACP `todo-write` 场景录制的是三条目、两个活跃的计划——任一推导退回单活跃项,对应的测试都会失败。 +现在 todo 列表可以忠实反映并行执行,并且每个 UI 都能一次渲染多个活跃标记:TUI 按状态区分的前缀无需改动,计划横条的表头会计数活跃条目,工具行则需要上述推导。设置 `allowParallelInProgress: true` 的组合不再拒绝一种此前无效的快照形状;设置为 `false` 的组合仍保留旧的拒绝行为,而持久日志不变式两者都接受。面向模型的描述发生了变化,这重新记录了 tool-catalog 页面以及每个带有 todo schema 的快照 sidecar。此处不记录数量:该集合会随每个新落地的 pin 场景增长,而本 Note 先前记过的两次点时刻计数都在几天内失实。有效规则是:改动工具描述的分支必须刷新它分叉之后落地的那些 sidecar —— 包括固定 subagent 类工具的编号文件 `tool-schemas..expected.json`,其 schema 不被父场景覆盖 —— `pnpm run test:snapshot:refresh` 可以无 key 地对整个语料完成刷新。web fixture 的 todo 样本现在有两个条目处于 `in_progress`,因此两个由 fixture 驱动的展示面渲染的都是并行计划。`packages/client/ui-conversation/tests/todo-panel.spec.tsx` 在 src 上固定工具行摘要与计划横条,ACP `todo-write` 场景录制的是三条目、两个活跃的计划,而 `apps/web/tests/todo-row.snapshot.ts` 在组装后的应用中固定这两个面——它从构建产物 `packages/client/*/lib/client.js` 启动,因此是唯一覆盖 keyed 注册与打包接线的地方。该文件把 `summary`、`suffix` 与横条表头记录为独立字段,因此即便拼接后的文本读起来一样,把 `+N` 计数折回摘要字符串也会改变预期输出。 diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts new file mode 100644 index 0000000000..c2c9f49e08 --- /dev/null +++ b/apps/web/tests/assembled-boot.ts @@ -0,0 +1,126 @@ +// Shared scaffolding for the assembled-jsdom snapshots: the real built +// `packages/client/*/lib/client.js` artifacts booted through AppWebEntry's +// ModuleLoader path (loadBundle) against the keyless FixtureApiClient +// transport. Every file that mounts this graph needs the same boot entry list, +// the same bundle map, the same jsdom globals, and the same mount call, and +// differs only in what it asserts afterwards, so the scaffolding lives here. +// +// Keyless and deterministic: the fixture is the fake server, so nothing here +// reaches a model or the network. +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { act, cleanup } from '@testing-library/react' +import { afterEach, beforeEach, vi } from 'vitest' +import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client' +import { AppWebEntry } from '@deepseek-ai/dsh-client-web' + +/** Boot entries for the minimal assembled graph, each carrying the workspace directory its bundle is read from. */ +export 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 + +/** + * Register the per-test jsdom setup and teardown the assembled boot needs: + * English pinned before boot so role/text locators stay deterministic across + * localized component migrations (the newEnglishPage e2e convention), the + * observers and frame callbacks jsdom lacks, and a full reset of the document, + * the boot globals, and the injected plugin styles afterwards. + */ +export function installAssembledBootEnv(): void { + beforeEach(() => { + localStorage.clear() + localStorage.setItem('dsh.locale', 'en') + 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() + }) +} + +/** + * Mount the assembled application on the fixture transport; the teardown + * registered by installAssembledBootEnv disposes it. + */ +export function mountAssembledApp(): 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, { + loadBundle: async (url) => { + const code = bundles.get(url) + if (code === undefined) throw new Error(`missing built bundle ${url}`) + ;(0, eval)(code) + }, + }) + void entry.run() + unmount = () => { entry.dispose() } + }) +} + +/** + * Match a CSS-module class by its logical name. + * Module class names carry a per-build hash in one of two schemes — + * ui-primitives emits `__` (name bounded by underscores), + * ui-conversation emits `_` (name at the end) — and a longer name + * containing this one must not match (`line` must not hit `lineNumber`). + * @param el - element whose class list is inspected. + * @param name - logical (unhashed) module class name. + * @returns whether the element carries that module class. + */ +export function hasClass(el: Element, name: string): boolean { + return [...el.classList].some(cls => cls === name || cls.endsWith(`_${name}`) || cls.startsWith(`_${name}_`) || cls.includes(`_${name}_`)) +} diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts index b102dff8b8..ef6ee9b70e 100644 --- a/apps/web/tests/built-boot.snapshot.ts +++ b/apps/web/tests/built-boot.snapshot.ts @@ -10,96 +10,14 @@ // benches over src). This smoke additionally pins the resident interaction // fixture's cross-plugin projection because only the built connection/runtime/ // workspace graph can prove that transport-to-row path end to end. -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' +import { act, fireEvent, screen, waitFor, within } from '@testing-library/react' +import { expect, it } from 'vitest' +import { installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts' -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() - // English pinned before boot: role/text locators stay deterministic across - // localized component migrations (the newEnglishPage e2e convention). - localStorage.setItem('dsh.locale', 'en') - 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() -}) +installAssembledBootEnv() it('boots the built plugin graph and renders a fixture session end to end', async () => { - history.replaceState(null, '', '/?fixture') - const root = document.createElement('div') - root.id = 'root' - document.body.appendChild(root) - win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) } - act(() => { - const entry = new AppWebEntry(root, { - loadBundle: async (url) => { - const code = bundles.get(url) - if (code === undefined) throw new Error(`missing built bundle ${url}`) - ;(0, eval)(code) - }, - }) - void entry.run() - unmount = () => { entry.dispose() } - }) + mountAssembledApp() // The sidebar renders from the boot graph: every inject layer activated. const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) diff --git a/apps/web/tests/search-card.snapshot.ts b/apps/web/tests/search-card.snapshot.ts index 2f72c8275c..745b545293 100644 --- a/apps/web/tests/search-card.snapshot.ts +++ b/apps/web/tests/search-card.snapshot.ts @@ -14,69 +14,20 @@ // derivation over the result view, pinned at every render site by the // ui-conversation suite; here the fixture turn exercises the assembled card // shape and its cap. -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { mkdirSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' -import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client' -import { AppWebEntry } from '@deepseek-ai/dsh-client-web' +import { act, fireEvent, screen, waitFor, within } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { hasClass, installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts' const EXPECTED = join(process.cwd(), 'apps/web/tests/snapshots/search-card/grep-card.expected.txt') const refreshing = process.env.DSH_SNAPSHOT === 'record' || process.env.DSH_SNAPSHOT === 'refresh' -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 +installAssembledBootEnv() /** Normalize a rendered search card to a stable text shape: the kind, the banner * summary, each file header (path + count), each visible match line, the expand - * control label, and the recovery footer. CSS-module class names carry a - * per-build hash in one of two schemes — ui-primitives emits `__` - * (name bounded by underscores), ui-conversation emits `_` (name at - * the end). `hasClass` matches a module class by its logical name under either, - * without matching a longer name that contains it (`line` must not hit - * `lineNumber`). */ -function hasClass(el: Element, name: string): boolean { - return [...el.classList].some(cls => cls === name || cls.endsWith(`_${name}`) || cls.startsWith(`_${name}_`) || cls.includes(`_${name}_`)) -} - + * control label, and the recovery footer. */ function cardShape(root: Element): string { const card = root.querySelector('[data-search]') if (card === null) return '' @@ -94,49 +45,9 @@ function cardShape(root: Element): string { return lines.join('\n') } -beforeEach(() => { - localStorage.clear() - // English pinned before boot so the sidebar's role/text locators stay - // deterministic (the built-boot smoke's convention). - localStorage.setItem('dsh.locale', 'en') - 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() -}) - describe('assembled search card', () => { it('renders the grep card, its truncation summary, and its capped head/tail slice from the built bundles', async () => { - history.replaceState(null, '', '/?fixture') - const root = document.createElement('div') - root.id = 'root' - document.body.appendChild(root) - win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) } - act(() => { - const entry = new AppWebEntry(root, { - loadBundle: async (url) => { - const code = bundles.get(url) - if (code === undefined) throw new Error(`missing built bundle ${url}`) - ;(0, eval)(code) - }, - }) - void entry.run() - unmount = () => { entry.dispose() } - }) + mountAssembledApp() const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) fireEvent.click(await within(tree).findByText('Fixture 历史会话')) diff --git a/apps/web/tests/snapshots/todo-row/parallel-plan.expected.txt b/apps/web/tests/snapshots/todo-row/parallel-plan.expected.txt new file mode 100644 index 0000000000..22c2ac777a --- /dev/null +++ b/apps/web/tests/snapshots/todo-row/parallel-plan.expected.txt @@ -0,0 +1,9 @@ +row=todo_write +title=Update to-do list +summary=1/4 completed · 实现 fixture 样本 +suffix=+1 +panel=1 completed · 2 in progress · 1 pending +item=completed 梳理需求 +item=in_progress 实现 fixture 样本 +item=in_progress 跑后台构建 +item=pending 浏览器验收 \ No newline at end of file diff --git a/apps/web/tests/todo-row.snapshot.ts b/apps/web/tests/todo-row.snapshot.ts new file mode 100644 index 0000000000..70fd95c447 --- /dev/null +++ b/apps/web/tests/todo-row.snapshot.ts @@ -0,0 +1,72 @@ +// @vitest-environment jsdom +// Assembled todo snapshot: boots the real built `packages/client/*/lib/ +// client.js` bundles through AppWebEntry's ModuleLoader path against the +// keyless FixtureApiClient transport, opens the fixture session, and pins the +// two surfaces the fixture's parallel plan (turn 71, two items `in_progress`) +// reaches — the `todo_write` tool row and the dock's plan strip. +// +// The row is pinned as three separate fields on purpose. `summary=` is the +// ellipsized text and `suffix=` is ToolRow's non-shrinking `summarySuffix` +// slot, so a regression that folds the `+N` count back into the summary string +// changes this file even though the concatenated text would read the same; the +// jsdom package suites bench over src and cannot see the bundled registration. +import { mkdirSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fireEvent, screen, waitFor, within } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { hasClass, installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts' + +const EXPECTED = join(process.cwd(), 'apps/web/tests/snapshots/todo-row/parallel-plan.expected.txt') +const refreshing = process.env.DSH_SNAPSHOT === 'record' || process.env.DSH_SNAPSHOT === 'refresh' + +installAssembledBootEnv() + +/** Normalize the todo row and the plan strip to a stable text shape: the row's + * title, its truncatable summary, its non-shrinking suffix, then the panel's + * per-status header and every list item with its status. */ +function todoShape(row: Element, panel: Element): string { + const pick = (from: Element, name: string): Element[] => + [...from.querySelectorAll('*')].filter(el => hasClass(el, name)) + const first = (from: Element, name: string): string => + pick(from, name)[0]?.textContent?.trim() ?? '' + const items = [...panel.querySelectorAll('[data-status]')] + .map(item => `item=${item.getAttribute('data-status')} ${item.textContent?.trim() ?? ''}`) + return [ + `row=${row.getAttribute('data-tool')}`, + `title=${first(row, 'title')}`, + `summary=${first(row, 'summary')}`, + `suffix=${first(row, 'summarySuffix')}`, + `panel=${first(panel, 'progress')}`, + ...items, + ].join('\n') +} + +describe('assembled todo surfaces', () => { + it('renders the parallel plan as a row summary, a separate active count, and the dock plan strip', async () => { + mountAssembledApp() + + const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) + fireEvent.click(await within(tree).findByText('Fixture 历史会话')) + // The todo turn is the fixture's last, so wait for its keyed row rather + // than for chat content in general. + const row = await waitFor(() => { + const found = document.querySelector('[data-tool="todo_write"]') + expect(found).not.toBeNull() + return found! + }, { timeout: 10_000 }) + // The panel is the standing plan the turn's `todo/write` event feeds; it + // mounts above the composer, outside the row, and starts collapsed — its + // list only exists once expanded. + const panel = await screen.findByTestId('todo-panel', undefined, { timeout: 10_000 }) + const toggle = panel.querySelector('button[aria-expanded]') + if (toggle === null) throw new Error('the plan strip must expose its expand toggle') + if (toggle.getAttribute('aria-expanded') === 'false') fireEvent.click(toggle) + + const shape = todoShape(row, panel) + if (refreshing) { + mkdirSync(dirname(EXPECTED), { recursive: true }) + writeFileSync(EXPECTED, shape) + } + await expect(shape).toMatchFileSnapshot(EXPECTED) + }) +}) diff --git a/packages/client/ui-conversation/tests/todo-panel.spec.tsx b/packages/client/ui-conversation/tests/todo-panel.spec.tsx index 2b33f6a83d..57149c9195 100644 --- a/packages/client/ui-conversation/tests/todo-panel.spec.tsx +++ b/packages/client/ui-conversation/tests/todo-panel.spec.tsx @@ -126,7 +126,7 @@ describe('TodoPanel', () => { it('marks every parallel active item, and counts them all in the header', () => { render() fireEvent.click(screen.getByRole('button', { expanded: false })) - // The cap this branch removes made this list unreachable: three items carry + // The old unconditional cap made this list unreachable: three items carry // the in-progress glyph at once, and the header counts all three. const statuses = screen.getAllByRole('listitem').map(li => li.getAttribute('data-status')) expect(statuses.filter(s => s === 'in_progress')).toHaveLength(3) diff --git a/packages/todo/tool-todo/tests/tool-todo.spec.ts b/packages/todo/tool-todo/tests/tool-todo.spec.ts index 76f129fcda..12d1f5f665 100644 --- a/packages/todo/tool-todo/tests/tool-todo.spec.ts +++ b/packages/todo/tool-todo/tests/tool-todo.spec.ts @@ -140,13 +140,13 @@ describe('dsh-tool-todo', () => { expect(agent.session.events.findLast(e => e.type === 'todo/write')!.data.todos).toEqual(todos) }) - describe('allowParallelInProgress: false', () => { + describe('allowParallelInProgress', () => { const parallel = [ { content: 'run subagent a', status: 'in_progress' }, { content: 'run subagent b', status: 'in_progress' }, ] - it('rejects a call marking several items in_progress', async () => { + it('false rejects a call marking several items in_progress', async () => { const ctx = await setup(false) const agent = agentWithSession('single-active') const result = await callTodo(ctx, { todos: parallel }, { agent }) @@ -156,7 +156,7 @@ describe('dsh-tool-todo', () => { expect(agent.session.events.some(e => e.type === 'todo/write')).toBe(false) }) - it('still accepts one active item', async () => { + it('false still accepts one active item', async () => { const ctx = await setup(false) const todos: TodoItem[] = [ { content: 'run subagent a', status: 'in_progress' }, @@ -166,7 +166,7 @@ describe('dsh-tool-todo', () => { expect(result.isError).toBe(false) }) - it('an explicit true accepts a parallel write', async () => { + it('true accepts the very list false rejects', async () => { const ctx = await setup(true) const result = await callTodo(ctx, { todos: parallel }) expect(result.isError).toBe(false)