'use strict'; const childProcess = require('child_process'); const fs = require('fs'); const http = require('http'); const net = require('net'); const os = require('os'); const path = require('path'); const WebSocket = require('ws'); const REPO_DIR = path.resolve(__dirname, '..', '..'); const OUTPUT_DIR = path.join(__dirname, 'screenshots'); const REPORT_PATH = path.join(__dirname, 'visual-report.json'); const CHROME_PATH = process.env.CHROME_PATH || '/home/hdzx/.cache/ms-playwright/chromium-1228/chrome-linux64/chrome'; const PASSWORD = 'UsageVisual!234'; const THEMES = [ 'washi', 'coolvibe', 'editorial', 'sage', 'ink', 'dawn', 'carbon', 'nocturne', 'cinder', 'gilded', 'wasteland', ]; function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function getFreePort() { return new Promise((resolve, reject) => { const server = net.createServer(); server.unref(); server.on('error', reject); server.listen(0, '127.0.0.1', () => { const address = server.address(); server.close(() => resolve(address.port)); }); }); } async function getJson(url) { return new Promise((resolve, reject) => { const request = http.get(url, (response) => { let body = ''; response.setEncoding('utf8'); response.on('data', (chunk) => { body += chunk; }); response.on('end', () => { try { resolve(JSON.parse(body)); } catch (error) { reject(error); } }); }); request.on('error', reject); request.setTimeout(2000, () => request.destroy(new Error(`请求超时:${url}`))); }); } async function waitFor(check, timeoutMs = 15000, intervalMs = 80) { const started = Date.now(); let lastError = null; while (Date.now() - started < timeoutMs) { try { const value = await check(); if (value) return value; } catch (error) { lastError = error; } await sleep(intervalMs); } throw lastError || new Error(`等待条件超时(${timeoutMs}ms)`); } function writeUsageFixtures(sessionsDir) { const tools = [ ['search-server', 'web_search'], ['data-server', 'query_data'], ['code-server', 'run_code'], ['doc-server', 'fetch_doc'], ['api-server', 'api_request'], ['file-server', 'read_file'], ['memory-server', 'remember'], ['browser-server', 'open_page'], ['terminal-server', 'run_task'], ['github-server', 'list_issues'], ]; const skills = ['数据分析', '报表生成', '文档总结', '代码解释', '翻译助手']; for (let day = 1; day <= 31; day += 1) { const messages = []; const messageCount = 7 + (day % 7); for (let index = 0; index < messageCount; index += 1) { const hour = 1 + (index % 20); const minute = (index * 7) % 60; const timestamp = new Date(Date.UTC(2026, 6, day, hour, minute)).toISOString(); const skill = skills[(day + index) % skills.length]; messages.push({ role: 'user', content: `视觉验收消息 ${day}-${index}`, timestamp, crossConversation: (day + index) % 4 === 0, composerMentions: index % 2 === 0 ? [{ kind: 'skill', name: skill, label: `$${skill}` }] : [], }); if (index % 2 === 0) { const [server, tool] = tools[(day + index) % tools.length]; const failed = (day + index) % 9 === 0; const other = !failed && (day + index) % 13 === 0; messages.push({ role: 'assistant', content: `视觉验收回复 ${day}-${index}`, timestamp: new Date(Date.UTC(2026, 6, day, hour, minute + 1)).toISOString(), toolCalls: [{ name: 'McpToolCall', kind: 'mcp_tool_call', input: { server, tool, arguments: { fixture: true } }, result: 'visual-fixture-result', done: !other, meta: { kind: 'mcp_tool_call', status: failed ? 'failed' : other ? 'pending' : 'completed', }, }], }); } } const created = new Date(Date.UTC(2026, 6, day, 0, 0)).toISOString(); const updated = new Date(Date.UTC(2026, 6, day, 23, 30)).toISOString(); const id = `visual-usage-${String(day).padStart(2, '0')}`; fs.writeFileSync(path.join(sessionsDir, `${id}.json`), JSON.stringify({ id, title: `使用统计视觉验收会话 ${String(day).padStart(2, '0')}`, agent: day % 2 === 0 ? 'codexapp' : 'claude', cwd: `/tmp/usage-visual/project-${day % 4}`, projectName: `视觉项目 ${day % 4}`, created, updated, messages, }, null, 2)); } } async function connectCdp(webSocketUrl) { const socket = new WebSocket(webSocketUrl); await new Promise((resolve, reject) => { socket.once('open', resolve); socket.once('error', reject); }); let sequence = 0; const pending = new Map(); const rejectPending = (error) => { for (const entry of pending.values()) entry.reject(error); pending.clear(); }; socket.on('message', (payload) => { const message = JSON.parse(String(payload)); if (!message.id || !pending.has(message.id)) return; const entry = pending.get(message.id); pending.delete(message.id); if (message.error) entry.reject(new Error(message.error.message || JSON.stringify(message.error))); else entry.resolve(message.result || {}); }); socket.on('close', () => rejectPending(new Error('Chrome CDP 连接已关闭'))); socket.on('error', (error) => rejectPending(error)); return { socket, send(method, params = {}) { sequence += 1; const id = sequence; return new Promise((resolve, reject) => { pending.set(id, { resolve, reject }); socket.send(JSON.stringify({ id, method, params }), (error) => { if (!error) return; pending.delete(id); reject(error); }); }); }, }; } async function evaluate(cdp, expression) { const result = await cdp.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true, }); if (result.exceptionDetails) { throw new Error(result.exceptionDetails.text || '页面脚本执行失败'); } return result.result?.value; } async function setViewport(cdp, width, height) { await cdp.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false, screenWidth: width, screenHeight: height, }); await sleep(120); } async function capture(cdp, fileName) { const result = await cdp.send('Page.captureScreenshot', { format: 'png', fromSurface: true, captureBeyondViewport: false, }); fs.writeFileSync(path.join(OUTPUT_DIR, fileName), Buffer.from(result.data, 'base64')); } async function stopChildProcess(child) { if (!child || child.exitCode !== null) return; const exited = new Promise((resolve) => child.once('exit', resolve)); child.kill('SIGTERM'); await Promise.race([exited, sleep(2000)]); } async function collectGeometry(cdp, theme, width, height) { return evaluate(cdp, `(() => { const rect = (selector) => { const element = document.querySelector(selector); if (!element) return null; const box = element.getBoundingClientRect(); return { x: Math.round(box.x * 10) / 10, y: Math.round(box.y * 10) / 10, width: Math.round(box.width * 10) / 10, height: Math.round(box.height * 10) / 10, }; }; const metricRects = Array.from(document.querySelectorAll('.usage-dashboard__metric')).map((element) => { const box = element.getBoundingClientRect(); return { x: Math.round(box.x), y: Math.round(box.y), width: Math.round(box.width), height: Math.round(box.height) }; }); const overflowSelectors = [ '.usage-dashboard__metric-label', '.usage-dashboard__metric-copy strong', '.usage-dashboard__panel-heading h3', '.usage-dashboard__tool-name', '.usage-dashboard__rank-list strong', '.usage-dashboard__session-link', ]; const textOverflow = overflowSelectors.flatMap((selector) => Array.from(document.querySelectorAll(selector)) .filter((element) => element.scrollWidth > element.clientWidth + 1) .map((element) => ({ selector, text: element.textContent.trim().slice(0, 80) }))); const dashboard = document.querySelector('.usage-dashboard'); const body = document.querySelector('.usage-dashboard__body'); const sparklineCanvases = document.querySelectorAll('[data-usage-spark] canvas').length; const donutCanvases = document.querySelectorAll('.usage-dashboard__donut-chart canvas').length; return { theme: ${JSON.stringify(theme)}, viewport: { width: ${width}, height: ${height} }, dashboard: rect('.usage-dashboard'), header: rect('.usage-dashboard__header'), toolbar: rect('.usage-dashboard__toolbar'), metricGrid: rect('.usage-dashboard__metric-grid'), metrics: metricRects, primary: rect('.usage-dashboard__content-grid--primary'), trend: rect('.usage-dashboard__panel--trend'), mcp: rect('.usage-dashboard__panel--mcp'), lower: rect('.usage-dashboard__content-grid--lower'), status: rect('.usage-dashboard__panel--status'), projects: rect('.usage-dashboard__panel--projects'), sessions: rect('.usage-dashboard__panel--sessions'), dashboardOverflowX: Math.max(0, dashboard.scrollWidth - dashboard.clientWidth), bodyOverflowX: Math.max(0, body.scrollWidth - body.clientWidth), bodyScrollHeight: body.scrollHeight, bodyClientHeight: body.clientHeight, metricColumns: getComputedStyle(document.querySelector('.usage-dashboard__metric-grid')).gridTemplateColumns, primaryColumns: getComputedStyle(document.querySelector('.usage-dashboard__content-grid--primary')).gridTemplateColumns, panelBackground: getComputedStyle(document.querySelector('.usage-dashboard__panel')).backgroundColor, pageBackground: getComputedStyle(dashboard).backgroundColor, charts: { runtime: typeof window.echarts?.init === 'function', sparklines: sparklineCanvases, trend: document.querySelectorAll('#usage-dashboard-trend canvas').length, donuts: donutCanvases, ready: sparklineCanvases === 5 && document.querySelectorAll('#usage-dashboard-trend canvas').length === 1 && donutCanvases === 3, }, skillTextVisible: /Skill/i.test(dashboard.innerText), textOverflow, }; })()`); } async function main() { if (!fs.existsSync(CHROME_PATH)) throw new Error(`找不到 Chrome:${CHROME_PATH}`); fs.mkdirSync(OUTPUT_DIR, { recursive: true }); const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-usage-visual-')); const configDir = path.join(tempRoot, 'config'); const sessionsDir = path.join(tempRoot, 'sessions'); const logsDir = path.join(tempRoot, 'logs'); const profileDir = path.join(tempRoot, 'chrome-profile'); [configDir, sessionsDir, logsDir, profileDir].forEach((dir) => fs.mkdirSync(dir, { recursive: true })); writeUsageFixtures(sessionsDir); const port = await getFreePort(); const debugPort = await getFreePort(); let server = null; let chrome = null; let cdp = null; try { server = childProcess.spawn(process.execPath, ['server.js'], { cwd: REPO_DIR, env: { ...process.env, PORT: String(port), CC_WEB_PASSWORD: PASSWORD, CC_WEB_CONFIG_DIR: configDir, CC_WEB_SESSIONS_DIR: sessionsDir, CC_WEB_LOGS_DIR: logsDir, CC_WEB_USAGE_STATISTICS: '1', }, stdio: ['ignore', 'pipe', 'pipe'], }); let serverError = ''; server.stderr.on('data', (chunk) => { serverError += String(chunk); }); await waitFor(() => new Promise((resolve) => { const request = http.get(`http://127.0.0.1:${port}/`, (response) => { response.resume(); resolve(response.statusCode === 200); }); request.on('error', () => resolve(false)); }), 20000); if (server.exitCode !== null) throw new Error(serverError || '测试服务提前退出'); chrome = childProcess.spawn(CHROME_PATH, [ '--headless=new', '--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage', '--hide-scrollbars', '--remote-allow-origins=*', `--remote-debugging-port=${debugPort}`, `--user-data-dir=${profileDir}`, '--window-size=1467,943', 'about:blank', ], { stdio: ['ignore', 'ignore', 'pipe'] }); const targets = await waitFor(async () => { const list = await getJson(`http://127.0.0.1:${debugPort}/json/list`); return list.find((target) => target.type === 'page') ? list : null; }, 20000); const pageTarget = targets.find((target) => target.type === 'page'); cdp = await connectCdp(pageTarget.webSocketDebuggerUrl); await cdp.send('Page.enable'); await cdp.send('Runtime.enable'); await cdp.send('Network.enable'); await cdp.send('Network.setBlockedURLs', { urls: ['https://cdnjs.cloudflare.com/*'] }); await cdp.send('Page.addScriptToEvaluateOnNewDocument', { source: `(() => { class Renderer {} window.marked = { Renderer, setOptions() {}, parse: (value) => String(value || '') }; window.hljs = { getLanguage: () => false, highlight: (_value, options) => ({ value: String(options?.language || '') }), highlightAuto: (value) => ({ value: String(value || '') }), }; window.mermaid = { initialize() {}, render: async () => ({ svg: '' }) }; })();`, }); await setViewport(cdp, 1467, 943); const navigation = await cdp.send('Page.navigate', { url: `http://127.0.0.1:${port}/` }); if (navigation.errorText) throw new Error(`页面导航失败:${navigation.errorText}`); try { await waitFor(() => evaluate(cdp, "document.readyState === 'complete'"), 30000); } catch (error) { const diagnostic = await evaluate(cdp, `(() => ({ href: location.href, readyState: document.readyState, title: document.title, body: document.body?.textContent?.trim().slice(0, 240) || '', }))()`); throw new Error(`页面未完成加载:${JSON.stringify(diagnostic)};${error.message}`); } await waitFor(() => evaluate(cdp, "!!document.querySelector('#login-form')"), 10000); await sleep(500); await evaluate(cdp, `(() => { document.querySelector('#login-password').value = ${JSON.stringify(PASSWORD)}; document.querySelector('#login-form').requestSubmit(); return true; })()`); try { try { await waitFor(() => evaluate(cdp, "document.querySelector('#login-overlay').hidden && !document.querySelector('#usage-dashboard-open').hidden"), 3000); } catch { await evaluate(cdp, `(() => { document.querySelector('#login-password').value = ${JSON.stringify(PASSWORD)}; document.querySelector('#login-form').requestSubmit(); return true; })()`); await waitFor(() => evaluate(cdp, "document.querySelector('#login-overlay').hidden && !document.querySelector('#usage-dashboard-open').hidden"), 17000); } } catch (error) { const diagnostic = await evaluate(cdp, `(() => ({ loginHidden: document.querySelector('#login-overlay')?.hidden, loginError: document.querySelector('#login-error')?.hidden ? '' : document.querySelector('#login-error')?.textContent, appHidden: document.querySelector('#app')?.hidden, usageHidden: document.querySelector('#usage-dashboard-open')?.hidden, marked: typeof window.marked?.parse, echarts: typeof window.echarts?.init, }))()`); throw new Error(`登录后页面未就绪:${JSON.stringify(diagnostic)};${error.message}`); } await evaluate(cdp, "document.querySelector('#usage-dashboard-open').click(); true"); await waitFor(() => evaluate(cdp, "!document.querySelector('#usage-dashboard-panel').hidden"), 10000); await waitFor(() => evaluate(cdp, "document.querySelector('#usage-dashboard-panel').dataset.state !== 'loading'"), 30000); await sleep(320); const loadingLayout = await evaluate(cdp, `(() => { const metricGrid = document.querySelector('.usage-dashboard__metric-grid'); const beforeTop = metricGrid.getBoundingClientRect().top; document.querySelector('#usage-dashboard-refresh').click(); const afterTop = metricGrid.getBoundingClientRect().top; return { state: document.querySelector('#usage-dashboard-panel').dataset.state, label: document.querySelector('#usage-dashboard-refresh-label').textContent, busy: document.querySelector('#usage-dashboard-refresh').getAttribute('aria-busy'), contentShift: Math.round(Math.abs(afterTop - beforeTop) * 10) / 10, standaloneLoading: !!document.querySelector('#usage-dashboard-loading'), }; })()`); if (loadingLayout.state !== 'loading' || loadingLayout.label !== '整理中' || loadingLayout.busy !== 'true' || loadingLayout.contentShift > 0.5 || loadingLayout.standaloneLoading) { throw new Error(`加载状态仍引发布局变化:${JSON.stringify(loadingLayout)}`); } await waitFor(() => evaluate(cdp, `document.querySelector('#usage-dashboard-panel').dataset.state !== 'loading' && document.querySelector('#usage-dashboard-refresh-label').textContent === '刷新'`), 30000); await sleep(300); await evaluate(cdp, `(() => { const from = document.querySelector('#usage-dashboard-from'); const to = document.querySelector('#usage-dashboard-to'); from.value = '2026-07-01'; to.value = '2026-07-31'; from.dispatchEvent(new Event('change', { bubbles: true })); to.dispatchEvent(new Event('change', { bubbles: true })); document.querySelector('#usage-dashboard-apply').click(); return true; })()`); try { await waitFor(() => evaluate(cdp, `document.querySelector('#usage-dashboard-panel').dataset.state === 'ready' && document.querySelector('[data-usage-metric="newSessions"]').textContent !== '0' && document.querySelectorAll('[data-usage-spark] canvas').length === 5 && document.querySelectorAll('#usage-dashboard-trend canvas').length === 1 && document.querySelectorAll('.usage-dashboard__donut-chart canvas').length === 3 && document.querySelectorAll('#usage-dashboard-project-rows li').length === 4 && !/Skill/i.test(document.querySelector('#usage-dashboard-panel').innerText)`), 30000); } catch (error) { const diagnostic = await evaluate(cdp, `(() => ({ state: document.querySelector('#usage-dashboard-panel').dataset.state, error: document.querySelector('#usage-dashboard-error').textContent, from: document.querySelector('#usage-dashboard-from').value, to: document.querySelector('#usage-dashboard-to').value, applyDisabled: document.querySelector('#usage-dashboard-apply').disabled, newSessions: document.querySelector('[data-usage-metric="newSessions"]').textContent, status: document.querySelector('#usage-dashboard-status').textContent, }))()`); throw new Error(`自定义统计查询未返回预期数据:${JSON.stringify(diagnostic)};${error.message}`); } await evaluate(cdp, `(() => { const rows = document.querySelectorAll('#usage-dashboard-mcp-rows tr').length; const more = document.querySelector('#usage-dashboard-mcp-more'); if (rows !== 6 || !more || more.hidden || !/其余/.test(more.textContent)) { throw new Error(JSON.stringify({ rows, moreHidden: more?.hidden, moreText: more?.textContent })); } more.click(); return true; })()`); await waitFor(() => evaluate(cdp, `document.querySelectorAll('#usage-dashboard-mcp-rows tr').length === 10 && document.querySelector('#usage-dashboard-mcp-more').getAttribute('aria-expanded') === 'true'`), 5000); await evaluate(cdp, "document.querySelector('#usage-dashboard-mcp-more').click(); true"); await waitFor(() => evaluate(cdp, "document.querySelectorAll('#usage-dashboard-mcp-rows tr').length === 6"), 5000); const report = { generatedAt: new Date().toISOString(), chrome: CHROME_PATH, views: [], themes: [] }; const viewports = [ { width: 1467, height: 943, name: 'reference' }, { width: 1024, height: 768, name: 'desktop-narrow' }, { width: 768, height: 1024, name: 'tablet' }, { width: 390, height: 844, name: 'mobile' }, ]; await evaluate(cdp, "document.documentElement.dataset.theme = 'washi'; document.querySelector('.usage-dashboard__body').scrollTop = 0; true"); for (const viewport of viewports) { await setViewport(cdp, viewport.width, viewport.height); await evaluate(cdp, "document.querySelector('.usage-dashboard__body').scrollTop = 0; true"); await sleep(100); await capture(cdp, `dashboard-washi-${viewport.width}x${viewport.height}.png`); report.views.push(await collectGeometry(cdp, 'washi', viewport.width, viewport.height)); } await setViewport(cdp, 1467, 943); await evaluate(cdp, "document.querySelector('.usage-dashboard__body').scrollTop = 0; document.querySelector('.usage-dashboard__rate-action')?.click(); true"); await waitFor(() => evaluate(cdp, "!document.querySelector('#usage-dashboard-detail').hidden"), 5000); await sleep(220); await capture(cdp, 'dashboard-washi-detail-1467x943.png'); await evaluate(cdp, "document.querySelector('#usage-dashboard-detail-close').click(); document.querySelector('.usage-dashboard__body').scrollTop = 0; true"); await setViewport(cdp, 1440, 900); for (const theme of THEMES) { await evaluate(cdp, `document.documentElement.dataset.theme = ${JSON.stringify(theme)}; document.querySelector('.usage-dashboard__body').scrollTop = 0; true`); await sleep(80); await capture(cdp, `dashboard-theme-${theme}-1440x900.png`); report.themes.push(await collectGeometry(cdp, theme, 1440, 900)); } fs.writeFileSync(REPORT_PATH, JSON.stringify(report, null, 2)); process.stdout.write(`${JSON.stringify({ report: REPORT_PATH, screenshots: fs.readdirSync(OUTPUT_DIR).length }, null, 2)}\n`); } finally { if (cdp?.socket?.readyState === WebSocket.OPEN) cdp.socket.close(); await stopChildProcess(chrome); await stopChildProcess(server); fs.rmSync(tempRoot, { recursive: true, force: true }); } } main().catch((error) => { console.error(error.stack || error.message || String(error)); process.exitCode = 1; });