Files
mnote/scripts/qa-block-handle-menu.js

700 lines
22 KiB
JavaScript

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