#!/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!"; // 说明:该 smoke 仍在验证 Convex media / OnlyOffice 的 cloud upload 兼容入口,不是 local-first 默认上传路径。 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); });