diff --git a/docs/webui.md b/docs/webui.md index 3d62c7c2..9aec0359 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -1196,7 +1196,8 @@ settings (`refs/ui/03-settings-usage-models.jpg`, `04-settings-general.jpg`). Ten tabs in four groups. Every tab carries the reference's 18×18 stroke glyph; the state column says what a user actually gets, and a control that renders but cannot act is called **placeholder** — a designed outcome, not a -missing feature. Only Worktree is genuinely not implemented. +missing feature. None of the ten is a bare placeholder any more: the last +one, Worktree, reads the engine since PB-3. | Group | Tab | State | | --- | --- | --- | @@ -1208,9 +1209,66 @@ missing feature. Only Worktree is genuinely not implemented. | Management | Connection | implemented | | Management | Account | implemented as a read — the section reads `GET /api/account` on mount and renders the account name, the current plan name, the quota overview (plan-quota state plus the 5-hour and weekly remaining figures) and the account status; sign-out stays disabled (no engine method acts on it) | | Coding | Code review | implemented — 自定义审查准则 persists to `localStorage`; 审查方式 is a disabled single-option dropdown showing 子会话 | -| Coding | Worktree | **not implemented** — the tab is a one-line panel reading 「本地版暂不支持工作树管理」 | +| Coding | Worktree | implemented as a CLEANUP page since PB-3 — see **Worktree — what the page can and cannot do** | | Archived | Archived tasks | the tab renders its empty state 「暂无已归档任务」; the list and its actions need an archived-session contract that does not exist | +**Worktree — what the page can and cannot do.** The Worktree tab is a +**cleanup** page, not a workspace manager. It lists the worktrees Git already +knows about for the current repository and removes the ones you select; the +desktop reference (`design-ref/screenshots/ref-23.jpg`) has no 「新建工作树」 +button, and `ManagedWorktreeServicePort` declares no create either, so adding +one would be inventing a capability on both sides at once. Two endpoints: + +| Endpoint | Engine call | Notes | +| --- | --- | --- | +| `GET /api/worktrees?workspace=` | `services.managedWorktrees.list` | `workspace` is optional; without it the request's own conversation workspace is used, and with neither source the endpoint is 400 rather than guessing the server's cwd | +| `POST /api/worktrees/remove` | `services.managedWorktrees.removeBatch` | body `{items: [{workspace, worktreeDir}], activeWorktreeDir?}` | + +Both are gated by PB-8's three-state presence gate rather than by a capability +key, because no key covers `services.managedWorktrees`: no host → **503** +`engine_host_unavailable`, a host with no owner graph → **501** +`engine_services_unavailable`, an owner graph without the service → **501** +`worktree_service_unavailable`. None of them answers 200 with an empty list — a +page that says "nothing to clean up" for a runtime that failed to boot would +tell the user their project is clean when it was never read. + +A folder that is not a Git repository is a **report**, not a failure: 200 with +`ok: false` and the engine's own `code` (`not_git_repository`, +`workspace_unavailable`, `worktree_list_failed`). The page names the code, so +the three mean three different operator actions instead of one 「读取失败」. + +The three toolbar tabs (**近 3 天 / 3-7 天前 / 7 天以上**) filter on +`lastModifiedMs` and the boundaries are inclusive at the top of each band: +exactly 3 days old is still 近 3 天, exactly 7 days old is still 3-7 天前. They +filter rather than sort, because each tab is an age band in the desktop's mental +model, and sorting would make the older bands unreachable without scrolling. +A row whose timestamp the engine could not read (`lastModifiedMs` is genuinely +optional — the engine falls back from the directory mtime to the last reflog +entry) appears in **every** tab labelled 时间未知. Filing it at 0 would put a +worktree modified seconds ago under 7 天以上; hiding it would make a real +worktree invisible. + +`removeBatch` verdicts pass through per item and are never collapsed: a batch +where every item was refused is `ok: true` with a full `failedItems` list, each +carrying its `WorktreeRemovalReason` (`main_worktree` / `active_worktree` / +`not_found` / `locked_worktree` / `dirty_worktree` / `unknown`). The page maps +each through one table and lists them under the toolbar. The main worktree, a +locked worktree and the worktree an active session runs in render their +checkbox **disabled with the matching reason beside it**, because a checkbox +that ticks and then fails on submit teaches the user the button lies. The +runtime-safety check the engine runs before any removal +(`listRunningWorktreeDirs`) is inside the service, so the UI cannot bypass it. + +The `workspace` a browser names is containment-gated on both endpoints +(`assertWorkspacePath`, the same boundary as `/api/fs/*`); a batch is refused +as a whole when one of its repositories is out of root, because an out-of-root +repository is a forged request rather than a worktree that happened to fail. +`worktreeDir` is deliberately **not** gated separately: the engine only removes +a path that `git worktree list` reports as a linked worktree of that repository, +which is strictly stronger than a root check, and adding the weaker gate in +front would make legitimate out-of-root worktrees (a sibling checkout next to +the repo) unremovable. + **There is no Browser tab in Settings.** The browser surface is a workspace column tab (`workspaceTabs.tab.browser`) that mounts `BrowserPanel` over the workspace tabs, not a settings section; the `settings.tab.browser` dictionary @@ -2529,7 +2587,8 @@ Invariants worth keeping when touching either branch: snapshot, which is the cold-load path. - Rendering tests for both components and the reduced-motion tripwire live in `webapp/test/loading-skeleton.test.ts` (SSR through - `renderToStaticMarkup`; the suite has no DOM harness). + `renderToStaticMarkup`; interaction-level coverage of this component can + now use the DOM harness — see the tests section). - **Streaming-label phrase rotation (webui-parity 61, restoring the desktop shape)**: the desktop does not park one static label on screen for the length of a turn. The schedule and the draw are transcribed from the @@ -3213,11 +3272,69 @@ name: `mcode-trajectory-studio`, version `0.1.1`. Protocols supported: ```bash pnpm --filter @mavis/webui test # full node:test suite (unit + mocked + integration + matrix + trajectory) pnpm test:webui # same, from the repository root (CI gate) +pnpm test:webapp # webapp (browser) suite: render + interaction tests node packages/webui/scripts/check-docs-alignment.mjs ``` The package has three runtime dependencies (`hono` + `@hono/node-server` for the HTTP layer, `@mavis/shared` for the workspace path contract) and requires Node 22.19+ (the trajectory studio additionally needs `node:sqlite`, floor 22.13). +### Two ways to test a webapp component + +`pnpm test:webapp` runs `webapp/test/**/*.test.ts` on `node --test`. It has two rendering tools, and they answer different questions. + +| Tool | Answers | Cannot answer | +| --- | --- | --- | +| `renderToStaticMarkup` (`react-dom/server`) | What does this page print? | Anything requiring an event, an effect or a re-render | +| the DOM harness (`webapp/test/helpers/dom.ts`) | What happens when the user presses a key? | Nothing about a tree that is not mounted — it is not a snapshot tool | + +A defect that only appears once a keydown reaches a handler is invisible to static markup: a string has no listeners. That is not hypothetical. The Shortcuts page's capture → verdict → conflict-report path had no test at all, and the mutation that swallowed the conflict report stayed green. Keep static markup for "what does it print" (`settings-extra-pages.test.ts`) and reach for the harness when the answer is "what happens when". + +```ts +// webapp/test/helpers/dom.ts — mount a component, drive it, unmount it. +import { withDom, mount, resetStorage } from "./helpers/dom"; + +test("the conflict is reported on the row that was edited", async () => { + await withDom(createElement(ShortcutsSection, { t }), async (view) => { + await view.pressKey("settings-shortcuts-binding-global-search", { + key: "O", ctrlKey: true, altKey: true, + }); + assert.match(view.text("settings-shortcuts-conflict-global-search") ?? "", /新建无项目任务/); + }); +}); +``` + +The handle `mount` / `withDom` returns: + +| Member | Purpose | +| --- | --- | +| `find(id)` / `query(id)` / `findAll(id)` / `text(id)` / `has(id)` | Look up by `data-testid`; `find` throws and lists the testids that *were* rendered | +| `pressKey(target, {key, ctrlKey, altKey, shiftKey, metaKey})` | Dispatch a bubbling, cancelable keydown and flush React | +| `keyEvent(press)` | Build that keydown without dispatching, to assert on `defaultPrevented` afterwards | +| `click(target)` / `fire(target, type, init)` / `type(target, value)` | The other events, all flushed | +| `run(fn)` | Run an arbitrary block inside `act`, for a raw `dispatchEvent` | +| `rerender(node)` / `flush()` / `html()` | Re-render, drain timers, serialize | +| `unmount()` | Detach the root; `withDom` does it on the throw path too | +| `window` | The happy-dom window, for its `localStorage` | + +Four rules, each one a way the harness would otherwise lie: + +1. **Import `helpers/dom` before any component import.** `react-dom` captures `canUseDOM` when it is first evaluated; with no window at that moment it falls back to a host config with no event system. The harness publishes the window in its own module body and pulls `react-dom/client` in dynamically, so the order is safe as long as the harness import comes first. Getting it wrong is loud rather than silent: the tree renders empty and the first assertion fails. +2. **Targets are `data-testid` strings**, matching the convention the components already use. A missing one throws with the testids that *are* on screen. +3. **`resetStorage()` between tests.** One window, one storage origin, exactly like a browser tab — state you did not clear leaks into the next test. +4. **`webapp/test/helpers/dom-shim.ts` is still the right tool for the markdown walker.** It serves a `DOMParser` over `parse5` and needs no window at all. + +### Why `happy-dom` + +The webapp suite runs on `node:test`, not Vitest's DOM environment, so `@testing-library` would bring a `beforeEach` / auto-cleanup protocol this runner does not have — and the three APIs it would add are the three the handle above already exposes. + +| Candidate | Transitive deps | Cost | Why not | +| --- | --- | --- | --- | +| `jsdom` 30 | 22 | `undici` + `css-tree` + `whatwg-url`, ~20 MB | Reference-complete. The suite asserts on attributes, text and event delivery — none of which is where the two implementations diverge in practice. | +| `happy-dom` 20 | 4 (`entities`, `whatwg-mimetype`, `buffer-image-size`, `ws`) | 8 MB unpacked | **Chosen.** | +| neither | 0 | — | The `parse5` shim in `dom-shim.ts` shows the middle path works for a parser, but a `DOMParser` cannot dispatch an event. | + +`happy-dom` is a `@mavis/webui` devDependency, so it never reaches the product bundle. It does widen `pnpm-lock.yaml`: `vitest` declares it as an optional peer, so the vitest resolution key changes in every workspace importer. Any checkout that consumes the lockfile needs `pnpm install --frozen-lockfile` afterwards. Measured on this machine: 103 ms to import and 3 ms to construct a window, paid once per test file, and only by files that import the harness. + ## Origin The package migrates the community mcode-webui plugin (v1.0.0 → v2.0.0, MiniMax-Code-Plugins PRs #16/#23/#31/#55) and the mcode-trajectory-studio plugin (PR #56) into the product. The full people and history record is [co-builders.md](../co-builders.md). diff --git a/docs/webui.zh-CN.md b/docs/webui.zh-CN.md index cd8693e7..b10a7254 100644 --- a/docs/webui.zh-CN.md +++ b/docs/webui.zh-CN.md @@ -995,7 +995,7 @@ slice 22 增强: **导航结构与可用性** -四个分组共 10 个页签,每个都带桌面参照的 18×18 线性图标。「状态」一列写的是用户实际能拿到什么:**渲染出来但无法操作**的控制件叫「诚实占位」——那是设计如此,不是没做。真正没做的只有「工作树」一个。 +四个分组共 10 个页签,每个都带桌面参照的 18×18 线性图标。「状态」一列写的是用户实际能拿到什么:**渲染出来但无法操作**的控制件叫「诚实占位」——那是设计如此,不是没做。最后一个纯占位的「工作树」自 PB-3 起也接上了引擎。 | 分组 | 页签 | 状态 | | --- | --- | --- | @@ -1007,9 +1007,55 @@ slice 22 增强: | 管理 | 连接 | 已实装 | | 管理 | 账户 | 以读取实现——该分区挂载时读一次 `GET /api/account`,渲染账户名、当前套餐名、配额概况(套餐配额状态 + 5 小时/周窗口剩余读数)与账户状态;「退出登录」维持禁用(引擎没有可调用的登录登出方法) | | 编码 | 代码审查 | 已实装——「自定义审查准则」真存 `localStorage`;「审查方式」是禁用单选下拉,显示「子会话」 | -| 编码 | 工作树 | **未实装**——页签是一行文案「本地版暂不支持工作树管理」 | +| 编码 | 工作树 | 自 PB-3 起以**清理页**形态实装——见**工作树:这一页能做什么、不能做什么** | | 归档 | 已归档任务 | 页签渲染空态「暂无已归档任务」;列表与其操作需要目前不存在的归档会话契约 | +**工作树:这一页能做什么、不能做什么。** 工作树页签是一个**清理**页,不是 +工作区管理器。它列出 Git 已经为当前仓库登记的工作树,并移除你勾选的那些; +桌面参照(`design-ref/screenshots/ref-23.jpg`)没有「新建工作树」按钮, +`ManagedWorktreeServicePort` 也没有声明任何创建方法,两边同时自创一个能力并 +不现实。两个端点: + +| 端点 | 引擎调用 | 说明 | +| --- | --- | --- | +| `GET /api/worktrees?workspace=` | `services.managedWorktrees.list` | `workspace` 可省;不传时用本次请求所属会话的工作区,两处都没有则 400,而不是去猜服务端 cwd | +| `POST /api/worktrees/remove` | `services.managedWorktrees.removeBatch` | 请求体 `{items: [{workspace, worktreeDir}], activeWorktreeDir?}` | + +两者都用 PB-8 的三态在场门控,而不是能力键——没有任何能力键覆盖 +`services.managedWorktrees`:没有宿主 → **503** `engine_host_unavailable`; +有宿主但没有 owner graph → **501** `engine_services_unavailable`; +有 owner graph 但没有该服务 → **501** `worktree_service_unavailable`。三者都 +不会用空列表回 200——一次没读到引擎却显示「没有可清理项」的页面,会让用户 +以为项目是干净的。 + +目录不是 Git 仓库时是**一条读数**而不是失败:200 + `ok: false` + 引擎自己的 +`code`(`not_git_repository` / `workspace_unavailable` / +`worktree_list_failed`)。页面把 code 原样点出来,因此三个 code 对应三种不同 +的处理动作,而不是笼统的一句「读取失败」。 + +工具栏的三档(**近 3 天 / 3-7 天前 / 7 天以上**)按 `lastModifiedMs` 过滤, +边界取「含」:恰好 3 天算近 3 天,恰好 7 天算 3-7 天前。它们是过滤而不是排序, +因为每一档在桌面心智模型里就是一个时间带,排序会让更早的档不滚动就看不到。 +时间戳读不出来的行(`lastModifiedMs` 确实是可选的——引擎先读目录 mtime, +再回退到最近一条 reflog)在**每一档**里都出现,并标注「最后修改时间未知」。 +把它当成 0 会把几秒前改过的工作树塞进「7 天以上」;把它藏起来则会让一个真实 +的工作树不可见。 + +`removeBatch` 的结论逐条透传、绝不合并:整批全被拒绝时是 `ok: true` 加一份完整 +的 `failedItems`,每项带自己的 `WorktreeRemovalReason`(`main_worktree` / +`active_worktree` / `not_found` / `locked_worktree` / `dirty_worktree` / +`unknown`)。页面用一张映射表把每个原因翻成句子,列在工具栏下方。主工作树、 +被锁定的工作树以及会话正在使用的工作树,其复选框**禁用并在旁边写明原因**—— +一个能勾上、提交后才失败的复选框只会让用户觉得按钮在骗人。引擎在真正移除前 +做的运行中检查(`listRunningWorktreeDirs`)在服务内部,界面绕不过去。 + +浏览器指定的工作区在两个端点上都过隔离门(`assertWorkspacePath`,与 +`/api/fs/*` 同一道边界);一批里只要有一个仓库在允许根之外,整批拒绝——越界的 +仓库是一次伪造请求,而不是某个恰好失败的工作树。`worktreeDir` 刻意**不再**单独 +过门:引擎只会移除 `git worktree list` 登记为该仓库关联工作树的路径,这道检查 +严格强于根检查;在它前面再加一道更弱的门,只会让合法的越根工作树(仓库旁边的 +同级 checkout)变得无法移除。 + **设置页没有「浏览器」页签。** 浏览器能力是工作区的一列标签页(`workspaceTabs.tab.browser`),在工作区标签里挂载 `BrowserPanel`,不是设置分区;`settings.tab.browser` 这个文案键没有任何调用点。本文早期版本曾在「偏好」组下列出「浏览器」页签,那是错的。 **快捷键:浏览器能截获什么** @@ -2407,11 +2453,69 @@ node packages/webui/server/trajectory/main.mjs --stdio # MCP over stdio (7 too ```bash pnpm --filter @mavis/webui test # full node:test suite (unit + mocked + integration + matrix + trajectory) pnpm test:webui # same, from the repository root (CI gate) +pnpm test:webapp # webapp(浏览器端)套件:渲染测试 + 交互测试 node packages/webui/scripts/check-docs-alignment.mjs ``` 该包有三个运行时依赖(HTTP 层的 `hono` + `@hono/node-server`,以及工作区路径约定的 `@mavis/shared`),需要 Node 22.19+(轨迹工作室另外需要 `node:sqlite`,下限 22.13)。 +### 组件测试的两条路 + +`pnpm test:webapp` 用 `node --test` 跑 `webapp/test/**/*.test.ts`。它有两件渲染工具,回答的是两类不同的问题。 + +| 工具 | 能回答 | 回答不了 | +| --- | --- | --- | +| `renderToStaticMarkup`(`react-dom/server`) | 这个页面印出来是什么样? | 任何需要事件、副作用或重渲染的问题 | +| DOM 测试台(`webapp/test/helpers/dom.ts`) | 用户按下某个键之后发生了什么? | 未挂载的树——它不是快照工具 | + +只有「按键真正送达处理函数」才暴露的缺陷,对静态标记是不可见的:字符串没有监听器。这不是假设。快捷键页的「捕获 → 判定 → 冲突上报」整条路径当时一条测试都没有,把冲突上报吞掉的变异照样全绿。「页面印出来是什么样」继续用静态标记(`settings-extra-pages.test.ts`);答案落在「按下去会怎样」时才换测试台。 + +```ts +// webapp/test/helpers/dom.ts —— 挂载组件、驱动它、卸载它。 +import { withDom, mount, resetStorage } from "./helpers/dom"; + +test("冲突上报在用户编辑的那一行上", async () => { + await withDom(createElement(ShortcutsSection, { t }), async (view) => { + await view.pressKey("settings-shortcuts-binding-global-search", { + key: "O", ctrlKey: true, altKey: true, + }); + assert.match(view.text("settings-shortcuts-conflict-global-search") ?? "", /新建无项目任务/); + }); +}); +``` + +`mount` / `withDom` 返回的句柄成员: + +| 成员 | 用途 | +| --- | --- | +| `find(id)` / `query(id)` / `findAll(id)` / `text(id)` / `has(id)` | 按 `data-testid` 查找;`find` 找不到就抛错,并列出树上实际渲染出来的 testid | +| `pressKey(target, {key, ctrlKey, altKey, shiftKey, metaKey})` | 派发冒泡且可取消的 keydown,并让 React 落定 | +| `keyEvent(press)` | 只构造不派发,用于事后断言 `defaultPrevented` | +| `click(target)` / `fire(target, type, init)` / `type(target, value)` | 其余事件,同样都会落定 | +| `run(fn)` | 在 `act` 里跑任意代码块,用于裸 `dispatchEvent` | +| `rerender(node)` / `flush()` / `html()` | 重渲染、排空定时器、序列化 | +| `unmount()` | 卸载并摘除根节点;`withDom` 在抛异常的路径上也会做 | +| `window` | happy-dom 的 window,用于取它的 `localStorage` | + +四条规则,每条都对应测试台会「说谎」的一种方式: + +1. **`helpers/dom` 必须早于任何组件 import。** `react-dom` 在自己第一次被求值时抓一次 `canUseDOM`;那一刻没有 window,它会退回到没有事件系统的主机配置。测试台在自己的模块体里发布 window,再用动态 import 拉 `react-dom/client`,所以只要测试台 import 写在最前面,顺序就是安全的。写错的后果是响的而不是哑的:树渲染成空,第一条断言就会失败。 +2. **目标一律是 `data-testid` 字符串**,与组件已有的约定一致。找不到会抛错,并附上当前树上真实存在的 testid。 +3. **测试之间调用 `resetStorage()`。** 一个 window、一个存储源,和浏览器标签页一样——没清掉的状态会漏进下一个测试。 +4. **markdown 遍历器仍然该用 `webapp/test/helpers/dom-shim.ts`。** 它基于 `parse5` 提供 `DOMParser`,完全不需要 window。 + +### 为什么选 `happy-dom` + +webapp 套件跑在 `node:test` 上,不是 Vitest 的 DOM 环境,所以 `@testing-library` 会带来本运行器并没有的 `beforeEach` / 自动清理协议——而它能补的那三个 API,上面这个句柄已经都有了。 + +| 候选 | 传递依赖 | 代价 | 落选原因 | +| --- | --- | --- | --- | +| `jsdom` 30 | 22 个 | 含 `undici` + `css-tree` + `whatwg-url`,约 20 MB | 规范最完整。但套件断言的是属性、文本和事件投递,而这些恰好不是两者实现分歧的地方。 | +| `happy-dom` 20 | 4 个(`entities`、`whatwg-mimetype`、`buffer-image-size`、`ws`) | 解包 8 MB | **选定。** | +| 都不引入 | 0 | — | `dom-shim.ts` 里的 `parse5` 垫片证明「中间路线」对解析器可行,但 `DOMParser` 派发不了事件。 | + +`happy-dom` 是 `@mavis/webui` 的 devDependency,不会进入产品产物。但它会撑大 `pnpm-lock.yaml`:`vitest` 把它声明为可选 peer,于是 vitest 的解析键在每个 workspace importer 上都会变。凡是消费该 lockfile 的检出,之后都需要 `pnpm install --frozen-lockfile`。本机实测:import 103 ms、构造 window 3 ms,每个测试文件付一次,且只由 import 测试台的文件付。 + ## 起源 该包把社区 mcode-webui 插件(v1.0.0 → v2.0.0,MiniMax-Code-Plugins PRs #16/#23/#31/#55)和 mcode-trajectory-studio 插件(PR #56)迁移进了产品。完整的人员与历史记录见 [co-builders.md](../co-builders.md)。 diff --git a/packages/webui/docs/API.md b/packages/webui/docs/API.md index 1dd1a320..2f4451b1 100644 --- a/packages/webui/docs/API.md +++ b/packages/webui/docs/API.md @@ -3113,6 +3113,87 @@ Contract details (the 14-key table, every provider's levels, the migration state) live in [`docs/webui.md`](../../../docs/webui.md) under "Engine capability declaration". +### `GET /api/worktrees` + +The 工作树 settings page's list: every worktree Git has registered for one +repository. `workspace` is optional; without it the request's own conversation +workspace is used, and with neither source present the endpoint answers 400 +`no_workspace` rather than guessing the server's cwd. + +**Response 200** — the engine's `WorkspaceGitWorktreeList`, field for field: +```json +{ + "ok": true, + "workspace": "/home/u/repo", + "current": "/home/u/repo", + "worktrees": [ + { "path": "/home/u/repo", "branch": "main", "head": "abc1234", + "isMain": true, "isLocked": false, "isActive": true, + "isMcodeManaged": false, "lastModifiedMs": 1700000000000 } + ] +} +``` + +`lastModifiedMs` is OPTIONAL and `ok: false` is a report, not a transport +failure. A folder that is not a Git repository answers **200** with +`ok: false` and the engine's own `code` — `not_git_repository`, +`workspace_unavailable` or `worktree_list_failed` — because the request +succeeded and the engine reported a fact about the user's directory. A silent +empty list would tell the user they have nothing to clean up. + +- `400 no_workspace` — neither `?workspace=` nor a conversation workspace. +- `403 workspace_outside_allowed_roots` — the named path is outside the + allowed roots (`assertWorkspacePath`, the same boundary as `/api/fs/*`). + Checked **before** the engine is reached. +- `501 engine_services_unavailable` / `worktree_service_unavailable` +- `503 engine_host_unavailable` + +There is deliberately **no** create endpoint: the desktop page +(`design-ref/screenshots/ref-23.jpg`) has no create button and the port +declares no create method. + +### `POST /api/worktrees/remove` + +The page's 「一键移除」 over a selection. The engine decides each item's fate +and its verdicts pass through per item. + +**Request** — `items` must be a non-empty array of `{workspace, worktreeDir}`; +`activeWorktreeDir` is optional and means "let the engine decide" (it defaults +to the item's own workspace inside the service). +```json +{ "items": [{ "workspace": "/home/u/repo", "worktreeDir": "/home/u/repo/.worktrees/a" }] } +``` + +**Response 200** +```json +{ + "ok": true, + "removedPaths": ["/home/u/repo/.worktrees/b"], + "failedItems": [ + { "worktreeDir": "/home/u/repo", "reason": "main_worktree", + "error": "The main worktree cannot be removed" } + ] +} +``` + +`ok: true` means the REQUEST was carried out, not that something was deleted: +a selection where every item was refused still answers `ok: true` with a full +`failedItems` list. `reason` is narrowed to the engine's closed +`WorktreeRemovalReason` set (`main_worktree` / `active_worktree` / +`not_found` / `locked_worktree` / `dirty_worktree` / `unknown`); a value +outside it is reported as `unknown` rather than shipped as raw text. + +`worktreeDir` is NOT containment-gated separately from `workspace`, and that is +deliberate: the engine only removes a path that `git worktree list` reports as +a linked worktree of that repository, which is strictly stronger than a root +check. A forged path achieves a `not_found` refusal and nothing else. + +- `400 invalid_removal_request` — the body is not a removal request. +- `403 workspace_outside_allowed_roots` — one item names an out-of-root + repository. The WHOLE batch is refused: an out-of-root repository is a + forged request, not a worktree that happened to fail. +- `501` / `503` — the same presence gate as the list. + --- ## Error responses diff --git a/packages/webui/package.json b/packages/webui/package.json index 1df9d1e2..1fee4016 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -33,6 +33,7 @@ "@types/react-dom": "18.3.7", "antd": "5.29.3", "autoprefixer": "10.6.1", + "happy-dom": "20.14.5", "highlight.js": "10.7.3", "katex": "0.18.7", "marked": "18.0.12", diff --git a/packages/webui/server/app.js b/packages/webui/server/app.js index ebdfa25b..74d107d2 100644 --- a/packages/webui/server/app.js +++ b/packages/webui/server/app.js @@ -72,6 +72,7 @@ import * as gitRoute from "./routes/git.js"; import * as pluginsRoute from "./routes/plugins.js"; import * as turnDiffRoute from "./routes/turn-diff.js"; import * as engineCapabilitiesRoute from "./routes/engine-capabilities.js"; +import * as worktreesRoute from "./routes/worktrees.js"; import * as authorizeRoute from "./lib/authorize.js"; /** @@ -141,6 +142,19 @@ export const OWNED_ROUTES = new Set([ // has to come back to the input box. See `engine/follow-up.js` for the // ownership gate and the KNOWN DEBT list. "POST /api/follow-up", + // PB-3 — the 工作树 settings page. Two windows over + // `services.managedWorktrees` (`ManagedWorktreeServicePort`, + // packages/local-runtime/src/files/managed-worktrees.ts:39-50), which + // had been fully implemented in v1 and reachable only through the PB-8 + // owner-graph window. The tab rendered 「本地版暂不支持工作树管理」 — + // true about the route, false about the capability. There is no create + // endpoint and no create button: the desktop page is a CLEANUP page + // (`design-ref/screenshots/ref-23.jpg`), and adding one would invent a + // capability the port does not declare. Removal is batch-only because + // the desktop action is 「一键移除」 over a selection. See + // `engine/worktrees.js` for the three-state presence gate. + "GET /api/worktrees", + "POST /api/worktrees/remove", // Usage / quota. "POST /api/usage", "POST /api/usage-trigger", @@ -573,6 +587,17 @@ export function createHonoApp() { invokeHandler(c, c.get(CAPTURE_KEY), followUpRoute.handleFollowUp), ); + // ----- PB-3: 工作树 ----- + // Registered next to the settings families they serve. The list is a + // read that degrades to the engine's own `code` (a non-git directory is + // a fact, not a failure); the removal is the page's only write. + app.get("/api/worktrees", (c) => + invokeHandler(c, c.get(CAPTURE_KEY), worktreesRoute.handleGetWorktrees), + ); + app.post("/api/worktrees/remove", (c) => + invokeHandler(c, c.get(CAPTURE_KEY), worktreesRoute.handleRemoveWorktrees), + ); + // ----- Usage / quota ----- // /api/usage and /api/usage-trigger share one handler in the legacy table; // register both paths in Hono so the legacy alias keeps working. diff --git a/packages/webui/server/engine/worktrees.js b/packages/webui/server/engine/worktrees.js new file mode 100644 index 00000000..0e05b915 --- /dev/null +++ b/packages/webui/server/engine/worktrees.js @@ -0,0 +1,377 @@ +// webui/server/engine/worktrees.js +// +// Placeholder batch PB-3: the 工作树 settings page. +// +// GET /api/worktrees → services.managedWorktrees.list(workspace) +// POST /api/worktrees/remove → services.managedWorktrees.removeBatch(items) +// +// What this batch is, stated plainly: an HTTP window. The capability was +// always there. `ManagedWorktreeServicePort` +// (packages/local-runtime/src/files/managed-worktrees.ts:39-50) declares +// `list` / `remove` / `removeBatch`, all three are implemented in the same +// file (`:52-82`), the v2 runtime constructs the service +// (local-runtime-v2/src/compat/v1/runtime.ts:467) and hangs it on the owner +// graph (`services.ts:229`, `:584`). Before this batch the settings tab +// rendered a one-line sentence reading 「本地版暂不支持工作树管理」 — which +// was true about the ROUTE and false about the CAPABILITY. The portal that +// let webui see the owner graph at all is PB-8's `getHostServices()` +// (engine/host-services.js), and this is the first consumer of it. +// +// The gate is PB-8's presence gate, and it is the same one PB-1's `pin` +// uses (`engine/session-context-actions.js`), for the same reason: there is +// no capability key for it. `ENGINE_CAPABILITY_KEYS` is a fixed audited +// matrix (`engine/capabilities.js:44`) and adding a key for one service +// would restate three provider declarations and the snapshot audit to +// describe three methods. The honest answer is the three-state member +// read, and all three of its answers are distinct failures: +// +// null → no runtime booted → 503 engine_host_unavailable +// undefined → a host with no owner graph → 501 engine_services_unavailable +// object → but no managedWorktrees → 501 worktree_service_unavailable +// +// None of them may fall through to a success payload. A 工作树 page that +// answered "no worktrees" for a runtime that failed to boot is the +// fake-empty-list shape this repository refuses to reintroduce: the user +// would read it as "clean up done" and never learn the engine was down. +// +// The list failure is a FOURTH answer and it is NOT a gate failure. When +// the member is present and the workspace simply is not a Git repository, +// the engine's own envelope says so (`WorkspaceGitWorktreeList.code`, +// packages/local-runtime/src/files/worktrees.ts:22-28: +// `not_git_repository` / `workspace_unavailable` / `worktree_list_failed`). +// That is a fact about the user's folder, reported as `200 {ok:false, +// code, error, worktrees: []}` — the same soft-fail shape +// `engine/account-reads.js` uses, because the REQUEST succeeded and the +// engine reported a reading. A silent empty list would be a lie +// (doc/placeholder-batch-plan.md §3.5.1). +// +// Removal is verbatim pass-through, and that is the whole design. +// +// `removeBatch` already returns `{success, removedPaths, failedItems[]}` +// with a per-item `reason` from the closed set `WorktreeRemovalReason` +// (`main_worktree` / `active_worktree` / `not_found` / +// `locked_worktree` / `dirty_worktree` / `unknown`). The engine refuses +// the main worktree, the worktree an active session is running in, locked +// and dirty worktrees, and every running directory the runtime safety +// adapter found — `listRunningWorktreeDirs()` is consulted INSIDE the +// service, so there is nothing for the UI to bypass. This layer +// therefore adds no rule of its own and reorders no field: it maps the +// envelope onto the wire and stops. Reasons reach the browser as the +// engine spelled them, and the page maps them to sentences in one table +// (webapp/components/settings-worktree-section.tsx#REASON_TEXT). +// +// A note on the workspace parameter, and on why only ONE of the two paths +// is containment-gated. The `workspace` comes from the browser, so it goes +// through the same `assertWorkspacePath` gate `/api/fs/*` uses — a path +// outside the allowed roots is rejected before Git is invoked. The +// `worktreeDir` does NOT get that gate, and the reason is that the engine +// applies a strictly stronger one: `prepareManagedWorktreeRemoval` +// (managed-worktrees.ts:205-220) looks the requested path up in the +// repository's own `git worktree list` snapshot and returns `not_found` +// for anything that is not a registered linked worktree of THAT repo. A +// client cannot aim a removal at an arbitrary directory — the worst a +// forged path achieves is a 404-shaped refusal, and the removal itself is +// always `git worktree remove -- ` from the repo root, never an +// `rm -rf` of a client-supplied string. Adding a second, weaker gate in +// front of that would only turn legitimate out-of-root worktrees +// (a sibling checkout next to the repo) into unremovable ones. +// +// Boot-path discipline, unchanged from `host-services.js`: nothing heavy is +// imported statically. The PB-8 window is reached through `await import()` +// inside the resolver. + +/** + * The removal reasons this module accepts from the engine, as the closed + * set `WorktreeRemovalReason` spells it + * (packages/local-runtime/src/files/managed-worktrees.ts:7-13). + * + * Read as an allow-list rather than trusted blindly: `removeBatch` runs + * the reason through this module, so a value outside the set is reported + * as `unknown` instead of becoming a free-form string the UI has no + * sentence for. The engine's own default is already `unknown`, so this + * narrows nothing that was going to be readable — it only stops a future + * reason from shipping as raw text before anyone wrote a translation. + */ +export const WORKTREE_REMOVAL_REASONS = Object.freeze([ + "main_worktree", + "active_worktree", + "not_found", + "locked_worktree", + "dirty_worktree", + "unknown", +]); + +/** + * The endpoint declaration of this family. + * + * `gate` is always `"host-services"`: there is no capability key for + * `services.managedWorktrees` (see the header), so the row is enforced by + * the member read at dispatch time against the live host, exactly as + * PB-8's own header prescribes — "Consumers that need cron must gate on + * its presence rather than assume it." + * + * `method` names the PORT method, and each endpoint uses a different one + * on purpose. The page never calls `remove` for a single row: the desktop + * action is 「一键移除」 over a selection (doc/placeholder-batch-plan.md + * §3.3 step 4 — "一键移除(多选)"), and `removeBatch` is the method that + * carries the per-item failure list the page has to render. `remove` stays + * reachable through the same service for any future single-row caller; it + * is deliberately not wired here so there is one removal path, not two + * with two different failure shapes. + * + * @type {Readonly>} + */ +export const WORKTREE_ENDPOINTS = Object.freeze({ + "GET /api/worktrees": Object.freeze({ + gate: "host-services", + member: "services.managedWorktrees", + method: "list", + }), + "POST /api/worktrees/remove": Object.freeze({ + gate: "host-services", + member: "services.managedWorktrees", + method: "removeBatch", + }), +}); + +/** The endpoint keys of this family, for the ledger and the tests. */ +export const WORKTREE_ROUTES = Object.freeze(Object.keys(WORKTREE_ENDPOINTS)); + +/** + * @typedef {{ok: true, service: object, list: Function, removeBatch: Function}} ResolvedWorktreeService + * @typedef {{ok: false, code: string, status: number, error: string}} ResolvedWorktreeFailure + */ + +// --------------------------------------------------------------------------- +// Member resolution — the three-state rule, once +// --------------------------------------------------------------------------- + +/** + * Read `services.managedWorktrees` off a booted catalogue host, honouring + * PB-8's three-state answer without collapsing any of them. + * + * @param {object} options + * @param {string} options.endpoint Endpoint key, for the error text. + * @param {object} [options.deps] Injection seams. `getServices` defaults to + * the PB-8 window; a test hands in a fake rather than booting a runtime. + * @returns {Promise} + */ +async function resolveWorktreeService(options) { + const { endpoint, deps = {} } = options; + const getServices = + deps.getServices ?? (await import("./host-services.js")).getHostServices; + let window; + try { + window = await getServices(); + } catch (e) { + // A throwing host getter propagates unchanged everywhere else in the + // facade; here it is caught so the route can answer 503 with a body + // instead of an unhandled rejection. Same rule as + // `session-context-actions.js#resolveContextActionMember`. + return { + ok: false, + code: "engine_host_unavailable", + status: 503, + error: e && e.message ? e.message : String(e), + }; + } + if (window === null) { + return { + ok: false, + code: "engine_host_unavailable", + status: 503, + error: `${endpoint}: the engine catalogue host is not available`, + }; + } + if (window === undefined) { + return { + ok: false, + code: "engine_services_unavailable", + status: 501, + error: `${endpoint}: this engine host carries no services owner graph`, + }; + } + const service = window.managedWorktrees; + if (!service || typeof service.list !== "function" || typeof service.removeBatch !== "function") { + return { + ok: false, + code: "worktree_service_unavailable", + status: 501, + error: `${endpoint}: host.services.managedWorktrees is absent or does not implement the port`, + }; + } + return { + ok: true, + service, + list: service.list.bind(service), + removeBatch: service.removeBatch.bind(service), + }; +} + +// --------------------------------------------------------------------------- +// Pure derivations — exported and tested on their INPUTS +// --------------------------------------------------------------------------- + +/** + * The workspace this request is about. + * + * The explicit query parameter wins; otherwise the request's own + * conversation workspace (`ctx.cs.workspace.dir`, the same field + * `routes/workspace.js:52` reads) is used. A caller that names neither has + * no repository in mind, and this refuses rather than guessing the + * server's own cwd — a page that silently listed the wrong repository + * would offer to delete worktrees from a project the user never opened. + * + * @param {{workspace?: unknown}|undefined|null} query Parsed query. + * @param {{cs?: {workspace?: {dir?: unknown}}}|undefined|null} ctx Request context. + * @returns {{ok: true, workspace: string}|{ok: false, error: string}} + */ +export function resolveWorktreeWorkspace(query, ctx) { + const named = query && typeof query.workspace === "string" ? query.workspace.trim() : ""; + if (named) return { ok: true, workspace: named }; + const fromCtx = + ctx && ctx.cs && ctx.cs.workspace && typeof ctx.cs.workspace.dir === "string" + ? ctx.cs.workspace.dir.trim() + : ""; + if (fromCtx) return { ok: true, workspace: fromCtx }; + return { + ok: false, + error: + "no workspace: pass ?workspace=, or open the page from a conversation that has one", + }; +} + +/** + * The removal request body, validated into the shape the port takes. + * + * Every field is required and type-checked, because every one of them ends + * up as an argument to a Git subprocess: `items` must be a non-empty array, + * each entry an object with two non-empty strings. An absent + * `activeWorktreeDir` is allowed and means "the engine decides" — it + * defaults to the item's own workspace inside the service, which is the + * protection that matters. + * + * @param {unknown} body + * @returns {{ok: true, items: Array<{workspace: string, worktreeDir: string}>, activeWorktreeDir: string|undefined}|{ok: false, error: string}} + */ +export function parseWorktreeRemoveBody(body) { + if (!body || typeof body !== "object" || Array.isArray(body)) { + return { ok: false, error: "body must be a JSON object" }; + } + const rawItems = body.items; + if (!Array.isArray(rawItems) || rawItems.length === 0) { + return { ok: false, error: "items must be a non-empty array" }; + } + const items = []; + for (const [index, item] of rawItems.entries()) { + if (!item || typeof item !== "object" || Array.isArray(item)) { + return { ok: false, error: `items[${index}] must be an object` }; + } + const workspace = typeof item.workspace === "string" ? item.workspace.trim() : ""; + const worktreeDir = typeof item.worktreeDir === "string" ? item.worktreeDir.trim() : ""; + if (!workspace) return { ok: false, error: `items[${index}].workspace must be a non-empty string` }; + if (!worktreeDir) { + return { ok: false, error: `items[${index}].worktreeDir must be a non-empty string` }; + } + items.push({ workspace, worktreeDir }); + } + const active = typeof body.activeWorktreeDir === "string" ? body.activeWorktreeDir.trim() : ""; + return { ok: true, items, activeWorktreeDir: active || undefined }; +} + +/** + * The engine's list envelope as this endpoint's payload, field for field. + * + * The row objects are passed through UNCHANGED — `path` / `branch` / + * `head` / `isMain` / `isLocked` / `isActive` / `isMcodeManaged` / + * `lastModifiedMs` (`WorkspaceGitWorktree`, + * packages/local-runtime/src/files/worktrees.ts:11-20). In particular + * `lastModifiedMs` stays optional: `worktreeLastModifiedMs` returns + * `undefined` when neither the directory mtime nor the reflog could be + * read, and a row with an unknown age is a real reading that the page's + * three time tabs must be able to show, not a row to drop or a `0` to + * invent (a `0` would file it under 「7 天以上」 and hide a fresh worktree + * from the default tab). + * + * @param {object} list The engine's `WorkspaceGitWorktreeList`. + * @param {string} workspace The workspace the list was taken for. + * @returns {{ok: boolean, workspace: string, current: string|undefined, worktrees: object[], code: string|undefined, error: string|undefined}} + */ +export function worktreeListPayload(list, workspace) { + const source = list && typeof list === "object" ? list : {}; + const rows = Array.isArray(source.worktrees) ? source.worktrees : []; + const current = typeof source.current === "string" && source.current ? source.current : undefined; + return { + ok: source.success === true, + workspace, + ...(current === undefined ? {} : { current }), + worktrees: rows, + ...(typeof source.code === "string" ? { code: source.code } : {}), + ...(typeof source.error === "string" ? { error: source.error } : {}), + }; +} + +/** + * The engine's batch result as this endpoint's payload. + * + * `success` is NOT re-derived from "did anything get removed". A batch + * where every item was refused is a SUCCESSFUL REQUEST whose answer is + * "nothing was removed, here is why, per item" — `ok: true` with a full + * `failedItems` list is the honest report, and flattening it to `ok:false` + * would throw away the per-item reasons the page exists to render + * (doc/placeholder-batch-plan.md §3.5.3). + * + * Reasons are narrowed to the closed set (§ WORKTREE_REMOVAL_REASONS) and + * the item path is kept verbatim, because it is the string the page must + * be able to match back to the row it just tried to remove. + * + * @param {object} result The engine's `WorktreeBatchRemovalResult`. + * @returns {{ok: boolean, removedPaths: string[], failedItems: Array<{worktreeDir: string, reason: string, error: string|undefined}>}} + */ +export function worktreeRemovalPayload(result) { + const source = result && typeof result === "object" ? result : {}; + const removedPaths = Array.isArray(source.removedPaths) + ? source.removedPaths.filter((path) => typeof path === "string") + : []; + const rawFailed = Array.isArray(source.failedItems) ? source.failedItems : []; + const failedItems = rawFailed + .filter((item) => item && typeof item === "object" && typeof item.worktreeDir === "string") + .map((item) => ({ + worktreeDir: item.worktreeDir, + reason: WORKTREE_REMOVAL_REASONS.includes(item.reason) ? item.reason : "unknown", + ...(typeof item.error === "string" ? { error: item.error } : {}), + })); + return { ok: source.success === true, removedPaths, failedItems }; +} + +// --------------------------------------------------------------------------- +// The two reads +// --------------------------------------------------------------------------- + +/** + * `GET /api/worktrees` — the page's list. + * + * @param {{workspace: string, deps?: {getServices?: Function}}} input + * @returns {Promise<{status: number, payload: object}>} + */ +export async function readEngineWorktreeList(input) { + const endpoint = "GET /api/worktrees"; + const resolved = await resolveWorktreeService({ endpoint, deps: input.deps }); + if (!resolved.ok) return { status: resolved.status, payload: resolved }; + const list = await resolved.list(input.workspace); + return { status: 200, payload: worktreeListPayload(list, input.workspace) }; +} + +/** + * `POST /api/worktrees/remove` — the page's 一键移除. + * + * @param {{items: Array<{workspace: string, worktreeDir: string}>, activeWorktreeDir?: string, deps?: {getServices?: Function}}} input + * @returns {Promise<{status: number, payload: object}>} + */ +export async function removeEngineWorktrees(input) { + const endpoint = "POST /api/worktrees/remove"; + const resolved = await resolveWorktreeService({ endpoint, deps: input.deps }); + if (!resolved.ok) return { status: resolved.status, payload: resolved }; + const result = await resolved.removeBatch(input.items, input.activeWorktreeDir); + return { status: 200, payload: worktreeRemovalPayload(result) }; +} diff --git a/packages/webui/server/routes/worktrees.js b/packages/webui/server/routes/worktrees.js new file mode 100644 index 00000000..33aa2118 --- /dev/null +++ b/packages/webui/server/routes/worktrees.js @@ -0,0 +1,143 @@ +// webui/server/routes/worktrees.js +// +// The 工作树 family (placeholder batch PB-3): +// +// GET /api/worktrees ?workspace= list one repository's worktrees +// POST /api/worktrees/remove {items, activeWorktreeDir?} +// +// This file is the HTTP shape and nothing else. The member resolution +// (PB-8's three-state `getHostServices()`), the body validation, the +// envelope mapping and the failure taxonomy live in `../engine/worktrees.js`, +// which is where every other family keeps them. What stays here is what +// only an HTTP layer can own: the query/body read, the containment gate on +// the one path that comes from the browser as free text, the status line +// and the JSON body. +// +// THE CONTAINMENT GATE, and why it covers one path and not two. +// +// `workspace` is browser-supplied, so it goes through the same +// `assertWorkspacePath` boundary as `/api/fs/*`: a path outside the +// allowed roots is refused with 403 before Git is invoked. `worktreeDir` is +// deliberately NOT gated here, and the engine applies a stronger check +// instead — `prepareManagedWorktreeRemoval` only removes a path that +// `git worktree list` reports as a linked worktree of THAT repository, so +// the worst a forged path achieves is a `not_found` refusal. The full +// reasoning is in `engine/worktrees.js`; a second, weaker gate in front of +// it would only make legitimate out-of-root worktrees unremovable. +// +// THE STATUS MAP, because 200 is not the only success here: +// +// 200 {ok:true, …} the engine reported a reading (including the honest +// `ok:false` list failures — a non-git directory is a +// fact about the user's folder, not a bad request) +// 400 the body is not a removal request +// 403 the named workspace is outside the allowed roots +// 503 no runtime booted +// 501 the host has no owner graph, or no worktree service +// +// A `413` for an oversized body comes from `readJson` itself, the same +// bounded reader every other JSON route uses. + +import { readJson } from "../lib/read-json.js"; +import { assertWorkspacePath } from "../lib/workspace.js"; +import { + parseWorktreeRemoveBody, + readEngineWorktreeList, + removeEngineWorktrees, + resolveWorktreeWorkspace, +} from "../engine/worktrees.js"; + +/** + * One JSON answer. The engine facade already decided the status and the + * code; this only serialises it. + * + * @param {object} res + * @param {number} status + * @param {object} payload + * @returns {number} The status written. + */ +function json(res, status, payload) { + res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(payload)); + return status; +} + +/** + * `GET /api/worktrees` — the page's list. + * + * `workspace` is optional: without it the request's own conversation + * workspace is used, so the page can open with a bare `GET` while the user + * is in a project. Neither source exists → 400, never a guess. + * + * Every handler takes an optional FOURTH argument forwarded to the engine + * facade's `deps` (`{getServices}` — nothing in production, a fake window + * in `test/routes/worktrees.test.js`). `app.js#invokeHandler` passes three + * arguments, so the seam costs production nothing and keeps the suite + * hermetic: no runtime boot, no network, no tmpdir. + */ +export async function handleGetWorktrees(req, res, ctx, deps = {}) { + const url = new URL(req.url, "http://localhost"); + const workspace = resolveWorktreeWorkspace( + { workspace: url.searchParams.get("workspace") }, + ctx, + ); + if (!workspace.ok) { + return json(res, 400, { ok: false, code: "no_workspace", error: workspace.error }); + } + const gate = assertWorkspacePath(workspace.workspace); + if (!gate.ok) { + return json(res, 403, { + ok: false, + code: "workspace_outside_allowed_roots", + error: gate.error, + }); + } + // `gate.real` is the symlink-resolved form, and the engine compares + // paths by realpath too (`worktrees.ts#listWorkspaceGitWorktrees`), so + // the symlinked form is the one that must go to Git — see + // `routes/fs.js#safePath` for the same rule and the reason. + const answer = await readEngineWorktreeList({ + workspace: gate.real ?? gate.path ?? workspace.workspace, + deps, + }); + return json(res, answer.status, answer.payload); +} + +/** + * `POST /api/worktrees/remove` — the page's 一键移除 over a selection. + * + * Body: `{ "items": [{ "workspace": string, "worktreeDir": string }], + * "activeWorktreeDir"?: string }`. + * + * 200 with the engine's own per-item verdicts: `removedPaths[]` and + * `failedItems[]` where every failure carries its `WorktreeRemovalReason`. + * The page renders those reasons; this layer never collapses them. + */ +export async function handleRemoveWorktrees(req, res, _ctx, deps = {}) { + const parsed = parseWorktreeRemoveBody(await readJson(req)); + if (!parsed.ok) { + return json(res, 400, { ok: false, code: "invalid_removal_request", error: parsed.error }); + } + // Every item names a repository, so every item's repository is gated the + // same way the query parameter is. One refusal fails the request rather + // than being reported per item: an out-of-root repository is a forged + // request, not a worktree that happened to fail. + const items = []; + for (const [index, item] of parsed.items.entries()) { + const gate = assertWorkspacePath(item.workspace); + if (!gate.ok) { + return json(res, 403, { + ok: false, + code: "workspace_outside_allowed_roots", + error: `items[${index}]: ${gate.error}`, + }); + } + items.push({ ...item, workspace: gate.real ?? gate.path ?? item.workspace }); + } + const answer = await removeEngineWorktrees({ + items, + activeWorktreeDir: parsed.activeWorktreeDir, + deps, + }); + return json(res, answer.status, answer.payload); +} diff --git a/packages/webui/test/routes/worktrees.test.js b/packages/webui/test/routes/worktrees.test.js new file mode 100644 index 00000000..5ce28100 --- /dev/null +++ b/packages/webui/test/routes/worktrees.test.js @@ -0,0 +1,482 @@ +// webui/test/routes/worktrees.test.js +// The `/api/worktrees` family (placeholder batch PB-3) — the 工作树 page's +// two windows over `services.managedWorktrees`. +// +// Hermetic by construction: both handlers take an optional FOURTH argument +// that reaches the engine facade's `deps.getServices` seam, so these tests +// hand in a fake owner graph — no runtime boot, no network, no temporary +// directory, no spawned server. +// +// SIX invariants, in the order they matter: +// +// 1. THE THREE-STATE GATE STAYS THREE STATES. No host → 503, a host with +// no `services` → 501, an owner graph without `managedWorktrees` → 501. +// None of them may answer 200 with an empty list: a 工作树 page that +// says "nothing to clean up" for a runtime that failed to boot tells +// the user their project is clean when it was never read. +// 2. THE LIST IS THE ENGINE'S ENVELOPE, FIELD FOR FIELD. Rows pass +// through unchanged — in particular `lastModifiedMs` stays OPTIONAL, +// because "no timestamp" is a real reading the page must be able to +// show, and a coerced `0` would file a fresh worktree under +// 「7 天以上」. +// 3. A NON-GIT DIRECTORY IS A REPORT, NOT AN EMPTY LIST. `ok:false` plus +// the engine's own `code`, with 200 — the request succeeded and the +// engine reported a fact about the user's folder. +// 4. THE BROWSER-SUPPLIED PATH IS CONTAINMENT-GATED. A workspace outside +// the allowed roots is refused with 403 before Git is invoked, on BOTH +// endpoints, and a removal batch is refused as a whole rather than +// item by item: an out-of-root repository is a forged request, not a +// worktree that happened to fail. +// 5. REMOVAL VERDICTS PASS THROUGH PER ITEM. A batch where every item was +// refused is `ok:true` with a full `failedItems` list; the reasons +// reach the browser as the engine spelled them, narrowed to the closed +// `WorktreeRemovalReason` set. +// 6. THE PAGE IS NOT A CREATE PAGE. No endpoint in this family creates +// anything, and the engine's `remove` method is not reachable from +// here — the desktop's 「一键移除」 is a batch action, and one removal +// path means one failure shape. + +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { Readable } from "node:stream"; +import { pathToFileURL } from "node:url"; +import { join } from "node:path"; + +const absPath = (rel) => + pathToFileURL(join(import.meta.dirname, "..", "..", "server", rel)).href; +const absFile = (rel) => join(import.meta.dirname, "..", "..", rel); +const route = await import(absPath("routes/worktrees.js")); +const engine = await import(absPath("engine/worktrees.js")); +const { ownsRequest } = await import(absPath("app.js")); + +/** A stand-in for the Node ServerResponse. */ +function fakeRes() { + let resolveDone; + const done = new Promise((r) => (resolveDone = r)); + return { + status: 0, + body: "", + writeHead(status) { + this.status = status; + }, + end(chunk) { + if (chunk !== undefined) this.body += chunk; + resolveDone(); + }, + done, + }; +} + +/** A GET request carrying a query string. */ +function getReq(url) { + const req = new Readable({ read() {} }); + req.url = url; + return req; +} + +/** A POST request carrying `body` as JSON. */ +function postReq(body) { + const req = new Readable({ read() {} }); + req.url = "/api/worktrees/remove"; + process.nextTick(() => { + req.push(JSON.stringify(body)); + req.push(null); + }); + return req; +} + +/** + * A fake owner graph exposing only what the route touches. `list` and + * `removeBatch` record their arguments so the tests can assert the wiring + * rather than only the payload. + */ +function fakeServices(overrides = {}) { + const calls = { list: [], removeBatch: [] }; + const managedWorktrees = { + async list(workspace) { + calls.list.push(workspace); + return overrides.list ? overrides.list(workspace) : { success: true, worktrees: [] }; + }, + async removeBatch(items, activeWorktreeDir) { + calls.removeBatch.push({ items, activeWorktreeDir }); + return overrides.removeBatch + ? overrides.removeBatch(items, activeWorktreeDir) + : { success: true, removedPaths: [], failedItems: [] }; + }, + }; + return { services: { managedWorktrees }, calls }; +} + +async function readJsonBody(res) { + await res.done; + return JSON.parse(res.body); +} + +describe("GET /api/worktrees — the three-state presence gate", () => { + test("no host answers 503 and never an empty list", async () => { + const res = fakeRes(); + const status = await route.handleGetWorktrees( + getReq("/api/worktrees"), + res, + { cs: { workspace: { dir: homedir() } } }, + { getServices: async () => null }, + ); + const body = await readJsonBody(res); + assert.equal(status, 503); + assert.equal(res.status, 503); + assert.equal(body.ok, false); + assert.equal(body.code, "engine_host_unavailable"); + assert.equal("worktrees" in body, false, "a gate failure must not look like an empty list"); + }); + + test("a host with no owner graph answers 501, not 503", async () => { + const res = fakeRes(); + const status = await route.handleGetWorktrees( + getReq("/api/worktrees"), + res, + { cs: { workspace: { dir: homedir() } } }, + { getServices: async () => undefined }, + ); + const body = await readJsonBody(res); + assert.equal(status, 501); + assert.equal(body.code, "engine_services_unavailable"); + }); + + test("an owner graph without the service answers 501 with its own code", async () => { + const res = fakeRes(); + const status = await route.handleGetWorktrees( + getReq("/api/worktrees"), + res, + { cs: { workspace: { dir: homedir() } } }, + { getServices: async () => ({ agent: {}, pinService: {} }) }, + ); + const body = await readJsonBody(res); + assert.equal(status, 501); + assert.equal(body.code, "worktree_service_unavailable"); + }); + + test("a throwing host getter becomes a 503 with a body, not a rejection", async () => { + const res = fakeRes(); + const status = await route.handleGetWorktrees( + getReq("/api/worktrees"), + res, + { cs: { workspace: { dir: homedir() } } }, + { + getServices: async () => { + throw new Error("runtime socket closed"); + }, + }, + ); + const body = await readJsonBody(res); + assert.equal(status, 503); + assert.equal(body.ok, false); + assert.match(body.error, /runtime socket closed/); + }); +}); + +describe("GET /api/worktrees — the payload is the engine's envelope", () => { + const workspace = homedir(); + const rows = [ + { + path: "/repo", + branch: "main", + head: "abc123", + isMain: true, + isLocked: false, + isActive: true, + isMcodeManaged: false, + lastModifiedMs: 1_700_000_000_000, + }, + { + path: "/repo/.worktrees/feature", + branch: "feature", + head: "def456", + isMain: false, + isLocked: false, + isActive: false, + isMcodeManaged: true, + // `lastModifiedMs` genuinely absent: the engine could read neither the + // directory mtime nor the reflog. This must stay absent. + }, + ]; + + test("rows pass through unchanged, including the absent timestamp", async () => { + const { services } = fakeServices({ + list: () => ({ success: true, current: "/repo", worktrees: rows }), + }); + const res = fakeRes(); + await route.handleGetWorktrees( + getReq(`/api/worktrees?workspace=${encodeURIComponent(workspace)}`), + res, + null, + { getServices: async () => services }, + ); + const body = await readJsonBody(res); + assert.equal(res.status, 200); + assert.equal(body.ok, true); + assert.equal(body.workspace, workspace); + assert.equal(body.current, "/repo"); + assert.deepEqual(body.worktrees, rows); + assert.equal("lastModifiedMs" in body.worktrees[1], false); + }); + + test("a non-git directory is reported with its code, not as an empty list", async () => { + const { services } = fakeServices({ + list: () => ({ + success: false, + worktrees: [], + error: "fatal: not a git repository", + code: "not_git_repository", + }), + }); + const res = fakeRes(); + await route.handleGetWorktrees( + getReq(`/api/worktrees?workspace=${encodeURIComponent(workspace)}`), + res, + null, + { getServices: async () => services }, + ); + const body = await readJsonBody(res); + assert.equal(res.status, 200, "the request succeeded; the engine reported a fact"); + assert.equal(body.ok, false); + assert.equal(body.code, "not_git_repository"); + assert.match(body.error, /not a git repository/); + }); + + test("the query parameter wins over the conversation workspace", async () => { + const { services, calls } = fakeServices(); + const res = fakeRes(); + await route.handleGetWorktrees( + getReq(`/api/worktrees?workspace=${encodeURIComponent(workspace)}`), + res, + { cs: { workspace: { dir: "/somewhere/else" } } }, + { getServices: async () => services }, + ); + assert.deepEqual(calls.list, [workspace]); + }); + + test("without a parameter the conversation workspace is used", async () => { + const { services, calls } = fakeServices(); + const res = fakeRes(); + await route.handleGetWorktrees(getReq("/api/worktrees"), res, { + cs: { workspace: { dir: workspace } }, + }, { getServices: async () => services }); + assert.deepEqual(calls.list, [workspace]); + }); + + test("neither source present is a 400, never a guess at the server's cwd", async () => { + const res = fakeRes(); + const status = await route.handleGetWorktrees( + getReq("/api/worktrees"), + res, + null, + { getServices: async () => fakeServices().services }, + ); + const body = await readJsonBody(res); + assert.equal(status, 400); + assert.equal(body.code, "no_workspace"); + }); + + test("a workspace outside the allowed roots is refused before Git is called", async () => { + const { services, calls } = fakeServices(); + const res = fakeRes(); + const status = await route.handleGetWorktrees( + getReq("/api/worktrees?workspace=%2Fproc%2Fself%2Fenviron"), + res, + null, + { getServices: async () => services }, + ); + const body = await readJsonBody(res); + assert.equal(status, 403); + assert.equal(body.code, "workspace_outside_allowed_roots"); + assert.deepEqual(calls.list, [], "the engine must not be reached at all"); + }); +}); + +describe("POST /api/worktrees/remove — per-item verdicts", () => { + const workspace = homedir(); + + test("a batch where every item was refused is ok:true with the reasons", async () => { + const { services, calls } = fakeServices({ + removeBatch: () => ({ + success: true, + removedPaths: [], + failedItems: [ + { worktreeDir: "/repo", reason: "main_worktree", error: "The main worktree cannot be removed" }, + { worktreeDir: "/repo/.worktrees/a", reason: "dirty_worktree" }, + { worktreeDir: "/repo/.worktrees/b", reason: "active_worktree" }, + { worktreeDir: "/repo/.worktrees/c", reason: "locked_worktree" }, + ], + }), + }); + const res = fakeRes(); + const status = await route.handleRemoveWorktrees( + postReq({ + items: [ + { workspace, worktreeDir: "/repo" }, + { workspace, worktreeDir: "/repo/.worktrees/a" }, + { workspace, worktreeDir: "/repo/.worktrees/b" }, + { workspace, worktreeDir: "/repo/.worktrees/c" }, + ], + }), + res, + null, + { getServices: async () => services }, + ); + const body = await readJsonBody(res); + assert.equal(status, 200); + assert.equal(body.ok, true, "the request was carried out; nothing was removed"); + assert.deepEqual(body.removedPaths, []); + assert.deepEqual( + body.failedItems.map((item) => item.reason), + ["main_worktree", "dirty_worktree", "active_worktree", "locked_worktree"], + "the four refusals the acceptance criteria name must each survive verbatim", + ); + assert.equal(calls.removeBatch.length, 1); + assert.equal(calls.removeBatch[0].items.length, 4); + }); + + test("removed paths come back and the optional active dir is forwarded", async () => { + const { services, calls } = fakeServices({ + removeBatch: () => ({ + success: true, + removedPaths: ["/repo/.worktrees/gone"], + failedItems: [], + }), + }); + const res = fakeRes(); + await route.handleRemoveWorktrees( + postReq({ + items: [{ workspace, worktreeDir: "/repo/.worktrees/gone" }], + activeWorktreeDir: workspace, + }), + res, + null, + { getServices: async () => services }, + ); + const body = await readJsonBody(res); + assert.deepEqual(body.removedPaths, ["/repo/.worktrees/gone"]); + assert.equal(calls.removeBatch[0].activeWorktreeDir, workspace); + }); + + test("a reason outside the closed set is reported as unknown, never raw", async () => { + const { services } = fakeServices({ + removeBatch: () => ({ + success: true, + removedPaths: [], + failedItems: [{ worktreeDir: "/repo/.worktrees/x", reason: "brand_new_reason" }], + }), + }); + const res = fakeRes(); + await route.handleRemoveWorktrees( + postReq({ items: [{ workspace, worktreeDir: "/repo/.worktrees/x" }] }), + res, + null, + { getServices: async () => services }, + ); + const body = await readJsonBody(res); + assert.equal(body.failedItems[0].reason, "unknown"); + }); + + test("the gate fires before the body is even read as a removal", async () => { + for (const [services, code, status] of [ + [null, "engine_host_unavailable", 503], + [undefined, "engine_services_unavailable", 501], + [{}, "worktree_service_unavailable", 501], + ]) { + const res = fakeRes(); + const written = await route.handleRemoveWorktrees( + postReq({ items: [{ workspace, worktreeDir: "/repo/.worktrees/a" }] }), + res, + null, + { getServices: async () => services }, + ); + const body = await readJsonBody(res); + assert.equal(written, status); + assert.equal(body.code, code); + } + }); +}); + +describe("POST /api/worktrees/remove — the body is validated", () => { + const workspace = homedir(); + const cases = [ + ["a missing items array", {}, "items must be a non-empty array"], + ["an empty items array", { items: [] }, "items must be a non-empty array"], + ["a non-object item", { items: ["x"] }, "items[0] must be an object"], + ["a blank workspace", { items: [{ workspace: " ", worktreeDir: "/a" }] }, "items[0].workspace must be a non-empty string"], + ["a blank worktreeDir", { items: [{ workspace, worktreeDir: "" }] }, "items[0].worktreeDir must be a non-empty string"], + ]; + for (const [name, body, error] of cases) { + test(`${name} is a 400 and never reaches the engine`, async () => { + const { services, calls } = fakeServices(); + const res = fakeRes(); + const status = await route.handleRemoveWorktrees(postReq(body), res, null, { + getServices: async () => services, + }); + const answer = await readJsonBody(res); + assert.equal(status, 400); + assert.equal(answer.code, "invalid_removal_request"); + assert.equal(answer.error, error); + assert.deepEqual(calls.removeBatch, []); + }); + } + + test("an out-of-root repository fails the whole batch, not one item", async () => { + const { services, calls } = fakeServices(); + const res = fakeRes(); + const status = await route.handleRemoveWorktrees( + postReq({ + items: [ + { workspace, worktreeDir: "/repo/.worktrees/a" }, + { workspace: "/proc/self/environ", worktreeDir: "/whatever" }, + ], + }), + res, + null, + { getServices: async () => services }, + ); + const body = await readJsonBody(res); + assert.equal(status, 403); + assert.equal(body.code, "workspace_outside_allowed_roots"); + assert.deepEqual(calls.removeBatch, []); + }); +}); + +describe("the family is a window, not a workspace manager", () => { + test("the declaration covers exactly the two routes Hono owns", () => { + assert.deepEqual(engine.WORKTREE_ROUTES, [ + "GET /api/worktrees", + "POST /api/worktrees/remove", + ]); + for (const need of Object.values(engine.WORKTREE_ENDPOINTS)) { + assert.equal(need.gate, "host-services"); + assert.equal(need.member, "services.managedWorktrees"); + } + assert.equal( + Object.values(engine.WORKTREE_ENDPOINTS).some((need) => need.method === "remove"), + false, + "the desktop action is a batch remove; a second single-row path would mean two failure shapes", + ); + }); + + test("Hono's own router serves both paths", () => { + assert.equal(ownsRequest("GET", "/api/worktrees"), true); + assert.equal(ownsRequest("POST", "/api/worktrees/remove"), true); + }); + + test("the ledger in app.js lists both paths", () => { + const app = readFileSync(absFile("server/app.js"), "utf8"); + assert.match(app, /"GET \/api\/worktrees"/); + assert.match(app, /"POST \/api\/worktrees\/remove"/); + }); + + test("no endpoint in this family creates a worktree", () => { + const code = readFileSync(absFile("server/routes/worktrees.js"), "utf8"); + assert.doesNotMatch(code, /handleCreateWorktree|createWorktree|app\.post\(/); + const engineCode = readFileSync(absFile("server/engine/worktrees.js"), "utf8"); + assert.doesNotMatch(engineCode, /services\.managedWorktrees\.create/); + }); +}); diff --git a/packages/webui/test/server/app-hono.test.js b/packages/webui/test/server/app-hono.test.js index 9ef8156a..8d225182 100644 --- a/packages/webui/test/server/app-hono.test.js +++ b/packages/webui/test/server/app-hono.test.js @@ -86,6 +86,11 @@ describe("app.js — migration ledger", () => { // turn), and unlike /api/send its response carries the engine's // answer rather than an acknowledgement. "POST /api/follow-up", + // PB-3 — the 工作树 settings page. A list read and the page's only + // write. There is deliberately no create endpoint: the desktop page + // is a cleanup page and the port declares no create method. + "GET /api/worktrees", + "POST /api/worktrees/remove", "POST /api/usage", "POST /api/usage-trigger", "GET /api/usage-real", diff --git a/packages/webui/webapp/components/settings-modal-port.tsx b/packages/webui/webapp/components/settings-modal-port.tsx index ee9f3bfb..c0fc8a7b 100644 --- a/packages/webui/webapp/components/settings-modal-port.tsx +++ b/packages/webui/webapp/components/settings-modal-port.tsx @@ -55,6 +55,10 @@ import { ProviderManagementPanel } from "./provider-management"; import { SettingsPanel, UsageModelsSection } from "./panels"; // SB-5:账户 Tab 的真读取分区(`GET /api/account`),见该文件头的分工说明。 import { AccountSection } from "./settings-account-section"; +// PB-3:工作树 Tab 的真读取分区(`GET /api/worktrees` / +// `POST /api/worktrees/remove`)。此前的「本地版暂不支持工作树管理」是对 +// 路由的描述,不是对能力的描述——引擎侧的 managedWorktrees 一直是实现好的。 +import { WorktreeSection } from "./settings-worktree-section"; // 55a 四子页(工单 58 线 D 接线):与移植壳同目录的纯前端组件,无 // store/api 依赖;面板自带 localStorage 持久化(lib/settings-local.ts)。 import { @@ -505,9 +509,12 @@ export function SettingsModalPort({ {active === "coding" ? : null} {active === "worktree" ? (
- -

{t("settings.worktree.empty")}

-
+ {/* PB-3: the tab used to render one honest-looking sentence + * reading 「本地版暂不支持工作树管理」, which was true about the + * route and false about the capability — `services.managedWorktrees` + * had been implemented in v1 all along. It now reads the + * engine's own list. See `settings-worktree-section.tsx`. */} +
) : null} diff --git a/packages/webui/webapp/components/settings-worktree-section.tsx b/packages/webui/webapp/components/settings-worktree-section.tsx new file mode 100644 index 00000000..80ff4d69 --- /dev/null +++ b/packages/webui/webapp/components/settings-worktree-section.tsx @@ -0,0 +1,433 @@ +"use client"; + +/** + * Settings-modal port — the 工作树 tab (placeholder batch PB-3). + * + * The desktop reference (`design-ref/screenshots/ref-23.jpg`, read + * directly) is a CLEANUP page, not a workspace manager: a title with an ⓘ, + * a toolbar of three time tabs · ↻ refresh · 一键移除 in a red outline, and + * an empty list reading 「没有可管理 Worktree」. There is no 「新建工作树」 + * button in that reference, and the engine's + * `ManagedWorktreeServicePort` declares no create either — so this page has + * no create button. Adding one would be inventing a capability on both + * sides at once. + * + * What replaced the old placeholder. The tab used to render one sentence, + * 「本地版暂不支持工作树管理」, which was true about the HTTP route and false + * about the capability: `services.managedWorktrees` had been implemented in + * v1 the whole time. The window is `GET /api/worktrees` / + * `POST /api/worktrees/remove` (`server/engine/worktrees.js`). + * + * Four decisions this page makes, each of which had a plausible wrong + * answer: + * + * 1. THE THREE TABS FILTER, THEY DO NOT SORT. `lastModifiedMs` is the + * only field the desktop's tabs can be driven by, and a tab that + * re-ordered rows would break the desktop's mental model (each tab is + * "what is in this age band"). Filtering also keeps every row + * reachable: sorting by age and hiding nothing means the 7-days-ago + * tab has to be scrolled past to reach the recent ones. + * 2. A ROW WITH NO TIMESTAMP APPEARS IN EVERY TAB, labelled 时间未知. + * `worktreeLastModifiedMs` (worktrees.ts:115-127) falls back from the + * directory mtime to the last reflog entry and can reach neither, and + * `lastModifiedMs` is then genuinely `undefined`. Filing it at 0 would + * put a worktree modified seconds ago under 「7 天以上」; hiding it + * from all three would make a real worktree invisible. Both are worse + * than showing it everywhere with an honest label. + * 3. PROTECTED ROWS ARE NOT SELECTABLE, AND SAY WHY. The engine refuses + * the main worktree, the worktree an active session runs in, and a + * locked one. A checkbox that ticks and then fails on submit teaches + * the user that the button lies, so those rows render their checkbox + * disabled with the matching reason beside them — the refusal is + * visible BEFORE the click instead of after it. + * 4. FAILURES ARE REPORTED PER ITEM, NEVER COLLAPSED. `removeBatch` + * returns one `WorktreeRemovalReason` per refused item; the page maps + * each through one table (`REASON_TEXT`) and lists them under the + * toolbar. A removal where everything was refused still re-reads the + * list, because the rows it refused are still there. + * + * Pure derivations (`worktreeAgeBucket`, `worktreeBlockReason`, + * `visibleWorktrees`) are exported and tested on their inputs in + * `webapp/test/settings-worktree-section.test.ts`; the boundary cases are + * exactly 3 days and exactly 7 days, which is where a `<`/`<=` slip would + * quietly move a row between tabs. + * + * Import style: relative specifiers, like `settings-extra-pages.tsx`, so + * the render harness can load this module under the tsx loader without the + * `@/` alias. + */ + +import * as React from "react"; +import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react"; +import { Popconfirm as AntPopconfirm } from "antd"; + +import * as api from "../lib/api"; +import type { MessageKey } from "../lib/i18n"; +import { Icon } from "./icons"; + +/** The three time bands of the desktop toolbar, plus the always-visible one. */ +export type WorktreeTimeBucket = "recent3d" | "days3to7" | "older7d"; + +/** Every tab key, in the desktop's left-to-right order. */ +export const WORKTREE_TIME_BUCKETS: readonly WorktreeTimeBucket[] = Object.freeze([ + "recent3d", + "days3to7", + "older7d", +]); + +/** A full day in milliseconds — the tab boundaries are whole days. */ +export const WORKTREE_DAY_MS = 24 * 60 * 60 * 1000; + +/** + * Which band a timestamp falls into, or `null` when there is no timestamp. + * + * Boundaries are INCLUSIVE at the top of each band: exactly 3 days old is + * still 「近 3 天」, exactly 7 days old is still 「3-7 天前」, and only + * beyond 7 days is it 「7 天以上」. A future timestamp (clock skew between + * the workstation and the machine hosting the repository) yields a + * NEGATIVE age, which the `recent3d` test accepts — "modified in the + * future" is closest to "modified just now", and hiding the row would be + * worse than mis-bucketing it. + * + * @param lastModifiedMs Epoch milliseconds, or `undefined` when unknown. + * @param nowMs The page's single clock reading for this list load. + * @returns The band, or `null` when the engine reported no timestamp. + */ +export function worktreeAgeBucket( + lastModifiedMs: number | undefined, + nowMs: number, +): WorktreeTimeBucket | null { + if (typeof lastModifiedMs !== "number" || !Number.isFinite(lastModifiedMs)) return null; + const age = nowMs - lastModifiedMs; + if (age <= 3 * WORKTREE_DAY_MS) return "recent3d"; + if (age <= 7 * WORKTREE_DAY_MS) return "days3to7"; + return "older7d"; +} + +/** + * Why this row cannot be removed, or `null` when it can. + * + * The order is the order the engine checks in + * (`prepareManagedWorktreeRemoval`, managed-worktrees.ts:221-259), so the + * label shown matches the reason the engine would actually return if the + * row were submitted anyway. Checking `isMain` before `isActive` matters: + * the repository's primary checkout is usually also the conversation's + * workspace, and calling it 「当前工作树」 there would hide the stronger + * 「主工作树不可移除」 fact. + */ +export function worktreeBlockReason( + row: api.WorktreeRow, +): api.WorktreeRemovalReason | null { + if (row.isMain) return "main_worktree"; + if (row.isActive) return "active_worktree"; + if (row.isLocked) return "locked_worktree"; + return null; +} + +/** + * The rows a tab shows: the band it names, plus every row whose age the + * engine could not report. + */ +export function visibleWorktrees( + rows: readonly api.WorktreeRow[], + bucket: WorktreeTimeBucket, + nowMs: number, +): api.WorktreeRow[] { + return rows.filter((row) => { + const band = worktreeAgeBucket(row.lastModifiedMs, nowMs); + return band === null || band === bucket; + }); +} + +/** + * The engine's closed reason set, mapped to one sentence each. + * + * Exhaustive over `api.WorktreeRemovalReason` on purpose: adding a reason + * upstream makes this table a compile error rather than a raw token in the + * UI. `dirty_worktree` is the one a user can act on, so its sentence names + * the three ways out (commit / stash / discard) instead of just refusing. + */ +const REASON_TEXT: Readonly> = Object.freeze({ + main_worktree: "settings.worktree.reason.main", + active_worktree: "settings.worktree.reason.active", + locked_worktree: "settings.worktree.reason.locked", + dirty_worktree: "settings.worktree.reason.dirty", + not_found: "settings.worktree.reason.notFound", + unknown: "settings.worktree.reason.unknown", +}); + +/** + * The label key for a refusal reason. + * + * Exported so a test can walk the SAME table the page renders from rather + * than re-deriving the key names — a test that rebuilt `reason` into a key + * itself would pass against a table that had lost a row, which is the one + * thing the table exists to prevent. + */ +export function worktreeReasonLabelKey(reason: api.WorktreeRemovalReason): MessageKey { + return REASON_TEXT[reason] ?? REASON_TEXT.unknown; +} + +/** The tab label key, per band. */ +const BUCKET_LABEL: Readonly> = Object.freeze({ + recent3d: "settings.worktree.filter.recent3d", + days3to7: "settings.worktree.filter.days3to7", + older7d: "settings.worktree.filter.older7d", +}); + +/** The label key for a time band. Exported for the same reason as above. */ +export function worktreeBucketLabelKey(bucket: WorktreeTimeBucket): MessageKey { + return BUCKET_LABEL[bucket]; +} + +/** + * The sentence for a list failure. The engine's own discovery code is + * appended verbatim after it: the three codes mean different operator + * actions (open a different folder / fix permissions / look at Git), and + * collapsing them into one 「读取失败」 would throw that away. + */ +const LIST_ERROR: Readonly> = Object.freeze({ + not_git_repository: "settings.worktree.listError.notGit", + workspace_unavailable: "settings.worktree.listError.unavailable", + worktree_list_failed: "settings.worktree.listError.listFailed", +}); + +/** One refused item, resolved to the two strings the page prints. */ +interface RemovalFailureLine { + readonly worktreeDir: string; + readonly text: string; +} + +/** A removal outcome, resolved for rendering: what went, what stayed, why. */ +export interface RemovalOutcome { + readonly removed: number; + readonly failures: RemovalFailureLine[]; +} + +/** + * Turn the engine's batch answer into the lines the page prints. + * + * A failure whose reason is not in the table cannot happen through the + * route — `worktreeRemovalPayload` narrows reasons to the closed set — but + * the default arm exists so a widened union degrades to the engine's own + * `unknown` sentence rather than to an empty string. + */ +export function removalOutcomeOf( + payload: api.WorktreeRemovalPayload, + t: (key: MessageKey) => string, +): RemovalOutcome { + const failures = payload.failedItems.map((item) => ({ + worktreeDir: item.worktreeDir, + text: t(REASON_TEXT[item.reason] ?? REASON_TEXT.unknown), + })); + return { removed: payload.removedPaths.length, failures }; +} + +/** The absolute time a row was last modified, in the browser's locale. */ +function formatModified(lastModifiedMs: number | undefined, t: (key: MessageKey) => string): string { + if (typeof lastModifiedMs !== "number" || !Number.isFinite(lastModifiedMs)) { + return t("settings.worktree.timeUnknown"); + } + const stamp = new Date(lastModifiedMs); + if (Number.isNaN(stamp.getTime())) return t("settings.worktree.timeUnknown"); + return stamp.toLocaleString(); +} + +export function WorktreeSection({ + t, +}: { + t: (key: MessageKey) => string; +}): ReactElement { + const [payload, setPayload] = useState(null); + const [phase, setPhase] = useState<"loading" | "ready" | "failed">("loading"); + // The clock is read ONCE per list load and kept in state, not taken from + // `Date.now()` during render: a render that re-bucketed rows would move a + // row across a tab boundary while the user was looking at the list, and + // the tests could not pin the result. + const [nowMs, setNowMs] = useState(() => Date.now()); + const [bucket, setBucket] = useState("recent3d"); + const [selected, setSelected] = useState([]); + const [removing, setRemoving] = useState(false); + const [outcome, setOutcome] = useState(null); + + const load = useCallback(() => { + setPhase("loading"); + return api + .getWorktrees() + .then((answer) => { + setPayload(answer); + setNowMs(Date.now()); + setPhase("ready"); + }) + .catch(() => setPhase("failed")); + }, []); + + useEffect(() => { + let live = true; + void load().catch(() => { + if (live) setPhase("failed"); + }); + return () => { + live = false; + }; + }, [load]); + + const rows = payload?.worktrees ?? []; + const shown = useMemo(() => visibleWorktrees(rows, bucket, nowMs), [rows, bucket, nowMs]); + const removable = useMemo( + () => shown.filter((row) => worktreeBlockReason(row) === null), + [shown], + ); + const selectedPaths = useMemo( + () => selected.filter((path) => removable.some((row) => row.path === path)), + [selected, removable], + ); + + const toggle = (path: string) => { + setSelected((current) => + current.includes(path) ? current.filter((item) => item !== path) : [...current, path], + ); + }; + + const runRemoval = async () => { + if (selectedPaths.length === 0 || removing) return; + setRemoving(true); + setOutcome(null); + const workspace = payload?.workspace ?? ""; + try { + const answer = await api.removeWorktrees( + selectedPaths.map((worktreeDir) => ({ workspace, worktreeDir })), + ); + setOutcome(removalOutcomeOf(answer, t)); + } catch { + setOutcome({ removed: 0, failures: [] }); + } finally { + setSelected([]); + setRemoving(false); + // Re-read either way: a transport failure may still have removed + // rows, and leaving a stale list on screen is how a user tries the + // same removal twice. + await load(); + } + }; + + const listErrorKey = payload && !payload.ok ? LIST_ERROR[payload.code ?? ""] : undefined; + const errorText = + phase === "failed" + ? t("settings.worktree.readFailed") + : phase === "loading" + ? t("settings.worktree.loading") + : listErrorKey + ? t(listErrorKey) + : payload?.error ?? null; + + return ( +
+

+ {t("settings.tab.worktree")} + +

+ +
+
+ {WORKTREE_TIME_BUCKETS.map((candidate) => ( + + ))} +
+
+ + void runRemoval()} + > + + +
+
+ + {errorText ?

{errorText}

: null} + + {outcome ? ( +

+ {t("settings.worktree.removedCount").replace("{n}", String(outcome.removed))} + {outcome.failures.map((failure) => ( + + {`${failure.worktreeDir} — ${failure.text}`} + + ))} +

+ ) : null} + + {phase === "ready" && payload?.ok && shown.length === 0 ? ( +

+ {t("settings.worktree.empty")} +

+ ) : null} + + {shown.length > 0 ? ( +
    + {shown.map((row) => { + const blocked = worktreeBlockReason(row); + const checked = selected.includes(row.path); + return ( +
  • + + + {formatModified(row.lastModifiedMs, t)} + {blocked !== null ? ( + + {t(REASON_TEXT[blocked])} + + ) : null} + +
  • + ); + })} +
+ ) : null} +
+ ); +} diff --git a/packages/webui/webapp/lib/api.ts b/packages/webui/webapp/lib/api.ts index 96cf5074..5b1dd622 100644 --- a/packages/webui/webapp/lib/api.ts +++ b/packages/webui/webapp/lib/api.ts @@ -2381,3 +2381,119 @@ export function reapplyTurnDiff( timeoutMs: TURN_DIFF_MUTATION_TIMEOUT_MS, }); } + +// --- worktrees (PB-3) ------------------------------------------------------- + +/** + * One row of `GET /api/worktrees`, exactly as the engine's + * `WorkspaceGitWorktree` (packages/local-runtime/src/files/worktrees.ts:11-20) + * spells it. Nothing is renamed and nothing is defaulted, because the page's + * three time tabs and its 「当前」 marker both read these flags directly and a + * renamed field would be a second definition of the same fact. + */ +export interface WorktreeRow { + /** Absolute path of the worktree checkout. */ + path: string; + /** Branch name with the `refs/heads/` prefix already stripped by the engine. */ + branch: string; + /** Commit sha the worktree is parked on; empty for a detached head. */ + head: string; + /** The repository's primary checkout — the engine refuses to remove it. */ + isMain: boolean; + /** `git worktree lock` was applied; the engine refuses to remove it. */ + isLocked: boolean; + /** This is the workspace the current conversation is running in. */ + isActive: boolean; + /** Lives under the repository's own `.worktrees/` directory. */ + isMcodeManaged: boolean; + /** + * Last modification, in epoch milliseconds. + * + * `undefined` is a REAL reading, not a missing field: the engine falls back + * from the directory mtime to the last reflog entry and can reach neither. + * The page shows such a row in every time tab and labels it 「时间未知」 — + * filing it at 0 would hide a fresh worktree from the default tab. + */ + lastModifiedMs?: number; +} + +/** + * The engine's closed removal-reason set + * (packages/local-runtime/src/files/managed-worktrees.ts:7-13). It is a union + * rather than `string` so that adding a reason upstream fails the page's + * reason table at compile time instead of shipping a raw token into the UI. + */ +export type WorktreeRemovalReason = + | "main_worktree" + | "active_worktree" + | "not_found" + | "locked_worktree" + | "dirty_worktree" + | "unknown"; + +/** One refused item of a batch removal, with the engine's own reason. */ +export interface WorktreeRemovalFailure { + worktreeDir: string; + reason: WorktreeRemovalReason; + error?: string; +} + +/** + * `GET /api/worktrees` — the 工作树 page's list. + * + * `ok: false` is a REPORT, not a transport failure: the engine answered and + * said the directory is not a Git repository (`code: "not_git_repository"`), + * could not be reached, or could not be listed. A page that turned that into + * an empty list would tell the user they have nothing to clean up. + */ +export interface WorktreeListPayload { + ok: boolean; + workspace: string; + /** The repository's primary checkout path, when the engine reported one. */ + current?: string; + worktrees: WorktreeRow[]; + /** Engine discovery code: `not_git_repository` / `workspace_unavailable` / `worktree_list_failed`. */ + code?: string; + error?: string; +} + +/** + * `POST /api/worktrees/remove` — the page's 一键移除. + * + * `ok: true` means the REQUEST was carried out, not that something was + * deleted: a selection where every item was refused comes back as + * `ok: true` with a full `failedItems` list. Each failure carries the + * engine's own reason, which is what the page turns into a sentence. + */ +export interface WorktreeRemovalPayload { + ok: boolean; + removedPaths: string[]; + failedItems: WorktreeRemovalFailure[]; +} + +/** + * List one repository's worktrees. + * + * `workspace` is optional: without it the server uses the current + * conversation's workspace, so the page can open with a bare GET. A browser + * outside any conversation sends the path it was given. + */ +export const getWorktrees = (workspace?: string) => + request( + workspace === undefined || workspace === "" + ? "/api/worktrees" + : `/api/worktrees?workspace=${encodeURIComponent(workspace)}`, + ); + +/** Remove a selection of worktrees. The engine decides each item's fate. */ +export const removeWorktrees = ( + items: Array<{ workspace: string; worktreeDir: string }>, + activeWorktreeDir?: string, +) => + request("/api/worktrees/remove", { + method: "POST", + json: { + items, + ...(activeWorktreeDir === undefined ? {} : { activeWorktreeDir }), + }, + }); diff --git a/packages/webui/webapp/lib/i18n.ts b/packages/webui/webapp/lib/i18n.ts index 372666e8..efa7245c 100644 --- a/packages/webui/webapp/lib/i18n.ts +++ b/packages/webui/webapp/lib/i18n.ts @@ -1236,7 +1236,41 @@ const en = { "settings.account.quotaState.notSubscribed": "No plan subscribed, no quota to read", "settings.account.quotaState.unavailable": "The engine reported the quota as unavailable", "settings.archived.empty": "No archived tasks yet", - "settings.worktree.empty": "Worktree management is not available in the local edition yet", + // PB-3 — the 工作树 tab reads `GET /api/worktrees`. The old value of + // `settings.worktree.empty` claimed the local edition could not manage + // worktrees at all, which was true about the HTTP route and false about + // the capability; the empty state is now the desktop's own sentence and + // names what is missing instead of a version. + "settings.worktree.empty": "No manageable worktree", + "settings.worktree.hint": + "A cleanup page: it lists the worktrees Git already knows about and removes the ones you select. It does not create worktrees — the desktop page has no create button either.", + "settings.worktree.loading": "Reading the worktree list…", + "settings.worktree.readFailed": "The worktree list could not be read (GET /api/worktrees)", + "settings.worktree.refresh": "Refresh the worktree list", + "settings.worktree.removeOneClick": "Remove selected", + "settings.worktree.confirmTitle": "Remove the selected worktrees?", + "settings.worktree.confirmBody": + "Only clean, unlocked worktrees can be removed. Dirty, locked, main and in-use worktrees are refused by the engine and reported back one by one.", + "settings.worktree.confirmOk": "Remove", + "settings.worktree.confirmCancel": "Cancel", + "settings.worktree.removedCount": "Removed {n} worktree(s).", + "settings.worktree.timeUnknown": "last-modified time unknown", + "settings.worktree.filter.recent3d": "Last 3 days", + "settings.worktree.filter.days3to7": "3–7 days ago", + "settings.worktree.filter.older7d": "Over 7 days ago", + "settings.worktree.listError.notGit": + "This folder is not a Git repository, so it has no worktrees (code: not_git_repository)", + "settings.worktree.listError.unavailable": + "The folder could not be read (code: workspace_unavailable)", + "settings.worktree.listError.listFailed": + "Git could not list the worktrees (code: worktree_list_failed)", + "settings.worktree.reason.main": "the main worktree cannot be removed", + "settings.worktree.reason.active": "an active session is running in it", + "settings.worktree.reason.locked": "this worktree is locked", + "settings.worktree.reason.dirty": + "it has uncommitted changes — commit, stash or discard them first", + "settings.worktree.reason.notFound": "Git no longer knows this worktree", + "settings.worktree.reason.unknown": "the engine could not say why", "settings.mode.section": "Mode", "settings.mode.coding": "Built for coding", "settings.mode.codingHint": "Keeps technical detail and developer tooling", @@ -2342,7 +2376,36 @@ const zh: Record = { "settings.account.quotaState.notSubscribed": "未订阅套餐,无配额读数", "settings.account.quotaState.unavailable": "引擎报告配额不可用", "settings.archived.empty": "暂无已归档任务", - "settings.worktree.empty": "本地版暂不支持工作树管理", + // PB-3 对应中文侧。桌面原文是「没有可管理 Worktree」,此处保留 + // 「Worktree」这一原文词,因为它是参照物上的实际措辞;「本地版暂不支持 + // 工作树管理」已删除——那句话描述的是路由,不是能力。 + "settings.worktree.empty": "没有可管理 Worktree", + "settings.worktree.hint": + "这是一个清理页:列出 Git 已登记的工作树,并移除你勾选的那些。它不新建工作树——桌面版这一页同样没有「新建」按钮。", + "settings.worktree.loading": "正在读取工作树列表…", + "settings.worktree.readFailed": "工作树列表读取失败(GET /api/worktrees)", + "settings.worktree.refresh": "刷新工作树列表", + "settings.worktree.removeOneClick": "一键移除", + "settings.worktree.confirmTitle": "确认移除所选工作树?", + "settings.worktree.confirmBody": + "只有干净、未锁定的工作树能被移除。有未提交改动、被锁定、主工作树以及会话正在使用的工作树会被引擎逐条拒绝并回报原因。", + "settings.worktree.confirmOk": "移除", + "settings.worktree.confirmCancel": "取消", + "settings.worktree.removedCount": "已移除 {n} 个工作树。", + "settings.worktree.timeUnknown": "最后修改时间未知", + "settings.worktree.filter.recent3d": "近 3 天", + "settings.worktree.filter.days3to7": "3-7 天前", + "settings.worktree.filter.older7d": "7 天以上", + "settings.worktree.listError.notGit": + "当前目录不是 Git 仓库,因此没有工作树(code: not_git_repository)", + "settings.worktree.listError.unavailable": "当前目录无法读取(code: workspace_unavailable)", + "settings.worktree.listError.listFailed": "Git 无法列出工作树(code: worktree_list_failed)", + "settings.worktree.reason.main": "主工作树不可移除", + "settings.worktree.reason.active": "有会话正在其中运行", + "settings.worktree.reason.locked": "该工作树已锁定", + "settings.worktree.reason.dirty": "存在未提交改动——请先提交、暂存或丢弃", + "settings.worktree.reason.notFound": "Git 已不再登记该工作树", + "settings.worktree.reason.unknown": "引擎未说明原因", "settings.mode.section": "模式", "settings.mode.coding": "适用于编程开发", "settings.mode.codingHint": "保留技术细节与开发工具", diff --git a/packages/webui/webapp/styles/settings-modal.css b/packages/webui/webapp/styles/settings-modal.css index d7dda813..cfd13fe7 100644 --- a/packages/webui/webapp/styles/settings-modal.css +++ b/packages/webui/webapp/styles/settings-modal.css @@ -47,6 +47,21 @@ .webui-settings-action { margin: 0 var(--spacing_20) var(--spacing_16); padding: var(--spacing_8) var(--spacing_12); border: 1px solid var(--border_default); border-radius: var(--radius_8); background: transparent; color: var(--text_default_primary); } .webui-settings-error { margin: 0 var(--spacing_20) var(--spacing_16); color: var(--text_label_danger_secondary_default); font-size: var(--size_12); } .webui-settings-empty-panel { max-width: 760px; min-height: 220px; margin: 0 auto; } + + /* PB-3 工作树页(参照 design-ref/screenshots/ref-23.jpg)。工具栏一行: + * 三档时间筛选在左,↻ 刷新与红色描边的「一键移除」在右。 */ + .webui-worktree-toolbar { display: flex; align-items: center; justify-content: space-between; gap: var(--spacing_12); padding: var(--spacing_12) var(--spacing_20); } + .webui-worktree-actions { display: flex; align-items: center; gap: var(--spacing_8); } + .webui-worktree-remove { border: 1px solid var(--border_danger_default, var(--border_default)); background: transparent; color: var(--text_label_danger_secondary_default, var(--text_default_primary)); } + .webui-worktree-failure { display: block; margin-top: var(--spacing_4); } + .webui-worktree-list { margin: 0; padding: 0; list-style: none; } + .webui-worktree-row { display: flex; min-height: 56px; align-items: center; justify-content: space-between; gap: var(--spacing_16); padding: var(--spacing_8) var(--spacing_20); border-top: 1px solid var(--border_default); } + .webui-worktree-row-main { display: flex; min-width: 0; flex: 1; align-items: center; gap: var(--spacing_12); } + .webui-worktree-row-copy { display: flex; min-width: 0; flex-direction: column; gap: var(--spacing_4); } + .webui-worktree-row-copy strong { overflow: hidden; color: var(--text_default_primary); font-size: var(--size_14); font-weight: var(--weight_regular); text-overflow: ellipsis; white-space: nowrap; } + .webui-worktree-row-copy span { overflow: hidden; color: var(--text_default_tertiary); font-size: var(--size_12); text-overflow: ellipsis; white-space: nowrap; } + .webui-worktree-row-meta { display: flex; flex-shrink: 0; flex-direction: column; align-items: flex-end; gap: var(--spacing_4); color: var(--text_default_tertiary); font-size: var(--size_12); } + .webui-worktree-badge { color: var(--text_label_danger_secondary_default, var(--text_default_secondary)); font-style: normal; } .webui-settings-data-dir { max-width: 760px; margin: var(--spacing_32) auto 0; color: var(--text_default_tertiary); font-size: var(--size_12); word-break: break-all; } .webui-settings-empty { height: var(--spacing_128); } .webui-settings-theme-options { display: flex; gap: var(--spacing_12); } diff --git a/packages/webui/webapp/test/dom-harness.test.ts b/packages/webui/webapp/test/dom-harness.test.ts new file mode 100644 index 00000000..6bb5b576 --- /dev/null +++ b/packages/webui/webapp/test/dom-harness.test.ts @@ -0,0 +1,259 @@ +// webapp/test/dom-harness.test.ts +// +// The contract of the DOM harness itself. +// +// A test harness that is not itself tested is a liability: the next +// author who reaches for `pressKey` and finds it silently does nothing +// writes a test that passes and proves nothing — the exact failure mode +// this harness was built to end (SB-2's M9 survived because a string of +// markup could not dispatch an event). Every member of the handle is +// exercised here against a component that records what it received, so a +// regression in the harness fails loudly and in one place. +// +// This file is also where the harness's own rules are pinned, because +// they are invisible from a test that happens to satisfy them: +// - a `data-testid` that is not on screen throws, and the error names +// the testids that ARE; +// - events bubble from the element, they are not faked at the root; +// - `withDom` detaches the container even when the body throws; +// - storage is one origin shared by every mount, and `resetStorage` +// empties it. + +import { test, describe, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { createElement, useEffect, useState } from "react"; + +import { mount, withDom, resetStorage } from "./helpers/dom"; + +/** Everything the component under test saw, as plain data. */ +interface Log { + events: string[]; + value: string; +} + +function Probe({ onLog }: { onLog: (log: Log) => void }) { + const [value, setValue] = useState(""); + const [events, setEvents] = useState([]); + const record = (entry: string) => { + setEvents((previous) => [...previous, entry]); + onLog({ events: [...events, entry], value }); + }; + return createElement( + "div", + null, + createElement("input", { + "data-testid": "input", + value, + onChange: (event: React.ChangeEvent) => setValue(event.target.value), + }), + createElement("button", { + "data-testid": "outer", + onClick: () => record(`outer:${value}`), + }, "outer"), + createElement( + "span", + { "data-testid": "inner" }, + createElement("span", { + "data-testid": "inner-span", + onKeyDown: (event: React.KeyboardEvent) => { + event.preventDefault(); + record(`key:${event.key}:${event.ctrlKey ? "ctrl" : "-"}`); + }, + }, "inner"), + ), + createElement("p", { "data-testid": "log" }, events.join("|")), + createElement("p", { "data-testid": "twins" }, ""), + createElement("p", { "data-testid": "twins" }, ""), + ); +} + +const nothing = () => {}; + +/** A probe with no observers, for the plumbing assertions. */ +const probe = () => createElement(Probe, { onLog: nothing }); + +beforeEach(() => resetStorage()); + +describe("a missing testid fails with the tree that is actually there", () => { + test("find throws, and lists what rendered", async () => { + await withDom(probe(), async (view) => { + assert.throws( + () => view.find("nope"), + (error: Error) => { + assert.match(error.message, /no element with data-testid="nope"/); + // The diagnosis, not just the failure: which testids exist. + assert.match(error.message, /\n {2}input\n/); + assert.match(error.message, /\n {2}log\n/); + return true; + }, + ); + assert.equal(view.query("nope"), null, "query is the non-throwing lookup"); + assert.equal(view.has("nope"), false); + }); + }); + + test("an empty tree says so rather than printing nothing", async () => { + await withDom(createElement("div", null), async (view) => { + assert.throws(() => view.find("input"), /the tree rendered empty/); + }); + }); +}); + +describe("queries address the real tree", () => { + test("text returns the content, and null for an absent node", async () => { + await withDom(probe(), async (view) => { + assert.equal(view.text("log"), ""); + assert.equal(view.text("nope"), null); + }); + }); + + test("findAll returns every match in document order", async () => { + await withDom(probe(), async (view) => { + assert.equal(view.findAll("twins").length, 2); + assert.equal(view.findAll("input").length, 1); + assert.deepEqual(view.findAll("nope"), []); + }); + }); + + test("html serializes the container, and the container is reachable", async () => { + await withDom(probe(), async (view) => { + assert.match(view.html(), /data-testid="input"/); + assert.equal(view.container.getAttribute("data-testid"), "dom-harness-root"); + assert.equal(view.container.parentElement, document.body); + }); + }); +}); + +describe("events reach the handler that is listening", () => { + test("pressKey delivers key and modifiers to the element that owns the handler", async () => { + await withDom(probe(), async (view) => { + await view.pressKey("inner-span", { key: "k", ctrlKey: true }); + assert.equal(view.text("log"), "key:k:ctrl"); + }); + }); + + test("the event bubbles, and a handler above the target sees it", async () => { + await withDom(probe(), async (view) => { + await view.click("outer"); + assert.equal(view.text("log"), "outer:"); + }); + }); + + test("keyEvent builds the event without delivering it", async () => { + await withDom(probe(), async (view) => { + const event = view.keyEvent({ key: "k", ctrlKey: true }); + assert.equal(event.type, "keydown"); + assert.equal(event.bubbles, true); + assert.equal(event.cancelable, true); + assert.equal(event.defaultPrevented, false); + assert.equal(view.text("log"), "", "building an event delivers nothing"); + + // Delivering it by hand, inside `run`, is what the handler cases do. + await view.run(() => view.find("inner-span").dispatchEvent(event)); + assert.equal(view.text("log"), "key:k:ctrl"); + assert.equal(event.defaultPrevented, true, "the handler consumed the event"); + }); + }); + + test("fire delivers an arbitrary event type", async () => { + const seen: string[] = []; + const Doubler = () => + createElement("div", { + "data-testid": "target", + onDoubleClick: () => seen.push("dblclick"), + }); + await withDom(createElement(Doubler), async (view) => { + await view.fire("target", "dblclick"); + assert.deepEqual(seen, ["dblclick"]); + }); + }); + + test("type writes a value React can see, through the native setter", async () => { + await withDom(probe(), async (view) => { + // Assigning `.value` directly is swallowed by React's value tracker; + // the harness goes through the prototype setter for that reason. + await view.type("input", "hello"); + assert.equal(view.find("input").getAttribute("value"), "hello"); + }); + }); +}); + +describe("the root can be re-rendered and drained", () => { + test("rerender replaces the tree and settles its effects", async () => { + // The effect writes what it saw, so the assertion is about WHEN it + // ran, not about the markup. `mount` wraps the first render in `act` + // for exactly this: effects have run before the handle is returned. + const WithEffect = ({ label }: { label: string }) => { + const [seen, setSeen] = useState(""); + useEffect(() => setSeen(label), [label]); + return createElement("p", { "data-testid": "label" }, seen); + }; + await withDom(createElement(WithEffect, { label: "first" }), async (view) => { + assert.equal(view.text("label"), "first", "the mount effect already ran"); + await view.rerender(createElement(WithEffect, { label: "second" })); + assert.equal(view.text("label"), "second", "and so did the one after the re-render"); + }); + }); + + test("flush drains a zero-delay timer queued from an effect", async () => { + const done: string[] = []; + const Timed = () => { + useEffect(() => { + setTimeout(() => done.push("late"), 5); + }, []); + return createElement("p", { "data-testid": "timed" }, "mounted"); + }; + await withDom(createElement(Timed), async (view) => { + assert.equal(view.text("timed"), "mounted"); + assert.deepEqual(done, [], "the effect ran, the timer has not"); + await new Promise((resolve) => setTimeout(resolve, 10)); + await view.flush(); + assert.deepEqual(done, ["late"]); + }); + }); +}); + +describe("unmount leaves nothing behind", () => { + test("unmount detaches the container and is idempotent", async () => { + const view = await mount(probe()); + assert.equal(document.querySelectorAll('[data-testid="dom-harness-root"]').length, 1); + await view.unmount(); + await view.unmount(); + assert.equal(document.querySelectorAll('[data-testid="dom-harness-root"]').length, 0); + }); + + test("withDom unmounts on the throw path too", async () => { + await assert.rejects( + withDom(probe(), async () => { + throw new Error("assertion failed"); + }), + /assertion failed/, + ); + assert.equal( + document.querySelectorAll('[data-testid="dom-harness-root"]').length, + 0, + "a failing assertion must not leave a live root for the next test", + ); + }); +}); + +describe("storage is one origin, shared across mounts", () => { + test("a write in one mount is visible in the next", async () => { + await withDom(probe(), async (view) => { + view.window.localStorage.setItem("harness-probe", "kept"); + }); + await withDom(probe(), (view) => { + assert.equal(view.window.localStorage.getItem("harness-probe"), "kept"); + }); + }); + + test("resetStorage empties it, which is why a beforeEach calls it", async () => { + await withDom(probe(), (view) => { + view.window.localStorage.setItem("harness-probe", "dropped"); + }); + resetStorage(); + await withDom(probe(), (view) => { + assert.equal(view.window.localStorage.getItem("harness-probe"), null); + }); + }); +}); diff --git a/packages/webui/webapp/test/helpers/dom.ts b/packages/webui/webapp/test/helpers/dom.ts new file mode 100644 index 00000000..b1933f4a --- /dev/null +++ b/packages/webui/webapp/test/helpers/dom.ts @@ -0,0 +1,283 @@ +// webapp/test/helpers/dom.ts +// +// A mounted-DOM harness for the Node test runner: real elements, real +// bubbling events, real React effects. +// +// Why it exists +// ------------- +// +// The webapp suite renders through `react-dom/server`'s +// `renderToStaticMarkup`, which produces a string and therefore cannot +// dispatch an event, run an effect, or observe a re-render. A defect that +// only shows up when a keydown actually reaches a handler is therefore +// invisible to it: the shortcut rebind box in +// `components/settings-extra-pages.tsx` had a whole capture → verdict → +// conflict-report path that no test could enter, and a mutation that +// swallowed the conflict report left the suite green (SB-2's M9). +// +// This module closes that hole without displacing `renderToStaticMarkup`. +// The two coexist by design: static markup is the right tool for "what +// does this page print", a mounted root for "what happens when the user +// presses a key". A test file picks one, or both. +// +// Why happy-dom +// ------------- +// +// The three candidates, on the axes that decide it here: +// +// | dependency | transitive deps | what it buys | what it costs | +// |-------------------|-----------------|------------------------|--------------------------| +// | `jsdom` 30 | 22 | the reference impl. | `undici` + `css-tree` + | +// | | | | `whatwg-url` ≈ 20 MB | +// | `happy-dom` 20 | 4 | elements, events, | 8 MB unpacked | +// | | | storage, MutationObs. | | +// | `@testing-library` | 2 more on top | queries, `user-event` | needs its own global | +// | | of either | ergonomics | install/cleanup protocol | +// +// The suite drives `node:test`, not Vitest's DOM environment, so +// testing-library would bring its own `beforeEach`/auto-cleanup machinery +// that this runner does not have. Everything it would provide — mount, +// query, dispatch, unmount — is about forty lines here, and those forty +// lines are the contract this suite actually wants to read. jsdom buys +// spec completeness this suite does not exercise: the tests assert on +// attributes, text and event delivery, none of which is where jsdom and +// happy-dom diverge in practice. +// +// `dom-shim.ts` stays as it is. It serves a `DOMParser` to the markdown +// walker over `parse5` and needs no window at all; replacing it with a +// full DOM would be a downgrade. +// +// Import order +// ------------ +// +// `react-dom` captures `canUseDOM` when it is first evaluated, so the +// globals below must exist before it loads. ES modules evaluate a +// module's dependencies in declaration order, so **import this module +// before any component import** in a test file. `react-dom/client` is +// then pulled in with a dynamic import from here, so it cannot be +// evaluated early even if a component reaches it. Getting the order +// wrong is loud, not silent: React falls back to its no-DOM host config +// and `mount()` renders nothing, so the first assertion fails. + +import { act } from "react"; +import type { ReactElement } from "react"; + +import { installHappyDom, type HappyWindow } from "./happy-window"; + +// `IS_REACT_ACT_ENVIRONMENT` is React 18's switch for "this root is +// driven by a test, not by a user". Without it `act()` warns on every +// call. Declared through a typed alias rather than a `var` redeclaration: +// `lib: ["dom"]` already types the ambient global, and a narrower +// structural type fails `tsc` with TS2403. +const actEnvironment = globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }; +actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + +/** One event to deliver to a mounted tree. */ +export interface KeyPress { + key: string; + ctrlKey?: boolean; + altKey?: boolean; + shiftKey?: boolean; + metaKey?: boolean; +} + +/** A live React root over a real DOM subtree. */ +export interface Mounted { + /** The element the tree is mounted into. */ + readonly container: HTMLElement; + /** The window backing the tree; its `localStorage` is the real one. */ + readonly window: HappyWindow; + + /** The one element with `data-testid=""`, or a thrown error. */ + find(testId: string): HTMLElement; + /** The same lookup without the throw. */ + query(testId: string): HTMLElement | null; + /** Every element with the testid, in document order. */ + findAll(testId: string): HTMLElement[]; + /** `textContent` of the testid element, or `null` when absent. */ + text(testId: string): string | null; + /** Whether the testid element is currently rendered. */ + has(testId: string): boolean; + + /** Dispatch a bubbling, cancelable `keydown` and await React's flush. */ + pressKey(target: string | HTMLElement, press: KeyPress): Promise; + /** + * Build a `keydown` the harness will dispatch, for the cases that assert + * on the event object itself — whether the handler cancelled it, most of + * all, which is the only evidence that a captured combination did not + * also run its own action. + */ + keyEvent(press: KeyPress): KeyboardEvent; + /** Dispatch a bubbling, cancelable `click` and await React's flush. */ + click(target: string | HTMLElement): Promise; + /** Dispatch any event type and await React's flush. */ + fire(target: string | HTMLElement, type: string, init?: EventInit): Promise; + /** + * Run an arbitrary block inside `act`, for the cases the typed helpers + * do not cover — asserting on the raw `KeyboardEvent` object after the + * handler consumed it, most of all. Dispatching outside `act` still + * works but makes React warn on every state update it causes. + */ + run(fn: () => T | Promise): Promise; + /** Type into an input the way a user does, one value + input event. */ + type(target: string | HTMLElement, value: string): Promise; + + /** Re-render the root with a new tree, awaiting effects. */ + rerender(node: ReactElement): Promise; + /** Let queued microtasks and zero-delay timers drain. */ + flush(): Promise; + /** The container's serialized markup, for snapshot-style assertions. */ + html(): string; + + /** Unmount the root and detach the container. Idempotent. */ + unmount(): Promise; +} + +// The window has to exist before `react-dom` is evaluated: react-dom +// captures `canUseDOM` once, when it is first evaluated, and a false +// there silently disables the whole delegated event system. This module's +// body is the only place guaranteed to run before the dynamic import +// below, so the installation happens here rather than on first `mount()`. +const sharedWindow: HappyWindow = installHappyDom(); + +// `react-dom` is imported dynamically, AFTER the window above exists — +// see the import-order note at the top of this file. +const { createRoot } = await import("react-dom/client"); + +/** Empty `localStorage`, for a `beforeEach`. */ +export function resetStorage(): void { + sharedWindow.localStorage.clear(); +} + +/** The first `data-testid` match, or a thrown error carrying the markup. */ +function requireTestId(container: HTMLElement, testId: string): HTMLElement { + const found = container.querySelector(`[data-testid="${testId}"]`); + if (!found) { + const rendered = Array.from(container.querySelectorAll("[data-testid]")) + .map((node) => ` ${node.getAttribute("data-testid")}`) + .join("\n"); + throw new Error( + `no element with data-testid="${testId}" in the mounted tree.\n` + + `testids actually rendered:\n${rendered || " (none — the tree rendered empty)"}`, + ); + } + return found as HTMLElement; +} + +function resolveTarget(container: HTMLElement, target: string | HTMLElement): HTMLElement { + return typeof target === "string" ? requireTestId(container, target) : target; +} + +/** Let React's work loop and any pending timers settle. */ +async function settle(): Promise { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +/** + * Mount `node` into the shared document and return a handle over it. + * + * The initial render is wrapped in `act`, so effects (`useEffect`, + * `useLayoutEffect`) have run by the time this resolves — a test that + * mounts and immediately reads the DOM sees the settled tree, not the + * first pass. + */ +export async function mount(node: ReactElement): Promise { + const container = document.createElement("div"); + container.setAttribute("data-testid", "dom-harness-root"); + document.body.appendChild(container); + + const root = createRoot(container); + let disposed = false; + await act(async () => { + root.render(node); + }); + + const handle: Mounted = { + container, + window: sharedWindow, + + find: (testId) => requireTestId(container, testId), + query: (testId) => container.querySelector(`[data-testid="${testId}"]`) as HTMLElement | null, + findAll: (testId) => Array.from(container.querySelectorAll(`[data-testid="${testId}"]`)), + text: (testId) => handle.query(testId)?.textContent ?? null, + has: (testId) => handle.query(testId) !== null, + + // The bare `Event` / `KeyboardEvent` / `MouseEvent` identifiers are + // the globals `happy-window.ts` installed: at runtime they are + // happy-dom's constructors, while `tsc` checks them against lib.dom. + // That is the one place the two DOM typings meet, and it is where + // they agree — dispatch only needs `type`, `bubbles` and + // `cancelable`, which both declare. + keyEvent: (init) => new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...init }), + async pressKey(target, press) { + const element = resolveTarget(container, target); + await act(async () => { + element.dispatchEvent(handle.keyEvent(press)); + }); + }, + async click(target) { + const element = resolveTarget(container, target); + await act(async () => { + element.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + }, + async fire(target, type, init) { + const element = resolveTarget(container, target); + await act(async () => { + element.dispatchEvent(new Event(type, { bubbles: true, cancelable: true, ...init })); + }); + }, + async type(target, value) { + const element = resolveTarget(container, target) as HTMLInputElement; + // React reads the value off the DOM node, so the native setter has + // to be used: assigning `.value` directly is swallowed by React's + // value tracker and the component never sees a change. + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + await act(async () => { + setter?.call(element, value); + element.dispatchEvent(new Event("input", { bubbles: true })); + }); + }, + + async run(fn) { + return act(async () => fn()); + }, + + async rerender(next) { + await act(async () => { + root.render(next); + }); + }, + flush: settle, + html: () => container.innerHTML, + + async unmount() { + if (disposed) return; + disposed = true; + await act(async () => { + root.unmount(); + }); + container.remove(); + }, + }; + + return handle; +} + +/** + * Mount `node`, hand it to `fn`, and unmount it whatever `fn` does. + * + * The unmount runs on the throw path too: a failing assertion must not + * leave a live root (and its timers) behind for the next test in the + * file. + */ +export async function withDom(node: ReactElement, fn: (view: Mounted) => Promise | T): Promise { + const view = await mount(node); + try { + return await fn(view); + } finally { + await view.unmount(); + } +} diff --git a/packages/webui/webapp/test/helpers/happy-window.ts b/packages/webui/webapp/test/helpers/happy-window.ts new file mode 100644 index 00000000..54690dcc --- /dev/null +++ b/packages/webui/webapp/test/helpers/happy-window.ts @@ -0,0 +1,112 @@ +// webapp/test/helpers/happy-window.ts +// +// The one place that puts a DOM on `globalThis` for the Node test +// runner. Split out of `dom.ts` so the installation can happen at module +// evaluation time, before `dom.ts` reaches for `react-dom`. +// +// Two constraints shaped the code: +// +// 1. Node ≥ 21 ships a read-only `globalThis.navigator`, so a plain +// assignment throws in an ES module. Every global goes in through +// `Object.defineProperty`, which is also the pattern +// AGENTS.md prescribes for stubbing ambient globals in tests. +// 2. The window must be reachable for `new win.KeyboardEvent(…)`. +// Dispatching an event the component did not construct is a real +// difference from a browser: React compares against the event's own +// prototypes, and a Node `Event` would be a different object. + +import { Window } from "happy-dom"; + +/** The happy-dom window the harness drives. */ +export type HappyWindow = Window; + +/** + * The globals copied off the window. + * + * Deliberately explicit rather than a `for (const key of Object.keys(win))` + * loop: a blanket copy would overwrite Node's own `Event`, `AbortController` + * and `fetch`-adjacent globals with happy-dom's narrower implementations, + * and a test that wanted the real one could not get it back. The list is + * what a React webapp actually reaches for. + */ +const EXPOSED = [ + "window", + "document", + "navigator", + "location", + "history", + "localStorage", + "sessionStorage", + "getComputedStyle", + "requestAnimationFrame", + "cancelAnimationFrame", + "matchMedia", + "Event", + "CustomEvent", + "UIEvent", + "KeyboardEvent", + "MouseEvent", + "InputEvent", + "FocusEvent", + "PointerEvent", + "ClipboardEvent", + "Node", + "Element", + "HTMLElement", + "HTMLInputElement", + "HTMLButtonElement", + "HTMLTextAreaElement", + "HTMLSelectElement", + "HTMLFormElement", + "HTMLAnchorElement", + "SVGElement", + "Text", + "Comment", + "DocumentFragment", + "ShadowRoot", + "NodeList", + "HTMLCollection", + "DOMParser", + "XMLSerializer", + "MutationObserver", + "ResizeObserver", + "IntersectionObserver", + "PerformanceObserver", + "AbortController", + "Blob", + "URL", + "CSS", + "Image", +] as const; + +let installed: Window | null = null; + +/** + * Create a happy-dom window and publish its globals. + * + * Idempotent: the same window is returned for every call, so a test file + * that mounts ten components still has one `localStorage` origin and one + * `document`. + */ +export function installHappyDom(url = "http://localhost/"): Window { + if (installed) return installed; + + const win = new Window({ url }); + const target = globalThis as unknown as Record; + + for (const key of EXPOSED) { + const value = (win as unknown as Record)[key]; + if (value === undefined) continue; + // `window` is published as the window itself, not as a property of + // it — happy-dom's `win.window` is already `win`. + Object.defineProperty(target, key, { + configurable: true, + writable: true, + enumerable: false, + value: key === "window" ? win : value, + }); + } + + installed = win; + return win; +} diff --git a/packages/webui/webapp/test/settings-parity-nav.test.ts b/packages/webui/webapp/test/settings-parity-nav.test.ts index eacadb49..dec52d61 100644 --- a/packages/webui/webapp/test/settings-parity-nav.test.ts +++ b/packages/webui/webapp/test/settings-parity-nav.test.ts @@ -150,11 +150,14 @@ describe("settings tab registry parity (webui-parity 58 line A)", () => { ); }); - test("the 55a sub-pages render in their tabs; worktree stays an honest placeholder", () => { + test("the 55a sub-pages render in their tabs; worktree renders the real section", () => { // 工单 58 线 D:55a 的四子页内容已接进移植壳的对应 Tab(voice / // shortcuts / custom-instructions / coding),记忆摘要弹窗由 - // PersonalizationSection 内部管理;工作树与已归档任务仍无本地内容, - // 保持参照的诚实空面板。 + // PersonalizationSection 内部管理;已归档任务仍无本地内容,保持参照的 + // 诚实空面板。工作树页签自 PB-3 起不再是占位——它接上引擎 + // (`GET /api/worktrees` / `POST /api/worktrees/remove`),因此本组断言 + // 从「诚实占位」翻转为「真实分区」,且必须同时钉住旧占位文案已消失: + // 那句「本地版暂不支持工作树管理」描述的是路由而不是能力,留着就是失真。 for (const [tab, component] of [ ["voice", "VoiceSection"], ["shortcuts", "ShortcutsSection"], @@ -171,12 +174,18 @@ describe("settings tab registry parity (webui-parity 58 line A)", () => { "worktree renders a real panel (59 B2: the blank div is gone)", ); assert.ok( + portSource.includes(""), + "PB-3: the worktree tab must render the engine-backed section", + ); + assert.equal( portSource.includes('title={t("settings.tab.worktree")}'), - "the worktree panel carries the nav tab's own title", + false, + "the old inline panel is gone; the section carries its own title", ); - assert.ok( - portSource.includes('{t("settings.worktree.empty")}'), - "worktree states honestly that it is not available locally yet — no blank panel", + assert.equal( + portSource.includes('t("settings.worktree.empty")'), + false, + "the render branch no longer prints the placeholder sentence itself", ); }); diff --git a/packages/webui/webapp/test/settings-worktree-section.test.ts b/packages/webui/webapp/test/settings-worktree-section.test.ts new file mode 100644 index 00000000..2351d176 --- /dev/null +++ b/packages/webui/webapp/test/settings-worktree-section.test.ts @@ -0,0 +1,314 @@ +// webapp/test/settings-worktree-section.test.ts +// +// The 工作树 settings page (placeholder batch PB-3). +// +// Two halves, and the split is deliberate: +// +// - The three time tabs and the protected-row rule are PURE functions +// (`worktreeAgeBucket` / `visibleWorktrees` / `worktreeBlockReason`), +// and they are tested on their inputs — including the two boundaries +// where a `<` / `<=` slip would quietly move a row between tabs, and +// the "no timestamp" case that must be visible in every tab rather +// than filed at epoch 0 under 「7 天以上」. +// - The rendered markup is checked for the things a source grep cannot +// decide: that the toolbar really carries the desktop's three tabs in +// order, that the page has NO create control (the desktop reference +// `design-ref/screenshots/ref-23.jpg` has none, and the port declares +// no create either), and that the failure reasons reach the DOM as +// sentences rather than as raw enum tokens. +// +// The section fetches on mount, and `renderToStaticMarkup` does not run +// effects, so the list body is exercised through the pure derivations and +// through `removalOutcomeOf` rather than through a fake network. The +// wiring from the settings tab to the section is pinned by a source +// tripwire at the bottom — the render harness cannot mount the modal +// (it pulls in the whole store/api graph), and a tripwire is the +// acceptable substitute there. + +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { + WORKTREE_DAY_MS, + WORKTREE_TIME_BUCKETS, + WorktreeSection, + removalOutcomeOf, + visibleWorktrees, + worktreeAgeBucket, + worktreeBlockReason, + worktreeBucketLabelKey, + worktreeReasonLabelKey, +} from "../components/settings-worktree-section"; +import { translate, type MessageKey } from "../lib/i18n"; +import type { WorktreeRow } from "../lib/api"; + +const tZh = (key: MessageKey) => translate("zh", key); +const tEn = (key: MessageKey) => translate("en", key); + +const NOW = 1_700_000_000_000; + +function row(overrides: Partial = {}): WorktreeRow { + return { + path: "/repo/.worktrees/a", + branch: "feature/a", + head: "abc1234", + isMain: false, + isLocked: false, + isActive: false, + isMcodeManaged: true, + lastModifiedMs: NOW - WORKTREE_DAY_MS, + ...overrides, + }; +} + +describe("the three time tabs", () => { + test("the buckets are the desktop's, in the desktop's order", () => { + assert.deepEqual([...WORKTREE_TIME_BUCKETS], ["recent3d", "days3to7", "older7d"]); + assert.deepEqual( + WORKTREE_TIME_BUCKETS.map((bucket) => tZh(worktreeBucketLabelKey(bucket))), + ["近 3 天", "3-7 天前", "7 天以上"], + ); + }); + + test("the band boundaries are inclusive at the top of each band", () => { + // EXACTLY 3 days is still 近 3 天; one millisecond older is not. A + // `<`/`<=` slip here moves a row across tabs on a boundary nobody can + // see by eye, which is why both sides are pinned. + assert.equal(worktreeAgeBucket(NOW - 3 * WORKTREE_DAY_MS, NOW), "recent3d"); + assert.equal(worktreeAgeBucket(NOW - 3 * WORKTREE_DAY_MS - 1, NOW), "days3to7"); + // EXACTLY 7 days is still 3-7 天前. + assert.equal(worktreeAgeBucket(NOW - 7 * WORKTREE_DAY_MS, NOW), "days3to7"); + assert.equal(worktreeAgeBucket(NOW - 7 * WORKTREE_DAY_MS - 1, NOW), "older7d"); + }); + + test("a fresh, an old and a future timestamp each land in one band", () => { + assert.equal(worktreeAgeBucket(NOW, NOW), "recent3d"); + assert.equal(worktreeAgeBucket(NOW - 400 * WORKTREE_DAY_MS, NOW), "older7d"); + // Clock skew between the workstation and the machine hosting the repo. + // "Modified in the future" is closest to "modified just now"; hiding + // the row would be worse than mis-bucketing it. + assert.equal(worktreeAgeBucket(NOW + 60_000, NOW), "recent3d"); + }); + + test("a missing or nonsensical timestamp has no band at all", () => { + assert.equal(worktreeAgeBucket(undefined, NOW), null); + assert.equal(worktreeAgeBucket(Number.NaN, NOW), null); + assert.equal(worktreeAgeBucket(Number.POSITIVE_INFINITY, NOW), null); + }); +}); + +describe("filtering keeps every row reachable", () => { + const rows: WorktreeRow[] = [ + row({ path: "/fresh", lastModifiedMs: NOW - WORKTREE_DAY_MS }), + row({ path: "/edge3d", lastModifiedMs: NOW - 3 * WORKTREE_DAY_MS }), + row({ path: "/edge7d", lastModifiedMs: NOW - 7 * WORKTREE_DAY_MS }), + row({ path: "/old", lastModifiedMs: NOW - 30 * WORKTREE_DAY_MS }), + // The engine read neither the directory mtime nor the reflog. + row({ path: "/unknown", lastModifiedMs: undefined }), + ]; + + test("each tab shows its own band", () => { + assert.deepEqual( + visibleWorktrees(rows, "recent3d", NOW).map((item) => item.path), + ["/fresh", "/edge3d", "/unknown"], + ); + assert.deepEqual( + visibleWorktrees(rows, "days3to7", NOW).map((item) => item.path), + ["/edge7d", "/unknown"], + "exactly 3 days old stays in 近 3 天 (the band boundary is inclusive at its top), so /edge3d is not repeated here", + ); + assert.deepEqual( + visibleWorktrees(rows, "older7d", NOW).map((item) => item.path), + ["/old", "/unknown"], + ); + }); + + test("a row with no timestamp is visible in EVERY tab, not hidden or zeroed", () => { + for (const bucket of WORKTREE_TIME_BUCKETS) { + assert.ok( + visibleWorktrees(rows, bucket, NOW).some((item) => item.path === "/unknown"), + `${bucket} must still show the row whose age the engine could not report`, + ); + } + // And it is not filed at epoch 0 either, which is what would have put + // it under 7 天以上. + assert.equal(worktreeAgeBucket(0, NOW), "older7d"); + assert.equal(worktreeAgeBucket(0, NOW) === "older7d", true); + assert.notEqual(worktreeAgeBucket(undefined, NOW), "older7d"); + }); +}); + +describe("the protected rows", () => { + test("main beats active: the primary checkout is usually the active one too", () => { + const both = row({ isMain: true, isActive: true }); + assert.equal(worktreeBlockReason(both), "main_worktree"); + // The engine checks in this order (managed-worktrees.ts:221-259), so + // the label shown matches the reason it would actually return. + assert.equal(worktreeBlockReason(row({ isActive: true })), "active_worktree"); + assert.equal(worktreeBlockReason(row({ isLocked: true })), "locked_worktree"); + assert.equal(worktreeBlockReason(row()), null); + }); + + test("a clean row is removable", () => { + assert.equal(worktreeBlockReason(row({ path: "/plain" })), null); + }); +}); + +describe("removal verdicts reach the page as sentences", () => { + test("every reason in the closed set has a translation, in both languages", () => { + const reasons = [ + "main_worktree", + "active_worktree", + "locked_worktree", + "dirty_worktree", + "not_found", + "unknown", + ] as const; + for (const reason of reasons) { + // The key comes from the page's OWN table, so a table that lost a + // row fails here instead of quietly rendering the wrong sentence. + const key = worktreeReasonLabelKey(reason); + const zh = tZh(key); + const en = tEn(key); + assert.ok(zh.length > 0, `${reason} must have a Chinese sentence`); + assert.ok(en.length > 0, `${reason} must have an English sentence`); + assert.notEqual(zh, en, `${reason} must be translated, not copied`); + assert.doesNotMatch(zh, /^[a-z_]+$/, `${reason} leaked a raw enum token into the UI`); + } + }); + + test("a fully refused batch reports 0 removed and one line per refusal", () => { + const outcome = removalOutcomeOf( + { + ok: true, + removedPaths: [], + failedItems: [ + { worktreeDir: "/repo", reason: "main_worktree" }, + { worktreeDir: "/repo/.worktrees/a", reason: "dirty_worktree" }, + { worktreeDir: "/repo/.worktrees/b", reason: "active_worktree" }, + ], + }, + tZh, + ); + assert.equal(outcome.removed, 0); + assert.deepEqual( + outcome.failures.map((line) => line.text), + [ + "主工作树不可移除", + "存在未提交改动——请先提交、暂存或丢弃", + "有会话正在其中运行", + ], + "the three refusals the acceptance criteria name each survive as their own sentence", + ); + assert.deepEqual( + outcome.failures.map((line) => line.worktreeDir), + ["/repo", "/repo/.worktrees/a", "/repo/.worktrees/b"], + ); + }); + + test("a partial batch keeps both halves", () => { + const outcome = removalOutcomeOf( + { + ok: true, + removedPaths: ["/repo/.worktrees/gone"], + failedItems: [{ worktreeDir: "/repo", reason: "main_worktree" }], + }, + tEn, + ); + assert.equal(outcome.removed, 1); + assert.equal(outcome.failures.length, 1); + assert.match(outcome.failures[0]!.text, /main worktree/); + }); +}); + +describe("the rendered page", () => { + const markup = renderToStaticMarkup(createElement(WorktreeSection, { t: tZh })); + + test("the toolbar carries the three tabs in the desktop's order", () => { + const at = (label: string) => markup.indexOf(label); + assert.ok(at("近 3 天") > 0, "近 3 天 must render"); + assert.ok(at("3-7 天前") > at("近 3 天"), "the tab order must match the desktop reference"); + assert.ok(at("7 天以上") > at("3-7 天前")); + assert.match(markup, /role="radiogroup"/); + assert.match(markup, /aria-checked="true"/, "the default tab must be the selected one"); + }); + + test("the toolbar carries refresh and the red-outlined 一键移除", () => { + assert.ok(markup.includes("↻"), "the refresh affordance must render"); + assert.match(markup, /一键移除/); + assert.match(markup, /webui-worktree-remove/); + }); + + test("the page has NO create control — the desktop reference has none", () => { + // The port declares list / remove / removeBatch and no create, and + // ref-23.jpg shows a cleanup page. A 新建 button here would be + // self-authored UI on both the client and the engine side. + for (const word of ["新建", "创建", "Add", "Create", "New worktree"]) { + assert.equal( + markup.includes(word), + false, + `the page must not render a create affordance, found "${word}"`, + ); + } + }); + + test("the popconfirm is wired to the removal, and the button starts disabled", () => { + // With nothing selected the button cannot act, so it must not be + // clickable — the desktop's red outline is a confirmation gate, not a + // live-fire button. + assert.match(markup, /一键移除<\/button><\/span>|一键移除/); + assert.match(markup, /disabled=""/, "the remove control must start disabled with no selection"); + }); +}); + +describe("the list failure is named, not swallowed", () => { + test("each discovery code has its own sentence in both languages", () => { + const codes = ["notGit", "unavailable", "listFailed"] as const; + for (const code of codes) { + const key = `settings.worktree.listError.${code}` as MessageKey; + assert.match(tZh(key), /code: /, `${code} must name the engine code verbatim`); + assert.match(tEn(key), /code: /); + } + assert.notEqual(tZh("settings.worktree.listError.notGit"), tZh("settings.worktree.listError.unavailable")); + }); + + test("the empty state is the desktop's own sentence, not the old version claim", () => { + // 「本地版暂不支持工作树管理」 was true about the route and false about + // the capability; the sentence had to change or the delivery would lie + // on its first screen. + assert.equal(tZh("settings.worktree.empty"), "没有可管理 Worktree"); + assert.equal(tEn("settings.worktree.empty"), "No manageable worktree"); + assert.doesNotMatch(tZh("settings.worktree.empty"), /不支持/); + }); +}); + +describe("the wiring is pinned", () => { + test("the settings modal renders this section for the worktree tab", () => { + const source = readFileSync( + new URL("../components/settings-modal-port.tsx", import.meta.url), + "utf8", + ); + assert.match( + source, + /active === "worktree"[\s\S]{0,900}/, + "the 工作树 tab must render the real section, not the old one-line placeholder", + ); + assert.doesNotMatch( + source, + /active === "worktree"[\s\S]{0,900}settings\.worktree\.empty/, + "the placeholder sentence must be gone from the render branch", + ); + }); + + test("the placeholder sentence is gone from both dictionaries", () => { + const source = readFileSync(new URL("../lib/i18n.ts", import.meta.url), "utf8"); + assert.equal(source.includes("本地版暂不支持工作树管理"), false); + assert.equal( + source.includes("Worktree management is not available in the local edition yet"), + false, + ); + }); +}); diff --git a/packages/webui/webapp/test/shortcuts-capture.test.ts b/packages/webui/webapp/test/shortcuts-capture.test.ts new file mode 100644 index 00000000..9028bd5c --- /dev/null +++ b/packages/webui/webapp/test/shortcuts-capture.test.ts @@ -0,0 +1,323 @@ +// webapp/test/shortcuts-capture.test.ts +// +// The rebind path of the Shortcuts page, driven by real keydowns. +// +// Why this file exists +// -------------------- +// +// SB-2 built `shortcuts.ts` and asserted it end to end, and the Shortcuts +// page's markup was asserted with `renderToStaticMarkup`. Neither could +// reach the part that matters most: a string of markup has no focus and +// no listeners, so "press Ctrl+Alt+O in the Global search box" was never +// executed by anything. The capture → verdict → conflict-report path — +// the only place a user learns their new combination was refused — had no +// test at all, and the mutation that proved it: swallow the conflict +// report, so a refused rebind silently leaves the box showing the old +// combination and nothing else. That mutant stayed green (SB-2's M9) +// because the suite had no way to type into a page. +// +// With the DOM harness the path is drivable: dispatch a keydown on the +// real input, let React's handler run, read the resulting DOM. The +// markup assertions in `settings-extra-pages.test.ts` stay where they +// are — "what does the page print" and "what happens when the user +// presses a key" are different questions, and this file answers only +// the second. +// +// What is pinned +// -------------- +// +// 1. A live row records the combination actually pressed and persists +// it. This is the whole point of the page; a capture that renders +// but never writes is the mirror-image silent failure. +// 2. A combination another dispatched row already owns is REFUSED, +// the refusal is reported on the row that was edited, and it names +// the action that holds it. This is SB-2's M9, the mutation that +// used to survive. +// 3. A refused capture writes nothing: the box, the status badge and +// `localStorage` all keep their previous values. +// 4. Escape abandons a capture in progress — including a refusal that +// is on screen — without touching storage. +// 5. A captured combination does not also run its own action: +// recording `Ctrl+,` must not open the settings page. +// 6. A blocked row and a `partial` row are not editable, and a +// keydown on them changes nothing at all. +// 7. The ✕ restores the printed default and drops the stored override. + +import { test, describe, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { createElement } from "react"; + +// The harness must be imported before the component: it publishes the +// window that react-dom captures `canUseDOM` from. See its header. +import { withDom, resetStorage, type KeyPress, type Mounted } from "./helpers/dom"; + +import { ShortcutsSection } from "../components/settings-extra-pages"; +import { translate, type MessageKey } from "../lib/i18n"; +import { SHORTCUT_BINDINGS_KEY } from "../lib/shortcuts"; + +const t = (key: MessageKey) => translate("zh", key); + +const box = (id: string) => `settings-shortcuts-binding-${id}`; +const conflict = (id: string) => `settings-shortcuts-conflict-${id}`; +const status = (id: string) => `settings-shortcuts-status-${id}`; + +const GLOBAL_SEARCH = box("global-search"); +const NEW_TASK_NO_PROJECT = box("new-task-no-project"); +const OPEN_SETTINGS = box("open-settings"); +const SEARCH_TASKS = box("search-tasks"); +const NEW_TASK = box("new-task"); + +/** The persisted override map, or `null` when the key is absent. */ +function stored(): Record | null { + const raw = window.localStorage.getItem(SHORTCUT_BINDINGS_KEY); + return raw === null ? null : (JSON.parse(raw) as Record); +} + +/** Mount the page and hand the view to `fn`, unmounting afterwards. */ +function onPage(fn: (view: Mounted) => Promise | void): Promise { + return withDom(createElement(ShortcutsSection, { t }), fn); +} + +beforeEach(() => resetStorage()); + +describe("a live row records the combination the user presses", () => { + test("the pressed combination lands in the box, the badge and storage", async () => { + await onPage(async (view) => { + assert.equal(view.find(GLOBAL_SEARCH).getAttribute("value"), "Ctrl+K"); + assert.equal(view.find(GLOBAL_SEARCH).getAttribute("data-customized"), null); + assert.equal(view.text(status("global-search")), t("settings.shortcuts.status.live")); + + await view.pressKey(GLOBAL_SEARCH, { key: "P", ctrlKey: true, shiftKey: true }); + + assert.equal(view.find(GLOBAL_SEARCH).getAttribute("value"), "Ctrl+Shift+P"); + assert.equal(view.text(status("global-search")), t("settings.shortcuts.status.customized")); + assert.deepEqual(stored(), { "global-search": "Ctrl+Shift+P" }); + assert.equal(view.has(conflict("global-search")), false, "an accepted capture reports nothing"); + }); + }); + + test("each modifier is read from the event, not from a fixed chord", async () => { + // Table-driven: the press on the left, the chord it must produce. + // Every candidate is one no other live row owns, so a refusal cannot + // be mistaken for a parse failure here. + const table: [Parameters[1], string][] = [ + [{ key: "K", ctrlKey: true, shiftKey: true }, "Ctrl+Shift+K"], + [{ key: ",", ctrlKey: true }, "Ctrl+,"], + [{ key: "O", ctrlKey: true, altKey: true, shiftKey: true }, "Ctrl+Alt+Shift+O"], + // Meta and Ctrl are the same modifier to the registry — the macOS + // spelling of the same chord — so Cmd+P records as Ctrl+P. + [{ key: "p", ctrlKey: true, metaKey: true }, "Ctrl+P"], + [{ key: "J", ctrlKey: true, altKey: true, shiftKey: true }, "Ctrl+Alt+Shift+J"], + ]; + for (const [press, chord] of table) { + resetStorage(); + await onPage(async (view) => { + await view.pressKey(OPEN_SETTINGS, press); + assert.equal(view.find(OPEN_SETTINGS).getAttribute("value"), chord, `${JSON.stringify(press)}`); + assert.deepEqual(stored(), { "open-settings": chord }); + }); + } + }); + + test("a second capture replaces the first", async () => { + await onPage(async (view) => { + await view.pressKey(GLOBAL_SEARCH, { key: "P", ctrlKey: true, shiftKey: true }); + await view.pressKey(GLOBAL_SEARCH, { key: "F", ctrlKey: true, altKey: true }); + assert.equal(view.find(GLOBAL_SEARCH).getAttribute("value"), "Ctrl+Alt+F"); + assert.deepEqual(stored(), { "global-search": "Ctrl+Alt+F" }); + }); + }); +}); + +describe("a combination another row owns is refused, and the refusal is on screen", () => { + // SB-2's M9. The mutant that survived the static-markup suite dropped + // this report: `commit` returned early on a refusal without telling the + // page, so the user pressed Ctrl+Alt+O, the binding did not change, and + // the page said nothing. There is no assertion that can see that from + // a string of markup. + test("the edited row names the action that already holds it", async () => { + await onPage(async (view) => { + assert.equal(view.has(conflict("global-search")), false, "nothing is reported before the press"); + + await view.pressKey(GLOBAL_SEARCH, { key: "O", ctrlKey: true, altKey: true }); + + assert.equal( + view.text(conflict("global-search")), + `${t("settings.shortcuts.conflict")} ${t("settings.shortcuts.item.newTaskNoProject")}`, + ); + }); + }); + + test("a refusal writes nothing and changes nothing on screen", async () => { + await onPage(async (view) => { + await view.pressKey(GLOBAL_SEARCH, { key: "O", ctrlKey: true, altKey: true }); + + assert.equal(view.find(GLOBAL_SEARCH).getAttribute("value"), "Ctrl+K", "the old binding stays"); + assert.equal(view.find(GLOBAL_SEARCH).getAttribute("data-customized"), null); + assert.equal(view.text(status("global-search")), t("settings.shortcuts.status.live")); + assert.equal(stored(), null, "a refused capture must not touch localStorage"); + assert.equal(view.has(conflict("new-task-no-project")), false, "the owning row is not the one reporting"); + }); + }); + + test("the owning row is named for every combination it holds", async () => { + // Table-driven: which box is pressed, and whose name must appear. + const table: [string, KeyPress, string][] = [ + [GLOBAL_SEARCH, { key: "O", ctrlKey: true, altKey: true }, "settings.shortcuts.item.newTaskNoProject"], + [OPEN_SETTINGS, { key: "k", ctrlKey: true }, "settings.shortcuts.item.globalSearch"], + ]; + for (const [target, press, ownerKey] of table) { + await onPage(async (view) => { + const id = target === GLOBAL_SEARCH ? "global-search" : "open-settings"; + await view.pressKey(target, press); + assert.equal( + view.text(conflict(id)), + `${t("settings.shortcuts.conflict")} ${t(ownerKey as MessageKey)}`, + ); + }); + } + }); + + test("a refusal is superseded by the next accepted capture", async () => { + await onPage(async (view) => { + await view.pressKey(GLOBAL_SEARCH, { key: "O", ctrlKey: true, altKey: true }); + assert.equal(view.has(conflict("global-search")), true); + + await view.pressKey(GLOBAL_SEARCH, { key: "P", ctrlKey: true, shiftKey: true }); + assert.equal(view.has(conflict("global-search")), false, "the stale refusal must not linger"); + assert.deepEqual(stored(), { "global-search": "Ctrl+Shift+P" }); + }); + }); +}); + +describe("Escape abandons a capture without touching storage", () => { + test("Escape clears a refusal that is on screen", async () => { + await onPage(async (view) => { + await view.pressKey(GLOBAL_SEARCH, { key: "O", ctrlKey: true, altKey: true }); + assert.equal(view.has(conflict("global-search")), true); + + await view.pressKey(GLOBAL_SEARCH, { key: "Escape" }); + + assert.equal(view.has(conflict("global-search")), false); + assert.equal(stored(), null); + }); + }); + + test("Escape is prevented so it cannot also close the settings modal", async () => { + await onPage(async (view) => { + const input = view.find(GLOBAL_SEARCH); + const event = view.keyEvent({ key: "Escape" }); + await view.run(() => input.dispatchEvent(event)); + assert.equal(event.defaultPrevented, true, "the modal's own Esc handler must not also run"); + }); + }); +}); + +describe("a captured combination does not also run its own action", () => { + // Recording `Ctrl+,` in the Open settings box must not be swallowed by + // the page-level handler that dispatches `Ctrl+,` to open this very + // page. The evidence is `defaultPrevented` on the dispatched event, + // which a render assertion cannot produce. + test("the keydown the capture consumed is cancelled", async () => { + await onPage(async (view) => { + const input = view.find(OPEN_SETTINGS); + const event = view.keyEvent({ key: ",", ctrlKey: true }); + await view.run(() => input.dispatchEvent(event)); + assert.equal(event.defaultPrevented, true); + }); + }); + + test("a bare modifier press is not cancelled and captures nothing", async () => { + // A user holding Ctrl on the way to a chord passes through this box + // first; cancelling it would be a defect of its own. + await onPage(async (view) => { + for (const key of ["Control", "Shift", "Alt", "Meta"]) { + const input = view.find(GLOBAL_SEARCH); + const event = view.keyEvent({ key, ctrlKey: true }); + await view.run(() => input.dispatchEvent(event)); + assert.equal(event.defaultPrevented, false, `${key} alone must pass through`); + } + assert.equal(view.find(GLOBAL_SEARCH).getAttribute("value"), "Ctrl+K"); + assert.equal(stored(), null); + }); + }); +}); + +describe("a row the registry does not allow rebinding takes no input", () => { + test("a blocked row and a platform-limited row ignore every keydown", async () => { + await onPage(async (view) => { + // search-tasks prints Ctrl+G, which the browser owns; new-task is + // `partial` — dispatched, but taken by the browser on Windows and + // Linux, so rebinding it would buy nothing. + for (const target of [SEARCH_TASKS, NEW_TASK]) { + assert.equal(view.find(target).hasAttribute("disabled"), true, `${target} renders disabled`); + await view.pressKey(target, { key: "9", ctrlKey: true, altKey: true }); + } + assert.equal(view.find(SEARCH_TASKS).getAttribute("value"), "Ctrl+G"); + assert.equal(view.find(NEW_TASK).getAttribute("value"), "Ctrl+N"); + assert.equal(stored(), null); + assert.equal(view.find(SEARCH_TASKS).hasAttribute("data-customized"), false); + }); + }); + + test("a blocked row's ✕ is present but dead, and it reports nothing", async () => { + await onPage(async (view) => { + // The disabled box keeps the desktop's ✕ for shape parity; what + // matters is that it cannot be operated, not that it is absent. + const clear = view.find(`${SEARCH_TASKS}-clear`); + assert.equal(clear.hasAttribute("disabled"), true, "a blocked row's ✕ is dead"); + await view.click(`${SEARCH_TASKS}-clear`); + assert.equal(view.find(SEARCH_TASKS).getAttribute("value"), "Ctrl+G"); + assert.equal(stored(), null); + assert.equal(view.has(conflict("search-tasks")), false); + assert.equal(view.has(status("search-tasks")), false, "a blocked row prints its reason instead of a badge"); + }); + }); +}); + +describe("the ✕ restores the default and drops the override", () => { + test("clearing a customised row rewrites storage to the empty map", async () => { + await onPage(async (view) => { + await view.pressKey(GLOBAL_SEARCH, { key: "P", ctrlKey: true, shiftKey: true }); + assert.deepEqual(stored(), { "global-search": "Ctrl+Shift+P" }); + + await view.click(`${GLOBAL_SEARCH}-clear`); + + assert.equal(view.find(GLOBAL_SEARCH).getAttribute("value"), "Ctrl+K"); + assert.equal(view.find(GLOBAL_SEARCH).getAttribute("data-customized"), null); + assert.equal(stored(), null, "an empty override map removes the key rather than writing {}"); + }); + }); + + test("clearing also takes a refusal off the screen", async () => { + await onPage(async (view) => { + await view.pressKey(GLOBAL_SEARCH, { key: "O", ctrlKey: true, altKey: true }); + await view.pressKey(GLOBAL_SEARCH, { key: "P", ctrlKey: true, shiftKey: true }); + await view.click(`${GLOBAL_SEARCH}-clear`); + assert.equal(view.has(conflict("global-search")), false); + }); + }); +}); + +describe("a stored override hydrates into the box before the first press", () => { + test("the page opens on the saved combination, not the default", async () => { + window.localStorage.setItem(SHORTCUT_BINDINGS_KEY, '{"global-search":"Ctrl+Shift+P"}'); + await onPage(async (view) => { + assert.equal(view.find(GLOBAL_SEARCH).getAttribute("value"), "Ctrl+Shift+P"); + assert.equal(view.find(GLOBAL_SEARCH).getAttribute("data-customized"), "true"); + assert.equal(view.text(status("global-search")), t("settings.shortcuts.status.customized")); + // A stored override cannot smuggle in a broken chord, so the box a + // user re-records from is one the registry accepts. + assert.equal(view.find(NEW_TASK_NO_PROJECT).getAttribute("value"), "Ctrl+Alt+O"); + }); + }); + + test("a stored override the registry rejects is dropped, not rendered", async () => { + // A blocked row is not rebindable, so a hand-edited entry for it is + // discarded on read and the row prints its desktop value. + window.localStorage.setItem(SHORTCUT_BINDINGS_KEY, '{"search-tasks":"Ctrl+Shift+G"}'); + await onPage(async (view) => { + assert.equal(view.find(SEARCH_TASKS).getAttribute("value"), "Ctrl+G"); + }); + }); +}); diff --git a/packages/webui/webapp/test/turn-chevron.test.ts b/packages/webui/webapp/test/turn-chevron.test.ts index e34dfefd..69973487 100644 --- a/packages/webui/webapp/test/turn-chevron.test.ts +++ b/packages/webui/webapp/test/turn-chevron.test.ts @@ -35,6 +35,10 @@ import * as React from "react"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; +// The DOM harness publishes the window react-dom captures `canUseDOM` +// from, so it is imported before the component below. +import { withDom } from "./helpers/dom"; + { const reactModule = React; Object.defineProperty(globalThis, "React", { @@ -135,6 +139,65 @@ describe("the chevron is gated on the turn actually having process steps", () => }); describe("the chevron reports and follows the expansion state", () => { + // The two markup assertions above pin what the bar PRINTS per state. + // The block below pins that the state is actually reachable by a click: + // a `