// deploy/m3_e2e.js —— M3 综合验收(PM 使用,在远端运行) // 覆盖:KG 自由浏览(真实 Neo4j)、抽取任务 tab(真实 8000)、三档视口布局、主题切换 const { chromium } = require('playwright'); const BASE = 'http://127.0.0.1:5180'; const OUT_DIR = `${__dirname}/../artifacts/m3`; const fs = require('fs'); fs.mkdirSync(OUT_DIR, { recursive: true }); const results = []; function check(name, ok, extra = '') { results.push({ name, ok }); console.log(`${ok ? ' ✅' : ' ❌'} ${name}${extra ? ' —— ' + extra : ''}`); } async function waitFor(fn, timeoutMs, intervalMs = 700, label = '') { const start = Date.now(); while (Date.now() - start < timeoutMs) { try { const v = await fn(); if (v) return v; } catch {} await new Promise((r) => setTimeout(r, intervalMs)); } throw new Error(`等待超时(${timeoutMs}ms): ${label}`); } (async () => { const browser = await chromium.launch(); // ============ 桌面档:KG 浏览 + 抽取 ============ { const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); const page = await ctx.newPage(); const errors = []; page.on('pageerror', (e) => errors.push(e.message)); await page.goto(BASE, { waitUntil: 'domcontentloaded' }); await waitFor(() => page.locator('.app-shell').count(), 15000, 500, 'AppShell'); // KG tab:实体搜索 + 邻域 await page.click('.bench-tab:has-text("知识图谱")'); await waitFor(() => page.locator('.bench-body input[type="text"], .bench-body input').count(), 10000, 500, 'KG 搜索框'); await page.fill('.bench-body input', 'OsSPL14'); await page.press('.bench-body input', 'Enter'); await waitFor(async () => { const n = await page.locator('.bench-body .kg-result, .bench-body [class*="result"], .bench-body [class*="entity"]').count(); return n > 0; }, 20000, 800, '实体搜索结果'); check('KG:实体搜索有结果(真实 Neo4j fulltext 替代)', true); await page.screenshot({ path: `${OUT_DIR}/01-kg-search.png` }); // 邻域渲染(点击第一个结果或通过已选中实体) const clicked = await page.evaluate(async () => { const items = document.querySelectorAll('.bench-body [class*="result"] button, .bench-body [class*="entity"] button, .bench-body [class*="result"] [class*="item"]'); if (items[0]) { items[0].click(); return true; } return false; }); await waitFor(async () => { const svg = await page.locator('.bench-body svg').count(); const nodes = await page.locator('.bench-body svg circle, .bench-body svg [class*="node"]').count(); return svg > 0 && nodes > 0; }, 30000, 800, 'KG 图谱渲染'); check('KG:邻域图谱渲染(节点+边)', true); await page.screenshot({ path: `${OUT_DIR}/02-kg-graph.png` }); // 抽取任务 tab await page.click('.bench-tab:has-text("提取任务")'); await waitFor(async () => { const n = await page.locator('.bench-body [class*="job"], .bench-body [class*="prog"]').count(); return n > 0; }, 20000, 800, '抽取任务列表'); check('抽取:真实任务列表渲染', true); const jobText = await page.locator('.bench-body').textContent(); check('抽取:含真实任务名或状态', /MinerU|completed|failed|running|queued|wang|Pecan/i.test(jobText)); await page.screenshot({ path: `${OUT_DIR}/03-extraction.png` }); check('桌面档无 JS 错误', errors.length === 0, errors.slice(0, 2).join('|')); await ctx.close(); } // ============ 平板档:抽屉布局 ============ { const ctx = await browser.newContext({ viewport: { width: 768, height: 1024 } }); const page = await ctx.newPage(); await page.goto(BASE, { waitUntil: 'domcontentloaded' }); await waitFor(() => page.locator('.app-shell.drawers-mode').count(), 15000, 500, 'drawers-mode'); check('平板:drawers-mode 生效(单栏)', true); // 汉堡开左抽屉 const hamburger = await page.locator('.hamburger-btn').count(); check('平板:顶栏汉堡按钮出现', hamburger > 0); if (hamburger > 0) { await page.click('.hamburger-btn'); await waitFor(() => page.locator('.drawer').count(), 5000, 400, '左抽屉'); const drawerVisible = await page.locator('.drawer').first().isVisible(); check('平板:左抽屉打开', drawerVisible); await page.screenshot({ path: `${OUT_DIR}/04-tablet-drawer.png` }); // Esc 关闭(抽屉仍挂载但移出屏幕——断言 open class 而非 isHidden) await page.keyboard.press('Escape'); await page.waitForTimeout(500); const openClass = await page.locator('.drawer.left').first().evaluate((el) => el.classList.contains('open')).catch(() => false); check('平板:Esc 关闭抽屉', !openClass); } await ctx.close(); } // ============ 手机档:全屏覆盖层 + 主题 ============ { const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } }); const page = await ctx.newPage(); await page.goto(BASE, { waitUntil: 'domcontentloaded' }); await waitFor(() => page.locator('.app-shell.mobile').count(), 15000, 500, 'mobile 档'); check('手机:mobile 档生效', true); const sb = await page.locator('.status-bar').isHidden().catch(() => true); check('手机:底栏隐藏(FR-RESP-004)', sb); const benchBtn = await page.locator('.bench-btn').count(); check('手机:工作台按钮出现', benchBtn > 0); if (benchBtn > 0) { await page.click('.bench-btn'); await waitFor(() => page.locator('.bench-overlay').count(), 5000, 400, '工作台覆盖层'); check('手机:工作台全屏覆盖层打开', true); await page.screenshot({ path: `${OUT_DIR}/05-mobile-overlay.png` }); // tab 切换(PM 修复的接缝)。注意覆盖层内有两套 tab(外壳 tab 条 + BenchPanel 内部 tab), // 用外壳 tab 条(.bench-overlay-tabs 内)点击,用最后匹配的 on 断言 await page.click('.bench-overlay-tabs .bench-tab:has-text("文献来源")'); const activeTab = await page.locator('.bench-overlay-tabs .bench-tab.on').first().textContent(); check('手机:覆盖层 tab 切换(PM 接线修复)', activeTab.includes('文献来源'), activeTab.trim()); await page.click('.bench-overlay .bench-close-btn, .bench-overlay [class*="close"]').catch(async () => { await page.keyboard.press('Escape'); }); } // 主题切换(浅色) await page.click('.top-bar .icon-btn:last-child'); await page.waitForTimeout(300); const theme = await page.evaluate(() => document.documentElement.dataset.theme); check('主题:切换为浅色(dataset.theme)', theme === 'light'); const canvasBg = await page.evaluate(() => getComputedStyle(document.body).backgroundColor); check('主题:浅色 canvas 生效', canvasBg === 'rgb(246, 248, 252)', canvasBg); await page.screenshot({ path: `${OUT_DIR}/06-mobile-light.png` }); await ctx.close(); } await browser.close(); const passed = results.filter((r) => r.ok).length; console.log(`\nM3 验收结果:${passed}/${results.length} 通过`); process.exit(passed === results.length ? 0 : 1); })();