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:
-458
@@ -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);
|
||||
}
|
||||
}
|
||||
-101
@@ -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\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));
|
||||
-108
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user