Persist PageTree expand state via control-plane view-state and align chevron/DOM with restored expansion; keep Sidex-style shallow page-tree scan and drop the unused recursive scanner that only added cargo noise. Add password vault workbench routes/runtime/skill/CLI, split page_ai_pi into a module package, and retire Hermes/ACP/OpenHub recycle + root harness evidence from the index while gitignoring recycle and local diag dumps. Archive superseded design/bugs docs under old/, point architecture at ARCHITECTURE.md, and refresh smokes for Pi S1–S7, vault, and editor regressions so the working tree can stay clean.
702 lines
22 KiB
JavaScript
702 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 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);
|
|
});
|