700 lines
22 KiB
JavaScript
700 lines
22 KiB
JavaScript
#!/usr/bin/env node
|
|
"use strict";
|
|
|
|
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);
|
|
const btn = page.getByRole("button", { name: "测试账号快速登录" });
|
|
if (await btn.isVisible().catch(() => false)) {
|
|
await btn.click();
|
|
await page.waitForURL("**/documents/**", { timeout: TIMEOUT_MS }).catch(() => {});
|
|
await page.waitForTimeout(2000);
|
|
} else {
|
|
await page.getByLabel("邮箱").fill(TEST_EMAIL);
|
|
await page.getByLabel("密码").fill(TEST_PASSWORD);
|
|
await page.getByRole("button", { name: /登录|Login|Sign in/i }).click();
|
|
await page.waitForURL("**/documents/**", { 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 screenshot("99-error").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);
|
|
});
|