Files
2026-07-13 12:30:05 +08:00

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);
});
});
});
});