Files
cc-web/.planning/usage-statistics-reference-refit/visual-check.js

526 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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;
});