439 lines
16 KiB
JavaScript
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);
|
|
});
|