Files
mnote/scripts/task801-image-double-click-preview-smoke.js

339 lines
16 KiB
JavaScript

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