feat: consolidate local-first mnote web runtime
This commit is contained in:
@@ -8,8 +8,8 @@
|
||||
- 对 `mnote` 来说,最有价值的不是把所有测试都换成“浏览器插件式控制”,而是把已有 smoke 的结果变得更可看、更易复核。
|
||||
- `/doko` 适合作为 smoke 之后的“真实页面直观复核层”,不是用来替代 smoke。
|
||||
- 2026-05-20 起,默认主入口固定按 `http://127.0.0.1:3000` 理解;历史记录里的 `3001` 只代表当时临时 mnote-web 实例,不再作为默认验收入口。
|
||||
- 历史 smoke 分三类维护:`current` 可直接作为当前主链证据,`compat` 只验证 Convex/control-plane/兼容链,`retired/debug` 不应进入默认回归,除非脚本自身要求显式环境变量。
|
||||
- 2026-05-26 起,默认 smoke 基线只覆盖 `3000 Rust SSR + leptos-tiptap + local-first` 主路径;Convex export、Next、3104、BlockNote 默认路径脚本不再放在默认候选里。
|
||||
- 历史 smoke 分为 `current` 与 `retired/debug` 两类维护;退役脚本不应进入默认回归,除非脚本自身要求显式环境变量。
|
||||
- 2026-05-27 起,默认 smoke 基线只覆盖 `3000 Rust SSR + leptos-tiptap + local-first` 主路径;Convex export、Convex 兼容、Next、3104、BlockNote 默认路径脚本不再放在默认候选里。
|
||||
|
||||
## 0. 当前 smoke 状态清单
|
||||
|
||||
@@ -36,37 +36,21 @@ node scripts/task490-runtime-surfaces-smoke.js
|
||||
- Rust SSR 文档页 / Page Aggregate:local-first 默认优先 `task167-local-markdown-title-body-options-no-convex-smoke.js`;cloud/control-plane 文档可补跑 `task110-page-title-single-truth-smoke.js`、`task-page-aggregate-body-sync-smoke.js`、`task-page-aggregate-options-sync-smoke.js`、`task-page-aggregate-refresh-persistence-smoke.js`
|
||||
- leptos-tiptap runtime surface:`task490-runtime-surfaces-smoke.js`;需要验证保存回读可补跑 `task121-rust-web-editor-island-hydration-smoke.js`,但它仍使用 `/api/tree/commands create` 准备文档,不作为无 Convex 默认基线;需要验证浮层互斥和更多菜单状态时再跑 `task158-e30-menu-state-smoke.js`
|
||||
- local-first 本地工作区:`task164-desktop-hot-local-folder-main-entry-smoke.js`、`task166-local-first-managed-workspace-no-convex-smoke.js`、`task167-local-markdown-title-body-options-no-convex-smoke.js`、`task436-local-markdown-open-document-external-change-smoke.js`、`task443-local-markdown-asset-upload-smoke.js`、`task451-local-markdown-conflict-resolution-ui-smoke.js`、`task452-local-search-index-browser-smoke.js`、`task453-local-folder-page-ai-changed-files-smoke.js`
|
||||
- local Markdown conflict regression:`task510-local-markdown-conflict-regression-group.js` 串联真实冲突、连续上传、新建页面、外部恢复、多 tab CAS 与上传 409 孤儿策略;可用 `MNOTE_CONFLICT_REGRESSION_TASKS=a.js,b.js` 做定点子集。
|
||||
- tree realtime / live cache:`task446-tree-rename-dual-browser-live-smoke.js`、`task447-tree-move-order-dual-browser-live-smoke.js`、`task448-tree-resync-recovery-dual-browser-smoke.js`、`task449-tree-sse-reconnect-snapshot-recovery-smoke.js`
|
||||
- 资源对象与 mindmap:`task455-local-folder-mindmap-clean-smoke.js`、`task456-resource-object-shell-sync-smoke.js`、`task166-mindmap-phase6-block-smoke.js`、`task167-mindmap-kmind-parity-smoke.js`、`task168-mindmap-put-validator-smoke.js`
|
||||
- local resource lifecycle API:`task437-local-folder-asset-trash-lifecycle-smoke.js`,现在只验证 `local-file:*` delete / restore / purge,不再依赖普通 `.txt` 是否出现在 UI 文件树。
|
||||
|
||||
### 0.2 兼容/control-plane 脚本
|
||||
|
||||
这些脚本仍可用于 Convex/control-plane 或 cloud-source 兼容链,但不能单独证明 local-first 主链正确:
|
||||
|
||||
- `task427-trash-empty-isolated-workspace-smoke.js`
|
||||
- `task428-filetree-bulk-delete-selection-smoke.js`
|
||||
- `task429-trash-restore-location-reveal-smoke.js`
|
||||
- `task430-vscode-explorer-stage7-smoke.js`
|
||||
- `task431-vscode-explorer-dnd-readonly-conflict-smoke.js`
|
||||
- `task432-filetree-trash-page-dual-browser-no-refresh-smoke.js`
|
||||
- `task433-filetree-trash-file-asset-dual-browser-no-refresh-smoke.js`
|
||||
- `task434-filetree-trash-mindmap-table-dual-browser-no-refresh-smoke.js`
|
||||
- `task174-rust-onlyoffice-attachment-open-smoke.js`
|
||||
- `task175-rust-upload-entry-smoke.js`
|
||||
|
||||
注意:上述 427 / 428 / 429 / 430 / 431 / 432 / 433 / 434 脚本默认入口已改回 `3000`;如需对临时端口验证,显式设置 `MNOTE_UI_BASE_URL`。`task174` / `task175` 仍验证 Convex media / OnlyOffice cloud upload 兼容入口,不属于 local-first 默认上传链。
|
||||
|
||||
### 0.3 需要退役或拆分的历史脚本
|
||||
### 0.2 需要退役或拆分的历史脚本
|
||||
|
||||
- `task444-convex-workspace-export-local-fixture-smoke.js`、`task455-convex-export-plan-rollback-smoke.js` 与 `export-convex-workspace-to-local.js`:Convex export 迁移验证已退出默认 smoke,已归档到 `recycle/scripts/retired-convex-export-smokes-20260526/`。
|
||||
- 注意编号复用:`scripts/task444-filetree-create-mindmap-title-cache-smoke.js` 与 `scripts/task455-local-folder-mindmap-clean-smoke.js` 仍是当前本地 filetree / mindmap 脚本;退役的是带 `convex-export` 名称的 task444 / task455。
|
||||
- 旧 Convex 兼容 smoke 已软删除到 `recycle/20260527-legacy-cleanup/convex/scripts/`,包括 `task163`、`task174`、`task175`、`task427`、`task428`、`task429`、`task430`、`task431`、`task432`、`task433`、`task434`。
|
||||
- `task179-tree-create-delete-no-reload-smoke.js`:覆盖 `/tree` debug shell。`/tree` 已是显式 debug/internal 边界,脚本默认阻断;只有设置 `MNOTE_ALLOW_DEBUG_TREE_SMOKE=1` 时才可执行。
|
||||
- `task163-local-folder-unified-tree-browser-smoke.js`:历史“大一统”脚本,同时混合 local folder、Convex、拖拽、复制粘贴、watcher 和文件树行为。后续不要作为默认回归入口;优先用 164 / 166 / 436 / 441 / 443 / 451 / 452 / 455 这些聚焦脚本替代。
|
||||
- `task123-rust-web-tree-live-stream-consumer-smoke.js`:只证明 `/api/tree/events` SSE fallback 仍可用。当前 realtime 主链是 WebSocket push + SSE fallback;验证主链时优先使用 446 / 447 / 448,只有排查 fallback 时再跑 123。
|
||||
- 旧 `task019`、`task021`、`task022` 这类早期 UI regression 脚本已软删除到 `recycle/scripts/retired-ui-regressions/`,只作为历史对照;后续默认不要用于当前 Rust SSR / local-first 主路径验收。
|
||||
|
||||
### 0.4 其他 current 候选脚本
|
||||
### 0.3 其他 current 候选脚本
|
||||
|
||||
这些脚本不是最小默认基线,但仍属于当前 Rust SSR / leptos-tiptap / local-first 维护面,按改动范围选择性运行:
|
||||
|
||||
@@ -93,7 +77,6 @@ node scripts/task490-runtime-surfaces-smoke.js
|
||||
|
||||
- `task097-homepage-entry-smoke.js`
|
||||
- `task114-rust-web-gateway-entry-smoke.js`
|
||||
- `task117-next-retirement-guard.js`
|
||||
- `task159-auth-entry-smoke.js`
|
||||
|
||||
适用场景:
|
||||
|
||||
@@ -1,449 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const WAIVER = "local-first-allow-convex-main-storage";
|
||||
const MIGRATION_PERIOD_WAIVER = "sqlite-migration-waiver";
|
||||
|
||||
const RUNTIME_FORBIDDEN = [
|
||||
{
|
||||
name: "未标注的 Convex documents 直连",
|
||||
pattern: /documents:(?:createWithParentReference|updateTitle|move|updateContent|getMeta|getContent|purge)\b/,
|
||||
},
|
||||
{
|
||||
name: "未标注的 Convex media 直连",
|
||||
pattern: /mediaAssets:(?:generateUploadUrl|createWithStorage|getById|refreshUrl|patchById|emptyTrashByWorkspace|purgeById|listByIds)\b/,
|
||||
},
|
||||
{
|
||||
name: "未标注的 Convex AI session 直连",
|
||||
pattern:
|
||||
/aiSessions:(?:upsertRuntimeRun|appendRuntimeEvent|getRuntimeRun|listRuntimeRuns|listRuntimeEvents|renameRuntimeSession|autoTitleRuntimeSession|deleteRuntimeSession|searchRuntimeSessions)\b/,
|
||||
},
|
||||
{
|
||||
name: "Convex media URL 主路径",
|
||||
pattern: /\/api\/media\/(?:upload|sign|batch)|assetId=/,
|
||||
},
|
||||
];
|
||||
|
||||
const DESIGN_DEFAULT_CONVEX = /(?:Convex|convex).*(?:默认|default).*(?:主存储|主数据层|数据真相|页面正文真相|文件树真相|附件|AI 会话|session)/;
|
||||
const DESIGN_NEGATION =
|
||||
/(?:不应|不得|不再|禁止|只作为|降级|控制面|检查|若把|应要求改成|不是|不作为|不依赖|可选|sync replica|cloud source|compat|依赖证据|代码盘点|未完成|部分完成|当前状态)/;
|
||||
const ACTIVE_CONVEX_FORBIDDEN = [
|
||||
{
|
||||
name: "未标注的 Convex documents 表",
|
||||
pattern: /\bdocuments\s*:\s*defineTable\b/,
|
||||
},
|
||||
{
|
||||
name: "未标注的 Convex media_assets 表",
|
||||
pattern: /\bmedia_assets\s*:\s*defineTable\b/,
|
||||
},
|
||||
{
|
||||
name: "未标注的 Convex documents 函数",
|
||||
pattern: /export const (?:createWithParentReference|updateTitle|move|updateContent|getMeta|getContent|purge|listByWorkspace)\b/,
|
||||
},
|
||||
{
|
||||
name: "未标注的 Convex mediaAssets 函数",
|
||||
pattern: /export const (?:generateUploadUrl|createWithStorage|getById|refreshUrl|patchById|emptyTrashByWorkspace|purgeById|listByIds)\b/,
|
||||
},
|
||||
];
|
||||
|
||||
const RETIRED_ROOT_CONVEX_PATHS = [
|
||||
{
|
||||
name: "根 convex schema",
|
||||
pattern: /^convex\/schema\.ts$/,
|
||||
},
|
||||
{
|
||||
name: "根 Convex Auth 函数",
|
||||
pattern: /^convex\/auth(?:\.config)?\.ts$/,
|
||||
},
|
||||
{
|
||||
name: "根 Convex 用户函数",
|
||||
pattern: /^convex\/users\.ts$/,
|
||||
},
|
||||
{
|
||||
name: "根 Convex AI session 函数",
|
||||
pattern: /^convex\/aiSessions\.ts$/,
|
||||
},
|
||||
];
|
||||
|
||||
const RETIRED_CONVEX_INFRA_PATHS = [
|
||||
{
|
||||
name: "Convex 自托管 infra",
|
||||
pattern: /^infra\/convex(?:\/|$)/,
|
||||
},
|
||||
{
|
||||
name: "根 storage-convex-bridge 占位文件",
|
||||
pattern: /^storage-convex-bridge$/,
|
||||
},
|
||||
];
|
||||
|
||||
// ─── SQLite 迁移期 forbid ───────────────────────────────────
|
||||
// 在 SQLite 控制面迁移阶段(Phase 1-5),新增 Convex 控制面代码
|
||||
// (users/workspaces/sessions/policies/audit/outbox/share 等)
|
||||
// 应标记 sqlite-migration-waiver,否则提示应迁移到 SQLite。
|
||||
const MIGRATION_PERIOD_FORBIDDEN = [
|
||||
{
|
||||
name: "未标注迁移例外的 Convex 控制面 users 代码",
|
||||
pattern: /(?:[Uu]sers?\s*[:=.]|[a-z]+[A-Z][a-z]*[Uu]ser\b|auth_identities|auth_sessions).*(?:defineTable|mutation|query|schema)/,
|
||||
scope: "convex",
|
||||
},
|
||||
{
|
||||
name: "未标注迁移例外的 Convex 控制面 workspace 代码",
|
||||
pattern: /(?:[Ww]orkspaces?\s*[:=]|[Ww]orkspace[Mm]embers\b|[Ww]orkspace[Mm]ember\b).*(?:defineTable|mutation|query|schema)/,
|
||||
scope: "convex",
|
||||
},
|
||||
{
|
||||
name: "未标注迁移例外的 Convex 控制面 grants/access 代码",
|
||||
pattern: /\b(?:directory_grants|directoryGrants|access_policy|accessPolicy)\b/,
|
||||
scope: "convex",
|
||||
},
|
||||
{
|
||||
name: "未标注迁移例外的 Convex 控制面 share 代码",
|
||||
pattern: /\b(?:share_links|shareLinks)\b/,
|
||||
scope: "convex",
|
||||
},
|
||||
{
|
||||
name: "未标注迁移例外的 Convex 控制面 sync/audit/outbox 代码",
|
||||
pattern: /\b(?:sync_state|syncState|audit_log|auditLog|outbox_events|outboxEvents)\b/,
|
||||
scope: "convex",
|
||||
},
|
||||
{
|
||||
name: "未标注迁移例外的 Convex 控制面 ai_policies 代码",
|
||||
pattern: /\b(?:ai_policies|aiPolicies|ai_policy|aiPolicy)\b/,
|
||||
scope: "convex",
|
||||
},
|
||||
{
|
||||
name: "未标注迁移例外的 Convex 控制面 legacy_id_map 代码",
|
||||
pattern: /\b(?:legacy_id_map|legacyIdMap)\b/,
|
||||
scope: "convex",
|
||||
},
|
||||
];
|
||||
|
||||
function usage() {
|
||||
return [
|
||||
"用法:",
|
||||
" node scripts/check-local-first-convex-guard.js",
|
||||
" node scripts/check-local-first-convex-guard.js --base origin/main",
|
||||
" node scripts/check-local-first-convex-guard.js --staged",
|
||||
" node scripts/check-local-first-convex-guard.js --files <path...>",
|
||||
" node scripts/check-local-first-convex-guard.js --self-test",
|
||||
"",
|
||||
`如需保留明确的 cloud/compat 直连,请在同一新增行加入 ${WAIVER}。`,
|
||||
"根 convex/ functions 源码已退役;如确需新增显式 cloud/compat Convex functions,应先放到明确的辅助目录并更新 guard。",
|
||||
"infra/convex 自托管基础设施已退役到 recycle;不要重新引回 active 路径。",
|
||||
`SQLite 迁移期中新增 Convex 控制面代码(users/workspaces/grants/share 等)需加 ${MIGRATION_PERIOD_WAIVER} 标注迁移例外。`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const result = {
|
||||
base: "HEAD",
|
||||
staged: false,
|
||||
files: [],
|
||||
selfTest: false,
|
||||
};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
console.log(usage());
|
||||
process.exit(0);
|
||||
}
|
||||
if (arg === "--staged") {
|
||||
result.staged = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--self-test") {
|
||||
result.selfTest = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--base") {
|
||||
const value = argv[index + 1];
|
||||
if (!value) throw new Error("--base 需要一个 git ref");
|
||||
result.base = value;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--files") {
|
||||
result.files = argv.slice(index + 1);
|
||||
break;
|
||||
}
|
||||
throw new Error(`未知参数:${arg}\n${usage()}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function isDesignPath(filePath) {
|
||||
return filePath.startsWith("design/") && !filePath.startsWith("design/old/") && filePath.endsWith(".md");
|
||||
}
|
||||
|
||||
function isRuntimePath(filePath) {
|
||||
return filePath.startsWith("rust/crates/mnote-web/src/") && filePath.endsWith(".rs");
|
||||
}
|
||||
|
||||
function isActiveConvexPath(filePath) {
|
||||
return filePath.startsWith("convex/") && filePath.endsWith(".ts") && !filePath.startsWith("convex/_generated/");
|
||||
}
|
||||
|
||||
function isRetiredRootConvexPath(filePath) {
|
||||
return RETIRED_ROOT_CONVEX_PATHS.some((entry) => entry.pattern.test(filePath));
|
||||
}
|
||||
|
||||
function isRetiredConvexInfraPath(filePath) {
|
||||
return RETIRED_CONVEX_INFRA_PATHS.some((entry) => entry.pattern.test(filePath));
|
||||
}
|
||||
|
||||
function isAllowedRuntimeAdapter(filePath) {
|
||||
return (
|
||||
filePath === "rust/crates/mnote-web/src/transport/convex.rs" ||
|
||||
filePath === "rust/crates/mnote-web/src/routes/compat.rs"
|
||||
);
|
||||
}
|
||||
|
||||
function normalizePath(filePath) {
|
||||
return filePath.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function readDiffLines(options) {
|
||||
const args = ["diff", "--unified=0", "--no-ext-diff"];
|
||||
if (options.staged) {
|
||||
args.push("--cached");
|
||||
} else {
|
||||
args.push(options.base);
|
||||
}
|
||||
args.push("--");
|
||||
const output = execFileSync("git", args, { encoding: "utf8" });
|
||||
return output.split(/\r?\n/);
|
||||
}
|
||||
|
||||
function collectAddedLinesFromDiff(lines) {
|
||||
const added = [];
|
||||
let currentFile = null;
|
||||
let newLine = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
const fileMatch = line.match(/^\+\+\+ b\/(.+)$/);
|
||||
if (fileMatch) {
|
||||
currentFile = fileMatch[1];
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("+++ /dev/null")) {
|
||||
currentFile = null;
|
||||
continue;
|
||||
}
|
||||
const hunkMatch = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
||||
if (hunkMatch) {
|
||||
newLine = Number(hunkMatch[1]);
|
||||
continue;
|
||||
}
|
||||
if (!currentFile || line.startsWith("diff --git") || line.startsWith("index ")) {
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("+") && !line.startsWith("+++")) {
|
||||
added.push({ filePath: currentFile, lineNumber: newLine, text: line.slice(1) });
|
||||
newLine += 1;
|
||||
continue;
|
||||
}
|
||||
if (!line.startsWith("-")) {
|
||||
newLine += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return added;
|
||||
}
|
||||
|
||||
function collectLinesFromFiles(files) {
|
||||
const rows = [];
|
||||
for (const rawPath of files) {
|
||||
const filePath = normalizePath(rawPath);
|
||||
const text = fs.readFileSync(filePath, "utf8");
|
||||
text.split(/\r?\n/).forEach((line, index) => {
|
||||
rows.push({ filePath, lineNumber: index + 1, text: line });
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function isMigrationWaived(row) {
|
||||
return row.text.includes(WAIVER) || row.text.includes(MIGRATION_PERIOD_WAIVER);
|
||||
}
|
||||
|
||||
function checkLine(row) {
|
||||
if (isMigrationWaived(row)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (isDesignPath(row.filePath)) {
|
||||
if (DESIGN_DEFAULT_CONVEX.test(row.text) && !DESIGN_NEGATION.test(row.text)) {
|
||||
return [
|
||||
{
|
||||
...row,
|
||||
reason: "设计稿新增口径把 Convex 描述成默认主存储;请改成 LocalFS/WorkspaceSource 默认,或明确标注 cloud/control-plane/compat。",
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
if (isRetiredRootConvexPath(row.filePath)) {
|
||||
return [
|
||||
{
|
||||
...row,
|
||||
reason: `${RETIRED_ROOT_CONVEX_PATHS.find((entry) => entry.pattern.test(row.filePath)).name} 已随根 convex/ 退役;不要重新新增为默认控制面,请迁到 Rust SQLite control-plane 或明确的 cloud/compat 辅助目录。`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (isRetiredConvexInfraPath(row.filePath)) {
|
||||
return [
|
||||
{
|
||||
...row,
|
||||
reason: `${RETIRED_CONVEX_INFRA_PATHS.find((entry) => entry.pattern.test(row.filePath)).name} 已软删除到 recycle;不要重新引回 active 路径或默认启动链路。`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (!isRuntimePath(row.filePath) || isAllowedRuntimeAdapter(row.filePath)) {
|
||||
if (!isActiveConvexPath(row.filePath)) {
|
||||
return [];
|
||||
}
|
||||
const violations = ACTIVE_CONVEX_FORBIDDEN.filter((entry) => entry.pattern.test(row.text)).map((entry) => ({
|
||||
...row,
|
||||
reason: `${entry.name} 出现在 active convex 目录;新增 Convex 代码只能是 auth、membership、share grants、sync state、AI policy、cloud source、compat 或 sync replica,并需用 ${WAIVER} 标注例外。`,
|
||||
}));
|
||||
// 在迁移期内,新增控制面 Convex 代码也应标记例外
|
||||
if (violations.length === 0) {
|
||||
const migrationHit = MIGRATION_PERIOD_FORBIDDEN.filter((entry) => entry.pattern.test(row.text)).map((entry) => ({
|
||||
...row,
|
||||
reason: `${entry.name} — SQLite 迁移期内新增 Convex 控制面代码应标记 ${MIGRATION_PERIOD_WAIVER} 迁移例外,或将逻辑迁移到 Rust control-plane SQLite 实现。`,
|
||||
}));
|
||||
violations.push(...migrationHit);
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
return RUNTIME_FORBIDDEN.filter((entry) => entry.pattern.test(row.text)).map((entry) => ({
|
||||
...row,
|
||||
reason: `${entry.name} 出现在非 Convex adapter 路径;请先走 WorkspaceSource / Rust kernel / LocalFS executor,或加 ${WAIVER} 并说明原因。`,
|
||||
}));
|
||||
}
|
||||
|
||||
function runSelfTest() {
|
||||
const rows = [
|
||||
{
|
||||
filePath: "convex/schema.ts",
|
||||
lineNumber: 1,
|
||||
text: " documents: defineTable({",
|
||||
},
|
||||
{
|
||||
filePath: "convex/aiSessions.ts",
|
||||
lineNumber: 1,
|
||||
text: "export const upsertRuntimeRun = mutation({",
|
||||
},
|
||||
{
|
||||
filePath: "convex/mediaAssets.ts",
|
||||
lineNumber: 1,
|
||||
text: "export const generateUploadUrl = mutation({",
|
||||
},
|
||||
{
|
||||
filePath: "convex/shareGrants.ts",
|
||||
lineNumber: 1,
|
||||
text: "export const upsertShareGrant = mutation({",
|
||||
},
|
||||
{
|
||||
filePath: "rust/crates/mnote-web/src/routes/example.rs",
|
||||
lineNumber: 1,
|
||||
text: '"documents:updateContent"',
|
||||
},
|
||||
{
|
||||
filePath: "rust/crates/mnote-web/src/transport/convex.rs",
|
||||
lineNumber: 1,
|
||||
text: '"documents:updateContent"',
|
||||
},
|
||||
{
|
||||
filePath: "convex/users.ts",
|
||||
lineNumber: 1,
|
||||
text: " users: defineTable({",
|
||||
},
|
||||
{
|
||||
filePath: "convex/workspaces_migration.ts",
|
||||
lineNumber: 1,
|
||||
text: " workspaces: defineTable({",
|
||||
},
|
||||
{
|
||||
filePath: "infra/convex/docker-compose.yml",
|
||||
lineNumber: 1,
|
||||
text: "services:",
|
||||
},
|
||||
{
|
||||
filePath: "storage-convex-bridge",
|
||||
lineNumber: 1,
|
||||
text: "",
|
||||
},
|
||||
];
|
||||
const violations = rows.flatMap(checkLine);
|
||||
const labels = violations.map((item) => item.filePath);
|
||||
if (!labels.includes("convex/schema.ts")) {
|
||||
throw new Error("self-test 失败:未拦截 active convex documents 表");
|
||||
}
|
||||
if (!labels.includes("convex/aiSessions.ts")) {
|
||||
throw new Error("self-test 失败:未拦截退役根 convex AI session 函数");
|
||||
}
|
||||
if (!labels.includes("convex/mediaAssets.ts")) {
|
||||
throw new Error("self-test 失败:未拦截 active convex media 函数");
|
||||
}
|
||||
if (!labels.includes("rust/crates/mnote-web/src/routes/example.rs")) {
|
||||
throw new Error("self-test 失败:未拦截非 adapter Rust Convex 直连");
|
||||
}
|
||||
if (labels.includes("convex/shareGrants.ts")) {
|
||||
throw new Error("self-test 失败:误拦截控制面 share grants");
|
||||
}
|
||||
if (labels.includes("rust/crates/mnote-web/src/transport/convex.rs")) {
|
||||
throw new Error("self-test 失败:误拦截 Convex adapter");
|
||||
}
|
||||
if (!labels.includes("convex/users.ts")) {
|
||||
throw new Error("self-test 失败:未拦截迁移期新增 Convex 控制面 users 代码");
|
||||
}
|
||||
if (!labels.includes("convex/workspaces_migration.ts")) {
|
||||
throw new Error("self-test 失败:未拦截迁移期新增 Convex 控制面 workspaces 代码");
|
||||
}
|
||||
if (!labels.includes("infra/convex/docker-compose.yml")) {
|
||||
throw new Error("self-test 失败:未拦截退役的 Convex infra 回流");
|
||||
}
|
||||
if (!labels.includes("storage-convex-bridge")) {
|
||||
throw new Error("self-test 失败:未拦截根 storage-convex-bridge 占位文件回流");
|
||||
}
|
||||
console.log(JSON.stringify({ ok: true, guard: "local-first-convex", selfTest: true }, null, 2));
|
||||
}
|
||||
|
||||
function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.selfTest) {
|
||||
runSelfTest();
|
||||
return;
|
||||
}
|
||||
const rows = options.files.length > 0 ? collectLinesFromFiles(options.files) : collectAddedLinesFromDiff(readDiffLines(options));
|
||||
const violations = rows.flatMap(checkLine);
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error("local-first Convex guard 发现新增主存储绑定:");
|
||||
for (const violation of violations) {
|
||||
console.error(`- ${violation.filePath}:${violation.lineNumber} ${violation.reason}`);
|
||||
console.error(` ${violation.text.trim()}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({ ok: true, checkedLines: rows.length, guard: "local-first-convex" }, null, 2));
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,8 @@
|
||||
#
|
||||
# 注意:
|
||||
# - hostname 需要先在 Cloudflare DNS 里绑定到该 tunnel
|
||||
# - 本示例把 app/supabase/lightrag/mineru 分别用子域名暴露
|
||||
# - 生产环境建议对 lightrag/mineru 增加额外保护(Cloudflare Access / IP 白名单)
|
||||
# - 本示例把 app/supabase/mineru 分别用子域名暴露
|
||||
# - 生产环境建议对 mineru 增加额外保护(Cloudflare Access / IP 白名单)
|
||||
|
||||
tunnel: <YOUR_TUNNEL_NAME_OR_ID>
|
||||
credentials-file: ./<YOUR_TUNNEL_ID>.json
|
||||
@@ -26,15 +26,11 @@ ingress:
|
||||
- hostname: backend.mnote.example.com
|
||||
service: http://127.0.0.1:8000
|
||||
|
||||
# 4) LightRAG(你当前规划为 7777)
|
||||
- hostname: lightrag.mnote.example.com
|
||||
service: http://127.0.0.1:7777
|
||||
|
||||
# 5) MinerU(你当前 .env.local 为 18888)
|
||||
# 4) MinerU(你当前 .env.local 为 18888)
|
||||
- hostname: mineru.mnote.example.com
|
||||
service: http://127.0.0.1:18888
|
||||
|
||||
# 6) OnlyOffice Document Server(docker: onlyoffice-dev 暴露到 8081)
|
||||
# 5) OnlyOffice Document Server(docker: onlyoffice-dev 暴露到 8081)
|
||||
- hostname: onlyoffice.mnote.example.com
|
||||
service: http://127.0.0.1:8081
|
||||
|
||||
|
||||
+31
-167
@@ -1,33 +1,20 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* 热启动 mnote-web 单入口,以及按需启用的 FastAPI / Celery。
|
||||
* 热启动 mnote-web 单入口,以及按需启用的 FastAPI。
|
||||
* 可使用以下环境变量调整行为:
|
||||
* - FRONTEND_CMD:覆盖历史 Next 启动命令,仅在显式启用 legacy compat 或跳过 Rust gateway 时生效
|
||||
* - ENABLE_BACKEND:设为 "1" or "true" 时启用默认 FastAPI 后端
|
||||
* - BACKEND_CMD:覆盖 FastAPI 启动命令;设置后即视为显式启用后端
|
||||
* - SKIP_BACKEND:设为 "1" or "true" 可强制跳过 FastAPI 后端
|
||||
* - ENABLE_CELERY:设为 "1" or "true" 时启用默认 Celery worker
|
||||
* - CELERY_CMD:覆盖 Celery 启动命令;设置后即视为显式启用 Celery
|
||||
* - CELERY_POOL:只在 CELERY_CMD 未覆盖时生效,设置 Celery worker pool;Windows 默认 "solo",其他平台默认使用 Celery 自身默认值
|
||||
* - PYTHON_BIN:只在 BACKEND_CMD 未覆盖时,设置 Python 可执行文件,默认 "python"
|
||||
* - CELERY_BIN:只在 CELERY_CMD 未覆盖时,设置 Celery 可执行文件,默认 "celery"
|
||||
* - REDIS_URL:仅用于探测 Redis 是否就绪,默认 "redis://localhost:6379/0"
|
||||
* - SKIP_CELERY:设为 "1" or "true" 可强制跳过 Celery。
|
||||
* - MNOTE_WEB_SKIP_GATEWAY:设为 "1" or "true" 临时恢复旧 Next 3000 入口。
|
||||
* - MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT:已废弃;desktop:hot 默认不再启动 Next legacy upstream。
|
||||
* - NEXT_LEGACY_PORT:显式启用 legacy compat 时的 Next upstream 端口,默认 3100。
|
||||
* - SKIP_NEXT_LEGACY:兼容旧环境变量;设为 "1" or "true" 时强制只启动 Rust gateway。
|
||||
*/
|
||||
|
||||
const { spawn, execSync } = require("child_process");
|
||||
const path = require("path");
|
||||
const net = require("net");
|
||||
const { URL } = require("url");
|
||||
const fs = require("fs");
|
||||
|
||||
const rootDir = path.resolve(__dirname, "..");
|
||||
const frontendDir = path.join(rootDir, "wolai-frontend");
|
||||
const backendDir = path.join(rootDir, "wolai-backend");
|
||||
|
||||
function resolveBackendExecutable(envName, fallbackName) {
|
||||
@@ -57,23 +44,35 @@ function resolveBackendExecutable(envName, fallbackName) {
|
||||
}
|
||||
|
||||
const pythonBin = resolveBackendExecutable("PYTHON_BIN", "python");
|
||||
const celeryBin = resolveBackendExecutable("CELERY_BIN", "celery");
|
||||
const celeryPoolFromEnv = (process.env.CELERY_POOL || "").trim();
|
||||
const celeryCmdFromEnv = (process.env.CELERY_CMD || "").trim();
|
||||
const redisUrl = process.env.REDIS_URL || "redis://localhost:6379/0";
|
||||
const backendPortFromEnv = Number(process.env.BACKEND_PORT || 8000);
|
||||
|
||||
function hasCommand(command) {
|
||||
try {
|
||||
execSync(`command -v ${command}`, { stdio: "ignore", shell: "/bin/sh" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function shouldUseUvBackendRuntime() {
|
||||
if (String(process.env.PYTHON_BIN || "").trim()) return false;
|
||||
if (!fs.existsSync(path.join(backendDir, "requirements.txt"))) return false;
|
||||
return hasCommand("uv");
|
||||
}
|
||||
|
||||
function buildDefaultBackendCommand(port) {
|
||||
if (shouldUseUvBackendRuntime()) {
|
||||
return `uv run --with-requirements requirements.txt python -m uvicorn app.main:app --reload --port ${port}`;
|
||||
}
|
||||
return `${pythonBin} -m uvicorn app.main:app --reload --port ${port}`;
|
||||
}
|
||||
|
||||
function isEnabledEnv(value) {
|
||||
const normalized = String(value || "").toLowerCase();
|
||||
return normalized === "1" || normalized === "true";
|
||||
}
|
||||
|
||||
function shouldStartCelery(env = process.env) {
|
||||
if (isEnabledEnv(env.SKIP_CELERY)) return false;
|
||||
if (String(env.CELERY_CMD || "").trim()) return true;
|
||||
return isEnabledEnv(env.ENABLE_CELERY);
|
||||
}
|
||||
|
||||
function shouldStartBackend(env = process.env) {
|
||||
if (isEnabledEnv(env.SKIP_BACKEND)) return false;
|
||||
if (String(env.BACKEND_CMD || "").trim()) return true;
|
||||
@@ -82,49 +81,25 @@ function shouldStartBackend(env = process.env) {
|
||||
|
||||
function resolveRuntimePlan(env = process.env) {
|
||||
const frontendPort = Number(env.FRONTEND_PORT || 3000);
|
||||
const nextLegacyPort = Number(env.NEXT_LEGACY_PORT || 3100);
|
||||
const skipGateway = isEnabledEnv(env.MNOTE_WEB_SKIP_GATEWAY);
|
||||
const skipNextLegacy = !skipGateway;
|
||||
const skipGateway = false;
|
||||
const publicPort = Number.isFinite(frontendPort) ? Math.floor(frontendPort) : 3000;
|
||||
const legacyPort = Number.isFinite(nextLegacyPort) ? Math.floor(nextLegacyPort) : 3100;
|
||||
const legacyUrl = `http://127.0.0.1:${legacyPort}`;
|
||||
const legacyCompatEnabled = "0";
|
||||
|
||||
return {
|
||||
skipGateway,
|
||||
skipNextLegacy,
|
||||
publicPort,
|
||||
legacyPort,
|
||||
publicUrl: `http://localhost:${publicPort}`,
|
||||
legacyUrl,
|
||||
frontendTaskName: skipGateway ? "frontend" : null,
|
||||
frontendCommand: env.FRONTEND_CMD || `pnpm dev -p ${skipGateway ? publicPort : legacyPort}`,
|
||||
mnoteWebCommand: env.MNOTE_WEB_CMD || "cargo run -p mnote-web --bin mnote-web",
|
||||
mnoteWebEnv: skipGateway
|
||||
? {}
|
||||
: {
|
||||
MNOTE_WEB_BIND: env.MNOTE_WEB_BIND || `0.0.0.0:${publicPort}`,
|
||||
MNOTE_WEB_PUBLIC_BIND: env.MNOTE_WEB_PUBLIC_BIND || `127.0.0.1:${publicPort}`,
|
||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: legacyCompatEnabled,
|
||||
},
|
||||
mnoteWebEnv: {
|
||||
MNOTE_WEB_BIND: env.MNOTE_WEB_BIND || `0.0.0.0:${publicPort}`,
|
||||
MNOTE_WEB_PUBLIC_BIND: env.MNOTE_WEB_PUBLIC_BIND || `127.0.0.1:${publicPort}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const runtimePlan = resolveRuntimePlan(process.env);
|
||||
const skipMnoteWebGateway = runtimePlan.skipGateway;
|
||||
|
||||
const frontendTasks = runtimePlan.frontendTaskName
|
||||
? [
|
||||
{
|
||||
name: runtimePlan.frontendTaskName,
|
||||
command: runtimePlan.frontendCommand,
|
||||
cwd: frontendDir,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const tasks = [
|
||||
...frontendTasks,
|
||||
...(skipMnoteWebGateway
|
||||
? []
|
||||
: [
|
||||
@@ -142,7 +117,7 @@ const tasks = [
|
||||
name: "backend",
|
||||
command:
|
||||
process.env.BACKEND_CMD ||
|
||||
`${pythonBin} -m uvicorn app.main:app --reload --port 8000`,
|
||||
buildDefaultBackendCommand(8000),
|
||||
cwd: backendDir,
|
||||
},
|
||||
]
|
||||
@@ -394,18 +369,6 @@ function logPrefix(name, message) {
|
||||
console.log(`[${name}] ${message}`);
|
||||
}
|
||||
|
||||
function getDefaultCeleryPool() {
|
||||
if (celeryPoolFromEnv) return celeryPoolFromEnv;
|
||||
if (process.platform === "win32") return "solo";
|
||||
return "";
|
||||
}
|
||||
|
||||
function buildDefaultCeleryCommand() {
|
||||
const pool = getDefaultCeleryPool();
|
||||
const poolArg = pool ? ` --pool=${pool}` : "";
|
||||
return `${celeryBin} -A app.workers.celery_app worker --loglevel=info${poolArg}`;
|
||||
}
|
||||
|
||||
function startTask(task) {
|
||||
logPrefix(task.name, `启动命令:${task.command}`);
|
||||
const child = spawn(task.command, {
|
||||
@@ -470,55 +433,11 @@ function shutdown(code) {
|
||||
process.on("SIGINT", () => shutdown(0));
|
||||
process.on("SIGTERM", () => shutdown(0));
|
||||
|
||||
async function checkRedisReachable(urlString, timeoutMs = 2000) {
|
||||
try {
|
||||
const url = new URL(urlString);
|
||||
const host = url.hostname || "localhost";
|
||||
const port = Number(url.port) || 6379;
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
const socket = net.createConnection({ host, port });
|
||||
const timer = setTimeout(() => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
}, timeoutMs);
|
||||
|
||||
socket.once("connect", () => {
|
||||
clearTimeout(timer);
|
||||
socket.end();
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
socket.once("error", () => {
|
||||
clearTimeout(timer);
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
logPrefix("celery", `REDIS_URL (${urlString}) 解析失败:${error.message},跳过连通性检查。`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (runtimePlan.frontendTaskName) {
|
||||
// 说明:Next dev 在异常退出时可能残留 `.next/dev/lock`,会导致后续启动直接失败。
|
||||
// 只有显式启动历史 Next 时才清理该 lock,避免默认热启动继续触碰旧前端目录。
|
||||
const nextDevLockPath = path.join(frontendDir, ".next", "dev", "lock");
|
||||
try {
|
||||
if (fs.existsSync(nextDevLockPath)) {
|
||||
fs.rmSync(nextDevLockPath, { force: true });
|
||||
logPrefix("frontend", `检测到残留的 Next dev lock,已移除:${nextDevLockPath}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logPrefix("frontend", `尝试移除 Next dev lock 失败:${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 说明:你外网绑定了 3000 端口,这里默认强制使用 3000。
|
||||
// 如果检测到 3000 被占用,则自动结束旧进程后重启,以保证始终跑在 3000。
|
||||
const desiredFrontendPort = runtimePlan.publicPort;
|
||||
const frontendOwnerName = skipMnoteWebGateway ? "frontend" : "mnote-web";
|
||||
const frontendOwnerName = "mnote-web";
|
||||
const frontendPortOk = await ensurePortFree(desiredFrontendPort, frontendOwnerName);
|
||||
if (!frontendPortOk) {
|
||||
console.error(`前端端口 ${desiredFrontendPort} 无法释放,已中止启动。`);
|
||||
@@ -527,35 +446,10 @@ async function main() {
|
||||
const frontendPort = desiredFrontendPort;
|
||||
const frontendUrl = `http://localhost:${frontendPort}`;
|
||||
|
||||
let nextLegacyPort = null;
|
||||
if (runtimePlan.frontendTaskName === "next-legacy") {
|
||||
nextLegacyPort = runtimePlan.legacyPort;
|
||||
const nextLegacyPortOk = await ensurePortFree(nextLegacyPort, "next-legacy");
|
||||
if (!nextLegacyPortOk) {
|
||||
console.error(`Next legacy 端口 ${nextLegacyPort} 无法释放,已中止启动。`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const frontendTask = runtimePlan.frontendTaskName ? findTask(runtimePlan.frontendTaskName) : null;
|
||||
if (runtimePlan.frontendTaskName && !frontendTask) {
|
||||
throw new Error(`缺少前端任务配置:${runtimePlan.frontendTaskName}`);
|
||||
}
|
||||
if (frontendTask) {
|
||||
// 说明:在 Windows 的 cmd.exe 下,`pnpm dev -- -p 3000` 会把 `--` 原样传给 next,导致 next 把 `-p` 误当成目录。
|
||||
// 用 `pnpm dev -p 3000` 在 PowerShell/cmd.exe 下都能正确传参。
|
||||
frontendTask.command = runtimePlan.frontendCommand;
|
||||
logPrefix(frontendTask.name, `前端目录:${frontendDir}`);
|
||||
}
|
||||
if (skipMnoteWebGateway) {
|
||||
logPrefix("frontend", `前端地址:${frontendUrl}`);
|
||||
} else {
|
||||
logPrefix("mnote-web", `Rust gateway 公开入口:${frontendUrl}`);
|
||||
if (runtimePlan.frontendTaskName === "next-legacy") {
|
||||
logPrefix("next-legacy", `Next legacy upstream:${runtimePlan.legacyUrl}`);
|
||||
} else {
|
||||
|
||||
}
|
||||
const gatewayTask = tasks.find((task) => task.name === "mnote-web");
|
||||
if (gatewayTask) {
|
||||
gatewayTask.command = runtimePlan.mnoteWebCommand;
|
||||
@@ -574,42 +468,13 @@ async function main() {
|
||||
if (!backendTask) {
|
||||
throw new Error("缺少后端任务配置");
|
||||
}
|
||||
backendTask.command = `${pythonBin} -m uvicorn app.main:app --reload --port ${desiredBackendPort}`;
|
||||
backendTask.command = buildDefaultBackendCommand(desiredBackendPort);
|
||||
} else if (isEnabledEnv(process.env.SKIP_BACKEND)) {
|
||||
logPrefix("backend", "已跳过 FastAPI 后端(SKIP_BACKEND=1)。");
|
||||
} else if (!shouldStartBackend(process.env)) {
|
||||
|
||||
}
|
||||
|
||||
if (shouldStartCelery(process.env)) {
|
||||
const defaultCeleryCommand = buildDefaultCeleryCommand();
|
||||
const celeryTask = {
|
||||
name: "celery",
|
||||
command: celeryCmdFromEnv || defaultCeleryCommand,
|
||||
cwd: backendDir,
|
||||
};
|
||||
|
||||
if (celeryCmdFromEnv) {
|
||||
tasks.push(celeryTask);
|
||||
} else if (await checkRedisReachable(redisUrl)) {
|
||||
const defaultPool = getDefaultCeleryPool();
|
||||
if (defaultPool) {
|
||||
logPrefix("celery", `未显式设置 CELERY_CMD,当前平台默认使用 worker pool:${defaultPool}`);
|
||||
}
|
||||
tasks.push(celeryTask);
|
||||
} else {
|
||||
// Redis 未就绪时直接跳过 Celery,避免热调试流程整体退出。
|
||||
logPrefix(
|
||||
"celery",
|
||||
`检测到 ${redisUrl} 无法连接,自动跳过 Celery。请先启动 Redis,或继续保持当前默认关闭策略。`,
|
||||
);
|
||||
}
|
||||
} else if (isEnabledEnv(process.env.SKIP_CELERY)) {
|
||||
logPrefix("celery", "已跳过 Celery(SKIP_CELERY=1)。");
|
||||
} else {
|
||||
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
console.error("未配置任何可运行的任务,检查环境变量设置。");
|
||||
process.exit(1);
|
||||
@@ -635,6 +500,5 @@ module.exports = {
|
||||
resolveRuntimePlan,
|
||||
resolveBackendExecutable,
|
||||
shouldStartBackend,
|
||||
shouldStartCelery,
|
||||
terminatePid,
|
||||
};
|
||||
|
||||
@@ -8,7 +8,6 @@ const {
|
||||
isPortFree,
|
||||
resolveRuntimePlan,
|
||||
shouldStartBackend,
|
||||
shouldStartCelery,
|
||||
} = require("./desktop-hot.js");
|
||||
|
||||
function findFreePort() {
|
||||
@@ -116,83 +115,20 @@ test("ensurePortFree 在非 Windows 平台能释放后端监听进程", async (t
|
||||
await waitForExit(child);
|
||||
});
|
||||
|
||||
test("默认热启动计划只使用 mnote-web 作为 3000 owner,不启动 Next legacy upstream", () => {
|
||||
test("默认热启动计划只使用 mnote-web 作为 3000 owner", () => {
|
||||
const plan = resolveRuntimePlan({
|
||||
FRONTEND_PORT: "3000",
|
||||
NEXT_LEGACY_PORT: "3100",
|
||||
});
|
||||
|
||||
assert.equal(plan.skipGateway, false);
|
||||
assert.equal(plan.skipNextLegacy, true);
|
||||
assert.equal(plan.publicPort, 3000);
|
||||
assert.equal(plan.legacyPort, 3100);
|
||||
assert.equal(plan.frontendTaskName, null);
|
||||
assert.equal(plan.mnoteWebCommand, "cargo run -p mnote-web --bin mnote-web");
|
||||
assert.deepEqual(plan.mnoteWebEnv, {
|
||||
MNOTE_WEB_BIND: "0.0.0.0:3000",
|
||||
MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000",
|
||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
|
||||
});
|
||||
});
|
||||
|
||||
test("显式 legacy compat 也不再启动 Next legacy upstream", () => {
|
||||
const plan = resolveRuntimePlan({
|
||||
FRONTEND_PORT: "3000",
|
||||
NEXT_LEGACY_PORT: "3100",
|
||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "1",
|
||||
});
|
||||
|
||||
assert.equal(plan.skipGateway, false);
|
||||
assert.equal(plan.skipNextLegacy, true);
|
||||
assert.equal(plan.frontendTaskName, null);
|
||||
assert.equal(plan.mnoteWebCommand, "cargo run -p mnote-web --bin mnote-web");
|
||||
assert.deepEqual(plan.mnoteWebEnv, {
|
||||
MNOTE_WEB_BIND: "0.0.0.0:3000",
|
||||
MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000",
|
||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
|
||||
});
|
||||
});
|
||||
|
||||
test("MNOTE_WEB_SKIP_GATEWAY 可临时恢复旧 Next 3000 入口", () => {
|
||||
const plan = resolveRuntimePlan({
|
||||
FRONTEND_PORT: "3000",
|
||||
NEXT_LEGACY_PORT: "3100",
|
||||
MNOTE_WEB_SKIP_GATEWAY: "1",
|
||||
});
|
||||
|
||||
assert.equal(plan.skipGateway, true);
|
||||
assert.equal(plan.frontendTaskName, "frontend");
|
||||
assert.equal(plan.frontendCommand, "pnpm dev -p 3000");
|
||||
assert.deepEqual(plan.mnoteWebEnv, {});
|
||||
});
|
||||
|
||||
|
||||
test("SKIP_NEXT_LEGACY 保持 Rust gateway 为 3000 owner,但不启动 Next legacy upstream", () => {
|
||||
const plan = resolveRuntimePlan({
|
||||
FRONTEND_PORT: "3000",
|
||||
NEXT_LEGACY_PORT: "3100",
|
||||
SKIP_NEXT_LEGACY: "1",
|
||||
});
|
||||
|
||||
assert.equal(plan.skipGateway, false);
|
||||
assert.equal(plan.skipNextLegacy, true);
|
||||
assert.equal(plan.frontendTaskName, null);
|
||||
assert.equal(plan.mnoteWebCommand, "cargo run -p mnote-web --bin mnote-web");
|
||||
assert.deepEqual(plan.mnoteWebEnv, {
|
||||
MNOTE_WEB_BIND: "0.0.0.0:3000",
|
||||
MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000",
|
||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
|
||||
});
|
||||
});
|
||||
|
||||
test("默认跳过 Celery,只有显式开启时才启动", () => {
|
||||
assert.equal(shouldStartCelery({}), false);
|
||||
assert.equal(shouldStartCelery({ ENABLE_CELERY: "1" }), true);
|
||||
assert.equal(shouldStartCelery({ ENABLE_CELERY: "true" }), true);
|
||||
assert.equal(shouldStartCelery({ CELERY_CMD: "custom-celery" }), true);
|
||||
assert.equal(shouldStartCelery({ ENABLE_CELERY: "1", SKIP_CELERY: "1" }), false);
|
||||
});
|
||||
|
||||
test("默认跳过 FastAPI 后端,只有显式开启时才启动", () => {
|
||||
assert.equal(shouldStartBackend({}), false);
|
||||
assert.equal(shouldStartBackend({ ENABLE_BACKEND: "1" }), true);
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 一键启动:wolai-backend + 桌面端(Electron + 内置 Next standalone)
|
||||
*
|
||||
* 目的:
|
||||
* - 本地联调无需反复安装 NSIS 安装包
|
||||
* - Electron 走内置 Next(更接近安装包运行方式)
|
||||
*
|
||||
* 可选环境变量:
|
||||
* - SKIP_DESKTOP_NEXT_BUILD=1:跳过 build:desktop:next(仅启动)
|
||||
* - PYTHON_BIN:默认 python
|
||||
* - BACKEND_CMD:覆盖后端启动命令
|
||||
* - ELECTRON_CMD:覆盖 Electron 启动命令
|
||||
* - MNOTE_DATA_DIR:覆盖桌面端 data 目录(默认 <repo>/data-desktop-test)
|
||||
*/
|
||||
|
||||
const { spawnSync, spawn } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const net = require("net");
|
||||
const path = require("path");
|
||||
|
||||
const rootDir = path.resolve(__dirname, "..");
|
||||
const backendDir = path.join(rootDir, "wolai-backend");
|
||||
|
||||
function resolveBackendPython() {
|
||||
const fromEnv = (process.env.PYTHON_BIN || "").trim();
|
||||
if (fromEnv) return fromEnv;
|
||||
|
||||
const isWin = process.platform === "win32";
|
||||
const candidates = isWin
|
||||
? [
|
||||
path.join(backendDir, ".venv", "Scripts", "python.exe"),
|
||||
path.join(backendDir, ".venv312", "Scripts", "python.exe"),
|
||||
path.join(backendDir, "venv", "Scripts", "python.exe"),
|
||||
]
|
||||
: [
|
||||
path.join(backendDir, ".venv-linux", "bin", "python"),
|
||||
path.join(backendDir, ".venv", "bin", "python"),
|
||||
path.join(backendDir, "venv", "bin", "python"),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return "python";
|
||||
}
|
||||
|
||||
const pythonBin = resolveBackendPython();
|
||||
const dataDir = process.env.MNOTE_DATA_DIR || path.join(rootDir, "data-desktop-test");
|
||||
|
||||
const backendCmd =
|
||||
process.env.BACKEND_CMD || `${pythonBin} -m uvicorn app.main:app --reload --host 127.0.0.1 --port 8000`;
|
||||
const electronCmd = process.env.ELECTRON_CMD || "electron desktop-electron/main.js";
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return {};
|
||||
const content = fs.readFileSync(filePath, "utf8");
|
||||
return content
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.trim() && !line.trim().startsWith("#"))
|
||||
.reduce((acc, line) => {
|
||||
const idx = line.indexOf("=");
|
||||
if (idx === -1) return acc;
|
||||
const key = line.slice(0, idx).trim();
|
||||
const value = line.slice(idx + 1).trim();
|
||||
acc[key] = value;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function runOrExit(command, cwd, env) {
|
||||
const r = spawnSync(command, {
|
||||
cwd,
|
||||
env,
|
||||
stdio: "inherit",
|
||||
shell: true,
|
||||
});
|
||||
if (r.status !== 0) process.exit(r.status ?? 1);
|
||||
}
|
||||
|
||||
async function waitForPort(host, port, timeoutMs = 20_000) {
|
||||
const start = Date.now();
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const ok = await new Promise((resolve) => {
|
||||
const socket = net.createConnection({ host, port });
|
||||
const timer = setTimeout(() => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
}, 500);
|
||||
socket.once("connect", () => {
|
||||
clearTimeout(timer);
|
||||
socket.end();
|
||||
resolve(true);
|
||||
});
|
||||
socket.once("error", () => {
|
||||
clearTimeout(timer);
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
if (ok) return;
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error(`等待端口超时:${host}:${port}`);
|
||||
}
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const envAllPath = path.join(rootDir, ".env.all");
|
||||
if (!fs.existsSync(envAllPath)) {
|
||||
console.error("[desktop-local] 缺少 .env.all:请在仓库根目录创建全局唯一 env 文件后重试。");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const envMerged = {
|
||||
...process.env,
|
||||
// 约定:全局仅使用仓库根目录的 .env.all 作为配置来源(生产优先)。
|
||||
...loadEnvFile(envAllPath),
|
||||
PYTHONUTF8: "1",
|
||||
MNOTE_DATA_DIR: dataDir,
|
||||
};
|
||||
|
||||
const skipBuild =
|
||||
(process.env.SKIP_DESKTOP_NEXT_BUILD || "").toLowerCase() === "1" ||
|
||||
(process.env.SKIP_DESKTOP_NEXT_BUILD || "").toLowerCase() === "true";
|
||||
|
||||
if (!skipBuild) {
|
||||
console.log("[desktop-local] 构建桌面端内置 Next(standalone)…");
|
||||
runOrExit("pnpm run build:desktop:next", rootDir, envMerged);
|
||||
} else {
|
||||
console.log("[desktop-local] 已跳过 build:desktop:next(SKIP_DESKTOP_NEXT_BUILD=1)");
|
||||
}
|
||||
|
||||
console.log(`[desktop-local] data 目录:${dataDir}`);
|
||||
console.log(`[desktop-local] 后端命令:${backendCmd}`);
|
||||
console.log(`[desktop-local] Electron 命令:${electronCmd}`);
|
||||
|
||||
const children = [];
|
||||
let shuttingDown = false;
|
||||
|
||||
const shutdown = () => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
console.log("[desktop-local] 正在关闭子进程…");
|
||||
for (const child of children) {
|
||||
try {
|
||||
if (!child.killed) child.kill("SIGINT");
|
||||
} catch {}
|
||||
}
|
||||
setTimeout(() => process.exit(0), 200);
|
||||
};
|
||||
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
|
||||
const backend = spawn(backendCmd, {
|
||||
cwd: backendDir,
|
||||
env: envMerged,
|
||||
stdio: "inherit",
|
||||
shell: true,
|
||||
});
|
||||
children.push(backend);
|
||||
|
||||
backend.on("exit", () => shutdown());
|
||||
|
||||
await waitForPort("127.0.0.1", 8000).catch(() => {
|
||||
console.log("[desktop-local] 提示:未检测到后端 8000 端口就绪(可能仍在启动或启动失败)。");
|
||||
});
|
||||
|
||||
const electron = spawn(electronCmd, {
|
||||
cwd: rootDir,
|
||||
env: envMerged,
|
||||
stdio: "inherit",
|
||||
shell: true,
|
||||
});
|
||||
children.push(electron);
|
||||
|
||||
electron.on("exit", () => shutdown());
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err instanceof Error ? err.stack : String(err));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,362 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 一键启动“生产模式”的前后端与相关服务(适用于 Cloudflare Tunnel / 公网访问)。
|
||||
*
|
||||
* 目标:
|
||||
* - 前后端与内部服务统一读取仓库根目录 `.env.all`(生产优先,全局唯一 env 文件)
|
||||
*
|
||||
* 可选环境变量:
|
||||
* - SKIP_FRONTEND_BUILD=1:跳过前端 build(仅 start)
|
||||
* - FRONTEND_PORT:默认 3000
|
||||
* - BACKEND_PORT:默认 8000
|
||||
* - RAG_GATEWAY_PORT:默认 8778
|
||||
* - INGEST_PORT:默认 8779
|
||||
* - PYTHON_BIN:默认 python
|
||||
* - CELERY_BIN:默认 celery
|
||||
* - ENABLE_CELERY=1:启用默认 Celery worker
|
||||
* - CELERY_CMD:覆盖 Celery 启动命令;设置后即视为显式启用 Celery
|
||||
* - SKIP_CELERY=1:强制跳过 Celery worker
|
||||
* - REDIS_URL:用于探测 Redis,默认 redis://127.0.0.1:6379/0
|
||||
*/
|
||||
|
||||
const { spawn } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const net = require("net");
|
||||
const path = require("path");
|
||||
const { URL } = require("url");
|
||||
|
||||
const rootDir = path.resolve(__dirname, "..");
|
||||
const frontendDir = path.join(rootDir, "wolai-frontend");
|
||||
const backendDir = path.join(rootDir, "wolai-backend");
|
||||
const ingestDir = path.join(rootDir, "services", "ingest_service");
|
||||
const ragGatewayDir = path.join(rootDir, "services", "rag_gateway");
|
||||
|
||||
function resolveBackendExecutable(envName, fallbackName) {
|
||||
const fromEnv = (process.env[envName] || "").trim();
|
||||
if (fromEnv) return fromEnv;
|
||||
|
||||
const isWin = process.platform === "win32";
|
||||
const candidates = isWin
|
||||
? [
|
||||
path.join(backendDir, ".venv", "Scripts", `${fallbackName}.exe`),
|
||||
path.join(backendDir, ".venv312", "Scripts", `${fallbackName}.exe`),
|
||||
path.join(backendDir, "venv", "Scripts", `${fallbackName}.exe`),
|
||||
]
|
||||
: [
|
||||
path.join(backendDir, ".venv-linux", "bin", fallbackName),
|
||||
path.join(backendDir, ".venv", "bin", fallbackName),
|
||||
path.join(backendDir, "venv", "bin", fallbackName),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return fallbackName;
|
||||
}
|
||||
|
||||
const pythonBin = resolveBackendExecutable("PYTHON_BIN", "python");
|
||||
const celeryBin = resolveBackendExecutable("CELERY_BIN", "celery");
|
||||
const celeryCmdFromEnv = (process.env.CELERY_CMD || "").trim();
|
||||
const redisUrl = process.env.REDIS_URL || "redis://127.0.0.1:6379/0";
|
||||
|
||||
const frontendPort = Number(process.env.FRONTEND_PORT || 3000);
|
||||
const backendPort = Number(process.env.BACKEND_PORT || 8000);
|
||||
const ragGatewayPort = Number(process.env.RAG_GATEWAY_PORT || 8778);
|
||||
const ingestPort = Number(process.env.INGEST_PORT || 8779);
|
||||
|
||||
const children = [];
|
||||
let shuttingDown = false;
|
||||
|
||||
function logPrefix(name, message) {
|
||||
console.log(`[${name}] ${message}`);
|
||||
}
|
||||
|
||||
function isEnabledEnv(value) {
|
||||
const normalized = String(value || "").toLowerCase();
|
||||
return normalized === "1" || normalized === "true";
|
||||
}
|
||||
|
||||
function shouldStartCelery(env = process.env) {
|
||||
if (isEnabledEnv(env.SKIP_CELERY)) return false;
|
||||
if (String(env.CELERY_CMD || "").trim()) return true;
|
||||
return isEnabledEnv(env.ENABLE_CELERY);
|
||||
}
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return {};
|
||||
const content = fs.readFileSync(filePath, "utf8");
|
||||
return content
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.trim() && !line.trim().startsWith("#"))
|
||||
.reduce((acc, line) => {
|
||||
const idx = line.indexOf("=");
|
||||
if (idx === -1) return acc;
|
||||
const key = line.slice(0, idx).trim();
|
||||
const value = line.slice(idx + 1).trim();
|
||||
acc[key] = value;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
async function waitForExit(child, name) {
|
||||
return await new Promise((resolve) => {
|
||||
child.on("exit", (code, signal) => {
|
||||
const status = signal ? `信号 ${signal}` : `退出码 ${code ?? "null"}`;
|
||||
logPrefix(name, `进程结束(${status})`);
|
||||
resolve(code ?? 0);
|
||||
});
|
||||
child.on("error", () => resolve(1));
|
||||
});
|
||||
}
|
||||
|
||||
function spawnTask({ name, command, cwd, env }) {
|
||||
logPrefix(name, `启动命令:${command}`);
|
||||
const child = spawn(command, {
|
||||
cwd,
|
||||
stdio: "inherit",
|
||||
shell: true,
|
||||
env,
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
if (shuttingDown) return;
|
||||
const status = signal !== null ? `因信号 ${signal} 退出` : `退出码 ${code ?? "null"}`;
|
||||
logPrefix(name, `进程结束(${status}),准备清理其它任务。`);
|
||||
shutdown(code ?? 0);
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
logPrefix(name, `启动失败:${err.message}`);
|
||||
shutdown(1);
|
||||
});
|
||||
children.push(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
function shutdown(code) {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
logPrefix("system", "收到终止信号,正在关闭所有子进程…");
|
||||
for (const child of children) {
|
||||
if (!child.killed) {
|
||||
child.kill("SIGINT");
|
||||
setTimeout(() => {
|
||||
if (!child.killed) {
|
||||
child.kill("SIGTERM");
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
setTimeout(() => process.exit(code), 200);
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => shutdown(0));
|
||||
process.on("SIGTERM", () => shutdown(0));
|
||||
|
||||
async function checkPortOpen(host, port, timeoutMs = 1200) {
|
||||
return await new Promise((resolve) => {
|
||||
const socket = net.createConnection({ host, port });
|
||||
const timer = setTimeout(() => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
}, timeoutMs);
|
||||
socket.once("connect", () => {
|
||||
clearTimeout(timer);
|
||||
socket.end();
|
||||
resolve(true);
|
||||
});
|
||||
socket.once("error", () => {
|
||||
clearTimeout(timer);
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function checkRedisReachable(urlString, timeoutMs = 2000) {
|
||||
try {
|
||||
const url = new URL(urlString);
|
||||
const host = url.hostname || "127.0.0.1";
|
||||
const port = Number(url.port) || 6379;
|
||||
return await checkPortOpen(host, port, timeoutMs);
|
||||
} catch (error) {
|
||||
logPrefix("celery", `REDIS_URL (${urlString}) 解析失败:${error.message},跳过连通性检查。`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function requireFile(filePath, hint) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error(`[system] 缺少文件:${filePath}`);
|
||||
if (hint) console.error(`[system] 提示:${hint}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureStandaloneStatic(frontendDir) {
|
||||
// 说明:当 next.config.ts 配置了 output: "standalone" 时,推荐使用
|
||||
// `node .next/standalone/server.js` 启动。
|
||||
// 但 Next 不会自动把 `.next/static` 放入 `.next/standalone/.next/static`,
|
||||
// 若缺失会导致 `/_next/static/*` 404(远程访问尤其明显)。
|
||||
const standaloneServer = path.join(frontendDir, ".next", "standalone", "server.js");
|
||||
if (!fs.existsSync(standaloneServer)) return false;
|
||||
|
||||
const src = path.join(frontendDir, ".next", "static");
|
||||
const dest = path.join(frontendDir, ".next", "standalone", ".next", "static");
|
||||
if (!fs.existsSync(src)) return true;
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
// Node 16+:fs.cpSync
|
||||
fs.cpSync(src, dest, { recursive: true });
|
||||
return true;
|
||||
} catch (err) {
|
||||
logPrefix(
|
||||
"frontend",
|
||||
`复制 .next/static 到 standalone 失败:${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
// 失败时不强制退出,仍尝试启动(方便你自行排查)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// 约定:全局仅使用仓库根目录的 .env.all 作为配置来源(生产优先)。
|
||||
const envAllPath = path.join(rootDir, ".env.all");
|
||||
requireFile(envAllPath, "请先在仓库根目录创建 .env.all(全局唯一 env 文件)");
|
||||
const envAll = loadEnvFile(envAllPath);
|
||||
|
||||
// 生产模式:前端必须使用 production env,避免本地开发配置污染公网用户
|
||||
const envFrontend = {
|
||||
...process.env,
|
||||
...envAll,
|
||||
NODE_ENV: "production",
|
||||
NEXT_TELEMETRY_DISABLED: "1",
|
||||
};
|
||||
|
||||
// 后端/内部服务:尽量使用本机/内网配置(不会暴露给浏览器)
|
||||
const envBackend = {
|
||||
...process.env,
|
||||
...envAll,
|
||||
PYTHONUTF8: "1",
|
||||
};
|
||||
const envInternalServices = {
|
||||
...process.env,
|
||||
...envAll,
|
||||
PYTHONUTF8: "1",
|
||||
};
|
||||
|
||||
const shouldSkipBuild =
|
||||
(process.env.SKIP_FRONTEND_BUILD || "").toLowerCase() === "1" ||
|
||||
(process.env.SKIP_FRONTEND_BUILD || "").toLowerCase() === "true";
|
||||
|
||||
if (!shouldSkipBuild) {
|
||||
logPrefix("frontend", "开始 production build…");
|
||||
const build = spawn("pnpm -C wolai-frontend build", {
|
||||
cwd: rootDir,
|
||||
stdio: "inherit",
|
||||
shell: true,
|
||||
env: envFrontend,
|
||||
});
|
||||
const code = await waitForExit(build, "frontend");
|
||||
if (code !== 0) {
|
||||
process.exit(code);
|
||||
}
|
||||
} else {
|
||||
logPrefix("frontend", "已跳过 build(SKIP_FRONTEND_BUILD=1)");
|
||||
}
|
||||
|
||||
const useStandalone = ensureStandaloneStatic(frontendDir);
|
||||
|
||||
// 预检查:提醒必要端口是否已就绪(不强制退出,避免误伤已有进程)
|
||||
const portChecks = [
|
||||
{ name: "frontend", port: frontendPort },
|
||||
{ name: "backend", port: backendPort },
|
||||
{ name: "rag_gateway", port: ragGatewayPort },
|
||||
{ name: "ingest_service", port: ingestPort },
|
||||
];
|
||||
for (const item of portChecks) {
|
||||
// 仅检查本机 127.0.0.1 是否已有服务在监听,方便你快速判断“是否重复启动”
|
||||
// 监听着不代表冲突:可能是你已经手动启动了。
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const open = await checkPortOpen("127.0.0.1", item.port);
|
||||
if (open) {
|
||||
logPrefix("system", `检测到端口 ${item.port} 已有服务监听(${item.name}),若你准备全量启动,请确认不会端口冲突。`);
|
||||
}
|
||||
}
|
||||
|
||||
// 启动前端(production)
|
||||
const envFrontendServer = {
|
||||
...envFrontend,
|
||||
PORT: String(frontendPort),
|
||||
HOSTNAME: "0.0.0.0",
|
||||
};
|
||||
spawnTask({
|
||||
name: "frontend",
|
||||
// 说明:next.config.ts 配置了 output: "standalone" 时,应使用 standalone server 启动,避免 next start 行为不一致。
|
||||
command: useStandalone
|
||||
? "node .next/standalone/server.js"
|
||||
: `pnpm -C wolai-frontend exec next start -p ${frontendPort} -H 0.0.0.0`,
|
||||
cwd: useStandalone ? frontendDir : rootDir,
|
||||
env: envFrontendServer,
|
||||
});
|
||||
|
||||
// 启动 wolai-backend(FastAPI)
|
||||
spawnTask({
|
||||
name: "backend",
|
||||
command: `${pythonBin} -m uvicorn app.main:app --host 0.0.0.0 --port ${backendPort}`,
|
||||
cwd: backendDir,
|
||||
env: envBackend,
|
||||
});
|
||||
|
||||
// Celery(可选)
|
||||
if (shouldStartCelery(process.env)) {
|
||||
const ok = await checkRedisReachable(envBackend.REDIS_URL || redisUrl);
|
||||
if (ok) {
|
||||
spawnTask({
|
||||
name: "celery",
|
||||
command: celeryCmdFromEnv || `${celeryBin} -A app.workers.celery_app worker --loglevel=info`,
|
||||
cwd: backendDir,
|
||||
env: envBackend,
|
||||
});
|
||||
} else {
|
||||
logPrefix("celery", `检测到 Redis 不可达(${envBackend.REDIS_URL || redisUrl}),跳过 Celery。`);
|
||||
}
|
||||
} else if (isEnabledEnv(process.env.SKIP_CELERY)) {
|
||||
logPrefix("celery", "已跳过 Celery(SKIP_CELERY=1)");
|
||||
} else {
|
||||
logPrefix("celery", "默认不启动 Celery;当前主线页面不依赖 Redis/Celery。如需启用请设置 ENABLE_CELERY=1 或 CELERY_CMD。");
|
||||
}
|
||||
|
||||
// 启动 ingest_service(自动入库、OCR 触发、延迟删除清理)
|
||||
spawnTask({
|
||||
name: "ingest",
|
||||
command: `${pythonBin} -m uvicorn app.main:app --host 0.0.0.0 --port ${ingestPort}`,
|
||||
cwd: ingestDir,
|
||||
env: envInternalServices,
|
||||
});
|
||||
|
||||
// 启动 rag_gateway(/rag 查询网关)
|
||||
spawnTask({
|
||||
name: "rag_gateway",
|
||||
command: `${pythonBin} -m uvicorn app.main:app --host 0.0.0.0 --port ${ragGatewayPort}`,
|
||||
cwd: ragGatewayDir,
|
||||
env: envInternalServices,
|
||||
});
|
||||
|
||||
logPrefix(
|
||||
"system",
|
||||
`已启动:frontend:${frontendPort} backend:${backendPort} ingest:${ingestPort} rag_gateway:${ragGatewayPort}(如需公网访问,请确保 cloudflared tunnel 正在运行)`,
|
||||
);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
console.error(`[system] 启动失败:${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
shouldStartCelery,
|
||||
};
|
||||
@@ -1,12 +0,0 @@
|
||||
const assert = require("node:assert");
|
||||
const { test } = require("node:test");
|
||||
|
||||
const { shouldStartCelery } = require("./desktop-prod.js");
|
||||
|
||||
test("desktop-prod 默认跳过 Celery,只有显式开启时才启动", () => {
|
||||
assert.equal(shouldStartCelery({}), false);
|
||||
assert.equal(shouldStartCelery({ ENABLE_CELERY: "1" }), true);
|
||||
assert.equal(shouldStartCelery({ ENABLE_CELERY: "true" }), true);
|
||||
assert.equal(shouldStartCelery({ CELERY_CMD: "custom-celery" }), true);
|
||||
assert.equal(shouldStartCelery({ ENABLE_CELERY: "1", SKIP_CELERY: "1" }), false);
|
||||
});
|
||||
@@ -111,12 +111,6 @@ function Get-AppliedEnvFiles {
|
||||
}
|
||||
}
|
||||
|
||||
# Vite(开发模式)默认读取:仅对仓库内已存在的 webui 目录做显式识别
|
||||
$viteDev = Join-Path $Root "LightRAG\lightrag_webui\.env.development"
|
||||
if (Test-Path -LiteralPath $viteDev) {
|
||||
$applied.Add((Resolve-Path -LiteralPath $viteDev).Path) | Out-Null
|
||||
}
|
||||
|
||||
return $applied | Select-Object -Unique
|
||||
}
|
||||
|
||||
@@ -273,10 +267,7 @@ function Get-MergeOrderScore {
|
||||
# 目录组优先级:越大越[后覆盖](优先级更高)
|
||||
$group = 0
|
||||
if ($rel -match "^(?i)wolai-backend[/\\\\]") { $group = 60 }
|
||||
elseif ($rel -match "^(?i)wolai-frontend[/\\\\]") { $group = 50 }
|
||||
elseif ($rel -match "^(?i)infra[/\\\\]convex[/\\\\]") { $group = 40 }
|
||||
elseif ($rel -match "^(?i)services[/\\\\]") { $group = 30 }
|
||||
elseif ($rel -match "^(?i)LightRAG[/\\\\]") { $group = 20 }
|
||||
elseif ($rel -match "^(?i)cankao[/\\\\]") { $group = -50 }
|
||||
elseif ($rel -match "(?i)[/\\\\]test[/\\\\]") { $group = -40 }
|
||||
else { $group = 0 }
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 生成桌面端内置的 Next standalone 产物到 `desktop-electron/desktop-next`。
|
||||
*
|
||||
* 目标:
|
||||
* - 打包时不依赖用户机器上的 pnpm / node 环境即可运行(Electron 自身携带 Node)。
|
||||
* - 运行期文件读写落到安装目录的 data/(由 Electron 注入 MNOTE_DATA_DIR)。
|
||||
*
|
||||
* 注意:需要 wolai-frontend 的 next.config.ts 开启 output: "standalone"。
|
||||
*/
|
||||
|
||||
const { spawnSync } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const rootDir = path.resolve(__dirname, "..");
|
||||
const frontendDir = path.join(rootDir, "wolai-frontend");
|
||||
const outDir = path.join(rootDir, "desktop-electron", "desktop-next");
|
||||
|
||||
function parseDotenv(filePath) {
|
||||
if (!fs.existsSync(filePath)) return {};
|
||||
const raw = fs.readFileSync(filePath, { encoding: "utf8" });
|
||||
const out = {};
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const idx = trimmed.indexOf("=");
|
||||
if (idx === -1) continue;
|
||||
const key = trimmed.slice(0, idx).trim();
|
||||
const value = trimmed.slice(idx + 1).trim();
|
||||
if (!key) continue;
|
||||
out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function ensureDir(dir) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
function rmDir(dir) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function run(command, cwd, env) {
|
||||
const r = spawnSync(command, {
|
||||
cwd,
|
||||
env: { ...process.env, ...env },
|
||||
stdio: "inherit",
|
||||
shell: true,
|
||||
});
|
||||
if (r.status !== 0) {
|
||||
process.exit(r.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
function assertExists(filePath, hint) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error(`\n[prepare-desktop-next] 缺少:${filePath}`);
|
||||
if (hint) console.error(`[prepare-desktop-next] 提示:${hint}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function copyDir(from, to) {
|
||||
// 关键:Next standalone 在 pnpm 环境下可能包含大量 symlink/junction。
|
||||
// electron-builder 在复制 extraResources 时对链接目录处理不稳定,
|
||||
// 因此这里强制“解引用复制”,把真实文件复制出来,确保运行期能 require('next')。
|
||||
fs.cpSync(from, to, { recursive: true, force: true, dereference: true });
|
||||
}
|
||||
|
||||
function main() {
|
||||
console.log("[prepare-desktop-next] 开始构建 wolai-frontend(standalone)…");
|
||||
|
||||
// 约定:全局仅使用仓库根目录的 .env.all 作为配置来源(生产优先)。
|
||||
// 桌面端如需覆盖,可通过系统环境变量注入(例如在启动命令前临时设置)。
|
||||
const envAllPath = path.join(rootDir, ".env.all");
|
||||
assertExists(envAllPath, "请先在仓库根目录创建 .env.all(全局唯一 env 文件)。");
|
||||
const desktopEnv = {
|
||||
...parseDotenv(envAllPath),
|
||||
};
|
||||
console.log("[prepare-desktop-next] 桌面端 env 摘要:", {
|
||||
NEXT_PUBLIC_BACKEND_URL: desktopEnv.NEXT_PUBLIC_BACKEND_URL,
|
||||
NEXT_PUBLIC_ONLYOFFICE_BASE_URL: desktopEnv.NEXT_PUBLIC_ONLYOFFICE_BASE_URL,
|
||||
});
|
||||
|
||||
run("pnpm -C wolai-frontend build", rootDir, {
|
||||
NODE_ENV: "production",
|
||||
NEXT_TELEMETRY_DISABLED: "1",
|
||||
...desktopEnv,
|
||||
});
|
||||
|
||||
const standaloneDir = path.join(frontendDir, ".next", "standalone");
|
||||
const staticDir = path.join(frontendDir, ".next", "static");
|
||||
const publicDir = path.join(frontendDir, "public");
|
||||
|
||||
assertExists(
|
||||
path.join(standaloneDir, "server.js"),
|
||||
'请在 wolai-frontend/next.config.ts 增加 `output: "standalone"` 后重试。',
|
||||
);
|
||||
assertExists(staticDir, "Next build 未生成 .next/static(可能构建失败或目录异常)。");
|
||||
assertExists(publicDir, "wolai-frontend/public 不存在。");
|
||||
|
||||
console.log(`[prepare-desktop-next] 清理旧产物:${outDir}`);
|
||||
rmDir(outDir);
|
||||
ensureDir(outDir);
|
||||
|
||||
console.log("[prepare-desktop-next] 复制 standalone …");
|
||||
copyDir(standaloneDir, outDir);
|
||||
|
||||
console.log("[prepare-desktop-next] 补齐 .next/static …");
|
||||
ensureDir(path.join(outDir, ".next"));
|
||||
copyDir(staticDir, path.join(outDir, ".next", "static"));
|
||||
|
||||
console.log("[prepare-desktop-next] 补齐 public …");
|
||||
copyDir(publicDir, path.join(outDir, "public"));
|
||||
|
||||
console.log("[prepare-desktop-next] 完成。");
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -169,7 +169,7 @@ async function main() {
|
||||
details: `收到 ${receivedDeltas.length} 条 delta 事件`,
|
||||
});
|
||||
|
||||
// ── 6. 验证编辑器内容已更新(通过回读 Convex) ──
|
||||
// ── 6. 验证编辑器内容已更新(通过 runtime 回读) ──
|
||||
const readbackRes = await callTool(request, {
|
||||
toolName: "mnote.doc.fetch",
|
||||
workspaceId: target.workspaceId,
|
||||
@@ -185,7 +185,7 @@ async function main() {
|
||||
const finalBlocks = readbackRes.body?.blockDocument?.blocks || [];
|
||||
const hasReplaced = finalBlocks.some((b) => b.text?.includes("段落B已替换"));
|
||||
report.checks.push({
|
||||
name: "Convex 回读确认替换成功",
|
||||
name: "runtime 回读确认替换成功",
|
||||
passed: hasReplaced,
|
||||
details: finalBlocks.map((b) => b.text).join(" | "),
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*
|
||||
* 验证 EditorRuntimeActor Phase A:
|
||||
* - 块写工具(replace / insert_after)在 actor 启用时返回时间 < 800ms
|
||||
* - 多次写入循环不依赖 Convex RTT
|
||||
* - 多次写入循环不依赖远端 RTT
|
||||
* - 写后回读内容正确
|
||||
*
|
||||
* 依赖:运行中的 mnote-web (localhost:3000),已登录状态,测试 Helpers
|
||||
@@ -154,7 +154,7 @@ async function main() {
|
||||
console.log(`2/3 insert_after: ${insertMs}ms — ok`);
|
||||
|
||||
// 3. doc.fetch — 回读验证
|
||||
await sleep(500); // 等 Convex 写入完成
|
||||
await sleep(500); // 等待 runtime 写入完成
|
||||
const t4 = Date.now();
|
||||
const readbackRes = await callTool(request, {
|
||||
toolName: "mnote.doc.fetch",
|
||||
@@ -184,11 +184,11 @@ async function main() {
|
||||
assert.ok(texts.some((t) => t.includes("第三段")), "应包含第三段");
|
||||
assert.ok(texts.some((t) => t.includes("插入段")), "应包含插入段");
|
||||
|
||||
// ── 延迟断言:三次写循环总时间 < 800ms(不含 Convex wait) ──
|
||||
// ── 延迟断言:三次写循环总时间 < 800ms(不含回读等待) ──
|
||||
const writeTotal = replaceMs + insertMs;
|
||||
const allWithReadback = writeTotal + readbackMs;
|
||||
console.log(`\n=== 延迟报告 ===`);
|
||||
console.log(`三次操作总延迟(不含 await Convex): ${writeTotal}ms`);
|
||||
console.log(`三次操作总延迟(不含回读等待): ${writeTotal}ms`);
|
||||
console.log(`含回读总延迟: ${allWithReadback}ms`);
|
||||
|
||||
// 写操作若 > 800ms 打印 warning 但不 fail(因为首跑可能较慢)
|
||||
|
||||
@@ -121,7 +121,7 @@ async function validateGateway(baseUrl) {
|
||||
const rootText = await root.text();
|
||||
assert.equal(root.status, 303, `/ 未登录应跳转 /auth: ${root.status} ${rootText.slice(0, 120)}`);
|
||||
assert.equal(root.headers.get("x-mnote-web-owner"), "mnote-web");
|
||||
assert.equal(root.headers.get("location"), "/auth");
|
||||
assert.equal(root.headers.get("location"), "/auth?next=%2F");
|
||||
|
||||
const authedRoot = await fetchWithTimeout(`${baseUrl}/`, {
|
||||
headers: {
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const { spawn } = require("node:child_process");
|
||||
const {
|
||||
fetchWithTimeout,
|
||||
findFreePort,
|
||||
waitForGateway,
|
||||
} = require("./task114-rust-web-gateway-entry-smoke.js");
|
||||
const { resolveRuntimePlan } = require("./desktop-hot.js");
|
||||
|
||||
function buildRetirementFixtureEnv(port) {
|
||||
return {
|
||||
SKIP_NEXT_LEGACY: "1",
|
||||
MNOTE_WEB_ALLOW_DEV_FIXTURES: "1",
|
||||
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
|
||||
MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000",
|
||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
|
||||
MNOTE_WEB_QUERY_FIXTURES_JSON: JSON.stringify({
|
||||
"documents:getMeta": {
|
||||
id: "doc_1",
|
||||
workspace_id: "ws_demo",
|
||||
title: "服务端页面",
|
||||
updated_at: "2026-04-28T00:00:00Z",
|
||||
can_edit: true,
|
||||
word_count: 42,
|
||||
character_count: 128,
|
||||
block_count: 3,
|
||||
},
|
||||
"documents:getContent": {
|
||||
content: [{ id: "block_1", type: "paragraph", content: [] }],
|
||||
revision: 7,
|
||||
conflict_detection_key: "doc_1:7",
|
||||
pageSubtree: { rootNodeId: "doc_1", outline: [] },
|
||||
},
|
||||
"sidebar:datasetList": {
|
||||
active_workspace_id: "ws_demo",
|
||||
workspaces: [],
|
||||
documents: [
|
||||
{
|
||||
id: "doc_1",
|
||||
workspace_id: "ws_demo",
|
||||
title: "服务端页面",
|
||||
parent_id: null,
|
||||
sort_order: 0,
|
||||
is_starred: false,
|
||||
is_template: false,
|
||||
created_at: "2026-04-28T00:00:00Z",
|
||||
updated_at: "2026-04-28T00:00:00Z",
|
||||
},
|
||||
],
|
||||
trashed_documents: [],
|
||||
media_assets: [],
|
||||
trashed_media_assets: [],
|
||||
mindmap_assets: [],
|
||||
trashed_mindmap_assets: [],
|
||||
table_assets: [],
|
||||
trashed_table_assets: [],
|
||||
mindmap_docs: [],
|
||||
mindmap_asset_children: {},
|
||||
},
|
||||
"bridgeLogs:listWorkspaceOverview": {
|
||||
workspace_id: "ws_demo",
|
||||
command_logs: [],
|
||||
domain_events: [],
|
||||
next_cursor: null,
|
||||
has_more: false,
|
||||
filters: {
|
||||
command_status: null,
|
||||
event_status: null,
|
||||
target_page_id: null,
|
||||
target_block_id: null,
|
||||
aggregate_type: null,
|
||||
aggregate_id: null,
|
||||
},
|
||||
generated_at: "2026-04-28T00:00:00Z",
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function startRetiredNextGateway(port) {
|
||||
return spawn("cargo", ["run", "-q", "-p", "mnote-web", "--bin", "mnote-web"], {
|
||||
cwd: "/mnt/Data1T/mnote/rust",
|
||||
env: {
|
||||
...process.env,
|
||||
...buildRetirementFixtureEnv(port),
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
|
||||
function authedInit(init = {}) {
|
||||
return {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.headers || {}),
|
||||
"x-mnote-actor-id": "user_real",
|
||||
"x-mnote-actor-type": "user",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function readText(baseUrl, path, init) {
|
||||
const response = await fetchWithTimeout(`${baseUrl}${path}`, init);
|
||||
const text = await response.text();
|
||||
assert.equal(response.status, 200, `${path} 请求失败: ${response.status} ${text.slice(0, 200)}`);
|
||||
assert.equal(response.headers.get("x-mnote-web-owner"), "mnote-web", `${path} 缺少 mnote-web owner`);
|
||||
assert.equal(
|
||||
response.headers.get("x-mnote-legacy-upstream"),
|
||||
null,
|
||||
`${path} 默认主路径不应出现 Next fallback header`,
|
||||
);
|
||||
return { response, text };
|
||||
}
|
||||
|
||||
async function validateSkipNextRuntimePlan() {
|
||||
const plan = resolveRuntimePlan({
|
||||
FRONTEND_PORT: "3000",
|
||||
NEXT_LEGACY_PORT: "3100",
|
||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "1",
|
||||
});
|
||||
assert.equal(plan.skipGateway, false);
|
||||
assert.equal(plan.skipNextLegacy, true);
|
||||
assert.equal(plan.frontendTaskName, null);
|
||||
assert.equal(plan.publicPort, 3000);
|
||||
assert.equal(plan.legacyPort, 3100);
|
||||
assert.equal(plan.mnoteWebEnv.MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT, "0");
|
||||
assert.equal("MNOTE_WEB_LEGACY_NEXT_BASE_URL" in plan.mnoteWebEnv, false);
|
||||
return plan;
|
||||
}
|
||||
|
||||
async function validateCoreShellsWithoutNext(baseUrl) {
|
||||
const root = await readText(baseUrl, "/?workspaceId=ws_demo", authedInit());
|
||||
assert.match(root.text, /data-mnote-shell="workspace"/);
|
||||
assert.doesNotMatch(root.text, /next-app-router/i);
|
||||
|
||||
const auth = await readText(baseUrl, "/auth");
|
||||
assert.match(auth.text, /data-mnote-shell="auth"/);
|
||||
assert.doesNotMatch(auth.text, /next-app-router/i);
|
||||
|
||||
const document = await readText(baseUrl, "/documents/doc_1?workspaceId=ws_demo", authedInit());
|
||||
assert.equal(document.response.headers.get("x-mnote-web-shell"), "document");
|
||||
assert.match(document.text, /mnote\.page_aggregate\.v1/);
|
||||
|
||||
const treeEvents = await fetchWithTimeout(`${baseUrl}/api/tree/events?workspaceId=ws_demo&maxPolls=0`, authedInit());
|
||||
const treeBody = await treeEvents.text();
|
||||
assert.equal(treeEvents.status, 200, `/api/tree/events 请求失败: ${treeEvents.status} ${treeBody.slice(0, 200)}`);
|
||||
assert.equal(treeEvents.headers.get("x-mnote-web-owner"), "mnote-web");
|
||||
assert.equal(treeEvents.headers.get("x-mnote-tree-stream-owner"), "rust-web");
|
||||
assert.match(treeBody, /event:\s*snapshot/);
|
||||
|
||||
const search = await readText(baseUrl, "/search?workspaceId=ws_demo&q=Rust", authedInit());
|
||||
assert.equal(search.response.headers.get("x-mnote-web-shell"), "search");
|
||||
assert.match(search.text, /mnote\.search_shell\.v1/);
|
||||
|
||||
const mindmap = await readText(baseUrl, "/mindmap/doc_1/mind_1", authedInit());
|
||||
assert.equal(mindmap.response.headers.get("x-mnote-web-shell"), "mindmap");
|
||||
assert.match(mindmap.text, /mnote\.mindmap_shell\.v1/);
|
||||
assert.doesNotMatch(mindmap.text, /next-app-router/i);
|
||||
}
|
||||
|
||||
async function validateDocs() {
|
||||
const fs = require("node:fs");
|
||||
const architecture = fs.readFileSync("/mnt/Data1T/mnote/ARCHITECTURE.md", "utf8");
|
||||
assert.match(architecture, /Next App Router.*legacy compat/is, "ARCHITECTURE.md 未标记 Next App Router legacy compat");
|
||||
assert.doesNotMatch(architecture, /Next App Router 仍是当前 legacy 主壳/, "ARCHITECTURE.md 仍保留旧主壳口径");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const plan = await validateSkipNextRuntimePlan();
|
||||
const port = await findFreePort();
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
const gateway = startRetiredNextGateway(port);
|
||||
let stderr = "";
|
||||
gateway.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString("utf8");
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForGateway(baseUrl);
|
||||
await validateCoreShellsWithoutNext(baseUrl);
|
||||
await validateDocs();
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
baseUrl,
|
||||
publicEntry: String(plan.publicPort),
|
||||
legacyPort: String(plan.legacyPort),
|
||||
nextLegacyStarted: false,
|
||||
owner: "mnote-web",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
if (stderr.trim()) {
|
||||
console.error(stderr.trim().slice(-2000));
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (gateway.pid) {
|
||||
gateway.kill("SIGTERM");
|
||||
setTimeout(() => {
|
||||
if (!gateway.killed) {
|
||||
gateway.kill("SIGKILL");
|
||||
}
|
||||
}, 2000).unref();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,241 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const { chromium } = require("playwright");
|
||||
const { fetchWithTimeout } = require("./task114-rust-web-gateway-entry-smoke.js");
|
||||
|
||||
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||
const SCREENSHOT_DIR = process.env.MNOTE_PHASE_E_SCREENSHOT_DIR || "/mnt/Data1T/mnote/tmp/wolai-editor-parity/task154-e26-anchor-local-smoke";
|
||||
const LEPTOS_SPIKE_SOURCE = "/mnt/Data1T/mnote/rust/spikes/leptos-tiptap-spike/src/lib.rs";
|
||||
const CONVERTER_SOURCE = "/mnt/Data1T/mnote/wolai-frontend/src/lib/documents/tiptap-content-converter.ts";
|
||||
const TARGET_BLOCK_ID = "e26-anchor-target";
|
||||
const SECOND_BLOCK_ID = "e26-anchor-second";
|
||||
|
||||
function assertAnchorSourceBoundary() {
|
||||
const spike = fs.readFileSync(LEPTOS_SPIKE_SOURCE, "utf8");
|
||||
assert(!spike.includes('Some(format!("top-level-{index}"))'), "E26 不能继续把 top-level 序号当块锚点 id");
|
||||
assert(spike.includes("data-block-id"), "E26 runtime DOM 必须暴露 data-block-id");
|
||||
assert(spike.includes("navigator.clipboard.writeText"), "E26 复制链接必须真实写入剪贴板");
|
||||
assert(spike.includes("scroll_mnote_block_anchor_from_hash"), "E26 必须处理 reload/hash 定位");
|
||||
const converter = fs.readFileSync(CONVERTER_SOURCE, "utf8");
|
||||
assert(converter.includes('const BLOCK_ID_ATTR = "blockId"'), "TS 转换层必须继续以 Rust blockId 作为 Tiptap attrs 真源");
|
||||
assert(converter.includes("editorBlockDocumentFromTiptapDoc"), "保存链必须继续回到 EditorBlockDocument");
|
||||
}
|
||||
|
||||
async function readJsonResponse(response, label) {
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
throw new Error(`${label} 返回了非 JSON 内容: ${text.slice(0, 1200)}`);
|
||||
}
|
||||
assert(response.ok, `${label} 请求失败: ${response.status}; body: ${text.slice(0, 1200)}`);
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function postTreeCommand(body, label) {
|
||||
const response = await fetchWithTimeout(`${BASE_URL}/api/tree/commands`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const payload = await readJsonResponse(response, label);
|
||||
assert(payload?.result, `${label} 缺少 result`);
|
||||
return payload.result;
|
||||
}
|
||||
|
||||
async function createTempDocument() {
|
||||
const title = `task154-e26-anchor-${Date.now().toString(36)}`;
|
||||
const result = await postTreeCommand({ action: "create", title }, "创建 E26 临时文档");
|
||||
assert(result.documentId, "创建 E26 临时文档缺少 documentId");
|
||||
assert(result.workspaceId, "创建 E26 临时文档缺少 workspaceId");
|
||||
return { documentId: result.documentId, workspaceId: result.workspaceId, title };
|
||||
}
|
||||
|
||||
async function purgeTempDocument(target) {
|
||||
if (!target?.documentId || !target?.workspaceId) return;
|
||||
await postTreeCommand({ action: "purge", workspaceId: target.workspaceId, documentId: target.documentId }, "清理 E26 临时文档");
|
||||
}
|
||||
|
||||
async function loadDocumentContent(target, label) {
|
||||
const url = `${BASE_URL}/api/documents/content?documentId=${encodeURIComponent(target.documentId)}&workspaceId=${encodeURIComponent(target.workspaceId)}`;
|
||||
const response = await fetchWithTimeout(url, { method: "GET" });
|
||||
return readJsonResponse(response, label);
|
||||
}
|
||||
|
||||
async function waitForRuntimeIsland(page) {
|
||||
const root = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first();
|
||||
await root.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const editor = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]').first();
|
||||
await editor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(() => {
|
||||
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
const editorNode = host?.querySelector('.editor-surface .ProseMirror[contenteditable="true"]');
|
||||
return host?.getAttribute("data-runtime-editor-status") !== "error" && editorNode instanceof HTMLElement && editorNode.isContentEditable;
|
||||
}, null, { timeout: UI_TIMEOUT_MS });
|
||||
return editor;
|
||||
}
|
||||
|
||||
async function screenshot(page, name) {
|
||||
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/${name}.png`, fullPage: true });
|
||||
}
|
||||
|
||||
function collectBlockIds(value, ids = []) {
|
||||
if (!value || typeof value !== "object") return ids;
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) collectBlockIds(item, ids);
|
||||
return ids;
|
||||
}
|
||||
if (typeof value.blockId === "string") ids.push(value.blockId);
|
||||
if (typeof value.id === "string") ids.push(value.id);
|
||||
if (value.attrs && typeof value.attrs.blockId === "string") ids.push(value.attrs.blockId);
|
||||
collectBlockIds(value.content, ids);
|
||||
collectBlockIds(value.children, ids);
|
||||
collectBlockIds(value.blocks, ids);
|
||||
collectBlockIds(value.editorDocument, ids);
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function setAnchorFixture(page) {
|
||||
await page.evaluate(({ targetBlockId, secondBlockId }) => {
|
||||
const editor = document.querySelector('.editor-surface .ProseMirror')?.editor;
|
||||
if (!editor) throw new Error('找不到 Tiptap editor');
|
||||
editor.commands.setContent({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
attrs: { blockId: targetBlockId },
|
||||
content: [{ type: 'text', text: 'E26 anchor target paragraph' }],
|
||||
},
|
||||
{
|
||||
type: 'heading',
|
||||
attrs: { blockId: secondBlockId, level: 2 },
|
||||
content: [{ type: 'text', text: 'E26 second heading' }],
|
||||
},
|
||||
],
|
||||
}, true);
|
||||
editor.commands.focus('start');
|
||||
}, { targetBlockId: TARGET_BLOCK_ID, secondBlockId: SECOND_BLOCK_ID });
|
||||
try {
|
||||
await page.waitForFunction(({ targetBlockId, secondBlockId }) => {
|
||||
return document.querySelector(`[data-block-id="${targetBlockId}"]`) instanceof HTMLElement
|
||||
&& document.querySelector(`[data-block-id="${secondBlockId}"]`) instanceof HTMLElement;
|
||||
}, { targetBlockId: TARGET_BLOCK_ID, secondBlockId: SECOND_BLOCK_ID }, { timeout: UI_TIMEOUT_MS });
|
||||
} catch (error) {
|
||||
const diagnostics = await page.evaluate(() => {
|
||||
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
const editor = document.querySelector('.editor-surface .ProseMirror');
|
||||
return {
|
||||
status: host?.getAttribute('data-runtime-editor-status') || null,
|
||||
html: editor?.innerHTML || null,
|
||||
json: editor?.editor?.getJSON?.() || null,
|
||||
};
|
||||
}).catch((evalError) => ({ evalError: String(evalError) }));
|
||||
throw new Error(`E26 fixture 未渲染 data-block-id: ${JSON.stringify(diagnostics).slice(0, 2400)}; cause=${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForSavedDocument(page, expectedBlockIds) {
|
||||
await page.waitForFunction(({ expectedBlockIds }) => {
|
||||
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
if (host?.getAttribute('data-runtime-editor-status') !== 'saved') return false;
|
||||
const editor = host?.querySelector('.editor-surface .ProseMirror');
|
||||
if (!(editor instanceof HTMLElement)) return false;
|
||||
return expectedBlockIds.every((blockId) => editor.querySelector(`[data-block-id="${blockId}"]`) instanceof HTMLElement);
|
||||
}, { expectedBlockIds }, { timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
async function openBlockMenuForTarget(page) {
|
||||
const target = page.locator(`[data-block-id="${TARGET_BLOCK_ID}"]`).first();
|
||||
await target.scrollIntoViewIfNeeded({ timeout: UI_TIMEOUT_MS });
|
||||
await target.hover({ timeout: UI_TIMEOUT_MS });
|
||||
const handle = page.locator('[data-testid="block-drag-handle-trigger"]').first();
|
||||
await handle.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await handle.click({ timeout: UI_TIMEOUT_MS });
|
||||
const menu = page.locator('[data-testid="block-drag-menu"]').first();
|
||||
await menu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
return menu;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
assertAnchorSourceBoundary();
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 960 }, permissions: ["clipboard-read", "clipboard-write"] });
|
||||
const page = await context.newPage();
|
||||
const saveRequests = [];
|
||||
page.on("request", (request) => {
|
||||
if (!request.url().includes("/api/documents/save")) return;
|
||||
const body = request.postData();
|
||||
if (!body) return;
|
||||
try {
|
||||
saveRequests.push(JSON.parse(body));
|
||||
} catch {
|
||||
saveRequests.push({ raw: body });
|
||||
}
|
||||
});
|
||||
|
||||
let target = null;
|
||||
try {
|
||||
target = await createTempDocument();
|
||||
const url = `${BASE_URL}/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`;
|
||||
const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
assert(response, "文档页没有返回响应");
|
||||
assert.equal(response.status(), 200, `文档页状态码异常: ${response.status()}`);
|
||||
|
||||
await waitForRuntimeIsland(page);
|
||||
await setAnchorFixture(page);
|
||||
await waitForSavedDocument(page, [TARGET_BLOCK_ID, SECOND_BLOCK_ID]);
|
||||
await screenshot(page, "01-anchor-blocks-with-data-block-id");
|
||||
|
||||
const persistedByRequest = [...saveRequests].reverse().find((request) => collectBlockIds(request).includes(TARGET_BLOCK_ID));
|
||||
assert(persistedByRequest, `保存请求必须包含 Rust blockId 派生的 EditorBlockDocument/TiptapDocument: ${JSON.stringify(saveRequests.slice(-3)).slice(0, 2000)}`);
|
||||
|
||||
const contentAfterSave = await loadDocumentContent(target, "读取 E26 保存后的正文");
|
||||
assert(collectBlockIds(contentAfterSave?.result ?? contentAfterSave).includes(TARGET_BLOCK_ID), `/api/documents/content 必须保留目标 blockId: ${JSON.stringify(contentAfterSave).slice(0, 1600)}`);
|
||||
|
||||
const menu = await openBlockMenuForTarget(page);
|
||||
await screenshot(page, "02-block-menu-copy-link-entry");
|
||||
await menu.locator('[data-testid="block-drag-menu-item-copy-link"]').first().click({ timeout: UI_TIMEOUT_MS });
|
||||
const clipboardText = await page.evaluate(() => navigator.clipboard.readText());
|
||||
assert(clipboardText.includes(`/documents/${target.documentId}`), `复制链接必须指向当前页面: ${clipboardText}`);
|
||||
assert(clipboardText.endsWith(`#${TARGET_BLOCK_ID}`), `复制链接必须使用 Rust blockId 作为 hash: ${clipboardText}`);
|
||||
assert(!clipboardText.includes("top-level-"), `复制链接不能使用前端序号 id: ${clipboardText}`);
|
||||
|
||||
const hashUrl = new URL(clipboardText);
|
||||
assert.equal(hashUrl.hash, `#${TARGET_BLOCK_ID}`, `复制链接 hash 异常: ${clipboardText}`);
|
||||
await page.goto(hashUrl.href, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await waitForRuntimeIsland(page);
|
||||
await page.waitForFunction((targetBlockId) => {
|
||||
const block = document.querySelector(`[data-block-id="${targetBlockId}"]`);
|
||||
if (!(block instanceof HTMLElement)) return false;
|
||||
const rect = block.getBoundingClientRect();
|
||||
const anchorMatched = block.id === targetBlockId && block.matches(":target");
|
||||
const highlighted = anchorMatched
|
||||
|| block.getAttribute("data-anchor-highlight") === "true"
|
||||
|| block.classList.contains("mnote-block-anchor-highlight");
|
||||
return rect.top >= 0 && rect.top < window.innerHeight * 0.75 && highlighted;
|
||||
}, TARGET_BLOCK_ID, { timeout: UI_TIMEOUT_MS });
|
||||
await screenshot(page, "03-after-hash-reload-anchor-highlight");
|
||||
|
||||
console.log(JSON.stringify({ ok: true, baseUrl: BASE_URL, documentId: target.documentId, blockId: TARGET_BLOCK_ID, clipboardText, screenshotDir: SCREENSHOT_DIR }, null, 2));
|
||||
} finally {
|
||||
if (target) await purgeTempDocument(target).catch(() => undefined);
|
||||
await page.close().catch(() => undefined);
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -1,904 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
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);
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function fileUrl(localPath) {
|
||||
return `file://${localPath}`;
|
||||
}
|
||||
|
||||
function treeUrl(root, mode) {
|
||||
const url = new URL(`${BASE_URL}/`);
|
||||
url.searchParams.set("treeView", mode);
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", fileUrl(root));
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function documentUrl(root, documentId) {
|
||||
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(documentId)}`);
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", fileUrl(root));
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function convexTreeUrl(mode = "filetree") {
|
||||
const url = new URL(`${BASE_URL}/`);
|
||||
url.searchParams.set("treeView", mode);
|
||||
url.searchParams.set("workspaceId", "ws_demo");
|
||||
url.searchParams.set("sourceKind", "convex_workspace");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async function waitForText(page, text) {
|
||||
await page.waitForFunction(
|
||||
(expectedText) => Array.from(document.querySelectorAll("body *")).some((element) => {
|
||||
const textContent = element.textContent ? element.textContent.trim() : "";
|
||||
if (textContent !== expectedText) return false;
|
||||
const style = window.getComputedStyle(element);
|
||||
const rect = element.getBoundingClientRect();
|
||||
return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
|
||||
}),
|
||||
text,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForContextMenu(page) {
|
||||
const menu = page.locator('[data-testid="mnote-tree-context-menu"]').first();
|
||||
await menu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
return menu;
|
||||
}
|
||||
|
||||
async function clickMenuAction(page, action) {
|
||||
await page.locator(`[data-testid="mnote-tree-context-menu"] [data-menu-action="${action}"]`).click({ timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
async function expectMenuAction(page, action, options = {}) {
|
||||
const item = page.locator(`[data-testid="mnote-tree-context-menu"] [data-menu-action="${action}"]`).first();
|
||||
await item.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const disabled = await item.isDisabled();
|
||||
if (options.disabled !== undefined) {
|
||||
assert(disabled === options.disabled, `${action} 菜单项 disabled=${disabled} 不符合预期`);
|
||||
}
|
||||
if (options.reasonIncludes) {
|
||||
const reason = await item.getAttribute("data-disabled-reason");
|
||||
assert((reason || "").includes(options.reasonIncludes), `${action} 菜单项应展示禁用原因`);
|
||||
}
|
||||
}
|
||||
|
||||
async function closeContextMenu(page) {
|
||||
await page.keyboard.press("Escape");
|
||||
try {
|
||||
await page.locator('[data-testid="mnote-tree-context-menu"]').waitFor({ state: "detached", timeout: 1_000 });
|
||||
} catch {
|
||||
await page.evaluate(() => {
|
||||
document.querySelectorAll('[data-testid="mnote-tree-context-menu"]').forEach((element) => element.remove());
|
||||
});
|
||||
await page.locator('[data-testid="mnote-tree-context-menu"]').waitFor({ state: "detached", timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
}
|
||||
|
||||
async function dispatchFolderContextMenu(page, rowSelector) {
|
||||
await page.evaluate(
|
||||
({ rowSelector }) => {
|
||||
const row = document.querySelector(rowSelector);
|
||||
if (!(row instanceof HTMLElement)) throw new Error("filetree folder row 不存在");
|
||||
row.dispatchEvent(new MouseEvent("contextmenu", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: 24,
|
||||
clientY: 64,
|
||||
}));
|
||||
},
|
||||
{ rowSelector },
|
||||
);
|
||||
}
|
||||
|
||||
async function dispatchRootContextMenu(page) {
|
||||
await page.evaluate(() => {
|
||||
const root = document.querySelector("#sidebar-file-tree-root .tree-root");
|
||||
if (!(root instanceof HTMLElement)) throw new Error("filetree root 不存在");
|
||||
root.dispatchEvent(new MouseEvent("contextmenu", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: 24,
|
||||
clientY: 64,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForPreflight(page, titleText) {
|
||||
const preflight = page.locator('[data-testid="tree-preflight"]').first();
|
||||
await preflight.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
if (titleText) {
|
||||
await preflight.getByText(titleText, { exact: false }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
return preflight;
|
||||
}
|
||||
|
||||
async function cancelPreflight(page) {
|
||||
await page.locator('[data-testid="tree-preflight"] [data-role="cancel"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="tree-preflight"]').waitFor({ state: "detached", timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
async function confirmPreflight(page) {
|
||||
await page.locator('[data-testid="tree-preflight"] [data-role="confirm"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="tree-preflight"]').waitFor({ state: "detached", timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
async function dispatchInternalDrop(page, sourceRowSelector, targetRowSelector, options = {}) {
|
||||
await page.evaluate(
|
||||
({ sourceRowSelector, targetRowSelector, altKey }) => {
|
||||
const source = document.querySelector(sourceRowSelector);
|
||||
const target = document.querySelector(targetRowSelector);
|
||||
if (!(source instanceof HTMLElement) || !(target instanceof HTMLElement)) {
|
||||
throw new Error("拖拽目标不存在");
|
||||
}
|
||||
const dataTransfer = new DataTransfer();
|
||||
source.dispatchEvent(new DragEvent("dragstart", { bubbles: true, cancelable: true, dataTransfer }));
|
||||
target.dispatchEvent(new DragEvent("dragover", { bubbles: true, cancelable: true, dataTransfer, altKey }));
|
||||
target.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer, altKey }));
|
||||
source.dispatchEvent(new DragEvent("dragend", { bubbles: true, cancelable: true, dataTransfer }));
|
||||
},
|
||||
{ sourceRowSelector, targetRowSelector, altKey: Boolean(options.altKey) },
|
||||
);
|
||||
}
|
||||
|
||||
async function dispatchInternalDragOver(page, sourceRowSelector, targetRowSelector, ratio) {
|
||||
return await page.evaluate(
|
||||
({ sourceRowSelector, targetRowSelector, ratio }) => {
|
||||
const source = document.querySelector(sourceRowSelector);
|
||||
const target = document.querySelector(targetRowSelector);
|
||||
if (!(source instanceof HTMLElement) || !(target instanceof HTMLElement)) {
|
||||
throw new Error("拖拽目标不存在");
|
||||
}
|
||||
const rect = target.getBoundingClientRect();
|
||||
const dataTransfer = new DataTransfer();
|
||||
source.dispatchEvent(new DragEvent("dragstart", { bubbles: true, cancelable: true, dataTransfer }));
|
||||
target.dispatchEvent(new DragEvent("dragover", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
dataTransfer,
|
||||
clientX: rect.left + rect.width / 2,
|
||||
clientY: rect.top + rect.height * ratio,
|
||||
}));
|
||||
return {
|
||||
dropTarget: target.dataset.dropTarget || "",
|
||||
dropPosition: target.dataset.dropPosition || "",
|
||||
};
|
||||
},
|
||||
{ sourceRowSelector, targetRowSelector, ratio },
|
||||
);
|
||||
}
|
||||
|
||||
async function dispatchExternalFileDrop(page, targetRowSelector, fileName, text) {
|
||||
await page.evaluate(
|
||||
({ targetRowSelector, fileName, text }) => {
|
||||
const target = document.querySelector(targetRowSelector);
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
throw new Error("外部拖入目标不存在");
|
||||
}
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(new File([text], fileName, { type: "text/plain" }));
|
||||
target.dispatchEvent(new DragEvent("dragover", { bubbles: true, cancelable: true, dataTransfer }));
|
||||
target.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer }));
|
||||
},
|
||||
{ targetRowSelector, fileName, text },
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForHostMessage(page, type, predicate = () => true) {
|
||||
return await page.waitForFunction(
|
||||
({ type }) => {
|
||||
const messages = Array.isArray(window.__mnoteHostMessages) ? window.__mnoteHostMessages : [];
|
||||
return messages.find((message) => message && message.type === type) || null;
|
||||
},
|
||||
{ type },
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
).then(async (handle) => {
|
||||
const message = await handle.evaluate((value) => {
|
||||
const files = Array.from(value?.files || []).map((file) => ({
|
||||
name: file?.name || "",
|
||||
size: Number.isFinite(file?.size) ? file.size : 0,
|
||||
type: file?.type || "",
|
||||
}));
|
||||
return {
|
||||
...value,
|
||||
files,
|
||||
};
|
||||
});
|
||||
assert(predicate(message), `${type} host message 不符合预期: ${JSON.stringify(message)}`);
|
||||
return message;
|
||||
});
|
||||
}
|
||||
|
||||
async function dispatchForeignInternalDrop(page, targetRowSelector) {
|
||||
await page.evaluate(
|
||||
({ targetRowSelector }) => {
|
||||
const target = document.querySelector(targetRowSelector);
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
throw new Error("跨 workspace 拖拽目标不存在");
|
||||
}
|
||||
const dataTransfer = new DataTransfer();
|
||||
const payload = JSON.stringify({ type: "mnote-file-tree-dnd", version: 1, rowIds: ["foreign:workspace-row"] });
|
||||
dataTransfer.setData("application/x-mnote-filetree-row-ids", payload);
|
||||
target.dispatchEvent(new DragEvent("dragover", { bubbles: true, cancelable: true, dataTransfer }));
|
||||
target.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer }));
|
||||
},
|
||||
{ targetRowSelector },
|
||||
);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-local-folder-smoke-"));
|
||||
fs.mkdirSync(path.join(root, "docs"));
|
||||
fs.mkdirSync(path.join(root, "docs", "nested"));
|
||||
fs.mkdirSync(path.join(root, "readonly-dir"));
|
||||
fs.mkdirSync(path.join(root, ".git"));
|
||||
fs.mkdirSync(path.join(root, "node_modules"));
|
||||
fs.mkdirSync(path.join(root, ".mnote"));
|
||||
fs.writeFileSync(path.join(root, "README.md"), "---\ntitle: Frontmatter Title\n---\n# Ignored\n正文\n", "utf8");
|
||||
fs.writeFileSync(path.join(root, "docs", "child.md"), "# Child H1\n", "utf8");
|
||||
fs.writeFileSync(path.join(root, "docs", "nested", "deep.md"), "# Deep Page\n", "utf8");
|
||||
fs.writeFileSync(
|
||||
path.join(root, "docs", "blocks.md"),
|
||||
"---\ntitle: Complex Title\n---\n# Complex H1\n\nparagraph with `inline code`, **bold**, *italic*, ~~strike~~, and [Link](https://example.com).\n\n- Bullet item\n1. Numbered item\n- [ ] Unchecked item\n- [x] Checked item\n> Quote item\n```js\nconsole.log('hi')\n```\n---\n[Spec](assets/spec.pdf)\n\n| Name | Value |\n| --- | --- |\n| Mark | `cell` |\n",
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(path.join(root, "image.png"), "png", "utf8");
|
||||
fs.writeFileSync(path.join(root, ".git", "hidden.md"), "# hidden\n", "utf8");
|
||||
fs.writeFileSync(path.join(root, "node_modules", "hidden.md"), "# hidden\n", "utf8");
|
||||
fs.chmodSync(path.join(root, "readonly-dir"), 0o555);
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1280, height: 860 },
|
||||
extraHTTPHeaders: {
|
||||
"x-mnote-actor-id": "user_real",
|
||||
"x-mnote-actor-type": "user",
|
||||
},
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const requests = [];
|
||||
const navigationEvents = [];
|
||||
page.on("framenavigated", (frame) => {
|
||||
if (frame === page.mainFrame()) {
|
||||
navigationEvents.push({
|
||||
url: frame.url(),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
});
|
||||
page.on("request", (request) => {
|
||||
if (
|
||||
request.url().includes("/api/tree/commands") ||
|
||||
request.url().includes("/api/documents/title") ||
|
||||
request.url().includes("/api/documents/save") ||
|
||||
request.url().includes("/api/documents/options") ||
|
||||
request.url().includes("/api/ui/preferences")
|
||||
) {
|
||||
requests.push({
|
||||
url: request.url(),
|
||||
method: request.method(),
|
||||
body: request.postData() || "",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(documentUrl(root, "local-md:README.md"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await waitForText(page, "Frontmatter Title");
|
||||
const dialogs = [];
|
||||
page.on("dialog", async (dialog) => {
|
||||
dialogs.push(dialog.type());
|
||||
await dialog.dismiss().catch(() => {});
|
||||
});
|
||||
await page.locator('[data-mnote-action="open-local-folder"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="mnote-local-folder-dialog"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
assert(dialogs.length === 0, "打开本地文件夹入口不能再依赖 prompt/alert/confirm 原生弹窗");
|
||||
await page.locator('[data-testid="mnote-local-folder-path-input"]').fill(root);
|
||||
await page.locator('[data-testid="mnote-local-folder-open-confirm"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForURL(
|
||||
(url) =>
|
||||
url.pathname === "/" &&
|
||||
url.searchParams.get("treeView") === "filetree" &&
|
||||
url.searchParams.get("sourceKind") === "local_folder" &&
|
||||
url.searchParams.get("rootUri") === fileUrl(root),
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await waitForText(page, "README.md");
|
||||
const recentLocalRoots = await page.evaluate(() => {
|
||||
const raw = window.localStorage.getItem("mnote.localFolder.recentRoots") || "[]";
|
||||
return JSON.parse(raw);
|
||||
});
|
||||
assert(recentLocalRoots[0] === fileUrl(root), "打开本地文件夹入口应记录最近 rootUri");
|
||||
|
||||
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await waitForText(page, "README.md");
|
||||
await waitForText(page, "docs");
|
||||
await waitForText(page, "image.png");
|
||||
assert(!(await page.getByText(".git", { exact: true }).isVisible().catch(() => false)), ".git 不应出现在 Explorer");
|
||||
assert(!(await page.getByText("node_modules", { exact: true }).isVisible().catch(() => false)), "node_modules 不应出现在 Explorer");
|
||||
assert(await page.locator('[data-row-kind="folder"][data-row-id="local:folder:docs"]').count() === 1, "目录 rowKind 应保持 folder");
|
||||
assert(await page.locator('[data-row-kind="markdown"][data-row-id="local:markdown:README.md"]').count() === 1, "Markdown rowKind 应保持 markdown");
|
||||
|
||||
await page.goto(treeUrl(root, "page"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await waitForText(page, "Frontmatter Title");
|
||||
await waitForText(page, "Child H1");
|
||||
assert(!(await page.getByText("image.png", { exact: true }).isVisible().catch(() => false)), "page_tree 不应显示普通文件");
|
||||
|
||||
await page.goto(documentUrl(root, "local-md:README.md"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await waitForText(page, "Frontmatter Title");
|
||||
await waitForText(page, "正文");
|
||||
const defaultHeadingBefore = await page.locator("#mnote-leptos-tiptap-island-editor-root .ProseMirror h1").first().evaluate((node) =>
|
||||
window.getComputedStyle(node, "::before").content
|
||||
);
|
||||
assert(defaultHeadingBefore === "none", `本地 Markdown 默认不应开启标题自动编号,实际 ::before=${defaultHeadingBefore}`);
|
||||
await page.evaluate(async ({ rootUri }) => {
|
||||
const common = {
|
||||
documentId: "local-md:README.md",
|
||||
sourceKind: "local_folder",
|
||||
rootUri,
|
||||
};
|
||||
const titleResponse = await fetch("/api/documents/title", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
...common,
|
||||
title: "Browser Saved Title",
|
||||
commandName: "page.head.updateTitle",
|
||||
}),
|
||||
});
|
||||
if (!titleResponse.ok) throw new Error(`title_failed_${titleResponse.status}`);
|
||||
const saveResponse = await fetch("/api/documents/save", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
...common,
|
||||
content: [
|
||||
{
|
||||
id: "h1",
|
||||
type: "heading",
|
||||
props: { level: 1 },
|
||||
content: [{ type: "text", text: "Browser Saved Heading" }],
|
||||
},
|
||||
{
|
||||
id: "p1",
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "Browser saved body" }],
|
||||
},
|
||||
],
|
||||
blockCount: 2,
|
||||
}),
|
||||
});
|
||||
if (!saveResponse.ok) throw new Error(`save_failed_${saveResponse.status}`);
|
||||
const optionsResponse = await fetch("/api/documents/options", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ...common, options: { wideLayout: true, showToc: false, showHeadingNumbers: true } }),
|
||||
});
|
||||
if (!optionsResponse.ok) throw new Error(`options_failed_${optionsResponse.status}`);
|
||||
}, { rootUri: fileUrl(root) });
|
||||
assert(fs.readFileSync(path.join(root, "README.md"), "utf8").includes("Browser saved body"), "浏览器保存应写回 Markdown 正文");
|
||||
assert(!fs.existsSync(path.join(root, ".mnote", "page-options.json")), "浏览器页面设置保存不应继续写入 .mnote/page-options.json");
|
||||
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await waitForText(page, "Browser Saved Title");
|
||||
await waitForText(page, "Browser saved body");
|
||||
const enabledHeadingBefore = await page.locator("#mnote-leptos-tiptap-island-editor-root .ProseMirror h1").first().evaluate((node) =>
|
||||
window.getComputedStyle(node, "::before").content
|
||||
);
|
||||
assert(enabledHeadingBefore !== "none", "SQLite 偏好开启后应显示标题自动编号");
|
||||
await page.goto(documentUrl(root, "local-md:docs~2Fblocks.md"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await waitForText(page, "Complex Title");
|
||||
await waitForText(page, "Bullet item");
|
||||
await waitForText(page, "Numbered item");
|
||||
await waitForText(page, "Quote item");
|
||||
await waitForText(page, "console.log('hi')");
|
||||
await waitForText(page, "Spec");
|
||||
await page.locator(".ProseMirror").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const checkboxStates = await page.locator(".ProseMirror input[type='checkbox']").evaluateAll((nodes) =>
|
||||
nodes.map((node) => Boolean(node.checked))
|
||||
);
|
||||
assert(checkboxStates.length === 2, `本地 Markdown 应渲染两个 checkbox,实际 ${checkboxStates.length}`);
|
||||
assert(checkboxStates.includes(true), "本地 Markdown 应渲染 checked checkbox");
|
||||
assert(checkboxStates.includes(false), "本地 Markdown 应渲染 unchecked checkbox");
|
||||
assert(await page.locator(".ProseMirror p code", { hasText: "inline code" }).count() >= 1, "inline code 应渲染为 code mark");
|
||||
assert(await page.locator(".ProseMirror strong", { hasText: "bold" }).count() >= 1, "strong 应渲染为 strong 标签");
|
||||
assert(await page.locator(".ProseMirror em", { hasText: "italic" }).count() >= 1, "em 应渲染为 em 标签");
|
||||
assert(await page.locator(".ProseMirror s, .ProseMirror del", { hasText: "strike" }).count() >= 1, "strike 应渲染为删除线标签");
|
||||
const linkTexts = await page.locator(".ProseMirror a").evaluateAll((nodes) => nodes.map((node) => node.textContent?.trim() || ""));
|
||||
assert(linkTexts.includes("Link"), `普通链接应渲染为 a 标签,实际 ${JSON.stringify(linkTexts)}`);
|
||||
assert(linkTexts.includes("Spec"), `附件链接应渲染为 a 标签,实际 ${JSON.stringify(linkTexts)}`);
|
||||
assert(await page.locator(".ProseMirror table td code", { hasText: "cell" }).count() >= 1, "table cell inline code 应保留 code mark");
|
||||
|
||||
if (process.env.MNOTE_TASK163_RUN_EXTENDED_TREE_OPS !== "1") {
|
||||
const filetreeRows = await page.locator('#sidebar-file-tree-root [data-testid="filetree-asset-row"]').evaluateAll((nodes) =>
|
||||
nodes.map((node) => ({
|
||||
rowId: node.getAttribute("data-row-id") || "",
|
||||
rowKind: node.getAttribute("data-row-kind") || "",
|
||||
}))
|
||||
);
|
||||
assert(filetreeRows.some((row) => row.rowId === "local:folder:docs" && row.rowKind === "folder"), "当前主入口下的 filetree 容器应继续渲染 docs folder 行");
|
||||
assert(filetreeRows.some((row) => row.rowId === "local:markdown:README.md" && row.rowKind === "markdown"), "当前主入口下的 filetree 容器应继续渲染 README markdown 行");
|
||||
const pageAggregateResult = await page.evaluate(async ({ rootUri }) => {
|
||||
const response = await fetch(`/api/page-aggregate/${encodeURIComponent("local-md:docs~2Fblocks.md")}?sourceKind=local_folder&rootUri=${encodeURIComponent(rootUri)}`, {
|
||||
headers: { accept: "application/json" },
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
payload,
|
||||
};
|
||||
}, { rootUri: fileUrl(root) });
|
||||
assert(pageAggregateResult.ok, `page aggregate 路由应返回 200,实际 ${pageAggregateResult.status}`);
|
||||
assert(pageAggregateResult.payload?.schema === "mnote.page_aggregate.v1", `page aggregate schema 应保持稳定,实际 ${JSON.stringify(pageAggregateResult.payload)}`);
|
||||
assert(pageAggregateResult.payload?.result?.title === "Complex Title", "page aggregate 应继续返回本地 Markdown 标题");
|
||||
assert(requests.some((request) => request.body.includes('"sourceKind":"local_folder"')), "本地 Markdown 保存写操作必须透传 sourceKind=local_folder");
|
||||
await browser.close();
|
||||
fs.chmodSync(path.join(root, "readonly-dir"), 0o755);
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
console.log("task163 local markdown parser browser smoke passed");
|
||||
return;
|
||||
}
|
||||
|
||||
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
const readmeRow = '.tree-row[data-row-id="local:markdown:README.md"]';
|
||||
const docsRow = '.tree-row[data-row-id="local:folder:docs"]';
|
||||
const nestedRow = '.tree-row[data-row-id="local:folder:docs/nested"]';
|
||||
const readonlyRow = '.tree-row[data-row-id="local:folder:readonly-dir"]';
|
||||
let imageRow = '.tree-row[data-row-id="local:asset:image.png"]';
|
||||
await page.locator(readmeRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(docsRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(nestedRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(readonlyRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(imageRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
await dispatchFolderContextMenu(page, docsRow);
|
||||
await waitForContextMenu(page);
|
||||
await expectMenuAction(page, "newPage", { disabled: false });
|
||||
await expectMenuAction(page, "newFolder", { disabled: false });
|
||||
await expectMenuAction(page, "upload", { disabled: true });
|
||||
await expectMenuAction(page, "refresh", { disabled: false });
|
||||
await closeContextMenu(page);
|
||||
await page.locator(readmeRow).click({ button: "right", timeout: UI_TIMEOUT_MS });
|
||||
await waitForContextMenu(page);
|
||||
await expectMenuAction(page, "open", { disabled: false });
|
||||
await expectMenuAction(page, "rename", { disabled: false });
|
||||
await expectMenuAction(page, "copy", { disabled: false });
|
||||
await expectMenuAction(page, "cut", { disabled: false });
|
||||
await expectMenuAction(page, "paste", { disabled: true });
|
||||
await expectMenuAction(page, "delete", { disabled: false });
|
||||
await closeContextMenu(page);
|
||||
await page.locator(docsRow).click({ button: "right", timeout: UI_TIMEOUT_MS });
|
||||
await waitForContextMenu(page);
|
||||
await expectMenuAction(page, "newPage", { disabled: false });
|
||||
await expectMenuAction(page, "newFolder", { disabled: false });
|
||||
await expectMenuAction(page, "paste", { disabled: true });
|
||||
await expectMenuAction(page, "rename", { disabled: false });
|
||||
await expectMenuAction(page, "delete", { disabled: true, reasonIncludes: "目录删除" });
|
||||
await closeContextMenu(page);
|
||||
await page.locator(imageRow).click({ button: "right", timeout: UI_TIMEOUT_MS });
|
||||
await waitForContextMenu(page);
|
||||
await expectMenuAction(page, "open", { disabled: false });
|
||||
await expectMenuAction(page, "rename", { disabled: false });
|
||||
await expectMenuAction(page, "copy", { disabled: true });
|
||||
await expectMenuAction(page, "cut", { disabled: true });
|
||||
await expectMenuAction(page, "delete", { disabled: true });
|
||||
await expectMenuAction(page, "download", { disabled: true });
|
||||
await clickMenuAction(page, "rename");
|
||||
let assetRenameInput = page.locator(".tree-rename-input").first();
|
||||
await assetRenameInput.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await assetRenameInput.fill("renamed-image");
|
||||
await page.keyboard.press("Enter");
|
||||
await page.waitForTimeout(800);
|
||||
assert(fs.existsSync(path.join(root, "renamed-image.png")), "asset rename 应通过同一 inline rename 写入本地文件系统");
|
||||
imageRow = '.tree-row[data-row-id="local:asset:renamed-image.png"]';
|
||||
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(readmeRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(docsRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(nestedRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(readonlyRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(imageRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
await dispatchFolderContextMenu(page, docsRow);
|
||||
await waitForContextMenu(page);
|
||||
await clickMenuAction(page, "newFolder");
|
||||
let createRenameInput = page.locator(".tree-rename-input").first();
|
||||
await createRenameInput.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await createRenameInput.fill("新建资料");
|
||||
await page.keyboard.press("Enter");
|
||||
await page.waitForTimeout(800);
|
||||
assert(fs.existsSync(path.join(root, "新建资料")), "根菜单新建文件夹后应进入 inline rename 并写入本地目录");
|
||||
|
||||
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(docsRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(docsRow).click({ button: "right", timeout: UI_TIMEOUT_MS });
|
||||
await waitForContextMenu(page);
|
||||
await clickMenuAction(page, "newPage");
|
||||
createRenameInput = page.locator(".tree-rename-input").first();
|
||||
await createRenameInput.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await createRenameInput.fill("菜单新页面");
|
||||
await page.keyboard.press("Enter");
|
||||
await page.waitForTimeout(800);
|
||||
assert(fs.existsSync(path.join(root, "docs", "菜单新页面.md")), "文件夹菜单新建页面后应进入 inline rename 并写入本地 Markdown");
|
||||
|
||||
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(readmeRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(docsRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(nestedRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(readonlyRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(imageRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const createdPageRow = '.tree-row[data-row-id="local:markdown:docs/菜单新页面.md"]';
|
||||
await page.locator(createdPageRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(createdPageRow).click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.keyboard.press("Delete");
|
||||
await waitForPreflight(page, "删除预检");
|
||||
await confirmPreflight(page);
|
||||
await page.waitForTimeout(800);
|
||||
assert(!fs.existsSync(path.join(root, "docs", "菜单新页面.md")), "确认 Delete preflight 后本地 Markdown 原路径应消失");
|
||||
assert(fs.existsSync(path.join(root, ".mnote", "trash", "菜单新页面.md")), "确认 Delete preflight 后本地 Markdown 应进入 .mnote/trash");
|
||||
const trashIndex = JSON.parse(fs.readFileSync(path.join(root, ".mnote", "trash-index.json"), "utf8"));
|
||||
const createdTrashEntry = Object.values(trashIndex.entries || {}).find((entry) => entry.originalRelativePath === "docs/菜单新页面.md");
|
||||
assert(createdTrashEntry && createdTrashEntry.documentId, "trash-index 应记录被删除页面的 documentId");
|
||||
await page.evaluate(async ({ rootUri, documentId }) => {
|
||||
const response = await fetch("/api/tree/commands", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "restore",
|
||||
sourceKind: "local_folder",
|
||||
rootUri,
|
||||
documentId,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error(`restore_failed_${response.status}`);
|
||||
}, { rootUri: fileUrl(root), documentId: createdTrashEntry.documentId });
|
||||
await page.waitForTimeout(300);
|
||||
assert(fs.existsSync(path.join(root, "docs", "菜单新页面.md")), "local restore 应从 .mnote/trash 恢复 Markdown");
|
||||
await page.evaluate(async ({ rootUri, documentId }) => {
|
||||
const response = await fetch("/api/tree/commands", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "purge",
|
||||
sourceKind: "local_folder",
|
||||
rootUri,
|
||||
documentId,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error(`purge_failed_${response.status}`);
|
||||
}, { rootUri: fileUrl(root), documentId: createdTrashEntry.documentId });
|
||||
await page.waitForTimeout(300);
|
||||
assert(!fs.existsSync(path.join(root, "docs", "菜单新页面.md")), "local purge 应永久删除已恢复的 Markdown");
|
||||
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(readmeRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(docsRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(nestedRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
assert((await dispatchInternalDragOver(page, readmeRow, docsRow, 0.05)).dropPosition === "before", "内部拖拽应显示 before indicator");
|
||||
assert((await dispatchInternalDragOver(page, readmeRow, docsRow, 0.5)).dropPosition === "inside", "内部拖拽应显示 inside indicator");
|
||||
assert((await dispatchInternalDragOver(page, readmeRow, docsRow, 0.95)).dropPosition === "after", "内部拖拽应显示 after indicator");
|
||||
await dispatchInternalDragOver(page, readmeRow, nestedRow, 0.5);
|
||||
await page.locator('.tree-row[data-row-id="local:markdown:docs/nested/deep.md"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const illegalDropRequestCount = requests.length;
|
||||
await dispatchInternalDrop(page, readmeRow, readmeRow);
|
||||
await dispatchInternalDrop(page, docsRow, nestedRow);
|
||||
await dispatchForeignInternalDrop(page, docsRow);
|
||||
await page.waitForTimeout(500);
|
||||
assert(requests.length === illegalDropRequestCount, "自拖自身、拖到后代、跨 workspace 不应发 execute 请求");
|
||||
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
await page.locator(docsRow).click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(nestedRow).click({ modifiers: [modifier], timeout: UI_TIMEOUT_MS });
|
||||
const redundantMoveRequestCount = requests.length;
|
||||
await dispatchInternalDrop(page, docsRow, readmeRow);
|
||||
await waitForPreflight(page, "移动预检");
|
||||
await confirmPreflight(page);
|
||||
await page.waitForTimeout(800);
|
||||
const redundantMoveRequests = requests.slice(redundantMoveRequestCount).filter((request) => request.body.includes('"action":"move"'));
|
||||
assert(redundantMoveRequests.length === 1, "多选父子节点 move 时应过滤子节点重复执行");
|
||||
assert(redundantMoveRequests[0].body.includes("local:node:docs"), "多选父子节点 move 应保留父节点作为唯一执行项");
|
||||
assert(!redundantMoveRequests[0].body.includes("local:node:docs/nested"), "多选父子节点 move 不应重复执行子节点");
|
||||
|
||||
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(readmeRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(docsRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(nestedRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(readonlyRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(imageRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
await page.locator(readmeRow).click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(docsRow).click({ modifiers: [modifier], timeout: UI_TIMEOUT_MS });
|
||||
assert(await page.locator(`${readmeRow}[data-selected="true"]`).count() === 1, "Ctrl/Cmd+Click 后原选区应保留");
|
||||
assert(await page.locator(`${docsRow}[data-selected="true"]`).count() === 1, "Ctrl/Cmd+Click 后目标行应加入选区");
|
||||
await page.locator(docsRow).click({ button: "right", timeout: UI_TIMEOUT_MS });
|
||||
assert(await page.locator(`${readmeRow}[data-selected="true"]`).count() === 1, "已选区内右键不应清空多选");
|
||||
await waitForContextMenu(page);
|
||||
await expectMenuAction(page, "copy", { disabled: false });
|
||||
await expectMenuAction(page, "cut", { disabled: false });
|
||||
await expectMenuAction(page, "delete", { disabled: false });
|
||||
await expectMenuAction(page, "moveTo", { disabled: true });
|
||||
await closeContextMenu(page);
|
||||
await page.locator(readmeRow).click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(nestedRow).click({ modifiers: ["Shift"], timeout: UI_TIMEOUT_MS });
|
||||
assert(await page.locator('.tree-row[data-shell-mode="filetree"][data-selected="true"]').count() >= 2, "Shift+Click 应形成范围选区");
|
||||
await page.locator(readmeRow).click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(imageRow).click({ button: "right", timeout: UI_TIMEOUT_MS });
|
||||
assert(await page.locator(`${imageRow}[data-selected="true"]`).count() === 1, "未选项右键应切换到该行");
|
||||
assert(await page.locator(`${readmeRow}[data-selected="true"]`).count() === 0, "未选项右键应清理旧选区");
|
||||
await closeContextMenu(page);
|
||||
await page.evaluate(() => {
|
||||
const root = document.querySelector("#sidebar-file-tree-root .tree-root");
|
||||
if (!(root instanceof HTMLElement)) throw new Error("filetree root 不存在");
|
||||
root.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
assert(await page.locator('.tree-row[data-shell-mode="filetree"][data-selected="true"]').count() === 0, "空白区点击应清空 selection/focus");
|
||||
await page.locator(readmeRow).click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.keyboard.press(process.platform === "darwin" ? "Meta+A" : "Control+A");
|
||||
const visibleFileTreeRows = await page.locator('.tree-row[data-shell-mode="filetree"]').count();
|
||||
assert(await page.locator('.tree-row[data-shell-mode="filetree"][data-selected="true"]').count() === visibleFileTreeRows, "Ctrl/Cmd+A 应选择全部可见 filetree 行");
|
||||
|
||||
await page.locator(readmeRow).click({ timeout: UI_TIMEOUT_MS });
|
||||
const deletePreflightRequestCount = requests.length;
|
||||
await page.keyboard.press("Delete");
|
||||
const deletePreflight = await waitForPreflight(page, "删除预检");
|
||||
assert(await deletePreflight.getByText("影响:", { exact: false }).isVisible(), "Delete preflight 应说明影响集合");
|
||||
await cancelPreflight(page);
|
||||
await page.waitForTimeout(300);
|
||||
assert(requests.length === deletePreflightRequestCount, "取消 Delete preflight 后不应发 execute 请求");
|
||||
assert(fs.existsSync(path.join(root, "README.md")), "取消 Delete preflight 后文件不应被移动到 trash");
|
||||
|
||||
await page.locator(readmeRow).click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.keyboard.press("F2");
|
||||
const renameInput = page.locator(".tree-rename-input").first();
|
||||
await renameInput.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await renameInput.fill("README Renamed");
|
||||
await page.keyboard.press("Enter");
|
||||
await page.waitForTimeout(800);
|
||||
assert(fs.existsSync(path.join(root, "README Renamed.md")), "F2 inline rename 应真实重命名 Markdown 文件");
|
||||
|
||||
const renamedRow = '.tree-row[data-row-id="local:markdown:README Renamed.md"]';
|
||||
await page.locator(renamedRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(renamedRow).click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.keyboard.press(process.platform === "darwin" ? "Meta+X" : "Control+X");
|
||||
assert(await page.locator(`${renamedRow}[data-cut="true"]`).count() === 1, "Ctrl/Cmd+X 后应出现 cut decoration");
|
||||
await page.locator(docsRow).click({ timeout: UI_TIMEOUT_MS });
|
||||
const pasteCancelRequestCount = requests.length;
|
||||
await page.keyboard.press(process.platform === "darwin" ? "Meta+V" : "Control+V");
|
||||
const pastePreflight = await waitForPreflight(page, "粘贴移动预检");
|
||||
assert(await pastePreflight.getByText("命名冲突策略", { exact: false }).isVisible(), "Paste preflight 应展示命名冲突策略");
|
||||
await cancelPreflight(page);
|
||||
await page.waitForTimeout(300);
|
||||
assert(requests.length === pasteCancelRequestCount, "取消 Paste preflight 后不应发 execute 请求");
|
||||
assert(fs.existsSync(path.join(root, "README Renamed.md")), "取消 Paste preflight 后文件应留在原目录");
|
||||
await page.locator(docsRow).click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.keyboard.press(process.platform === "darwin" ? "Meta+V" : "Control+V");
|
||||
await waitForPreflight(page, "粘贴移动预检");
|
||||
await confirmPreflight(page);
|
||||
await page.waitForTimeout(800);
|
||||
assert(fs.existsSync(path.join(root, "docs", "README Renamed.md")), "剪切粘贴应真实移动 Markdown 文件");
|
||||
|
||||
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await dispatchInternalDrop(page, '.tree-row[data-row-id="local:markdown:docs/README Renamed.md"]', '.tree-row[data-row-id="local:folder:docs"]', { altKey: true });
|
||||
const copyPreflight = await waitForPreflight(page, "复制预检");
|
||||
assert(await copyPreflight.getByText("命名冲突策略", { exact: false }).isVisible(), "内部复制 drop preflight 应展示命名冲突策略");
|
||||
await confirmPreflight(page);
|
||||
await page.waitForTimeout(800);
|
||||
assert(fs.existsSync(path.join(root, "docs", "README Renamed 2.md")), "Alt 内部拖拽应真实复制 Markdown 文件");
|
||||
|
||||
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
const externalCancelRequestCount = requests.length;
|
||||
await dispatchExternalFileDrop(page, '.tree-row[data-row-id="local:folder:docs"]', "drop-cancel.txt", "取消外部拖入");
|
||||
await waitForPreflight(page, "外部拖入预检");
|
||||
await cancelPreflight(page);
|
||||
await page.waitForTimeout(300);
|
||||
assert(requests.length === externalCancelRequestCount, "取消外部 File drop preflight 后不应发 execute 请求");
|
||||
assert(!fs.existsSync(path.join(root, "docs", "drop-cancel.txt")), "取消外部 File drop preflight 后不应写入文件");
|
||||
await dispatchExternalFileDrop(page, '.tree-row[data-row-id="local:folder:docs"]', "drop-real.txt", "真实外部拖入");
|
||||
const externalPreflight = await waitForPreflight(page, "外部拖入预检");
|
||||
assert(await externalPreflight.getByText("drop-real.txt", { exact: false }).isVisible(), "外部 drop preflight 应展示文件名");
|
||||
await confirmPreflight(page);
|
||||
await page.waitForTimeout(800);
|
||||
assert(fs.existsSync(path.join(root, "docs", "drop-real.txt")), "外部 File drop 应真实写入本地目录");
|
||||
await dispatchExternalFileDrop(page, '.tree-row[data-row-id="local:markdown:docs/README Renamed.md"]', "drop-on-md.txt", "拖到 Markdown 行");
|
||||
const externalMdPreflight = await waitForPreflight(page, "外部拖入预检");
|
||||
assert(await externalMdPreflight.getByText("README Renamed.md 的父目录", { exact: false }).isVisible(), "drop 到 md 文件时 preflight 应说明目标归属为父目录");
|
||||
await confirmPreflight(page);
|
||||
await page.waitForTimeout(800);
|
||||
assert(fs.existsSync(path.join(root, "docs", "drop-on-md.txt")), "drop 到 md 文件时应写入该 md 的父目录");
|
||||
assert(!fs.existsSync(path.join(root, "drop-on-md.txt")), "drop 到 Markdown 行时不应回退写入 root");
|
||||
const readonlyDropRequestCount = requests.length;
|
||||
await dispatchExternalFileDrop(page, readonlyRow, "readonly-drop.txt", "readonly");
|
||||
await waitForPreflight(page, "操作预检失败");
|
||||
assert(await page.getByText("只读", { exact: false }).first().isVisible(), "readonly 目标应展示统一预检失败原因");
|
||||
await confirmPreflight(page);
|
||||
await page.waitForTimeout(300);
|
||||
assert(requests.length === readonlyDropRequestCount, "readonly 目标预检失败后不应发 execute 请求");
|
||||
assert(!fs.existsSync(path.join(root, "readonly-dir", "readonly-drop.txt")), "readonly 目标预检失败后不应写入文件");
|
||||
|
||||
await page.goto(treeUrl(root, "page"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
let watcherNavigationStart = navigationEvents.length;
|
||||
fs.writeFileSync(path.join(root, "docs", "watcher-added.md"), "# Watcher Added\n", "utf8");
|
||||
await waitForText(page, "Watcher Added");
|
||||
assert(navigationEvents.length === watcherNavigationStart, "外部新增 Markdown 后 page tree 不应发生浏览器导航或 reload");
|
||||
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
watcherNavigationStart = navigationEvents.length;
|
||||
fs.writeFileSync(path.join(root, "docs", "watcher-asset.txt"), "watcher asset", "utf8");
|
||||
await waitForText(page, "watcher-asset.txt");
|
||||
assert(navigationEvents.length === watcherNavigationStart, "外部新增非 md 资源后 filetree 不应发生浏览器导航或 reload");
|
||||
watcherNavigationStart = navigationEvents.length;
|
||||
fs.rmSync(path.join(root, "docs", "watcher-asset.txt"));
|
||||
await page.getByText("watcher-asset.txt", { exact: true }).waitFor({ state: "detached", timeout: UI_TIMEOUT_MS });
|
||||
assert(navigationEvents.length === watcherNavigationStart, "外部删除非 md 资源后 filetree 不应发生浏览器导航或 reload");
|
||||
|
||||
await page.goto(convexTreeUrl("filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await waitForText(page, "工作区首页");
|
||||
await waitForText(page, "封面.png");
|
||||
assert(!(await page.getByText("Watcher Added", { exact: true }).isVisible().catch(() => false)), "切回 Convex 后不应显示旧 local watcher 内容");
|
||||
assert(await page.locator('.tree-row[data-shell-mode="filetree"][data-selected="true"]').count() === 0, "切回 Convex 后旧 local selection 不应污染新 source");
|
||||
await dispatchRootContextMenu(page);
|
||||
await waitForContextMenu(page);
|
||||
await expectMenuAction(page, "newPage", { disabled: false });
|
||||
await expectMenuAction(page, "newFolder", { disabled: true, reasonIncludes: "Convex workspace" });
|
||||
await closeContextMenu(page);
|
||||
await page.getByText("工作区首页", { exact: true }).click({ button: "right", timeout: UI_TIMEOUT_MS });
|
||||
await waitForContextMenu(page);
|
||||
await expectMenuAction(page, "open", { disabled: false });
|
||||
await expectMenuAction(page, "rename", { disabled: false });
|
||||
await expectMenuAction(page, "copy", { disabled: false });
|
||||
await expectMenuAction(page, "paste", { disabled: true });
|
||||
await closeContextMenu(page);
|
||||
const convexRootRow = '.tree-row[data-row-id="doc:page_root"]';
|
||||
const convexChildRow = '.tree-row[data-row-id="doc:page_child"]';
|
||||
const convexAssetFolderRow = '.tree-row[data-row-id="asset-folder:mind_1"]';
|
||||
await page.locator(convexRootRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(convexChildRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(convexAssetFolderRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
assert((await dispatchInternalDragOver(page, convexChildRow, convexRootRow, 0.05)).dropPosition === "before", "Convex document 拖拽应显示 before indicator");
|
||||
assert((await dispatchInternalDragOver(page, convexChildRow, convexRootRow, 0.5)).dropPosition === "inside", "Convex document 拖拽应显示 inside indicator");
|
||||
assert((await dispatchInternalDragOver(page, convexChildRow, convexRootRow, 0.95)).dropPosition === "after", "Convex document 拖拽应显示 after indicator");
|
||||
assert((await dispatchInternalDragOver(page, convexChildRow, convexAssetFolderRow, 0.5)).dropPosition === "inside", "Convex asset-folder 拖拽应显示 inside indicator");
|
||||
|
||||
await page.locator(convexChildRow).click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.keyboard.press(process.platform === "darwin" ? "Meta+C" : "Control+C");
|
||||
await page.locator(convexRootRow).click({ timeout: UI_TIMEOUT_MS });
|
||||
const convexCopyRequestCount = requests.length;
|
||||
await page.keyboard.press(process.platform === "darwin" ? "Meta+V" : "Control+V");
|
||||
await waitForPreflight(page, "粘贴复制预检");
|
||||
await confirmPreflight(page);
|
||||
await page.waitForTimeout(800);
|
||||
const convexCopyRequests = requests.slice(convexCopyRequestCount).filter((request) => request.body.includes('"sourceKind":"convex_workspace"'));
|
||||
assert(convexCopyRequests.some((request) => request.body.includes('"action":"copy"')), "Convex copy paste 应通过同一 command endpoint 发送 copy action");
|
||||
|
||||
await page.goto(convexTreeUrl("filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(convexRootRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(convexChildRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(convexChildRow).click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.keyboard.press(process.platform === "darwin" ? "Meta+X" : "Control+X");
|
||||
await page.locator(convexRootRow).click({ timeout: UI_TIMEOUT_MS });
|
||||
const convexMoveRequestCount = requests.length;
|
||||
await page.keyboard.press(process.platform === "darwin" ? "Meta+V" : "Control+V");
|
||||
await waitForPreflight(page, "粘贴移动预检");
|
||||
await confirmPreflight(page);
|
||||
await page.waitForTimeout(800);
|
||||
const convexMoveRequests = requests.slice(convexMoveRequestCount).filter((request) => request.body.includes('"sourceKind":"convex_workspace"'));
|
||||
assert(convexMoveRequests.some((request) => request.body.includes('"action":"move"')), "Convex cut paste 应通过同一 command endpoint 发送 move action");
|
||||
await page.goto(convexTreeUrl("filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(convexChildRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(convexChildRow).click({ timeout: UI_TIMEOUT_MS });
|
||||
const convexDeleteRequestCount = requests.length;
|
||||
await page.keyboard.press("Delete");
|
||||
await waitForPreflight(page, "删除预检");
|
||||
await confirmPreflight(page);
|
||||
await page.waitForTimeout(800);
|
||||
const convexDeleteRequests = requests.slice(convexDeleteRequestCount).filter((request) => request.body.includes('"sourceKind":"convex_workspace"'));
|
||||
assert(convexDeleteRequests.some((request) => request.body.includes('"action":"delete"')), "Convex Delete 应通过同一 command endpoint 发送 delete action");
|
||||
const convexRestoreRequestCount = requests.length;
|
||||
await page.evaluate(async () => {
|
||||
const response = await fetch("/api/tree/commands", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "restore",
|
||||
sourceKind: "convex_workspace",
|
||||
workspaceId: "ws_demo",
|
||||
documentId: "page_child",
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error(`convex_restore_failed_${response.status}`);
|
||||
});
|
||||
await page.evaluate(async () => {
|
||||
const response = await fetch("/api/tree/commands", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "purge",
|
||||
sourceKind: "convex_workspace",
|
||||
workspaceId: "ws_demo",
|
||||
documentId: "page_child",
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error(`convex_purge_failed_${response.status}`);
|
||||
});
|
||||
const convexRestorePurgeRequests = requests.slice(convexRestoreRequestCount).filter((request) => request.body.includes('"sourceKind":"convex_workspace"'));
|
||||
assert(convexRestorePurgeRequests.some((request) => request.body.includes('"action":"restore"')), "Convex restore 应继续走 tree.node.restore command");
|
||||
assert(convexRestorePurgeRequests.some((request) => request.body.includes('"action":"purge"')), "Convex purge 应继续走 tree.node.purge command");
|
||||
|
||||
await page.setContent(`
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<script>
|
||||
window.__mnoteHostMessages = [];
|
||||
window.addEventListener("message", (event) => {
|
||||
window.__mnoteHostMessages.push(event.data);
|
||||
});
|
||||
</script>
|
||||
<iframe id="tree-host" src="${convexTreeUrl("filetree")}"></iframe>
|
||||
`);
|
||||
const treeFrameLocator = page.frameLocator("#tree-host");
|
||||
await treeFrameLocator.locator(convexRootRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const treeFrameElement = await page.locator("#tree-host").elementHandle({ timeout: UI_TIMEOUT_MS });
|
||||
const treeFrame = treeFrameElement ? await treeFrameElement.contentFrame() : null;
|
||||
assert(treeFrame, "Convex iframe tree shell 应加载成功");
|
||||
await treeFrame.locator(convexRootRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await treeFrame.locator(convexAssetFolderRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await dispatchExternalFileDrop(treeFrame, convexRootRow, "convex-doc-drop.txt", "上传到 Convex document");
|
||||
await treeFrame.locator('[data-testid="tree-preflight"]').getByText("外部拖入预检", { exact: false }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await treeFrame.locator('[data-testid="tree-preflight"]').getByText("上传到对象存储", { exact: false }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await treeFrame.locator('[data-testid="tree-preflight"] [data-role="confirm"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
const convexDocDropMessage = await waitForHostMessage(page, "tree.filetree.external-drop", (message) =>
|
||||
message.documentId === "page_root" &&
|
||||
message.rowKind === "document" &&
|
||||
Array.isArray(message.files) &&
|
||||
message.files.length === 1 &&
|
||||
message.files[0].name === "convex-doc-drop.txt"
|
||||
);
|
||||
assert(convexDocDropMessage.fileCount === undefined || convexDocDropMessage.fileCount === 1, "Convex document 外部 drop host message 应保留文件数量");
|
||||
await page.evaluate(() => {
|
||||
window.__mnoteHostMessages = [];
|
||||
});
|
||||
await dispatchExternalFileDrop(treeFrame, convexAssetFolderRow, "convex-asset-folder-drop.txt", "上传到 Convex asset folder");
|
||||
await treeFrame.locator('[data-testid="tree-preflight"]').getByText("外部拖入预检", { exact: false }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await treeFrame.locator('[data-testid="tree-preflight"] [data-role="confirm"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await waitForHostMessage(page, "tree.filetree.external-drop", (message) =>
|
||||
message.assetId === "mind_1" &&
|
||||
message.rowKind === "asset_folder" &&
|
||||
Array.isArray(message.files) &&
|
||||
message.files.some((file) => file.name === "convex-asset-folder-drop.txt")
|
||||
);
|
||||
|
||||
assert(requests.some((request) => request.body.includes('"sourceKind":"local_folder"')), "写操作必须透传 sourceKind=local_folder");
|
||||
assert(requests.some((request) => request.body.includes('"action":"move"')), "剪切粘贴应走统一 move action");
|
||||
assert(requests.some((request) => request.body.includes('"action":"copy"')), "内部 copy drop 应走统一 copy action");
|
||||
assert(requests.some((request) => request.body.includes('"action":"dropFiles"')), "外部文件 drop 应走统一 dropFiles action");
|
||||
|
||||
await browser.close();
|
||||
fs.chmodSync(path.join(root, "readonly-dir"), 0o755);
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
console.log("task163 local folder unified tree browser smoke passed");
|
||||
} catch (error) {
|
||||
await browser.close().catch(() => undefined);
|
||||
console.error(`local root preserved for debugging: ${root}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -84,8 +84,6 @@ async function main() {
|
||||
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
|
||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
|
||||
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
|
||||
CONVEX_SELF_HOSTED_URL: "http://127.0.0.1:9",
|
||||
NEXT_PUBLIC_CONVEX_URL: "http://127.0.0.1:9",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
@@ -101,8 +101,6 @@ async function main() {
|
||||
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
|
||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
|
||||
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
|
||||
CONVEX_SELF_HOSTED_URL: "http://127.0.0.1:9",
|
||||
NEXT_PUBLIC_CONVEX_URL: "http://127.0.0.1:9",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
@@ -124,6 +122,8 @@ async function main() {
|
||||
|
||||
const firstAggregate = await loadAggregate(baseUrl, actorId, documentId, rootUri);
|
||||
assert.equal(firstAggregate.head.title, "README");
|
||||
assert.equal(firstAggregate.body.projectionSource, "local_markdown.content", "local-first Page Aggregate 应声明 local markdown projection source");
|
||||
assert(firstAggregate.body.blockDocument, "local-first Page Aggregate 应输出 blockDocument,runtime 不应只依赖 legacy content");
|
||||
|
||||
const titlePayload = await requestJson(baseUrl, actorId, "/api/documents/title", {
|
||||
method: "POST",
|
||||
@@ -178,6 +178,8 @@ async function main() {
|
||||
|
||||
const finalAggregate = await loadAggregate(baseUrl, actorId, documentId, rootUri);
|
||||
assert.equal(finalAggregate.head.title, "P3 标题", "frontmatter title 应优先于正文 H1");
|
||||
assert.equal(finalAggregate.body.projectionSource, "local_markdown.content", "保存后 Page Aggregate source 不应退回 legacy documents.content");
|
||||
assert(finalAggregate.body.blockDocument, "保存后 Page Aggregate 应保留 blockDocument");
|
||||
assert.equal(finalAggregate.layout.pageOptions.wideLayout, true);
|
||||
assert.equal(finalAggregate.layout.pageOptions.showToc, true);
|
||||
assert.equal(finalAggregate.layout.pageOptions.showHeadingNumbers, true);
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
// 说明:
|
||||
// - 验证 Rust 3000 主入口的 Convex 附件行打开 OnlyOffice 链路。
|
||||
// - 脚本会创建临时页面、上传临时 docx、点击文件树附件行并断言新窗口打开 /onlyoffice。
|
||||
// - 结束后清理临时页面,避免污染长期测试空间。
|
||||
|
||||
const fs = require("node:fs");
|
||||
const { chromium } = require("playwright");
|
||||
const {
|
||||
BASE_URL,
|
||||
UI_TIMEOUT_MS,
|
||||
assert,
|
||||
createTempDocument,
|
||||
ensureAuthenticated,
|
||||
openDocument,
|
||||
openFilesystemView,
|
||||
purgeDocument,
|
||||
} = require("./tree-shell-smoke-helpers");
|
||||
|
||||
const DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
const ONLYOFFICE_READY_TIMEOUT_MS = Number(process.env.MNOTE_ONLYOFFICE_READY_TIMEOUT_MS || 120_000);
|
||||
const PROBE_DOCX_PATH =
|
||||
process.env.MNOTE_ONLYOFFICE_PROBE_DOCX ||
|
||||
"/tmp/mnote-onlyoffice-manual/mnote-onlyoffice-upload-test.docx";
|
||||
const TEST_EMAIL = "mnote.e2e@example.com";
|
||||
const TEST_PASSWORD = "MnoteE2E123!";
|
||||
let authCookieHeader = "";
|
||||
const popupConsoleMessages = [];
|
||||
const popupNetworkFailures = [];
|
||||
|
||||
function parseSetCookie(setCookie, origin) {
|
||||
const [nameValue] = String(setCookie || "").split(";");
|
||||
const separator = nameValue.indexOf("=");
|
||||
if (separator <= 0) return null;
|
||||
return {
|
||||
name: nameValue.slice(0, separator).trim(),
|
||||
value: nameValue.slice(separator + 1).trim(),
|
||||
domain: new URL(origin).hostname,
|
||||
path: "/",
|
||||
httpOnly: /;\s*httponly\b/i.test(setCookie),
|
||||
sameSite: "Lax",
|
||||
};
|
||||
}
|
||||
|
||||
async function forceTestAccountLogin(context) {
|
||||
const response = await context.request.fetch(`${BASE_URL}/api/auth`, {
|
||||
method: "POST",
|
||||
data: {
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: {
|
||||
email: TEST_EMAIL,
|
||||
password: TEST_PASSWORD,
|
||||
flow: "signIn",
|
||||
},
|
||||
},
|
||||
},
|
||||
headers: { "content-type": "application/json" },
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const payload = await response.json().catch(async () => ({ raw: await response.text() }));
|
||||
assert(response.ok(), `测试账号登录失败:${response.status()} ${JSON.stringify(payload)}`);
|
||||
|
||||
const cookies = response
|
||||
.headersArray()
|
||||
.filter((header) => header.name.toLowerCase() === "set-cookie")
|
||||
.map((header) => parseSetCookie(header.value, BASE_URL))
|
||||
.filter(Boolean);
|
||||
assert(cookies.length > 0, "测试账号登录响应缺少 set-cookie");
|
||||
await context.addCookies(cookies);
|
||||
authCookieHeader = cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join("; ");
|
||||
}
|
||||
|
||||
async function uploadProbeDocx(requestContext, target) {
|
||||
assert(fs.existsSync(PROBE_DOCX_PATH), `缺少探测文件:${PROBE_DOCX_PATH}`);
|
||||
const buffer = fs.readFileSync(PROBE_DOCX_PATH);
|
||||
const response = await requestContext.fetch(`${BASE_URL}/api/media/upload`, {
|
||||
method: "POST",
|
||||
headers: authCookieHeader ? { cookie: authCookieHeader } : undefined,
|
||||
multipart: {
|
||||
file: {
|
||||
name: "task174-onlyoffice-attachment.docx",
|
||||
mimeType: DOCX_MIME,
|
||||
buffer,
|
||||
},
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
},
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const payload = await response.json().catch(async () => ({ raw: await response.text() }));
|
||||
assert(
|
||||
response.ok(),
|
||||
`/api/media/upload 请求失败:${response.status()} ${JSON.stringify(payload)}`,
|
||||
);
|
||||
assert(
|
||||
payload && payload.asset && typeof payload.asset.id === "string",
|
||||
`上传结果缺少 asset.id:${JSON.stringify(payload)}`,
|
||||
);
|
||||
return payload.asset;
|
||||
}
|
||||
|
||||
async function waitForAssetRow(page, assetId) {
|
||||
const selector = `[data-testid="filetree-asset-row"][data-asset-id="${assetId}"]`;
|
||||
const row = page.locator(selector).first();
|
||||
await row.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
return row;
|
||||
}
|
||||
|
||||
async function waitForOnlyOfficeReady(page) {
|
||||
page.on("console", (message) => {
|
||||
popupConsoleMessages.push({
|
||||
type: message.type(),
|
||||
text: message.text().slice(0, 1000),
|
||||
});
|
||||
});
|
||||
page.on("pageerror", (error) => {
|
||||
popupConsoleMessages.push({
|
||||
type: "pageerror",
|
||||
text: error.message.slice(0, 1000),
|
||||
});
|
||||
});
|
||||
page.on("response", (response) => {
|
||||
if (response.status() >= 400) {
|
||||
popupNetworkFailures.push({
|
||||
status: response.status(),
|
||||
url: response.url().slice(0, 1000),
|
||||
});
|
||||
}
|
||||
});
|
||||
try {
|
||||
await page.waitForFunction(
|
||||
() => window.__MNOTE_ONLYOFFICE_READY__ === true,
|
||||
undefined,
|
||||
{ timeout: ONLYOFFICE_READY_TIMEOUT_MS },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const root = document.getElementById("onlyoffice-frame");
|
||||
if (root && root.querySelector("iframe,canvas")) return true;
|
||||
return Boolean(document.querySelector("iframe,canvas"));
|
||||
},
|
||||
undefined,
|
||||
{ timeout: ONLYOFFICE_READY_TIMEOUT_MS },
|
||||
);
|
||||
} catch (error) {
|
||||
const debug = await page.evaluate(() => ({
|
||||
url: window.location.href,
|
||||
title: document.title,
|
||||
readyState: document.readyState,
|
||||
ready: window.__MNOTE_ONLYOFFICE_READY__ === true,
|
||||
debug: window.__MNOTE_ONLYOFFICE_DEBUG__ || null,
|
||||
errors: window.__MNOTE_ONLYOFFICE_ERRLOG__ || [],
|
||||
hasFrameRoot: Boolean(document.getElementById("onlyoffice-frame")),
|
||||
bodyText: document.body ? document.body.innerText.slice(0, 800) : "",
|
||||
html: document.body ? document.body.innerHTML.slice(0, 1600) : "",
|
||||
scriptCount: document.scripts.length,
|
||||
frameCount: document.querySelectorAll("iframe,canvas").length,
|
||||
hasFrameRoot: Boolean(document.getElementById("onlyoffice-frame")),
|
||||
})).catch((debugError) => ({
|
||||
evaluateError: debugError instanceof Error ? debugError.message : String(debugError),
|
||||
isClosed: page.isClosed(),
|
||||
}));
|
||||
debug.consoleMessages = popupConsoleMessages.slice(-20);
|
||||
debug.networkFailures = popupNetworkFailures.slice(-30);
|
||||
throw new Error(
|
||||
`OnlyOffice ready 超时:${error instanceof Error ? error.message : String(error)} ${JSON.stringify(debug)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function readOnlyOfficeDebug(page) {
|
||||
return await page.evaluate(() => {
|
||||
const debug = window.__MNOTE_ONLYOFFICE_DEBUG__ || null;
|
||||
return {
|
||||
debug,
|
||||
docKey: debug && typeof debug.docKey === "string" ? debug.docKey : "",
|
||||
ready: window.__MNOTE_ONLYOFFICE_READY__ === true,
|
||||
frameCount: document.querySelectorAll("iframe,canvas").length,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function assertOnlyOfficeDocumentUrlReachableFromContainer(debug) {
|
||||
const resolvedFileUrl = String(debug && debug.resolvedFileUrl || "");
|
||||
assert(resolvedFileUrl.includes("/api/onlyoffice/proxy"), `OnlyOffice resolvedFileUrl 未走 proxy:${JSON.stringify(debug)}`);
|
||||
assert(
|
||||
/^(http:\/\/host\.docker\.internal:3000|http:\/\/172\.17\.0\.1:3000)/.test(resolvedFileUrl),
|
||||
`OnlyOffice resolvedFileUrl 应使用 DocumentServer 可回源的 mnote-web 地址:${resolvedFileUrl}`,
|
||||
);
|
||||
const { execFile } = require("node:child_process");
|
||||
const { promisify } = require("node:util");
|
||||
const execFileAsync = promisify(execFile);
|
||||
const response = await execFileAsync(
|
||||
"docker",
|
||||
["exec", "mnote-onlyoffice-documentserver", "curl", "-fsSI", "--max-time", "10", resolvedFileUrl],
|
||||
{ timeout: 15_000, maxBuffer: 1024 * 1024 },
|
||||
);
|
||||
assert(/HTTP\/[0-9.]+ 200/.test(response.stdout), `DocumentServer 容器无法回源读取文档:${response.stdout}\n${response.stderr}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1" });
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
let target = null;
|
||||
try {
|
||||
await forceTestAccountLogin(context);
|
||||
await ensureAuthenticated(page, context.request);
|
||||
target = await createTempDocument(context.request, null);
|
||||
const asset = await uploadProbeDocx(context.request, target);
|
||||
|
||||
await openDocument(page, target.workspaceId, target.documentId);
|
||||
await openFilesystemView(page);
|
||||
const row = await waitForAssetRow(page, asset.id);
|
||||
|
||||
const [popup] = await Promise.all([
|
||||
page.waitForEvent("popup", { timeout: UI_TIMEOUT_MS }),
|
||||
row.locator('[data-rust-action="open"]').first().click({ timeout: UI_TIMEOUT_MS }),
|
||||
]);
|
||||
await popup.waitForLoadState("domcontentloaded", { timeout: UI_TIMEOUT_MS }).catch(() => undefined);
|
||||
|
||||
const opened = new URL(popup.url());
|
||||
assert(opened.pathname === "/onlyoffice", `附件应在新窗口打开 /onlyoffice,实际为:${popup.url()}`);
|
||||
assert(opened.searchParams.get("assetId") === asset.id, "OnlyOffice URL 缺少正确 assetId");
|
||||
assert(
|
||||
opened.searchParams.get("documentId") === target.documentId,
|
||||
"OnlyOffice URL 缺少正确 documentId",
|
||||
);
|
||||
assert(opened.searchParams.get("fileType") === "docx", "OnlyOffice URL fileType 应为 docx");
|
||||
assert(opened.searchParams.get("mode") === "edit", "OnlyOffice URL mode 应为 edit");
|
||||
await waitForOnlyOfficeReady(popup);
|
||||
const firstDebug = await readOnlyOfficeDebug(popup);
|
||||
assert(firstDebug.ready, "OnlyOffice ready flag 应为 true");
|
||||
assert(firstDebug.frameCount > 0, "OnlyOffice 应创建 iframe/canvas");
|
||||
assert(firstDebug.debug && firstDebug.debug.documentUrlBase, `OnlyOffice debug 应暴露 documentUrlBase:${JSON.stringify(firstDebug)}`);
|
||||
await assertOnlyOfficeDocumentUrlReachableFromContainer(firstDebug.debug);
|
||||
assert(
|
||||
/^[0-9A-Za-z_.=-]{1,128}$/.test(firstDebug.docKey),
|
||||
`OnlyOffice document.key 不符合安全字符集或长度:${firstDebug.docKey}`,
|
||||
);
|
||||
assert(
|
||||
firstDebug.docKey === asset.id || firstDebug.docKey.startsWith(`${asset.id}_`),
|
||||
`OnlyOffice document.key 应绑定 assetId,实际为:${firstDebug.docKey}`,
|
||||
);
|
||||
|
||||
const reopened = await context.newPage();
|
||||
await reopened.goto(popup.url(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await waitForOnlyOfficeReady(reopened);
|
||||
const secondDebug = await readOnlyOfficeDebug(reopened);
|
||||
assert(
|
||||
secondDebug.docKey === firstDebug.docKey,
|
||||
`同一附件重复打开 document.key 应稳定:first=${firstDebug.docKey} second=${secondDebug.docKey}`,
|
||||
);
|
||||
await reopened.close().catch(() => undefined);
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
documentId: target.documentId,
|
||||
workspaceId: target.workspaceId,
|
||||
assetId: asset.id,
|
||||
openedUrl: popup.url(),
|
||||
docKey: firstDebug.docKey,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (target && target.documentId) {
|
||||
await purgeDocument(context.request, target.documentId).catch((error) => {
|
||||
console.warn(`清理临时页面失败:${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
}
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,368 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
const {
|
||||
BASE_URL,
|
||||
UI_TIMEOUT_MS,
|
||||
assert,
|
||||
createTempDocument,
|
||||
ensureAuthenticated,
|
||||
openDocument,
|
||||
openFilesystemView,
|
||||
purgeDocument,
|
||||
} = require("./tree-shell-smoke-helpers");
|
||||
|
||||
const DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
const PNG_MIME = "image/png";
|
||||
const TEST_EMAIL = "mnote.e2e@example.com";
|
||||
const TEST_PASSWORD = "MnoteE2E123!";
|
||||
// 说明:该 smoke 仍在验证 Convex media / OnlyOffice 的 cloud upload 兼容入口,不是 local-first 默认上传路径。
|
||||
const PROBE_DOCX_PATH =
|
||||
process.env.MNOTE_ONLYOFFICE_PROBE_DOCX ||
|
||||
"/tmp/mnote-onlyoffice-manual/mnote-onlyoffice-upload-test.docx";
|
||||
const SCREENSHOT_DIR =
|
||||
process.env.MNOTE_UPLOAD_ENTRY_SCREENSHOT_DIR ||
|
||||
"/mnt/Data1T/mnote/tmp/wolai-editor-parity/task175-rust-upload-entry";
|
||||
|
||||
function ensureProbeDocx() {
|
||||
assert(fs.existsSync(PROBE_DOCX_PATH), `缺少 Office 探测文件:${PROBE_DOCX_PATH}`);
|
||||
return {
|
||||
name: "task175-slash-attachment.docx",
|
||||
mimeType: DOCX_MIME,
|
||||
buffer: fs.readFileSync(PROBE_DOCX_PATH),
|
||||
};
|
||||
}
|
||||
|
||||
function tinyPngBuffer() {
|
||||
return Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
);
|
||||
}
|
||||
|
||||
function parseSetCookie(setCookie, origin) {
|
||||
const [nameValue] = String(setCookie || "").split(";");
|
||||
const separator = nameValue.indexOf("=");
|
||||
if (separator <= 0) return null;
|
||||
return {
|
||||
name: nameValue.slice(0, separator).trim(),
|
||||
value: nameValue.slice(separator + 1).trim(),
|
||||
domain: new URL(origin).hostname,
|
||||
path: "/",
|
||||
httpOnly: /;\s*httponly\b/i.test(setCookie),
|
||||
sameSite: "Lax",
|
||||
};
|
||||
}
|
||||
|
||||
async function forceTestAccountLogin(context) {
|
||||
const response = await context.request.fetch(`${BASE_URL}/api/auth`, {
|
||||
method: "POST",
|
||||
data: {
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: {
|
||||
email: TEST_EMAIL,
|
||||
password: TEST_PASSWORD,
|
||||
flow: "signIn",
|
||||
},
|
||||
},
|
||||
},
|
||||
headers: { "content-type": "application/json" },
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const payload = await response.json().catch(async () => ({ raw: await response.text() }));
|
||||
assert(response.ok(), `测试账号登录失败:${response.status()} ${JSON.stringify(payload)}`);
|
||||
const cookies = response
|
||||
.headersArray()
|
||||
.filter((header) => header.name.toLowerCase() === "set-cookie")
|
||||
.map((header) => parseSetCookie(header.value, BASE_URL))
|
||||
.filter(Boolean);
|
||||
assert(cookies.length > 0, "测试账号登录响应缺少 set-cookie");
|
||||
await context.addCookies(cookies);
|
||||
}
|
||||
|
||||
async function waitForRuntimeEditor(page) {
|
||||
const editor = page
|
||||
.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]')
|
||||
.first();
|
||||
await editor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
return editor;
|
||||
}
|
||||
|
||||
async function waitForUploadResponse(page, fileName, action) {
|
||||
const response = await page.waitForResponse(
|
||||
async (candidate) => {
|
||||
if (!candidate.url().includes("/api/media/upload") || candidate.request().method() !== "POST") {
|
||||
return false;
|
||||
}
|
||||
const payload = await candidate.json().catch(() => null);
|
||||
return Boolean(payload?.asset?.id && (!fileName || payload.asset.file_name === fileName));
|
||||
},
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
const payload = await response.json();
|
||||
assert(response.ok(), `${action} 上传失败:${response.status()} ${JSON.stringify(payload)}`);
|
||||
assert(payload?.asset?.id, `${action} 上传响应缺少 asset.id:${JSON.stringify(payload)}`);
|
||||
return payload.asset;
|
||||
}
|
||||
|
||||
async function waitForAssetRow(page, assetId, action) {
|
||||
try {
|
||||
await page.waitForFunction(
|
||||
(targetAssetId) =>
|
||||
Array.from(document.querySelectorAll(`[data-testid="filetree-asset-row"][data-asset-id="${targetAssetId}"]`)).some(
|
||||
(row) =>
|
||||
row instanceof HTMLElement &&
|
||||
window.getComputedStyle(row).display !== "none" &&
|
||||
window.getComputedStyle(row).visibility !== "hidden" &&
|
||||
row.getClientRects().length > 0,
|
||||
),
|
||||
assetId,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
} catch (error) {
|
||||
const debug = await page.evaluate((targetAssetId) => ({
|
||||
mode: document.documentElement.getAttribute("data-mnote-sidebar-tree-mode"),
|
||||
lastUpload: document.documentElement.getAttribute("data-mnote-last-upload-asset-id"),
|
||||
targetCount: document.querySelectorAll(`[data-testid="filetree-asset-row"][data-asset-id="${targetAssetId}"]`).length,
|
||||
assets: Array.from(document.querySelectorAll('[data-testid="filetree-asset-row"]')).slice(-12).map((row) => ({
|
||||
id: row.getAttribute("data-asset-id"),
|
||||
visible: row instanceof HTMLElement && window.getComputedStyle(row).display !== "none" && window.getComputedStyle(row).visibility !== "hidden" && row.getClientRects().length > 0,
|
||||
text: row.textContent,
|
||||
})),
|
||||
body: document.body.innerText.slice(0, 1200),
|
||||
}), assetId).catch((debugError) => ({ debugError: String(debugError) }));
|
||||
throw new Error(`${action} 上传后的文件树附件行不可见:${assetId} ${JSON.stringify(debug)}`);
|
||||
}
|
||||
const title = await page.evaluate((targetAssetId) => {
|
||||
const row = Array.from(document.querySelectorAll(`[data-testid="filetree-asset-row"][data-asset-id="${targetAssetId}"]`)).find(
|
||||
(candidate) =>
|
||||
candidate instanceof HTMLElement &&
|
||||
window.getComputedStyle(candidate).display !== "none" &&
|
||||
window.getComputedStyle(candidate).visibility !== "hidden" &&
|
||||
candidate.getClientRects().length > 0,
|
||||
);
|
||||
return row?.querySelector(".tree-link-title")?.textContent?.trim() || "";
|
||||
}, assetId);
|
||||
assert(title.length > 0, `${action} 上传后的文件树附件行缺少标题`);
|
||||
}
|
||||
|
||||
async function waitForEditorOfficeAttachment(page, assetId, fileName, action) {
|
||||
const attachment = page
|
||||
.locator(`.editor-surface .ProseMirror a[data-mnote-attachment-link="true"][href*="/onlyoffice"][href*="${assetId}"]`)
|
||||
.first();
|
||||
await attachment.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const text = (await attachment.innerText()).trim();
|
||||
assert(text.includes(fileName), `${action} 正文附件标题不正确:${text}`);
|
||||
const href = await attachment.getAttribute("href");
|
||||
assert(href && href.includes("/onlyoffice?"), `${action} 正文 Office 附件 href 应指向 /onlyoffice,实际:${href}`);
|
||||
assert(
|
||||
href && href.startsWith("/onlyoffice?"),
|
||||
`${action} 正文 Office 附件 href 应保存为相对 /onlyoffice 链接,实际:${href}`,
|
||||
);
|
||||
assert(href.includes(`assetId=${encodeURIComponent(assetId)}`), `${action} 正文 Office 附件 href 缺少 assetId:${href}`);
|
||||
return attachment;
|
||||
}
|
||||
|
||||
async function assertAttachmentActions(page, attachment, action) {
|
||||
await attachment.hover({ timeout: UI_TIMEOUT_MS });
|
||||
const actions = page.locator('[data-testid="mnote-attachment-actions"]').first();
|
||||
await actions.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await actions.locator('[data-testid="mnote-attachment-action-menu"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
const menu = page.locator('[data-testid="mnote-tree-context-menu"][data-kind="attachment"]').first();
|
||||
await menu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const text = await menu.innerText();
|
||||
for (const label of ["拷贝副本", "删除", "复制链接", "弹窗预览", "右侧预览", "下载", "更换文件", "重命名", "添加说明文字"]) {
|
||||
assert(text.includes(label), `${action} 附件三点菜单缺少“${label}”:${text}`);
|
||||
}
|
||||
await page.keyboard.press("Escape").catch(() => undefined);
|
||||
}
|
||||
|
||||
async function screenshot(page, name) {
|
||||
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
||||
await page.screenshot({
|
||||
path: path.join(SCREENSHOT_DIR, `${name}.png`),
|
||||
fullPage: false,
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadViaSlash(page, itemTestId, filePayload, action) {
|
||||
const editor = await waitForRuntimeEditor(page);
|
||||
await editor.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.keyboard.type("/");
|
||||
const slash = page.locator('[data-testid="mnote-leptos-tiptap-slash-menu"]').first();
|
||||
await slash.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const slashText = await slash.innerText();
|
||||
assert(slashText.includes("媒体与附件"), `slash 菜单缺少媒体与附件分组:${slashText}`);
|
||||
|
||||
const item = page.locator(`[data-testid="${itemTestId}"]`).first();
|
||||
await item.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await item.scrollIntoViewIfNeeded({ timeout: UI_TIMEOUT_MS });
|
||||
await screenshot(page, `slash-${itemTestId}`);
|
||||
|
||||
const [fileChooser] = await Promise.all([
|
||||
page.waitForEvent("filechooser", { timeout: UI_TIMEOUT_MS }),
|
||||
item.click({ timeout: UI_TIMEOUT_MS }),
|
||||
]);
|
||||
const uploadResponse = waitForUploadResponse(page, filePayload.name, action);
|
||||
await fileChooser.setFiles(filePayload);
|
||||
const asset = await uploadResponse;
|
||||
await waitForAssetRow(page, asset.id, action);
|
||||
return asset;
|
||||
}
|
||||
|
||||
async function dispatchFileDrop(page, selector, filePayload, action) {
|
||||
const uploadPromise = waitForUploadResponse(page, filePayload.name, action);
|
||||
await page.evaluate(
|
||||
({ selector, fileName, mimeType, bytes }) => {
|
||||
const target = Array.from(document.querySelectorAll(selector)).find(
|
||||
(candidate) => candidate instanceof HTMLElement && candidate.getClientRects().length > 0,
|
||||
) || document.querySelector(selector);
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
throw new Error(`拖放目标不存在:${selector}`);
|
||||
}
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(new File([new Uint8Array(bytes)], fileName, { type: mimeType }));
|
||||
target.dispatchEvent(new DragEvent("dragover", { bubbles: true, cancelable: true, dataTransfer }));
|
||||
target.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer }));
|
||||
},
|
||||
{
|
||||
selector,
|
||||
fileName: filePayload.name,
|
||||
mimeType: filePayload.mimeType,
|
||||
bytes: Array.from(filePayload.buffer),
|
||||
},
|
||||
);
|
||||
const asset = await uploadPromise;
|
||||
await waitForAssetRow(page, asset.id, action);
|
||||
return asset;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
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 networkNotes = [];
|
||||
page.on("response", async (response) => {
|
||||
if (!/\/api\/(media\/upload|tree\/filetree\/upload-target-preflight)/.test(response.url())) return;
|
||||
const text = await response.text().catch(() => "");
|
||||
networkNotes.push({
|
||||
status: response.status(),
|
||||
url: response.url(),
|
||||
body: text.slice(0, 1000),
|
||||
});
|
||||
});
|
||||
page.on("pageerror", (error) => {
|
||||
networkNotes.push({ type: "pageerror", message: error.message });
|
||||
});
|
||||
page.on("console", (message) => {
|
||||
if (["error", "warning"].includes(message.type())) {
|
||||
networkNotes.push({ type: message.type(), message: message.text().slice(0, 1000) });
|
||||
}
|
||||
});
|
||||
page.on("dialog", async (dialog) => {
|
||||
networkNotes.push({ type: "dialog", message: dialog.message() });
|
||||
await dialog.dismiss().catch(() => undefined);
|
||||
});
|
||||
let target = null;
|
||||
|
||||
try {
|
||||
await forceTestAccountLogin(context);
|
||||
await ensureAuthenticated(page, context.request);
|
||||
target = await createTempDocument(context.request, null);
|
||||
await openDocument(page, target.workspaceId, target.documentId);
|
||||
await openFilesystemView(page);
|
||||
|
||||
const slashAttachment = ensureProbeDocx();
|
||||
const slashImage = {
|
||||
name: "task175-slash-image.png",
|
||||
mimeType: PNG_MIME,
|
||||
buffer: tinyPngBuffer(),
|
||||
};
|
||||
const treeDropAttachment = {
|
||||
name: "task175-filetree-drop.docx",
|
||||
mimeType: DOCX_MIME,
|
||||
buffer: slashAttachment.buffer,
|
||||
};
|
||||
const editorDropImage = {
|
||||
name: "task175-editor-drop.png",
|
||||
mimeType: PNG_MIME,
|
||||
buffer: tinyPngBuffer(),
|
||||
};
|
||||
|
||||
const attachmentAsset = await uploadViaSlash(
|
||||
page,
|
||||
"slash-item-upload-attachment",
|
||||
slashAttachment,
|
||||
"slash 上传附件",
|
||||
);
|
||||
const editorAttachment = await waitForEditorOfficeAttachment(
|
||||
page,
|
||||
attachmentAsset.id,
|
||||
slashAttachment.name,
|
||||
"slash 上传附件",
|
||||
);
|
||||
await assertAttachmentActions(page, editorAttachment, "slash 上传附件");
|
||||
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await waitForRuntimeEditor(page);
|
||||
await waitForEditorOfficeAttachment(page, attachmentAsset.id, slashAttachment.name, "刷新后正文附件");
|
||||
await openFilesystemView(page);
|
||||
const imageAsset = await uploadViaSlash(page, "slash-item-image", slashImage, "slash 上传图片");
|
||||
|
||||
const docRowSelector = `[data-testid="filetree-doc-row"][data-document-id="${target.documentId}"]`;
|
||||
const droppedAttachment = await dispatchFileDrop(page, docRowSelector, treeDropAttachment, "文件树拖入附件");
|
||||
|
||||
const editorStageSelector = '[data-testid="mnote-leptos-tiptap-editor-stage"]';
|
||||
const droppedImage = await dispatchFileDrop(page, editorStageSelector, editorDropImage, "主编辑区拖入图片");
|
||||
await page.waitForFunction(
|
||||
(fileName) => {
|
||||
const images = Array.from(document.querySelectorAll(".editor-surface .ProseMirror img[src]"));
|
||||
return images.some((image) => image.getAttribute("alt") === fileName || image.getAttribute("title") === fileName);
|
||||
},
|
||||
editorDropImage.name,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
uploadedAssetIds: [
|
||||
attachmentAsset.id,
|
||||
imageAsset.id,
|
||||
droppedAttachment.id,
|
||||
droppedImage.id,
|
||||
],
|
||||
screenshotDir: SCREENSHOT_DIR,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
if (networkNotes.length) {
|
||||
console.error(`上传入口调试信息:${JSON.stringify(networkNotes.slice(-20), null, 2)}`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (target?.documentId) {
|
||||
await purgeDocument(context.request, target.documentId).catch((error) => {
|
||||
console.warn(`清理临时页面失败:${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
}
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,206 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const CONVEX_URL = (process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_SELF_HOSTED_URL || "http://127.0.0.1:3210").replace(/\/+$/, "");
|
||||
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
|
||||
// 说明:该 smoke 覆盖 convex-source trash 兼容路径,不代表 local-first 默认文件回收策略。
|
||||
|
||||
async function requestJson(request, path, init = {}) {
|
||||
const response = await request.fetch(`${BASE_URL}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
|
||||
...(init.headers || {}),
|
||||
},
|
||||
timeout: 20_000,
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
payload = text;
|
||||
}
|
||||
if (!response.ok()) {
|
||||
throw new Error(`${path} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function convexCall(context, kind, path, args) {
|
||||
const cookies = await context.cookies(BASE_URL);
|
||||
const jwt = cookies.find((cookie) => cookie.name === "__convexAuthJWT")?.value;
|
||||
assert(jwt, "缺少 __convexAuthJWT cookie");
|
||||
const response = await fetch(`${CONVEX_URL}/api/${kind}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${jwt}`,
|
||||
"content-type": "application/json",
|
||||
"Convex-Client": "mnote-trash-empty-smoke",
|
||||
},
|
||||
body: JSON.stringify({ path, format: "convex_encoded_json", args: [args] }),
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok || body.status !== "success") {
|
||||
throw new Error(`Convex ${kind} ${path} 失败: ${response.status} ${JSON.stringify(body)}`);
|
||||
}
|
||||
return body.value;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const stamp = Date.now();
|
||||
const email = `mnote.trash.${stamp}@example.com`;
|
||||
const username = `trash-${stamp}`;
|
||||
const assetId = `asset_trash_${stamp}`;
|
||||
const mindmapId = `mind_trash_${stamp}`;
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
const request = context.request;
|
||||
|
||||
try {
|
||||
await requestJson(request, "/api/auth", {
|
||||
method: "POST",
|
||||
data: {
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: { email, password: e2ePassword(), flow: "signUp", name: username },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`${BASE_URL}/`, { waitUntil: "domcontentloaded", timeout: 20_000 });
|
||||
const currentUser = await convexCall(context, "query", "users:currentUser", {});
|
||||
const userId = currentUser?._id;
|
||||
assert(userId, "缺少 Convex currentUser._id");
|
||||
const workspaces = await convexCall(context, "query", "workspaces:fetchWorkspaceSummaries", {});
|
||||
const workspaceId = workspaces.activeWorkspaceId;
|
||||
assert(workspaceId, "缺少隔离 workspaceId");
|
||||
|
||||
const root = await requestJson(request, "/api/tree/commands", {
|
||||
method: "POST",
|
||||
data: { action: "create", workspaceId, title: `TEST-10REVIEW-07-P3-root-${stamp}` },
|
||||
});
|
||||
const rootId = root.result.documentId;
|
||||
const child = await requestJson(request, "/api/tree/commands", {
|
||||
method: "POST",
|
||||
data: { action: "create", workspaceId, parentId: rootId, title: `TEST-10REVIEW-07-P3-child-${stamp}` },
|
||||
});
|
||||
const childId = child.result.documentId;
|
||||
const resourceDoc = await requestJson(request, "/api/tree/commands", {
|
||||
method: "POST",
|
||||
data: { action: "create", workspaceId, title: `TEST-10REVIEW-07-P3-resources-${stamp}` },
|
||||
});
|
||||
const resourceDocId = resourceDoc.result.documentId;
|
||||
|
||||
await convexCall(context, "mutation", "mediaAssets:create", {
|
||||
userId,
|
||||
asset: {
|
||||
id: assetId,
|
||||
workspace_id: workspaceId,
|
||||
document_id: resourceDocId,
|
||||
asset_type: "file",
|
||||
file_url: null,
|
||||
thumbnail_url: null,
|
||||
storage_id: null,
|
||||
bucket: null,
|
||||
storage_path: null,
|
||||
file_name: `TEST-10REVIEW-07-P3-file-${stamp}.txt`,
|
||||
file_size: 12,
|
||||
mime_type: "text/plain",
|
||||
},
|
||||
});
|
||||
await convexCall(context, "mutation", "mindmaps:put", {
|
||||
docId: resourceDocId,
|
||||
mindmapId,
|
||||
data: { root: { data: { text: `TEST-10REVIEW-07-P3-mind-${stamp}` }, children: [] } },
|
||||
createOnly: true,
|
||||
});
|
||||
const table = await convexCall(context, "mutation", "tables:create", {
|
||||
userId,
|
||||
workspaceId,
|
||||
documentId: resourceDocId,
|
||||
title: `TEST-10REVIEW-07-P3-table-${stamp}`,
|
||||
schema: {},
|
||||
snapshot: null,
|
||||
});
|
||||
const tableId = table.id;
|
||||
await convexCall(context, "mutation", "tables:update", {
|
||||
userId,
|
||||
tableId,
|
||||
rows: [{ cells: ["p3-row"] }],
|
||||
});
|
||||
|
||||
await requestJson(request, "/api/tree/commands", {
|
||||
method: "POST",
|
||||
data: { action: "archive", workspaceId, documentId: rootId },
|
||||
});
|
||||
await requestJson(request, "/api/media/batch", {
|
||||
method: "POST",
|
||||
data: { action: "delete", assetIds: [assetId] },
|
||||
});
|
||||
await requestJson(request, `/api/mindmap/${encodeURIComponent(resourceDocId)}/${encodeURIComponent(mindmapId)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
await requestJson(request, `/api/tables/${encodeURIComponent(tableId)}`, { method: "DELETE" });
|
||||
|
||||
const trashedDocs = await convexCall(context, "query", "documents:listTrashedByWorkspace", { workspaceId });
|
||||
assert(trashedDocs.some((doc) => doc.id === rootId), "根页面未进入垃圾箱");
|
||||
assert(trashedDocs.some((doc) => doc.id === childId), "子页面未级联进入垃圾箱");
|
||||
const mediaBefore = await convexCall(context, "query", "mediaAssets:getById", { userId, id: assetId });
|
||||
assert(mediaBefore?.deleted_at, "附件未进入垃圾箱");
|
||||
const mindmapsBefore = await convexCall(context, "query", "mindmaps:listByWorkspace", { workspaceId, includeDeleted: true });
|
||||
assert(mindmapsBefore.some((row) => row.mindmap_id === mindmapId && row.deleted_at), "mindmap 未进入垃圾箱");
|
||||
const tablesBefore = await convexCall(context, "query", "tables:listByWorkspaceForSearch", {
|
||||
userId,
|
||||
workspaceId,
|
||||
includeArchived: true,
|
||||
limit: 100,
|
||||
});
|
||||
assert(tablesBefore.some((row) => row.id === tableId && row.deleted_at), "table 未进入垃圾箱");
|
||||
|
||||
const pageEmpty = await requestJson(request, "/api/documents/empty-trash", { method: "POST", data: { workspaceId } });
|
||||
const mediaEmpty = await requestJson(request, "/api/media/empty-trash", { method: "POST", data: { workspaceId } });
|
||||
const mindmapEmpty = await requestJson(request, "/api/mindmap-trash/empty", { method: "POST", data: { workspaceId } });
|
||||
const tableEmpty = await requestJson(request, "/api/tables/empty-trash", { method: "POST", data: { workspaceId } });
|
||||
|
||||
assert.equal(await convexCall(context, "query", "documents:getMeta", { id: rootId }), null, "根页面 purge 后仍可查询");
|
||||
assert.equal(await convexCall(context, "query", "documents:getMeta", { id: childId }), null, "子页面 purge 后仍可查询");
|
||||
assert.equal(await convexCall(context, "query", "mediaAssets:getById", { userId, id: assetId }), null, "附件 purge 后仍可查询");
|
||||
const mindmapsAfter = await convexCall(context, "query", "mindmaps:listByWorkspace", { workspaceId, includeDeleted: true });
|
||||
assert(!mindmapsAfter.some((row) => row.mindmap_id === mindmapId), "mindmap purge 后仍存在");
|
||||
assert.equal(await convexCall(context, "query", "tables:get", { userId, tableId }), null, "table purge 后仍可查询");
|
||||
assert.equal((await convexCall(context, "query", "tables:getRows", { tableId })).length, 0, "table rows purge 后仍存在");
|
||||
|
||||
await requestJson(request, "/api/documents/purge", { method: "POST", data: { documentId: resourceDocId } }).catch(() => null);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
email,
|
||||
userId,
|
||||
workspaceId,
|
||||
rootId,
|
||||
childId,
|
||||
resourceDocId,
|
||||
assetId,
|
||||
mindmapId,
|
||||
tableId,
|
||||
pageEmpty: pageEmpty.result,
|
||||
mediaEmpty: mediaEmpty.result,
|
||||
mindmapEmpty: mindmapEmpty.result,
|
||||
tableEmpty: tableEmpty.result,
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,535 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const TASK = "task428-filetree-bulk-delete-selection-smoke";
|
||||
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const CONVEX_URL = (process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_SELF_HOSTED_URL || "http://127.0.0.1:3210").replace(/\/+$/, "");
|
||||
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
|
||||
// 说明:该 smoke 覆盖 convex-source 批量删除兼容路径,不代表 local-first 默认 File Tree 主链。
|
||||
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");
|
||||
|
||||
async function writeResult(payload) {
|
||||
await fs.mkdir(OUTPUT_DIR, { recursive: true });
|
||||
await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
async function requestJson(request, requestPath, init = {}) {
|
||||
const response = await request.fetch(`${BASE_URL}${requestPath}`, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
|
||||
...(init.headers || {}),
|
||||
},
|
||||
timeout: 20_000,
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
payload = text;
|
||||
}
|
||||
if (!response.ok()) {
|
||||
throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function convexCall(context, kind, convexPath, args) {
|
||||
const cookies = await context.cookies(BASE_URL);
|
||||
const jwt = cookies.find((cookie) => cookie.name === "__convexAuthJWT")?.value;
|
||||
assert(jwt, "缺少 __convexAuthJWT cookie");
|
||||
const response = await fetch(`${CONVEX_URL}/api/${kind}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${jwt}`,
|
||||
"content-type": "application/json",
|
||||
"Convex-Client": TASK,
|
||||
},
|
||||
body: JSON.stringify({ path: convexPath, format: "convex_encoded_json", args: [args] }),
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok || body.status !== "success") {
|
||||
throw new Error(`Convex ${kind} ${convexPath} 失败: ${response.status} ${JSON.stringify(body)}`);
|
||||
}
|
||||
return body.value;
|
||||
}
|
||||
|
||||
function docRowSelector(documentId) {
|
||||
return `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="doc:${cssEscape(documentId)}"]`;
|
||||
}
|
||||
|
||||
function assetRowSelector(assetId) {
|
||||
return `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-asset-id="${cssEscape(assetId)}"]`;
|
||||
}
|
||||
|
||||
function cssEscape(value) {
|
||||
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
async function openDocumentFileTree(page, workspaceId, documentId) {
|
||||
await page.goto(`${BASE_URL}/documents/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, {
|
||||
waitUntil: "commit",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const fileRoot = document.getElementById("sidebar-file-tree-root");
|
||||
const fileTab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
|
||||
return Boolean(fileRoot || fileTab);
|
||||
},
|
||||
undefined,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
const fileRoot = document.getElementById("sidebar-file-tree-root");
|
||||
const visible = (node) =>
|
||||
node instanceof HTMLElement &&
|
||||
!node.hidden &&
|
||||
getComputedStyle(node).display !== "none" &&
|
||||
getComputedStyle(node).visibility !== "hidden" &&
|
||||
node.getClientRects().length > 0;
|
||||
if (visible(fileRoot)) return;
|
||||
const tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
|
||||
if (tab instanceof HTMLElement) tab.click();
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const fileRoot = document.getElementById("sidebar-file-tree-root");
|
||||
return fileRoot instanceof HTMLElement && fileRoot.getClientRects().length > 0;
|
||||
},
|
||||
undefined,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForRow(page, selector, label) {
|
||||
await page.locator(selector).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }).catch(async (error) => {
|
||||
const text = await page.locator("#sidebar-file-tree-root").innerText({ timeout: 3_000 }).catch(() => "");
|
||||
throw new Error(`${label} 未出现: ${error.message}; filetree=${text.slice(0, 2000)}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function expandDocIfNeeded(page, documentId) {
|
||||
const selector = docRowSelector(documentId);
|
||||
await waitForRow(page, selector, `页面 ${documentId}`);
|
||||
const expanded = await page.locator(selector).first().getAttribute("aria-expanded").catch(() => null);
|
||||
if (expanded === "true") return;
|
||||
const clicked = await page.locator(`${selector} [data-testid="filetree-toggle"]`).first().click({ timeout: 2_000 }).then(() => true).catch(() => false);
|
||||
if (!clicked) {
|
||||
await page.locator(selector).first().dblclick({ timeout: 2_000 }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectedRows(page) {
|
||||
return page.locator('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-selected="true"]').evaluateAll((rows) =>
|
||||
rows.map((row) => ({
|
||||
rowId: row instanceof HTMLElement ? row.dataset.rowId || "" : "",
|
||||
assetId: row instanceof HTMLElement ? row.dataset.assetId || "" : "",
|
||||
documentId: row instanceof HTMLElement ? row.dataset.documentId || "" : "",
|
||||
text: row.textContent || "",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
async function clickRow(page, selector, options = {}) {
|
||||
await page.locator(selector).first().click({ timeout: UI_TIMEOUT_MS, ...options });
|
||||
}
|
||||
|
||||
async function rightClickRow(page, selector) {
|
||||
await page.locator(selector).first().click({ button: "right", timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
async function closeContextMenu(page) {
|
||||
await page.keyboard.press("Escape").catch(() => undefined);
|
||||
await page.waitForTimeout(150);
|
||||
}
|
||||
|
||||
async function deleteSelectionAndCaptureConfirm(page, key, focusSelector) {
|
||||
const target = focusSelector ? page.locator(focusSelector).first() : null;
|
||||
if (focusSelector) {
|
||||
await target.focus({ timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
const dialogPromise = page.waitForEvent("dialog", { timeout: UI_TIMEOUT_MS }).then(async (dialog) => {
|
||||
const message = dialog.message();
|
||||
await dialog.accept();
|
||||
return message;
|
||||
});
|
||||
if (target) {
|
||||
await target.press(key, { timeout: UI_TIMEOUT_MS });
|
||||
} else {
|
||||
await page.keyboard.press(key);
|
||||
}
|
||||
return await dialogPromise.catch((error) => {
|
||||
throw new Error(`${key} 未触发删除确认弹窗: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteSelectionAndCaptureConfirmAndAlert(page, key, focusSelector) {
|
||||
const confirmPromise = page.waitForEvent("dialog", { timeout: UI_TIMEOUT_MS }).then(async (dialog) => {
|
||||
const message = dialog.message();
|
||||
await dialog.accept();
|
||||
return message;
|
||||
});
|
||||
await page.locator(focusSelector).first().press(key, { timeout: UI_TIMEOUT_MS });
|
||||
const confirm = await confirmPromise.catch((error) => {
|
||||
throw new Error(`${key} 未触发删除确认弹窗: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
const alert = await page.waitForEvent("dialog", { timeout: UI_TIMEOUT_MS }).then(async (dialog) => {
|
||||
const message = dialog.message();
|
||||
await dialog.accept();
|
||||
return message;
|
||||
}).catch((error) => {
|
||||
throw new Error(`${key} 未触发失败摘要弹窗: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
return { confirm, alert };
|
||||
}
|
||||
|
||||
async function expectDetached(page, selector, label) {
|
||||
await page.locator(selector).first().waitFor({ state: "detached", timeout: UI_TIMEOUT_MS }).catch(async (error) => {
|
||||
const state = await selectedRows(page).catch(() => []);
|
||||
throw new Error(`${label} 未从 filetree 消失: ${error.message}; selection=${JSON.stringify(state)}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function createPage(request, workspaceId, title, parentId = null) {
|
||||
const payload = await requestJson(request, "/api/tree/commands", {
|
||||
method: "POST",
|
||||
data: {
|
||||
action: "create",
|
||||
workspaceId,
|
||||
parentId,
|
||||
title,
|
||||
},
|
||||
});
|
||||
const result = payload.result || payload;
|
||||
assert(result.documentId, `创建页面失败: ${JSON.stringify(payload)}`);
|
||||
return result.documentId;
|
||||
}
|
||||
|
||||
async function seedFixture(context, request, stamp) {
|
||||
const currentUser = await convexCall(context, "query", "users:currentUser", {});
|
||||
const userId = currentUser?._id;
|
||||
assert(userId, "缺少 Convex currentUser._id");
|
||||
const workspaces = await convexCall(context, "query", "workspaces:fetchWorkspaceSummaries", {});
|
||||
const workspaceId = workspaces.activeWorkspaceId;
|
||||
assert(workspaceId, "缺少隔离 workspaceId");
|
||||
|
||||
const rootId = await createPage(request, workspaceId, `TEST-10REVIEW-07-P4-root-${stamp}`);
|
||||
const childId = await createPage(request, workspaceId, `TEST-10REVIEW-07-P4-child-${stamp}`, rootId);
|
||||
const siblingId = await createPage(request, workspaceId, `TEST-10REVIEW-07-P4-sibling-${stamp}`);
|
||||
const failureDocId = await createPage(request, workspaceId, `TEST-10REVIEW-07-P4-failure-doc-${stamp}`);
|
||||
const resourceDocId = await createPage(request, workspaceId, `TEST-10REVIEW-07-P4-resources-${stamp}`);
|
||||
|
||||
const fileAssetA = `asset_p4_a_${stamp}`;
|
||||
const fileAssetB = `asset_p4_b_${stamp}`;
|
||||
const failureAssetId = `asset_p4_failure_${stamp}`;
|
||||
const mindmapId = `mind_p4_${stamp}`;
|
||||
|
||||
for (const [assetId, fileName] of [
|
||||
[fileAssetA, `TEST-10REVIEW-07-P4-file-a-${stamp}.txt`],
|
||||
[fileAssetB, `TEST-10REVIEW-07-P4-file-b-${stamp}.txt`],
|
||||
[failureAssetId, `TEST-10REVIEW-07-P4-failure-file-${stamp}.txt`],
|
||||
]) {
|
||||
await convexCall(context, "mutation", "mediaAssets:create", {
|
||||
userId,
|
||||
asset: {
|
||||
id: assetId,
|
||||
workspace_id: workspaceId,
|
||||
document_id: resourceDocId,
|
||||
asset_type: "file",
|
||||
file_url: null,
|
||||
thumbnail_url: null,
|
||||
storage_id: null,
|
||||
bucket: null,
|
||||
storage_path: null,
|
||||
file_name: fileName,
|
||||
file_size: 12,
|
||||
mime_type: "text/plain",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await convexCall(context, "mutation", "mindmaps:put", {
|
||||
docId: resourceDocId,
|
||||
mindmapId,
|
||||
data: { root: { data: { text: `TEST-10REVIEW-07-P4-mind-${stamp}` }, children: [] } },
|
||||
createOnly: true,
|
||||
});
|
||||
|
||||
const table = await convexCall(context, "mutation", "tables:create", {
|
||||
userId,
|
||||
workspaceId,
|
||||
documentId: resourceDocId,
|
||||
title: `TEST-10REVIEW-07-P4-table-${stamp}`,
|
||||
schema: {},
|
||||
snapshot: null,
|
||||
});
|
||||
await convexCall(context, "mutation", "tables:update", {
|
||||
userId,
|
||||
tableId: table.id,
|
||||
rows: [{ cells: ["p4-row"] }],
|
||||
});
|
||||
|
||||
return {
|
||||
userId,
|
||||
workspaceId,
|
||||
rootId,
|
||||
childId,
|
||||
siblingId,
|
||||
failureDocId,
|
||||
resourceDocId,
|
||||
fileAssetA,
|
||||
fileAssetB,
|
||||
failureAssetId,
|
||||
mindmapId,
|
||||
tableId: table.id,
|
||||
};
|
||||
}
|
||||
|
||||
async function assertTrashState(context, fixture, stage) {
|
||||
const trashedDocs = await convexCall(context, "query", "documents:listTrashedByWorkspace", {
|
||||
workspaceId: fixture.workspaceId,
|
||||
});
|
||||
for (const documentId of stage.documentIds || []) {
|
||||
assert(trashedDocs.some((doc) => doc.id === documentId), `${stage.name}: 页面 ${documentId} 未进入垃圾箱`);
|
||||
}
|
||||
for (const assetId of stage.fileAssetIds || []) {
|
||||
const row = await convexCall(context, "query", "mediaAssets:getById", { userId: fixture.userId, id: assetId });
|
||||
assert(row?.deleted_at, `${stage.name}: 附件 ${assetId} 未进入垃圾箱`);
|
||||
}
|
||||
if (stage.mindmapId) {
|
||||
const mindmaps = await convexCall(context, "query", "mindmaps:listByWorkspace", {
|
||||
workspaceId: fixture.workspaceId,
|
||||
includeDeleted: true,
|
||||
});
|
||||
assert(
|
||||
mindmaps.some((row) => row.mindmap_id === stage.mindmapId && row.deleted_at),
|
||||
`${stage.name}: mindmap ${stage.mindmapId} 未进入垃圾箱`,
|
||||
);
|
||||
}
|
||||
if (stage.tableId) {
|
||||
const tables = await convexCall(context, "query", "tables:listByWorkspaceForSearch", {
|
||||
userId: fixture.userId,
|
||||
workspaceId: fixture.workspaceId,
|
||||
includeArchived: true,
|
||||
limit: 100,
|
||||
});
|
||||
assert(tables.some((row) => row.id === stage.tableId && row.deleted_at), `${stage.name}: table ${stage.tableId} 未进入垃圾箱`);
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanup(context, request, fixture) {
|
||||
if (!fixture?.workspaceId) return;
|
||||
await requestJson(request, "/api/documents/empty-trash", { method: "POST", data: { workspaceId: fixture.workspaceId } }).catch(() => null);
|
||||
await requestJson(request, "/api/media/empty-trash", { method: "POST", data: { workspaceId: fixture.workspaceId } }).catch(() => null);
|
||||
await requestJson(request, "/api/mindmap-trash/empty", { method: "POST", data: { workspaceId: fixture.workspaceId } }).catch(() => null);
|
||||
await requestJson(request, "/api/tables/empty-trash", { method: "POST", data: { workspaceId: fixture.workspaceId } }).catch(() => null);
|
||||
if (fixture.resourceDocId) {
|
||||
if (fixture.failureAssetId) {
|
||||
await requestJson(request, "/api/media/batch", {
|
||||
method: "POST",
|
||||
data: { action: "delete", assetIds: [fixture.failureAssetId] },
|
||||
}).catch(() => null);
|
||||
await requestJson(request, "/api/media/empty-trash", { method: "POST", data: { workspaceId: fixture.workspaceId } }).catch(() => null);
|
||||
}
|
||||
await requestJson(request, "/api/documents/purge", { method: "POST", data: { documentId: fixture.resourceDocId } }).catch(() => null);
|
||||
}
|
||||
await convexCall(context, "query", "users:currentUser", {}).catch(() => null);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const stamp = Date.now();
|
||||
const result = {
|
||||
ok: false,
|
||||
task: TASK,
|
||||
baseUrl: BASE_URL,
|
||||
convexUrl: CONVEX_URL,
|
||||
email: `mnote.filetree.${stamp}@example.com`,
|
||||
requests: [],
|
||||
confirms: [],
|
||||
fixture: null,
|
||||
};
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
const request = context.request;
|
||||
let fixture = null;
|
||||
|
||||
page.on("request", (req) => {
|
||||
const url = req.url();
|
||||
if (
|
||||
url.includes("/api/tree/filetree/delete-preflight") ||
|
||||
url.includes("/api/tree/commands") ||
|
||||
url.includes("/api/media/batch") ||
|
||||
url.includes("/api/mindmap/") ||
|
||||
url.includes("/api/tables/")
|
||||
) {
|
||||
result.requests.push({
|
||||
method: req.method(),
|
||||
url,
|
||||
body: req.postData() || null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await requestJson(request, "/api/auth", {
|
||||
method: "POST",
|
||||
data: {
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: { email: result.email, password: e2ePassword(), flow: "signUp", name: `filetree-${stamp}` },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`${BASE_URL}/`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
|
||||
fixture = await seedFixture(context, request, stamp);
|
||||
result.fixture = fixture;
|
||||
|
||||
await openDocumentFileTree(page, fixture.workspaceId, fixture.resourceDocId);
|
||||
for (const documentId of [fixture.rootId, fixture.siblingId, fixture.failureDocId, fixture.resourceDocId]) {
|
||||
await waitForRow(page, docRowSelector(documentId), `页面 ${documentId}`);
|
||||
}
|
||||
await expandDocIfNeeded(page, fixture.rootId);
|
||||
await waitForRow(page, docRowSelector(fixture.childId), `子页面 ${fixture.childId}`);
|
||||
await expandDocIfNeeded(page, fixture.resourceDocId);
|
||||
for (const assetId of [fixture.fileAssetA, fixture.fileAssetB, fixture.failureAssetId, fixture.mindmapId, fixture.tableId]) {
|
||||
await waitForRow(page, assetRowSelector(assetId), `资源 ${assetId}`);
|
||||
}
|
||||
|
||||
const accel = process.platform === "darwin" ? "Meta" : "Control";
|
||||
await clickRow(page, docRowSelector(fixture.rootId));
|
||||
await clickRow(page, assetRowSelector(fixture.fileAssetA), { modifiers: [accel] });
|
||||
let selected = await selectedRows(page);
|
||||
assert(selected.length >= 2, `Ctrl/Cmd 多选后选区数量异常: ${JSON.stringify(selected)}`);
|
||||
|
||||
await clickRow(page, docRowSelector(fixture.rootId));
|
||||
await clickRow(page, docRowSelector(fixture.childId), { modifiers: ["Shift"] });
|
||||
selected = await selectedRows(page);
|
||||
assert(selected.length >= 2, `Shift 范围选择后选区数量异常: ${JSON.stringify(selected)}`);
|
||||
|
||||
await clickRow(page, docRowSelector(fixture.rootId));
|
||||
await clickRow(page, assetRowSelector(fixture.fileAssetA), { modifiers: [accel] });
|
||||
const beforeContextSelected = await selectedRows(page);
|
||||
await rightClickRow(page, assetRowSelector(fixture.fileAssetA));
|
||||
const afterContextSelected = await selectedRows(page);
|
||||
assert(
|
||||
afterContextSelected.length === beforeContextSelected.length,
|
||||
`右键已选项不应清空多选: before=${JSON.stringify(beforeContextSelected)} after=${JSON.stringify(afterContextSelected)}`,
|
||||
);
|
||||
await closeContextMenu(page);
|
||||
await rightClickRow(page, assetRowSelector(fixture.fileAssetB));
|
||||
selected = await selectedRows(page);
|
||||
assert(
|
||||
selected.length === 1 && selected[0].assetId === fixture.fileAssetB,
|
||||
`右键未选项应切换 action target: ${JSON.stringify(selected)}`,
|
||||
);
|
||||
await closeContextMenu(page);
|
||||
|
||||
await clickRow(page, docRowSelector(fixture.rootId));
|
||||
await clickRow(page, docRowSelector(fixture.childId), { modifiers: [accel] });
|
||||
await clickRow(page, assetRowSelector(fixture.fileAssetA), { modifiers: [accel] });
|
||||
await clickRow(page, assetRowSelector(fixture.mindmapId), { modifiers: [accel] });
|
||||
await clickRow(page, assetRowSelector(fixture.tableId), { modifiers: [accel] });
|
||||
selected = await selectedRows(page);
|
||||
assert(selected.length >= 5, `Delete 前混合选区数量异常: ${JSON.stringify(selected)}`);
|
||||
const deleteConfirm = await deleteSelectionAndCaptureConfirm(page, "Delete", assetRowSelector(fixture.tableId));
|
||||
result.confirms.push({ key: "Delete", message: deleteConfirm });
|
||||
assert(deleteConfirm.includes("1 个页面"), `Delete 确认文案缺少页面计数: ${deleteConfirm}`);
|
||||
assert(deleteConfirm.includes("1 个附件"), `Delete 确认文案缺少附件计数: ${deleteConfirm}`);
|
||||
assert(deleteConfirm.includes("1 个思维导图"), `Delete 确认文案缺少思维导图计数: ${deleteConfirm}`);
|
||||
assert(deleteConfirm.includes("1 个在线表格"), `Delete 确认文案缺少在线表格计数: ${deleteConfirm}`);
|
||||
|
||||
await expectDetached(page, docRowSelector(fixture.rootId), "root 页面");
|
||||
await expectDetached(page, docRowSelector(fixture.childId), "child 页面");
|
||||
await expectDetached(page, assetRowSelector(fixture.fileAssetA), "普通附件 A");
|
||||
await expectDetached(page, assetRowSelector(fixture.mindmapId), "mindmap");
|
||||
await expectDetached(page, assetRowSelector(fixture.tableId), "table");
|
||||
await assertTrashState(context, fixture, {
|
||||
name: "Delete",
|
||||
documentIds: [fixture.rootId, fixture.childId],
|
||||
fileAssetIds: [fixture.fileAssetA],
|
||||
mindmapId: fixture.mindmapId,
|
||||
tableId: fixture.tableId,
|
||||
});
|
||||
|
||||
await clickRow(page, docRowSelector(fixture.siblingId));
|
||||
await clickRow(page, assetRowSelector(fixture.fileAssetB), { modifiers: [accel] });
|
||||
selected = await selectedRows(page);
|
||||
assert(selected.length >= 2, `Backspace 前混合选区数量异常: ${JSON.stringify(selected)}`);
|
||||
const backspaceConfirm = await deleteSelectionAndCaptureConfirm(page, "Backspace", assetRowSelector(fixture.fileAssetB));
|
||||
result.confirms.push({ key: "Backspace", message: backspaceConfirm });
|
||||
assert(backspaceConfirm.includes("1 个页面"), `Backspace 确认文案缺少页面计数: ${backspaceConfirm}`);
|
||||
assert(backspaceConfirm.includes("1 个附件"), `Backspace 确认文案缺少附件计数: ${backspaceConfirm}`);
|
||||
await expectDetached(page, docRowSelector(fixture.siblingId), "sibling 页面");
|
||||
await expectDetached(page, assetRowSelector(fixture.fileAssetB), "普通附件 B");
|
||||
await assertTrashState(context, fixture, {
|
||||
name: "Backspace",
|
||||
documentIds: [fixture.siblingId],
|
||||
fileAssetIds: [fixture.fileAssetB],
|
||||
});
|
||||
|
||||
await clickRow(page, docRowSelector(fixture.failureDocId));
|
||||
await clickRow(page, assetRowSelector(fixture.failureAssetId), { modifiers: [accel] });
|
||||
let mediaBatchFailed = false;
|
||||
await page.route("**/api/media/batch", async (route) => {
|
||||
const postData = route.request().postData() || "";
|
||||
if (postData.includes(fixture.failureAssetId)) {
|
||||
mediaBatchFailed = true;
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "task428 forced media failure" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
const failureDialogs = await deleteSelectionAndCaptureConfirmAndAlert(page, "Delete", assetRowSelector(fixture.failureAssetId));
|
||||
await page.unroute("**/api/media/batch").catch(() => undefined);
|
||||
result.confirms.push({ key: "Delete-partial", message: failureDialogs.confirm });
|
||||
result.partialFailureAlert = failureDialogs.alert;
|
||||
assert(mediaBatchFailed, "部分失败场景未拦截到 media batch 请求");
|
||||
assert(failureDialogs.alert.includes("部分对象删除失败"), `失败摘要文案不正确: ${failureDialogs.alert}`);
|
||||
await expectDetached(page, docRowSelector(fixture.failureDocId), "partial failure 页面");
|
||||
await waitForRow(page, assetRowSelector(fixture.failureAssetId), "partial failure 附件应保留");
|
||||
await assertTrashState(context, fixture, {
|
||||
name: "PartialFailure",
|
||||
documentIds: [fixture.failureDocId],
|
||||
});
|
||||
const failureAsset = await convexCall(context, "query", "mediaAssets:getById", {
|
||||
userId: fixture.userId,
|
||||
id: fixture.failureAssetId,
|
||||
});
|
||||
assert(failureAsset && !failureAsset.deleted_at, "部分失败后附件不应被错误移入垃圾箱");
|
||||
|
||||
result.ok = true;
|
||||
await writeResult(result);
|
||||
} catch (error) {
|
||||
result.error = error instanceof Error ? error.stack || error.message : String(error);
|
||||
result.selectedRows = await selectedRows(page).catch(() => []);
|
||||
result.filetreeText = await page.locator("#sidebar-file-tree-root").innerText({ timeout: 3_000 }).catch(() => "");
|
||||
await writeResult(result);
|
||||
throw error;
|
||||
} finally {
|
||||
await cleanup(context, request, fixture).catch((error) => {
|
||||
console.warn(`清理 task428 临时数据失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,323 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const TASK = "task429-trash-restore-location-reveal-smoke";
|
||||
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const CONVEX_URL = (process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_SELF_HOSTED_URL || "http://127.0.0.1:3210").replace(/\/+$/, "");
|
||||
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
|
||||
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");
|
||||
|
||||
async function writeResult(payload) {
|
||||
await fs.mkdir(OUTPUT_DIR, { recursive: true });
|
||||
await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
async function requestJson(request, requestPath, init = {}) {
|
||||
const response = await request.fetch(`${BASE_URL}${requestPath}`, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
|
||||
...(init.headers || {}),
|
||||
},
|
||||
timeout: 20_000,
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
payload = text;
|
||||
}
|
||||
if (!response.ok()) {
|
||||
throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function convexCall(context, kind, convexPath, args) {
|
||||
const cookies = await context.cookies(BASE_URL);
|
||||
const jwt = cookies.find((cookie) => cookie.name === "__convexAuthJWT")?.value;
|
||||
assert(jwt, "缺少 __convexAuthJWT cookie");
|
||||
const response = await fetch(`${CONVEX_URL}/api/${kind}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${jwt}`,
|
||||
"content-type": "application/json",
|
||||
"Convex-Client": TASK,
|
||||
},
|
||||
body: JSON.stringify({ path: convexPath, format: "convex_encoded_json", args: [args] }),
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok || body.status !== "success") {
|
||||
throw new Error(`Convex ${kind} ${convexPath} 失败: ${response.status} ${JSON.stringify(body)}`);
|
||||
}
|
||||
return body.value;
|
||||
}
|
||||
|
||||
async function createPage(request, workspaceId, title, parentId = null) {
|
||||
const payload = await requestJson(request, "/api/tree/commands", {
|
||||
method: "POST",
|
||||
data: {
|
||||
action: "create",
|
||||
workspaceId,
|
||||
parentId,
|
||||
title,
|
||||
},
|
||||
});
|
||||
const result = payload.result || payload;
|
||||
assert(result.documentId, `创建页面失败: ${JSON.stringify(payload)}`);
|
||||
return result.documentId;
|
||||
}
|
||||
|
||||
async function postTreeCommand(request, data) {
|
||||
return await requestJson(request, "/api/tree/commands", {
|
||||
method: "POST",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
function pageTreeRowSelector(documentId) {
|
||||
const escaped = String(documentId).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
return `[data-testid="page-tree-row"][data-node-id="${escaped}"], [data-testid="wolai-sidebar-row"][data-node-id="${escaped}"], #sidebar-file-tree-root .tree-row[data-document-id="${escaped}"], #sidebar-file-tree-root .tree-row[data-doc-id="${escaped}"]`;
|
||||
}
|
||||
|
||||
async function waitForPageTreeRow(page, documentId, label) {
|
||||
await page.locator(pageTreeRowSelector(documentId)).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }).catch(async (error) => {
|
||||
const text = await page.locator('[data-testid="sidebar-page-tree-shell"], #sidebar-file-tree-root, [data-testid="wolai-sidebar"]').innerText({ timeout: 3_000 }).catch(() => "");
|
||||
throw new Error(`${label} 未出现在侧边栏树: ${error.message}; tree=${text.slice(0, 1600)}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function openDocumentFileTree(page, workspaceId, documentId) {
|
||||
await page.goto(`${BASE_URL}/documents/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, {
|
||||
waitUntil: "commit",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const fileRoot = document.getElementById("sidebar-file-tree-root");
|
||||
const fileTab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
|
||||
return Boolean(fileRoot || fileTab);
|
||||
},
|
||||
undefined,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
const fileRoot = document.getElementById("sidebar-file-tree-root");
|
||||
const visible = (node) =>
|
||||
node instanceof HTMLElement &&
|
||||
!node.hidden &&
|
||||
getComputedStyle(node).display !== "none" &&
|
||||
getComputedStyle(node).visibility !== "hidden" &&
|
||||
node.getClientRects().length > 0;
|
||||
if (visible(fileRoot)) return;
|
||||
const tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
|
||||
if (tab instanceof HTMLElement) tab.click();
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const fileRoot = document.getElementById("sidebar-file-tree-root");
|
||||
return fileRoot instanceof HTMLElement && fileRoot.getClientRects().length > 0;
|
||||
},
|
||||
undefined,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function clickTrashButton(page) {
|
||||
if (await page.getByRole("heading", { name: "垃圾桶" }).isVisible().catch(() => false)) {
|
||||
return;
|
||||
}
|
||||
await page.getByRole("button", { name: /^垃圾桶\b/ }).first().click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.getByRole("heading", { name: "垃圾桶" }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
async function restoreTrashItemWithDialogs(page, title, expectFallbackAlert = false) {
|
||||
const confirmPromise = page.waitForEvent("dialog", { timeout: UI_TIMEOUT_MS }).then(async (dialog) => {
|
||||
const message = dialog.message();
|
||||
await dialog.accept();
|
||||
return message;
|
||||
});
|
||||
const clicked = await page.evaluate((targetTitle) => {
|
||||
const rows = Array.from(document.querySelectorAll("div"));
|
||||
for (const row of rows) {
|
||||
if (!row.textContent?.includes(targetTitle)) continue;
|
||||
const button = Array.from(row.querySelectorAll("button")).find((candidate) => candidate.textContent?.trim() === "恢复");
|
||||
if (button instanceof HTMLButtonElement) {
|
||||
button.click();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, title);
|
||||
assert(clicked, `未找到垃圾桶恢复按钮: ${title}`);
|
||||
const dialogs = [await confirmPromise];
|
||||
if (expectFallbackAlert) {
|
||||
const alertMessage = await page.waitForEvent("dialog", { timeout: UI_TIMEOUT_MS }).then(async (dialog) => {
|
||||
const message = dialog.message();
|
||||
await dialog.accept();
|
||||
return message;
|
||||
});
|
||||
dialogs.push(alertMessage);
|
||||
assert(dialogs.some((message) => message.includes("原父页面已不存在")), `缺少 fallback 提示: ${JSON.stringify(dialogs)}`);
|
||||
}
|
||||
return dialogs;
|
||||
}
|
||||
|
||||
async function listDocuments(context, workspaceId) {
|
||||
return await convexCall(context, "query", "documents:listByWorkspace", { workspaceId });
|
||||
}
|
||||
|
||||
async function cleanup(request, workspaceId, rootId, orphanId) {
|
||||
if (!workspaceId) return;
|
||||
if (orphanId) {
|
||||
await postTreeCommand(request, { action: "archive", workspaceId, documentId: orphanId }).catch(() => null);
|
||||
}
|
||||
if (rootId) {
|
||||
await postTreeCommand(request, { action: "archive", workspaceId, documentId: rootId }).catch(() => null);
|
||||
}
|
||||
await requestJson(request, "/api/documents/empty-trash", { method: "POST", data: { workspaceId } }).catch(() => null);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const stamp = Date.now();
|
||||
const email = `mnote.restore.${stamp}@example.com`;
|
||||
const username = `restore-${stamp}`;
|
||||
const parentTitle = `TEST-10REVIEW-07-P6-parent-${stamp}`;
|
||||
const childTitle = `TEST-10REVIEW-07-P6-child-${stamp}`;
|
||||
const siblingTitle = `TEST-10REVIEW-07-P6-sibling-${stamp}`;
|
||||
const orphanTitle = `TEST-10REVIEW-07-P6-orphan-${stamp}`;
|
||||
const orphanId = `tree_restore_orphan_${stamp}`;
|
||||
const missingParentId = `tree_missing_parent_${stamp}`;
|
||||
const result = {
|
||||
ok: false,
|
||||
task: TASK,
|
||||
baseUrl: BASE_URL,
|
||||
convexUrl: CONVEX_URL,
|
||||
email,
|
||||
dialogs: [],
|
||||
};
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
const request = context.request;
|
||||
let workspaceId = null;
|
||||
let parentId = null;
|
||||
|
||||
try {
|
||||
await requestJson(request, "/api/auth", {
|
||||
method: "POST",
|
||||
data: {
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: { email, password: e2ePassword(), flow: "signUp", name: username },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`${BASE_URL}/`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
|
||||
const currentUser = await convexCall(context, "query", "users:currentUser", {});
|
||||
const userId = currentUser?._id;
|
||||
assert(userId, "缺少 Convex currentUser._id");
|
||||
const workspaces = await convexCall(context, "query", "workspaces:fetchWorkspaceSummaries", {});
|
||||
workspaceId = workspaces.activeWorkspaceId;
|
||||
assert(workspaceId, "缺少隔离 workspaceId");
|
||||
|
||||
parentId = await createPage(request, workspaceId, parentTitle);
|
||||
const childId = await createPage(request, workspaceId, childTitle, parentId);
|
||||
const siblingId = await createPage(request, workspaceId, siblingTitle, parentId);
|
||||
await convexCall(context, "mutation", "documents:create", {
|
||||
id: orphanId,
|
||||
workspaceId,
|
||||
parentId: missingParentId,
|
||||
title: orphanTitle,
|
||||
accessScope: "private",
|
||||
content: [],
|
||||
});
|
||||
|
||||
await page.goto(`${BASE_URL}/documents/${encodeURIComponent(parentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, {
|
||||
waitUntil: "commit",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await waitForPageTreeRow(page, parentId, "父页面");
|
||||
await waitForPageTreeRow(page, childId, "子页面");
|
||||
await waitForPageTreeRow(page, siblingId, "兄弟页面");
|
||||
|
||||
await postTreeCommand(request, { action: "archive", workspaceId, documentId: childId });
|
||||
await page.locator(pageTreeRowSelector(childId)).first().waitFor({ state: "detached", timeout: UI_TIMEOUT_MS });
|
||||
await postTreeCommand(request, { action: "archive", workspaceId, documentId: orphanId });
|
||||
|
||||
const restorePayload = await postTreeCommand(request, { action: "restore", workspaceId, documentId: childId });
|
||||
result.restorePayload = restorePayload.result || restorePayload;
|
||||
await waitForPageTreeRow(page, childId, "恢复后的子页面");
|
||||
await page.waitForFunction(
|
||||
(documentId) => {
|
||||
const row = document.querySelector(`[data-testid="page-tree-row"][data-node-id="${documentId}"], [data-testid="wolai-sidebar-row"][data-node-id="${documentId}"], #sidebar-file-tree-root .tree-row[data-document-id="${documentId}"], #sidebar-file-tree-root .tree-row[data-doc-id="${documentId}"]`);
|
||||
return row instanceof HTMLElement && row.getClientRects().length > 0;
|
||||
},
|
||||
childId,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
let docs = await listDocuments(context, workspaceId);
|
||||
const childDoc = docs.find((doc) => doc.id === childId);
|
||||
const siblingDoc = docs.find((doc) => doc.id === siblingId);
|
||||
assert.equal(childDoc?.parent_id, parentId, "恢复后的子页面应回到原父页面");
|
||||
assert.equal(childDoc?.sort_order, 0, "恢复后的子页面应回到原排序位");
|
||||
assert.equal(siblingDoc?.sort_order, 1, "原排序位被恢复页面占用时,兄弟页面应后移");
|
||||
|
||||
const fallbackPayload = await postTreeCommand(request, { action: "restore", workspaceId, documentId: orphanId });
|
||||
result.fallbackPayload = fallbackPayload.result || fallbackPayload;
|
||||
assert.equal(
|
||||
result.fallbackPayload?.execution?.restore_location?.fallback_reason,
|
||||
"parent_missing_or_deleted",
|
||||
`fallback restore 应返回 parent_missing_or_deleted: ${JSON.stringify(result.fallbackPayload)}`,
|
||||
);
|
||||
await waitForPageTreeRow(page, orphanId, "fallback 恢复后的孤儿页面");
|
||||
docs = await listDocuments(context, workspaceId);
|
||||
const orphanDoc = docs.find((doc) => doc.id === orphanId);
|
||||
assert.equal(orphanDoc?.parent_id ?? null, null, "原父页面不存在时应恢复到根目录");
|
||||
|
||||
const order = await page.evaluate(
|
||||
({ child, sibling }) =>
|
||||
Array.from(document.querySelectorAll('[data-testid="page-tree-row"], [data-testid="wolai-sidebar-row"], #sidebar-file-tree-root .tree-row'))
|
||||
.map((row) => row instanceof HTMLElement ? row.dataset.nodeId : "")
|
||||
.filter((id) => id === child || id === sibling)
|
||||
.filter((id, index, list) => list.indexOf(id) === index),
|
||||
{ child: childId, sibling: siblingId },
|
||||
);
|
||||
assert.deepEqual(order, [childId, siblingId], `页面树顺序应为 child -> sibling: ${JSON.stringify(order)}`);
|
||||
|
||||
result.ok = true;
|
||||
result.workspaceId = workspaceId;
|
||||
result.parentId = parentId;
|
||||
result.childId = childId;
|
||||
result.siblingId = siblingId;
|
||||
result.orphanId = orphanId;
|
||||
await writeResult(result);
|
||||
} catch (error) {
|
||||
result.error = error instanceof Error ? error.stack || error.message : String(error);
|
||||
result.pageTreeText = await page.locator('[data-testid="sidebar-page-tree-shell"], #sidebar-file-tree-root, [data-testid="wolai-sidebar"]').innerText({ timeout: 3_000 }).catch(() => "");
|
||||
await writeResult(result);
|
||||
throw error;
|
||||
} finally {
|
||||
await cleanup(request, workspaceId, parentId, orphanId).catch((error) => {
|
||||
console.warn(`清理 task429 临时数据失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,597 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const TASK = "task430-vscode-explorer-stage7-smoke";
|
||||
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const CONVEX_URL = (process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_SELF_HOSTED_URL || "http://127.0.0.1:3210").replace(/\/+$/, "");
|
||||
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
|
||||
// 说明:该 smoke 覆盖 convex-source Explorer 兼容路径;local-first 默认主链另有 local workspace smoke。
|
||||
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");
|
||||
|
||||
async function writeResult(payload) {
|
||||
await fs.mkdir(OUTPUT_DIR, { recursive: true });
|
||||
await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
async function requestJson(request, requestPath, init = {}) {
|
||||
const response = await request.fetch(`${BASE_URL}${requestPath}`, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
|
||||
...(init.headers || {}),
|
||||
},
|
||||
timeout: 20_000,
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
payload = text;
|
||||
}
|
||||
if (!response.ok()) {
|
||||
throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function requestJsonAllowError(request, requestPath, init = {}) {
|
||||
let response;
|
||||
try {
|
||||
response = await request.fetch(`${BASE_URL}${requestPath}`, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
|
||||
...(init.headers || {}),
|
||||
},
|
||||
timeout: 20_000,
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
payload: {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
};
|
||||
}
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
payload = text;
|
||||
}
|
||||
return { ok: response.ok(), status: response.status(), payload };
|
||||
}
|
||||
|
||||
async function convexCall(context, kind, convexPath, args) {
|
||||
const cookies = await context.cookies(BASE_URL);
|
||||
const jwt = cookies.find((cookie) => cookie.name === "__convexAuthJWT")?.value;
|
||||
assert(jwt, "缺少 __convexAuthJWT cookie");
|
||||
const response = await fetch(`${CONVEX_URL}/api/${kind}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${jwt}`,
|
||||
"content-type": "application/json",
|
||||
"Convex-Client": TASK,
|
||||
},
|
||||
body: JSON.stringify({ path: convexPath, format: "convex_encoded_json", args: [args] }),
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok || body.status !== "success") {
|
||||
throw new Error(`Convex ${kind} ${convexPath} 失败: ${response.status} ${JSON.stringify(body)}`);
|
||||
}
|
||||
return body.value;
|
||||
}
|
||||
|
||||
function cssEscape(value) {
|
||||
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
function docRowSelector(documentId) {
|
||||
return `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="doc:${cssEscape(documentId)}"]`;
|
||||
}
|
||||
|
||||
function assetRowSelector(assetId) {
|
||||
return `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-asset-id="${cssEscape(assetId)}"]`;
|
||||
}
|
||||
|
||||
async function createPage(request, workspaceId, title, parentId = null) {
|
||||
const payload = await requestJson(request, "/api/tree/commands", {
|
||||
method: "POST",
|
||||
data: {
|
||||
action: "create",
|
||||
workspaceId,
|
||||
parentId,
|
||||
title,
|
||||
},
|
||||
});
|
||||
const result = payload.result || payload;
|
||||
assert(result.documentId, `创建页面失败: ${JSON.stringify(payload)}`);
|
||||
return result.documentId;
|
||||
}
|
||||
|
||||
async function openDocumentFileTree(page, workspaceId, documentId) {
|
||||
await page.goto(`${BASE_URL}/documents/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, {
|
||||
waitUntil: "commit",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const fileRoot = document.getElementById("sidebar-file-tree-root");
|
||||
const fileTab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
|
||||
return Boolean(fileRoot || fileTab);
|
||||
},
|
||||
undefined,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
const fileRoot = document.getElementById("sidebar-file-tree-root");
|
||||
const visible = (node) =>
|
||||
node instanceof HTMLElement &&
|
||||
!node.hidden &&
|
||||
getComputedStyle(node).display !== "none" &&
|
||||
getComputedStyle(node).visibility !== "hidden" &&
|
||||
node.getClientRects().length > 0;
|
||||
if (visible(fileRoot)) return;
|
||||
const tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
|
||||
if (tab instanceof HTMLElement) tab.click();
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const fileRoot = document.getElementById("sidebar-file-tree-root");
|
||||
return fileRoot instanceof HTMLElement && fileRoot.getClientRects().length > 0;
|
||||
},
|
||||
undefined,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForRow(page, selector, label) {
|
||||
await page.locator(selector).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }).catch(async (error) => {
|
||||
const text = await page.locator("#sidebar-file-tree-root").innerText({ timeout: 3_000 }).catch(() => "");
|
||||
throw new Error(`${label} 未出现: ${error.message}; filetree=${text.slice(0, 2000)}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function expandDocIfNeeded(page, documentId) {
|
||||
const selector = docRowSelector(documentId);
|
||||
await waitForRow(page, selector, `页面 ${documentId}`);
|
||||
const expanded = await page.locator(selector).first().getAttribute("aria-expanded").catch(() => null);
|
||||
if (expanded === "true") return;
|
||||
const clicked = await page.locator(`${selector} [data-testid="filetree-toggle"]`).first().click({ timeout: 2_000 }).then(() => true).catch(() => false);
|
||||
if (!clicked) {
|
||||
await page.locator(selector).first().dblclick({ timeout: 2_000 }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function readDocument(context, workspaceId, documentId) {
|
||||
const docs = await convexCall(context, "query", "documents:listByWorkspace", { workspaceId });
|
||||
return docs.find((doc) => doc.id === documentId) || null;
|
||||
}
|
||||
|
||||
async function waitForAssetFileName(context, userId, assetId, fileName) {
|
||||
const deadline = Date.now() + UI_TIMEOUT_MS;
|
||||
let lastAsset = null;
|
||||
while (Date.now() < deadline) {
|
||||
lastAsset = await convexCall(context, "query", "mediaAssets:getById", { userId, id: assetId });
|
||||
if (lastAsset?.file_name === fileName) {
|
||||
return lastAsset;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
assert.equal(lastAsset?.file_name, fileName, "F2 inline rename 附件后 Convex 文件名未更新");
|
||||
return lastAsset;
|
||||
}
|
||||
|
||||
async function renameRowWithF2(page, selector, nextTitle) {
|
||||
await page.locator(selector).first().click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(selector).first().press("F2", { timeout: UI_TIMEOUT_MS });
|
||||
const input = page.locator(`${selector} .tree-rename-input`).first();
|
||||
await input.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await input.fill(nextTitle, { timeout: UI_TIMEOUT_MS });
|
||||
await input.press("Enter", { timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(selector).first().getByText(nextTitle, { exact: false }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
async function visibleContextMenuLabels(page, selector) {
|
||||
await page.locator(selector).first().click({ button: "right", timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForTimeout(250);
|
||||
const labels = ["New File", "New Folder", "Paste Into", "Refresh", "Collapse All", "Copy Path", "Reveal"];
|
||||
const visible = [];
|
||||
for (const label of labels) {
|
||||
if (await page.getByText(label, { exact: true }).first().isVisible().catch(() => false)) {
|
||||
visible.push(label);
|
||||
}
|
||||
}
|
||||
const disabledTitles = await page.evaluate(() =>
|
||||
Array.from(document.querySelectorAll("button[disabled][title]"))
|
||||
.map((button) => button instanceof HTMLButtonElement ? button.title : "")
|
||||
.filter(Boolean),
|
||||
);
|
||||
await page.keyboard.press("Escape").catch(() => undefined);
|
||||
return { visible, disabledTitles };
|
||||
}
|
||||
|
||||
async function runDropPreflight(request, payload) {
|
||||
return await requestJsonAllowError(request, "/api/tree/filetree/drop-preflight", {
|
||||
method: "POST",
|
||||
data: payload,
|
||||
});
|
||||
}
|
||||
|
||||
function preflightRow({ rowId, rowKind, documentId, assetId = null, assetDocumentId = null, assetType = null, storagePath = null }) {
|
||||
return { rowId, rowKind, documentId, assetId, assetDocumentId, assetType, storagePath };
|
||||
}
|
||||
|
||||
async function cleanup(request, workspaceId, ids) {
|
||||
if (!workspaceId) return;
|
||||
for (const documentId of ids.filter(Boolean)) {
|
||||
await requestJson(request, "/api/tree/commands", {
|
||||
method: "POST",
|
||||
data: { action: "archive", workspaceId, documentId },
|
||||
}).catch(() => null);
|
||||
}
|
||||
await requestJson(request, "/api/documents/empty-trash", { method: "POST", data: { workspaceId } }).catch(() => null);
|
||||
await requestJson(request, "/api/media/empty-trash", { method: "POST", data: { workspaceId } }).catch(() => null);
|
||||
}
|
||||
|
||||
async function runOptional(result, area, fn) {
|
||||
try {
|
||||
const details = await fn();
|
||||
result.checks.push({ area, ok: true, details });
|
||||
return details;
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
result.skipped.push({ area, reason });
|
||||
result.checks.push({ area, ok: false, skipped: true, reason });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const stamp = Date.now();
|
||||
const prefix = `TEST-10REVIEW-07-P7-${stamp}`;
|
||||
const result = {
|
||||
ok: false,
|
||||
task: TASK,
|
||||
baseUrl: BASE_URL,
|
||||
convexUrl: CONVEX_URL,
|
||||
email: `mnote.stage7.${stamp}@example.com`,
|
||||
prefix,
|
||||
requests: [],
|
||||
checks: [],
|
||||
skipped: [],
|
||||
phases: [],
|
||||
};
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
const request = context.request;
|
||||
let workspaceId = null;
|
||||
const cleanupDocIds = [];
|
||||
|
||||
page.on("request", (req) => {
|
||||
const url = req.url();
|
||||
if (
|
||||
url.includes("/api/tree/commands") ||
|
||||
url.includes("/api/tree/filetree/drop-preflight") ||
|
||||
url.includes("/api/media/batch")
|
||||
) {
|
||||
result.requests.push({
|
||||
method: req.method(),
|
||||
url,
|
||||
body: req.postData() || null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
// 阶段:auth - signIn
|
||||
result.phases.push({ name: "auth-signin", ok: false });
|
||||
await requestJson(request, "/api/auth", {
|
||||
method: "POST",
|
||||
data: {
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: { email: result.email, password: e2ePassword(), flow: "signUp", name: `stage7-${stamp}` },
|
||||
},
|
||||
},
|
||||
});
|
||||
result.phases[result.phases.length - 1].ok = true;
|
||||
|
||||
// 阶段:auth - cookie 初始化(导航到首页以设置 cookie)
|
||||
result.phases.push({ name: "auth-navigate", ok: false });
|
||||
await page.goto(`${BASE_URL}/`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
|
||||
result.phases[result.phases.length - 1].ok = true;
|
||||
|
||||
// 阶段:workspace bootstrap
|
||||
result.phases.push({ name: "workspace-bootstrap", ok: false });
|
||||
const currentUser = await convexCall(context, "query", "users:currentUser", {});
|
||||
const userId = currentUser?._id;
|
||||
assert(userId, "[env] Convex 不可用: users:currentUser 未返回用户 ID");
|
||||
let workspaces;
|
||||
try {
|
||||
workspaces = await convexCall(context, "query", "workspaces:fetchWorkspaceSummaries", {});
|
||||
} catch (workspaceQueryError) {
|
||||
result.skipped.push({
|
||||
area: "workspace-bootstrap",
|
||||
reason: `workspaces:fetchWorkspaceSummaries 查询失败(可能是函数未部署或 Convex 不可用),尝试通过 /api/tree/commands create 自举: ${workspaceQueryError instanceof Error ? workspaceQueryError.message : String(workspaceQueryError)}`,
|
||||
});
|
||||
}
|
||||
workspaceId = workspaces?.activeWorkspaceId;
|
||||
if (!workspaceId) {
|
||||
// Fallback: 通过 /api/tree/commands create(不带 workspaceId)触发 Rust 端 workspace bootstrap
|
||||
const bootstrapResult = await requestJson(request, "/api/tree/commands", {
|
||||
method: "POST",
|
||||
data: { action: "create", title: `${prefix}-bootstrap` },
|
||||
});
|
||||
workspaceId = bootstrapResult.workspaceId || bootstrapResult.result?.workspaceId;
|
||||
}
|
||||
assert(workspaceId, "[env] workspace bootstrap 失败:fetchWorkspaceSummaries 无返回 + /api/tree/commands create 自举也未产生 workspaceId");
|
||||
result.phases[result.phases.length - 1].ok = true;
|
||||
|
||||
const parentA = await createPage(request, workspaceId, `${prefix}-parent-a`);
|
||||
const parentB = await createPage(request, workspaceId, `${prefix}-parent-b`);
|
||||
const child = await createPage(request, workspaceId, `${prefix}-child`, parentA);
|
||||
const renameDoc = await createPage(request, workspaceId, `${prefix}-rename-doc`, parentA);
|
||||
const resourceDoc = await createPage(request, workspaceId, `${prefix}-resource-doc`);
|
||||
cleanupDocIds.push(parentA, parentB, child, renameDoc, resourceDoc);
|
||||
|
||||
const assetId = `asset_p7_${stamp}`;
|
||||
await convexCall(context, "mutation", "mediaAssets:create", {
|
||||
userId,
|
||||
asset: {
|
||||
id: assetId,
|
||||
workspace_id: workspaceId,
|
||||
document_id: resourceDoc,
|
||||
asset_type: "file",
|
||||
file_url: null,
|
||||
thumbnail_url: null,
|
||||
storage_id: null,
|
||||
bucket: null,
|
||||
storage_path: null,
|
||||
file_name: `${prefix}-file.txt`,
|
||||
file_size: 12,
|
||||
mime_type: "text/plain",
|
||||
},
|
||||
});
|
||||
|
||||
await openDocumentFileTree(page, workspaceId, resourceDoc);
|
||||
for (const documentId of [parentA, parentB, resourceDoc]) {
|
||||
await waitForRow(page, docRowSelector(documentId), `页面 ${documentId}`);
|
||||
}
|
||||
await expandDocIfNeeded(page, parentA);
|
||||
await waitForRow(page, docRowSelector(child), `子页面 ${child}`);
|
||||
await waitForRow(page, docRowSelector(renameDoc), `重命名页面 ${renameDoc}`);
|
||||
await expandDocIfNeeded(page, resourceDoc);
|
||||
await waitForRow(page, assetRowSelector(assetId), `附件 ${assetId}`);
|
||||
|
||||
// 阶段:FileTree active row 与 Open Editors 状态
|
||||
await runOptional(result, "filetree-active-row", async () => {
|
||||
const activeRowIds = await page.evaluate(() => {
|
||||
const rows = document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-active="true"]');
|
||||
return Array.from(rows).map(row => row.getAttribute('data-row-id') || '');
|
||||
});
|
||||
const expectedDocRow = `doc:${resourceDoc}`;
|
||||
if (activeRowIds.length === 0) {
|
||||
// 当前打开的资源行可能没有 data-active 标记——记录但不报错,属于已知缺口
|
||||
return { activeRowIds, note: `当前页 ${expectedDocRow} 的 FileTree row 无 data-active="true" 标记(UI 尚未同步 active 状态)` };
|
||||
}
|
||||
assert(activeRowIds.includes(expectedDocRow), `业务: FileTree active row 应为 ${expectedDocRow},实际为 [${activeRowIds.join(", ")}]`);
|
||||
return { activeRowIds, expectedDocRow };
|
||||
});
|
||||
|
||||
await runOptional(result, "filetree-selected-row", async () => {
|
||||
const selectedRowIds = await page.evaluate(() => {
|
||||
const rows = document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-selected="true"]');
|
||||
return Array.from(rows).map(row => row.getAttribute('data-row-id') || '');
|
||||
});
|
||||
// 当前只记录选中行状态,不做强制断言——选中语义可能随点击位置变化
|
||||
return { selectedRowIds };
|
||||
});
|
||||
|
||||
await runOptional(result, "open-editors-section", async () => {
|
||||
const hasOpenEditors = await page.evaluate(() => {
|
||||
return !!(document.querySelector('[data-testid="open-editors"]') ||
|
||||
document.querySelector('[data-mnote-section="open-editors"]') ||
|
||||
document.getElementById('sidebar-open-editors') ||
|
||||
document.querySelector('[data-mnote-editors-section]'));
|
||||
});
|
||||
// Open Editors section 当前尚未在 MNote sidebar 中实现(已知缺口 vs VSCode Open Editors)
|
||||
// 这里只记录状态不报错,让将来实装时可以切换为断言
|
||||
return { implemented: hasOpenEditors, note: hasOpenEditors ? "Open Editors 区域已出现" : "Open Editors 区域尚未实现(当前为已知缺口)" };
|
||||
});
|
||||
|
||||
await runOptional(result, "f2-inline-rename-doc", async () => {
|
||||
const renamedDocTitle = `${prefix}-renamed-doc`;
|
||||
await renameRowWithF2(page, docRowSelector(renameDoc), renamedDocTitle);
|
||||
const renamedDoc = await readDocument(context, workspaceId, renameDoc);
|
||||
assert.equal(renamedDoc?.title, renamedDocTitle, "F2 inline rename 页面后 Convex 标题未更新");
|
||||
return { documentId: renameDoc, title: renamedDocTitle };
|
||||
});
|
||||
|
||||
await runOptional(result, "f2-inline-rename-asset", async () => {
|
||||
const renamedAssetTitle = `${prefix}-renamed-file.txt`;
|
||||
await renameRowWithF2(page, assetRowSelector(assetId), renamedAssetTitle);
|
||||
await waitForAssetFileName(context, userId, assetId, renamedAssetTitle);
|
||||
return { assetId, fileName: renamedAssetTitle };
|
||||
});
|
||||
|
||||
const menu = await visibleContextMenuLabels(page, docRowSelector(parentA));
|
||||
result.contextMenu = menu;
|
||||
const expectedMenuLabels = ["New File", "New Folder", "Paste Into", "Refresh", "Collapse All", "Copy Path", "Reveal"];
|
||||
const missingMenuLabels = expectedMenuLabels.filter((label) => !menu.visible.includes(label));
|
||||
if (missingMenuLabels.length > 0) {
|
||||
result.skipped.push({
|
||||
area: "context-menu-minimum",
|
||||
reason: `当前真实 filetree 右键菜单未暴露这些 React Sidebar 菜单项: ${missingMenuLabels.join(", ")}`,
|
||||
});
|
||||
result.checks.push({
|
||||
area: "context-menu-minimum",
|
||||
ok: false,
|
||||
skipped: true,
|
||||
reason: `当前真实 filetree 右键菜单未暴露这些 React Sidebar 菜单项: ${missingMenuLabels.join(", ")}`,
|
||||
});
|
||||
}
|
||||
assert(
|
||||
menu.visible.length === 0 || menu.disabledTitles.some((title) => title.includes("右键 Paste Into") || title.includes("文件夹")),
|
||||
`右键菜单禁用态缺少可解释原因: ${JSON.stringify(menu)}`,
|
||||
);
|
||||
if (missingMenuLabels.length === 0) {
|
||||
result.checks.push({
|
||||
area: "context-menu-minimum",
|
||||
ok: true,
|
||||
details: menu,
|
||||
});
|
||||
}
|
||||
|
||||
const accel = process.platform === "darwin" ? "Meta" : "Control";
|
||||
await runOptional(result, "cut-paste-move", async () => {
|
||||
await page.locator(docRowSelector(child)).first().click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(docRowSelector(child)).first().press(`${accel}+X`, { timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(docRowSelector(parentB)).first().click({ timeout: UI_TIMEOUT_MS });
|
||||
const moveRequestCount = result.requests.length;
|
||||
await page.locator(docRowSelector(parentB)).first().press(`${accel}+V`, { timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
({ selector, expectedParent }) => {
|
||||
const row = document.querySelector(selector);
|
||||
return row instanceof HTMLElement && row.textContent?.includes(expectedParent);
|
||||
},
|
||||
{ selector: docRowSelector(parentB), expectedParent: `${prefix}-parent-b` },
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
).catch(() => undefined);
|
||||
await page.waitForTimeout(800);
|
||||
const movedChild = await readDocument(context, workspaceId, child);
|
||||
assert.equal(movedChild?.parent_id, parentB, "Ctrl/Cmd+X 后 Ctrl/Cmd+V 应移动页面到目标父页面");
|
||||
const moveRequests = result.requests.slice(moveRequestCount).filter((entry) => entry.body?.includes('"action":"move"'));
|
||||
assert(moveRequests.length > 0, "Cut/Paste move 未捕获到 tree move 请求");
|
||||
return { documentId: child, parentId: parentB, moveRequestCount: moveRequests.length };
|
||||
});
|
||||
|
||||
const childAfterCutPaste = await readDocument(context, workspaceId, child);
|
||||
const childParentId = childAfterCutPaste?.parent_id ?? parentA;
|
||||
|
||||
const rows = [
|
||||
preflightRow({ rowId: `doc:${parentA}`, rowKind: "doc", documentId: parentA }),
|
||||
preflightRow({ rowId: `doc:${parentB}`, rowKind: "doc", documentId: parentB }),
|
||||
preflightRow({ rowId: `doc:${child}`, rowKind: "doc", documentId: child }),
|
||||
preflightRow({ rowId: `doc:${renameDoc}`, rowKind: "doc", documentId: renameDoc }),
|
||||
preflightRow({
|
||||
rowId: `asset:${assetId}`,
|
||||
rowKind: "asset",
|
||||
documentId: resourceDoc,
|
||||
assetId,
|
||||
assetDocumentId: resourceDoc,
|
||||
assetType: "file",
|
||||
}),
|
||||
];
|
||||
const documentParents = [
|
||||
{ documentId: parentA, parentId: null },
|
||||
{ documentId: parentB, parentId: null },
|
||||
{ documentId: child, parentId: childParentId },
|
||||
{ documentId: renameDoc, parentId: parentA },
|
||||
{ documentId: resourceDoc, parentId: null },
|
||||
];
|
||||
|
||||
const selfDrop = await runDropPreflight(request, {
|
||||
workspaceId,
|
||||
copy: false,
|
||||
targetDocumentId: child,
|
||||
targetRowId: `doc:${child}`,
|
||||
focusedRowId: `doc:${child}`,
|
||||
activeDocumentId: resourceDoc,
|
||||
rowIds: [`doc:${child}`],
|
||||
rows,
|
||||
documentParents,
|
||||
});
|
||||
|
||||
const parentToChildDrop = await runDropPreflight(request, {
|
||||
workspaceId,
|
||||
copy: false,
|
||||
targetDocumentId: renameDoc,
|
||||
targetRowId: `doc:${renameDoc}`,
|
||||
focusedRowId: `doc:${renameDoc}`,
|
||||
activeDocumentId: resourceDoc,
|
||||
rowIds: [`doc:${parentA}`],
|
||||
rows,
|
||||
documentParents,
|
||||
});
|
||||
|
||||
const copyDrop = await runDropPreflight(request, {
|
||||
workspaceId,
|
||||
copy: true,
|
||||
targetDocumentId: parentB,
|
||||
targetRowId: `doc:${parentB}`,
|
||||
focusedRowId: `doc:${parentB}`,
|
||||
activeDocumentId: resourceDoc,
|
||||
rowIds: [`doc:${renameDoc}`],
|
||||
rows,
|
||||
documentParents,
|
||||
});
|
||||
if ([selfDrop, parentToChildDrop, copyDrop].some((entry) => entry.status === 0 || entry.status === 404)) {
|
||||
result.skipped.push({
|
||||
area: "dnd-preflight-guard",
|
||||
reason: "当前 3000 入口未暴露或已断开 /api/tree/filetree/drop-preflight,无法稳定自动化 DnD preflight guard;未把入口失败伪造成通过。",
|
||||
});
|
||||
result.checks.push({
|
||||
area: "dnd-preflight-guard",
|
||||
ok: false,
|
||||
skipped: true,
|
||||
reason: "当前 3000 入口未暴露或已断开 /api/tree/filetree/drop-preflight",
|
||||
});
|
||||
} else {
|
||||
assert(!selfDrop.ok, `拖到自身应被 preflight 拒绝: ${JSON.stringify(selfDrop)}`);
|
||||
assert(!parentToChildDrop.ok, `父拖子应被 preflight 拒绝: ${JSON.stringify(parentToChildDrop)}`);
|
||||
assert(copyDrop.ok && copyDrop.payload?.plan?.copy === true, `copy modifier preflight 应保留 copy=true: ${JSON.stringify(copyDrop)}`);
|
||||
result.checks.push({
|
||||
area: "dnd-preflight-guard",
|
||||
ok: true,
|
||||
details: {
|
||||
selfDropStatus: selfDrop.status,
|
||||
parentToChildDropStatus: parentToChildDrop.status,
|
||||
copyDropPlan: copyDrop.payload?.plan ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
result.dndPreflight = {
|
||||
selfDropStatus: selfDrop.status,
|
||||
parentToChildDropStatus: parentToChildDrop.status,
|
||||
copyDropPlan: copyDrop.payload?.plan ?? null,
|
||||
};
|
||||
result.skipped.push({
|
||||
area: "dnd-readonly-conflict",
|
||||
reason: "主 Sidebar Convex filetree 当前 smoke 未构造 readonly source 与真实重名冲突确认弹窗;已有 local-folder smoke 和 bridge preflight 单测覆盖,仍需后续端到端矩阵补齐。",
|
||||
});
|
||||
|
||||
result.ok = true;
|
||||
result.workspaceId = workspaceId;
|
||||
result.fixture = { parentA, parentB, child, renameDoc, resourceDoc, assetId };
|
||||
await writeResult(result);
|
||||
} catch (error) {
|
||||
result.error = error instanceof Error ? error.stack || error.message : String(error);
|
||||
result.filetreeText = await page.locator("#sidebar-file-tree-root").innerText({ timeout: 3_000 }).catch(() => "");
|
||||
await writeResult(result);
|
||||
throw error;
|
||||
} finally {
|
||||
await cleanup(request, workspaceId, cleanupDocIds).catch((error) => {
|
||||
console.warn(`清理 task430 临时数据失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,295 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const TASK = "task431-vscode-explorer-dnd-readonly-conflict-smoke";
|
||||
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const AUTH_BASE_URL = (process.env.MNOTE_AUTH_BASE_URL || BASE_URL).replace(/\/+$/, "");
|
||||
const CONVEX_URL = (process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_SELF_HOSTED_URL || "http://127.0.0.1:3210").replace(/\/+$/, "");
|
||||
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
|
||||
async function writeResult(payload) {
|
||||
await fs.mkdir(OUTPUT_DIR, { recursive: true });
|
||||
await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
async function requestJson(request, requestPath, init = {}) {
|
||||
const response = await request.fetch(`${BASE_URL}${requestPath}`, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
|
||||
...(init.headers || {}),
|
||||
},
|
||||
timeout: 20_000,
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
payload = text;
|
||||
}
|
||||
if (!response.ok()) {
|
||||
throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function requestAuthJson(request, requestPath, init = {}) {
|
||||
const response = await request.fetch(`${AUTH_BASE_URL}${requestPath}`, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
|
||||
...(init.headers || {}),
|
||||
},
|
||||
timeout: 20_000,
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
payload = text;
|
||||
}
|
||||
if (!response.ok()) {
|
||||
throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function requestJsonAllowError(request, requestPath, init = {}) {
|
||||
let response;
|
||||
try {
|
||||
response = await request.fetch(`${BASE_URL}${requestPath}`, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
|
||||
...(init.headers || {}),
|
||||
},
|
||||
timeout: 20_000,
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
payload: { error: error instanceof Error ? error.message : String(error) },
|
||||
};
|
||||
}
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
payload = text;
|
||||
}
|
||||
return { ok: response.ok(), status: response.status(), payload };
|
||||
}
|
||||
|
||||
async function convexCall(context, kind, convexPath, args) {
|
||||
const cookies = await context.cookies(BASE_URL);
|
||||
const jwt = cookies.find((cookie) => cookie.name === "__convexAuthJWT")?.value;
|
||||
assert(jwt, "缺少 __convexAuthJWT cookie");
|
||||
const response = await fetch(`${CONVEX_URL}/api/${kind}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${jwt}`,
|
||||
"content-type": "application/json",
|
||||
"Convex-Client": TASK,
|
||||
},
|
||||
body: JSON.stringify({ path: convexPath, format: "convex_encoded_json", args: [args] }),
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok || body.status !== "success") {
|
||||
throw new Error(`Convex ${kind} ${convexPath} 失败: ${response.status} ${JSON.stringify(body)}`);
|
||||
}
|
||||
return body.value;
|
||||
}
|
||||
|
||||
async function createPage(request, workspaceId, title, parentId = null) {
|
||||
const data = {
|
||||
action: "create",
|
||||
parentId,
|
||||
title,
|
||||
};
|
||||
if (workspaceId) {
|
||||
data.workspaceId = workspaceId;
|
||||
}
|
||||
const payload = await requestJson(request, "/api/tree/commands", {
|
||||
method: "POST",
|
||||
data,
|
||||
});
|
||||
const result = payload.result || payload;
|
||||
assert(result.documentId, `创建页面失败: ${JSON.stringify(payload)}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function docRow(documentId, title) {
|
||||
return {
|
||||
rowId: `doc:${documentId}`,
|
||||
rowKind: "doc",
|
||||
documentId,
|
||||
assetId: null,
|
||||
assetDocumentId: null,
|
||||
assetType: null,
|
||||
storagePath: null,
|
||||
title,
|
||||
};
|
||||
}
|
||||
|
||||
async function runDropPreflight(request, payload) {
|
||||
return await requestJsonAllowError(request, "/api/tree/filetree/drop-preflight", {
|
||||
method: "POST",
|
||||
data: payload,
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanup(request, workspaceId, ids) {
|
||||
if (!workspaceId) return;
|
||||
for (const documentId of ids.filter(Boolean)) {
|
||||
await requestJson(request, "/api/tree/commands", {
|
||||
method: "POST",
|
||||
data: { action: "archive", workspaceId, documentId },
|
||||
}).catch(() => null);
|
||||
}
|
||||
await requestJson(request, "/api/documents/empty-trash", {
|
||||
method: "POST",
|
||||
data: { workspaceId },
|
||||
}).catch(() => null);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const stamp = Date.now();
|
||||
const prefix = `TEST-10REVIEW-07-P7-DND-${stamp}`;
|
||||
const result = {
|
||||
ok: false,
|
||||
task: TASK,
|
||||
baseUrl: BASE_URL,
|
||||
authBaseUrl: AUTH_BASE_URL,
|
||||
convexUrl: CONVEX_URL,
|
||||
email: `mnote.stage7.dnd.${stamp}@example.com`,
|
||||
prefix,
|
||||
checks: [],
|
||||
};
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext();
|
||||
const request = context.request;
|
||||
let workspaceId = null;
|
||||
const cleanupDocIds = [];
|
||||
|
||||
try {
|
||||
await requestAuthJson(request, "/api/auth", {
|
||||
method: "POST",
|
||||
data: {
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: { email: result.email, password: e2ePassword(), flow: "signUp", name: `stage7-dnd-${stamp}` },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const currentUser = await convexCall(context, "query", "users:currentUser", {});
|
||||
assert(currentUser?._id, "缺少 Convex currentUser._id");
|
||||
const workspaces = await convexCall(context, "query", "workspaces:fetchWorkspaceSummaries", {});
|
||||
workspaceId = workspaces.activeWorkspaceId;
|
||||
const targetResult = await createPage(request, workspaceId, `${prefix}-target`);
|
||||
workspaceId = workspaceId || targetResult.workspaceId;
|
||||
assert(workspaceId, "缺少隔离 workspaceId");
|
||||
|
||||
const target = targetResult.documentId;
|
||||
const source = (await createPage(request, workspaceId, `${prefix}-same-name`)).documentId;
|
||||
const existing = (await createPage(request, workspaceId, `${prefix}-same-name`, target)).documentId;
|
||||
cleanupDocIds.push(target, source, existing);
|
||||
|
||||
const rows = [
|
||||
docRow(target, `${prefix}-target`),
|
||||
docRow(source, `${prefix}-same-name`),
|
||||
docRow(existing, `${prefix}-same-name`),
|
||||
];
|
||||
const documentParents = [
|
||||
{ documentId: target, parentId: null },
|
||||
{ documentId: source, parentId: null },
|
||||
{ documentId: existing, parentId: target },
|
||||
];
|
||||
const basePayload = {
|
||||
workspaceId,
|
||||
copy: false,
|
||||
sourceCapabilities: ["read", "write", "move"],
|
||||
targetCapabilities: ["read", "write", "drop"],
|
||||
targetDocumentId: target,
|
||||
targetRowId: `doc:${target}`,
|
||||
focusedRowId: `doc:${target}`,
|
||||
activeDocumentId: target,
|
||||
rowIds: [`doc:${source}`],
|
||||
rows,
|
||||
targetChildren: [
|
||||
{
|
||||
rowKind: "doc",
|
||||
documentId: existing,
|
||||
assetId: null,
|
||||
title: `${prefix}-same-name`,
|
||||
},
|
||||
],
|
||||
documentParents,
|
||||
conflictPolicy: "prompt",
|
||||
};
|
||||
|
||||
const readonlyTarget = await runDropPreflight(request, {
|
||||
...basePayload,
|
||||
targetCapabilities: ["read"],
|
||||
targetChildren: [],
|
||||
});
|
||||
assert.equal(readonlyTarget.ok, false, `readonly target 应被拒绝: ${JSON.stringify(readonlyTarget)}`);
|
||||
assert.match(JSON.stringify(readonlyTarget.payload), /只读|readonly|目标位置/);
|
||||
|
||||
const readonlySource = await runDropPreflight(request, {
|
||||
...basePayload,
|
||||
sourceCapabilities: ["read"],
|
||||
targetChildren: [],
|
||||
});
|
||||
assert.equal(readonlySource.ok, false, `readonly source 应被拒绝: ${JSON.stringify(readonlySource)}`);
|
||||
assert.match(JSON.stringify(readonlySource.payload), /只读|readonly|来源/);
|
||||
|
||||
const conflict = await runDropPreflight(request, basePayload);
|
||||
assert.equal(conflict.ok, true, `同名冲突应返回确认计划: ${JSON.stringify(conflict)}`);
|
||||
assert.equal(conflict.payload?.plan?.requiresConfirmation, true);
|
||||
assert.equal(conflict.payload?.plan?.conflicts?.[0]?.title, `${prefix}-same-name`);
|
||||
assert.equal(conflict.payload?.plan?.conflicts?.[0]?.existingDocumentId, existing);
|
||||
|
||||
const normalCopy = await runDropPreflight(request, {
|
||||
...basePayload,
|
||||
copy: true,
|
||||
sourceCapabilities: ["read"],
|
||||
targetChildren: [],
|
||||
});
|
||||
assert.equal(normalCopy.ok, true, `copy modifier 不应被 readonly source move 规则误拒绝: ${JSON.stringify(normalCopy)}`);
|
||||
assert.equal(normalCopy.payload?.plan?.copy, true);
|
||||
assert.equal(normalCopy.payload?.plan?.requiresConfirmation ?? false, false);
|
||||
|
||||
result.checks.push(
|
||||
{ area: "readonly-target", ok: true, status: readonlyTarget.status },
|
||||
{ area: "readonly-source", ok: true, status: readonlySource.status },
|
||||
{ area: "conflict-confirmation-plan", ok: true, conflict: conflict.payload.plan.conflicts[0] },
|
||||
{ area: "copy-modifier-no-false-positive", ok: true, plan: normalCopy.payload.plan },
|
||||
);
|
||||
result.workspaceId = workspaceId;
|
||||
result.fixture = { target, source, existing };
|
||||
result.ok = true;
|
||||
} catch (error) {
|
||||
result.error = error instanceof Error ? error.stack || error.message : String(error);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await cleanup(request, workspaceId, cleanupDocIds).catch((error) => {
|
||||
result.cleanupError = error instanceof Error ? error.message : String(error);
|
||||
});
|
||||
await browser.close().catch(() => undefined);
|
||||
await writeResult(result);
|
||||
}
|
||||
})();
|
||||
@@ -1,389 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const TASK = "task432-filetree-trash-page-dual-browser-no-refresh-smoke";
|
||||
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const AUTH_BASE_URL = (process.env.MNOTE_AUTH_BASE_URL || BASE_URL).replace(/\/+$/, "");
|
||||
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 35_000);
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
|
||||
async function writeResult(payload) {
|
||||
await fs.mkdir(OUTPUT_DIR, { recursive: true });
|
||||
await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
async function requestJson(request, requestPath, init = {}) {
|
||||
const response = await request.fetch(`${BASE_URL}${requestPath}`, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
|
||||
...(init.headers || {}),
|
||||
},
|
||||
timeout: 20_000,
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
payload = text;
|
||||
}
|
||||
if (!response.ok()) {
|
||||
throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function requestAuthJson(request, requestPath, init = {}) {
|
||||
const response = await request.fetch(`${AUTH_BASE_URL}${requestPath}`, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
|
||||
...(init.headers || {}),
|
||||
},
|
||||
timeout: 20_000,
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
payload = text;
|
||||
}
|
||||
if (!response.ok()) {
|
||||
throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function cssEscape(value) {
|
||||
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
function docRowSelector(documentId) {
|
||||
return `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="doc:${cssEscape(documentId)}"]`;
|
||||
}
|
||||
|
||||
function trashDocRowSelector(documentId) {
|
||||
return `[data-testid="mnote-trash-workbench"] [data-trash-row="document"][data-document-id="${cssEscape(documentId)}"]`;
|
||||
}
|
||||
|
||||
async function createPage(request, workspaceId, title, parentId = null) {
|
||||
const payload = await requestJson(request, "/api/tree/commands", {
|
||||
method: "POST",
|
||||
data: {
|
||||
action: "create",
|
||||
workspaceId,
|
||||
parentId,
|
||||
title,
|
||||
},
|
||||
});
|
||||
const result = payload.result || payload;
|
||||
const documentId = result.documentId || payload.documentId || result.id || "";
|
||||
const resolvedWorkspaceId = result.workspaceId || payload.workspaceId || payload.workspace_id || workspaceId || "";
|
||||
assert(documentId, `创建页面失败: ${JSON.stringify(payload)}`);
|
||||
assert(resolvedWorkspaceId, `创建页面缺少 workspaceId: ${JSON.stringify(payload)}`);
|
||||
return { documentId, workspaceId: resolvedWorkspaceId, payload };
|
||||
}
|
||||
|
||||
async function treeCommand(request, workspaceId, action, documentId) {
|
||||
return await requestJson(request, "/api/tree/commands", {
|
||||
method: "POST",
|
||||
data: { action, workspaceId, documentId },
|
||||
});
|
||||
}
|
||||
|
||||
async function emptyDocumentTrash(request, workspaceId) {
|
||||
return await requestJson(request, "/api/documents/empty-trash", {
|
||||
method: "POST",
|
||||
data: { workspaceId },
|
||||
});
|
||||
}
|
||||
|
||||
async function authenticate(request, email, name) {
|
||||
await requestAuthJson(request, "/api/auth", {
|
||||
method: "POST",
|
||||
data: {
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: { email, password: e2ePassword(), flow: "signUp", name },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function openDocumentFileTree(page, workspaceId, documentId) {
|
||||
await page.goto(`${BASE_URL}/documents/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, {
|
||||
waitUntil: "commit",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const fileRoot = document.getElementById("sidebar-file-tree-root");
|
||||
const fileTab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
|
||||
return Boolean(fileRoot || fileTab);
|
||||
},
|
||||
undefined,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
const visible = (node) =>
|
||||
node instanceof HTMLElement &&
|
||||
!node.hidden &&
|
||||
getComputedStyle(node).display !== "none" &&
|
||||
getComputedStyle(node).visibility !== "hidden" &&
|
||||
node.getClientRects().length > 0;
|
||||
const fileRoot = document.getElementById("sidebar-file-tree-root");
|
||||
if (visible(fileRoot)) return;
|
||||
const tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
|
||||
if (tab instanceof HTMLElement) tab.click();
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const fileRoot = document.getElementById("sidebar-file-tree-root");
|
||||
return fileRoot instanceof HTMLElement && fileRoot.getClientRects().length > 0;
|
||||
},
|
||||
undefined,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function openTrash(page, workspaceId) {
|
||||
await page.goto(`${BASE_URL}/trash?workspaceId=${encodeURIComponent(workspaceId)}`, {
|
||||
waitUntil: "commit",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.locator('[data-testid="mnote-trash-workbench"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
async function waitForVisible(page, selector, label) {
|
||||
await page.locator(selector).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }).catch(async (error) => {
|
||||
throw new Error(`${label} 未出现: ${error.message}; state=${JSON.stringify(await readPageState(page).catch(() => null))}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForDetached(page, selector, label) {
|
||||
await page.locator(selector).first().waitFor({ state: "detached", timeout: UI_TIMEOUT_MS }).catch(async (error) => {
|
||||
throw new Error(`${label} 未消失: ${error.message}; state=${JSON.stringify(await readPageState(page).catch(() => null))}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function installTreeEventRecorder(page, label, records) {
|
||||
await page.addInitScript(() => {
|
||||
window.__MNOTE_TASK432_TREE_EVENTS__ = [];
|
||||
const record = (name, event) => {
|
||||
const detail = event && event.detail ? event.detail : {};
|
||||
const payload = detail.payload || detail || {};
|
||||
window.__MNOTE_TASK432_TREE_EVENTS__.push({
|
||||
name,
|
||||
at: Date.now(),
|
||||
revision: detail.revision || payload.revision || payload.cursor || "",
|
||||
op: payload && payload.data && payload.data.op ? payload.data.op : "",
|
||||
});
|
||||
};
|
||||
window.addEventListener("tree:snapshot", (event) => record("tree:snapshot", event));
|
||||
window.addEventListener("tree:delta", (event) => record("tree:delta", event));
|
||||
window.addEventListener("tree:resync", (event) => record("tree:resync", event));
|
||||
});
|
||||
page.on("request", (request) => {
|
||||
const url = request.url();
|
||||
if (url.includes("/api/tree/events")) {
|
||||
records.push({ label, type: "request", method: request.method(), url, at: Date.now() });
|
||||
}
|
||||
});
|
||||
page.on("requestfailed", (request) => {
|
||||
const url = request.url();
|
||||
if (url.includes("/api/tree/events")) {
|
||||
records.push({
|
||||
label,
|
||||
type: "requestfailed",
|
||||
method: request.method(),
|
||||
url,
|
||||
failure: request.failure()?.errorText || "",
|
||||
at: Date.now(),
|
||||
});
|
||||
}
|
||||
});
|
||||
page.on("console", (message) => {
|
||||
const text = message.text();
|
||||
if (/tree live|EventSource|trash|error|failed/i.test(text)) {
|
||||
records.push({ label, type: "console", level: message.type(), text: text.slice(0, 2000), at: Date.now() });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function recordNavigation(page, label, records) {
|
||||
page.on("framenavigated", (frame) => {
|
||||
if (frame === page.mainFrame()) {
|
||||
records.push({ label, url: frame.url(), at: Date.now() });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function readPageState(page) {
|
||||
return await page.evaluate(() => ({
|
||||
url: window.location.href,
|
||||
liveStatus: document.documentElement.getAttribute("data-mnote-tree-live-status") || "",
|
||||
liveApplied: document.documentElement.getAttribute("data-mnote-tree-live-applied") || "",
|
||||
liveRevision: document.documentElement.getAttribute("data-mnote-tree-live-revision") || "",
|
||||
liveError: document.documentElement.getAttribute("data-mnote-tree-live-apply-error") || "",
|
||||
trashLiveReason:
|
||||
document.querySelector('[data-testid="mnote-trash-workbench"]')?.getAttribute("data-live-refresh-reason") || "",
|
||||
trashLiveAt:
|
||||
document.querySelector('[data-testid="mnote-trash-workbench"]')?.getAttribute("data-live-refresh-at") || "",
|
||||
filetreeRows: Array.from(document.querySelectorAll("#sidebar-file-tree-root .tree-row[data-shell-mode='filetree']")).map((row) => ({
|
||||
rowId: row instanceof HTMLElement ? row.dataset.rowId || "" : "",
|
||||
documentId: row instanceof HTMLElement ? row.dataset.documentId || "" : "",
|
||||
text: row.textContent || "",
|
||||
})),
|
||||
trashDocumentRows: Array.from(document.querySelectorAll('[data-testid="mnote-trash-workbench"] [data-trash-row="document"]')).map((row) => ({
|
||||
documentId: row instanceof HTMLElement ? row.dataset.documentId || "" : "",
|
||||
text: row.textContent || "",
|
||||
})),
|
||||
treeEvents: window.__MNOTE_TASK432_TREE_EVENTS__ || [],
|
||||
}));
|
||||
}
|
||||
|
||||
async function snapshotStep(result, name, fileTreePage, trashPage, navigationStart) {
|
||||
result.steps.push({
|
||||
name,
|
||||
fileTree: await readPageState(fileTreePage),
|
||||
trash: await readPageState(trashPage),
|
||||
navigationEvents: result.navigationEvents.slice(navigationStart),
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanup(request, workspaceId, documentIds) {
|
||||
if (!workspaceId) return;
|
||||
for (const documentId of documentIds.filter(Boolean)) {
|
||||
await treeCommand(request, workspaceId, "archive", documentId).catch(() => null);
|
||||
}
|
||||
await emptyDocumentTrash(request, workspaceId).catch(() => null);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const stamp = Date.now();
|
||||
const email = `mnote.stage8.page.${stamp}@example.com`;
|
||||
const prefix = `TEST-10REVIEW-07-P8-PAGE-${stamp}`;
|
||||
const result = {
|
||||
ok: false,
|
||||
task: TASK,
|
||||
baseUrl: BASE_URL,
|
||||
authBaseUrl: AUTH_BASE_URL,
|
||||
email,
|
||||
prefix,
|
||||
fixture: {},
|
||||
requests: [],
|
||||
navigationEvents: [],
|
||||
treeEventRequests: [],
|
||||
steps: [],
|
||||
};
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const contextA = await browser.newContext();
|
||||
const contextB = await browser.newContext();
|
||||
const pageA = await contextA.newPage();
|
||||
const fileTreeB = await contextB.newPage();
|
||||
const trashB = await contextB.newPage();
|
||||
const requestA = contextA.request;
|
||||
let workspaceId = "";
|
||||
const cleanupIds = [];
|
||||
|
||||
pageA.on("request", (request) => {
|
||||
const url = request.url();
|
||||
if (url.includes("/api/tree/commands") || url.includes("/api/documents/empty-trash")) {
|
||||
result.requests.push({ side: "A-page", method: request.method(), url, body: request.postData() || null, at: Date.now() });
|
||||
}
|
||||
});
|
||||
for (const requestContext of [requestA, contextB.request]) {
|
||||
await authenticate(requestContext, email, `stage8-page-${stamp}`);
|
||||
}
|
||||
await installTreeEventRecorder(fileTreeB, "B-filetree", result.treeEventRequests);
|
||||
await installTreeEventRecorder(trashB, "B-trash", result.treeEventRequests);
|
||||
recordNavigation(fileTreeB, "B-filetree", result.navigationEvents);
|
||||
recordNavigation(trashB, "B-trash", result.navigationEvents);
|
||||
|
||||
try {
|
||||
const root = await createPage(requestA, null, `${prefix}-root`);
|
||||
workspaceId = root.workspaceId;
|
||||
cleanupIds.push(root.documentId);
|
||||
result.fixture.rootId = root.documentId;
|
||||
result.fixture.workspaceId = workspaceId;
|
||||
|
||||
await pageA.goto(`${BASE_URL}/documents/${encodeURIComponent(root.documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, {
|
||||
waitUntil: "commit",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await openDocumentFileTree(fileTreeB, workspaceId, root.documentId);
|
||||
await openTrash(trashB, workspaceId);
|
||||
await waitForVisible(fileTreeB, docRowSelector(root.documentId), "B 文件树 root 页面");
|
||||
|
||||
const navigationStart = result.navigationEvents.length;
|
||||
await snapshotStep(result, "initial", fileTreeB, trashB, navigationStart);
|
||||
|
||||
const lifecycle = await createPage(requestA, workspaceId, `${prefix}-lifecycle`, root.documentId);
|
||||
cleanupIds.push(lifecycle.documentId);
|
||||
result.fixture.lifecycleId = lifecycle.documentId;
|
||||
await waitForVisible(fileTreeB, docRowSelector(lifecycle.documentId), "B 文件树 lifecycle 新建页面");
|
||||
await snapshotStep(result, "create-visible-on-b", fileTreeB, trashB, navigationStart);
|
||||
|
||||
await treeCommand(requestA, workspaceId, "archive", lifecycle.documentId);
|
||||
await waitForDetached(fileTreeB, docRowSelector(lifecycle.documentId), "B 文件树 lifecycle 删除后");
|
||||
await waitForVisible(trashB, trashDocRowSelector(lifecycle.documentId), "B 垃圾箱 lifecycle 删除后");
|
||||
await snapshotStep(result, "archive-visible-on-b", fileTreeB, trashB, navigationStart);
|
||||
|
||||
await treeCommand(requestA, workspaceId, "restore", lifecycle.documentId);
|
||||
await waitForVisible(fileTreeB, docRowSelector(lifecycle.documentId), "B 文件树 lifecycle 恢复后");
|
||||
await waitForDetached(trashB, trashDocRowSelector(lifecycle.documentId), "B 垃圾箱 lifecycle 恢复后");
|
||||
await snapshotStep(result, "restore-visible-on-b", fileTreeB, trashB, navigationStart);
|
||||
|
||||
await treeCommand(requestA, workspaceId, "archive", lifecycle.documentId);
|
||||
await waitForVisible(trashB, trashDocRowSelector(lifecycle.documentId), "B 垃圾箱 lifecycle 再次删除后");
|
||||
await treeCommand(requestA, workspaceId, "purge", lifecycle.documentId);
|
||||
await waitForDetached(trashB, trashDocRowSelector(lifecycle.documentId), "B 垃圾箱 lifecycle 彻底删除后");
|
||||
await waitForDetached(fileTreeB, docRowSelector(lifecycle.documentId), "B 文件树 lifecycle 彻底删除后");
|
||||
await snapshotStep(result, "purge-visible-on-b", fileTreeB, trashB, navigationStart);
|
||||
|
||||
const emptyA = await createPage(requestA, workspaceId, `${prefix}-empty-a`, root.documentId);
|
||||
const emptyB = await createPage(requestA, workspaceId, `${prefix}-empty-b`, root.documentId);
|
||||
cleanupIds.push(emptyA.documentId, emptyB.documentId);
|
||||
result.fixture.emptyIds = [emptyA.documentId, emptyB.documentId];
|
||||
await waitForVisible(fileTreeB, docRowSelector(emptyA.documentId), "B 文件树 empty-a 新建后");
|
||||
await waitForVisible(fileTreeB, docRowSelector(emptyB.documentId), "B 文件树 empty-b 新建后");
|
||||
await treeCommand(requestA, workspaceId, "archive", emptyA.documentId);
|
||||
await treeCommand(requestA, workspaceId, "archive", emptyB.documentId);
|
||||
await waitForVisible(trashB, trashDocRowSelector(emptyA.documentId), "B 垃圾箱 empty-a 删除后");
|
||||
await waitForVisible(trashB, trashDocRowSelector(emptyB.documentId), "B 垃圾箱 empty-b 删除后");
|
||||
await emptyDocumentTrash(requestA, workspaceId);
|
||||
await waitForDetached(trashB, trashDocRowSelector(emptyA.documentId), "B 垃圾箱 empty-a 清空后");
|
||||
await waitForDetached(trashB, trashDocRowSelector(emptyB.documentId), "B 垃圾箱 empty-b 清空后");
|
||||
await waitForDetached(fileTreeB, docRowSelector(emptyA.documentId), "B 文件树 empty-a 清空后");
|
||||
await waitForDetached(fileTreeB, docRowSelector(emptyB.documentId), "B 文件树 empty-b 清空后");
|
||||
await snapshotStep(result, "empty-trash-visible-on-b", fileTreeB, trashB, navigationStart);
|
||||
|
||||
const unexpectedNavigations = result.navigationEvents.slice(navigationStart);
|
||||
assert.equal(unexpectedNavigations.length, 0, `B 浏览器发生了刷新/导航: ${JSON.stringify(unexpectedNavigations)}`);
|
||||
result.ok = true;
|
||||
} catch (error) {
|
||||
result.error = error instanceof Error ? error.stack || error.message : String(error);
|
||||
result.failure = {
|
||||
fileTree: await readPageState(fileTreeB).catch(() => null),
|
||||
trash: await readPageState(trashB).catch(() => null),
|
||||
};
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await cleanup(requestA, workspaceId, cleanupIds).catch((error) => {
|
||||
result.cleanupError = error instanceof Error ? error.message : String(error);
|
||||
});
|
||||
await browser.close().catch(() => undefined);
|
||||
await writeResult(result);
|
||||
}
|
||||
})();
|
||||
@@ -1,426 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const TASK = "task433-filetree-trash-file-asset-dual-browser-no-refresh-smoke";
|
||||
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const AUTH_BASE_URL = (process.env.MNOTE_AUTH_BASE_URL || BASE_URL).replace(/\/+$/, "");
|
||||
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
|
||||
// 说明:该 smoke 仍在验证 convex-source 的 file asset trash 兼容链路,不是 local-first 默认资源流。
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 35_000);
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
|
||||
async function writeResult(payload) {
|
||||
await fs.mkdir(OUTPUT_DIR, { recursive: true });
|
||||
await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
async function requestJson(request, requestPath, init = {}) {
|
||||
const response = await request.fetch(`${BASE_URL}${requestPath}`, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
|
||||
...(init.headers || {}),
|
||||
},
|
||||
timeout: 30_000,
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
payload = text;
|
||||
}
|
||||
if (!response.ok()) {
|
||||
throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function requestAuthJson(request, requestPath, init = {}) {
|
||||
const response = await request.fetch(`${AUTH_BASE_URL}${requestPath}`, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
|
||||
...(init.headers || {}),
|
||||
},
|
||||
timeout: 20_000,
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
payload = text;
|
||||
}
|
||||
if (!response.ok()) {
|
||||
throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function cssEscape(value) {
|
||||
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
function docRowSelector(documentId) {
|
||||
return `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="doc:${cssEscape(documentId)}"]`;
|
||||
}
|
||||
|
||||
function assetRowSelector(assetId) {
|
||||
return `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-asset-id="${cssEscape(assetId)}"]`;
|
||||
}
|
||||
|
||||
function trashAssetRowSelector(assetId) {
|
||||
return `[data-testid="mnote-trash-workbench"] [data-trash-row="resource"][data-resource-kind="media"][data-resource-id="${cssEscape(assetId)}"]`;
|
||||
}
|
||||
|
||||
async function authenticate(request, email, name) {
|
||||
await requestAuthJson(request, "/api/auth", {
|
||||
method: "POST",
|
||||
data: {
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: { email, password: e2ePassword(), flow: "signUp", name },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createPage(request, workspaceId, title, parentId = null) {
|
||||
const payload = await requestJson(request, "/api/tree/commands", {
|
||||
method: "POST",
|
||||
data: { action: "create", workspaceId, parentId, title },
|
||||
});
|
||||
const result = payload.result || payload;
|
||||
const documentId = result.documentId || payload.documentId || result.id || "";
|
||||
const resolvedWorkspaceId = result.workspaceId || payload.workspaceId || payload.workspace_id || workspaceId || "";
|
||||
assert(documentId, `创建页面失败: ${JSON.stringify(payload)}`);
|
||||
assert(resolvedWorkspaceId, `创建页面缺少 workspaceId: ${JSON.stringify(payload)}`);
|
||||
return { documentId, workspaceId: resolvedWorkspaceId, payload };
|
||||
}
|
||||
|
||||
async function treeCommand(request, workspaceId, action, documentId) {
|
||||
return await requestJson(request, "/api/tree/commands", {
|
||||
method: "POST",
|
||||
data: { action, workspaceId, documentId },
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadFileAsset(request, workspaceId, documentId, fileName, text) {
|
||||
const response = await request.fetch(`${BASE_URL}/api/media/upload`, {
|
||||
method: "POST",
|
||||
multipart: {
|
||||
workspaceId,
|
||||
documentId,
|
||||
file: {
|
||||
name: fileName,
|
||||
mimeType: "text/plain",
|
||||
buffer: Buffer.from(text, "utf8"),
|
||||
},
|
||||
},
|
||||
timeout: 30_000,
|
||||
});
|
||||
const payload = await response.json().catch(async () => await response.text());
|
||||
if (!response.ok()) {
|
||||
throw new Error(`/api/media/upload 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
|
||||
}
|
||||
const assetId = payload?.asset?.id || payload?.assetId || "";
|
||||
assert(assetId, `上传附件缺少 asset id: ${JSON.stringify(payload)}`);
|
||||
return { assetId, payload };
|
||||
}
|
||||
|
||||
async function archiveAsset(request, assetId) {
|
||||
return await requestJson(request, "/api/media/batch", {
|
||||
method: "POST",
|
||||
data: { action: "delete", assetIds: [assetId] },
|
||||
});
|
||||
}
|
||||
|
||||
async function restoreAsset(request, assetId) {
|
||||
return await requestJson(request, "/api/media/batch", {
|
||||
method: "POST",
|
||||
data: { action: "restore", assetIds: [assetId] },
|
||||
});
|
||||
}
|
||||
|
||||
async function purgeAsset(request, assetId) {
|
||||
return await requestJson(request, "/api/media/purge", {
|
||||
method: "POST",
|
||||
data: { assetId },
|
||||
});
|
||||
}
|
||||
|
||||
async function emptyResourceTrash(request, workspaceId) {
|
||||
return await requestJson(request, "/api/media/empty-trash", {
|
||||
method: "POST",
|
||||
data: { workspaceId },
|
||||
});
|
||||
}
|
||||
|
||||
async function openDocumentFileTree(page, workspaceId, documentId) {
|
||||
await page.goto(`${BASE_URL}/documents/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, {
|
||||
waitUntil: "commit",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const fileRoot = document.getElementById("sidebar-file-tree-root");
|
||||
const fileTab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
|
||||
return Boolean(fileRoot || fileTab);
|
||||
},
|
||||
undefined,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
const visible = (node) =>
|
||||
node instanceof HTMLElement &&
|
||||
!node.hidden &&
|
||||
getComputedStyle(node).display !== "none" &&
|
||||
getComputedStyle(node).visibility !== "hidden" &&
|
||||
node.getClientRects().length > 0;
|
||||
const fileRoot = document.getElementById("sidebar-file-tree-root");
|
||||
if (visible(fileRoot)) return;
|
||||
const tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
|
||||
if (tab instanceof HTMLElement) tab.click();
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const fileRoot = document.getElementById("sidebar-file-tree-root");
|
||||
return fileRoot instanceof HTMLElement && fileRoot.getClientRects().length > 0;
|
||||
},
|
||||
undefined,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function openTrash(page, workspaceId) {
|
||||
await page.goto(`${BASE_URL}/trash?workspaceId=${encodeURIComponent(workspaceId)}`, {
|
||||
waitUntil: "commit",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.locator('[data-testid="mnote-trash-workbench"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
async function waitForVisible(page, selector, label) {
|
||||
await page.locator(selector).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }).catch(async (error) => {
|
||||
throw new Error(`${label} 未出现: ${error.message}; state=${JSON.stringify(await readPageState(page).catch(() => null))}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForDetached(page, selector, label) {
|
||||
await page.locator(selector).first().waitFor({ state: "detached", timeout: UI_TIMEOUT_MS }).catch(async (error) => {
|
||||
throw new Error(`${label} 未消失: ${error.message}; state=${JSON.stringify(await readPageState(page).catch(() => null))}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function installTreeEventRecorder(page, label, records) {
|
||||
await page.addInitScript(() => {
|
||||
window.__MNOTE_TASK433_TREE_EVENTS__ = [];
|
||||
const record = (name, event) => {
|
||||
const detail = event && event.detail ? event.detail : {};
|
||||
const payload = detail.payload || detail || {};
|
||||
window.__MNOTE_TASK433_TREE_EVENTS__.push({
|
||||
name,
|
||||
at: Date.now(),
|
||||
revision: detail.revision || payload.revision || payload.cursor || "",
|
||||
op: payload && payload.data && payload.data.op ? payload.data.op : "",
|
||||
});
|
||||
};
|
||||
window.addEventListener("tree:snapshot", (event) => record("tree:snapshot", event));
|
||||
window.addEventListener("tree:delta", (event) => record("tree:delta", event));
|
||||
window.addEventListener("tree:resync", (event) => record("tree:resync", event));
|
||||
});
|
||||
page.on("request", (request) => {
|
||||
const url = request.url();
|
||||
if (url.includes("/api/tree/events")) {
|
||||
records.push({ label, type: "request", method: request.method(), url, at: Date.now() });
|
||||
}
|
||||
});
|
||||
page.on("console", (message) => {
|
||||
const text = message.text();
|
||||
if (/tree live|EventSource|trash|error|failed/i.test(text)) {
|
||||
records.push({ label, type: "console", level: message.type(), text: text.slice(0, 2000), at: Date.now() });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function recordNavigation(page, label, records) {
|
||||
page.on("framenavigated", (frame) => {
|
||||
if (frame === page.mainFrame()) {
|
||||
records.push({ label, url: frame.url(), at: Date.now() });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function readPageState(page) {
|
||||
return await page.evaluate(() => ({
|
||||
url: window.location.href,
|
||||
liveStatus: document.documentElement.getAttribute("data-mnote-tree-live-status") || "",
|
||||
liveApplied: document.documentElement.getAttribute("data-mnote-tree-live-applied") || "",
|
||||
liveRevision: document.documentElement.getAttribute("data-mnote-tree-live-revision") || "",
|
||||
liveError: document.documentElement.getAttribute("data-mnote-tree-live-apply-error") || "",
|
||||
trashLiveReason:
|
||||
document.querySelector('[data-testid="mnote-trash-workbench"]')?.getAttribute("data-live-refresh-reason") || "",
|
||||
trashLiveAt:
|
||||
document.querySelector('[data-testid="mnote-trash-workbench"]')?.getAttribute("data-live-refresh-at") || "",
|
||||
filetreeRows: Array.from(document.querySelectorAll("#sidebar-file-tree-root .tree-row[data-shell-mode='filetree']")).map((row) => ({
|
||||
rowId: row instanceof HTMLElement ? row.dataset.rowId || "" : "",
|
||||
assetId: row instanceof HTMLElement ? row.dataset.assetId || "" : "",
|
||||
documentId: row instanceof HTMLElement ? row.dataset.documentId || "" : "",
|
||||
objectKind: row instanceof HTMLElement ? row.dataset.objectKind || "" : "",
|
||||
text: row.textContent || "",
|
||||
})),
|
||||
trashResourceRows: Array.from(document.querySelectorAll('[data-testid="mnote-trash-workbench"] [data-trash-row="resource"]')).map((row) => ({
|
||||
resourceKind: row instanceof HTMLElement ? row.dataset.resourceKind || "" : "",
|
||||
resourceId: row instanceof HTMLElement ? row.dataset.resourceId || "" : "",
|
||||
documentId: row instanceof HTMLElement ? row.dataset.documentId || "" : "",
|
||||
text: row.textContent || "",
|
||||
})),
|
||||
treeEvents: window.__MNOTE_TASK433_TREE_EVENTS__ || [],
|
||||
}));
|
||||
}
|
||||
|
||||
async function snapshotStep(result, name, fileTreePage, trashPage, navigationStart) {
|
||||
result.steps.push({
|
||||
name,
|
||||
fileTree: await readPageState(fileTreePage),
|
||||
trash: await readPageState(trashPage),
|
||||
navigationEvents: result.navigationEvents.slice(navigationStart),
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanup(request, workspaceId, documentIds, assetIds) {
|
||||
for (const assetId of assetIds.filter(Boolean)) {
|
||||
await archiveAsset(request, assetId).catch(() => null);
|
||||
}
|
||||
if (workspaceId) {
|
||||
await emptyResourceTrash(request, workspaceId).catch(() => null);
|
||||
}
|
||||
for (const documentId of documentIds.filter(Boolean)) {
|
||||
await treeCommand(request, workspaceId, "archive", documentId).catch(() => null);
|
||||
}
|
||||
if (workspaceId) {
|
||||
await requestJson(request, "/api/documents/empty-trash", { method: "POST", data: { workspaceId } }).catch(() => null);
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const stamp = Date.now();
|
||||
const email = `mnote.stage8.file.${stamp}@example.com`;
|
||||
const prefix = `TEST-10REVIEW-07-P8-FILE-${stamp}`;
|
||||
const result = {
|
||||
ok: false,
|
||||
task: TASK,
|
||||
baseUrl: BASE_URL,
|
||||
authBaseUrl: AUTH_BASE_URL,
|
||||
email,
|
||||
prefix,
|
||||
fixture: {},
|
||||
navigationEvents: [],
|
||||
treeEventRequests: [],
|
||||
steps: [],
|
||||
};
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const contextA = await browser.newContext();
|
||||
const contextB = await browser.newContext();
|
||||
const requestA = contextA.request;
|
||||
const fileTreeB = await contextB.newPage();
|
||||
const trashB = await contextB.newPage();
|
||||
let workspaceId = "";
|
||||
const cleanupDocumentIds = [];
|
||||
const cleanupAssetIds = [];
|
||||
|
||||
await installTreeEventRecorder(fileTreeB, "B-filetree", result.treeEventRequests);
|
||||
await installTreeEventRecorder(trashB, "B-trash", result.treeEventRequests);
|
||||
recordNavigation(fileTreeB, "B-filetree", result.navigationEvents);
|
||||
recordNavigation(trashB, "B-trash", result.navigationEvents);
|
||||
|
||||
try {
|
||||
for (const requestContext of [requestA, contextB.request]) {
|
||||
await authenticate(requestContext, email, `stage8-file-${stamp}`);
|
||||
}
|
||||
|
||||
const root = await createPage(requestA, null, `${prefix}-root`);
|
||||
workspaceId = root.workspaceId;
|
||||
cleanupDocumentIds.push(root.documentId);
|
||||
result.fixture.rootId = root.documentId;
|
||||
result.fixture.workspaceId = workspaceId;
|
||||
|
||||
await openDocumentFileTree(fileTreeB, workspaceId, root.documentId);
|
||||
await openTrash(trashB, workspaceId);
|
||||
await waitForVisible(fileTreeB, docRowSelector(root.documentId), "B 文件树 root 页面");
|
||||
const navigationStart = result.navigationEvents.length;
|
||||
await snapshotStep(result, "initial", fileTreeB, trashB, navigationStart);
|
||||
|
||||
const uploaded = await uploadFileAsset(
|
||||
requestA,
|
||||
workspaceId,
|
||||
root.documentId,
|
||||
`${prefix}-lifecycle.txt`,
|
||||
`${prefix} lifecycle asset`,
|
||||
);
|
||||
cleanupAssetIds.push(uploaded.assetId);
|
||||
result.fixture.lifecycleAssetId = uploaded.assetId;
|
||||
await waitForVisible(fileTreeB, assetRowSelector(uploaded.assetId), "B 文件树上传附件");
|
||||
await snapshotStep(result, "upload-visible-on-b", fileTreeB, trashB, navigationStart);
|
||||
|
||||
await archiveAsset(requestA, uploaded.assetId);
|
||||
await waitForDetached(fileTreeB, assetRowSelector(uploaded.assetId), "B 文件树附件删除后");
|
||||
await waitForVisible(trashB, trashAssetRowSelector(uploaded.assetId), "B 垃圾箱附件删除后");
|
||||
await snapshotStep(result, "archive-visible-on-b", fileTreeB, trashB, navigationStart);
|
||||
|
||||
await restoreAsset(requestA, uploaded.assetId);
|
||||
await waitForVisible(fileTreeB, assetRowSelector(uploaded.assetId), "B 文件树附件恢复后");
|
||||
await waitForDetached(trashB, trashAssetRowSelector(uploaded.assetId), "B 垃圾箱附件恢复后");
|
||||
await snapshotStep(result, "restore-visible-on-b", fileTreeB, trashB, navigationStart);
|
||||
|
||||
await archiveAsset(requestA, uploaded.assetId);
|
||||
await waitForVisible(trashB, trashAssetRowSelector(uploaded.assetId), "B 垃圾箱附件再次删除后");
|
||||
await purgeAsset(requestA, uploaded.assetId);
|
||||
await waitForDetached(trashB, trashAssetRowSelector(uploaded.assetId), "B 垃圾箱附件彻底删除后");
|
||||
await waitForDetached(fileTreeB, assetRowSelector(uploaded.assetId), "B 文件树附件彻底删除后");
|
||||
await snapshotStep(result, "purge-visible-on-b", fileTreeB, trashB, navigationStart);
|
||||
|
||||
const emptyAssetA = await uploadFileAsset(requestA, workspaceId, root.documentId, `${prefix}-empty-a.txt`, "empty a");
|
||||
const emptyAssetB = await uploadFileAsset(requestA, workspaceId, root.documentId, `${prefix}-empty-b.txt`, "empty b");
|
||||
cleanupAssetIds.push(emptyAssetA.assetId, emptyAssetB.assetId);
|
||||
result.fixture.emptyAssetIds = [emptyAssetA.assetId, emptyAssetB.assetId];
|
||||
await waitForVisible(fileTreeB, assetRowSelector(emptyAssetA.assetId), "B 文件树 empty-a 上传后");
|
||||
await waitForVisible(fileTreeB, assetRowSelector(emptyAssetB.assetId), "B 文件树 empty-b 上传后");
|
||||
await archiveAsset(requestA, emptyAssetA.assetId);
|
||||
await archiveAsset(requestA, emptyAssetB.assetId);
|
||||
await waitForVisible(trashB, trashAssetRowSelector(emptyAssetA.assetId), "B 垃圾箱 empty-a 删除后");
|
||||
await waitForVisible(trashB, trashAssetRowSelector(emptyAssetB.assetId), "B 垃圾箱 empty-b 删除后");
|
||||
await emptyResourceTrash(requestA, workspaceId);
|
||||
await waitForDetached(trashB, trashAssetRowSelector(emptyAssetA.assetId), "B 垃圾箱 empty-a 清空后");
|
||||
await waitForDetached(trashB, trashAssetRowSelector(emptyAssetB.assetId), "B 垃圾箱 empty-b 清空后");
|
||||
await waitForDetached(fileTreeB, assetRowSelector(emptyAssetA.assetId), "B 文件树 empty-a 清空后");
|
||||
await waitForDetached(fileTreeB, assetRowSelector(emptyAssetB.assetId), "B 文件树 empty-b 清空后");
|
||||
await snapshotStep(result, "empty-trash-visible-on-b", fileTreeB, trashB, navigationStart);
|
||||
|
||||
const unexpectedNavigations = result.navigationEvents.slice(navigationStart);
|
||||
assert.equal(unexpectedNavigations.length, 0, `B 浏览器发生了刷新/导航: ${JSON.stringify(unexpectedNavigations)}`);
|
||||
result.ok = true;
|
||||
} catch (error) {
|
||||
result.error = error instanceof Error ? error.stack || error.message : String(error);
|
||||
result.failure = {
|
||||
fileTree: await readPageState(fileTreeB).catch(() => null),
|
||||
trash: await readPageState(trashB).catch(() => null),
|
||||
};
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await cleanup(requestA, workspaceId, cleanupDocumentIds, cleanupAssetIds).catch((error) => {
|
||||
result.cleanupError = error instanceof Error ? error.message : String(error);
|
||||
});
|
||||
await browser.close().catch(() => undefined);
|
||||
await writeResult(result);
|
||||
}
|
||||
})();
|
||||
@@ -1,559 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const TASK = "task434-filetree-trash-mindmap-table-dual-browser-no-refresh-smoke";
|
||||
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const AUTH_BASE_URL = (process.env.MNOTE_AUTH_BASE_URL || BASE_URL).replace(/\/+$/, "");
|
||||
const CONVEX_URL = (process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_SELF_HOSTED_URL || "http://127.0.0.1:3210").replace(/\/+$/, "");
|
||||
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
|
||||
// 说明:该 smoke 覆盖 convex-source mindmap/table trash 兼容链路,不代表 local-first 默认资源真相。
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 35_000);
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
|
||||
async function writeResult(payload) {
|
||||
await fs.mkdir(OUTPUT_DIR, { recursive: true });
|
||||
await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
async function requestJson(request, requestPath, init = {}) {
|
||||
const response = await request.fetch(`${BASE_URL}${requestPath}`, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
|
||||
...(init.headers || {}),
|
||||
},
|
||||
timeout: 30_000,
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
payload = text;
|
||||
}
|
||||
if (!response.ok()) {
|
||||
throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function requestAuthJson(request, requestPath, init = {}) {
|
||||
const response = await request.fetch(`${AUTH_BASE_URL}${requestPath}`, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
|
||||
...(init.headers || {}),
|
||||
},
|
||||
timeout: 20_000,
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
payload = text;
|
||||
}
|
||||
if (!response.ok()) {
|
||||
throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function convexCall(context, kind, convexPath, args) {
|
||||
const cookies = await context.cookies(BASE_URL);
|
||||
const jwt = cookies.find((cookie) => cookie.name === "__convexAuthJWT")?.value;
|
||||
assert(jwt, "缺少 __convexAuthJWT cookie");
|
||||
const response = await fetch(`${CONVEX_URL}/api/${kind}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${jwt}`,
|
||||
"content-type": "application/json",
|
||||
"Convex-Client": TASK,
|
||||
},
|
||||
body: JSON.stringify({ path: convexPath, format: "convex_encoded_json", args: [args] }),
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok || body.status !== "success") {
|
||||
throw new Error(`Convex ${kind} ${convexPath} 失败: ${response.status} ${JSON.stringify(body)}`);
|
||||
}
|
||||
return body.value;
|
||||
}
|
||||
|
||||
function cssEscape(value) {
|
||||
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
function docRowSelector(documentId) {
|
||||
return `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="doc:${cssEscape(documentId)}"]`;
|
||||
}
|
||||
|
||||
function assetRowSelector(assetId) {
|
||||
return `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-asset-id="${cssEscape(assetId)}"]`;
|
||||
}
|
||||
|
||||
function trashAssetRowSelector(assetId) {
|
||||
return `[data-testid="mnote-trash-workbench"] [data-trash-row="resource"][data-resource-kind="media"][data-resource-id="${cssEscape(assetId)}"]`;
|
||||
}
|
||||
|
||||
function trashResourceRowSelector(kind, resourceId) {
|
||||
return `[data-testid="mnote-trash-workbench"] [data-trash-row="resource"][data-resource-kind="${cssEscape(kind)}"][data-resource-id="${cssEscape(resourceId)}"]`;
|
||||
}
|
||||
|
||||
async function authenticate(request, email, name) {
|
||||
await requestAuthJson(request, "/api/auth", {
|
||||
method: "POST",
|
||||
data: {
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: { email, password: e2ePassword(), flow: "signUp", name },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createPage(request, workspaceId, title, parentId = null) {
|
||||
const payload = await requestJson(request, "/api/tree/commands", {
|
||||
method: "POST",
|
||||
data: { action: "create", workspaceId, parentId, title },
|
||||
});
|
||||
const result = payload.result || payload;
|
||||
const documentId = result.documentId || payload.documentId || result.id || "";
|
||||
const resolvedWorkspaceId = result.workspaceId || payload.workspaceId || payload.workspace_id || workspaceId || "";
|
||||
assert(documentId, `创建页面失败: ${JSON.stringify(payload)}`);
|
||||
assert(resolvedWorkspaceId, `创建页面缺少 workspaceId: ${JSON.stringify(payload)}`);
|
||||
return { documentId, workspaceId: resolvedWorkspaceId, payload };
|
||||
}
|
||||
|
||||
async function treeCommand(request, workspaceId, action, documentId) {
|
||||
return await requestJson(request, "/api/tree/commands", {
|
||||
method: "POST",
|
||||
data: { action, workspaceId, documentId },
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadFileAsset(request, workspaceId, documentId, fileName, text) {
|
||||
const response = await request.fetch(`${BASE_URL}/api/media/upload`, {
|
||||
method: "POST",
|
||||
multipart: {
|
||||
workspaceId,
|
||||
documentId,
|
||||
file: {
|
||||
name: fileName,
|
||||
mimeType: "text/plain",
|
||||
buffer: Buffer.from(text, "utf8"),
|
||||
},
|
||||
},
|
||||
timeout: 30_000,
|
||||
});
|
||||
const payload = await response.json().catch(async () => await response.text());
|
||||
if (!response.ok()) {
|
||||
throw new Error(`/api/media/upload 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
|
||||
}
|
||||
const assetId = payload?.asset?.id || payload?.assetId || "";
|
||||
assert(assetId, `上传附件缺少 asset id: ${JSON.stringify(payload)}`);
|
||||
return { assetId, payload };
|
||||
}
|
||||
|
||||
async function createMindmap(request, workspaceId, documentId, mindmapId, title) {
|
||||
return await requestJson(request, `/api/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`, {
|
||||
method: "POST",
|
||||
data: {
|
||||
commandName: "mindmaps.put",
|
||||
workspaceId,
|
||||
createOnly: true,
|
||||
data: { root: { data: { text: title }, children: [] } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function archiveMindmap(request, documentId, mindmapId) {
|
||||
return await requestJson(request, `/api/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
async function restoreMindmap(request, documentId, mindmapId) {
|
||||
return await requestJson(request, `/api/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`, {
|
||||
method: "PATCH",
|
||||
data: { action: "restore" },
|
||||
});
|
||||
}
|
||||
|
||||
async function purgeMindmap(request, documentId, mindmapId) {
|
||||
return await requestJson(request, `/api/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`, {
|
||||
method: "PATCH",
|
||||
data: { action: "purge" },
|
||||
});
|
||||
}
|
||||
|
||||
async function createTable(request, documentId, title) {
|
||||
const payload = await requestJson(request, "/api/tables/create", {
|
||||
method: "POST",
|
||||
data: {
|
||||
documentId,
|
||||
title,
|
||||
schema: {},
|
||||
snapshot: null,
|
||||
},
|
||||
});
|
||||
const tableId = payload?.result?.id || payload?.id || "";
|
||||
assert(tableId, `创建 table 缺少 id: ${JSON.stringify(payload)}`);
|
||||
return tableId;
|
||||
}
|
||||
|
||||
async function archiveTable(request, tableId) {
|
||||
return await requestJson(request, `/api/tables/${encodeURIComponent(tableId)}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async function restoreTable(request, tableId) {
|
||||
return await requestJson(request, "/api/tables/restore", {
|
||||
method: "POST",
|
||||
data: { tableId },
|
||||
});
|
||||
}
|
||||
|
||||
async function purgeTable(request, tableId) {
|
||||
return await requestJson(request, "/api/tables/purge", {
|
||||
method: "POST",
|
||||
data: { tableId },
|
||||
});
|
||||
}
|
||||
|
||||
async function archiveAsset(request, assetId) {
|
||||
return await requestJson(request, "/api/media/batch", {
|
||||
method: "POST",
|
||||
data: { action: "delete", assetIds: [assetId] },
|
||||
});
|
||||
}
|
||||
|
||||
async function restoreAsset(request, assetId) {
|
||||
return await requestJson(request, "/api/media/batch", {
|
||||
method: "POST",
|
||||
data: { action: "restore", assetIds: [assetId] },
|
||||
});
|
||||
}
|
||||
|
||||
async function purgeAsset(request, assetId) {
|
||||
return await requestJson(request, "/api/media/purge", {
|
||||
method: "POST",
|
||||
data: { assetId },
|
||||
});
|
||||
}
|
||||
|
||||
async function emptyResourceTrash(request, workspaceId) {
|
||||
return await requestJson(request, "/api/media/empty-trash", {
|
||||
method: "POST",
|
||||
data: { workspaceId },
|
||||
});
|
||||
}
|
||||
|
||||
async function emptyMindmapTrash(request, workspaceId) {
|
||||
return await requestJson(request, "/api/mindmap-trash/empty", {
|
||||
method: "POST",
|
||||
data: { workspaceId },
|
||||
});
|
||||
}
|
||||
|
||||
async function emptyTableTrash(request, workspaceId) {
|
||||
return await requestJson(request, "/api/tables/empty-trash", {
|
||||
method: "POST",
|
||||
data: { workspaceId },
|
||||
});
|
||||
}
|
||||
|
||||
async function openDocumentFileTree(page, workspaceId, documentId) {
|
||||
await page.goto(`${BASE_URL}/documents/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, {
|
||||
waitUntil: "commit",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const fileRoot = document.getElementById("sidebar-file-tree-root");
|
||||
const fileTab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
|
||||
return Boolean(fileRoot || fileTab);
|
||||
},
|
||||
undefined,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
const visible = (node) =>
|
||||
node instanceof HTMLElement &&
|
||||
!node.hidden &&
|
||||
getComputedStyle(node).display !== "none" &&
|
||||
getComputedStyle(node).visibility !== "hidden" &&
|
||||
node.getClientRects().length > 0;
|
||||
const fileRoot = document.getElementById("sidebar-file-tree-root");
|
||||
if (visible(fileRoot)) return;
|
||||
const tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
|
||||
if (tab instanceof HTMLElement) tab.click();
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const fileRoot = document.getElementById("sidebar-file-tree-root");
|
||||
return fileRoot instanceof HTMLElement && fileRoot.getClientRects().length > 0;
|
||||
},
|
||||
undefined,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function openTrash(page, workspaceId) {
|
||||
await page.goto(`${BASE_URL}/trash?workspaceId=${encodeURIComponent(workspaceId)}`, {
|
||||
waitUntil: "commit",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.locator('[data-testid="mnote-trash-workbench"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
async function waitForVisible(page, selector, label) {
|
||||
await page.locator(selector).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }).catch(async (error) => {
|
||||
throw new Error(`${label} 未出现: ${error.message}; state=${JSON.stringify(await readPageState(page).catch(() => null))}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForDetached(page, selector, label) {
|
||||
await page.locator(selector).first().waitFor({ state: "detached", timeout: UI_TIMEOUT_MS }).catch(async (error) => {
|
||||
throw new Error(`${label} 未消失: ${error.message}; state=${JSON.stringify(await readPageState(page).catch(() => null))}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function installTreeEventRecorder(page, label, records) {
|
||||
await page.addInitScript(() => {
|
||||
window.__MNOTE_TASK433_TREE_EVENTS__ = [];
|
||||
const record = (name, event) => {
|
||||
const detail = event && event.detail ? event.detail : {};
|
||||
const payload = detail.payload || detail || {};
|
||||
window.__MNOTE_TASK433_TREE_EVENTS__.push({
|
||||
name,
|
||||
at: Date.now(),
|
||||
revision: detail.revision || payload.revision || payload.cursor || "",
|
||||
op: payload && payload.data && payload.data.op ? payload.data.op : "",
|
||||
});
|
||||
};
|
||||
window.addEventListener("tree:snapshot", (event) => record("tree:snapshot", event));
|
||||
window.addEventListener("tree:delta", (event) => record("tree:delta", event));
|
||||
window.addEventListener("tree:resync", (event) => record("tree:resync", event));
|
||||
});
|
||||
page.on("request", (request) => {
|
||||
const url = request.url();
|
||||
if (url.includes("/api/tree/events")) {
|
||||
records.push({ label, type: "request", method: request.method(), url, at: Date.now() });
|
||||
}
|
||||
});
|
||||
page.on("console", (message) => {
|
||||
const text = message.text();
|
||||
if (/tree live|EventSource|trash|error|failed/i.test(text)) {
|
||||
records.push({ label, type: "console", level: message.type(), text: text.slice(0, 2000), at: Date.now() });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function recordNavigation(page, label, records) {
|
||||
page.on("framenavigated", (frame) => {
|
||||
if (frame === page.mainFrame()) {
|
||||
records.push({ label, url: frame.url(), at: Date.now() });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function readPageState(page) {
|
||||
return await page.evaluate(() => ({
|
||||
url: window.location.href,
|
||||
liveStatus: document.documentElement.getAttribute("data-mnote-tree-live-status") || "",
|
||||
liveApplied: document.documentElement.getAttribute("data-mnote-tree-live-applied") || "",
|
||||
liveRevision: document.documentElement.getAttribute("data-mnote-tree-live-revision") || "",
|
||||
liveError: document.documentElement.getAttribute("data-mnote-tree-live-apply-error") || "",
|
||||
trashLiveReason:
|
||||
document.querySelector('[data-testid="mnote-trash-workbench"]')?.getAttribute("data-live-refresh-reason") || "",
|
||||
trashLiveAt:
|
||||
document.querySelector('[data-testid="mnote-trash-workbench"]')?.getAttribute("data-live-refresh-at") || "",
|
||||
filetreeRows: Array.from(document.querySelectorAll("#sidebar-file-tree-root .tree-row[data-shell-mode='filetree']")).map((row) => ({
|
||||
rowId: row instanceof HTMLElement ? row.dataset.rowId || "" : "",
|
||||
assetId: row instanceof HTMLElement ? row.dataset.assetId || "" : "",
|
||||
documentId: row instanceof HTMLElement ? row.dataset.documentId || "" : "",
|
||||
objectKind: row instanceof HTMLElement ? row.dataset.objectKind || "" : "",
|
||||
text: row.textContent || "",
|
||||
})),
|
||||
trashResourceRows: Array.from(document.querySelectorAll('[data-testid="mnote-trash-workbench"] [data-trash-row="resource"]')).map((row) => ({
|
||||
resourceKind: row instanceof HTMLElement ? row.dataset.resourceKind || "" : "",
|
||||
resourceId: row instanceof HTMLElement ? row.dataset.resourceId || "" : "",
|
||||
documentId: row instanceof HTMLElement ? row.dataset.documentId || "" : "",
|
||||
text: row.textContent || "",
|
||||
})),
|
||||
treeEvents: window.__MNOTE_TASK433_TREE_EVENTS__ || [],
|
||||
}));
|
||||
}
|
||||
|
||||
async function snapshotStep(result, name, fileTreePage, trashPage, navigationStart) {
|
||||
result.steps.push({
|
||||
name,
|
||||
fileTree: await readPageState(fileTreePage),
|
||||
trash: await readPageState(trashPage),
|
||||
navigationEvents: result.navigationEvents.slice(navigationStart),
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanup(request, workspaceId, documentIds, mindmapIds, tableIds) {
|
||||
for (const mindmapId of mindmapIds.filter(Boolean)) {
|
||||
for (const documentId of documentIds.filter(Boolean)) {
|
||||
await archiveMindmap(request, documentId, mindmapId).catch(() => null);
|
||||
}
|
||||
}
|
||||
for (const tableId of tableIds.filter(Boolean)) {
|
||||
await archiveTable(request, tableId).catch(() => null);
|
||||
}
|
||||
if (workspaceId) {
|
||||
await emptyMindmapTrash(request, workspaceId).catch(() => null);
|
||||
await emptyTableTrash(request, workspaceId).catch(() => null);
|
||||
}
|
||||
for (const documentId of documentIds.filter(Boolean)) {
|
||||
await treeCommand(request, workspaceId, "archive", documentId).catch(() => null);
|
||||
}
|
||||
if (workspaceId) {
|
||||
await requestJson(request, "/api/documents/empty-trash", { method: "POST", data: { workspaceId } }).catch(() => null);
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const stamp = Date.now();
|
||||
const email = `mnote.stage8.mindtable.${stamp}@example.com`;
|
||||
const prefix = `TEST-10REVIEW-07-P8-MT-${stamp}`;
|
||||
const result = {
|
||||
ok: false,
|
||||
task: TASK,
|
||||
baseUrl: BASE_URL,
|
||||
authBaseUrl: AUTH_BASE_URL,
|
||||
email,
|
||||
prefix,
|
||||
fixture: {},
|
||||
navigationEvents: [],
|
||||
treeEventRequests: [],
|
||||
steps: [],
|
||||
};
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const contextA = await browser.newContext();
|
||||
const contextB = await browser.newContext();
|
||||
const requestA = contextA.request;
|
||||
const fileTreeB = await contextB.newPage();
|
||||
const trashB = await contextB.newPage();
|
||||
let workspaceId = "";
|
||||
const cleanupDocumentIds = [];
|
||||
const cleanupMindmapIds = [];
|
||||
const cleanupTableIds = [];
|
||||
|
||||
await installTreeEventRecorder(fileTreeB, "B-filetree", result.treeEventRequests);
|
||||
await installTreeEventRecorder(trashB, "B-trash", result.treeEventRequests);
|
||||
recordNavigation(fileTreeB, "B-filetree", result.navigationEvents);
|
||||
recordNavigation(trashB, "B-trash", result.navigationEvents);
|
||||
|
||||
try {
|
||||
for (const requestContext of [requestA, contextB.request]) {
|
||||
await authenticate(requestContext, email, `stage8-mindtable-${stamp}`);
|
||||
}
|
||||
|
||||
const root = await createPage(requestA, null, `${prefix}-root`);
|
||||
workspaceId = root.workspaceId;
|
||||
cleanupDocumentIds.push(root.documentId);
|
||||
result.fixture.rootId = root.documentId;
|
||||
result.fixture.workspaceId = workspaceId;
|
||||
|
||||
await openDocumentFileTree(fileTreeB, workspaceId, root.documentId);
|
||||
await openTrash(trashB, workspaceId);
|
||||
await waitForVisible(fileTreeB, docRowSelector(root.documentId), "B 文件树 root 页面");
|
||||
const navigationStart = result.navigationEvents.length;
|
||||
await snapshotStep(result, "initial", fileTreeB, trashB, navigationStart);
|
||||
|
||||
const mindmapId = `mind_stage8_${stamp}_lifecycle`;
|
||||
await createMindmap(requestA, workspaceId, root.documentId, mindmapId, `${prefix}-mind-lifecycle`);
|
||||
cleanupMindmapIds.push(mindmapId);
|
||||
result.fixture.mindmapId = mindmapId;
|
||||
await waitForVisible(fileTreeB, assetRowSelector(mindmapId), "B 文件树 mindmap 创建后");
|
||||
await snapshotStep(result, "mindmap-create-visible-on-b", fileTreeB, trashB, navigationStart);
|
||||
|
||||
await archiveMindmap(requestA, root.documentId, mindmapId);
|
||||
await waitForDetached(fileTreeB, assetRowSelector(mindmapId), "B 文件树 mindmap 删除后");
|
||||
await waitForVisible(trashB, trashResourceRowSelector("mindmap", mindmapId), "B 垃圾箱 mindmap 删除后");
|
||||
await snapshotStep(result, "mindmap-archive-visible-on-b", fileTreeB, trashB, navigationStart);
|
||||
|
||||
await restoreMindmap(requestA, root.documentId, mindmapId);
|
||||
await waitForVisible(fileTreeB, assetRowSelector(mindmapId), "B 文件树 mindmap 恢复后");
|
||||
await waitForDetached(trashB, trashResourceRowSelector("mindmap", mindmapId), "B 垃圾箱 mindmap 恢复后");
|
||||
await snapshotStep(result, "mindmap-restore-visible-on-b", fileTreeB, trashB, navigationStart);
|
||||
|
||||
await archiveMindmap(requestA, root.documentId, mindmapId);
|
||||
await waitForVisible(trashB, trashResourceRowSelector("mindmap", mindmapId), "B 垃圾箱 mindmap 再次删除后");
|
||||
await purgeMindmap(requestA, root.documentId, mindmapId);
|
||||
await waitForDetached(trashB, trashResourceRowSelector("mindmap", mindmapId), "B 垃圾箱 mindmap 彻底删除后");
|
||||
await waitForDetached(fileTreeB, assetRowSelector(mindmapId), "B 文件树 mindmap 彻底删除后");
|
||||
await snapshotStep(result, "mindmap-purge-visible-on-b", fileTreeB, trashB, navigationStart);
|
||||
|
||||
const tableId = await createTable(requestA, root.documentId, `${prefix}-table-lifecycle`);
|
||||
cleanupTableIds.push(tableId);
|
||||
result.fixture.tableId = tableId;
|
||||
await waitForVisible(fileTreeB, assetRowSelector(tableId), "B 文件树 table 创建后");
|
||||
await snapshotStep(result, "table-create-visible-on-b", fileTreeB, trashB, navigationStart);
|
||||
|
||||
await archiveTable(requestA, tableId);
|
||||
await waitForDetached(fileTreeB, assetRowSelector(tableId), "B 文件树 table 删除后");
|
||||
await waitForVisible(trashB, trashResourceRowSelector("table", tableId), "B 垃圾箱 table 删除后");
|
||||
await snapshotStep(result, "table-archive-visible-on-b", fileTreeB, trashB, navigationStart);
|
||||
|
||||
await restoreTable(requestA, tableId);
|
||||
await waitForVisible(fileTreeB, assetRowSelector(tableId), "B 文件树 table 恢复后");
|
||||
await waitForDetached(trashB, trashResourceRowSelector("table", tableId), "B 垃圾箱 table 恢复后");
|
||||
await snapshotStep(result, "table-restore-visible-on-b", fileTreeB, trashB, navigationStart);
|
||||
|
||||
await archiveTable(requestA, tableId);
|
||||
await waitForVisible(trashB, trashResourceRowSelector("table", tableId), "B 垃圾箱 table 再次删除后");
|
||||
await purgeTable(requestA, tableId);
|
||||
await waitForDetached(trashB, trashResourceRowSelector("table", tableId), "B 垃圾箱 table 彻底删除后");
|
||||
await waitForDetached(fileTreeB, assetRowSelector(tableId), "B 文件树 table 彻底删除后");
|
||||
await snapshotStep(result, "table-purge-visible-on-b", fileTreeB, trashB, navigationStart);
|
||||
|
||||
const emptyMindmapId = `mind_stage8_${stamp}_empty`;
|
||||
await createMindmap(requestA, workspaceId, root.documentId, emptyMindmapId, `${prefix}-mind-empty`);
|
||||
const emptyTableId = await createTable(requestA, root.documentId, `${prefix}-table-empty`);
|
||||
cleanupMindmapIds.push(emptyMindmapId);
|
||||
cleanupTableIds.push(emptyTableId);
|
||||
result.fixture.emptyMindmapId = emptyMindmapId;
|
||||
result.fixture.emptyTableId = emptyTableId;
|
||||
await waitForVisible(fileTreeB, assetRowSelector(emptyMindmapId), "B 文件树 empty mindmap 创建后");
|
||||
await waitForVisible(fileTreeB, assetRowSelector(emptyTableId), "B 文件树 empty table 创建后");
|
||||
await archiveMindmap(requestA, root.documentId, emptyMindmapId);
|
||||
await archiveTable(requestA, emptyTableId);
|
||||
await waitForVisible(trashB, trashResourceRowSelector("mindmap", emptyMindmapId), "B 垃圾箱 empty mindmap 删除后");
|
||||
await waitForVisible(trashB, trashResourceRowSelector("table", emptyTableId), "B 垃圾箱 empty table 删除后");
|
||||
await emptyMindmapTrash(requestA, workspaceId);
|
||||
await emptyTableTrash(requestA, workspaceId);
|
||||
await waitForDetached(trashB, trashResourceRowSelector("mindmap", emptyMindmapId), "B 垃圾箱 empty mindmap 清空后");
|
||||
await waitForDetached(trashB, trashResourceRowSelector("table", emptyTableId), "B 垃圾箱 empty table 清空后");
|
||||
await waitForDetached(fileTreeB, assetRowSelector(emptyMindmapId), "B 文件树 empty mindmap 清空后");
|
||||
await waitForDetached(fileTreeB, assetRowSelector(emptyTableId), "B 文件树 empty table 清空后");
|
||||
await snapshotStep(result, "mindmap-table-empty-trash-visible-on-b", fileTreeB, trashB, navigationStart);
|
||||
|
||||
const unexpectedNavigations = result.navigationEvents.slice(navigationStart);
|
||||
assert.equal(unexpectedNavigations.length, 0, `B 浏览器发生了刷新/导航: ${JSON.stringify(unexpectedNavigations)}`);
|
||||
result.ok = true;
|
||||
} catch (error) {
|
||||
result.error = error instanceof Error ? error.stack || error.message : String(error);
|
||||
result.failure = {
|
||||
fileTree: await readPageState(fileTreeB).catch(() => null),
|
||||
trash: await readPageState(trashB).catch(() => null),
|
||||
};
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await cleanup(requestA, workspaceId, cleanupDocumentIds, cleanupMindmapIds, cleanupTableIds).catch((error) => {
|
||||
result.cleanupError = error instanceof Error ? error.message : String(error);
|
||||
});
|
||||
await browser.close().catch(() => undefined);
|
||||
await writeResult(result);
|
||||
}
|
||||
})();
|
||||
@@ -165,6 +165,7 @@ async function run() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-local-open-doc-external-"));
|
||||
const files = {
|
||||
clean: "clean-sync.md",
|
||||
aiClean: "clean-ai-sync.md",
|
||||
dirty: "dirty-conflict.md",
|
||||
aiDirty: "dirty-ai-conflict.md",
|
||||
rename: "rename-open.md",
|
||||
@@ -172,6 +173,7 @@ async function run() {
|
||||
};
|
||||
writeWorkspaceManifest(root, "user_real");
|
||||
fs.writeFileSync(path.join(root, files.clean), markdown("Clean Sync", ["initial clean"]), "utf8");
|
||||
fs.writeFileSync(path.join(root, files.aiClean), markdown("Clean AI Sync", ["initial ai clean"]), "utf8");
|
||||
fs.writeFileSync(path.join(root, files.dirty), markdown("Dirty Conflict", ["initial dirty"]), "utf8");
|
||||
fs.writeFileSync(path.join(root, files.aiDirty), markdown("Dirty AI Conflict", ["initial ai dirty"]), "utf8");
|
||||
fs.writeFileSync(path.join(root, files.rename), markdown("Rename Open", ["initial rename"]), "utf8");
|
||||
@@ -211,6 +213,17 @@ async function run() {
|
||||
await waitForEditorStatus(page, "synced-external-change");
|
||||
}));
|
||||
|
||||
await openDocument(page, root, files.aiClean);
|
||||
await waitForEditorText(page, "initial ai clean");
|
||||
await page.waitForTimeout(300);
|
||||
navigationEvents.length = 0;
|
||||
steps.push(await runStep("clean 文档 AI 后台写入后自动同步内容", navigationEvents, async () => {
|
||||
const aiToken = `ai-clean-${Date.now()}`;
|
||||
await callMarkdownEdit(root, files.aiClean, "initial ai clean", `initial ai clean ${aiToken}`);
|
||||
await waitForEditorText(page, aiToken);
|
||||
await waitForEditorStatus(page, "synced-external-change");
|
||||
}));
|
||||
|
||||
await openDocument(page, root, files.dirty);
|
||||
await waitForEditorText(page, "initial dirty");
|
||||
await page.waitForTimeout(300);
|
||||
@@ -262,7 +275,7 @@ async function run() {
|
||||
fs.rmSync(path.join(root, files.delete));
|
||||
await waitForEditorStatus(page, "external-change-conflict");
|
||||
const runtime = await readEditorRuntime(page);
|
||||
assert(runtime.error.includes("本地 Markdown 文件已在外部更新"), `删除提示不正确: ${JSON.stringify(runtime)}`);
|
||||
assert(runtime.error.includes("已被删除"), `删除提示不正确: ${JSON.stringify(runtime)}`);
|
||||
}));
|
||||
|
||||
const result = {
|
||||
|
||||
@@ -256,6 +256,9 @@ async function main() {
|
||||
output: scenario.message,
|
||||
agentAudit: {
|
||||
eventId: `audit_local_changed_${currentScenario}_${suffix}`,
|
||||
actorId,
|
||||
actorType: "user",
|
||||
agentKind: "reasonix",
|
||||
rootUri,
|
||||
diffSummary: "1 changed file(s)",
|
||||
changedFiles: [
|
||||
@@ -263,6 +266,10 @@ async function main() {
|
||||
path: scenario.relativePath,
|
||||
changeType: "modified",
|
||||
summary: `追加 ${scenario.marker}`,
|
||||
hashBefore: "111",
|
||||
hashAfter: "222",
|
||||
modifiedBeforeMs: 10,
|
||||
modifiedAfterMs: 20,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -308,6 +315,8 @@ async function main() {
|
||||
&& card.text.includes("agent.changed_files")
|
||||
&& card.text.includes("README.md")
|
||||
&& card.text.includes(marker)
|
||||
&& card.text.includes("hash 111→222")
|
||||
&& card.text.includes("reasonix/user/user_real")
|
||||
),
|
||||
`本地 AI changed files 工具卡未显示 README.md 与 diff 摘要: ${JSON.stringify(cards)}`,
|
||||
);
|
||||
@@ -337,8 +346,70 @@ async function main() {
|
||||
assert.equal(runBody.documentId, documentId, `Hermes run 应携带 local documentId: ${JSON.stringify(runBody)}`);
|
||||
assert.equal(runBody.sourceKind, "local_folder", `Hermes run 应携带 local_folder sourceKind: ${JSON.stringify(runBody)}`);
|
||||
assert.equal(runBody.rootUri, rootUri, `Hermes run 应携带 rootUri: ${JSON.stringify(runBody)}`);
|
||||
assert.equal(runBody.editorTarget?.schema, "mnote.ai_editor_target.v1", `Hermes run 应携带 AI editor target: ${JSON.stringify(runBody)}`);
|
||||
assert.equal(runBody.editorTarget?.source, "open_editors_snapshot", `AI editor target 应来自 OpenEditorsSnapshot: ${JSON.stringify(runBody.editorTarget)}`);
|
||||
assert.equal(runBody.editorTarget?.documentId, documentId, `AI editor target 应指向当前文档: ${JSON.stringify(runBody.editorTarget)}`);
|
||||
assert.equal(runBody.runTargetSnapshot?.schema, "mnote.page_ai_run_target_snapshot.v1", `Hermes run 应携带冻结 target snapshot: ${JSON.stringify(runBody.runTargetSnapshot)}`);
|
||||
assert.equal(runBody.runTargetSnapshot?.editorTarget?.documentId, documentId, `冻结 target snapshot 应指向发起 run 时的文档: ${JSON.stringify(runBody.runTargetSnapshot)}`);
|
||||
assert.equal(runBody.runTargetSnapshot?.editorTarget?.workspacePath?.rootUri, rootUri, `冻结 target snapshot 应保留发起 run 时的 rootUri: ${JSON.stringify(runBody.runTargetSnapshot)}`);
|
||||
assert.equal(runBody.pageContext?.aiContext?.runTargetSnapshot?.editorTarget?.documentId, documentId, `pageContext.aiContext 应保留冻结 target snapshot: ${JSON.stringify(runBody.pageContext?.aiContext)}`);
|
||||
assert.equal(runBody.pageContext?.aiContext?.activeEditorTarget?.source, "open_editors_snapshot", `pageContext.aiContext 应包含 activeEditorTarget: ${JSON.stringify(runBody.pageContext?.aiContext)}`);
|
||||
assert.equal(runBody.pageContext?.aiContext?.openEditorsSnapshot?.activeEditor?.documentId, documentId, `aiContext.openEditorsSnapshot 应包含 active editor: ${JSON.stringify(runBody.pageContext?.aiContext?.openEditorsSnapshot)}`);
|
||||
assert(runBody.pageContext?.aiContext?.openEditorsSnapshot?.groups?.primary, `aiContext.openEditorsSnapshot 应保留 primary group: ${JSON.stringify(runBody.pageContext?.aiContext?.openEditorsSnapshot)}`);
|
||||
assert(runBody.pageContext?.aiContext?.openEditorsSnapshot?.groups?.secondary, `aiContext.openEditorsSnapshot 应保留 secondary group: ${JSON.stringify(runBody.pageContext?.aiContext?.openEditorsSnapshot)}`);
|
||||
const cleanCapturedKinds = captured.map((entry) => entry.kind);
|
||||
|
||||
captured.length = 0;
|
||||
await page.evaluate(({ otherRootUri, otherWorkspaceId }) => {
|
||||
const runtime = window.__mnoteDocumentPaneRuntime;
|
||||
if (!runtime || typeof runtime.getOpenEditorsSnapshot !== "function") {
|
||||
throw new Error("missing_open_editors_snapshot_runtime");
|
||||
}
|
||||
window.__task453OriginalOpenEditorsSnapshot = runtime.getOpenEditorsSnapshot.bind(runtime);
|
||||
runtime.getOpenEditorsSnapshot = () => {
|
||||
const snapshot = window.__task453OriginalOpenEditorsSnapshot();
|
||||
const cloned = JSON.parse(JSON.stringify(snapshot || {}));
|
||||
const poisonEditor = (entry) => {
|
||||
if (!entry || typeof entry !== "object") return;
|
||||
entry.workspaceId = otherWorkspaceId;
|
||||
entry.workspacePath = Object.assign({}, entry.workspacePath || {}, {
|
||||
workspaceId: otherWorkspaceId,
|
||||
rootUri: otherRootUri,
|
||||
sourceKind: "local_folder",
|
||||
});
|
||||
};
|
||||
poisonEditor(cloned.activeEditor);
|
||||
(cloned.editors || []).forEach((entry) => {
|
||||
if (entry && entry.active) poisonEditor(entry);
|
||||
});
|
||||
Object.values(cloned.groups || {}).forEach((group) => {
|
||||
(group.editors || []).forEach((entry) => {
|
||||
if (entry && entry.active) poisonEditor(entry);
|
||||
});
|
||||
});
|
||||
return cloned;
|
||||
};
|
||||
}, { otherRootUri: "file:///tmp/mnote-task453-other-root", otherWorkspaceId: "local-ws:user_real:other" });
|
||||
await page.locator("[data-page-ai-input]").fill(`请错误写入其他 workspace ${marker}`, { timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const drawerText = document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "";
|
||||
return drawerText.includes("AI target 与当前本地工作区 rootUri 不一致")
|
||||
|| drawerText.includes("AI target 与当前 workspaceId 不一致");
|
||||
},
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
assert(!captured.some((entry) => entry.kind === "run"), `跨 workspace target 不应发起 Hermes run: ${JSON.stringify(captured)}`);
|
||||
await page.evaluate(() => {
|
||||
const runtime = window.__mnoteDocumentPaneRuntime;
|
||||
if (runtime && window.__task453OriginalOpenEditorsSnapshot) {
|
||||
runtime.getOpenEditorsSnapshot = window.__task453OriginalOpenEditorsSnapshot;
|
||||
}
|
||||
delete window.__task453OriginalOpenEditorsSnapshot;
|
||||
});
|
||||
|
||||
currentScenario = "dirty";
|
||||
captured.length = 0;
|
||||
const dirtyUrl = new URL(`${BASE_URL}/documents/${encodeURIComponent(dirtyDocumentId)}`);
|
||||
@@ -353,49 +424,31 @@ async function main() {
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const drawerText = document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "";
|
||||
return drawerText.includes("agent.changed_files") && drawerText.includes("Dirty.md");
|
||||
return drawerText.includes("目标文档存在未保存或外部变更状态");
|
||||
},
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await waitForEditorStatus(page, "external-change-conflict");
|
||||
await page.locator('[data-testid="mnote-editor-conflict-panel"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
(expected) => {
|
||||
const panel = document.querySelector('[data-testid="mnote-editor-conflict-panel"]');
|
||||
return (panel?.textContent || "").includes(expected);
|
||||
},
|
||||
`agent run ${dirtyRunId}`,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
const envelope = await conflictEnvelope(page, dirtyDocumentId);
|
||||
assert(envelope, "dirty AI 写入应生成冲突信封");
|
||||
assert("externalActor" in envelope, `冲突信封应包含 externalActor: ${JSON.stringify(envelope)}`);
|
||||
assert("dirtyState" in envelope, `冲突信封应包含 dirtyState: ${JSON.stringify(envelope)}`);
|
||||
assert("bufferFileVersion" in envelope, `冲突信封应包含 bufferFileVersion: ${JSON.stringify(envelope)}`);
|
||||
await page.locator('[data-testid="mnote-conflict-open-diff"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="mnote-conflict-diff-panel"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
({ localToken, aiToken }) => {
|
||||
const panel = document.querySelector('[data-testid="mnote-conflict-diff-panel"]');
|
||||
const text = panel?.textContent || "";
|
||||
return text.includes(localToken) && text.includes(aiToken);
|
||||
},
|
||||
{ localToken: dirtyLocalToken, aiToken: dirtyMarker },
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
const diffText = await page.locator('[data-testid="mnote-conflict-diff-panel"]').innerText({ timeout: UI_TIMEOUT_MS });
|
||||
assert(diffText.includes(dirtyLocalToken), `dirty diff 应包含本地未保存内容: ${diffText}`);
|
||||
assert(diffText.includes(dirtyMarker), `dirty diff 应包含 AI 写盘内容: ${diffText}`);
|
||||
assert(!captured.some((entry) => entry.kind === "run"), `dirty buffer 下 Page AI 不应发起 Hermes run: ${JSON.stringify(captured)}`);
|
||||
const dirtyDiskText = fs.readFileSync(dirtyPath, "utf8");
|
||||
assert(!dirtyDiskText.includes(dirtyMarker), "dirty buffer 阻断后磁盘不应出现 AI 写入标记");
|
||||
const dirtyEditorState = await page.evaluate(() => {
|
||||
const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
|
||||
const conflictPanel = document.querySelector('[data-testid="mnote-editor-conflict-panel"]');
|
||||
return {
|
||||
status: root?.getAttribute("data-runtime-editor-status") || "",
|
||||
text: editor?.textContent || "",
|
||||
conflictVisible: Boolean(conflictPanel && conflictPanel.getClientRects().length > 0),
|
||||
};
|
||||
});
|
||||
assert(dirtyEditorState.text.includes(dirtyLocalToken), `dirty buffer 阻断后本地未保存内容应仍在编辑器中: ${JSON.stringify(dirtyEditorState)}`);
|
||||
assert.equal(dirtyEditorState.conflictVisible, false, `dirty buffer 阻断不应生成冲突面板: ${JSON.stringify(dirtyEditorState)}`);
|
||||
const dirtyAggregate = await fetchPageAggregate(page, dirtyDocumentId, rootUri);
|
||||
assert(
|
||||
JSON.stringify(dirtyAggregate.payload || {}).includes(dirtyMarker),
|
||||
`dirty Page Aggregate 应读回 AI 写入标记: ${JSON.stringify(dirtyAggregate)}`,
|
||||
!JSON.stringify(dirtyAggregate.payload || {}).includes(dirtyMarker),
|
||||
`dirty buffer 阻断后 Page Aggregate 不应读回 AI 写入标记: ${JSON.stringify(dirtyAggregate)}`,
|
||||
);
|
||||
const dirtyRunBody = JSON.parse(captured.find((entry) => entry.kind === "run")?.body || "{}");
|
||||
assert.equal(dirtyRunBody.documentId, dirtyDocumentId, `dirty Hermes run 应携带 local documentId: ${JSON.stringify(dirtyRunBody)}`);
|
||||
assert.equal(dirtyRunBody.sourceKind, "local_folder", `dirty Hermes run 应携带 local_folder sourceKind: ${JSON.stringify(dirtyRunBody)}`);
|
||||
assert.equal(dirtyRunBody.rootUri, rootUri, `dirty Hermes run 应携带 rootUri: ${JSON.stringify(dirtyRunBody)}`);
|
||||
|
||||
const result = {
|
||||
ok: true,
|
||||
@@ -409,8 +462,8 @@ async function main() {
|
||||
dirtyDocumentId,
|
||||
dirtyRunId,
|
||||
dirtyMarker,
|
||||
dirtyConflictStatus: "external-change-conflict",
|
||||
dirtyEnvelope: envelope,
|
||||
dirtyBlocked: true,
|
||||
dirtyEditorStatus: dirtyEditorState.status,
|
||||
capturedKinds: cleanCapturedKinds,
|
||||
dirtyCapturedKinds: captured.map((entry) => entry.kind),
|
||||
resultPath: RESULT_PATH,
|
||||
|
||||
@@ -64,6 +64,7 @@ async function uploadLocalAsset(page, root, documentId, fileName, mimeType, byte
|
||||
const form = new FormData();
|
||||
form.append("rootUri", rootUri);
|
||||
form.append("documentId", documentId);
|
||||
form.append("uploadIntent", "editor.markdown.attach");
|
||||
form.append("kind", kind);
|
||||
form.append("file", new File([new Uint8Array(bytes)], fileName, { type: mimeType }));
|
||||
const response = await fetch("/api/local-folder/assets/upload", { method: "POST", body: form });
|
||||
@@ -169,6 +170,16 @@ async function main() {
|
||||
assert(!/\b(description|article|notes)\b/i.test(markdownTabState.resourceText), `Markdown tab 不应显示 Material Symbols 文本: ${JSON.stringify(markdownTabState)}`);
|
||||
assert.equal(markdownTabState.badgeKind, "code", `Markdown tab 应使用黑色 code 色块: ${JSON.stringify(markdownTabState)}`);
|
||||
assert(markdownTabState.badgeColor === "rgb(17, 17, 17)", `Markdown tab 色块应为黑色: ${JSON.stringify(markdownTabState)}`);
|
||||
const openEditorsSnapshot = await page.evaluate(() => window.__mnoteOpenEditorsSnapshot || null);
|
||||
assert.equal(openEditorsSnapshot?.schema, "mnote.open_editors_snapshot.v1", `应输出 open editors snapshot: ${JSON.stringify(openEditorsSnapshot)}`);
|
||||
assert(openEditorsSnapshot.groups?.primary, `snapshot 应包含 primary group: ${JSON.stringify(openEditorsSnapshot)}`);
|
||||
assert(openEditorsSnapshot.groups?.secondary, `snapshot 应包含 secondary group: ${JSON.stringify(openEditorsSnapshot)}`);
|
||||
assert(openEditorsSnapshot.activeObjectIdentity.includes("resource-note.md"), `全局 active 应优先 primary active resource,而不是 secondary page: ${JSON.stringify(openEditorsSnapshot)}`);
|
||||
assert(openEditorsSnapshot.groups.primary.activeObjectIdentity.includes("resource-note.md"), `primary group active 应指向 markdown resource: ${JSON.stringify(openEditorsSnapshot.groups.primary)}`);
|
||||
assert(openEditorsSnapshot.groups.primary.resourceEditors.some((entry) => entry.paneRole === "primary" && entry.kind === "markdown" && entry.preview === false && entry.pinned === false), `primary resource editor 应包含轻量 editor 字段: ${JSON.stringify(openEditorsSnapshot.groups.primary)}`);
|
||||
assert(openEditorsSnapshot.groups.primary.editors.some((entry) => entry.kind === "page" && entry.pinned === true), `primary group 应包含 pinned page editor: ${JSON.stringify(openEditorsSnapshot.groups.primary)}`);
|
||||
assert(openEditorsSnapshot.groups.secondary.editors.some((entry) => entry.kind === "page" && entry.paneRole === "secondary"), `secondary group 应包含 secondary page editor: ${JSON.stringify(openEditorsSnapshot.groups.secondary)}`);
|
||||
assert(openEditorsSnapshot.groups.secondary.editors.some((entry) => entry.kind === "page" && entry.documentId === localMdDocumentId(sideRelativePath) && entry.workspacePath?.relativePath === sideRelativePath), `secondary page editor 应保留自己的 documentId/workspacePath: ${JSON.stringify(openEditorsSnapshot.groups.secondary)}`);
|
||||
const editor = page.locator('.mnote-resource-tab-panel:not([hidden]) .editor-surface .ProseMirror[contenteditable="true"]').first();
|
||||
await editor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await editor.click({ timeout: UI_TIMEOUT_MS });
|
||||
@@ -213,14 +224,24 @@ async function main() {
|
||||
assert(!/\b(description|article|notes)\b/i.test(officeTabState.resourceText), `Office tab 不应显示 Material Symbols 文本: ${JSON.stringify(officeTabState)}`);
|
||||
assert.equal(officeTabState.badgeKind, "word", `docx tab 应使用 word 色块: ${JSON.stringify(officeTabState)}`);
|
||||
assert(officeTabState.badgeColor === "rgb(79, 125, 243)", `docx tab 色块应与附件 word 蓝色一致: ${JSON.stringify(officeTabState)}`);
|
||||
const openEditorsSnapshotAfterOffice = await page.evaluate(() => window.__mnoteOpenEditorsSnapshot || null);
|
||||
const activeOfficeEditor = openEditorsSnapshotAfterOffice?.groups?.primary?.resourceEditors?.find((entry) => entry.active && entry.kind === "office");
|
||||
assert(activeOfficeEditor, `打开 Office 后 snapshot 应把 office resource 标为 active: ${JSON.stringify(openEditorsSnapshotAfterOffice?.groups?.primary)}`);
|
||||
assert.equal(activeOfficeEditor.documentId, documentId, `Office snapshot 应携带 documentId: ${JSON.stringify(activeOfficeEditor)}`);
|
||||
assert(activeOfficeEditor.workspaceId, `Office snapshot 应携带 workspaceId: ${JSON.stringify(activeOfficeEditor)}`);
|
||||
assert.equal(activeOfficeEditor.editorKind, "office", `Office snapshot 应携带 editorKind: ${JSON.stringify(activeOfficeEditor)}`);
|
||||
assert(activeOfficeEditor.lastActiveAt > 0, `Office passive resource 激活后 lastActiveAt 应大于 0: ${JSON.stringify(activeOfficeEditor)}`);
|
||||
const officeFrameSrc = await page.locator('.mnote-resource-tab-panel:not([hidden]) iframe.mnote-resource-tab-frame').first().getAttribute("src", {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
assert(officeFrameSrc, "Office resource tab 应创建 iframe");
|
||||
const officeFrameUrl = new URL(officeFrameSrc, BASE_URL);
|
||||
assert(officeFrameUrl.pathname === "/onlyoffice", `Office resource tab iframe 应指向 /onlyoffice,实际为 ${officeFrameSrc}`);
|
||||
assert(officeFrameUrl.searchParams.get("assetId") === officeAsset.id, "Office iframe URL 应携带当前资源 assetId");
|
||||
assert(officeFrameUrl.searchParams.get("mode") === "view", "Office resource tab iframe 应使用 view 模式");
|
||||
assert(officeFrameUrl.pathname === "/office-preview", `Office resource tab iframe 应指向 /office-preview 轻量预览,实际为 ${officeFrameSrc}`);
|
||||
assert(officeFrameUrl.searchParams.get("assetId") === officeAsset.id, "Office preview URL 应携带当前资源 assetId");
|
||||
assert(officeFrameUrl.searchParams.get("documentId") === documentId, "Office preview URL 应携带当前 documentId");
|
||||
assert(officeFrameUrl.searchParams.get("fileName") === "resource-office.docx", "Office preview URL 应携带资源文件名");
|
||||
assert(officeFrameUrl.searchParams.get("fileType") === "docx", "Office preview URL 应携带 docx 类型");
|
||||
assert(officeFrameUrl.searchParams.get("fileUrl")?.includes("/api/local-folder/files/open"), "Office preview URL 应通过 local-folder file open 回源");
|
||||
|
||||
const [officePopup] = await Promise.all([
|
||||
page.waitForEvent("popup", { timeout: UI_TIMEOUT_MS }),
|
||||
@@ -271,6 +292,31 @@ async function main() {
|
||||
if (markdownActiveState.resourceRows.includes(asset.id)) {
|
||||
assert.equal(markdownActiveState.activeRowAssetId, asset.id, `已渲染对应 filetree row 时应标记 data-active: ${JSON.stringify(markdownActiveState)}`);
|
||||
}
|
||||
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('.mnote-main-tab.is-active[data-mnote-tab-kind="markdown"]').waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.locator('.mnote-resource-tab-panel:not([hidden]) .editor-surface .ProseMirror[contenteditable="true"]').first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const restoredMarkdownState = await page.evaluate(() => {
|
||||
const snapshot = window.__mnoteOpenEditorsSnapshot || null;
|
||||
const active = snapshot?.groups?.primary?.resourceEditors?.find((entry) => entry.active && entry.kind === "markdown");
|
||||
return {
|
||||
activeResource: new URL(window.location.href).searchParams.get("resourceTab") || "",
|
||||
activeObjectIdentity: active?.objectIdentity || "",
|
||||
path: active?.path || "",
|
||||
workspacePath: active?.workspacePath || null,
|
||||
editorText: document.querySelector('.mnote-resource-tab-panel:not([hidden]) .editor-surface .ProseMirror')?.textContent || "",
|
||||
};
|
||||
});
|
||||
assert(restoredMarkdownState.activeResource.includes("resource-note.md"), `刷新后应保留 canonical resourceTab URL 状态: ${JSON.stringify(restoredMarkdownState)}`);
|
||||
assert(restoredMarkdownState.activeObjectIdentity.includes("resource-note.md"), `刷新后应从 OpenEditorsSnapshot 恢复 active markdown resource: ${JSON.stringify(restoredMarkdownState)}`);
|
||||
assert.equal(restoredMarkdownState.path, "README/resource-note.md", `刷新后 active resource snapshot 应保留 path: ${JSON.stringify(restoredMarkdownState)}`);
|
||||
assert.equal(restoredMarkdownState.workspacePath?.relativePath, "README/resource-note.md", `刷新后 active resource 应带 workspacePath.relativePath: ${JSON.stringify(restoredMarkdownState)}`);
|
||||
assert(restoredMarkdownState.editorText.includes("新增资源正文"), `刷新后 resource editor 应读回资源正文: ${JSON.stringify(restoredMarkdownState)}`);
|
||||
|
||||
const mdCloseBtn = page.locator('.mnote-main-tab.is-active[data-mnote-tab-kind="markdown"] .mnote-main-tab-close');
|
||||
await mdCloseBtn.click({ timeout: UI_TIMEOUT_MS });
|
||||
|
||||
@@ -59,6 +59,7 @@ async function uploadLocalAsset(page, root, documentId, fileName, mimeType, byte
|
||||
const form = new FormData();
|
||||
form.append("rootUri", rootUri);
|
||||
form.append("documentId", documentId);
|
||||
form.append("uploadIntent", "editor.markdown.attach");
|
||||
form.append("kind", kind);
|
||||
form.append("file", new File([new Uint8Array(bytes)], fileName, { type: mimeType }));
|
||||
const response = await fetch("/api/local-folder/assets/upload", { method: "POST", body: form });
|
||||
@@ -173,7 +174,7 @@ async function main() {
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
// 尝试关闭 dirty 的 resource tab — 应被阻止
|
||||
const closeBtn = page.locator('.mnote-main-tab.is-active .mnote-main-tab-close');
|
||||
const closeBtn = page.locator('.mnote-main-tab.is-active[data-pane-role="primary"] .mnote-main-tab-close:not([data-mnote-pane-close])');
|
||||
await closeBtn.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
|
||||
@@ -84,6 +84,20 @@ async function waitForFileExists(filePath, timeoutMs) {
|
||||
assert(fs.existsSync(filePath), `等待文件出现失败: ${filePath}`);
|
||||
}
|
||||
|
||||
async function expandFileTreeFolder(page, title) {
|
||||
const folderRow = page.locator('#sidebar-file-tree-root .tree-row[data-row-kind="folder"]', { hasText: title }).first();
|
||||
await folderRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const expanded = await folderRow.getAttribute("aria-expanded");
|
||||
if (expanded !== "true") {
|
||||
const toggle = folderRow.locator('[data-rust-action="toggle"]').first();
|
||||
if (await toggle.count()) {
|
||||
await toggle.click({ timeout: UI_TIMEOUT_MS });
|
||||
} else {
|
||||
await folderRow.click({ timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function suggestedFilenames(downloads, startIndex) {
|
||||
return downloads.slice(startIndex).map((download) => download.suggestedFilename()).sort();
|
||||
}
|
||||
@@ -145,6 +159,7 @@ async function main() {
|
||||
}).catch(() => undefined);
|
||||
const editorReady = await editorSurface.isVisible().catch(() => false);
|
||||
|
||||
await expandFileTreeFolder(page, "README");
|
||||
const assetRow = page.locator('[data-testid="filetree-asset-row"]', { hasText: "report-a.pdf" }).first();
|
||||
await assetRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await assetRow.click({ button: "right", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
@@ -521,7 +521,16 @@ async function main() {
|
||||
assert(fs.existsSync(assetPath), "测试前置条件:附件文件应存在");
|
||||
fs.unlinkSync(assetPath);
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
async () => {
|
||||
const now = Date.now();
|
||||
if (
|
||||
typeof window.__mnoteRefreshEditorLocalAttachmentExistence === "function"
|
||||
&& (!window.__mnoteTask479LastAttachmentRefreshAt || now - window.__mnoteTask479LastAttachmentRefreshAt > 500)
|
||||
) {
|
||||
window.__mnoteTask479LastAttachmentRefreshAt = now;
|
||||
window.__mnoteRefreshEditorLocalAttachmentExistence();
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
const link = Array.from(document.querySelectorAll('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]'))
|
||||
.find((node) => (node.textContent || "").includes("resource-note.md"));
|
||||
return link instanceof HTMLAnchorElement && link.getAttribute("data-mnote-attachment-missing") === "true";
|
||||
@@ -560,12 +569,16 @@ async function main() {
|
||||
// 预期行为:链接应保留
|
||||
const linkPreserved = linkTextAfter.includes("resource-note.md");
|
||||
const popupCountBefore = popups.length;
|
||||
const brokenLink = page
|
||||
.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a', {
|
||||
hasText: "resource-note.md",
|
||||
})
|
||||
.first();
|
||||
await brokenLink.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.evaluate(() => {
|
||||
const brokenLink = Array.from(document.querySelectorAll('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a'))
|
||||
.find((node) => node instanceof HTMLAnchorElement && (node.textContent || "").includes("resource-note.md"));
|
||||
if (!(brokenLink instanceof HTMLAnchorElement)) throw new Error("broken_link_missing");
|
||||
brokenLink.dispatchEvent(new MouseEvent("click", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
view: window,
|
||||
}));
|
||||
});
|
||||
await page.waitForTimeout(1500);
|
||||
const popupCountAfter = popups.length;
|
||||
const resourceTabErrorVisible = await page
|
||||
@@ -602,10 +615,36 @@ async function main() {
|
||||
}
|
||||
} catch (err) {
|
||||
overallOk = false;
|
||||
const debugState = await page.evaluate(async () => {
|
||||
const collectLinks = () => Array.from(document.querySelectorAll('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a'))
|
||||
.map((node) => ({
|
||||
text: node.textContent || "",
|
||||
href: node instanceof HTMLAnchorElement ? node.getAttribute("href") || "" : "",
|
||||
missing: node instanceof HTMLElement ? node.getAttribute("data-mnote-attachment-missing") : "",
|
||||
className: node instanceof HTMLElement ? node.getAttribute("class") || "" : "",
|
||||
}));
|
||||
const beforeManualRefresh = collectLinks();
|
||||
if (typeof window.__mnoteRefreshEditorLocalAttachmentExistence === "function") {
|
||||
window.__mnoteRefreshEditorLocalAttachmentExistence();
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
}
|
||||
const pane = document.querySelector('.document-pane[data-pane-role="primary"]');
|
||||
const editor = pane?.querySelector('.editor-surface .ProseMirror');
|
||||
return {
|
||||
url: window.location.href,
|
||||
hasManualRefresh: typeof window.__mnoteRefreshEditorLocalAttachmentExistence,
|
||||
status: pane?.querySelector('[data-runtime-editor-status]')?.getAttribute("data-runtime-editor-status") || "",
|
||||
conflictVisible: Boolean(pane?.querySelector('[data-testid="mnote-editor-conflict-panel"]')),
|
||||
editorText: editor instanceof HTMLElement ? (editor.textContent || "").slice(0, 500) : "",
|
||||
beforeManualRefresh,
|
||||
afterManualRefresh: collectLinks(),
|
||||
};
|
||||
}).catch((debugErr) => ({ debugError: debugErr.message }));
|
||||
checks[checkId] = {
|
||||
ok: false,
|
||||
message: `删除真实附件后正文链接保留检查失败: ${err.message}`,
|
||||
error: err.stack,
|
||||
debugState,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,8 +87,6 @@ async function main() {
|
||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
|
||||
MNOTE_CONTROL_PLANE_DB_PATH: dbPath,
|
||||
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
|
||||
CONVEX_SELF_HOSTED_URL: "http://127.0.0.1:9",
|
||||
NEXT_PUBLIC_CONVEX_URL: "http://127.0.0.1:9",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
@@ -122,8 +122,6 @@ async function main() {
|
||||
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
|
||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
|
||||
MNOTE_LOCAL_ACCESS_POLICY_FILE: policyFile,
|
||||
CONVEX_SELF_HOSTED_URL: "http://127.0.0.1:9",
|
||||
NEXT_PUBLIC_CONVEX_URL: "http://127.0.0.1:9",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
@@ -173,6 +171,9 @@ async function main() {
|
||||
showHeadingNumbers: true,
|
||||
wideLayout: true,
|
||||
pageFont: "song",
|
||||
"pageWidth.markdown": { mode: "wide", custom: null },
|
||||
"pageWidth.word": { mode: "wide", custom: null },
|
||||
"pageWidth.excel": { mode: "full", custom: null },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -187,10 +188,35 @@ async function main() {
|
||||
assert.equal(sameUserEffective.pageOptions.showHeadingNumbers, true, "同用户第二浏览器应读到标题编号偏好");
|
||||
assert.equal(sameUserEffective.pageOptions.wideLayout, true, "同用户第二浏览器应读到页面宽度偏好");
|
||||
assert.equal(sameUserEffective.pageOptions.pageFont, "song", "同用户第二浏览器应读到页面字体偏好");
|
||||
assert.equal(sameUserEffective.pageWidthPreferences.markdown.mode, "wide", "同用户第二浏览器应读到 Markdown 宽度偏好");
|
||||
assert.equal(sameUserEffective.pageWidthPreferences.markdown.source, "global", "页面宽度偏好应存为全局 scope");
|
||||
assert.equal(sameUserEffective.pageWidthPreferences.excel.mode, "full", "同用户第二浏览器应读到 Excel 宽度偏好");
|
||||
|
||||
const documentPage = await secondContext.newPage();
|
||||
const documentUrl = new URL(`/documents/${encodeURIComponent(documentId)}`, baseUrl);
|
||||
documentUrl.searchParams.set("sourceKind", "local_folder");
|
||||
documentUrl.searchParams.set("rootUri", rootUri);
|
||||
await documentPage.goto(documentUrl.toString(), { waitUntil: "domcontentloaded", timeout: TIMEOUT_MS });
|
||||
await documentPage.waitForSelector('.document-shell[data-page-width-resolved-mode="wide"]', { timeout: TIMEOUT_MS });
|
||||
const shellWidthState = await documentPage.evaluate(() => {
|
||||
const shell = document.querySelector(".document-shell");
|
||||
if (!(shell instanceof HTMLElement)) return null;
|
||||
return {
|
||||
mode: shell.getAttribute("data-page-width-resolved-mode"),
|
||||
source: shell.getAttribute("data-page-width-source"),
|
||||
maxWidth: getComputedStyle(shell).maxWidth,
|
||||
};
|
||||
});
|
||||
assert(shellWidthState, "文档页应渲染 document-shell");
|
||||
assert.equal(shellWidthState.mode, "wide", "Markdown 文档页应消费全局 Markdown 宽度偏好");
|
||||
assert.equal(shellWidthState.source, "global", "Markdown 文档页宽度来源应为 global");
|
||||
assert.equal(shellWidthState.maxWidth, "1180px", "Markdown 文档页宽版 max-width 应为 1180px");
|
||||
await documentPage.close();
|
||||
|
||||
const otherUserEffective = await effectivePreferences(otherContext.request, baseUrl, documentId, rootUri);
|
||||
assert.equal(otherUserEffective.pageOptions.hideTitleHeader, true, "不同用户不应串读 source family 偏好");
|
||||
assert.equal(otherUserEffective.pageOptions.showHeadingNumbers, false, "不同用户不应串读 global 偏好");
|
||||
assert.equal(otherUserEffective.pageWidthPreferences.markdown.mode, "readable", "不同用户不应串读 Markdown 宽度偏好");
|
||||
|
||||
console.log("task493 page settings sqlite preferences smoke passed");
|
||||
} finally {
|
||||
|
||||
@@ -92,8 +92,6 @@ async function main() {
|
||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
|
||||
MNOTE_CONTROL_PLANE_DB_PATH: dbPath,
|
||||
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
|
||||
CONVEX_SELF_HOSTED_URL: "http://127.0.0.1:9",
|
||||
NEXT_PUBLIC_CONVEX_URL: "http://127.0.0.1:9",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
@@ -9,6 +9,14 @@ const RUNTIME_PATH = path.join(
|
||||
REPO_ROOT,
|
||||
"rust/crates/mnote-web/browser/sidebar-filetree-command-runtime.js",
|
||||
);
|
||||
const FILETREE_RUNTIME_PATH = path.join(
|
||||
REPO_ROOT,
|
||||
"rust/crates/mnote-web/browser/filetree-runtime.js",
|
||||
);
|
||||
const FILETREE_CONTEXT_MENU_RUNTIME_PATH = path.join(
|
||||
REPO_ROOT,
|
||||
"rust/crates/mnote-web/browser/filetree-context-menu-runtime.js",
|
||||
);
|
||||
|
||||
function loadRuntimeFactory() {
|
||||
const source = fs
|
||||
@@ -64,6 +72,9 @@ function loadRuntimeFactory() {
|
||||
};
|
||||
context.globalThis = context;
|
||||
vm.createContext(context);
|
||||
vm.runInContext(fs.readFileSync(FILETREE_CONTEXT_MENU_RUNTIME_PATH, "utf8"), context, {
|
||||
filename: FILETREE_CONTEXT_MENU_RUNTIME_PATH,
|
||||
});
|
||||
vm.runInContext(
|
||||
`${source}\nglobalThis.__factory = createSidebarFileTreeCommandRuntime;`,
|
||||
context,
|
||||
@@ -72,6 +83,23 @@ function loadRuntimeFactory() {
|
||||
return context;
|
||||
}
|
||||
|
||||
function loadFileTreeRuntime() {
|
||||
const context = {
|
||||
console,
|
||||
HTMLElement: class HTMLElement {},
|
||||
document: {},
|
||||
window: {},
|
||||
URL,
|
||||
};
|
||||
context.globalThis = context;
|
||||
context.window = context;
|
||||
vm.createContext(context);
|
||||
vm.runInContext(fs.readFileSync(FILETREE_RUNTIME_PATH, "utf8"), context, {
|
||||
filename: FILETREE_RUNTIME_PATH,
|
||||
});
|
||||
return context;
|
||||
}
|
||||
|
||||
async function runCopyId(runtime, context, detail) {
|
||||
context.__clipboardText = "";
|
||||
runtime.handleTreeContextMenuAction("copy-id", detail, null);
|
||||
@@ -80,10 +108,151 @@ async function runCopyId(runtime, context, detail) {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const fileTreeContext = loadFileTreeRuntime();
|
||||
class FakeRow extends fileTreeContext.HTMLElement {
|
||||
constructor(attrs, textContent = "") {
|
||||
super();
|
||||
this.attrs = attrs;
|
||||
this.textContent = textContent;
|
||||
}
|
||||
getAttribute(name) {
|
||||
return this.attrs[name] || "";
|
||||
}
|
||||
querySelector() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const readWorkspacePath = (attrs, textContent = "") =>
|
||||
fileTreeContext.window.__mnoteFileTreeRuntime.readWorkspacePathFromRow(
|
||||
new FakeRow(attrs, textContent),
|
||||
{
|
||||
currentSourceKind: () => attrs.__sourceKind || "local_folder",
|
||||
currentRootUri: () => attrs.__rootUri || "file:///mnt/Data1T/mnote",
|
||||
resolveWorkspaceId: () => "local-folder",
|
||||
rowTitle: (row) => row.textContent,
|
||||
},
|
||||
);
|
||||
const workspacePath = readWorkspacePath({
|
||||
"data-row-id": "local:asset:assets/report.pdf",
|
||||
"data-row-kind": "asset",
|
||||
"data-document-id": "local-md:README.md",
|
||||
"data-asset-id": "local-file:assets/report.pdf",
|
||||
"data-local-relative-path": "assets/report.pdf",
|
||||
"data-object-identity": JSON.stringify({
|
||||
objectKind: "attachment",
|
||||
documentId: "local-md:README.md",
|
||||
assetId: "local-file:assets/report.pdf",
|
||||
}),
|
||||
}, "report.pdf");
|
||||
assert.equal(workspacePath.schema, "mnote.workspace_path.v1");
|
||||
assert.equal(workspacePath.workspaceId, "local-folder");
|
||||
assert.equal(workspacePath.sourceKind, "local_folder");
|
||||
assert.equal(workspacePath.rootUri, "file:///mnt/Data1T/mnote");
|
||||
assert.equal(workspacePath.relativePath, "assets/report.pdf");
|
||||
assert.equal(workspacePath.documentId, "local-md:README.md");
|
||||
assert.equal(workspacePath.assetId, "local-file:assets/report.pdf");
|
||||
assert.equal(workspacePath.objectKind, "attachment");
|
||||
assert.equal(workspacePath.resourceKind, "attachment");
|
||||
assert.equal(workspacePath.objectIdentity.assetId, "local-file:assets/report.pdf");
|
||||
|
||||
const markdownWorkspacePath = readWorkspacePath({
|
||||
"data-row-id": "local:markdown:docs/Page.md",
|
||||
"data-row-kind": "markdown",
|
||||
"data-document-id": "local-md:docs~2FPage.md",
|
||||
"data-local-relative-path": "docs/Page.md",
|
||||
"data-object-identity": JSON.stringify({
|
||||
objectKind: "page",
|
||||
documentId: "local-md:docs~2FPage.md",
|
||||
assetId: null,
|
||||
}),
|
||||
}, "Page.md");
|
||||
assert.equal(markdownWorkspacePath.relativePath, "docs/Page.md");
|
||||
assert.equal(markdownWorkspacePath.objectKind, "page");
|
||||
assert.equal(markdownWorkspacePath.isLocalFolder, true);
|
||||
|
||||
const folderWorkspacePath = readWorkspacePath({
|
||||
"data-row-id": "local:folder:docs",
|
||||
"data-row-kind": "folder",
|
||||
"data-document-id": "local-dir:docs",
|
||||
"data-local-relative-path": "docs",
|
||||
"data-object-identity": JSON.stringify({
|
||||
objectKind: "index",
|
||||
documentId: "local-dir:docs",
|
||||
assetId: null,
|
||||
}),
|
||||
}, "docs");
|
||||
assert.equal(folderWorkspacePath.relativePath, "docs");
|
||||
assert.equal(folderWorkspacePath.objectKind, "index");
|
||||
|
||||
const mindmapWorkspacePath = readWorkspacePath({
|
||||
"data-row-id": "local:asset:Page/思维导图.json",
|
||||
"data-row-kind": "asset",
|
||||
"data-asset-id": "local-file:Page/思维导图.json",
|
||||
"data-local-relative-path": "Page/思维导图.json",
|
||||
"data-object-identity": JSON.stringify({
|
||||
objectKind: "mindmap",
|
||||
documentId: "local-md:Page~2FPage.md",
|
||||
assetId: "local-file:Page/思维导图.json",
|
||||
}),
|
||||
}, "思维导图.json");
|
||||
assert.equal(mindmapWorkspacePath.objectKind, "mindmap");
|
||||
assert.equal(mindmapWorkspacePath.objectIdentity.assetId, "local-file:Page/思维导图.json");
|
||||
|
||||
const officeWorkspacePath = readWorkspacePath({
|
||||
"data-row-id": "local:asset:Page/report.docx",
|
||||
"data-row-kind": "asset",
|
||||
"data-asset-id": "local-file:Page/report.docx",
|
||||
"data-local-relative-path": "Page/report.docx",
|
||||
"data-object-identity": JSON.stringify({
|
||||
objectKind: "only_office",
|
||||
documentId: "local-md:Page~2FPage.md",
|
||||
assetId: "local-file:Page/report.docx",
|
||||
}),
|
||||
}, "report.docx");
|
||||
assert.equal(officeWorkspacePath.objectKind, "only_office");
|
||||
assert.equal(officeWorkspacePath.objectIdentity.assetId, "local-file:Page/report.docx");
|
||||
|
||||
const nonLocalWorkspacePath = readWorkspacePath({
|
||||
__sourceKind: "cloud",
|
||||
__rootUri: "",
|
||||
"data-row-id": "doc:cloud-page",
|
||||
"data-row-kind": "document",
|
||||
"data-document-id": "local-cloud-compatible-id",
|
||||
"data-object-identity": JSON.stringify({
|
||||
objectKind: "page",
|
||||
documentId: "local-cloud-compatible-id",
|
||||
assetId: null,
|
||||
}),
|
||||
}, "cloud");
|
||||
assert.equal(nonLocalWorkspacePath.isLocalFolder, false, "sourceKind 非 local_folder 时不应仅因 local-* documentId 误判成本地文件夹");
|
||||
|
||||
const context = loadRuntimeFactory();
|
||||
class FakeCommandBadge extends context.HTMLElement {
|
||||
constructor(kind) {
|
||||
super();
|
||||
this.kind = kind;
|
||||
}
|
||||
getAttribute(name) {
|
||||
return name === "data-kind" ? this.kind : "";
|
||||
}
|
||||
}
|
||||
class FakeCommandRow extends context.HTMLElement {
|
||||
constructor(attrs, textContent = "", badgeKind = "") {
|
||||
super();
|
||||
this.attrs = attrs;
|
||||
this.textContent = textContent;
|
||||
this.badge = badgeKind ? new FakeCommandBadge(badgeKind) : null;
|
||||
}
|
||||
getAttribute(name) {
|
||||
return this.attrs[name] || "";
|
||||
}
|
||||
querySelector(selector) {
|
||||
return selector === ".tree-kind-badge" ? this.badge : null;
|
||||
}
|
||||
}
|
||||
const runtime = context.__factory({
|
||||
currentRootUri: () => "file:///mnt/Data1T/mnote",
|
||||
currentSourceKind: () => "local_folder",
|
||||
currentRootUri: () => "file:///mnt/Data1T/mnote",
|
||||
cssEscape: (value) => String(value).replace(/"/g, '\\"'),
|
||||
fileTreeRuntimeFunction: () => null,
|
||||
localFilePathFromAssetId: (assetId) =>
|
||||
@@ -92,6 +261,139 @@ async function main() {
|
||||
runtimeState: { activeTreeContextMenu: null },
|
||||
selectedSidebarFileTreeSelection: { selectedRowIds: new Set(), focusedRowId: null },
|
||||
});
|
||||
assert.equal(
|
||||
runtime.classifySidebarFileTreeAsset(new FakeCommandRow({}, "legacy.luckysheet", "luckysheet")),
|
||||
"table",
|
||||
"legacy luckysheet 行仍应归为 table 资源",
|
||||
);
|
||||
const dirtyCommandRow = new FakeCommandRow({
|
||||
"data-row-id": "local:markdown:docs/Page.md",
|
||||
"data-row-kind": "markdown",
|
||||
"data-document-id": "local-md:docs~2FPage.md",
|
||||
"data-local-relative-path": "docs/Page.md",
|
||||
}, "Page.md");
|
||||
const commandContextRuntime = context.window.__mnoteFileTreeContextMenuRuntime;
|
||||
const dirtyContext = commandContextRuntime.buildFileTreeCommandContext(
|
||||
dirtyCommandRow,
|
||||
{
|
||||
schema: "mnote.open_editors_snapshot.v1",
|
||||
editors: [
|
||||
{
|
||||
documentId: "local-md:docs~2FPage.md",
|
||||
dirtyState: "dirty",
|
||||
workspacePath: { relativePath: "docs/Page.md" },
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
{
|
||||
currentSourceKind: () => "local_folder",
|
||||
workspaceReadonly: () => false,
|
||||
selection: { selectedRowIds: new Set(["local:markdown:docs/Page.md"]) },
|
||||
queryRowById: () => dirtyCommandRow,
|
||||
},
|
||||
);
|
||||
assert.equal(dirtyContext["workspace.sourceKind"], "local_folder");
|
||||
assert.equal(dirtyContext["tree.selectionCount"], 1);
|
||||
assert.equal(dirtyContext["tree.selectionResourceKind"], "page");
|
||||
assert.equal(dirtyContext["tree.targetResourceKind"], "page");
|
||||
assert.equal(dirtyContext["editor.dirty"], true);
|
||||
assert.equal(dirtyContext["editor.dirtyState"], "dirty");
|
||||
assert.equal(dirtyContext["ai.canWrite"], false);
|
||||
assert.equal(
|
||||
commandContextRuntime.evaluateSidebarFileTreeWhen(dirtyContext, "!workspace.readonly && !editor.dirty"),
|
||||
false,
|
||||
"dirty 目标下菜单与快捷键写操作应被统一 command context 禁用",
|
||||
);
|
||||
const readonlyContext = commandContextRuntime.buildFileTreeCommandContext(
|
||||
dirtyCommandRow,
|
||||
{ editors: [] },
|
||||
null,
|
||||
{
|
||||
currentSourceKind: () => "local_folder",
|
||||
workspaceReadonly: () => true,
|
||||
selection: { selectedRowIds: new Set(["local:markdown:docs/Page.md"]) },
|
||||
queryRowById: () => dirtyCommandRow,
|
||||
},
|
||||
);
|
||||
assert.equal(readonlyContext["workspace.readonly"], true);
|
||||
assert.equal(
|
||||
commandContextRuntime.evaluateSidebarFileTreeWhen(readonlyContext, "!workspace.readonly"),
|
||||
false,
|
||||
"readonly 目标下菜单与快捷键写操作应被统一 command context 禁用",
|
||||
);
|
||||
const cleanTargetRow = new FakeCommandRow({
|
||||
"data-row-id": "local:markdown:docs/Clean.md",
|
||||
"data-row-kind": "markdown",
|
||||
"data-document-id": "local-md:docs~2FClean.md",
|
||||
"data-local-relative-path": "docs/Clean.md",
|
||||
}, "Clean.md");
|
||||
const selectedRows = new Map([
|
||||
["local:markdown:docs/Clean.md", cleanTargetRow],
|
||||
["local:markdown:docs/Page.md", dirtyCommandRow],
|
||||
]);
|
||||
const multiSelectDirtyContext = commandContextRuntime.buildFileTreeCommandContext(
|
||||
cleanTargetRow,
|
||||
{
|
||||
editors: [
|
||||
{
|
||||
documentId: "local-md:docs~2FPage.md",
|
||||
dirtyState: "Dirty",
|
||||
workspacePath: { relativePath: "docs/Page.md" },
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
{
|
||||
currentSourceKind: () => "local_folder",
|
||||
workspaceReadonly: () => false,
|
||||
selection: { selectedRowIds: new Set(["local:markdown:docs/Clean.md", "local:markdown:docs/Page.md"]) },
|
||||
queryRowById: (rowId) => selectedRows.get(rowId),
|
||||
},
|
||||
);
|
||||
assert.equal(multiSelectDirtyContext["tree.selectionCount"], 2);
|
||||
assert.equal(multiSelectDirtyContext["tree.selectionResourceKind"], "page");
|
||||
assert.equal(multiSelectDirtyContext["editor.dirty"], true);
|
||||
assert.equal(multiSelectDirtyContext["editor.dirtyState"], "dirty");
|
||||
assert.equal(
|
||||
commandContextRuntime.evaluateSidebarFileTreeWhen(multiSelectDirtyContext, "!workspace.readonly && !editor.dirty"),
|
||||
false,
|
||||
"多选中任一非 target 行 dirty 时,菜单与快捷键写操作也必须禁用",
|
||||
);
|
||||
const dirtyAssetRow = new FakeCommandRow({
|
||||
"data-row-id": "local:asset:assets/report.pdf",
|
||||
"data-row-kind": "asset",
|
||||
"data-asset-id": "local-file:assets/report.pdf",
|
||||
"data-local-relative-path": "assets/report.pdf",
|
||||
}, "report.pdf");
|
||||
const dirtyAssetContext = commandContextRuntime.buildFileTreeCommandContext(
|
||||
dirtyAssetRow,
|
||||
{
|
||||
resourceEditors: [
|
||||
{
|
||||
assetId: "local-file:assets/report.pdf",
|
||||
dirtyState: "ExternalModified",
|
||||
workspacePath: { relativePath: "assets/report.pdf", assetId: "local-file:assets/report.pdf" },
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
{
|
||||
currentSourceKind: () => "local_folder",
|
||||
workspaceReadonly: () => false,
|
||||
selection: { selectedRowIds: new Set(["local:asset:assets/report.pdf"]) },
|
||||
queryRowById: () => dirtyAssetRow,
|
||||
},
|
||||
);
|
||||
assert.equal(dirtyAssetContext["tree.selectionResourceKind"], "asset");
|
||||
assert.equal(dirtyAssetContext["tree.targetIsAsset"], true);
|
||||
assert.equal(dirtyAssetContext["editor.dirty"], true);
|
||||
assert.equal(dirtyAssetContext["editor.dirtyState"], "external_modified");
|
||||
assert.equal(
|
||||
commandContextRuntime.evaluateSidebarFileTreeWhen(dirtyAssetContext, "!workspace.readonly && !editor.dirty"),
|
||||
false,
|
||||
"dirty resource tab 对应 asset 的 rename/delete 也必须禁用",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
await runCopyId(runtime, context, {
|
||||
@@ -99,6 +401,7 @@ async function main() {
|
||||
rowKind: "folder",
|
||||
documentId: "local-dir:docs",
|
||||
rowId: "local:folder:docs",
|
||||
workspacePath: folderWorkspacePath,
|
||||
title: "docs",
|
||||
}),
|
||||
"/mnt/Data1T/mnote/docs",
|
||||
@@ -111,6 +414,7 @@ async function main() {
|
||||
rowKind: "markdown",
|
||||
documentId: "local-md:docs~2FPage.md",
|
||||
rowId: "local:markdown:docs~2FPage.md",
|
||||
workspacePath: markdownWorkspacePath,
|
||||
title: "Page",
|
||||
}),
|
||||
"/mnt/Data1T/mnote/docs/Page.md",
|
||||
@@ -121,8 +425,9 @@ async function main() {
|
||||
await runCopyId(runtime, context, {
|
||||
contextKind: "filetree",
|
||||
rowKind: "asset",
|
||||
assetId: "local-file:assets~2Freport.pdf",
|
||||
rowId: "local:asset:assets~2Freport.pdf",
|
||||
assetId: "local-file:assets/report.pdf",
|
||||
rowId: "local:asset:assets/report.pdf",
|
||||
workspacePath,
|
||||
title: "report.pdf",
|
||||
}),
|
||||
"/mnt/Data1T/mnote/assets/report.pdf",
|
||||
|
||||
@@ -87,8 +87,6 @@ async function main() {
|
||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
|
||||
MNOTE_CONTROL_PLANE_DB_PATH: dbPath,
|
||||
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
|
||||
CONVEX_SELF_HOSTED_URL: "http://127.0.0.1:9",
|
||||
NEXT_PUBLIC_CONVEX_URL: "http://127.0.0.1:9",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
@@ -163,8 +163,6 @@ async function main() {
|
||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
|
||||
MNOTE_CONTROL_PLANE_DB_PATH: dbPath,
|
||||
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
|
||||
CONVEX_SELF_HOSTED_URL: "http://127.0.0.1:9",
|
||||
NEXT_PUBLIC_CONVEX_URL: "http://127.0.0.1:9",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
@@ -166,8 +166,6 @@ async function main() {
|
||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
|
||||
MNOTE_CONTROL_PLANE_DB_PATH: dbPath,
|
||||
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
|
||||
CONVEX_SELF_HOSTED_URL: "http://127.0.0.1:9",
|
||||
NEXT_PUBLIC_CONVEX_URL: "http://127.0.0.1:9",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
@@ -190,8 +190,6 @@ async function main() {
|
||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
|
||||
MNOTE_CONTROL_PLANE_DB_PATH: dbPath,
|
||||
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
|
||||
CONVEX_SELF_HOSTED_URL: "http://127.0.0.1:9",
|
||||
NEXT_PUBLIC_CONVEX_URL: "http://127.0.0.1:9",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const http = require("node:http");
|
||||
const net = require("node:net");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { spawn } = require("node:child_process");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const TASK = "task500-navigation-page-route-guard-smoke";
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 45_000);
|
||||
const TEST_PASSWORD = process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
|
||||
|
||||
function resolveChromiumExecutablePath() {
|
||||
const explicit = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || "";
|
||||
if (explicit && fs.existsSync(explicit)) return explicit;
|
||||
return [
|
||||
"/usr/bin/google-chrome-stable",
|
||||
"/usr/bin/google-chrome",
|
||||
"/snap/bin/chromium",
|
||||
"/usr/bin/chromium",
|
||||
"/usr/bin/chromium-browser",
|
||||
].find((candidate) => fs.existsSync(candidate)) || "";
|
||||
}
|
||||
|
||||
function pickPort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
const port = address && typeof address === "object" ? address.port : 0;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
server.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function waitForHttpOk(url, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
return new Promise((resolve, reject) => {
|
||||
const tick = () => {
|
||||
const request = http.get(url, (response) => {
|
||||
response.resume();
|
||||
if (response.statusCode >= 200 && response.statusCode < 500) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
retry();
|
||||
});
|
||||
request.on("error", retry);
|
||||
request.setTimeout(1000, () => {
|
||||
request.destroy();
|
||||
retry();
|
||||
});
|
||||
};
|
||||
const retry = () => {
|
||||
if (Date.now() > deadline) {
|
||||
reject(new Error(`server_not_ready: ${url}`));
|
||||
return;
|
||||
}
|
||||
setTimeout(tick, 250);
|
||||
};
|
||||
tick();
|
||||
});
|
||||
}
|
||||
|
||||
function fileUrlToPath(value) {
|
||||
const url = new URL(value);
|
||||
return decodeURIComponent(url.pathname);
|
||||
}
|
||||
|
||||
async function signUp(context, baseUrl, actorId) {
|
||||
const authResponse = await context.request.fetch(`${baseUrl}/api/auth`, {
|
||||
method: "POST",
|
||||
data: {
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: {
|
||||
email: `${actorId}@example.com`,
|
||||
username: actorId,
|
||||
name: actorId,
|
||||
password: TEST_PASSWORD,
|
||||
flow: "signUp",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
assert(authResponse.ok(), `测试账号注册失败: ${authResponse.status()} ${await authResponse.text()}`);
|
||||
}
|
||||
|
||||
async function openDefaultLocalWorkspace(page, baseUrl) {
|
||||
await page.goto(baseUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
const createButton = page.locator('[data-testid="mnote-create-default-local-workspace"]').first();
|
||||
if (await createButton.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await createButton.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForURL((url) => url.searchParams.get("sourceKind") === "local_folder", {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
const current = new URL(page.url());
|
||||
const rootUri = current.searchParams.get("rootUri")
|
||||
|| await page.evaluate(() => document.body.getAttribute("data-mnote-root-uri") || "");
|
||||
assert(rootUri, "应进入 local_folder workspace");
|
||||
return {
|
||||
rootUri,
|
||||
workspaceId: current.searchParams.get("workspaceId") || "",
|
||||
};
|
||||
}
|
||||
|
||||
async function assertNavigationPage(page) {
|
||||
await page.locator('[data-testid="mnote-navigation-page"]').waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
assert.equal(await page.locator("#__MNOTE_PAGE_AGGREGATE__").count(), 0, "导航页不应注入 Page Aggregate");
|
||||
assert.equal(await page.locator("#__MNOTE_EDITOR_BOOTSTRAP__").count(), 0, "导航页不应注入 editor bootstrap");
|
||||
assert.equal(await page.locator(".mnote-leptos-tiptap-spike-island").count(), 0, "导航页不应启动 tiptap island");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const port = await pickPort();
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), `${TASK}-`));
|
||||
const dbPath = path.join(dataRoot, "control-plane.sqlite3");
|
||||
const actorId = `${TASK}-${process.pid}-${Date.now()}`;
|
||||
const server = spawn("cargo", ["run", "-p", "mnote-web", "--bin", "mnote-web"], {
|
||||
cwd: path.join(__dirname, "..", "rust"),
|
||||
env: {
|
||||
...process.env,
|
||||
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
|
||||
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
|
||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
|
||||
MNOTE_CONTROL_PLANE_DB_PATH: dbPath,
|
||||
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stderr = "";
|
||||
server.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
const executablePath = resolveChromiumExecutablePath();
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
});
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||
const page = await context.newPage();
|
||||
const diagnostics = [];
|
||||
page.on("console", (message) => diagnostics.push(`console:${message.type()}:${message.text()}`));
|
||||
page.on("pageerror", (error) => diagnostics.push(`pageerror:${error.message}`));
|
||||
|
||||
try {
|
||||
await waitForHttpOk(`${baseUrl}/health`, 60_000);
|
||||
|
||||
const unauthContext = await browser.newContext();
|
||||
const unauthPage = await unauthContext.newPage();
|
||||
await unauthPage.goto(`${baseUrl}/documents/local-md%3Adocs%7E2FPlan.md?sourceKind=local_folder&rootUri=file:///tmp/missing`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
assert.equal(new URL(unauthPage.url()).pathname, "/auth", "未登录文档访问应跳转 auth");
|
||||
assert(new URL(unauthPage.url()).searchParams.get("next") || "", "auth 跳转应保留 next");
|
||||
await unauthContext.close();
|
||||
|
||||
await signUp(context, baseUrl, actorId);
|
||||
const { rootUri, workspaceId } = await openDefaultLocalWorkspace(page, baseUrl);
|
||||
const rootPath = fileUrlToPath(rootUri);
|
||||
fs.mkdirSync(path.join(rootPath, "docs"), { recursive: true });
|
||||
fs.mkdirSync(path.join(rootPath, "other"), { recursive: true });
|
||||
fs.writeFileSync(path.join(rootPath, "Home.md"), "# Home\n", "utf8");
|
||||
fs.writeFileSync(path.join(rootPath, "docs", "Plan.md"), "# Plan\n正文\n", "utf8");
|
||||
fs.writeFileSync(path.join(rootPath, "other", "Other.md"), "# Other\n", "utf8");
|
||||
fs.writeFileSync(path.join(rootPath, "Report.pdf"), "%PDF-1.4\n", "utf8");
|
||||
|
||||
const rootUrl = new URL(baseUrl);
|
||||
if (workspaceId) rootUrl.searchParams.set("workspaceId", workspaceId);
|
||||
rootUrl.searchParams.set("sourceKind", "local_folder");
|
||||
rootUrl.searchParams.set("rootUri", rootUri);
|
||||
rootUrl.searchParams.set("treeView", "filetree");
|
||||
await page.goto(rootUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await assertNavigationPage(page);
|
||||
assert.equal(await page.locator('[data-root-active-page-id="local-md:Home.md"]').count(), 0, "打开文件夹 root 不应自动打开 Home.md");
|
||||
await page.waitForFunction(() => Boolean(window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab), null, {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const popupPromise = page.waitForEvent("popup", { timeout: 3000 }).then((popup) => popup.url()).catch(() => null);
|
||||
const reportRow = page.locator('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="Report.pdf"]').first();
|
||||
await reportRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await reportRow.locator('[data-rust-action="open"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-mnote-tab-kind="pdf"] .mnote-main-tab-title', { hasText: "Report.pdf" }).waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.locator('[data-mnote-resource-tab-panel][data-resource-kind="pdf"] iframe.mnote-resource-tab-frame').waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
assert.equal(await popupPromise, null, "导航页打开 PDF 应进入 mnoteTab,不应新开浏览器 Tab");
|
||||
assert.equal(new URL(page.url()).pathname, "/", "导航页打开 PDF 不应离开主页");
|
||||
await page.locator('[data-mnote-main-tab="page"][data-pane-role="primary"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="mnote-navigation-page"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
const docsRow = page.locator('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="docs"]').first();
|
||||
await docsRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const rootNavigationUrl = page.url();
|
||||
await docsRow.locator('[data-rust-action="open"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(() => {
|
||||
const row = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="docs"]');
|
||||
return row && row.getAttribute("aria-expanded") === "true";
|
||||
}, null, { timeout: UI_TIMEOUT_MS });
|
||||
assert.equal(new URL(page.url()).searchParams.get("fileTreeScope"), null, "文件树文件夹点击只应展开,不应进入 scoped 导航页");
|
||||
assert.equal(page.url(), rootNavigationUrl, "文件树文件夹展开不应改 URL");
|
||||
|
||||
await page.locator('[data-testid="mnote-navigation-folders"] [data-navigation-item-kind="folder"][data-local-relative-path="docs"]').first().click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForURL((url) => url.searchParams.get("fileTreeScope") === "docs", {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await assertNavigationPage(page);
|
||||
assert(await page.locator('[data-testid="mnote-navigation-pages"] >> text=Plan.md').isVisible({ timeout: UI_TIMEOUT_MS }), "scope 导航页应展示当前层 Markdown");
|
||||
assert.equal(await page.locator('text=Other.md').count(), 0, "scope 导航页不应渲染 root 兄弟目录页面");
|
||||
|
||||
const planRow = page.locator('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-document-id="local-md:docs~2FPlan.md"]').first();
|
||||
await planRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await planRow.locator('[data-rust-action="open"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForURL((url) => url.pathname.includes("/documents/local-md%3Adocs~2FPlan.md"), {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.locator("#__MNOTE_PAGE_AGGREGATE__").waitFor({ state: "attached", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
const scopedUrl = new URL(baseUrl);
|
||||
if (workspaceId) scopedUrl.searchParams.set("workspaceId", workspaceId);
|
||||
scopedUrl.searchParams.set("sourceKind", "local_folder");
|
||||
scopedUrl.searchParams.set("rootUri", rootUri);
|
||||
scopedUrl.searchParams.set("treeView", "filetree");
|
||||
scopedUrl.searchParams.set("fileTreeScope", "docs");
|
||||
await page.goto(scopedUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await assertNavigationPage(page);
|
||||
assert(await page.locator('[data-testid="mnote-navigation-recent-folders"] >> text=docs').isVisible({ timeout: UI_TIMEOUT_MS }), "recent folders 应展示 docs");
|
||||
assert(await page.locator('[data-testid="mnote-navigation-recent-pages"] >> text=Plan').isVisible({ timeout: UI_TIMEOUT_MS }), "recent pages 应展示 Plan");
|
||||
|
||||
const documentUrl = `${baseUrl}/documents/local-md%3Adocs%7E2FPlan.md?sourceKind=local_folder&rootUri=${encodeURIComponent(rootUri)}&treeView=filetree&fileTreeScope=docs`;
|
||||
await page.goto(documentUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
fs.unlinkSync(path.join(rootPath, "docs", "Plan.md"));
|
||||
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForURL((url) => url.pathname === "/" && url.searchParams.get("missingPage") === "local-md:docs~2FPlan.md", {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await assertNavigationPage(page);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
task: TASK,
|
||||
rootUri,
|
||||
finalUrl: page.url(),
|
||||
}, null, 2));
|
||||
} catch (error) {
|
||||
error.message += `\nserver stderr:\n${stderr.slice(-4000)}\nbrowser diagnostics:\n${diagnostics.slice(-20).join("\n")}`;
|
||||
throw error;
|
||||
} finally {
|
||||
await browser.close().catch(() => {});
|
||||
server.kill("SIGTERM");
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,361 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const fsp = require("node:fs/promises");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const JSZip = require("jszip");
|
||||
const XLSX = require("@e965/xlsx");
|
||||
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", "task501-office-preview-light-viewer-smoke");
|
||||
const TEST_ACCOUNT = "mnote-e2e";
|
||||
const TEST_EMAIL = "mnote.e2e@example.com";
|
||||
const TEST_PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!";
|
||||
const WORKSPACE_ID = "local-ws:mnote-e2e:office-preview-smoke";
|
||||
const DOCUMENT_ID = "local-md:office-preview-smoke.md";
|
||||
|
||||
function resolveChromiumExecutablePath() {
|
||||
const explicit = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || "";
|
||||
if (explicit && fs.existsSync(explicit)) return explicit;
|
||||
return [
|
||||
"/usr/bin/google-chrome-stable",
|
||||
"/usr/bin/google-chrome",
|
||||
"/snap/bin/chromium",
|
||||
"/usr/bin/chromium",
|
||||
"/usr/bin/chromium-browser",
|
||||
].find((candidate) => fs.existsSync(candidate)) || "";
|
||||
}
|
||||
|
||||
function officePreviewUrl(root, fileName, fileType) {
|
||||
const fileUrl = new URL("/api/local-folder/files/open", BASE_URL);
|
||||
fileUrl.searchParams.set("rootUri", `file://${root}`);
|
||||
fileUrl.searchParams.set("path", fileName);
|
||||
|
||||
const url = new URL("/office-preview", BASE_URL);
|
||||
url.searchParams.set("fileUrl", `${fileUrl.pathname}${fileUrl.search}`);
|
||||
url.searchParams.set("fileName", fileName);
|
||||
url.searchParams.set("fileType", fileType);
|
||||
url.searchParams.set("workspaceId", WORKSPACE_ID);
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", `file://${root}`);
|
||||
url.searchParams.set("documentId", DOCUMENT_ID);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async function writeDocx(filePath) {
|
||||
const zip = new JSZip();
|
||||
zip.file("[Content_Types].xml", `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
||||
</Types>`);
|
||||
zip.folder("_rels").file(".rels", `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
|
||||
</Relationships>`);
|
||||
zip.folder("word").file("document.xml", `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body>
|
||||
<w:p><w:r><w:t>MNote DOCX light preview works</w:t></w:r></w:p>
|
||||
<w:p><w:r><w:t>第二行测试内容</w:t></w:r></w:p>
|
||||
<w:sectPr/>
|
||||
</w:body>
|
||||
</w:document>`);
|
||||
await fsp.writeFile(filePath, await zip.generateAsync({ type: "nodebuffer" }));
|
||||
}
|
||||
|
||||
function writeXlsx(filePath) {
|
||||
const workbook = XLSX.utils.book_new();
|
||||
const sheet = XLSX.utils.aoa_to_sheet([
|
||||
["Name", "Value"],
|
||||
["MNote XLSX light preview works", "ok"],
|
||||
["中文", "正常"],
|
||||
]);
|
||||
XLSX.utils.book_append_sheet(workbook, sheet, "Sheet1");
|
||||
XLSX.writeFile(workbook, filePath);
|
||||
}
|
||||
|
||||
async function writePptx(filePath) {
|
||||
const zip = new JSZip();
|
||||
zip.file("[Content_Types].xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>
|
||||
<Override PartName="/ppt/slides/slide1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>
|
||||
<Override PartName="/ppt/slideMasters/slideMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"/>
|
||||
<Override PartName="/ppt/slideLayouts/slideLayout1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/>
|
||||
<Override PartName="/ppt/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/>
|
||||
</Types>`);
|
||||
zip.folder("_rels").file(".rels", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/>
|
||||
</Relationships>`);
|
||||
zip.folder("ppt").file("presentation.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<p:presentation xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
|
||||
<p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst>
|
||||
<p:sldIdLst><p:sldId id="256" r:id="rId2"/></p:sldIdLst>
|
||||
<p:sldSz cx="12192000" cy="6858000" type="wide"/>
|
||||
<p:notesSz cx="6858000" cy="9144000"/>
|
||||
<p:defaultTextStyle>
|
||||
<a:defPPr><a:defRPr lang="zh-CN"/></a:defPPr>
|
||||
<a:lvl1pPr marL="0" algn="l"><a:defRPr sz="1800" lang="zh-CN"/></a:lvl1pPr>
|
||||
</p:defaultTextStyle>
|
||||
</p:presentation>`);
|
||||
zip.folder("ppt").folder("_rels").file("presentation.xml.rels", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="slideMasters/slideMaster1.xml"/>
|
||||
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml"/>
|
||||
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="theme/theme1.xml"/>
|
||||
</Relationships>`);
|
||||
zip.folder("ppt").folder("slideMasters").file("slideMaster1.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<p:sldMaster xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
|
||||
<p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr></p:spTree></p:cSld>
|
||||
<p:sldLayoutIdLst><p:sldLayoutId id="2147483649" r:id="rId1"/></p:sldLayoutIdLst>
|
||||
</p:sldMaster>`);
|
||||
zip.folder("ppt").folder("slideMasters").folder("_rels").file("slideMaster1.xml.rels", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>
|
||||
</Relationships>`);
|
||||
zip.folder("ppt").folder("slideLayouts").file("slideLayout1.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<p:sldLayout xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" type="blank">
|
||||
<p:cSld name="Blank"><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr></p:spTree></p:cSld>
|
||||
</p:sldLayout>`);
|
||||
zip.folder("ppt").folder("slideLayouts").folder("_rels").file("slideLayout1.xml.rels", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="../slideMasters/slideMaster1.xml"/>
|
||||
</Relationships>`);
|
||||
zip.folder("ppt").folder("slides").file("slide1.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
|
||||
<p:cSld>
|
||||
<p:spTree>
|
||||
<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>
|
||||
<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>
|
||||
<p:sp>
|
||||
<p:nvSpPr><p:cNvPr id="2" name="Title"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr>
|
||||
<p:spPr><a:xfrm><a:off x="914400" y="914400"/><a:ext cx="10363200" cy="1371600"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr>
|
||||
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:rPr lang="zh-CN" sz="4400"/><a:t>MNote PPTX light preview works</a:t></a:r></a:p></p:txBody>
|
||||
</p:sp>
|
||||
</p:spTree>
|
||||
</p:cSld>
|
||||
</p:sld>`);
|
||||
zip.folder("ppt").folder("slides").folder("_rels").file("slide1.xml.rels", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>
|
||||
</Relationships>`);
|
||||
zip.folder("ppt").folder("theme").file("theme1.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="MNote">
|
||||
<a:themeElements>
|
||||
<a:clrScheme name="MNote"><a:dk1><a:srgbClr val="000000"/></a:dk1><a:lt1><a:srgbClr val="FFFFFF"/></a:lt1><a:dk2><a:srgbClr val="1F2937"/></a:dk2><a:lt2><a:srgbClr val="F8FAFC"/></a:lt2><a:accent1><a:srgbClr val="2563EB"/></a:accent1><a:accent2><a:srgbClr val="22A559"/></a:accent2><a:accent3><a:srgbClr val="E0525B"/></a:accent3><a:accent4><a:srgbClr val="F59E0B"/></a:accent4><a:accent5><a:srgbClr val="8B5CF6"/></a:accent5><a:accent6><a:srgbClr val="14B8A6"/></a:accent6><a:hlink><a:srgbClr val="2563EB"/></a:hlink><a:folHlink><a:srgbClr val="7C3AED"/></a:folHlink></a:clrScheme>
|
||||
<a:fontScheme name="MNote"><a:majorFont><a:latin typeface="Arial"/></a:majorFont><a:minorFont><a:latin typeface="Arial"/></a:minorFont></a:fontScheme>
|
||||
<a:fmtScheme name="MNote"><a:fillStyleLst/><a:lnStyleLst/><a:effectStyleLst/><a:bgFillStyleLst/></a:fmtScheme>
|
||||
</a:themeElements>
|
||||
</a:theme>`);
|
||||
await fsp.writeFile(filePath, await zip.generateAsync({ type: "nodebuffer" }));
|
||||
}
|
||||
|
||||
async function signIn(context) {
|
||||
const payload = (params) => ({
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params,
|
||||
},
|
||||
});
|
||||
let response = await context.request.fetch(`${BASE_URL}/api/auth`, {
|
||||
method: "POST",
|
||||
data: payload({
|
||||
account: TEST_ACCOUNT,
|
||||
password: TEST_PASSWORD,
|
||||
flow: "signIn",
|
||||
}),
|
||||
});
|
||||
if (response.ok()) return;
|
||||
response = await context.request.fetch(`${BASE_URL}/api/auth`, {
|
||||
method: "POST",
|
||||
data: payload({
|
||||
email: TEST_EMAIL,
|
||||
username: TEST_ACCOUNT,
|
||||
name: TEST_ACCOUNT,
|
||||
password: TEST_PASSWORD,
|
||||
flow: "signUp",
|
||||
}),
|
||||
});
|
||||
assert(response.ok(), `测试账号登录失败: ${response.status()} ${await response.text()}`);
|
||||
}
|
||||
|
||||
async function setPageWidthPreferences(context, root, updates) {
|
||||
const response = await context.request.fetch(`${BASE_URL}/api/ui/preferences`, {
|
||||
method: "PUT",
|
||||
data: {
|
||||
documentId: DOCUMENT_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
sourceKind: "local_folder",
|
||||
rootUri: `file://${root}`,
|
||||
updates,
|
||||
},
|
||||
});
|
||||
assert(response.ok(), `页面宽度偏好写入失败: ${response.status()} ${await response.text()}`);
|
||||
}
|
||||
|
||||
async function verifyPreview(page, root, item) {
|
||||
const consoleMessages = [];
|
||||
page.on("console", (message) => {
|
||||
consoleMessages.push(`${message.type()}: ${message.text()}`);
|
||||
});
|
||||
page.on("pageerror", (error) => {
|
||||
consoleMessages.push(`pageerror: ${error && error.stack ? error.stack : error.message}`);
|
||||
});
|
||||
|
||||
await page.goto(officePreviewUrl(root, item.fileName, item.fileType), {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const status = document.documentElement.getAttribute("data-mnote-office-preview-status");
|
||||
return status && status !== "加载中";
|
||||
},
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
const status = await page.locator("html").getAttribute("data-mnote-office-preview-status");
|
||||
const text = await page.locator("#mnote-office-viewer").innerText({ timeout: UI_TIMEOUT_MS });
|
||||
const layout = await page.evaluate(() => {
|
||||
const viewer = document.querySelector("#mnote-office-viewer");
|
||||
const tableWrap = document.querySelector(".mnote-office-table-wrap");
|
||||
const docxPage = document.querySelector(".docx-wrapper > section.docx, .mnote-docx-wrapper > section.mnote-docx");
|
||||
const pptxStage = document.querySelector(".mnote-office-pptx-stage");
|
||||
const pptxSlide = document.querySelector(".pptx-preview-slide-wrapper");
|
||||
const rectOf = (node) => {
|
||||
if (!(node instanceof Element)) return null;
|
||||
const rect = node.getBoundingClientRect();
|
||||
return {
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
};
|
||||
};
|
||||
return {
|
||||
viewportWidth: window.innerWidth,
|
||||
pageWidthContentType: document.documentElement.getAttribute("data-page-width-content-type"),
|
||||
pageWidthMode: document.documentElement.getAttribute("data-page-width-resolved-mode"),
|
||||
toolbarCount: document.querySelectorAll(".mnote-office-toolbar").length,
|
||||
viewer: rectOf(viewer),
|
||||
tableWrap: rectOf(tableWrap),
|
||||
docxPage: rectOf(docxPage),
|
||||
pptxStage: rectOf(pptxStage),
|
||||
pptxSlide: rectOf(pptxSlide),
|
||||
};
|
||||
});
|
||||
const screenshot = path.join(OUT_DIR, `${item.fileType}.png`);
|
||||
await page.screenshot({ path: screenshot, fullPage: true });
|
||||
|
||||
assert.equal(status, "完成", `${item.fileType} 预览状态应完成`);
|
||||
assert(text.includes(item.expected), `${item.fileType} 预览应包含样本文本: ${item.expected}`);
|
||||
assert.equal(consoleMessages.length, 0, `${item.fileType} 预览不应产生控制台错误: ${consoleMessages.join("\n")}`);
|
||||
assert.equal(layout.toolbarCount, 0, "轻量预览页不应显示顶部状态条");
|
||||
if (item.fileType === "docx") {
|
||||
assert.equal(layout.pageWidthContentType, "word", "DOCX 应使用 Word 宽度类型");
|
||||
assert.equal(layout.pageWidthMode, item.expectedMode || "wide", "DOCX 应使用指定页面宽度");
|
||||
assert(layout.viewer && layout.viewer.width >= Math.min(layout.viewportWidth, 1180) - 24, "DOCX 预览容器应接近宽版宽度");
|
||||
assert(layout.viewer.left > 0 && layout.viewer.left < 80, "DOCX 宽版容器应居中而不是左侧小块");
|
||||
assert(layout.docxPage && layout.docxPage.width >= layout.viewportWidth * 0.75, "DOCX 页面应放大到接近可视宽度");
|
||||
} else if (item.fileType === "pptx") {
|
||||
assert.equal(layout.pageWidthContentType, "ppt", "PPTX 应使用 PPT 宽度类型");
|
||||
assert.equal(layout.pageWidthMode, item.expectedMode || "wide", "PPTX 应使用指定页面宽度");
|
||||
if ((item.expectedMode || "wide") === "full") {
|
||||
assert(layout.viewer && layout.viewer.width >= layout.viewportWidth - 2, "PPTX 全宽模式应占满页面宽度");
|
||||
assert(layout.pptxStage && layout.pptxStage.width >= layout.viewportWidth - 40, "PPTX 舞台应随全宽容器放大");
|
||||
} else {
|
||||
assert(layout.viewer && layout.viewer.width >= Math.min(layout.viewportWidth, 1180) - 24, "PPTX 预览容器应接近宽版宽度");
|
||||
}
|
||||
} else {
|
||||
assert.equal(layout.pageWidthContentType, "excel", `${item.fileType} 应使用 Excel 宽度类型`);
|
||||
assert.equal(layout.pageWidthMode, item.expectedMode || "full", `${item.fileType} 应使用指定页面宽度`);
|
||||
assert(layout.viewer && layout.viewer.left <= 1, `${item.fileType} 预览容器应从页面左侧开始`);
|
||||
assert(layout.viewer.width >= layout.viewportWidth - 2, `${item.fileType} 预览容器应占满页面宽度`);
|
||||
assert(layout.tableWrap && layout.tableWrap.width >= layout.viewportWidth - 40, `${item.fileType} 表格容器应接近全宽`);
|
||||
}
|
||||
return { fileType: item.fileType, status, text: text.slice(0, 300), layout, screenshot };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await fsp.mkdir(OUT_DIR, { recursive: true });
|
||||
const root = await fsp.mkdtemp(path.join(os.tmpdir(), "mnote-office-preview-"));
|
||||
await fsp.mkdir(path.join(root, ".mnote"), { recursive: true });
|
||||
await fsp.writeFile(
|
||||
path.join(root, ".mnote", "workspace.json"),
|
||||
`${JSON.stringify({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
ownerId: TEST_ACCOUNT,
|
||||
createdAt: new Date().toISOString(),
|
||||
capabilities: ["local_files", "markdown_edit", "asset_upload"],
|
||||
}, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeDocx(path.join(root, "sample.docx"));
|
||||
writeXlsx(path.join(root, "sample.xlsx"));
|
||||
await writePptx(path.join(root, "sample.pptx"));
|
||||
await fsp.writeFile(
|
||||
path.join(root, "sample.csv"),
|
||||
"Name,Value\nMNote CSV light preview works,ok\n中文,正常\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const executablePath = resolveChromiumExecutablePath();
|
||||
const browser = await chromium.launch({
|
||||
headless: process.env.HEADFUL !== "1",
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
});
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
try {
|
||||
await signIn(context);
|
||||
await setPageWidthPreferences(context, root, {
|
||||
"pageWidth.word": { mode: "wide", custom: null },
|
||||
"pageWidth.excel": { mode: "full", custom: null },
|
||||
"pageWidth.ppt": { mode: "wide", custom: null },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const results = [];
|
||||
for (const item of [
|
||||
{ fileName: "sample.docx", fileType: "docx", expected: "MNote DOCX light preview works" },
|
||||
{ fileName: "sample.xlsx", fileType: "xlsx", expected: "MNote XLSX light preview works" },
|
||||
{ fileName: "sample.pptx", fileType: "pptx", expected: "MNote PPTX light preview works" },
|
||||
{ fileName: "sample.csv", fileType: "csv", expected: "MNote CSV light preview works" },
|
||||
]) {
|
||||
results.push(await verifyPreview(page, root, item));
|
||||
}
|
||||
await setPageWidthPreferences(context, root, {
|
||||
"pageWidth.ppt": { mode: "full", custom: null },
|
||||
});
|
||||
results.push(await verifyPreview(page, root, {
|
||||
fileName: "sample.pptx",
|
||||
fileType: "pptx",
|
||||
expected: "MNote PPTX light preview works",
|
||||
expectedMode: "full",
|
||||
}));
|
||||
const result = { ok: true, baseUrl: BASE_URL, root, results };
|
||||
await fsp.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 fsp.mkdir(OUT_DIR, { recursive: true });
|
||||
await fsp.writeFile(
|
||||
path.join(OUT_DIR, "result.json"),
|
||||
`${JSON.stringify({ ok: false, baseUrl: BASE_URL, error: error && error.stack ? error.stack : String(error) }, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
console.error(error && error.stack ? error.stack : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
const {
|
||||
BASE_URL,
|
||||
UI_TIMEOUT_MS,
|
||||
ensureAuthenticated,
|
||||
} = require("./tree-shell-smoke-helpers");
|
||||
|
||||
const TASK = "task502-page-ai-agent-selector-context-smoke";
|
||||
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 localMdDocumentId(relativePath) {
|
||||
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
|
||||
}
|
||||
|
||||
function writeWorkspaceManifest(root, ownerId, workspaceId) {
|
||||
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(root, ".mnote", "workspace.json"),
|
||||
`${JSON.stringify({
|
||||
workspaceId,
|
||||
ownerId,
|
||||
createdAt: new Date().toISOString(),
|
||||
capabilities: ["local_files", "ai_sessions", "markdown_edit"],
|
||||
}, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
function documentUrl(root, relativePath) {
|
||||
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", fileUrl(root));
|
||||
url.searchParams.set("treeView", "filetree");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async function saveScreenshot(page, name) {
|
||||
const target = path.join(OUTPUT_DIR, `${name}.png`);
|
||||
await page.screenshot({ path: target, fullPage: false });
|
||||
return target;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
const suffix = Date.now().toString(36);
|
||||
const actorId = "mnote-e2e";
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task502-agent-context-"));
|
||||
const rootUri = fileUrl(root);
|
||||
const workspaceId = `local-ws:${actorId}:task502`;
|
||||
const relativePath = "AgentContext.md";
|
||||
const documentId = localMdDocumentId(relativePath);
|
||||
const captured = [];
|
||||
let caughtError = null;
|
||||
|
||||
writeWorkspaceManifest(root, actorId, workspaceId);
|
||||
fs.writeFileSync(
|
||||
path.join(root, relativePath),
|
||||
["# Agent Context", "", `Task502 ${suffix}`, ""].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
|
||||
});
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 960 },
|
||||
locale: "zh-CN",
|
||||
extraHTTPHeaders: {
|
||||
"x-mnote-actor-id": actorId,
|
||||
"x-mnote-actor-type": "user",
|
||||
},
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await page.route("**/api/user/access-policy**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
controlPlane: "sqlite",
|
||||
grants: [{
|
||||
id: `grant_task502_${suffix}`,
|
||||
userId: actorId,
|
||||
workspaceId,
|
||||
rootUri,
|
||||
rootPath: root,
|
||||
permission: "write",
|
||||
recursive: true,
|
||||
capabilities: ["ai", "markdown_edit"],
|
||||
source: "user",
|
||||
status: "active",
|
||||
}],
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/ui/preferences**", async (route) => {
|
||||
captured.push({
|
||||
kind: "ui-preferences",
|
||||
method: route.request().method(),
|
||||
body: route.request().postData() || "",
|
||||
});
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ok: true, owner: "mnote-web", result: {} }),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/hermes/client/gateway/health**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
gateway: { ok: true, status: "mocked" },
|
||||
profile: { name: "reasonix", modelConfigured: true, apiKeyConfigured: true },
|
||||
suggestions: [],
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/hermes/client/tools**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ok: true, tools: [] }),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/hermes/client/profiles", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
active: "reasonix",
|
||||
profiles: [{ name: "reasonix", label: "Reasonix", modelConfigured: true, apiKeyConfigured: true }],
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/hermes/client/skills**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ok: true, categories: [], archived: [] }),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/hermes/client/sessions", async (route) => {
|
||||
captured.push({ kind: "session", method: route.request().method(), body: route.request().postData() || "" });
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
sessionId: `mnote_task502_${suffix}`,
|
||||
title: "task502",
|
||||
traceId: `trace_task502_session_${suffix}`,
|
||||
persistence: "local_ai_session_jsonl",
|
||||
sessionStorage: "local_private",
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/hermes/client/runs", async (route) => {
|
||||
captured.push({ kind: "run", method: route.request().method(), body: route.request().postData() || "" });
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
sessionId: `mnote_task502_${suffix}`,
|
||||
runId: `run_task502_${suffix}`,
|
||||
events: [],
|
||||
traceId: `trace_task502_run_${suffix}`,
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/hermes/client/events/*", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream; charset=utf-8" },
|
||||
body:
|
||||
`data: ${JSON.stringify({ event: "message.delta", run_id: `run_task502_${suffix}`, delta: "Task502 response" })}\n\n` +
|
||||
`data: ${JSON.stringify({ event: "run.completed", run_id: `run_task502_${suffix}`, output: "Task502 response" })}\n\n`,
|
||||
});
|
||||
});
|
||||
|
||||
await ensureAuthenticated(page, context.request);
|
||||
const response = await page.goto(documentUrl(root, relativePath), {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
assert(response && response.status() === 200, `文档页状态码异常: ${response && response.status()}`);
|
||||
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .ProseMirror').first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="wolai-page-ai-drawer"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
const drawer = page.locator('[data-testid="wolai-page-ai-drawer"]');
|
||||
const defaultChatText = await drawer.innerText({ timeout: UI_TIMEOUT_MS });
|
||||
assert(!defaultChatText.includes("问 Hermes"), "默认输入区不应继续固定写“问 Hermes”");
|
||||
assert(!defaultChatText.includes("model.default"), "默认聊天面不应显示 model.default");
|
||||
assert(!defaultChatText.includes("gateway:"), "默认聊天面不应显示 gateway 技术详情");
|
||||
assert(!defaultChatText.includes("Hermes profile"), "默认聊天面不应显示 Hermes profile 技术项");
|
||||
|
||||
await page.locator("[data-page-ai-agent-selector]").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const agentIds = await page.$$eval("[data-page-ai-agent-id]", (nodes) =>
|
||||
nodes.map((node) => node.getAttribute("data-page-ai-agent-id")).filter(Boolean),
|
||||
);
|
||||
assert.deepStrictEqual(agentIds.sort(), ["chat_only", "hermes", "reasonix"]);
|
||||
await page.locator('[data-page-ai-agent-id="reasonix"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
() => document.documentElement.getAttribute("data-mnote-page-ai-agent-id") === "reasonix",
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
const contextRefs = page.locator("[data-page-ai-context-refs]");
|
||||
await contextRefs.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
assert(
|
||||
await page.locator('[data-page-ai-context-ref="current_page"][aria-pressed="true"]').isVisible(),
|
||||
"当前页 contextRef 应默认勾选",
|
||||
);
|
||||
assert(
|
||||
await page.locator("[data-page-ai-allowed-root]").first().isVisible(),
|
||||
"SQLite 授权区域应作为 allowedRoot chip 显示",
|
||||
);
|
||||
|
||||
await page.locator('.wolai-page-ai-header-actions [data-page-ai-tab="agent"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-page-ai-panel="agent"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const commonSettingsText = await page.locator('[data-page-ai-panel="agent"]').innerText({ timeout: UI_TIMEOUT_MS });
|
||||
assert(commonSettingsText.includes("授权区域"), "Common 设置页应展示授权区域");
|
||||
assert(commonSettingsText.includes("默认上下文"), "Common 设置页应展示默认 contextRefs");
|
||||
assert(commonSettingsText.includes("审计"), "Common 设置页应展示审计/changed files 共性设置");
|
||||
assert(!commonSettingsText.includes("Hermes profile"), "Common 设置页不应包含 Hermes profile");
|
||||
assert(!commonSettingsText.includes("ACP runtime"), "Common 设置页不应包含 agent runtime 差异项");
|
||||
|
||||
await page.locator('[data-page-ai-panel="agent"] [data-page-ai-tab="hermes-settings"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-page-ai-panel="hermes-settings"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const hermesSettingsText = await page.locator('[data-page-ai-panel="hermes-settings"]').innerText({ timeout: UI_TIMEOUT_MS });
|
||||
assert(hermesSettingsText.includes("Hermes profile"), "Hermes 设置页应只承接 Hermes profile");
|
||||
assert(!hermesSettingsText.includes("Reasonix 专属设置"), "Hermes 设置页不应混入 Reasonix 设置");
|
||||
|
||||
await page.locator('[data-page-ai-panel="hermes-settings"] [data-page-ai-tab="reasonix-settings"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-page-ai-panel="reasonix-settings"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const reasonixSettingsText = await page.locator('[data-page-ai-panel="reasonix-settings"]').innerText({ timeout: UI_TIMEOUT_MS });
|
||||
assert(reasonixSettingsText.includes("ACP runtime"), "Reasonix 设置页应展示 ACP runtime");
|
||||
assert(reasonixSettingsText.includes("Reasonix 专属设置"), "Reasonix 设置页应展示 Reasonix 专属设置");
|
||||
assert(!reasonixSettingsText.includes("Hermes profile"), "Reasonix 设置页不应混入 Hermes profile");
|
||||
|
||||
await page.locator('[data-page-ai-panel="reasonix-settings"] [data-page-ai-tab="chat-only-settings"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-page-ai-panel="chat-only-settings"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const chatOnlySettingsText = await page.locator('[data-page-ai-panel="chat-only-settings"]').innerText({ timeout: UI_TIMEOUT_MS });
|
||||
assert(chatOnlySettingsText.includes("默认不申请文件写权限"), "Chat-only 设置页应说明默认不申请写权限");
|
||||
assert(!chatOnlySettingsText.includes("ACP runtime"), "Chat-only 设置页不应混入 ACP runtime");
|
||||
await page.locator('[data-page-ai-panel="chat-only-settings"] [data-page-ai-tab="chat"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-page-ai-panel="chat"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
await page.locator('[data-page-ai-context-ref="current_page"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-page-ai-context-ref="folder"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-page-ai-context-ref="changed_files"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator("[data-page-ai-input]").fill(`Task502 agent/context ${suffix}`, { timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
() => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("Task502 response"),
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
const runs = captured.filter((item) => item.kind === "run");
|
||||
assert(runs.length >= 1, "未捕获 Page AI run payload");
|
||||
const runBody = JSON.parse(runs[runs.length - 1].body);
|
||||
assert.strictEqual(runBody.agentId, "reasonix");
|
||||
assert(Array.isArray(runBody.contextRefs), "run payload 必须包含 contextRefs 数组");
|
||||
assert(!runBody.contextRefs.some((item) => item.kind === "current_page"), "取消当前页后不应发送 current_page contextRef");
|
||||
assert.strictEqual(runBody.pageContext?.aiContext?.pageText, undefined, "取消当前页后不应发送 pageText");
|
||||
assert.strictEqual(runBody.pageContext?.aiContext?.pageXml, undefined, "取消当前页后不应发送 pageXml");
|
||||
assert.strictEqual(runBody.pageContext?.aiContext?.contextBlocks, undefined, "取消当前页后不应发送 contextBlocks");
|
||||
assert(runBody.contextRefs.some((item) => item.kind === "active_editor" && item.documentId === documentId));
|
||||
assert(runBody.contextRefs.some((item) => item.kind === "folder" && item.rootUri === rootUri));
|
||||
assert(runBody.contextRefs.some((item) => item.kind === "changed_files"));
|
||||
assert(Array.isArray(runBody.allowedRoots), "run payload 必须包含 allowedRoots 数组");
|
||||
assert(runBody.allowedRoots.some((item) =>
|
||||
item.rootUri === rootUri
|
||||
&& item.permission === "write"
|
||||
&& item.source === "sqlite_directory_grant"
|
||||
));
|
||||
assert.strictEqual(runBody.runTargetSnapshot?.source, "open_editors_snapshot");
|
||||
|
||||
const screenshot = await saveScreenshot(page, "01-agent-selector-context");
|
||||
const result = {
|
||||
ok: true,
|
||||
task: TASK,
|
||||
baseUrl: BASE_URL,
|
||||
root,
|
||||
rootUri,
|
||||
documentId,
|
||||
screenshot,
|
||||
captured,
|
||||
};
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
caughtError = error;
|
||||
await saveScreenshot(page, "failure").catch(() => undefined);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
await page.close().catch(() => undefined);
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
if (caughtError) {
|
||||
throw caughtError;
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
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 PPTX_PATH = process.env.MNOTE_TASK503_PPTX_PATH
|
||||
|| "/home/lix/Downloads/1768096586803-672be7a7-5cb3-454d-9b1b-6230f86ba392.pptx";
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task503-local-pptx-upload-filetree-open-smoke");
|
||||
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"]
|
||||
.find((candidate) => fs.existsSync(candidate));
|
||||
|
||||
function fileUrl(localPath) {
|
||||
return `file://${localPath}`;
|
||||
}
|
||||
|
||||
function localMdDocumentId(relativePath) {
|
||||
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
|
||||
}
|
||||
|
||||
function workspaceId(ownerId) {
|
||||
return `local-ws:${ownerId}:task503`;
|
||||
}
|
||||
|
||||
function documentUrl(root, relativePath) {
|
||||
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", fileUrl(root));
|
||||
url.searchParams.set("treeView", "filetree");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function writeWorkspaceManifest(root, ownerId) {
|
||||
const metadataDir = path.join(root, ".mnote");
|
||||
fs.mkdirSync(metadataDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(metadataDir, "workspace.json"),
|
||||
`${JSON.stringify({
|
||||
workspaceId: workspaceId(ownerId),
|
||||
ownerId,
|
||||
createdAt: new Date().toISOString(),
|
||||
capabilities: ["local_files", "markdown_edit", "asset_upload"],
|
||||
}, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
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 }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function openDocument(page, root, relativePath) {
|
||||
await page.goto(documentUrl(root, relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadAttachmentViaSlash(page, filePath) {
|
||||
const editor = page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first();
|
||||
await editor.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.keyboard.press("End").catch(() => undefined);
|
||||
await page.keyboard.type("/");
|
||||
const item = page.locator('.document-pane[data-pane-role="primary"] [data-testid="slash-item-upload-attachment"]').first();
|
||||
await item.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const [fileChooser] = await Promise.all([
|
||||
page.waitForEvent("filechooser", { timeout: UI_TIMEOUT_MS }),
|
||||
item.click({ timeout: UI_TIMEOUT_MS }),
|
||||
]);
|
||||
await fileChooser.setFiles(filePath);
|
||||
}
|
||||
|
||||
async function waitForEditorLinks(page, expectedCount) {
|
||||
await page.waitForFunction(
|
||||
({ count }) => {
|
||||
const links = Array.from(document.querySelectorAll('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a'));
|
||||
return links.filter((link) => /\.pptx(?:\?|$)/i.test(link.textContent || "")).length >= count;
|
||||
},
|
||||
{ count: expectedCount },
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
return await page.evaluate(() => Array.from(document.querySelectorAll('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a'))
|
||||
.filter((link) => /\.pptx(?:\?|$)/i.test(link.textContent || ""))
|
||||
.map((link) => ({
|
||||
text: link.textContent || "",
|
||||
href: link.getAttribute("href") || "",
|
||||
assetId: link.getAttribute("data-asset-id") || "",
|
||||
className: link.getAttribute("class") || "",
|
||||
})));
|
||||
}
|
||||
|
||||
async function assertNoConflict(page, label) {
|
||||
await page.waitForTimeout(1500);
|
||||
const state = await page.evaluate(() => {
|
||||
const pane = document.querySelector('.document-pane[data-pane-role="primary"]');
|
||||
const runtimeRoot = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
return {
|
||||
status: runtimeRoot?.getAttribute("data-runtime-editor-status") || "",
|
||||
statusError: runtimeRoot?.getAttribute("data-runtime-editor-error") || "",
|
||||
conflictVisible: Boolean(pane?.querySelector('[data-testid="mnote-editor-conflict-panel"]')),
|
||||
lastSaveError: document.documentElement.getAttribute("data-mnote-last-upload-save-error") || "",
|
||||
};
|
||||
});
|
||||
assert.notEqual(state.status, "external-change-conflict", `${label}: 不应进入文件冲突态: ${JSON.stringify(state)}`);
|
||||
assert.equal(state.conflictVisible, false, `${label}: 不应显示文件冲突面板: ${JSON.stringify(state)}`);
|
||||
assert.equal(state.lastSaveError, "", `${label}: 上传保存不应失败: ${JSON.stringify(state)}`);
|
||||
return state;
|
||||
}
|
||||
|
||||
async function setPptPageWidth(context, root, relativePath, mode) {
|
||||
const response = await context.request.fetch(`${BASE_URL}/api/ui/preferences`, {
|
||||
method: "PUT",
|
||||
data: {
|
||||
documentId: localMdDocumentId(relativePath),
|
||||
workspaceId: workspaceId("mnote-e2e"),
|
||||
sourceKind: "local_folder",
|
||||
rootUri: fileUrl(root),
|
||||
updates: {
|
||||
"pageWidth.ppt": { mode, custom: null },
|
||||
},
|
||||
},
|
||||
});
|
||||
assert(response.ok(), `PPT 页面宽度偏好写入失败: ${response.status()} ${await response.text()}`);
|
||||
}
|
||||
|
||||
async function waitForActivePptxResourceTabLayout(page, expectedMode) {
|
||||
await page.waitForFunction(
|
||||
({ mode }) => {
|
||||
const shell = document.querySelector('.document-pane[data-pane-role="primary"] .document-shell');
|
||||
const panel = Array.from(document.querySelectorAll(".mnote-resource-tab-panel"))
|
||||
.find((node) => node instanceof HTMLElement && !node.hidden && node.getAttribute("data-resource-kind") === "office");
|
||||
const frame = panel?.querySelector("iframe.mnote-resource-tab-frame");
|
||||
const frameDocument = frame instanceof HTMLIFrameElement ? frame.contentDocument : null;
|
||||
return shell instanceof HTMLElement
|
||||
&& shell.getAttribute("data-page-width-content-type") === "ppt"
|
||||
&& shell.getAttribute("data-page-width-resolved-mode") === mode
|
||||
&& frameDocument?.documentElement?.getAttribute("data-mnote-office-preview-status") === "完成"
|
||||
&& frameDocument.documentElement.getAttribute("data-page-width-resolved-mode") === mode
|
||||
&& frameDocument.querySelector(".mnote-office-pptx-stage");
|
||||
},
|
||||
{ mode: expectedMode },
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
return await page.evaluate((mode) => {
|
||||
const rectOf = (node) => {
|
||||
if (!(node instanceof Element)) return null;
|
||||
const rect = node.getBoundingClientRect();
|
||||
return { left: rect.left, width: rect.width };
|
||||
};
|
||||
const shell = document.querySelector('.document-pane[data-pane-role="primary"] .document-shell');
|
||||
const panel = Array.from(document.querySelectorAll(".mnote-resource-tab-panel"))
|
||||
.find((node) => node instanceof HTMLElement && !node.hidden && node.getAttribute("data-resource-kind") === "office");
|
||||
const frame = panel?.querySelector("iframe.mnote-resource-tab-frame");
|
||||
const frameDocument = frame instanceof HTMLIFrameElement ? frame.contentDocument : null;
|
||||
const viewer = frameDocument?.querySelector("#mnote-office-viewer");
|
||||
const stage = frameDocument?.querySelector(".mnote-office-pptx-stage");
|
||||
const viewerRect = rectOf(viewer);
|
||||
const stageRect = rectOf(stage);
|
||||
return {
|
||||
expectedMode: mode,
|
||||
viewportWidth: window.innerWidth,
|
||||
shellMode: shell?.getAttribute("data-page-width-resolved-mode") || "",
|
||||
shellContentType: shell?.getAttribute("data-page-width-content-type") || "",
|
||||
documentMode: document.documentElement.getAttribute("data-page-width-resolved-mode") || "",
|
||||
activeResourceWidthType: document.documentElement.getAttribute("data-mnote-active-resource-width-type") || "",
|
||||
iframeMode: frameDocument?.documentElement?.getAttribute("data-page-width-resolved-mode") || "",
|
||||
shell: rectOf(shell),
|
||||
panel: rectOf(panel),
|
||||
frame: rectOf(frame),
|
||||
viewer: viewerRect,
|
||||
stage: stageRect,
|
||||
};
|
||||
}, expectedMode);
|
||||
}
|
||||
|
||||
async function clickEditorPptxLink(page, context, titleContains) {
|
||||
await page.waitForFunction(
|
||||
(needle) => {
|
||||
return Array.from(document.querySelectorAll('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a'))
|
||||
.some((node) => {
|
||||
const rect = node.getBoundingClientRect();
|
||||
return (node.textContent || "").includes(needle) && rect.width > 0 && rect.height > 0;
|
||||
});
|
||||
},
|
||||
titleContains,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
const target = await page.evaluate((needle) => {
|
||||
const candidates = Array.from(document.querySelectorAll('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a'));
|
||||
for (let index = candidates.length - 1; index >= 0; index -= 1) {
|
||||
const node = candidates[index];
|
||||
const rect = node.getBoundingClientRect();
|
||||
if (!(node.textContent || "").includes(needle) || rect.width <= 0 || rect.height <= 0) continue;
|
||||
return {
|
||||
href: node instanceof HTMLAnchorElement ? node.href : "",
|
||||
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}, titleContains);
|
||||
assert(target?.href, `编辑器附件缺少 href: ${titleContains}`);
|
||||
assert(target?.rect, `编辑器附件链接不可见或无坐标: ${titleContains}`);
|
||||
await page.mouse.click(target.rect.x + target.rect.width / 2, target.rect.y + target.rect.height / 2);
|
||||
await page.waitForFunction(
|
||||
(needle) => {
|
||||
const activePanel = document.querySelector('.mnote-resource-tab-panel:not([hidden])');
|
||||
const activeFrame = activePanel?.querySelector?.('iframe.mnote-resource-tab-frame');
|
||||
const frameSrc = activeFrame instanceof HTMLIFrameElement ? activeFrame.getAttribute('src') || '' : '';
|
||||
return decodeURIComponent(frameSrc.replace(/\+/g, ' ')).includes(needle)
|
||||
|| decodeURIComponent(location.href.replace(/\+/g, ' ')).includes(needle);
|
||||
},
|
||||
titleContains,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.locator('.mnote-resource-tab-panel:not([hidden]) iframe.mnote-resource-tab-frame').first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
async function activatePrimaryPageTab(page) {
|
||||
const pageTab = page.locator('[data-mnote-main-tab="page"][data-pane-role="primary"]').first();
|
||||
await pageTab.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await pageTab.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
function assertLocalUploadSaveVersions(result, expectedCount) {
|
||||
const saves = result.documentSaveRequests.filter((item) => item.editorSource === "local-upload-runtime");
|
||||
assert(
|
||||
saves.length >= expectedCount,
|
||||
`local-upload-runtime 保存次数不足: expected>=${expectedCount}, actual=${saves.length}`,
|
||||
);
|
||||
saves.slice(0, expectedCount).forEach((item, index) => {
|
||||
assert(
|
||||
item.expectedFileVersion,
|
||||
`第 ${index + 1} 次附件正文保存缺少 expectedFileVersion: ${JSON.stringify(item)}`,
|
||||
);
|
||||
assert(
|
||||
item.writeIntentId,
|
||||
`第 ${index + 1} 次附件正文保存缺少 writeIntentId: ${JSON.stringify(item)}`,
|
||||
);
|
||||
assert(
|
||||
item.saveOperationId,
|
||||
`第 ${index + 1} 次附件正文保存缺少 saveOperationId: ${JSON.stringify(item)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function typeLineAfterUpload(page, text) {
|
||||
const editor = page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first();
|
||||
await editor.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.keyboard.press("End").catch(() => undefined);
|
||||
await page.keyboard.press("Enter");
|
||||
await page.keyboard.type(text);
|
||||
}
|
||||
|
||||
async function waitForFileTreeAssetRows(page, expectedCount) {
|
||||
await page.waitForFunction(
|
||||
({ count }) => {
|
||||
return Array.from(document.querySelectorAll('#sidebar-file-tree-root [data-testid="filetree-asset-row"]'))
|
||||
.filter((row) => /\.pptx/i.test(row.textContent || ""))
|
||||
.length >= count;
|
||||
},
|
||||
{ count: expectedCount },
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
return await page.evaluate(() => Array.from(document.querySelectorAll('#sidebar-file-tree-root [data-testid="filetree-asset-row"]'))
|
||||
.filter((row) => /\.pptx/i.test(row.textContent || ""))
|
||||
.map((row) => ({
|
||||
title: row.textContent?.trim() || "",
|
||||
rowId: row.getAttribute("data-row-id") || "",
|
||||
assetId: row.getAttribute("data-asset-id") || "",
|
||||
localRelativePath: row.getAttribute("data-local-relative-path") || "",
|
||||
objectIdentity: row.getAttribute("data-object-identity") || "",
|
||||
})));
|
||||
}
|
||||
|
||||
async function clickPptxFileTreeRow(page, titleContains) {
|
||||
const row = page.locator('#sidebar-file-tree-root [data-testid="filetree-asset-row"]', { hasText: titleContains }).last();
|
||||
await row.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await row.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
(needle) => {
|
||||
const active = document.querySelector('[data-testid="document-resource-tab"][data-active="true"], [data-mnote-resource-tab][data-active="true"]');
|
||||
if (active instanceof HTMLElement && (active.textContent || "").includes(needle)) return true;
|
||||
return decodeURIComponent(location.href.replace(/\+/g, " ")).includes(needle);
|
||||
},
|
||||
titleContains,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForLocalFile(filePath) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < UI_TIMEOUT_MS) {
|
||||
if (fs.existsSync(filePath)) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
assert(fs.existsSync(filePath), `文件应存在: ${filePath}`);
|
||||
}
|
||||
|
||||
async function dispatchPptxDrop(page, filePath, targetText) {
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
const base64 = buffer.toString("base64");
|
||||
const fileName = path.basename(filePath);
|
||||
const target = page.locator('#sidebar-file-tree-root .tree-row[data-row-kind="folder"]', { hasText: targetText }).first();
|
||||
await target.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await target.dispatchEvent("dragover", {
|
||||
dataTransfer: await page.evaluateHandle(({ name, payload }) => {
|
||||
const bytes = Uint8Array.from(atob(payload), (char) => char.charCodeAt(0));
|
||||
const transfer = new DataTransfer();
|
||||
transfer.items.add(new File([bytes], name, {
|
||||
type: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
}));
|
||||
return transfer;
|
||||
}, { name: fileName, payload: base64 }),
|
||||
});
|
||||
await target.dispatchEvent("drop", {
|
||||
dataTransfer: await page.evaluateHandle(({ name, payload }) => {
|
||||
const bytes = Uint8Array.from(atob(payload), (char) => char.charCodeAt(0));
|
||||
const transfer = new DataTransfer();
|
||||
transfer.items.add(new File([bytes], name, {
|
||||
type: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
}));
|
||||
return transfer;
|
||||
}, { name: fileName, payload: base64 }),
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
assert(fs.existsSync(PPTX_PATH), `测试 pptx 不存在: ${PPTX_PATH}`);
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task503-pptx-"));
|
||||
const relativePath = "README/README.md";
|
||||
const resourceDir = path.join(root, "README");
|
||||
const fileName = path.basename(PPTX_PATH);
|
||||
writeWorkspaceManifest(root, "mnote-e2e");
|
||||
fs.mkdirSync(resourceDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(root, relativePath), "# PPTX Upload\n\n正文\n", "utf8");
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: process.env.HEADFUL !== "1",
|
||||
executablePath: CHROMIUM_EXECUTABLE_PATH,
|
||||
});
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1366, height: 900 },
|
||||
extraHTTPHeaders: {
|
||||
"x-mnote-actor-id": "mnote-e2e",
|
||||
"x-mnote-actor-type": "user",
|
||||
},
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const result = { root, fileName, states: [], console: [], pageErrors: [], documentSaveRequests: [] };
|
||||
page.on("console", (message) => result.console.push({ type: message.type(), text: message.text() }));
|
||||
page.on("pageerror", (error) => result.pageErrors.push(String(error && error.stack || error)));
|
||||
page.on("request", (request) => {
|
||||
try {
|
||||
const url = new URL(request.url());
|
||||
if (url.pathname !== "/api/documents/save" || request.method() !== "POST") return;
|
||||
const payload = JSON.parse(request.postData() || "{}");
|
||||
result.documentSaveRequests.push({
|
||||
editorSource: String(payload.editorSource || ""),
|
||||
documentId: String(payload.documentId || ""),
|
||||
expectedFileVersion: String(payload.expectedFileVersion || ""),
|
||||
writeIntentId: String(payload.writeIntentId || ""),
|
||||
saveOperationId: String(payload.saveOperationId || ""),
|
||||
blockCount: Number(payload.blockCount || 0),
|
||||
});
|
||||
} catch (_error) {
|
||||
// ignore diagnostics-only capture failures
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await quickLogin(page);
|
||||
await setPptPageWidth(context, root, relativePath, "full");
|
||||
await openDocument(page, root, relativePath);
|
||||
|
||||
await uploadAttachmentViaSlash(page, PPTX_PATH);
|
||||
const firstLinks = await waitForEditorLinks(page, 1);
|
||||
const firstConflictState = await assertNoConflict(page, "first-editor-upload");
|
||||
assertLocalUploadSaveVersions(result, 1);
|
||||
result.states.push({ step: "first-editor-upload", links: firstLinks, conflictState: firstConflictState });
|
||||
await typeLineAfterUpload(page, "第一份附件后的正文");
|
||||
const afterFirstEditConflictState = await assertNoConflict(page, "after-first-upload-edit");
|
||||
result.states.push({ step: "after-first-upload-edit", conflictState: afterFirstEditConflictState });
|
||||
|
||||
await uploadAttachmentViaSlash(page, PPTX_PATH);
|
||||
const secondLinks = await waitForEditorLinks(page, 2);
|
||||
const secondConflictState = await assertNoConflict(page, "second-editor-upload");
|
||||
assertLocalUploadSaveVersions(result, 2);
|
||||
await delay(1800);
|
||||
const editorRows = await waitForFileTreeAssetRows(page, 2);
|
||||
result.states.push({ step: "second-editor-upload", links: secondLinks, conflictState: secondConflictState, fileTreeRows: editorRows });
|
||||
assert(editorRows.every((row) => row.localRelativePath), `文件树上传行缺少本地路径: ${JSON.stringify(editorRows)}`);
|
||||
await openDocument(page, root, relativePath);
|
||||
await waitForEditorLinks(page, 2);
|
||||
await clickEditorPptxLink(page, context, fileName);
|
||||
await activatePrimaryPageTab(page);
|
||||
await clickEditorPptxLink(page, context, fileName.replace(/\.pptx$/i, "-1.pptx"));
|
||||
await clickPptxFileTreeRow(page, fileName.replace(/\.pptx$/i, "-1.pptx"));
|
||||
const fullWidthLayout = await waitForActivePptxResourceTabLayout(page, "full");
|
||||
assert(fullWidthLayout.panel?.width >= fullWidthLayout.viewportWidth - 320, `PPT 资源 tab 面板应使用主编辑区宽度: ${JSON.stringify(fullWidthLayout)}`);
|
||||
assert(fullWidthLayout.frame?.width >= fullWidthLayout.viewportWidth - 320, `PPT 资源 tab iframe 应使用主编辑区宽度: ${JSON.stringify(fullWidthLayout)}`);
|
||||
result.states.push({ step: "pptx-resource-tab-full-width", layout: fullWidthLayout });
|
||||
|
||||
await dispatchPptxDrop(page, PPTX_PATH, "README");
|
||||
const droppedFileName = fileName.replace(/\.pptx$/i, " 2.pptx");
|
||||
const droppedPath = path.join(resourceDir, droppedFileName);
|
||||
await page.waitForFunction(
|
||||
(targetPath) => document.documentElement.getAttribute("data-mnote-assets-local-applied") === "true" && targetPath,
|
||||
droppedPath,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
).catch(() => undefined);
|
||||
await waitForLocalFile(droppedPath);
|
||||
const finalRows = await waitForFileTreeAssetRows(page, 3);
|
||||
result.states.push({ step: "filetree-drop-upload", droppedPath, fileTreeRows: finalRows });
|
||||
await clickPptxFileTreeRow(page, droppedFileName);
|
||||
|
||||
await page.screenshot({ path: path.join(OUTPUT_DIR, "success.png"), fullPage: false });
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify({ ok: true, ...result }, null, 2)}\n`, "utf8");
|
||||
console.log(JSON.stringify({ ok: true, resultPath: RESULT_PATH, root, fileName }, null, 2));
|
||||
} catch (error) {
|
||||
const diagnostics = await page.evaluate(() => ({
|
||||
url: location.href,
|
||||
lastUploadAssetId: document.documentElement.getAttribute("data-mnote-last-upload-asset-id") || "",
|
||||
lastUploadInserted: document.documentElement.getAttribute("data-mnote-last-upload-inserted") || "",
|
||||
assetsLocalApplied: document.documentElement.getAttribute("data-mnote-assets-local-applied") || "",
|
||||
filetreeRows: Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]')).map((row) => ({
|
||||
text: row.textContent?.trim() || "",
|
||||
rowKind: row.getAttribute("data-row-kind") || "",
|
||||
rowId: row.getAttribute("data-row-id") || "",
|
||||
assetId: row.getAttribute("data-asset-id") || "",
|
||||
localRelativePath: row.getAttribute("data-local-relative-path") || "",
|
||||
})).slice(0, 80),
|
||||
editorLinks: Array.from(document.querySelectorAll('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a')).map((link) => ({
|
||||
text: link.textContent || "",
|
||||
href: link.getAttribute("href") || "",
|
||||
assetId: link.getAttribute("data-asset-id") || "",
|
||||
className: link.getAttribute("class") || "",
|
||||
})),
|
||||
documentSessions: typeof window.__mnoteDebugDocumentSessions?.snapshot === "function"
|
||||
? window.__mnoteDebugDocumentSessions.snapshot()
|
||||
: null,
|
||||
aggregate: (() => {
|
||||
try {
|
||||
const script = document.getElementById("__MNOTE_PAGE_AGGREGATE__");
|
||||
return script ? JSON.parse(script.textContent || "null") : null;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
})(),
|
||||
})).catch((err) => ({ diagnosticsError: String(err) }));
|
||||
await page.screenshot({ path: path.join(OUTPUT_DIR, "failure.png"), fullPage: true }).catch(() => undefined);
|
||||
fs.writeFileSync(
|
||||
RESULT_PATH,
|
||||
`${JSON.stringify({ ok: false, ...result, diagnostics, error: String(error && error.stack || error) }, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
console.error(JSON.stringify({ ok: false, resultPath: RESULT_PATH, root, diagnostics, error: String(error && error.stack || error) }, null, 2));
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error && error.stack ? error.stack : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,410 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
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", "task504-local-md-external-conflict-recovery-smoke");
|
||||
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"]
|
||||
.find((candidate) => fs.existsSync(candidate));
|
||||
|
||||
function fileUrl(localPath) {
|
||||
return `file://${localPath}`;
|
||||
}
|
||||
|
||||
function localMdDocumentId(relativePath) {
|
||||
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
|
||||
}
|
||||
|
||||
function workspaceId(ownerId) {
|
||||
return `local-ws:${ownerId}:task504`;
|
||||
}
|
||||
|
||||
function documentUrl(root, relativePath) {
|
||||
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", fileUrl(root));
|
||||
url.searchParams.set("treeView", "filetree");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function writeWorkspaceManifest(root, ownerId) {
|
||||
const metadataDir = path.join(root, ".mnote");
|
||||
fs.mkdirSync(metadataDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(metadataDir, "workspace.json"),
|
||||
`${JSON.stringify({
|
||||
workspaceId: workspaceId(ownerId),
|
||||
ownerId,
|
||||
createdAt: new Date().toISOString(),
|
||||
capabilities: ["local_files", "markdown_edit", "asset_upload"],
|
||||
}, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
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 }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function openDocument(page, root, relativePath) {
|
||||
await page.goto(documentUrl(root, relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
async function typeUnsavedEditorText(page, text) {
|
||||
const editor = page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first();
|
||||
await editor.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.keyboard.press("End").catch(() => undefined);
|
||||
await page.keyboard.press("Enter");
|
||||
await page.keyboard.type(text, { delay: 2 });
|
||||
}
|
||||
|
||||
async function waitForFileContent(filePath, predicate, timeoutMs) {
|
||||
const startedAt = Date.now();
|
||||
let lastContent = "";
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if (fs.existsSync(filePath)) {
|
||||
lastContent = fs.readFileSync(filePath, "utf8");
|
||||
if (predicate(lastContent)) return lastContent;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
}
|
||||
throw new Error(`文件内容未达到预期: ${filePath}; lastContent=${JSON.stringify(lastContent)}`);
|
||||
}
|
||||
|
||||
function withTimeout(promise, timeoutMs, makeError) {
|
||||
let timer = 0;
|
||||
const timeout = new Promise((_, reject) => {
|
||||
timer = setTimeout(async () => {
|
||||
try {
|
||||
const error = typeof makeError === "function"
|
||||
? await makeError()
|
||||
: new Error(String(makeError || "operation_timeout"));
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
}, timeoutMs);
|
||||
});
|
||||
return Promise.race([promise, timeout]).finally(() => {
|
||||
if (timer) clearTimeout(timer);
|
||||
});
|
||||
}
|
||||
|
||||
async function captureEditorDiagnostics(page, extra = {}) {
|
||||
return await page.evaluate((extraData) => ({
|
||||
...extraData,
|
||||
url: location.href,
|
||||
status: document.querySelector('[data-runtime-editor-status]')?.getAttribute('data-runtime-editor-status') || "",
|
||||
statusError: document.querySelector('[data-runtime-editor-error]')?.getAttribute('data-runtime-editor-error') || "",
|
||||
conflictVisible: Boolean(document.querySelector('[data-testid="mnote-editor-conflict-panel"]')),
|
||||
editorText: document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror')?.textContent || "",
|
||||
panelText: document.querySelector('[data-testid="mnote-editor-conflict-panel"]')?.textContent || "",
|
||||
sessions: typeof window.__mnoteDebugDocumentSessions?.snapshot === "function"
|
||||
? window.__mnoteDebugDocumentSessions.snapshot()
|
||||
: null,
|
||||
}), extra).catch((err) => ({ ...extra, diagnosticsError: String(err) }));
|
||||
}
|
||||
|
||||
async function waitForConflictState(page, expectedEditorText, expectedDiskText) {
|
||||
await page.locator('[data-testid="mnote-editor-conflict-panel"]').first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.waitForFunction(
|
||||
({ editorNeedle, diskNeedle }) => {
|
||||
const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
|
||||
const panel = document.querySelector('[data-testid="mnote-editor-conflict-panel"]');
|
||||
return root?.getAttribute("data-runtime-editor-status") === "external-change-conflict"
|
||||
&& (editor?.textContent || "").includes(editorNeedle)
|
||||
&& (panel?.textContent || "").includes("文件冲突")
|
||||
&& diskNeedle;
|
||||
},
|
||||
{ editorNeedle: expectedEditorText, diskNeedle: expectedDiskText },
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
return await page.evaluate(() => {
|
||||
const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
|
||||
const panel = document.querySelector('[data-testid="mnote-editor-conflict-panel"]');
|
||||
const sessions = typeof window.__mnoteDebugDocumentSessions?.snapshot === "function"
|
||||
? window.__mnoteDebugDocumentSessions.snapshot()
|
||||
: null;
|
||||
return {
|
||||
status: root?.getAttribute("data-runtime-editor-status") || "",
|
||||
statusError: root?.getAttribute("data-runtime-editor-error") || "",
|
||||
editorText: editor?.textContent || "",
|
||||
panelText: panel?.textContent || "",
|
||||
sessions,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function triggerExternalConflict(page, markdownPath, editorToken, diskToken) {
|
||||
await typeUnsavedEditorText(page, editorToken);
|
||||
fs.writeFileSync(markdownPath, `# Conflict Base\n\n${diskToken}\n`, "utf8");
|
||||
const conflictState = await waitForConflictState(page, editorToken, diskToken);
|
||||
assert(conflictState.editorText.includes(editorToken), `冲突后应保留编辑器未保存内容: ${JSON.stringify(conflictState)}`);
|
||||
assert(conflictState.status === "external-change-conflict", `应进入外部冲突态: ${JSON.stringify(conflictState)}`);
|
||||
return conflictState;
|
||||
}
|
||||
|
||||
async function openConflictDiff(page, editorToken, diskToken) {
|
||||
await page.getByTestId("mnote-conflict-open-diff").click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="mnote-conflict-diff-panel"]').first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.waitForFunction(
|
||||
({ editorNeedle, diskNeedle }) => {
|
||||
const current = document.querySelector('[data-testid="mnote-conflict-current-text"]')?.textContent || "";
|
||||
const disk = document.querySelector('[data-testid="mnote-conflict-disk-text"]')?.textContent || "";
|
||||
return current.includes(editorNeedle) && disk.includes(diskNeedle);
|
||||
},
|
||||
{ editorNeedle: editorToken, diskNeedle: diskToken },
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
return await page.evaluate(() => ({
|
||||
currentText: document.querySelector('[data-testid="mnote-conflict-current-text"]')?.textContent || "",
|
||||
diskText: document.querySelector('[data-testid="mnote-conflict-disk-text"]')?.textContent || "",
|
||||
mergeText: document.querySelector('[data-testid="mnote-conflict-merge-text"]')?.value || "",
|
||||
}));
|
||||
}
|
||||
|
||||
async function acceptDiskVersion(page, diskToken) {
|
||||
await page.getByTestId("mnote-conflict-accept-disk").click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
(needle) => {
|
||||
const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
const editorText = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror')?.textContent || "";
|
||||
const conflictVisible = Boolean(document.querySelector('[data-testid="mnote-editor-conflict-panel"]'));
|
||||
return !conflictVisible
|
||||
&& root?.getAttribute("data-runtime-editor-status") !== "external-change-conflict"
|
||||
&& editorText.includes(needle);
|
||||
},
|
||||
diskToken,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
return await page.evaluate(() => ({
|
||||
status: document.querySelector('[data-runtime-editor-status]')?.getAttribute('data-runtime-editor-status') || "",
|
||||
editorText: document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror')?.textContent || "",
|
||||
conflictVisible: Boolean(document.querySelector('[data-testid="mnote-editor-conflict-panel"]')),
|
||||
}));
|
||||
}
|
||||
|
||||
async function keepCurrentVersion(page, markdownPath, editorToken) {
|
||||
await page.getByTestId("mnote-conflict-keep-current").click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
(needle) => {
|
||||
const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
const editorText = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror')?.textContent || "";
|
||||
const conflictVisible = Boolean(document.querySelector('[data-testid="mnote-editor-conflict-panel"]'));
|
||||
return !conflictVisible
|
||||
&& root?.getAttribute("data-runtime-editor-status") !== "external-change-conflict"
|
||||
&& editorText.includes(needle);
|
||||
},
|
||||
editorToken,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
const saved = await waitForFileContent(markdownPath, (content) => content.includes(editorToken), UI_TIMEOUT_MS);
|
||||
return await page.evaluate((savedContent) => ({
|
||||
status: document.querySelector('[data-runtime-editor-status]')?.getAttribute('data-runtime-editor-status') || "",
|
||||
editorText: document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror')?.textContent || "",
|
||||
conflictVisible: Boolean(document.querySelector('[data-testid="mnote-editor-conflict-panel"]')),
|
||||
savedContent,
|
||||
}), saved);
|
||||
}
|
||||
|
||||
async function installDelayedFirstSave(page) {
|
||||
const gate = {
|
||||
enabled: false,
|
||||
intercepted: false,
|
||||
saveRequestCount: 0,
|
||||
release: null,
|
||||
interceptedPromise: null,
|
||||
releasePromise: null,
|
||||
};
|
||||
gate.interceptedPromise = new Promise((resolve) => {
|
||||
gate.markIntercepted = resolve;
|
||||
});
|
||||
gate.releasePromise = new Promise((resolve) => {
|
||||
gate.release = resolve;
|
||||
});
|
||||
const interceptSave = async (route) => {
|
||||
gate.saveRequestCount += 1;
|
||||
if (gate.enabled && !gate.intercepted) {
|
||||
gate.intercepted = true;
|
||||
gate.markIntercepted();
|
||||
await gate.releasePromise;
|
||||
}
|
||||
await route.continue();
|
||||
};
|
||||
await page.route("**/api/documents/save", interceptSave);
|
||||
await page.route("**/api/page-body/write", interceptSave);
|
||||
return gate;
|
||||
}
|
||||
|
||||
async function assertSaveWhileTypingKeepsDirty(page, markdownPath, firstToken, secondToken, gate) {
|
||||
const saveRequestCountBeforeRace = gate.saveRequestCount;
|
||||
gate.enabled = true;
|
||||
await typeUnsavedEditorText(page, firstToken);
|
||||
await withTimeout(
|
||||
gate.interceptedPromise,
|
||||
UI_TIMEOUT_MS,
|
||||
async () => new Error(`保存请求未被拦截: ${JSON.stringify(await captureEditorDiagnostics(page, {
|
||||
saveRequestCount: gate.saveRequestCount,
|
||||
firstToken,
|
||||
secondToken,
|
||||
}))}`),
|
||||
);
|
||||
await typeUnsavedEditorText(page, secondToken);
|
||||
gate.release();
|
||||
await page.waitForFunction(
|
||||
({ firstNeedle, secondNeedle }) => {
|
||||
const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
const editorText = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror')?.textContent || "";
|
||||
const conflictVisible = Boolean(document.querySelector('[data-testid="mnote-editor-conflict-panel"]'));
|
||||
return !conflictVisible
|
||||
&& root?.getAttribute("data-runtime-editor-status") !== "external-change-conflict"
|
||||
&& editorText.includes(firstNeedle)
|
||||
&& editorText.includes(secondNeedle);
|
||||
},
|
||||
{ firstNeedle: firstToken, secondNeedle: secondToken },
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
const saved = await waitForFileContent(
|
||||
markdownPath,
|
||||
(content) => content.includes(firstToken) && content.includes(secondToken),
|
||||
UI_TIMEOUT_MS,
|
||||
);
|
||||
assert(
|
||||
gate.saveRequestCount >= saveRequestCountBeforeRace + 2,
|
||||
`保存中继续输入应触发第二轮保存: before=${saveRequestCountBeforeRace}; after=${gate.saveRequestCount}`,
|
||||
);
|
||||
return await page.evaluate(({ savedContent, saveRequestCountBeforeRace, saveRequestCountAfterRace }) => ({
|
||||
status: document.querySelector('[data-runtime-editor-status]')?.getAttribute('data-runtime-editor-status') || "",
|
||||
editorText: document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror')?.textContent || "",
|
||||
conflictVisible: Boolean(document.querySelector('[data-testid="mnote-editor-conflict-panel"]')),
|
||||
savedContent,
|
||||
saveRequestCountBeforeRace,
|
||||
saveRequestCountAfterRace,
|
||||
}), {
|
||||
savedContent: saved,
|
||||
saveRequestCountBeforeRace,
|
||||
saveRequestCountAfterRace: gate.saveRequestCount,
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task504-conflict-"));
|
||||
const relativePath = "README.md";
|
||||
const keepRelativePath = "Keep.md";
|
||||
const raceRelativePath = "SaveRace.md";
|
||||
const markdownPath = path.join(root, relativePath);
|
||||
const keepMarkdownPath = path.join(root, keepRelativePath);
|
||||
const raceMarkdownPath = path.join(root, raceRelativePath);
|
||||
writeWorkspaceManifest(root, "mnote-e2e");
|
||||
fs.writeFileSync(markdownPath, "# Conflict Base\n\n原始正文\n", "utf8");
|
||||
fs.writeFileSync(keepMarkdownPath, "# Conflict Base\n\n保留当前基线\n", "utf8");
|
||||
fs.writeFileSync(raceMarkdownPath, "# Save Race\n\n保存中继续输入基线\n", "utf8");
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: process.env.HEADFUL !== "1",
|
||||
executablePath: CHROMIUM_EXECUTABLE_PATH,
|
||||
});
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1366, height: 900 },
|
||||
extraHTTPHeaders: {
|
||||
"x-mnote-actor-id": "mnote-e2e",
|
||||
"x-mnote-actor-type": "user",
|
||||
},
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const delayedSaveGate = await installDelayedFirstSave(page);
|
||||
const result = { root, relativePath, states: [], console: [], pageErrors: [] };
|
||||
page.on("console", (message) => result.console.push({ type: message.type(), text: message.text() }));
|
||||
page.on("pageerror", (error) => result.pageErrors.push(String(error && error.stack || error)));
|
||||
|
||||
try {
|
||||
await quickLogin(page);
|
||||
await openDocument(page, root, relativePath);
|
||||
const editorToken = `编辑器未保存 ${Date.now()}`;
|
||||
const diskToken = `磁盘外部修改 ${Date.now()}`;
|
||||
const conflictState = await triggerExternalConflict(page, markdownPath, editorToken, diskToken);
|
||||
result.states.push({ step: "external-edit-conflict", conflictState, diskText: fs.readFileSync(markdownPath, "utf8") });
|
||||
const diffState = await openConflictDiff(page, editorToken, diskToken);
|
||||
result.states.push({ step: "open-diff", diffState });
|
||||
const acceptState = await acceptDiskVersion(page, diskToken);
|
||||
result.states.push({ step: "accept-disk", acceptState });
|
||||
|
||||
await openDocument(page, root, keepRelativePath);
|
||||
const keepEditorToken = `保留当前编辑器 ${Date.now()}`;
|
||||
const keepDiskToken = `保留当前磁盘外部修改 ${Date.now()}`;
|
||||
const keepConflictState = await triggerExternalConflict(page, keepMarkdownPath, keepEditorToken, keepDiskToken);
|
||||
result.states.push({ step: "keep-current-conflict", conflictState: keepConflictState, diskText: fs.readFileSync(keepMarkdownPath, "utf8") });
|
||||
const keepState = await keepCurrentVersion(page, keepMarkdownPath, keepEditorToken);
|
||||
result.states.push({ step: "keep-current", keepState });
|
||||
|
||||
await openDocument(page, root, raceRelativePath);
|
||||
const firstRaceToken = `保存中第一段 ${Date.now()}`;
|
||||
const secondRaceToken = `保存中第二段 ${Date.now()}`;
|
||||
const raceState = await assertSaveWhileTypingKeepsDirty(
|
||||
page,
|
||||
raceMarkdownPath,
|
||||
firstRaceToken,
|
||||
secondRaceToken,
|
||||
delayedSaveGate,
|
||||
);
|
||||
result.states.push({ step: "save-while-typing", raceState });
|
||||
|
||||
await page.screenshot({ path: path.join(OUTPUT_DIR, "conflict.png"), fullPage: false });
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify({ ok: true, ...result }, null, 2)}\n`, "utf8");
|
||||
console.log(JSON.stringify({ ok: true, resultPath: RESULT_PATH, root, conflictState }, null, 2));
|
||||
} catch (error) {
|
||||
const diagnostics = await captureEditorDiagnostics(page, {
|
||||
delayedSaveGate: {
|
||||
enabled: delayedSaveGate.enabled,
|
||||
intercepted: delayedSaveGate.intercepted,
|
||||
saveRequestCount: delayedSaveGate.saveRequestCount,
|
||||
},
|
||||
});
|
||||
await page.screenshot({ path: path.join(OUTPUT_DIR, "failure.png"), fullPage: true }).catch(() => undefined);
|
||||
fs.writeFileSync(
|
||||
RESULT_PATH,
|
||||
`${JSON.stringify({ ok: false, ...result, diagnostics, diskText: fs.readFileSync(markdownPath, "utf8"), error: String(error && error.stack || error) }, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
console.error(JSON.stringify({ ok: false, resultPath: RESULT_PATH, root, diagnostics, error: String(error && error.stack || error) }, null, 2));
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error && error.stack ? error.stack : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
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 CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|
||||
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"]
|
||||
.find((candidate) => fs.existsSync(candidate));
|
||||
|
||||
function fileUrl(localPath) {
|
||||
return `file://${localPath}`;
|
||||
}
|
||||
|
||||
function localMdDocumentId(relativePath) {
|
||||
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
|
||||
}
|
||||
|
||||
function documentUrl(root, relativePath) {
|
||||
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", fileUrl(root));
|
||||
url.searchParams.set("treeView", "filetree");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function writeWorkspaceManifest(root, ownerId) {
|
||||
const metadataDir = path.join(root, ".mnote");
|
||||
fs.mkdirSync(metadataDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(metadataDir, "workspace.json"),
|
||||
`${JSON.stringify({
|
||||
workspaceId: `local-ws:${ownerId}:task505`,
|
||||
ownerId,
|
||||
createdAt: new Date().toISOString(),
|
||||
capabilities: ["local_files", "markdown_edit", "asset_upload"],
|
||||
}, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
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 }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadLocalAsset(page, root, documentId, fileName, mimeType, bytes, kind) {
|
||||
return await page.evaluate(
|
||||
async ({ rootUri, documentId, fileName, mimeType, bytes, kind }) => {
|
||||
const form = new FormData();
|
||||
form.append("rootUri", rootUri);
|
||||
form.append("documentId", documentId);
|
||||
form.append("uploadIntent", "editor.markdown.attach");
|
||||
form.append("kind", kind);
|
||||
form.append("file", new File([new Uint8Array(bytes)], fileName, { type: mimeType }));
|
||||
const response = await fetch("/api/local-folder/assets/upload", { method: "POST", body: form });
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok || !payload || payload.ok !== true) {
|
||||
throw new Error(`upload_failed_${response.status}:${JSON.stringify(payload)}`);
|
||||
}
|
||||
return payload.asset;
|
||||
},
|
||||
{ rootUri: fileUrl(root), documentId, fileName, mimeType, bytes: Array.from(bytes), kind },
|
||||
);
|
||||
}
|
||||
|
||||
async function openResourceTab(page, asset, documentId) {
|
||||
await page.evaluate(({ asset, documentId }) => {
|
||||
window.dispatchEvent(new CustomEvent("tree.asset.open", {
|
||||
detail: {
|
||||
assetId: asset.id,
|
||||
documentId,
|
||||
title: asset.file_name || "resource-note.md",
|
||||
assetType: asset.asset_type || "attachment",
|
||||
},
|
||||
}));
|
||||
}, { asset, documentId });
|
||||
await page.locator('[data-testid="mnote-resource-tab-host"]:not([hidden])').waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.locator('.mnote-resource-tab-panel:not([hidden]) .editor-surface .ProseMirror[contenteditable="true"]').first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task505-resource-watch-"));
|
||||
const relativePath = "README.md";
|
||||
const documentId = localMdDocumentId(relativePath);
|
||||
const resourceRelativePath = "README/resource-note.md";
|
||||
const resourcePath = path.join(root, resourceRelativePath);
|
||||
writeWorkspaceManifest(root, "mnote-e2e");
|
||||
fs.writeFileSync(path.join(root, relativePath), "# Page\n\n页面正文保持不变\n", "utf8");
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: process.env.HEADFUL !== "1",
|
||||
executablePath: CHROMIUM_EXECUTABLE_PATH,
|
||||
});
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1366, height: 900 },
|
||||
extraHTTPHeaders: {
|
||||
"x-mnote-actor-id": "mnote-e2e",
|
||||
"x-mnote-actor-type": "user",
|
||||
},
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const result = { root, resourceRelativePath, states: [] };
|
||||
try {
|
||||
await quickLogin(page);
|
||||
await page.goto(documentUrl(root, relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const asset = await uploadLocalAsset(
|
||||
page,
|
||||
root,
|
||||
documentId,
|
||||
"resource-note.md",
|
||||
"text/markdown",
|
||||
Buffer.from("# Resource Note\n\n旧资源正文\n", "utf8"),
|
||||
"attachment",
|
||||
);
|
||||
await page.goto(documentUrl(root, relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await openResourceTab(page, asset, documentId);
|
||||
await page.waitForFunction(
|
||||
(resourceNeedle) => {
|
||||
const snapshot = typeof window.__mnoteDebugDocumentSessions?.snapshot === "function"
|
||||
? window.__mnoteDebugDocumentSessions.snapshot()
|
||||
: null;
|
||||
return Array.isArray(snapshot?.localFolderRoots)
|
||||
&& snapshot.localFolderRoots.some((item) => String(item || "").includes(`resource:${resourceNeedle}`));
|
||||
},
|
||||
resourceRelativePath,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
const externalCleanToken = `外部资源更新 ${Date.now()}`;
|
||||
fs.writeFileSync(resourcePath, `# Resource Note\n\n${externalCleanToken}\n`, "utf8");
|
||||
await page.waitForFunction(
|
||||
(needle) => {
|
||||
const resourceText = document.querySelector('.mnote-resource-tab-panel:not([hidden]) .editor-surface .ProseMirror')?.textContent || "";
|
||||
const pageConflict = Boolean(document.querySelector('.document-pane[data-pane-role="primary"] > [data-testid="mnote-editor-conflict-panel"]'));
|
||||
return resourceText.includes(needle) && !pageConflict;
|
||||
},
|
||||
externalCleanToken,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
result.states.push({ step: "clean-resource-external-update", externalCleanToken });
|
||||
|
||||
const editor = page.locator('.mnote-resource-tab-panel:not([hidden]) .editor-surface .ProseMirror[contenteditable="true"]').first();
|
||||
const dirtyToken = `资源未保存 ${Date.now()}`;
|
||||
const externalDirtyToken = `资源外部二次更新 ${Date.now()}`;
|
||||
await editor.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.keyboard.press(process.platform === "darwin" ? "Meta+End" : "Control+End").catch(() => undefined);
|
||||
await page.keyboard.type(`\n${dirtyToken}`, { delay: 2 });
|
||||
fs.writeFileSync(resourcePath, `# Resource Note\n\n${externalDirtyToken}\n`, "utf8");
|
||||
await page.waitForFunction(
|
||||
({ dirtyNeedle }) => {
|
||||
const resourceText = document.querySelector('.mnote-resource-tab-panel:not([hidden]) .editor-surface .ProseMirror')?.textContent || "";
|
||||
const resourceConflict = document.querySelector('.mnote-resource-tab-panel:not([hidden]) [data-testid="mnote-editor-conflict-panel"]');
|
||||
const pageConflict = Boolean(document.querySelector('.document-pane[data-pane-role="primary"] > [data-testid="mnote-editor-conflict-panel"]'));
|
||||
return resourceText.includes(dirtyNeedle)
|
||||
&& Boolean(resourceConflict)
|
||||
&& /资源文件已在外部更新|文件冲突/.test(resourceConflict.textContent || "")
|
||||
&& !pageConflict;
|
||||
},
|
||||
{ dirtyNeedle: dirtyToken },
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
result.states.push({ step: "dirty-resource-external-conflict", dirtyToken, externalDirtyToken });
|
||||
|
||||
const finalState = await page.evaluate(() => ({
|
||||
resourceStatus: document.querySelector('.mnote-resource-tab-panel:not([hidden]) [data-runtime-editor-status]')?.getAttribute('data-runtime-editor-status') || "",
|
||||
resourceText: document.querySelector('.mnote-resource-tab-panel:not([hidden]) .editor-surface .ProseMirror')?.textContent || "",
|
||||
resourceConflictText: document.querySelector('.mnote-resource-tab-panel:not([hidden]) [data-testid="mnote-editor-conflict-panel"]')?.textContent || "",
|
||||
pageText: document.querySelector('.document-pane[data-pane-role="primary"] > .document-shell .editor-surface .ProseMirror')?.textContent || "",
|
||||
pageConflictVisible: Boolean(document.querySelector('.document-pane[data-pane-role="primary"] > [data-testid="mnote-editor-conflict-panel"]')),
|
||||
sessions: typeof window.__mnoteDebugDocumentSessions?.snapshot === "function"
|
||||
? window.__mnoteDebugDocumentSessions.snapshot()
|
||||
: null,
|
||||
}));
|
||||
assert.equal(finalState.pageConflictVisible, false, `资源冲突不应污染正文页面: ${JSON.stringify(finalState)}`);
|
||||
assert(finalState.resourceText.includes(dirtyToken), `资源冲突后应保留未保存内容: ${JSON.stringify(finalState)}`);
|
||||
result.states.push({ step: "final", finalState });
|
||||
const officeAsset = await uploadLocalAsset(
|
||||
page,
|
||||
root,
|
||||
documentId,
|
||||
"resource-office.docx",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
Buffer.from("task505 office initial", "utf8"),
|
||||
"attachment",
|
||||
);
|
||||
const officePath = path.join(root, "README", "resource-office.docx");
|
||||
await page.evaluate(({ asset, documentId }) => {
|
||||
window.dispatchEvent(new CustomEvent("tree.asset.open", {
|
||||
detail: {
|
||||
assetId: asset.id,
|
||||
documentId,
|
||||
title: asset.file_name || "resource-office.docx",
|
||||
assetType: asset.asset_type || "attachment",
|
||||
},
|
||||
}));
|
||||
}, { asset: officeAsset, documentId });
|
||||
await page.locator('.mnote-resource-tab-panel:not([hidden]) iframe.mnote-resource-tab-frame').first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.locator('.mnote-resource-tab-panel:not([hidden])[data-mnote-resource-watch-ready="true"]').waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const officeFrameBefore = await page.locator('.mnote-resource-tab-panel:not([hidden]) iframe.mnote-resource-tab-frame').first().getAttribute("src");
|
||||
fs.writeFileSync(officePath, "task505 office externally updated", "utf8");
|
||||
await page.waitForFunction(
|
||||
(previousSrc) => {
|
||||
const frame = document.querySelector('.mnote-resource-tab-panel:not([hidden]) iframe.mnote-resource-tab-frame');
|
||||
const src = frame instanceof HTMLIFrameElement ? String(frame.getAttribute('src') || frame.src || '') : '';
|
||||
return src && src !== previousSrc && src.includes('mnoteResourceReload=');
|
||||
},
|
||||
officeFrameBefore,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
result.states.push({ step: "passive-office-external-update", officeFrameBefore });
|
||||
fs.unlinkSync(officePath);
|
||||
await page.locator('.mnote-resource-tab-panel:not([hidden]) [data-mnote-resource-missing="true"]').first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const officeMissingState = await page.evaluate(() => ({
|
||||
activeKind: document.querySelector('.mnote-main-tab.is-active')?.getAttribute('data-mnote-tab-kind') || '',
|
||||
missingText: document.querySelector('.mnote-resource-tab-panel:not([hidden]) [data-mnote-resource-missing="true"]')?.textContent || '',
|
||||
pageConflictVisible: Boolean(document.querySelector('.document-pane[data-pane-role="primary"] > [data-testid="mnote-editor-conflict-panel"]')),
|
||||
}));
|
||||
assert.equal(officeMissingState.pageConflictVisible, false, `Office 删除态不应污染正文页面: ${JSON.stringify(officeMissingState)}`);
|
||||
result.states.push({ step: "passive-office-delete-missing", officeMissingState });
|
||||
console.log(JSON.stringify({ ok: true, ...result }, null, 2));
|
||||
} catch (error) {
|
||||
const diagnostics = await page.evaluate(() => ({
|
||||
url: location.href,
|
||||
resourceText: document.querySelector('.mnote-resource-tab-panel:not([hidden]) .editor-surface .ProseMirror')?.textContent || "",
|
||||
resourceStatus: document.querySelector('.mnote-resource-tab-panel:not([hidden]) [data-runtime-editor-status]')?.getAttribute('data-runtime-editor-status') || "",
|
||||
resourceConflictText: document.querySelector('.mnote-resource-tab-panel:not([hidden]) [data-testid="mnote-editor-conflict-panel"]')?.textContent || "",
|
||||
pageText: document.querySelector('.document-pane[data-pane-role="primary"] > .document-shell .editor-surface .ProseMirror')?.textContent || "",
|
||||
pageConflictVisible: Boolean(document.querySelector('.document-pane[data-pane-role="primary"] > [data-testid="mnote-editor-conflict-panel"]')),
|
||||
sessions: typeof window.__mnoteDebugDocumentSessions?.snapshot === "function"
|
||||
? window.__mnoteDebugDocumentSessions.snapshot()
|
||||
: null,
|
||||
})).catch((diagnosticsError) => ({ diagnosticsError: String(diagnosticsError) }));
|
||||
console.error(JSON.stringify({ ok: false, ...result, diagnostics, error: String(error && error.stack || error) }, null, 2));
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error && error.stack ? error.stack : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
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 CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|
||||
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"]
|
||||
.find((candidate) => fs.existsSync(candidate));
|
||||
|
||||
function fileUrl(localPath) {
|
||||
return `file://${localPath}`;
|
||||
}
|
||||
|
||||
function localMdDocumentId(relativePath) {
|
||||
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
|
||||
}
|
||||
|
||||
function documentUrl(root, relativePath) {
|
||||
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", fileUrl(root));
|
||||
url.searchParams.set("treeView", "filetree");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function writeWorkspaceManifest(root, ownerId) {
|
||||
const metadataDir = path.join(root, ".mnote");
|
||||
fs.mkdirSync(metadataDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(metadataDir, "workspace.json"),
|
||||
`${JSON.stringify({
|
||||
workspaceId: `local-ws:${ownerId}:task506`,
|
||||
ownerId,
|
||||
createdAt: new Date().toISOString(),
|
||||
capabilities: ["local_files", "markdown_edit", "asset_upload"],
|
||||
}, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
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 }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function openDocument(page, root, relativePath) {
|
||||
await page.goto(documentUrl(root, relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForDeletedConflict(page, retainedText) {
|
||||
await page.waitForFunction(
|
||||
(needle) => {
|
||||
const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
const editorText = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror')?.textContent || "";
|
||||
const panelText = document.querySelector('[data-testid="mnote-editor-conflict-panel"]')?.textContent || "";
|
||||
const snapshot = typeof window.__mnoteDebugDocumentSessions?.snapshot === "function"
|
||||
? window.__mnoteDebugDocumentSessions.snapshot()
|
||||
: null;
|
||||
const envelopeDirtyState = snapshot?.sessions?.[0]?.lastExternalConflictEnvelope?.dirtyState || "";
|
||||
return root?.getAttribute("data-runtime-editor-status") === "external-change-conflict"
|
||||
&& editorText.includes(needle)
|
||||
&& panelText.includes("已被删除")
|
||||
&& envelopeDirtyState === "Deleted";
|
||||
},
|
||||
retainedText,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
return await page.evaluate(() => ({
|
||||
status: document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]')?.getAttribute("data-runtime-editor-status") || "",
|
||||
editorText: document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror')?.textContent || "",
|
||||
panelText: document.querySelector('[data-testid="mnote-editor-conflict-panel"]')?.textContent || "",
|
||||
sessions: typeof window.__mnoteDebugDocumentSessions?.snapshot === "function"
|
||||
? window.__mnoteDebugDocumentSessions.snapshot()
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
|
||||
async function waitForExternalMoveConflict(page, retainedText) {
|
||||
await page.waitForFunction(
|
||||
(needle) => {
|
||||
const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
const editorText = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror')?.textContent || "";
|
||||
const panelText = document.querySelector('[data-testid="mnote-editor-conflict-panel"]')?.textContent || "";
|
||||
return root?.getAttribute("data-runtime-editor-status") === "external-change-conflict"
|
||||
&& editorText.includes(needle)
|
||||
&& panelText.includes("文件冲突");
|
||||
},
|
||||
retainedText,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
return await page.evaluate(() => ({
|
||||
status: document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]')?.getAttribute("data-runtime-editor-status") || "",
|
||||
editorText: document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror')?.textContent || "",
|
||||
panelText: document.querySelector('[data-testid="mnote-editor-conflict-panel"]')?.textContent || "",
|
||||
sessions: typeof window.__mnoteDebugDocumentSessions?.snapshot === "function"
|
||||
? window.__mnoteDebugDocumentSessions.snapshot()
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task506-lifecycle-"));
|
||||
const deleteRelativePath = "DeleteMe.md";
|
||||
const moveRelativePath = "MoveMe.md";
|
||||
writeWorkspaceManifest(root, "mnote-e2e");
|
||||
fs.writeFileSync(path.join(root, deleteRelativePath), "# Delete Me\n\n保留删除 buffer\n", "utf8");
|
||||
fs.writeFileSync(path.join(root, moveRelativePath), "# Move Me\n\n保留移动 buffer\n", "utf8");
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: process.env.HEADFUL !== "1",
|
||||
executablePath: CHROMIUM_EXECUTABLE_PATH,
|
||||
});
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1366, height: 900 },
|
||||
extraHTTPHeaders: {
|
||||
"x-mnote-actor-id": "mnote-e2e",
|
||||
"x-mnote-actor-type": "user",
|
||||
},
|
||||
});
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
await quickLogin(page);
|
||||
await openDocument(page, root, deleteRelativePath);
|
||||
fs.rmSync(path.join(root, deleteRelativePath));
|
||||
const deletedState = await waitForDeletedConflict(page, "保留删除 buffer");
|
||||
|
||||
await openDocument(page, root, moveRelativePath);
|
||||
fs.renameSync(path.join(root, moveRelativePath), path.join(root, "Moved.md"));
|
||||
const movedState = await waitForExternalMoveConflict(page, "保留移动 buffer");
|
||||
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
root,
|
||||
states: [
|
||||
{ step: "external-delete", deletedState },
|
||||
{ step: "external-rename-as-deleted", movedState },
|
||||
],
|
||||
}, null, 2));
|
||||
} catch (error) {
|
||||
const diagnostics = await page.evaluate(() => ({
|
||||
url: location.href,
|
||||
status: document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]')?.getAttribute("data-runtime-editor-status") || "",
|
||||
statusError: document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]')?.getAttribute("data-runtime-editor-error") || "",
|
||||
editorText: document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror')?.textContent || "",
|
||||
panelText: document.querySelector('[data-testid="mnote-editor-conflict-panel"]')?.textContent || "",
|
||||
sessions: typeof window.__mnoteDebugDocumentSessions?.snapshot === "function"
|
||||
? window.__mnoteDebugDocumentSessions.snapshot()
|
||||
: null,
|
||||
})).catch((diagnosticsError) => ({ diagnosticsError: String(diagnosticsError) }));
|
||||
console.error(JSON.stringify({ ok: false, root, diagnostics, error: String(error && error.stack || error) }, null, 2));
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error && error.stack ? error.stack : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
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 CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|
||||
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"]
|
||||
.find((candidate) => fs.existsSync(candidate));
|
||||
|
||||
function fileUrl(localPath) {
|
||||
return `file://${localPath}`;
|
||||
}
|
||||
|
||||
function localMdDocumentId(relativePath) {
|
||||
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
|
||||
}
|
||||
|
||||
function documentUrl(root, relativePath) {
|
||||
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", fileUrl(root));
|
||||
url.searchParams.set("treeView", "filetree");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function writeWorkspaceManifest(root, ownerId) {
|
||||
const metadataDir = path.join(root, ".mnote");
|
||||
fs.mkdirSync(metadataDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(metadataDir, "workspace.json"),
|
||||
`${JSON.stringify({
|
||||
workspaceId: `local-ws:${ownerId}:task508`,
|
||||
ownerId,
|
||||
createdAt: new Date().toISOString(),
|
||||
capabilities: ["local_files", "markdown_edit", "asset_upload"],
|
||||
}, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
async function openDocument(page, root, relativePath) {
|
||||
await page.goto(documentUrl(root, relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
async function typeEditorText(page, text) {
|
||||
const editor = page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first();
|
||||
await editor.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.keyboard.press(process.platform === "darwin" ? "Meta+End" : "Control+End").catch(() => undefined);
|
||||
await page.keyboard.type(`\n${text}`, { delay: 2 });
|
||||
}
|
||||
|
||||
async function waitForEditorText(page, text) {
|
||||
await page.waitForFunction(
|
||||
(needle) => {
|
||||
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
|
||||
return (editor?.textContent || "").includes(needle);
|
||||
},
|
||||
text,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForFileText(filePath, text) {
|
||||
const deadline = Date.now() + UI_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
const content = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : "";
|
||||
if (content.includes(text)) return content;
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
}
|
||||
throw new Error(`文件未出现期望内容: ${text}`);
|
||||
}
|
||||
|
||||
async function withTimeout(promise, timeoutMs, diagnostics) {
|
||||
let timer = 0;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(async () => {
|
||||
let detail = null;
|
||||
if (typeof diagnostics === "function") {
|
||||
try {
|
||||
detail = await diagnostics();
|
||||
} catch (error) {
|
||||
detail = { diagnosticsError: error && error.message ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
reject(new Error(`等待超时: ${JSON.stringify(detail)}`));
|
||||
}, timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function installDelayedSave(page) {
|
||||
let release = null;
|
||||
const gate = {
|
||||
intercepted: false,
|
||||
status: 0,
|
||||
body: "",
|
||||
release: () => release && release(),
|
||||
interceptedPromise: null,
|
||||
};
|
||||
gate.interceptedPromise = new Promise((resolve) => {
|
||||
gate.markIntercepted = resolve;
|
||||
});
|
||||
const releasePromise = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await page.route("**/api/page-body/write", async (route) => {
|
||||
if (!gate.intercepted) {
|
||||
gate.intercepted = true;
|
||||
gate.markIntercepted();
|
||||
await releasePromise;
|
||||
const response = await route.fetch();
|
||||
gate.status = response.status();
|
||||
gate.body = await response.text();
|
||||
await route.fulfill({
|
||||
status: gate.status,
|
||||
headers: response.headers(),
|
||||
body: gate.body,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
return gate;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task508-multitab-"));
|
||||
const relativePath = "Concurrent.md";
|
||||
const markdownPath = path.join(root, relativePath);
|
||||
writeWorkspaceManifest(root, "mnote-e2e");
|
||||
fs.writeFileSync(markdownPath, "# Concurrent\n\n初始正文\n", "utf8");
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: process.env.HEADFUL !== "1",
|
||||
executablePath: CHROMIUM_EXECUTABLE_PATH,
|
||||
});
|
||||
const contextA = await browser.newContext({
|
||||
viewport: { width: 1280, height: 860 },
|
||||
extraHTTPHeaders: { "x-mnote-actor-id": "mnote-e2e", "x-mnote-actor-type": "user" },
|
||||
});
|
||||
const contextB = await browser.newContext({
|
||||
viewport: { width: 1280, height: 860 },
|
||||
extraHTTPHeaders: { "x-mnote-actor-id": "mnote-e2e", "x-mnote-actor-type": "user" },
|
||||
});
|
||||
const pageA = await contextA.newPage();
|
||||
const pageB = await contextB.newPage();
|
||||
const pageBEvents = [];
|
||||
pageB.on("request", (request) => {
|
||||
const url = request.url();
|
||||
if (url.includes("/api/page-body/write") || url.includes("/api/documents/save") || url.includes("/api/documents/buffer-state") || url.includes("/api/page-aggregate")) {
|
||||
pageBEvents.push({ type: "request", method: request.method(), url });
|
||||
}
|
||||
});
|
||||
pageB.on("response", (response) => {
|
||||
const url = response.url();
|
||||
if (url.includes("/api/page-body/write") || url.includes("/api/documents/save") || url.includes("/api/documents/buffer-state") || url.includes("/api/page-aggregate")) {
|
||||
pageBEvents.push({ type: "response", status: response.status(), url });
|
||||
}
|
||||
});
|
||||
pageB.on("console", (message) => {
|
||||
const text = message.text();
|
||||
if (text.includes("mnote") || text.includes("conflict")) {
|
||||
pageBEvents.push({ type: "console", level: message.type(), text });
|
||||
}
|
||||
});
|
||||
try {
|
||||
await Promise.all([
|
||||
openDocument(pageA, root, relativePath),
|
||||
openDocument(pageB, root, relativePath),
|
||||
]);
|
||||
|
||||
const cleanToken = `A-clean-sync-${Date.now()}`;
|
||||
await typeEditorText(pageA, cleanToken);
|
||||
await waitForFileText(markdownPath, cleanToken);
|
||||
await waitForEditorText(pageB, cleanToken);
|
||||
|
||||
const gate = await installDelayedSave(pageB);
|
||||
const dirtyToken = `B-dirty-pending-${Date.now()}`;
|
||||
await typeEditorText(pageB, dirtyToken);
|
||||
await withTimeout(gate.interceptedPromise, UI_TIMEOUT_MS, async () => {
|
||||
const state = await pageB.evaluate(() => ({
|
||||
status: document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]')?.getAttribute("data-runtime-editor-status") || "",
|
||||
text: document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror')?.textContent || "",
|
||||
sessions: typeof window.__mnoteDebugDocumentSessions?.snapshot === "function"
|
||||
? window.__mnoteDebugDocumentSessions.snapshot()
|
||||
: null,
|
||||
}));
|
||||
return { ...state, pageBEvents };
|
||||
});
|
||||
|
||||
const winnerToken = `A-winner-${Date.now()}`;
|
||||
await typeEditorText(pageA, winnerToken);
|
||||
await waitForFileText(markdownPath, winnerToken);
|
||||
gate.release();
|
||||
await pageB.waitForFunction(
|
||||
(needle) => {
|
||||
const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
|
||||
return root?.getAttribute("data-runtime-editor-status") === "external-change-conflict"
|
||||
&& (editor?.textContent || "").includes(needle);
|
||||
},
|
||||
dirtyToken,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
assert.equal(gate.status, 409, `B tab stale save 应返回 409,实际 ${gate.status}: ${gate.body}`);
|
||||
const finalB = await pageB.evaluate(() => ({
|
||||
status: document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]')?.getAttribute("data-runtime-editor-status") || "",
|
||||
text: document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror')?.textContent || "",
|
||||
conflictText: document.querySelector('[data-testid="mnote-editor-conflict-panel"]')?.textContent || "",
|
||||
sessions: typeof window.__mnoteDebugDocumentSessions?.snapshot === "function"
|
||||
? window.__mnoteDebugDocumentSessions.snapshot()
|
||||
: null,
|
||||
}));
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
root,
|
||||
states: [
|
||||
{ step: "clean-tab-auto-sync", cleanToken },
|
||||
{ step: "dirty-tab-save-409", dirtyToken, winnerToken, gateStatus: gate.status, finalB },
|
||||
],
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await contextA.close().catch(() => undefined);
|
||||
await contextB.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error && error.stack ? error.stack : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
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 PPTX_PATH = process.env.MNOTE_UPLOAD_TEST_FILE || "/home/lix/Downloads/1768096586803-672be7a7-5cb3-454d-9b1b-6230f86ba392.pptx";
|
||||
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|
||||
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"]
|
||||
.find((candidate) => fs.existsSync(candidate));
|
||||
|
||||
function fileUrl(localPath) {
|
||||
return `file://${localPath}`;
|
||||
}
|
||||
|
||||
function localMdDocumentId(relativePath) {
|
||||
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
|
||||
}
|
||||
|
||||
function documentUrl(root, relativePath) {
|
||||
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", fileUrl(root));
|
||||
url.searchParams.set("treeView", "filetree");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function writeWorkspaceManifest(root, ownerId) {
|
||||
const metadataDir = path.join(root, ".mnote");
|
||||
fs.mkdirSync(metadataDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(metadataDir, "workspace.json"),
|
||||
`${JSON.stringify({
|
||||
workspaceId: `local-ws:${ownerId}:task509`,
|
||||
ownerId,
|
||||
createdAt: new Date().toISOString(),
|
||||
capabilities: ["local_files", "markdown_edit", "asset_upload"],
|
||||
}, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
async function openDocument(page, root, relativePath) {
|
||||
await page.goto(documentUrl(root, relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadAttachmentViaSlash(page, filePath) {
|
||||
const editor = page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first();
|
||||
await editor.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.keyboard.press("End").catch(() => undefined);
|
||||
await page.keyboard.type("/");
|
||||
const item = page.locator('.document-pane[data-pane-role="primary"] [data-testid="slash-item-upload-attachment"]').first();
|
||||
await item.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const [fileChooser] = await Promise.all([
|
||||
page.waitForEvent("filechooser", { timeout: UI_TIMEOUT_MS }),
|
||||
item.click({ timeout: UI_TIMEOUT_MS }),
|
||||
]);
|
||||
await fileChooser.setFiles(filePath);
|
||||
}
|
||||
|
||||
async function typeEditorText(page, text) {
|
||||
const editor = page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first();
|
||||
await editor.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.keyboard.press(process.platform === "darwin" ? "Meta+End" : "Control+End").catch(() => undefined);
|
||||
await page.keyboard.type(`\n${text}`, { delay: 2 });
|
||||
}
|
||||
|
||||
async function waitForEditorStatus(page, status) {
|
||||
await page.waitForFunction(
|
||||
(expected) => {
|
||||
const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
return root?.getAttribute("data-runtime-editor-status") === expected;
|
||||
},
|
||||
status,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
assert(fs.existsSync(PPTX_PATH), `测试 pptx 不存在: ${PPTX_PATH}`);
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task509-upload-409-"));
|
||||
const relativePath = "README/README.md";
|
||||
writeWorkspaceManifest(root, "mnote-e2e");
|
||||
fs.mkdirSync(path.join(root, "README"), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, relativePath), "# Upload 409\n\n正文\n", "utf8");
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: process.env.HEADFUL !== "1",
|
||||
executablePath: CHROMIUM_EXECUTABLE_PATH,
|
||||
});
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1366, height: 900 },
|
||||
extraHTTPHeaders: { "x-mnote-actor-id": "mnote-e2e", "x-mnote-actor-type": "user" },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
let interceptedSave = null;
|
||||
await page.route("**/api/documents/save", async (route) => {
|
||||
const payload = JSON.parse(route.request().postData() || "{}");
|
||||
if (payload.editorSource === "local-upload-runtime") {
|
||||
interceptedSave = {
|
||||
expectedFileVersion: String(payload.expectedFileVersion || ""),
|
||||
writeIntentId: String(payload.writeIntentId || ""),
|
||||
saveOperationId: String(payload.saveOperationId || ""),
|
||||
};
|
||||
await route.fulfill({
|
||||
status: 409,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
ok: false,
|
||||
error: {
|
||||
message: "本地 Markdown 文件已在外部更新,请刷新或保存前先处理冲突",
|
||||
code: "local_markdown_external_change",
|
||||
},
|
||||
conflict: {
|
||||
code: "local_markdown_external_change",
|
||||
currentDiskVersion: "simulated-disk-version",
|
||||
editorBaseVersion: payload.expectedFileVersion || "",
|
||||
},
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
try {
|
||||
await openDocument(page, root, relativePath);
|
||||
await uploadAttachmentViaSlash(page, PPTX_PATH);
|
||||
await page.waitForFunction(
|
||||
() => document.documentElement.getAttribute("data-mnote-last-upload-orphaned-policy") === "asset-kept-reference-unsaved-retry-required",
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
const state = await page.evaluate(() => ({
|
||||
saveError: document.documentElement.getAttribute("data-mnote-last-upload-save-error") || "",
|
||||
orphanedAssetId: document.documentElement.getAttribute("data-mnote-last-upload-orphaned-asset-id") || "",
|
||||
orphanedPolicy: document.documentElement.getAttribute("data-mnote-last-upload-orphaned-policy") || "",
|
||||
inserted: document.documentElement.getAttribute("data-mnote-last-upload-inserted") || "",
|
||||
links: Array.from(document.querySelectorAll('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a')).map((link) => ({
|
||||
text: link.textContent || "",
|
||||
href: link.getAttribute("href") || "",
|
||||
})),
|
||||
}));
|
||||
assert(interceptedSave?.expectedFileVersion, `local-upload save 应携带 expectedFileVersion: ${JSON.stringify(interceptedSave)}`);
|
||||
assert(interceptedSave?.writeIntentId && interceptedSave?.saveOperationId, `local-upload save 应携带写入意图: ${JSON.stringify(interceptedSave)}`);
|
||||
assert(state.saveError.includes("本地 Markdown 文件已在外部更新"), `应显示保存冲突错误: ${JSON.stringify(state)}`);
|
||||
assert(state.orphanedAssetId, `409 后应标记已落盘孤儿附件: ${JSON.stringify(state)}`);
|
||||
assert(state.links.some((link) => /\.pptx/i.test(link.text)), `编辑器中应保留未落盘引用以便用户重试/处理: ${JSON.stringify(state)}`);
|
||||
const assetPath = path.join(root, "README", path.basename(PPTX_PATH));
|
||||
assert(fs.existsSync(assetPath), `附件文件应已落盘并由 orphan policy 显式保留: ${assetPath}`);
|
||||
|
||||
const blockedRelativePath = "Blocked.md";
|
||||
const blockedPath = path.join(root, blockedRelativePath);
|
||||
const blockedToken = `blocked-conflict-${Date.now()}`;
|
||||
fs.writeFileSync(blockedPath, "# Blocked\n\n初始正文\n", "utf8");
|
||||
await openDocument(page, root, blockedRelativePath);
|
||||
await typeEditorText(page, blockedToken);
|
||||
fs.writeFileSync(blockedPath, `# Blocked\n\n外部版本 ${blockedToken}\n`, "utf8");
|
||||
await waitForEditorStatus(page, "external-change-conflict");
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.removeAttribute("data-mnote-last-upload-blocked-conflict");
|
||||
});
|
||||
const beforeBlockedFiles = new Set(fs.readdirSync(root));
|
||||
await uploadAttachmentViaSlash(page, PPTX_PATH);
|
||||
await page.waitForFunction(
|
||||
() => document.documentElement.getAttribute("data-mnote-last-upload-blocked-conflict") === "true",
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
const afterBlockedFiles = new Set(fs.readdirSync(root));
|
||||
assert.deepEqual(
|
||||
[...afterBlockedFiles].sort(),
|
||||
[...beforeBlockedFiles].sort(),
|
||||
"session 已冲突时不应继续写入新的附件文件",
|
||||
);
|
||||
const blockedState = await page.evaluate(() => ({
|
||||
blockedConflict: document.documentElement.getAttribute("data-mnote-last-upload-blocked-conflict") || "",
|
||||
saveError: document.documentElement.getAttribute("data-mnote-last-upload-save-error") || "",
|
||||
}));
|
||||
assert.equal(blockedState.blockedConflict, "true", `冲突 session 应阻断上传: ${JSON.stringify(blockedState)}`);
|
||||
assert(blockedState.saveError.includes("冲突"), `冲突 session 阻断应给出提示: ${JSON.stringify(blockedState)}`);
|
||||
|
||||
console.log(JSON.stringify({ ok: true, root, assetPath, interceptedSave, state, blockedState }, null, 2));
|
||||
} finally {
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error && error.stack ? error.stack : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const { spawnSync } = require("node:child_process");
|
||||
const path = require("node:path");
|
||||
|
||||
const DEFAULT_TASKS = [
|
||||
"scripts/task451-local-markdown-conflict-resolution-ui-smoke.js",
|
||||
"scripts/task503-local-pptx-upload-filetree-open-smoke.js",
|
||||
"scripts/task458-local-create-page-no-conflict-smoke.js",
|
||||
"scripts/task504-local-md-external-conflict-recovery-smoke.js",
|
||||
"scripts/task508-local-md-multitab-conflict-smoke.js",
|
||||
"scripts/task509-local-upload-save-409-orphan-smoke.js",
|
||||
];
|
||||
|
||||
function requestedTasks() {
|
||||
const raw = String(process.env.MNOTE_CONFLICT_REGRESSION_TASKS || "").trim();
|
||||
if (!raw) return DEFAULT_TASKS;
|
||||
return raw.split(",").map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function runTask(scriptPath) {
|
||||
const absolute = path.resolve(process.cwd(), scriptPath);
|
||||
const result = spawnSync(process.execPath, [absolute], {
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${scriptPath} failed with status ${result.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const tasks = requestedTasks();
|
||||
for (const task of tasks) {
|
||||
console.log(`[task510] running ${task}`);
|
||||
runTask(task);
|
||||
}
|
||||
console.log(JSON.stringify({ ok: true, tasks }, null, 2));
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user