Files
mnote/scripts/task518-onlyoffice-real-iframe-session-scope-smoke.js

491 lines
20 KiB
JavaScript

#!/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);
});