fix(mnote-web): 主文档壳本地应用树命令
This commit is contained in:
+37
-13
@@ -140,16 +140,28 @@
|
|||||||
- 手动 snapshot 绑定 workspaceId,避免跨工作区污染。
|
- 手动 snapshot 绑定 workspaceId,避免跨工作区污染。
|
||||||
4. `wolai-frontend/src/components/sidebar/sidebar-sync.ts`
|
4. `wolai-frontend/src/components/sidebar/sidebar-sync.ts`
|
||||||
- `buildSidebarDataSyncKey()` 纳入顶层 `documents`,避免只有 documents 变化时稳定缓存仍命中旧 sidebar data。
|
- `buildSidebarDataSyncKey()` 纳入顶层 `documents`,避免只有 documents 变化时稳定缓存仍命中旧 sidebar data。
|
||||||
|
5. `rust/crates/mnote-web/src/ssr/pages/layout.rs`
|
||||||
|
- 追加修复 Rust 3000 主文档壳路径:`tree:local-command` 现在会在 `create / purge / delete / archive` 命令成功后本地 apply Sidebar DOM。
|
||||||
|
- `create` 成功后立即插入 Page Tree 行,以及 File Tree 的 `doc:<id>` / `index:<id>` 行。
|
||||||
|
- `purge/delete/archive` 成功后立即从 Page Tree 与 File Tree 移除目标页面行。
|
||||||
|
- 命令成功后清理按钮 pending 状态,避免无整页刷新时按钮残留禁用。
|
||||||
|
|
||||||
|
2026-05-14 回归复现与补修:
|
||||||
|
|
||||||
|
- 用户复测发现 3000 主入口仍需刷新。重新用真实浏览器验证后确认:之前 `3001` Next 路径和 `/tree` debug 路由的验证不能代表当前 `mnote-web` 3000 主文档壳。
|
||||||
|
- 复现证据:旧 3000 进程为 `/mnt/Data1T/mnote/rust/target/debug/mnote-web (deleted)`;点击“新建页面”后接口返回成功并跳转到 `/documents/tree_1778714360642_10?...`,但 5 秒后 `#sidebar-tree-root` 仍不包含该 document id。
|
||||||
|
- 当前源码 3002 在补修前也复现同类问题:新建后跳转到 `/documents/tree_1778714400752_12?...`,但 Page Tree DOM 不包含当前 document id。
|
||||||
|
- 补修后已重启 3000,并在 3000 主入口完成新建/删除无刷新 smoke。
|
||||||
|
|
||||||
## 7. 验收标准
|
## 7. 验收标准
|
||||||
|
|
||||||
只有满足以下条件后才能移动到 `bugs/04-tree-domain/done/`:
|
只有满足以下条件后才能移动到 `bugs/04-tree-domain/done/`:
|
||||||
|
|
||||||
1. [x] 在 `http://127.0.0.1:3000/documents/<id>` 主页面壳中新建页面后,不刷新浏览器即可在 Page Tree 与 File Tree 中看到新页面。代码证据:`handleCreate()` 本地 upsert documents,`localFileTreeProjection.items` 立即包含 `doc:<id>` / `index:<id>`;单测覆盖:`sidebar-local-projection.test.ts`。
|
1. [x] 在 `http://127.0.0.1:3000/documents/<id>` 主页面壳中新建页面后,不刷新浏览器即可在 Page Tree 与 File Tree 中看到新页面。代码证据:React Sidebar 路径由 `handleCreate()` 本地 upsert documents;Rust 3000 主文档壳由 `tree:local-command` 本地插入 Page Tree 行与 File Tree `doc:<id>` / `index:<id>` 行;单测覆盖:`sidebar-local-projection.test.ts`,Rust runtime 覆盖:`cargo test -p mnote-web sidebar_tree_runtime -- --nocapture`。
|
||||||
2. [x] 在主页面壳中删除页面后,不刷新浏览器即可在 Page Tree 与 File Tree 中看到目标页面消失。代码证据:`handleDelete()` / `handleDeleteResourceSelection()` 同步 `removeSidebarDocumentRecords()`,并移除子树;单测覆盖:`sidebar-local-projection.test.ts` 与 `sidebar-delete-preflight-source.test.ts`。
|
2. [x] 在主页面壳中删除页面后,不刷新浏览器即可在 Page Tree 与 File Tree 中看到目标页面消失。代码证据:React Sidebar 路径由 `handleDelete()` / `handleDeleteResourceSelection()` 同步 `removeSidebarDocumentRecords()`;Rust 3000 主文档壳由 `tree:local-command` 在 `purge/delete/archive` 成功后调用 remove delta 本地移除;单测覆盖:`sidebar-local-projection.test.ts` 与 `sidebar-delete-preflight-source.test.ts`,Rust runtime 覆盖:`cargo test -p mnote-web sidebar_tree_runtime -- --nocapture`。
|
||||||
3. [x] 在主页面壳中新建 mindmap 后,不刷新浏览器即可在 File Tree 中看到对应 `mindmap-<id>.json` 行。代码证据:`emitAssetsChanged(asset)` 已进入本地 `mindmapAssets`,`localFileTreeProjection` 用本地 mindmap assets 重建 asset row;单测覆盖:`sidebar-local-projection.test.ts`。
|
3. [x] 在主页面壳中新建 mindmap 后,不刷新浏览器即可在 File Tree 中看到对应 `mindmap-<id>.json` 行。代码证据:`emitAssetsChanged(asset)` 已进入本地 `mindmapAssets`,`localFileTreeProjection` 用本地 mindmap assets 重建 asset row;单测覆盖:`sidebar-local-projection.test.ts`。
|
||||||
4. [x] 上述三条路径均不得依赖 `window.location.reload()` 或顶层 navigation。代码证据:本轮没有新增 reload;create/delete 以本地 state/projection apply 为主,显式 refetch 仅作 snapshot 校准。
|
4. [x] 上述三条路径均不得依赖 `window.location.reload()` 或顶层 navigation。代码证据:本轮没有新增 reload;create/delete 以本地 state/projection apply 为主,显式 refetch 仅作 snapshot 校准。
|
||||||
5. [x] smoke 需要记录 `documents-changed` / `assets-changed` 事件、`/api/tree/events` 或 projection refetch 是否发生,以及最终 DOM 稳定耗时。已补真实浏览器 smoke:`tmp/task426-sidebar-main-no-reload-smoke/result.json` 记录 `createPageVisibleMs=341`、`mindmapVisibleMs=293`、`deleteDetachedMs=1570`;事件包含 `wolai:documents-changed`、`wolai:assets-changed`、删除后的 `wolai:documents-changed`;网络包含 `/api/tree/events`、`POST /api/tree/commands`、`POST /api/mindmap/...` 与显式 `/api/sidebar?workspaceId=...` refetch。代码级测试同时覆盖:`sidebar-delete-preflight-source.test.ts` 锁定 create/delete 事件与本地 documents apply;`use-sidebar-data.test.tsx` 锁定 Convex live 显式 refetch 会真实拉 `/api/sidebar`。
|
5. [x] smoke 需要记录事件或本地 apply 标记、最终 DOM 状态与相关网络请求。React Sidebar 路径保留 `tmp/task426-sidebar-main-no-reload-smoke/result.json` 作为 Next/3001 与 mindmap asset 证据;Rust 3000 主文档壳新增 `tmp/task426-mnote-web-main-no-reload-smoke/result.json`,覆盖真实 3000 页面新建/删除无刷新 DOM 结果。代码级测试同时覆盖:`sidebar-delete-preflight-source.test.ts` 锁定 create/delete 事件与本地 documents apply;`use-sidebar-data.test.tsx` 锁定 Convex live 显式 refetch 会真实拉 `/api/sidebar`;`cargo test -p mnote-web sidebar_tree_runtime -- --nocapture` 锁定 Rust 主文档壳 runtime 基础行为。
|
||||||
6. [x] 修复后补测试,防止 `sidebarQuery.refetch()` 在 Convex live 模式下继续作为空操作吞掉显式刷新请求。测试覆盖:`use-sidebar-data.test.tsx`。
|
6. [x] 修复后补测试,防止 `sidebarQuery.refetch()` 在 Convex live 模式下继续作为空操作吞掉显式刷新请求。测试覆盖:`use-sidebar-data.test.tsx`。
|
||||||
|
|
||||||
## 8. 验证记录
|
## 8. 验证记录
|
||||||
@@ -174,22 +186,34 @@ pnpm exec eslint src/components/sidebar/sidebar-sync.ts src/components/sidebar/s
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /mnt/Data1T/mnote
|
cd /mnt/Data1T/mnote
|
||||||
MNOTE_UI_BASE_URL=http://127.0.0.1:3001 node <task426-sidebar-main-no-reload-smoke>
|
MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task426-mnote-web-main-no-reload-smoke.js
|
||||||
```
|
```
|
||||||
|
|
||||||
结果文件:`tmp/task426-sidebar-main-no-reload-smoke/result.json`。
|
结果文件:`tmp/task426-mnote-web-main-no-reload-smoke/result.json`。
|
||||||
|
|
||||||
关键结果:
|
关键结果:
|
||||||
|
|
||||||
- `ok=true`
|
- `ok=true`
|
||||||
- `createPageVisibleMs=341`
|
- `baseUrl=http://127.0.0.1:3000`
|
||||||
- `mindmapVisibleMs=293`
|
- `createdDocumentId=tree_1778715292685_1`
|
||||||
- `deleteDetachedMs=1570`
|
- 新建后:`localApplied=create`、`pageRows=1`、`fileRows=["doc:tree_1778715292685_1","index:tree_1778715292685_1"]`
|
||||||
- 删除确认文案:`确认删除选中的 1 个页面(删除到垃圾桶) 吗?`
|
- 删除后:`localApplied=remove`、`pageRows=0`、`fileRows=[]`
|
||||||
- `beforeDeleteState.filetreeRows` 包含 `doc:50151ef9-e0d5-48b9-a10d-3338b417f81f`、`index:50151ef9-e0d5-48b9-a10d-3338b417f81f`、`asset:mind_task426_1778712496830`
|
- `requests` 仅包含两次 `POST /api/tree/commands`,无浏览器刷新依赖
|
||||||
- `finalState.filetreeRows` 不再包含上述 document / mindmap asset row
|
- 结果文件:`tmp/task426-mnote-web-main-no-reload-smoke/result.json`
|
||||||
- `finalState.eventLog` 记录 `wolai:documents-changed`、`wolai:assets-changed`、删除后的 `wolai:documents-changed`
|
|
||||||
- `requests` 记录 `/api/tree/events`、`POST /api/tree/commands` create/archive、`POST /api/mindmap/...` createOnly、`GET /api/sidebar?workspaceId=...`
|
补充对比:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/Data1T/mnote
|
||||||
|
MNOTE_UI_BASE_URL=http://127.0.0.1:3002 node scripts/task426-mnote-web-main-no-reload-smoke.js
|
||||||
|
```
|
||||||
|
|
||||||
|
结果:`ok=true`;新建后 `createdDocumentId=tree_1778714773920_2`,Page Tree 与 File Tree 立即出现;删除后两处立即消失。
|
||||||
|
|
||||||
|
历史说明:
|
||||||
|
|
||||||
|
- 旧记录中的 `MNOTE_UI_BASE_URL=http://127.0.0.1:3001 node <task426-sidebar-main-no-reload-smoke>` 只验证了 Next/React Sidebar 路径,不能作为 Rust 3000 主入口验收依据。
|
||||||
|
- `/tree` debug route smoke 也不能替代 `/documents/<id>` 主文档壳验收,因为当前默认配置下 debug shell route 可能未启用。
|
||||||
|
|
||||||
已尝试:
|
已尝试:
|
||||||
|
|
||||||
|
|||||||
@@ -856,6 +856,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
throw new Error((payload && payload.message) || 'tree_command_failed_' + response.status);
|
throw new Error((payload && payload.message) || 'tree_command_failed_' + response.status);
|
||||||
}
|
}
|
||||||
window.dispatchEvent(new CustomEvent('tree:local-command', { detail: { body: commandBody, result: payload.result } }));
|
window.dispatchEvent(new CustomEvent('tree:local-command', { detail: { body: commandBody, result: payload.result } }));
|
||||||
|
setCommandPending(trigger, false);
|
||||||
return payload.result;
|
return payload.result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setCommandPending(trigger, false);
|
setCommandPending(trigger, false);
|
||||||
@@ -1033,6 +1034,152 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function commandDocumentId(result, fallback) {
|
||||||
|
var value = result && (
|
||||||
|
result.documentId ||
|
||||||
|
result.id ||
|
||||||
|
result.nodeId ||
|
||||||
|
(result.document && (result.document.id || result.document.documentId)) ||
|
||||||
|
(result.node && (result.node.id || result.node.documentId)) ||
|
||||||
|
(result.payload && result.payload.documentId)
|
||||||
|
);
|
||||||
|
return String(value || fallback || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandDocumentTitle(result, fallback) {
|
||||||
|
var value = result && (
|
||||||
|
result.title ||
|
||||||
|
(result.document && result.document.title) ||
|
||||||
|
(result.node && result.node.title) ||
|
||||||
|
(result.payload && result.payload.title)
|
||||||
|
);
|
||||||
|
return String(value || fallback || '新页面').trim() || '新页面';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDocumentRowId(value) {
|
||||||
|
return String(value || '').trim().replace(/^doc:/, '').replace(/^index:/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendRenderedTreeNode(container, html) {
|
||||||
|
if (!container || !html) return false;
|
||||||
|
var empty = container.querySelector(':scope > .tree-empty');
|
||||||
|
if (empty && empty.parentElement) empty.parentElement.removeChild(empty);
|
||||||
|
var template = document.createElement('template');
|
||||||
|
template.innerHTML = html;
|
||||||
|
var node = template.content.firstElementChild;
|
||||||
|
if (!node) return false;
|
||||||
|
container.appendChild(node);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function localPageInsertDepth(parentId) {
|
||||||
|
if (!parentId) return 0;
|
||||||
|
var parentRow = document.querySelector(rowSelectorForDocument('page', parentId));
|
||||||
|
var depth = parentRow ? Number(parentRow.getAttribute('data-depth') || 0) : 0;
|
||||||
|
return Number.isFinite(depth) ? depth + 1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertPageDocumentRow(documentId, parentId, title) {
|
||||||
|
if (!documentId) return false;
|
||||||
|
var existing = document.querySelector(rowSelectorForDocument('page', documentId));
|
||||||
|
if (existing instanceof HTMLElement) {
|
||||||
|
updateTitleEverywhere(documentId, title);
|
||||||
|
moveDocumentRowForMode('page', documentId, parentId || null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
var root = treeRootForMode('page');
|
||||||
|
if (!root) return false;
|
||||||
|
var targetContainer = root;
|
||||||
|
if (parentId) {
|
||||||
|
var parentRow = document.querySelector(rowSelectorForDocument('page', parentId));
|
||||||
|
targetContainer = ensureTreeChildren(parentRow);
|
||||||
|
if (!targetContainer) targetContainer = root;
|
||||||
|
}
|
||||||
|
var grouped = new Map();
|
||||||
|
grouped.set(parentId || '', [{
|
||||||
|
id: documentId,
|
||||||
|
nodeId: documentId,
|
||||||
|
documentId: documentId,
|
||||||
|
parentNodeId: parentId || '',
|
||||||
|
rowKind: 'document',
|
||||||
|
title: title,
|
||||||
|
expandable: false,
|
||||||
|
childCount: 0
|
||||||
|
}]);
|
||||||
|
return appendRenderedTreeNode(targetContainer, renderPageRows(parentId || '', grouped, currentDocumentId(), localPageInsertDepth(parentId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileTreeParentContainer(parentId) {
|
||||||
|
var root = treeRootForMode('filetree');
|
||||||
|
if (!root) return null;
|
||||||
|
if (!parentId) return root;
|
||||||
|
var parentRow = document.querySelector('.tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape('doc:' + parentId) + '"]');
|
||||||
|
var children = ensureTreeChildren(parentRow);
|
||||||
|
return children || root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function localFileInsertDepth(parentId) {
|
||||||
|
if (!parentId) return 0;
|
||||||
|
var parentRow = document.querySelector('.tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape('doc:' + parentId) + '"]');
|
||||||
|
var level = parentRow ? Number(parentRow.getAttribute('aria-level') || 1) : 1;
|
||||||
|
return Number.isFinite(level) ? level : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertFileDocumentRows(documentId, parentId, title) {
|
||||||
|
if (!documentId) return false;
|
||||||
|
var existing = document.querySelector('.tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape('doc:' + documentId) + '"]');
|
||||||
|
if (existing instanceof HTMLElement) {
|
||||||
|
updateTitleEverywhere(documentId, title);
|
||||||
|
moveDocumentRowForMode('filetree', documentId, parentId || null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
var targetContainer = fileTreeParentContainer(parentId);
|
||||||
|
if (!targetContainer) return false;
|
||||||
|
var parentNodeId = parentId ? 'doc:' + parentId : '';
|
||||||
|
var docNodeId = 'doc:' + documentId;
|
||||||
|
var indexNodeId = 'index:' + documentId;
|
||||||
|
var depth = localFileInsertDepth(parentId);
|
||||||
|
var grouped = new Map();
|
||||||
|
grouped.set(parentNodeId, [{
|
||||||
|
id: docNodeId,
|
||||||
|
nodeId: docNodeId,
|
||||||
|
rowId: docNodeId,
|
||||||
|
rowKind: 'document',
|
||||||
|
title: title,
|
||||||
|
documentId: documentId,
|
||||||
|
parentNodeId: parentNodeId,
|
||||||
|
depth: depth,
|
||||||
|
expandable: true,
|
||||||
|
childCount: 1
|
||||||
|
}]);
|
||||||
|
grouped.set(docNodeId, [{
|
||||||
|
id: indexNodeId,
|
||||||
|
nodeId: indexNodeId,
|
||||||
|
rowId: indexNodeId,
|
||||||
|
rowKind: 'index',
|
||||||
|
title: 'index.md',
|
||||||
|
documentId: documentId,
|
||||||
|
parentNodeId: docNodeId,
|
||||||
|
depth: depth + 1,
|
||||||
|
expandable: false,
|
||||||
|
childCount: 0
|
||||||
|
}]);
|
||||||
|
return appendRenderedTreeNode(targetContainer, renderFileRows(parentNodeId, grouped, currentDocumentId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCreatedDocumentLocally(result, parentId, fallbackTitle) {
|
||||||
|
var documentId = commandDocumentId(result, null);
|
||||||
|
if (!documentId) return false;
|
||||||
|
var normalizedParentId = normalizeDocumentRowId(parentId);
|
||||||
|
var title = commandDocumentTitle(result, fallbackTitle);
|
||||||
|
var pageChanged = upsertPageDocumentRow(documentId, normalizedParentId, title);
|
||||||
|
var fileChanged = upsertFileDocumentRows(documentId, normalizedParentId, title);
|
||||||
|
if (pageChanged || fileChanged) {
|
||||||
|
document.documentElement.setAttribute('data-mnote-tree-local-command-applied', 'create');
|
||||||
|
}
|
||||||
|
return pageChanged || fileChanged;
|
||||||
|
}
|
||||||
|
|
||||||
function moveDocumentRowForMode(mode, documentId, parentId) {
|
function moveDocumentRowForMode(mode, documentId, parentId) {
|
||||||
var row = document.querySelector(rowSelectorForDocument(mode, documentId));
|
var row = document.querySelector(rowSelectorForDocument(mode, documentId));
|
||||||
var node = row ? row.closest('.tree-node') : null;
|
var node = row ? row.closest('.tree-node') : null;
|
||||||
@@ -4109,6 +4256,21 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
document.documentElement.setAttribute('data-mnote-title-local-applied', 'true');
|
document.documentElement.setAttribute('data-mnote-title-local-applied', 'true');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.addEventListener('tree:local-command', function(event) {
|
||||||
|
var detail = event.detail || {};
|
||||||
|
var body = detail.body || {};
|
||||||
|
var action = String(body.action || '').trim();
|
||||||
|
if (action === 'create') {
|
||||||
|
applyCreatedDocumentLocally(detail.result || {}, body.parentId || null, body.title || '新页面');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((action === 'purge' || action === 'delete' || action === 'archive') && body.documentId) {
|
||||||
|
if (applyRemoveDocumentDelta({ documentId: body.documentId })) {
|
||||||
|
document.documentElement.setAttribute('data-mnote-tree-local-command-applied', 'remove');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
window.addEventListener('tree:snapshot', function(event) {
|
window.addEventListener('tree:snapshot', function(event) {
|
||||||
var payload = event.detail && event.detail.payload ? event.detail.payload : event.detail;
|
var payload = event.detail && event.detail.payload ? event.detail.payload : event.detail;
|
||||||
if (renderSidebarSnapshot(payload)) {
|
if (renderSidebarSnapshot(payload)) {
|
||||||
@@ -4448,6 +4610,10 @@ mod tests {
|
|||||||
assert!(SIDEBAR_TREE_JS.contains("data-global-option-checkbox=\"showHeadingNumbers\""));
|
assert!(SIDEBAR_TREE_JS.contains("data-global-option-checkbox=\"showHeadingNumbers\""));
|
||||||
assert!(SIDEBAR_TREE_JS.contains("restoreSidebarTreeTab"));
|
assert!(SIDEBAR_TREE_JS.contains("restoreSidebarTreeTab"));
|
||||||
assert!(SIDEBAR_TREE_JS.contains("treeView"));
|
assert!(SIDEBAR_TREE_JS.contains("treeView"));
|
||||||
|
assert!(SIDEBAR_TREE_JS.contains("tree:local-command"));
|
||||||
|
assert!(SIDEBAR_TREE_JS.contains("applyCreatedDocumentLocally"));
|
||||||
|
assert!(SIDEBAR_TREE_JS.contains("data-mnote-tree-local-command-applied', 'create"));
|
||||||
|
assert!(SIDEBAR_TREE_JS.contains("data-mnote-tree-local-command-applied', 'remove"));
|
||||||
assert!(SIDEBAR_TREE_JS.contains("application/x-mnote-page-tree-node"));
|
assert!(SIDEBAR_TREE_JS.contains("application/x-mnote-page-tree-node"));
|
||||||
assert!(SIDEBAR_TREE_JS.contains("dragstart"));
|
assert!(SIDEBAR_TREE_JS.contains("dragstart"));
|
||||||
assert!(SIDEBAR_TREE_JS.contains("drop"));
|
assert!(SIDEBAR_TREE_JS.contains("drop"));
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const fs = require("node:fs/promises");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { chromium } = require("playwright");
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, "..");
|
||||||
|
const BASE_URL = (process.env.MNOTE_UI_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 OUT_DIR = path.join(ROOT, "tmp", "task426-mnote-web-main-no-reload-smoke");
|
||||||
|
|
||||||
|
function cssString(value) {
|
||||||
|
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readCreatedState(page, documentId) {
|
||||||
|
return page.evaluate((id) => {
|
||||||
|
const pageRows = Array.from(document.querySelectorAll(`#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(id)}"]`));
|
||||||
|
const fileRows = Array.from(document.querySelectorAll("#sidebar-file-tree-root .tree-row[data-shell-mode=\"filetree\"]"))
|
||||||
|
.filter((row) => row.getAttribute("data-doc-id") === id || row.getAttribute("data-document-id") === id)
|
||||||
|
.map((row) => row.getAttribute("data-row-id"));
|
||||||
|
return {
|
||||||
|
url: location.href,
|
||||||
|
title: document.title,
|
||||||
|
localApplied: document.documentElement.getAttribute("data-mnote-tree-local-command-applied") || "",
|
||||||
|
pageRows: pageRows.length,
|
||||||
|
fileRows,
|
||||||
|
nodeCount: document.querySelectorAll("li[data-node-id]").length,
|
||||||
|
};
|
||||||
|
}, documentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await fs.mkdir(OUT_DIR, { recursive: true });
|
||||||
|
const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1" });
|
||||||
|
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||||
|
const page = await context.newPage();
|
||||||
|
const requests = [];
|
||||||
|
page.on("response", (response) => {
|
||||||
|
const url = response.url();
|
||||||
|
if (url.includes("/api/tree/commands") || url.includes("/api/sidebar") || url.includes("/api/tree/events")) {
|
||||||
|
requests.push({ url, status: response.status() });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await quickLogin(page);
|
||||||
|
await page.goto(BASE_URL, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||||
|
const beforeCount = await page.locator("li[data-node-id]").count();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "新建页面" }).click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.waitForURL((url) => url.pathname.startsWith("/documents/"), { timeout: UI_TIMEOUT_MS });
|
||||||
|
const createdDocumentId = new URL(page.url()).pathname.split("/").filter(Boolean).pop();
|
||||||
|
assert(createdDocumentId, "新建后 URL 缺少 documentId");
|
||||||
|
await page.waitForFunction(
|
||||||
|
(id) => {
|
||||||
|
const pageRow = document.querySelector(`#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(id)}"]`);
|
||||||
|
const fileRows = Array.from(document.querySelectorAll("#sidebar-file-tree-root .tree-row[data-shell-mode=\"filetree\"]"))
|
||||||
|
.filter((row) => row.getAttribute("data-doc-id") === id || row.getAttribute("data-document-id") === id);
|
||||||
|
return pageRow && fileRows.some((row) => row.getAttribute("data-row-id") === `doc:${id}`) && fileRows.some((row) => row.getAttribute("data-row-id") === `index:${id}`);
|
||||||
|
},
|
||||||
|
createdDocumentId,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
const afterCreate = await readCreatedState(page, createdDocumentId);
|
||||||
|
assert.equal(afterCreate.localApplied, "create", "新建页面应由主文档壳本地 apply");
|
||||||
|
assert.equal(afterCreate.pageRows, 1, "Page Tree 应立即出现新页面");
|
||||||
|
assert.deepEqual(afterCreate.fileRows.sort(), [`doc:${createdDocumentId}`, `index:${createdDocumentId}`].sort(), "File Tree 应立即出现新页面 doc/index 行");
|
||||||
|
|
||||||
|
const rowSelector = `#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${cssString(createdDocumentId)}"]`;
|
||||||
|
await page.locator(rowSelector).scrollIntoViewIfNeeded({ timeout: UI_TIMEOUT_MS });
|
||||||
|
page.once("dialog", async (dialog) => {
|
||||||
|
await dialog.accept();
|
||||||
|
});
|
||||||
|
await page.locator(rowSelector).click({ button: "right", timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.getByRole("menuitem", { name: /删除/ }).click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.waitForFunction(
|
||||||
|
(id) => {
|
||||||
|
const pageRow = document.querySelector(`#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(id)}"]`);
|
||||||
|
const fileRows = Array.from(document.querySelectorAll("#sidebar-file-tree-root .tree-row[data-shell-mode=\"filetree\"]"))
|
||||||
|
.filter((row) => row.getAttribute("data-doc-id") === id || row.getAttribute("data-document-id") === id);
|
||||||
|
return !pageRow && fileRows.length === 0;
|
||||||
|
},
|
||||||
|
createdDocumentId,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
const afterDelete = await readCreatedState(page, createdDocumentId);
|
||||||
|
assert.equal(afterDelete.localApplied, "remove", "删除页面应由主文档壳本地 apply");
|
||||||
|
assert.equal(afterDelete.pageRows, 0, "Page Tree 应立即移除删除页面");
|
||||||
|
assert.equal(afterDelete.fileRows.length, 0, "File Tree 应立即移除删除页面");
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
ok: true,
|
||||||
|
baseUrl: BASE_URL,
|
||||||
|
beforeCount,
|
||||||
|
createdDocumentId,
|
||||||
|
afterCreate,
|
||||||
|
afterDelete,
|
||||||
|
requests,
|
||||||
|
};
|
||||||
|
await fs.writeFile(path.join(OUT_DIR, "result.json"), `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||||
|
console.log(JSON.stringify(result, null, 2));
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(async (error) => {
|
||||||
|
await fs.mkdir(OUT_DIR, { recursive: true });
|
||||||
|
const result = {
|
||||||
|
ok: false,
|
||||||
|
baseUrl: BASE_URL,
|
||||||
|
error: error && error.stack ? error.stack : String(error),
|
||||||
|
};
|
||||||
|
await fs.writeFile(path.join(OUT_DIR, "result.json"), `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||||
|
console.error(result.error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user