#!/usr/bin/env node "use strict"; const { loginViaAuthForm } = require('./lib/browser-auth-login'); const path = require("path"); const { chromium } = require("playwright"); const TIMEOUT_MS = 30_000; const SCREENSHOT_DIR = path.join( process.env.MNOTE_QA_SCREENSHOT_DIR || path.resolve(__dirname, "../tmp/qa-block-handle"), ); const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); const TEST_EMAIL = "mnote.e2e@example.com"; const TEST_PASSWORD = "MnoteE2E123!"; const WORKSPACE_DIR = path.join( process.env.MNOTE_QA_WORKSPACE_DIR || path.resolve(__dirname, "../tmp/mnote-qa-handle"), ); const TEST_MARKDOWN = `\ # 块手柄测试页 第一段正文,用于测试段落块的手柄和菜单。 第二段正文,用于测试第二个段落块。 ## 二级标题块 标题下方正文段落。 - 列表项 A - 列表项 B \`\`\` 代码块示例 \`\`\` `; function uniqueSuffix() { return Math.random().toString(36).slice(2, 8); } function ensureDirSync(dir) { const fs = require("fs"); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); } function writeWsManifest() { const fs = require("fs"); ensureDirSync(path.join(WORKSPACE_DIR, ".mnote")); fs.writeFileSync( path.join(WORKSPACE_DIR, ".mnote/workspace.json"), JSON.stringify({ name: "QA Block Handle Test", version: 1, createdAt: new Date().toISOString(), }), ); fs.writeFileSync(path.join(WORKSPACE_DIR, "BlockHandleTest.md"), TEST_MARKDOWN); } async function ensureAuthenticated(page, requestContext) { const response = await requestContext.fetch(`${BASE_URL}/api/auth/whoami`, { method: "GET", timeout: 10_000, }); const body = await response.json().catch(() => null); if (body?.user) return body.user; await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: TIMEOUT_MS }); await page.waitForTimeout(1000); await loginViaAuthForm(page, { baseUrl: BASE_URL, email: TEST_EMAIL, password: TEST_PASSWORD, timeoutMs: TIMEOUT_MS, gotoAuth: false, }); await page.waitForURL((url) => !String(url).includes("/auth"), { timeout: TIMEOUT_MS }).catch(() => {}); await page.waitForTimeout(2000); return null; } async function main() { const fs = require("fs"); ensureDirSync(SCREENSHOT_DIR); writeWsManifest(); const suffix = uniqueSuffix(); const rootUri = `file://${WORKSPACE_DIR}`; const docUrl = `${BASE_URL}/documents/local-md%3ABlockHandleTest.md` + `?sourceKind=local_folder&rootUri=${encodeURIComponent(rootUri)}&treeView=filetree`; const browser = await chromium.launch({ headless: true }); const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2, }); const page = await context.newPage(); page.setDefaultTimeout(TIMEOUT_MS); const consoleErrors = []; page.on("console", (msg) => { if (msg.type() === "error") consoleErrors.push(msg.text()); }); const result = { pass: false, steps: [], screenshots: [], consoleErrors, failures: [], }; const step = (name, extra = {}) => { const s = { step: name, status: "ok", ...extra }; result.steps.push(s); return s; }; const fail = (name, message, extra = {}) => { const s = { step: name, status: "fail", message, ...extra }; result.steps.push(s); result.failures.push({ step: name, status: "fail", ...extra }); return s; }; try { await ensureAuthenticated(page, page.request); step("auth"); await page.goto(docUrl, { waitUntil: "domcontentloaded", timeout: TIMEOUT_MS }); await page .locator('.tiptap.ProseMirror, [contenteditable="true"]') .first() .waitFor({ state: "visible", timeout: TIMEOUT_MS }) .catch(() => {}); await page.waitForTimeout(2000); step("navigate", { url: docUrl }); // ─────────────── Helper functions ─────────────── async function screenshot(name) { const filePath = path.join(SCREENSHOT_DIR, `${name}.png`); await page.screenshot({ path: filePath, fullPage: false }); result.screenshots.push(filePath); return filePath; } async function getEditor() { const editor = page.locator('.tiptap.ProseMirror, [contenteditable="true"]').first(); await editor.waitFor({ state: "visible", timeout: 5000 }); return editor; } async function getAllBlocks() { const editor = await getEditor(); const children = editor.locator("> *").filter({ has: page.locator( ".ProseMirror-gapcursor, .ProseMirror-selection, .ProseMirror-cursor", ), }); // Fallback: just get top-level children const blocks = editor.locator("> *"); const count = await blocks.count(); return { editor, blocks, count }; } async function closeAnyOpenMenu() { await page.keyboard.press("Escape"); await page.waitForTimeout(300); } async function getBlockHandle() { // The block handle (grip icon) appears on hover near the left edge of a block // It's typically a div with a drag handle icon const handle = page.locator('[class*="block-handle"], [class*="drag-handle"], [data-block-handle]').first(); if (await handle.isVisible().catch(() => false)) return handle; // Try finding by the grip icon pattern const grip = page.locator('.editor-block-handle, .ProseMirror .block-handle, .ProseMirror [contenteditable] > div > div:first-child').first(); return grip; } async function findHandleNearBlock(blockEl, blockIndex) { // Hover over the block to trigger handle visibility const box = await blockEl.boundingBox(); if (!box) return null; // Hover at the left edge of the block to trigger the handle await page.mouse.move(box.x - 10, box.y + box.height / 2); await page.waitForTimeout(500); // Look for visible handles const handles = page.locator('[class*="block-handle"], [class*="drag-handle"], .drag-handle, .block-handle-wrapper, [data-testid*="handle"]'); const count = await handles.count(); for (let i = 0; i < count; i++) { const h = handles.nth(i); if (await h.isVisible().catch(() => false)) { return h; } } return null; } async function openMenuViaHover(blockEl) { const box = await blockEl.boundingBox(); if (!box) throw new Error("Block has no bounding box"); // Move to left edge to trigger handle await page.mouse.move(box.x - 5, box.y + box.height / 2); await page.waitForTimeout(500); // Find the visible handle const handles = page.locator('[class*="block-handle"], [class*="drag-handle"], .drag-handle, .block-handle-wrapper, [data-testid*="handle"]'); const count = await handles.count(); let handle = null; for (let i = 0; i < count; i++) { const h = handles.nth(i); if (await h.isVisible().catch(() => false)) { handle = h; break; } } if (!handle) { // Try clicking at the left edge area await page.mouse.click(box.x - 2, box.y + box.height / 2); await waitForMenuVisible(); return; } // Click the handle to open the menu const handleBox = await handle.boundingBox(); if (handleBox) { await page.mouse.click(handleBox.x + handleBox.width / 2, handleBox.y + handleBox.height / 2); } else { await handle.click(); } await waitForMenuVisible(); } async function waitForMenuVisible() { // Wait for menu container to appear await page.waitForTimeout(500); // Try various menu selectors const menuSelectors = [ '[class*="block-menu"]', '[class*="block-action"]', '[class*="slash-menu"]', '[class*="floating-menu"]', '[role="menu"]', '[data-testid*="menu"]', '.ProseMirror [contenteditable="false"] [class*="menu"]', ]; for (const sel of menuSelectors) { const el = page.locator(sel).first(); if (await el.isVisible({ timeout: 1000 }).catch(() => false)) { return el; } } return null; } // ─────────────── TEST 1: Block Handle Visibility ─────────────── { const { editor, blocks, count } = await getAllBlocks(); step("editor-visible", { blockCount: count }); if (count === 0) { throw new Error("No blocks found in editor"); } // Test first paragraph block const firstBlock = blocks.nth(0); const firstBox = await firstBlock.boundingBox(); if (!firstBox) { throw new Error("First block has no bounding box"); } // Hover over left edge of first block await page.mouse.move(firstBox.x - 5, firstBox.y + firstBox.height / 2); await page.waitForTimeout(800); await screenshot("01-handle-visible"); // Check if any handle appeared const handleSelectors = [ '[class*="block-handle"]', '[class*="drag-handle"]', '.drag-handle', '[data-testid*="handle"]', 'svg[data-block-handle]', '[contenteditable="true"] ~ div', ]; let handleFound = false; for (const sel of handleSelectors) { const els = page.locator(sel); const cnt = await els.count(); for (let i = 0; i < cnt; i++) { if (await els.nth(i).isVisible().catch(() => false)) { handleFound = true; break; } } if (handleFound) break; } if (handleFound) { step("handle-visible", { selector: "matched" }); } else { fail("handle-visible", "Block handle did not appear on hover"); } } // ─────────────── TEST 2: Open Menu ─────────────── { const { editor, blocks, count } = await getAllBlocks(); const firstBlock = blocks.nth(0); const firstBox = await firstBlock.boundingBox(); if (firstBox) { // Hover to show handle await page.mouse.move(firstBox.x - 5, firstBox.y + firstBox.height / 2); await page.waitForTimeout(600); // Find handle and click it const handles = page.locator('[class*="block-handle"], [class*="drag-handle"], .drag-handle, [data-testid*="handle"]'); const hCount = await handles.count(); let clicked = false; for (let i = 0; i < hCount; i++) { const h = handles.nth(i); if (await h.isVisible().catch(() => false)) { const hBox = await h.boundingBox(); if (hBox) { await page.mouse.click(hBox.x + hBox.width / 2, hBox.y + hBox.height / 2); clicked = true; } break; } } if (!clicked) { // Fallback: click near the left edge await page.mouse.click(firstBox.x - 2, firstBox.y + firstBox.height / 2); } await page.waitForTimeout(800); await screenshot("02-menu-opened"); // Find the menu const menuSelectors = [ '[class*="block-menu"]', '[class*="block-action"]', '[class*="slash-menu"]', '[class*="floating-menu"]', '[role="menu"]', ]; let menuEl = null; for (const sel of menuSelectors) { const el = page.locator(sel).first(); if (await el.isVisible({ timeout: 1000 }).catch(() => false)) { menuEl = el; break; } } if (menuEl) { const menuBox = await menuEl.boundingBox(); step("menu-opened", { width: Math.round(menuBox?.width || 0), height: Math.round(menuBox?.height || 0), }); // Measure menu items const menuItems = menuEl.locator('[class*="menu-item"], [role="menuitem"], [class*="action-item"]'); const itemCount = await menuItems.count(); step("menu-item-count", { count: itemCount }); // Check menu items text const itemTexts = []; for (let i = 0; i < Math.min(itemCount, 20); i++) { const text = await menuItems.nth(i).innerText().catch(() => ""); itemTexts.push(text.trim()); } step("menu-items-text", { items: itemTexts }); await screenshot("03-menu-detail"); } else { fail("menu-opened", "Menu did not appear after clicking handle"); } // Close menu await page.keyboard.press("Escape"); await page.waitForTimeout(300); } } // ─────────────── TEST 3: Heading Block Handle ─────────────── { const { editor, blocks, count } = await getAllBlocks(); // Find the heading block (h2) let headingBlock = null; for (let i = 0; i < count; i++) { const block = blocks.nth(i); const tagName = await block.evaluate((el) => el.tagName.toLowerCase()); if (tagName === "h2" || tagName === "h1" || tagName === "h3") { headingBlock = block; break; } } if (!headingBlock) { fail("heading-block", "Could not find heading block"); } else { const hBox = await headingBlock.boundingBox(); if (hBox) { // Hover over heading block await page.mouse.move(hBox.x - 5, hBox.y + hBox.height / 2); await page.waitForTimeout(600); await screenshot("04-heading-hover"); // Find and click handle const handles = page.locator('[class*="block-handle"], [class*="drag-handle"], .drag-handle, [data-testid*="handle"]'); const hCount = await handles.count(); let clicked = false; for (let i = 0; i < hCount; i++) { const h = handles.nth(i); if (await h.isVisible().catch(() => false)) { const hBox2 = await h.boundingBox(); if (hBox2) { await page.mouse.click(hBox2.x + hBox2.width / 2, hBox2.y + hBox2.height / 2); clicked = true; } break; } } if (!clicked) { await page.mouse.click(hBox.x - 2, hBox.y + hBox.height / 2); } await page.waitForTimeout(800); await screenshot("05-heading-menu"); // Find the menu const menuSelectors = [ '[class*="block-menu"]', '[class*="block-action"]', '[class*="slash-menu"]', '[class*="floating-menu"]', '[role="menu"]', ]; let menuEl = null; for (const sel of menuSelectors) { const el = page.locator(sel).first(); if (await el.isVisible({ timeout: 1000 }).catch(() => false)) { menuEl = el; break; } } if (menuEl) { const menuBox = await menuEl.boundingBox(); step("heading-menu-opened", { width: Math.round(menuBox?.width || 0), height: Math.round(menuBox?.height || 0), }); // Check menu items for heading-specific items const menuItems = menuEl.locator('[class*="menu-item"], [role="menuitem"], [class*="action-item"]'); const itemCount = await menuItems.count(); step("heading-menu-item-count", { count: itemCount }); // Get menu items text const itemTexts = []; for (let i = 0; i < Math.min(itemCount, 20); i++) { const text = await menuItems.nth(i).innerText().catch(() => ""); itemTexts.push(text.trim()); } step("heading-menu-items-text", { items: itemTexts }); await screenshot("06-heading-menu-detail"); } else { fail("heading-menu-opened", "Menu did not appear for heading block"); } await page.keyboard.press("Escape"); await page.waitForTimeout(300); } } } // ─────────────── TEST 4: List Block Handle ─────────────── { const { editor, blocks, count } = await getAllBlocks(); // Find list item block let listBlock = null; for (let i = 0; i < count; i++) { const block = blocks.nth(i); const tagName = await block.evaluate((el) => el.tagName.toLowerCase()); if (tagName === "li" || tagName === "ul" || tagName === "ol") { listBlock = block; break; } } if (!listBlock) { fail("list-block", "Could not find list block"); } else { const lBox = await listBlock.boundingBox(); if (lBox) { await page.mouse.move(lBox.x - 5, lBox.y + lBox.height / 2); await page.waitForTimeout(600); await screenshot("06-list-hover"); const handles = page.locator('[class*="block-handle"], [class*="drag-handle"], .drag-handle, [data-testid*="handle"]'); const hCount = await handles.count(); let clicked = false; for (let i = 0; i < hCount; i++) { const h = handles.nth(i); if (await h.isVisible().catch(() => false)) { const hBox = await h.boundingBox(); if (hBox) { await page.mouse.click(hBox.x + hBox.width / 2, hBox.y + hBox.height / 2); clicked = true; } break; } } if (!clicked) { await page.mouse.click(lBox.x - 2, lBox.y + lBox.height / 2); } await page.waitForTimeout(800); await screenshot("07-list-menu"); const menuSelectors = [ '[class*="block-menu"]', '[class*="block-action"]', '[class*="slash-menu"]', '[class*="floating-menu"]', '[role="menu"]', ]; let menuEl = null; for (const sel of menuSelectors) { const el = page.locator(sel).first(); if (await el.isVisible({ timeout: 1000 }).catch(() => false)) { menuEl = el; break; } } if (menuEl) { const menuBox = await menuEl.boundingBox(); step("list-menu-opened", { width: Math.round(menuBox?.width || 0), height: Math.round(menuBox?.height || 0), }); const menuItems = menuEl.locator('[class*="menu-item"], [role="menuitem"], [class*="action-item"]'); const itemCount = await menuItems.count(); step("list-menu-item-count", { count: itemCount }); await screenshot("08-list-menu-detail"); } else { fail("list-menu-opened", "Menu did not appear for list block"); } await page.keyboard.press("Escape"); await page.waitForTimeout(300); } } } // ─────────────── TEST 5: Code Block Handle ─────────────── { const { editor, blocks, count } = await getAllBlocks(); let codeBlock = null; for (let i = 0; i < count; i++) { const block = blocks.nth(i); const tagName = await block.evaluate((el) => el.tagName.toLowerCase()); if (tagName === "pre" || tagName === "code") { codeBlock = block; break; } } if (!codeBlock) { fail("code-block", "Could not find code block"); } else { const cBox = await codeBlock.boundingBox(); if (cBox) { await page.mouse.move(cBox.x - 5, cBox.y + cBox.height / 2); await page.waitForTimeout(600); await screenshot("09-code-hover"); const handles = page.locator('[class*="block-handle"], [class*="drag-handle"], .drag-handle, [data-testid*="handle"]'); const hCount = await handles.count(); let clicked = false; for (let i = 0; i < hCount; i++) { const h = handles.nth(i); if (await h.isVisible().catch(() => false)) { const hBox = await h.boundingBox(); if (hBox) { await page.mouse.click(hBox.x + hBox.width / 2, hBox.y + hBox.height / 2); clicked = true; } break; } } if (!clicked) { await page.mouse.click(cBox.x - 2, cBox.y + cBox.height / 2); } await page.waitForTimeout(800); await screenshot("10-code-menu"); const menuSelectors = [ '[class*="block-menu"]', '[class*="block-action"]', '[class*="slash-menu"]', '[class*="floating-menu"]', '[role="menu"]', ]; let menuEl = null; for (const sel of menuSelectors) { const el = page.locator(sel).first(); if (await el.isVisible({ timeout: 1000 }).catch(() => false)) { menuEl = el; break; } } if (menuEl) { const menuBox = await menuEl.boundingBox(); step("code-menu-opened", { width: Math.round(menuBox?.width || 0), height: Math.round(menuBox?.height || 0), }); const menuItems = menuEl.locator('[class*="menu-item"], [role="menuitem"], [class*="action-item"]'); const itemCount = await menuItems.count(); step("code-menu-item-count", { count: itemCount }); await screenshot("11-code-menu-detail"); } else { fail("code-menu-opened", "Menu did not appear for code block"); } await page.keyboard.press("Escape"); await page.waitForTimeout(300); } } } result.pass = result.failures.length === 0; } catch (err) { result.steps.push({ step: "exception", status: "fail", message: String(err) }); await page .screenshot({ path: path.join(SCREENSHOT_DIR, "99-error.png"), fullPage: false }) .catch(() => {}); } finally { await browser.close(); } // Cleanup temp workspace try { const fs = require("fs"); fs.rmSync(WORKSPACE_DIR, { recursive: true, force: true }); } catch {} process.stdout.write(JSON.stringify(result, null, 2) + "\n"); process.exit(result.pass ? 0 : 1); } main().catch((err) => { console.error(err); process.exit(1); });