diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c7d8a402..3467e208 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,8 +50,11 @@ jobs: build-macos-x64: needs: validate-release runs-on: macos-15-intel + environment: ncuratore env: - CSC_IDENTITY_AUTO_DISCOVERY: 'false' + CSC_LINK: ${{ secrets.MAC_CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }} + CSC_IDENTITY_AUTO_DISCOVERY: ${{ secrets.MAC_CSC_LINK != '' }} steps: - uses: actions/checkout@v4 with: @@ -93,8 +96,11 @@ jobs: build-macos-arm64: needs: validate-release runs-on: macos-15 + environment: ncuratore env: - CSC_IDENTITY_AUTO_DISCOVERY: 'false' + CSC_LINK: ${{ secrets.MAC_CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }} + CSC_IDENTITY_AUTO_DISCOVERY: ${{ secrets.MAC_CSC_LINK != '' }} steps: - uses: actions/checkout@v4 with: @@ -241,6 +247,74 @@ jobs: test -n "$R2_BUCKET_NAME" test "${R2_PUBLIC_BASE_URL%/}" = "https://download.ncurator.com" + - name: Prepare R2 update metadata + run: | + node scripts/prepare-r2-update-metadata.mjs \ + release-artifacts \ + r2-update-metadata \ + "$RELEASE_TAG" + + - name: Upload versioned update assets to Cloudflare R2 + shell: bash + run: | + set -euo pipefail + endpoint="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" + while IFS= read -r -d '' source; do + asset="$(basename "$source")" + aws s3 cp "$source" \ + "s3://${R2_BUCKET_NAME}/updates/${RELEASE_TAG}/$asset" \ + --endpoint-url "$endpoint" \ + --region auto \ + --cache-control 'public, max-age=31536000, immutable' \ + --no-progress + done < <(find release-artifacts -type f \ + \( -name '*.dmg' -o -name '*.zip' -o -name '*.exe' -o -name '*.blockmap' \) \ + -print0) + + - name: Verify versioned R2 update assets + shell: bash + run: | + set -euo pipefail + base_url="${R2_PUBLIC_BASE_URL%/}" + while IFS= read -r -d '' source; do + asset="$(basename "$source")" + expected_size="$(stat -c '%s' "$source")" + actual_size="$(curl --fail --silent --show-error --head \ + "$base_url/updates/$RELEASE_TAG/$asset" \ + | tr -d '\r' \ + | awk 'tolower($1)=="content-length:" {value=$2} END {print value}')" + test "$actual_size" = "$expected_size" + done < <(find release-artifacts -type f \ + \( -name '*.dmg' -o -name '*.zip' -o -name '*.exe' -o -name '*.blockmap' \) \ + -print0) + + - name: Promote R2 update metadata + shell: bash + run: | + set -euo pipefail + endpoint="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" + for metadata in latest.yml latest-mac.yml; do + aws s3 cp "r2-update-metadata/$metadata" \ + "s3://${R2_BUCKET_NAME}/updates/latest/$metadata" \ + --endpoint-url "$endpoint" \ + --region auto \ + --content-type application/yaml \ + --cache-control 'no-store' \ + --no-progress + done + + - name: Verify latest R2 update metadata + shell: bash + run: | + set -euo pipefail + base_url="${R2_PUBLIC_BASE_URL%/}" + for metadata in latest.yml latest-mac.yml; do + content="$(curl --fail --silent --show-error \ + "$base_url/updates/latest/$metadata?noCache=${GITHUB_RUN_ID}")" + grep -F "version: ${RELEASE_TAG#v}" <<< "$content" + grep -F "../${RELEASE_TAG}/" <<< "$content" + done + - name: Upload versioned installers to Cloudflare R2 shell: bash run: | diff --git a/.github/workflows/sync-r2.yml b/.github/workflows/sync-r2.yml index 9894f9e1..86ad25e1 100644 --- a/.github/workflows/sync-r2.yml +++ b/.github/workflows/sync-r2.yml @@ -19,6 +19,8 @@ jobs: R2_PUBLIC_BASE_URL: ${{ secrets.R2_PUBLIC_BASE_URL }} RELEASE_TAG: ${{ inputs.tag }} steps: + - uses: actions/checkout@v4 + - name: Validate R2 configuration shell: bash run: | @@ -30,15 +32,24 @@ jobs: test "${R2_PUBLIC_BASE_URL%/}" = "https://download.ncurator.com" [[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] - - name: Download installers from GitHub Release + - name: Download update assets from GitHub Release shell: bash run: | set -euo pipefail mkdir -p release-assets for asset in \ + latest.yml \ + latest-mac.yml \ Ncurator-mac-arm64.dmg \ + Ncurator-mac-arm64.dmg.blockmap \ + Ncurator-mac-arm64.zip \ + Ncurator-mac-arm64.zip.blockmap \ Ncurator-mac-x64.dmg \ - Ncurator-windows-x64.exe + Ncurator-mac-x64.dmg.blockmap \ + Ncurator-mac-x64.zip \ + Ncurator-mac-x64.zip.blockmap \ + Ncurator-windows-x64.exe \ + Ncurator-windows-x64.exe.blockmap do curl --fail --location --retry 3 \ --output "release-assets/$asset" \ @@ -46,6 +57,76 @@ jobs: done ls -lh release-assets + - name: Prepare R2 update metadata + run: | + node scripts/prepare-r2-update-metadata.mjs \ + release-assets \ + r2-update-metadata \ + "$RELEASE_TAG" + + - name: Upload versioned update assets to R2 + shell: bash + run: | + set -euo pipefail + endpoint="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" + for source in release-assets/*; do + asset="$(basename "$source")" + case "$asset" in + latest.yml|latest-mac.yml) continue ;; + esac + aws s3 cp "$source" \ + "s3://${R2_BUCKET_NAME}/updates/${RELEASE_TAG}/$asset" \ + --endpoint-url "$endpoint" \ + --region auto \ + --cache-control 'public, max-age=31536000, immutable' \ + --no-progress + done + + - name: Verify versioned R2 update assets + shell: bash + run: | + set -euo pipefail + base_url="${R2_PUBLIC_BASE_URL%/}" + for source in release-assets/*; do + asset="$(basename "$source")" + case "$asset" in + latest.yml|latest-mac.yml) continue ;; + esac + expected_size="$(stat -c '%s' "$source")" + actual_size="$(curl --fail --silent --show-error --head \ + "$base_url/updates/$RELEASE_TAG/$asset" \ + | tr -d '\r' \ + | awk 'tolower($1)=="content-length:" {value=$2} END {print value}')" + test "$actual_size" = "$expected_size" + done + + - name: Promote R2 update metadata + shell: bash + run: | + set -euo pipefail + endpoint="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" + for metadata in latest.yml latest-mac.yml; do + aws s3 cp "r2-update-metadata/$metadata" \ + "s3://${R2_BUCKET_NAME}/updates/latest/$metadata" \ + --endpoint-url "$endpoint" \ + --region auto \ + --content-type application/yaml \ + --cache-control 'no-store' \ + --no-progress + done + + - name: Verify latest R2 update metadata + shell: bash + run: | + set -euo pipefail + base_url="${R2_PUBLIC_BASE_URL%/}" + for metadata in latest.yml latest-mac.yml; do + content="$(curl --fail --silent --show-error \ + "$base_url/updates/latest/$metadata?noCache=${GITHUB_RUN_ID}")" + grep -F "version: ${RELEASE_TAG#v}" <<< "$content" + grep -F "../${RELEASE_TAG}/" <<< "$content" + done + - name: Upload versioned installers to R2 shell: bash run: | diff --git a/doc/app-update-test.md b/doc/app-update-test.md new file mode 100644 index 00000000..a9a37274 --- /dev/null +++ b/doc/app-update-test.md @@ -0,0 +1,81 @@ +# 自动更新测试说明 + +自动更新分为三层测试。 + +## 1. 快速测试 + +运行单元测试和模拟 E2E: + +```bash +pnpm test:update +``` + +也可以分开运行: + +```bash +pnpm test:update:unit +pnpm test:update:e2e +``` + +单元测试验证主进程的更新状态、错误处理和安装调用。模拟 E2E 验证应用更新页面,不会下载真实更新包。 + +## 2. 真实升级测试 + +只有明确需要验证真实自动更新时才运行: + +```bash +pnpm test:update:real +``` + +当前脚本支持 macOS,默认执行 `0.1.0 → 0.1.1`。脚本会: + +1. 使用当前代码打包一个版本号为 `0.1.0` 的测试 App。 +2. 写入与正式构建相同的 Generic 更新地址。 +3. 使用独立的用户数据和缓存启动测试 App。 +4. 从正式 R2 更新源检查并下载 `0.1.1`。 +5. 调用重启安装。 +6. 检查 App 版本是否变成 `0.1.1`。 +7. 检查测试用户数据是否保留。 +8. 删除临时 App,保留测试报告。 + +不会修改正在运行的正式 App,也不会使用正式用户数据。 + +可以指定版本。也可以传入已经打包好的旧版 App: + +```bash +pnpm test:update:real -- \ + --source-app dist/mac-arm64/Ncurator.app \ + --source-version 0.1.0 \ + --expected-version 0.1.1 +``` + +需要单独诊断 GitHub Release 文件时,可以启动本地元数据代理: + +```bash +pnpm test:update:real -- --github-release-tag v0.1.1 +``` + +这个参数只用于区分“更新安装逻辑”和“正式 R2 更新源”问题,不能代替正式更新源测试。 + +报告保存在: + +```text +tests/logs/settings/update/real/<运行时间>/report.json +tests/logs/settings/update/real/<运行时间>/process.log +``` + +真实升级测试会下载完整更新包,耗时和流量都比较大,不应放入普通 PR 或日常测试。 + +## 3. 判定标准 + +只有同时满足以下条件,真实升级才算通过: + +- 能发现指定的新版本。 +- 更新包下载完成且校验通过。 +- 调用重启安装后 App 被替换。 +- App 版本变成目标版本。 +- 测试用户数据仍然存在。 + +如果下载完成但 App 没有被替换,应优先检查 macOS 代码签名和 Squirrel.Mac 日志。 + +当前 macOS 正式包没有 Developer ID 签名。应用会关闭 macOS 自动更新,并在设置页直接显示原因。配置签名后,应重新执行真实升级测试,只有安装替换成功才算完整通过。 diff --git a/doc/release.md b/doc/release.md index 7cc69a95..05c34277 100644 --- a/doc/release.md +++ b/doc/release.md @@ -3,10 +3,10 @@ ## 1. 三个发布位置的职责 - 源码仓库 `Yoan98/Ncurator-Desktop`:保存源码、Tag 和 GitHub Actions。 -- 公共 Release 仓库 `Yoan98/Ncurator`:保存安装包和自动更新文件,供应用内更新使用。 -- Cloudflare R2 `https://download.ncurator.com`:保存官网安装包,供用户首次下载安装。 +- 公共 Release 仓库 `Yoan98/Ncurator`:保存完整发布归档,也作为 R2 同步来源。 +- Cloudflare R2 `https://download.ncurator.com`:保存官网安装包和应用内自动更新文件。 -官网不直接使用 GitHub Release 下载链接。应用内自动更新目前仍然使用 GitHub Release,两者不要混用。 +官网安装包使用 R2 的 `releases/latest`,应用内自动更新使用 R2 的 `updates/latest`。 `NCURATOR_RELEASE_TOKEN` 只供 GitHub Actions 向公共 Release 仓库上传文件,不会被打进应用。 @@ -79,15 +79,23 @@ git push origin v0.1.1 ## 7. 应用内更新 -- 打包后的应用会自动检查更新。 +- Windows 打包应用会自动检查更新。 +- macOS 只有通过 Developer ID 正式签名后才启用自动更新。 - 用户也可以在 `设置 -> 应用更新` 手动检查。 -- 找到新版本后,应用在后台下载 GitHub Release 中的更新包。 +- 找到新版本后,应用从 `https://download.ncurator.com/updates/latest` 下载更新包。 - 下载完成后,用户可以点击重启安装。 -应用内更新仍走 GitHub,不走 `download.ncurator.com`。以后如果要迁移,必须同时修改 electron-builder 发布配置和更新服务,不能只替换官网链接。 +未签名的 macOS 构建会直接显示签名原因,不会先下载完整更新包再安装失败。 ## 8. 当前签名限制 -- macOS 还没有 Apple 公证,首次打开时可能需要用户在系统设置中允许。 +- macOS 还没有 Developer ID 签名和 Apple 公证,因此当前不会启用自动更新,首次打开时也可能需要用户在系统设置中允许。 - Windows 还没有代码签名证书,SmartScreen 可能提示未知发布者。 - 自动更新文件会通过 `sha512` 校验,校验失败不会安装。 + +启用 macOS 自动更新时,在 GitHub Actions Environment `ncuratore` 中配置: + +- `MAC_CSC_LINK`:Developer ID Application 证书的 base64 内容或安全下载地址。 +- `MAC_CSC_KEY_PASSWORD`:证书密码。 + +CI 检测到证书后会自动签名。应用运行时也会检查 Developer ID,签名有效才启用 macOS 自动更新。 diff --git a/doc/test-plan/ai-test.md b/doc/test-plan/ai-test.md index 5c678957..c8921a26 100644 --- a/doc/test-plan/ai-test.md +++ b/doc/test-plan/ai-test.md @@ -41,14 +41,12 @@ - 通过率:`22 / 22` - 检测时间:`2026-06-11 17:16:38 +08:00` - `human / live` - - 当前有效结论:`2 / 2`(2026-06-11 定向执行 `web_crawl` live case;重复的 DeepSeek web_search live backend case 已删除,网页搜索改由 Workbench UI E2E 覆盖) + - 登记 case:`8`;当前最近有效结论为 2026-06-11 定向执行的 `web_crawl` live `2 / 2`,其余代表性 memory、resume、compaction 与 docx 交付 case 保留并按需全量复验。 - 检测时间:`2026-06-11` - 样例评分: - `human-web-crawl-url-inference-reading-live`:`9.0 / 10`;最近有效执行:`2026-06-11`;依据:模型自行推断官网地址并多次调用 `web_crawl` 完成 CSV 文档整理;主要风险:live 模型稳定性和冷启动耗时。 - `human-web-crawl-multi-hop-cross-domain-reading-live`:`8.8 / 10`;最近有效执行:`2026-06-11`;依据:模型围绕入口网址多跳抓取并整合申请对象、材料、截止时间和办理方式;主要风险:多轮 live 调用耗时偏长。 - `human-risk-report-draft-live`:`8.3 / 10`;最近有效执行:`2026-06-01`;依据:能创建周报 docx 并读取多源材料完成负责人、后续动作和风险主线;主要风险:风险口径表达仍需按最低断言收敛。 - - `human-docx-cause-remedy-reading-live`:`8.4 / 10`;最近有效执行:`2026-06-01`;依据:能读取两份已选课件并讲清过拟合与反走样核心事实;主要风险:结构化输出超时。 - - `human-docx-cross-source-summary-draft-live`:`8.0 / 10`;最近有效执行:`2026-06-01`;依据:能创建跨源总结 docx 并形成可交付文件;主要风险:目标路径和资料读取方式不稳定。 - `human-conversation-memory-multi-run-live`:`8.2 / 10`;最近有效执行:`2026-06-01`;依据:能把第一轮风险主线、负责人分工和待确认项带入第二轮追问;主要风险:连续主线保持不稳定。 - `human-context-compaction-natural-trigger-live`:`9.0 / 10`;最近有效执行:`2026-06-01`;依据:能自然触发压缩、压缩后继续任务并产出文件 artifact;主要风险:整体耗时偏长。 - `human-session-resume-with-new-selection-live`:`8.5 / 10`;最近有效执行:`2026-06-01`;依据:恢复快照保留旧事实,第二轮可结合新选择文件形成对比小结;主要风险:不得绑定固定 prompt 原文。 @@ -623,6 +621,15 @@ AI unit / 辅助测试目录: - Oracle:结果包含 `requestedUrl`、`finalUrl`、`title`、`markdown`、`publishedAt`、`links.internal/external`,且 links 项包含 `intrinsicScore/contextualScore/totalScore`。 - Not:不验证复杂浏览器交互或外网可达性。 +##### `web-search-contract` + +- 测试类型:`backend` +- What:验证 `web_search` 返回 query 与结构化候选网页结果。 +- Why:网页搜索负责提供后续可读取的候选入口,不能只返回不可追踪的拼接文本。 +- How:通过真实 web tool harness 调用 `web_search`,使用可控搜索服务返回候选页面。 +- Oracle:结果保留原 query,候选项包含稳定 rank、标题和 URL,且第一条结果指向预期页面。 +- Not:不验证候选页面正文读取;正文读取由 `web_crawl` 与 Workbench 网页读取 case 覆盖。 + ##### `web-crawl-entry-page-contract` - 测试类型:`backend` @@ -668,6 +675,15 @@ AI unit / 辅助测试目录: - Oracle:工具结果仍是成功字符串。 - Not:不验证 UI 渲染。 +##### `llm-recognize-unsupported-vision-contract` + +- 测试类型:`backend` +- What:验证模型协议明确声明不支持图像识别时,`llm_recognize` 返回稳定错误结果。 +- Why:不支持图片输入属于模型能力边界,必须与普通“无法确认”文本和底层调用失败区分。 +- How:启动本地 OpenAI-compatible HTTP server,返回 `supports_image_recognition=false`,再调用 `llm_recognize`。 +- Oracle:工具结果返回包含当前模型名的固定不支持图像识别错误,提示用户切换模型。 +- Not:不验证 UI 中的模型切换操作。 + ##### `llm-recognize-bottom-error-contract` - 测试类型:`backend` @@ -993,23 +1009,7 @@ AI unit / 辅助测试目录: - Oracle:任务最终完成;共享 workspace 中全部已支持文件均被索引;事件流中至少出现一次 `terminal_run`、至少一次真实资料读取/检索步骤(可来自 `docx_*`、`kb_*` 或其他支持的读工具)以及一次 `docx_edit(create_document)`;模型会打开模板和上周周报;最终输出 artifacts 中包含携带 `applyId` 的 docx file artifact;模板与既有参考 `docx` 文件不被覆盖;新建周报文件中应写入本周整体判断、负责人说明和后续动作;若发生 docx 写,则写工具结果应包含 `postWriteReadback.regions`;不要求固定取证路径,不要求固定风险结论措辞,只要任务真实完成即可通过。 - Not:不绑定最终答案措辞,不要求固定是否使用 `docx_outline` 还是 `docx_read_blocks`,也不强制一定调用 KB;若模型仅凭工作区材料完成任务,也视为符合预期。 -##### `human-docx-cause-remedy-reading-live` -- 测试类型:`backend` -- What:验证 live 模式下 `human` 会基于两份已选 `docx` 做跨文件精读,并准确回答“过拟合 / 反走样”的成因与处理思路;同时工作区存在其他 docx 干扰项也不会偏题。 -- Why:真实用户在复习场景里经常已经选中了资料,但 workspace 中通常不止这两份文档;这条 case 要长期守住“已选资料范围优先 + 跨文件准确回答”的能力,对齐 `WB-CORE-002-AC2`、`WB-CORE-004-AC1`、`WB-CORE-006-AC1`、`WB-CORE-006-AC2`。 -- How:注入真实 live LLM 配置;把 `tests/fixtures/workspace` 共享目录复制进 workspace,其中只将 `学习笔记/深度学习.docx` 与 `学习笔记/图形学.docx` 作为已选资料;直接输入“我这两天复习的时候老把这两个概念混了。你就按我刚选的这两份课件,帮我讲清楚:过拟合和反走样分别是什么、为什么会出现、一般怎么处理。别自己补课外内容,哪份资料没写你就直接告诉我没写。”的自然语言诉求,让真实 `human` 自主完成。 -- Oracle:任务最终完成;事件流中至少出现一次 `docx_search`,并且至少两次 `docx_read_blocks` / `docx_outline` / `docx_search` 覆盖两份已选目标文档;最终回答同时命中“过拟合”的定义/成因事实组,并对处理办法给出文档内事实或明确说明“没写”;同时命中“反走样”的成因/处理事实组;不出现 docx 编辑工具;回答不应明显被 workspace 里的其他 docx 干扰。 -- Not:不要求固定回答结构,也不对最终文案做逐字断言。 - -##### `human-docx-cross-source-summary-draft-live` - -- 测试类型:`backend` -- What:验证 live 模式下 `human` 会读取两份已选课件,自主创建一份新的总结 docx,并把跨源总结写进去。 -- Why:真实用户常见诉求不只是“口头总结”或“补现有主稿”,也包括“新建一份能直接发出去的总结报告”;这条 case 要长期守住跨源读取、单目标写入与不覆盖原文件的能力,对齐 `WB-CORE-002-AC2`、`WB-CORE-003-AC1`、`WB-CORE-004-AC1`、`WB-CORE-006-AC1`、`WB-CORE-006-AC2`。 -- How:注入真实 live LLM 配置;把 `深度学习.docx` 与 `图形学.docx` 作为已选资料复制进 workspace;直接输入“帮我基于我刚选的这两个文件,生成一个给同学复习用的 Word 总结文件,文件名就叫《深度学习与图形学复习总结》。里面把关键概念、出现原因和常见处理方式整理清楚;哪份资料没写你就直接说明没写。”的自然语言诉求,让真实 `human` 自主完成。 -- Oracle:任务最终完成;事件流中至少两次 `docx_search` / `docx_read_blocks` / `docx_outline` 覆盖两份源资料;至少出现一次 `docx_edit(create_document)`;最终输出 artifacts 中包含携带 `applyId` 的 docx file artifact;两份原始源 `docx` 文件未被覆盖;创建目标只应落在 `深度学习与图形学复习总结.docx` 上且目标文件在 run 完成后存在;最终答复应包含“过拟合/过度拟合”与“反走样”两个主题。 -- Not:不要求固定总结结构,也不要求一定生成表格。 ##### `human-docx-positioned-structural-edit-fallback-live` diff --git a/doc/test-plan/settings-test.md b/doc/test-plan/settings-test.md index 904919b2..07c25476 100644 --- a/doc/test-plan/settings-test.md +++ b/doc/test-plan/settings-test.md @@ -84,10 +84,10 @@ Settings 的回归测试顶层 suite 直接对齐 PRD 模块: #### `model-create-edit-form` - 测试类型:`e2e` -- What:验证新增和编辑连接配置的表单内容、预填逻辑与必填校验。 +- What:验证新增和编辑连接配置的表单内容、预填逻辑、必填校验,以及空列表中第一条配置的普通服务语义。 - Why:连接配置是后续 workbench 可用性的前提,一旦表单字段、预填或校验失真,用户很容易保存出不可用配置。 -- How:分别进入新增和编辑流;新增流检查表单字段,编辑流检查预填;再覆盖缺少必填字段时点击保存的路径。 -- Oracle:新增表单可填写配置名称、模型服务地址和 API Key,对齐 `SET-MODEL-002-AC1`;编辑现有配置时表单带出当前值,对齐 `SET-MODEL-002-AC2`;必填字段不完整时保存被拦截,对齐 `SET-MODEL-002-AC3`;新增和编辑弹窗点击遮罩后保持打开且输入不丢失,对齐 `SET-MODEL-002-AC6`。 +- How:从空配置列表进入新增流,检查表单字段、必填校验和首次保存后的列表语义;再进入编辑流检查预填。 +- Oracle:新增表单可填写配置名称、模型服务地址和 API Key,对齐 `SET-MODEL-002-AC1`;第一条配置保存后作为普通可用服务展示,不产生默认、当前或启用状态,对齐 `SET-MODEL-001-AC1`、`SET-MODEL-001-AC2`;编辑现有配置时表单带出当前值,对齐 `SET-MODEL-002-AC2`;必填字段不完整时保存被拦截,对齐 `SET-MODEL-002-AC3`;新增和编辑弹窗点击遮罩后保持打开且输入不丢失,对齐 `SET-MODEL-002-AC6`。 - Not:不验证 workbench 模型选择。 #### `model-remote-list-refresh` @@ -99,14 +99,6 @@ Settings 的回归测试顶层 suite 直接对齐 PRD 模块: - Oracle:获取模型列表成功后,配置列表展示模型数量且弹窗展示具体模型,对齐 `SET-MODEL-002-AC4`;失败时列表和弹窗展示明确错误提示,对齐 `SET-MODEL-002-AC5`。 - Not:不验证模型列表持久化;当前模型列表仅作为展示和 workbench 下拉来源。 -#### `model-first-config-saves-service` - -- 测试类型:`e2e` -- What:验证当配置列表为空时,新增第一条配置后会作为可用服务展示。 -- Why:第一条配置保存后应立即进入可用服务列表,但不产生默认、启用或当前状态。 -- How:以空配置列表进入新增流,保存第一条有效连接配置。 -- Oracle:第一条配置保存成功后出现在配置列表中,对齐 `SET-MODEL-001-AC1` 和 `SET-MODEL-001-AC2`。 -- Not:不验证 workbench 是否自动选择该模型。 #### `model-delete-config-feedback` @@ -140,10 +132,10 @@ Settings 的回归测试顶层 suite 直接对齐 PRD 模块: #### `workspace-add-selection-flow` - 测试类型:`e2e` -- What:验证添加工作空间时按名称创建空容器,并在成功后自动切换到新工作空间。 +- What:验证无工作空间和未绑定目录时的空状态,以及按名称创建空容器后自动切换到新工作空间。 - Why:工作空间已从“目录快照”改为“具名容器”,首个创建动作必须反映这一语义。 -- How:进入空工作空间页,覆盖空名称提交和有效名称提交两条路径。 -- Oracle:名称为空时不允许提交,对齐 `SET-WORKSPACE-002-AC1`;点击遮罩后弹窗保持打开且输入不丢失,对齐 `SET-WORKSPACE-002-AC3`;创建成功后,新工作空间立即进入列表并成为当前选中项,对齐 `SET-WORKSPACE-002-AC2`。 +- How:进入无工作空间页面检查列表和详情空状态,覆盖空名称提交和有效名称提交,再检查新建未绑定工作空间的文件区空状态。 +- Oracle:没有工作空间时显示明确选择提示,对齐 `SET-WORKSPACE-007-AC1`;名称为空时不允许提交,对齐 `SET-WORKSPACE-002-AC1`;点击遮罩后弹窗保持打开且输入不丢失,对齐 `SET-WORKSPACE-002-AC3`;创建成功后,新工作空间立即进入列表并成为当前选中项,对齐 `SET-WORKSPACE-002-AC2`;未绑定目录时文件区显示明确空态,对齐 `SET-WORKSPACE-007-AC2`。 - Not:不验证创建后自动扫描或自动索引;该需求未在 PRD 中定义。 #### `workspace-set-root-path` @@ -173,16 +165,6 @@ Settings 的回归测试顶层 suite 直接对齐 PRD 模块: - Oracle:移除成功后,该工作空间从列表中消失,对齐 `SET-WORKSPACE-006-AC2`;详情区不会继续显示已删除工作空间的陈旧数据。 - Not:不验证其他消费页面中的范围清理展示;只覆盖 Settings 页内可见结果。 -#### `workspace-empty-tree-state` - -- 测试类型:`e2e` -- What:验证无工作空间和文件树为空两类提示都能明确展示。 -- Why:这些都是首屏阻塞状态,需要直接解释“现在为什么不能继续”。 -- How:先进入无工作空间的 Settings 工作空间页,再通过 UI 创建一个未绑定目录的空工作区。 -- Oracle:没有工作空间时,列表和详情区都给出明确空态,对齐 `SET-WORKSPACE-007-AC1`;当前目录没有可显示文件时,目录树区域给出空态,对齐 `SET-WORKSPACE-007-AC2`。 -- Not:不验证旧本地导入提示;旧数据清理属于 backend / 启动迁移契约,不用假 e2e 制造。 - -### 4.3 `workspace / files` #### `workspace-index-job-store-contract` @@ -225,18 +207,10 @@ Settings 的回归测试顶层 suite 直接对齐 PRD 模块: - 测试类型:`e2e` - What:验证点击“更新索引”后,文件状态会刷新为已索引并同步更新摘要。 - Why:这是“索引已经可用”的核心用户反馈链路。 -- How:创建一条带索引延迟的 fixture 工作空间,触发更新索引并读取刷新后的文件树响应。 -- Oracle:更新索引过程中出现持久化总进度,且检查更新/更新索引按钮同时禁用,对齐 `SET-WORKSPACE-004-AC3`;更新成功后,`indexed / needs_index` 摘要和节点状态同步刷新,对齐 `SET-WORKSPACE-004-AC2`。 +- How:复制共享 workspace fixture 的隔离副本并通过真实 UI 绑定,触发更新索引,读取刷新后的文件树和全部支持文件状态。 +- Oracle:更新索引过程中出现持久化总进度,且检查更新/更新索引按钮同时禁用,对齐 `SET-WORKSPACE-004-AC3`;共享 fixture 中全部支持文件真实进入处理范围,对齐 `SET-WORKSPACE-004-AC1`;更新后所有支持文件收敛为 `indexed / failed`,摘要和节点状态同步刷新,对齐 `SET-WORKSPACE-004-AC2`。 - Not:不验证向量内容质量和搜索召回结果。 -#### `workspace-prepare-real-fixture-refresh` - -- 测试类型:`e2e` -- What:验证 Settings 页面点击“更新索引”时,会对共享真实工作区 fixture 中的支持文件执行真实索引链路,并完成状态收敛。 -- Why:现有文件索引 E2E 需要覆盖真实 docx 加载、切分、embedding 与本地索引写入;该链路是用户手动点击更新索引时最容易出现闪退的真实风险面。 -- How:把 `tests/fixtures/workspace` 原样复制到隔离临时目录,创建工作空间并绑定该目录,从 Settings 文件 tab 点击“更新索引”,轮询持久化 job、摘要和文件节点状态。 -- Oracle:更新索引执行中出现持久化进度且两个索引操作按钮禁用,对齐 `SET-WORKSPACE-004-AC3`;任务结束后应用仍存活,支持文件状态收敛为 `indexed / failed`,文件摘要同步刷新,对齐 `SET-WORKSPACE-004-AC1`、`SET-WORKSPACE-004-AC2`。 -- Not:不验证检索召回排序和向量语义质量;只验证真实文件索引链路不会停留在执行中或导致页面不可用。 #### `workspace-prepare-failed-retry` @@ -294,40 +268,24 @@ Settings 的回归测试顶层 suite 直接对齐 PRD 模块: ### 4.4 `workspace / web` -#### `workspace-detail-contains-web-management` - -- 测试类型:`e2e` -- What:验证网页资料管理直接内嵌在当前工作空间详情区内,并与文件 tab 平级切换。 -- Why:网页不再从属于文件区域;如果这里退回旧布局,交互心智会再次偏向文件主视角。 -- How:创建工作区和网页资料,进入工作空间详情并切到网页 tab。 -- Oracle:网页 tab 内直接出现索引状态面板、列表和添加入口,对齐 `SET-WORKSPACE-005-AC3`、`SET-WEB-001-AC3`;整个操作停留在 `/settings/workspace` 内完成,对齐 `SET-WORKSPACE-005-AC3`。 -- Not:不验证网页列表项的打开和删除动作;这些由独立 case 覆盖。 #### `web-add-sources-dialog` - 测试类型:`e2e` -- What:验证网页资料添加弹窗支持空输入校验、多行 URL 提交和成功后的清空收尾。 +- What:验证网页资料管理内嵌在工作空间详情中,并验证添加弹窗的空输入校验、多行 URL 提交和成功后的清空收尾。 - Why:这是网页资料进入系统的主入口。 -- How:进入网页 tab,覆盖空输入提交、多行 URL 提交和再次打开弹窗三条路径。 -- Oracle:输入为空时提交被拦截,对齐 `SET-WEB-002-AC1`;点击遮罩后弹窗保持打开且已输入 URL 不丢失,对齐 `SET-WEB-002-AC4`;多行 URL 提交后,网页资料进入当前工作区列表,对齐 `SET-WEB-002-AC2`;提交成功后弹窗关闭且输入被清空,对齐 `SET-WEB-002-AC3`。 +- How:进入工作空间详情的网页 tab,确认列表和添加入口保持在 `/settings/workspace`,再覆盖空输入提交、多行 URL 提交和再次打开弹窗三条路径。 +- Oracle:网页管理位于工作空间详情且不跳转独立路由,对齐 `SET-WORKSPACE-005-AC3`、`SET-WEB-001-AC3`;输入为空时提交被拦截,对齐 `SET-WEB-002-AC1`;点击遮罩后弹窗保持打开且已输入 URL 不丢失,对齐 `SET-WEB-002-AC4`;多行 URL 提交后,网页资料进入当前工作区列表,对齐 `SET-WEB-002-AC2`;提交成功后弹窗关闭且输入被清空,对齐 `SET-WEB-002-AC3`。 - Not:不验证 URL 抓取内容质量,也不验证 include/exclude selector 的解析效果。 -#### `web-scan-summary-refresh` - -- 测试类型:`e2e` -- What:验证点击“检查更新”后,网页状态会刷新为需索引,并同步更新摘要统计。 -- Why:网页和文件一样,需要先判断“内容是否变了”再决定是否重建索引。 -- How:预置一条已索引网页,执行“检查更新”并读取刷新后的网页列表。 -- Oracle:检查执行中出现持久化进度,且检查更新/更新索引按钮同时禁用,对齐 `SET-WEB-003-AC1`;检查成功后,网页列表状态与需索引摘要同步刷新,对齐 `SET-WEB-003-AC2`。 -- Not:不验证真实网页 HTTP 缓存头、正文 diff 算法和网络失败分支。 #### `web-prepare-summary` - 测试类型:`e2e` -- What:验证点击“更新索引”后,网页会区分成功索引与爬取失败,并刷新摘要统计。 +- What:验证网页“检查更新 → 更新索引”完整链路会先刷新可索引状态,再区分成功索引与爬取失败并刷新摘要。 - Why:网页索引是“爬取 + 建索引”两阶段链路,失败类型必须可区分。 - How:预置一条成功网页和一条带失败标记的网页,先执行“检查更新”,再执行“更新索引”。 -- Oracle:更新索引执行中出现抓取/索引分阶段进度,且检查更新/更新索引按钮同时禁用,对齐 `SET-WEB-004-AC4`;成功处理的网页转为已索引,对齐 `SET-WEB-004-AC2`;失败网页被标记为爬取失败,失败数量同步更新,对齐 `SET-WEB-004-AC3`。 +- Oracle:检查更新执行中出现进度且两个按钮禁用,完成后网页进入 `needs_index`,对齐 `SET-WEB-003-AC1`、`SET-WEB-003-AC2`;更新索引执行中出现抓取/索引分阶段进度,且两个按钮继续禁用,对齐 `SET-WEB-004-AC4`;成功网页转为已索引,对齐 `SET-WEB-004-AC2`;失败网页被标记为爬取失败,失败数量同步更新,对齐 `SET-WEB-004-AC3`。 - Not:不验证真实网页正文切块、embedding 写入质量和索引失败以外的网络细节。 #### `web-prepare-progress-restore` diff --git a/doc/test-plan/workbench-test.md b/doc/test-plan/workbench-test.md index 3bec8ca7..10b676fa 100644 --- a/doc/test-plan/workbench-test.md +++ b/doc/test-plan/workbench-test.md @@ -29,7 +29,7 @@ - 登记 case:`9` 个 e2e,`1` 个 backend contract - 当前有效结论:backend contract `1 / 1`;e2e `8 / 9` 通过,`1 / 9` 阻断。 - 检测时间:backend `2026-07-13 13:08:55 +08:00`;e2e `2026-07-13 15:13:08 +08:00` - - 备注:`browser-use-deepseek-financing-search` 阻断;真实模型完成了 `web_search + web_crawl` 并正确回答融资信息,但没有遵循用户“用浏览器搜索”的明确要求调用 `browser_use`,未满足右侧内置浏览器闭环验收。 + - 备注:公网搜索中的模型工具选择不再作为稳定 E2E 门禁;内置浏览器闭环统一由本地可控网页的 `browser-use-task-panel` 覆盖。 - `result` - 登记 case:`5` - 当前有效结论:`4 / 5` 通过,`1 / 5` 阻断。 @@ -132,10 +132,10 @@ tests/case/workbench/ #### `send-guard-mode-switch-and-preferences` - 测试类型:`e2e` -- What:验证发送前置条件、模式切换保留输入、模型 / 工作区 / AI 推进偏好恢复和失效回退。 +- What:验证缺少模型时的模式提示、发送前置条件、模式切换保留输入,以及模型 / 工作区 / AI 推进偏好恢复。 - Why:Workbench 的任务入口依赖这些基础交互;任一状态失真都会导致用户无法发起任务、把任务发到错误上下文或使用错误资料边界。 -- How:通过 UI 配置真实模型和工作区;覆盖无工作区、模式切换、模型搜索、模型 / 工作区选择、AI 推进偏好二级菜单、搜索模式隐藏偏好入口和重新进入页面后的恢复;真实发送时走真实模型。 -- Oracle:未绑定工作区不会触发发送,对齐 `WB-COMPOSER-001-AC1`、`WB-COMPOSER-005-AC1`;切换模式后输入保留且发送路径按新模式执行,对齐 `WB-MODE-001-AC1`、`WB-MODE-001-AC2`;模型和工作区恢复符合 `WB-COMPOSER-007-AC1` 至 `WB-COMPOSER-007-AC5`;可选择并恢复“本地资料·严谨”,搜索模式隐藏偏好入口且切回后保留选择,对齐 `WB-COMPOSER-007-AC2`、`WB-COMPOSER-007-AC6`、`WB-COMPOSER-007-AC7`。 +- How:先在无模型配置状态下切换智能 / 搜索模式检查提示,再通过 UI 配置真实模型和工作区;覆盖无工作区、输入保留、模型 / 工作区选择、AI 推进偏好、重新进入页面后的恢复和真实发送。 +- Oracle:无模型时智能模式快速显示配置提示,搜索模式隐藏且切回后恢复;未绑定工作区不会触发发送,对齐 `WB-COMPOSER-001-AC1`、`WB-COMPOSER-005-AC1`;切换模式后输入保留且发送路径按新模式执行,对齐 `WB-MODE-001-AC1`、`WB-MODE-001-AC2`;模型和工作区恢复符合 `WB-COMPOSER-007-AC1` 至 `WB-COMPOSER-007-AC5`;可选择并恢复“本地资料·严谨”,搜索模式隐藏偏好入口且切回后保留选择,对齐 `WB-COMPOSER-007-AC2`、`WB-COMPOSER-007-AC6`、`WB-COMPOSER-007-AC7`。 - Not:不验证真实模型回答质量,只验证用户路径和发送前置条件。 #### `user-message-actions` @@ -156,14 +156,6 @@ tests/case/workbench/ - Oracle:真实粘贴会触发附件保存;添加附件后没有真实会话;图片预览可打开;文件副标题显示 `MD`;不支持格式只提示不加入;大文件只提示不拦截;普通文本粘贴仍正常,对齐 `WB-COMPOSER-010-AC2`、`WB-COMPOSER-010-AC3`。 - Not:不验证系统原生文件选择器本身;不通过 `attachmentPickResponse` 或直接 `attachmentSaveFile` 伪造附件结果。 -#### `composer-text-selection` - -- 测试类型:`e2e` -- What:验证输入框鼠标松开后保留用户已框选文字,不会把光标强制移动到末尾。 -- Why:用户编辑长输入时需要稳定选择文本;如果 selection 被重置,会造成误删、误改或无法替换选中文本。 -- How:在真实 Workbench composer 中输入多段文本,用鼠标框选中间文字并松开,读取 selection 状态。 -- Oracle:鼠标松开后选区仍覆盖原选中文字,selection 不被自动折叠到输入末尾。 -- Not:不验证系统输入法和浏览器原生 selection 的全部边界。 #### `reference-mention-keyboard` @@ -248,14 +240,6 @@ tests/case/workbench/ - Oracle:收到浏览器面板事件后右侧进入内置浏览器状态并随面板 resize 对齐,对齐 `WB-PANEL-004-AC1`;传入 URL 时先导航再执行网页操作,对齐 `WB-PANEL-004-AC2`;切换其他面板或收起时浏览器视图不遮挡其他内容,对齐 `WB-PANEL-004-AC3`;任务完成后真实 browser_use 结果包含本地网页返回的 `BROWSER_TASK_DONE` 和口令,对齐 `WB-PANEL-004-AC4`、`WB-CORE-005-AC1`、`WB-CORE-006-AC1`;记录模型、耗时、完成度评分和失败归因。 - Not:不验证公网网页稳定性;本 case 使用本地测试网页减少外部波动;不允许用 fake browser 或 fake tool result 替代;不要求 live 模型必须在固定时限内结束整轮最终回答,只要真实内置浏览器已完成输入、点击和结果读取即可判定 browser panel 能力有效。 -#### `browser-use-deepseek-financing-search` - -- 测试类型:`e2e` -- What:验证真实 AI 使用 `browser_use` 完成公网搜索页导航、观察、操作、抽取,并基于抽取内容回答 DeepSeek 最近融资问题;同时守住新的 browser_use 输出 schema。 -- Why:本地网页 case 能验证浏览器面板和交互,但不能验证公网搜索页链路;输出 schema 变更后也需要真实 AI run 防止旧字段继续泄漏。 -- How:通过真实 Workbench UI 发送“用浏览器搜索下最近deepseek的融资”,等待 `navigate / observe 或 act / extract` 链路和最终回答;检查工具结果只使用 `action/page/result/error` 新结构。 -- Oracle:run 完成;至少出现 `navigate`、`observe` 或 `act`、`extract`;结果满足新 schema 且不包含废弃字段;访问真实搜索引擎页面;最终回答包含 DeepSeek 和融资相关信息;不出现 fake browser / fake AI 信号。 -- Not:不固定融资金额、日期或搜索引擎;该 case 受公网和搜索引擎页面变化影响,失败时必须结合 raw logs 区分产品回归与外部波动。 #### `web-search-diagnostic` diff --git a/electron-builder.yml b/electron-builder.yml index 9a475252..c730c32a 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -66,6 +66,5 @@ appImage: artifactName: ${name}-${version}.${ext} npmRebuild: false publish: - provider: github - owner: Yoan98 - repo: Ncurator + provider: generic + url: https://download.ncurator.com/updates/latest diff --git a/package.json b/package.json index 88e71064..e41f2d7f 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,10 @@ "test:settings": "node scripts/test-workflow.mjs --domain settings", "test:settings:backend": "node scripts/test-workflow.mjs --domain settings --type backend", "test:settings:e2e": "node scripts/test-workflow.mjs --domain settings --type e2e", + "test:update": "node scripts/test-workflow.mjs --domain settings --suite update", + "test:update:unit": "node scripts/test-workflow.mjs --domain settings --suite update --type backend", + "test:update:e2e": "node scripts/test-workflow.mjs --domain settings --suite update --type e2e", + "test:update:real": "node scripts/test-app-update-real.mjs", "test:workbench": "node scripts/test-workflow.mjs --domain workbench", "test:workbench:backend": "node scripts/test-workflow.mjs --domain workbench --type backend", "test:workbench:e2e": "node scripts/test-workflow.mjs --domain workbench --type e2e", diff --git a/scripts/prepare-r2-update-metadata.mjs b/scripts/prepare-r2-update-metadata.mjs new file mode 100644 index 00000000..3cba1351 --- /dev/null +++ b/scripts/prepare-r2-update-metadata.mjs @@ -0,0 +1,31 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' +import process from 'node:process' + +const [inputDirectory = '', outputDirectory = '', releaseTag = ''] = process.argv.slice(2) + +if (!inputDirectory || !outputDirectory || !/^v\d+\.\d+\.\d+$/u.test(releaseTag)) { + throw new Error( + 'usage: node scripts/prepare-r2-update-metadata.mjs ' + ) +} + +const rewriteMetadata = (content) => + content.replace( + /^(\s*(?:- url:|path:)\s+)([^\s/][^\s]*)$/gmu, + (_line, prefix, assetName) => `${prefix}../${releaseTag}/${assetName}` + ) + +await mkdir(outputDirectory, { recursive: true }) + +for (const fileName of ['latest.yml', 'latest-mac.yml']) { + const inputPath = path.join(inputDirectory, fileName) + const outputPath = path.join(outputDirectory, fileName) + const content = await readFile(inputPath, 'utf8') + const rewritten = rewriteMetadata(content) + if (!rewritten.includes(`../${releaseTag}/`)) { + throw new Error(`${fileName} does not contain rewritten release asset URLs`) + } + await writeFile(outputPath, rewritten, 'utf8') +} diff --git a/scripts/test-app-update-real.mjs b/scripts/test-app-update-real.mjs new file mode 100644 index 00000000..be8eb45e --- /dev/null +++ b/scripts/test-app-update-real.mjs @@ -0,0 +1,426 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { execFile, spawn } from 'node:child_process' +import { access, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { createServer } from 'node:http' +import net from 'node:net' +import path from 'node:path' +import process from 'node:process' +import { promisify } from 'node:util' +import { _electron as electron } from 'playwright' + +const execFileAsync = promisify(execFile) +const ROOT = process.cwd() +const DEFAULT_SOURCE_VERSION = '0.1.0' +const DEFAULT_EXPECTED_VERSION = '0.1.1' +const DEFAULT_TIMEOUT_MS = 45 * 60 * 1000 + +const parseArgs = (argv) => { + const result = { + sourceApp: '', + sourceVersion: DEFAULT_SOURCE_VERSION, + expectedVersion: DEFAULT_EXPECTED_VERSION, + timeoutMs: DEFAULT_TIMEOUT_MS, + githubReleaseTag: '' + } + + for (let index = 0; index < argv.length; index += 1) { + const key = argv[index] + const value = argv[index + 1] + if (key === '--source-app' && value) result.sourceApp = path.resolve(value) + else if (key === '--source-version' && value) result.sourceVersion = value + else if (key === '--expected-version' && value) result.expectedVersion = value + else if (key === '--timeout-ms' && value) result.timeoutMs = Number(value) + else if (key === '--github-release-tag' && value) result.githubReleaseTag = value + else continue + index += 1 + } + + return result +} + +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +const allocatePort = async () => { + const server = net.createServer() + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const address = server.address() + if (!address || typeof address === 'string') throw new Error('无法分配调试端口') + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())) + ) + return address.port +} + +const startGitHubReleaseProxy = async (releaseTag) => { + if (!/^v\d+\.\d+\.\d+$/u.test(releaseTag)) { + throw new Error(`GitHub Release Tag 不合法:${releaseTag}`) + } + const releaseApiUrl = `https://api.github.com/repos/Yoan98/Ncurator/releases/tags/${releaseTag}` + const { stdout: releaseJson } = await execFileAsync( + '/usr/bin/curl', + ['--fail', '--silent', '--show-error', '--retry', '3', '--max-time', '60', releaseApiUrl], + { maxBuffer: 5 * 1024 * 1024 } + ) + const release = JSON.parse(releaseJson) + const assets = new Map(release.assets.map((asset) => [asset.name, asset])) + const activeDownloads = new Set() + const server = createServer(async (request, response) => { + const assetName = path.basename(new URL(request.url || '/', 'http://127.0.0.1').pathname) + const asset = assets.get(assetName) + if (!asset) { + response.writeHead(404).end() + return + } + const curlArgs = [ + '--fail', + '--location', + '--silent', + '--show-error', + '--retry', + '3', + '--header', + 'Accept: application/octet-stream', + asset.url + ] + const download = spawn('/usr/bin/curl', curlArgs, { stdio: ['ignore', 'pipe', 'pipe'] }) + activeDownloads.add(download) + let stderr = '' + download.stderr.on('data', (chunk) => { + stderr += String(chunk) + }) + response.writeHead(200, { + 'content-type': asset.content_type || 'application/octet-stream', + 'content-length': String(asset.size), + 'cache-control': 'no-store' + }) + download.stdout.pipe(response) + download.once('close', (code) => { + activeDownloads.delete(download) + if (code !== 0 && !response.destroyed) response.destroy(new Error(stderr || `curl ${code}`)) + }) + response.once('close', () => { + if (!download.killed && !response.writableFinished) download.kill('SIGTERM') + }) + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const address = server.address() + if (!address || typeof address === 'string') throw new Error('无法启动 GitHub Release 测试代理') + return { + url: `http://127.0.0.1:${address.port}`, + close: async () => { + for (const download of activeDownloads) download.kill('SIGTERM') + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())) + ) + } + } +} + +const readBundleVersion = async (appPath) => { + const plistPath = path.join(appPath, 'Contents', 'Info.plist') + const { stdout } = await execFileAsync('/usr/libexec/PlistBuddy', [ + '-c', + 'Print :CFBundleShortVersionString', + plistPath + ]) + return stdout.trim() +} + +const setBundleVersion = async (appPath, version) => { + const plistPath = path.join(appPath, 'Contents', 'Info.plist') + for (const key of ['CFBundleShortVersionString', 'CFBundleVersion']) { + await execFileAsync('/usr/libexec/PlistBuddy', ['-c', `Set :${key} ${version}`, plistPath]) + } +} + +const readGenericUpdateUrl = async () => { + const builderConfig = await readFile(path.join(ROOT, 'electron-builder.yml'), 'utf8') + const publishBlock = builderConfig.match( + /publish:\s*\n\s*provider:\s*generic\s*\n\s*url:\s*([^\s#]+)/u + ) + if (!publishBlock?.[1]) { + throw new Error('electron-builder.yml 必须配置 generic 自动更新地址') + } + return publishBlock[1] +} + +const writeUpdateConfig = async (appPath, updateUrl) => { + const configPath = path.join(appPath, 'Contents', 'Resources', 'app-update.yml') + await writeFile( + configPath, + `provider: generic\nurl: ${updateUrl}\nupdaterCacheDirName: ncurator-updater\n`, + 'utf8' + ) +} + +const resolveBuiltAppPath = async (outputPath) => { + const candidates = [ + path.join(outputPath, `mac-${process.arch}`, 'Ncurator.app'), + path.join(outputPath, 'mac', 'Ncurator.app') + ] + for (const candidate of candidates) { + if ( + await access(candidate) + .then(() => true) + .catch(() => false) + ) + return candidate + } + throw new Error(`没有找到测试旧版本 App:${outputPath}`) +} + +const buildOldApp = async ({ outputPath, version, logs }) => { + const electronBuilder = path.join(ROOT, 'node_modules', '.bin', 'electron-builder') + process.stdout.write(`[update-real] 打包测试旧版本 ${version}\n`) + const { stdout, stderr } = await execFileAsync( + electronBuilder, + [ + '--mac', + `--${process.arch}`, + '--dir', + '--publish', + 'never', + `--config.extraMetadata.version=${version}`, + `--config.directories.output=${outputPath}` + ], + { + cwd: ROOT, + env: { + ...process.env, + CSC_IDENTITY_AUTO_DISCOVERY: 'false' + }, + maxBuffer: 10 * 1024 * 1024 + } + ) + if (stdout.trim()) logs.push(`[electron-builder:stdout] ${stdout.trim()}`) + if (stderr.trim()) logs.push(`[electron-builder:stderr] ${stderr.trim()}`) + return await resolveBuiltAppPath(outputPath) +} + +const terminateTestApp = async (appPath) => { + await execFileAsync('/usr/bin/pkill', ['-f', appPath]).catch(() => undefined) + await delay(1000) +} + +const waitForBundleVersion = async (appPath, expectedVersion, timeoutMs) => { + const deadline = Date.now() + timeoutMs + let version = await readBundleVersion(appPath) + while (Date.now() < deadline && version !== expectedVersion) { + await delay(2000) + version = await readBundleVersion(appPath) + } + return version +} + +const launchApp = async ({ appPath, userDataPath, homePath, remoteDebuggingPort, logs }) => { + const executablePath = path.join(appPath, 'Contents', 'MacOS', 'Ncurator') + const electronApp = await electron.launch({ + executablePath, + cwd: path.dirname(appPath), + env: { + ...process.env, + HOME: homePath, + NCURATOR_E2E_OBSERVER: '1', + NCURATOR_E2E_WINDOW_MODE: 'background', + NCURATOR_USER_DATA_PATH: userDataPath, + NCURATOR_EMBEDDED_BROWSER_REMOTE_DEBUGGING_PORT: String(remoteDebuggingPort) + } + }) + const child = electronApp.process() + child?.stdout?.on('data', (chunk) => logs.push(`[stdout] ${String(chunk).trim()}`)) + child?.stderr?.on('data', (chunk) => logs.push(`[stderr] ${String(chunk).trim()}`)) + const page = await electronApp.firstWindow() + await page.waitForFunction(() => Boolean(window.api)) + return { electronApp, page } +} + +const waitForUpdateDownload = async (page, timeoutMs, statusHistory) => { + const deadline = Date.now() + timeoutMs + let previousStatus = '' + let previousProgressStep = -1 + + while (Date.now() < deadline) { + const snapshot = await page.evaluate(() => window.api.appUpdateGetSnapshot()) + const progressStep = Math.floor(snapshot.progressPercent || 0) + const shouldRecord = + snapshot.status !== previousStatus || + (snapshot.status === 'downloading' && progressStep > previousProgressStep) + if (shouldRecord) { + statusHistory.push({ + at: new Date().toISOString(), + status: snapshot.status, + currentVersion: snapshot.currentVersion, + availableVersion: snapshot.availableVersion, + progressPercent: snapshot.progressPercent, + error: snapshot.error + }) + process.stdout.write( + `[update-real] ${snapshot.status}${snapshot.progressPercent == null ? '' : ` ${snapshot.progressPercent.toFixed(1)}%`}\n` + ) + previousStatus = snapshot.status + previousProgressStep = progressStep + } + if (snapshot.status === 'downloaded' || snapshot.status === 'error') return snapshot + await delay(2000) + } + + throw new Error(`等待更新下载超时(${String(timeoutMs)}ms)`) +} + +const main = async () => { + if (process.platform !== 'darwin') { + throw new Error('当前真实升级脚本只支持 macOS;Windows 需要在 Windows 测试机执行') + } + + const options = parseArgs(process.argv.slice(2)) + const runId = new Date().toISOString().replace(/[:.]/g, '-') + const reportDir = path.join(ROOT, 'tests', 'logs', 'settings', 'update', 'real', runId) + const runtimeDir = path.join(ROOT, '.codex-tmp', 'app-update-real', runId) + const packagedOutputPath = path.join(runtimeDir, 'packaged') + const testAppPath = path.join(runtimeDir, 'Ncurator.app') + const userDataPath = path.join(runtimeDir, 'user-data') + const homePath = path.join(runtimeDir, 'home') + const markerPath = path.join(userDataPath, 'update-smoke-marker.txt') + const logs = [] + const statusHistory = [] + const startedAt = new Date().toISOString() + let firstLaunch + let releaseProxy + let report + + await mkdir(reportDir, { recursive: true }) + await mkdir(userDataPath, { recursive: true }) + await mkdir(homePath, { recursive: true }) + await writeFile(markerPath, `created=${startedAt}\n`, 'utf8') + + try { + releaseProxy = options.githubReleaseTag + ? await startGitHubReleaseProxy(options.githubReleaseTag) + : undefined + const updateUrl = releaseProxy?.url || (await readGenericUpdateUrl()) + const sourceApp = + options.sourceApp || + (await buildOldApp({ + outputPath: packagedOutputPath, + version: options.sourceVersion, + logs + })) + options.sourceApp = sourceApp + process.stdout.write(`[update-real] 克隆测试 App:${sourceApp}\n`) + await execFileAsync('/bin/cp', ['-cR', sourceApp, testAppPath]) + await setBundleVersion(testAppPath, options.sourceVersion) + await writeUpdateConfig(testAppPath, updateUrl) + const preparedVersion = await readBundleVersion(testAppPath) + if (preparedVersion !== options.sourceVersion) { + throw new Error(`测试旧版本准备失败:${preparedVersion}`) + } + + const remoteDebuggingPort = await allocatePort() + firstLaunch = await launchApp({ + appPath: testAppPath, + userDataPath, + homePath, + remoteDebuggingPort, + logs + }) + const initialSnapshot = await firstLaunch.page.evaluate(() => window.api.appUpdateGetSnapshot()) + if (initialSnapshot.currentVersion !== options.sourceVersion) { + throw new Error(`应用读取到的版本不正确:${initialSnapshot.currentVersion}`) + } + + await firstLaunch.page.evaluate(() => window.api.appUpdateCheck()) + const downloadedSnapshot = await waitForUpdateDownload( + firstLaunch.page, + options.timeoutMs, + statusHistory + ) + if (downloadedSnapshot.status === 'error') { + throw new Error(downloadedSnapshot.error || '真实更新下载失败') + } + if (downloadedSnapshot.availableVersion !== options.expectedVersion) { + throw new Error( + `线上目标版本不符合预期:${downloadedSnapshot.availableVersion || '未知版本'}` + ) + } + + const processExit = new Promise((resolve) => { + firstLaunch.electronApp.process()?.once('exit', (code, signal) => resolve({ code, signal })) + }) + await firstLaunch.page.evaluate(() => window.api.appUpdateInstall()).catch(() => undefined) + await Promise.race([processExit, delay(30_000)]) + + const finalBundleVersion = await waitForBundleVersion( + testAppPath, + options.expectedVersion, + 120_000 + ) + const markerPreserved = await import('node:fs/promises') + .then(({ access }) => access(markerPath).then(() => true)) + .catch(() => false) + const installError = [...logs] + .reverse() + .find( + (entry) => + entry.includes('SQRLCodeSignatureErrorDomain') || entry.includes('Code signature at URL') + ) + + report = { + success: finalBundleVersion === options.expectedVersion && markerPreserved, + startedAt, + finishedAt: new Date().toISOString(), + platform: process.platform, + arch: process.arch, + sourceVersion: options.sourceVersion, + expectedVersion: options.expectedVersion, + finalBundleVersion, + markerPreserved, + installError, + statusHistory, + sourceApp: options.sourceApp, + updateUrl, + note: + finalBundleVersion === options.expectedVersion + ? '真实更新已替换测试 App' + : installError + ? '更新包已下载并校验通过,但 macOS 代码签名校验阻止了安装' + : '更新包已下载,但测试 App 没有被替换;请检查 Squirrel 日志' + } + + if (!report.success) process.exitCode = 1 + } catch (error) { + report = { + success: false, + startedAt, + finishedAt: new Date().toISOString(), + platform: process.platform, + arch: process.arch, + sourceVersion: options.sourceVersion, + expectedVersion: options.expectedVersion, + statusHistory, + sourceApp: options.sourceApp, + updateUrl: releaseProxy?.url || 'production', + error: error instanceof Error ? error.message : String(error) + } + process.exitCode = 1 + } finally { + await firstLaunch?.electronApp.close().catch(() => undefined) + await terminateTestApp(testAppPath) + await releaseProxy?.close().catch(() => undefined) + await writeFile(path.join(reportDir, 'process.log'), `${logs.join('\n')}\n`, 'utf8') + await writeFile( + path.join(reportDir, 'report.json'), + `${JSON.stringify(report, null, 2)}\n`, + 'utf8' + ) + await rm(runtimeDir, { recursive: true, force: true }) + process.stdout.write(`[update-real] 报告:${path.join(reportDir, 'report.json')}\n`) + } +} + +await main() diff --git a/src/main/services/update/AppUpdateService.ts b/src/main/services/update/AppUpdateService.ts index 083add5e..96abc8d4 100644 --- a/src/main/services/update/AppUpdateService.ts +++ b/src/main/services/update/AppUpdateService.ts @@ -1,4 +1,5 @@ import { app, BrowserWindow, ipcMain } from 'electron' +import { spawnSync } from 'node:child_process' import { autoUpdater } from 'electron-updater' import type { ProgressInfo, UpdateDownloadedEvent, UpdateInfo } from 'electron-updater' import { @@ -9,8 +10,45 @@ import { } from '../../../shared/constants' import type { AppUpdateActionResponse, AppUpdateSnapshot } from '../../../shared/types' -const SUPPORTED_UPDATE_PLATFORMS = new Set(['darwin', 'win32']) +const MAC_UPDATE_SIGNATURE_ERROR = 'macOS 自动更新需要有效的 Developer ID 签名' const STARTUP_UPDATE_CHECK_DELAY_MS = 5000 +const UPDATE_BUSY_STATUSES = new Set([ + 'checking', + 'available', + 'downloading' +]) + +type AppUpdateDependencies = { + app: Pick & { readonly isPackaged: boolean } + updater: typeof autoUpdater + ipc: Pick + getWindows: typeof BrowserWindow.getAllWindows + platform: NodeJS.Platform + schedule: (callback: () => void, delayMs: number) => ReturnType + macCodeSignatureValid: () => boolean +} + +const hasValidMacCodeSignature = (): boolean => { + const result = spawnSync('/usr/bin/codesign', ['-dv', '--verbose=2', process.execPath], { + encoding: 'utf8' + }) + const output = `${result.stdout || ''}\n${result.stderr || ''}` + return ( + result.status === 0 && + /Authority=Developer ID Application:/u.test(output) && + /TeamIdentifier=(?!not set\b)\S+/u.test(output) + ) +} + +const createDefaultDependencies = (): AppUpdateDependencies => ({ + app, + updater: autoUpdater, + ipc: ipcMain, + getWindows: () => BrowserWindow.getAllWindows(), + platform: process.platform, + schedule: (callback, delayMs) => setTimeout(callback, delayMs), + macCodeSignatureValid: hasValidMacCodeSignature +}) const getUpdateErrorMessage = (error: Error | string): string => error instanceof Error ? error.message : error @@ -21,12 +59,17 @@ const normalizeOptionalText = (value: string | null | undefined): string | undef export class AppUpdateService { private initialized = false private checking = false - private snapshot: AppUpdateSnapshot = { - status: 'idle', - currentVersion: app.getVersion(), - isPackaged: app.isPackaged, - updateSupported: app.isPackaged && SUPPORTED_UPDATE_PLATFORMS.has(process.platform), - platform: process.platform + private readonly dependencies: AppUpdateDependencies + private readonly unsupportedReason: string | undefined + private snapshot: AppUpdateSnapshot + + constructor(dependencies: Partial = {}) { + this.dependencies = { + ...createDefaultDependencies(), + ...dependencies + } + this.unsupportedReason = this.resolveUnsupportedReason() + this.snapshot = this.createSnapshot({ status: 'idle' }) } initialize(): void { @@ -39,15 +82,16 @@ export class AppUpdateService { return } - autoUpdater.logger = console - autoUpdater.autoDownload = true - autoUpdater.autoInstallOnAppQuit = true + const { updater } = this.dependencies + updater.logger = console + updater.autoDownload = true + updater.autoInstallOnAppQuit = true - autoUpdater.on('checking-for-update', () => { + updater.on('checking-for-update', () => { this.setSnapshot({ status: 'checking', error: undefined }) }) - autoUpdater.on('update-available', (info: UpdateInfo) => { + updater.on('update-available', (info: UpdateInfo) => { this.setSnapshot({ status: 'available', availableVersion: info.version, @@ -57,7 +101,8 @@ export class AppUpdateService { }) }) - autoUpdater.on('update-not-available', (info: UpdateInfo) => { + updater.on('update-not-available', (info: UpdateInfo) => { + this.checking = false this.setSnapshot({ status: 'not-available', availableVersion: info.version, @@ -71,7 +116,7 @@ export class AppUpdateService { }) }) - autoUpdater.on('download-progress', (progress: ProgressInfo) => { + updater.on('download-progress', (progress: ProgressInfo) => { this.setSnapshot({ status: 'downloading', progressPercent: progress.percent, @@ -82,7 +127,8 @@ export class AppUpdateService { }) }) - autoUpdater.on('update-downloaded', (event: UpdateDownloadedEvent) => { + updater.on('update-downloaded', (event: UpdateDownloadedEvent) => { + this.checking = false this.setSnapshot({ status: 'downloaded', availableVersion: event.version, @@ -93,7 +139,7 @@ export class AppUpdateService { }) }) - autoUpdater.on('error', (error: Error) => { + updater.on('error', (error: Error) => { this.checking = false this.setSnapshot({ status: 'error', @@ -104,15 +150,15 @@ export class AppUpdateService { checkForUpdatesOnLaunch(): void { if (!this.snapshot.updateSupported) return - setTimeout(() => { + this.dependencies.schedule(() => { void this.checkForUpdates() }, STARTUP_UPDATE_CHECK_DELAY_MS) } private registerIpcHandlers(): void { - ipcMain.handle(APP_UPDATE_GET_SNAPSHOT_CHANNEL, () => this.snapshot) - ipcMain.handle(APP_UPDATE_CHECK_CHANNEL, () => this.checkForUpdates()) - ipcMain.handle(APP_UPDATE_INSTALL_CHANNEL, () => this.quitAndInstall()) + this.dependencies.ipc.handle(APP_UPDATE_GET_SNAPSHOT_CHANNEL, () => this.snapshot) + this.dependencies.ipc.handle(APP_UPDATE_CHECK_CHANNEL, () => this.checkForUpdates()) + this.dependencies.ipc.handle(APP_UPDATE_INSTALL_CHANNEL, () => this.quitAndInstall()) } private async checkForUpdates(): Promise { @@ -120,11 +166,11 @@ export class AppUpdateService { return { success: false, snapshot: this.snapshot, - error: app.isPackaged ? '当前系统暂不支持自动更新' : '开发模式不会检查自动更新' + error: this.snapshot.unsupportedReason || '当前系统暂不支持自动更新' } } - if (this.checking || this.snapshot.status === 'downloading') { + if (this.checking || UPDATE_BUSY_STATUSES.has(this.snapshot.status)) { return { success: true, snapshot: this.snapshot @@ -135,7 +181,14 @@ export class AppUpdateService { this.setSnapshot({ status: 'checking', error: undefined }) try { - await autoUpdater.checkForUpdates() + await this.dependencies.updater.checkForUpdates() + if (this.snapshot.status === 'error') { + return { + success: false, + snapshot: this.snapshot, + error: this.snapshot.error || '检查更新失败' + } + } return { success: true, snapshot: this.snapshot @@ -162,7 +215,7 @@ export class AppUpdateService { } } - autoUpdater.quitAndInstall(false, true) + this.dependencies.updater.quitAndInstall(false, true) return { success: true, snapshot: this.snapshot @@ -170,19 +223,39 @@ export class AppUpdateService { } private setSnapshot(patch: Partial): void { - this.snapshot = { + this.snapshot = this.createSnapshot({ ...this.snapshot, - ...patch, - currentVersion: app.getVersion(), - isPackaged: app.isPackaged, - updateSupported: app.isPackaged && SUPPORTED_UPDATE_PLATFORMS.has(process.platform), - platform: process.platform - } + ...patch + }) this.emitSnapshot() } + private createSnapshot( + snapshot: Pick & Partial + ): AppUpdateSnapshot { + const { app: electronApp, platform } = this.dependencies + return { + ...snapshot, + currentVersion: electronApp.getVersion(), + isPackaged: electronApp.isPackaged, + updateSupported: this.unsupportedReason === undefined, + unsupportedReason: this.unsupportedReason, + platform + } + } + + private resolveUnsupportedReason(): string | undefined { + const { app: electronApp, platform, macCodeSignatureValid } = this.dependencies + if (!electronApp.isPackaged) return '开发模式不会检查自动更新' + if (platform === 'win32') return undefined + if (platform === 'darwin') { + return macCodeSignatureValid() ? undefined : MAC_UPDATE_SIGNATURE_ERROR + } + return '当前系统暂不支持自动更新' + } + private emitSnapshot(): void { - for (const window of BrowserWindow.getAllWindows()) { + for (const window of this.dependencies.getWindows()) { if (!window.isDestroyed()) { window.webContents.send(APP_UPDATE_STATUS_CHANNEL, this.snapshot) } diff --git a/src/preload/index.ts b/src/preload/index.ts index 3500cbde..96409661 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -20,6 +20,27 @@ const isE2EFixtureMode = process.env.NCURATOR_E2E_MODE === '1' const isE2EObserverEnabled = isE2EFixtureMode || process.env.NCURATOR_E2E_OBSERVER === '1' const downloadProgressListeners = new Set<(progressData: DownloadProgress) => void>() const workspaceIndexJobProgressListeners = new Set<(job: WorkspaceIndexJobRecord) => void>() +const appUpdateFixtureListeners = new Set<(snapshot: AppUpdateSnapshot) => void>() +const APP_UPDATE_E2E_SET_SNAPSHOT_EVENT = 'ncurator-e2e-app-update-set-snapshot' + +let appUpdateFixtureSnapshot: AppUpdateSnapshot = { + status: 'idle', + currentVersion: '0.1.0', + isPackaged: true, + updateSupported: true, + platform: process.platform +} + +const waitForAppUpdateFixtureStep = async (): Promise => { + await new Promise((resolve) => setTimeout(resolve, 250)) +} + +const setAppUpdateFixtureSnapshot = (snapshot: AppUpdateSnapshot): void => { + appUpdateFixtureSnapshot = { ...snapshot } + for (const listener of appUpdateFixtureListeners) { + listener({ ...appUpdateFixtureSnapshot }) + } +} const emitE2EIpcCall = (name: string, payload?: JsonValue): void => { if (!isE2EObserverEnabled) return @@ -54,6 +75,18 @@ if (isE2EObserverEnabled) { }) } +if (isE2EFixtureMode) { + window.addEventListener(APP_UPDATE_E2E_SET_SNAPSHOT_EVENT, (event) => { + const detail = (event as CustomEvent).detail + if (typeof detail !== 'string') return + try { + setAppUpdateFixtureSnapshot(JSON.parse(detail) as AppUpdateSnapshot) + } catch (error) { + console.error('Failed to apply app update E2E snapshot', error) + } + }) +} + // Custom APIs for renderer const api: DesktopApi = { ingestWeb: (payload): Promise<{ success: boolean; count?: number; error?: string }> => @@ -227,10 +260,74 @@ const api: DesktopApi = { emitE2EIpcCall('aiFileChangeRevert') return ipcRenderer.invoke('ai-file-change-revert', payload) }, - appUpdateGetSnapshot: () => ipcRenderer.invoke(APP_UPDATE_GET_SNAPSHOT_CHANNEL), - appUpdateCheck: () => ipcRenderer.invoke(APP_UPDATE_CHECK_CHANNEL), - appUpdateInstall: () => ipcRenderer.invoke(APP_UPDATE_INSTALL_CHANNEL), + appUpdateGetSnapshot: () => + isE2EFixtureMode + ? Promise.resolve({ ...appUpdateFixtureSnapshot }) + : ipcRenderer.invoke(APP_UPDATE_GET_SNAPSHOT_CHANNEL), + appUpdateCheck: async () => { + emitE2EIpcCall('appUpdateCheck') + if (!isE2EFixtureMode) { + return await ipcRenderer.invoke(APP_UPDATE_CHECK_CHANNEL) + } + + setAppUpdateFixtureSnapshot({ + ...appUpdateFixtureSnapshot, + status: 'checking', + error: undefined + }) + await waitForAppUpdateFixtureStep() + setAppUpdateFixtureSnapshot({ + ...appUpdateFixtureSnapshot, + status: 'available', + availableVersion: '0.1.1', + releaseName: 'v0.1.1', + releaseDate: '2026-07-13T08:00:00.000Z' + }) + await waitForAppUpdateFixtureStep() + setAppUpdateFixtureSnapshot({ + ...appUpdateFixtureSnapshot, + status: 'downloading', + progressPercent: 42, + transferredBytes: 42 * 1024 * 1024, + totalBytes: 100 * 1024 * 1024, + bytesPerSecond: 8 * 1024 * 1024 + }) + await waitForAppUpdateFixtureStep() + setAppUpdateFixtureSnapshot({ + ...appUpdateFixtureSnapshot, + status: 'downloaded', + progressPercent: 100, + transferredBytes: 100 * 1024 * 1024 + }) + return { + success: true, + snapshot: { ...appUpdateFixtureSnapshot } + } + }, + appUpdateInstall: async () => { + emitE2EIpcCall('appUpdateInstall') + if (!isE2EFixtureMode) { + return await ipcRenderer.invoke(APP_UPDATE_INSTALL_CHANNEL) + } + if (appUpdateFixtureSnapshot.status !== 'downloaded') { + return { + success: false, + snapshot: { ...appUpdateFixtureSnapshot }, + error: '更新包尚未下载完成' + } + } + return { + success: true, + snapshot: { ...appUpdateFixtureSnapshot } + } + }, onAppUpdateStatus: (cb: (snapshot: AppUpdateSnapshot) => void) => { + if (isE2EFixtureMode) { + appUpdateFixtureListeners.add(cb) + return () => { + appUpdateFixtureListeners.delete(cb) + } + } const listener = (_event: Electron.IpcRendererEvent, snapshot: AppUpdateSnapshot): void => { cb(snapshot) } diff --git a/src/renderer/src/components/settings/AppUpdateSettingsPanel.tsx b/src/renderer/src/components/settings/AppUpdateSettingsPanel.tsx index b2826575..10dafeab 100644 --- a/src/renderer/src/components/settings/AppUpdateSettingsPanel.tsx +++ b/src/renderer/src/components/settings/AppUpdateSettingsPanel.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react' import { Alert, Button, Progress, Space, Tag, message } from 'antd' import { HiArrowPath, HiCheckCircle, HiExclamationTriangle, HiRocketLaunch } from 'react-icons/hi2' import type { AppUpdateSnapshot, AppUpdateStatus } from '../../../../shared/types' +import { settingsSelectors } from '../../testing/selectors' const STATUS_META: Record = { idle: { label: '待检查', className: 'border-[#EEEEEC] text-[#787774]' }, @@ -22,7 +23,7 @@ const formatBytes = (bytes: number | undefined): string => { const getStatusText = (snapshot: AppUpdateSnapshot | null): string => { if (!snapshot) return '正在读取更新状态' if (!snapshot.updateSupported) { - return snapshot.isPackaged ? '当前系统暂不支持自动更新' : '开发模式不会检查自动更新' + return snapshot.unsupportedReason || '当前系统暂不支持自动更新' } if (snapshot.status === 'downloaded') return '更新已下载,重启后完成安装' if (snapshot.status === 'downloading') return '正在下载更新包' @@ -105,12 +106,21 @@ const AppUpdateSettingsPanel: React.FC = () => { } return ( -
+
- 当前版本 {snapshot?.currentVersion || '-'} + 当前版本{' '} + + {snapshot?.currentVersion || '-'} +
{statusMeta.label} @@ -122,6 +132,7 @@ const AppUpdateSettingsPanel: React.FC = () => { loading={checking || snapshot?.status === 'checking'} disabled={checkDisabled} onClick={handleCheck} + data-testid={settingsSelectors.appUpdateCheckButton} > 检查更新 @@ -131,6 +142,7 @@ const AppUpdateSettingsPanel: React.FC = () => { loading={installing} disabled={installDisabled} onClick={handleInstall} + data-testid={settingsSelectors.appUpdateInstallButton} > 重启安装 @@ -138,6 +150,7 @@ const AppUpdateSettingsPanel: React.FC = () => {
{
可用版本
-
{snapshot.availableVersion}
+
+ {snapshot.availableVersion} +
发布时间
@@ -172,7 +190,7 @@ const AppUpdateSettingsPanel: React.FC = () => { ) : null} {snapshot?.status === 'downloading' || snapshot?.status === 'downloaded' ? ( -
+
- openedSourcePaths.some( - (sourcePath) => - sourcePath.endsWith(expectedRelativePath) || sourcePath.endsWith(`/${expectedName}`) - ) - -const hasCompletedDelivery = (output: { deliveryMarkdown?: string | null }): boolean => - typeof output.deliveryMarkdown === 'string' && - output.deliveryMarkdown.trim().length > 0 - -const hasOverfittingRemedyResponse = (deliveryText: string): boolean => - /(没写|未写|没有写|文档没写|未提及|未说明|没有提到|未给出|未提供)/.test(deliveryText) || - /(文档中未提及处理方法|文档未提及处理方法|文档中未说明处理方法|文档未说明处理方法)/.test( - deliveryText - ) || - /(数据增强|未使用过的数据|训练时未使用过的数据|验证数据)/.test(deliveryText) - -describe('ai human live backend', () => { - it('human-docx-cause-remedy-reading-live', async () => { - await runWithStructuredOutputRetry(async () => { - await runBackendCase({ - caseId: 'human-docx-cause-remedy-reading-live', - harnessMode: 'live', - domain: 'ai', - suite: 'human', - groups: ['live'], - description: '验证 live 模式下 human 会基于两份已选 docx 覆盖过拟合与反走样的核心事实,并对缺失项保持保守。', - fixtures: [SHARED_WORKSPACE_FIXTURE_PATH], - runMode: 'single', - runtimeModelName: hasLiveLlmConfigInEnv() - ? loadLiveLlmConfigFromEnv().model_name - : null, - execute: async (runtime) => { - if (!hasLiveLlmConfigInEnv()) { - return { - skipped: true, - output: null, - fixtureDocs: [], - events: [] - } - } - - await runtime.seedLlmConfig(loadLiveLlmConfigFromEnv()) - const fixtureDocs = resolveSelectedDocxFixtures(runtime, DOCX_READING_FIXTURE_PATHS) - const runner = createHumanTestRunner(runtime, { - selectedReferences: buildSelectedDocxFixtureReferences(runtime, DOCX_READING_FIXTURE_PATHS) - }) - const output = await runner.run({ - taskId: runtime.taskId, - userInput: HUMAN_DOCX_CAUSE_REMEDY_READING_USER_INPUT - }) - - return { - skipped: false, - output, - fixtureDocs, - events: runtime.eventCollector.snapshot() - } - }, - assert: async (result, runtime) => { - if (result.skipped) { - runtime.assertThat(true, { - name: 'live-config-not-provided', - message: '未提供 live 模型配置,已跳过 human live docx cause/remedy reading case。' - }) - return - } - - const toolResults = filterAiRunEventsByType(result.events, 'tool_call_result') - const toolNames = toolResults.map((event) => event.toolName) - const openedSourcePaths = filterAiRunEventsByType(result.events, 'tool_call_started') - .filter( - (event) => - event.toolName === 'docx_search' || - event.toolName === 'docx_outline' || - event.toolName === 'docx_read_blocks' || - event.toolName === 'docx_history' - ) - .map((event) => event.input.sourcePath) - .filter((value): value is string => typeof value === 'string') - const deliveryText = String(result.output?.deliveryMarkdown || '') - const hasDocxReadTrace = toolNames.some((toolName) => - ['docx_search', 'docx_read_blocks', 'docx_outline', 'docx_history'].includes(toolName) - ) - - runtime.assertThat( - hasCompletedDelivery(result.output || {}) && - result.fixtureDocs.every((fixtureDoc) => - openedExpectedDoc(openedSourcePaths, fixtureDoc.relativePath, fixtureDoc.name) - ) && - hasDocxReadTrace && - toolNames.some( - (toolName) => toolName === 'docx_read_blocks' || toolName === 'docx_outline' - ) && - !toolNames.some((toolName) => toolName.endsWith('_edit')) && - /(过拟合|过度拟合)/.test(deliveryText) && - /(记住过多训练数据中的特征值|训练时间过久)/.test(deliveryText) && - hasOverfittingRemedyResponse(deliveryText) && - /反走样/.test(deliveryText) && - /(采样频率低于信号频率|像素.*边界)/.test(deliveryText) && - /(低通滤波|多个采样点|多个像素点|多次采样)/.test(deliveryText), - { - name: 'human-live-reads-both-selected-docx-and-explains-cause-and-remedy', - message: - 'live human 读 case 应真实打开两份已选 docx,覆盖过拟合与反走样的核心事实,并对文档未写部分保持保守表达;不要求固定必须经过 docx_search。', - data: { - openedSourcePaths, - toolNames, - deliveryMarkdown: result.output?.deliveryMarkdown - } - } - ) - } - }) - }) - }) -}) diff --git a/tests/case/ai/human/live/human-docx-cross-source-summary-draft-live.test.ts b/tests/case/ai/human/live/human-docx-cross-source-summary-draft-live.test.ts deleted file mode 100644 index 75b20e3c..00000000 --- a/tests/case/ai/human/live/human-docx-cross-source-summary-draft-live.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import fs from 'node:fs/promises' -import { describe, it } from 'vitest' -import { loadLiveLlmConfigFromEnv, hasLiveLlmConfigInEnv } from '../../../../harness/backend/llmConfig' -import { createHumanTestRunner } from '../../../../harness/backend/humanHarness' -import { runBackendCase } from '../../../../harness/backend/run-backend-case' -import { - buildSelectedDocxReferences, - captureDocxFixtureBytes, - DOCX_CROSS_SOURCE_SUMMARY_FIXTURE_PATHS, - DOCX_CROSS_SOURCE_SUMMARY_REPORT_RELATIVE_PATH, - HUMAN_DOCX_CROSS_SOURCE_SUMMARY_USER_INPUT, - SHARED_WORKSPACE_FIXTURE_PATH, - resolveSelectedDocxFixtures -} from '../../../../harness/backend/aiLiveTestData' -import { - collectDocxApplyOpsContentKinds, - collectStartedDocxEditSourcePaths, - filterAiRunEventsByType, - hasDocxApplyArtifact, - hasDocxFileArtifact, - isDocxEditCreateInput, - isDocxEditWriteInput, - runWithStructuredOutputRetry -} from '../testUtils' - -const openedExpectedDoc = ( - openedSourcePaths: readonly string[], - expectedRelativePath: string, - expectedName: string -): boolean => - openedSourcePaths.some( - (sourcePath) => - sourcePath.endsWith(expectedRelativePath) || sourcePath.endsWith(`/${expectedName}`) - ) - -describe('ai human live backend', () => { - it('human-docx-cross-source-summary-draft-live', async () => { - await runWithStructuredOutputRetry(async () => { - await runBackendCase({ - caseId: 'human-docx-cross-source-summary-draft-live', - harnessMode: 'live', - domain: 'ai', - suite: 'human', - groups: ['live'], - description: '验证 live 模式下 human 会读取两份已选课件,自主创建总结 docx 并写入跨源总结。', - fixtures: [SHARED_WORKSPACE_FIXTURE_PATH], - runMode: 'single', - runtimeModelName: hasLiveLlmConfigInEnv() - ? loadLiveLlmConfigFromEnv().model_name - : null, - execute: async (runtime) => { - if (!hasLiveLlmConfigInEnv()) { - return { - skipped: true, - output: null, - workspaceSeed: null, - events: [] - } - } - - await runtime.seedLlmConfig(loadLiveLlmConfigFromEnv()) - const workspaceSeed = { - sourceDocs: resolveSelectedDocxFixtures(runtime, DOCX_CROSS_SOURCE_SUMMARY_FIXTURE_PATHS), - trackedDocs: await captureDocxFixtureBytes(runtime, DOCX_CROSS_SOURCE_SUMMARY_FIXTURE_PATHS) - } - const runner = createHumanTestRunner(runtime, { - selectedReferences: buildSelectedDocxReferences(workspaceSeed.sourceDocs) - }) - const output = await runner.run({ - taskId: runtime.taskId, - userInput: HUMAN_DOCX_CROSS_SOURCE_SUMMARY_USER_INPUT - }) - - return { - skipped: false, - output, - workspaceSeed, - events: runtime.eventCollector.snapshot() - } - }, - assert: async (result, runtime) => { - if (result.skipped || !result.workspaceSeed) { - runtime.assertThat(true, { - name: 'live-config-not-provided', - message: '未提供 live 模型配置,已跳过 human live cross-source summary draft case。' - }) - return - } - - const events = result.events - const toolResults = filterAiRunEventsByType(events, 'tool_call_result') - const toolNames = toolResults.map((event) => event.toolName) - const openedSourcePaths = filterAiRunEventsByType(events, 'tool_call_started') - .filter((event) => event.toolName.startsWith('docx_')) - .map((event) => event.input.sourcePath) - .filter((value): value is string => typeof value === 'string') - const createSourcePaths = filterAiRunEventsByType(events, 'tool_call_started') - .filter((event) => event.toolName === 'docx_edit' && isDocxEditCreateInput(event.input)) - .map((event) => event.input.sourcePath) - .filter((value): value is string => typeof value === 'string') - const writeSourcePaths = collectStartedDocxEditSourcePaths(events, isDocxEditWriteInput) - const changedContentKinds = collectDocxApplyOpsContentKinds(events) - const targetExistsInWorkspace = await fs - .access( - `${runtime.workspaceRoot}/${DOCX_CROSS_SOURCE_SUMMARY_REPORT_RELATIVE_PATH}` - ) - .then(() => true) - .catch(() => false) - const unchangedFixtures = await Promise.all( - result.workspaceSeed.trackedDocs.map(async (fixture) => { - const currentBytes = await fs.readFile(fixture.workspacePath) - return Buffer.compare(currentBytes, Buffer.from(fixture.bytes)) === 0 - }) - ) - const deliveryText = String(result.output?.deliveryMarkdown || '') - const sourceDocsOpened = result.workspaceSeed.sourceDocs.every((fixtureDoc) => - openedExpectedDoc(openedSourcePaths, fixtureDoc.relativePath, fixtureDoc.name) - ) - const targetDocOpened = openedExpectedDoc( - openedSourcePaths, - DOCX_CROSS_SOURCE_SUMMARY_REPORT_RELATIVE_PATH, - DOCX_CROSS_SOURCE_SUMMARY_REPORT_RELATIVE_PATH - ) - const targetDocCreated = - createSourcePaths.length >= 1 && - createSourcePaths.every((sourcePath) => - sourcePath.endsWith(DOCX_CROSS_SOURCE_SUMMARY_REPORT_RELATIVE_PATH) - ) - const targetDocWritten = - writeSourcePaths.length === 0 || - writeSourcePaths.every( - (sourcePath) => - sourcePath.endsWith(DOCX_CROSS_SOURCE_SUMMARY_REPORT_RELATIVE_PATH) - ) - - runtime.assertThat( - result.output?.status === 'completed' && - sourceDocsOpened && - targetDocCreated && - targetDocOpened && - toolNames.some((toolName) => toolName === 'docx_search' || toolName === 'docx_read_blocks') && - toolNames.includes('docx_edit') && - targetDocWritten && - filterAiRunEventsByType(events, 'file_artifact').some( - (event) => event.capability === 'docx' && Boolean(event.applyId) - ) && - hasDocxApplyArtifact(result.output?.artifacts) && - hasDocxFileArtifact(result.output?.artifacts) && - unchangedFixtures.every(Boolean) && - targetExistsInWorkspace === true && - /(过拟合|过度拟合)/.test(deliveryText) && - /反走样/.test(deliveryText) && - /总结|报告/.test(deliveryText), - { - name: 'human-live-cross-source-summary-writes-only-new-report-doc', - message: - 'live human cross-source summary case 应读取两份源课件,自主创建总结 docx,并且只把总结写到这份新文件上。', - data: { - openedSourcePaths, - createSourcePaths, - writeSourcePaths, - toolNames, - targetReportPath: DOCX_CROSS_SOURCE_SUMMARY_REPORT_RELATIVE_PATH, - changedContentKinds, - targetExistsInWorkspace, - deliveryMarkdown: result.output?.deliveryMarkdown, - artifacts: result.output?.artifacts - } - } - ) - } - }) - }) - }) -}) diff --git a/tests/case/ai/human/mock-llm/ai-run-service-embedding-preflight.test.ts b/tests/case/ai/human/mock-llm/ai-run-service-embedding-preflight.test.ts index b32692a0..7a8e04a1 100644 --- a/tests/case/ai/human/mock-llm/ai-run-service-embedding-preflight.test.ts +++ b/tests/case/ai/human/mock-llm/ai-run-service-embedding-preflight.test.ts @@ -40,7 +40,10 @@ describe('ai human mock-llm backend', () => { runId: runtime.runId, sessionId: 'embedding-preflight-session', input: '请读取 docx,找到反走样相关内容。', - modelName: 'scripted-llm', + mainModel: { + configId: 'mock-llm-scripted', + modelName: 'scripted-llm' + }, workspace: { workspaceId: 'test-workspace', workspaceName: 'test-workspace', @@ -49,6 +52,7 @@ describe('ai human mock-llm backend', () => { }, { runtimeSettings, + llmStrategyPreferenceStore: runtime.storageService.llmStrategyPreference, llmStore: runtime.llmStore, chatStore: runtime.chatStore, documentsStore: runtime.documentsStore, @@ -157,7 +161,10 @@ describe('ai human mock-llm backend', () => { runId: runtime.runId, sessionId: 'embedding-preflight-failure-session', input: '这轮不应该进入 human。', - modelName: 'scripted-llm', + mainModel: { + configId: 'mock-llm-scripted', + modelName: 'scripted-llm' + }, workspace: { workspaceId: 'test-workspace', workspaceName: 'test-workspace', @@ -166,6 +173,7 @@ describe('ai human mock-llm backend', () => { }, { runtimeSettings, + llmStrategyPreferenceStore: runtime.storageService.llmStrategyPreference, llmStore: runtime.llmStore, chatStore: runtime.chatStore, documentsStore: runtime.documentsStore, diff --git a/tests/case/ai/human/mock-llm/ai-run-service-human-only.test.ts b/tests/case/ai/human/mock-llm/ai-run-service-human-only.test.ts index abe3952c..1118cac0 100644 --- a/tests/case/ai/human/mock-llm/ai-run-service-human-only.test.ts +++ b/tests/case/ai/human/mock-llm/ai-run-service-human-only.test.ts @@ -28,7 +28,10 @@ describe('ai human mock-llm backend', () => { runId: runtime.runId, sessionId: 'human-only-session', input: '请学习一个 skill,调用一次工具,然后直接给出最终答复。', - modelName: 'scripted-llm', + mainModel: { + configId: 'mock-llm-scripted', + modelName: 'scripted-llm' + }, workspace: { workspaceId: 'test-workspace', workspaceName: 'test-workspace', @@ -37,6 +40,7 @@ describe('ai human mock-llm backend', () => { }, { runtimeSettings, + llmStrategyPreferenceStore: runtime.storageService.llmStrategyPreference, llmStore: runtime.llmStore, chatStore: runtime.chatStore, documentsStore: runtime.documentsStore, @@ -136,17 +140,14 @@ describe('ai human mock-llm backend', () => { } ) - runtime.assertThat( - answerCompletedEvents.length === 1 && runCompletedEvents.length === 0, - { - name: 'ai-run-service-leaves-run-lifecycle-to-ipc-layer', - message: 'AiRunService 本身应只产出 human 结果,不直接发 run_completed 事件。', - data: { - answerCompletedCount: answerCompletedEvents.length, - runCompletedCount: runCompletedEvents.length - } + runtime.assertThat(answerCompletedEvents.length === 1 && runCompletedEvents.length === 0, { + name: 'ai-run-service-leaves-run-lifecycle-to-ipc-layer', + message: 'AiRunService 本身应只产出 human 结果,不直接发 run_completed 事件。', + data: { + answerCompletedCount: answerCompletedEvents.length, + runCompletedCount: runCompletedEvents.length } - ) + }) const learningStartedIndex = result.events.findIndex( (event) => @@ -196,8 +197,7 @@ describe('ai human mock-llm backend', () => { } } return ( - lastThinkingStartedIndex >= 0 && - lastThinkingCompletedIndex > lastThinkingStartedIndex + lastThinkingStartedIndex >= 0 && lastThinkingCompletedIndex > lastThinkingStartedIndex ) }) diff --git a/tests/case/ai/human/mock-llm/ai-run-service-resume-snapshot.test.ts b/tests/case/ai/human/mock-llm/ai-run-service-resume-snapshot.test.ts index 8f8397f3..52a097f8 100644 --- a/tests/case/ai/human/mock-llm/ai-run-service-resume-snapshot.test.ts +++ b/tests/case/ai/human/mock-llm/ai-run-service-resume-snapshot.test.ts @@ -17,6 +17,7 @@ const buildDeps = async ( controller, deps: { runtimeSettings, + llmStrategyPreferenceStore: runtime.storageService.llmStrategyPreference, llmStore: runtime.llmStore, chatStore: runtime.chatStore, documentsStore: runtime.documentsStore, @@ -61,18 +62,39 @@ describe('ai human mock-llm backend', () => { summary: '先学习一个 skill 再继续。' }) }, + { + error: 'llm_call_timeout' + }, + { + error: 'llm_call_timeout' + }, + { + error: 'llm_call_timeout' + }, + { + error: 'llm_call_timeout' + }, { error: 'llm_call_timeout' } ] }) + await runtime.chatStore.saveChatSession({ + id: SESSION_ID, + title: 'timeout resume snapshot test', + created_at: Date.now(), + workspace_id: 'test-workspace' + }) const { deps: timeoutDeps } = await buildDeps(runtime, timeoutDriver) const timeoutResult = await AiRunService.getInstance().run( { runId: `${runtime.runId}-timeout`, sessionId: SESSION_ID, input: '先学一个 skill,然后继续处理任务。', - modelName: 'scripted-llm', + mainModel: { + configId: 'mock-llm-scripted', + modelName: 'scripted-llm' + }, selectedReferences: [ { kind: 'file', @@ -123,7 +145,15 @@ describe('ai human mock-llm backend', () => { runId: `${runtime.runId}-resume`, sessionId: SESSION_ID, input: '继续', - modelName: 'scripted-llm' + mainModel: { + configId: 'mock-llm-scripted', + modelName: 'scripted-llm' + }, + workspace: { + workspaceId: 'test-workspace', + workspaceName: 'test-workspace', + rootPath: runtime.workspaceRoot + } }, secondDeps ) @@ -137,13 +167,22 @@ describe('ai human mock-llm backend', () => { } ] }) + await runtime.chatStore.saveChatSession({ + id: `${SESSION_ID}-network`, + title: 'network resume snapshot test', + created_at: Date.now(), + workspace_id: 'test-workspace' + }) const { deps: networkDeps } = await buildDeps(runtime, networkDriver) const networkResult = await AiRunService.getInstance().run( { runId: `${runtime.runId}-network`, sessionId: `${SESSION_ID}-network`, input: '触发网络错误恢复', - modelName: 'scripted-llm', + mainModel: { + configId: 'mock-llm-scripted', + modelName: 'scripted-llm' + }, workspace: { workspaceId: 'test-workspace', workspaceName: 'test-workspace', @@ -152,7 +191,9 @@ describe('ai human mock-llm backend', () => { }, networkDeps ) - const networkSnapshot = await runtime.chatStore.getSessionResumeSnapshot(`${SESSION_ID}-network`) + const networkSnapshot = await runtime.chatStore.getSessionResumeSnapshot( + `${SESSION_ID}-network` + ) const ordinaryDriver = createScriptedStructuredOutputDriver({ 'human.brain': [ @@ -161,13 +202,22 @@ describe('ai human mock-llm backend', () => { } ] }) + await runtime.chatStore.saveChatSession({ + id: `${SESSION_ID}-error`, + title: 'ordinary error snapshot test', + created_at: Date.now(), + workspace_id: 'test-workspace' + }) const { deps: ordinaryDeps } = await buildDeps(runtime, ordinaryDriver) const ordinaryResult = await AiRunService.getInstance().run( { runId: `${runtime.runId}-error`, sessionId: `${SESSION_ID}-error`, input: '触发普通错误', - modelName: 'scripted-llm', + mainModel: { + configId: 'mock-llm-scripted', + modelName: 'scripted-llm' + }, workspace: { workspaceId: 'test-workspace', workspaceName: 'test-workspace', @@ -176,7 +226,9 @@ describe('ai human mock-llm backend', () => { }, ordinaryDeps ) - const ordinarySnapshot = await runtime.chatStore.getSessionResumeSnapshot(`${SESSION_ID}-error`) + const ordinarySnapshot = await runtime.chatStore.getSessionResumeSnapshot( + `${SESSION_ID}-error` + ) return { timeoutResult, @@ -194,13 +246,14 @@ describe('ai human mock-llm backend', () => { }, assert: async (result, runtime) => { runtime.assertThat( - result.timeoutResult.status === 'failed' && + result.timeoutResult.status === 'failed' && result.timeoutResult.failureCode === 'timeout' && result.timeoutResult.resumeSnapshotSaved === true && result.timeoutSnapshot?.reason === 'timeout' && result.timeoutSnapshot.selectedReferences.length === 1 && result.timeoutSnapshot.workspace?.rootPath === runtime.workspaceRoot && - result.timeoutSnapshot.humanState.skills?.learnedSkillIds?.includes('docx-read') === true && + result.timeoutSnapshot.humanState.skills?.learnedSkillIds?.includes('docx-read') === + true && result.resumedResult.status === 'completed' && result.resumedResult.finalDeliveryMarkdown?.includes('恢复快照') === true && result.clearedSnapshot === null && diff --git a/tests/case/ai/human/mock-llm/multi-tool-collaboration.test.ts b/tests/case/ai/human/mock-llm/multi-tool-collaboration.test.ts index 7869d5af..fe08a99a 100644 --- a/tests/case/ai/human/mock-llm/multi-tool-collaboration.test.ts +++ b/tests/case/ai/human/mock-llm/multi-tool-collaboration.test.ts @@ -1,10 +1,7 @@ import { describe, it } from 'vitest' import { createHumanTestRunner } from '../../../../harness/backend/humanHarness' import { runBackendCase } from '../../../../harness/backend/run-backend-case' -import { - buildCompleteTaskDecision, - filterAiRunEventsByType -} from '../testUtils' +import { buildCompleteTaskDecision, filterAiRunEventsByType } from '../testUtils' describe('ai human mock-llm backend', () => { it('multi-tool-collaboration', async () => { @@ -45,7 +42,8 @@ describe('ai human mock-llm backend', () => { { toolName: 'terminal_run', args: { - command: 'pwd' + command: 'pwd', + commandExplanation: '读取当前测试工作区路径' } }, { diff --git a/tests/case/ai/human/testUtils.ts b/tests/case/ai/human/testUtils.ts index 5f0491ae..a249ab14 100644 --- a/tests/case/ai/human/testUtils.ts +++ b/tests/case/ai/human/testUtils.ts @@ -1,18 +1,21 @@ import type { HumanThoughtDecision, - HumanState, HumanThought, RuntimeSkillId } from '../../../../src/main/services/ai/human/types' -import type { AiArtifactRef, AiRunEvent, JsonArray, JsonObject, JsonValue } from '../../../../src/shared/types' +import type { + AiArtifactRef, + AiRunEvent, + JsonArray, + JsonObject, + JsonValue +} from '../../../../src/shared/types' export const filterAiRunEventsByType = ( events: readonly AiRunEvent[], type: TType ): Extract[] => - events.filter( - (event): event is Extract => event.type === type - ) + events.filter((event): event is Extract => event.type === type) export const summarizeHumanSteps = (events: readonly AiRunEvent[]): string[] => filterAiRunEventsByType(events, 'human_step').map((event) => { @@ -20,16 +23,20 @@ export const summarizeHumanSteps = (events: readonly AiRunEvent[]): string[] => return `${stage}:${event.action}` }) -const buildThoughtMetadata = (input: { summary: string } & Partial): HumanThought => ({ +const buildThoughtMetadata = ( + input: { summary: string } & Partial +): HumanThought => ({ next: input.next || input.summary, todolist: input.todolist, progressionSummary: input.progressionSummary }) -export const buildLearnSkillsDecision = (input: { - skillIds: RuntimeSkillId[] - summary: string -} & Partial): HumanThoughtDecision => ({ +export const buildLearnSkillsDecision = ( + input: { + skillIds: RuntimeSkillId[] + summary: string + } & Partial +): HumanThoughtDecision => ({ thought: buildThoughtMetadata(input), nextAction: { action: 'learning', @@ -37,26 +44,38 @@ export const buildLearnSkillsDecision = (input: { } }) -export const buildToolDecision = (input: { - toolName: string - args: JsonObject - summary: string -} & Partial): HumanThoughtDecision => ({ +export const buildToolDecision = ( + input: { + toolName: string + args: JsonObject + summary: string + } & Partial +): HumanThoughtDecision => ({ thought: buildThoughtMetadata(input), nextAction: { action: 'hand', calls: [ { toolName: input.toolName, - args: input.args + args: + input.toolName === 'terminal_run' && + typeof input.args.command === 'string' && + typeof input.args.commandExplanation !== 'string' + ? { + ...input.args, + commandExplanation: `执行测试命令:${input.args.command}` + } + : input.args } ] } }) -export const buildCompleteTaskDecision = (input: { - summary: string -} & Partial): HumanThoughtDecision => ({ +export const buildCompleteTaskDecision = ( + input: { + summary: string + } & Partial +): HumanThoughtDecision => ({ thought: buildThoughtMetadata(input), nextAction: { action: 'mouth' @@ -73,7 +92,9 @@ export const filterToolCallStartedEventsByName = ( events: readonly AiRunEvent[], toolName: string ): Extract[] => - filterAiRunEventsByType(events, 'tool_call_started').filter((event) => event.toolName === toolName) + filterAiRunEventsByType(events, 'tool_call_started').filter( + (event) => event.toolName === toolName + ) export const collectStartedToolInputs = ( events: readonly AiRunEvent[], @@ -120,7 +141,9 @@ export const collectStartedDocxEditSourcePaths = ( .filter((value): value is string => typeof value === 'string') export const hasKbToolCall = (events: readonly AiRunEvent[]): boolean => - filterAiRunEventsByType(events, 'tool_call_result').some((event) => event.toolName.startsWith('kb_')) + filterAiRunEventsByType(events, 'tool_call_result').some((event) => + event.toolName.startsWith('kb_') + ) export const hasDocxApplyArtifact = (artifacts: readonly AiArtifactRef[] | undefined): boolean => Array.isArray(artifacts) && @@ -145,54 +168,14 @@ export const countDocxApplyArtifacts = (artifacts: readonly AiArtifactRef[] | un export const hasDocxFileArtifact = (artifacts: readonly AiArtifactRef[] | undefined): boolean => Array.isArray(artifacts) && - artifacts.some( - (artifact) => - artifact.kind === 'file' && - artifact.capability === 'docx' - ) + artifacts.some((artifact) => artifact.kind === 'file' && artifact.capability === 'docx') export const countDocxFileArtifacts = (artifacts: readonly AiArtifactRef[] | undefined): number => Array.isArray(artifacts) - ? artifacts.filter( - (artifact) => - artifact.kind === 'file' && - artifact.capability === 'docx' - ).length + ? artifacts.filter((artifact) => artifact.kind === 'file' && artifact.capability === 'docx') + .length : 0 -const DOCX_WRITE_TOOL_NAMES = [ - 'docx_edit', - 'docx_rollback' -] as const - -type DocxWriteToolName = (typeof DOCX_WRITE_TOOL_NAMES)[number] - -type DocxChangedBlockPreview = { - blockId?: string - beforeText?: string - afterText?: string -} - -type DocxMutationResultPreview = { - changedBlocks?: DocxChangedBlockPreview[] -} - -export const isDocxWriteToolName = (toolName: string): toolName is DocxWriteToolName => - DOCX_WRITE_TOOL_NAMES.includes(toolName as DocxWriteToolName) - -export const hasDocxWriteToolCall = (events: readonly AiRunEvent[]): boolean => - filterAiRunEventsByType(events, 'tool_call_result').some((event) => isDocxWriteToolName(event.toolName)) - -export const collectDocxWriteChangedBlocks = ( - events: readonly AiRunEvent[] -): DocxChangedBlockPreview[] => - filterAiRunEventsByType(events, 'tool_call_result') - .filter((event) => isDocxWriteToolName(event.toolName)) - .flatMap((event) => { - const mutation = (event.result?.data as DocxMutationResultPreview | undefined) || {} - return mutation.changedBlocks || [] - }) - const isJsonObject = (value: JsonValue | undefined): value is JsonObject => typeof value === 'object' && value !== null && !Array.isArray(value) @@ -306,31 +289,3 @@ export const collectToolDiagnosticMessages = ( .filter((event) => (toolName ? event.toolName === toolName : true)) .map((event) => event.result?.diagnostic?.message) .filter((value): value is string => typeof value === 'string' && value.trim().length > 0) - -export const readToolCallInput = ( - events: readonly AiRunEvent[], - toolName: string, - index = -1 -): TValue => { - const matches = filterToolCallStartedEventsByName(events, toolName) - const resolvedIndex = index < 0 ? matches.length + index : index - const event = matches[resolvedIndex] - if (!event) { - throw new Error(`tool_call_started not found for ${toolName} at index ${String(index)}`) - } - return event.input as TValue -} - -export const readSingleToolResultData = ( - state: HumanState, - toolName?: string -): TValue => { - const result = state.execution.lastToolResults?.[0] - if (!result) { - throw new Error('execution.lastToolResults is empty') - } - if (toolName && result.toolName !== toolName) { - throw new Error(`expected tool result ${toolName}, received ${result.toolName}`) - } - return result.data as TValue -} diff --git a/tests/case/ai/tool/docx/testUtils.ts b/tests/case/ai/tool/docx/testUtils.ts index 624208cd..7e787b4a 100644 --- a/tests/case/ai/tool/docx/testUtils.ts +++ b/tests/case/ai/tool/docx/testUtils.ts @@ -1,4 +1,7 @@ -import type { DocxChangedBlock, DocxOutlineBlock } from '../../../../../src/main/services/ai/human/tools/docx/types' +import type { + DocxChangedBlock, + DocxOutlineBlock +} from '../../../../../src/main/services/ai/human/tools/docx/types' export type DocxOutlineResult = { sourcePath: string @@ -234,9 +237,6 @@ export const findParagraphBlockIndexByText = ( (block) => block.type === 'paragraph' && block.text.includes(textFragment) ).blockIndex -export const findTableBlockId = (blocks: readonly DocxOutlineResult['blocks']): string => - findOutlineBlock(blocks, (block) => block.type === 'table').blockId - export const findTableCellBlockIdByText = ( blocks: readonly DocxOutlineResult['blocks'], textFragment: string @@ -245,12 +245,3 @@ export const findTableCellBlockIdByText = ( blocks, (block) => block.type === 'table_cell' && block.text.includes(textFragment) ).blockId - -export const findTableCellBlockIndexByText = ( - blocks: readonly DocxOutlineResult['blocks'], - textFragment: string -): number => - findOutlineBlock( - blocks, - (block) => block.type === 'table_cell' && block.text.includes(textFragment) - ).blockIndex diff --git a/tests/case/ai/tool/testHelpers.ts b/tests/case/ai/tool/testHelpers.ts index ec63b2ec..dd4103cf 100644 --- a/tests/case/ai/tool/testHelpers.ts +++ b/tests/case/ai/tool/testHelpers.ts @@ -1,44 +1,7 @@ -import type { DocumentRecord, SearchResult } from '../../../../src/shared/types' import type { AiRunEvent } from '../../../../src/shared/types' -export const createTestDocumentRecord = (input: { - id: string - name: string - sourceType?: 'file' | 'web' -}): DocumentRecord => ({ - id: input.id, - name: input.name, - source_type: input.sourceType || 'file', - created_at: Date.now(), - import_status: 3 -}) - -export const createTestSearchResult = (input: { - id: string - text: string - documentId: string - documentName: string - sourceType?: 'file' | 'web' - score?: number -}): SearchResult => ({ - id: input.id, - text: input.text, - document_id: input.documentId, - document_name: input.documentName, - source_type: input.sourceType || 'file', - _score: input.score ?? 1 -}) - export const filterEventsByType = ( events: readonly AiRunEvent[], type: TType ): Extract[] => - events.filter( - (event): event is Extract => event.type === type - ) - -export const findToolResultEvent = ( - events: readonly AiRunEvent[], - toolName: string -): Extract | undefined => - filterEventsByType(events, 'tool_call_result').find((event) => event.toolName === toolName) + events.filter((event): event is Extract => event.type === type) diff --git a/tests/case/settings/model/model-create-edit-form.e2e.test.ts b/tests/case/settings/model/model-create-edit-form.e2e.test.ts index b243aac1..b77b8d8f 100644 --- a/tests/case/settings/model/model-create-edit-form.e2e.test.ts +++ b/tests/case/settings/model/model-create-edit-form.e2e.test.ts @@ -23,6 +23,7 @@ describe('settings model e2e', () => { let validationBlocked = false let createMaskClickPreservedForm = false let editMaskClickPreservedForm = false + let createdRowText = '' await runtime.step({ name: 'open-create-form', @@ -74,6 +75,11 @@ describe('settings model e2e', () => { .locator('tbody tr') .filter({ hasText: 'https://api.openai.com/v1' }) .waitFor({ state: 'visible' }) + createdRowText = + (await page + .locator('tbody tr') + .filter({ hasText: 'https://api.openai.com/v1' }) + .textContent()) || '' } }) @@ -111,6 +117,7 @@ describe('settings model e2e', () => { validationBlocked, createMaskClickPreservedForm, editMaskClickPreservedForm, + createdRowText, editPrefill: { name: await getSettingsLocator(page, settingsSelectors.modelNameInput).inputValue(), baseUrl: await getSettingsLocator(page, settingsSelectors.modelBaseUrlInput) @@ -158,6 +165,17 @@ describe('settings model e2e', () => { data: result } ) + + await runtime.assertThat( + result.createdRowText.includes('创建编辑测试') && + !/默认|当前|启用/u.test(result.createdRowText), + { + name: 'first-config-remains-an-ordinary-service', + message: + '空列表中的第一条连接配置保存后应作为普通可用服务展示,不自动产生默认、当前或启用状态,对齐 SET-MODEL-001-AC1、SET-MODEL-001-AC2。', + data: result + } + ) } }) }, diff --git a/tests/case/settings/model/model-first-config-saves-service.e2e.test.ts b/tests/case/settings/model/model-first-config-saves-service.e2e.test.ts deleted file mode 100644 index 40721fec..00000000 --- a/tests/case/settings/model/model-first-config-saves-service.e2e.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { Page } from 'playwright' -import { describe, it } from 'vitest' -import { settingsSelectors } from '../../../../src/renderer/src/testing/selectors' -import { runE2ECase } from '../../../harness/e2e/run-e2e-case' -import { - SETTINGS_E2E_TIMEOUT_MS, - getSettingsLocator, - navigateToSettingsSection -} from '../../../harness/e2e/settings' - -const fillModelForm = async (page: Page): Promise => { - await getSettingsLocator(page, settingsSelectors.modelNameInput).fill('公司 OpenAI') - await getSettingsLocator(page, settingsSelectors.modelBaseUrlInput) - .locator('input') - .fill('https://api.openai.com/v1') - await getSettingsLocator(page, settingsSelectors.modelApiKeyInput).fill('sk-first-config') -} - -describe('settings model e2e', () => { - it( - 'model-first-config-saves-available-service', - async () => { - await runE2ECase({ - caseId: 'model-first-config-saves-available-service', - domain: 'settings', - suite: 'model', - description: '配置列表为空时新增第一条连接配置后,会作为可用服务展示。', - fixtures: [], - runMode: 'single', - execute: async (runtime) => { - const { page } = runtime - - await runtime.step({ - name: 'open-model-settings', - message: '进入 Settings 大模型页并打开新增配置弹窗。', - run: async () => { - await navigateToSettingsSection(page, 'model') - await getSettingsLocator(page, settingsSelectors.modelAddButton).click() - await getSettingsLocator(page, settingsSelectors.modelNameInput).waitFor({ - state: 'visible' - }) - } - }) - - await runtime.step({ - name: 'create-first-config', - message: '填写第一条连接配置并保存。', - run: async () => { - await fillModelForm(page) - await getSettingsLocator(page, settingsSelectors.modelSaveButton).click() - await page.locator('tbody tr').filter({ hasText: '公司 OpenAI' }).waitFor({ - state: 'visible' - }) - } - }) - - const rowText = - (await page.locator('tbody tr').filter({ hasText: '公司 OpenAI' }).textContent()) || '' - - return { rowText } - }, - assert: async (result, runtime) => { - await runtime.assertThat( - result.rowText.includes('公司 OpenAI') && - result.rowText.includes('https://api.openai.com/v1'), - { - name: 'first-config-is-listed-as-service', - message: '第一条连接配置保存后,应作为可用服务展示,不产生当前/默认状态。', - data: result - } - ) - } - }) - }, - SETTINGS_E2E_TIMEOUT_MS - ) -}) diff --git a/tests/case/settings/update/app-update-panel.e2e.test.ts b/tests/case/settings/update/app-update-panel.e2e.test.ts new file mode 100644 index 00000000..967c1041 --- /dev/null +++ b/tests/case/settings/update/app-update-panel.e2e.test.ts @@ -0,0 +1,204 @@ +import { describe, it } from 'vitest' +import { settingsSelectors } from '../../../../src/renderer/src/testing/selectors' +import { runE2ECase } from '../../../harness/e2e/run-e2e-case' +import { + SETTINGS_E2E_TIMEOUT_MS, + getSettingsLocator, + navigateToSettingsSection +} from '../../../harness/e2e/settings' + +describe('settings app update e2e', () => { + it( + 'app-update-panel', + async () => { + await runE2ECase({ + caseId: 'app-update-panel', + domain: 'settings', + suite: 'update', + description: '应用更新页展示检查、下载、安装和错误状态,并调用对应 IPC。', + fixtures: ['app-update-fixture'], + runMode: 'single', + execute: async (runtime) => { + const { page } = runtime + const status = getSettingsLocator(page, settingsSelectors.appUpdateStatus) + const checkButton = getSettingsLocator(page, settingsSelectors.appUpdateCheckButton) + const installButton = getSettingsLocator(page, settingsSelectors.appUpdateInstallButton) + + await runtime.step({ + name: 'open-app-update-settings', + message: '进入应用更新设置页并读取初始版本。', + run: async () => { + await navigateToSettingsSection(page, 'app') + await status.getByText('可以手动检查是否有新版本').waitFor({ state: 'visible' }) + } + }) + + const initialState = { + currentVersion: await getSettingsLocator( + page, + settingsSelectors.appUpdateCurrentVersion + ).textContent(), + checkDisabled: await checkButton.isDisabled(), + installDisabled: await installButton.isDisabled() + } + + const observedStatuses: string[] = [] + await runtime.step({ + name: 'check-and-download-update', + message: '点击检查更新并观察完整的模拟下载状态变化。', + run: async () => { + await checkButton.click() + const expectedStatuses = [ + '正在检查最新版本', + '已发现新版本,正在准备下载', + '正在下载更新包', + '更新已下载,重启后完成安装' + ] + for (const expectedStatus of expectedStatuses) { + await status.getByText(expectedStatus).waitFor({ state: 'visible' }) + observedStatuses.push(expectedStatus) + } + } + }) + + const downloadedState = { + availableVersion: await getSettingsLocator( + page, + settingsSelectors.appUpdateAvailableVersion + ).textContent(), + progressPercent: await getSettingsLocator(page, settingsSelectors.appUpdateProgress) + .getByRole('progressbar') + .getAttribute('aria-valuenow'), + installDisabled: await installButton.isDisabled() + } + + await runtime.step({ + name: 'request-update-install', + message: '点击重启安装并记录安装 IPC。', + run: async () => { + await installButton.click() + await page.waitForTimeout(100) + } + }) + + await runtime.step({ + name: 'show-update-error', + message: '注入更新失败状态,确认页面展示具体错误。', + run: async () => { + await page.evaluate(() => { + window.dispatchEvent( + new CustomEvent('ncurator-e2e-app-update-set-snapshot', { + detail: JSON.stringify({ + status: 'error', + currentVersion: '0.1.0', + isPackaged: true, + updateSupported: true, + platform: 'darwin', + error: 'network unavailable' + }) + }) + ) + }) + await status.getByText('network unavailable').waitFor({ state: 'visible' }) + } + }) + const errorText = await status.textContent() + + await runtime.step({ + name: 'show-unsigned-macos-reason', + message: '注入未签名 macOS 状态,确认页面阻止无效的自动更新。', + run: async () => { + await page.evaluate(() => { + window.dispatchEvent( + new CustomEvent('ncurator-e2e-app-update-set-snapshot', { + detail: JSON.stringify({ + status: 'idle', + currentVersion: '0.1.1', + isPackaged: true, + updateSupported: false, + unsupportedReason: 'macOS 自动更新需要有效的 Developer ID 签名', + platform: 'darwin' + }) + }) + ) + }) + await status + .getByText('macOS 自动更新需要有效的 Developer ID 签名') + .waitFor({ state: 'visible' }) + } + }) + + const signals = await runtime.collectSignals() + return { + initialState, + observedStatuses, + downloadedState, + errorText, + unsignedMacState: { + statusText: await status.textContent(), + checkDisabled: await checkButton.isDisabled() + }, + ipcCalls: signals.ipcInvokeEntries.map((entry) => entry.name) + } + }, + assert: async (result, runtime) => { + await runtime.assertThat( + result.initialState.currentVersion === '0.1.0' && + !result.initialState.checkDisabled && + result.initialState.installDisabled, + { + name: 'initial-update-state-is-actionable', + message: '初始页面应显示当前版本,允许检查更新,并禁止提前安装。', + data: result.initialState + } + ) + + await runtime.assertThat(result.observedStatuses.length === 4, { + name: 'update-status-flow-is-visible', + message: '页面应依次展示检查、发现版本、下载和等待安装状态。', + data: { observedStatuses: result.observedStatuses } + }) + + await runtime.assertThat( + result.downloadedState.availableVersion === '0.1.1' && + result.downloadedState.progressPercent === '100' && + !result.downloadedState.installDisabled, + { + name: 'downloaded-update-can-be-installed', + message: '下载完成后应显示新版本与完整进度,并允许重启安装。', + data: result.downloadedState + } + ) + + await runtime.assertThat( + result.ipcCalls.includes('appUpdateCheck') && + result.ipcCalls.includes('appUpdateInstall'), + { + name: 'update-actions-call-desktop-api', + message: '检查更新和重启安装按钮应调用对应桌面 API。', + data: { ipcCalls: result.ipcCalls } + } + ) + + await runtime.assertThat(result.errorText?.includes('network unavailable') === true, { + name: 'update-error-is-visible', + message: '更新失败时应显示具体错误信息。', + data: { errorText: result.errorText } + }) + + await runtime.assertThat( + result.unsignedMacState.statusText?.includes( + 'macOS 自动更新需要有效的 Developer ID 签名' + ) === true && result.unsignedMacState.checkDisabled, + { + name: 'unsigned-macos-update-is-blocked', + message: '未签名 macOS 构建应说明原因并禁止检查更新。', + data: result.unsignedMacState + } + ) + } + }) + }, + SETTINGS_E2E_TIMEOUT_MS + ) +}) diff --git a/tests/case/settings/update/app-update-publish-config.test.ts b/tests/case/settings/update/app-update-publish-config.test.ts new file mode 100644 index 00000000..f2e9262b --- /dev/null +++ b/tests/case/settings/update/app-update-publish-config.test.ts @@ -0,0 +1,14 @@ +import { readFile } from 'node:fs/promises' +import path from 'node:path' +import { describe, expect, it } from 'vitest' + +describe('app update publish config', () => { + it('uses the direct release download feed instead of the GitHub Atom provider', async () => { + const config = await readFile(path.resolve('electron-builder.yml'), 'utf8') + + expect(config).toMatch( + /publish:\s*\n\s*provider:\s*generic\s*\n\s*url:\s*https:\/\/download\.ncurator\.com\/updates\/latest/u + ) + expect(config).not.toMatch(/publish:\s*\n\s*provider:\s*github/u) + }) +}) diff --git a/tests/case/settings/update/app-update-service-contract.test.ts b/tests/case/settings/update/app-update-service-contract.test.ts new file mode 100644 index 00000000..99a01b75 --- /dev/null +++ b/tests/case/settings/update/app-update-service-contract.test.ts @@ -0,0 +1,359 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => { + const updaterListeners = new Map void>() + const ipcHandlers = new Map unknown>() + const updater: Record = {} + const appState = { + isPackaged: true, + version: '0.1.0' + } + const windowSend = vi.fn() + + updater.on = vi.fn((event: string, listener: (...args: unknown[]) => void) => { + updaterListeners.set(event, listener) + return updater + }) + updater.checkForUpdates = vi.fn() + updater.quitAndInstall = vi.fn() + updater.autoDownload = false + updater.autoInstallOnAppQuit = false + updater.logger = null + + return { + updater, + updaterListeners, + ipcHandlers, + appState, + windowSend + } +}) + +vi.mock('electron', () => ({ + app: { + get isPackaged(): boolean { + return mocks.appState.isPackaged + }, + getVersion: () => mocks.appState.version + }, + BrowserWindow: { + getAllWindows: () => [ + { + isDestroyed: () => false, + webContents: { + send: mocks.windowSend + } + } + ] + }, + ipcMain: { + handle: vi.fn((channel: string, handler: (...args: unknown[]) => unknown) => { + mocks.ipcHandlers.set(channel, handler) + }) + } +})) + +vi.mock('electron-updater', () => ({ + autoUpdater: mocks.updater +})) + +import { + APP_UPDATE_CHECK_CHANNEL, + APP_UPDATE_GET_SNAPSHOT_CHANNEL, + APP_UPDATE_INSTALL_CHANNEL +} from '../../../../src/shared/constants' +import type { AppUpdateActionResponse, AppUpdateSnapshot } from '../../../../src/shared/types' +import { AppUpdateService } from '../../../../src/main/services/update/AppUpdateService' + +const originalPlatform = process.platform + +const getIpcHandler = (channel: string): ((...args: unknown[]) => unknown) => { + const handler = mocks.ipcHandlers.get(channel) + if (!handler) throw new Error(`missing IPC handler: ${channel}`) + return handler +} + +const emitUpdaterEvent = (event: string, ...args: unknown[]): void => { + const listener = mocks.updaterListeners.get(event) + if (!listener) throw new Error(`missing updater listener: ${event}`) + listener(...args) +} + +describe('AppUpdateService contract', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + mocks.appState.isPackaged = true + mocks.appState.version = '0.1.0' + mocks.updaterListeners.clear() + mocks.ipcHandlers.clear() + mocks.windowSend.mockReset() + vi.mocked(mocks.updater.checkForUpdates as () => Promise).mockReset() + vi.mocked(mocks.updater.quitAndInstall as () => void).mockReset() + ;(mocks.updater.on as ReturnType).mockClear() + mocks.updater.autoDownload = false + mocks.updater.autoInstallOnAppQuit = false + mocks.updater.logger = null + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }) + }) + + it('checks, downloads and installs an available update', async () => { + const checkForUpdates = vi.mocked(mocks.updater.checkForUpdates as () => Promise) + checkForUpdates.mockImplementation(async () => { + emitUpdaterEvent('checking-for-update') + emitUpdaterEvent('update-available', { + version: '0.1.1', + releaseName: 'v0.1.1', + releaseDate: '2026-07-13T08:00:00.000Z' + }) + emitUpdaterEvent('download-progress', { + percent: 42, + transferred: 42, + total: 100, + bytesPerSecond: 8 + }) + emitUpdaterEvent('update-downloaded', { + version: '0.1.1', + releaseName: 'v0.1.1', + releaseDate: '2026-07-13T08:00:00.000Z' + }) + return null + }) + + const service = new AppUpdateService() + service.initialize() + + const response = (await getIpcHandler(APP_UPDATE_CHECK_CHANNEL)()) as AppUpdateActionResponse + const snapshot = getIpcHandler(APP_UPDATE_GET_SNAPSHOT_CHANNEL)() as AppUpdateSnapshot + + expect(mocks.updater.autoDownload).toBe(true) + expect(mocks.updater.autoInstallOnAppQuit).toBe(true) + expect(response).toMatchObject({ + success: true, + snapshot: { + status: 'downloaded', + currentVersion: '0.1.0', + availableVersion: '0.1.1', + progressPercent: 100 + } + }) + expect(snapshot.status).toBe('downloaded') + expect( + mocks.windowSend.mock.calls.map((call) => (call[1] as AppUpdateSnapshot).status) + ).toEqual(['checking', 'checking', 'available', 'downloading', 'downloaded']) + + const installResponse = getIpcHandler(APP_UPDATE_INSTALL_CHANNEL)() as AppUpdateActionResponse + expect(installResponse.success).toBe(true) + expect(mocks.updater.quitAndInstall).toHaveBeenCalledWith(false, true) + }) + + it('reports that the packaged app is already up to date', async () => { + vi.mocked(mocks.updater.checkForUpdates as () => Promise).mockImplementation( + async () => { + emitUpdaterEvent('update-not-available', { + version: '0.1.0', + releaseName: 'v0.1.0', + releaseDate: '2026-07-13T05:42:00.000Z' + }) + return null + } + ) + + const service = new AppUpdateService() + service.initialize() + + const response = (await getIpcHandler(APP_UPDATE_CHECK_CHANNEL)()) as AppUpdateActionResponse + expect(response).toMatchObject({ + success: true, + snapshot: { + status: 'not-available', + availableVersion: '0.1.0' + } + }) + }) + + it('surfaces check failures and refuses installation before download finishes', async () => { + vi.mocked(mocks.updater.checkForUpdates as () => Promise).mockRejectedValue( + new Error('network unavailable') + ) + + const service = new AppUpdateService() + service.initialize() + + const response = (await getIpcHandler(APP_UPDATE_CHECK_CHANNEL)()) as AppUpdateActionResponse + expect(response).toMatchObject({ + success: false, + error: 'network unavailable', + snapshot: { + status: 'error', + error: 'network unavailable' + } + }) + + const installResponse = getIpcHandler(APP_UPDATE_INSTALL_CHANNEL)() as AppUpdateActionResponse + expect(installResponse).toMatchObject({ + success: false, + error: '更新包尚未下载完成' + }) + expect(mocks.updater.quitAndInstall).not.toHaveBeenCalled() + }) + + it('surfaces updater error events such as checksum verification failures', async () => { + vi.mocked(mocks.updater.checkForUpdates as () => Promise).mockImplementation( + async () => { + emitUpdaterEvent('error', new Error('sha512 checksum mismatch')) + return null + } + ) + + const service = new AppUpdateService() + service.initialize() + + const response = (await getIpcHandler(APP_UPDATE_CHECK_CHANNEL)()) as AppUpdateActionResponse + expect(response).toMatchObject({ + success: false, + error: 'sha512 checksum mismatch', + snapshot: { + status: 'error', + error: 'sha512 checksum mismatch' + } + }) + }) + + it('does not start another check while an update is already available', async () => { + let finishFirstCheck: (() => void) | undefined + const firstCheckPending = new Promise((resolve) => { + finishFirstCheck = resolve + }) + vi.mocked(mocks.updater.checkForUpdates as () => Promise).mockImplementation( + async () => { + emitUpdaterEvent('update-available', { + version: '0.1.1', + releaseName: 'v0.1.1', + releaseDate: '2026-07-13T08:00:00.000Z' + }) + await firstCheckPending + return null + } + ) + + const service = new AppUpdateService() + service.initialize() + + const firstResponsePromise = getIpcHandler(APP_UPDATE_CHECK_CHANNEL)() + const secondResponse = (await getIpcHandler( + APP_UPDATE_CHECK_CHANNEL + )()) as AppUpdateActionResponse + + expect(secondResponse).toMatchObject({ + success: true, + snapshot: { + status: 'available', + availableVersion: '0.1.1' + } + }) + expect(mocks.updater.checkForUpdates).toHaveBeenCalledTimes(1) + + finishFirstCheck?.() + await firstResponsePromise + }) + + it('runs the startup update check once after the configured delay', async () => { + const schedule = + vi.fn<(callback: () => void, delayMs: number) => ReturnType>() + schedule.mockImplementation((callback) => { + callback() + return 1 as unknown as ReturnType + }) + vi.mocked(mocks.updater.checkForUpdates as () => Promise).mockResolvedValue(null) + + const service = new AppUpdateService({ schedule }) + service.initialize() + service.checkForUpdatesOnLaunch() + + expect(schedule).toHaveBeenCalledOnce() + expect(schedule.mock.calls[0]?.[1]).toBe(5000) + await vi.waitFor(() => { + expect(mocks.updater.checkForUpdates).toHaveBeenCalledOnce() + }) + }) + + it('initializes listeners and IPC handlers only once', () => { + const service = new AppUpdateService() + service.initialize() + service.initialize() + + expect(mocks.updater.on).toHaveBeenCalledTimes(6) + expect(mocks.ipcHandlers.size).toBe(3) + }) + + it('keeps automatic updates disabled in development mode', async () => { + mocks.appState.isPackaged = false + const service = new AppUpdateService() + service.initialize() + + const response = (await getIpcHandler(APP_UPDATE_CHECK_CHANNEL)()) as AppUpdateActionResponse + expect(response).toMatchObject({ + success: false, + error: '开发模式不会检查自动更新', + snapshot: { + isPackaged: false, + updateSupported: false + } + }) + expect(mocks.updater.checkForUpdates).not.toHaveBeenCalled() + expect(mocks.updater.on).not.toHaveBeenCalled() + }) + + it('keeps automatic updates disabled on unsupported packaged platforms', async () => { + Object.defineProperty(process, 'platform', { value: 'linux' }) + const service = new AppUpdateService() + service.initialize() + + const response = (await getIpcHandler(APP_UPDATE_CHECK_CHANNEL)()) as AppUpdateActionResponse + expect(response).toMatchObject({ + success: false, + error: '当前系统暂不支持自动更新', + snapshot: { + isPackaged: true, + updateSupported: false, + platform: 'linux' + } + }) + expect(mocks.updater.checkForUpdates).not.toHaveBeenCalled() + expect(mocks.updater.on).not.toHaveBeenCalled() + }) + + it('explains why an unsigned macOS build cannot auto update', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }) + const service = new AppUpdateService({ macCodeSignatureValid: () => false }) + service.initialize() + + const response = (await getIpcHandler(APP_UPDATE_CHECK_CHANNEL)()) as AppUpdateActionResponse + expect(response).toMatchObject({ + success: false, + error: 'macOS 自动更新需要有效的 Developer ID 签名', + snapshot: { + updateSupported: false, + unsupportedReason: 'macOS 自动更新需要有效的 Developer ID 签名', + platform: 'darwin' + } + }) + expect(mocks.updater.checkForUpdates).not.toHaveBeenCalled() + }) + + it('enables automatic updates for a signed macOS build', () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }) + const service = new AppUpdateService({ macCodeSignatureValid: () => true }) + service.initialize() + + const snapshot = getIpcHandler(APP_UPDATE_GET_SNAPSHOT_CHANNEL)() as AppUpdateSnapshot + expect(snapshot).toMatchObject({ + updateSupported: true, + platform: 'darwin' + }) + expect(mocks.updater.on).toHaveBeenCalledTimes(6) + }) +}) diff --git a/tests/case/settings/update/r2-update-metadata-contract.test.ts b/tests/case/settings/update/r2-update-metadata-contract.test.ts new file mode 100644 index 00000000..5c411dd1 --- /dev/null +++ b/tests/case/settings/update/r2-update-metadata-contract.test.ts @@ -0,0 +1,48 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { afterEach, describe, expect, it } from 'vitest' + +const execFileAsync = promisify(execFile) +let temporaryDirectory = '' + +afterEach(async () => { + if (temporaryDirectory) await rm(temporaryDirectory, { recursive: true, force: true }) +}) + +describe('R2 update metadata', () => { + it('points latest metadata to immutable versioned assets', async () => { + temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), 'ncurator-update-metadata-')) + const inputDirectory = path.join(temporaryDirectory, 'input') + const outputDirectory = path.join(temporaryDirectory, 'output') + await import('node:fs/promises').then(({ mkdir }) => mkdir(inputDirectory, { recursive: true })) + await writeFile( + path.join(inputDirectory, 'latest.yml'), + 'version: 0.1.1\nfiles:\n - url: Ncurator-windows-x64.exe\npath: Ncurator-windows-x64.exe\n', + 'utf8' + ) + await writeFile( + path.join(inputDirectory, 'latest-mac.yml'), + 'version: 0.1.1\nfiles:\n - url: Ncurator-mac-arm64.zip\npath: Ncurator-mac-arm64.zip\n', + 'utf8' + ) + + await execFileAsync( + process.execPath, + [ + path.resolve('scripts/prepare-r2-update-metadata.mjs'), + inputDirectory, + outputDirectory, + 'v0.1.1' + ], + { cwd: process.cwd() } + ) + + const windowsMetadata = await readFile(path.join(outputDirectory, 'latest.yml'), 'utf8') + const macMetadata = await readFile(path.join(outputDirectory, 'latest-mac.yml'), 'utf8') + expect(windowsMetadata).toContain('../v0.1.1/Ncurator-windows-x64.exe') + expect(macMetadata).toContain('../v0.1.1/Ncurator-mac-arm64.zip') + }) +}) diff --git a/tests/case/settings/vector/embedding-runtime-contract.test.ts b/tests/case/settings/vector/embedding-runtime-contract.test.ts deleted file mode 100644 index 851ee0ef..00000000 --- a/tests/case/settings/vector/embedding-runtime-contract.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { describe, it } from 'vitest' -import { createEmbeddingRuntimeDispatcher } from '../../../../src/main/services/vector/runtime/embeddingRuntimeProcess' -import type { - EmbeddingExecutor, - EmbeddingRuntimeRequest, - EmbeddingRuntimeResponse -} from '../../../../src/main/services/vector/runtime/types' -import { runBackendCase } from '../../../harness/backend/run-backend-case' - -describe('settings vector backend', () => { - it('embedding-runtime-contract', async () => { - await runBackendCase({ - caseId: 'embedding-runtime-contract', - harnessMode: 'tool-real', - domain: 'settings', - suite: 'vector', - description: 'embedding runtime 只保留一个串行推理执行器,并使用请求协议返回结果。', - fixtures: [], - runMode: 'single', - execute: async () => { - let initialized = false - let activeCalls = 0 - let maxActiveCalls = 0 - const executor: EmbeddingExecutor = { - initialize: async () => { - initialized = true - }, - embed: async (texts) => { - if (!initialized) throw new Error('not initialized') - activeCalls += 1 - maxActiveCalls = Math.max(maxActiveCalls, activeCalls) - await new Promise((resolve) => setTimeout(resolve, 20)) - activeCalls -= 1 - return { - data: Float32Array.from(texts.map((text) => text.length)), - dims: [texts.length, 1] - } - } - } - const responses: EmbeddingRuntimeResponse[] = [] - const dispatcher = createEmbeddingRuntimeDispatcher({ - executor, - postMessage: (response) => responses.push(response) - }) - const requests: EmbeddingRuntimeRequest[] = [ - { requestId: 'initialize', action: 'initialize', payload: null }, - { requestId: 'first', action: 'embed', payload: { texts: ['first'] } }, - { requestId: 'second', action: 'embed', payload: { texts: ['second'] } } - ] - requests.forEach((request) => dispatcher.dispatch(request)) - await dispatcher.whenIdle() - return { - maxActiveCalls, - responses: responses.map((response) => ({ - requestId: response.requestId, - action: response.action, - ok: response.ok, - dims: response.action === 'embed' ? response.payload?.dims : undefined - })) - } - }, - assert: async (result, runtime) => { - runtime.assertThat( - result.maxActiveCalls === 1 && - result.responses.length === 3 && - result.responses.every((response) => response.ok) && - result.responses.map((response) => response.requestId).join(',') === - 'initialize,first,second', - { - name: 'embedding-runtime-serializes-all-inference', - message: '所有 embedding 请求必须由同一个 runtime 按顺序执行,不能并行进入 ONNX。', - data: result - } - ) - } - }) - }) -}) diff --git a/tests/case/settings/workspace/core/workspace-add-selection-flow.e2e.test.ts b/tests/case/settings/workspace/core/workspace-add-selection-flow.e2e.test.ts index c8367fb3..5c53bbc7 100644 --- a/tests/case/settings/workspace/core/workspace-add-selection-flow.e2e.test.ts +++ b/tests/case/settings/workspace/core/workspace-add-selection-flow.e2e.test.ts @@ -25,6 +25,7 @@ describe('settings workspace e2e', () => { let emptyNameBlocked = false let createModalVisibleAfterEmptySubmit = false let maskClickPreservedInput = false + let noWorkspaceDetailPromptVisible = false await runtime.step({ name: 'open-empty-workspace-page', @@ -34,6 +35,8 @@ describe('settings workspace e2e', () => { await getSettingsLocator(page, importSelectors.workspaceEmptyState).waitFor({ state: 'visible' }) + await page.getByText('选择一个工作空间').waitFor({ state: 'visible' }) + noWorkspaceDetailPromptVisible = await page.getByText('选择一个工作空间').isVisible() await getSettingsLocator(page, importSelectors.createWorkspaceButton).click() await getSettingsLocator(page, importSelectors.createWorkspaceNameInput).fill( '遮罩保留工作空间' @@ -83,6 +86,7 @@ describe('settings workspace e2e', () => { await getSettingsLocator(page, importSelectors.workspaceDetail) .getByRole('tab', { name: /文件/u }) .waitFor({ state: 'visible' }) + await page.getByText('当前目录没有可显示文件').waitFor({ state: 'visible' }) } }) @@ -93,6 +97,8 @@ describe('settings workspace e2e', () => { emptyNameBlocked, createModalStillVisible: createModalVisibleAfterEmptySubmit, maskClickPreservedInput, + noWorkspaceDetailPromptVisible, + emptyTreeVisible: await page.getByText('当前目录没有可显示文件').isVisible(), workspaceCardCount: await getSettingsPrefixLocator( page, importSelectors.workspaceCardPrefix @@ -110,6 +116,16 @@ describe('settings workspace e2e', () => { } }, assert: async (result, runtime) => { + await runtime.assertThat( + result.noWorkspaceDetailPromptVisible && result.emptyTreeVisible, + { + name: 'workspace-empty-states-remain-explicit', + message: + '没有工作空间时详情区应显示选择提示,创建未绑定目录的工作空间后文件区应显示明确空态,对齐 SET-WORKSPACE-007-AC1、SET-WORKSPACE-007-AC2。', + data: result + } + ) + await runtime.assertThat(result.emptyNameBlocked && result.createModalStillVisible, { name: 'empty-name-is-blocked', message: '工作空间名称为空时不允许提交创建,对齐 SET-WORKSPACE-002-AC1。', diff --git a/tests/case/settings/workspace/core/workspace-empty-tree-state.e2e.test.ts b/tests/case/settings/workspace/core/workspace-empty-tree-state.e2e.test.ts deleted file mode 100644 index 450c5681..00000000 --- a/tests/case/settings/workspace/core/workspace-empty-tree-state.e2e.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, it } from 'vitest' -import { importSelectors } from '../../../../../src/renderer/src/testing/selectors' -import { runE2ECase } from '../../../../harness/e2e/run-e2e-case' -import { - SETTINGS_E2E_TIMEOUT_MS, - createFixtureWorkspace, - getSettingsLocator, - navigateToSettingsSection -} from '../../../../harness/e2e/settings' - -describe('settings workspace e2e', () => { - it('workspace-empty-tree-state', async () => { - await runE2ECase({ - caseId: 'workspace-empty-tree-state', - domain: 'settings', - suite: 'workspace', - groups: ['core'], - description: '没有工作空间和空工作空间详情都能通过真实 UI 呈现明确空态。', - fixtures: [], - runMode: 'single', - execute: async (runtime) => { - const { page } = runtime - - await runtime.step({ - name: 'show-no-workspace-empty-state', - message: '进入工作空间页,确认无工作空间时左侧和详情区都有空态。', - run: async () => { - await navigateToSettingsSection(page, 'workspace') - await getSettingsLocator(page, importSelectors.workspaceEmptyState).waitFor({ - state: 'visible' - }) - await page.getByText('选择一个工作空间').waitFor({ state: 'visible' }) - } - }) - - const noWorkspaceListVisible = await getSettingsLocator( - page, - importSelectors.workspaceEmptyState - ).isVisible() - const noWorkspaceDetailPromptVisible = await page.getByText('选择一个工作空间').isVisible() - - await runtime.step({ - name: 'show-empty-tree-state', - message: '通过 UI 创建一个未绑定目录工作空间,确认文件 tab 显示空目录提示。', - run: async () => { - await createFixtureWorkspace(page, { name: '空目录工作空间' }) - await page.getByText('当前目录没有可显示文件').waitFor({ state: 'visible' }) - } - }) - - return { - noWorkspaceListVisible, - noWorkspaceDetailPromptVisible, - emptyTreeVisible: await page.getByText('当前目录没有可显示文件').isVisible() - } - }, - assert: async (result, runtime) => { - await runtime.assertThat( - result.noWorkspaceListVisible && result.noWorkspaceDetailPromptVisible, - { - name: 'no-workspace-shows-explicit-empty-state', - message: - '没有工作空间时,左侧列表和详情区都应呈现明确空态,而不是空白页,对齐 SET-WORKSPACE-007-AC1。', - data: result - } - ) - - await runtime.assertThat(result.emptyTreeVisible, { - name: 'empty-tree-shows-file-empty-state', - message: '当前目录没有可显示文件时,目录树区域应展示明确空态,对齐 SET-WORKSPACE-007-AC2。', - data: result - }) - } - }) - }, SETTINGS_E2E_TIMEOUT_MS) -}) diff --git a/tests/case/settings/workspace/files/realWorkspaceHelpers.ts b/tests/case/settings/workspace/files/realWorkspaceHelpers.ts index 248531a3..9daa3a64 100644 --- a/tests/case/settings/workspace/files/realWorkspaceHelpers.ts +++ b/tests/case/settings/workspace/files/realWorkspaceHelpers.ts @@ -1,25 +1,7 @@ import { mkdir, writeFile, cp, rm, mkdtemp } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' -import type { Page } from 'playwright' -import type { WorkspaceIndexJobRecord, WorkspaceTreeListResponse } from '../../../../../src/shared/types' -import { importSelectors } from '../../../../../src/renderer/src/testing/selectors' -import { - createRealWorkspaceWithRoot, - getSettingsLocator, - navigateToSettingsSection, - readWorkspaceTree, - waitForFileIndexJobIdle -} from '../../../../harness/e2e/settings' - -export type RealWorkspacePrepareResult = { - workspaceId: string - workspaceRoot: string - beforeTree: WorkspaceTreeListResponse - runningJob?: WorkspaceIndexJobRecord - lastRunningJob?: WorkspaceIndexJobRecord - finalTree: WorkspaceTreeListResponse -} +import type { WorkspaceTreeListResponse } from '../../../../../src/shared/types' export type SupportedFileStatus = { name: string @@ -51,19 +33,6 @@ export const createWorkspaceWithFiles = async ( return workspaceRoot } -export const createWorkspaceWithFixtureFiles = async ( - caseId: string, - files: Array<{ sourcePath: string; relativePath: string }> -): Promise => { - const workspaceRoot = await mkWorkspaceRoot(caseId) - for (const file of files) { - const target = path.join(workspaceRoot, file.relativePath) - await mkdir(path.dirname(target), { recursive: true }) - await cp(path.resolve(file.sourcePath), target) - } - return workspaceRoot -} - export const cleanupRealWorkspace = async (workspaceRoot: string): Promise => { await rm(workspaceRoot, { recursive: true, @@ -71,44 +40,7 @@ export const cleanupRealWorkspace = async (workspaceRoot: string): Promise }) } -export const triggerRealWorkspacePrepare = async ( - page: Page, - input: { workspaceRoot: string; workspaceName: string } -): Promise => { - const workspace = await createRealWorkspaceWithRoot(page, { - name: input.workspaceName, - rootPath: input.workspaceRoot - }) - const workspaceId = workspace.workspace!.id - await navigateToSettingsSection(page, 'workspace') - await getSettingsLocator(page, importSelectors.workspaceQuickPrepareButton).waitFor({ - state: 'visible' - }) - const beforeTree = await readWorkspaceTree(page, workspaceId) - await getSettingsLocator(page, importSelectors.workspaceQuickPrepareButton).click() - const prepareResponse = await page.evaluate(async (selectedWorkspaceId) => { - return await window.api.workspaceIndexJobGet({ - workspaceId: selectedWorkspaceId, - sourceKind: 'file' - }) - }, workspaceId) - if (!prepareResponse.success) { - throw new Error(prepareResponse.error || 'failed to read workspace prepare job') - } - const { lastRunningJob, tree } = await waitForFileIndexJobIdle(page, workspaceId) - return { - workspaceId, - workspaceRoot: input.workspaceRoot, - beforeTree, - runningJob: prepareResponse.job || lastRunningJob, - lastRunningJob, - finalTree: tree - } -} - -export const toSupportedFileStatuses = ( - tree: WorkspaceTreeListResponse -): SupportedFileStatus[] => +export const toSupportedFileStatuses = (tree: WorkspaceTreeListResponse): SupportedFileStatus[] => tree.items .filter((item) => item.kind === 'file' && item.supported_for_index) .map((item) => ({ @@ -119,4 +51,6 @@ export const toSupportedFileStatuses = ( })) const mkWorkspaceRoot = async (caseId: string): Promise => - await mkdtemp(path.join(os.tmpdir(), `ncurator-settings-${caseId.replace(/[^a-zA-Z0-9-_]/g, '-')}-`)) + await mkdtemp( + path.join(os.tmpdir(), `ncurator-settings-${caseId.replace(/[^a-zA-Z0-9-_]/g, '-')}-`) + ) diff --git a/tests/case/settings/workspace/files/workspace-prepare-real-fixture-refresh.e2e.test.ts b/tests/case/settings/workspace/files/workspace-prepare-real-fixture-refresh.e2e.test.ts deleted file mode 100644 index e2aa9518..00000000 --- a/tests/case/settings/workspace/files/workspace-prepare-real-fixture-refresh.e2e.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, it } from 'vitest' -import { runE2ECase } from '../../../../harness/e2e/run-e2e-case' -import { SETTINGS_REAL_WORKSPACE_E2E_TIMEOUT_MS } from '../../../../harness/e2e/settings' -import { - cleanupRealWorkspace, - copySharedWorkspaceFixture, - toSupportedFileStatuses, - triggerRealWorkspacePrepare -} from './realWorkspaceHelpers' - -describe('settings workspace e2e', () => { - it('workspace-prepare-real-fixture-refresh', async () => { - await runE2ECase({ - caseId: 'workspace-prepare-real-fixture-refresh', - domain: 'settings', - suite: 'workspace', - groups: ['files'], - description: '点击更新索引时,真实共享 workspace fixture 的支持文件应进入真实索引链路并完成状态收敛。', - fixtures: ['tests/fixtures/workspace'], - launchMode: 'real', - runMode: 'single', - execute: async (runtime) => { - const { page } = runtime - let workspaceRoot = '' - try { - workspaceRoot = await copySharedWorkspaceFixture('workspace-prepare-real-fixture-refresh') - const result = await runtime.step({ - name: 'prepare-real-workspace', - message: '创建真实工作空间,绑定共享 workspace fixture 的隔离副本,并点击更新索引。', - run: async () => - await triggerRealWorkspacePrepare(page, { - workspaceRoot, - workspaceName: '真实索引工作区' - }) - }) - const supportedStatuses = toSupportedFileStatuses(result.finalTree) - return { - runningJob: result.runningJob, - lastRunningJob: result.lastRunningJob, - beforeSummary: result.beforeTree.summary, - finalSummary: result.finalTree.summary, - supportedStatuses - } - } finally { - if (workspaceRoot) { - await cleanupRealWorkspace(workspaceRoot) - } - } - }, - assert: async (result, runtime) => { - await runtime.assertThat( - result.supportedStatuses.length >= 12 && - result.supportedStatuses.every((item) => - item.status === 'indexed' || item.status === 'failed' - ) && - result.finalSummary.indexed_file_count + result.finalSummary.failed_file_count === - result.supportedStatuses.length, - { - name: 'real-fixture-files-converge', - message: - '真实 workspace fixture 中的支持文件应完成索引或失败收敛,摘要应同步刷新,对齐 SET-WORKSPACE-004-AC1、SET-WORKSPACE-004-AC2。', - data: result - } - ) - } - }) - }, SETTINGS_REAL_WORKSPACE_E2E_TIMEOUT_MS) -}) diff --git a/tests/case/settings/workspace/files/workspace-prepare-refresh.e2e.test.ts b/tests/case/settings/workspace/files/workspace-prepare-refresh.e2e.test.ts index ff40cce6..375c99c2 100644 --- a/tests/case/settings/workspace/files/workspace-prepare-refresh.e2e.test.ts +++ b/tests/case/settings/workspace/files/workspace-prepare-refresh.e2e.test.ts @@ -15,94 +15,100 @@ import { } from './realWorkspaceHelpers' describe('settings workspace e2e', () => { - it('workspace-prepare-refresh', async () => { - await runE2ECase({ - caseId: 'workspace-prepare-refresh', - domain: 'settings', - suite: 'workspace', - groups: ['files'], - description: '点击更新索引后,页面按增量策略执行,并在完成后刷新文件索引状态。', - fixtures: ['tests/fixtures/workspace'], - launchMode: 'real', - runMode: 'single', - execute: async (runtime) => { - const { page } = runtime - let workspaceRoot = '' + it( + 'workspace-prepare-refresh', + async () => { + await runE2ECase({ + caseId: 'workspace-prepare-refresh', + domain: 'settings', + suite: 'workspace', + groups: ['files'], + description: '点击更新索引后,页面按增量策略执行,并在完成后刷新文件索引状态。', + fixtures: ['tests/fixtures/workspace'], + launchMode: 'real', + runMode: 'single', + execute: async (runtime) => { + const { page } = runtime + let workspaceRoot = '' - try { - workspaceRoot = await copySharedWorkspaceFixture('workspace-prepare-refresh') - const workspace = await createRealWorkspaceWithRoot(page, { - name: '索引工作区', - rootPath: workspaceRoot - }) - const workspaceId = workspace.workspace!.id - let prepareProgressVisible = false - let buttonsDisabled = false + try { + workspaceRoot = await copySharedWorkspaceFixture('workspace-prepare-refresh') + const workspace = await createRealWorkspaceWithRoot(page, { + name: '索引工作区', + rootPath: workspaceRoot + }) + const workspaceId = workspace.workspace!.id + let prepareProgressVisible = false + let buttonsDisabled = false - await runtime.step({ - name: 'prepare-workspace-through-ui', - message: '通过 UI 点击更新索引,并确认执行中进度块和按钮禁用状态。', - run: async () => { - await navigateToSettingsSection(page, 'workspace') - await getSettingsLocator(page, importSelectors.workspaceQuickPrepareButton).waitFor({ - state: 'visible' - }) - await getSettingsLocator(page, importSelectors.workspaceQuickPrepareButton).click() - await getSettingsLocator(page, importSelectors.workspaceIndexJobProgress).waitFor({ - state: 'visible' - }) - prepareProgressVisible = true - buttonsDisabled = - (await getSettingsLocator( - page, - importSelectors.workspaceQuickScanButton - ).isDisabled()) && - (await getSettingsLocator( - page, - importSelectors.workspaceQuickPrepareButton - ).isDisabled()) + await runtime.step({ + name: 'prepare-workspace-through-ui', + message: '通过 UI 点击更新索引,并确认执行中进度块和按钮禁用状态。', + run: async () => { + await navigateToSettingsSection(page, 'workspace') + await getSettingsLocator(page, importSelectors.workspaceQuickPrepareButton).waitFor( + { + state: 'visible' + } + ) + await getSettingsLocator(page, importSelectors.workspaceQuickPrepareButton).click() + await getSettingsLocator(page, importSelectors.workspaceIndexJobProgress).waitFor({ + state: 'visible' + }) + prepareProgressVisible = true + buttonsDisabled = + (await getSettingsLocator( + page, + importSelectors.workspaceQuickScanButton + ).isDisabled()) && + (await getSettingsLocator( + page, + importSelectors.workspaceQuickPrepareButton + ).isDisabled()) + } + }) + + const result = await waitForFileIndexJobIdle(page, workspaceId) + const supportedStatuses = toSupportedFileStatuses(result.tree) + + return { + prepareProgressVisible, + buttonsDisabled, + summary: result.tree.summary, + supportedStatuses } + } finally { + if (workspaceRoot) { + await cleanupRealWorkspace(workspaceRoot) + } + } + }, + assert: async (result, runtime) => { + await runtime.assertThat(result.prepareProgressVisible && result.buttonsDisabled, { + name: 'prepare-progress-is-visible', + message: + '更新索引执行中,界面应展示持久化总进度并禁用两个索引操作按钮,对齐 SET-WORKSPACE-004-AC3。', + data: result }) - const result = await waitForFileIndexJobIdle(page, workspaceId) - const supportedStatuses = toSupportedFileStatuses(result.tree) - - return { - prepareProgressVisible, - buttonsDisabled, - summary: result.tree.summary, - supportedStatuses - } - } finally { - if (workspaceRoot) { - await cleanupRealWorkspace(workspaceRoot) - } + await runtime.assertThat( + result.supportedStatuses.length >= 12 && + result.summary.needs_index_count === 0 && + result.summary.indexed_file_count + result.summary.failed_file_count === + result.supportedStatuses.length && + result.supportedStatuses.every( + (item) => item.status === 'indexed' || item.status === 'failed' + ), + { + name: 'prepare-refreshes-index-summary', + message: + '真实共享 workspace fixture 中的支持文件应全部完成索引或失败收敛,文件索引摘要和节点状态应同步刷新,对齐 SET-WORKSPACE-004-AC1、SET-WORKSPACE-004-AC2。', + data: result + } + ) } - }, - assert: async (result, runtime) => { - await runtime.assertThat(result.prepareProgressVisible && result.buttonsDisabled, { - name: 'prepare-progress-is-visible', - message: - '更新索引执行中,界面应展示持久化总进度并禁用两个索引操作按钮,对齐 SET-WORKSPACE-004-AC3。', - data: result - }) - - await runtime.assertThat( - result.supportedStatuses.length > 0 && - result.summary.needs_index_count === 0 && - result.summary.indexed_file_count + result.summary.failed_file_count === - result.supportedStatuses.length && - result.supportedStatuses.every( - (item) => item.status === 'indexed' || item.status === 'failed' - ), - { - name: 'prepare-refreshes-index-summary', - message: - '更新索引成功后,文件索引摘要和文件节点状态都应同步刷新,对齐 SET-WORKSPACE-004-AC2。', - data: result - } - ) - } - }) - }, SETTINGS_REAL_WORKSPACE_E2E_TIMEOUT_MS) + }) + }, + SETTINGS_REAL_WORKSPACE_E2E_TIMEOUT_MS + ) }) diff --git a/tests/case/settings/workspace/web/web-add-sources-dialog.e2e.test.ts b/tests/case/settings/workspace/web/web-add-sources-dialog.e2e.test.ts index 20f2a09a..e5004cad 100644 --- a/tests/case/settings/workspace/web/web-add-sources-dialog.e2e.test.ts +++ b/tests/case/settings/workspace/web/web-add-sources-dialog.e2e.test.ts @@ -28,6 +28,8 @@ describe('settings workspace web e2e', () => { let emptyWarningShown = false let modalClosedAfterSubmit = false let maskClickPreservedInput = false + let webManagementEmbedded = false + let workspaceRoutePreserved = false await runtime.step({ name: 'open-add-dialog', @@ -40,6 +42,16 @@ describe('settings workspace web e2e', () => { await navigateToSettingsSection(page, 'workspace') await workspaceCardLocator(page, workspace.workspace!.id).click() await page.getByRole('tab', { name: '网页 0' }).click() + webManagementEmbedded = + (await getSettingsLocator(page, importSelectors.workspaceDetail).isVisible()) && + (await getSettingsLocator(page, importSelectors.webSourceSection).isVisible()) && + (await getSettingsLocator(page, importSelectors.webSourceList).isVisible()) && + (await getSettingsLocator( + page, + importSelectors.webSourceOpenModalButton + ).isVisible()) + workspaceRoutePreserved = + (await page.evaluate(() => window.location.hash)) === '#/settings/workspace' await getSettingsLocator(page, importSelectors.webSourceOpenModalButton).click() await urlTextarea.waitFor({ state: 'visible' }) await urlTextarea.fill('https://example.com/mask-preserved') @@ -94,6 +106,8 @@ describe('settings workspace web e2e', () => { emptyWarningShown, modalClosedAfterSubmit, maskClickPreservedInput, + webManagementEmbedded, + workspaceRoutePreserved, cardCount: await getSettingsPrefixLocator( page, importSelectors.webSourceCardPrefix @@ -102,6 +116,13 @@ describe('settings workspace web e2e', () => { } }, assert: async (result, runtime) => { + await runtime.assertThat(result.webManagementEmbedded && result.workspaceRoutePreserved, { + name: 'workspace-detail-contains-web-management', + message: + '网页资料列表和添加入口应直接位于当前工作空间详情中,并保持在工作空间设置路由,对齐 SET-WORKSPACE-005-AC3、SET-WEB-001-AC3。', + data: result + }) + await runtime.assertThat(result.emptyWarningShown, { name: 'empty-submit-is-blocked', message: '输入为空时,提交应被拦截,并提示至少输入一个 URL,对齐 SET-WEB-002-AC1。', diff --git a/tests/case/settings/workspace/web/web-prepare-summary.e2e.test.ts b/tests/case/settings/workspace/web/web-prepare-summary.e2e.test.ts index 481828c7..ef280aaf 100644 --- a/tests/case/settings/workspace/web/web-prepare-summary.e2e.test.ts +++ b/tests/case/settings/workspace/web/web-prepare-summary.e2e.test.ts @@ -52,15 +52,28 @@ describe('settings workspace web e2e', () => { let buttonsDisabled = false let crawlStepVisible = false let progressText = '' + let scanProgressVisible = false + let scanButtonsDisabled = false + let scanSummary: { needs_index_count: number } | null = null + let scanStatuses: string[] = [] const httpServer = await startLocalSettingsHttpServer({ '/prepare-success': { body: 'Prepare SuccessLocal web fixture for indexing.', delayMs: 1500 }, '/refresh-fail': { - status: 500, - body: 'refresh failed', - delayMs: 1500 + body: 'Refresh First PassAvailable during scan.', + responses: [ + { + body: 'Refresh First PassAvailable during scan.', + delayMs: 1500 + }, + { + status: 500, + body: 'refresh failed during prepare', + delayMs: 1500 + } + ] } }) @@ -82,7 +95,21 @@ describe('settings workspace web e2e', () => { await workspaceCardLocator(page, workspaceId).click() await page.getByRole('tab', { name: '网页 2' }).click() await page.getByRole('button', { name: '检查更新' }).click() + await page.getByTestId(importSelectors.webSourceIndexJobProgress).waitFor({ + state: 'visible' + }) + scanProgressVisible = true + scanButtonsDisabled = + (await page.getByRole('button', { name: '检查更新' }).isDisabled()) && + (await page.getByRole('button', { name: '更新索引' }).isDisabled()) await waitForWebIndexJobIdle(page, workspaceId) + const scanResult = await page.evaluate(async (selectedWorkspaceId) => { + return await window.api.webSourceList({ + workspaceId: String(selectedWorkspaceId || '') + }) + }, workspaceId) + scanSummary = scanResult.summary + scanStatuses = scanResult.items.map((item) => item.index_status) } }) @@ -121,6 +148,10 @@ describe('settings workspace web e2e', () => { buttonsDisabled, crawlStepVisible, progressText, + scanProgressVisible, + scanButtonsDisabled, + scanSummary, + scanStatuses, uiSummary, summary: webList.summary, statuses: webList.items.map((item) => item.index_status) @@ -131,9 +162,20 @@ describe('settings workspace web e2e', () => { }, assert: async (result, runtime) => { await runtime.assertThat( - result.progressVisible && - result.buttonsDisabled && - result.crawlStepVisible, + result.scanProgressVisible && + result.scanButtonsDisabled && + result.scanSummary?.needs_index_count === 2 && + result.scanStatuses.every((status) => status === 'needs_index'), + { + name: 'web-scan-refreshes-summary-before-prepare', + message: + '检查更新时应展示进度、禁用两个索引按钮,并把两条网页资料刷新为可索引状态,对齐 SET-WEB-003-AC1、SET-WEB-003-AC2。', + data: result + } + ) + + await runtime.assertThat( + result.progressVisible && result.buttonsDisabled && result.crawlStepVisible, { name: 'prepare-web-progress-is-visible', message: diff --git a/tests/case/settings/workspace/web/web-scan-summary-refresh.e2e.test.ts b/tests/case/settings/workspace/web/web-scan-summary-refresh.e2e.test.ts deleted file mode 100644 index 33f7a738..00000000 --- a/tests/case/settings/workspace/web/web-scan-summary-refresh.e2e.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { describe, it } from 'vitest' -import { importSelectors } from '../../../../../src/renderer/src/testing/selectors' -import { runE2ECase } from '../../../../harness/e2e/run-e2e-case' -import { - SETTINGS_WEB_E2E_TIMEOUT_MS, - createFixtureWorkspace, - navigateToSettingsSection, - seedWebSources, - startLocalSettingsHttpServer, - waitForWebIndexJobIdle, - workspaceCardLocator -} from '../../../../harness/e2e/settings' - -describe('settings workspace web e2e', () => { - it( - 'web-scan-summary-refresh', - async () => { - await runE2ECase({ - caseId: 'web-scan-summary-refresh', - domain: 'settings', - suite: 'workspace', - groups: ['web'], - description: '点击检查更新后,网页列表状态会刷新为可索引,并同步更新摘要统计。', - fixtures: [], - runMode: 'single', - execute: async (runtime) => { - const { page } = runtime - let workspaceId = '' - let progressVisible = false - let buttonsDisabled = false - const httpServer = await startLocalSettingsHttpServer({ - '/scan-summary-refresh': { - body: 'Scan SummaryLocal scan summary fixture.', - delayMs: 800 - } - }) - - try { - await runtime.step({ - name: 'seed-web-source', - message: '预置工作区与一条本地网页资料,进入网页 tab。', - run: async () => { - const workspace = await createFixtureWorkspace(page, { - rootPath: '/tmp/settings-web-scan-summary-refresh', - name: '网页检查工作区' - }) - workspaceId = workspace.workspace!.id - await seedWebSources(page, workspaceId, [httpServer.url('/scan-summary-refresh')]) - await navigateToSettingsSection(page, 'workspace') - await workspaceCardLocator(page, workspaceId).click() - await page.getByRole('tab', { name: '网页 1' }).click() - } - }) - - await runtime.step({ - name: 'scan-web-sources', - message: '点击检查更新,确认网页状态与摘要切换到可索引。', - run: async () => { - await page.getByRole('button', { name: '检查更新' }).click() - await page.getByTestId(importSelectors.webSourceIndexJobProgress).waitFor({ - state: 'visible' - }) - progressVisible = true - buttonsDisabled = - (await page.getByRole('button', { name: '检查更新' }).isDisabled()) && - (await page.getByRole('button', { name: '更新索引' }).isDisabled()) - await waitForWebIndexJobIdle(page, workspaceId) - } - }) - - const webList = await page.evaluate(async (selectedWorkspaceId) => { - return await window.api.webSourceList({ - workspaceId: String(selectedWorkspaceId || '') - }) - }, workspaceId) - - return { - progressVisible, - buttonsDisabled, - summary: webList.summary, - statuses: webList.items.map((item) => item.index_status) - } - } finally { - await httpServer.close() - } - }, - assert: async (result, runtime) => { - await runtime.assertThat(result.progressVisible && result.buttonsDisabled, { - name: 'scan-web-progress-is-visible', - message: '网页检查执行中应展示持久化进度并禁用两个索引操作按钮,对齐 SET-WEB-003-AC1。', - data: result - }) - - await runtime.assertThat( - result.summary.needs_index_count === 1 && result.statuses.includes('needs_index'), - { - name: 'scan-refreshes-web-summary-and-status', - message: - '检查更新成功后,网页摘要中的可索引数和列表项状态都应同步刷新,对齐 SET-WEB-003-AC2。', - data: result - } - ) - } - }) - }, - SETTINGS_WEB_E2E_TIMEOUT_MS - ) -}) diff --git a/tests/case/settings/workspace/web/workspace-detail-contains-web-management.e2e.test.ts b/tests/case/settings/workspace/web/workspace-detail-contains-web-management.e2e.test.ts deleted file mode 100644 index 11a026cb..00000000 --- a/tests/case/settings/workspace/web/workspace-detail-contains-web-management.e2e.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { describe, it } from 'vitest' -import { importSelectors } from '../../../../../src/renderer/src/testing/selectors' -import { runE2ECase } from '../../../../harness/e2e/run-e2e-case' -import { - SETTINGS_WEB_E2E_TIMEOUT_MS, - createFixtureWorkspace, - getSettingsLocator, - navigateToSettingsSection, - seedWebSources, - workspaceCardLocator -} from '../../../../harness/e2e/settings' - -describe('settings workspace web e2e', () => { - it('workspace-detail-contains-web-management', async () => { - await runE2ECase({ - caseId: 'workspace-detail-contains-web-management', - domain: 'settings', - suite: 'workspace', - groups: ['web'], - description: '网页资料管理直接承载在当前工作空间详情区内,并与文件 tab 平级切换。', - fixtures: [], - runMode: 'single', - execute: async (runtime) => { - const { page } = runtime - - await runtime.step({ - name: 'seed-workspace-and-web-sources', - message: '创建工作区与网页资料,进入工作空间详情并切到网页 tab。', - run: async () => { - const workspace = await createFixtureWorkspace(page, { - rootPath: '/tmp/settings-workspace-detail-contains-web-management', - name: '网页资料内嵌工作区' - }) - await seedWebSources(page, workspace.workspace!.id, [ - 'https://example.com/embedded-web-a', - 'https://example.com/embedded-web-b' - ]) - await navigateToSettingsSection(page, 'workspace') - await workspaceCardLocator(page, workspace.workspace!.id).click() - await page.getByRole('tab', { name: '网页 2' }).click() - await getSettingsLocator(page, importSelectors.webSourceSection).waitFor({ - state: 'visible' - }) - await getSettingsLocator(page, importSelectors.webSourceList).waitFor({ - state: 'visible' - }) - } - }) - - return { - detailVisible: await getSettingsLocator(page, importSelectors.workspaceDetail).isVisible(), - webSectionVisible: await getSettingsLocator(page, importSelectors.webSourceSection).isVisible(), - webListVisible: await getSettingsLocator(page, importSelectors.webSourceList).isVisible(), - addButtonVisible: await getSettingsLocator( - page, - importSelectors.webSourceOpenModalButton - ).isVisible(), - currentHash: await page.evaluate(() => window.location.hash) - } - }, - assert: async (result, runtime) => { - await runtime.assertThat( - result.detailVisible && - result.webSectionVisible && - result.webListVisible && - result.addButtonVisible, - { - name: 'workspace-detail-renders-web-management', - message: - '选中工作区后,网页资料管理应直接出现在当前工作空间详情区内,并包含列表与添加入口,对齐 SET-WORKSPACE-005-AC3、SET-WEB-001-AC3。', - data: result - } - ) - - await runtime.assertThat(result.currentHash === '#/settings/workspace', { - name: 'web-management-stays-inside-workspace-route', - message: - '网页资料管理应在工作空间详情内完成,而不是跳转到独立页面,对齐 SET-WORKSPACE-005-AC3。', - data: result - } - ) - } - }) - }, SETTINGS_WEB_E2E_TIMEOUT_MS) -}) diff --git a/tests/case/unit/ai/human-references.test.ts b/tests/case/unit/ai/human-references.test.ts index cdbe9760..44a4d0e1 100644 --- a/tests/case/unit/ai/human-references.test.ts +++ b/tests/case/unit/ai/human-references.test.ts @@ -104,30 +104,4 @@ describe('ai unit human references', () => { expect(inferReferenceIconKind({ resourceType: 'file' })).toBe('file') expect(inferReferenceIconKind({ resourceType: 'web' })).toBe('web') }) - - it('keeps readable unicode in markdown ref links without full URL encoding', () => { - const markdown = buildEvidenceReferenceMarkdown({ - label: '工作/客户周会纪要.docx', - path: '/Users/test/work-space/test/workspace/工作/客户周会纪要.docx', - resourceType: 'file', - usedFor: '了解客户关注点' - }) - - expect(markdown).toContain('label=工作/客户周会纪要.docx') - expect(markdown).toContain('path=/Users/test/work-space/test/workspace/工作/客户周会纪要.docx') - expect(markdown).not.toContain('%E5') - }) - - it('escapes only query-breaking characters in markdown ref links', () => { - const markdown = buildEvidenceReferenceMarkdown({ - label: 'A&B=测试.docx', - path: 'https://example.com/a?x=1&name=测试', - resourceType: 'web', - usedFor: '验证链接参数' - }) - - expect(markdown).toContain('label=A%26B%3D测试.docx') - expect(markdown).toContain('path=https://example.com/a?x%3D1%26name%3D测试') - expect(markdown).not.toContain('%E6') - }) }) diff --git a/tests/case/unit/ai/markdown-reference.test.ts b/tests/case/unit/ai/markdown-reference.test.ts deleted file mode 100644 index d29ecd03..00000000 --- a/tests/case/unit/ai/markdown-reference.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { parseRefHref } from '../../../../src/renderer/src/components/markdownReference' - -describe('ai unit markdown reference helpers', () => { - it('parses ncurator reference href', () => { - expect( - parseRefHref( - 'ncurator://ref?kind=reference&resourceType=file&label=%E6%8A%A5%E5%91%8A.docx&path=%2Ftmp%2Fworkspace%2Freport.docx' - ) - ).toEqual({ - type: 'reference', - kind: 'reference', - resourceType: 'file', - label: '报告.docx', - path: '/tmp/workspace/report.docx' - }) - }) - - it('rejects ncurator reference href values without kind', () => { - expect( - parseRefHref( - 'ncurator://ref?label=%E6%8A%A5%E5%91%8A.docx&path=%2Ftmp%2Fworkspace%2Freport.docx' - ) - ).toEqual({ - type: 'invalid', - href: 'ncurator://ref?label=%E6%8A%A5%E5%91%8A.docx&path=%2Ftmp%2Fworkspace%2Freport.docx', - reason: 'invalid_kind' - }) - }) - - it('infers file resource type when resourceType is missing', () => { - expect( - parseRefHref( - 'ncurator://ref?kind=reference&label=%E6%8A%A5%E5%91%8A.docx&path=%2Ftmp%2Fworkspace%2Freport.docx' - ) - ).toEqual({ - type: 'reference', - kind: 'reference', - resourceType: 'file', - label: '报告.docx', - path: '/tmp/workspace/report.docx' - }) - }) - - it('infers web resource type when resourceType is missing and path is a URL', () => { - expect(parseRefHref('ncurator://ref?kind=normal&label=OpenAI&path=https://openai.com')).toEqual( - { - type: 'reference', - kind: 'normal', - resourceType: 'web', - label: 'OpenAI', - path: 'https://openai.com' - } - ) - }) - - it('passes through normal href values', () => { - expect(parseRefHref('https://openai.com')).toEqual({ - type: 'normal', - href: 'https://openai.com' - }) - }) - - it('parses raw windows ncurator href values from rendered markdown', () => { - const path = - 'D:\\1-work-space\\test\\workspace\\.ncurator\\assets\\abc\\交互式探究学习案例:产业转移.docx' - expect( - parseRefHref( - `ncurator://ref?kind=reference&resourceType=file&label=交互式探究学习案例:产业转移.docx&path=${path}` - ) - ).toEqual({ - type: 'reference', - kind: 'reference', - resourceType: 'file', - label: '交互式探究学习案例:产业转移.docx', - path - }) - }) -}) diff --git a/tests/case/unit/ai/ncurator-ref-codec.test.ts b/tests/case/unit/ai/ncurator-ref-codec.test.ts index a0fd4fc1..2e731841 100644 --- a/tests/case/unit/ai/ncurator-ref-codec.test.ts +++ b/tests/case/unit/ai/ncurator-ref-codec.test.ts @@ -111,6 +111,39 @@ describe('ai unit ncurator ref codec', () => { expect(parseNcuratorRefHref(href)).toEqual({ type: 'invalid', href, reason: 'invalid_kind' }) }) + it('infers file resource type when resourceType is missing', () => { + expect( + parseNcuratorRefHref( + 'ncurator://ref?kind=reference&label=报告.docx&path=/tmp/workspace/report.docx' + ) + ).toEqual({ + type: 'reference', + kind: 'reference', + resourceType: 'file', + label: '报告.docx', + path: '/tmp/workspace/report.docx' + }) + }) + + it('infers web resource type when resourceType is missing and path is a URL', () => { + expect( + parseNcuratorRefHref('ncurator://ref?kind=normal&label=OpenAI&path=https://openai.com') + ).toEqual({ + type: 'reference', + kind: 'normal', + resourceType: 'web', + label: 'OpenAI', + path: 'https://openai.com' + }) + }) + + it('passes through normal href values', () => { + expect(parseNcuratorRefHref('https://openai.com')).toEqual({ + type: 'normal', + href: 'https://openai.com' + }) + }) + it('parses raw windows paths emitted without canonical encoding', () => { const rawPath = 'D:\\1-work-space\\test\\workspace\\.ncurator\\assets\\d6fd02d9-b641-4a73-b047-0f286e2e5072\\交互式探究学习案例:产业转移.docx' diff --git a/tests/case/unit/vector/embedding-runtime-contract.test.ts b/tests/case/unit/vector/embedding-runtime-contract.test.ts index 6ca8a628..145d17a0 100644 --- a/tests/case/unit/vector/embedding-runtime-contract.test.ts +++ b/tests/case/unit/vector/embedding-runtime-contract.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { createEmbeddingRuntimeDispatcher } from '../../../../src/main/services/vector/runtime/embeddingRuntimeProcess' import type { EmbeddingExecutor, + EmbeddingRuntimeRequest, EmbeddingRuntimeResponse } from '../../../../src/main/services/vector/runtime/types' @@ -11,9 +12,14 @@ describe('embedding runtime contract', () => { let maxActiveCalls = 0 const callOrder: string[] = [] const responses: EmbeddingRuntimeResponse[] = [] + let initialized = false const executor: EmbeddingExecutor = { - initialize: async () => {}, + initialize: async () => { + initialized = true + callOrder.push('initialize') + }, embed: async (texts) => { + if (!initialized) throw new Error('not initialized') activeCalls += 1 maxActiveCalls = Math.max(maxActiveCalls, activeCalls) callOrder.push(`start:${texts[0]}`) @@ -31,20 +37,21 @@ describe('embedding runtime contract', () => { postMessage: (response) => responses.push(response) }) - dispatcher.dispatch({ - requestId: 'first', - action: 'embed', - payload: { texts: ['alpha'] } - }) - dispatcher.dispatch({ - requestId: 'second', - action: 'embed', - payload: { texts: ['beta'] } - }) + const requests: EmbeddingRuntimeRequest[] = [ + { requestId: 'initialize', action: 'initialize', payload: null }, + { requestId: 'first', action: 'embed', payload: { texts: ['alpha'] } }, + { requestId: 'second', action: 'embed', payload: { texts: ['beta'] } } + ] + requests.forEach((request) => dispatcher.dispatch(request)) await dispatcher.whenIdle() expect(maxActiveCalls).toBe(1) - expect(callOrder).toEqual(['start:alpha', 'end:alpha', 'start:beta', 'end:beta']) - expect(responses.map((response) => response.requestId)).toEqual(['first', 'second']) + expect(callOrder).toEqual(['initialize', 'start:alpha', 'end:alpha', 'start:beta', 'end:beta']) + expect(responses.map((response) => response.requestId)).toEqual([ + 'initialize', + 'first', + 'second' + ]) + expect(responses.every((response) => response.ok)).toBe(true) }) }) diff --git a/tests/case/unit/workbench/composer-text-selection.test.ts b/tests/case/unit/workbench/composer-text-selection.test.ts new file mode 100644 index 00000000..dd463295 --- /dev/null +++ b/tests/case/unit/workbench/composer-text-selection.test.ts @@ -0,0 +1,145 @@ +import React, { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { JSDOM } from 'jsdom' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { workbenchSelectors } from '../../../../src/renderer/src/testing/selectors' + +vi.mock('antd', () => { + const App = Object.assign(({ children }: { children?: React.ReactNode }) => children, { + useApp: () => ({ + message: { + error: vi.fn(), + warning: vi.fn(), + success: vi.fn() + } + }) + }) + return { + App, + Spin: () => null, + Tooltip: ({ children }: { children?: React.ReactNode }) => children + } +}) + +vi.mock('../../../../src/renderer/src/components/workbench/WorkbenchSelect', () => ({ + default: () => null +})) +vi.mock('../../../../src/renderer/src/components/workbench/WorkbenchModelSelector', () => ({ + default: () => null +})) +vi.mock('../../../../src/renderer/src/components/workbench/composer/ReferenceMentionPanel', () => ({ + default: () => null +})) + +describe('workbench composer text selection', () => { + let dom: JSDOM + let container: HTMLDivElement + let root: Root | undefined + + beforeEach(() => { + dom = new JSDOM('
', { + url: 'http://localhost/' + }) + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + HTMLBRElement: dom.window.HTMLBRElement, + SVGElement: dom.window.SVGElement, + Node: dom.window.Node, + Text: dom.window.Text, + MouseEvent: dom.window.MouseEvent, + File: dom.window.File, + FileReader: dom.window.FileReader, + Blob: dom.window.Blob, + IS_REACT_ACT_ENVIRONMENT: true + }) + Object.assign(dom.window, { + api: { + referenceItemList: vi.fn(async () => ({ items: [] })) + }, + requestAnimationFrame: (callback: FrameRequestCallback) => + dom.window.setTimeout(() => callback(Date.now()), 0), + cancelAnimationFrame: (id: number) => dom.window.clearTimeout(id) + }) + container = dom.window.document.getElementById('root') as HTMLDivElement + root = createRoot(container) + }) + + afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + dom.window.close() + vi.clearAllMocks() + }) + + it('preserves a non-collapsed selection after selection events', async () => { + const { default: WorkbenchComposer } = + await import('../../../../src/renderer/src/components/workbench/WorkbenchComposer') + const draftText = '这是开头,中间这段需要被选中,后面还有文字。' + const selectedText = '中间这段' + + await act(async () => { + root?.render( + React.createElement(WorkbenchComposer, { + mode: 'ai', + onModeChange: vi.fn(), + aiPreference: 'default', + onAiPreferenceChange: vi.fn(), + modelConfigs: [], + modelListsByConfigId: {}, + modelSelection: { mainModel: null, visionModel: null }, + onModelSelectionChange: vi.fn(), + workspaces: [], + selectedWorkspaceId: null, + onWorkspaceChange: vi.fn(), + onDraftChange: vi.fn(), + onSubmit: vi.fn(), + onCancel: vi.fn(), + loading: false, + canSubmit: false, + showCancel: false, + draftValue: { + input: draftText, + contentNodes: [{ kind: 'text', text: draftText }], + selectedReferences: [], + attachments: [] + }, + draftKey: 'draft-1', + requestAttachmentTarget: vi.fn(async () => null) + }) + ) + }) + + const editor = container.querySelector( + `[data-testid="${workbenchSelectors.input}"]` + ) + expect(editor).not.toBeNull() + const textNode = Array.from(editor?.childNodes || []).find( + (node): node is Text => node instanceof Text && node.data.includes(selectedText) + ) + expect(textNode).toBeTruthy() + if (!textNode) return + + const offset = textNode.data.indexOf(selectedText) + const range = document.createRange() + range.setStart(textNode, offset) + range.setEnd(textNode, offset + selectedText.length) + const selection = window.getSelection() + selection?.removeAllRanges() + selection?.addRange(range) + + await act(async () => { + document.dispatchEvent(new dom.window.Event('selectionchange')) + editor?.dispatchEvent(new dom.window.MouseEvent('mouseup', { bubbles: true })) + await new Promise((resolve) => dom.window.setTimeout(resolve, 0)) + }) + + const nextSelection = window.getSelection() + expect(nextSelection?.toString()).toBe(selectedText) + expect(nextSelection?.isCollapsed).toBe(false) + expect(nextSelection?.anchorNode && editor?.contains(nextSelection.anchorNode)).toBe(true) + expect(nextSelection?.focusNode && editor?.contains(nextSelection.focusNode)).toBe(true) + }) +}) diff --git a/tests/case/workbench/conversation/composer/composer-text-selection.e2e.test.ts b/tests/case/workbench/conversation/composer/composer-text-selection.e2e.test.ts deleted file mode 100644 index 927c8fb4..00000000 --- a/tests/case/workbench/conversation/composer/composer-text-selection.e2e.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { describe, it } from 'vitest' -import { runE2ECase } from '../../../../harness/e2e/run-e2e-case' -import { - WORKBENCH_LONG_E2E_TIMEOUT_MS, - assertNoWorkbenchFakeSignals, - getWorkbenchLocator, - setWorkbenchInput, - setupWorkbenchWorkspaceOnly, - workbenchSelectors -} from '../../../../harness/e2e/workbench' - -const CASE_ID = 'composer-text-selection' -const DRAFT_TEXT = '这是开头,中间这段需要被选中,后面还有文字。' -const SELECTED_TEXT = '中间这段' - -describe('workbench conversation e2e', () => { - it( - CASE_ID, - async () => { - await runE2ECase({ - caseId: CASE_ID, - domain: 'workbench', - suite: 'conversation', - groups: ['composer'], - description: '输入框鼠标松开后应保留用户已经框选的文字,不应把光标强制移动到末尾。', - fixtures: ['tests/fixtures/workspace'], - launchMode: 'real', - windowMode: 'visible', - runMode: 'single', - execute: async (runtime) => { - const { page } = runtime - const setup = await setupWorkbenchWorkspaceOnly(page, { - caseId: CASE_ID, - workspaceName: 'Workbench Text Selection Workspace' - }) - try { - await setWorkbenchInput(page, DRAFT_TEXT) - return await getWorkbenchLocator(page, workbenchSelectors.input).evaluate( - (element, selectedText) => { - if (!(element instanceof HTMLElement)) { - throw new Error('workbench input is not an HTMLElement') - } - - const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT) - let selectedNode: Text | null = null - let selectedOffset = -1 - let current = walker.nextNode() - while (current) { - if (current instanceof Text) { - const offset = current.data.indexOf(selectedText) - if (offset >= 0) { - selectedNode = current - selectedOffset = offset - break - } - } - current = walker.nextNode() - } - if (!selectedNode || selectedOffset < 0) { - throw new Error(`selected text not found: ${selectedText}`) - } - - element.focus() - const range = document.createRange() - range.setStart(selectedNode, selectedOffset) - range.setEnd(selectedNode, selectedOffset + selectedText.length) - const selection = window.getSelection() - selection?.removeAllRanges() - selection?.addRange(range) - element.dispatchEvent( - new MouseEvent('mouseup', { - bubbles: true, - cancelable: true, - view: window - }) - ) - - const nextSelection = window.getSelection() - return { - selectedText: nextSelection?.toString() || '', - isCollapsed: nextSelection?.isCollapsed ?? true, - anchorInsideEditor: Boolean( - nextSelection?.anchorNode && element.contains(nextSelection.anchorNode) - ), - focusInsideEditor: Boolean( - nextSelection?.focusNode && element.contains(nextSelection.focusNode) - ) - } - }, - SELECTED_TEXT - ) - } finally { - await setup.workspace.cleanup() - } - }, - assert: async (result, runtime) => { - await runtime.assertThat( - result.selectedText === SELECTED_TEXT && - !result.isCollapsed && - result.anchorInsideEditor && - result.focusInsideEditor, - { - name: 'workbench-composer-preserve-text-selection-after-mouseup', - message: 'Composer 输入框应在 mouseup 后保留用户框选的文字。', - data: result - } - ) - await assertNoWorkbenchFakeSignals(runtime) - } - }) - }, - WORKBENCH_LONG_E2E_TIMEOUT_MS - ) -}) diff --git a/tests/case/workbench/conversation/composer/missing-llm-notification-startup.e2e.test.ts b/tests/case/workbench/conversation/composer/missing-llm-notification-startup.e2e.test.ts deleted file mode 100644 index eb75f0d4..00000000 --- a/tests/case/workbench/conversation/composer/missing-llm-notification-startup.e2e.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, it } from 'vitest' -import { runE2ECase } from '../../../../harness/e2e/run-e2e-case' -import { - WORKBENCH_E2E_TIMEOUT_MS, - getWorkbenchLocator, - switchWorkbenchMode, - workbenchSelectors -} from '../../../../harness/e2e/workbench' - -const CASE_ID = 'missing-llm-notification-startup' - -describe('workbench conversation e2e', () => { - it( - CASE_ID, - async () => { - await runE2ECase({ - caseId: CASE_ID, - domain: 'workbench', - suite: 'conversation', - groups: ['composer'], - description: '无大模型配置时,智能模式首屏应立即提示,搜索模式不提示。', - fixtures: [], - runMode: 'repeatable', - execute: async (runtime) => { - const { page } = runtime - const notification = getWorkbenchLocator( - page, - workbenchSelectors.llmConfigMissingNotification - ) - - const startedAt = Date.now() - await notification.waitFor({ state: 'visible', timeout: 1500 }) - const initialVisibleDelayMs = Date.now() - startedAt - const initialText = ((await notification.textContent()) || '').trim() - - await switchWorkbenchMode(page, '没头脑') - await notification.waitFor({ state: 'hidden' }) - const hiddenInSearchMode = !(await notification.isVisible().catch(() => false)) - - const switchedBackAt = Date.now() - await switchWorkbenchMode(page, '大聪明') - await notification.waitFor({ state: 'visible', timeout: 1500 }) - const modeSwitchVisibleDelayMs = Date.now() - switchedBackAt - - return { - initialVisibleDelayMs, - modeSwitchVisibleDelayMs, - initialText, - hiddenInSearchMode - } - }, - assert: async (result, runtime) => { - await runtime.assertThat( - result.initialVisibleDelayMs < 1500 && - result.modeSwitchVisibleDelayMs < 1500 && - result.initialText.includes('未配置大模型 API') && - result.hiddenInSearchMode, - { - name: 'missing-llm-notification-follows-shared-mode-state', - message: - '无配置时智能模式应快速显示提示,切到搜索模式应隐藏,切回智能模式应再次快速显示。', - data: result - } - ) - } - }) - }, - WORKBENCH_E2E_TIMEOUT_MS - ) -}) diff --git a/tests/case/workbench/conversation/composer/send-guard-mode-switch-and-preferences.e2e.test.ts b/tests/case/workbench/conversation/composer/send-guard-mode-switch-and-preferences.e2e.test.ts index bb1c1a92..2f58e49a 100644 --- a/tests/case/workbench/conversation/composer/send-guard-mode-switch-and-preferences.e2e.test.ts +++ b/tests/case/workbench/conversation/composer/send-guard-mode-switch-and-preferences.e2e.test.ts @@ -43,6 +43,28 @@ describe('workbench conversation e2e', () => { runMode: 'single', execute: async (runtime) => { const { page } = runtime + const missingModelNotification = getWorkbenchLocator( + page, + workbenchSelectors.llmConfigMissingNotification + ) + const missingModelStartedAt = Date.now() + await missingModelNotification.waitFor({ state: 'visible', timeout: 1500 }) + const missingModelInitialDelayMs = Date.now() - missingModelStartedAt + const missingModelInitialText = ( + (await missingModelNotification.textContent()) || '' + ).trim() + + await switchWorkbenchMode(page, '没头脑') + await missingModelNotification.waitFor({ state: 'hidden' }) + const missingModelHiddenInSearch = !(await missingModelNotification + .isVisible() + .catch(() => false)) + + const missingModelReturnedAt = Date.now() + await switchWorkbenchMode(page, '大聪明') + await missingModelNotification.waitFor({ state: 'visible', timeout: 1500 }) + const missingModelReturnDelayMs = Date.now() - missingModelReturnedAt + const env = loadWorkbenchLiveModelEnv() const workspace = await createTemporaryWorkbenchWorkspace(CASE_ID) try { @@ -124,6 +146,10 @@ describe('workbench conversation e2e', () => { recordWorkbenchAiEvidence(runtime, evidence, 0.75) return { + missingModelInitialDelayMs, + missingModelReturnDelayMs, + missingModelInitialText, + missingModelHiddenInSearch, disabledWithoutWorkspace, searchModeInputText, aiModeInputText, @@ -144,6 +170,19 @@ describe('workbench conversation e2e', () => { } }, assert: async (result, runtime) => { + await runtime.assertThat( + result.missingModelInitialDelayMs < 1500 && + result.missingModelReturnDelayMs < 1500 && + result.missingModelInitialText.includes('未配置大模型 API') && + result.missingModelHiddenInSearch, + { + name: 'missing-llm-notification-follows-mode-state', + message: + '没有模型配置时智能模式应快速提示,搜索模式应隐藏提示,切回智能模式后应再次显示。', + data: result + } + ) + await runtime.assertThat( result.disabledWithoutWorkspace && result.searchModeInputText.includes(DRAFT_TEXT) && diff --git a/tests/case/workbench/run/browser/browser-use-deepseek-financing-search.e2e.test.ts b/tests/case/workbench/run/browser/browser-use-deepseek-financing-search.e2e.test.ts deleted file mode 100644 index a7270a26..00000000 --- a/tests/case/workbench/run/browser/browser-use-deepseek-financing-search.e2e.test.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { describe, it } from 'vitest' -import type { AiRunEvent, JsonValue } from '../../../../../src/shared/types' -import { runE2ECase } from '../../../../harness/e2e/run-e2e-case' -import { - WORKBENCH_LIVE_AI_E2E_TIMEOUT_MS, - assertNoWorkbenchFakeSignals, - collectWorkbenchAiRunEvents, - decideWorkbenchApprovalBatch, - recordWorkbenchAiEvidence, - sendWorkbenchMessage, - setupWorkbenchWithTempWorkspace -} from '../../../../harness/e2e/workbench' - -const CASE_ID = 'browser-use-deepseek-financing-search' -const PROMPT = '用浏览器搜索下最近deepseek的融资' -const SEARCH_E2E_TIMEOUT_MS = 300_000 -const DEPRECATED_BROWSER_USE_FIELDS = [ - 'summary', - 'content', - 'pageState', - 'outcome', - 'pageText', - 'selector', - 'cacheStatus' -] - -const isObjectRecord = (value: JsonValue | undefined): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value) - -const readToolInputString = ( - event: Extract, - key: string -): string => { - const input = event.input as JsonValue - if (!isObjectRecord(input)) return '' - const value = input[key] - return typeof value === 'string' ? value : '' -} - -const hasDeprecatedBrowserUseField = ( - value: JsonValue | undefined, - isRoot = true -): boolean => { - if (Array.isArray(value)) return value.some((item) => hasDeprecatedBrowserUseField(item, false)) - if (!isObjectRecord(value)) return false - return Object.entries(value).some(([key, child]) => { - if (isRoot && (key === 'url' || key === 'title' || key === 'success' || key === 'message')) { - return true - } - if (DEPRECATED_BROWSER_USE_FIELDS.includes(key)) return true - return hasDeprecatedBrowserUseField(child, false) - }) -} - -const isBrowserUseDataContract = (data: JsonValue | undefined): boolean => { - if (!isObjectRecord(data)) return false - const action = data.action - if (action !== 'navigate' && action !== 'observe' && action !== 'act' && action !== 'extract') { - return typeof data.error === 'string' - } - if (!isObjectRecord(data.page) || typeof data.page.url !== 'string') return false - if ('title' in data.page && typeof data.page.title !== 'string') return false - if (action === 'navigate') return data.result === null - if (!isObjectRecord(data.result)) return false - if (action === 'extract') return typeof data.result.extracted === 'string' - if (action === 'observe') return Array.isArray(data.result.actions) - return ( - typeof data.result.success === 'boolean' && - typeof data.result.message === 'string' && - typeof data.result.actionDescription === 'string' && - Array.isArray(data.result.actions) - ) -} - -const readBrowserUseResultText = ( - event: Extract -): string => { - const data = event.result?.data - if (!isObjectRecord(data) || !isObjectRecord(data.result)) return '' - const parts: string[] = [] - for (const key of ['extracted', 'message', 'actionDescription']) { - const value = data.result[key] - if (typeof value === 'string' && value.trim()) { - parts.push(value.trim()) - } - } - const actions = data.result.actions - if (Array.isArray(actions)) { - parts.push(JSON.stringify(actions)) - } - return parts.join('\n') -} - -const waitForDeepseekBrowserSearchOutcome = async ( - runtime: Parameters[0], - page: Parameters[0], - startedAtMs: number, - modelName: string -) => { - const deadline = Date.now() + SEARCH_E2E_TIMEOUT_MS - const handledBatchIds = new Set() - - for (;;) { - const events = await collectWorkbenchAiRunEvents(runtime) - const runFailed = events.find((event) => event.type === 'run_failed') - if (runFailed?.type === 'run_failed') { - return { - status: 'failed' as const, - modelName, - durationMs: Date.now() - startedAtMs, - events, - finalAnswerText: '', - failure: runFailed.error - } - } - - const approvalRequired = events - .filter( - (event): event is Extract => - event.type === 'approval_batch_required' - ) - .find((event) => !handledBatchIds.has(event.batchId)) - if (approvalRequired) { - handledBatchIds.add(approvalRequired.batchId) - await decideWorkbenchApprovalBatch(runtime, page, { - event: approvalRequired, - action: 'approve' - }) - continue - } - - const runCompleted = events.find((event) => event.type === 'run_completed') - if (runCompleted?.type === 'run_completed') { - const finalAnswerText = - events - .filter( - (event): event is Extract => - event.type === 'answer_completed' - ) - .map((event) => event.text) - .at(-1) || '' - return { - status: 'completed' as const, - modelName, - durationMs: Date.now() - startedAtMs, - events, - finalAnswerText, - failure: '' - } - } - - if (Date.now() >= deadline) { - return { - status: 'timed_out' as const, - modelName, - durationMs: Date.now() - startedAtMs, - events, - finalAnswerText: '', - failure: 'AI run did not complete before diagnostic timeout' - } - } - - await page.waitForTimeout(500) - } -} - -describe('workbench run e2e', () => { - it( - CASE_ID, - async () => { - await runE2ECase({ - caseId: CASE_ID, - domain: 'workbench', - suite: 'run', - groups: ['browser'], - description: - '真实 AI 使用用户原话“用浏览器搜索下最近deepseek的融资”触发 browser_use,诊断搜索页面导航、抽取和最终回答闭环。', - fixtures: ['tests/fixtures/workspace'], - launchMode: 'real', - windowMode: 'visible', - runMode: 'single', - execute: async (runtime) => { - const { page } = runtime - const setup = await setupWorkbenchWithTempWorkspace(page, { caseId: CASE_ID }) - try { - const startedAtMs = Date.now() - await sendWorkbenchMessage(page, PROMPT) - const evidence = await waitForDeepseekBrowserSearchOutcome( - runtime, - page, - startedAtMs, - setup.env.modelName - ) - recordWorkbenchAiEvidence( - runtime, - { - modelName: evidence.modelName, - durationMs: evidence.durationMs, - events: evidence.events, - finalAnswerText: evidence.finalAnswerText - }, - evidence.status === 'completed' ? 0.8 : 0.35, - evidence.failure - ) - - const toolStarts = evidence.events.filter( - (event): event is Extract => - event.type === 'tool_call_started' - ) - const browserStarts = toolStarts.filter((event) => event.toolName === 'browser_use') - const browserResults = evidence.events.filter( - (event): event is Extract => - event.type === 'tool_call_result' && event.toolName === 'browser_use' - ) - const browserTexts = browserResults.map(readBrowserUseResultText).filter(Boolean) - const browserData = browserResults.map((event) => event.result?.data) - - return { - status: evidence.status, - failure: evidence.failure, - finalAnswerText: evidence.finalAnswerText, - eventTypes: evidence.events.map((event) => event.type), - browserActions: browserStarts.map((event) => readToolInputString(event, 'action')), - browserResultContractValid: - browserData.length > 0 && browserData.every(isBrowserUseDataContract), - deprecatedBrowserUseFieldsAbsent: browserData.every( - (data) => !hasDeprecatedBrowserUseField(data) - ), - browserInstructions: browserStarts.map((event) => - readToolInputString(event, 'instruction') - ), - browserUrls: [ - ...browserStarts.map((event) => readToolInputString(event, 'url')), - ...browserResults - .map((event) => { - const data = event.result?.data - return isObjectRecord(data) && - isObjectRecord(data.page) && - typeof data.page.url === 'string' - ? data.page.url - : '' - }) - .filter(Boolean) - ], - browserResultTexts: browserTexts.slice(-6), - hasExtractedBrowserText: browserData.some( - (data) => - isObjectRecord(data) && - data.action === 'extract' && - isObjectRecord(data.result) && - typeof data.result.extracted === 'string' && - data.result.extracted.trim().length > 0 - ), - browserErrors: browserResults.map((event) => event.error || '').filter(Boolean) - } - } finally { - await setup.workspace.cleanup() - } - }, - assert: async (result, runtime) => { - await runtime.assertThat( - result.status === 'completed' && - result.eventTypes.includes('run_completed') && - result.browserActions.length > 0 && - result.browserActions.some((action) => action === 'navigate') && - result.browserActions.some((action) => action === 'observe' || action === 'act') && - result.browserActions.some((action) => action === 'extract') && - result.browserResultContractValid && - result.deprecatedBrowserUseFieldsAbsent && - result.hasExtractedBrowserText && - result.browserUrls.some((url) => /baidu|bing|google|sogou|so\.com/i.test(url)) && - /deepseek/i.test(result.finalAnswerText) && - /融资|投资|估值|fund|financ/i.test(result.finalAnswerText) && - result.browserErrors.length === 0, - { - name: 'workbench-browser-use-deepseek-financing-search', - message: - '真实 e2e 应通过 browser_use 完成搜索页导航/读取,并基于搜索结果回答 DeepSeek 最近融资信息。', - data: result - } - ) - await assertNoWorkbenchFakeSignals(runtime) - } - }) - }, - WORKBENCH_LIVE_AI_E2E_TIMEOUT_MS - ) -}) diff --git a/tests/case/workbench/run/persistence/chat-run-service-contract.test.ts b/tests/case/workbench/run/persistence/chat-run-service-contract.test.ts index a37f8e35..cde98487 100644 --- a/tests/case/workbench/run/persistence/chat-run-service-contract.test.ts +++ b/tests/case/workbench/run/persistence/chat-run-service-contract.test.ts @@ -161,8 +161,6 @@ afterEach(async () => { }) describe('workbench chat run backend', () => { - const performanceIt = process.env.NCURATOR_RUN_SQLITE_CHAT_PERF === '1' ? it : it.skip - it('chat-submit-ai-creates-placeholder-and-run', async () => { const setup = await createTestService() const run = await submitAiRun(setup) @@ -760,175 +758,4 @@ describe('workbench chat run backend', () => { expect(storedRun).toBeNull() expect(events).toEqual([]) }) - - performanceIt('sqlite-chat-session-open-performance-shape', async () => { - const setup = await createTestService() - const startedAt = Date.now() - const baseTime = Date.now() - 1_000_000 - const sessionRows: Array> = [] - const messageRows: Array> = [] - const runRows: Array> = [] - const eventRows: Array> = [] - - for (let sessionIndex = 0; sessionIndex < 100; sessionIndex += 1) { - const sessionId = `perf-session-${sessionIndex}` - sessionRows.push({ - id: sessionId, - title: `性能会话 ${sessionIndex}`, - created_at: baseTime + sessionIndex, - workspace_id: 'workspace-1' - }) - for (let messageIndex = 0; messageIndex < 20; messageIndex += 1) { - messageRows.push({ - id: `${sessionId}-message-${messageIndex}`, - session_id: sessionId, - role: messageIndex % 2 === 0 ? 'user' : 'assistant', - content: `message ${messageIndex}`, - timestamp: baseTime + sessionIndex * 10_000 + messageIndex - }) - } - for (let runIndex = 0; runIndex < 5; runIndex += 1) { - const runId = `${sessionId}-run-${runIndex}` - const events: AiRunEvent[] = Array.from({ length: 200 }, (_, eventIndex) => ({ - type: 'assistant_process_thought', - runId, - thoughtId: `${runId}-thought-${eventIndex}`, - text: `thought ${eventIndex}`, - createdAt: baseTime + eventIndex - })) - runRows.push({ - id: runId, - session_id: sessionId, - user_message_id: `${sessionId}-message-0`, - assistant_message_id: `${sessionId}-message-1`, - status: 'completed', - input: 'perf', - partial_answer: 'done', - final_answer: 'done', - selected_references_json: null, - attachments_json: null, - workspace_json: null, - model_name: null, - error: null, - pending_approval_json: null, - artifacts_json: null, - event_count: events.length, - visible_event_count: events.length, - last_event_at: baseTime + events.length, - created_at: baseTime + runIndex, - updated_at: baseTime + runIndex, - finished_at: baseTime + runIndex - }) - eventRows.push( - ...events.map((event, eventIndex) => ({ - id: `${runId}-event-${eventIndex}`, - run_id: runId, - session_id: sessionId, - seq: eventIndex + 1, - type: event.type, - event_json: JSON.stringify(event), - created_at: baseTime + eventIndex - })) - ) - } - } - - setup.storage.sqlite.transaction(() => { - const insertSession = setup.storage.sqlite.prepare( - ` - INSERT INTO chat_session (id, title, created_at, updated_at, workspace_id) - VALUES (@id, @title, @created_at, @created_at, @workspace_id) - ` - ) - const insertMessage = setup.storage.sqlite.prepare( - ` - INSERT INTO chat_message ( - id, session_id, seq, role, content, created_at - ) VALUES ( - @id, @session_id, @seq, @role, @content, @created_at - ) - ` - ) - const insertRun = setup.storage.sqlite.prepare( - ` - INSERT INTO ai_run ( - id, session_id, user_message_id, assistant_message_id, status, input, - selected_references_json, attachments_json, workspace_json, model_name, - partial_answer, final_answer, error, pending_approval_json, artifacts_json, - event_count, visible_event_count, last_event_at, created_at, updated_at, finished_at - ) VALUES ( - @id, @session_id, @user_message_id, @assistant_message_id, @status, @input, - @selected_references_json, @attachments_json, @workspace_json, @model_name, - @partial_answer, @final_answer, @error, @pending_approval_json, @artifacts_json, - @event_count, @visible_event_count, @last_event_at, @created_at, @updated_at, @finished_at - ) - ` - ) - const insertEvent = setup.storage.sqlite.prepare( - ` - INSERT INTO ai_run_event (id, run_id, session_id, seq, type, event_json, created_at) - VALUES (@id, @run_id, @session_id, @seq, @type, @event_json, @created_at) - ` - ) - - for (const row of sessionRows) insertSession.run(row) - for (const [index, row] of messageRows.entries()) { - insertMessage.run({ - ...row, - seq: (index % 20) + 1, - created_at: row.timestamp - }) - } - for (const row of runRows) insertRun.run(row) - for (const row of eventRows) insertEvent.run(row) - }) - - const extremeSessionId = 'perf-session-0' - const extremeRunId = `${extremeSessionId}-extreme-token-run` - await setup.storage.aiRun.saveRun({ - id: extremeRunId, - sessionId: extremeSessionId, - userMessageId: `${extremeSessionId}-message-0`, - assistantMessageId: `${extremeSessionId}-message-1`, - status: 'completed', - input: 'extreme', - partialAnswer: 'done', - finalAnswer: 'done', - processEvents: [], - eventCount: 3000, - visibleEventCount: 0, - createdAt: baseTime, - updatedAt: baseTime, - finishedAt: baseTime - }) - await setup.storage.aiRun.appendEvents({ - runId: extremeRunId, - sessionId: extremeSessionId, - events: Array.from({ length: 3000 }, (_, index) => ({ - type: 'answer_token', - runId: extremeRunId, - token: String(index) - })) - }) - - const snapshotStartedAt = Date.now() - const snapshot = await setup.service.getSessionSnapshot(extremeSessionId) - const snapshotDurationMs = Date.now() - snapshotStartedAt - - const openStartedAt = Date.now() - const opened = await setup.service.openSession({ sessionId: extremeSessionId, limit: 50 }) - const openDurationMs = Date.now() - openStartedAt - - console.info('[SQLITE-CHAT-PERF]', { - seedDurationMs: snapshotStartedAt - startedAt, - snapshotDurationMs, - openDurationMs, - messages: opened.messages.length, - snapshotRuns: snapshot.runs?.length || 0, - activeRuns: opened.activeRuns.length, - eventRows: eventRows.length - }) - expect(opened.messages).toHaveLength(20) - expect(openDurationMs).toBeLessThan(300) - }) }) diff --git a/tests/harness/backend/aiLiveTestData.ts b/tests/harness/backend/aiLiveTestData.ts index ffb523e4..d640fe4c 100644 --- a/tests/harness/backend/aiLiveTestData.ts +++ b/tests/harness/backend/aiLiveTestData.ts @@ -3,38 +3,8 @@ import path from 'node:path' import type { SelectedReference } from '../../../src/shared/types' import type { BackendTestRuntime, WorkspaceKbSeedResult } from './test-types' -export const HUMAN_RISK_REPORT_DRAFT_USER_INPUT = - '帮我写下这周的工作周报文件,基于我工作目录下的所有文件信息,记得参考我上周写的周报风格来哦' - export const SHARED_WORKSPACE_FIXTURE_PATH = 'tests/fixtures/workspace' as const const WORKSPACE_LEARNING_FIXTURE_DIR = `${SHARED_WORKSPACE_FIXTURE_PATH}/学习笔记` -const WORKSPACE_WORK_FIXTURE_DIR = `${SHARED_WORKSPACE_FIXTURE_PATH}/工作` - -export const RISK_REPORT_LAST_WEEK_REPORT_FIXTURE_PATH = - `${WORKSPACE_WORK_FIXTURE_DIR}/上周周报-已发版.docx` as const -export const RISK_REPORT_CLIENT_MEETING_FIXTURE_PATH = - `${WORKSPACE_WORK_FIXTURE_DIR}/客户周会纪要.docx` as const -export const RISK_REPORT_DELIVERY_SYNC_FIXTURE_PATH = - `${WORKSPACE_WORK_FIXTURE_DIR}/交付合规同步记录.docx` as const -export const RISK_REPORT_VENDOR_UPDATE_FIXTURE_PATH = - `${WORKSPACE_WORK_FIXTURE_DIR}/供应商接口补件说明.docx` as const -export const RISK_REPORT_LEGAL_REVIEW_FIXTURE_PATH = - `${WORKSPACE_WORK_FIXTURE_DIR}/法务复核意见.docx` as const -export const RISK_REPORT_TEAM_STANDUP_FIXTURE_PATH = - `${WORKSPACE_WORK_FIXTURE_DIR}/团队站会记录.docx` as const -export const RISK_REPORT_Q2_REVIEW_FIXTURE_PATH = - `${WORKSPACE_WORK_FIXTURE_DIR}/Q2阶段复盘.docx` as const -export const RISK_REPORT_STYLE_NOTE_FIXTURE_PATH = - `${WORKSPACE_WORK_FIXTURE_DIR}/对外口径确认.txt` as const -export const RISK_REPORT_FINANCE_NOTE_FIXTURE_PATH = - `${WORKSPACE_WORK_FIXTURE_DIR}/财务与付款材料跟进.txt` as const -export const RISK_REPORT_SCHEDULE_NOTE_FIXTURE_PATH = - `${WORKSPACE_WORK_FIXTURE_DIR}/本周排期更新.txt` as const -export const RISK_REPORT_TEMP_NOTE_FIXTURE_PATH = - `${WORKSPACE_WORK_FIXTURE_DIR}/临时备注.txt` as const - -const toWorkspaceRelativePath = (runtime: BackendTestRuntime, absolutePath: string): string => - path.relative(runtime.workspaceRoot, absolutePath).split(path.sep).join('/') export const DOCX_READING_FIXTURE_PATHS = [ `${WORKSPACE_LEARNING_FIXTURE_DIR}/深度学习.docx`, @@ -50,54 +20,24 @@ export const DOCX_MULTI_EDIT_FIXTURE_PATHS = [ `${SHARED_WORKSPACE_FIXTURE_PATH}/交互式探究学习案例:产业转移.docx` ] as const -export const DOCX_CROSS_SOURCE_SUMMARY_FIXTURE_PATHS = DOCX_READING_FIXTURE_PATHS - -export const DOCX_CROSS_SOURCE_SUMMARY_REPORT_RELATIVE_PATH = '深度学习与图形学复习总结.docx' - export const DOCX_ALL_FIXTURE_PATHS = [ ...DOCX_READING_FIXTURE_PATHS, ...DOCX_MULTI_EDIT_FIXTURE_PATHS, `${SHARED_WORKSPACE_FIXTURE_PATH}/sample-edit.docx` ] as const -export const HUMAN_DOCX_CAUSE_REMEDY_READING_USER_INPUT = - '我这两天复习的时候老把这两个概念混了。你就按我刚选的这两份课件,帮我讲清楚:过拟合和反走样分别是什么、为什么会出现、一般怎么处理。别自己补课外内容,哪份资料没写你就直接告诉我没写。' - -export const HUMAN_DOCX_CROSS_SOURCE_SUMMARY_USER_INPUT = - '帮我基于我刚选的这两个文件,生成一个给同学复习用的总结文件。按照关键概念、出现原因和常见处理方式整理清楚。' - export const HUMAN_DOCX_POSITIONED_STRUCTURAL_EDIT_FALLBACK_USER_INPUT = '我准备把这份图形学讲义发给同学复习,想把“反走样”这一节补得更容易记一些。你直接在这一节后面加一段更通俗的说明,再加一个很短的总结表,帮助大家快速区分两种常见做法。' export const HUMAN_KB_GUIDED_DOCX_COMPARISON_DRAFT_USER_INPUT = '我工作区里课件和教学材料有点多,我忘了哪两份课件专门讲过反走样和过拟合。你先把相关资料找出来看一遍,只能依据工作区里实际写到的内容,再把适合老师讲“概念区分”的内容分别补到课堂 PRD 和交互式案例里,写法尽量像备课备注和课堂引导语,不要像教材摘抄。' -export const HUMAN_KB_GUIDED_DOCX_READING_USER_INPUT = - '我工作区里应该有两份课件分别讲过过拟合和反走样,但我忘了文件名。你先帮我找出来,再告诉我这两个问题一般怎么处理。' - export const buildHumanWebCrawlMultiHopCrossDomainUserInput = (entryUrl: string): string => `帮我基于这个网站整理杭州灵活就业补贴的申请说明:${entryUrl}。我需要知道谁可以申请、需要哪些材料、截止时间和线上办理方式。` export const HUMAN_WEB_CRAWL_URL_INFERENCE_USER_INPUT = '帮我基于 pandas 官网文档分别看下 read_csv 和 DataFrame.to_csv 这两个条目,说明它们分别是做什么的,以及最直接的使用区别。不要用终端命令,也不要打开浏览器操作。' -type RiskReportTrackedFile = { - workspacePath: string - relativePath: string - bytes: Uint8Array -} - -export type RiskReportWorkspaceSeed = { - lastWeekReportRelativePath: string - expectedRiskConclusion: string - expectedOwnerSummary: string - expectedNextStep: string - relevantDocxRelativePaths: string[] - relevantTxtRelativePaths: string[] - preexistingDocxRelativePaths: string[] - trackedDocFiles: RiskReportTrackedFile[] -} - export type SelectedDocxFixture = { sourcePath: string workspacePath: string @@ -180,112 +120,3 @@ export const buildKbGuidedDocxWorkspaceSeed = async ( targetDocs: resolveSelectedDocxFixtures(runtime, DOCX_MULTI_EDIT_FIXTURE_PATHS), trackedDocs: await captureDocxFixtureBytes(runtime, DOCX_ALL_FIXTURE_PATHS) }) - -const captureWorkspaceFiles = async ( - runtime: BackendTestRuntime, - fixturePaths: readonly string[] -): Promise => - await Promise.all( - fixturePaths.map(async (fixturePath) => { - const workspacePath = runtime.getFixtureWorkspacePath(fixturePath) - return { - workspacePath, - relativePath: toWorkspaceRelativePath(runtime, workspacePath), - bytes: await fs.readFile(workspacePath) - } - }) - ) - -export const loadRiskReportWorkspaceSeed = async ( - runtime: BackendTestRuntime -): Promise => { - const expectedRiskConclusion = '在最终合规签署完成前,当前风险状态仍需保持跟进。' - const expectedOwnerSummary = '最终复核窗口由合规负责人 Alice 牵头。' - const expectedNextStep = '财务接口人 Bob 本周内补齐付款材料,并在复核前完成附件对齐。' - const lastWeekReportRelativePath = toWorkspaceRelativePath( - runtime, - runtime.getFixtureWorkspacePath(RISK_REPORT_LAST_WEEK_REPORT_FIXTURE_PATH) - ) - const clientMeetingRelativePath = toWorkspaceRelativePath( - runtime, - runtime.getFixtureWorkspacePath(RISK_REPORT_CLIENT_MEETING_FIXTURE_PATH) - ) - const deliverySyncRelativePath = toWorkspaceRelativePath( - runtime, - runtime.getFixtureWorkspacePath(RISK_REPORT_DELIVERY_SYNC_FIXTURE_PATH) - ) - const vendorUpdateRelativePath = toWorkspaceRelativePath( - runtime, - runtime.getFixtureWorkspacePath(RISK_REPORT_VENDOR_UPDATE_FIXTURE_PATH) - ) - const legalReviewRelativePath = toWorkspaceRelativePath( - runtime, - runtime.getFixtureWorkspacePath(RISK_REPORT_LEGAL_REVIEW_FIXTURE_PATH) - ) - const styleNoteRelativePath = toWorkspaceRelativePath( - runtime, - runtime.getFixtureWorkspacePath(RISK_REPORT_STYLE_NOTE_FIXTURE_PATH) - ) - const financeNoteRelativePath = toWorkspaceRelativePath( - runtime, - runtime.getFixtureWorkspacePath(RISK_REPORT_FINANCE_NOTE_FIXTURE_PATH) - ) - const scheduleNoteRelativePath = toWorkspaceRelativePath( - runtime, - runtime.getFixtureWorkspacePath(RISK_REPORT_SCHEDULE_NOTE_FIXTURE_PATH) - ) - const teamStandupRelativePath = toWorkspaceRelativePath( - runtime, - runtime.getFixtureWorkspacePath(RISK_REPORT_TEAM_STANDUP_FIXTURE_PATH) - ) - const q2ReviewRelativePath = toWorkspaceRelativePath( - runtime, - runtime.getFixtureWorkspacePath(RISK_REPORT_Q2_REVIEW_FIXTURE_PATH) - ) - const preexistingDocxRelativePaths = [ - lastWeekReportRelativePath, - clientMeetingRelativePath, - deliverySyncRelativePath, - vendorUpdateRelativePath, - legalReviewRelativePath, - teamStandupRelativePath, - q2ReviewRelativePath - ] - const trackedDocFiles = await captureWorkspaceFiles(runtime, [ - RISK_REPORT_LAST_WEEK_REPORT_FIXTURE_PATH, - RISK_REPORT_CLIENT_MEETING_FIXTURE_PATH, - RISK_REPORT_DELIVERY_SYNC_FIXTURE_PATH, - RISK_REPORT_VENDOR_UPDATE_FIXTURE_PATH, - RISK_REPORT_LEGAL_REVIEW_FIXTURE_PATH, - RISK_REPORT_TEAM_STANDUP_FIXTURE_PATH, - RISK_REPORT_Q2_REVIEW_FIXTURE_PATH - ]) - - return { - lastWeekReportRelativePath, - expectedRiskConclusion, - expectedOwnerSummary, - expectedNextStep, - relevantDocxRelativePaths: [ - clientMeetingRelativePath, - deliverySyncRelativePath, - vendorUpdateRelativePath, - legalReviewRelativePath - ], - relevantTxtRelativePaths: [ - styleNoteRelativePath, - financeNoteRelativePath, - scheduleNoteRelativePath - ], - preexistingDocxRelativePaths, - trackedDocFiles - } -} - -export const hasUnchangedFileBytes = async ( - filePath: string, - expectedBytes: Uint8Array -): Promise => { - const currentBytes = await fs.readFile(filePath) - return Buffer.compare(currentBytes, Buffer.from(expectedBytes)) === 0 -} diff --git a/tests/harness/backend/aiToolHarness.ts b/tests/harness/backend/aiToolHarness.ts index 89a93664..c32e03cc 100644 --- a/tests/harness/backend/aiToolHarness.ts +++ b/tests/harness/backend/aiToolHarness.ts @@ -372,31 +372,3 @@ export const writeTestDocxFile = async ( blocks.map((block) => toRichBlock(block)) ) } - -export const writeWorkbenchSampleDocx = async ( - workspaceRoot: string, - fileName: string -): Promise => { - return await writeTestDocxFile(workspaceRoot, fileName, [ - { - type: 'heading', - text: 'NCurator Weekly Report', - level: 1 - }, - { - type: 'paragraph', - text: 'Hello team. Project status is on track.' - }, - { - type: 'paragraph', - text: 'Risk summary: monitor integration edge cases.' - }, - { - type: 'table', - rows: [ - ['Topic', 'Owner'], - ['Risk', 'Alice'] - ] - } - ]) -} diff --git a/tests/harness/backend/llmConfig.ts b/tests/harness/backend/llmConfig.ts index 3ceeb8f7..faf866b9 100644 --- a/tests/harness/backend/llmConfig.ts +++ b/tests/harness/backend/llmConfig.ts @@ -1,7 +1,7 @@ import type { LiveLlmConfig } from './test-types' import { loadRootEnvOnce } from '../core/rootEnv' import { normalizeOpenAIBaseUrl } from '../../../src/shared/llmBaseUrl' -import { resolveLlmProviderKind } from '../../../src/shared/llmProvider' +import { inferLlmProviderKind, resolveLlmProviderKind } from '../../../src/shared/llmProvider' const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1' const DEFAULT_PRIMARY_MODEL_NAME = 'gpt-5' @@ -59,7 +59,7 @@ export const loadLiveLlmConfigFromEnv = (): LiveLlmConfig => { id: 'ai-live-config', name: 'AI Live', providerKind: resolveLlmProviderKind({ - providerKind: readEnv('AI_LIVE_PROVIDER_KIND'), + providerKind: readEnv('AI_LIVE_PROVIDER_KIND') || inferLlmProviderKind(baseUrl), baseUrl }), base_url: baseUrl, diff --git a/tests/harness/core/cache-summary.ts b/tests/harness/core/cache-summary.ts index e0bca0c5..d0dafa21 100644 --- a/tests/harness/core/cache-summary.ts +++ b/tests/harness/core/cache-summary.ts @@ -1,4 +1,4 @@ -import type { AiLlmIoRecord, JsonObject } from '../../../src/shared/types' +import type { AiLlmIoRecord } from '../../../src/shared/types' export type CacheSummaryBucket = { calls: number @@ -82,6 +82,3 @@ export const buildCacheSummary = (records: readonly AiLlmIoRecord[]): CacheSumma ) } } - -export const cacheSummaryToJson = (summary: CacheSummary): JsonObject => - JSON.parse(JSON.stringify(summary)) as JsonObject diff --git a/tests/harness/e2e/browserLlmConfig.ts b/tests/harness/e2e/browserLlmConfig.ts deleted file mode 100644 index 0776f885..00000000 --- a/tests/harness/e2e/browserLlmConfig.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { LlmRuntimeConfig } from '../../../src/shared/types' -import { normalizeOpenAIBaseUrl } from '../../../src/shared/llmBaseUrl' -import { resolveLlmProviderKind } from '../../../src/shared/llmProvider' -import { loadRootEnvOnce } from '../core/rootEnv' - -const readEnv = (name: string): string => { - loadRootEnvOnce() - return String(process.env[name] || '').trim() -} - -const resolveBrowserE2EModelName = (): string => readEnv('AI_LIVE_PRIMARY_MODEL_NAME') - -const resolveBrowserE2EBaseUrl = (): string => { - return normalizeOpenAIBaseUrl(readEnv('AI_LIVE_BASE_URL')) -} - -export const hasBrowserE2ELlmConfigInEnv = (): boolean => { - const apiKey = readEnv('AI_LIVE_API_KEY') - return Boolean(resolveBrowserE2EBaseUrl() && resolveBrowserE2EModelName() && apiKey) -} - -export const loadBrowserE2ELlmConfigFromEnv = (): LlmRuntimeConfig => { - const baseUrl = resolveBrowserE2EBaseUrl() - const modelName = resolveBrowserE2EModelName() - const apiKey = readEnv('AI_LIVE_API_KEY') - - if (!baseUrl || !modelName || !apiKey) { - throw new Error( - [ - 'browser e2e 缺少网页操作模型配置,请设置环境变量:', - '- AI_LIVE_BASE_URL', - '- AI_LIVE_API_KEY', - '- AI_LIVE_PRIMARY_MODEL_NAME', - '可选配置:', - '- AI_LIVE_PROVIDER_KIND(默认按 base URL 推断)' - ].join('\n') - ) - } - - return { - id: 'browser-e2e-config', - name: 'Browser E2E', - providerKind: resolveLlmProviderKind({ - providerKind: readEnv('AI_LIVE_PROVIDER_KIND'), - baseUrl - }), - base_url: baseUrl, - api_key: apiKey, - model_name: modelName - } -} diff --git a/tests/harness/e2e/settings.ts b/tests/harness/e2e/settings.ts index cd45a5f6..cf00f97c 100644 --- a/tests/harness/e2e/settings.ts +++ b/tests/harness/e2e/settings.ts @@ -17,7 +17,7 @@ import { vectorSelectors } from '../../../src/renderer/src/testing/selectors' -export type SettingsPageTarget = 'model' | 'workspace' | 'vector' +export type SettingsPageTarget = 'model' | 'workspace' | 'vector' | 'app' export const SETTINGS_E2E_TIMEOUT_MS = 30_000 export const SETTINGS_REAL_WORKSPACE_E2E_TIMEOUT_MS = 180_000 @@ -135,6 +135,17 @@ export type LocalSettingsHttpServer = { close: () => Promise } +type LocalSettingsHttpResponse = { + status?: number + body: string + contentType?: string + delayMs?: number +} + +type LocalSettingsHttpRoute = LocalSettingsHttpResponse & { + responses?: readonly LocalSettingsHttpResponse[] +} + const byTestId = (testId: string): string => `[data-testid="${testId}"]` const byTestIdPrefix = (testIdPrefix: string): string => `[data-testid^="${testIdPrefix}-"]` @@ -142,19 +153,22 @@ const byTestIdPrefix = (testIdPrefix: string): string => `[data-testid^="${testI const SETTINGS_TARGET_HEADINGS: Record = { model: '大模型', workspace: '工作空间', - vector: '向量模型' + vector: '向量模型', + app: '应用更新' } const SETTINGS_TARGET_HASHES: Record = { model: '#/settings/model', workspace: '#/settings/workspace', - vector: '#/model-download' + vector: '#/model-download', + app: '#/settings/app' } const SETTINGS_TARGET_SELECTORS: Record = { model: settingsSelectors.page, workspace: settingsSelectors.page, - vector: vectorSelectors.page + vector: vectorSelectors.page, + app: settingsSelectors.appUpdatePanel } const closeServer = async (server: Server): Promise => { @@ -167,8 +181,9 @@ const closeServer = async (server: Server): Promise => { } export const startLocalSettingsHttpServer = async ( - routes: Record + routes: Record ): Promise => { + const requestCounts = new Map() const server = createServer((request, response) => { const pathname = new URL(request.url || '/', 'http://127.0.0.1').pathname const route = routes[pathname] || routes['/'] @@ -177,15 +192,20 @@ export const startLocalSettingsHttpServer = async ( response.end('not found') return } + const requestCount = requestCounts.get(pathname) || 0 + requestCounts.set(pathname, requestCount + 1) + const routeResponse = route.responses?.length + ? route.responses[Math.min(requestCount, route.responses.length - 1)] + : route const respond = (): void => { - response.writeHead(route.status || 200, { - 'content-type': route.contentType || 'text/html; charset=utf-8', + response.writeHead(routeResponse.status || 200, { + 'content-type': routeResponse.contentType || 'text/html; charset=utf-8', 'cache-control': 'no-store' }) - response.end(route.body) + response.end(routeResponse.body) } - if (route.delayMs && route.delayMs > 0) { - setTimeout(respond, route.delayMs) + if (routeResponse.delayMs && routeResponse.delayMs > 0) { + setTimeout(respond, routeResponse.delayMs) return } respond() @@ -311,12 +331,6 @@ export const navigateToSettingsSection = async ( }) } -export const waitForAntMessage = async (page: Page, text: string): Promise => { - await page.locator('.ant-message').getByText(text).last().waitFor({ - state: 'visible' - }) -} - export const createModelConfigThroughSettingsUi = async ( page: Page, input: { name?: string; baseUrl: string; apiKey: string } @@ -567,6 +581,3 @@ export const waitForWebIndexJobIdle = async ( export const workspaceCardLocator = (page: Page, workspaceId: string): Locator => getSettingsLocator(page, `${importSelectors.workspaceCardPrefix}-${workspaceId}`) - -export const webSourceCardLocator = (page: Page, webSourceId: string): Locator => - getSettingsLocator(page, `${importSelectors.webSourceCardPrefix}-${webSourceId}`) diff --git a/tests/harness/e2e/workbench.ts b/tests/harness/e2e/workbench.ts index 36fb9a2e..39b28c53 100644 --- a/tests/harness/e2e/workbench.ts +++ b/tests/harness/e2e/workbench.ts @@ -1383,15 +1383,6 @@ export const removeFirstWorkbenchReferenceToken = async (page: Page): Promise => { - const locator = getWorkbenchPrefixLocator( - page, - workbenchSelectors.assistantFinalAnswerPrefix - ).last() - await locator.waitFor({ state: 'visible' }) - return locator -} - export const waitForLatestAssistantProcess = async (page: Page): Promise => { const locator = getWorkbenchPrefixLocator(page, workbenchSelectors.assistantProcessPrefix).last() await locator.waitFor({ state: 'visible' })