#!/usr/bin/env node "use strict"; const { loginViaAuthForm } = require('./lib/browser-auth-login'); const assert = require("node:assert"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const { chromium } = require("playwright"); const TASK = "task518-onlyoffice-real-iframe-session-scope-smoke"; const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 120_000); const PROBE_DOCX_PATH = process.env.MNOTE_ONLYOFFICE_PROBE_DOCX || "/tmp/mnote-onlyoffice-manual/mnote-onlyoffice-upload-test.docx"; const OUT_DIR = path.join(process.cwd(), "tmp", TASK); const SCREENSHOT_DIR = path.join(OUT_DIR, "screenshots"); const RESULT_PATH = path.join(OUT_DIR, "result.json"); const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"] .find((candidate) => fs.existsSync(candidate)); function fileUrl(localPath) { return `file://${localPath}`; } function localOfficeFileUrl(root, relativePath) { const url = new URL(`${BASE_URL}/api/local-folder/files/open`); url.searchParams.set("rootUri", fileUrl(root)); url.searchParams.set("path", relativePath); return url.toString(); } function onlyofficeUrl(root, relativePath, assetId) { const url = new URL(`${BASE_URL}/onlyoffice`); url.searchParams.set("fileUrl", localOfficeFileUrl(root, relativePath)); url.searchParams.set("fileName", path.basename(relativePath)); url.searchParams.set("fileType", "docx"); url.searchParams.set("assetId", assetId); url.searchParams.set("documentId", "local-md:Page~2FPage.md"); url.searchParams.set("mode", "edit"); return url.toString(); } function bridgeToolPayload(sessionId, assetId, allowedResourceIds, toolName = "mnote.onlyoffice.document.insert_text", options = {}) { return { toolName, workspaceId: "local-ws:task518", documentId: "local-md:Page~2FPage.md", actorId: "user_1", sessionId: `task518-${Date.now()}-${Math.random().toString(36).slice(2)}`, runId: "task518-run", toolCallId: "task518-call", traceId: "task518-trace", idempotencyKey: `task518-${Date.now()}-${Math.random().toString(36).slice(2)}`, dryRun: options.dryRun !== false, capabilityScope: ["office.write"], args: { onlyofficeSessionId: sessionId, text: options.text || "task518 dry run", aiAccessScope: { permissionLevel: "read_write", allowedResourceIds, }, }, }; } async function quickLogin(page) { // 7-76 P0: 标准表单登录(无测试快速登录按钮) const base = (typeof BASE_URL !== "undefined" && BASE_URL) || (typeof baseUrl !== "undefined" && baseUrl) || process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000"; const timeout = (typeof UI_TIMEOUT_MS !== "undefined" && UI_TIMEOUT_MS) || (typeof TIMEOUT !== "undefined" && TIMEOUT) || 30_000; if (!String(page.url() || "").includes("/auth")) { await page.goto(String(base).replace(/\/+$/, "") + "/auth", { waitUntil: "commit", timeout, }); } await loginViaAuthForm(page, { baseUrl: base, timeoutMs: timeout, gotoAuth: false, }); await page .waitForURL((url) => !String(url).includes("/auth"), { timeout }) .catch(() => {}); } async function waitForOfficeReady(page, label) { await page.waitForFunction( () => { const debug = window.__MNOTE_ONLYOFFICE_DEBUG__ || null; const frameCount = document.querySelectorAll("#onlyoffice-frame iframe, #onlyoffice-frame canvas").length; return Boolean(debug && debug.bridgeSessionId && debug.bridgeToken) && Boolean(window.__MNOTE_ONLYOFFICE_EDITOR__) && (Boolean(window.__MNOTE_ONLYOFFICE_READY__) || frameCount > 0); }, null, { timeout: UI_TIMEOUT_MS }, ); await page.screenshot({ path: path.join(SCREENSHOT_DIR, `${label}.png`), fullPage: true }); return await page.evaluate(() => ({ url: location.href, ready: Boolean(window.__MNOTE_ONLYOFFICE_READY__), editorExists: Boolean(window.__MNOTE_ONLYOFFICE_EDITOR__), frameCount: document.querySelectorAll("#onlyoffice-frame iframe, #onlyoffice-frame canvas").length, errorVisible: document.getElementById("onlyoffice-error")?.getAttribute("data-visible") || "", debug: window.__MNOTE_ONLYOFFICE_DEBUG__ || null, errorLog: Array.isArray(window.__MNOTE_ONLYOFFICE_ERRLOG__) ? window.__MNOTE_ONLYOFFICE_ERRLOG__.slice() : [], })); } async function readBridgeSessions(context) { const response = await context.request.fetch(`${BASE_URL}/api/onlyoffice/bridge/sessions`, { method: "GET", timeout: UI_TIMEOUT_MS, }); const body = await response.json().catch(async () => ({ raw: await response.text() })); return { status: response.status(), body, }; } async function waitForBridgeSession(context, sessionId) { const deadline = Date.now() + UI_TIMEOUT_MS; let last = null; while (Date.now() < deadline) { last = await readBridgeSessions(context); if ( last.status === 200 && Array.isArray(last.body?.sessions) && last.body.sessions.some((session) => session.sessionId === sessionId) ) { return last; } await new Promise((resolve) => setTimeout(resolve, 500)); } throw new Error(`bridge session 未注册: ${sessionId}; last=${JSON.stringify(last)}`); } async function runBridgeSelection(page) { return await page.evaluate(async () => { return await window.__MNOTE_ONLYOFFICE_BRIDGE__.run("selection.get", {}, 30_000); }); } async function runBridgeExport(page, format = "html") { return await page.evaluate(async (selectedFormat) => { return await window.__MNOTE_ONLYOFFICE_BRIDGE__.run("document.export", { format: selectedFormat }, 30_000); }, format); } async function postToolCall(context, payload) { const response = await context.request.fetch(`${BASE_URL}/api/mnote/tools/call`, { method: "POST", headers: { "content-type": "application/json", "x-mnote-actor-id": "user_1", }, data: payload, timeout: UI_TIMEOUT_MS, }); const body = await response.json().catch(async () => ({ raw: await response.text() })); return { status: response.status(), headers: response.headers(), body, }; } function classifyOnlyOfficeSignals(result, states, selections, scopeChecks) { const bridgeTranslation404 = result.httpErrors.filter((entry) => ( /\/api\/onlyoffice\/bridge\/plugin\/index\/translations\//.test(entry.url) && entry.status === 404 )); const mnoteBridgePluginHttpErrors = result.httpErrors.filter((entry) => ( /\/api\/onlyoffice\/bridge\/plugin\//.test(entry.url) && !/\/api\/onlyoffice\/bridge\/plugin\/index\/translations\//.test(entry.url) )); const builtinPluginNoise = result.httpErrors.filter((entry) => ( /\/sdkjs-plugins\/|custom.?assistant|annotation/i.test(entry.url) )); const bridgeConsoleNoise = result.consoleErrors.filter((entry) => ( /\/api\/onlyoffice\/bridge\/plugin\/index\/translations\//.test(entry.text) || /Failed to load resource: the server responded with a status of 404/i.test(entry.text) )); const errorCodeMinus18 = [ ...result.consoleErrors.map((entry) => entry.text || ""), ...states.flatMap((state) => Array.isArray(state.errorLog) ? state.errorLog.map((item) => JSON.stringify(item)) : []), ].some((text) => /errorCode["'=:\s-]*-18|\b-18\b/.test(text)); const mainDocumentFailures = [ ...result.networkFailures.filter((entry) => ( !/\/api\/onlyoffice\/bridge\/commands\/next/.test(entry.url) )), ...result.httpErrors.filter((entry) => { if (/\/api\/onlyoffice\/bridge\/plugin\/index\/translations\//.test(entry.url)) return false; if (/\/sdkjs-plugins\/|custom.?assistant|annotation/i.test(entry.url)) return false; if (/\/api\/onlyoffice\/bridge\/plugin\//.test(entry.url)) return false; if (/\/api\/onlyoffice\/bridge\/commands\/next/.test(entry.url)) return false; return /\/onlyoffice-server\/|\/api\/onlyoffice\/proxy|\/api\/local-folder\/files\/open/.test(entry.url); }), ...states.filter((state) => state.errorVisible || !state.editorExists || !state.ready).map((state) => ({ url: state.url, ready: state.ready, editorExists: state.editorExists, errorVisible: state.errorVisible, })), ]; return { mnoteBridgePlugin: { configUrls: states.map((state) => state.debug?.bridgePluginConfigUrl || ""), httpErrors: mnoteBridgePluginHttpErrors, sessionsRegistered: states.every((state) => ( result.bridgeSessionsFinal?.body?.sessions || [] ).some((session) => session.sessionId === state.debug?.bridgeSessionId)), commandLoopOk: scopeChecks?.allowedBDryRun?.status === 200 && scopeChecks?.allowedBWrite?.status === 200, }, bridgeTranslationNoise: { count: bridgeTranslation404.length, urls: Array.from(new Set(bridgeTranslation404.map((entry) => entry.url))), }, builtinPluginNoise: { count: builtinPluginNoise.length, urls: Array.from(new Set(builtinPluginNoise.map((entry) => entry.url))), }, mainDocument: { ready: mainDocumentFailures.length === 0 && selections.every((selection) => selection?.editorType === "word"), failures: mainDocumentFailures, errorCodeMinus18, }, consoleNoise: { bridgeTranslationOrGeneric404Count: bridgeConsoleNoise.length, }, }; } async function main() { assert(fs.existsSync(PROBE_DOCX_PATH), `缺少探测 docx: ${PROBE_DOCX_PATH}`); fs.mkdirSync(SCREENSHOT_DIR, { recursive: true }); const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task518-onlyoffice-")); fs.mkdirSync(path.join(root, "Page"), { recursive: true }); fs.writeFileSync(path.join(root, ".mnote-placeholder"), "task518\n", "utf8"); fs.copyFileSync(PROBE_DOCX_PATH, path.join(root, "Page", "office-a.docx")); fs.copyFileSync(PROBE_DOCX_PATH, path.join(root, "Page", "office-b.docx")); const consoleErrors = []; const networkFailures = []; const httpErrors = []; const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1", executablePath: CHROMIUM_EXECUTABLE_PATH, }); const context = await browser.newContext({ viewport: { width: 1440, height: 900 } }); const authPage = await context.newPage(); const result = { ok: false, task: TASK, baseUrl: BASE_URL, root, screenshots: [], consoleErrors, networkFailures, httpErrors, }; try { await quickLogin(authPage); await authPage.close(); const pageA = await context.newPage(); const pageADuplicate = await context.newPage(); const pageB = await context.newPage(); for (const page of [pageA, pageADuplicate, pageB]) { page.on("console", (message) => { if (message.type() === "error") consoleErrors.push({ url: page.url(), text: message.text() }); }); page.on("requestfailed", (request) => { networkFailures.push({ url: request.url(), failure: request.failure()?.errorText || "" }); }); page.on("response", (response) => { if (response.status() >= 400) { httpErrors.push({ url: response.url(), status: response.status() }); } }); } const assetA = "local-file:Page/office-a.docx"; const assetB = "local-file:Page/office-b.docx"; await pageA.goto(onlyofficeUrl(root, "Page/office-a.docx", assetA), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS, }); await pageADuplicate.goto(onlyofficeUrl(root, "Page/office-a.docx", assetA), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS, }); await pageB.goto(onlyofficeUrl(root, "Page/office-b.docx", assetB), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS, }); const stateA = await waitForOfficeReady(pageA, "office-a"); const stateADuplicate = await waitForOfficeReady(pageADuplicate, "office-a-duplicate"); const stateB = await waitForOfficeReady(pageB, "office-b"); result.screenshots.push(path.join(SCREENSHOT_DIR, "office-a.png")); result.screenshots.push(path.join(SCREENSHOT_DIR, "office-a-duplicate.png")); result.screenshots.push(path.join(SCREENSHOT_DIR, "office-b.png")); assert.equal(stateA.errorVisible, "", `A 页面不应显示错误: ${JSON.stringify(stateA)}`); assert.equal(stateADuplicate.errorVisible, "", `A duplicate 页面不应显示错误: ${JSON.stringify(stateADuplicate)}`); assert.equal(stateB.errorVisible, "", `B 页面不应显示错误: ${JSON.stringify(stateB)}`); assert(stateA.debug.bridgeSessionId !== stateB.debug.bridgeSessionId, `A/B sessionId 不应相同: ${JSON.stringify({ a: stateA.debug, b: stateB.debug })}`); assert( stateA.debug.bridgeSessionId !== stateADuplicate.debug.bridgeSessionId, `同一 Office 文档双 tab sessionId 不应相同: ${JSON.stringify({ a: stateA.debug, duplicate: stateADuplicate.debug })}`, ); assert.equal(stateA.debug.docKey, stateADuplicate.debug.docKey, `同一 Office 文档双 tab 应共享 docKey 但使用不同 session salt: ${JSON.stringify({ a: stateA.debug, duplicate: stateADuplicate.debug })}`); assert.equal(stateA.debug.assetId, assetA, `A debug assetId 不匹配: ${JSON.stringify(stateA.debug)}`); assert.equal(stateADuplicate.debug.assetId, assetA, `A duplicate debug assetId 不匹配: ${JSON.stringify(stateADuplicate.debug)}`); assert.equal(stateB.debug.assetId, assetB, `B debug assetId 不匹配: ${JSON.stringify(stateB.debug)}`); result.officeAInitial = stateA; result.officeADuplicateInitial = stateADuplicate; result.officeBInitial = stateB; result.bridgeSessionsAfterReady = await readBridgeSessions(context); await waitForBridgeSession(context, stateA.debug.bridgeSessionId); await waitForBridgeSession(context, stateADuplicate.debug.bridgeSessionId); await waitForBridgeSession(context, stateB.debug.bridgeSessionId); result.bridgeSessionsFinal = await readBridgeSessions(context); const selectionA = await runBridgeSelection(pageA); const selectionADuplicate = await runBridgeSelection(pageADuplicate); const selectionB = await runBridgeSelection(pageB); assert.equal(selectionA.editorType, "word", `A selection 应来自 word editor: ${JSON.stringify(selectionA)}`); assert.equal(selectionADuplicate.editorType, "word", `A duplicate selection 应来自 word editor: ${JSON.stringify(selectionADuplicate)}`); assert.equal(selectionB.editorType, "word", `B selection 应来自 word editor: ${JSON.stringify(selectionB)}`); const forbiddenBFromScopeA = await postToolCall( context, bridgeToolPayload(stateB.debug.bridgeSessionId, assetB, [assetA, `resource:onlyoffice:${stateA.debug.documentId}:${assetA}`]), ); assert.equal(forbiddenBFromScopeA.status, 403, JSON.stringify(forbiddenBFromScopeA)); assert.equal(forbiddenBFromScopeA.headers["x-error-code"], "mnote_onlyoffice_resource_scope_forbidden"); const allowedBDryRun = await postToolCall( context, bridgeToolPayload(stateB.debug.bridgeSessionId, assetB, [assetB, `resource:onlyoffice:${stateB.debug.documentId}:${assetB}`]), ); assert.equal(allowedBDryRun.status, 200, JSON.stringify(allowedBDryRun)); assert.equal(allowedBDryRun.body.result.schema, "mnote.onlyoffice.action_plan.v1"); assert.equal(allowedBDryRun.body.result.sessionId, stateB.debug.bridgeSessionId); const writeMarker = `task518-write-${Date.now()}-${Math.random().toString(36).slice(2)}`; const allowedBWrite = await postToolCall( context, bridgeToolPayload( stateB.debug.bridgeSessionId, assetB, [assetB, `resource:onlyoffice:${stateB.debug.documentId}:${assetB}`], "mnote.onlyoffice.document.insert_text", { dryRun: false, text: writeMarker }, ), ); assert.equal(allowedBWrite.status, 200, JSON.stringify(allowedBWrite)); assert.equal(allowedBWrite.body.result.schema, "mnote.onlyoffice.action_result.v1"); assert.equal(allowedBWrite.body.result.sessionId, stateB.debug.bridgeSessionId); await pageA.screenshot({ path: path.join(SCREENSHOT_DIR, "office-a-after-write.png"), fullPage: true }); await pageB.screenshot({ path: path.join(SCREENSHOT_DIR, "office-b-after-write.png"), fullPage: true }); result.screenshots.push(path.join(SCREENSHOT_DIR, "office-a-after-write.png")); result.screenshots.push(path.join(SCREENSHOT_DIR, "office-b-after-write.png")); const exportAAfterWrite = await runBridgeExport(pageA, "html"); const exportBAfterWrite = await runBridgeExport(pageB, "html"); const exportAText = String(exportAAfterWrite?.content || exportAAfterWrite?.result?.content || ""); const exportBText = String(exportBAfterWrite?.content || exportBAfterWrite?.result?.content || ""); assert( exportBText.includes(writeMarker), `B 导出内容应包含写入标记 ${writeMarker}: ${JSON.stringify(exportBAfterWrite).slice(0, 1000)}`, ); assert( !exportAText.includes(writeMarker), `A 导出内容不应包含 B 写入标记 ${writeMarker}: ${JSON.stringify(exportAAfterWrite).slice(0, 1000)}`, ); const scopeChecks = { forbiddenBFromScopeA: { status: forbiddenBFromScopeA.status, code: forbiddenBFromScopeA.headers["x-error-code"], }, allowedBDryRun: { status: allowedBDryRun.status, schema: allowedBDryRun.body.result.schema, sessionId: allowedBDryRun.body.result.sessionId, }, allowedBWrite: { status: allowedBWrite.status, schema: allowedBWrite.body.result.schema, sessionId: allowedBWrite.body.result.sessionId, commandId: allowedBWrite.body.result.commandId, }, exportAfterWrite: { writeMarker, aContainsMarker: exportAText.includes(writeMarker), bContainsMarker: exportBText.includes(writeMarker), }, }; result.bridgeSessionsFinal = await readBridgeSessions(context); const errorClassification = classifyOnlyOfficeSignals( result, [stateA, stateADuplicate, stateB], [selectionA, selectionADuplicate, selectionB], scopeChecks, ); assert.equal( errorClassification.mnoteBridgePlugin.httpErrors.length, 0, `MNote bridge 插件 config/index 失败不能归为普通噪音: ${JSON.stringify(errorClassification.mnoteBridgePlugin.httpErrors)}`, ); assert.equal( errorClassification.mnoteBridgePlugin.sessionsRegistered, true, `MNote bridge 插件应完成 session 注册: ${JSON.stringify(errorClassification.mnoteBridgePlugin)}`, ); assert.equal( errorClassification.mnoteBridgePlugin.commandLoopOk, true, `MNote bridge 插件 command loop 应可用: ${JSON.stringify(errorClassification.mnoteBridgePlugin)}`, ); assert.equal( errorClassification.mainDocument.errorCodeMinus18, false, `errorCode=-18 必须作为主文档失败暴露: ${JSON.stringify(errorClassification.mainDocument)}`, ); assert.equal( errorClassification.mainDocument.ready, true, `主文档失败不能被插件噪音吞掉: ${JSON.stringify(errorClassification.mainDocument)}`, ); Object.assign(result, { ok: true, errorClassification, officeA: { sessionId: stateA.debug.bridgeSessionId, assetId: stateA.debug.assetId, docKey: stateA.debug.docKey, frameCount: stateA.frameCount, selection: selectionA, }, officeADuplicate: { sessionId: stateADuplicate.debug.bridgeSessionId, assetId: stateADuplicate.debug.assetId, docKey: stateADuplicate.debug.docKey, frameCount: stateADuplicate.frameCount, selection: selectionADuplicate, }, officeB: { sessionId: stateB.debug.bridgeSessionId, assetId: stateB.debug.assetId, docKey: stateB.debug.docKey, frameCount: stateB.frameCount, selection: selectionB, }, scopeChecks, }); fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8"); console.log(JSON.stringify(result, null, 2)); } catch (error) { result.error = error && error.stack ? error.stack : String(error); fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8"); throw error; } finally { await context.close().catch(() => undefined); await browser.close().catch(() => undefined); } } main().catch((error) => { console.error(error && error.stack ? error.stack : error); process.exit(1); });