#!/usr/bin/env node "use strict"; const assert = require("node:assert"); const { spawn } = require("node:child_process"); const net = require("node:net"); const { chromium } = require("playwright"); const UI_TIMEOUT_MS = Number(process.env.MNOTE_UI_TIMEOUT_MS || 45000); function findFreePort() { return new Promise((resolve, reject) => { const server = net.createServer(); server.listen(0, "127.0.0.1", () => { const port = server.address().port; server.close(() => resolve(port)); }); server.on("error", reject); }); } async function waitForGateway(baseUrl, timeoutMs = 120000) { const deadline = Date.now() + timeoutMs; let lastError = ""; while (Date.now() < deadline) { try { const response = await fetch(`${baseUrl}/`); if (response.status === 200 || response.status === 303) return; lastError = `${response.status} ${await response.text().catch(() => "")}`; } catch (error) { lastError = error && error.message ? error.message : String(error); } await new Promise((resolve) => setTimeout(resolve, 500)); } throw new Error(`mnote-web 未就绪: ${lastError}`); } function startGateway(port) { const env = { ...process.env, MNOTE_WEB_BIND: `127.0.0.1:${port}`, MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`, MNOTE_WEB_ALLOW_DEV_FIXTURES: "1", }; const child = spawn("cargo", ["run", "-q", "-p", "mnote-web", "--bin", "mnote-web"], { cwd: "/mnt/Data1T/mnote/rust", env, stdio: ["ignore", "pipe", "pipe"], }); let stderr = ""; child.stderr.on("data", (chunk) => { stderr += chunk.toString("utf8"); }); child.stdout.on("data", () => {}); return { child, stderr: () => stderr, }; } async function stopGateway(gateway) { if (!gateway || gateway.child.killed) return; gateway.child.kill("SIGTERM"); await new Promise((resolve) => setTimeout(resolve, 500)); if (!gateway.child.killed) gateway.child.kill("SIGKILL"); } async function requestJson(baseUrl, path, init = {}) { const response = await fetch(`${baseUrl}${path}`, { ...init, headers: { "content-type": "application/json", ...(init.headers || {}), }, }); const body = response.status === 204 ? null : await response.json().catch(() => null); return { status: response.status, body }; } async function enqueueCommand(baseUrl, sessionId, token, action, payload) { const response = await requestJson(baseUrl, "/api/onlyoffice/bridge/commands", { method: "POST", body: JSON.stringify({ sessionId, token, action, payload }), }); assert.equal(response.status, 200, JSON.stringify(response)); assert(response.body && response.body.command && response.body.command.id, JSON.stringify(response)); return response.body.command.id; } async function readResult(baseUrl, sessionId, token, commandId) { const query = new URLSearchParams({ sessionId, token, commandId, timeoutMs: "5000", }); const response = await requestJson(baseUrl, `/api/onlyoffice/bridge/results?${query.toString()}`); assert.equal(response.status, 200, JSON.stringify(response)); assert.equal(response.body && response.body.ok, true, JSON.stringify(response)); return response.body.result; } async function waitForSession(baseUrl, sessionId, timeoutMs = 5000) { const deadline = Date.now() + timeoutMs; let lastResponse = null; while (Date.now() < deadline) { lastResponse = await requestJson(baseUrl, "/api/onlyoffice/bridge/sessions"); if (lastResponse.status === 200 && lastResponse.body && Array.isArray(lastResponse.body.sessions)) { const found = lastResponse.body.sessions.find((session) => session.sessionId === sessionId); if (found) return { response: lastResponse, session: found }; } await new Promise((resolve) => setTimeout(resolve, 100)); } throw new Error(`bridge session 未注册: ${JSON.stringify(lastResponse)}`); } async function main() { const port = await findFreePort(); const baseUrl = `http://127.0.0.1:${port}`; const gateway = startGateway(port); const suffix = `${Date.now()}-${Math.random().toString(36).slice(2)}`; const sessionId = `mnote-oo-plugin-direct-${suffix}`; const token = `token-${suffix}`; const consoleErrors = []; const networkFailures = []; try { await waitForGateway(baseUrl); const browser = await chromium.launch({ headless: true }); const context = await browser.newContext(); const page = await context.newPage(); page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); }); page.on("requestfailed", (request) => { networkFailures.push({ url: request.url(), failure: request.failure()?.errorText || "" }); }); await page.route("**/onlyoffice-server/sdkjs-plugins/v1/plugins.js", async (route) => { await route.fulfill({ contentType: "application/javascript; charset=utf-8", body: ` window.__MNOTE_ONLYOFFICE_PLUGIN_MOCK__ = { executeCalls: [], commandCalls: [] }; window.Asc = { scope: {}, plugin: { info: { editorType: "word" }, executeMethod(name, args, callback) { window.__MNOTE_ONLYOFFICE_PLUGIN_MOCK__.executeCalls.push({ name, args }); const value = name === "GetSelectedText" ? "mock selected text" : name === "PasteText" ? true : name === "ConvertDocument" ? "# mock document" : null; callback(value); }, callCommand(func, _close, _calc, callback) { window.__MNOTE_ONLYOFFICE_PLUGIN_MOCK__.commandCalls.push({ scope: window.Asc.scope.mnoteBridge || {} }); callback(func()); }, init: null, button: null } }; `, }); }); const pluginUrl = new URL(`${baseUrl}/api/onlyoffice/bridge/plugin/index`); pluginUrl.searchParams.set("sessionId", sessionId); pluginUrl.searchParams.set("token", token); pluginUrl.searchParams.set("apiBase", baseUrl); pluginUrl.searchParams.set("documentId", "doc_plugin_direct"); pluginUrl.searchParams.set("assetId", "asset_plugin_direct"); pluginUrl.searchParams.set("fileType", "docx"); pluginUrl.searchParams.set("docKey", "doc_key_plugin_direct"); pluginUrl.searchParams.set("pageOrigin", baseUrl); await page.goto(pluginUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await page.evaluate(() => window.Asc.plugin.init()); const { session: registeredSession } = await waitForSession(baseUrl, sessionId); assert.equal(registeredSession.docKey, "doc_key_plugin_direct", JSON.stringify(registeredSession)); assert.equal(registeredSession.pageOrigin, baseUrl, JSON.stringify(registeredSession)); const wrongToken = await requestJson( baseUrl, `/api/onlyoffice/bridge/commands/next?sessionId=${encodeURIComponent(sessionId)}&token=wrong-${encodeURIComponent(token)}&timeoutMs=1`, ); assert.equal(wrongToken.status, 401, JSON.stringify(wrongToken)); const selectionCommandId = await enqueueCommand(baseUrl, sessionId, token, "selection.get", {}); const selection = await readResult(baseUrl, sessionId, token, selectionCommandId); assert.equal(selection.editorType, "word", JSON.stringify(selection)); assert.equal(selection.text, "mock selected text", JSON.stringify(selection)); const insertCommandId = await enqueueCommand(baseUrl, sessionId, token, "document.insert_text", { text: "hello" }); const inserted = await readResult(baseUrl, sessionId, token, insertCommandId); assert.equal(inserted.value, true, JSON.stringify(inserted)); const mockState = await page.evaluate(() => window.__MNOTE_ONLYOFFICE_PLUGIN_MOCK__); assert(mockState.executeCalls.some((call) => call.name === "GetSelectedText"), JSON.stringify(mockState)); assert(mockState.executeCalls.some((call) => call.name === "PasteText"), JSON.stringify(mockState)); await browser.close(); console.log(JSON.stringify({ ok: true, task: "task517-onlyoffice-bridge-plugin-direct-smoke", baseUrl, sessionId, wrongToken: { status: wrongToken.status, code: wrongToken.body && wrongToken.body.code }, registeredSession, selection, inserted, executeCalls: mockState.executeCalls.map((call) => call.name), consoleErrors, networkFailures, }, null, 2)); } finally { await stopGateway(gateway); } } main().catch((error) => { console.error(error && error.stack ? error.stack : error); process.exit(1); });