diff --git a/docs/tool-reference.md b/docs/tool-reference.md index 35f04752e..415e9dbd1 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -484,6 +484,7 @@ in the DevTools Elements panel (if any). - **filePath** (string) **(required)**: A path to a .heapsnapshot file to read. - **id** (number) **(required)**: The ID for the class, obtained from details. +- **filterName** (enum: "objectsRetainedByDetachedDomNodes", "objectsRetainedByConsole", "objectsRetainedByEventHandlers", "objectsRetainedByContexts") _(optional)_: An optional filter to apply to the nodes. - **pageIdx** (number) _(optional)_: The page index for pagination. - **pageSize** (number) _(optional)_: The page size for pagination. @@ -496,6 +497,7 @@ in the DevTools Elements panel (if any). **Parameters:** - **filePath** (string) **(required)**: A path to a .heapsnapshot file to read. +- **filterName** (enum: "objectsRetainedByDetachedDomNodes", "objectsRetainedByConsole", "objectsRetainedByEventHandlers", "objectsRetainedByContexts") _(optional)_: An optional filter to apply to the aggregates. - **pageIdx** (number) _(optional)_: The page index for pagination of aggregates. - **pageSize** (number) _(optional)_: The page size for pagination of aggregates. diff --git a/src/HeapSnapshotManager.ts b/src/HeapSnapshotManager.ts index 088d9671b..067d9f69b 100644 --- a/src/HeapSnapshotManager.ts +++ b/src/HeapSnapshotManager.ts @@ -73,10 +73,14 @@ export class HeapSnapshotManager { async getAggregates( filePath: string, + filterName?: string, ): Promise> { const snapshot = await this.getSnapshot(filePath); const filter = new DevTools.HeapSnapshotModel.HeapSnapshotModel.NodeFilter(); + if (filterName) { + filter.filterName = filterName; + } const aggregates: Record = await snapshot.aggregatesWithFilter(filter); @@ -122,10 +126,14 @@ export class HeapSnapshotManager { async getNodesById( filePath: string, id: number, + filterName?: string, ): Promise { const snapshot = await this.getSnapshot(filePath); const filter = new DevTools.HeapSnapshotModel.HeapSnapshotModel.NodeFilter(); + if (filterName) { + filter.filterName = filterName; + } const className = await this.resolveClassKeyFromId(filePath, id); if (!className) { throw new Error(`Class with ID ${id} not found in heap snapshot`); diff --git a/src/McpContext.ts b/src/McpContext.ts index d01f6445c..985a32416 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -952,8 +952,9 @@ export class McpContext implements Context { async getHeapSnapshotAggregates( filePath: string, + filterName?: string, ): Promise> { - return await this.#heapSnapshotManager.getAggregates(filePath); + return await this.#heapSnapshotManager.getAggregates(filePath, filterName); } async getHeapSnapshotDuplicateStrings( @@ -977,8 +978,13 @@ export class McpContext implements Context { async getHeapSnapshotNodesById( filePath: string, id: number, + filterName?: string, ): Promise { - return await this.#heapSnapshotManager.getNodesById(filePath, id); + return await this.#heapSnapshotManager.getNodesById( + filePath, + id, + filterName, + ); } async getHeapSnapshotRetainers( diff --git a/src/bin/chrome-devtools-cli-options.ts b/src/bin/chrome-devtools-cli-options.ts index b0d6a4fea..8224c0be6 100644 --- a/src/bin/chrome-devtools-cli-options.ts +++ b/src/bin/chrome-devtools-cli-options.ts @@ -349,6 +349,18 @@ export const commands: Commands = { description: 'The ID for the class, obtained from details.', required: true, }, + filterName: { + name: 'filterName', + type: 'string', + description: 'An optional filter to apply to the nodes.', + required: false, + enum: [ + 'objectsRetainedByDetachedDomNodes', + 'objectsRetainedByConsole', + 'objectsRetainedByEventHandlers', + 'objectsRetainedByContexts', + ], + }, pageIdx: { name: 'pageIdx', type: 'number', @@ -374,6 +386,18 @@ export const commands: Commands = { description: 'A path to a .heapsnapshot file to read.', required: true, }, + filterName: { + name: 'filterName', + type: 'string', + description: 'An optional filter to apply to the aggregates.', + required: false, + enum: [ + 'objectsRetainedByDetachedDomNodes', + 'objectsRetainedByConsole', + 'objectsRetainedByEventHandlers', + 'objectsRetainedByContexts', + ], + }, pageIdx: { name: 'pageIdx', type: 'number', diff --git a/src/telemetry/tool_call_metrics.json b/src/telemetry/tool_call_metrics.json index 2d44ef995..6b19f8953 100644 --- a/src/telemetry/tool_call_metrics.json +++ b/src/telemetry/tool_call_metrics.json @@ -684,6 +684,10 @@ { "name": "id", "argType": "number" + }, + { + "name": "filter_name", + "argType": "string" } ] }, @@ -701,6 +705,10 @@ { "name": "page_size", "argType": "number" + }, + { + "name": "filter_name", + "argType": "string" } ] }, diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index 36948e2af..ab1914c4a 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -257,6 +257,7 @@ export type Context = Readonly<{ ): string | undefined; getHeapSnapshotAggregates( filePath: string, + filterName?: string, ): Promise>; getHeapSnapshotDuplicateStrings( filePath: string, @@ -270,6 +271,7 @@ export type Context = Readonly<{ getHeapSnapshotNodesById( filePath: string, id: number, + filterName?: string, ): Promise; getHeapSnapshotRetainers( filePath: string, diff --git a/src/tools/memory.ts b/src/tools/memory.ts index e4d9c37d8..d368580ca 100644 --- a/src/tools/memory.ts +++ b/src/tools/memory.ts @@ -9,6 +9,13 @@ import {zod} from '../third_party/index.js'; import {ToolCategory} from './categories.js'; import {definePageTool, defineTool} from './ToolDefinition.js'; +const HEAP_SNAPSHOT_FILTERS: readonly [string, ...string[]] = [ + 'objectsRetainedByDetachedDomNodes', + 'objectsRetainedByConsole', + 'objectsRetainedByEventHandlers', + 'objectsRetainedByContexts', +]; + export const takeHeapSnapshot = definePageTool({ name: 'take_heapsnapshot', description: `Capture a heap snapshot of the currently selected page. Use to analyze the memory distribution of JavaScript objects and debug memory leaks.`, @@ -73,6 +80,10 @@ export const getHeapSnapshotDetails = defineTool({ }, schema: { filePath: zod.string().describe('A path to a .heapsnapshot file to read.'), + filterName: zod + .enum(HEAP_SNAPSHOT_FILTERS) + .optional() + .describe('An optional filter to apply to the aggregates.'), pageIdx: zod .number() .optional() @@ -87,6 +98,7 @@ export const getHeapSnapshotDetails = defineTool({ handler: async (request, response, context) => { const aggregates = await context.getHeapSnapshotAggregates( request.params.filePath, + request.params.filterName, ); response.setHeapSnapshotAggregates(aggregates, { @@ -108,6 +120,10 @@ export const getHeapSnapshotClassNodes = defineTool({ schema: { filePath: zod.string().describe('A path to a .heapsnapshot file to read.'), id: zod.number().describe('The ID for the class, obtained from details.'), + filterName: zod + .enum(HEAP_SNAPSHOT_FILTERS) + .optional() + .describe('An optional filter to apply to the nodes.'), pageIdx: zod.number().optional().describe('The page index for pagination.'), pageSize: zod.number().optional().describe('The page size for pagination.'), }, @@ -117,6 +133,7 @@ export const getHeapSnapshotClassNodes = defineTool({ const nodes = await context.getHeapSnapshotNodesById( request.params.filePath, request.params.id, + request.params.filterName, ); response.setHeapSnapshotNodes(nodes, { diff --git a/tests/tools/memory.test.js.snapshot b/tests/tools/memory.test.js.snapshot index 3bb55cf4c..9f32ca89d 100644 --- a/tests/tools/memory.test.js.snapshot +++ b/tests/tools/memory.test.js.snapshot @@ -49,6 +49,18 @@ nodeId,nodeName,type,distance,selfSize,retainedSize Showing 1-8 of 8 (Page 1 of 1). `; +exports[`memory > get_heapsnapshot_class_nodes > with objectsRetainedByContexts filterName 1`] = ` +## Heap Snapshot Data +nodeId,nodeName,type,distance,selfSize,retainedSize +45897,makeCallable,closure,5,0.0 kB,0.2 kB +46147,makeCallable,closure,5,0.0 kB,0.2 kB +45893,Apply,closure,5,0.0 kB,0.0 kB +45895,Save,closure,5,0.0 kB,0.0 kB +46143,Apply,closure,5,0.0 kB,0.0 kB +46145,Save,closure,5,0.0 kB,0.0 kB +Showing 1-6 of 6 (Page 1 of 1). +`; + exports[`memory > get_heapsnapshot_details > with default options 1`] = ` ## Heap Snapshot Data Showing 1-157 of 157 (Page 1 of 1). @@ -212,6 +224,16 @@ id,name,count,selfSize,maxRetainedSize 108,HTMLBodyElement (internal cache) / https://example.com,1,0.0 kB,0.0 kB `; +exports[`memory > get_heapsnapshot_details > with objectsRetainedByContexts filterName 1`] = ` +## Heap Snapshot Data +Showing 1-4 of 4 (Page 1 of 1). +id,name,count,selfSize,maxRetainedSize +1,system / Context,116,2.5 kB,3.5 kB +3,Function,6,0.2 kB,0.7 kB +2,(compiled code),22,0.5 kB,0.5 kB +4,Array,4,0.3 kB,0.3 kB +`; + exports[`memory > get_heapsnapshot_dominators > with valid nodeId 1`] = ` ## Heap Snapshot Data ### Dominator Chain diff --git a/tests/tools/memory.test.ts b/tests/tools/memory.test.ts index 0e8538cbd..e77bfbd43 100644 --- a/tests/tools/memory.test.ts +++ b/tests/tools/memory.test.ts @@ -24,6 +24,7 @@ import { compareHeapSnapshots, getHeapSnapshotDuplicateStrings, } from '../../src/tools/memory.js'; +import {stableIdSymbol} from '../../src/utils/id.js'; import {withMcpContext} from '../utils.js'; describe('memory', () => { @@ -104,6 +105,31 @@ describe('memory', () => { t.assert.snapshot(output); }); }); + + it('with objectsRetainedByContexts filterName', async t => { + await withMcpContext(async (response, context) => { + const filePath = join( + process.cwd(), + 'tests/fixtures/example.heapsnapshot', + ); + + await getHeapSnapshotDetails.handler( + {params: {filePath, filterName: 'objectsRetainedByContexts'}}, + response, + context, + ); + + const responseData = await response.handle( + getHeapSnapshotDetails.name, + context, + ); + const output = responseData.content + .map(c => (c.type === 'text' ? c.text : '')) + .join('\n'); + + t.assert.snapshot(output); + }); + }); }); describe('get_heapsnapshot_class_nodes', () => { @@ -135,6 +161,43 @@ describe('memory', () => { }); }); + it('with objectsRetainedByContexts filterName', async t => { + await withMcpContext(async (response, context) => { + const filePath = join( + process.cwd(), + 'tests/fixtures/example.heapsnapshot', + ); + + const aggregates = await context.getHeapSnapshotAggregates( + filePath, + 'objectsRetainedByContexts', + ); + const aggregate = Object.values(aggregates).find( + a => a.name === 'Function', + ); + assert.ok(aggregate); + const id = aggregate[stableIdSymbol]; + assert.ok(id); + + await getHeapSnapshotClassNodes.handler( + {params: {filePath, id, filterName: 'objectsRetainedByContexts'}}, + response, + context, + ); + + const responseData = await response.handle( + getHeapSnapshotClassNodes.name, + context, + ); + + const output = responseData.content + .map(c => (c.type === 'text' ? c.text : '')) + .join('\n'); + + t.assert.snapshot(output); + }); + }); + it('with non-existent class name', async () => { await withMcpContext(async (response, context) => { const filePath = join(