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();