2026-07-10 10:54:34 +08:00
#!/usr/bin/env node
"use strict" ;
2026-07-28 17:04:27 +08:00
const { loginViaAuthForm } = require ( './lib/browser-auth-login' );
2026-07-10 10:54:34 +08:00
/**
* 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 ──
2026-07-28 17:04:27 +08:00
let s1 = step ( "login" , "Login via standard auth form (7-76 P0)" );
2026-07-10 10:54:34 +08:00
console . log ( `\n🔍 Step 1: ${ s1 . desc } ` );
await page . goto ( ` ${ BASE } /auth` , { waitUntil : "networkidle" , timeout : 15000 });
2026-07-28 17:04:27 +08:00
await loginViaAuthForm ( page , { baseUrl : BASE , timeoutMs : 15000 , gotoAuth : false });
await page
. waitForURL (( url ) => ! url . pathname . includes ( "/auth" ), { timeout : 15000 })
. catch (() => {});
2026-07-10 10:54:34 +08:00
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 );
});