From eef5d9f5f4d78031ec38d2bceacdd3553af25e6d Mon Sep 17 00:00:00 2001 From: hangerye Date: Fri, 10 Oct 2025 01:16:32 +0800 Subject: [PATCH] feat: add performance tool --- .../background/tools/browser/common.ts | 34 +- .../background/tools/browser/index.ts | 5 + .../background/tools/browser/performance.ts | 545 ++++++++++++++++++ app/native-server/package.json | 3 +- app/native-server/src/file-handler.ts | 21 +- app/native-server/src/mcp/register-tools.ts | 2 +- app/native-server/src/shims/devtools.d.ts | 7 + app/native-server/src/trace-analyzer.ts | 86 +++ .../src/types/devtools-frontend.d.ts | 28 + app/native-server/tsconfig.json | 40 +- packages/shared/src/tools.ts | 66 +++ pnpm-lock.yaml | 8 + 12 files changed, 816 insertions(+), 29 deletions(-) create mode 100644 app/chrome-extension/entrypoints/background/tools/browser/performance.ts create mode 100644 app/native-server/src/shims/devtools.d.ts create mode 100644 app/native-server/src/trace-analyzer.ts create mode 100644 app/native-server/src/types/devtools-frontend.d.ts diff --git a/app/chrome-extension/entrypoints/background/tools/browser/common.ts b/app/chrome-extension/entrypoints/background/tools/browser/common.ts index 472fd1c..84f8707 100644 --- a/app/chrome-extension/entrypoints/background/tools/browser/common.ts +++ b/app/chrome-extension/entrypoints/background/tools/browser/common.ts @@ -269,20 +269,42 @@ class CloseTabsTool extends BaseBrowserToolExecutor { // If URL is provided, close all tabs matching that URL if (urlPattern) { console.log(`Searching for tabs with URL: ${url}`); - if (!urlPattern.endsWith('/')) { - urlPattern += '/*'; + try { + // Build a proper Chrome match pattern from a concrete URL. + // If caller already provided a match pattern with '*', use as-is. + if (!urlPattern.includes('*')) { + // Ignore search/hash; match by origin + pathname prefix. + // Use URL to normalize; fallback to simple suffixing when parsing fails. + try { + const u = new URL(urlPattern); + const basePath = u.pathname || '/'; + const pathWithWildcard = basePath.endsWith('/') ? `${basePath}*` : `${basePath}/*`; + urlPattern = `${u.protocol}//${u.host}${pathWithWildcard}`; + } catch { + // Not a fully-qualified URL; ensure it ends with wildcard + urlPattern = urlPattern.endsWith('/') ? `${urlPattern}*` : `${urlPattern}/*`; + } + } + } catch { + // Best-effort: ensure we have some wildcard + urlPattern = urlPattern.endsWith('*') + ? urlPattern + : urlPattern.endsWith('/') + ? `${urlPattern}*` + : `${urlPattern}/*`; } - const tabs = await chrome.tabs.query({ url }); + + const tabs = await chrome.tabs.query({ url: urlPattern }); if (!tabs || tabs.length === 0) { - console.log(`No tabs found with URL: ${url}`); + console.log(`No tabs found with URL pattern: ${urlPattern}`); return { content: [ { type: 'text', text: JSON.stringify({ success: false, - message: `No tabs found with URL: ${url}`, + message: `No tabs found with URL pattern: ${urlPattern}`, closedCount: 0, }), }, @@ -291,7 +313,7 @@ class CloseTabsTool extends BaseBrowserToolExecutor { }; } - console.log(`Found ${tabs.length} tabs with URL: ${url}`); + console.log(`Found ${tabs.length} tabs with URL pattern: ${urlPattern}`); const tabIdsToClose = tabs .map((tab) => tab.id) .filter((id): id is number => id !== undefined); diff --git a/app/chrome-extension/entrypoints/background/tools/browser/index.ts b/app/chrome-extension/entrypoints/background/tools/browser/index.ts index 05f86bc..29a2e8a 100644 --- a/app/chrome-extension/entrypoints/background/tools/browser/index.ts +++ b/app/chrome-extension/entrypoints/background/tools/browser/index.ts @@ -17,3 +17,8 @@ export { readPageTool } from './read-page'; export { computerTool } from './computer'; export { handleDialogTool } from './dialog'; export { userscriptTool } from './userscript'; +export { + performanceStartTraceTool, + performanceStopTraceTool, + performanceAnalyzeInsightTool, +} from './performance'; diff --git a/app/chrome-extension/entrypoints/background/tools/browser/performance.ts b/app/chrome-extension/entrypoints/background/tools/browser/performance.ts new file mode 100644 index 0000000..8fcb257 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/tools/browser/performance.ts @@ -0,0 +1,545 @@ +import { createErrorResponse, ToolResult } from '@/common/tool-handler'; +import { BaseBrowserToolExecutor } from '../base-browser'; +import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { cdpSessionManager } from '@/utils/cdp-session-manager'; + +type OwnerTag = 'performance'; + +interface StartTraceParams { + reload?: boolean; // whether to reload the page after starting trace + autoStop?: boolean; // whether to auto stop after a short duration + durationMs?: number; // custom duration when autoStop is true (default 5000) +} + +interface StopTraceParams { + saveToDownloads?: boolean; // save trace to Downloads as JSON (default true) + filenamePrefix?: string; // filename prefix (default 'performance_trace') +} + +interface AnalyzeInsightParams { + insightName?: string; // placeholder for future deep insights +} + +type DebuggeeEvent = (source: chrome.debugger.Debuggee, method: string, params?: any) => void; + +interface TraceSessionState { + recording: boolean; + events: any[]; + startedAt: number; + pageUrl?: string; + listener: DebuggeeEvent; + stopResolver?: (value: { completed: boolean }) => void; + stopPromise?: Promise<{ completed: boolean }>; +} + +const sessions = new Map(); +const LAST_RESULTS = new Map< + number, + { + events: any[]; + startedAt: number; + endedAt: number; + tabUrl: string; + saved?: { downloadId?: number; filename?: string; fullPath?: string }; + metrics?: Record; + } +>(); + +function tracingCategories(): string[] { + // Keep broadly consistent with other project + return [ + '-*', + 'blink.console', + 'blink.user_timing', + 'devtools.timeline', + 'disabled-by-default-devtools.screenshot', + 'disabled-by-default-devtools.timeline', + 'disabled-by-default-devtools.timeline.invalidationTracking', + 'disabled-by-default-devtools.timeline.frame', + 'disabled-by-default-devtools.timeline.stack', + 'disabled-by-default-v8.cpu_profiler', + 'disabled-by-default-v8.cpu_profiler.hires', + 'latencyInfo', + 'loading', + 'disabled-by-default-lighthouse', + 'v8.execute', + 'v8', + ]; +} + +async function enablePerformanceMetrics(tabId: number): Promise> { + try { + await cdpSessionManager.sendCommand(tabId, 'Performance.enable'); + const result = (await cdpSessionManager.sendCommand(tabId, 'Performance.getMetrics')) as { + metrics: Array<{ name: string; value: number }>; + }; + await cdpSessionManager.sendCommand(tabId, 'Performance.disable'); + const map: Record = {}; + for (const m of result.metrics || []) map[m.name] = m.value; + return map; + } catch (e) { + return {}; + } +} + +async function saveTraceToDownloads( + json: string, + filenamePrefix = 'performance_trace', +): Promise<{ downloadId?: number; filename?: string; fullPath?: string }> { + try { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const filename = `${filenamePrefix}_${timestamp}.json`; + const dataUrl = `data:application/json;base64,${btoa(unescape(encodeURIComponent(json)))}`; + const downloadId = await chrome.downloads.download({ url: dataUrl, filename, saveAs: false }); + // Attempt to resolve full path + try { + await new Promise((r) => setTimeout(r, 120)); + const [item] = await chrome.downloads.search({ id: downloadId }); + return { downloadId, filename, fullPath: item?.filename }; + } catch { + return { downloadId, filename }; + } + } catch { + return {}; + } +} + +async function saveTraceToNativeTemp( + json: string, + filenamePrefix = 'performance_trace', +): Promise<{ filename?: string; fullPath?: string } | undefined> { + try { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const filename = `${filenamePrefix}_${timestamp}.json`; + const base64 = btoa(unescape(encodeURIComponent(json))); + + const requestId = `trace-temp-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const timeoutMs = 30000; + const resp = await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + chrome.runtime.onMessage.removeListener(listener); + reject(new Error('Native temp save timed out')); + }, timeoutMs); + const listener = (message: any) => { + if ( + message && + message.type === 'file_operation_response' && + message.responseToRequestId === requestId + ) { + clearTimeout(timer); + chrome.runtime.onMessage.removeListener(listener); + resolve(message.payload); + } + }; + chrome.runtime.onMessage.addListener(listener); + chrome.runtime + .sendMessage({ + type: 'forward_to_native', + message: { + type: 'file_operation', + requestId, + payload: { + action: 'prepareFile', + base64Data: base64, + fileName: filename, + }, + }, + }) + .catch((err) => { + clearTimeout(timer); + chrome.runtime.onMessage.removeListener(listener); + reject(err); + }); + }); + + if (resp && resp.success && resp.filePath) { + return { filename, fullPath: resp.filePath }; + } + } catch { + // ignore, fallback will apply + } + return undefined; +} + +async function cleanupNativeTempFile(filePath: string): Promise { + if (!filePath) return; + try { + const requestId = `trace-clean-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const timeoutMs = 10000; + await new Promise((resolve) => { + const timer = setTimeout(() => { + chrome.runtime.onMessage.removeListener(listener); + resolve(); // best-effort + }, timeoutMs); + const listener = (message: any) => { + if ( + message && + message.type === 'file_operation_response' && + message.responseToRequestId === requestId + ) { + clearTimeout(timer); + chrome.runtime.onMessage.removeListener(listener); + resolve(); + } + }; + chrome.runtime.onMessage.addListener(listener); + chrome.runtime + .sendMessage({ + type: 'forward_to_native', + message: { + type: 'file_operation', + requestId, + payload: { + action: 'cleanupFile', + filePath, + }, + }, + }) + .catch(() => { + clearTimeout(timer); + chrome.runtime.onMessage.removeListener(listener); + resolve(); + }); + }); + } catch { + // ignore + } +} + +function getOrCreateStopPromise(session: TraceSessionState): Promise<{ completed: boolean }> { + if (session.stopPromise) return session.stopPromise; + session.stopPromise = new Promise((resolve) => { + session.stopResolver = resolve; + }); + return session.stopPromise; +} + +/** + * Start performance trace + */ +class PerformanceStartTraceTool extends BaseBrowserToolExecutor { + name = TOOL_NAMES.BROWSER.PERFORMANCE_START_TRACE; + + async execute(args: StartTraceParams): Promise { + const { reload = false, autoStop = false, durationMs = 5000 } = args || {}; + + try { + const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!activeTab?.id) { + return createErrorResponse('No active tab found'); + } + const tabId = activeTab.id; + const existed = sessions.get(tabId); + if (existed?.recording) { + return { + content: [{ type: 'text', text: 'Error: a performance trace is already running.' }], + isError: false, + }; + } + + await cdpSessionManager.attach(tabId, 'performance'); + + const state: TraceSessionState = { + recording: true, + events: [], + startedAt: Date.now(), + pageUrl: activeTab.url || '', + listener: (source, method, params) => { + if (source.tabId !== tabId) return; + if (method === 'Tracing.dataCollected' && params?.value) { + try { + state.events.push(...(params.value as any[])); + } catch { + // ignore + } + } else if (method === 'Tracing.tracingComplete') { + state.recording = false; + state.stopResolver?.({ completed: true }); + } + }, + }; + chrome.debugger.onEvent.addListener(state.listener); + sessions.set(tabId, state); + + // Start tracing with categories + const cats = tracingCategories().join(','); + await cdpSessionManager.sendCommand(tabId, 'Tracing.start', { + categories: cats, + options: 'record-as-much-as-possible', + transferMode: 'ReportEvents', + }); + + if (reload) { + try { + await cdpSessionManager.sendCommand(tabId, 'Page.reload', { ignoreCache: true }); + } catch { + // best effort; ignore if fails + } + } + + if (autoStop) { + setTimeout( + async () => { + try { + await cdpSessionManager.sendCommand(tabId, 'Tracing.end'); + } catch { + // ignore + } + }, + Math.max(1000, Math.min(durationMs, 60000)), + ); + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + message: 'Performance trace is recording. Use performance_stop_trace to stop it.', + reload, + autoStop, + }), + }, + ], + isError: false, + }; + } catch (e: any) { + return createErrorResponse(`Failed to start performance trace: ${e?.message || e}`); + } + } +} + +/** + * Stop performance trace + */ +class PerformanceStopTraceTool extends BaseBrowserToolExecutor { + name = TOOL_NAMES.BROWSER.PERFORMANCE_STOP_TRACE; + + async execute(args: StopTraceParams): Promise { + const { saveToDownloads = true, filenamePrefix } = args || {}; + try { + const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!activeTab?.id) return createErrorResponse('No active tab found'); + const tabId = activeTab.id; + const session = sessions.get(tabId); + if (!session) { + return { + content: [ + { type: 'text', text: 'No performance trace session found for the current tab.' }, + ], + isError: false, + }; + } + + let stopResult: { completed: boolean } = { completed: false }; + if (session.recording) { + // End tracing and wait for completion signal + await cdpSessionManager.sendCommand(tabId, 'Tracing.end'); + await getOrCreateStopPromise(session); + stopResult = await session.stopPromise!; + } else { + // Already auto-stopped; proceed to finalize without waiting + stopResult = { completed: true }; + } + // Fetch metrics before detach + const metrics = await enablePerformanceMetrics(tabId); + + // Cleanup event listener and detach + try { + chrome.debugger.onEvent.removeListener(session.listener); + } catch { + // ignore + } + try { + await cdpSessionManager.detach(tabId, 'performance'); + } catch { + // ignore + } + + const endedAt = Date.now(); + const trace = { traceEvents: session.events }; + const json = JSON.stringify(trace); + + let saved: { downloadId?: number; filename?: string; fullPath?: string } | undefined; + if (saveToDownloads) { + saved = await saveTraceToDownloads(json, filenamePrefix || 'performance_trace'); + } else { + // Persist to native temp directory so that analysis can run without Downloads permission + const tempSaved = await saveTraceToNativeTemp(json, filenamePrefix || 'performance_trace'); + if (tempSaved) { + saved = { ...tempSaved } as any; + } + } + + LAST_RESULTS.set(tabId, { + events: session.events, + startedAt: session.startedAt, + endedAt, + tabUrl: session.pageUrl || '', + saved, + metrics, + }); + + sessions.delete(tabId); + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + message: 'The performance trace has been stopped.', + eventCount: session.events.length, + saved, + metrics, + startedAt: session.startedAt, + endedAt, + durationMs: endedAt - session.startedAt, + url: session.pageUrl || '', + tracingCompleted: stopResult?.completed === true, + }), + }, + ], + isError: false, + }; + } catch (e: any) { + return createErrorResponse(`Failed to stop performance trace: ${e?.message || e}`); + } + } +} + +/** + * Analyze last trace (lightweight) + * Note: Deep insights require DevTools front-end trace engine on the native side; this is a + * pragmatic first step returning basic metrics and a quick event histogram. + */ +class PerformanceAnalyzeInsightTool extends BaseBrowserToolExecutor { + name = TOOL_NAMES.BROWSER.PERFORMANCE_ANALYZE_INSIGHT; + + async execute(args: AnalyzeInsightParams & { timeoutMs?: number }): Promise { + const { insightName } = args || {}; + try { + const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!activeTab?.id) return createErrorResponse('No active tab found'); + const tabId = activeTab.id; + const result = LAST_RESULTS.get(tabId); + if (!result) { + return { + content: [ + { + type: 'text', + text: 'No recorded traces found. Start and stop a performance trace first.', + }, + ], + isError: false, + }; + } + + // Prefer native-side deep analysis when we have a saved file path + const fullPath = (result.saved && (result.saved as any).fullPath) || undefined; + if (fullPath) { + try { + const requestId = `trace-analyze-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const timeoutMs = Math.max(10000, Math.min((args as any)?.timeoutMs ?? 60000, 300000)); + const resp = await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + chrome.runtime.onMessage.removeListener(listener); + reject(new Error('Native trace analysis timed out')); + }, timeoutMs); + const listener = (message: any) => { + if ( + message && + message.type === 'file_operation_response' && + message.responseToRequestId === requestId + ) { + clearTimeout(timer); + chrome.runtime.onMessage.removeListener(listener); + resolve(message.payload); + } + }; + chrome.runtime.onMessage.addListener(listener); + chrome.runtime + .sendMessage({ + type: 'forward_to_native', + message: { + type: 'file_operation', + requestId, + payload: { action: 'analyzeTrace', traceFilePath: fullPath, insightName }, + }, + }) + .catch((err) => { + clearTimeout(timer); + chrome.runtime.onMessage.removeListener(listener); + reject(err); + }); + }); + if (resp && resp.success) { + // Best-effort cleanup for temp files (Downloads paths are ignored by native cleaner) + await cleanupNativeTempFile(fullPath); + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + url: result.tabUrl, + startedAt: result.startedAt, + endedAt: result.endedAt, + durationMs: result.endedAt - result.startedAt, + metrics: result.metrics || {}, + saved: result.saved, + summary: resp.summary, + insight: resp.insight, + }), + }, + ], + isError: false, + }; + } + // If native returned error, fall through to lightweight analysis + } catch (e) { + // Fallback to lightweight analysis below + } + } + + // Lightweight fallback (when no saved file path) + const counts = new Map(); + for (const ev of result.events.slice(0, 100000)) { + const n = typeof (ev as any)?.name === 'string' ? (ev as any).name : 'unknown'; + counts.set(n, (counts.get(n) || 0) + 1); + } + const top = [...counts.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 20) + .map(([name, count]) => ({ name, count })); + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + info: 'Lightweight analysis (no saved file path). Native-side deep analysis unavailable.', + requestedInsight: insightName || null, + url: result.tabUrl, + startedAt: result.startedAt, + endedAt: result.endedAt, + durationMs: result.endedAt - result.startedAt, + metrics: result.metrics || {}, + topEventNames: top, + saved: result.saved, + }), + }, + ], + isError: false, + }; + } catch (e: any) { + return createErrorResponse(`Failed to analyze trace: ${e?.message || e}`); + } + } +} + +export const performanceStartTraceTool = new PerformanceStartTraceTool(); +export const performanceStopTraceTool = new PerformanceStopTraceTool(); +export const performanceAnalyzeInsightTool = new PerformanceAnalyzeInsightTool(); diff --git a/app/native-server/package.json b/app/native-server/package.json index 6453f3c..e3091ac 100644 --- a/app/native-server/package.json +++ b/app/native-server/package.json @@ -43,7 +43,8 @@ "is-admin": "^4.0.0", "node-fetch": "2", "pino": "^9.6.0", - "uuid": "^11.1.0" + "uuid": "^11.1.0", + "chrome-devtools-frontend": "^1.0.1299282" }, "devDependencies": { "@jest/globals": "^29.7.0", diff --git a/app/native-server/src/file-handler.ts b/app/native-server/src/file-handler.ts index 5f86aee..92f8f40 100644 --- a/app/native-server/src/file-handler.ts +++ b/app/native-server/src/file-handler.ts @@ -22,7 +22,7 @@ export class FileHandler { * Handle file preparation request from the extension */ async handleFileRequest(request: any): Promise { - const { action, fileUrl, base64Data, fileName, filePath } = request; + const { action, fileUrl, base64Data, fileName, filePath, traceFilePath, insightName } = request; try { switch (action) { @@ -39,6 +39,21 @@ export class FileHandler { case 'cleanupFile': return await this.cleanupFile(filePath); + case 'analyzeTrace': { + const targetPath = traceFilePath || filePath; + if (!targetPath) { + return { success: false, error: 'traceFilePath is required' }; + } + try { + // With tsconfig moduleResolution=NodeNext, relative ESM imports need explicit .js extension + const { analyzeTraceFile } = await import('./trace-analyzer.js'); + const res = await analyzeTraceFile(targetPath, insightName); + return { success: true, ...res }; + } catch (e: any) { + return { success: false, error: e?.message || String(e) }; + } + } + default: return { success: false, @@ -91,7 +106,7 @@ export class FileHandler { try { // Remove data URL prefix if present const base64Content = base64Data.replace(/^data:.*?;base64,/, ''); - + // Convert base64 to buffer const buffer = Buffer.from(base64Content, 'base64'); @@ -222,4 +237,4 @@ export class FileHandler { } } -export default new FileHandler(); \ No newline at end of file +export default new FileHandler(); diff --git a/app/native-server/src/mcp/register-tools.ts b/app/native-server/src/mcp/register-tools.ts index f917d8e..44a5bc8 100644 --- a/app/native-server/src/mcp/register-tools.ts +++ b/app/native-server/src/mcp/register-tools.ts @@ -26,7 +26,7 @@ const handleToolCall = async (name: string, args: any): Promise args, }, NativeMessageType.CALL_TOOL, - 30000, // 30秒超时 + 120000, // 延长到 120 秒,避免性能分析等长任务超时 ); if (response.status === 'success') { return response.data; diff --git a/app/native-server/src/shims/devtools.d.ts b/app/native-server/src/shims/devtools.d.ts new file mode 100644 index 0000000..2207dd3 --- /dev/null +++ b/app/native-server/src/shims/devtools.d.ts @@ -0,0 +1,7 @@ +// Single-file shim for all deep imports from chrome-devtools-frontend. +// This prevents TypeScript from type-checking the upstream DevTools source tree. +// Runtime still loads the real package; this file is types-only and local to TS. +declare module 'chrome-devtools-frontend/*' { + const anyExport: any; + export = anyExport; +} diff --git a/app/native-server/src/trace-analyzer.ts b/app/native-server/src/trace-analyzer.ts new file mode 100644 index 0000000..0332665 --- /dev/null +++ b/app/native-server/src/trace-analyzer.ts @@ -0,0 +1,86 @@ +import * as fs from 'fs'; + +// Import DevTools trace engine and formatters from chrome-devtools-frontend +// We intentionally use deep imports to match the package structure. +// These modules are ESM and require NodeNext module resolution. +// Types are loosely typed to minimize coupling with DevTools internals. +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import * as TraceEngine from 'chrome-devtools-frontend/front_end/models/trace/trace.js'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import { PerformanceTraceFormatter } from 'chrome-devtools-frontend/front_end/models/ai_assistance/data_formatters/PerformanceTraceFormatter.js'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import { PerformanceInsightFormatter } from 'chrome-devtools-frontend/front_end/models/ai_assistance/data_formatters/PerformanceInsightFormatter.js'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import { AgentFocus } from 'chrome-devtools-frontend/front_end/models/ai_assistance/performance/AIContext.js'; + +const engine = TraceEngine.TraceModel.Model.createWithAllHandlers(); + +function readJsonFile(path: string): any { + const text = fs.readFileSync(path, 'utf-8'); + return JSON.parse(text); +} + +export async function parseTrace(json: any): Promise<{ + parsedTrace: any; + insights: any | null; +}> { + engine.resetProcessor(); + const events = Array.isArray(json) ? json : json.traceEvents; + if (!events || !Array.isArray(events)) { + throw new Error('Invalid trace format: expected array or {traceEvents: []}'); + } + await engine.parse(events); + const parsedTrace = engine.parsedTrace(); + const insights = parsedTrace?.insights ?? null; + if (!parsedTrace) throw new Error('No parsed trace returned by engine'); + return { parsedTrace, insights }; +} + +export function getTraceSummary(parsedTrace: any): string { + const focus = AgentFocus.fromParsedTrace(parsedTrace); + const formatter = new PerformanceTraceFormatter(focus); + return formatter.formatTraceSummary(); +} + +export function getInsightText(parsedTrace: any, insights: any, insightName: string): string { + if (!insights) throw new Error('No insights available for this trace'); + const mainNavId = parsedTrace.data?.Meta?.mainFrameNavigations?.at(0)?.args?.data?.navigationId; + const NO_NAV = TraceEngine.Types.Events.NO_NAVIGATION; + const set = insights.get(mainNavId ?? NO_NAV); + if (!set) throw new Error('No insights for selected navigation'); + const model = set.model || {}; + if (!(insightName in model)) throw new Error(`Insight not found: ${insightName}`); + const formatter = new PerformanceInsightFormatter( + AgentFocus.fromParsedTrace(parsedTrace), + model[insightName], + ); + return formatter.formatInsight(); +} + +export async function analyzeTraceFile( + filePath: string, + insightName?: string, +): Promise<{ + summary: string; + insight?: string; +}> { + const json = readJsonFile(filePath); + const { parsedTrace, insights } = await parseTrace(json); + const summary = getTraceSummary(parsedTrace); + if (insightName) { + try { + const insight = getInsightText(parsedTrace, insights, insightName); + return { summary, insight }; + } catch { + // If requested insight missing, still return summary + return { summary }; + } + } + return { summary }; +} + +export default { analyzeTraceFile }; diff --git a/app/native-server/src/types/devtools-frontend.d.ts b/app/native-server/src/types/devtools-frontend.d.ts new file mode 100644 index 0000000..9c24d68 --- /dev/null +++ b/app/native-server/src/types/devtools-frontend.d.ts @@ -0,0 +1,28 @@ +// Minimal ambient declarations to avoid compiling chrome-devtools-frontend sources. +// We intentionally treat these modules as `any` to keep our build lightweight and decoupled +// from DevTools' internal TypeScript and lib targets. + +declare module 'chrome-devtools-frontend/front_end/models/trace/trace.js' { + // Shape used by our code: TraceModel + Types + Insights + export const TraceModel: any; + export const Types: any; + export const Insights: any; +} + +declare module 'chrome-devtools-frontend/front_end/models/ai_assistance/data_formatters/PerformanceTraceFormatter.js' { + export class PerformanceTraceFormatter { + constructor(...args: any[]); + formatTraceSummary(): string; + } +} + +declare module 'chrome-devtools-frontend/front_end/models/ai_assistance/data_formatters/PerformanceInsightFormatter.js' { + export class PerformanceInsightFormatter { + constructor(...args: any[]); + formatInsight(): string; + } +} + +declare module 'chrome-devtools-frontend/front_end/models/ai_assistance/performance/AIContext.js' { + export const AgentFocus: any; +} diff --git a/app/native-server/tsconfig.json b/app/native-server/tsconfig.json index 788c525..2d14e69 100644 --- a/app/native-server/tsconfig.json +++ b/app/native-server/tsconfig.json @@ -1,19 +1,23 @@ { - "compilerOptions": { - "target": "ES2018", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "lib": ["ES2018", "DOM"], - "outDir": "dist", - "rootDir": "src", - "strict": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "skipLibCheck": true, - "declaration": true, - "sourceMap": true, - "resolveJsonModule": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] - } \ No newline at end of file + "compilerOptions": { + "target": "ES2018", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2018", "DOM"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "declaration": true, + "sourceMap": true, + "resolveJsonModule": true, + "baseUrl": ".", + "paths": { + "chrome-devtools-frontend/*": ["src/shims/devtools.d.ts"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] +} diff --git a/packages/shared/src/tools.ts b/packages/shared/src/tools.ts index 111d282..d18e102 100644 --- a/packages/shared/src/tools.ts +++ b/packages/shared/src/tools.ts @@ -31,6 +31,9 @@ export const TOOL_NAMES = { COMPUTER: 'chrome_computer', HANDLE_DIALOG: 'chrome_handle_dialog', USERSCRIPT: 'chrome_userscript', + PERFORMANCE_START_TRACE: 'performance_start_trace', + PERFORMANCE_STOP_TRACE: 'performance_stop_trace', + PERFORMANCE_ANALYZE_INSIGHT: 'performance_analyze_insight', }, }; @@ -44,6 +47,69 @@ export const TOOL_SCHEMAS: Tool[] = [ required: [], }, }, + { + name: TOOL_NAMES.BROWSER.PERFORMANCE_START_TRACE, + description: + 'Starts a performance trace recording on the selected page. Optionally reloads the page and/or auto-stops after a short duration.', + inputSchema: { + type: 'object', + properties: { + reload: { + type: 'boolean', + description: + 'Determines if, once tracing has started, the page should be automatically reloaded (ignore cache).', + }, + autoStop: { + type: 'boolean', + description: 'Determines if the trace should be automatically stopped (default false).', + }, + durationMs: { + type: 'number', + description: 'Auto-stop duration in milliseconds when autoStop is true (default 5000).', + }, + }, + required: [], + }, + }, + { + name: TOOL_NAMES.BROWSER.PERFORMANCE_STOP_TRACE, + description: 'Stops the active performance trace recording on the selected page.', + inputSchema: { + type: 'object', + properties: { + saveToDownloads: { + type: 'boolean', + description: 'Whether to save the trace as a JSON file in Downloads (default true).', + }, + filenamePrefix: { + type: 'string', + description: 'Optional filename prefix for the downloaded trace JSON.', + }, + }, + required: [], + }, + }, + { + name: TOOL_NAMES.BROWSER.PERFORMANCE_ANALYZE_INSIGHT, + description: + 'Provides a lightweight summary of the last recorded trace. For deep insights (CWV, breakdowns), integrate native-side DevTools trace engine.', + inputSchema: { + type: 'object', + properties: { + insightName: { + type: 'string', + description: + 'Optional insight name for future deep analysis (e.g., "DocumentLatency"). Currently informational only.', + }, + timeoutMs: { + type: 'number', + description: + 'Timeout for deep analysis via native host (milliseconds). Default 60000. Increase for large traces.', + }, + }, + required: [], + }, + }, { name: TOOL_NAMES.BROWSER.READ_PAGE, description: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ef98e3..ed89a4a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,6 +111,9 @@ importers: chalk: specifier: ^5.4.1 version: 5.4.1 + chrome-devtools-frontend: + specifier: ^1.0.1299282 + version: 1.0.1526630 chrome-mcp-shared: specifier: workspace:* version: link:../../packages/shared @@ -1563,6 +1566,9 @@ packages: chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + chrome-devtools-frontend@1.0.1526630: + resolution: {integrity: sha512-zl2taUBtNgA0Mlz1pnL0DfXCYq5M+G9Yo9hFx9b4Y2pJ8Q/J/qepq2/6hC3I/N3tP7KnuJ/+eJcBr2qMewwoag==} + chrome-launcher@1.1.2: resolution: {integrity: sha512-YclTJey34KUm5jB1aEJCq807bSievi7Nb/TU4Gu504fUYi3jw3KCIaH6L7nFWQhdEgH3V+wCh+kKD1P5cXnfxw==} engines: {node: '>=12.13.0'} @@ -6138,6 +6144,8 @@ snapshots: chownr@1.1.4: {} + chrome-devtools-frontend@1.0.1526630: {} + chrome-launcher@1.1.2: dependencies: '@types/node': 22.15.30