feat: Add pagination to compare_heapsnapshots (#3140)

Support pagination in the summary diff of compare_heapsnapshots. The
"detailed" case will be done in a follow-up.

For this `HeapSnapshotManager.getClassDiffs()` now adds an `index` field
to each class. It also computes a total for all classes (not just the
current page). Both classes and the total are then returned in the new
`HeapSnapshotDiff` object. Previously this method only returned classes
as `HeapSnapshotClassDiff[]`.
This commit is contained in:
Dominik Inführ authored and GitHub committed 2026-10-15 09:39:20 +00:00
1 parent 9171f77c4d
commit daebfd1f6f
14 files changed
+257 -59

No files matched your search

+2
View File
@@ -552,6 +552,8 @@ in the DevTools Elements panel (if any).
- **currentFilePath** (string) **(required)**: A path to the current .heapsnapshot file (later snapshot).
- **classIndex** (number) _(optional)_: Optional 0-based index of the class in the summary list to filter results, showing individual objects.
- **maxNameLength** (integer) _(optional)_: Maximum length of names before truncation. Defaults to 100.
- **pageIdx** (integer) _(optional)_: The page index for pagination.
- **pageSize** (integer) _(optional)_: The page size for pagination.
---
+2 -2
View File
@@ -13,7 +13,7 @@ import {overrideDevToolsGlobals} from './devtools/DevtoolsUtils.js';
import type {
HeapSnapshotManager,
HeapSnapshotAggregateData,
HeapSnapshotClassDiff,
HeapSnapshotDiff,
HeapSnapshotDetailedClassDiff,
DuplicateStringGroup,
HeapEdgesQueryOptions,
@@ -993,7 +993,7 @@ export class McpContext implements Context {
async getHeapSnapshotClassDiffs(
baseFilePath: string,
currentFilePath: string,
): Promise<HeapSnapshotClassDiff[]> {
): Promise<HeapSnapshotDiff> {
return await this.#heapSnapshotManager.getClassDiffs(
baseFilePath,
currentFilePath,
+23 -10
View File
@@ -34,7 +34,9 @@ import {SnapshotFormatter} from './formatters/SnapshotFormatter.js';
import type {
HeapSnapshotAggregateData,
HeapSnapshotClassDiff,
HeapSnapshotClassDiffTotals,
HeapSnapshotDetailedClassDiff,
HeapSnapshotDiff,
DuplicateStringGroup,
} from './processors/HeapSnapshotManager.js';
import type {McpContext} from './McpContext.js';
@@ -113,7 +115,7 @@ export class McpResponse implements Response {
nodes?: DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange;
retainingPaths?: DevTools.HeapSnapshotModel.HeapSnapshotModel.RetainingPaths;
dominators?: DevTools.HeapSnapshotModel.HeapSnapshotModel.DominatorChain;
classDiffs?: HeapSnapshotClassDiff[];
diff?: HeapSnapshotDiff;
detailedClassDiff?: HeapSnapshotDetailedClassDiff;
duplicateStrings?: DuplicateStringGroup[];
objectInfo?: DevTools.HeapSnapshotModel.HeapSnapshotModel.ObjectInfo;
@@ -458,13 +460,14 @@ export class McpResponse implements Response {
}
setHeapSnapshotClassDiffs(
classDiffs: HeapSnapshotClassDiff[],
options?: HeapSnapshotFormatOptions,
diff: HeapSnapshotDiff,
options?: PaginationOptions & HeapSnapshotFormatOptions,
): void {
this.#heapSnapshotOptions = {
...this.#heapSnapshotOptions,
include: true,
classDiffs,
diff,
pagination: options,
formatOptions: options,
};
}
@@ -917,12 +920,13 @@ export class McpResponse implements Response {
objectCount: number;
totalSelfSize: number;
};
classDiffStats?: HeapSnapshotClassDiffTotals;
};
heapSnapshotAggregates?: readonly object[];
heapSnapshotNodes?: readonly object[];
heapSnapshotRetainingPaths?: object;
heapSnapshotDominators?: readonly object[];
heapSnapshotClassDiffs?: HeapSnapshotClassDiff[];
heapSnapshotClassDiffs?: readonly HeapSnapshotClassDiff[];
heapSnapshotDetailedClassDiff?: HeapSnapshotDetailedClassDiff;
heapSnapshotDuplicateStrings?: readonly DuplicateStringGroup[];
heapSnapshotObjectDetails?: DevTools.HeapSnapshotModel.HeapSnapshotModel.ObjectInfo;
@@ -1453,23 +1457,32 @@ Call ${handleDialog(this.#args).name} to handle it before continuing.`);
}
structuredContent.heapSnapshotDominators = dominators;
}
const classDiffs = this.#heapSnapshotOptions.classDiffs;
if (classDiffs) {
const diff = this.#heapSnapshotOptions.diff;
if (diff) {
response.push('### Heap Snapshot Diff');
response.push(HeapSnapshotFormatter.formatDiffTotals(diff.totals));
structuredContent.heapSnapshot = structuredContent.heapSnapshot || {};
structuredContent.heapSnapshot.classDiffStats = diff.totals;
const paginationData = this.#dataWithPagination(
diff.classDiffs,
this.#heapSnapshotOptions.pagination,
);
structuredContent.pagination = paginationData.pagination;
response.push(...paginationData.info);
response.push(
compactEncode
? compactEncode(
classDiffs.map(diff => ({
paginationData.items.map(diff => ({
...diff,
className: formatName(diff.className, formatOptions),
})),
)
: HeapSnapshotFormatter.formatDiffSummary(
classDiffs,
paginationData.items,
formatOptions,
),
);
structuredContent.heapSnapshotClassDiffs = classDiffs;
structuredContent.heapSnapshotClassDiffs = paginationData.items;
}
const detailedClassDiff = this.#heapSnapshotOptions.detailedClassDiff;
if (detailedClassDiff) {
+14
View File
@@ -227,6 +227,20 @@ export const commands: Commands = {
required: false,
positional: false,
},
pageIdx: {
name: 'pageIdx',
type: 'integer',
description: 'The page index for pagination.',
required: false,
positional: false,
},
pageSize: {
name: 'pageSize',
type: 'integer',
description: 'The page size for pagination.',
required: false,
positional: false,
},
},
},
drag: {
+7 -4
View File
@@ -7,6 +7,7 @@
import type {
HeapSnapshotAggregate,
HeapSnapshotClassDiff,
HeapSnapshotClassDiffTotals,
HeapSnapshotDetailedClassDiff,
DuplicateStringGroup,
} from '../processors/HeapSnapshotManager.js';
@@ -267,7 +268,7 @@ export class HeapSnapshotFormatter {
}
static formatDiffSummary(
diffs: HeapSnapshotClassDiff[],
diffs: readonly HeapSnapshotClassDiff[],
options: HeapSnapshotFormatOptions,
): string {
const lines: string[] = [];
@@ -275,17 +276,19 @@ export class HeapSnapshotFormatter {
'index,className,addedCount,removedCount,countDelta,addedSize,removedSize,sizeDelta',
);
let index = 0;
for (const diff of diffs) {
lines.push(
`${index},${formatName(diff.className, options)},${diff.addedCount},${diff.removedCount},${diff.countDelta},${formatBytesToKb(diff.addedSize)},${formatBytesToKb(diff.removedSize)},${formatBytesToKb(diff.sizeDelta)}`,
`${diff.index},${formatName(diff.className, options)},${diff.addedCount},${diff.removedCount},${diff.countDelta},${formatBytesToKb(diff.addedSize)},${formatBytesToKb(diff.removedSize)},${formatBytesToKb(diff.sizeDelta)}`,
);
index++;
}
return lines.join('\n');
}
static formatDiffTotals(totals: HeapSnapshotClassDiffTotals): string {
return `Total: # new: ${totals.addedCount}, # deleted: ${totals.removedCount}, # delta: ${formatSignedCount(totals.countDelta)}, alloc size: ${formatSignedSize(totals.addedSize)}, freed size: ${formatSignedSize(totals.removedSize)}, size delta: ${formatSignedSize(totals.sizeDelta)}`;
}
static formatDiffDetails(
diff: HeapSnapshotDetailedClassDiff,
options: HeapSnapshotFormatOptions,
+46 -10
View File
@@ -24,7 +24,14 @@ export interface HeapSnapshotAggregateData {
totalSelfSize: number;
}
export interface HeapSnapshotDiff {
classDiffs: HeapSnapshotClassDiff[];
totals: HeapSnapshotClassDiffTotals;
}
export interface HeapSnapshotClassDiff {
/** Position in the full sorted diff list. Use as `classIndex`. */
index: number;
className: string;
addedCount: number;
removedCount: number;
@@ -41,6 +48,15 @@ export interface HeapSnapshotDetailedClassDiff extends HeapSnapshotClassDiff {
deletedSelfSizes: number[];
}
export interface HeapSnapshotClassDiffTotals {
addedCount: number;
removedCount: number;
countDelta: number;
addedSize: number;
removedSize: number;
sizeDelta: number;
}
export type DuplicateStringGroup =
DevTools.HeapSnapshotModel.HeapSnapshotModel.DuplicateStringGroup;
@@ -303,20 +319,39 @@ export class HeapSnapshotManager {
async getClassDiffs(
baseFilePath: string,
currentFilePath: string,
): Promise<HeapSnapshotClassDiff[]> {
): Promise<HeapSnapshotDiff> {
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,
}));
const classDiffs: HeapSnapshotClassDiff[] = [];
const totals: HeapSnapshotClassDiffTotals = {
addedCount: 0,
removedCount: 0,
countDelta: 0,
addedSize: 0,
removedSize: 0,
sizeDelta: 0,
};
for (const [index, rawDiff] of rawDiffs.entries()) {
classDiffs.push({
index,
className: rawDiff.name,
addedCount: rawDiff.addedCount,
removedCount: rawDiff.removedCount,
countDelta: rawDiff.countDelta,
addedSize: rawDiff.addedSize,
removedSize: rawDiff.removedSize,
sizeDelta: rawDiff.sizeDelta,
});
totals.addedCount += rawDiff.addedCount;
totals.removedCount += rawDiff.removedCount;
totals.countDelta += rawDiff.countDelta;
totals.addedSize += rawDiff.addedSize;
totals.removedSize += rawDiff.removedSize;
totals.sizeDelta += rawDiff.sizeDelta;
}
return {classDiffs, totals};
}
async getDetailedClassDiff(
@@ -335,6 +370,7 @@ export class HeapSnapshotManager {
);
}
return {
index: classIndex,
className: rawDiff.name,
addedCount: rawDiff.addedCount,
removedCount: rawDiff.removedCount,
+8
View File
@@ -1091,6 +1091,14 @@
{
"name": "max_name_length",
"argType": "number"
},
{
"name": "page_idx",
"argType": "number"
},
{
"name": "page_size",
"argType": "number"
}
]
},
+4 -4
View File
@@ -7,7 +7,7 @@
import type {ParsedArguments} from '../config/ConfigParser.js';
import type {
HeapSnapshotAggregateData,
HeapSnapshotClassDiff,
HeapSnapshotDiff,
HeapSnapshotDetailedClassDiff,
DuplicateStringGroup,
HeapEdgesQueryOptions,
@@ -220,8 +220,8 @@ export interface Response {
options?: HeapSnapshotFormatOptions,
): void;
setHeapSnapshotClassDiffs(
classDiffs: HeapSnapshotClassDiff[],
options?: HeapSnapshotFormatOptions,
diff: HeapSnapshotDiff,
options?: PaginationOptions & HeapSnapshotFormatOptions,
): void;
setHeapSnapshotDetailedClassDiff(
detailedClassDiff: HeapSnapshotDetailedClassDiff,
@@ -400,7 +400,7 @@ export type Context = Readonly<{
getHeapSnapshotClassDiffs(
baseFilePath: string,
currentFilePath: string,
): Promise<HeapSnapshotClassDiff[]>;
): Promise<HeapSnapshotDiff>;
getHeapSnapshotDetailedClassDiff(
baseFilePath: string,
currentFilePath: string,
+4
View File
@@ -437,6 +437,8 @@ export const compareHeapSnapshots = defineTool(() => ({
'Optional 0-based index of the class in the summary list to filter results, showing individual objects.',
),
maxNameLength: maxNameLengthSchema,
pageIdx: pageIdxSchema,
pageSize: pageSizeSchema,
},
blockedByDialog: false,
handler: async (request, response, context) => {
@@ -455,6 +457,8 @@ export const compareHeapSnapshots = defineTool(() => ({
request.params.currentFilePath,
);
response.setHeapSnapshotClassDiffs(diff, {
pageIdx: request.params.pageIdx,
pageSize: request.params.pageSize,
maxNameLength: request.params.maxNameLength,
});
}
+39 -6
View File
@@ -40,9 +40,11 @@ import {
createMockCSSProperty,
createMockCSSStyleDeclaration,
createMockCSSStyleRule,
createMockClassDiffs,
createMockClassDiff,
createMockClassDiffTotals,
createMockContextAnalysisResult,
createMockDetailedClassDiff,
createMockHeapSnapshotDiff,
createMockHeapSnapshotEdge,
createMockHeapSnapshotNode,
createMockHeapSnapshotStats,
@@ -1688,8 +1690,7 @@ describe('McpResponse heap snapshot formatting', () => {
it('formats class diff summary and detailed diff headers', async () => {
const response = new McpResponse(createMockParsedArguments());
const diffs = createMockClassDiffs();
response.setHeapSnapshotClassDiffs(diffs);
response.setHeapSnapshotClassDiffs(createMockHeapSnapshotDiff());
const context = createMockMcpContext();
const {content} = await response.handle(context);
@@ -1708,12 +1709,44 @@ describe('McpResponse heap snapshot formatting', () => {
assert.ok(detailedText.includes('TestClass: # new: 1, # deleted: 0'));
});
it('paginates class diff summary and keeps global indices', async () => {
const response = new McpResponse(createMockParsedArguments());
const classDiffs = [
createMockClassDiff({index: 0, className: 'ClassA'}),
createMockClassDiff({index: 1, className: 'ClassB'}),
createMockClassDiff({index: 2, className: 'ClassC'}),
];
const totals = createMockClassDiffTotals({addedCount: 3, countDelta: 3});
response.setHeapSnapshotClassDiffs(
createMockHeapSnapshotDiff({classDiffs, totals}),
{pageIdx: 1, pageSize: 2},
);
const context = createMockMcpContext();
const {content, structuredContent} = await response.handle(context);
const text = getTextContent(content[0]);
assert.ok(text.includes('Showing 3-3 of 3 (Page 2 of 2).'));
assert.ok(text.includes('Previous page: 0'));
assert.ok(text.includes('Total: # new: 3, # deleted: 0, # delta: +3'));
assert.ok(text.includes('\n2,ClassC,'));
assert.ok(!text.includes('ClassA'));
assert.ok(!text.includes('ClassB'));
assert.deepStrictEqual(
Reflect.get(structuredContent, 'heapSnapshotClassDiffs'),
[classDiffs[2]],
);
const heapData: unknown = Reflect.get(structuredContent, 'heapSnapshot');
assert.ok(heapData && typeof heapData === 'object');
assert.deepStrictEqual(Reflect.get(heapData, 'classDiffStats'), totals);
});
it('truncates class diff names in compact format', async () => {
const context = createMockMcpContext();
const diffs = createMockClassDiffs();
const toonDiffResponse = new McpResponse(createMockParsedArguments());
toonDiffResponse.setHeapSnapshotClassDiffs(diffs, {maxNameLength: 4});
toonDiffResponse.setHeapSnapshotClassDiffs(createMockHeapSnapshotDiff(), {
maxNameLength: 4,
});
const toonDiffResult = await toonDiffResponse.handle(context, 'toon');
const toonDiffText = getTextContent(toonDiffResult.content[0]);
assert.ok(toonDiffText.includes('Test...'));
+12 -4
View File
@@ -162,6 +162,7 @@ describe('HeapSnapshotFormatter', () => {
it('includes classes with balanced added and removed objects', () => {
const summarized = [
{
index: 0,
className: 'Balanced',
addedCount: 1,
removedCount: 1,
@@ -188,6 +189,7 @@ describe('HeapSnapshotFormatter', () => {
it('truncates className according to maxNameLength', () => {
const summarized = [
{
index: 0,
className: 'LongClassName',
addedCount: 1,
removedCount: 0,
@@ -212,6 +214,7 @@ describe('HeapSnapshotFormatter', () => {
describe('formatDiffDetails', () => {
it('formats detailed diffs correctly', () => {
const details = {
index: 0,
className: 'MyClass',
addedCount: 2,
removedCount: 1,
@@ -242,6 +245,7 @@ describe('HeapSnapshotFormatter', () => {
it('truncates className according to maxNameLength', () => {
const details = {
index: 0,
className: 'LongClassName',
addedCount: 1,
removedCount: 0,
@@ -644,13 +648,17 @@ describe('HeapSnapshotFormatter', () => {
describe('formatDiffSummary and formatDiffDetails', () => {
it('formats diff summary comparing heap-1 to heap-2', async t => {
const diffs = await manager.getClassDiffs(heap1Path, heap2Path);
t.assert.snapshot(HeapSnapshotFormatter.formatDiffSummary(diffs, {}));
const {classDiffs} = await manager.getClassDiffs(heap1Path, heap2Path);
t.assert.snapshot(
HeapSnapshotFormatter.formatDiffSummary(classDiffs, {}),
);
});
it('formats diff summary comparing heap-2 to heap-3', async t => {
const diffs = await manager.getClassDiffs(heap2Path, heap3Path);
t.assert.snapshot(HeapSnapshotFormatter.formatDiffSummary(diffs, {}));
const {classDiffs} = await manager.getClassDiffs(heap2Path, heap3Path);
t.assert.snapshot(
HeapSnapshotFormatter.formatDiffSummary(classDiffs, {}),
);
});
it('formats detailed diff comparing heap-1 to heap-2 with classIndex filter', async t => {
+38 -12
View File
@@ -38,7 +38,9 @@ import type {
HeapSnapshotAggregate,
HeapSnapshotAggregateData,
HeapSnapshotClassDiff,
HeapSnapshotClassDiffTotals,
HeapSnapshotDetailedClassDiff,
HeapSnapshotDiff,
} from '../src/processors/HeapSnapshotManager.js';
import {
CdpBrowser,
@@ -1019,22 +1021,46 @@ export function createMockDominatorChain(): DevTools.HeapSnapshotModel.HeapSnaps
return [];
}
export function createMockClassDiffs(): HeapSnapshotClassDiff[] {
return [
{
className: 'TestClass',
addedCount: 1,
removedCount: 0,
countDelta: 1,
addedSize: 10,
removedSize: 0,
sizeDelta: 10,
},
];
export function createMockClassDiff(
options: Partial<HeapSnapshotClassDiff> = {},
): HeapSnapshotClassDiff {
return {
index: options.index ?? 0,
className: options.className ?? 'TestClass',
addedCount: options.addedCount ?? 1,
removedCount: options.removedCount ?? 0,
countDelta: options.countDelta ?? 1,
addedSize: options.addedSize ?? 10,
removedSize: options.removedSize ?? 0,
sizeDelta: options.sizeDelta ?? 10,
};
}
export function createMockClassDiffTotals(
options: Partial<HeapSnapshotClassDiffTotals> = {},
): HeapSnapshotClassDiffTotals {
return {
addedCount: options.addedCount ?? 1,
removedCount: options.removedCount ?? 0,
countDelta: options.countDelta ?? 1,
addedSize: options.addedSize ?? 10,
removedSize: options.removedSize ?? 0,
sizeDelta: options.sizeDelta ?? 10,
};
}
export function createMockHeapSnapshotDiff(
options: Partial<HeapSnapshotDiff> = {},
): HeapSnapshotDiff {
return {
classDiffs: options.classDiffs ?? [createMockClassDiff()],
totals: options.totals ?? createMockClassDiffTotals(),
};
}
export function createMockDetailedClassDiff(): HeapSnapshotDetailedClassDiff {
return {
index: 0,
className: 'TestClass',
addedCount: 1,
removedCount: 0,
+28 -2
View File
@@ -46,6 +46,32 @@ describe('HeapSnapshotManager', () => {
sinon.assert.calledOnce(disposeSpy);
});
it('assigns class diff indices that resolve to the same detailed diff', async () => {
const manager = new HeapSnapshotManager();
try {
const filePathA = 'tests/fixtures/heap-1.heapsnapshot';
const filePathB = 'tests/fixtures/heap-2.heapsnapshot';
const {classDiffs} = await manager.getClassDiffs(filePathA, filePathB);
assert.deepStrictEqual(
classDiffs.map(diff => diff.index),
classDiffs.map((_, position) => position),
);
const lastDiff = classDiffs.at(-1);
assert.ok(lastDiff);
const detailedDiff = await manager.getDetailedClassDiff(
filePathA,
filePathB,
lastDiff.index,
);
assert.strictEqual(detailedDiff.index, lastDiff.index);
assert.strictEqual(detailedDiff.className, lastDiff.className);
} finally {
manager.dispose();
}
});
// 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
@@ -60,10 +86,10 @@ describe('HeapSnapshotManager', () => {
const firstDiff = await manager.getClassDiffs(filePathA, filePathC);
const secondDiff = await manager.getClassDiffs(filePathB, filePathC);
const firstNewObjectDiff = firstDiff.find(
const firstNewObjectDiff = firstDiff.classDiffs.find(
entry => entry.className === 'NewObject',
);
const secondNewObjectDiff = secondDiff.find(
const secondNewObjectDiff = secondDiff.classDiffs.find(
entry => entry.className === 'NewObject',
);
assert.ok(firstNewObjectDiff);
+30 -5
View File
@@ -31,12 +31,12 @@ import {parseByteSizeRange} from '../../src/utils/bytes.js';
import {resolveCanonicalPath} from '../../src/utils/files.js';
import {
createHandlerMocks,
createMockClassDiffs,
createMockContextAnalysisResult,
createMockDetailedClassDiff,
createMockDominatorChain,
createMockDuplicateStrings,
createMockHeapSnapshotAggregateData,
createMockHeapSnapshotDiff,
createMockHeapSnapshotStats,
createMockHeapSnapshotStaticData,
createMockItemsRange,
@@ -581,8 +581,8 @@ describe('memory', () => {
describe('compare_heapsnapshots', () => {
it('returns summary diff when classIndex is omitted', async () => {
const {context, response, args} = createHandlerMocks();
const diffs = createMockClassDiffs();
context.getHeapSnapshotClassDiffs.resolves(diffs);
const diff = createMockHeapSnapshotDiff();
context.getHeapSnapshotClassDiffs.resolves(diff);
await compareHeapSnapshots(args).handler(
{
@@ -602,8 +602,33 @@ describe('memory', () => {
);
sinon.assert.calledOnceWithExactly(
response.setHeapSnapshotClassDiffs,
diffs,
{maxNameLength: undefined},
diff,
{pageIdx: undefined, pageSize: undefined, maxNameLength: undefined},
);
});
it('passes pagination options to the summary diff', async () => {
const {context, response, args} = createHandlerMocks();
const diff = createMockHeapSnapshotDiff();
context.getHeapSnapshotClassDiffs.resolves(diff);
await compareHeapSnapshots(args).handler(
{
params: {
baseFilePath: 'heap1.heapsnapshot',
currentFilePath: 'heap2.heapsnapshot',
pageIdx: 1,
pageSize: 10,
},
},
response,
context,
);
sinon.assert.calledOnceWithExactly(
response.setHeapSnapshotClassDiffs,
diff,
{pageIdx: 1, pageSize: 10, maxNameLength: undefined},
);
});