"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 || ""}`); } 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); });