339 lines
16 KiB
JavaScript
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);
|
|
});
|
|
}
|