Files
mnote/recycle/scripts/retired-ui-regressions/task022-onlyoffice-ui-regression.js
T
lix-2026 47e224d419 chore: align sqlite control plane architecture
- replace default Convex control-plane wording with Rust SQLite control-plane across architecture, AGENTS, Reasonix, and design docs

- retire root Convex functions source and deploy script into recycle while keeping explicit cloud/compat/sync-replica boundaries

- add control-plane migration guard/docs and keep CodeGraph refreshed after the SQLite control-plane cutover
2026-05-22 17:45:22 +08:00

415 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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);
});