feat: Paginate detailed diff in compare_heapsnapshots (#3195)

This adds pagination support when compare_heapsnapshots is called with a
given `classIndex`.
This commit is contained in:
Dominik Inführ authored and GitHub committed 2026-10-16 09:36:34 +00:00
1 parent f73a440b49
commit 4a9dd53fe6
10 files changed
+173 -110

No files matched your search

+25 -10
View File
@@ -474,12 +474,13 @@ export class McpResponse implements Response {
setHeapSnapshotDetailedClassDiff(
detailedClassDiff: HeapSnapshotDetailedClassDiff,
options?: HeapSnapshotFormatOptions,
options?: PaginationOptions & HeapSnapshotFormatOptions,
): void {
this.#heapSnapshotOptions = {
...this.#heapSnapshotOptions,
include: true,
detailedClassDiff,
pagination: options,
formatOptions: options,
};
}
@@ -1487,21 +1488,35 @@ Call ${handleDialog(this.#args).name} to handle it before continuing.`);
const detailedClassDiff = this.#heapSnapshotOptions.detailedClassDiff;
if (detailedClassDiff) {
response.push('### Heap Snapshot Detailed Diff');
const {classDiff, objects} = detailedClassDiff;
const paginationData = this.#dataWithPagination(
objects,
this.#heapSnapshotOptions.pagination,
);
structuredContent.pagination = paginationData.pagination;
response.push(
compactEncode
? compactEncode({
...detailedClassDiff,
className: formatName(
detailedClassDiff.className,
formatOptions,
),
...classDiff,
className: formatName(classDiff.className, formatOptions),
})
: HeapSnapshotFormatter.formatDiffDetails(
detailedClassDiff,
: HeapSnapshotFormatter.formatDetailedClassDiffHeader(
classDiff,
formatOptions,
),
);
structuredContent.heapSnapshotDetailedClassDiff = detailedClassDiff;
response.push(...paginationData.info);
response.push(
compactEncode
? compactEncode(paginationData.items)
: HeapSnapshotFormatter.formatDetailedClassDiffObjects(
paginationData.items,
),
);
structuredContent.heapSnapshotDetailedClassDiff = {
classDiff,
objects: paginationData.items,
};
}
const duplicateStrings = this.#heapSnapshotOptions.duplicateStrings;
if (duplicateStrings) {
@@ -1789,7 +1804,7 @@ Call ${handleDialog(this.#args).name} to handle it before continuing.`);
}
#dataWithPagination<T>(
data: T[],
data: readonly T[],
pagination?: PaginationOptions,
): {
info: string[];
+17 -23
View File
@@ -8,7 +8,7 @@ import type {
HeapSnapshotAggregate,
HeapSnapshotClassDiff,
HeapSnapshotClassDiffTotals,
HeapSnapshotDetailedClassDiff,
HeapSnapshotDiffObject,
DuplicateStringGroup,
} from '../processors/HeapSnapshotManager.js';
import {DevTools} from '../third_party/index.js';
@@ -286,36 +286,26 @@ export class HeapSnapshotFormatter {
}
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)}`;
return `Total: ${formatDiffCounts(totals)}`;
}
static formatDiffDetails(
diff: HeapSnapshotDetailedClassDiff,
static formatDetailedClassDiffHeader(
diff: HeapSnapshotClassDiff,
options: HeapSnapshotFormatOptions,
): string {
const lines: string[] = [];
lines.push(
`${formatName(diff.className, options)}: # 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)}`,
);
return `${formatName(diff.className, options)}: ${formatDiffCounts(diff)}`;
}
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++) {
static formatDetailedClassDiffObjects(
objects: readonly HeapSnapshotDiffObject[],
): string {
const lines = ['Objects:'];
for (const object of objects) {
const sign = object.change === 'added' ? '+' : '-';
lines.push(
` + @${addedIds[i]} (self_size: ${formatBytesToKb(addedSelfSizes[i])})`,
` ${sign} @${object.id} (self_size: ${formatBytesToKb(object.selfSize)})`,
);
}
for (let i = 0; i < deletedIds.length; i++) {
lines.push(
` - @${deletedIds[i]} (self_size: ${formatBytesToKb(deletedSelfSizes[i])})`,
);
}
return lines.join('\n');
}
@@ -416,3 +406,7 @@ function formatSignedSize(bytes: number): string {
const formatted = formatBytesToKb(bytes);
return bytes > 0 ? `+${formatted}` : formatted;
}
function formatDiffCounts(counts: HeapSnapshotClassDiffTotals): string {
return `# new: ${counts.addedCount}, # deleted: ${counts.removedCount}, # delta: ${formatSignedCount(counts.countDelta)}, alloc size: ${formatSignedSize(counts.addedSize)}, freed size: ${formatSignedSize(counts.removedSize)}, size delta: ${formatSignedSize(counts.sizeDelta)}`;
}
+38 -29
View File
@@ -41,11 +41,15 @@ export interface HeapSnapshotClassDiff {
sizeDelta: number;
}
export interface HeapSnapshotDetailedClassDiff extends HeapSnapshotClassDiff {
addedIds: number[];
addedSelfSizes: number[];
deletedIds: number[];
deletedSelfSizes: number[];
export interface HeapSnapshotDetailedClassDiff {
classDiff: HeapSnapshotClassDiff;
objects: readonly HeapSnapshotDiffObject[];
}
export interface HeapSnapshotDiffObject {
id: number;
selfSize: number;
change: 'added' | 'deleted';
}
export interface HeapSnapshotClassDiffTotals {
@@ -66,6 +70,22 @@ export type HeapQueryOptions =
export type HeapEdgesQueryOptions =
DevTools.HeapSnapshotModel.HeapSnapshotModel.HeapEdgesQueryOptions;
function toClassDiff(
index: number,
rawDiff: DevTools.HeapSnapshotModel.HeapSnapshotModel.Diff,
): HeapSnapshotClassDiff {
return {
index,
className: rawDiff.name,
addedCount: rawDiff.addedCount,
removedCount: rawDiff.removedCount,
countDelta: rawDiff.countDelta,
addedSize: rawDiff.addedSize,
removedSize: rawDiff.removedSize,
sizeDelta: rawDiff.sizeDelta,
};
}
export class HeapSnapshot {
proxy: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotProxy;
worker: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotWorkerProxy;
@@ -334,16 +354,7 @@ export class HeapSnapshotManager {
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,
});
classDiffs.push(toClassDiff(index, rawDiff));
totals.addedCount += rawDiff.addedCount;
totals.removedCount += rawDiff.removedCount;
totals.countDelta += rawDiff.countDelta;
@@ -369,20 +380,18 @@ export class HeapSnapshotManager {
`Invalid classIndex: ${classIndex}. Total classes with changes: ${classDiffs.length}`,
);
}
return {
index: classIndex,
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 ?? [],
};
const objects: HeapSnapshotDiffObject[] = [];
const addedIds = rawDiff.addedIds ?? [];
const addedSelfSizes = rawDiff.addedSelfSizes ?? [];
for (const [i, id] of addedIds.entries()) {
objects.push({id, selfSize: addedSelfSizes[i], change: 'added'});
}
const deletedIds = rawDiff.deletedIds ?? [];
const deletedSelfSizes = rawDiff.deletedSelfSizes ?? [];
for (const [i, id] of deletedIds.entries()) {
objects.push({id, selfSize: deletedSelfSizes[i], change: 'deleted'});
}
return {classDiff: toClassDiff(classIndex, rawDiff), objects};
}
async #getSortedRawClassDiffs(
+1 -1
View File
@@ -236,7 +236,7 @@ export interface Response {
): void;
setHeapSnapshotDetailedClassDiff(
detailedClassDiff: HeapSnapshotDetailedClassDiff,
options?: HeapSnapshotFormatOptions,
options?: PaginationOptions & HeapSnapshotFormatOptions,
): void;
setHeapSnapshotObjectDetails(
objectInfo: DevTools.HeapSnapshotModel.HeapSnapshotModel.ObjectInfo,
+2
View File
@@ -449,6 +449,8 @@ export const compareHeapSnapshots = defineBrowserlessTool(() => ({
request.params.classIndex,
);
response.setHeapSnapshotDetailedClassDiff(classDiffResult, {
pageIdx: request.params.pageIdx,
pageSize: request.params.pageSize,
maxNameLength: request.params.maxNameLength,
});
} else {
+29
View File
@@ -44,6 +44,7 @@ import {
createMockClassDiffTotals,
createMockContextAnalysisResult,
createMockDetailedClassDiff,
createMockDiffObject,
createMockHeapSnapshotDiff,
createMockHeapSnapshotEdge,
createMockHeapSnapshotNode,
@@ -1724,6 +1725,34 @@ describe('McpResponse heap snapshot formatting', () => {
assert.ok(detailedText.includes('TestClass: # new: 1, # deleted: 0'));
});
it('paginates detailed class diff objects below the class header', async () => {
const response = new McpResponse(createMockParsedArguments());
const objects = [
createMockDiffObject({id: 101, change: 'added'}),
createMockDiffObject({id: 102, change: 'added'}),
createMockDiffObject({id: 201, change: 'deleted'}),
];
const classDiff = createMockClassDiff({className: 'Leaky'});
response.setHeapSnapshotDetailedClassDiff(
createMockDetailedClassDiff({classDiff, objects}),
{pageIdx: 1, pageSize: 2},
);
const context = createMockMcpContext();
const {content, structuredContent} = await response.handle(context);
const text = getTextContent(content[0]);
assert.ok(text.includes('Leaky: # new: 1, # deleted: 0'));
assert.ok(text.includes('Showing 3-3 of 3 (Page 2 of 2).'));
assert.ok(text.includes(' - @201'));
assert.ok(!text.includes('@101'));
assert.ok(!text.includes('@102'));
assert.deepStrictEqual(
Reflect.get(structuredContent, 'heapSnapshotDetailedClassDiff'),
{classDiff, objects: [objects[2]]},
);
});
it('paginates class diff summary and keeps global indices', async () => {
const response = new McpResponse(createMockParsedArguments());
const classDiffs = [
+40 -30
View File
@@ -18,6 +18,7 @@ import {
import {
HeapSnapshotManager,
type HeapSnapshotAggregate,
type HeapSnapshotDiffObject,
} from '../../src/processors/HeapSnapshotManager.js';
import {DevTools} from '../../src/third_party/index.js';
import {parseByteSizeRange} from '../../src/utils/bytes.js';
@@ -211,8 +212,8 @@ describe('HeapSnapshotFormatter', () => {
});
});
describe('formatDiffDetails', () => {
it('formats detailed diffs correctly', () => {
describe('formatDetailedClassDiffHeader and formatDetailedClassDiffObjects', () => {
it('formats the class header and its objects', () => {
const details = {
index: 0,
className: 'MyClass',
@@ -222,25 +223,35 @@ describe('HeapSnapshotFormatter', () => {
addedSize: 120,
removedSize: 60,
sizeDelta: 60,
addedIds: [101, 102],
addedSelfSizes: [60, 60],
deletedIds: [201],
deletedSelfSizes: [60],
};
const objects: HeapSnapshotDiffObject[] = [
{id: 101, selfSize: 60, change: 'added'},
{id: 102, selfSize: 60, change: 'added'},
{id: 201, selfSize: 60, change: 'deleted'},
];
const formatted = HeapSnapshotFormatter.formatDiffDetails(details, {});
const header = HeapSnapshotFormatter.formatDetailedClassDiffHeader(
details,
{},
);
const formattedObjects =
HeapSnapshotFormatter.formatDetailedClassDiffObjects(objects);
const formatted120 = formatBytesToKb(120);
const formatted60 = formatBytesToKb(60);
const expected = [
assert.strictEqual(
header,
`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);
);
assert.strictEqual(
formattedObjects,
[
'Objects:',
` + @101 (self_size: ${formatted60})`,
` + @102 (self_size: ${formatted60})`,
` - @201 (self_size: ${formatted60})`,
].join('\n'),
);
});
it('truncates className according to maxNameLength', () => {
@@ -253,24 +264,20 @@ describe('HeapSnapshotFormatter', () => {
addedSize: 60,
removedSize: 0,
sizeDelta: 60,
addedIds: [101],
addedSelfSizes: [60],
deletedIds: [],
deletedSelfSizes: [],
};
const formatted = HeapSnapshotFormatter.formatDiffDetails(details, {
maxNameLength: 4,
});
const header = HeapSnapshotFormatter.formatDetailedClassDiffHeader(
details,
{
maxNameLength: 4,
},
);
const formatted60 = formatBytesToKb(60);
const expected = [
assert.strictEqual(
header,
`Long...: # new: 1, # deleted: 0, # delta: +1, alloc size: +${formatted60}, freed size: 0.0\u00A0kB, size delta: +${formatted60}`,
'Objects:',
` + @101 (self_size: ${formatted60})`,
].join('\n');
assert.strictEqual(formatted, expected);
);
});
});
@@ -662,13 +669,16 @@ describe('HeapSnapshotFormatter', () => {
});
it('formats detailed diff comparing heap-1 to heap-2 with classIndex filter', async t => {
const detailedDiff = await manager.getDetailedClassDiff(
const {classDiff, objects} = await manager.getDetailedClassDiff(
heap1Path,
heap2Path,
2,
);
t.assert.snapshot(
HeapSnapshotFormatter.formatDiffDetails(detailedDiff, {}),
[
HeapSnapshotFormatter.formatDetailedClassDiffHeader(classDiff, {}),
HeapSnapshotFormatter.formatDetailedClassDiffObjects(objects),
].join('\n'),
);
});
});
+16 -13
View File
@@ -43,6 +43,7 @@ import type {
HeapSnapshotClassDiffTotals,
HeapSnapshotDetailedClassDiff,
HeapSnapshotDiff,
HeapSnapshotDiffObject,
} from '../src/processors/HeapSnapshotManager.js';
import {
CdpBrowser,
@@ -1105,20 +1106,22 @@ export function createMockHeapSnapshotDiff(
};
}
export function createMockDetailedClassDiff(): HeapSnapshotDetailedClassDiff {
export function createMockDiffObject(
options: Partial<HeapSnapshotDiffObject> = {},
): HeapSnapshotDiffObject {
return {
index: 0,
className: 'TestClass',
addedCount: 1,
removedCount: 0,
countDelta: 1,
addedSize: 10,
removedSize: 0,
sizeDelta: 10,
addedIds: [1],
addedSelfSizes: [10],
deletedIds: [],
deletedSelfSizes: [],
id: options.id ?? 1,
selfSize: options.selfSize ?? 10,
change: options.change ?? 'added',
};
}
export function createMockDetailedClassDiff(
options: Partial<HeapSnapshotDetailedClassDiff> = {},
): HeapSnapshotDetailedClassDiff {
return {
classDiff: options.classDiff ?? createMockClassDiff(),
objects: options.objects ?? [createMockDiffObject()],
};
}
+2 -3
View File
@@ -71,13 +71,12 @@ describe('HeapSnapshotManager', () => {
const lastDiff = classDiffs.at(-1);
assert.ok(lastDiff);
const detailedDiff = await manager.getDetailedClassDiff(
const {classDiff} = await manager.getDetailedClassDiff(
filePathA,
filePathB,
lastDiff.index,
);
assert.strictEqual(detailedDiff.index, lastDiff.index);
assert.strictEqual(detailedDiff.className, lastDiff.className);
assert.deepStrictEqual(classDiff, lastDiff);
});
// Verifies that the caching mechanism in HeapSnapshotManager correctly
+3 -1
View File
@@ -644,6 +644,8 @@ describe('memory', () => {
currentFilePath: 'heap2.heapsnapshot',
classIndex: 2,
maxNameLength: 500,
pageIdx: 1,
pageSize: 5,
},
},
response,
@@ -659,7 +661,7 @@ describe('memory', () => {
sinon.assert.calledOnceWithExactly(
response.setHeapSnapshotDetailedClassDiff,
detailedDiff,
{maxNameLength: 500},
{pageIdx: 1, pageSize: 5, maxNameLength: 500},
);
});
});