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

439 lines
16 KiB
JavaScript

#!/usr/bin/env node
"use strict";
const { loginViaAuthForm } = require('./lib/browser-auth-login');
/**
* Minimal Browser QA: Block handle menu icon/layout check
* Scope: open editor → hover paragraph handle → click handle → check menu
* NO expansion to multiple block types, pages, or features.
*/
const fs = require("node:fs");
const path = require("node:path");
const { chromium } = require("playwright");
const BASE = process.env.MNOTE_BASE || "http://localhost:3000";
const EVIDENCE_DIR = path.join(__dirname, "..", "tmp", "browser-qa-evidence");
const STEPS = [];
let status = "PASS";
fs.mkdirSync(EVIDENCE_DIR, { recursive: true });
async function screenshot(page, name) {
const fp = path.join(EVIDENCE_DIR, `${name}.png`);
await page.screenshot({ path: fp, fullPage: false });
const size = fs.statSync(fp).size;
console.log(` 📸 ${name}.png (${(size / 1024).toFixed(1)} KB)`);
return fp;
}
function step(id, desc) {
return { id, desc, status: "pending", evidence: [] };
}
async function run() {
const browser = await chromium.launch({
headless: true,
executablePath:
process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE ||
(fs.existsSync("/usr/bin/chromium-browser") ? "/usr/bin/chromium-browser" : "") ||
(fs.existsSync("/usr/bin/chromium") ? "/usr/bin/chromium" : "") ||
(fs.existsSync("/usr/bin/google-chrome") ? "/usr/bin/google-chrome" : ""),
args: ["--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"],
});
const context = await browser.newContext({
viewport: { width: 1280, height: 800 },
locale: "zh-CN",
});
const page = await context.newPage();
// Collect console errors
const consoleErrors = [];
page.on("console", (msg) => {
if (msg.type() === "error") {
consoleErrors.push(msg.text().slice(0, 200));
}
});
const networkErrors = [];
page.on("requestfailed", (req) => {
networkErrors.push(`${req.failure().errorText} ${req.url().slice(0, 120)}`);
});
try {
// ── Step 1: Login ──
let s1 = step("login", "Login via standard auth form (7-76 P0)");
console.log(`\n🔍 Step 1: ${s1.desc}`);
await page.goto(`${BASE}/auth`, { waitUntil: "networkidle", timeout: 15000 });
await loginViaAuthForm(page, { baseUrl: BASE, timeoutMs: 15000, gotoAuth: false });
await page
.waitForURL((url) => !url.pathname.includes("/auth"), { timeout: 15000 })
.catch(() => {});
await page.waitForTimeout(2000);
const afterLogin = page.url();
s1.status = afterLogin.includes("/auth") ? "FAIL" : "PASS";
s1.evidence.push(await screenshot(page, "01-after-login"));
console.log(` URL: ${afterLogin}${s1.status}`);
STEPS.push(s1);
if (s1.status === "FAIL") {
console.log("\n❌ Login failed, aborting.");
status = "FAIL";
printResult();
return;
}
// ── Step 2: Navigate to a page with content ──
let s2 = step("navigate", "Navigate to an existing page with paragraph content");
console.log(`\n🔍 Step 2: ${s2.desc}`);
await page.waitForTimeout(1500);
s2.evidence.push(await screenshot(page, "02-main-screen"));
// The sidebar lists page items under "我的页面". Click the first one.
// Sidebar items are typically list items or links inside the sidebar.
const sidebarPageItem = page.locator('[data-testid*="sidebar"] li, [data-testid*="sidebar"] a, [class*="sidebar"] li, [class*="sidebar"] a, [class*="page-tree"] li, [class*="page-tree"] a, nav li a').first();
const sidebarVisible = await sidebarPageItem.isVisible({ timeout: 5000 }).catch(() => false);
console.log(` Sidebar item visible: ${sidebarVisible}`);
let foundPage = false;
if (sidebarVisible) {
const itemText = await sidebarPageItem.textContent();
console.log(` Clicking sidebar item: "${itemText}"`);
await sidebarPageItem.click();
await page.waitForTimeout(2500);
foundPage = true;
} else {
// Fallback: try to click on any visible text that looks like a page name in the main area
const pageLink = page.locator('text=有机合成中的保护基').first();
if (await pageLink.isVisible({ timeout: 3000 }).catch(() => false)) {
console.log(` Clicking page link in main area`);
await pageLink.click();
await page.waitForTimeout(2500);
foundPage = true;
}
}
// Check if we're now in an editor (ProseMirror / contenteditable visible)
const editorCheck = await page.locator('.ProseMirror, [contenteditable="true"], .tiptap').first()
.isVisible({ timeout: 3000 }).catch(() => false);
console.log(` Editor visible after nav: ${editorCheck}`);
if (!editorCheck && foundPage) {
// Maybe we landed on a file-explorer view, try clicking the first .md page in the list
const mdLink = page.locator('text=有机合成中的保护基').first();
if (await mdLink.isVisible({ timeout: 2000 }).catch(() => false)) {
await mdLink.click();
await page.waitForTimeout(2500);
}
}
s2.status = foundPage ? "PASS" : "BLOCKED";
s2.evidence.push(await screenshot(page, "03-page-loaded"));
console.log(` Found page: ${foundPage}${s2.status}`);
STEPS.push(s2);
if (s2.status !== "PASS") {
status = s2.status;
printResult();
return;
}
// ── Step 3: Locate paragraph block and hover handle ──
let s3 = step("hover-handle", "Hover paragraph block left handle to reveal drag handle icon");
console.log(`\n🔍 Step 3: ${s3.desc}`);
// Wait for editor content to be ready
await page.waitForTimeout(1500);
// tiptap editor renders paragraphs inside .ProseMirror or similar
// The block handle (drag handle / menu trigger) typically appears on hover
// Common selectors for tiptap paragraph blocks:
const editorArea = page.locator('.ProseMirror, [contenteditable="true"], .tiptap, [data-testid*="editor"]').first();
const editorVisible = await editorArea.isVisible({ timeout: 5000 }).catch(() => false);
console.log(` Editor area visible: ${editorVisible}`);
if (!editorVisible) {
s3.status = "BLOCKED";
s3.evidence.push(await screenshot(page, "04-no-editor"));
console.log(" ❌ No editor area found");
STEPS.push(s3);
status = "BLOCKED";
printResult();
return;
}
// Find a paragraph element inside the editor
const paragraph = page.locator('.ProseMirror p, .tiptap p, [contenteditable="true"] p').first();
const paraVisible = await paragraph.isVisible({ timeout: 5000 }).catch(() => false);
console.log(` Paragraph visible: ${paraVisible}`);
if (!paraVisible) {
s3.status = "BLOCKED";
s3.evidence.push(await screenshot(page, "04b-no-paragraph"));
console.log(" ❌ No paragraph found in editor");
STEPS.push(s3);
status = "BLOCKED";
printResult();
return;
}
// Hover near the left edge of the paragraph to trigger handle appearance
const paraBox = await paragraph.boundingBox();
console.log(` Paragraph box: ${JSON.stringify(paraBox)}`);
// Hover at the left edge of the paragraph (where the handle appears)
await page.mouse.move(paraBox.x - 5, paraBox.y + paraBox.height / 2, { steps: 10 });
await page.waitForTimeout(800);
// Take screenshot to see if handle appeared
s3.evidence.push(await screenshot(page, "05-after-hover-handle"));
// Look for handle / drag handle element
// Common handle selectors in tiptap/leptos-tiptap
const handleSelectors = [
'[data-testid*="handle"]',
'[data-testid*="drag"]',
'.block-handle',
'.drag-handle',
'.ProseMirror .handle',
'.ProseMirror [contenteditable="false"]',
'.tiptap .handle',
'.tiptap .drag-handle',
'button[aria-label*="handle" i]',
'button[aria-label*="drag" i]',
'button[aria-label*="menu" i]',
'[class*="handle"]',
'[class*="Handle"]',
'[class*="drag-handle"]',
'[class*="block-handle"]',
'.leptos-tiptap-handle',
'[data-drag-handle]',
'.ProseMirror > div:first-child .handle',
];
let handleEl = null;
let matchedSelector = null;
for (const sel of handleSelectors) {
const el = page.locator(sel).first();
if (await el.isVisible({ timeout: 500 }).catch(() => false)) {
handleEl = el;
matchedSelector = sel;
break;
}
}
if (handleEl) {
console.log(` ✅ Handle found with selector: ${matchedSelector}`);
s3.status = "PASS";
} else {
console.log(" ⚠️ No handle element found via standard selectors, checking DOM...");
// Dump DOM near paragraph for debugging
const parentHTML = await paragraph.evaluate((el) => {
return el.parentElement?.outerHTML?.slice(0, 2000) || "N/A";
});
console.log(` Parent HTML: ${parentHTML.slice(0, 500)}`);
// Also check if handle is inside a wrapper
const wrapperHandle = await paragraph.evaluate((el) => {
const wrapper = el.closest('[class*="block"], [class*="node"], [data-node-type], [data-type]');
if (wrapper) {
return wrapper.outerHTML.slice(0, 1500);
}
return null;
});
if (wrapperHandle) {
console.log(` Wrapper HTML: ${wrapperHandle.slice(0, 500)}`);
}
s3.status = "FAIL";
s3.evidence.push(await screenshot(page, "05b-no-handle-visible"));
}
STEPS.push(s3);
// ── Step 4: Click handle to open block menu ──
let s4 = step("click-handle", "Click handle to open block context menu");
console.log(`\n🔍 Step 4: ${s4.desc}`);
if (!handleEl) {
s4.status = "BLOCKED";
s4.evidence.push(await screenshot(page, "06-no-handle-to-click"));
console.log(" ⏭️ Skipped: no handle found");
STEPS.push(s4);
} else {
await handleEl.click();
await page.waitForTimeout(800);
s4.evidence.push(await screenshot(page, "06-after-click-handle"));
// Look for menu that appeared
const menuSelectors = [
'[data-testid*="menu"]',
'[data-testid*="slash"]',
'[role="menu"]',
'[role="listbox"]',
'.block-menu',
'.slash-menu',
'[class*="menu"]',
'[class*="Menu"]',
'[class*="popup"]',
'[class*="dropdown"]',
'.ProseMirror [contenteditable="false"] [role]',
];
let menuEl = null;
let menuSelector = null;
for (const sel of menuSelectors) {
const el = page.locator(sel).first();
if (await el.isVisible({ timeout: 500 }).catch(() => false)) {
menuEl = el;
menuSelector = sel;
break;
}
}
if (menuEl) {
console.log(` ✅ Menu appeared with selector: ${menuSelector}`);
s4.status = "PASS";
// Check menu contents - icons and layout
const menuItems = await menuEl.locator('[role="menuitem"], [role="option"], li, button').all();
console.log(` Menu items count: ${menuItems.length}`);
// Check for icons within menu items
let iconsFound = 0;
for (const item of menuItems.slice(0, 10)) {
const hasIcon = await item.locator('svg, img, [class*="icon"], [class*="Icon"]').count();
if (hasIcon > 0) iconsFound++;
}
console.log(` Items with icons: ${iconsFound}/${Math.min(menuItems.length, 10)}`);
s4.evidence.push(await screenshot(page, "07-menu-open-detail"));
// Dump menu DOM for evidence
const menuHTML = await menuEl.evaluate((el) => el.outerHTML.slice(0, 3000));
const menuTexts = await menuEl.evaluate((el) => {
return Array.from(el.querySelectorAll('*')).map(e => e.textContent?.trim()).filter(Boolean).slice(0, 30);
});
console.log(` Menu texts: ${JSON.stringify(menuTexts.slice(0, 15))}`);
} else {
console.log(" ⚠️ No menu appeared after clicking handle");
s4.status = "FAIL";
// Check if maybe a different element appeared
const anyPopup = await page.locator('[class*="popup"], [class*="overlay"], [class*="modal"], [class*="dropdown"], [class*="tooltip"]').first();
const popupVisible = await anyPopup.isVisible({ timeout: 500 }).catch(() => false);
console.log(` Any popup visible: ${popupVisible}`);
s4.evidence.push(await screenshot(page, "07b-no-menu"));
}
STEPS.push(s4);
}
// ── Step 5: Layout / visual check ──
let s5 = step("layout-check", "Check handle icon, menu icons and menu layout");
console.log(`\n🔍 Step 5: ${s5.desc}`);
if (s4.status !== "PASS") {
s5.status = "BLOCKED";
s5.evidence.push(await screenshot(page, "08-layout-check-skipped"));
console.log(" ⏭️ Skipped: menu not confirmed open");
} else {
// Check menu layout: bounding box, items alignment
const menuBox = await menuEl.boundingBox();
console.log(` Menu bounding box: ${JSON.stringify(menuBox)}`);
// Verify menu is not overlapping editor content badly
const editorBox = await editorArea.boundingBox();
if (menuBox && editorBox) {
const isOnScreen = menuBox.x >= 0 && menuBox.y >= 0 &&
menuBox.x + menuBox.width <= 1280 && menuBox.y + menuBox.height <= 800;
const hasReasonableSize = menuBox.width > 50 && menuBox.height > 30;
console.log(` On screen: ${isOnScreen}, Reasonable size: ${hasReasonableSize}`);
s5.status = (isOnScreen && hasReasonableSize) ? "PASS" : "FAIL";
} else {
s5.status = "FAIL";
}
s5.evidence.push(await screenshot(page, "08-layout-final"));
}
STEPS.push(s5);
// Console/network summary
printResult(consoleErrors, networkErrors);
} catch (err) {
console.error(`\n💥 Fatal error: ${err.message}`);
status = "FAIL";
try { await screenshot(page, "99-fatal-error"); } catch (_) {}
printResult(consoleErrors, networkErrors, err);
} finally {
await browser.close();
}
}
function printResult(consoleErrors = [], networkErrors = [], fatalErr = null) {
const allPass = STEPS.every((s) => s.status === "PASS" || s.status === "no-op");
const anyBlocked = STEPS.some((s) => s.status === "BLOCKED");
if (!fatalErr && allPass) status = "PASS";
else if (anyBlocked) status = "BLOCKED";
else if (fatalErr) status = "FAIL";
console.log("\n" + "═".repeat(70));
console.log("BROWSER_QA_RESULT");
console.log("═".repeat(70));
console.log(`STATUS: ${status}`);
console.log(`steps_run: ${STEPS.length}`);
console.log(`steps_detail:`);
for (const s of STEPS) {
console.log(` ${s.id}: ${s.status}${s.desc}`);
}
console.log(`actual_vs_expected:`);
for (const s of STEPS) {
const expected = s.id === "login" ? "Redirected to workspace"
: s.id === "navigate" ? "Editor page loaded with paragraph content"
: s.id === "hover-handle" ? "Drag handle icon visible on left of paragraph"
: s.id === "click-handle" ? "Block context menu opens with icons and proper layout"
: s.id === "layout-check" ? "Menu on screen, reasonable size, icons present"
: "N/A";
console.log(` ${s.id}: expected="${expected}" actual="${s.status}"`);
}
console.log(`evidence_paths:`);
for (const s of STEPS) {
for (const e of s.evidence) {
console.log(` ${s.id}: ${e}`);
}
}
console.log(`console_network_summary:`);
console.log(` console_errors: ${consoleErrors.length}`);
for (const e of consoleErrors.slice(0, 5)) console.log(` - ${e}`);
console.log(` network_errors: ${networkErrors.length}`);
for (const e of networkErrors.slice(0, 5)) console.log(` - ${e}`);
const hasHandleBug = STEPS.some(s =>
(s.id === "hover-handle" && s.status === "FAIL") ||
(s.id === "click-handle" && s.status === "FAIL")
);
console.log(`candidate_bug:`);
if (hasHandleBug) {
console.log(` title: "Block handle icon not visible or block menu does not open on handle click"`);
console.log(` scope: "leptos-tiptap editor paragraph block handle interaction"`);
console.log(` symptoms: "Handle icon does not appear on hover, or clicking handle does not open context menu"`);
} else if (status === "BLOCKED") {
console.log(` title: "BLOCKED - Could not reach editor or find paragraph block"`);
console.log(` scope: "test infrastructure / page navigation"`);
} else {
console.log(` title: "none detected in this run"`);
}
console.log(`needs_triage: ${hasHandleBug || status === "BLOCKED" ? "YES" : "NO"}`);
console.log("═".repeat(70));
}
run().catch((err) => {
console.error("Unhandled:", err);
process.exit(1);
});