621 lines
17 KiB
TypeScript
621 lines
17 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2026 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import assert from 'node:assert';
|
|
import {existsSync} from 'node:fs';
|
|
import {rm} from 'node:fs/promises';
|
|
import {tmpdir} from 'node:os';
|
|
import {join} from 'node:path';
|
|
import {describe, it} from 'node:test';
|
|
|
|
import {
|
|
takeHeapSnapshot,
|
|
getHeapSnapshotSummary,
|
|
getHeapSnapshotDetails,
|
|
getHeapSnapshotClassNodes,
|
|
getHeapSnapshotRetainers,
|
|
closeHeapSnapshot,
|
|
getHeapSnapshotRetainingPaths,
|
|
getHeapSnapshotEdges,
|
|
getHeapSnapshotDominators,
|
|
compareHeapSnapshots,
|
|
getHeapSnapshotDuplicateStrings,
|
|
} from '../../src/tools/memory.js';
|
|
import {stableIdSymbol} from '../../src/utils/id.js';
|
|
import {withMcpContext} from '../utils.js';
|
|
|
|
describe('memory', () => {
|
|
describe('take_heapsnapshot', () => {
|
|
it('with default options', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const filePath = join(tmpdir(), 'test-screenshot.heapsnapshot');
|
|
try {
|
|
await takeHeapSnapshot.handler(
|
|
{params: {filePath}, page: context.getSelectedMcpPage()},
|
|
response,
|
|
context,
|
|
);
|
|
assert.equal(
|
|
response.responseLines.at(0),
|
|
`Heap snapshot saved to ${filePath}`,
|
|
);
|
|
assert.ok(existsSync(filePath));
|
|
} finally {
|
|
await rm(filePath, {force: true});
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('get_heapsnapshot_summary', () => {
|
|
it('with default options', async t => {
|
|
await withMcpContext(async (response, context) => {
|
|
const filePath = join(
|
|
process.cwd(),
|
|
'tests/fixtures/example.heapsnapshot',
|
|
);
|
|
|
|
assert.ok(existsSync(filePath), `Fixture not found at ${filePath}`);
|
|
|
|
await getHeapSnapshotSummary.handler(
|
|
{params: {filePath}},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
// Call handle to trigger formatting (similar to network tests)
|
|
const responseData = await response.handle(
|
|
getHeapSnapshotSummary.name,
|
|
context,
|
|
);
|
|
const output = responseData.content
|
|
.map(c => (c.type === 'text' ? c.text : ''))
|
|
.join('\n');
|
|
|
|
t.assert.snapshot(output);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('get_heapsnapshot_details', () => {
|
|
it('with default options', async t => {
|
|
await withMcpContext(async (response, context) => {
|
|
const filePath = join(
|
|
process.cwd(),
|
|
'tests/fixtures/example.heapsnapshot',
|
|
);
|
|
|
|
await getHeapSnapshotDetails.handler(
|
|
{params: {filePath}},
|
|
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);
|
|
});
|
|
});
|
|
|
|
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', () => {
|
|
it('with default options', async t => {
|
|
await withMcpContext(async (response, context) => {
|
|
const filePath = join(
|
|
process.cwd(),
|
|
'tests/fixtures/example.heapsnapshot',
|
|
);
|
|
|
|
await context.getHeapSnapshotAggregates(filePath);
|
|
|
|
await getHeapSnapshotClassNodes.handler(
|
|
{params: {filePath, id: 19}},
|
|
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 objectsRetainedByContexts filterName', async t => {
|
|
await withMcpContext(async (response, context) => {
|
|
const filePath = join(
|
|
process.cwd(),
|
|
'tests/fixtures/example.heapsnapshot',
|
|
);
|
|
|
|
const aggregateData = await context.getHeapSnapshotAggregates(
|
|
filePath,
|
|
'objectsRetainedByContexts',
|
|
);
|
|
const aggregate = Object.values(aggregateData.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(
|
|
process.cwd(),
|
|
'tests/fixtures/example.heapsnapshot',
|
|
);
|
|
|
|
await context.getHeapSnapshotAggregates(filePath);
|
|
|
|
await assert.rejects(
|
|
getHeapSnapshotClassNodes.handler(
|
|
{params: {filePath, id: 999999}},
|
|
response,
|
|
context,
|
|
),
|
|
{message: 'Class with ID 999999 not found in heap snapshot'},
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('get_heapsnapshot_retainers', () => {
|
|
it('with valid nodeId', async t => {
|
|
await withMcpContext(async (response, context) => {
|
|
const filePath = join(
|
|
process.cwd(),
|
|
'tests/fixtures/example.heapsnapshot',
|
|
);
|
|
|
|
await getHeapSnapshotRetainers.handler(
|
|
{params: {filePath, nodeId: 25341}},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
const responseData = await response.handle(
|
|
getHeapSnapshotRetainers.name,
|
|
context,
|
|
);
|
|
const output = responseData.content
|
|
.map(c => (c.type === 'text' ? c.text : ''))
|
|
.join('\n');
|
|
|
|
t.assert.snapshot(output);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('close_heapsnapshot', () => {
|
|
it('with default options', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const filePath = join(
|
|
process.cwd(),
|
|
'tests/fixtures/example.heapsnapshot',
|
|
);
|
|
|
|
await getHeapSnapshotSummary.handler(
|
|
{params: {filePath}},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
assert.ok(context.hasHeapSnapshots());
|
|
|
|
await closeHeapSnapshot.handler(
|
|
{params: {filePath}},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
assert.ok(
|
|
response.responseLines.includes(`Closed heap snapshot: ${filePath}`),
|
|
);
|
|
assert.ok(!context.hasHeapSnapshots());
|
|
});
|
|
});
|
|
|
|
it('with non-existent snapshot', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const filePath = join(
|
|
process.cwd(),
|
|
'tests/fixtures/example.heapsnapshot',
|
|
);
|
|
|
|
await assert.rejects(
|
|
closeHeapSnapshot.handler({params: {filePath}}, response, context),
|
|
{
|
|
message: `Failed to close heap snapshot: ${filePath} was not loaded.`,
|
|
},
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('get_heapsnapshot_retaining_paths', () => {
|
|
it('with valid nodeId', async t => {
|
|
await withMcpContext(async (response, context) => {
|
|
const filePath = join(
|
|
process.cwd(),
|
|
'tests/fixtures/example.heapsnapshot',
|
|
);
|
|
|
|
await getHeapSnapshotRetainingPaths.handler(
|
|
{params: {filePath, nodeId: 45901}},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
const responseData = await response.handle(
|
|
getHeapSnapshotRetainingPaths.name,
|
|
context,
|
|
);
|
|
const output = responseData.content
|
|
.map(c => (c.type === 'text' ? c.text : ''))
|
|
.join('\n');
|
|
|
|
t.assert.snapshot(output);
|
|
});
|
|
});
|
|
|
|
it('reports when limits are reached', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const filePath = join(
|
|
process.cwd(),
|
|
'tests/fixtures/example.heapsnapshot',
|
|
);
|
|
|
|
await getHeapSnapshotRetainingPaths.handler(
|
|
{params: {filePath, nodeId: 45901, maxDepth: 1}},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
const responseData = await response.handle(
|
|
getHeapSnapshotRetainingPaths.name,
|
|
context,
|
|
);
|
|
const output = responseData.content
|
|
.map(c => (c.type === 'text' ? c.text : ''))
|
|
.join('\n');
|
|
|
|
assert.match(output, /No retaining paths found\./);
|
|
assert.match(
|
|
output,
|
|
/Note: results are truncated, the following limits were reached: depth\./,
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('get_heapsnapshot_edges', () => {
|
|
it('with valid nodeId', async t => {
|
|
await withMcpContext(async (response, context) => {
|
|
const filePath = join(
|
|
process.cwd(),
|
|
'tests/fixtures/example.heapsnapshot',
|
|
);
|
|
|
|
await getHeapSnapshotEdges.handler(
|
|
{params: {filePath, nodeId: 25341}},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
const responseData = await response.handle(
|
|
getHeapSnapshotEdges.name,
|
|
context,
|
|
);
|
|
const output = responseData.content
|
|
.map(c => (c.type === 'text' ? c.text : ''))
|
|
.join('\n');
|
|
|
|
t.assert.snapshot(output);
|
|
});
|
|
});
|
|
|
|
it('with pagination', async t => {
|
|
await withMcpContext(async (response, context) => {
|
|
const filePath = join(
|
|
process.cwd(),
|
|
'tests/fixtures/example.heapsnapshot',
|
|
);
|
|
|
|
await getHeapSnapshotEdges.handler(
|
|
{params: {filePath, nodeId: 25341, pageSize: 2}},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
const responseData = await response.handle(
|
|
getHeapSnapshotEdges.name,
|
|
context,
|
|
);
|
|
const output = responseData.content
|
|
.map(c => (c.type === 'text' ? c.text : ''))
|
|
.join('\n');
|
|
|
|
t.assert.snapshot(output);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('get_heapsnapshot_dominators', () => {
|
|
it('with valid nodeId', async t => {
|
|
await withMcpContext(async (response, context) => {
|
|
const filePath = join(
|
|
process.cwd(),
|
|
'tests/fixtures/example.heapsnapshot',
|
|
);
|
|
|
|
await getHeapSnapshotDominators.handler(
|
|
{params: {filePath, nodeId: 25341}},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
const responseData = await response.handle(
|
|
getHeapSnapshotDominators.name,
|
|
context,
|
|
);
|
|
const output = responseData.content
|
|
.map(c => (c.type === 'text' ? c.text : ''))
|
|
.join('\n');
|
|
|
|
t.assert.snapshot(output);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('compare_heapsnapshots', () => {
|
|
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 compareHeapSnapshots.handler(
|
|
{params: {baseFilePath: filePathA, currentFilePath: filePathB}},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
const responseData = await response.handle(
|
|
compareHeapSnapshots.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 compareHeapSnapshots.handler(
|
|
{params: {baseFilePath: filePathA, currentFilePath: filePathB}},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
const responseData = await response.handle(
|
|
compareHeapSnapshots.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 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 compareHeapSnapshots.handler(
|
|
{
|
|
params: {
|
|
baseFilePath: filePathA,
|
|
currentFilePath: filePathB,
|
|
classIndex: 2, // NewObject
|
|
},
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
const responseData = await response.handle(
|
|
compareHeapSnapshots.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 () => {
|
|
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 assert.rejects(
|
|
compareHeapSnapshots.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);
|
|
});
|
|
});
|
|
|
|
describe('get_heapsnapshot_duplicate_strings', () => {
|
|
it('with default options', async t => {
|
|
await withMcpContext(async (response, context) => {
|
|
const filePath = join(
|
|
process.cwd(),
|
|
'tests/fixtures/example.heapsnapshot',
|
|
);
|
|
|
|
await getHeapSnapshotDuplicateStrings.handler(
|
|
{params: {filePath}},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
const responseData = await response.handle(
|
|
getHeapSnapshotDuplicateStrings.name,
|
|
context,
|
|
);
|
|
const output = responseData.content
|
|
.map(c => (c.type === 'text' ? c.text : ''))
|
|
.join('\n');
|
|
|
|
t.assert.snapshot(output);
|
|
});
|
|
});
|
|
});
|
|
});
|