From dc75a79aa1db26cf77837372aa4ebc1688cd09e6 Mon Sep 17 00:00:00 2001 From: hy Date: Tue, 28 Jul 2026 10:49:49 +0800 Subject: [PATCH] refactor: enhance Excel tool functionality and testing - Updated AI test plan for Excel tools to clarify operations and expected outcomes. - Improved Workbench test cases to include comprehensive Excel file handling and attachment scenarios. - Refined Excel tool runtime to accurately track changes and manage baseline comparisons. - Enhanced unit tests for Excel tools to ensure correct handling of edge cases and multiple edits. - Expanded E2E tests for Workbench to validate Excel file attachments and user interactions. - Adjusted browser lifecycle tests to ensure proper handling of browser tabs and sessions. --- doc/prd/workbench.md | 44 +++-- doc/test-plan/ai-test.md | 4 +- doc/test-plan/workbench-test.md | 11 +- .../services/ai/human/tools/excel/runtime.ts | 28 +-- .../src/components/fileRenders/xlsx.tsx | 1 + .../tool/excel/excel-create-read-edit.test.ts | 30 +++- .../unit/ai/excel-tools-edge-cases.test.ts | 15 +- tests/case/unit/ai/excel-tools.test.ts | 24 ++- .../composer-attachments-context.e2e.test.ts | 51 +++++- .../excel-update-diff-and-revert.e2e.test.ts | 39 ++++- .../browser-view-lifecycle.e2e.test.ts | 163 ++++++++++++++++-- 11 files changed, 348 insertions(+), 62 deletions(-) diff --git a/doc/prd/workbench.md b/doc/prd/workbench.md index d6737d65..faa76b1d 100644 --- a/doc/prd/workbench.md +++ b/doc/prd/workbench.md @@ -39,8 +39,8 @@ - 当前本地知识库可索引文件类型为 `.pdf`、`.doc`、`.docx`、`.md`、`.markdown`、`.txt`;空文本文件不产生可检索内容。`.doc` 只读,不支持直接编辑。 - 未显式选择参考资料时,智能模式仍可处理通用任务。 - 显式选择目录、文件或网页资料后,任务应优先使用这些资料。只绑定工作区时,默认使用工作区内全部已导入资料;资料不足时,智能模式可用模型通用知识补充通用部分。 -- 当前可由 workbench 直接写回并支持回退的受支持文件类型包括 `docx`、`pptx`,以及 Markdown、TXT、配置文件和代码等 UTF-8 普通文本文件;普通文本支持创建、完整改写、唯一文本编辑和全部精确匹配替换。 -- 当前通过 composer 附件进入对话上下文的文件类型支持图片、`doc`、`docx`、`pptx`、`pdf`、`md`、`json`;这些附件不自动预解析,只保存到当前工作区并把名称与路径交给 AI 后续自行处理。`doc` 只支持读取,`pptx` 可交给 PPT 工具读取、搜索、渲染和编辑。 +- 当前可由 workbench 直接写回并支持回退的受支持文件类型包括 `docx`、`pptx`、`xlsx`,以及 Markdown、TXT、配置文件和代码等 UTF-8 普通文本文件;普通文本支持创建、完整改写、唯一文本编辑和全部精确匹配替换。Excel 工具可读取 `xlsx`、`xlsm`、`xltx`、`xltm`,但只允许创建和编辑 `xlsx`,避免写回时丢失宏或模板信息。 +- 当前通过 composer 附件进入对话上下文的文件类型支持图片、`doc`、`docx`、`pptx`、`pdf`、`md`、`json`、`xlsx`、`xlsm`、`xltx`、`xltm`;这些附件不自动预解析,只保存到当前工作区并把名称与路径交给 AI 后续自行处理。`doc` 只支持读取,`pptx` 可交给 PPT 工具读取、搜索、渲染和编辑,Excel 文件可交给 Excel 工具处理,但只有 `xlsx` 可写。 - 其他格式不保证直接写回和回退。AI 可以在工作区和审批边界内尝试处理,但不属于受支持文件能力范围。 - 终端执行只允许当前平台安全指令集直接运行;一条 terminal 命令被拆分后的每段都命中安全指令集时可直接执行,任一段未命中则必须先经用户手动批准。安全指令集只决定是否可直接执行,不决定执行模式。 - 搜索模式只检索当前工作区下可检索的已导入资料;`@` 候选仍可展示当前工作区下的全部本地目录和文件,但未索引的本地目录或文件不能产生纯搜索结果,部分可检索目录只覆盖其已索引子文件。 @@ -54,10 +54,10 @@ - 工作区绑定:当前独立选中的工作区,用于提供本地运行根目录与默认知识库范围(包括本地资料与该工作区下的网页资料)。 - 搜索模式:只执行本地检索,不启动 AI 运行。 - 智能模式:启动 AI 运行,允许产生阻塞、审批、文件直接写回与回退入口。 -- 受支持文件:当前已具备直接写回与回退能力的文件类型;当前包括 `docx`、`pptx` 和 UTF-8 普通文本文件。 +- 受支持文件:当前已具备直接写回与回退能力的文件类型;当前包括 `docx`、`pptx`、`xlsx` 和 UTF-8 普通文本文件。 - 目标文件:用户要求新建受支持文件时,AI 写工具直接创建的工作区文件路径。 -- 直接写回:AI 写工具将最终 docx、pptx 或普通文本结果原子写入源文件或创建目标文件的流程;工具成功即代表正式路径已可读取,run 收尾不再提交文件。 -- 修改记录:一次 AI run 中实际创建或更新的文件记录,提供打开文件、差异预览与回退本次修改入口;当前包括 docx、pptx、普通文本直接写回和可识别的 terminal 文件改动。 +- 直接写回:AI 写工具将最终 docx、pptx、xlsx 或普通文本结果原子写入源文件或创建目标文件的流程;工具成功即代表正式路径已可读取,run 收尾不再提交文件。 +- 修改记录:一次 AI run 中实际创建或更新的文件记录,提供打开文件、差异预览与回退本次修改入口;当前包括 docx、pptx、xlsx、普通文本直接写回和可识别的 terminal 文件改动。 - 回退本次修改:基于写回前 checkpoint 恢复文件;新建文件回退时删除目标文件,若文件在 AI 写回后又被修改,需提示用户确认会丢失后续修改。 - 内部引用链接:assistant markdown 中的 `ncurator://ref` 链接。`kind=reference` 表示证据引用,`kind=normal` 表示普通资源链接,`resourceType=file/web` 表示资源类型。证据引用在进入 mouth 前检查一次可访问性,并携带 `accessStatus=accessible/unavailable`;历史证据缺少该字段时按不可用处理。可用证据可点击,不可用证据显示为弱化文本;普通资源链接不包含 `accessStatus`。 - AI 推进偏好:智能模式下用户对资料使用边界的附加约束,界面选项包括“默认”“本地资料·宽松”“本地资料·严谨”。当前“本地资料”范围包含当前工作区知识库资料、工作区文件和显式参考资料。宽松模式优先使用这些资料,资料不足时允许补充模型知识或外部网页;严谨模式只能把这些资料作为事实依据,资料不足时必须明确说明,不得把模型知识、外部搜索或浏览器结果伪装成资料内事实。 @@ -88,9 +88,9 @@ #### WB-CORE-003 当前受支持文件的直接写回能力边界必须明确 -- 系统行为:workbench 明确区分“可直接写回并支持回退的受支持文件”和“可由 AI 自行尝试处理但不提供同等保证能力的其他任务”;当前受支持文件类型包括 `docx`、`pptx` 和 UTF-8 普通文本文件。 +- 系统行为:workbench 明确区分“可直接写回并支持回退的受支持文件”和“可由 AI 自行尝试处理但不提供同等保证能力的其他任务”;当前受支持文件类型包括 `docx`、`pptx`、`xlsx` 和 UTF-8 普通文本文件。`xlsm`、`xltx`、`xltm` 只读,不属于可直接写回范围。 - 验收: - - `WB-CORE-003-AC1` 当前支持直接写回与回退的受支持文件类型列表明确可见,包括 `docx` 与 `pptx` 的修改和新建,以及 Markdown、TXT、配置文件和代码等 UTF-8 普通文本文件。 + - `WB-CORE-003-AC1` 当前支持直接写回与回退的受支持文件类型列表明确可见,包括 `docx`、`pptx`、`xlsx` 的修改和新建,以及 Markdown、TXT、配置文件和代码等 UTF-8 普通文本文件。 - `WB-CORE-003-AC2` 不在当前受支持文件类型列表中的资料不会被伪装成可进入同一直接写回与回退流程的编辑或创建目标。 #### WB-CORE-004 资料优先级受 AI 推进偏好约束 @@ -296,7 +296,7 @@ - 验收: - `WB-COMPOSER-010-AC1` 未绑定可用工作区时,不允许添加附件,并提示先选择工作区或完成目录绑定。 - `WB-COMPOSER-010-AC2` 已绑定工作区后,`+` 和粘贴添加的附件都会保存到 `/.ncurator/runtime/assets//`,且不会提前创建真实会话。 - - `WB-COMPOSER-010-AC3` 当前附件支持范围只有图片、`doc`、`docx`、`pptx`、`pdf`、`md`、`json`;其中 `doc` 只读,`pptx` 可由 PPT 工具读取和编辑;其他格式提示不支持且不加入附件列表。 + - `WB-COMPOSER-010-AC3` 当前附件支持范围只有图片、`doc`、`docx`、`pptx`、`pdf`、`md`、`json`、`xlsx`、`xlsm`、`xltx`、`xltm`;其中 `doc` 只读,`pptx` 可由 PPT 工具读取和编辑,四种 Excel 格式均可读取但只有 `xlsx` 可编辑;其他格式提示不支持且不加入附件列表。 - `WB-COMPOSER-010-AC4` 发送给 AI 的附件上下文只包含顺序、名称和路径,不包含发送前预解析摘要。 ### 8.4 模式切换 @@ -422,7 +422,7 @@ - `WB-PANEL-003-AC11` assistant turn 的“修改的文件”列表使用 `工作区/目录/文件` 路径;右侧文件预览工具栏继续使用 `工作区 > 目录 > 文件` 面包屑。 - `WB-PANEL-003-AC12` 文本 Diff 行背景至少覆盖整个可见画布宽度;长行产生横向滚动时,新增、删除和上下文背景应延伸到完整可滚动宽度,不得在文件列表前留下无意义白块。 - `WB-PANEL-003-AC13` 从修改文件入口进入修改文件工作区时,工作区文件列表默认关闭且不占用预览宽度;用户点击“目录列表”后才显示文件列表,并默认进入“本会话改动”。 - - `WB-PANEL-003-AC14` 当前修改记录同时具备修改后快照和变更预览时,文件工具栏提供“查看文件 / 查看变更”切换;`docx`、`pptx` 和普通 UTF-8 文本文件均支持该切换。普通文本的“查看文件”使用修改后快照并按文件扩展名渲染 Markdown 或纯文本,“查看变更”使用结构化文本 Diff。修改记录回退后默认进入“查看文件”状态。 + - `WB-PANEL-003-AC14` 当前修改记录同时具备修改后快照和变更预览时,文件工具栏提供“查看文件 / 查看变更”切换;`docx`、`pptx`、`xlsx` 和普通 UTF-8 文本文件均支持该切换。普通文本的“查看文件”使用修改后快照并按文件扩展名渲染 Markdown 或纯文本,“查看变更”使用结构化文本 Diff;`xlsx` 的“查看文件”展示正式工作簿,“查看变更”展示带单元格边框标记的工作簿。修改记录回退后默认进入“查看文件”状态。 - `WB-PANEL-003-AC15` macOS 使用 Launch Services,Windows 使用 Shell Association Handler 查询当前文件的默认应用和其他关联应用;默认应用始终排在下拉首项并标记“默认”。Renderer 只接收不透明应用 ID、名称和图标,不能提交任意应用路径;同一应用重复注册时必须去重,查询失败时仍保留系统默认打开的降级能力。 - `WB-PANEL-003-AC16` 文件工具栏不单独展示文件夹按钮;打开系统应用的下拉列表底部使用分割线隔开放置无图标的“打开所在文件夹”。即使系统应用列表为空、加载失败或文件已删除,该入口仍可定位当前文件或其原目录。文件列表收起时为透明图标按钮,展开后保持浅灰背景、边框和圆角,收起后选中样式立即消失。 - `WB-PANEL-003-AC17` 没有专用 Diff 的 Run 扫描记录不得统一显示“没有专用预览稿”:重命名展示修改前后文件名并说明内容未变化,内容修改说明暂不支持应用内差异,新建、删除、预览失败和已回退分别展示对应状态。 @@ -456,13 +456,14 @@ #### WB-APPLY-002 AI 修改完成后直接写回 -- 系统行为:受支持 `docx`、`pptx` 和普通文本写任务完成后直接写回源文件或创建目标文件,并生成修改记录。普通文本支持 `create`、`rewrite`、唯一非重叠 `edit` 和精确 `replaceAll`;任一校验失败时不产生部分写入。 +- 系统行为:受支持 `docx`、`pptx`、`xlsx` 和普通文本写任务完成后直接写回源文件或创建目标文件,并生成修改记录。普通文本支持 `create`、`rewrite`、唯一非重叠 `edit` 和精确 `replaceAll`;任一校验失败时不产生部分写入。 - 验收: - `WB-APPLY-002-AC1` 修改现有 `docx` 后,源文件立即变为 AI 最终净稿,assistant turn 展示本轮“修改的文件”入口。 - `WB-APPLY-002-AC2` 新建 `docx` 后,目标文件立即存在,assistant turn 展示本轮“修改的文件”入口。 - `WB-APPLY-002-AC3` 创建或修改普通文本文件后,目标文件立即更新并生成修改记录;运行期间不展示修改入口,收到 `run_completed / run_failed / run_cancelled` 后 assistant turn 才展示本轮“修改的文件”,右侧可查看由真实写入前后内容计算的文本 diff。`answer_completed` 不视为 run 已结束。 - `WB-APPLY-002-AC4` 普通文本 `edit` 的每个 `oldText` 必须唯一且互不重叠;`replaceAll` 替换一个 `oldText` 的全部非重叠精确匹配;失败时文件保持原样。 - `WB-APPLY-002-AC5` 同一次 AI Run 对同一个 pptx 的多次逐页编辑只生成一条修改记录,加减数量按变化页计算。 + - `WB-APPLY-002-AC6` 创建或修改 `xlsx` 后,正式路径立即包含最终净工作簿并生成一条修改记录;同一次 AI Run 对同一路径连续编辑时保持首次写入前基线,不重复生成记录。 #### WB-TERMINAL-001 持久终端会话 @@ -483,7 +484,7 @@ - `WB-APPLY-003-AC3` 回退成功后,右侧修改的文件工作区应刷新记录状态;普通文本历史 diff 继续保留并标记“已回退”或“已删除”,避免丢失本次修改记录。 - `WB-APPLY-003-AC4` 终端通过 Shell、Python、Node 或其他程序造成的文件变化均由 Run 结束扫描捕获;终端修改的文本可生成普通行级 Diff,终端直接修改的 Docx 等二进制文件只提供修改前/修改后版本,不伪造语义审阅标记。 - `WB-APPLY-003-AC5` Docx 工具修改保存 before、committed 和 Review Docx;Review Docx 继续由现有 Docx Renderer 渲染并保留段落、表格、图片、字体和页面结构。 - - `WB-APPLY-003-AC6` PPT 修改记录提供页面级差异预览;Excel 不提供专用 Diff UI,也不自动显示“查看文件 / 查看变更”切换。终端产生的二进制文件变化只提供修改前/修改后版本,不自动显示变更切换;终端产生的 UTF-8 文本变化按普通文本规则处理。 + - `WB-APPLY-003-AC6` PPT 修改记录提供页面级差异预览;Excel 专用工具修改 `xlsx` 时提供单元格边框 Diff 和“查看文件 / 查看变更”切换。终端产生的二进制文件变化只提供修改前/修改后版本,不自动显示变更切换;终端产生的 UTF-8 文本变化按普通文本规则处理。 #### WB-APPLY-004 后续修改后的强制回退 @@ -528,6 +529,27 @@ - `WB-PPT-002-AC4` 新增页左侧为空;删除页右侧为空。 - `WB-PPT-002-AC5` 点击缩略图或滚动后,左右主区域与当前缩略图保持同步。 +### 8.10 Excel 读取、编辑与差异预览 + +#### WB-EXCEL-001 AI 读取和编辑 Excel + +- 系统行为:AI 通过 `excel-read` 读取工作簿概览、搜索值或公式并按 A1 范围读取单元格;通过 `excel-write` 创建或编辑工作表、单元格、公式、格式和结构。读取支持 `xlsx`、`xlsm`、`xltx`、`xltm`,写入只支持 `xlsx`。Excel 文件不进入本地知识库文本索引,由 Excel 专用工具按工作区路径处理。 +- 验收: + - `WB-EXCEL-001-AC1` `excel_read_outline` 返回工作表、使用范围、表格和公式数量概览;`excel_search` 可搜索单元格值、显示值、公式、工作表名和表格列名;`excel_read_cells` 可按多个工作表和 A1 范围返回地址、值、显示值、公式、类型与数字格式。 + - `WB-EXCEL-001-AC2` `excel_edit` 支持创建或更新 `xlsx`,可设置或删除单元格、批量写范围、清空范围、插入或删除行、设置列宽、增加或删除或重命名工作表、合并单元格,并保留未修改的工作簿结构与样式。 + - `WB-EXCEL-001-AC3` 同一次 `excel_edit` 的任一操作失败时不产生部分写入;文件在连续编辑之间被外部修改时拒绝覆盖并提示冲突;无实际变化时不生成修改记录。 + - `WB-EXCEL-001-AC4` `xlsm`、`xltx`、`xltm` 可以读取但不能写回;拒绝写入时源文件保持不变,并明确说明是为了避免丢失宏或模板信息。 + - `WB-EXCEL-001-AC5` Excel 创建或编辑成功后,正式文件中的值与公式可被其他程序立即读取,正式文件不包含用于差异预览的红绿边框。 + +#### WB-EXCEL-002 Excel 单网格差异预览 + +- 系统行为:用户打开 `xlsx` 修改记录后,在一个工作簿网格中查看修改后的内容;新增和修改单元格使用绿色边框,删除单元格使用红色边框。用户可切换工作表,不使用 PPT 的修改前/修改后左右画面对比。 +- 验收: + - `WB-EXCEL-002-AC1` “查看文件”展示正式净工作簿,“查看变更”展示基于首次写入前基线生成的差异工作簿,两者均可切换工作表。 + - `WB-EXCEL-002-AC2` 新增和修改单元格显示绿色边框,删除单元格显示红色边框;未变化单元格不增加差异边框,正式工作簿也不带差异边框。 + - `WB-EXCEL-002-AC3` 同一 AI Run 对同一路径连续编辑后只保留一条有效修改记录,差异始终相对该 Run 首次写入前基线计算。 + - `WB-EXCEL-002-AC4` 回退已有 `xlsx` 时恢复写入前原始 bytes;回退新建 `xlsx` 时删除目标文件,修改记录状态同步更新。 + ## 9. 非目标 - 不定义设置页中的工作空间、网页资料或模型配置行为。 diff --git a/doc/test-plan/ai-test.md b/doc/test-plan/ai-test.md index 45da1dc1..5376232e 100644 --- a/doc/test-plan/ai-test.md +++ b/doc/test-plan/ai-test.md @@ -575,8 +575,8 @@ AI unit / 辅助测试目录: - 测试类型:`backend / tool` - What:Excel 专用工具创建工作簿、批量写入数据和公式,并继续执行概览、搜索、范围读取和修改。 - Why:Excel 的值与公式必须由程序真实读写,不能用模型推算或用伪造工具结果代替。 -- How:真实调用 `excel_edit` 创建 xlsx,再调用三个只读工具,并继续修改和删除单元格;最后直接解析正式文件与 diff 文件。 -- Oracle:正式文件包含准确值和公式且不带 diff 边框;diff 文件对新增、修改显示绿色边框,对删除显示红色边框;同一路径只保留一条修改记录。 +- How:真实调用 `excel_edit` 创建 xlsx,再调用三个只读工具并继续修改同一路径;另对已有 xlsx 执行修改和删除单元格,最后直接解析正式文件与 diff 文件。 +- Oracle:正式文件包含准确值和公式且不带 diff 边框;diff 文件对新增、修改显示绿色边框,对删除显示红色边框;同一路径只保留一条修改记录,对齐 `WB-EXCEL-001`、`WB-EXCEL-002-AC2`、`WB-EXCEL-002-AC3`。 - Not:不验证 human 是否会选择 Excel 技能,也不评价表格视觉设计。 #### kb 类别 diff --git a/doc/test-plan/workbench-test.md b/doc/test-plan/workbench-test.md index 67faac72..362ac9e9 100644 --- a/doc/test-plan/workbench-test.md +++ b/doc/test-plan/workbench-test.md @@ -21,6 +21,7 @@ - `browser-use-task-panel` 需要真实 AI run 触发 `browser_use` 并驱动内置浏览器完成本地网页任务;同时验证多个浏览器 Tab 独立历史、共享持久站点数据、切换文件后网页状态不销毁,以及同 URL 标签不会让 Stagehand 串页,不能用 fake browser 替代。 - `web_search` 与 `web_crawl` 需要分开覆盖:搜索 case 只验证候选网页返回;读取 case 必须验证 AI 继续读取网页正文后再回答。 - Workbench 附件链路当前支持图片、`doc`、`docx`、`pptx`、`pdf`、`md`、`json`、`xlsx`、`xlsm`、`xltx`、`xltm`,统一保存到 `/.ncurator/runtime/assets//`;`assetBucketId` 为输入草稿 UUID,发送给 AI 时只传附件名称与路径,不再做发送前识图或文件解析。 +- `WB-EXCEL-001-AC1`、`WB-EXCEL-001-AC2`、`WB-EXCEL-001-AC3`、`WB-EXCEL-001-AC4` 的工具输入输出、完整编辑操作、原子失败、冲突和只读格式边界由 AI tool 与 Unit 回归承担;Workbench 的 `excel-update-diff-and-revert` 负责真实模型选用工具、正式文件、修改入口、单网格 Diff 和回退闭环,不重复枚举底层操作。 ## 3. Suite 设计 @@ -157,10 +158,10 @@ tests/case/workbench/ #### `composer-attachments-context` - 测试类型:`e2e` -- What:`+` 与粘贴添加附件时,附件会保存到工作区 `.ncurator/runtime/assets//`,不会提前创建真实会话;图片、`doc`、`docx`、`pptx`、`pdf`、`md`、`json` 可以添加,并保持图片预览、文件打开、删除、普通文本粘贴和格式提示行为。 +- What:`+` 与粘贴添加附件时,附件会保存到工作区 `.ncurator/runtime/assets//`,不会提前创建真实会话;图片、`doc`、`docx`、`pptx`、`pdf`、`md`、`json`、`xlsx`、`xlsm`、`xltx`、`xltm` 可以添加,并保持图片预览、文件打开、删除、普通文本粘贴和格式提示行为。 - Why:附件链路已改为“统一落盘到工作区 + 只把路径交给 AI”,必须用回归测试守住真实保存路径和交互行为。 - How:创建并通过原生目录选择绑定真实工作区;点击添加附件后,在 Windows 系统文件窗口或 macOS Open 面板中用系统级键鼠选择真实文件,经过 Renderer → IPC → Main → 附件保存链路;另外通过粘贴覆盖不支持格式和大文件 warning。 -- Oracle:未绑定工作区时提示先选择工作区且不添加附件;真实添加会落盘且不提前创建会话;图片预览、格式范围、不支持提示及只传名称与路径的发送上下文整体对齐 `WB-COMPOSER-010`。 +- Oracle:未绑定工作区时提示先选择工作区且不添加附件;真实添加会落盘且不提前创建会话;图片预览、Excel 格式范围、不支持提示及只传名称与路径的发送上下文整体对齐 `WB-COMPOSER-010`。 - Not:不在跨平台业务回归中验证系统原生文件选择器的视觉细节;不通过 `attachmentPickResponse` 或直接 `attachmentSaveFile` 伪造附件结果。 #### `reference-mention-keyboard` @@ -291,8 +292,8 @@ tests/case/workbench/ - 测试类型:`e2e` - What:内置浏览器原生视图在初次显示、面板折叠、原生自绘菜单和会话切换后仍可见、尺寸稳定且持续运行。 - Why:`WebContentsView` 由 Main 管理并覆盖在 Renderer 上方;生命周期或 bounds 同步错误会导致白屏、原生视图遮挡菜单、恢复后尺寸跳变,或切换会话时误销毁仍在运行的网页。 -- How:通过 Settings UI 创建工作空间并用真实模型发送消息形成会话;在 Workbench UI 新建浏览器 Tab,从“浏览器地址”输入框点击“前往”打开带高对比颜色和自增计时器的本地真实网页;折叠后在后台跳转新 URL、填写表单并滚动,再展开和切换会话;同时操作自绘菜单缩放控件,并采集各阶段窗口像素、viewport 尺寸、zoom、WebContents id、URL、表单、滚动位置与计时器值。 -- Oracle:各阶段浏览器 viewport 均包含稳定的非白真实网页像素且尺寸、zoom 不变;菜单打开时浏览器仍挂载且保持可见;折叠或切换会话只从显示树移除 View,不销毁 WebContents,计时器和后台导航持续进行;恢复后直接显示最新 URL、表单和滚动状态,且 WebContents id 不变,对齐 `WB-PANEL-001-AC5`、`WB-PANEL-004-AC1`、`WB-PANEL-004-AC3`、`WB-PANEL-004-AC7`、`WB-PANEL-004-AC11`。 +- How:通过 Settings UI 创建工作空间并用真实模型发送消息形成会话;在 Workbench UI 新建浏览器 Tab,验证空地址禁用“前往”、按 Enter 与点击按钮都可导航本地真实网页;折叠后在后台跳转新 URL、填写表单并滚动,再展开和切换会话;验证自绘菜单由 Escape、点击外部、切换资源 Tab 和主窗口移动关闭,最后分别通过关闭 Tab 和删除所属会话销毁浏览器。 +- Oracle:各阶段浏览器 viewport 均包含稳定的非白真实网页像素且尺寸、zoom 不变;菜单打开时浏览器仍挂载且保持可见,并响应 Escape、点击外部、切换 Tab 和窗口移动关闭;折叠或切换会话只从显示树移除 View,不销毁 WebContents,计时器和后台导航持续进行;恢复后直接显示最新 URL、表单和滚动状态,且 WebContents id 不变;用户关闭 Tab 或删除所属会话后,对应 WebContents 被销毁,对齐 `WB-PANEL-001-AC5`、`WB-PANEL-004-AC1`、`WB-PANEL-004-AC3`、`WB-PANEL-004-AC7`、`WB-PANEL-004-AC8`、`WB-PANEL-004-AC9`、`WB-PANEL-004-AC11`、`WB-PANEL-004-AC12`。 - Not:不验证公网网页、Stagehand 操作或最终 AI 回答;这些由 `browser-use-task-panel` 覆盖。 #### `web-search-diagnostic` @@ -375,7 +376,7 @@ tests/case/workbench/ - What:真实 AI 修改已有 Excel 后,用户在同一个网格中查看红绿单元格边框,并能回退到原始文件。 - Why:Excel 修改预览不能依赖模型描述,也不适合 PPT 式左右画面对比,必须直接验证工作簿数据、公式和单元格级 diff。 - How:通过真实 Workbench UI 请求 AI 读取并修改 xlsx;打开修改记录,切换工作表,检查红绿边框,再执行回退。 -- Oracle:正式 xlsx 的值准确且没有 diff 边框;修改预览中新增和修改为绿色、删除为红色;不出现 PPT 前后预览;回退后文件 bytes 与修改前一致;真实调用包含 Excel 读工具和 `excel_edit`。 +- Oracle:正式 xlsx 的值和公式准确且没有 diff 边框;修改预览中指定的新增和修改单元格为绿色、指定的删除单元格为红色;可以切换工作表且不出现 PPT 前后预览;回退后文件 bytes 与修改前一致;真实调用包含 Excel 读工具和 `excel_edit`,对齐 `WB-EXCEL-001-AC5`、`WB-EXCEL-002`、`WB-APPLY-002-AC6`。 - Not:不逐字断言最终回答,不把模型直接计算的文本结果当作数据正确性证据。 #### `terminal-background-service-flow` diff --git a/src/main/services/ai/human/tools/excel/runtime.ts b/src/main/services/ai/human/tools/excel/runtime.ts index 03b07501..f29377b2 100644 --- a/src/main/services/ai/human/tools/excel/runtime.ts +++ b/src/main/services/ai/human/tools/excel/runtime.ts @@ -506,13 +506,22 @@ export const createExcelToolRuntime = (input: ExcelCapabilityToolsInput): ExcelT const afterBytes = Buffer.from(await workbook.xlsx.writeBuffer()) const beforeWorkbook = beforeBytes ? await loadWorkbook(beforeBytes) : null const afterWorkbook = await loadWorkbook(afterBytes) - const changes = mergeChanges(compareWorkbooks(beforeWorkbook, afterWorkbook), forcedChanges) - if (changes.length === 0) + const operationChanges = mergeChanges( + compareWorkbooks(beforeWorkbook, afterWorkbook), + forcedChanges + ) + if (operationChanges.length === 0) return toJsonObject({ sourcePath, changed: false, changes: [], postWriteReadback: [] }) + const baseline = state.baselineBytesByPath.get(sourcePath) + const baselineWorkbook = baseline ? await loadWorkbook(baseline) : null + const cumulativeChanges = mergeChanges( + compareWorkbooks(baselineWorkbook, afterWorkbook), + forcedChanges + ) const diffBytes = await buildDiffWorkbook({ - before: beforeWorkbook, + before: baselineWorkbook, after: afterWorkbook, - changes + changes: cumulativeChanges }) const changeId = state.recordsByPath.get(sourcePath)?.id || randomUUID() const artifactRoot = path.join( @@ -529,7 +538,6 @@ export const createExcelToolRuntime = (input: ExcelCapabilityToolsInput): ExcelT ) await fs.mkdir(artifactRoot, { recursive: true }) await fs.mkdir(snapshotRoot, { recursive: true }) - const baseline = state.baselineBytesByPath.get(sourcePath) const beforeSnapshotPath = baseline ? path.join(snapshotRoot, `${changeId}-${path.basename(sourcePath)}`) : undefined @@ -550,9 +558,9 @@ export const createExcelToolRuntime = (input: ExcelCapabilityToolsInput): ExcelT source: 'excel_tool' as const, beforePath: baseline ? sourcePath : undefined, afterPath: sourcePath, - changeCount: changes.length, - insertCount: changes.filter((item) => item.kind !== 'deleted').length, - deleteCount: changes.filter((item) => item.kind === 'deleted').length, + changeCount: cumulativeChanges.length, + insertCount: cumulativeChanges.filter((item) => item.kind !== 'deleted').length, + deleteCount: cumulativeChanges.filter((item) => item.kind === 'deleted').length, beforeSnapshotPath, afterSnapshotPath: undefined, beforeChecksum: baseline ? hashBytes(baseline) : undefined, @@ -583,7 +591,7 @@ export const createExcelToolRuntime = (input: ExcelCapabilityToolsInput): ExcelT operation: record.operation, applyId: record.id }) - const postWriteReadback = changes.map((change) => { + const postWriteReadback = operationChanges.map((change) => { const cell = afterWorkbook.getWorksheet(change.sheetName)?.getCell(change.address) return cell ? { sheetName: change.sheetName, ...getCellSnapshot(cell) } @@ -598,7 +606,7 @@ export const createExcelToolRuntime = (input: ExcelCapabilityToolsInput): ExcelT return toJsonObject({ sourcePath, changed: true, - changes, + changes: operationChanges, postWriteReadback, diffPath: previewPath, record diff --git a/src/renderer/src/components/fileRenders/xlsx.tsx b/src/renderer/src/components/fileRenders/xlsx.tsx index db035065..4e1a7a7f 100644 --- a/src/renderer/src/components/fileRenders/xlsx.tsx +++ b/src/renderer/src/components/fileRenders/xlsx.tsx @@ -172,6 +172,7 @@ const XlsxTable = ({ sheet }: { sheet: XlsxSheet }) => { {cell ? cell.value : ''} diff --git a/tests/case/ai/tool/excel/excel-create-read-edit.test.ts b/tests/case/ai/tool/excel/excel-create-read-edit.test.ts index 385db781..f5425689 100644 --- a/tests/case/ai/tool/excel/excel-create-read-edit.test.ts +++ b/tests/case/ai/tool/excel/excel-create-read-edit.test.ts @@ -47,15 +47,31 @@ describe('ai tool excel backend', () => { sourcePath, ranges: [{ sheetName: 'Data', range: 'A1:C3' }] }) - const updated = await harness.invoke>('excel_edit', { + const createdUpdated = await harness.invoke>('excel_edit', { sourcePath, action: 'update', + operations: [{ type: 'set_cell', sheetName: 'Data', address: 'B2', value: 20 }] + }) + const existingSourcePath = 'existing-report.xlsx' + const existingWorkbook = new ExcelJS.Workbook() + existingWorkbook.addWorksheet('Data').addRows([ + ['Item', 'Amount', 'Double'], + ['A', 12, { formula: 'B2*2' }], + ['B', 8, { formula: 'B3*2' }] + ]) + await fs.writeFile( + path.join(runtime.workspaceRoot, existingSourcePath), + Buffer.from(await existingWorkbook.xlsx.writeBuffer()) + ) + const updated = await harness.invoke>('excel_edit', { + sourcePath: existingSourcePath, + action: 'update', operations: [ { type: 'set_cell', sheetName: 'Data', address: 'B2', value: 20 }, { type: 'delete_cell', sheetName: 'Data', address: 'A3' } ] }) - const targetPath = path.join(runtime.workspaceRoot, sourcePath) + const targetPath = path.join(runtime.workspaceRoot, existingSourcePath) const actual = new ExcelJS.Workbook() await actual.xlsx.load((await fs.readFile(targetPath)) as never) const previewPath = String( @@ -70,17 +86,25 @@ describe('ai tool excel backend', () => { read, updated, actualValue: actual.getWorksheet('Data')?.getCell('B2').value, + actualFormula: ( + actual.getWorksheet('Data')?.getCell('C2').value as { formula?: string } | undefined + )?.formula, actualHasDiffBorder: Boolean(actual.getWorksheet('Data')?.getCell('B2').border?.left), modifiedBorder: diff.getWorksheet('Data')?.getCell('B2').border.left?.color?.argb, - deletedBorder: diff.getWorksheet('Data')?.getCell('A3').border.left?.color?.argb + deletedBorder: diff.getWorksheet('Data')?.getCell('A3').border.left?.color?.argb, + createdRecordId: (created.record as { id?: string } | undefined)?.id, + createdUpdatedRecordId: (createdUpdated.record as { id?: string } | undefined)?.id } }, assert: async (result, runtime) => { runtime.assertThat( result.actualValue === 20 && + result.actualFormula === 'B2*2' && result.actualHasDiffBorder === false && result.modifiedBorder === 'FF22A06B' && result.deletedBorder === 'FFD14343' && + result.createdRecordId !== undefined && + result.createdRecordId === result.createdUpdatedRecordId && JSON.stringify(result.search).includes('C2') && JSON.stringify(result.read).includes('B2*2'), { diff --git a/tests/case/unit/ai/excel-tools-edge-cases.test.ts b/tests/case/unit/ai/excel-tools-edge-cases.test.ts index 72d9e89c..1d909147 100644 --- a/tests/case/unit/ai/excel-tools-edge-cases.test.ts +++ b/tests/case/unit/ai/excel-tools-edge-cases.test.ts @@ -318,7 +318,9 @@ describe('Excel tools edge cases', () => { it('keeps the original baseline across multiple edits to the same file', async () => { const { bytes } = await writeWorkbook('baseline.xlsx', (workbook) => { - workbook.addWorksheet('Data').getCell('A1').value = 1 + const sheet = workbook.addWorksheet('Data') + sheet.getCell('A1').value = 1 + sheet.getCell('B1').value = 10 }) const runtime = createRuntime() await runtime.edit({ @@ -329,17 +331,22 @@ describe('Excel tools edge cases', () => { await runtime.edit({ sourcePath: 'baseline.xlsx', action: 'update', - operations: [{ type: 'set_cell', sheetName: 'Data', address: 'A1', value: 3 }] + operations: [{ type: 'set_cell', sheetName: 'Data', address: 'B1', value: 20 }] }) expect(records).toHaveLength(1) - expect(records[0]?.changeCount).toBeGreaterThanOrEqual(1) + expect(records[0]?.changeCount).toBe(2) expect((await fs.readFile(records[0]!.beforeSnapshotPath!)).equals(bytes)).toBe(true) + const diff = await loadWorkbook(records[0]!.previewPath) + expect(diff.getWorksheet('Data')?.getCell('A1').value).toBe(2) + expect(diff.getWorksheet('Data')?.getCell('A1').border.left?.color?.argb).toBe('FF22A06B') + expect(diff.getWorksheet('Data')?.getCell('B1').value).toBe(20) + expect(diff.getWorksheet('Data')?.getCell('B1').border.left?.color?.argb).toBe('FF22A06B') expect( (await loadWorkbook(path.join(workspaceRootPath, 'baseline.xlsx'))) .getWorksheet('Data') ?.getCell('A1').value - ).toBe(3) + ).toBe(2) }) it('blocks common terminal mutation forms while allowing inspection commands', () => { diff --git a/tests/case/unit/ai/excel-tools.test.ts b/tests/case/unit/ai/excel-tools.test.ts index 66551df9..fb100cb9 100644 --- a/tests/case/unit/ai/excel-tools.test.ts +++ b/tests/case/unit/ai/excel-tools.test.ts @@ -67,7 +67,6 @@ describe('Excel tools', () => { it('creates, reads and updates values and formulas with a clean source and bordered diff', async () => { const runtime = createRuntime() - const sourcePath = path.join(workspaceRootPath, 'report.xlsx') await runtime.edit({ sourcePath: 'report.xlsx', action: 'create', @@ -89,8 +88,16 @@ describe('Excel tools', () => { expect(JSON.stringify(read)).toContain('A2*2') expect(JSON.stringify(search)).toContain('B2') + const existingPath = path.join(workspaceRootPath, 'existing.xlsx') + const existing = new ExcelJS.Workbook() + const existingSheet = existing.addWorksheet('Data') + existingSheet.getCell('A1').value = 'Amount' + existingSheet.getCell('A2').value = 12 + existingSheet.getCell('B2').value = { formula: 'A2*2' } + await fs.writeFile(existingPath, Buffer.from(await existing.xlsx.writeBuffer())) + await runtime.edit({ - sourcePath: 'report.xlsx', + sourcePath: 'existing.xlsx', action: 'update', operations: [ { type: 'set_cell', sheetName: 'Data', address: 'A2', value: 20 }, @@ -98,18 +105,19 @@ describe('Excel tools', () => { ] }) - expect(records).toHaveLength(1) - expect(records[0]?.capability).toBe('excel') - expect(records[0]?.source).toBe('excel_tool') - expect(records[0]?.deleteCount).toBeGreaterThanOrEqual(1) + expect(records).toHaveLength(2) + const existingRecord = records.find((record) => record.path === existingPath) + expect(existingRecord?.capability).toBe('excel') + expect(existingRecord?.source).toBe('excel_tool') + expect(existingRecord?.deleteCount).toBeGreaterThanOrEqual(1) const actual = new ExcelJS.Workbook() - await actual.xlsx.load((await fs.readFile(sourcePath)) as never) + await actual.xlsx.load((await fs.readFile(existingPath)) as never) expect(actual.getWorksheet('Data')?.getCell('A2').value).toBe(20) expect(actual.getWorksheet('Data')?.getCell('A1').border?.left).toBeUndefined() const diff = new ExcelJS.Workbook() - await diff.xlsx.load((await fs.readFile(records[0]!.previewPath!)) as never) + await diff.xlsx.load((await fs.readFile(existingRecord!.previewPath!)) as never) expect(diff.getWorksheet('Data')?.getCell('A2').border.left?.color?.argb).toBe('FF22A06B') expect(diff.getWorksheet('Data')?.getCell('A1').border.left?.color?.argb).toBe('FFD14343') expect(diff.getWorksheet('Data')?.getCell('A1').value).toBe('Amount') diff --git a/tests/case/workbench/conversation/composer/composer-attachments-context.e2e.test.ts b/tests/case/workbench/conversation/composer/composer-attachments-context.e2e.test.ts index 05d32588..4c998c34 100644 --- a/tests/case/workbench/conversation/composer/composer-attachments-context.e2e.test.ts +++ b/tests/case/workbench/conversation/composer/composer-attachments-context.e2e.test.ts @@ -1,6 +1,7 @@ import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' +import ExcelJS from 'exceljs' import { describe, it } from 'vitest' import { runE2ECase } from '../../../../harness/e2e/run-e2e-case' import { @@ -30,6 +31,10 @@ const createAttachmentFixtures = async (): Promise<{ markdownPath: string legacyDocPath: string pptxPath: string + xlsxPath: string + xlsmPath: string + xltxPath: string + xltmPath: string largePath: string cleanup: () => Promise }> => { @@ -39,12 +44,23 @@ const createAttachmentFixtures = async (): Promise<{ const markdownPath = path.join(rootPath, 'workbench-preview.md') const legacyDocPath = path.join(rootPath, 'legacy-reference.doc') const pptxPath = path.join(rootPath, 'slides-reference.pptx') + const xlsxPath = path.join(rootPath, 'sheet-reference.xlsx') + const xlsmPath = path.join(rootPath, 'macro-reference.xlsm') + const xltxPath = path.join(rootPath, 'template-reference.xltx') + const xltmPath = path.join(rootPath, 'macro-template-reference.xltm') const largePath = path.join(rootPath, 'qa-large.json') + const workbook = new ExcelJS.Workbook() + workbook.addWorksheet('Data').getCell('A1').value = 'attachment fixture' + const workbookBytes = Buffer.from(await workbook.xlsx.writeBuffer()) await Promise.all([ writeFile(imagePath, ONE_PIXEL_PNG), writeFile(markdownPath, '# Preview\n\nAttachment markdown body.'), writeFile(legacyDocPath, 'legacy doc attachment fixture'), writeFile(pptxPath, 'pptx attachment fixture'), + writeFile(xlsxPath, workbookBytes), + writeFile(xlsmPath, workbookBytes), + writeFile(xltxPath, workbookBytes), + writeFile(xltmPath, workbookBytes), writeFile(largePath, Buffer.alloc(11 * 1024 * 1024, 65)) ]) return { @@ -53,6 +69,10 @@ const createAttachmentFixtures = async (): Promise<{ markdownPath, legacyDocPath, pptxPath, + xlsxPath, + xlsmPath, + xltxPath, + xltmPath, largePath, cleanup: async () => { await rm(rootPath, { recursive: true, force: true }) @@ -118,7 +138,7 @@ describe('workbench conversation e2e', () => { suite: 'conversation', groups: ['composer'], description: - '对齐附件上下文 PRD:验证统一附件选择、doc/pptx 格式放行、图片预览、文件默认打开、删除、普通文本粘贴和大文件提示。', + '对齐附件上下文 PRD:验证统一附件选择、doc/pptx/Excel 格式放行、图片预览、文件默认打开、删除、普通文本粘贴和大文件提示。', fixtures: ['tests/fixtures/workspace'], serviceMode: 'real', execution: 'exclusive', @@ -163,6 +183,15 @@ describe('workbench conversation e2e', () => { await waitForAttachmentCardByName(page, 'legacy-reference.doc') await selectWorkbenchAttachmentThroughNativeDialog(page, attachments.pptxPath) await waitForAttachmentCardByName(page, 'slides-reference.pptx') + for (const spreadsheetPath of [ + attachments.xlsxPath, + attachments.xlsmPath, + attachments.xltxPath, + attachments.xltmPath + ]) { + await selectWorkbenchAttachmentThroughNativeDialog(page, spreadsheetPath) + await waitForAttachmentCardByName(page, path.basename(spreadsheetPath)) + } const sessionCountAfterPaste = await countWorkspaceSessions( page, workspaceBinding.workspaceId @@ -244,6 +273,20 @@ describe('workbench conversation e2e', () => { .filter({ hasText: 'slides-reference.pptx' }) .first() .textContent()) || '' + const spreadsheetCardTexts = await Promise.all( + [ + 'sheet-reference.xlsx', + 'macro-reference.xlsm', + 'template-reference.xltx', + 'macro-template-reference.xltm' + ].map( + async (fileName) => + (await getWorkbenchPrefixLocator(page, workbenchSelectors.attachmentCardPrefix) + .filter({ hasText: fileName }) + .first() + .textContent()) || '' + ) + ) await selectWorkbenchAttachmentThroughNativeDialog(page, attachments.largePath) await waitForAttachmentCardByName(page, 'qa-large.json') @@ -292,6 +335,7 @@ describe('workbench conversation e2e', () => { fileCardText, legacyDocCardText, pptxCardText, + spreadsheetCardTexts, largeFileMessage, inputText, openPathCallCount: openPathCalls.length, @@ -316,13 +360,16 @@ describe('workbench conversation e2e', () => { result.fileCardText.includes('MD') && result.legacyDocCardText.includes('DOC') && result.pptxCardText.includes('PPTX') && + ['XLSX', 'XLSM', 'XLTX', 'XLTM'].every((label, index) => + result.spreadsheetCardTexts[index]?.includes(label) + ) && result.largeFileMessage.includes('文件过大,建议放到工作空间索引里使用') && result.inputText.includes('plain paste ok') && result.openPathCallCount >= 1, { name: 'workbench-composer-attachments-prd-flow', message: - '附件输入应满足 PRD:真实选择附件会走系统选择和附件导入,doc/pptx 可以添加并显示正确类型,添加附件不创建新会话,图片可预览且控件不压图,文件可默认打开,超过 10MB 只提示不拦截,普通文本粘贴仍正常。', + '附件输入应满足 PRD:真实选择附件会走系统选择和附件导入,doc/pptx/Excel 可以添加并显示正确类型,添加附件不创建新会话,图片可预览且控件不压图,文件可默认打开,超过 10MB 只提示不拦截,普通文本粘贴仍正常。', data: result } ) diff --git a/tests/case/workbench/result/apply/excel-update-diff-and-revert.e2e.test.ts b/tests/case/workbench/result/apply/excel-update-diff-and-revert.e2e.test.ts index 7424ec44..521f9dfa 100644 --- a/tests/case/workbench/result/apply/excel-update-diff-and-revert.e2e.test.ts +++ b/tests/case/workbench/result/apply/excel-update-diff-and-revert.e2e.test.ts @@ -74,9 +74,13 @@ describe('workbench result e2e', () => { await applyEntry.click() const viewer = getWorkbenchLocator(page, workbenchSelectors.excelViewer) await viewer.waitFor({ state: 'visible', timeout: 30_000 }) - const borderColors = await viewer - .locator('td.border-2') - .evaluateAll((cells) => cells.map((cell) => getComputedStyle(cell).borderColor)) + const readCellBorderColor = async (address: string): Promise => + await viewer + .locator(`[data-cell-address="${address}"]`) + .evaluate((cell) => getComputedStyle(cell).borderColor) + const modifiedBorderColor = await readCellBorderColor('B2') + const deletedBorderColor = await readCellBorderColor('A3') + const addedBorderColor = await readCellBorderColor('D1') await viewer.getByRole('button', { name: 'Notes' }).click() await runtime.assertThat( (await getWorkbenchLocator(page, workbenchSelectors.excelGrid).textContent())?.includes( @@ -100,10 +104,29 @@ describe('workbench result e2e', () => { return { actualValue: actual.getWorksheet('Data')?.getCell('B2').value, + actualFormula: ( + actual.getWorksheet('Data')?.getCell('C2').value as { formula?: string } | undefined + )?.formula, deletedValue: actual.getWorksheet('Data')?.getCell('A3').value, addedValue: actual.getWorksheet('Data')?.getCell('D1').value, - actualHasDiffBorder: Boolean(actual.getWorksheet('Data')?.getCell('B2').border?.left), - borderColors, + actualHasDiffBorder: actual.worksheets.some((sheet) => { + let found = false + sheet.eachRow((row) => + row.eachCell((cell) => { + if ( + cell.border?.left || + cell.border?.right || + cell.border?.top || + cell.border?.bottom + ) + found = true + }) + ) + return found + }), + modifiedBorderColor, + deletedBorderColor, + addedBorderColor, pptBeforeAfterVisible, revertedToBaseline: revertedBytes.equals(baselineBytes), toolNames: evidence.events @@ -114,11 +137,13 @@ describe('workbench result e2e', () => { assert: async (result, runtime) => { await runtime.assertThat( result.actualValue === 20 && + result.actualFormula === 'B2*2' && result.deletedValue === null && result.addedValue === 'Checked' && result.actualHasDiffBorder === false && - result.borderColors.some((color) => color.includes('34, 160, 107')) && - result.borderColors.some((color) => color.includes('209, 67, 67')) && + result.modifiedBorderColor.includes('34, 160, 107') && + result.addedBorderColor.includes('34, 160, 107') && + result.deletedBorderColor.includes('209, 67, 67') && result.pptBeforeAfterVisible === false && result.revertedToBaseline && result.toolNames.includes('excel_read_outline') && diff --git a/tests/case/workbench/run/browser/browser-view-lifecycle.e2e.test.ts b/tests/case/workbench/run/browser/browser-view-lifecycle.e2e.test.ts index 6a789ca9..53e20036 100644 --- a/tests/case/workbench/run/browser/browser-view-lifecycle.e2e.test.ts +++ b/tests/case/workbench/run/browser/browser-view-lifecycle.e2e.test.ts @@ -1,5 +1,6 @@ import { describe, it } from 'vitest' import { runE2ECase } from '../../../../harness/e2e/run-e2e-case' +import { clickScreenPoint } from '../../../../harness/e2e/nativeMouse' import { WORKBENCH_E2E_TIMEOUT_MS, createWorkbenchNewSession, @@ -115,8 +116,21 @@ describe('workbench browser lifecycle e2e', () => { if (!browserTabId) throw new Error('browser tab id is missing') const browserPanel = getWorkbenchLocator(page, workbenchSelectors.browserPanel) - await browserPanel.getByRole('textbox', { name: /浏览器地址/u }).fill(server.url) - await browserPanel.getByRole('button', { name: /前往/u }).click() + const addressInput = browserPanel.getByRole('textbox', { name: /浏览器地址/u }) + const goButton = browserPanel.getByRole('button', { name: /前往/u }) + const emptyGoDisabled = await goButton.isDisabled() + const enterUrl = `${server.url}?enter=1` + await addressInput.fill(enterUrl) + await addressInput.press('Enter') + await page.waitForFunction( + async ({ tabId, url }) => { + const response = await window.api.browserPanelStateGet({ tabId }) + return response.success && response.state?.url === url && !response.state.isLoading + }, + { tabId: browserTabId, url: enterUrl } + ) + await addressInput.fill(server.url) + await goButton.click() await page.waitForFunction( async ({ tabId, url }) => { const response = await window.api.browserPanelStateGet({ tabId }) @@ -251,7 +265,10 @@ describe('workbench browser lifecycle e2e', () => { ) const menuButton = getWorkbenchLocator(page, workbenchSelectors.browserMenu) + const escapeMenuWindowPromise = runtime.electronApp.waitForEvent('window') await menuButton.click() + const escapeMenuPage = await escapeMenuWindowPromise + await escapeMenuPage.waitForLoadState('domcontentloaded') await page.waitForFunction( (testId) => document.querySelector(`[data-testid="${testId}"]`)?.getAttribute('aria-expanded') === @@ -264,18 +281,81 @@ describe('workbench browser lifecycle e2e', () => { async (tabId) => window.api.browserPanelStateGet({ tabId }), browserTabId ) - const closeMenuResponse = await page.evaluate(() => window.api.browserPanelCloseMenu()) - if (!closeMenuResponse.success) { - throw new Error(closeMenuResponse.error || 'failed to close browser menu') - } + const zoomControlsBox = await escapeMenuPage + .locator('[data-testid="workbench-browser-zoom-controls"]') + .boundingBox() + const externalMenuItemBox = await escapeMenuPage + .getByRole('menuitem', { name: '在系统浏览器中打开' }) + .boundingBox() + const escapeMenuClosePromise = escapeMenuPage.waitForEvent('close') + await escapeMenuPage.keyboard.press('Escape') + await escapeMenuClosePromise await page.waitForFunction( (testId) => document.querySelector(`[data-testid="${testId}"]`)?.getAttribute('aria-expanded') === 'false', workbenchSelectors.browserMenu ) + const outsideMenuWindowPromise = runtime.electronApp.waitForEvent('window') + await menuButton.click() + const outsideMenuPage = await outsideMenuWindowPromise + await outsideMenuPage.waitForLoadState('domcontentloaded') + const outsideMenuClosePromise = outsideMenuPage.waitForEvent('close') + const outsidePoint = await runtime.electronApp.evaluate(({ BrowserWindow }) => { + const mainWindow = BrowserWindow.getAllWindows().find( + (candidate) => candidate.getParentWindow() === null + ) + if (!mainWindow) throw new Error('main window is unavailable') + const bounds = mainWindow.getContentBounds() + return { x: bounds.x + 20, y: bounds.y + 100 } + }) + await outsideMenuPage.waitForTimeout(150) + await clickScreenPoint(outsidePoint.x, outsidePoint.y) + await outsideMenuClosePromise + + await selectWorkbenchResourceMenuItem(runtime, 'file') + const fileTab = page + .locator(`[data-testid^="${workbenchSelectors.resourceTabPrefix}-"]`) + .last() + await browserTab.click() + const switchMenuWindowPromise = runtime.electronApp.waitForEvent('window') + await menuButton.click() + const switchMenuPage = await switchMenuWindowPromise + await switchMenuPage.waitForLoadState('domcontentloaded') + const switchMenuClosePromise = switchMenuPage.waitForEvent('close') + await fileTab.click() + await switchMenuClosePromise + await browserTab.click() + await viewport.waitFor({ state: 'visible' }) const afterMenuRect = await readViewportRect() const afterMenuPixels = await captureViewportPixels() + const moveMenuWindowPromise = runtime.electronApp.waitForEvent('window') + await menuButton.click() + const moveMenuPage = await moveMenuWindowPromise + await moveMenuPage.waitForLoadState('domcontentloaded') + const moveMenuClosePromise = moveMenuPage.waitForEvent('close') + const originalWindowPosition = await runtime.electronApp.evaluate(({ BrowserWindow }) => { + const mainWindow = BrowserWindow.getAllWindows().find( + (candidate) => candidate.getParentWindow() === null + ) + if (!mainWindow) throw new Error('main window is unavailable') + const [x, y] = mainWindow.getPosition() + mainWindow.setPosition(x + 1, y) + return { x, y } + }) + await moveMenuClosePromise + await runtime.electronApp.evaluate(({ BrowserWindow }, position) => { + const mainWindow = BrowserWindow.getAllWindows().find( + (candidate) => candidate.getParentWindow() === null + ) + mainWindow?.setPosition(position.x, position.y) + }, originalWindowPosition) + await page.waitForFunction( + (testId) => + document.querySelector(`[data-testid="${testId}"]`)?.getAttribute('aria-expanded') === + 'false', + workbenchSelectors.browserMenu + ) await createWorkbenchNewSession(page) await page.waitForFunction( @@ -310,6 +390,56 @@ describe('workbench browser lifecycle e2e', () => { const sessionRestoredPixels = await captureViewportPixels() const finalRuntime = await inspectBrowserRuntime() + await browserTab.locator('button[aria-label^="关闭 "]').click() + await browserTab.waitFor({ state: 'detached' }) + const closedTabDestroyed = await runtime.electronApp.evaluate( + ({ webContents }, webContentsId) => + !webContents.getAllWebContents().some((contents) => contents.id === webContentsId), + Number(finalRuntime.id) + ) + + await selectWorkbenchResourceMenuItem(runtime, 'browser') + const ownerBrowserTab = page + .locator(`[data-testid^="${workbenchSelectors.resourceTabPrefix}-browser-"]`) + .last() + const ownerBrowserTabTestId = await ownerBrowserTab.getAttribute('data-testid') + const ownerBrowserTabId = ownerBrowserTabTestId?.split('-browser-').at(-1) || '' + if (!ownerBrowserTabId) throw new Error('owner browser tab id is missing') + const ownerUrl = `${server.url}?owner-delete=1` + const ownerBrowserPanel = getWorkbenchLocator(page, workbenchSelectors.browserPanel) + await ownerBrowserPanel.getByRole('textbox', { name: /浏览器地址/u }).fill(ownerUrl) + await ownerBrowserPanel.getByRole('button', { name: /前往/u }).click() + await page.waitForFunction( + async ({ tabId, url }) => { + const response = await window.api.browserPanelStateGet({ tabId }) + return response.success && response.state?.url === url && !response.state.isLoading + }, + { tabId: ownerBrowserTabId, url: ownerUrl } + ) + const ownerRuntimeId = await runtime.electronApp.evaluate(({ webContents }, url) => { + const contents = webContents + .getAllWebContents() + .find((candidate) => candidate.getURL() === url) + if (!contents) throw new Error('owner browser web contents is unavailable') + return contents.id + }, ownerUrl) + const sessionId = originalSessionTestId.replace( + `${workbenchSelectors.sessionItemPrefix}-`, + '' + ) + await originalSessionItem.hover() + await getWorkbenchLocator( + page, + `${workbenchSelectors.sessionDeleteButtonPrefix}-${sessionId}` + ).click() + await getWorkbenchLocator(page, workbenchSelectors.sessionDeleteConfirmButton).click() + await originalSessionItem.waitFor({ state: 'detached' }) + const deletedOwnerDestroyed = await runtime.electronApp.evaluate( + ({ webContents }, webContentsId) => + !webContents.getAllWebContents().some((contents) => contents.id === webContentsId), + ownerRuntimeId + ) + return { initialRect, restoredRect, @@ -331,7 +461,14 @@ describe('workbench browser lifecycle e2e', () => { restoredUrl: restoredState.state?.url, backgroundUrl, menuZoom: menuState.state?.zoomPercent, - restoreSamples + restoreSamples, + emptyGoDisabled, + externalMenuItemBelowZoom: + zoomControlsBox !== null && + externalMenuItemBox !== null && + externalMenuItemBox.y > zoomControlsBox.y + zoomControlsBox.height, + closedTabDestroyed, + deletedOwnerDestroyed } }, assert: async (result, runtime) => { @@ -361,7 +498,9 @@ describe('workbench browser lifecycle e2e', () => { sameSize(result.restoredRect, result.menuRect) && sameSize(result.menuRect, result.afterMenuRect) && result.restoredZoom === 100 && - result.menuZoom === 100, + result.menuZoom === 100 && + result.emptyGoDisabled && + result.externalMenuItemBelowZoom, { name: 'embedded-browser-native-pixels-remain-visible', message: @@ -402,14 +541,18 @@ describe('workbench browser lifecycle e2e', () => { mountedRestoreSamples.length > 0 && mountedRestoreSamples.every( (sample) => sample.visible === 'true' && sample.width > 100 - ), + ) && + result.closedTabDestroyed && + result.deletedOwnerDestroyed, { name: 'embedded-browser-survives-hidden-running-session', message: '折叠或切走会话后必须移除显示层但保留同一 WebContents;后台导航、表单和滚动状态应在恢复后直接显示。', data: { runtimeSamples, - restoreSamples: result.restoreSamples + restoreSamples: result.restoreSamples, + closedTabDestroyed: result.closedTabDestroyed, + deletedOwnerDestroyed: result.deletedOwnerDestroyed } } )