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