fix: 修复 Rust OnlyOffice 附件打开链路
This commit is contained in:
+33
-23
@@ -1,10 +1,12 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* 同时热启动前端、FastAPI,以及按需启用的 Celery。
|
||||
* 热启动 mnote-web 单入口,以及按需启用的 FastAPI / Celery。
|
||||
* 可使用以下环境变量调整行为:
|
||||
* - FRONTEND_CMD:覆盖历史 Next 启动命令,仅在显式启用 legacy compat 或跳过 Rust gateway 时生效
|
||||
* - BACKEND_CMD:覆盖 FastAPI 启动命令,默认为 "python -m uvicorn app.main:app --reload --port 8000"
|
||||
* - ENABLE_BACKEND:设为 "1" or "true" 时启用默认 FastAPI 后端
|
||||
* - BACKEND_CMD:覆盖 FastAPI 启动命令;设置后即视为显式启用后端
|
||||
* - SKIP_BACKEND:设为 "1" or "true" 可强制跳过 FastAPI 后端
|
||||
* - ENABLE_CELERY:设为 "1" or "true" 时启用默认 Celery worker
|
||||
* - CELERY_CMD:覆盖 Celery 启动命令;设置后即视为显式启用 Celery
|
||||
* - CELERY_POOL:只在 CELERY_CMD 未覆盖时生效,设置 Celery worker pool;Windows 默认 "solo",其他平台默认使用 Celery 自身默认值
|
||||
@@ -13,7 +15,7 @@
|
||||
* - REDIS_URL:仅用于探测 Redis 是否就绪,默认 "redis://localhost:6379/0"
|
||||
* - SKIP_CELERY:设为 "1" or "true" 可强制跳过 Celery。
|
||||
* - MNOTE_WEB_SKIP_GATEWAY:设为 "1" or "true" 临时恢复旧 Next 3000 入口。
|
||||
* - MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT:设为 "1" or "true" 时才启动 Next legacy upstream。
|
||||
* - MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT:已废弃;desktop:hot 默认不再启动 Next legacy upstream。
|
||||
* - NEXT_LEGACY_PORT:显式启用 legacy compat 时的 Next upstream 端口,默认 3100。
|
||||
* - SKIP_NEXT_LEGACY:兼容旧环境变量;设为 "1" or "true" 时强制只启动 Rust gateway。
|
||||
*/
|
||||
@@ -72,19 +74,21 @@ function shouldStartCelery(env = process.env) {
|
||||
return isEnabledEnv(env.ENABLE_CELERY);
|
||||
}
|
||||
|
||||
function shouldStartBackend(env = process.env) {
|
||||
if (isEnabledEnv(env.SKIP_BACKEND)) return false;
|
||||
if (String(env.BACKEND_CMD || "").trim()) return true;
|
||||
return isEnabledEnv(env.ENABLE_BACKEND);
|
||||
}
|
||||
|
||||
function resolveRuntimePlan(env = process.env) {
|
||||
const frontendPort = Number(env.FRONTEND_PORT || 3000);
|
||||
const nextLegacyPort = Number(env.NEXT_LEGACY_PORT || 3100);
|
||||
const skipGateway = isEnabledEnv(env.MNOTE_WEB_SKIP_GATEWAY);
|
||||
const legacyCompatRequested =
|
||||
!skipGateway &&
|
||||
isEnabledEnv(env.MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT) &&
|
||||
!isEnabledEnv(env.SKIP_NEXT_LEGACY);
|
||||
const skipNextLegacy = !skipGateway && !legacyCompatRequested;
|
||||
const skipNextLegacy = !skipGateway;
|
||||
const publicPort = Number.isFinite(frontendPort) ? Math.floor(frontendPort) : 3000;
|
||||
const legacyPort = Number.isFinite(nextLegacyPort) ? Math.floor(nextLegacyPort) : 3100;
|
||||
const legacyUrl = `http://127.0.0.1:${legacyPort}`;
|
||||
const legacyCompatEnabled = legacyCompatRequested ? "1" : "0";
|
||||
const legacyCompatEnabled = "0";
|
||||
|
||||
return {
|
||||
skipGateway,
|
||||
@@ -93,17 +97,14 @@ function resolveRuntimePlan(env = process.env) {
|
||||
legacyPort,
|
||||
publicUrl: `http://localhost:${publicPort}`,
|
||||
legacyUrl,
|
||||
frontendTaskName: skipGateway ? "frontend" : skipNextLegacy ? null : "next-legacy",
|
||||
frontendTaskName: skipGateway ? "frontend" : null,
|
||||
frontendCommand: env.FRONTEND_CMD || `pnpm dev -p ${skipGateway ? publicPort : legacyPort}`,
|
||||
mnoteWebCommand: env.MNOTE_WEB_CMD || "cargo run -p mnote-web --bin mnote-web",
|
||||
mnoteWebEnv: skipGateway
|
||||
? {}
|
||||
: {
|
||||
MNOTE_WEB_BIND: env.MNOTE_WEB_BIND || `127.0.0.1:${publicPort}`,
|
||||
MNOTE_WEB_BIND: env.MNOTE_WEB_BIND || `0.0.0.0:${publicPort}`,
|
||||
MNOTE_WEB_PUBLIC_BIND: env.MNOTE_WEB_PUBLIC_BIND || `127.0.0.1:${publicPort}`,
|
||||
...(skipNextLegacy
|
||||
? {}
|
||||
: { MNOTE_WEB_LEGACY_NEXT_BASE_URL: env.MNOTE_WEB_LEGACY_NEXT_BASE_URL || legacyUrl }),
|
||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: legacyCompatEnabled,
|
||||
},
|
||||
};
|
||||
@@ -135,13 +136,17 @@ const tasks = [
|
||||
cwd: path.join(rootDir, "rust"),
|
||||
},
|
||||
]),
|
||||
{
|
||||
name: "backend",
|
||||
command:
|
||||
process.env.BACKEND_CMD ||
|
||||
`${pythonBin} -m uvicorn app.main:app --reload --port 8000`,
|
||||
cwd: backendDir,
|
||||
},
|
||||
...(shouldStartBackend(process.env)
|
||||
? [
|
||||
{
|
||||
name: "backend",
|
||||
command:
|
||||
process.env.BACKEND_CMD ||
|
||||
`${pythonBin} -m uvicorn app.main:app --reload --port 8000`,
|
||||
cwd: backendDir,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
function findTask(name) {
|
||||
@@ -549,7 +554,7 @@ async function main() {
|
||||
if (runtimePlan.frontendTaskName === "next-legacy") {
|
||||
logPrefix("next-legacy", `Next legacy upstream:${runtimePlan.legacyUrl}`);
|
||||
} else {
|
||||
logPrefix("next-legacy", "默认不启动历史 Next upstream;如需临时兼容请设置 MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT=1。");
|
||||
logPrefix("next-legacy", "desktop:hot 默认不启动历史 Next upstream,3000 由 Rust mnote-web 独占。");
|
||||
}
|
||||
const gatewayTask = tasks.find((task) => task.name === "mnote-web");
|
||||
if (gatewayTask) {
|
||||
@@ -559,7 +564,7 @@ async function main() {
|
||||
}
|
||||
|
||||
const desiredBackendPort = backendPortFromEnv;
|
||||
if (!process.env.BACKEND_CMD) {
|
||||
if (shouldStartBackend(process.env) && !process.env.BACKEND_CMD) {
|
||||
const backendPortOk = await ensurePortFree(desiredBackendPort, "backend");
|
||||
if (!backendPortOk) {
|
||||
console.error(`后端端口 ${desiredBackendPort} 无法释放,已中止启动。`);
|
||||
@@ -570,6 +575,10 @@ async function main() {
|
||||
throw new Error("缺少后端任务配置");
|
||||
}
|
||||
backendTask.command = `${pythonBin} -m uvicorn app.main:app --reload --port ${desiredBackendPort}`;
|
||||
} else if (isEnabledEnv(process.env.SKIP_BACKEND)) {
|
||||
logPrefix("backend", "已跳过 FastAPI 后端(SKIP_BACKEND=1)。");
|
||||
} else if (!shouldStartBackend(process.env)) {
|
||||
logPrefix("backend", "默认不启动 FastAPI 后端;desktop:hot 保持 3000 单入口。如需启用请设置 ENABLE_BACKEND=1 或 BACKEND_CMD。");
|
||||
}
|
||||
|
||||
if (shouldStartCelery(process.env)) {
|
||||
@@ -625,6 +634,7 @@ module.exports = {
|
||||
isPortFree,
|
||||
resolveRuntimePlan,
|
||||
resolveBackendExecutable,
|
||||
shouldStartBackend,
|
||||
shouldStartCelery,
|
||||
terminatePid,
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ const {
|
||||
ensurePortFree,
|
||||
isPortFree,
|
||||
resolveRuntimePlan,
|
||||
shouldStartBackend,
|
||||
shouldStartCelery,
|
||||
} = require("./desktop-hot.js");
|
||||
|
||||
@@ -128,13 +129,13 @@ test("默认热启动计划只使用 mnote-web 作为 3000 owner,不启动 Nex
|
||||
assert.equal(plan.frontendTaskName, null);
|
||||
assert.equal(plan.mnoteWebCommand, "cargo run -p mnote-web --bin mnote-web");
|
||||
assert.deepEqual(plan.mnoteWebEnv, {
|
||||
MNOTE_WEB_BIND: "127.0.0.1:3000",
|
||||
MNOTE_WEB_BIND: "0.0.0.0:3000",
|
||||
MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000",
|
||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
|
||||
});
|
||||
});
|
||||
|
||||
test("显式开启 legacy compat 时才启动 Next legacy upstream", () => {
|
||||
test("显式 legacy compat 也不再启动 Next legacy upstream", () => {
|
||||
const plan = resolveRuntimePlan({
|
||||
FRONTEND_PORT: "3000",
|
||||
NEXT_LEGACY_PORT: "3100",
|
||||
@@ -142,15 +143,13 @@ test("显式开启 legacy compat 时才启动 Next legacy upstream", () => {
|
||||
});
|
||||
|
||||
assert.equal(plan.skipGateway, false);
|
||||
assert.equal(plan.skipNextLegacy, false);
|
||||
assert.equal(plan.frontendTaskName, "next-legacy");
|
||||
assert.equal(plan.frontendCommand, "pnpm dev -p 3100");
|
||||
assert.equal(plan.skipNextLegacy, true);
|
||||
assert.equal(plan.frontendTaskName, null);
|
||||
assert.equal(plan.mnoteWebCommand, "cargo run -p mnote-web --bin mnote-web");
|
||||
assert.deepEqual(plan.mnoteWebEnv, {
|
||||
MNOTE_WEB_BIND: "127.0.0.1:3000",
|
||||
MNOTE_WEB_BIND: "0.0.0.0:3000",
|
||||
MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000",
|
||||
MNOTE_WEB_LEGACY_NEXT_BASE_URL: "http://127.0.0.1:3100",
|
||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "1",
|
||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -180,7 +179,7 @@ test("SKIP_NEXT_LEGACY 保持 Rust gateway 为 3000 owner,但不启动 Next le
|
||||
assert.equal(plan.frontendTaskName, null);
|
||||
assert.equal(plan.mnoteWebCommand, "cargo run -p mnote-web --bin mnote-web");
|
||||
assert.deepEqual(plan.mnoteWebEnv, {
|
||||
MNOTE_WEB_BIND: "127.0.0.1:3000",
|
||||
MNOTE_WEB_BIND: "0.0.0.0:3000",
|
||||
MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000",
|
||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
|
||||
});
|
||||
@@ -193,3 +192,11 @@ test("默认跳过 Celery,只有显式开启时才启动", () => {
|
||||
assert.equal(shouldStartCelery({ CELERY_CMD: "custom-celery" }), true);
|
||||
assert.equal(shouldStartCelery({ ENABLE_CELERY: "1", SKIP_CELERY: "1" }), false);
|
||||
});
|
||||
|
||||
test("默认跳过 FastAPI 后端,只有显式开启时才启动", () => {
|
||||
assert.equal(shouldStartBackend({}), false);
|
||||
assert.equal(shouldStartBackend({ ENABLE_BACKEND: "1" }), true);
|
||||
assert.equal(shouldStartBackend({ ENABLE_BACKEND: "true" }), true);
|
||||
assert.equal(shouldStartBackend({ BACKEND_CMD: "custom-backend" }), true);
|
||||
assert.equal(shouldStartBackend({ ENABLE_BACKEND: "1", SKIP_BACKEND: "1" }), false);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
"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);
|
||||
});
|
||||
@@ -0,0 +1,367 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
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 PNG_MIME = "image/png";
|
||||
const TEST_EMAIL = "mnote.e2e@example.com";
|
||||
const TEST_PASSWORD = "MnoteE2E123!";
|
||||
const PROBE_DOCX_PATH =
|
||||
process.env.MNOTE_ONLYOFFICE_PROBE_DOCX ||
|
||||
"/tmp/mnote-onlyoffice-manual/mnote-onlyoffice-upload-test.docx";
|
||||
const SCREENSHOT_DIR =
|
||||
process.env.MNOTE_UPLOAD_ENTRY_SCREENSHOT_DIR ||
|
||||
"/mnt/Data1T/mnote/tmp/wolai-editor-parity/task175-rust-upload-entry";
|
||||
|
||||
function ensureProbeDocx() {
|
||||
assert(fs.existsSync(PROBE_DOCX_PATH), `缺少 Office 探测文件:${PROBE_DOCX_PATH}`);
|
||||
return {
|
||||
name: "task175-slash-attachment.docx",
|
||||
mimeType: DOCX_MIME,
|
||||
buffer: fs.readFileSync(PROBE_DOCX_PATH),
|
||||
};
|
||||
}
|
||||
|
||||
function tinyPngBuffer() {
|
||||
return Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
async function waitForRuntimeEditor(page) {
|
||||
const editor = page
|
||||
.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]')
|
||||
.first();
|
||||
await editor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
return editor;
|
||||
}
|
||||
|
||||
async function waitForUploadResponse(page, fileName, action) {
|
||||
const response = await page.waitForResponse(
|
||||
async (candidate) => {
|
||||
if (!candidate.url().includes("/api/media/upload") || candidate.request().method() !== "POST") {
|
||||
return false;
|
||||
}
|
||||
const payload = await candidate.json().catch(() => null);
|
||||
return Boolean(payload?.asset?.id && (!fileName || payload.asset.file_name === fileName));
|
||||
},
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
const payload = await response.json();
|
||||
assert(response.ok(), `${action} 上传失败:${response.status()} ${JSON.stringify(payload)}`);
|
||||
assert(payload?.asset?.id, `${action} 上传响应缺少 asset.id:${JSON.stringify(payload)}`);
|
||||
return payload.asset;
|
||||
}
|
||||
|
||||
async function waitForAssetRow(page, assetId, action) {
|
||||
try {
|
||||
await page.waitForFunction(
|
||||
(targetAssetId) =>
|
||||
Array.from(document.querySelectorAll(`[data-testid="filetree-asset-row"][data-asset-id="${targetAssetId}"]`)).some(
|
||||
(row) =>
|
||||
row instanceof HTMLElement &&
|
||||
window.getComputedStyle(row).display !== "none" &&
|
||||
window.getComputedStyle(row).visibility !== "hidden" &&
|
||||
row.getClientRects().length > 0,
|
||||
),
|
||||
assetId,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
} catch (error) {
|
||||
const debug = await page.evaluate((targetAssetId) => ({
|
||||
mode: document.documentElement.getAttribute("data-mnote-sidebar-tree-mode"),
|
||||
lastUpload: document.documentElement.getAttribute("data-mnote-last-upload-asset-id"),
|
||||
targetCount: document.querySelectorAll(`[data-testid="filetree-asset-row"][data-asset-id="${targetAssetId}"]`).length,
|
||||
assets: Array.from(document.querySelectorAll('[data-testid="filetree-asset-row"]')).slice(-12).map((row) => ({
|
||||
id: row.getAttribute("data-asset-id"),
|
||||
visible: row instanceof HTMLElement && window.getComputedStyle(row).display !== "none" && window.getComputedStyle(row).visibility !== "hidden" && row.getClientRects().length > 0,
|
||||
text: row.textContent,
|
||||
})),
|
||||
body: document.body.innerText.slice(0, 1200),
|
||||
}), assetId).catch((debugError) => ({ debugError: String(debugError) }));
|
||||
throw new Error(`${action} 上传后的文件树附件行不可见:${assetId} ${JSON.stringify(debug)}`);
|
||||
}
|
||||
const title = await page.evaluate((targetAssetId) => {
|
||||
const row = Array.from(document.querySelectorAll(`[data-testid="filetree-asset-row"][data-asset-id="${targetAssetId}"]`)).find(
|
||||
(candidate) =>
|
||||
candidate instanceof HTMLElement &&
|
||||
window.getComputedStyle(candidate).display !== "none" &&
|
||||
window.getComputedStyle(candidate).visibility !== "hidden" &&
|
||||
candidate.getClientRects().length > 0,
|
||||
);
|
||||
return row?.querySelector(".tree-link-title")?.textContent?.trim() || "";
|
||||
}, assetId);
|
||||
assert(title.length > 0, `${action} 上传后的文件树附件行缺少标题`);
|
||||
}
|
||||
|
||||
async function waitForEditorOfficeAttachment(page, assetId, fileName, action) {
|
||||
const attachment = page
|
||||
.locator(`.editor-surface .ProseMirror a[data-mnote-attachment-link="true"][href*="/onlyoffice"][href*="${assetId}"]`)
|
||||
.first();
|
||||
await attachment.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const text = (await attachment.innerText()).trim();
|
||||
assert(text.includes(fileName), `${action} 正文附件标题不正确:${text}`);
|
||||
const href = await attachment.getAttribute("href");
|
||||
assert(href && href.includes("/onlyoffice?"), `${action} 正文 Office 附件 href 应指向 /onlyoffice,实际:${href}`);
|
||||
assert(
|
||||
href && href.startsWith("/onlyoffice?"),
|
||||
`${action} 正文 Office 附件 href 应保存为相对 /onlyoffice 链接,实际:${href}`,
|
||||
);
|
||||
assert(href.includes(`assetId=${encodeURIComponent(assetId)}`), `${action} 正文 Office 附件 href 缺少 assetId:${href}`);
|
||||
return attachment;
|
||||
}
|
||||
|
||||
async function assertAttachmentActions(page, attachment, action) {
|
||||
await attachment.hover({ timeout: UI_TIMEOUT_MS });
|
||||
const actions = page.locator('[data-testid="mnote-attachment-actions"]').first();
|
||||
await actions.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await actions.locator('[data-testid="mnote-attachment-action-menu"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
const menu = page.locator('[data-testid="mnote-tree-context-menu"][data-kind="attachment"]').first();
|
||||
await menu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const text = await menu.innerText();
|
||||
for (const label of ["拷贝副本", "删除", "复制链接", "弹窗预览", "右侧预览", "下载", "更换文件", "重命名", "添加说明文字"]) {
|
||||
assert(text.includes(label), `${action} 附件三点菜单缺少“${label}”:${text}`);
|
||||
}
|
||||
await page.keyboard.press("Escape").catch(() => undefined);
|
||||
}
|
||||
|
||||
async function screenshot(page, name) {
|
||||
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
||||
await page.screenshot({
|
||||
path: path.join(SCREENSHOT_DIR, `${name}.png`),
|
||||
fullPage: false,
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadViaSlash(page, itemTestId, filePayload, action) {
|
||||
const editor = await waitForRuntimeEditor(page);
|
||||
await editor.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.keyboard.type("/");
|
||||
const slash = page.locator('[data-testid="mnote-leptos-tiptap-slash-menu"]').first();
|
||||
await slash.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const slashText = await slash.innerText();
|
||||
assert(slashText.includes("媒体与附件"), `slash 菜单缺少媒体与附件分组:${slashText}`);
|
||||
|
||||
const item = page.locator(`[data-testid="${itemTestId}"]`).first();
|
||||
await item.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await item.scrollIntoViewIfNeeded({ timeout: UI_TIMEOUT_MS });
|
||||
await screenshot(page, `slash-${itemTestId}`);
|
||||
|
||||
const [fileChooser] = await Promise.all([
|
||||
page.waitForEvent("filechooser", { timeout: UI_TIMEOUT_MS }),
|
||||
item.click({ timeout: UI_TIMEOUT_MS }),
|
||||
]);
|
||||
const uploadResponse = waitForUploadResponse(page, filePayload.name, action);
|
||||
await fileChooser.setFiles(filePayload);
|
||||
const asset = await uploadResponse;
|
||||
await waitForAssetRow(page, asset.id, action);
|
||||
return asset;
|
||||
}
|
||||
|
||||
async function dispatchFileDrop(page, selector, filePayload, action) {
|
||||
const uploadPromise = waitForUploadResponse(page, filePayload.name, action);
|
||||
await page.evaluate(
|
||||
({ selector, fileName, mimeType, bytes }) => {
|
||||
const target = Array.from(document.querySelectorAll(selector)).find(
|
||||
(candidate) => candidate instanceof HTMLElement && candidate.getClientRects().length > 0,
|
||||
) || document.querySelector(selector);
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
throw new Error(`拖放目标不存在:${selector}`);
|
||||
}
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(new File([new Uint8Array(bytes)], fileName, { type: mimeType }));
|
||||
target.dispatchEvent(new DragEvent("dragover", { bubbles: true, cancelable: true, dataTransfer }));
|
||||
target.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer }));
|
||||
},
|
||||
{
|
||||
selector,
|
||||
fileName: filePayload.name,
|
||||
mimeType: filePayload.mimeType,
|
||||
bytes: Array.from(filePayload.buffer),
|
||||
},
|
||||
);
|
||||
const asset = await uploadPromise;
|
||||
await waitForAssetRow(page, asset.id, action);
|
||||
return asset;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1" });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||
const page = await context.newPage();
|
||||
const networkNotes = [];
|
||||
page.on("response", async (response) => {
|
||||
if (!/\/api\/(media\/upload|tree\/filetree\/upload-target-preflight)/.test(response.url())) return;
|
||||
const text = await response.text().catch(() => "");
|
||||
networkNotes.push({
|
||||
status: response.status(),
|
||||
url: response.url(),
|
||||
body: text.slice(0, 1000),
|
||||
});
|
||||
});
|
||||
page.on("pageerror", (error) => {
|
||||
networkNotes.push({ type: "pageerror", message: error.message });
|
||||
});
|
||||
page.on("console", (message) => {
|
||||
if (["error", "warning"].includes(message.type())) {
|
||||
networkNotes.push({ type: message.type(), message: message.text().slice(0, 1000) });
|
||||
}
|
||||
});
|
||||
page.on("dialog", async (dialog) => {
|
||||
networkNotes.push({ type: "dialog", message: dialog.message() });
|
||||
await dialog.dismiss().catch(() => undefined);
|
||||
});
|
||||
let target = null;
|
||||
|
||||
try {
|
||||
await forceTestAccountLogin(context);
|
||||
await ensureAuthenticated(page, context.request);
|
||||
target = await createTempDocument(context.request, null);
|
||||
await openDocument(page, target.workspaceId, target.documentId);
|
||||
await openFilesystemView(page);
|
||||
|
||||
const slashAttachment = ensureProbeDocx();
|
||||
const slashImage = {
|
||||
name: "task175-slash-image.png",
|
||||
mimeType: PNG_MIME,
|
||||
buffer: tinyPngBuffer(),
|
||||
};
|
||||
const treeDropAttachment = {
|
||||
name: "task175-filetree-drop.docx",
|
||||
mimeType: DOCX_MIME,
|
||||
buffer: slashAttachment.buffer,
|
||||
};
|
||||
const editorDropImage = {
|
||||
name: "task175-editor-drop.png",
|
||||
mimeType: PNG_MIME,
|
||||
buffer: tinyPngBuffer(),
|
||||
};
|
||||
|
||||
const attachmentAsset = await uploadViaSlash(
|
||||
page,
|
||||
"slash-item-upload-attachment",
|
||||
slashAttachment,
|
||||
"slash 上传附件",
|
||||
);
|
||||
const editorAttachment = await waitForEditorOfficeAttachment(
|
||||
page,
|
||||
attachmentAsset.id,
|
||||
slashAttachment.name,
|
||||
"slash 上传附件",
|
||||
);
|
||||
await assertAttachmentActions(page, editorAttachment, "slash 上传附件");
|
||||
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await waitForRuntimeEditor(page);
|
||||
await waitForEditorOfficeAttachment(page, attachmentAsset.id, slashAttachment.name, "刷新后正文附件");
|
||||
await openFilesystemView(page);
|
||||
const imageAsset = await uploadViaSlash(page, "slash-item-image", slashImage, "slash 上传图片");
|
||||
|
||||
const docRowSelector = `[data-testid="filetree-doc-row"][data-document-id="${target.documentId}"]`;
|
||||
const droppedAttachment = await dispatchFileDrop(page, docRowSelector, treeDropAttachment, "文件树拖入附件");
|
||||
|
||||
const editorStageSelector = '[data-testid="mnote-leptos-tiptap-editor-stage"]';
|
||||
const droppedImage = await dispatchFileDrop(page, editorStageSelector, editorDropImage, "主编辑区拖入图片");
|
||||
await page.waitForFunction(
|
||||
(fileName) => {
|
||||
const images = Array.from(document.querySelectorAll(".editor-surface .ProseMirror img[src]"));
|
||||
return images.some((image) => image.getAttribute("alt") === fileName || image.getAttribute("title") === fileName);
|
||||
},
|
||||
editorDropImage.name,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
workspaceId: target.workspaceId,
|
||||
documentId: target.documentId,
|
||||
uploadedAssetIds: [
|
||||
attachmentAsset.id,
|
||||
imageAsset.id,
|
||||
droppedAttachment.id,
|
||||
droppedImage.id,
|
||||
],
|
||||
screenshotDir: SCREENSHOT_DIR,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
if (networkNotes.length) {
|
||||
console.error(`上传入口调试信息:${JSON.stringify(networkNotes.slice(-20), null, 2)}`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (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 instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user