chore: align sqlite control plane architecture
- replace default Convex control-plane wording with Rust SQLite control-plane across architecture, AGENTS, Reasonix, and design docs - retire root Convex functions source and deploy script into recycle while keeping explicit cloud/compat/sync-replica boundaries - add control-plane migration guard/docs and keep CodeGraph refreshed after the SQLite control-plane cutover
This commit is contained in:
@@ -42,7 +42,7 @@
|
||||
- `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 脚本只作为历史对照;后续默认不要用于当前 Rust SSR / local-first 主路径验收。
|
||||
- 旧 `task019`、`task021`、`task022` 这类早期 UI regression 脚本已软删除到 `recycle/scripts/retired-ui-regressions/`,只作为历史对照;后续默认不要用于当前 Rust SSR / local-first 主路径验收。
|
||||
|
||||
## 1. 当前测试分层
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ 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 = [
|
||||
{
|
||||
@@ -49,6 +50,67 @@ const ACTIVE_CONVEX_FORBIDDEN = [
|
||||
},
|
||||
];
|
||||
|
||||
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$/,
|
||||
},
|
||||
];
|
||||
|
||||
// ─── 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 [
|
||||
"用法:",
|
||||
@@ -59,6 +121,8 @@ function usage() {
|
||||
" node scripts/check-local-first-convex-guard.js --self-test",
|
||||
"",
|
||||
`如需保留明确的 cloud/compat 直连,请在同一新增行加入 ${WAIVER}。`,
|
||||
"根 convex/ functions 源码已退役;如确需新增显式 cloud/compat Convex functions,应先放到明确的辅助目录并更新 guard。",
|
||||
`SQLite 迁移期中新增 Convex 控制面代码(users/workspaces/grants/share 等)需加 ${MIGRATION_PERIOD_WAIVER} 标注迁移例外。`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
@@ -111,6 +175,10 @@ 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 isAllowedRuntimeAdapter(filePath) {
|
||||
return (
|
||||
filePath === "rust/crates/mnote-web/src/transport/convex.rs" ||
|
||||
@@ -182,8 +250,12 @@ function collectLinesFromFiles(files) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
function isMigrationWaived(row) {
|
||||
return row.text.includes(WAIVER) || row.text.includes(MIGRATION_PERIOD_WAIVER);
|
||||
}
|
||||
|
||||
function checkLine(row) {
|
||||
if (row.text.includes(WAIVER)) {
|
||||
if (isMigrationWaived(row)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -199,14 +271,32 @@ function checkLine(row) {
|
||||
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 (!isRuntimePath(row.filePath) || isAllowedRuntimeAdapter(row.filePath)) {
|
||||
if (!isActiveConvexPath(row.filePath)) {
|
||||
return [];
|
||||
}
|
||||
return ACTIVE_CONVEX_FORBIDDEN.filter((entry) => entry.pattern.test(row.text)).map((entry) => ({
|
||||
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) => ({
|
||||
@@ -222,6 +312,11 @@ function runSelfTest() {
|
||||
lineNumber: 1,
|
||||
text: " documents: defineTable({",
|
||||
},
|
||||
{
|
||||
filePath: "convex/aiSessions.ts",
|
||||
lineNumber: 1,
|
||||
text: "export const upsertRuntimeRun = mutation({",
|
||||
},
|
||||
{
|
||||
filePath: "convex/mediaAssets.ts",
|
||||
lineNumber: 1,
|
||||
@@ -242,12 +337,25 @@ function runSelfTest() {
|
||||
lineNumber: 1,
|
||||
text: '"documents:updateContent"',
|
||||
},
|
||||
{
|
||||
filePath: "convex/users.ts",
|
||||
lineNumber: 1,
|
||||
text: " users: defineTable({",
|
||||
},
|
||||
{
|
||||
filePath: "convex/workspaces_migration.ts",
|
||||
lineNumber: 1,
|
||||
text: " workspaces: defineTable({",
|
||||
},
|
||||
];
|
||||
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 函数");
|
||||
}
|
||||
@@ -260,6 +368,12 @@ function runSelfTest() {
|
||||
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 代码");
|
||||
}
|
||||
console.log(JSON.stringify({ ok: true, guard: "local-first-convex", selfTest: true }, null, 2));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* scripts/migrate-control-plane-to-sqlite.js
|
||||
*
|
||||
* Phase 0 dry-run 迁移工具 — 读取本地 control-plane 数据和工作区清单,
|
||||
* 生成 migration-report.json 建议(不写 DB,不改现有文件)。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/migrate-control-plane-to-sqlite.js --dry-run --out /tmp/report.json
|
||||
* node scripts/migrate-control-plane-to-sqlite.js --dry-run --access-policy-path /custom/path/access-policy.json
|
||||
* node scripts/migrate-control-plane-to-sqlite.js --dry-run --users-root /custom/users/root
|
||||
* node scripts/migrate-control-plane-to-sqlite.js --dry-run --skip-users
|
||||
*
|
||||
* 参考设计稿:
|
||||
* design/02-convex-rust-long-term-architecture/process/2-8-convex-replace-with-rust-sqlite-control-plane-v1.md
|
||||
*
|
||||
* 如运行时文件系统扫描过慢,可使用 --from-snapshot 传入预收集的数据快照:
|
||||
* 1. 用 Reasonix 工具扫描:list_directory + read_file 收集所有用户 workpace.json
|
||||
* 2. 写入 JSON 文件(格式见 README)
|
||||
* 3. 传入工具: --from-snapshot /tmp/data-snapshot.json --skip-users
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
// ─── 默认路径 ───────────────────────────────────────────────
|
||||
const DEFAULT_DATA_ROOT = "/mnt/Data1T/Mnote_data";
|
||||
const DEFAULT_ACCESS_POLICY = path.join(DEFAULT_DATA_ROOT, "control-plane", "access-policy.json");
|
||||
const DEFAULT_USERS_ROOT = path.join(DEFAULT_DATA_ROOT, "users");
|
||||
|
||||
// ─── 版本标记 ───────────────────────────────────────────────
|
||||
const TOOL_VERSION = "0.1.0";
|
||||
const MIGRATION_PHASE = "Phase 0: dry-run & inventory";
|
||||
|
||||
// ─── CLI 参数解析 ────────────────────────────────────────────
|
||||
function usage() {
|
||||
return [
|
||||
`migrate-control-plane-to-sqlite.js v${TOOL_VERSION}`,
|
||||
"",
|
||||
"用法:",
|
||||
" node scripts/migrate-control-plane-to-sqlite.js --dry-run --out <路径>",
|
||||
"",
|
||||
"参数:",
|
||||
" --dry-run 必要参数。启动干跑模式(仅扫描、不写 DB)。",
|
||||
" --out <路径> 输出 migration-report.json 路径。",
|
||||
" --access-policy-path <路径> 自定义 access-policy.json 路径。",
|
||||
" --users-root <路径> 自定义用户根目录。",
|
||||
" --from-snapshot <路径> 从预收集的数据快照文件读取,替代直接文件系统扫描。",
|
||||
" --skip-users 跳过用户扫描(仅读取 access-policy)。",
|
||||
" --verbose 输出详细扫描日志。",
|
||||
" --help, -h 显示此帮助。",
|
||||
"",
|
||||
"示例:",
|
||||
" node scripts/migrate-control-plane-to-sqlite.js --dry-run --out /tmp/mnote-control-plane-migration-report.json",
|
||||
" node scripts/migrate-control-plane-to-sqlite.js --dry-run --out ./migration-report.json --verbose",
|
||||
" node scripts/migrate-control-plane-to-sqlite.js --dry-run --from-snapshot ./data-snapshot.json --out /tmp/report.json",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const opts = {
|
||||
dryRun: false,
|
||||
outPath: null,
|
||||
accessPolicyPath: DEFAULT_ACCESS_POLICY,
|
||||
usersRoot: DEFAULT_USERS_ROOT,
|
||||
fromSnapshot: null,
|
||||
skipUsers: false,
|
||||
verbose: false,
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
console.log(usage());
|
||||
process.exit(0);
|
||||
}
|
||||
if (arg === "--dry-run") {
|
||||
opts.dryRun = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--out") {
|
||||
opts.outPath = argv[++i];
|
||||
if (!opts.outPath) throw new Error("--out 需要参数");
|
||||
continue;
|
||||
}
|
||||
if (arg === "--access-policy-path") {
|
||||
opts.accessPolicyPath = argv[++i];
|
||||
if (!opts.accessPolicyPath) throw new Error("--access-policy-path 需要参数");
|
||||
continue;
|
||||
}
|
||||
if (arg === "--users-root") {
|
||||
opts.usersRoot = argv[++i];
|
||||
if (!opts.usersRoot) throw new Error("--users-root 需要参数");
|
||||
continue;
|
||||
}
|
||||
if (arg === "--from-snapshot") {
|
||||
opts.fromSnapshot = argv[++i];
|
||||
if (!opts.fromSnapshot) throw new Error("--from-snapshot 需要参数");
|
||||
continue;
|
||||
}
|
||||
if (arg === "--skip-users") {
|
||||
opts.skipUsers = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--verbose") {
|
||||
opts.verbose = true;
|
||||
continue;
|
||||
}
|
||||
throw new Error(`未知参数:${arg}\n\n${usage()}`);
|
||||
}
|
||||
|
||||
if (!opts.dryRun) {
|
||||
throw new Error("必须指定 --dry-run(当前版本仅支持干跑模式)\n\n" + usage());
|
||||
}
|
||||
|
||||
return opts;
|
||||
}
|
||||
|
||||
// ─── 扫描工具函数 ──────────────────────────────────────────
|
||||
|
||||
function readJsonSafe(filePath, label) {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return { ok: false, error: `${label} 文件不存在:${filePath}` };
|
||||
}
|
||||
const raw = fs.readFileSync(filePath, "utf8");
|
||||
const data = JSON.parse(raw);
|
||||
return { ok: true, data };
|
||||
} catch (err) {
|
||||
return { ok: false, error: `读取 ${label} 失败:${err.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
function collectUsers(usersRoot) {
|
||||
const results = [];
|
||||
if (!fs.existsSync(usersRoot)) {
|
||||
return { ok: false, error: `用户目录不存在:${usersRoot}`, users: [] };
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(usersRoot, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (entry.name.startsWith(".")) continue;
|
||||
|
||||
const userId = entry.name;
|
||||
const userDir = path.join(usersRoot, userId);
|
||||
const workspacesDir = path.join(userDir, "workspaces");
|
||||
|
||||
const userEntry = {
|
||||
userId,
|
||||
userDir,
|
||||
workspacesDir,
|
||||
workspaceCount: 0,
|
||||
workspaces: [],
|
||||
errors: [],
|
||||
};
|
||||
|
||||
if (!fs.existsSync(workspacesDir)) {
|
||||
userEntry.errors.push("无 workspaces 目录");
|
||||
results.push(userEntry);
|
||||
continue;
|
||||
}
|
||||
|
||||
const wsEntries = fs.readdirSync(workspacesDir, { withFileTypes: true });
|
||||
for (const ws of wsEntries) {
|
||||
if (!ws.isDirectory()) continue;
|
||||
if (ws.name.startsWith(".")) continue;
|
||||
|
||||
const wsDir = path.join(workspacesDir, ws.name);
|
||||
const wsMetaDir = path.join(wsDir, ".mnote");
|
||||
const wsMetaFile = path.join(wsMetaDir, "workspace.json");
|
||||
|
||||
const wsEntry = {
|
||||
workspaceName: ws.name,
|
||||
workspaceDir: wsDir,
|
||||
metaFile: wsMetaFile,
|
||||
meta: null,
|
||||
pageCount: 0,
|
||||
pages: [],
|
||||
};
|
||||
|
||||
// 读取 workspace.json
|
||||
const metaResult = readJsonSafe(wsMetaFile, `${userId}/${ws.name}/workspace.json`);
|
||||
if (metaResult.ok) {
|
||||
wsEntry.meta = metaResult.data;
|
||||
} else {
|
||||
userEntry.errors.push(`${ws.name}:${metaResult.error}`);
|
||||
}
|
||||
|
||||
// 统计页面(一级 .md 文件)
|
||||
if (fs.existsSync(wsDir)) {
|
||||
const pageFiles = fs.readdirSync(wsDir).filter((f) => f.endsWith(".md"));
|
||||
wsEntry.pageCount = pageFiles.length;
|
||||
wsEntry.pages = pageFiles;
|
||||
}
|
||||
|
||||
userEntry.workspaces.push(wsEntry);
|
||||
}
|
||||
|
||||
userEntry.workspaceCount = userEntry.workspaces.length;
|
||||
results.push(userEntry);
|
||||
}
|
||||
|
||||
return { ok: true, users: results };
|
||||
}
|
||||
|
||||
// ─── 构建迁移报告 ──────────────────────────────────────────
|
||||
|
||||
function buildReport(opts, accessPolicyResult, usersResult) {
|
||||
const report = {
|
||||
meta: {
|
||||
toolVersion: TOOL_VERSION,
|
||||
migrationPhase: MIGRATION_PHASE,
|
||||
generatedAt: new Date().toISOString(),
|
||||
source: {
|
||||
accessPolicy: opts.accessPolicyPath,
|
||||
usersRoot: opts.usersRoot,
|
||||
},
|
||||
flags: {
|
||||
dryRun: true,
|
||||
skipUsers: opts.skipUsers,
|
||||
},
|
||||
},
|
||||
accessPolicy: {
|
||||
path: opts.accessPolicyPath,
|
||||
readOk: accessPolicyResult.ok,
|
||||
error: accessPolicyResult.ok ? null : accessPolicyResult.error,
|
||||
admins: accessPolicyResult.ok ? (accessPolicyResult.data.admins || []) : [],
|
||||
grants: accessPolicyResult.ok ? (accessPolicyResult.data.grants || []) : [],
|
||||
},
|
||||
users: {
|
||||
scanOk: usersResult.ok,
|
||||
error: usersResult.ok ? null : usersResult.error,
|
||||
total: usersResult.ok ? usersResult.users.length : 0,
|
||||
items: [],
|
||||
},
|
||||
suggestions: {
|
||||
users: [],
|
||||
workspaces: [],
|
||||
grants: [],
|
||||
legacyIdMap: [],
|
||||
},
|
||||
summary: {
|
||||
adminCount: 0,
|
||||
userCount: 0,
|
||||
workspaceCount: 0,
|
||||
grantCount: 0,
|
||||
legacyMappingCount: 0,
|
||||
errors: [],
|
||||
},
|
||||
};
|
||||
|
||||
if (!usersResult.ok) {
|
||||
report.summary.errors.push(usersResult.error);
|
||||
return report;
|
||||
}
|
||||
|
||||
for (const u of usersResult.users) {
|
||||
const userItem = {
|
||||
userId: u.userId,
|
||||
workspaceCount: u.workspaceCount,
|
||||
errors: u.errors,
|
||||
workspaces: [],
|
||||
};
|
||||
|
||||
for (const ws of u.workspaces) {
|
||||
const wsItem = {
|
||||
workspaceName: ws.workspaceName,
|
||||
meta: ws.meta,
|
||||
pageCount: ws.pageCount,
|
||||
};
|
||||
userItem.workspaces.push(wsItem);
|
||||
|
||||
// ── 生成 workspace 建议 ──
|
||||
if (ws.meta && ws.meta.workspaceId) {
|
||||
const parts = ws.meta.workspaceId.split(":");
|
||||
const newWsId = `sqlite-ws:${u.userId}:${ws.workspaceName}`;
|
||||
report.suggestions.workspaces.push({
|
||||
legacyWorkspaceId: ws.meta.workspaceId,
|
||||
suggestedNewId: newWsId,
|
||||
ownerId: u.userId,
|
||||
name: ws.workspaceName,
|
||||
rootPath: ws.workspaceDir,
|
||||
source: "workspace.json",
|
||||
});
|
||||
|
||||
// ── 生成 user 建议 ──
|
||||
const existingUser = report.suggestions.users.find((x) => x.legacyUserId === u.userId);
|
||||
if (!existingUser) {
|
||||
// 检查是否在 access-policy 的 admins 中
|
||||
const isAdmin = accessPolicyResult.ok && (accessPolicyResult.data.admins || []).includes(u.userId);
|
||||
const newUserId = `usr_${u.userId.replace(/[^a-zA-Z0-9_-]/g, "_")}`;
|
||||
report.suggestions.users.push({
|
||||
legacyUserId: u.userId,
|
||||
suggestedNewId: newUserId,
|
||||
role: isAdmin ? "admin" : "user",
|
||||
isAdmin,
|
||||
defaultWorkspace: ws.workspaceName,
|
||||
source: "workspace.json",
|
||||
});
|
||||
|
||||
// ── 生成 legacy_id_map 建议 ──
|
||||
report.suggestions.legacyIdMap.push({
|
||||
legacySystem: "local_workspace_manifest",
|
||||
legacyKind: "user",
|
||||
legacyId: u.userId,
|
||||
newKind: "user",
|
||||
newId: newUserId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
report.users.items.push(userItem);
|
||||
}
|
||||
|
||||
// ── 生成 grant 建议 ──
|
||||
if (accessPolicyResult.ok && accessPolicyResult.data.admins) {
|
||||
for (const adminId of accessPolicyResult.data.admins) {
|
||||
// 找到该 admin 对应的 user 建议
|
||||
const adminUser = report.suggestions.users.find((x) => x.legacyUserId === adminId);
|
||||
report.suggestions.grants.push({
|
||||
legacyAdminId: adminId,
|
||||
suggestedNewUserId: adminUser ? adminUser.suggestedNewId : adminId,
|
||||
type: "admin",
|
||||
rootUri: `mnote://users/${adminId}`,
|
||||
rootPath: path.join(opts.usersRoot, adminId),
|
||||
permission: "admin",
|
||||
capabilities: ["admin", "share", "ai"],
|
||||
source: "access-policy.json:admins",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (accessPolicyResult.ok && accessPolicyResult.data.grants) {
|
||||
for (const grant of accessPolicyResult.data.grants) {
|
||||
report.suggestions.grants.push({
|
||||
legacySource: "access-policy.json:grants",
|
||||
source: grant,
|
||||
type: "directory_grant",
|
||||
...grant,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── 生成 legacy_id_map 建议(workspaces)──
|
||||
for (const ws of report.suggestions.workspaces) {
|
||||
report.suggestions.legacyIdMap.push({
|
||||
legacySystem: "local_workspace_manifest",
|
||||
legacyKind: "workspace",
|
||||
legacyId: ws.legacyWorkspaceId,
|
||||
newKind: "workspace",
|
||||
newId: ws.suggestedNewId,
|
||||
});
|
||||
}
|
||||
|
||||
// ── 填写概要 ──
|
||||
report.summary.adminCount = report.accessPolicy.admins.length;
|
||||
report.summary.userCount = report.users.total;
|
||||
report.summary.workspaceCount = report.suggestions.workspaces.length;
|
||||
report.summary.grantCount = report.suggestions.grants.length;
|
||||
report.summary.legacyMappingCount = report.suggestions.legacyIdMap.length;
|
||||
|
||||
// 收集所有错误
|
||||
for (const u of usersResult.users) {
|
||||
for (const err of u.errors) {
|
||||
report.summary.errors.push(`[${u.userId}] ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
// ─── 入口 ──────────────────────────────────────────────────
|
||||
|
||||
function main() {
|
||||
const opts = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (opts.verbose) {
|
||||
console.error(`[migrate-control-plane] 版本 ${TOOL_VERSION}`);
|
||||
console.error(`[migrate-control-plane] access-policy: ${opts.accessPolicyPath}`);
|
||||
console.error(`[migrate-control-plane] users-root: ${opts.usersRoot}`);
|
||||
console.error(`[migrate-control-plane] skip-users: ${opts.skipUsers}`);
|
||||
console.error(`[migrate-control-plane] dry-run: ${opts.dryRun}`);
|
||||
}
|
||||
|
||||
// 1. 读取 access-policy.json(可能被快照覆盖,用 let)
|
||||
let accessPolicyResult = readJsonSafe(opts.accessPolicyPath, "access-policy.json");
|
||||
if (opts.verbose) {
|
||||
if (accessPolicyResult.ok) {
|
||||
console.error(`[migrate-control-plane] access-policy.json 读取成功`);
|
||||
console.error(`[migrate-control-plane] admins: ${JSON.stringify(accessPolicyResult.data.admins)}`);
|
||||
console.error(`[migrate-control-plane] grants: ${accessPolicyResult.data.grants ? accessPolicyResult.data.grants.length : 0} 条`);
|
||||
} else {
|
||||
console.error(`[migrate-control-plane] access-policy.json 读取失败: ${accessPolicyResult.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 扫描用户(支持从快照文件读取 vs 直接文件系统扫描)
|
||||
let usersResult = { ok: true, users: [] };
|
||||
if (opts.fromSnapshot) {
|
||||
const snapshotResult = readJsonSafe(opts.fromSnapshot, "数据快照");
|
||||
if (snapshotResult.ok) {
|
||||
const snapshot = snapshotResult.data;
|
||||
if (snapshot.users) {
|
||||
usersResult = { ok: true, users: snapshot.users };
|
||||
if (opts.verbose) {
|
||||
console.error(`[migrate-control-plane] 从快照读取 ${snapshot.users.length} 个用户`);
|
||||
}
|
||||
// 如果快照中已有 accessPolicy,覆盖 access-policy.json 的读取
|
||||
if (snapshot.accessPolicy && !opts.accessPolicyPath) {
|
||||
const policyData = { ok: true, data: snapshot.accessPolicy };
|
||||
// 替换 accessPolicyResult
|
||||
accessPolicyResult = policyData;
|
||||
if (opts.verbose) {
|
||||
console.error(`[migrate-control-plane] 快照中的 accessPolicy 已生效`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
usersResult = { ok: false, error: "快照文件中缺少 users 字段", users: [] };
|
||||
}
|
||||
} else {
|
||||
usersResult = { ok: false, error: snapshotResult.error, users: [] };
|
||||
}
|
||||
} else if (!opts.skipUsers) {
|
||||
usersResult = collectUsers(opts.usersRoot);
|
||||
if (opts.verbose) {
|
||||
if (usersResult.ok) {
|
||||
console.error(`[migrate-control-plane] 用户扫描完成: ${usersResult.users.length} 个用户`);
|
||||
for (const u of usersResult.users) {
|
||||
console.error(`[migrate-control-plane] ${u.userId}: ${u.workspaceCount} 个工作区`);
|
||||
}
|
||||
} else {
|
||||
console.error(`[migrate-control-plane] 用户扫描失败: ${usersResult.error}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (opts.verbose) {
|
||||
console.error(`[migrate-control-plane] 用户扫描已跳过 (--skip-users)`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 构建报告
|
||||
const report = buildReport(opts, accessPolicyResult, usersResult);
|
||||
|
||||
// 4. 输出
|
||||
const json = JSON.stringify(report, null, 2);
|
||||
|
||||
if (opts.outPath) {
|
||||
fs.mkdirSync(path.dirname(opts.outPath), { recursive: true });
|
||||
fs.writeFileSync(opts.outPath, json, "utf8");
|
||||
if (opts.verbose) {
|
||||
console.error(`[migrate-control-plane] 报告已写入: ${opts.outPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// stdout 只输出概要(便于管道处理)
|
||||
const brief = {
|
||||
ok: true,
|
||||
tool: "migrate-control-plane-to-sqlite",
|
||||
dryRun: true,
|
||||
summary: report.summary,
|
||||
outPath: opts.outPath || "(stdout)",
|
||||
};
|
||||
console.log(JSON.stringify(brief, null, 2));
|
||||
|
||||
// 如果有错误但不太严重,仍然 exit 0
|
||||
// 严重错误(access-policy 读不到、用户目录不存在)通过 summary.errors 和 accessPolicy.error 体现
|
||||
const fatalCount = report.summary.errors.length;
|
||||
if (fatalCount > 0) {
|
||||
console.error(`[migrate-control-plane] 警告:扫描中发现 ${fatalCount} 个非致命错误,详见报告 errors`);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
const { spawn } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
process.env.CONVEX_TMPDIR = "/mnt/Data1T/mnote/.convex-tmp";
|
||||
|
||||
const adminKey = "mnote-local|01cedce68c51e168c6aacb282a90f7d233f56eddfabb07944a1d9dd9506f73ba888fc7daff";
|
||||
const repoRoot = "/mnt/Data1T/mnote";
|
||||
const rootConvexDir = path.join(repoRoot, "convex");
|
||||
const requiredRootFiles = ["schema.ts", "aiSessions.ts"];
|
||||
|
||||
for (const fileName of requiredRootFiles) {
|
||||
const filePath = path.join(rootConvexDir, fileName);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error(`[convex-deploy] 缺少当前 active Convex 文件:${filePath}`);
|
||||
console.error("[convex-deploy] 不允许从 recycle/wolai-frontend/convex 回退部署;需要的函数必须迁回 root convex/ 或明确退役。");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const args = [
|
||||
"deploy",
|
||||
"--url", "http://127.0.0.1:3210", // Backend port, NOT HTTP actions (3211)
|
||||
"--admin-key", adminKey,
|
||||
"--typecheck", "disable",
|
||||
"--codegen", "disable",
|
||||
];
|
||||
|
||||
const child = spawn("npx", ["convex", ...args], {
|
||||
cwd: repoRoot,
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, CONVEX_TMPDIR: "/mnt/Data1T/mnote/.convex-tmp" },
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
process.exit(code || 0);
|
||||
});
|
||||
@@ -1,234 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
// 说明:
|
||||
// - 这是 task-019 的最小真实浏览器回归脚本。
|
||||
// - 目标只覆盖文档页元信息、Sidebar、标题/正文保存主链,不扩大到 Mindmap / OnlyOffice。
|
||||
// - 脚本会先创建一篇临时页面,完成回归后再彻底删除,避免污染现有数据。
|
||||
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const BASE_URL = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000";
|
||||
const REQUEST_TIMEOUT_MS = 20_000;
|
||||
const UI_TIMEOUT_MS = 30_000;
|
||||
const TEST_LOGIN_BUTTON_NAME = "测试账号快速登录";
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function requestJson(requestContext, path, init = {}) {
|
||||
const response = await requestContext.fetch(`${BASE_URL}${path}`, {
|
||||
...init,
|
||||
headers:
|
||||
init.data !== undefined
|
||||
? {
|
||||
"content-type": "application/json",
|
||||
...(init.headers || {}),
|
||||
}
|
||||
: {
|
||||
...(init.headers || {}),
|
||||
},
|
||||
timeout: REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
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()} ${response.statusText()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const contentType = response.headers()["content-type"] || "";
|
||||
if (!contentType.includes("application/json")) {
|
||||
const snippet = typeof payload === "string" ? payload.slice(0, 200) : JSON.stringify(payload).slice(0, 200);
|
||||
throw new Error(
|
||||
`${path} 返回了非 JSON 内容,当前回归脚本需要可直接调用的 API 会话。` +
|
||||
`如果页面被重定向到 /auth 或返回 HTML,说明前端未启用 MNOTE_DEV_AUTH=1,或当前节点没有带上有效的 Convex Auth 会话。` +
|
||||
`响应片段:${snippet}`,
|
||||
);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function createTempDocument(requestContext) {
|
||||
const payload = await requestJson(requestContext, "/api/documents/create", {
|
||||
method: "POST",
|
||||
data: { parentId: null },
|
||||
});
|
||||
|
||||
assert(payload && typeof payload.id === "string", "创建临时页面失败:缺少 id");
|
||||
assert(payload && typeof payload.workspace_id === "string", "创建临时页面失败:缺少 workspace_id");
|
||||
|
||||
return {
|
||||
documentId: payload.id,
|
||||
workspaceId: payload.workspace_id,
|
||||
};
|
||||
}
|
||||
|
||||
async function purgeTempDocument(requestContext, documentId) {
|
||||
await requestJson(requestContext, "/api/documents/purge", {
|
||||
method: "POST",
|
||||
data: { documentId },
|
||||
});
|
||||
}
|
||||
|
||||
async function getViewerIdentity(requestContext) {
|
||||
const payload = await requestJson(requestContext, "/api/auth/whoami", { method: "GET" });
|
||||
assert(payload && typeof payload.userId === "string" && payload.userId, "获取当前用户失败:缺少 userId");
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function ensureAuthenticated(page, requestContext) {
|
||||
await page.goto(`${BASE_URL}/auth`, { waitUntil: "networkidle", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
if (page.url().includes("/auth")) {
|
||||
const quickLoginButton = page.getByRole("button", { name: TEST_LOGIN_BUTTON_NAME });
|
||||
await quickLoginButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await quickLoginButton.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForURL((url) => !url.toString().includes("/auth"), {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
return await getViewerIdentity(requestContext);
|
||||
}
|
||||
|
||||
async function runBrowserRegression(page, target) {
|
||||
const uniqueSuffix = Date.now().toString();
|
||||
const nextTitle = `task019-ui-${uniqueSuffix}`;
|
||||
const nextBody = `task019 正文保存回归 ${uniqueSuffix}`;
|
||||
const documentUrl = `${BASE_URL}/documents/${target.documentId}?workspaceId=${encodeURIComponent(target.workspaceId)}`;
|
||||
await page.goto(documentUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
const sidebarPanel = page.getByText("页面树");
|
||||
const privateSection = page.getByText("私有 / 我的页面");
|
||||
const titleInput = page.getByLabel("页面标题");
|
||||
const editorSurface = page.locator(".wolai-editor [contenteditable=\"true\"]").first();
|
||||
const saveIndicator = page.locator("text=已保存");
|
||||
|
||||
await sidebarPanel.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await privateSection.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await titleInput.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await editorSurface.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await saveIndicator.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
await titleInput.fill(nextTitle);
|
||||
const titleSaveResponse = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes("/api/documents/title") &&
|
||||
response.request().method() === "POST" &&
|
||||
response.status() === 200,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await titleInput.evaluate((node) => {
|
||||
node.blur();
|
||||
});
|
||||
await titleSaveResponse;
|
||||
|
||||
const saveResponse = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes("/api/documents/save") &&
|
||||
response.request().method() === "POST" &&
|
||||
response.status() === 200 &&
|
||||
(response.request().postData() || "").includes(nextBody),
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await editorSurface.click({ timeout: UI_TIMEOUT_MS });
|
||||
await editorSurface.fill(nextBody, { timeout: UI_TIMEOUT_MS });
|
||||
await saveResponse;
|
||||
await saveIndicator.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await sidebarPanel.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await privateSection.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await titleInput.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(`text=${nextBody}`).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
const persistedTitle = await titleInput.inputValue();
|
||||
assert(persistedTitle === nextTitle, `标题刷新后不一致:期望 ${nextTitle},实际 ${persistedTitle}`);
|
||||
const editorText = await page.locator(".wolai-editor").innerText({ timeout: UI_TIMEOUT_MS });
|
||||
assert(editorText.includes(nextBody), "正文刷新后未保留刚写入的内容");
|
||||
|
||||
return {
|
||||
documentUrl,
|
||||
nextTitle,
|
||||
nextBody,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const health = await fetch(`${BASE_URL}/`, {
|
||||
method: "HEAD",
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
assert(
|
||||
[200, 307, 308].includes(health.status),
|
||||
`首页探活失败:收到状态码 ${health.status}`,
|
||||
);
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 960 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
let tempDocument = null;
|
||||
let regressionResult = null;
|
||||
let caughtError = null;
|
||||
|
||||
try {
|
||||
await ensureAuthenticated(page, context.request);
|
||||
tempDocument = await createTempDocument(context.request);
|
||||
regressionResult = await runBrowserRegression(page, tempDocument);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
workspaceId: tempDocument.workspaceId,
|
||||
documentId: tempDocument.documentId,
|
||||
...regressionResult,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
caughtError = error;
|
||||
} finally {
|
||||
if (tempDocument?.documentId) {
|
||||
try {
|
||||
await purgeTempDocument(context.request, tempDocument.documentId);
|
||||
} catch (cleanupError) {
|
||||
if (!caughtError) {
|
||||
caughtError = cleanupError;
|
||||
} else {
|
||||
console.error(
|
||||
`清理临时页面失败:${cleanupError instanceof Error ? cleanupError.stack || cleanupError.message : String(cleanupError)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
await page.close().catch(() => undefined);
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
if (caughtError) {
|
||||
throw caughtError;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,492 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
// 说明:
|
||||
// - 这是 task-021 的最小真实浏览器回归脚本。
|
||||
// - 目标覆盖 Mindmap 全屏页、节点新增/删除、保存链与 requestId/traceId 元信息同步。
|
||||
// - 脚本会创建临时页面和临时导图,回归结束后清理,避免污染现有数据。
|
||||
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const BASE_URL = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000";
|
||||
const REQUEST_TIMEOUT_MS = 20_000;
|
||||
const UI_TIMEOUT_MS = 30_000;
|
||||
const TEST_LOGIN_BUTTON_NAME = "测试账号快速登录";
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function requestJson(requestContext, path, init = {}) {
|
||||
const response = await requestContext.fetch(`${BASE_URL}${path}`, {
|
||||
...init,
|
||||
headers:
|
||||
init.data !== undefined
|
||||
? {
|
||||
"content-type": "application/json",
|
||||
...(init.headers || {}),
|
||||
}
|
||||
: {
|
||||
...(init.headers || {}),
|
||||
},
|
||||
timeout: REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
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()} ${response.statusText()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function createTempDocument(requestContext) {
|
||||
const payload = await requestJson(requestContext, "/api/documents/create", {
|
||||
method: "POST",
|
||||
data: { parentId: null },
|
||||
});
|
||||
|
||||
assert(payload && typeof payload.id === "string", "创建临时页面失败:缺少 id");
|
||||
assert(payload && typeof payload.workspace_id === "string", "创建临时页面失败:缺少 workspace_id");
|
||||
|
||||
return {
|
||||
documentId: payload.id,
|
||||
workspaceId: payload.workspace_id,
|
||||
};
|
||||
}
|
||||
|
||||
async function createTempMindmap(requestContext, documentId, mindmapId) {
|
||||
await requestJson(requestContext, `/api/mindmap/${documentId}/${mindmapId}`, {
|
||||
method: "POST",
|
||||
data: {
|
||||
createOnly: true,
|
||||
data: {
|
||||
data: { text: "中心主题" },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanupTempMindmap(requestContext, documentId, mindmapId) {
|
||||
try {
|
||||
await requestJson(requestContext, `/api/mindmap/${documentId}/${mindmapId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
} catch {
|
||||
// 忽略清理失败,继续尝试 purge 文档。
|
||||
}
|
||||
}
|
||||
|
||||
async function purgeTempDocument(requestContext, documentId) {
|
||||
await requestJson(requestContext, "/api/documents/purge", {
|
||||
method: "POST",
|
||||
data: { documentId },
|
||||
});
|
||||
}
|
||||
|
||||
async function getViewerIdentity(requestContext) {
|
||||
const payload = await requestJson(requestContext, "/api/auth/whoami", { method: "GET" });
|
||||
assert(payload && typeof payload.userId === "string" && payload.userId, "获取当前用户失败:缺少 userId");
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function ensureAuthenticated(page, requestContext) {
|
||||
await page.goto(`${BASE_URL}/auth`, { waitUntil: "networkidle", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
if (page.url().includes("/auth")) {
|
||||
const quickLoginButton = page.getByRole("button", { name: TEST_LOGIN_BUTTON_NAME });
|
||||
await quickLoginButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await quickLoginButton.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForURL((url) => !url.toString().includes("/auth"), {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
return await getViewerIdentity(requestContext);
|
||||
}
|
||||
|
||||
async function waitForMindmapInstance(page, mindmapId) {
|
||||
await page.waitForFunction(
|
||||
(id) => Boolean(window.__mindmapInstancesById?.[id] || window.__mindmapInstance),
|
||||
mindmapId,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForMindmapReady(page, mindmapId) {
|
||||
await page.waitForFunction(
|
||||
(id) => {
|
||||
const instance = window.__mindmapInstancesById?.[id] || window.__mindmapInstance;
|
||||
const persist = window.__mindmapPersistById?.[id];
|
||||
const fullscreen = document.querySelector("[data-testid=\"mindmap-fullscreen\"]");
|
||||
const requestId = fullscreen?.getAttribute("data-request-id");
|
||||
const traceId = fullscreen?.getAttribute("data-trace-id");
|
||||
return Boolean(instance && persist && requestId && traceId);
|
||||
},
|
||||
mindmapId,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function readMindmapMetaAttrs(page) {
|
||||
const fullscreen = page.locator("[data-testid=\"mindmap-fullscreen\"]");
|
||||
return {
|
||||
documentId: await fullscreen.getAttribute("data-document-id"),
|
||||
pageId: await fullscreen.getAttribute("data-page-id"),
|
||||
attachmentId: await fullscreen.getAttribute("data-attachment-id"),
|
||||
mindmapId: await fullscreen.getAttribute("data-mindmap-id"),
|
||||
workspaceId: await fullscreen.getAttribute("data-workspace-id"),
|
||||
requestId: await fullscreen.getAttribute("data-request-id"),
|
||||
traceId: await fullscreen.getAttribute("data-trace-id"),
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForMetaAttrs(page, meta) {
|
||||
await page.waitForFunction(
|
||||
({ requestId, traceId }) => {
|
||||
const el = document.querySelector("[data-testid=\"mindmap-fullscreen\"]");
|
||||
if (!el) return false;
|
||||
return (
|
||||
el.getAttribute("data-request-id") === requestId &&
|
||||
el.getAttribute("data-trace-id") === traceId
|
||||
);
|
||||
},
|
||||
{
|
||||
requestId: meta.requestId,
|
||||
traceId: meta.traceId,
|
||||
},
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForMetaMutation(page, previousMeta) {
|
||||
await page.waitForFunction(
|
||||
({ requestId, traceId }) => {
|
||||
const el = document.querySelector("[data-testid=\"mindmap-fullscreen\"]");
|
||||
if (!el) return false;
|
||||
const nextRequestId = el.getAttribute("data-request-id");
|
||||
const nextTraceId = el.getAttribute("data-trace-id");
|
||||
return Boolean(
|
||||
nextRequestId &&
|
||||
nextTraceId &&
|
||||
nextRequestId !== requestId &&
|
||||
nextTraceId !== traceId,
|
||||
);
|
||||
},
|
||||
{
|
||||
requestId: previousMeta.requestId,
|
||||
traceId: previousMeta.traceId,
|
||||
},
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
return await readMindmapMetaAttrs(page);
|
||||
}
|
||||
|
||||
async function waitForMindmapState(requestContext, documentId, mindmapId, check, description) {
|
||||
const deadline = Date.now() + UI_TIMEOUT_MS;
|
||||
let lastPayload = null;
|
||||
while (Date.now() < deadline) {
|
||||
lastPayload = await requestJson(requestContext, `/api/mindmap/${documentId}/${mindmapId}`);
|
||||
if (check(lastPayload)) {
|
||||
return lastPayload;
|
||||
}
|
||||
await sleep(500);
|
||||
}
|
||||
throw new Error(`${description} 超时:${JSON.stringify(lastPayload)}`);
|
||||
}
|
||||
|
||||
function assertRouteMeta(meta, expected) {
|
||||
assert(meta && typeof meta.requestId === "string" && meta.requestId, "缺少 meta.requestId");
|
||||
assert(meta && typeof meta.traceId === "string" && meta.traceId, "缺少 meta.traceId");
|
||||
assert(meta.documentId === expected.documentId, `documentId 不一致:${meta.documentId}`);
|
||||
assert(meta.pageId === expected.documentId, `pageId 不一致:${meta.pageId}`);
|
||||
assert(meta.mindmapId === expected.mindmapId, `mindmapId 不一致:${meta.mindmapId}`);
|
||||
assert(meta.attachmentId === expected.mindmapId, `attachmentId 不一致:${meta.attachmentId}`);
|
||||
assert(meta.workspaceId === expected.workspaceId, `workspaceId 不一致:${meta.workspaceId}`);
|
||||
}
|
||||
|
||||
async function persistInsertAndRename(page, mindmapId) {
|
||||
return page.evaluate(
|
||||
({ currentMindmapId }) => {
|
||||
const instance =
|
||||
window.__mindmapInstancesById?.[currentMindmapId] ?? window.__mindmapInstance;
|
||||
if (!instance) {
|
||||
throw new Error("未找到 mindmap 实例");
|
||||
}
|
||||
|
||||
const renderer = instance.renderer;
|
||||
const root = renderer?.root ?? renderer?.renderTree?._node;
|
||||
if (!root) {
|
||||
throw new Error("未找到根节点");
|
||||
}
|
||||
|
||||
renderer?.clearActiveNodeList?.();
|
||||
renderer?.addNodeToActiveList?.(root, true);
|
||||
renderer.lastActiveNodeList = [root];
|
||||
renderer?.emitNodeActiveEvent?.(root);
|
||||
instance.execCommand?.("SET_NODE_ACTIVE", root, true);
|
||||
instance.execCommand?.("INSERT_CHILD_NODE", false, [root]);
|
||||
|
||||
const snapshot = instance.getData?.(true) ?? instance.getData?.() ?? null;
|
||||
if (!snapshot?.root?.children?.[0]?.data) {
|
||||
throw new Error("插入子节点后未拿到快照");
|
||||
}
|
||||
|
||||
const persist = window.__mindmapPersistById?.[currentMindmapId];
|
||||
if (!persist) {
|
||||
throw new Error("未找到 mindmap 持久化回调");
|
||||
}
|
||||
persist(snapshot);
|
||||
return {
|
||||
childText: String(snapshot.root.children[0].data.text ?? ""),
|
||||
childCount: snapshot.root.children.length,
|
||||
};
|
||||
},
|
||||
{
|
||||
currentMindmapId: mindmapId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function persistDeleteChild(page, mindmapId, childUid) {
|
||||
return page.evaluate(async ({ currentMindmapId, currentChildUid }) => {
|
||||
const instance =
|
||||
window.__mindmapInstancesById?.[currentMindmapId] ?? window.__mindmapInstance;
|
||||
if (!instance) {
|
||||
throw new Error("未找到 mindmap 实例");
|
||||
}
|
||||
const renderer = instance.renderer;
|
||||
const child =
|
||||
typeof renderer?.findNodeByUid === "function"
|
||||
? renderer.findNodeByUid(currentChildUid)
|
||||
: null;
|
||||
if (!child) {
|
||||
throw new Error("删除子节点时未找到目标节点");
|
||||
}
|
||||
renderer?.clearActiveNodeList?.();
|
||||
renderer?.addNodeToActiveList?.(child, true);
|
||||
renderer.lastActiveNodeList = [child];
|
||||
renderer?.emitNodeActiveEvent?.(child);
|
||||
instance.execCommand?.("SET_NODE_ACTIVE", child, true);
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
instance.execCommand?.("REMOVE_NODE");
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
const snapshot = instance.getData?.(true) ?? instance.getData?.() ?? null;
|
||||
const root = snapshot?.root ?? snapshot;
|
||||
return {
|
||||
childCount:
|
||||
root && typeof root === "object" && Array.isArray(root.children)
|
||||
? root.children.length
|
||||
: -1,
|
||||
};
|
||||
}, { currentMindmapId: mindmapId, currentChildUid: childUid });
|
||||
}
|
||||
|
||||
async function openOutlinePanel(page) {
|
||||
const outlineButton = page.getByRole("button", { name: "大纲" });
|
||||
await outlineButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await outlineButton.click();
|
||||
}
|
||||
|
||||
async function runBrowserRegression(page, requestContext, target) {
|
||||
const uniqueSuffix = Date.now().toString();
|
||||
const mindmapId = `task021-${uniqueSuffix}`;
|
||||
const defaultChildText = "二级节点";
|
||||
|
||||
try {
|
||||
await createTempMindmap(requestContext, target.documentId, mindmapId);
|
||||
|
||||
const mindmapUrl = `${BASE_URL}/mindmap/${target.documentId}/${mindmapId}`;
|
||||
await page.goto(mindmapUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
const fullscreen = page.locator("[data-testid=\"mindmap-fullscreen\"]");
|
||||
const canvas = page.locator("[data-testid=\"mindmap-canvas\"]");
|
||||
const rootText = page.getByText("中心主题").first();
|
||||
|
||||
await fullscreen.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await canvas.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await rootText.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await waitForMindmapInstance(page, mindmapId);
|
||||
await waitForMindmapReady(page, mindmapId);
|
||||
|
||||
const initialMetaAttrs = await readMindmapMetaAttrs(page);
|
||||
assert(initialMetaAttrs.documentId === target.documentId, "页面 data-document-id 不正确");
|
||||
assert(initialMetaAttrs.pageId === target.documentId, "页面 data-page-id 不正确");
|
||||
assert(initialMetaAttrs.mindmapId === mindmapId, "页面 data-mindmap-id 不正确");
|
||||
assert(initialMetaAttrs.attachmentId === mindmapId, "页面 data-attachment-id 不正确");
|
||||
assert(initialMetaAttrs.workspaceId === target.workspaceId, "页面 data-workspace-id 不正确");
|
||||
|
||||
const insertMutation = await persistInsertAndRename(page, mindmapId);
|
||||
assert(insertMutation.childCount === 1, `插入子节点后数量异常:${insertMutation.childCount}`);
|
||||
assert(insertMutation.childText, "插入子节点后名称为空");
|
||||
|
||||
const insertSaveMeta = await waitForMetaMutation(page, initialMetaAttrs);
|
||||
assertRouteMeta(insertSaveMeta, {
|
||||
documentId: target.documentId,
|
||||
mindmapId,
|
||||
workspaceId: target.workspaceId,
|
||||
});
|
||||
await waitForMindmapState(
|
||||
requestContext,
|
||||
target.documentId,
|
||||
mindmapId,
|
||||
(payload) => Array.isArray(payload?.data?.children) && payload.data.children.length === 1,
|
||||
"插入子节点后后端回查",
|
||||
);
|
||||
|
||||
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await fullscreen.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await canvas.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await waitForMindmapInstance(page, mindmapId);
|
||||
await waitForMindmapReady(page, mindmapId);
|
||||
const beforeDeleteMeta = await readMindmapMetaAttrs(page);
|
||||
const insertSavedMindmap = await requestJson(requestContext, `/api/mindmap/${target.documentId}/${mindmapId}`);
|
||||
assert(
|
||||
Array.isArray(insertSavedMindmap.data?.children) &&
|
||||
insertSavedMindmap.data.children.length === 1,
|
||||
"刷新后导图子节点数量不正确",
|
||||
);
|
||||
assert(
|
||||
typeof insertSavedMindmap.data.children[0]?.data?.text === "string" &&
|
||||
insertSavedMindmap.data.children[0].data.text.trim(),
|
||||
"刷新后导图子节点名称为空",
|
||||
);
|
||||
const persistedChildText =
|
||||
String(insertSavedMindmap.data.children[0]?.data?.text ?? "")
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.trim() || defaultChildText;
|
||||
const persistedChildUid = String(insertSavedMindmap.data.children[0]?.data?.uid ?? "");
|
||||
assert(persistedChildUid, "刷新后导图子节点缺少 uid");
|
||||
await openOutlinePanel(page);
|
||||
await page.getByText(persistedChildText).first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
const deleteMutation = await persistDeleteChild(page, mindmapId, persistedChildUid);
|
||||
assert(deleteMutation.childCount === 0, `删除子节点后数量异常:${deleteMutation.childCount}`);
|
||||
|
||||
const deleteSaveMeta = await waitForMetaMutation(page, beforeDeleteMeta);
|
||||
assertRouteMeta(deleteSaveMeta, {
|
||||
documentId: target.documentId,
|
||||
mindmapId,
|
||||
workspaceId: target.workspaceId,
|
||||
});
|
||||
await waitForMindmapState(
|
||||
requestContext,
|
||||
target.documentId,
|
||||
mindmapId,
|
||||
(payload) => Array.isArray(payload?.data?.children) && payload.data.children.length === 0,
|
||||
"删除子节点后后端回查",
|
||||
);
|
||||
|
||||
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await fullscreen.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await canvas.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await waitForMindmapInstance(page, mindmapId);
|
||||
await waitForMindmapReady(page, mindmapId);
|
||||
await openOutlinePanel(page);
|
||||
|
||||
await page.getByText("中心主题").first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
const deleteSavedMindmap = await requestJson(requestContext, `/api/mindmap/${target.documentId}/${mindmapId}`);
|
||||
assert(
|
||||
Array.isArray(deleteSavedMindmap.data?.children) &&
|
||||
deleteSavedMindmap.data.children.length === 0,
|
||||
"删除子节点后后端仍保留子节点",
|
||||
);
|
||||
const canvasText = await canvas.innerText({ timeout: UI_TIMEOUT_MS });
|
||||
assert(!canvasText.includes(persistedChildText), "删除子节点后画布仍残留旧节点文本");
|
||||
|
||||
return {
|
||||
mindmapUrl,
|
||||
mindmapId,
|
||||
childText: persistedChildText,
|
||||
initialMetaAttrs,
|
||||
insertMeta: insertSaveMeta,
|
||||
deleteMeta: deleteSaveMeta,
|
||||
};
|
||||
} finally {
|
||||
await cleanupTempMindmap(requestContext, target.documentId, mindmapId);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const health = await fetch(`${BASE_URL}/`, {
|
||||
method: "HEAD",
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
assert(
|
||||
[200, 307, 308].includes(health.status),
|
||||
`首页探活失败:收到状态码 ${health.status}`,
|
||||
);
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 960 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
let tempDocument = null;
|
||||
let regressionResult = null;
|
||||
let caughtError = null;
|
||||
|
||||
try {
|
||||
await ensureAuthenticated(page, context.request);
|
||||
tempDocument = await createTempDocument(context.request);
|
||||
regressionResult = await runBrowserRegression(page, context.request, tempDocument);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
workspaceId: tempDocument.workspaceId,
|
||||
documentId: tempDocument.documentId,
|
||||
...regressionResult,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
caughtError = error;
|
||||
} finally {
|
||||
if (tempDocument?.documentId) {
|
||||
try {
|
||||
await purgeTempDocument(context.request, tempDocument.documentId);
|
||||
} catch (cleanupError) {
|
||||
if (!caughtError) {
|
||||
caughtError = cleanupError;
|
||||
} else {
|
||||
console.error(
|
||||
`清理临时页面失败:${cleanupError instanceof Error ? cleanupError.stack || cleanupError.message : String(cleanupError)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
await page.close().catch(() => undefined);
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
if (caughtError) {
|
||||
throw caughtError;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,414 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
// 说明:
|
||||
// - 这是 task-022 的最小真实浏览器回归脚本。
|
||||
// - 目标覆盖 OnlyOffice 页面打开、插件桥接插入文本、forcesave 按钮、callback 写回闭环。
|
||||
// - 脚本会创建临时页面并上传临时 docx,回归结束后 purge 页面,避免污染现有数据。
|
||||
|
||||
const fs = require("node:fs");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const BASE_URL = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000";
|
||||
const REQUEST_TIMEOUT_MS = 20_000;
|
||||
const UI_TIMEOUT_MS = 120_000;
|
||||
const CALLBACK_TIMEOUT_MS = 90_000;
|
||||
const PROBE_DOCX_PATH = process.env.MNOTE_ONLYOFFICE_PROBE_DOCX || "/tmp/mnote-onlyoffice-probe/probe.docx";
|
||||
const ONLYOFFICE_PLUGIN_CHANNEL = "mnote_onlyoffice_agent_tools_v1";
|
||||
const DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
const TEST_LOGIN_BUTTON_NAME = "测试账号快速登录";
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function requestPayload(requestContext, path, init = {}) {
|
||||
const headers =
|
||||
init.multipart || init.form
|
||||
? { ...(init.headers || {}) }
|
||||
: init.data !== undefined
|
||||
? {
|
||||
"content-type": "application/json",
|
||||
...(init.headers || {}),
|
||||
}
|
||||
: { ...(init.headers || {}) };
|
||||
|
||||
const response = await requestContext.fetch(`${BASE_URL}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
timeout: REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
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()} ${response.statusText()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function createTempDocument(requestContext) {
|
||||
const payload = await requestPayload(requestContext, "/api/documents/create", {
|
||||
method: "POST",
|
||||
data: { parentId: null },
|
||||
});
|
||||
|
||||
assert(payload && typeof payload.id === "string", "创建临时页面失败:缺少 id");
|
||||
assert(payload && typeof payload.workspace_id === "string", "创建临时页面失败:缺少 workspace_id");
|
||||
|
||||
return {
|
||||
documentId: payload.id,
|
||||
workspaceId: payload.workspace_id,
|
||||
};
|
||||
}
|
||||
|
||||
async function purgeTempDocument(requestContext, documentId) {
|
||||
await requestPayload(requestContext, "/api/documents/purge", {
|
||||
method: "POST",
|
||||
data: { documentId },
|
||||
});
|
||||
}
|
||||
|
||||
async function getViewerIdentity(requestContext) {
|
||||
const payload = await requestPayload(requestContext, "/api/auth/whoami", { method: "GET" });
|
||||
assert(payload && typeof payload.userId === "string" && payload.userId, "获取当前用户失败:缺少 userId");
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function uploadProbeDocx(requestContext, target) {
|
||||
assert(fs.existsSync(PROBE_DOCX_PATH), `缺少探测文件:${PROBE_DOCX_PATH}`);
|
||||
const buffer = fs.readFileSync(PROBE_DOCX_PATH);
|
||||
const payload = await requestPayload(requestContext, "/api/media/upload", {
|
||||
method: "POST",
|
||||
multipart: {
|
||||
file: {
|
||||
name: "task022-probe.docx",
|
||||
mimeType: DOCX_MIME,
|
||||
buffer,
|
||||
},
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
},
|
||||
});
|
||||
|
||||
assert(payload && payload.asset && typeof payload.asset.id === "string", "上传探测 docx 失败:缺少 asset.id");
|
||||
return payload.asset;
|
||||
}
|
||||
|
||||
async function getSignedAsset(requestContext, assetId) {
|
||||
const payload = await requestPayload(requestContext, `/api/media/sign?assetId=${encodeURIComponent(assetId)}`, {
|
||||
method: "GET",
|
||||
});
|
||||
assert(payload && typeof payload.signedUrl === "string" && payload.signedUrl, "缺少 signedUrl");
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function ensureAuthenticated(page, requestContext) {
|
||||
await page.goto(`${BASE_URL}/auth`, { waitUntil: "networkidle", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
if (page.url().includes("/auth")) {
|
||||
const quickLoginButton = page.getByRole("button", { name: TEST_LOGIN_BUTTON_NAME });
|
||||
await quickLoginButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await quickLoginButton.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForURL((url) => !url.toString().includes("/auth"), {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
return await getViewerIdentity(requestContext);
|
||||
}
|
||||
|
||||
async function installPluginBridge(page) {
|
||||
await page.addInitScript(
|
||||
({ channel }) => {
|
||||
const state = {
|
||||
channel,
|
||||
ready: false,
|
||||
origin: "*",
|
||||
target: null,
|
||||
pending: new Map(),
|
||||
};
|
||||
|
||||
window.__TASK022_ONLYOFFICE_PLUGIN__ = state;
|
||||
window.addEventListener("message", (event) => {
|
||||
const data = event?.data;
|
||||
if (!data || typeof data !== "object") return;
|
||||
if (data.channel !== channel) return;
|
||||
|
||||
if (data.type === "ready") {
|
||||
state.ready = true;
|
||||
state.origin = String(event.origin || "*");
|
||||
state.target =
|
||||
event.source && typeof event.source.postMessage === "function" ? event.source : null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === "result") {
|
||||
const callId = String(data.callId || "").trim();
|
||||
if (!callId) return;
|
||||
const pending = state.pending.get(callId);
|
||||
if (!pending) return;
|
||||
state.pending.delete(callId);
|
||||
window.clearTimeout(pending.timeoutId);
|
||||
if (data.ok) {
|
||||
pending.resolve(data.result ?? null);
|
||||
} else {
|
||||
pending.reject(new Error(String(data.error || "插件执行失败")));
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
{ channel: ONLYOFFICE_PLUGIN_CHANNEL },
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForOnlyOfficeReady(page) {
|
||||
await page.waitForFunction(() => window.__MNOTE_ONLYOFFICE_READY__ === true, {
|
||||
timeout: UI_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"));
|
||||
},
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
function getEditorIframe(page) {
|
||||
return page.locator('iframe[src*="/documenteditor/main/index.html"]').first();
|
||||
}
|
||||
|
||||
async function waitForPluginReady(page) {
|
||||
await page.waitForFunction(
|
||||
() => Boolean(window.__TASK022_ONLYOFFICE_PLUGIN__?.ready && window.__TASK022_ONLYOFFICE_PLUGIN__?.target),
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function callOnlyOfficePlugin(page, tool, args) {
|
||||
return page.evaluate(
|
||||
async ({ channel, toolName, toolArgs }) => {
|
||||
const state = window.__TASK022_ONLYOFFICE_PLUGIN__;
|
||||
if (!state || !state.ready || !state.target) {
|
||||
throw new Error("OnlyOffice 插件桥未就绪");
|
||||
}
|
||||
|
||||
const callId = `task022-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
return await new Promise((resolve, reject) => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
state.pending.delete(callId);
|
||||
reject(new Error(`插件调用超时: ${toolName}`));
|
||||
}, 60_000);
|
||||
|
||||
state.pending.set(callId, { resolve, reject, timeoutId });
|
||||
state.target.postMessage(
|
||||
{
|
||||
channel,
|
||||
type: "call",
|
||||
callId,
|
||||
tool: toolName,
|
||||
args: toolArgs,
|
||||
},
|
||||
state.origin || "*",
|
||||
);
|
||||
});
|
||||
},
|
||||
{
|
||||
channel: ONLYOFFICE_PLUGIN_CHANNEL,
|
||||
toolName: tool,
|
||||
toolArgs: args,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function getOnlyOfficeDebug(page) {
|
||||
return page.evaluate(() => ({
|
||||
ready: Boolean(window.__MNOTE_ONLYOFFICE_READY__),
|
||||
debug: window.__MNOTE_ONLYOFFICE_DEBUG__ ?? null,
|
||||
errlog: window.__MNOTE_ONLYOFFICE_ERRLOG__ ?? [],
|
||||
}));
|
||||
}
|
||||
|
||||
async function waitForStorageIdChange(requestContext, assetId, previousStorageId) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < CALLBACK_TIMEOUT_MS) {
|
||||
const payload = await getSignedAsset(requestContext, assetId);
|
||||
const nextStorageId = String(payload.asset?.storage_id || "").trim();
|
||||
if (nextStorageId && nextStorageId !== previousStorageId) {
|
||||
return payload;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 2_000));
|
||||
}
|
||||
throw new Error(`等待 callback 写回超时:storage_id 仍为 ${previousStorageId || "<empty>"}`);
|
||||
}
|
||||
|
||||
async function runBrowserRegression(page, requestContext, viewer, target) {
|
||||
const asset = await uploadProbeDocx(requestContext, target);
|
||||
const initialSigned = await getSignedAsset(requestContext, asset.id);
|
||||
const initialStorageId = String(initialSigned.asset?.storage_id || "").trim();
|
||||
assert(initialStorageId, "初始 storage_id 为空");
|
||||
|
||||
const uniqueSuffix = Date.now().toString();
|
||||
const insertedText = ` task022-onlyoffice-${uniqueSuffix} `;
|
||||
|
||||
await installPluginBridge(page);
|
||||
|
||||
try {
|
||||
const pageUrl = new URL("/onlyoffice", BASE_URL);
|
||||
pageUrl.searchParams.set("fileUrl", String(initialSigned.signedUrl));
|
||||
pageUrl.searchParams.set("fileName", "task022-probe.docx");
|
||||
pageUrl.searchParams.set("fileType", "docx");
|
||||
pageUrl.searchParams.set("mode", "edit");
|
||||
pageUrl.searchParams.set("assetId", asset.id);
|
||||
pageUrl.searchParams.set("documentId", target.documentId);
|
||||
pageUrl.searchParams.set("userId", viewer.userId);
|
||||
pageUrl.searchParams.set("channel", "web");
|
||||
|
||||
await page.goto(pageUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.getByText("ONLYOFFICE 加载失败").waitFor({ state: "hidden", timeout: 5_000 }).catch(() => null);
|
||||
|
||||
await waitForOnlyOfficeReady(page);
|
||||
await waitForPluginReady(page);
|
||||
|
||||
const editorIframe = getEditorIframe(page);
|
||||
await editorIframe.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await editorIframe.click({ position: { x: 160, y: 120 }, timeout: UI_TIMEOUT_MS });
|
||||
|
||||
const initialDebug = await getOnlyOfficeDebug(page);
|
||||
assert(initialDebug.ready === true, "OnlyOffice ready 标记未就绪");
|
||||
assert(initialDebug.debug && initialDebug.debug.assetId === asset.id, "OnlyOffice debug.assetId 不正确");
|
||||
assert(initialDebug.debug && initialDebug.debug.documentId === target.documentId, "OnlyOffice debug.documentId 不正确");
|
||||
assert(initialDebug.debug && initialDebug.debug.baseUrl === "/onlyoffice-server", `OnlyOffice baseUrl 异常:${JSON.stringify(initialDebug.debug)}`);
|
||||
assert(
|
||||
initialDebug.debug && typeof initialDebug.debug.resolvedFileUrl === "string" && initialDebug.debug.resolvedFileUrl.includes("/api/onlyoffice/proxy"),
|
||||
`OnlyOffice resolvedFileUrl 未走 proxy:${JSON.stringify(initialDebug.debug)}`,
|
||||
);
|
||||
assert(initialDebug.debug && typeof initialDebug.debug.docKey === "string" && initialDebug.debug.docKey, "OnlyOffice debug.docKey 为空");
|
||||
|
||||
const pluginResult = await callOnlyOfficePlugin(page, "oo_insert_text", { text: insertedText });
|
||||
assert(pluginResult && pluginResult.ok === true, `插件插入文本失败:${JSON.stringify(pluginResult)}`);
|
||||
|
||||
await page.waitForTimeout(2_000);
|
||||
|
||||
const forceSaveResponsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes(`/api/onlyoffice/forcesave?assetId=${encodeURIComponent(asset.id)}`) &&
|
||||
response.request().method() === "POST" &&
|
||||
response.status() === 200,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
const forceSaveButton = page.getByRole("button", { name: "同步保存" });
|
||||
await forceSaveButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await forceSaveButton.click({ timeout: UI_TIMEOUT_MS });
|
||||
const forceSaveResponse = await forceSaveResponsePromise;
|
||||
const forceSavePayload = await forceSaveResponse.json();
|
||||
assert(forceSavePayload && forceSavePayload.ok === true, `forcesave 返回异常:${JSON.stringify(forceSavePayload)}`);
|
||||
|
||||
await page.getByText("已触发同步保存").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const updatedSigned = await waitForStorageIdChange(requestContext, asset.id, initialStorageId);
|
||||
|
||||
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await waitForOnlyOfficeReady(page);
|
||||
await waitForPluginReady(page);
|
||||
|
||||
const reloadDebug = await getOnlyOfficeDebug(page);
|
||||
assert(reloadDebug.ready === true, "刷新后 OnlyOffice ready 标记未就绪");
|
||||
assert(
|
||||
String(updatedSigned.asset?.storage_id || "").trim() !== initialStorageId,
|
||||
"callback 写回后 storage_id 未发生变化",
|
||||
);
|
||||
|
||||
return {
|
||||
pageUrl: pageUrl.toString(),
|
||||
assetId: asset.id,
|
||||
initialStorageId,
|
||||
updatedStorageId: String(updatedSigned.asset?.storage_id || "").trim(),
|
||||
insertedText,
|
||||
debug: reloadDebug.debug,
|
||||
errlog: reloadDebug.errlog,
|
||||
};
|
||||
} catch (error) {
|
||||
const debug = await getOnlyOfficeDebug(page).catch(() => null);
|
||||
if (debug) {
|
||||
console.error(JSON.stringify({ onlyofficeDebug: debug }, null, 2));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const health = await fetch(`${BASE_URL}/`, {
|
||||
method: "HEAD",
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
assert([200, 307, 308].includes(health.status), `首页探活失败:收到状态码 ${health.status}`);
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 960 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
let tempDocument = null;
|
||||
let caughtError = null;
|
||||
|
||||
try {
|
||||
const viewer = await ensureAuthenticated(page, context.request);
|
||||
tempDocument = await createTempDocument(context.request);
|
||||
const result = await runBrowserRegression(page, context.request, viewer, tempDocument);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
workspaceId: tempDocument.workspaceId,
|
||||
documentId: tempDocument.documentId,
|
||||
...result,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
caughtError = error;
|
||||
} finally {
|
||||
if (tempDocument?.documentId) {
|
||||
try {
|
||||
await purgeTempDocument(context.request, tempDocument.documentId);
|
||||
} catch (cleanupError) {
|
||||
if (!caughtError) {
|
||||
caughtError = cleanupError;
|
||||
} else {
|
||||
console.error(
|
||||
`清理临时页面失败:${cleanupError instanceof Error ? cleanupError.stack || cleanupError.message : String(cleanupError)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await page.close().catch(() => undefined);
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
if (caughtError) {
|
||||
throw caughtError;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user