415 lines
14 KiB
JavaScript
415 lines
14 KiB
JavaScript
"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);
|
||
});
|