2026-05-11 08:27:52 +08:00
|
|
|
|
"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,
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 17:47:09 +08:00
|
|
|
|
async function assertOnlyOfficeDocumentUrlReachableFromContainer(debug) {
|
|
|
|
|
|
const resolvedFileUrl = String(debug && debug.resolvedFileUrl || "");
|
|
|
|
|
|
assert(resolvedFileUrl.includes("/api/onlyoffice/proxy"), `OnlyOffice resolvedFileUrl 未走 proxy:${JSON.stringify(debug)}`);
|
|
|
|
|
|
assert(
|
|
|
|
|
|
/^(http:\/\/host\.docker\.internal:3000|http:\/\/172\.17\.0\.1:3000)/.test(resolvedFileUrl),
|
|
|
|
|
|
`OnlyOffice resolvedFileUrl 应使用 DocumentServer 可回源的 mnote-web 地址:${resolvedFileUrl}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
const { execFile } = require("node:child_process");
|
|
|
|
|
|
const { promisify } = require("node:util");
|
|
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
|
|
const response = await execFileAsync(
|
|
|
|
|
|
"docker",
|
|
|
|
|
|
["exec", "mnote-onlyoffice-documentserver", "curl", "-fsSI", "--max-time", "10", resolvedFileUrl],
|
|
|
|
|
|
{ timeout: 15_000, maxBuffer: 1024 * 1024 },
|
|
|
|
|
|
);
|
|
|
|
|
|
assert(/HTTP\/[0-9.]+ 200/.test(response.stdout), `DocumentServer 容器无法回源读取文档:${response.stdout}\n${response.stderr}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-11 08:27:52 +08:00
|
|
|
|
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");
|
2026-05-20 17:47:09 +08:00
|
|
|
|
assert(firstDebug.debug && firstDebug.debug.documentUrlBase, `OnlyOffice debug 应暴露 documentUrlBase:${JSON.stringify(firstDebug)}`);
|
|
|
|
|
|
await assertOnlyOfficeDocumentUrlReachableFromContainer(firstDebug.debug);
|
2026-05-11 08:27:52 +08:00
|
|
|
|
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);
|
|
|
|
|
|
});
|