feat: 收口文档桥接与 OnlyOffice/Sidebar 回归

- 为 documents.save/meta/content、blocks.patch 与 sidebar.dataset.list 补齐 Rust 协议映射、共享契约与桥接执行器

- 对齐 BlockNote、Mindmap、OnlyOffice 的保存/路由元信息,并补真实浏览器回归脚本与 OnlyOffice 部署基线

- 忽略 Rust 本地构建产物与 Harness 调试状态文件,避免临时产物进入仓库历史
This commit is contained in:
lix-2026
2026-04-15 03:06:29 +08:00
parent 84a8454fa9
commit b33ffb99e7
51 changed files with 3260 additions and 379 deletions
+177
View File
@@ -0,0 +1,177 @@
"use strict";
// 说明:
// - 这是 task-019 的最小真实浏览器回归脚本。
// - 目标只覆盖文档页元信息、Sidebar、BlockNote 保存链,不扩大到 Mindmap / OnlyOffice。
// - 脚本会先创建一篇临时页面,完成回归后再彻底删除,避免污染现有数据。
const { chromium } = require("playwright");
const BASE_URL = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3001";
const REQUEST_TIMEOUT_MS = 20_000;
const UI_TIMEOUT_MS = 30_000;
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
async function requestJson(path, init = {}) {
const response = await fetch(`${BASE_URL}${path}`, {
...init,
headers: {
"content-type": "application/json",
...(init.headers || {}),
},
});
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() {
const payload = await requestJson("/api/documents/create", {
method: "POST",
body: JSON.stringify({ 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(documentId) {
await requestJson("/api/documents/purge", {
method: "POST",
body: JSON.stringify({ documentId }),
});
}
async function runBrowserRegression(target) {
const uniqueSuffix = Date.now().toString();
const nextTitle = `task019-ui-${uniqueSuffix}`;
const nextBody = `task019 正文保存回归 ${uniqueSuffix}`;
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({
viewport: { width: 1440, height: 960 },
});
try {
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,
};
} finally {
await page.close();
await browser.close();
}
}
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 tempDocument = await createTempDocument();
let regressionResult = null;
try {
regressionResult = await runBrowserRegression(tempDocument);
console.log(
JSON.stringify(
{
ok: true,
workspaceId: tempDocument.workspaceId,
documentId: tempDocument.documentId,
...regressionResult,
},
null,
2,
),
);
} finally {
await purgeTempDocument(tempDocument.documentId);
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
+373
View File
@@ -0,0 +1,373 @@
"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:3001";
const REQUEST_TIMEOUT_MS = 20_000;
const UI_TIMEOUT_MS = 30_000;
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
async function requestJson(path, init = {}) {
const response = await fetch(`${BASE_URL}${path}`, {
...init,
headers: {
"content-type": "application/json",
...(init.headers || {}),
},
});
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() {
const payload = await requestJson("/api/documents/create", {
method: "POST",
body: JSON.stringify({ 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(documentId, mindmapId) {
await requestJson(`/api/mindmap/${documentId}/${mindmapId}`, {
method: "POST",
body: JSON.stringify({
createOnly: true,
data: {
data: { text: "中心主题" },
children: [],
},
}),
});
}
async function cleanupTempMindmap(documentId, mindmapId) {
try {
await requestJson(`/api/mindmap/${documentId}/${mindmapId}`, {
method: "DELETE",
});
} catch {
// 忽略清理失败,继续尝试 purge 文档。
}
}
async function purgeTempDocument(documentId) {
await requestJson("/api/documents/purge", {
method: "POST",
body: JSON.stringify({ documentId }),
});
}
async function waitForMindmapInstance(page, mindmapId) {
await page.waitForFunction(
(id) => Boolean(window.__mindmapInstancesById?.[id] || window.__mindmapInstance),
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 },
);
}
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, childText) {
return page.evaluate(
({ currentMindmapId, nextChildText }) => {
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("插入子节点后未拿到快照");
}
snapshot.root.children[0].data.text = nextChildText;
window.__mindmapPersistById?.[currentMindmapId]?.(snapshot);
return {
childText: snapshot.root.children[0].data.text,
childCount: snapshot.root.children.length,
};
},
{
currentMindmapId: mindmapId,
nextChildText: childText,
},
);
}
async function persistDeleteChild(page, mindmapId) {
return page.evaluate((currentMindmapId) => {
const instance =
window.__mindmapInstancesById?.[currentMindmapId] ?? window.__mindmapInstance;
if (!instance) {
throw new Error("未找到 mindmap 实例");
}
const snapshot = instance.getData?.(true) ?? instance.getData?.() ?? null;
if (!snapshot?.root) {
throw new Error("删除子节点时未拿到快照");
}
snapshot.root.children = [];
window.__mindmapPersistById?.[currentMindmapId]?.(snapshot);
return {
childCount: snapshot.root.children.length,
};
}, mindmapId);
}
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(target) {
const uniqueSuffix = Date.now().toString();
const mindmapId = `task021-${uniqueSuffix}`;
const childText = `task021-节点-${uniqueSuffix}`;
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({
viewport: { width: 1440, height: 960 },
});
try {
await createTempMindmap(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);
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 insertSaveResponsePromise = page.waitForResponse(
(response) =>
response.url().includes(`/api/mindmap/${target.documentId}/${mindmapId}`) &&
response.request().method() === "POST" &&
response.status() === 200 &&
(response.request().postData() || "").includes(childText),
{ timeout: UI_TIMEOUT_MS },
);
const insertMutation = await persistInsertAndRename(page, mindmapId, childText);
assert(insertMutation.childCount === 1, `插入子节点后数量异常:${insertMutation.childCount}`);
assert(insertMutation.childText === childText, `插入子节点名称异常:${insertMutation.childText}`);
const insertSaveResponse = await insertSaveResponsePromise;
const insertSavePayload = await insertSaveResponse.json();
assertRouteMeta(insertSavePayload.meta, {
documentId: target.documentId,
mindmapId,
workspaceId: target.workspaceId,
});
await waitForMetaAttrs(page, insertSavePayload.meta);
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 openOutlinePanel(page);
await page.getByRole("button", { name: new RegExp(childText) }).waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
const insertSavedMindmap = await requestJson(`/api/mindmap/${target.documentId}/${mindmapId}`);
assert(
Array.isArray(insertSavedMindmap.data?.children) &&
insertSavedMindmap.data.children.length === 1,
"刷新后导图子节点数量不正确",
);
assert(
insertSavedMindmap.data.children[0]?.data?.text === childText,
`刷新后导图子节点名称不正确:${insertSavedMindmap.data.children[0]?.data?.text}`,
);
const deleteSaveResponsePromise = page.waitForResponse(
(response) =>
response.url().includes(`/api/mindmap/${target.documentId}/${mindmapId}`) &&
response.request().method() === "POST" &&
response.status() === 200 &&
!(response.request().postData() || "").includes(childText),
{ timeout: UI_TIMEOUT_MS },
);
const deleteMutation = await persistDeleteChild(page, mindmapId);
assert(deleteMutation.childCount === 0, `删除子节点后数量异常:${deleteMutation.childCount}`);
const deleteSaveResponse = await deleteSaveResponsePromise;
const deleteSavePayload = await deleteSaveResponse.json();
assertRouteMeta(deleteSavePayload.meta, {
documentId: target.documentId,
mindmapId,
workspaceId: target.workspaceId,
});
await waitForMetaAttrs(page, deleteSavePayload.meta);
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 openOutlinePanel(page);
const bodyText = await page.locator("body").innerText({ timeout: UI_TIMEOUT_MS });
assert(bodyText.includes("中心主题"), "刷新后未渲染根节点");
assert(!bodyText.includes(childText), "删除子节点后页面仍残留旧节点文本");
const deleteSavedMindmap = await requestJson(`/api/mindmap/${target.documentId}/${mindmapId}`);
assert(
Array.isArray(deleteSavedMindmap.data?.children) &&
deleteSavedMindmap.data.children.length === 0,
"删除子节点后后端仍保留子节点",
);
return {
mindmapUrl,
mindmapId,
childText,
initialMetaAttrs,
insertMeta: insertSavePayload.meta,
deleteMeta: deleteSavePayload.meta,
};
} finally {
await page.close();
await browser.close();
await cleanupTempMindmap(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 tempDocument = await createTempDocument();
let regressionResult = null;
try {
regressionResult = await runBrowserRegression(tempDocument);
console.log(
JSON.stringify(
{
ok: true,
workspaceId: tempDocument.workspaceId,
documentId: tempDocument.documentId,
...regressionResult,
},
null,
2,
),
);
} finally {
await purgeTempDocument(tempDocument.documentId);
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
+414
View File
@@ -0,0 +1,414 @@
"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:3001";
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);
});