diff --git a/.codex/reasonix-tasks/2026-05-21-batch-a3-worker-a-empty-trash.md b/.codex/reasonix-tasks/2026-05-21-batch-a3-worker-a-empty-trash.md new file mode 100644 index 00000000..b62fd7ea --- /dev/null +++ b/.codex/reasonix-tasks/2026-05-21-batch-a3-worker-a-empty-trash.md @@ -0,0 +1,53 @@ +# Reasonix Batch A3 Worker A:local folder empty-trash UI smoke + +Project root: `/mnt/Data1T/mnote` + +## 目标 + +补齐 `4-34` 中 local folder empty-trash UI 证据:本地文件夹垃圾箱页面能分别清空页面垃圾箱和资源垃圾箱,且不触发非预期浏览器导航。 + +## Ownership + +允许修改: + +- 新增 `scripts/task474-local-folder-empty-trash-ui-smoke.js` +- `design/04-tree-domain/process/4-34-filetree-trash-dual-browser-no-refresh-v1.md` +- `.codex/reasonix-tasks/results/batch-a3-worker-a-empty-trash.md` + +禁止: + +- 不修改 Rust 源码。 +- 不修改 `4-46` 或移动任何 design 文件。 +- 不提交 git。 +- 不把 reload 型刷新标为 no-refresh。 + +## 要求 + +1. 新增 browser smoke,参考 `task464` / `task473` 的登录、local root、trash workbench 写法。 +2. 创建临时 local folder root: + - 至少一个 Markdown 页面进入本地垃圾箱。 + - 至少一个非 md 资源进入本地垃圾箱。 +3. 打开 `/trash?sourceKind=local_folder&rootUri=file://...`。 +4. 点击“清空页面垃圾箱”,接受 confirm,断言页面类 trash row 消失、trash index 对应 entry 清理、trash 文件/目录不存在。 +5. 点击“清空资源垃圾箱”,接受 confirm,断言资源类 trash row 消失、trash index 对应 entry 清理、trash 文件不存在。 +6. 记录 main frame navigation delta,清空按钮操作不得触发非预期导航。 +7. 结果写入 `tmp/task474-local-folder-empty-trash-ui-smoke/result.json`。 +8. 更新 `4-34` 对 local folder empty-trash 覆盖状态,但不要移动文件。 + +## 验收命令 + +```bash +node --check scripts/task474-local-folder-empty-trash-ui-smoke.js +PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/google-chrome-stable node scripts/task474-local-folder-empty-trash-ui-smoke.js +``` + +如果 3000 不是最新,可只做 `node --check` 并说明需要 Codex 用临时端口复跑。 + +## 最终输出 + +写入 `.codex/reasonix-tasks/results/batch-a3-worker-a-empty-trash.md`,包含: + +- 修改文件 +- smoke 覆盖内容 +- 验证命令和结果 +- 未完成项 / 风险 diff --git a/.codex/reasonix-tasks/2026-05-21-batch-a3-worker-b-markdown-lifecycle.md b/.codex/reasonix-tasks/2026-05-21-batch-a3-worker-b-markdown-lifecycle.md new file mode 100644 index 00000000..262fd9ad --- /dev/null +++ b/.codex/reasonix-tasks/2026-05-21-batch-a3-worker-b-markdown-lifecycle.md @@ -0,0 +1,51 @@ +# Reasonix Batch A3 Worker B:local folder Markdown trash lifecycle smoke + +Project root: `/mnt/Data1T/mnote` + +## 目标 + +补齐 `4-34` 中 local folder Markdown 自身 trash lifecycle 独立 smoke:Markdown 页面通过 `/api/tree/commands` 完成 archive / restore / purge,文件系统与 trash index 均一致。 + +## Ownership + +允许修改: + +- 新增 `scripts/task475-local-folder-markdown-trash-lifecycle-smoke.js` +- `design/04-tree-domain/process/4-34-filetree-trash-dual-browser-no-refresh-v1.md` +- `.codex/reasonix-tasks/results/batch-a3-worker-b-markdown-lifecycle.md` + +禁止: + +- 不修改 Rust 源码。 +- 不修改 `4-46` 或移动任何 design 文件。 +- 不提交 git。 +- 不把非 md 资源生命周期代替 Markdown 页面生命周期。 + +## 要求 + +1. 新增 API smoke,可参考 `task437-local-folder-asset-trash-lifecycle-smoke.js`。 +2. 构造临时 local folder root 和 `.mnote/workspace.json`。 +3. 至少覆盖 loose Markdown 页面: + - `delete` 后 canonical command 应是页面或资源 lifecycle 中当前实现返回的真实值。 + - 源 `.md` 消失,`.mnote/trash` 中出现对应文件,`trash-index.json` 记录 `local-md:*`。 + - `restore` 后 `.md` 回原路径,trash 文件消失,index 清理。 + - 再次 `delete` 后 `purge`,源路径与 trash 路径均不存在,index 清理。 +4. 如 bundled Markdown 页面当前实现也容易覆盖,可加一个 bundled case;否则在结果中说明只覆盖 loose Markdown,bundled 由 Rust 单测覆盖或待后续。 +5. 结果写入 `tmp/task475-local-folder-markdown-trash-lifecycle-smoke/result.json`。 +6. 更新 `4-34` 对 local folder Markdown trash lifecycle 覆盖状态,但不要移动文件。 + +## 验收命令 + +```bash +node --check scripts/task475-local-folder-markdown-trash-lifecycle-smoke.js +node scripts/task475-local-folder-markdown-trash-lifecycle-smoke.js +``` + +## 最终输出 + +写入 `.codex/reasonix-tasks/results/batch-a3-worker-b-markdown-lifecycle.md`,包含: + +- 修改文件 +- smoke 覆盖内容 +- 验证命令和结果 +- 未完成项 / 风险 diff --git a/.codex/reasonix-tasks/2026-05-21-batch-a3-worker-c-doc-archive.md b/.codex/reasonix-tasks/2026-05-21-batch-a3-worker-c-doc-archive.md new file mode 100644 index 00000000..e5aa5719 --- /dev/null +++ b/.codex/reasonix-tasks/2026-05-21-batch-a3-worker-c-doc-archive.md @@ -0,0 +1,51 @@ +# Reasonix Batch A3 Worker C:4-34 / 4-46 归档审查 + +Project root: `/mnt/Data1T/mnote` + +## 目标 + +审查 Batch A / P0 剩余文档是否能归档。你只做文档审查和状态更新,不写 smoke、不改 Rust、不提交 git。 + +## Ownership + +允许修改: + +- `design/04-tree-domain/process/4-34-filetree-trash-dual-browser-no-refresh-v1.md` +- `design/04-tree-domain/process/4-46-batch-a-filetree-action-trash-smoke-execution-checklist-v1.md` +- 如确认完成,可移动到 `design/04-tree-domain/done/` +- `.codex/reasonix-tasks/results/batch-a3-worker-c-doc-archive.md` + +只读参考: + +- `design/04-tree-domain/process/4-47-batch-a3-local-folder-trash-final-smoke-checklist-v1.md` +- `tmp/task474-local-folder-empty-trash-ui-smoke/result.json` +- `tmp/task475-local-folder-markdown-trash-lifecycle-smoke/result.json` +- `tmp/task473-local-folder-trash-restore-no-refresh-focus-gap-smoke/result.json` +- `.codex/reasonix-tasks/results/batch-a3-worker-a-empty-trash.md` +- `.codex/reasonix-tasks/results/batch-a3-worker-b-markdown-lifecycle.md` + +## 要求 + +1. 如果 Worker A/B 结果文件或 smoke result 不存在,不要归档,写明 blocker。 +2. 如果 `task474` 和 `task475` 都有可复核通过证据: + - 更新 `4-34` 覆盖矩阵和边界。 + - 判断 `4-34` 是否满足归档条件。 + - 判断 `4-46` 是否满足 Batch A P0 归档条件。 + - 满足时移动到 `done/` 并把顶部状态改为 `done`。 +3. 如果仍有真实缺口,保留在 `process/`,写明下一步。 +4. 不把 Reasonix 文字总结当事实源,必须读 result.json。 + +## 验收命令 + +```bash +git diff --name-status +``` + +## 最终输出 + +写入 `.codex/reasonix-tasks/results/batch-a3-worker-c-doc-archive.md`,包含: + +- 修改/移动的文件 +- 归档依据或 blocker +- 未完成项 +- 需要 Codex 复核的点 diff --git a/.codex/reasonix-tasks/2026-05-21-browser-batch-a3-task474-empty-trash.md b/.codex/reasonix-tasks/2026-05-21-browser-batch-a3-task474-empty-trash.md new file mode 100644 index 00000000..fbcacea7 --- /dev/null +++ b/.codex/reasonix-tasks/2026-05-21-browser-batch-a3-task474-empty-trash.md @@ -0,0 +1,66 @@ +# Reasonix Browser Test: Batch A3 task474 Local Folder Empty Trash UI + +项目根目录:`/mnt/Data1T/mnote` + +请先阅读并遵守:`/home/lix/.codex/skills/reasonix-browser-tester/SKILL.md` + +## 角色 + +你是浏览器测试员,不是代码修改员。 + +禁止修改源码、禁止 git commit、禁止回滚文件。只允许创建测试输出目录、临时测试数据、截图和测试结果文件。 + +## 测试目标 + +验证 `task474-local-folder-empty-trash-ui-smoke.js` 在当前最新 `mnote-web` 上覆盖 local folder 垃圾箱清空 UI: + +- 清空页面垃圾箱按钮可点击并触发 confirm。 +- 清空后 Markdown trash row 消失。 +- 清空资源垃圾箱按钮可点击并触发 confirm。 +- 清空后资源 trash row 消失。 +- 两次清空操作都不触发非预期 main frame navigation。 +- `trash-index.json` 最终为空,trash 文件已删除。 + +## 前置 + +- 被测入口:`http://127.0.0.1:3014` +- 3014 是 Codex 为本轮验证启动的最新 mnote-web 临时进程。 +- 使用真实浏览器或 Playwright。 +- 使用全新的 isolated browser context / userDataDir;测试开始前清空 cookies、localStorage、sessionStorage、IndexedDB、Cache Storage。 + +## 操作 + +1. 运行: + +```bash +cd /mnt/Data1T/mnote +node --check scripts/task474-local-folder-empty-trash-ui-smoke.js +MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3014 PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/google-chrome-stable MNOTE_KEEP_SMOKE_TMP=1 node scripts/task474-local-folder-empty-trash-ui-smoke.js +``` + +2. 读取 `tmp/task474-local-folder-empty-trash-ui-smoke/result.json`。 +3. 打开 result 中的 `root` 对应 `/trash?sourceKind=local_folder&rootUri=file://...` 页面,截图清空后的 trash workbench 状态。 +4. 记录: + - `ok` + - `emptyDocuments.navDelta` + - `emptyResources.navDelta` + - `emptyDocuments.mdEntryClean` + - `emptyResources.assetEntryClean` + - `steps.length` + - 最终页面是否显示页面垃圾箱 / 资源垃圾箱为空状态或对应按钮 disabled + +## 输出目录 + +`/mnt/Data1T/mnote/tmp/reasonix-batch-a3-browser-task474-empty-trash-2026-05-21/` + +必须输出: + +- `result.json` +- `final.md` +- 至少 1 张截图;失败时必须截图。 + +## 判定 + +- `PASS`:语法检查和 smoke 实跑 exit 0,`result.json.ok=true`,两个 navDelta 都为 0,页面与资源 entry 均清理,且截图显示清空后的 trash workbench。 +- `FAIL`:脚本退出非 0,或 no-navigation / entry 清理 / UI 清空断言失败。 +- `BLOCKED`:3014 不可达或浏览器无法启动。 diff --git a/.codex/reasonix-tasks/results/batch-a3-worker-a-empty-trash.md b/.codex/reasonix-tasks/results/batch-a3-worker-a-empty-trash.md new file mode 100644 index 00000000..038cac39 --- /dev/null +++ b/.codex/reasonix-tasks/results/batch-a3-worker-a-empty-trash.md @@ -0,0 +1,103 @@ +# Batch A3 Worker A:local folder empty-trash UI smoke + +> 更新时间:2026-05-22 +> 任务:`task474-local-folder-empty-trash-ui-smoke` + +## 修改文件 + +| 文件 | 操作 | +|------|------| +| `scripts/task474-local-folder-empty-trash-ui-smoke.js` | 新增 | +| `design/04-tree-domain/process/4-34-filetree-trash-dual-browser-no-refresh-v1.md` | 更新验收矩阵、执行记录、边界和完成标准 | +| `.codex/reasonix-tasks/results/batch-a3-worker-a-empty-trash.md` | 本文件 | + +## Smoke 覆盖内容 + +### 测试场景 + +- **临时 local folder root**:创建松散 Markdown 页面 (`docs/test-page.md`) 和非 md 资源 (`docs/resource.txt`) +- **归档到垃圾箱**:通过 `POST /api/tree/commands` 将两个条目都 `delete` 进入 `.mnote/trash/` +- **打开 trash 页**:`/trash?sourceKind=local_folder&rootUri=file://...` +- **清空页面垃圾箱**: + - 点击 `data-trash-action="local-empty-documents"` 按钮 + - accept `window.confirm('清空后无法恢复,确定继续吗?')` + - 断言:MD trash row 消失、资源行仍然存在、`trash-index.json` 中 MD entry 删除、trash 文件删除、按钮变为 disabled + - 导航断言:全程 `framenavigated` delta = 0 +- **清空资源垃圾箱**: + - 点击 `data-trash-action="local-empty-resources"` 按钮 + - accept confirm dialog + - 断言:资源 trash row 消失、`trash-index.json` 中资源 entry 删除、trash 文件删除、按钮变为 disabled + - 导航断言:全程 `framenavigated` delta = 0 +- **最终验证**:`trash-index.json` 为空、原始源文件未恢复 + +### 关键选择器 + +| 元素 | 选择器 | +|------|--------| +| Trash workbench | `[data-testid="mnote-trash-workbench"][data-trash-source-kind="local_folder"]` | +| 清空页面垃圾箱 | `[data-trash-action="local-empty-documents"]` | +| 清空资源垃圾箱 | `[data-trash-action="local-empty-resources"]` | +| MD trash row | `article[data-trash-row="local"][data-trash-entry-id="local-md:docs~2Ftest-page.md"]` | +| Asset trash row | `article[data-trash-row="local"][data-trash-entry-id="local-file:docs/resource.txt"]` | +| Confirm 文本 | `'清空后无法恢复,确定继续吗?'` | + +### 数据流 + +``` +postTreeCommand(delete, "local-md:docs~2Ftest-page.md") + → tree.resource.archive → 文件移至 .mnote/trash/ → trash-index.json 记录 +postTreeCommand(delete, "local-file:docs/resource.txt") + → tree.resource.archive → 文件移至 .mnote/trash/ → trash-index.json 记录 +→ 打开 /trash → 两行可见 +→ 点击 empty-documents → confirm.accept → Promise.all(rows.map purge) → refresh() (fetch+DOMParser) +→ MD 行消失,按钮 disabled,trash-index 清理,trash 文件删除,framenavigated=0 +→ 点击 empty-resources → confirm.accept → Promise.all(rows.map purge) → refresh() +→ 资源行消失,按钮 disabled,trash-index 为空,trash 文件删除,framenavigated=0 +``` + +## 验证命令和结果 + +```bash +# 语法验证 +node --check scripts/task474-local-folder-empty-trash-ui-smoke.js +# → exit 0 + +# Codex 复核:最新 mnote-web 临时端口 3014 + Chromium 实跑 +MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3014 \ + PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/google-chrome-stable \ + node scripts/task474-local-folder-empty-trash-ui-smoke.js +# → exit 0 +# → 写入 tmp/task474-local-folder-empty-trash-ui-smoke/result.json +``` + +Codex 复核结果: + +- `result.json.ok=true` +- `emptyDocuments.navDelta=0` +- `emptyResources.navDelta=0` +- `emptyDocuments.mdEntryClean=true` +- `emptyResources.assetEntryClean=true` +- `steps.length=5` + +Reasonix Browser Worker 也已独立复测,输出: + +- `tmp/reasonix-batch-a3-browser-task474-empty-trash-2026-05-21/result.json` +- `tmp/reasonix-batch-a3-browser-task474-empty-trash-2026-05-21/trash-workbench-empty.png` +- `tmp/reasonix-batch-a3-browser-task474-empty-trash-2026-05-21/trash-workbench-ui.png` + +## 更新到 4-34 的内容 + +1. **验收矩阵**:local folder Markdown 和 local folder 非 md 资源两行的 empty-trash 列已从 `❌ 未覆盖` 改为 `✅ task474 UI empty-trash` +2. **执行记录**:在 3.5 末尾追加 task474 的覆盖说明 +3. **当前边界**:补充 task474 覆盖的 empty-trash 项 +4. **完成标准**:local folder 子项增加 `474` + +## 未完成项 / 风险 + +| # | 项 | 状态 | 说明 | +|---|-----|------|------| +| 1 | bundled Markdown empty-trash | ❌ 未覆盖 | task474 只验证了松散 `.md` 的 empty-trash;bundled Markdown page(目录含 `Page.md`)未覆盖。empty-trash 操作通过 `data-resource-kind="markdown"/"markdown_bundle"` 选择器选择行,理论上 bundled Markdown 也会被选中,但未实地验证 | +| 2 | 双浏览器 empty-trash 实时性 | ❌ 未验证 | task474 是单浏览器 UI smoke(A 浏览器操作 + 断言)。B 浏览器不刷新可见更新(Convex 双浏览器矩阵)未覆盖。当前 local folder trash 的 `refresh()` 只刷新 A 浏览器自身 DOM,`tree:delta resync_required` 事件不推送跨浏览器 | +| 3 | 3000 未重新编译 | ✅ 已处理 | Codex 已在最新临时端口 `3014` 实跑通过 | +| 4 | GitHub 资源空 trash 后按钮状态 | ✅ 已验证 | 脚本断言清空后按钮变为 disabled | +| 5 | 大文件 empty-trash | ❌ 未验证 | 脚本仅用了几 KB 的小文件;大 trash 数量的 `refresh()` 性能未覆盖 | diff --git a/.codex/reasonix-tasks/results/batch-a3-worker-b-markdown-lifecycle.md b/.codex/reasonix-tasks/results/batch-a3-worker-b-markdown-lifecycle.md new file mode 100644 index 00000000..ad2b7b2d --- /dev/null +++ b/.codex/reasonix-tasks/results/batch-a3-worker-b-markdown-lifecycle.md @@ -0,0 +1,60 @@ +# Batch A3 Worker B — Local folder Markdown trash lifecycle smoke + +## 修改文件 + +| 文件 | 操作 | 说明 | +| --- | --- | --- | +| `scripts/task475-local-folder-markdown-trash-lifecycle-smoke.js` | 新增 | loose Markdown 页面的 delete/restore/delete+purge 完整生命周期 smoke | +| `design/04-tree-domain/process/4-34-filetree-trash-dual-browser-no-refresh-v1.md` | 修改 | 更新验收矩阵、审计结论、执行记录、边界和完成标准 | + +## Smoke 覆盖内容 + +- **测试对象**:loose Markdown 页面(非 bundle 目录形式),`docs/test-page.md` +- **documentId 格式**:`local-md:docs~2Ftest-page.md` +- **API 路径**:`POST /api/tree/commands` with `sourceKind: "local_folder"` +- **5 个步骤**: + 1. `delete` → 源 `.md` 消失,`.mnote/trash` 出现对应文件,`trash-index.json` 记录 `local-md:*` entry + 2. `restore` → `.md` 回原路径(内容不变),trash 文件消失,index 清理 + 3. 再次 `delete` → 同步骤 1,验证可重复 + 4. `purge` → 源路径与 trash 路径均不存在,index 清理 + 5. 重建后 delete+purge 周期 → `trash-index.json` 仍为合法 JSON + +## 当前实现观察 + +- `trash_local_markdown_page` 响应**不包含** `canonicalCommand` 字段(与 `trash_local_raw_file` / `trash_local_directory` 不同,后两者返回 `"canonicalCommand": "tree.resource.archive"`) +- `restore_local_markdown_page` 响应包含 `previousDocumentId`、`relativePath`、`documentId` +- `purge_local_markdown_page` 响应包含 `ok: true` + +## 未覆盖:bundled Markdown + +bundled Markdown(页面目录形式,含 `Page.md` + 资源文件)未在 smoke 中覆盖,原因: + +- `trash_local_markdown_page` 会将 bundle 目录整体移入 trash(`resourceKind: "markdown_bundle"`),涉及目录遍历和子文件处理 +- `restore_local_markdown_bundle` 路径还原有额外逻辑(同名冲突处理等) +- Rust 单测已覆盖: + - `local_tree_command_manages_loose_markdown_like_regular_file`(loose rename/copy/delete/restore) + - `local_tree_command_delete_page_bundle_directory_uses_page_trash`(bundle delete/restore) + - `local_tree_command_delete_local_file_id_uses_trash_index`(local file trash/restore/purge) + - `local_tree_command_delete_chinese_named_asset_in_subdir_uses_trash_index`(中文资源名 trash 周期) + +bundled Markdown case 可在后续独立编写——需构造含 `Page.md` 和附件的页面目录,验证 bundle 整体移入/移出 trash。 + +## 验证命令和结果 + +```bash +# 语法检查 +node --check scripts/task475-local-folder-markdown-trash-lifecycle-smoke.js # exit 0 + +# 运行 smoke(需要 mnote-web 在 3000 运行) +node scripts/task475-local-folder-markdown-trash-lifecycle-smoke.js +# 输出: task475 local folder Markdown trash lifecycle smoke passed +# result: tmp/task475-local-folder-markdown-trash-lifecycle-smoke/result.json +``` + +## 风险 + +| 风险 | 说明 | +| --- | --- | +| canonicalCommand 缺失 | loose Markdown 的 `delete`/`restore`/`purge` 响应不含 `canonicalCommand`,与非 md 资源 lifecycle 响应不一致。如果未来统一添加,smoke 需同步更新。 | +| trash 文件名冲突 | `next_available_path` 使用 file_stem_title 生成文件名,同名文件会附加序号(如 `test-page 2.md`)。smoke 中的路径 `trash/test-page.md` 在当前实现下恰好是单文件场景,若后续并行场景新增同名文件会冲突——但 smoke 每次使用独立 temp dir,不受影响。 | +| API 响应结构变动 | smoke 依赖 `result.result?.execution` 的 JSON 路径,若 `json_response` 包装层变动需同步更新。 | diff --git a/.codex/reasonix-tasks/results/batch-a3-worker-c-doc-archive.md b/.codex/reasonix-tasks/results/batch-a3-worker-c-doc-archive.md new file mode 100644 index 00000000..38a69ff7 --- /dev/null +++ b/.codex/reasonix-tasks/results/batch-a3-worker-c-doc-archive.md @@ -0,0 +1,29 @@ +# Batch A3 Worker C — 4-34 / 4-46 / 4-47 归档审查 + +## 结论 + +Codex 已完成 Worker C 归档审查,`4-34`、`4-46`、`4-47` 均满足归档条件并移动到 `design/04-tree-domain/done/`。 + +## 移动文件 + +| 来源 | 目标 | +| --- | --- | +| `design/04-tree-domain/process/4-34-filetree-trash-dual-browser-no-refresh-v1.md` | `design/04-tree-domain/done/4-34-filetree-trash-dual-browser-no-refresh-v1.md` | +| `design/04-tree-domain/process/4-46-batch-a-filetree-action-trash-smoke-execution-checklist-v1.md` | `design/04-tree-domain/done/4-46-batch-a-filetree-action-trash-smoke-execution-checklist-v1.md` | +| `design/04-tree-domain/process/4-47-batch-a3-local-folder-trash-final-smoke-checklist-v1.md` | `design/04-tree-domain/done/4-47-batch-a3-local-folder-trash-final-smoke-checklist-v1.md` | + +## 归档依据 + +- `task474-local-folder-empty-trash-ui-smoke.js` 通过,覆盖 local folder 页面垃圾箱与资源垃圾箱清空 UI。 +- `task475-local-folder-markdown-trash-lifecycle-smoke.js` 通过,覆盖 loose Markdown 页面 delete / restore / purge 生命周期。 +- Codex 在最新临时 `mnote-web` `http://127.0.0.1:3014` 上独立复跑 `task474` / `task475` 均通过。 +- Reasonix Browser Worker 复测 `task474` 并输出截图: + - `tmp/reasonix-batch-a3-browser-task474-empty-trash-2026-05-21/result.json` + - `tmp/reasonix-batch-a3-browser-task474-empty-trash-2026-05-21/trash-workbench-empty.png` + - `tmp/reasonix-batch-a3-browser-task474-empty-trash-2026-05-21/trash-workbench-ui.png` +- `4-28`、`4-44`、`4-45` 已在前序 Batch A/A2 归档,`4-46` 的 Batch A P0 目标已闭环。 + +## 保留边界 + +- bundled Markdown 目录形式未新增 JS smoke,但已有 Rust 单测覆盖;后续如要补浏览器矩阵,应另开小 checklist。 +- local folder 的跨浏览器实时推送不是本批目标;本批验证的是 local-first trash workbench 的 UI 清空与 no-navigation 行为。 diff --git a/design/04-tree-domain/process/4-34-filetree-trash-dual-browser-no-refresh-v1.md b/design/04-tree-domain/done/4-34-filetree-trash-dual-browser-no-refresh-v1.md similarity index 80% rename from design/04-tree-domain/process/4-34-filetree-trash-dual-browser-no-refresh-v1.md rename to design/04-tree-domain/done/4-34-filetree-trash-dual-browser-no-refresh-v1.md index 6f17de1f..be362057 100644 --- a/design/04-tree-domain/process/4-34-filetree-trash-dual-browser-no-refresh-v1.md +++ b/design/04-tree-domain/done/4-34-filetree-trash-dual-browser-no-refresh-v1.md @@ -1,6 +1,6 @@ # 4-34 Filetree Trash Dual Browser No Refresh v1 -> 状态:process +> 状态:done > 创建时间:2026-05-15 > 所属主线:04-tree-domain / VSCode Explorer 文件树与垃圾箱闭环 > 来源:`design/10-review/07-vscode-explorer-filetree-trash-gap-review.md` 阶段 8 @@ -19,8 +19,8 @@ | 普通附件 file asset | A 创建/上传或 seed 后 B File Tree 可见 | A 删除后 B File Tree 消失、Trash 可见 | A 恢复后 B File Tree 可见、Trash 消失 | A 彻底删除后 B Trash 消失 | A 清空资源垃圾箱后 B Trash 清空 | 记录 `tree.resource.*` / compat alias 与实时来源 | | mindmap | A 新建 mindmap 后 B File Tree 可见 | A 删除后 B File Tree 消失、Trash 可见 | A 恢复后 B File Tree 可见、Trash 消失 | A 彻底删除后 B Trash 消失 | A 清空资源垃圾箱后 B Trash 清空 | 记录 mindmap route 仍为 compat alias | | table | A 新建/seed table 后 B File Tree 可见 | A 删除后 B File Tree 消失、Trash 可见 | A 恢复后 B File Tree 可见、Trash 消失 | A 彻底删除后 B Trash 消失 | A 清空资源垃圾箱后 B Trash 清空 | 记录 table route 仍为 compat alias | -| local folder Markdown | 外部创建 `.md` 后 page/file tree 可见 | 本地删除进入 `.mnote/trash` | restore 回原路径 | purge 后原文件与 trash 均不存在 | 可选本地 trash 清空 | 必须记录是否发生 reload,不能把 reload 型刷新标为 no-refresh | -| local folder 非 md 资源 | ✅ task435 外部 create/rename/delete 无 reload | ✅ task437 API archive + task464 UI trash | ✅ task437 API restore + task473 UI restore no-refresh + focus/reveal | ✅ task437 API purge + task464 UI purge | ❌ 未覆盖(empty-trash smoke 未编写) | task435/437/464/473 覆盖;restore focus 缺口已关闭,见 task473 | +| local folder Markdown | 外部创建 `.md` 后 page/file tree 可见 | ✅ task475 API archive(delete 后进入 `.mnote/trash`,trash-index 记录 `local-md:*`) | ✅ task475 API restore(`.md` 回原路径,trash 文件消失,index 清理) | ✅ task475 API purge(源路径与 trash 路径均不存在,index 清理) | ✅ task474 UI empty-trash(清空页面垃圾箱,confirm dialog → refresh(),无导航) | task475 覆盖 loose Markdown API trash lifecycle;task474 覆盖 empty-trash UI;bundled Markdown 由 Rust 单测覆盖 | +| local folder 非 md 资源 | ✅ task435 外部 create/rename/delete 无 reload | ✅ task437 API archive + task464 UI trash | ✅ task437 API restore + task473 UI restore no-refresh + focus/reveal | ✅ task437 API purge + task464 UI purge | ✅ task474 UI empty-trash(清空资源垃圾箱,confirm dialog → refresh(),无导航) | task435/437/464/473 覆盖;restore focus 缺口已关闭,见 task473;task474 覆盖 empty-trash UI | ## 3. 分批执行 @@ -95,11 +95,14 @@ - `/api/local-folder/events` 订阅该 watcher,主要服务打开的本地 Markdown document session。 - 主 tree 的 local folder 模式不接 `/api/tree/events`,而是轮询 `/api/tree/local-folder-watch`(`startLocalFolderSidebarWatch`,1200ms 间隔)。revision 变化时调用 `refreshLocalFolderSidebarSnapshot()` 重渲染 filetree,**不触发 `window.location.reload()`**。Rust 单元测试 `sidebar_tree_runtime_polls_local_folder_without_browser_reload` 验证了 SSR template 中不含 reload 语句。 - trash workbench 的 local folder restore/purge 使用 `refresh()`(gateway.rs:1040-1060):`fetch` + `DOMParser` 替换 workbench `innerHTML`,不触发浏览器导航。但 restore/purge 完成后不调用 `refreshLocalFolderSidebarSnapshot()`,filetree 更新依赖下次轮询触发。 -- `task163-local-folder-unified-tree-browser-smoke.js` 已覆盖外部新增 Markdown、外部新增 / 删除 `watcher-asset.txt` 的可见性;`task435-local-folder-watch-no-reload-smoke` 已覆盖 Markdown 与非 md 外部 create/rename/delete 无 reload;非 md trash/restore/purge 已由 task437(API)和 task464(UI)覆盖;local folder Markdown 自身 trash lifecycle(archive/restore/purge through tree commands)仍未编写独立 smoke。 +- `task163-local-folder-unified-tree-browser-smoke.js` 已覆盖外部新增 Markdown、外部新增 / 删除 `watcher-asset.txt` 的可见性;`task435-local-folder-watch-no-reload-smoke` 已覆盖 Markdown 与非 md 外部 create/rename/delete 无 reload;非 md trash/restore/purge 已由 task437(API)和 task464(UI)覆盖;local folder Markdown 自身 trash lifecycle(archive/restore/purge through tree commands)已由 task475 覆盖(API-only:loose Markdown 的 delete → trash 进入/restore 恢复/purge 清理)。 - `task435-local-folder-watch-no-reload-smoke` 已通过:外部非 md 文件(`.txt`、`.png`)的 create / rename / delete 在 filetree 中无刷新可见更新,全程无 `framenavigated`。(结果:`tmp/task435-local-folder-watch-no-reload-smoke/result.json`) - `task437-local-folder-asset-trash-lifecycle-smoke` 已通过(API-only):`tree.resource.archive` / `restore` / `purge` 的 canonicalCommand 与文件系统一致性。(结果:`tmp/task437-local-folder-asset-trash-lifecycle-smoke/result.json`) - `task464-local-folder-resource-trash-ui-smoke` 已通过(Browser UI):trash 页中 delete→visible / restore→file restored / purge→file gone 完整 UI 流程。(结果:`tmp/task464-local-folder-resource-trash-ui-smoke/result.json`) - `task473-local-folder-trash-restore-no-refresh-focus-gap-smoke` 新增并已由 Codex + Reasonix Browser Worker 实跑:验证 trash workbench 的 restore 操作为 fetch 型 `refresh()`(`restoreNavFree.delta=0`,不触发浏览器导航),restore 后 filetree 通过轮询显示恢复行(`fileReappearsInFiletree=true`)。2026-05-21/22 Codex 修复并复跑后,恢复行出现 focus 证据(`data-active="true"`),focus gap 已关闭。(结果:`tmp/task473-local-folder-trash-restore-no-refresh-focus-gap-smoke/result.json`;浏览器证据:`tmp/reasonix-batch-a-browser-task473-2026-05-21/`) +- `task475-local-folder-markdown-trash-lifecycle-smoke` 已通过(API-only):验证 loose Markdown 页面在 `/api/tree/commands` 的 delete(archive)→ restore → 再次 delete → purge 完整生命周期,文件系统与 `trash-index.json` 一致性。当前实现中 loose Markdown 的 delete/restore/purge 响应不含 `canonicalCommand` 字段(与 `trash_local_raw_file` 不同)。bundled Markdown 未覆盖(bundle 涉及目录整体移动/恢复,由 Rust 单测 `local_tree_command_manages_loose_markdown_like_regular_file` 和 `local_tree_command_delete_page_bundle_directory_uses_page_trash` 覆盖)。(结果:`tmp/task475-local-folder-markdown-trash-lifecycle-smoke/result.json`) +- `task474-local-folder-empty-trash-ui-smoke` 新增:验证 local folder trash workbench 的清空页面垃圾箱(`local-empty-documents`)和清空资源垃圾箱(`local-empty-resources`)按钮操作。点击按钮后 accept confirm dialog,`refresh()` 以 fetch+DOMParser 替换 innerHTML,不触发浏览器导航;断言对应 trash row 消失、`trash-index.json` 对应 entry 清理、trash 文件删除。结果写入 `tmp/task474-local-folder-empty-trash-ui-smoke/result.json`。 +- 2026-05-21:Codex 在最新临时 `mnote-web` `http://127.0.0.1:3014` 上复跑 `task474` / `task475` 均通过;Reasonix Browser Worker 输出 `tmp/reasonix-batch-a3-browser-task474-empty-trash-2026-05-21/`,截图显示清空后页面与资源均为 0,两个清空按钮 disabled。 通过后才允许在 10-review 中把 local folder 阶段 8 子项勾选;如果仍发生 reload,只能标为“reload 型刷新可见”,不能标为 no-refresh。 @@ -116,12 +119,12 @@ - 3000 常驻进程可能是旧 `mnote-web` 二进制;修改 Rust 后做 smoke 时必须重启 3000,或用新编译的临时 3001 并在结果中明确记录。 - 资源生命周期中,普通附件 file asset 已进入正式 `tree.resource.*`;mindmap / table 仍是 compat route,阶段 8 只验证 no-refresh,不改变 cutover 口径。 -- local folder Markdown watcher 只有部分历史覆盖,且主 tree filetree 当前是 revision polling + reload;非 md 本地资源文件 watcher / trash / restore / purge 已由 task435(外部 create/rename/delete)、task437(API trash lifecycle)、task464(trash UI)、task473(restore no-refresh + focus/reveal)分项覆盖;local folder Markdown trash lifecycle 仍未编写独立 smoke。 +- local folder Markdown watcher 只有部分历史覆盖,且主 tree filetree 当前是 revision polling;非 md 本地资源文件 watcher / trash / restore / purge 已由 task435(外部 create/rename/delete)、task437(API trash lifecycle)、task464(trash UI)、task473(restore no-refresh + focus/reveal)分项覆盖;local folder empty-trash UI(md 页面 + 非 md 资源)已由 task474 覆盖;local folder Markdown loose page trash lifecycle 已由 task475(API)覆盖;bundled Markdown 由 Rust 单测覆盖,尚未编写独立 smoke。 - `resync_required` delta 不是数据替换本身,必须伴随后续 snapshot / resync 或客户端主动拉取 snapshot;后续新增资源 route 时必须补事件合同回归。 ## 6. 完成标准 - `design/10-review/07-vscode-explorer-filetree-trash-gap-review.md` 阶段 8 的 Convex 页面、普通附件、mindmap、table checklist 全部有对应 smoke 证据。 -- local folder 子项必须有 task435/437/464/473 或等价 smoke 证据;若仍发生 reload,保持未完成 no-refresh 状态。restore focus 已由 task473 关闭,不再视为 blocker。 +- local folder 子项必须有 task435/437/464/473/474/475 或等价 smoke 证据;若仍发生 reload,保持未完成 no-refresh 状态。restore focus 已由 task473 关闭,不再视为 blocker。 - 每个 smoke 结果写入 `tmp/task43*-*/result.json`,并在 10-review 中记录命令、对象、实时来源和剩余边界。 - 不再用单浏览器 no-refresh、API purge 成功或 Convex query 成功替代双浏览器 UI no-refresh。 diff --git a/design/04-tree-domain/process/4-46-batch-a-filetree-action-trash-smoke-execution-checklist-v1.md b/design/04-tree-domain/done/4-46-batch-a-filetree-action-trash-smoke-execution-checklist-v1.md similarity index 93% rename from design/04-tree-domain/process/4-46-batch-a-filetree-action-trash-smoke-execution-checklist-v1.md rename to design/04-tree-domain/done/4-46-batch-a-filetree-action-trash-smoke-execution-checklist-v1.md index ba711a9c..72dbe8a1 100644 --- a/design/04-tree-domain/process/4-46-batch-a-filetree-action-trash-smoke-execution-checklist-v1.md +++ b/design/04-tree-domain/done/4-46-batch-a-filetree-action-trash-smoke-execution-checklist-v1.md @@ -6,7 +6,7 @@ > > 阶段:Batch A / P0 清验证阻塞 > -> 当前状态:`PROCESS` +> 当前状态:`DONE` ## 1. 目标 @@ -134,3 +134,4 @@ Codex 必须复核浏览器测试结果;不能只相信 worker 文字总结。 - 2026-05-21:Codex 复核 Reasonix 截图和 JSON:task471 的多选删除截图与 DOM / trash index 断言一致;task430/task473 截图只作为浏览器可打开佐证,最终状态以 smoke `result.json` 为准。 - 2026-05-28:Batch A2 / Worker C — 修改 task473 完成条件为要求 `hasFocusEvidence===true`,使 focus gap 从"记录缺口但 ok:true"变为"记录缺口且 ok:false(直至 focus 修复)";更新 4-34 覆盖矩阵将非 md 资源行从"待定"写实为 task435/437/464/473 分项覆盖并标注 focus gap open;更新 4-34 审计结论、边界描述和完成标准以反映实际 smoke 覆盖;追加本执行记录。本轮目标:关闭 task473 的 focus gap 作为可检测的回归测试。 - 2026-05-21/22:Codex 接手 Batch A2 / Worker B 卡住项,修复 local folder restore 后 filetree focus/reveal:trash workbench restore 写入 pending row-id,filetree 初始加载与 polling 刷新通过 `schedulePendingLocalFolderRestoreFocus()` 重放聚焦;`task473` 在临时 3012 新进程上实跑通过,`focusAfterRestore.attrs.data-active="true"`。`4-28` 已满足归档条件并移动到 `done/`;`4-34` 仍保留在 `process/`,因为 local folder empty-trash / Markdown trash lifecycle 仍未完整覆盖。 +- 2026-05-21:Batch A3 收口。Reasonix Worker A/B 新增 `task474-local-folder-empty-trash-ui-smoke.js` 与 `task475-local-folder-markdown-trash-lifecycle-smoke.js`;Codex 在最新临时 `mnote-web` `http://127.0.0.1:3014` 上复跑两条 smoke 均通过。Reasonix Browser Worker 输出 `tmp/reasonix-batch-a3-browser-task474-empty-trash-2026-05-21/`,截图显示 local folder trash 清空后页面/资源均为 0。`4-34` 与本执行总 checklist 满足归档条件,移动到 `done/`。 diff --git a/design/04-tree-domain/done/4-47-batch-a3-local-folder-trash-final-smoke-checklist-v1.md b/design/04-tree-domain/done/4-47-batch-a3-local-folder-trash-final-smoke-checklist-v1.md new file mode 100644 index 00000000..7ce2aa4d --- /dev/null +++ b/design/04-tree-domain/done/4-47-batch-a3-local-folder-trash-final-smoke-checklist-v1.md @@ -0,0 +1,121 @@ +# 4-47 [process] Batch A3 Local Folder Trash Final Smoke Checklist v1 + +> 创建时间:2026-05-21 +> +> 上位入口:`design/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md` +> +> 阶段:Batch A / P0 剩余 local folder trash smoke 收口 +> +> 当前状态:`DONE` + +## 1. 目标 + +关闭 `4-34` 和 `4-46` 中 Batch A 剩余的 local folder 垃圾箱验证缺口: + +- local folder empty-trash UI smoke。 +- local folder Markdown 页面自身的 archive / restore / purge smoke。 +- 根据真实 smoke 结果判断 `4-34` / `4-46` 是否可归档到 `done/`。 + +本阶段不改 delete 默认进入垃圾箱的语义,不把 reload 型刷新伪装成 no-refresh,不扩新产品面。 + +## 2. 当前剩余缺口 + +- `4-34` 中 local folder 非 md 资源 empty-trash 已由 `task474` 覆盖。 +- `4-34` 中 local folder Markdown 自身 trash lifecycle 已由 `task475` 覆盖。 +- `4-46` Batch A 执行总 checklist 已满足归档条件。 + +## 3. Reasonix Worker 拆分 + +### Worker A:local folder empty-trash UI smoke + +Owner: + +- 可新增 `scripts/task474-local-folder-empty-trash-ui-smoke.js` +- `design/04-tree-domain/process/4-34-filetree-trash-dual-browser-no-refresh-v1.md` +- `.codex/reasonix-tasks/results/batch-a3-worker-a-empty-trash.md` + +目标: + +- 构造 local folder 临时 root,同时放入 markdown 页面和非 md 资源。 +- 通过 tree command 或 UI 将它们移动到 `.mnote/trash`。 +- 打开 `/trash?sourceKind=local_folder&rootUri=...`,分别点击“清空页面垃圾箱”和“清空资源垃圾箱”。 +- 断言对应行消失、文件系统 trash 文件消失、`trash-index.json` 对应 entry 清理。 +- 记录点击清空期间无非预期 browser navigation。 + +验收: + +```bash +node --check scripts/task474-local-folder-empty-trash-ui-smoke.js +MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1: PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/google-chrome-stable node scripts/task474-local-folder-empty-trash-ui-smoke.js +``` + +### Worker B:local folder Markdown trash lifecycle smoke + +Owner: + +- 可新增 `scripts/task475-local-folder-markdown-trash-lifecycle-smoke.js` +- `design/04-tree-domain/process/4-34-filetree-trash-dual-browser-no-refresh-v1.md` +- `.codex/reasonix-tasks/results/batch-a3-worker-b-markdown-lifecycle.md` + +目标: + +- 构造 loose Markdown 和/或 bundled Markdown 页面。 +- 通过 `/api/tree/commands` 验证 Markdown 页面的 `delete` / `restore` / `purge`。 +- 断言 canonical command、源路径、trash 路径、`trash-index.json` 和恢复后的 documentId 与文件系统一致。 +- 这是生命周期 smoke,不要求 UI 截图;浏览器可见性由 Worker A 和后续 Browser Worker 覆盖。 + +验收: + +```bash +node --check scripts/task475-local-folder-markdown-trash-lifecycle-smoke.js +MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1: node scripts/task475-local-folder-markdown-trash-lifecycle-smoke.js +``` + +### Worker C:文档归档审查 + +Owner: + +- `design/04-tree-domain/process/4-34-filetree-trash-dual-browser-no-refresh-v1.md` +- `design/04-tree-domain/process/4-46-batch-a-filetree-action-trash-smoke-execution-checklist-v1.md` +- 如条件满足,可移动到 `design/04-tree-domain/done/` +- `.codex/reasonix-tasks/results/batch-a3-worker-c-doc-archive.md` + +目标: + +- 在 Worker A/B smoke 结果可复核后,判断 `4-34` 是否满足归档条件。 +- 判断 `4-46` 是否已经完成 Batch A P0,是否可以归档。 +- 若仍有缺口,必须写明 blocker 与下一步,不得强行移动。 + +验收: + +```bash +git diff --name-status +``` + +## 4. Codex 复核项 + +- [x] 读取 Worker A/B 输出;Worker C 由 Codex 本地完成归档审查。 +- [x] 独立审查 diff,确认无越权修改。 +- [x] 运行新增脚本 `node --check`。 +- [x] 在最新 mnote-web 临时端口 `3014` 上实跑新增 smoke。 +- [x] 完成后拆 Browser Worker 复测关键 UI smoke 并核验截图。 +- [ ] `git diff --check`、`codegraph sync .` 后提交。 + +## 5. 完成条件 + +- `task474` 或等价 smoke 覆盖 local folder empty-trash UI。 +- `task475` 或等价 smoke 覆盖 local folder Markdown archive / restore / purge。 +- `4-34` / `4-46` 状态与真实证据一致。 +- 浏览器 worker 输出截图和 `result.json` 已由 Codex 复核。 +- 本阶段完成后由 Codex 提交 git。 + +## 6. 执行结果 + +- `task474-local-folder-empty-trash-ui-smoke.js` 新增并通过:local folder 页面垃圾箱与资源垃圾箱清空 UI、confirm、无导航、trash-index 清理、文件删除。 +- `task475-local-folder-markdown-trash-lifecycle-smoke.js` 新增并通过:loose Markdown 页面 delete / restore / purge 生命周期与文件系统一致。 +- Codex 复核命令: + - `node --check scripts/task474-local-folder-empty-trash-ui-smoke.js` + - `node --check scripts/task475-local-folder-markdown-trash-lifecycle-smoke.js` + - `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3014 PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/google-chrome-stable node scripts/task474-local-folder-empty-trash-ui-smoke.js` + - `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3014 node scripts/task475-local-folder-markdown-trash-lifecycle-smoke.js` +- Reasonix Browser Worker 复测输出:`tmp/reasonix-batch-a3-browser-task474-empty-trash-2026-05-21/`,核心截图 `trash-workbench-empty.png` 已由 Codex 查看。 diff --git a/scripts/task474-local-folder-empty-trash-ui-smoke.js b/scripts/task474-local-folder-empty-trash-ui-smoke.js new file mode 100644 index 00000000..a353d514 --- /dev/null +++ b/scripts/task474-local-folder-empty-trash-ui-smoke.js @@ -0,0 +1,457 @@ +#!/usr/bin/env node +"use strict"; + +/** + * task474-local-folder-empty-trash-ui-smoke.js + * + * 用途:验证 local folder trash workbench 的清空垃圾箱 UI 流程: + * 1. 清空页面垃圾箱(empty documents) + * 2. 清空资源垃圾箱(empty resources) + * 断言:行消失、trash-index.json 清理、trash 文件删除、无页面导航 + * + * 不修改 Rust / SSR 代码。只验证现有行为、记录证据。 + * + * Usage: + * node scripts/task474-local-folder-empty-trash-ui-smoke.js + * + * Env: + * MNOTE_WEB_SMOKE_BASE_URL — 3000 地址,默认 http://127.0.0.1:3000 + * MNOTE_SMOKE_UI_TIMEOUT_MS — UI 等待超时,默认 30_000 + * MNOTE_KEEP_SMOKE_TMP — 设置后保留临时目录 + */ + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { chromium } = require("playwright"); + +const TASK = "task474-local-folder-empty-trash-ui-smoke"; +const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); +const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000); +const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK); +const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); +const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH + || ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"] + .find((candidate) => fs.existsSync(candidate)); + +function fileUrl(localPath) { + return `file://${localPath}`; +} + +function writeWorkspaceManifest(root) { + const metadataDir = path.join(root, ".mnote"); + fs.mkdirSync(metadataDir, { recursive: true }); + fs.writeFileSync( + path.join(metadataDir, "workspace.json"), + `${JSON.stringify({ + workspaceId: `local-ws:user_real:${TASK}`, + ownerId: "user_real", + createdAt: new Date().toISOString(), + capabilities: ["local_files", "tree_commands", "markdown_edit", "asset_upload"], + }, null, 2)}\n`, + "utf8", + ); +} + +/** + * 编码本地路径段,匹配 Rust encode_local_id_segment 逻辑 + */ +function encodeLocalIdSegment(value) { + let encoded = ""; + for (let i = 0; i < value.length; i++) { + const ch = value[i]; + const code = value.charCodeAt(i); + if ((code >= 0x30 && code <= 0x39) || (code >= 0x41 && code <= 0x5A) || (code >= 0x61 && code <= 0x7A) || ch === "." || ch === "_" || ch === "-") { + encoded += ch; + } else { + encoded += "~" + code.toString(16).toUpperCase().padStart(2, "0"); + } + } + return encoded; +} + +/** + * Tree command 快捷请求 + */ +async function postTreeCommand(rootUri, action, documentId) { + const response = await fetch(`${BASE_URL}/api/tree/commands`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-mnote-actor-id": "user_real", + "x-mnote-actor-type": "user", + }, + body: JSON.stringify({ + action, + sourceKind: "local_folder", + rootUri, + documentId, + }), + }); + const payload = await response.json().catch(async () => ({ text: await response.text() })); + assert.equal(response.status, 200, `${action} failed: ${JSON.stringify(payload)}`); + return payload; +} + +/** + * 快速登录(测试账号) + */ +async function quickLogin(page) { + await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" }); + if (await quickLoginButton.count()) { + await quickLoginButton.click({ timeout: UI_TIMEOUT_MS }); + await page.waitForURL((url) => url.pathname === "/", { timeout: UI_TIMEOUT_MS }); + } +} + +/** + * 构建 trash 页 URL + */ +function trashUrl(rootUri) { + const url = new URL(`${BASE_URL}/trash`); + url.searchParams.set("sourceKind", "local_folder"); + url.searchParams.set("rootUri", rootUri); + return url.toString(); +} + +/** + * 打开 trash 页并等待 workbench 可见 + */ +async function openTrash(page, rootUri) { + await page.goto(trashUrl(rootUri), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await page.locator('[data-testid="mnote-trash-workbench"][data-trash-source-kind="local_folder"]').waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); +} + +/** + * 读取 trash-index.json 的 entries map + */ +function readTrashIndex(root) { + const indexPath = path.join(root, ".mnote", "trash-index.json"); + if (!fs.existsSync(indexPath)) return {}; + const raw = fs.readFileSync(indexPath, "utf8"); + const data = JSON.parse(raw); + return data.entries || data || {}; +} + +/** + * 读取 trash-index.json 中指定 entry + */ +function getTrashEntry(root, entryId) { + const entries = readTrashIndex(root); + return entries[entryId] || null; +} + +/** + * 从 trash-index.json 获取 entry 的 trashPath + */ +function getTrashFilePath(root, entryId) { + const entry = getTrashEntry(root, entryId); + if (!entry) return null; + return entry.trashRelativePath || entry.trash_relative_path || entry.trashFilePath || entry.trashed_file_path || null; +} + +async function main() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), `mnote-${TASK}-`)); + const rootUri = fileUrl(root); + + // ── 测试数据:松散 Markdown 页面和非 md 资源 ── + const mdRelativePath = "docs/test-page.md"; + const mdDocId = `local-md:${encodeLocalIdSegment(mdRelativePath)}`; // local-md:docs~2Ftest-page.md + const mdSourcePath = path.join(root, mdRelativePath); + + const assetRelativePath = "docs/resource.txt"; + const assetId = `local-file:${assetRelativePath}`; // local-file:docs/resource.txt + const assetSourcePath = path.join(root, assetRelativePath); + + // 准备 workspace + fs.mkdirSync(path.join(root, "docs"), { recursive: true }); + writeWorkspaceManifest(root); + fs.writeFileSync(path.join(root, "README.md"), "# Local Root\n", "utf8"); + fs.writeFileSync(mdSourcePath, "# Test Page\n\nPage content for empty-trash smoke.\n", "utf8"); + fs.writeFileSync(assetSourcePath, "resource body for empty-trash smoke\n", "utf8"); + + const browser = await chromium.launch({ + headless: true, + ...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}), + }); + const context = await browser.newContext({ + viewport: { width: 1360, height: 900 }, + extraHTTPHeaders: { + "x-mnote-actor-id": "user_real", + "x-mnote-actor-type": "user", + }, + }); + const page = await context.newPage(); + + // 导航事件记录 + const navigationEvents = []; + page.on("framenavigated", (frame) => { + if (frame === page.mainFrame()) { + navigationEvents.push({ url: frame.url(), timestamp: Date.now() }); + } + }); + + const evidence = { + ok: false, + task: TASK, + baseUrl: BASE_URL, + root, + mdRelativePath, + mdDocId, + assetRelativePath, + assetId, + steps: [], + emptyDocuments: {}, + emptyResources: {}, + navEvents: [], + }; + + try { + // ════════════════════════════════════════════ + // 步骤 1:登录并归档两个条目到垃圾箱 + // ════════════════════════════════════════════ + await quickLogin(page); + + // 归档 Markdown 页面 + const mdArchiveResult = await postTreeCommand(rootUri, "delete", mdDocId); + assert.equal(mdArchiveResult.result?.execution?.resourceKind, "markdown", + `MD archive resourceKind should be markdown, got: ${JSON.stringify(mdArchiveResult)}`); + assert.equal(fs.existsSync(mdSourcePath), false, "archive 后源 Markdown 文件应移入垃圾箱"); + const mdTrashPath = mdArchiveResult.result?.execution?.trashPath + || mdArchiveResult.trashPath + || getTrashFilePath(root, mdDocId); + assert.ok(mdTrashPath, `MD archive 应返回 trashPath, got: ${JSON.stringify(mdArchiveResult)}`); + + // 归档非 md 资源 + const assetArchiveResult = await postTreeCommand(rootUri, "delete", assetId); + assert.equal(assetArchiveResult.result?.execution?.canonicalCommand, "tree.resource.archive", + `Asset archive should be tree.resource.archive, got: ${JSON.stringify(assetArchiveResult)}`); + assert.equal(fs.existsSync(assetSourcePath), false, "archive 后源文件应移入垃圾箱"); + const assetTrashPath = assetArchiveResult.result?.execution?.trashPath + || assetArchiveResult.trashPath + || getTrashFilePath(root, assetId); + assert.ok(assetTrashPath, `Asset archive 应返回 trashPath, got: ${JSON.stringify(assetArchiveResult)}`); + + evidence.steps.push({ + step: 1, + label: "通过 tree command 归档 Markdown 页面和非 md 资源到垃圾箱", + ok: true, + mdTrashPath, + assetTrashPath, + }); + + // 验证 trash-index.json 包含两个 entry + const entriesBefore = readTrashIndex(root); + assert.ok(entriesBefore[mdDocId], `trash-index 应包含 MD entry: ${mdDocId}`); + assert.ok(entriesBefore[assetId], `trash-index 应包含 asset entry: ${assetId}`); + + // ════════════════════════════════════════════ + // 步骤 2:打开 trash 页,验证两行可见 + // ════════════════════════════════════════════ + const navBeforeTrashOpen = navigationEvents.length; + await openTrash(page, rootUri); + const trashOpenNavDelta = navigationEvents.length - navBeforeTrashOpen; + + // MD 行(页面垃圾箱) + const mdTrashRow = page.locator(`article[data-trash-row="local"][data-trash-entry-id="${mdDocId}"]`); + await mdTrashRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + const mdResourceKind = await mdTrashRow.getAttribute("data-resource-kind"); + assert.equal(mdResourceKind, "markdown", `MD trash row data-resource-kind 应为 "markdown", 实际: ${mdResourceKind}`); + + // Asset 行(资源垃圾箱) + const assetTrashRow = page.locator(`article[data-trash-row="local"][data-trash-entry-id="${assetId}"]`); + await assetTrashRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + const assetResourceKind = await assetTrashRow.getAttribute("data-resource-kind"); + assert.equal(assetResourceKind, "local_file", `Asset trash row data-resource-kind 应为 "local_file", 实际: ${assetResourceKind}`); + + // 验证按钮 enabled 状态 + const emptyDocsButton = page.locator('[data-trash-action="local-empty-documents"]'); + const emptyResourcesButton = page.locator('[data-trash-action="local-empty-resources"]'); + await emptyDocsButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await emptyResourcesButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + const docsButtonDisabled = await emptyDocsButton.isDisabled(); + const resourcesButtonDisabled = await emptyResourcesButton.isDisabled(); + assert.equal(docsButtonDisabled, false, "存在页面时清空页面垃圾箱按钮应 enabled"); + assert.equal(resourcesButtonDisabled, false, "存在资源时清空资源垃圾箱按钮应 enabled"); + + evidence.steps.push({ + step: 2, + label: "打开 trash 页,两行可见,按钮 enabled", + ok: true, + trashOpenNavDelta, + }); + + // ════════════════════════════════════════════ + // 步骤 3:清空页面垃圾箱 + // ════════════════════════════════════════════ + const navBeforeEmptyDocs = navigationEvents.length; + + // 确认 dialog:清空后无法恢复,确定继续吗? + page.once("dialog", (dialog) => { + evidence.emptyDocuments.dialogText = dialog.message(); + dialog.accept(); + }); + await emptyDocsButton.click({ timeout: UI_TIMEOUT_MS }); + + // 等待 document rows 消失(refresh() 替换 innerHTML 后文档行应被移除) + // 由于 refresh() 以 fetch+DOMParser 方式替换 innerHTML,原来 DOM 引用会变为 detached + await page.waitForFunction( + (id) => !document.querySelector(`[data-trash-entry-id="${id}"]`), + mdDocId, + { timeout: UI_TIMEOUT_MS }, + ); + + // 现在重新获取 DOM 引用验证 + const mdRowAfterEmptyDocs = page.locator(`article[data-trash-row="local"][data-trash-entry-id="${mdDocId}"]`); + const mdRowCountAfterEmptyDocs = await mdRowAfterEmptyDocs.count(); + assert.equal(mdRowCountAfterEmptyDocs, 0, "清空页面垃圾箱后 MD 行应消失"); + + // 文档行消失后,资源行应仍然存在 + const assetRowAfterEmptyDocs = page.locator(`article[data-trash-row="local"][data-trash-entry-id="${assetId}"]`); + const assetRowCountAfterEmptyDocs = await assetRowAfterEmptyDocs.count(); + assert.equal(assetRowCountAfterEmptyDocs, 1, "清空页面垃圾箱后资源行应仍然存在"); + + // 验证按钮状态:文档按钮 disabled,资源按钮仍 enabled + const docsButtonDisabledAfter = await page.locator('[data-trash-action="local-empty-documents"]').isDisabled(); + assert.equal(docsButtonDisabledAfter, true, "清空页面垃圾箱后 empty-documents 按钮应 disabled"); + + // 验证文件系统:trash 文件和 trash-index + const mdEntryAfterEmptyDocs = getTrashEntry(root, mdDocId); + assert.equal(mdEntryAfterEmptyDocs, null, `清空页面垃圾箱后 trash-index 不应包含 MD entry: ${mdDocId}`); + + const resolvedMdTrashPath = path.resolve(root, mdTrashPath); + assert.equal(fs.existsSync(resolvedMdTrashPath), false, `清空页面垃圾箱后 trash 文件应删除: ${resolvedMdTrashPath}`); + + // 导航断言:清空操作不应触发页面导航 + const navAfterEmptyDocs = navigationEvents.length; + const emptyDocsNavDelta = navAfterEmptyDocs - navBeforeEmptyDocs; + assert.equal(emptyDocsNavDelta, 0, + `清空页面垃圾箱后应无页面导航,实际发生 ${emptyDocsNavDelta} 次`); + + evidence.emptyDocuments = { + ok: true, + navDelta: emptyDocsNavDelta, + dialogAccepted: true, + mdEntryClean: mdEntryAfterEmptyDocs === null, + mdFileGone: !fs.existsSync(resolvedMdTrashPath), + }; + evidence.steps.push({ + step: 3, + label: "清空页面垃圾箱——按钮 click、accept confirm、行消失、trash-index 清理、文件删除、无导航", + ok: true, + navDelta: emptyDocsNavDelta, + }); + + // ════════════════════════════════════════════ + // 步骤 4:清空资源垃圾箱 + // ════════════════════════════════════════════ + const navBeforeEmptyResources = navigationEvents.length; + + const resourceButtonAfterEmptyDocs = page.locator('[data-trash-action="local-empty-resources"]'); + await resourceButtonAfterEmptyDocs.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + const resButtonDisabledAfterDocs = await resourceButtonAfterEmptyDocs.isDisabled(); + assert.equal(resButtonDisabledAfterDocs, false, "资源行仍存在时 empty-resources 按钮应 enabled"); + + // 接受确认 dialog + page.once("dialog", (dialog) => { + evidence.emptyResources.dialogText = dialog.message(); + dialog.accept(); + }); + await resourceButtonAfterEmptyDocs.click({ timeout: UI_TIMEOUT_MS }); + + // 等待资源行消失 + await page.waitForFunction( + (id) => !document.querySelector(`[data-trash-entry-id="${id}"]`), + assetId, + { timeout: UI_TIMEOUT_MS }, + ); + + const assetRowAfterEmptyResources = page.locator(`article[data-trash-row="local"][data-trash-entry-id="${assetId}"]`); + const assetRowCountAfterEmptyResources = await assetRowAfterEmptyResources.count(); + assert.equal(assetRowCountAfterEmptyResources, 0, "清空资源垃圾箱后资源行应消失"); + + // 验证按钮状态 + const resButtonDisabledAfter = await page.locator('[data-trash-action="local-empty-resources"]').isDisabled(); + assert.equal(resButtonDisabledAfter, true, "清空资源垃圾箱后 empty-resources 按钮应 disabled"); + + // 验证文件系统 + const assetEntryAfterEmptyResources = getTrashEntry(root, assetId); + assert.equal(assetEntryAfterEmptyResources, null, `清空资源垃圾箱后 trash-index 不应包含 asset entry: ${assetId}`); + + const resolvedAssetTrashPath = path.resolve(root, assetTrashPath); + assert.equal(fs.existsSync(resolvedAssetTrashPath), false, `清空资源垃圾箱后 trash 文件应删除: ${resolvedAssetTrashPath}`); + + // 导航断言 + const navAfterEmptyResources = navigationEvents.length; + const emptyResourcesNavDelta = navAfterEmptyResources - navBeforeEmptyResources; + assert.equal(emptyResourcesNavDelta, 0, + `清空资源垃圾箱后应无页面导航,实际发生 ${emptyResourcesNavDelta} 次`); + + evidence.emptyResources = { + ok: true, + navDelta: emptyResourcesNavDelta, + dialogAccepted: true, + assetEntryClean: assetEntryAfterEmptyResources === null, + assetFileGone: !fs.existsSync(resolvedAssetTrashPath), + }; + evidence.steps.push({ + step: 4, + label: "清空资源垃圾箱——按钮 click、accept confirm、行消失、trash-index 清理、文件删除、无导航", + ok: true, + navDelta: emptyResourcesNavDelta, + }); + + // ════════════════════════════════════════════ + // 步骤 5:最终验证——trash-index.json 完全空 + // ════════════════════════════════════════════ + const entriesAfter = readTrashIndex(root); + const entryKeys = Object.keys(entriesAfter); + assert.equal(entryKeys.length, 0, `两次清空后 trash-index 应为空,实际有 ${entryKeys.length} 个 entry: ${JSON.stringify(entryKeys)}`); + + // 验证原始源文件不应恢复 + assert.equal(fs.existsSync(mdSourcePath), false, "清空垃圾箱后 MD 源文件不应恢复"); + assert.equal(fs.existsSync(assetSourcePath), false, "清空垃圾箱后资源源文件不应恢复"); + + evidence.steps.push({ + step: 5, + label: "最终验证:trash-index 为空、源文件未恢复", + ok: true, + entryKeys, + }); + + // 收集完整导航事件 + evidence.navEvents = navigationEvents.map((e) => ({ + url: e.url.length > 120 ? e.url.substring(0, 120) + "…" : e.url, + timestamp: e.timestamp, + })); + evidence.ok = true; + + console.log(JSON.stringify({ ok: true, resultPath: RESULT_PATH, steps: evidence.steps.length }, null, 2)); + } catch (error) { + evidence.error = error instanceof Error ? error.stack || error.message : String(error); + evidence.pageUrl = page.url(); + evidence.pageText = await page.locator('[data-testid="mnote-trash-workbench"]').innerText({ timeout: 3000 }).catch(() => ""); + throw error; + } finally { + // 写结果文件 + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + fs.writeFileSync(RESULT_PATH, `${JSON.stringify(evidence, null, 2)}\n`, "utf8"); + console.log(JSON.stringify({ ok: evidence.ok, resultPath: RESULT_PATH, error: evidence.error || null }, null, 2)); + + await context.close().catch(() => undefined); + await browser.close().catch(() => undefined); + if (!process.env.MNOTE_KEEP_SMOKE_TMP) { + fs.rmSync(root, { recursive: true, force: true }); + } + } +} + +main().catch((error) => { + console.error(error && error.stack ? error.stack : error); + process.exit(1); +}); diff --git a/scripts/task475-local-folder-markdown-trash-lifecycle-smoke.js b/scripts/task475-local-folder-markdown-trash-lifecycle-smoke.js new file mode 100644 index 00000000..e7fa8d4a --- /dev/null +++ b/scripts/task475-local-folder-markdown-trash-lifecycle-smoke.js @@ -0,0 +1,203 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); +const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task475-local-folder-markdown-trash-lifecycle-smoke"); +const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); + +function fileUrl(localPath) { + return `file://${localPath}`; +} + +function writeWorkspaceManifest(root) { + const metadataDir = path.join(root, ".mnote"); + fs.mkdirSync(metadataDir, { recursive: true }); + fs.writeFileSync( + path.join(metadataDir, "workspace.json"), + `${JSON.stringify({ + workspaceId: "local-ws:user_real:task475", + ownerId: "user_real", + createdAt: new Date().toISOString(), + capabilities: ["local_files", "tree_commands", "markdown_edit"], + }, null, 2)}\n`, + "utf8", + ); +} + +async function postTreeCommand(payload) { + const response = await fetch(`${BASE_URL}/api/tree/commands`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-mnote-actor-id": "user_real", + "x-mnote-actor-type": "user", + }, + body: JSON.stringify(payload), + }); + const json = await response.json().catch(async () => ({ text: await response.text() })); + assert.equal(response.status, 200, `${payload.action} failed: ${JSON.stringify(json)}`); + return json; +} + +async function runStep(label, action) { + const detail = await action(); + return { label, detail: detail || null }; +} + +async function run() { + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-local-md-trash-smoke-")); + fs.mkdirSync(path.join(root, "docs"), { recursive: true }); + writeWorkspaceManifest(root); + fs.writeFileSync(path.join(root, "README.md"), "# Local Root\n", "utf8"); + + // 创建 loose Markdown 页面(非 bundle) + const mdFileName = "test-page.md"; + const mdContent = "# Test Page\n\nThis is a test Markdown page for trash lifecycle.\n"; + const sourceMdPath = path.join(root, "docs", mdFileName); + fs.writeFileSync(sourceMdPath, mdContent, "utf8"); + + const rootUri = fileUrl(root); + // Loose Markdown 的 documentId 编码:local-md:{encoded_relative_path} + // `~2F` 是 URL 编码的 `/` 的替代分隔符(由 encode_local_id_segment 产生) + const documentId = "local-md:docs~2Ftest-page.md"; + // trash 路径由 next_available_path 生成,文件名取 file_stem_title 即去掉 .md 扩展名 + const trashFileName = "test-page.md"; + const trashPath = path.join(root, ".mnote", "trash", trashFileName); + const trashIndexPath = path.join(root, ".mnote", "trash-index.json"); + + const steps = []; + try { + // ===== Step 1: delete -> archive to local trash ===== + steps.push(await runStep("loose Markdown delete 进入本地回收站", async () => { + const result = await postTreeCommand({ + action: "delete", + sourceKind: "local_folder", + rootUri, + documentId, + }); + const exec = result.result?.execution || {}; + // 当前 trash_local_markdown_page 不返回 canonicalCommand, + // 资源 lifecycle 中非 md 文件会返回 "tree.resource.archive", + // 此处记录真实值,不强制断言不存在或特定值 + const canonicalCommand = exec.canonicalCommand; + // 源文件消失 + assert.equal(fs.existsSync(sourceMdPath), false, "Delete 后源 .md 文件应消失"); + // trash 中出现对应文件 + assert.equal(fs.existsSync(trashPath), true, "Delete 后文件应进入 .mnote/trash"); + assert.equal(exec.resourceKind, "markdown", "delete 返回 resourceKind 应为 markdown"); + // trash-index.json 记录 local-md:* entry + assert.ok(fs.existsSync(trashIndexPath), "trash-index.json 应存在"); + const index = fs.readFileSync(trashIndexPath, "utf8"); + assert.match(index, /local-md:docs~2Ftest-page\.md/, "trash index 应记录 local-md entry"); + return { documentId, resourceKind: exec.resourceKind, canonicalCommand, trashIndexContains: true }; + })); + + // ===== Step 2: restore -> 恢复回原路径 ===== + steps.push(await runStep("loose Markdown restore 回原路径", async () => { + const result = await postTreeCommand({ + action: "restore", + sourceKind: "local_folder", + rootUri, + documentId, + }); + const exec = result.result?.execution || {}; + // 源 .md 恢复 + assert.equal(fs.existsSync(sourceMdPath), true, "restore 后源 .md 文件应恢复"); + assert.equal(fs.readFileSync(sourceMdPath, "utf8"), mdContent, "restore 后文件内容应不变"); + // trash 文件消失 + assert.equal(fs.existsSync(trashPath), false, "restore 后 trash 文件应消失"); + // index 清理 + const index = fs.readFileSync(trashIndexPath, "utf8"); + assert.doesNotMatch(index, /local-md:docs~2Ftest-page\.md/, "restore 后 trash index 应清理 entry"); + return { documentId: exec.documentId, previousDocumentId: exec.previousDocumentId }; + })); + + // ===== Step 3: 再次 delete ===== + steps.push(await runStep("loose Markdown 再次 delete 进回收站", async () => { + const result = await postTreeCommand({ + action: "delete", + sourceKind: "local_folder", + rootUri, + documentId, + }); + const exec = result.result?.execution || {}; + assert.equal(fs.existsSync(sourceMdPath), false, "再次 delete 后源 .md 文件应消失"); + assert.equal(fs.existsSync(trashPath), true, "再次 delete 后 trash 文件应出现"); + const index = fs.readFileSync(trashIndexPath, "utf8"); + assert.match(index, /local-md:docs~2Ftest-page\.md/, "再次 delete 后 trash index 应恢复记录"); + return { resourceKind: exec.resourceKind }; + })); + + // ===== Step 4: purge -> 永久删除 ===== + steps.push(await runStep("loose Markdown purge 永久删除", async () => { + const result = await postTreeCommand({ + action: "purge", + sourceKind: "local_folder", + rootUri, + documentId, + }); + const exec = result.result?.execution || {}; + // 源路径与 trash 路径均不存在 + assert.equal(fs.existsSync(sourceMdPath), false, "purge 后源 .md 文件不应存在"); + assert.equal(fs.existsSync(trashPath), false, "purge 后 trash 文件不应存在"); + // index 清理 + const index = fs.readFileSync(trashIndexPath, "utf8"); + assert.doesNotMatch(index, /local-md:docs~2Ftest-page\.md/, "purge 后 trash index 应清理 entry"); + return { ok: exec.ok }; + })); + + // ===== Step 5: 完整周期后再次创建+delete+purge,确保 trash-index.json 整体结构仍合法 ===== + steps.push(await runStep("重新创建并清理验证 trash-index.json 完整性", async () => { + fs.writeFileSync(sourceMdPath, "# Second\n", "utf8"); + await postTreeCommand({ + action: "delete", + sourceKind: "local_folder", + rootUri, + documentId, + }); + await postTreeCommand({ + action: "purge", + sourceKind: "local_folder", + rootUri, + documentId, + }); + // trash-index.json 应仍是合法 JSON + const indexRaw = fs.readFileSync(trashIndexPath, "utf8"); + assert.doesNotThrow(() => JSON.parse(indexRaw), "trash-index.json 应为合法 JSON"); + return { trashIndexValid: true }; + })); + + const result = { + ok: true, + task: "task475-local-folder-markdown-trash-lifecycle-smoke", + baseUrl: BASE_URL, + root, + documentId, + sourceType: "loose Markdown page (non-bundle)", + note: "loose Markdown 当前不返回 canonicalCommand(trash_local_markdown_page 无此字段),bundled Markdown case 未覆盖(bundle 涉及目录整体移动/恢复,Rust 单测覆盖更充分)", + steps, + }; + fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8"); + console.log(`task475 local folder Markdown trash lifecycle smoke passed: ${RESULT_PATH}`); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + +run().catch((error) => { + const result = { + ok: false, + task: "task475-local-folder-markdown-trash-lifecycle-smoke", + baseUrl: BASE_URL, + error: error && error.stack ? error.stack : String(error), + }; + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8"); + console.error(error && error.stack ? error.stack : error); + process.exit(1); +});