"use strict"; // 说明: // - 验证 Rust 3000 主入口的 Convex 附件行打开 OnlyOffice 链路。 // - 脚本会创建临时页面、上传临时 docx、点击文件树附件行并断言新窗口打开 /onlyoffice。 // - 结束后清理临时页面,避免污染长期测试空间。 const fs = require("node:fs"); const { chromium } = require("playwright"); const { BASE_URL, UI_TIMEOUT_MS, assert, createTempDocument, ensureAuthenticated, openDocument, openFilesystemView, purgeDocument, } = require("./tree-shell-smoke-helpers"); const DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; const ONLYOFFICE_READY_TIMEOUT_MS = Number(process.env.MNOTE_ONLYOFFICE_READY_TIMEOUT_MS || 120_000); const PROBE_DOCX_PATH = process.env.MNOTE_ONLYOFFICE_PROBE_DOCX || "/tmp/mnote-onlyoffice-manual/mnote-onlyoffice-upload-test.docx"; const TEST_EMAIL = "mnote.e2e@example.com"; const TEST_PASSWORD = "MnoteE2E123!"; let authCookieHeader = ""; const popupConsoleMessages = []; const popupNetworkFailures = []; function parseSetCookie(setCookie, origin) { const [nameValue] = String(setCookie || "").split(";"); const separator = nameValue.indexOf("="); if (separator <= 0) return null; return { name: nameValue.slice(0, separator).trim(), value: nameValue.slice(separator + 1).trim(), domain: new URL(origin).hostname, path: "/", httpOnly: /;\s*httponly\b/i.test(setCookie), sameSite: "Lax", }; } async function forceTestAccountLogin(context) { const response = await context.request.fetch(`${BASE_URL}/api/auth`, { method: "POST", data: { action: "auth:signIn", args: { provider: "password", params: { email: TEST_EMAIL, password: TEST_PASSWORD, flow: "signIn", }, }, }, headers: { "content-type": "application/json" }, timeout: UI_TIMEOUT_MS, }); const payload = await response.json().catch(async () => ({ raw: await response.text() })); assert(response.ok(), `测试账号登录失败:${response.status()} ${JSON.stringify(payload)}`); const cookies = response .headersArray() .filter((header) => header.name.toLowerCase() === "set-cookie") .map((header) => parseSetCookie(header.value, BASE_URL)) .filter(Boolean); assert(cookies.length > 0, "测试账号登录响应缺少 set-cookie"); await context.addCookies(cookies); authCookieHeader = cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join("; "); } async function uploadProbeDocx(requestContext, target) { assert(fs.existsSync(PROBE_DOCX_PATH), `缺少探测文件:${PROBE_DOCX_PATH}`); const buffer = fs.readFileSync(PROBE_DOCX_PATH); const response = await requestContext.fetch(`${BASE_URL}/api/media/upload`, { method: "POST", headers: authCookieHeader ? { cookie: authCookieHeader } : undefined, multipart: { file: { name: "task174-onlyoffice-attachment.docx", mimeType: DOCX_MIME, buffer, }, workspaceId: target.workspaceId, documentId: target.documentId, }, timeout: UI_TIMEOUT_MS, }); const payload = await response.json().catch(async () => ({ raw: await response.text() })); assert( response.ok(), `/api/media/upload 请求失败:${response.status()} ${JSON.stringify(payload)}`, ); assert( payload && payload.asset && typeof payload.asset.id === "string", `上传结果缺少 asset.id:${JSON.stringify(payload)}`, ); return payload.asset; } async function waitForAssetRow(page, assetId) { const selector = `[data-testid="filetree-asset-row"][data-asset-id="${assetId}"]`; const row = page.locator(selector).first(); await row.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); return row; } async function waitForOnlyOfficeReady(page) { page.on("console", (message) => { popupConsoleMessages.push({ type: message.type(), text: message.text().slice(0, 1000), }); }); page.on("pageerror", (error) => { popupConsoleMessages.push({ type: "pageerror", text: error.message.slice(0, 1000), }); }); page.on("response", (response) => { if (response.status() >= 400) { popupNetworkFailures.push({ status: response.status(), url: response.url().slice(0, 1000), }); } }); try { await page.waitForFunction( () => window.__MNOTE_ONLYOFFICE_READY__ === true, undefined, { timeout: ONLYOFFICE_READY_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")); }, undefined, { timeout: ONLYOFFICE_READY_TIMEOUT_MS }, ); } catch (error) { const debug = await page.evaluate(() => ({ url: window.location.href, title: document.title, readyState: document.readyState, ready: window.__MNOTE_ONLYOFFICE_READY__ === true, debug: window.__MNOTE_ONLYOFFICE_DEBUG__ || null, errors: window.__MNOTE_ONLYOFFICE_ERRLOG__ || [], hasFrameRoot: Boolean(document.getElementById("onlyoffice-frame")), bodyText: document.body ? document.body.innerText.slice(0, 800) : "", html: document.body ? document.body.innerHTML.slice(0, 1600) : "", scriptCount: document.scripts.length, frameCount: document.querySelectorAll("iframe,canvas").length, hasFrameRoot: Boolean(document.getElementById("onlyoffice-frame")), })).catch((debugError) => ({ evaluateError: debugError instanceof Error ? debugError.message : String(debugError), isClosed: page.isClosed(), })); debug.consoleMessages = popupConsoleMessages.slice(-20); debug.networkFailures = popupNetworkFailures.slice(-30); throw new Error( `OnlyOffice ready 超时:${error instanceof Error ? error.message : String(error)} ${JSON.stringify(debug)}`, ); } } async function readOnlyOfficeDebug(page) { return await page.evaluate(() => { const debug = window.__MNOTE_ONLYOFFICE_DEBUG__ || null; return { debug, docKey: debug && typeof debug.docKey === "string" ? debug.docKey : "", ready: window.__MNOTE_ONLYOFFICE_READY__ === true, frameCount: document.querySelectorAll("iframe,canvas").length, }; }); } async function main() { const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1" }); const context = await browser.newContext(); const page = await context.newPage(); let target = null; try { await forceTestAccountLogin(context); await ensureAuthenticated(page, context.request); target = await createTempDocument(context.request, null); const asset = await uploadProbeDocx(context.request, target); await openDocument(page, target.workspaceId, target.documentId); await openFilesystemView(page); const row = await waitForAssetRow(page, asset.id); const [popup] = await Promise.all([ page.waitForEvent("popup", { timeout: UI_TIMEOUT_MS }), row.locator('[data-rust-action="open"]').first().click({ timeout: UI_TIMEOUT_MS }), ]); await popup.waitForLoadState("domcontentloaded", { timeout: UI_TIMEOUT_MS }).catch(() => undefined); const opened = new URL(popup.url()); assert(opened.pathname === "/onlyoffice", `附件应在新窗口打开 /onlyoffice,实际为:${popup.url()}`); assert(opened.searchParams.get("assetId") === asset.id, "OnlyOffice URL 缺少正确 assetId"); assert( opened.searchParams.get("documentId") === target.documentId, "OnlyOffice URL 缺少正确 documentId", ); assert(opened.searchParams.get("fileType") === "docx", "OnlyOffice URL fileType 应为 docx"); assert(opened.searchParams.get("mode") === "edit", "OnlyOffice URL mode 应为 edit"); await waitForOnlyOfficeReady(popup); const firstDebug = await readOnlyOfficeDebug(popup); assert(firstDebug.ready, "OnlyOffice ready flag 应为 true"); assert(firstDebug.frameCount > 0, "OnlyOffice 应创建 iframe/canvas"); assert( /^[0-9A-Za-z_.=-]{1,128}$/.test(firstDebug.docKey), `OnlyOffice document.key 不符合安全字符集或长度:${firstDebug.docKey}`, ); assert( firstDebug.docKey === asset.id || firstDebug.docKey.startsWith(`${asset.id}_`), `OnlyOffice document.key 应绑定 assetId,实际为:${firstDebug.docKey}`, ); const reopened = await context.newPage(); await reopened.goto(popup.url(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await waitForOnlyOfficeReady(reopened); const secondDebug = await readOnlyOfficeDebug(reopened); assert( secondDebug.docKey === firstDebug.docKey, `同一附件重复打开 document.key 应稳定:first=${firstDebug.docKey} second=${secondDebug.docKey}`, ); await reopened.close().catch(() => undefined); console.log( JSON.stringify( { ok: true, documentId: target.documentId, workspaceId: target.workspaceId, assetId: asset.id, openedUrl: popup.url(), docKey: firstDebug.docKey, }, null, 2, ), ); } finally { if (target && target.documentId) { await purgeDocument(context.request, target.documentId).catch((error) => { console.warn(`清理临时页面失败:${error instanceof Error ? error.message : String(error)}`); }); } await context.close().catch(() => undefined); await browser.close().catch(() => undefined); } } main().catch((error) => { console.error(error); process.exit(1); });