test: 收口本地文件夹垃圾箱 A3 smoke
This commit is contained in:
@@ -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 覆盖内容
|
||||
- 验证命令和结果
|
||||
- 未完成项 / 风险
|
||||
@@ -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 覆盖内容
|
||||
- 验证命令和结果
|
||||
- 未完成项 / 风险
|
||||
@@ -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 复核的点
|
||||
@@ -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 不可达或浏览器无法启动。
|
||||
@@ -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()` 性能未覆盖 |
|
||||
@@ -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` 包装层变动需同步更新。 |
|
||||
@@ -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 行为。
|
||||
+9
-6
@@ -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。
|
||||
+2
-1
@@ -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/`。
|
||||
+121
@@ -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:<port> 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:<port> 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 查看。
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user