#!/usr/bin/env node "use strict"; const assert = require("node:assert"); const { spawn } = require("node:child_process"); const fs = require("node:fs"); const net = require("node:net"); const os = require("node:os"); const path = require("node:path"); const { chromium } = require("playwright"); const { fetchWithTimeout } = require("./task114-rust-web-gateway-entry-smoke.js"); let BASE_URL = (process.env.MNOTE_UI_BASE_URL || process.env.MNOTE_WEB_SMOKE_BASE_URL || "").replace(/\/+$/, ""); const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000); const SERVER_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_SERVER_TIMEOUT_MS || 90_000); const SCREENSHOT_DIR = process.env.MNOTE_PHASE_E_SCREENSHOT_DIR || "/mnt/Data1T/mnote/tmp/wolai-editor-parity/task801-image-double-click-preview-local-smoke"; const LEPTOS_SPIKE_SOURCE = "/mnt/Data1T/mnote/rust/spikes/leptos-tiptap-spike/src/lib.rs"; const IMAGE_SRC = "/api/editor/image-placeholder.svg"; const ACTOR_ID = `task801-${process.pid}-${Date.now().toString(36)}`; const RELATIVE_PATH = "README.md"; function assertImagePreviewSourceBoundary() { const source = fs.readFileSync(LEPTOS_SPIKE_SOURCE, "utf8"); assert( source.includes("ImagePreviewState") && source.includes("image_preview_state_from_image"), "图片双击预览必须有独立 preview state,不能复用普通 image toolbar 状态", ); assert( source.includes("on:dblclick") && source.includes('data-testid="image-preview-dialog"'), "图片双击必须打开稳定可测的全屏预览 dialog", ); assert( source.includes("on:wheel") && source.includes("data-preview-offset-x") && source.includes("ImagePreviewPanState"), "图片预览必须支持滚轮缩放和中键拖拽平移,并暴露 offset 状态用于回归验证", ); } function fileUrl(localPath) { return `file://${localPath}`; } function localMdDocumentId(relativePath) { return `local-md:${relativePath.replaceAll("/", "~2F")}`; } function documentUrl(root, relativePath) { const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`); url.searchParams.set("sourceKind", "local_folder"); url.searchParams.set("rootUri", fileUrl(root)); url.searchParams.set("treeView", "filetree"); return url.toString(); } function writeWorkspaceManifest(root, ownerId) { const metadataDir = path.join(root, ".mnote"); fs.mkdirSync(metadataDir, { recursive: true }); fs.writeFileSync( path.join(metadataDir, "workspace.json"), `${JSON.stringify({ workspaceId: `local-ws:${ownerId}:task801`, ownerId, createdAt: new Date().toISOString(), capabilities: ["local_files", "markdown_edit", "asset_upload"], }, null, 2)}\n`, "utf8", ); } function createLocalFixture() { const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task801-image-preview-")); const root = path.join(dataRoot, "workspace"); fs.mkdirSync(root, { recursive: true }); writeWorkspaceManifest(root, ACTOR_ID); fs.writeFileSync( path.join(root, RELATIVE_PATH), ["---", "title: Image Preview", "---", "", "# Image Preview", "", "初始正文", ""].join("\n"), "utf8", ); return { dataRoot, root, relativePath: RELATIVE_PATH, documentId: localMdDocumentId(RELATIVE_PATH) }; } function pickPort() { return new Promise((resolve, reject) => { const server = net.createServer(); server.listen(0, "127.0.0.1", () => { const address = server.address(); const port = address && typeof address === "object" ? address.port : 0; server.close(() => resolve(port)); }); server.on("error", reject); }); } async function waitForServer(baseUrl) { const deadline = Date.now() + SERVER_TIMEOUT_MS; let lastError = null; while (Date.now() < deadline) { try { const response = await fetchWithTimeout(`${baseUrl}/health`); if (response.status >= 200 && response.status < 500) return; lastError = new Error(`server_not_ready_${response.status}`); } catch (error) { lastError = error; } await new Promise((resolve) => setTimeout(resolve, 300)); } throw lastError || new Error(`server_not_ready: ${baseUrl}`); } async function startLocalServer(dataRoot) { if (BASE_URL) return null; const port = await pickPort(); BASE_URL = `http://127.0.0.1:${port}`; const server = spawn("cargo", ["run", "-p", "mnote-web", "--bin", "mnote-web"], { cwd: path.join(__dirname, "..", "rust"), env: { ...process.env, MNOTE_WEB_BIND: `127.0.0.1:${port}`, MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`, MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot, }, stdio: ["ignore", "pipe", "pipe"], }); let stderr = ""; server.stderr.on("data", (chunk) => { stderr += chunk.toString(); }); try { await waitForServer(BASE_URL); } catch (error) { server.kill("SIGTERM"); throw new Error(`${error.message}\n${stderr.slice(-3000)}`); } return server; } async function waitForRuntimeIsland(page) { const root = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first(); await root.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); 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 }); await page.waitForFunction(() => { const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]'); const editorNode = host?.querySelector('.editor-surface .ProseMirror[contenteditable="true"]'); return host?.getAttribute("data-runtime-editor-status") !== "error" && editorNode instanceof HTMLElement && editorNode.isContentEditable; }, null, { timeout: UI_TIMEOUT_MS }); return editor; } async function screenshot(page, name) { fs.mkdirSync(SCREENSHOT_DIR, { recursive: true }); await page.screenshot({ path: `${SCREENSHOT_DIR}/${name}.png`, fullPage: true }); } async function setImageFixture(page) { await page.evaluate((imageSrc) => { const editor = document.querySelector('.editor-surface .ProseMirror')?.editor; if (!editor) throw new Error("找不到 Tiptap editor"); editor.commands.setContent({ type: "doc", content: [ { type: "paragraph", content: [{ type: "text", text: "task801 image preview fixture" }] }, { type: "image", attrs: { src: imageSrc, alt: "E24 图片占位", title: "E24 图片", "data-align": "center" } }, ], }, true); editor.commands.focus("start"); }, IMAGE_SRC); await page.waitForFunction((imageSrc) => { const image = document.querySelector(`.editor-surface .ProseMirror img[src="${imageSrc}"]`); return image instanceof HTMLImageElement && image.naturalWidth > 0 && image.naturalHeight > 0; }, IMAGE_SRC, { timeout: UI_TIMEOUT_MS }); } async function main() { assertImagePreviewSourceBoundary(); const target = createLocalFixture(); const server = await startLocalServer(target.dataRoot); const browser = await chromium.launch({ headless: true }); const context = await browser.newContext({ viewport: { width: 1440, height: 960 }, acceptDownloads: true, extraHTTPHeaders: { "x-mnote-actor-id": ACTOR_ID, "x-mnote-actor-type": "user", }, }); const page = await context.newPage(); try { const url = documentUrl(target.root, target.relativePath); const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); assert(response, "文档页没有返回响应"); assert.equal(response.status(), 200, `文档页状态码异常: ${response.status()}`); const editor = await waitForRuntimeIsland(page); await setImageFixture(page); const beforeUrl = page.url(); const beforeLayout = await page.evaluate(() => { const sidebar = document.querySelector(".wolai-sidebar, .mnote-sidebar"); const content = document.querySelector(".mnote-content, .document-workspace"); const sidebarRect = sidebar?.getBoundingClientRect(); const contentRect = content?.getBoundingClientRect(); return { sidebarLeft: sidebarRect?.left ?? null, sidebarTop: sidebarRect?.top ?? null, contentLeft: contentRect?.left ?? null, contentTop: contentRect?.top ?? null, }; }); const image = page.locator('.editor-surface .ProseMirror img[src]').first(); await image.dblclick({ timeout: UI_TIMEOUT_MS }); const dialog = page.locator('[data-testid="image-preview-dialog"]').first(); await dialog.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await screenshot(page, "01-image-preview-open"); assert.equal(page.url(), beforeUrl, "双击图片打开预览不应改变 URL"); assert.equal(await dialog.getAttribute("role"), "dialog", "图片预览应暴露 dialog 语义"); assert.equal(await dialog.getAttribute("aria-modal"), "true", "图片预览应是 modal"); assert.equal(await dialog.getAttribute("data-preview-index"), "0", "单图预览 index 应为 0"); assert.equal(await dialog.getAttribute("data-preview-total"), "1", "单图预览 total 应为 1"); assert.equal(await dialog.locator('[data-testid="image-preview-image"]').first().getAttribute("src"), IMAGE_SRC, "预览图 src 应保持原图"); assert.equal(await dialog.evaluate((node) => Boolean(node.closest('[data-testid="mnote-leptos-tiptap-editor-stage"]'))), false, "预览 dialog 不能挂在 editor-stage 内,否则 fixed 坐标会被外层布局/transform 改写"); const dialogRect = await dialog.evaluate((node) => { const rect = node.getBoundingClientRect(); return { left: rect.left, top: rect.top, width: rect.width, height: rect.height, viewportWidth: window.innerWidth, viewportHeight: window.innerHeight, }; }); assert.ok(Math.abs(dialogRect.left) <= 1 && Math.abs(dialogRect.top) <= 1, `预览遮罩必须从视口左上角开始: ${JSON.stringify(dialogRect)}`); assert.ok(Math.abs(dialogRect.width - dialogRect.viewportWidth) <= 1, `预览遮罩必须覆盖完整视口宽度: ${JSON.stringify(dialogRect)}`); assert.ok(Math.abs(dialogRect.height - dialogRect.viewportHeight) <= 1, `预览遮罩必须覆盖完整视口高度: ${JSON.stringify(dialogRect)}`); for (const testid of [ "image-preview-close", "image-preview-prev", "image-preview-next", "image-preview-zoom-in", "image-preview-zoom-out", "image-preview-one-to-one", "image-preview-rotate", "image-preview-download", "image-preview-fullscreen", ]) { await dialog.locator(`[data-testid="${testid}"]`).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); } assert.equal(await dialog.locator('[data-testid="image-preview-prev"]').first().isDisabled(), true, "单图预览上一张应禁用"); assert.equal(await dialog.locator('[data-testid="image-preview-next"]').first().isDisabled(), true, "单图预览下一张应禁用"); await dialog.locator('[data-testid="image-preview-zoom-in"]').first().click({ timeout: UI_TIMEOUT_MS }); await page.waitForFunction(() => document.querySelector('[data-testid="image-preview-dialog"]')?.getAttribute("data-preview-zoom") === "1.25", null, { timeout: UI_TIMEOUT_MS }); await dialog.locator('[data-testid="image-preview-rotate"]').first().click({ timeout: UI_TIMEOUT_MS }); await page.waitForFunction(() => document.querySelector('[data-testid="image-preview-dialog"]')?.getAttribute("data-preview-rotation") === "90", null, { timeout: UI_TIMEOUT_MS }); await dialog.locator('[data-testid="image-preview-one-to-one"]').first().click({ timeout: UI_TIMEOUT_MS }); await page.waitForFunction(() => { const node = document.querySelector('[data-testid="image-preview-dialog"]'); return node?.getAttribute("data-preview-zoom") === "1" && node?.getAttribute("data-preview-rotation") === "0" && node?.getAttribute("data-preview-offset-x") === "0" && node?.getAttribute("data-preview-offset-y") === "0"; }, null, { timeout: UI_TIMEOUT_MS }); const stageBox = await dialog.locator('[data-testid="image-preview-stage"]').first().boundingBox(); assert.ok(stageBox, "预览 stage 应有可交互区域"); const stageCenter = { x: stageBox.x + stageBox.width / 2, y: stageBox.y + stageBox.height / 2, }; await page.mouse.move(stageCenter.x, stageCenter.y); await page.mouse.wheel(0, -240); await page.waitForFunction(() => Number(document.querySelector('[data-testid="image-preview-dialog"]')?.getAttribute("data-preview-zoom") || "1") > 1, null, { timeout: UI_TIMEOUT_MS }); await page.mouse.down({ button: "middle" }); await page.mouse.move(stageCenter.x + 72, stageCenter.y + 38, { steps: 6 }); await page.mouse.up({ button: "middle" }); await page.waitForFunction(() => { const node = document.querySelector('[data-testid="image-preview-dialog"]'); return Math.abs(Number(node?.getAttribute("data-preview-offset-x") || "0")) >= 60 && Math.abs(Number(node?.getAttribute("data-preview-offset-y") || "0")) >= 30; }, null, { timeout: UI_TIMEOUT_MS }); const afterLayout = await page.evaluate(() => { const sidebar = document.querySelector(".wolai-sidebar, .mnote-sidebar"); const content = document.querySelector(".mnote-content, .document-workspace"); const sidebarRect = sidebar?.getBoundingClientRect(); const contentRect = content?.getBoundingClientRect(); return { sidebarLeft: sidebarRect?.left ?? null, sidebarTop: sidebarRect?.top ?? null, contentLeft: contentRect?.left ?? null, contentTop: contentRect?.top ?? null, }; }); for (const key of ["sidebarLeft", "sidebarTop", "contentLeft", "contentTop"]) { if (beforeLayout[key] === null || afterLayout[key] === null) continue; assert.ok(Math.abs(beforeLayout[key] - afterLayout[key]) <= 1, `预览交互不应导致左侧框架/正文布局漂移 ${key}: before=${beforeLayout[key]} after=${afterLayout[key]}`); } const downloadPromise = page.waitForEvent("download", { timeout: UI_TIMEOUT_MS }); await dialog.locator('[data-testid="image-preview-download"]').first().click({ timeout: UI_TIMEOUT_MS }); const download = await downloadPromise; assert(/E24.*\.svg$/i.test(download.suggestedFilename()), `图片预览下载文件名异常: ${download.suggestedFilename()}`); await download.delete().catch(() => undefined); await page.keyboard.press("Escape"); await dialog.waitFor({ state: "detached", timeout: UI_TIMEOUT_MS }); await image.dblclick({ timeout: UI_TIMEOUT_MS }); await dialog.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await dialog.locator('[data-testid="image-preview-close"]').first().click({ timeout: UI_TIMEOUT_MS }); await dialog.waitFor({ state: "detached", timeout: UI_TIMEOUT_MS }); await editor.click({ timeout: UI_TIMEOUT_MS }); assert.equal(await page.locator('[data-testid="image-preview-dialog"]').count(), 0, "关闭预览后不应残留 dialog"); console.log(JSON.stringify({ ok: true, baseUrl: BASE_URL, documentId: target.documentId, root: target.root, screenshotDir: SCREENSHOT_DIR }, null, 2)); } finally { await page.close().catch(() => undefined); await context.close().catch(() => undefined); await browser.close().catch(() => undefined); if (server?.pid) { server.kill("SIGTERM"); setTimeout(() => { if (!server.killed) server.kill("SIGKILL"); }, 2000).unref(); } } } if (require.main === module) { main().catch((error) => { console.error(error instanceof Error ? error.stack || error.message : String(error)); process.exit(1); }); }