feat: Add MCP tools for heap snapshot comparison (#2198)

This commit adds two MCP tools for comparing heap snapshots.
`compare_heapsnapshot_summary` compares two memory snapshot and returns
which classes have new/deleted objects.
`compare_heapsnapshot_class_nodes` can then be used to list the object
ids added and deleted for a specific class.

Co-authored-by: Dominik Inführ <dinfuehr@chromium.org>
This commit is contained in:
Dominik Inführ
2026-06-30 15:24:21 +02:00
committed by GitHub
parent 604b38f319
commit 5d7b656050
18 changed files with 767 additions and 10 deletions
+3 -1
View File
@@ -514,9 +514,11 @@ If you run into any issues, checkout our [troubleshooting guide](./docs/troubles
- [`take_snapshot`](docs/tool-reference.md#take_snapshot)
- [`screencast_start`](docs/tool-reference.md#screencast_start)
- [`screencast_stop`](docs/tool-reference.md#screencast_stop)
- **Memory** (9 tools)
- **Memory** (11 tools)
- [`take_heapsnapshot`](docs/tool-reference.md#take_heapsnapshot)
- [`close_heapsnapshot`](docs/tool-reference.md#close_heapsnapshot)
- [`compare_heapsnapshots_class_nodes`](docs/tool-reference.md#compare_heapsnapshots_class_nodes)
- [`compare_heapsnapshots_summary`](docs/tool-reference.md#compare_heapsnapshots_summary)
- [`get_heapsnapshot_class_nodes`](docs/tool-reference.md#get_heapsnapshot_class_nodes)
- [`get_heapsnapshot_details`](docs/tool-reference.md#get_heapsnapshot_details)
- [`get_heapsnapshot_dominators`](docs/tool-reference.md#get_heapsnapshot_dominators)
+26 -1
View File
@@ -39,9 +39,11 @@
- [`take_snapshot`](#take_snapshot)
- [`screencast_start`](#screencast_start)
- [`screencast_stop`](#screencast_stop)
- **[Memory](#memory)** (9 tools)
- **[Memory](#memory)** (11 tools)
- [`take_heapsnapshot`](#take_heapsnapshot)
- [`close_heapsnapshot`](#close_heapsnapshot)
- [`compare_heapsnapshots_class_nodes`](#compare_heapsnapshots_class_nodes)
- [`compare_heapsnapshots_summary`](#compare_heapsnapshots_summary)
- [`get_heapsnapshot_class_nodes`](#get_heapsnapshot_class_nodes)
- [`get_heapsnapshot_details`](#get_heapsnapshot_details)
- [`get_heapsnapshot_dominators`](#get_heapsnapshot_dominators)
@@ -469,6 +471,29 @@ in the DevTools Elements panel (if any).
---
### `compare_heapsnapshots_class_nodes`
**Description:** Loads two memory heapsnapshots and returns the diff details (added/deleted instances) for a specific class. (requires flag: --memoryDebugging=true)
**Parameters:**
- **baseFilePath** (string) **(required)**: A path to the base .heapsnapshot file (earlier snapshot).
- **classIndex** (number) **(required)**: 0-based index of the class in the summary list to filter results, showing individual objects.
- **currentFilePath** (string) **(required)**: A path to the current .heapsnapshot file (later snapshot).
---
### `compare_heapsnapshots_summary`
**Description:** Loads two memory heapsnapshots and returns the summary diff between them (classes with changes). (requires flag: --memoryDebugging=true)
**Parameters:**
- **baseFilePath** (string) **(required)**: A path to the base .heapsnapshot file (earlier snapshot).
- **currentFilePath** (string) **(required)**: A path to the current .heapsnapshot file (later snapshot).
---
### `get_heapsnapshot_class_nodes`
**Description:** Loads a memory heapsnapshot and returns instances of a specific class with their IDs. (requires flag: --memoryDebugging=true)
+1
View File
@@ -18,6 +18,7 @@ export default defineConfig([
'**/node_modules',
'**/build/',
'tests/tools/fixtures/',
'tests/fixtures/',
'src/third_party/lighthouse-devtools-mcp-bundle.js',
]),
importPlugin.flatConfigs.typescript,
+103 -3
View File
@@ -17,7 +17,25 @@ import {
export type AggregatedInfoWithId =
WithSymbolId<DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo>;
export interface HeapSnapshotClassDiff {
className: string;
addedCount: number;
removedCount: number;
countDelta: number;
addedSize: number;
removedSize: number;
sizeDelta: number;
}
export interface HeapSnapshotDetailedClassDiff extends HeapSnapshotClassDiff {
addedIds: number[];
addedSelfSizes: number[];
deletedIds: number[];
deletedSelfSizes: number[];
}
export class HeapSnapshotManager {
#snapshotIdGenerator = createIdGenerator();
#snapshots = new Map<
string,
{
@@ -39,7 +57,8 @@ export class HeapSnapshotManager {
return cached.snapshot;
}
const {snapshot, worker} = await this.#loadSnapshot(absolutePath);
const uid = this.#snapshotIdGenerator();
const {snapshot, worker} = await this.#loadSnapshot(absolutePath, uid);
this.#snapshots.set(absolutePath, {
snapshot,
worker,
@@ -173,6 +192,55 @@ export class HeapSnapshotManager {
return await provider.serializeItemsRange(0, Infinity);
}
async getClassDiffs(
baseFilePath: string,
currentFilePath: string,
): Promise<HeapSnapshotClassDiff[]> {
const rawDiffs = await this.#getSortedRawClassDiffs(
baseFilePath,
currentFilePath,
);
return rawDiffs.map(rawDiff => ({
className: rawDiff.name,
addedCount: rawDiff.addedCount,
removedCount: rawDiff.removedCount,
countDelta: rawDiff.countDelta,
addedSize: rawDiff.addedSize,
removedSize: rawDiff.removedSize,
sizeDelta: rawDiff.sizeDelta,
}));
}
async getDetailedClassDiff(
baseFilePath: string,
currentFilePath: string,
classIndex: number,
): Promise<HeapSnapshotDetailedClassDiff> {
const classDiffs = await this.#getSortedRawClassDiffs(
baseFilePath,
currentFilePath,
);
const rawDiff = classDiffs[classIndex];
if (!rawDiff) {
throw new Error(
`Invalid classIndex: ${classIndex}. Total classes with changes: ${classDiffs.length}`,
);
}
return {
className: rawDiff.name,
addedCount: rawDiff.addedCount,
removedCount: rawDiff.removedCount,
countDelta: rawDiff.countDelta,
addedSize: rawDiff.addedSize,
removedSize: rawDiff.removedSize,
sizeDelta: rawDiff.sizeDelta,
addedIds: rawDiff.addedIds ?? [],
addedSelfSizes: rawDiff.addedSelfSizes ?? [],
deletedIds: rawDiff.deletedIds ?? [],
deletedSelfSizes: rawDiff.deletedSelfSizes ?? [],
};
}
#getCachedSnapshot(filePath: string) {
const absolutePath = path.resolve(filePath);
const cached = this.#snapshots.get(absolutePath);
@@ -182,6 +250,35 @@ export class HeapSnapshotManager {
return cached;
}
async #getSortedRawClassDiffs(
baseFilePath: string,
currentFilePath: string,
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.Diff[]> {
const baseSnapshot = await this.getSnapshot(baseFilePath);
const currentSnapshot = await this.getSnapshot(currentFilePath);
const interfaceDefinitions = await currentSnapshot.interfaceDefinitions();
const aggregatesForDiff =
await baseSnapshot.aggregatesForDiff(interfaceDefinitions);
const baseSnapshotId = baseSnapshot.uid;
if (baseSnapshotId === undefined) {
throw new Error('Base snapshot UID is undefined');
}
// DevTools calculateSnapshotDiff uses the first parameter (baseSnapshotId)
// as a cache key. We pass the unique UID of the base snapshot.
const rawDiffs = await currentSnapshot.calculateSnapshotDiff(
baseSnapshotId,
aggregatesForDiff,
);
// Return a filtered and sorted array here to ensure that
// compare_heapsnapshot_summary and compare_heapsnapshot_details agree
// on indices.
return Object.values(rawDiffs)
.filter(diff => diff.addedCount > 0 || diff.removedCount > 0)
.sort((a, b) => b.sizeDelta - a.sizeDelta);
}
async resolveClassKeyFromId(
filePath: string,
id: number,
@@ -190,7 +287,10 @@ export class HeapSnapshotManager {
return cached.idToClassKey.get(id);
}
async #loadSnapshot(absolutePath: string): Promise<{
async #loadSnapshot(
absolutePath: string,
uid: number,
): Promise<{
snapshot: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotProxy;
worker: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotWorkerProxy;
}> {
@@ -205,7 +305,7 @@ export class HeapSnapshotManager {
const {promise: snapshotPromise, resolve: resolveSnapshot} =
Promise.withResolvers<DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotProxy>();
const loaderProxy = workerProxy.createLoader(1, snapshotProxy => {
const loaderProxy = workerProxy.createLoader(uid, snapshotProxy => {
resolveSnapshot(snapshotProxy);
});
+27 -1
View File
@@ -16,7 +16,11 @@ import {
UniverseManager,
} from './devtools/DevtoolsUtils.js';
import {HeapSnapshotManager} from './HeapSnapshotManager.js';
import type {AggregatedInfoWithId} from './HeapSnapshotManager.js';
import type {
AggregatedInfoWithId,
HeapSnapshotClassDiff,
HeapSnapshotDetailedClassDiff,
} from './HeapSnapshotManager.js';
import {McpPage} from './McpPage.js';
import {
NetworkCollector,
@@ -1016,4 +1020,26 @@ export class McpContext implements Context {
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange> {
return await this.#heapSnapshotManager.getEdges(filePath, nodeId);
}
async getHeapSnapshotClassDiffs(
baseFilePath: string,
currentFilePath: string,
): Promise<HeapSnapshotClassDiff[]> {
return await this.#heapSnapshotManager.getClassDiffs(
baseFilePath,
currentFilePath,
);
}
async getHeapSnapshotDetailedClassDiff(
baseFilePath: string,
currentFilePath: string,
classIndex: number,
): Promise<HeapSnapshotDetailedClassDiff> {
return await this.#heapSnapshotManager.getDetailedClassDiff(
baseFilePath,
currentFilePath,
classIndex,
);
}
}
+51 -2
View File
@@ -8,11 +8,18 @@ import type {WebMCPTool} from 'puppeteer-core';
import type {ParsedArguments} from './bin/chrome-devtools-mcp-cli-options.js';
import {ConsoleFormatter} from './formatters/ConsoleFormatter.js';
import {HeapSnapshotFormatter} from './formatters/HeapSnapshotFormatter.js';
import {isEdgeLike, isNodeLike} from './formatters/HeapSnapshotFormatter.js';
import {
HeapSnapshotFormatter,
isEdgeLike,
isNodeLike,
} from './formatters/HeapSnapshotFormatter.js';
import {IssueFormatter} from './formatters/IssueFormatter.js';
import {NetworkFormatter} from './formatters/NetworkFormatter.js';
import {SnapshotFormatter} from './formatters/SnapshotFormatter.js';
import type {
HeapSnapshotClassDiff,
HeapSnapshotDetailedClassDiff,
} from './HeapSnapshotManager.js';
import type {McpContext} from './McpContext.js';
import type {McpPage} from './McpPage.js';
import {UncaughtError} from './PageCollector.js';
@@ -225,6 +232,8 @@ export class McpResponse implements Response {
nodes?: DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange;
retainingPaths?: DevTools.HeapSnapshotModel.HeapSnapshotModel.RetainingPaths;
dominators?: DevTools.HeapSnapshotModel.HeapSnapshotModel.DominatorChain;
classDiffs?: HeapSnapshotClassDiff[];
detailedClassDiff?: HeapSnapshotDetailedClassDiff;
};
#networkRequestsOptions?: {
include: boolean;
@@ -501,6 +510,24 @@ export class McpResponse implements Response {
};
}
setHeapSnapshotClassDiffs(classDiffs: HeapSnapshotClassDiff[]) {
this.#heapSnapshotOptions = {
...this.#heapSnapshotOptions,
include: true,
classDiffs,
};
}
setHeapSnapshotDetailedClassDiff(
detailedClassDiff: HeapSnapshotDetailedClassDiff,
) {
this.#heapSnapshotOptions = {
...this.#heapSnapshotOptions,
include: true,
detailedClassDiff,
};
}
attachImage(value: ImageContentData): void {
this.#images.push(value);
}
@@ -832,6 +859,8 @@ export class McpResponse implements Response {
heapSnapshotNodes?: readonly object[];
heapSnapshotRetainingPaths?: object;
heapSnapshotDominators?: readonly object[];
heapSnapshotClassDiffs?: HeapSnapshotClassDiff[];
heapSnapshotDetailedClassDiff?: HeapSnapshotDetailedClassDiff;
extensionServiceWorkers?: object[];
extensionPages?: object[];
errorMessage?: string;
@@ -1170,6 +1199,26 @@ Call ${handleDialog.name} to handle it before continuing.`);
}
structuredContent.heapSnapshotDominators = dominators;
}
const classDiffs = this.#heapSnapshotOptions.classDiffs;
if (classDiffs) {
response.push('### Heap Snapshot Diff');
response.push(
useToon && toonEncode
? toonEncode(classDiffs)
: HeapSnapshotFormatter.formatDiffSummary(classDiffs),
);
structuredContent.heapSnapshotClassDiffs = classDiffs;
}
const detailedClassDiff = this.#heapSnapshotOptions.detailedClassDiff;
if (detailedClassDiff) {
response.push('### Heap Snapshot Detailed Diff');
response.push(
useToon && toonEncode
? toonEncode(detailedClassDiff)
: HeapSnapshotFormatter.formatDiffDetails(detailedClassDiff),
);
structuredContent.heapSnapshotDetailedClassDiff = detailedClassDiff;
}
}
if (data.detailedNetworkRequest) {
+49
View File
@@ -108,6 +108,55 @@ export const commands: Commands = {
},
},
},
compare_heapsnapshots_class_nodes: {
description:
'Loads two memory heapsnapshots and returns the diff details (added/deleted instances) for a specific class. (requires flag: --memoryDebugging=true)',
category: 'Memory',
args: {
baseFilePath: {
name: 'baseFilePath',
type: 'string',
description:
'A path to the base .heapsnapshot file (earlier snapshot).',
required: true,
},
currentFilePath: {
name: 'currentFilePath',
type: 'string',
description:
'A path to the current .heapsnapshot file (later snapshot).',
required: true,
},
classIndex: {
name: 'classIndex',
type: 'number',
description:
'0-based index of the class in the summary list to filter results, showing individual objects.',
required: true,
},
},
},
compare_heapsnapshots_summary: {
description:
'Loads two memory heapsnapshots and returns the summary diff between them (classes with changes). (requires flag: --memoryDebugging=true)',
category: 'Memory',
args: {
baseFilePath: {
name: 'baseFilePath',
type: 'string',
description:
'A path to the base .heapsnapshot file (earlier snapshot).',
required: true,
},
currentFilePath: {
name: 'currentFilePath',
type: 'string',
description:
'A path to the current .heapsnapshot file (later snapshot).',
required: true,
},
},
},
drag: {
description: 'Drag an element onto another element',
category: 'Input automation',
+58 -1
View File
@@ -4,7 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type {AggregatedInfoWithId} from '../HeapSnapshotManager.js';
import type {
AggregatedInfoWithId,
HeapSnapshotClassDiff,
HeapSnapshotDetailedClassDiff,
} from '../HeapSnapshotManager.js';
import {DevTools} from '../third_party/index.js';
import {stableIdSymbol} from '../utils/id.js';
@@ -157,4 +161,57 @@ export class HeapSnapshotFormatter {
> {
return Object.entries(aggregates).sort((a, b) => b[1].maxRet - a[1].maxRet);
}
static formatDiffSummary(diffs: HeapSnapshotClassDiff[]): string {
const lines: string[] = [];
lines.push(
'index,className,addedCount,removedCount,countDelta,addedSize,removedSize,sizeDelta',
);
let index = 0;
for (const diff of diffs) {
lines.push(
`${index},${diff.className},${diff.addedCount},${diff.removedCount},${diff.countDelta},${DevTools.I18n.ByteUtilities.formatBytesToKb(diff.addedSize)},${DevTools.I18n.ByteUtilities.formatBytesToKb(diff.removedSize)},${DevTools.I18n.ByteUtilities.formatBytesToKb(diff.sizeDelta)}`,
);
index++;
}
return lines.join('\n');
}
static formatDiffDetails(diff: HeapSnapshotDetailedClassDiff): string {
const lines: string[] = [];
lines.push(
`${diff.className}: # new: ${diff.addedCount}, # deleted: ${diff.removedCount}, # delta: ${formatSignedCount(diff.countDelta)}, alloc size: ${formatSignedSize(diff.addedSize)}, freed size: ${formatSignedSize(diff.removedSize)}, size delta: ${formatSignedSize(diff.sizeDelta)}`,
);
const addedIds = diff.addedIds;
const addedSelfSizes = diff.addedSelfSizes;
const deletedIds = diff.deletedIds;
const deletedSelfSizes = diff.deletedSelfSizes;
lines.push(`Objects:`);
for (let i = 0; i < addedIds.length; i++) {
lines.push(
` + @${addedIds[i]} (self_size: ${DevTools.I18n.ByteUtilities.formatBytesToKb(addedSelfSizes[i])})`,
);
}
for (let i = 0; i < deletedIds.length; i++) {
lines.push(
` - @${deletedIds[i]} (self_size: ${DevTools.I18n.ByteUtilities.formatBytesToKb(deletedSelfSizes[i])})`,
);
}
return lines.join('\n');
}
}
function formatSignedCount(n: number): string {
return n > 0 ? `+${n}` : `${n}`;
}
function formatSignedSize(bytes: number): string {
const formatted = DevTools.I18n.ByteUtilities.formatBytesToKb(bytes);
return bytes > 0 ? `+${formatted}` : formatted;
}
+30
View File
@@ -810,5 +810,35 @@
"argType": "number"
}
]
},
{
"name": "compare_heapsnapshots_class_nodes",
"args": [
{
"name": "base_file_path_length",
"argType": "number"
},
{
"name": "current_file_path_length",
"argType": "number"
},
{
"name": "class_index",
"argType": "number"
}
]
},
{
"name": "compare_heapsnapshots_summary",
"args": [
{
"name": "base_file_path_length",
"argType": "number"
},
{
"name": "current_file_path_length",
"argType": "number"
}
]
}
]
+18 -1
View File
@@ -5,7 +5,11 @@
*/
import type {ParsedArguments} from '../bin/chrome-devtools-mcp-cli-options.js';
import type {AggregatedInfoWithId} from '../HeapSnapshotManager.js';
import type {
AggregatedInfoWithId,
HeapSnapshotClassDiff,
HeapSnapshotDetailedClassDiff,
} from '../HeapSnapshotManager.js';
import type {McpPage} from '../McpPage.js';
import {zod} from '../third_party/index.js';
import type {
@@ -122,6 +126,10 @@ export interface Response {
setHeapSnapshotDominators(
dominators: DevTools.HeapSnapshotModel.HeapSnapshotModel.DominatorChain,
): void;
setHeapSnapshotClassDiffs(classDiffs: HeapSnapshotClassDiff[]): void;
setHeapSnapshotDetailedClassDiff(
detailedClassDiff: HeapSnapshotDetailedClassDiff,
): void;
setIncludePages(value: boolean): void;
setIncludeNetworkRequests(
value: boolean,
@@ -271,6 +279,15 @@ export type Context = Readonly<{
filePath: string,
nodeId: number,
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange>;
getHeapSnapshotClassDiffs(
baseFilePath: string,
currentFilePath: string,
): Promise<HeapSnapshotClassDiff[]>;
getHeapSnapshotDetailedClassDiff(
baseFilePath: string,
currentFilePath: string,
classIndex: number,
): Promise<HeapSnapshotDetailedClassDiff>;
}>;
/**
+63
View File
@@ -280,3 +280,66 @@ export const getHeapSnapshotDominators = defineTool({
response.setHeapSnapshotDominators(dominators);
},
});
export const compareHeapSnapshotsSummary = defineTool({
name: 'compare_heapsnapshots_summary',
description:
'Loads two memory heapsnapshots and returns the summary diff between them (classes with changes).',
annotations: {
category: ToolCategory.MEMORY,
readOnlyHint: true,
conditions: ['memoryDebugging'],
},
verifyFilesSchema: ['baseFilePath', 'currentFilePath'],
schema: {
baseFilePath: zod
.string()
.describe('A path to the base .heapsnapshot file (earlier snapshot).'),
currentFilePath: zod
.string()
.describe('A path to the current .heapsnapshot file (later snapshot).'),
},
blockedByDialog: false,
handler: async (request, response, context) => {
const diff = await context.getHeapSnapshotClassDiffs(
request.params.baseFilePath,
request.params.currentFilePath,
);
response.setHeapSnapshotClassDiffs(diff);
},
});
export const compareHeapSnapshotsClassNodes = defineTool({
name: 'compare_heapsnapshots_class_nodes',
description:
'Loads two memory heapsnapshots and returns the diff details (added/deleted instances) for a specific class.',
annotations: {
category: ToolCategory.MEMORY,
readOnlyHint: true,
conditions: ['memoryDebugging'],
},
verifyFilesSchema: ['baseFilePath', 'currentFilePath'],
schema: {
baseFilePath: zod
.string()
.describe('A path to the base .heapsnapshot file (earlier snapshot).'),
currentFilePath: zod
.string()
.describe('A path to the current .heapsnapshot file (later snapshot).'),
classIndex: zod
.number()
.describe(
'0-based index of the class in the summary list to filter results, showing individual objects.',
),
},
blockedByDialog: false,
handler: async (request, response, context) => {
const classDiffResult = await context.getHeapSnapshotDetailedClassDiff(
request.params.baseFilePath,
request.params.currentFilePath,
request.params.classIndex,
);
response.setHeapSnapshotDetailedClassDiff(classDiffResult);
},
});
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+49
View File
@@ -0,0 +1,49 @@
'use strict';
// Note: This file is not executed during tests. It is only used to document how
// heap-1.heapsnapshot, heap-2.heapsnapshot, and heap-3.heapsnapshot were created,
// and to allow recreating them in the future if necessary.
//
// Heap snapshots were created with this command line:
// d8 --allow-natives-syntax snapshot_diffs.js
function InitialObject(label, payloadSize) {
this.kind = 'initial';
this.label = label;
this.payload = `${label}-` + 'x'.repeat(payloadSize);
this.meta = {createdAt: Date.now()};
}
function NewObject(label, payloadSize) {
this.kind = 'new';
this.label = label;
this.payload = `${label}-` + 'y'.repeat(payloadSize);
this.extra = [1, 2, 3, 4, 5];
}
const refs = {
a: new InitialObject('a', 200000),
b: new InitialObject('b', 180000),
keep: new InitialObject('keep', 220000),
};
%TakeHeapSnapshot('heap-1.heapsnapshot');
// Drop two refs by overwriting them with new objects.
refs.a = new NewObject('a-replacement', 210000);
refs.b = new NewObject('b-replacement', 190000);
%TakeHeapSnapshot('heap-2.heapsnapshot');
// Add five more NewObject refs.
refs.c = new NewObject('c-extra', 150000);
refs.d = new NewObject('d-extra', 160000);
refs.e = new NewObject('e-extra', 170000);
refs.f = new NewObject('f-extra', 180000);
refs.g = new NewObject('g-extra', 190000);
%TakeHeapSnapshot('heap-3.heapsnapshot');
print(
'Wrote heap-1.heapsnapshot, heap-2.heapsnapshot, and heap-3.heapsnapshot',
);
@@ -130,6 +130,66 @@ describe('HeapSnapshotFormatter', () => {
});
});
describe('formatDiffSummary', () => {
it('includes classes with balanced added and removed objects', () => {
const summarized = [
{
className: 'Balanced',
addedCount: 1,
removedCount: 1,
countDelta: 0,
addedSize: 100,
removedSize: 100,
sizeDelta: 0,
},
];
const result = HeapSnapshotFormatter.formatDiffSummary(summarized);
const expected = [
'index,className,addedCount,removedCount,countDelta,addedSize,removedSize,sizeDelta',
`0,Balanced,1,1,0,${DevTools.I18n.ByteUtilities.formatBytesToKb(100)},${DevTools.I18n.ByteUtilities.formatBytesToKb(100)},${DevTools.I18n.ByteUtilities.formatBytesToKb(0)}`,
].join('\n');
assert.strictEqual(result, expected);
const summarizedJson = JSON.stringify(summarized);
assert.ok(summarizedJson);
assert.equal(summarizedJson.includes('addedIndexes'), false);
assert.equal(summarizedJson.includes('deletedIndexes'), false);
});
});
describe('formatDiffDetails', () => {
it('formats detailed diffs correctly', () => {
const details = {
className: 'MyClass',
addedCount: 2,
removedCount: 1,
countDelta: 1,
addedSize: 120,
removedSize: 60,
sizeDelta: 60,
addedIds: [101, 102],
addedSelfSizes: [60, 60],
deletedIds: [201],
deletedSelfSizes: [60],
};
const formatted = HeapSnapshotFormatter.formatDiffDetails(details);
const formatted120 = DevTools.I18n.ByteUtilities.formatBytesToKb(120);
const formatted60 = DevTools.I18n.ByteUtilities.formatBytesToKb(60);
const expected = [
`MyClass: # new: 2, # deleted: 1, # delta: +1, alloc size: +${formatted120}, freed size: +${formatted60}, size delta: +${formatted60}`,
'Objects:',
` + @101 (self_size: ${formatted60})`,
` + @102 (self_size: ${formatted60})`,
` - @201 (self_size: ${formatted60})`,
].join('\n');
assert.strictEqual(formatted, expected);
});
});
describe('sort', () => {
it('sorts aggregates by retained size descending', () => {
const unsortedAggregates: Record<
+37
View File
@@ -1,3 +1,40 @@
exports[`memory > compare_heapsnapshots_class_nodes > compare heap-1 to heap-2 with classIndex filter 1`] = `
## Heap Snapshot Data
### Heap Snapshot Detailed Diff
NewObject: # new: 2, # deleted: 0, # delta: +2, alloc size: +0.1 kB, freed size: 0.0 kB, size delta: +0.1 kB
Objects:
+ @35179 (self_size: 0.1 kB)
+ @35181 (self_size: 0.1 kB)
`;
exports[`memory > compare_heapsnapshots_summary > compare heap-1 to heap-2 1`] = `
## Heap Snapshot Data
### Heap Snapshot Diff
index,className,addedCount,removedCount,countDelta,addedSize,removedSize,sizeDelta
0,(object shape),11,1,10,0.4 kB,0.0 kB,0.4 kB
1,(compiled code),7,1,6,0.3 kB,0.0 kB,0.2 kB
2,NewObject,2,0,2,0.1 kB,0.0 kB,0.1 kB
3,(concatenated string),45,43,2,0.9 kB,0.9 kB,0.0 kB
4,Array,2,0,2,0.0 kB,0.0 kB,0.0 kB
5,{constructor},1,0,1,0.0 kB,0.0 kB,0.0 kB
6,(string),4,4,0,0.1 kB,0.1 kB,0.0 kB
7,(number),0,2,-2,0.0 kB,0.0 kB,-0.0 kB
8,Object,0,2,-2,0.0 kB,0.0 kB,-0.0 kB
9,InitialObject,0,2,-2,0.0 kB,0.1 kB,-0.1 kB
`;
exports[`memory > compare_heapsnapshots_summary > compare heap-2 to heap-3 1`] = `
## Heap Snapshot Data
### Heap Snapshot Diff
index,className,addedCount,removedCount,countDelta,addedSize,removedSize,sizeDelta
0,(concatenated string),105,0,105,2.1 kB,0.0 kB,2.1 kB
1,(object shape),7,1,6,0.3 kB,0.1 kB,0.3 kB
2,(string),10,0,10,0.2 kB,0.0 kB,0.2 kB
3,NewObject,5,0,5,0.1 kB,0.0 kB,0.1 kB
4,Array,5,0,5,0.1 kB,0.0 kB,0.1 kB
5,system / PropertyArray,1,0,1,0.0 kB,0.0 kB,0.0 kB
`;
exports[`memory > get_heapsnapshot_class_nodes > with default options 1`] = `
## Heap Snapshot Data
nodeId,nodeName,type,distance,selfSize,retainedSize
+168
View File
@@ -21,6 +21,8 @@ import {
getHeapSnapshotRetainingPaths,
getHeapSnapshotEdges,
getHeapSnapshotDominators,
compareHeapSnapshotsSummary,
compareHeapSnapshotsClassNodes,
} from '../../src/tools/memory.js';
import {withMcpContext} from '../utils.js';
@@ -361,4 +363,170 @@ describe('memory', () => {
});
});
});
describe('compare_heapsnapshots_summary', () => {
it('compare heap-1 to heap-2', async t => {
await withMcpContext(async (response, context) => {
const filePathA = join(
process.cwd(),
'tests/fixtures/heap-1.heapsnapshot',
);
const filePathB = join(
process.cwd(),
'tests/fixtures/heap-2.heapsnapshot',
);
await compareHeapSnapshotsSummary.handler(
{params: {baseFilePath: filePathA, currentFilePath: filePathB}},
response,
context,
);
const responseData = await response.handle(
compareHeapSnapshotsSummary.name,
context,
);
const output = responseData.content
.map(c => (c.type === 'text' ? c.text : ''))
.join('\n');
t.assert.snapshot(output);
});
});
it('compare heap-2 to heap-3', async t => {
await withMcpContext(async (response, context) => {
const filePathA = join(
process.cwd(),
'tests/fixtures/heap-2.heapsnapshot',
);
const filePathB = join(
process.cwd(),
'tests/fixtures/heap-3.heapsnapshot',
);
await compareHeapSnapshotsSummary.handler(
{params: {baseFilePath: filePathA, currentFilePath: filePathB}},
response,
context,
);
const responseData = await response.handle(
compareHeapSnapshotsSummary.name,
context,
);
const output = responseData.content
.map(c => (c.type === 'text' ? c.text : ''))
.join('\n');
t.assert.snapshot(output);
});
});
});
describe('compare_heapsnapshots_class_nodes', () => {
it('compare heap-1 to heap-2 with classIndex filter', async t => {
await withMcpContext(async (response, context) => {
const filePathA = join(
process.cwd(),
'tests/fixtures/heap-1.heapsnapshot',
);
const filePathB = join(
process.cwd(),
'tests/fixtures/heap-2.heapsnapshot',
);
await compareHeapSnapshotsClassNodes.handler(
{
params: {
baseFilePath: filePathA,
currentFilePath: filePathB,
classIndex: 2, // NewObject
},
},
response,
context,
);
const responseData = await response.handle(
compareHeapSnapshotsClassNodes.name,
context,
);
const output = responseData.content
.map(c => (c.type === 'text' ? c.text : ''))
.join('\n');
t.assert.snapshot(output);
});
});
it('compare heap-1 to heap-2 with invalid classIndex throws error', async t => {
await withMcpContext(async (response, context) => {
const filePathA = join(
process.cwd(),
'tests/fixtures/heap-1.heapsnapshot',
);
const filePathB = join(
process.cwd(),
'tests/fixtures/heap-2.heapsnapshot',
);
await t.assert.rejects(
compareHeapSnapshotsClassNodes.handler(
{
params: {
baseFilePath: filePathA,
currentFilePath: filePathB,
classIndex: 99,
},
},
response,
context,
),
/Invalid classIndex: 99. Total classes with changes: 10/,
);
});
});
});
// Verifies that the caching mechanism in HeapSnapshotManager correctly
// distinguishes comparisons when the same "current" snapshot is compared
// against different "base" snapshots. If the cache key (diffCacheKey) is
// not unique per base snapshot, the second comparison might incorrectly
// return cached results from the first comparison.
it('compares the same current snapshot against different bases', async () => {
await withMcpContext(async (_response, context) => {
const filePathA = join(
process.cwd(),
'tests/fixtures/heap-1.heapsnapshot',
);
const filePathB = join(
process.cwd(),
'tests/fixtures/heap-2.heapsnapshot',
);
const filePathC = join(
process.cwd(),
'tests/fixtures/heap-3.heapsnapshot',
);
const firstDiff = await context.getHeapSnapshotClassDiffs(
filePathA,
filePathC,
);
const secondDiff = await context.getHeapSnapshotClassDiffs(
filePathB,
filePathC,
);
const firstNewObjectDiff = firstDiff.find(
entry => entry.className === 'NewObject',
);
const secondNewObjectDiff = secondDiff.find(
entry => entry.className === 'NewObject',
);
assert.ok(firstNewObjectDiff);
assert.ok(secondNewObjectDiff);
assert.equal(firstNewObjectDiff.addedCount, 7);
assert.equal(secondNewObjectDiff.addedCount, 5);
});
});
});