chore: land tree view-state, vault, Pi module split, and repo hygiene

Persist PageTree expand state via control-plane view-state and align
chevron/DOM with restored expansion; keep Sidex-style shallow page-tree
scan and drop the unused recursive scanner that only added cargo noise.

Add password vault workbench routes/runtime/skill/CLI, split page_ai_pi
into a module package, and retire Hermes/ACP/OpenHub recycle + root
harness evidence from the index while gitignoring recycle and local
diag dumps.

Archive superseded design/bugs docs under old/, point architecture at
ARCHITECTURE.md, and refresh smokes for Pi S1–S7, vault, and editor
regressions so the working tree can stay clean.
This commit is contained in:
Agent Board
2026-07-21 05:13:05 +08:00
parent 6f9c7d3b58
commit b798f628ee
264 changed files with 17480 additions and 17314 deletions
@@ -1,458 +0,0 @@
#!/usr/bin/env node
"use strict";
const fs = require("node:fs");
const path = require("node:path");
function parseArgs(argv) {
const args = {
fixture: "",
out: "",
dryRun: false,
manifest: "",
conflictReport: "",
rollback: "",
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--fixture") {
args.fixture = argv[++index] || "";
continue;
}
if (arg === "--out") {
args.out = argv[++index] || "";
continue;
}
if (arg === "--dry-run") {
args.dryRun = true;
continue;
}
if (arg === "--manifest") {
args.manifest = argv[++index] || "";
continue;
}
if (arg === "--conflict-report") {
args.conflictReport = argv[++index] || "";
continue;
}
if (arg === "--rollback") {
args.rollback = argv[++index] || "";
continue;
}
if (arg === "--help" || arg === "-h") {
console.log(
[
"用法:node recycle/scripts/retired-convex-export-smokes-20260526/export-convex-workspace-to-local.js --fixture <fixture.json> --out <dir> [--dry-run] [--manifest <file>] [--conflict-report <file>]",
"回滚:node recycle/scripts/retired-convex-export-smokes-20260526/export-convex-workspace-to-local.js --rollback <manifest.json>",
].join("\n"),
);
process.exit(0);
}
throw new Error(`未知参数:${arg}`);
}
if (args.rollback) return args;
if (!args.fixture) throw new Error("缺少 --fixture");
if (!args.out) throw new Error("缺少 --out");
return args;
}
function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true });
}
function writeUtf8(filePath, content) {
ensureDir(path.dirname(filePath));
fs.writeFileSync(filePath, content, "utf8");
}
function writeJson(filePath, value) {
writeUtf8(filePath, `${JSON.stringify(value, null, 2)}\n`);
}
function sanitizeName(name) {
return String(name || "untitled")
.trim()
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_")
.replace(/\s+/g, " ")
.replace(/\.+$/g, "")
.trim() || "untitled";
}
function loadFixture(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function buildDocumentIndex(documents) {
const byId = new Map();
documents.forEach((doc) => {
byId.set(String(doc.id), doc);
});
return byId;
}
function buildMarkdownPath(doc, documentsById) {
const segments = [];
let current = doc;
while (current) {
segments.unshift(sanitizeName(current.title || current.id || "untitled"));
const parentId = current.parent_id || current.parentId || null;
current = parentId ? documentsById.get(String(parentId)) : null;
}
return path.posix.join("pages", ...segments) + ".md";
}
function buildAssetPath(markdownPath, fileName) {
const base = markdownPath.replace(/\.md$/i, ".assets");
return path.posix.join(base, sanitizeName(fileName));
}
function findDocumentForAsset(asset, documents) {
const directId = String(asset.document_id || asset.documentId || asset.page_id || asset.pageId || "").trim();
if (directId) return directId;
const assetId = String(asset.id || "").trim();
if (!assetId) return "";
const doc = documents.find((item) => {
const content = typeof item.content === "string" ? item.content : "";
const rawText = typeof item.raw_text === "string" ? item.raw_text : "";
const editorText = typeof item.editor_document === "string" ? item.editor_document : "";
const tiptapText = typeof item.tiptap_document === "string" ? item.tiptap_document : "";
return [content, rawText, editorText, tiptapText].some((text) => text.includes(assetId));
});
return doc ? String(doc.id) : "";
}
function decodeAssetContent(asset) {
if (typeof asset.contentBase64 === "string" && asset.contentBase64) {
return Buffer.from(asset.contentBase64, "base64");
}
if (typeof asset.content === "string") {
return Buffer.from(asset.content, "utf8");
}
return Buffer.from("", "utf8");
}
function rewriteAssetUrls(markdown, assetPathById) {
return String(markdown || "").replace(/\/api\/media\/sign\?assetId=([^)\s"'&#]+)/g, (_, assetId) => {
return assetPathById.get(String(assetId)) || _;
});
}
function serializePageOptions(doc) {
return {
wideLayout: doc.wide_layout ?? null,
useSmallText: doc.use_small_text ?? null,
showHeadingNumbers: doc.show_heading_numbers ?? null,
showToc: doc.show_toc ?? null,
showStructure: doc.show_structure ?? null,
protectEditing: doc.protect_editing ?? null,
showWordCount: doc.show_word_count ?? null,
collapseBacklinks: doc.collapse_backlinks ?? null,
pageFont: doc.page_font ?? null,
layoutDensity: doc.layout_density ?? null,
hideChildPages: doc.hide_child_pages ?? null,
showBlockRefCount: doc.show_block_ref_count ?? null,
embedDefaultBlockId: doc.embed_default_block_id ?? null,
};
}
function fileUriForPath(localPath) {
return `file://${path.resolve(localPath)}`;
}
function nowId() {
return new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14);
}
function normalizeRelativePath(relativePath) {
const normalized = String(relativePath || "").replace(/\\/g, "/").replace(/^\/+/, "");
if (!normalized || normalized.split("/").some((part) => part === "..")) {
throw new Error(`非法迁移相对路径:${relativePath}`);
}
return normalized;
}
function pushFileOperation(operations, relativePath, content, contentEncoding = "utf8") {
operations.push({
action: "create",
relativePath: normalizeRelativePath(relativePath),
content,
contentEncoding,
});
}
function buildMigrationPlan(fixture, out) {
const documents = Array.isArray(fixture.documents) ? fixture.documents : [];
const mediaAssets = Array.isArray(fixture.mediaAssets) ? fixture.mediaAssets : [];
const aiSessions = Array.isArray(fixture.aiSessions) ? fixture.aiSessions : [];
const docsById = buildDocumentIndex(documents);
const markdownPathByDocId = new Map();
const assetPathById = new Map();
const pageIds = {};
const pageOptions = {};
const resourceIndex = { version: 1, assets: {} };
documents.forEach((doc) => {
const markdownPath = buildMarkdownPath(doc, docsById);
markdownPathByDocId.set(String(doc.id), markdownPath);
pageIds[markdownPath] = `local-mdid:${doc.id}`;
pageOptions[`local-mdid:${doc.id}`] = serializePageOptions(doc);
});
mediaAssets.forEach((asset) => {
const ownerDocId = findDocumentForAsset(asset, documents) || String(documents[0]?.id || "");
const ownerMarkdownPath = markdownPathByDocId.get(ownerDocId) || "pages/attachments.md";
const relativePath = buildAssetPath(ownerMarkdownPath, asset.fileName || asset.name || asset.id);
assetPathById.set(String(asset.id), relativePath);
resourceIndex.assets[String(asset.id)] = {
fileName: sanitizeName(asset.fileName || asset.name || asset.id),
relativePath,
documentId: ownerDocId || null,
};
});
const operations = [];
const indexedDocuments = [];
const indexedResources = [];
documents.forEach((doc) => {
const markdownPath = markdownPathByDocId.get(String(doc.id));
const assetRoot = markdownPath.replace(/\.md$/i, ".assets");
const markdownDir = path.posix.dirname(markdownPath);
const markdownBody = rewriteAssetUrls(
typeof doc.content === "string"
? doc.content
: String(doc.raw_text || doc.editor_document || doc.tiptap_document || ""),
new Map(Array.from(assetPathById.entries()).map(([assetId, absolutePath]) => [
assetId,
path.posix.relative(markdownDir, absolutePath),
])),
);
const frontmatter = [
"---",
`title: ${String(doc.title || doc.id || "untitled")}`,
`mnote_id: ${String(doc.id)}`,
"---",
"",
].join("\n");
pushFileOperation(operations, markdownPath, `${frontmatter}${markdownBody}`);
indexedDocuments.push({
documentId: `local-mdid:${doc.id}`,
title: String(doc.title || doc.id || "untitled"),
path: markdownPath,
rawText: markdownBody,
tags: [],
backlinks: [],
resourceRefs: [],
updatedAt: Date.now(),
});
operations.push({
action: "ensureDir",
relativePath: normalizeRelativePath(assetRoot),
});
});
mediaAssets.forEach((asset) => {
const relativePath = assetPathById.get(String(asset.id));
if (!relativePath) return;
const content = decodeAssetContent(asset).toString("base64");
pushFileOperation(operations, relativePath, content, "base64");
indexedResources.push({
resourceId: String(asset.id),
resourceType: "media",
title: sanitizeName(asset.fileName || asset.name || asset.id),
path: relativePath,
updatedAt: Date.now(),
});
});
aiSessions.forEach((session) => {
const sessionId = sanitizeName(session.sessionId || session.id || "session");
const shareId = String(session.shareId || session.share_id || "").trim();
const visibility = String(session.visibility || "").trim();
const relativePath = shareId || visibility === "shared"
? path.posix.join("ai-sessions", "shared", sanitizeName(shareId || "share"), `${sessionId}.jsonl`)
: path.posix.join("ai-sessions", "private", `${sessionId}.jsonl`);
const events = Array.isArray(session.events) ? session.events : [];
const lines = events.map((event) => JSON.stringify(event)).join("\n");
pushFileOperation(operations, relativePath, lines ? `${lines}\n` : "");
});
const workspaceId = fixture.workspace?.id || "exported-workspace";
pushFileOperation(operations, ".mnote/page-ids.json", `${JSON.stringify({ version: 1, pages: pageIds }, null, 2)}\n`);
pushFileOperation(operations, ".mnote/page-options.json", `${JSON.stringify({ version: 1, pages: pageOptions }, null, 2)}\n`);
pushFileOperation(operations, ".mnote/resource-index.json", `${JSON.stringify(resourceIndex, null, 2)}\n`);
pushFileOperation(
operations,
".mnote/workspace.json",
`${JSON.stringify(
{
workspaceId,
ownerId: fixture.workspace?.ownerId || fixture.workspace?.owner_id || "unknown",
createdAt: new Date().toISOString(),
capabilities: ["local_files", "ai_sessions", "exported_from_convex"],
},
null,
2,
)}\n`,
);
pushFileOperation(
operations,
".mnote/index/search-index.json",
`${JSON.stringify(
{
version: 1,
builtAt: Date.now(),
rootUri: fileUriForPath(out),
workspaceId,
documents: indexedDocuments,
resources: indexedResources,
},
null,
2,
)}\n`,
);
return {
workspaceId,
operations,
documentCount: documents.length,
resourceCount: mediaAssets.length,
aiSessionCount: aiSessions.length,
};
}
function detectConflicts(out, operations) {
return operations
.filter((operation) => operation.action === "create")
.filter((operation) => fs.existsSync(path.join(out, operation.relativePath)))
.map((operation) => ({
code: "target_exists",
relativePath: operation.relativePath,
targetPath: path.join(out, operation.relativePath),
message: "目标文件已存在,迁移不会覆盖",
}));
}
function manifestForPlan(out, plan, dryRun, conflicts = []) {
const migrationId = `convex-export-${nowId()}`;
return {
version: 1,
migrationId,
source: "convex_fixture",
dryRun,
out: path.resolve(out),
createdAt: new Date().toISOString(),
backupDir: path.join(path.resolve(out), ".mnote", "migration-backups", migrationId),
workspaceId: plan.workspaceId,
documentCount: plan.documentCount,
resourceCount: plan.resourceCount,
aiSessionCount: plan.aiSessionCount,
operations: plan.operations.map((operation) => ({
action: operation.action,
relativePath: operation.relativePath,
contentEncoding: operation.contentEncoding || null,
})),
createdFiles: [],
backupFiles: [],
conflicts,
indexRefresh: {
status: dryRun ? "planned" : "pending",
path: ".mnote/index/search-index.json",
},
};
}
function writeConflictReport(filePath, out, conflicts) {
if (!filePath) return;
writeJson(filePath, {
version: 1,
code: conflicts.length ? "migration_conflict" : "ok",
out: path.resolve(out),
generatedAt: new Date().toISOString(),
conflicts,
});
}
function applyPlan(out, plan, manifest) {
ensureDir(out);
ensureDir(manifest.backupDir);
for (const operation of plan.operations) {
const target = path.join(out, operation.relativePath);
if (operation.action === "ensureDir") {
ensureDir(target);
continue;
}
if (operation.action !== "create") continue;
ensureDir(path.dirname(target));
const content = operation.contentEncoding === "base64"
? Buffer.from(operation.content, "base64")
: operation.content;
fs.writeFileSync(target, content, operation.contentEncoding === "base64" ? undefined : "utf8");
manifest.createdFiles.push(operation.relativePath);
}
manifest.indexRefresh.status = "written";
}
function rollbackManifest(manifestPath) {
const manifest = loadFixture(manifestPath);
const out = manifest.out;
if (!out) throw new Error("rollback manifest 缺少 out");
const createdFiles = Array.isArray(manifest.createdFiles) ? manifest.createdFiles : [];
createdFiles
.slice()
.reverse()
.forEach((relativePath) => {
const target = path.join(out, normalizeRelativePath(relativePath));
if (fs.existsSync(target)) fs.rmSync(target, { force: true });
});
const backupFiles = Array.isArray(manifest.backupFiles) ? manifest.backupFiles : [];
backupFiles.forEach((backup) => {
if (!backup || !backup.relativePath || !backup.backupPath) return;
const target = path.join(out, normalizeRelativePath(backup.relativePath));
ensureDir(path.dirname(target));
fs.copyFileSync(backup.backupPath, target);
});
console.log(JSON.stringify({ ok: true, rollback: manifestPath, removed: createdFiles.length }, null, 2));
}
function main() {
const args = parseArgs(process.argv.slice(2));
if (args.rollback) {
rollbackManifest(args.rollback);
return;
}
const fixture = loadFixture(args.fixture);
const plan = buildMigrationPlan(fixture, args.out);
const conflicts = detectConflicts(args.out, plan.operations);
const manifest = manifestForPlan(args.out, plan, args.dryRun, conflicts);
const manifestPath = args.manifest || path.join(args.out, ".mnote", "migration-manifest.json");
writeConflictReport(args.conflictReport, args.out, conflicts);
if (args.dryRun) {
manifest.indexRefresh.status = "planned";
writeJson(manifestPath, manifest);
console.log(JSON.stringify({ ok: true, dryRun: true, out: args.out, conflicts: conflicts.length }, null, 2));
return;
}
if (conflicts.length) {
writeJson(manifestPath, manifest);
console.error(JSON.stringify({ ok: false, code: "migration_conflict", conflicts }, null, 2));
process.exit(2);
}
applyPlan(args.out, plan, manifest);
writeJson(manifestPath, manifest);
if (path.resolve(manifestPath) !== path.resolve(path.join(args.out, ".mnote", "migration-manifest.json"))) {
writeJson(path.join(args.out, ".mnote", "migration-manifest.json"), manifest);
}
console.log(JSON.stringify({ ok: true, out: args.out, manifest: manifestPath }, null, 2));
}
if (require.main === module) {
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
}
}
@@ -1,101 +0,0 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const repoRoot = path.resolve(__dirname, "..", "..", "..");
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-convex-export-"));
const fixturePath = path.join(tmpRoot, "fixture.json");
const outRoot = path.join(tmpRoot, "local-workspace");
const fixture = {
workspace: {
id: "ws_legacy_1",
name: "旧云端空间",
ownerId: "user_1",
},
documents: [
{
id: "doc_root",
title: "Project",
parent_id: null,
sort_order: 1,
content: "# Project\n\n![logo](/api/media/sign?assetId=asset_logo)\n",
wide_layout: true,
created_at: "2026-05-01T00:00:00.000Z",
updated_at: "2026-05-02T00:00:00.000Z",
},
{
id: "doc_child",
title: "Child Spec",
parent_id: "doc_root",
sort_order: 1,
content: "Child body with [file](/api/media/sign?assetId=asset_pdf).\n",
created_at: "2026-05-01T00:00:00.000Z",
updated_at: "2026-05-02T00:00:00.000Z",
},
],
mediaAssets: [
{
id: "asset_logo",
fileName: "logo.png",
contentBase64: Buffer.from("PNG-FIXTURE").toString("base64"),
},
{
id: "asset_pdf",
fileName: "spec.pdf",
content: "PDF-FIXTURE",
},
],
aiSessions: [
{
sessionId: "sess_1",
visibility: "private",
events: [
{ eventType: "session.created", sessionId: "sess_1", userId: "user_1" },
{ eventType: "run.completed", sessionId: "sess_1", status: "completed" },
],
},
],
};
fs.writeFileSync(fixturePath, JSON.stringify(fixture, null, 2), "utf8");
const result = spawnSync(
process.execPath,
[
path.join(__dirname, "export-convex-workspace-to-local.js"),
"--fixture",
fixturePath,
"--out",
outRoot,
],
{ cwd: repoRoot, encoding: "utf8" },
);
assert.equal(result.status, 0, result.stderr || result.stdout);
const projectMd = fs.readFileSync(path.join(outRoot, "pages", "Project.md"), "utf8");
const childMd = fs.readFileSync(path.join(outRoot, "pages", "Project", "Child Spec.md"), "utf8");
const pageIds = JSON.parse(fs.readFileSync(path.join(outRoot, ".mnote", "page-ids.json"), "utf8"));
const pageOptions = JSON.parse(fs.readFileSync(path.join(outRoot, ".mnote", "page-options.json"), "utf8"));
const resourceIndex = JSON.parse(fs.readFileSync(path.join(outRoot, ".mnote", "resource-index.json"), "utf8"));
const sessionJsonl = fs.readFileSync(path.join(outRoot, "ai-sessions", "private", "sess_1.jsonl"), "utf8");
assert.match(projectMd, /mnote_id: doc_root/);
assert.match(projectMd, /!\[logo\]\(Project\.assets\/logo\.png\)/);
assert.match(childMd, /\[file\]\(Child Spec\.assets\/spec\.pdf\)/);
assert.equal(pageIds.pages["pages/Project.md"], "local-mdid:doc_root");
assert.equal(pageIds.pages["pages/Project/Child Spec.md"], "local-mdid:doc_child");
assert.equal(pageOptions.pages["local-mdid:doc_root"].wideLayout, true);
assert.equal(resourceIndex.assets.asset_logo.relativePath, "pages/Project.assets/logo.png");
assert.match(sessionJsonl, /"eventType":"run.completed"/);
assert.equal(fs.readFileSync(path.join(outRoot, "pages", "Project.assets", "logo.png"), "utf8"), "PNG-FIXTURE");
assert.equal(fs.readFileSync(path.join(outRoot, "pages", "Project", "Child Spec.assets", "spec.pdf"), "utf8"), "PDF-FIXTURE");
fs.rmSync(tmpRoot, { recursive: true, force: true });
console.log(JSON.stringify({ ok: true, smoke: "task444-convex-workspace-export-local-fixture" }, null, 2));
@@ -1,108 +0,0 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const repoRoot = path.resolve(__dirname, "..", "..", "..");
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-convex-export-plan-"));
const fixturePath = path.join(tmpRoot, "fixture.json");
const outRoot = path.join(tmpRoot, "local-workspace");
const manifestPath = path.join(tmpRoot, "migration-manifest.json");
const conflictPath = path.join(tmpRoot, "conflicts.json");
const fixture = {
workspace: {
id: "ws_legacy_plan",
name: "迁移计划空间",
ownerId: "user_plan",
},
documents: [
{
id: "doc_root",
title: "Project",
parent_id: null,
content: "# Project\n\n导出正文\n",
wide_layout: true,
},
],
mediaAssets: [
{
id: "asset_logo",
fileName: "logo.txt",
documentId: "doc_root",
content: "LOGO-NEW",
},
],
aiSessions: [],
};
function runExport(args, expectedStatus = 0) {
const result = spawnSync(
process.execPath,
[path.join(__dirname, "export-convex-workspace-to-local.js"), ...args],
{ cwd: repoRoot, encoding: "utf8" },
);
assert.equal(result.status, expectedStatus, result.stderr || result.stdout);
return result;
}
try {
fs.writeFileSync(fixturePath, JSON.stringify(fixture, null, 2), "utf8");
runExport([
"--fixture",
fixturePath,
"--out",
outRoot,
"--dry-run",
"--manifest",
manifestPath,
"--conflict-report",
conflictPath,
]);
assert.equal(fs.existsSync(path.join(outRoot, "pages", "Project.md")), false, "dry run 不应写入页面文件");
const dryRunManifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
assert.equal(dryRunManifest.dryRun, true);
assert.equal(dryRunManifest.operations.some((item) => item.action === "create" && item.relativePath === "pages/Project.md"), true);
fs.mkdirSync(path.join(outRoot, "pages"), { recursive: true });
fs.writeFileSync(path.join(outRoot, "pages", "Project.md"), "原有内容\n", "utf8");
const conflict = runExport([
"--fixture",
fixturePath,
"--out",
outRoot,
"--manifest",
manifestPath,
"--conflict-report",
conflictPath,
], 2);
assert.match(conflict.stderr + conflict.stdout, /migration_conflict/);
assert.equal(fs.readFileSync(path.join(outRoot, "pages", "Project.md"), "utf8"), "原有内容\n");
const conflictReport = JSON.parse(fs.readFileSync(conflictPath, "utf8"));
assert.equal(conflictReport.conflicts.some((item) => item.relativePath === "pages/Project.md"), true);
fs.rmSync(outRoot, { recursive: true, force: true });
runExport([
"--fixture",
fixturePath,
"--out",
outRoot,
"--manifest",
manifestPath,
]);
assert.match(fs.readFileSync(path.join(outRoot, "pages", "Project.md"), "utf8"), /导出正文/);
assert.equal(fs.existsSync(path.join(outRoot, ".mnote", "migration-manifest.json")), true);
runExport(["--rollback", path.join(outRoot, ".mnote", "migration-manifest.json")]);
assert.equal(fs.existsSync(path.join(outRoot, "pages", "Project.md")), false, "rollback 应删除本次新增页面");
assert.equal(fs.existsSync(path.join(outRoot, "pages", "Project.assets", "logo.txt")), false, "rollback 应删除本次新增资源");
console.log(JSON.stringify({ ok: true, smoke: "task455-convex-export-plan-rollback" }, null, 2));
} finally {
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
@@ -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);
});