import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { spawn } from 'node:child_process'; const BASE_URL = process.env.WENFAN_HELP_BASE_URL || 'http://localhost:3000'; const API_BASE = process.env.WENFAN_HELP_API_BASE || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1'; const VIEWPORT = { width: 1440, height: 1000 }; const OUTPUT_DIR = resolve('apps/web/public/help/wenfan-xiaobao'); const AUTH_USER = { id: 'm-8', name: '超级管理员', username: 'admin', roleId: 'role-admin', departmentId: '', email: '', }; const versionDetailRoute = (context) => (context.versionId ? `/versions/${context.versionId}` : '/versions'); const SCREENSHOTS = [ { file: 'product-create.png', route: '/products', waitForText: '产品' }, { file: 'project-create.png', route: '/projects', waitForText: '项目' }, { file: 'version-create.png', route: '/versions', waitForText: '新建版本' }, { file: 'requirement-pool.png', route: '/requirements', waitForText: '需求池' }, { file: 'version-requirement-include.png', route: versionDetailRoute, tabLabel: '关联需求', waitForText: '添加需求' }, { file: 'research-plan.png', route: versionDetailRoute, tabLabel: '调研', waitForText: '调研' }, { file: 'product-plan.png', route: versionDetailRoute, tabLabel: '产品方案', waitForText: '产品方案' }, { file: 'ai-decompose.png', route: versionDetailRoute, tabLabel: '产品方案', waitForText: 'AI 拆解', action: openProductPlanAiEntry, scrollText: 'AI 拆解' }, { file: 'ui-design-plan.png', route: versionDetailRoute, tabLabel: 'UI设计', waitForText: 'UI设计' }, { file: 'dev-task-workflow.png', route: versionDetailRoute, tabLabel: '开发任务', waitForText: '开发任务' }, { file: 'test-case-workflow.png', route: versionDetailRoute, tabLabel: '测试用例', waitForText: '测试用例' }, { file: 'bug-workflow.png', route: versionDetailRoute, tabLabel: '测试用例', waitForText: '提 BUG', action: openFailedTestCaseBugEntry }, { file: 'activity-logs.png', route: versionDetailRoute, tabLabel: '产品方案', waitForText: '日志', scrollText: '日志' }, { file: 'xiaobao-warning.png', route: '/xiaobao-warning', waitForText: '小宝预警' }, ]; function findBrowser() { const candidates = [ process.env.CHROME_PATH, 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe', 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe', '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', '/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser', ].filter(Boolean); return candidates.find((candidate) => existsSync(candidate)); } function delay(ms) { return new Promise((resolveDelay) => setTimeout(resolveDelay, ms)); } async function loadDataKey(key) { try { const response = await fetch(`${API_BASE}/data/${key}`); if (!response.ok) return null; const payload = await response.json(); return payload.value ?? null; } catch { return null; } } function toArray(value) { return Array.isArray(value) ? value : []; } function findPreferredVersionId(overview, testCases, versionPlans) { const versions = Array.isArray(overview) ? overview.flatMap((product) => (Array.isArray(product.versions) ? product.versions : [])) : []; const failedVersionIds = new Set( toArray(testCases) .filter((testCase) => testCase.status === 'failed' && testCase.versionId) .map((testCase) => testCase.versionId), ); const aiReadyVersionIds = new Set( toArray(versionPlans) .filter((plan) => plan.type === 'product' && plan.status === 'completed' && plan.resultUrl && plan.versionId) .map((plan) => plan.versionId), ); const scoreVersion = (version) => { let score = 0; if (!['released', 'closed'].includes(version.status)) score += 10; if (version.status === 'developing') score += 4; if (failedVersionIds.has(version.id)) score += 3; if (aiReadyVersionIds.has(version.id)) score += 2; return score; }; return [...versions].sort((a, b) => scoreVersion(b) - scoreVersion(a))[0]?.id ?? null; } function findFailedTestCase(testCases, versionId) { return toArray(testCases) .filter((testCase) => testCase.versionId === versionId && testCase.status === 'failed') .sort((a, b) => { const roundDiff = (a.roundNo ?? 1) - (b.roundNo ?? 1); if (roundDiff !== 0) return roundDiff; return String(a.caseNo ?? a.title ?? '').localeCompare(String(b.caseNo ?? b.title ?? '')); })[0] ?? null; } function findAiReadyProductPlan(versionPlans, versionId) { return toArray(versionPlans) .filter((plan) => ( plan.versionId === versionId && plan.type === 'product' && plan.status === 'completed' && plan.resultUrl )) .sort((a, b) => { const httpDiff = Number(String(b.resultUrl).startsWith('http')) - Number(String(a.resultUrl).startsWith('http')); if (httpDiff !== 0) return httpDiff; return String(b.completedAt ?? b.createdAt ?? '').localeCompare(String(a.completedAt ?? a.createdAt ?? '')); })[0] ?? null; } async function resolveScreenshotContext() { const [overview, testCases, versionPlans] = await Promise.all([ loadDataKey('products-overview'), loadDataKey('test-cases'), loadDataKey('version-plans'), ]); const versionId = findPreferredVersionId(overview, testCases, versionPlans); if (!versionId) { console.warn('未找到可进入的版本详情,版本内截图将退回版本列表。'); } const failedTestCase = findFailedTestCase(testCases, versionId); if (!failedTestCase) { console.warn('未找到失败/不通过测试用例,Bug 配图将退回测试用例列表。'); } const productPlan = findAiReadyProductPlan(versionPlans, versionId); if (!productPlan) { console.warn('未找到已完成且有成果链接的产品方案,AI 拆解配图可能无法展示按钮。'); } return { versionId, failedTestCase, productPlan }; } async function assertServerAvailable() { try { const response = await fetch(BASE_URL); if (!response.ok) throw new Error(`HTTP ${response.status}`); } catch (error) { throw new Error(`本地页面不可访问:${BASE_URL}。请先启动前端 dev server。`); } } function launchBrowser(browserPath, userDataDir) { const child = spawn( browserPath, [ '--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check', '--remote-debugging-port=0', `--user-data-dir=${userDataDir}`, `--window-size=${VIEWPORT.width},${VIEWPORT.height}`, 'about:blank', ], { stdio: ['ignore', 'pipe', 'pipe'] }, ); return child; } function waitForDevtools(child) { return new Promise((resolveDevtools, rejectDevtools) => { const timer = setTimeout(() => rejectDevtools(new Error('Chrome DevTools 启动超时。')), 15000); function onData(data) { const text = data.toString(); const match = text.match(/DevTools listening on (ws:\/\/[^\s]+)/); if (!match) return; clearTimeout(timer); child.stdout.off('data', onData); child.stderr.off('data', onData); resolveDevtools(match[1]); } child.stdout.on('data', onData); child.stderr.on('data', onData); child.once('exit', (code) => { clearTimeout(timer); rejectDevtools(new Error(`浏览器提前退出,退出码:${code}`)); }); }); } async function getPageWebSocketUrl(wsEndpoint) { const url = new URL(wsEndpoint); const listUrl = `http://${url.hostname}:${url.port}/json/list`; for (let i = 0; i < 30; i += 1) { const response = await fetch(listUrl); const targets = await response.json(); const page = targets.find((target) => target.type === 'page' && target.webSocketDebuggerUrl); if (page) return page.webSocketDebuggerUrl; await delay(200); } throw new Error('没有找到可截图的 Chrome 页面 target。'); } class CdpClient { constructor(webSocketUrl) { this.webSocketUrl = webSocketUrl; this.nextId = 1; this.pending = new Map(); this.eventWaiters = new Map(); } async connect() { this.ws = new WebSocket(this.webSocketUrl); this.ws.addEventListener('message', (event) => this.handleMessage(event.data)); await new Promise((resolveOpen, rejectOpen) => { this.ws.addEventListener('open', resolveOpen, { once: true }); this.ws.addEventListener('error', rejectOpen, { once: true }); }); } handleMessage(raw) { const message = JSON.parse(raw); if (message.id && this.pending.has(message.id)) { const { resolve: resolvePending, reject } = this.pending.get(message.id); this.pending.delete(message.id); if (message.error) reject(new Error(message.error.message)); else resolvePending(message.result); return; } if (message.method && this.eventWaiters.has(message.method)) { const waiters = this.eventWaiters.get(message.method); this.eventWaiters.delete(message.method); for (const waiter of waiters) waiter(message.params); } } send(method, params = {}) { const id = this.nextId; this.nextId += 1; const payload = JSON.stringify({ id, method, params }); return new Promise((resolveSend, rejectSend) => { this.pending.set(id, { resolve: resolveSend, reject: rejectSend }); this.ws.send(payload); }); } waitForEvent(method, timeoutMs = 15000) { return new Promise((resolveEvent, rejectEvent) => { const timer = setTimeout(() => rejectEvent(new Error(`等待 ${method} 超时。`)), timeoutMs); const waiters = this.eventWaiters.get(method) || []; waiters.push((params) => { clearTimeout(timer); resolveEvent(params); }); this.eventWaiters.set(method, waiters); }); } close() { this.ws?.close(); } } async function navigateAndWait(client, url) { const loadPromise = client.waitForEvent('Page.loadEventFired').catch(() => undefined); await client.send('Page.navigate', { url }); await loadPromise; await delay(1200); } async function evaluateValue(client, expression) { const result = await client.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true, }); if (result.exceptionDetails) { throw new Error(result.exceptionDetails.text || '页面脚本执行失败。'); } return result.result?.value; } async function waitForCondition(client, expression, timeoutMs = 10000) { const start = Date.now(); while (Date.now() - start < timeoutMs) { const matched = await evaluateValue(client, expression).catch(() => false); if (matched) return true; await delay(250); } return false; } async function waitForPageText(client, text, timeoutMs = 10000) { return waitForCondition( client, `document.body && document.body.innerText.includes(${JSON.stringify(text)})`, timeoutMs, ); } async function clickButtonByText(client, text) { return evaluateValue( client, `(() => { const target = ${JSON.stringify(text)}; const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim(); const buttons = Array.from(document.querySelectorAll('button,[role="button"]')); const button = buttons.find((node) => { const label = normalize(node.textContent); return label === target || label.includes(target); }); if (!button) return false; button.scrollIntoView({ block: 'center', inline: 'center' }); button.click(); return true; })()`, ); } async function clickNearestClickableByText(client, text) { return evaluateValue( client, `(() => { const target = ${JSON.stringify(text)}; const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim(); const clickables = Array.from(document.querySelectorAll('button,a,[role="button"],[class*="cursor-pointer"]')); let node = clickables.find((element) => normalize(element.textContent).includes(target)); if (!node) { const textNodes = Array.from(document.querySelectorAll('h1,h2,h3,p,span,div')) .filter((element) => normalize(element.textContent).length < 240); node = textNodes .map((element) => element.closest('button,a,[role="button"],[class*="cursor-pointer"]')) .find((element) => element && normalize(element.textContent).includes(target)); } if (!node) return false; node.scrollIntoView({ block: 'center', inline: 'center' }); node.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window })); return true; })()`, ); } async function scrollTextIntoView(client, text) { return evaluateValue( client, `(() => { const target = ${JSON.stringify(text)}; const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim(); const nodes = Array.from(document.querySelectorAll('button,a,h1,h2,h3,p,span,div')); const node = nodes.find((element) => normalize(element.textContent).includes(target)); if (!node) return false; node.scrollIntoView({ block: 'center', inline: 'center' }); return true; })()`, ); } async function filterTestCaseList(client, keyword) { if (!keyword) return false; return evaluateValue( client, `(() => { const keyword = ${JSON.stringify(keyword)}; const inputs = Array.from(document.querySelectorAll('input')); const input = inputs.find((node) => { const placeholder = node.getAttribute('placeholder') || ''; return placeholder.includes('搜索') || placeholder.includes('标题') || placeholder.includes('编号'); }) || inputs[0]; if (!input) return false; const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set; input.focus(); if (setter) setter.call(input, keyword); else input.value = keyword; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); return true; })()`, ); } async function scrollBugButtonIntoView(client) { return evaluateValue( client, `(() => { const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim(); const button = Array.from(document.querySelectorAll('button')).find((node) => { const text = normalize(node.textContent); return text.includes('提') && text.toUpperCase().includes('BUG'); }); if (!button) return false; button.scrollIntoView({ block: 'center', inline: 'center' }); return true; })()`, ); } async function openProductPlanAiEntry(client, context) { if (!context.productPlan) return; const opened = await clickNearestClickableByText(client, context.productPlan.title); if (!opened) { console.warn(`未打开产品方案:${context.productPlan.title}`); return; } await delay(800); const found = await waitForPageText(client, 'AI 拆解', 5000); if (!found) { console.warn(`已打开产品方案 ${context.productPlan.title},但未找到 AI 拆解入口。`); } await scrollTextIntoView(client, 'AI 拆解'); } async function openFailedTestCaseBugEntry(client, context) { const failedTestCase = context.failedTestCase; if (failedTestCase) { await filterTestCaseList(client, failedTestCase.caseNo || failedTestCase.title); await delay(700); } const opened = await evaluateValue( client, `(() => { const needles = ${JSON.stringify( [context.failedTestCase?.caseNo, context.failedTestCase?.title].filter(Boolean), )}; const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim(); const rows = Array.from(document.querySelectorAll('[class*="cursor-pointer"],[role="button"],button')); const row = rows.find((node) => { const text = normalize(node.textContent); if (needles.length > 0) return needles.some((needle) => text.includes(needle)); return text.includes('TC-') && text.includes('不通过'); }); if (!row) return false; row.scrollIntoView({ block: 'center', inline: 'center' }); row.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window })); return true; })()`, ); if (!opened) { console.warn('未找到失败/不通过的测试用例行,Bug 配图将停留在测试用例列表。'); await scrollTextIntoView(client, '不通过'); return; } await waitForCondition( client, `document.body && Array.from(document.querySelectorAll('button')).some((node) => { const text = (node.textContent || '').replace(/\\s+/g, ' ').trim(); return text.includes('提') && text.toUpperCase().includes('BUG'); })`, 5000, ); await scrollBugButtonIntoView(client); } async function authenticate(client) { await navigateAndWait(client, `${BASE_URL}/login`); const serialized = JSON.stringify(AUTH_USER).replace(/\\/g, '\\\\').replace(/'/g, "\\'"); await client.send('Runtime.evaluate', { expression: ` sessionStorage.setItem('ftb_auth_session', '${serialized}'); localStorage.setItem('ftb_auth_persist', '${serialized}'); `, }); } async function prepareScreenshot(client, item, context) { if (item.tabLabel) { const clicked = await clickButtonByText(client, item.tabLabel); if (!clicked) { console.warn(`未找到 Tab:${item.tabLabel}`); } await delay(800); } if (item.action) { await item.action(client, context); await delay(600); } if (item.scrollText) { await scrollTextIntoView(client, item.scrollText); await delay(400); } if (item.waitForText) { const found = await waitForPageText(client, item.waitForText, 8000); if (!found) { console.warn(`截图前未在页面找到文本:${item.waitForText}`); } } } async function capture(client, item, context, outputPath) { const route = typeof item.route === 'function' ? item.route(context) : item.route; await navigateAndWait(client, `${BASE_URL}${route}`); await client.send('Emulation.setDeviceMetricsOverride', { width: VIEWPORT.width, height: VIEWPORT.height, deviceScaleFactor: 1, mobile: false, }); await prepareScreenshot(client, item, context); const screenshot = await client.send('Page.captureScreenshot', { format: 'png', fromSurface: true, captureBeyondViewport: false, }); writeFileSync(outputPath, Buffer.from(screenshot.data, 'base64')); } async function terminateBrowser(browser) { if (browser.exitCode !== null) return; const exited = new Promise((resolveExit) => { browser.once('exit', resolveExit); }); browser.kill(); await Promise.race([exited, delay(2000)]); } async function main() { const browserPath = findBrowser(); if (!browserPath) { throw new Error('未找到 Chrome 或 Edge,请安装浏览器,或通过 CHROME_PATH 指定浏览器路径。'); } await assertServerAvailable(); const context = await resolveScreenshotContext(); mkdirSync(OUTPUT_DIR, { recursive: true }); const userDataDir = join(tmpdir(), `wenfan-help-screenshots-${Date.now()}`); const browser = launchBrowser(browserPath, userDataDir); let client; try { const browserWsEndpoint = await waitForDevtools(browser); const pageWsEndpoint = await getPageWebSocketUrl(browserWsEndpoint); client = new CdpClient(pageWsEndpoint); await client.connect(); await client.send('Page.enable'); await client.send('Runtime.enable'); await authenticate(client); for (const item of SCREENSHOTS) { const outputPath = join(OUTPUT_DIR, item.file); await capture(client, item, context, outputPath); console.log(`saved ${outputPath}`); } } finally { client?.close(); await terminateBrowser(browser); try { rmSync(userDataDir, { recursive: true, force: true }); } catch { console.warn(`临时浏览器目录未能立即清理,可稍后手动删除:${userDataDir}`); } } } main().catch((error) => { console.error(error.message); process.exit(1); });