Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 186 additions & 1 deletion tests/HeapSnapshotManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
*/

import assert from 'node:assert';
import {describe, it, afterEach} from 'node:test';
import {join} from 'node:path';
import {describe, it, after, afterEach} from 'node:test';

import sinon from 'sinon';

import {HeapSnapshotManager} from '../src/HeapSnapshotManager.js';
import {DevTools} from '../src/third_party/index.js';
import {stableIdSymbol} from '../src/utils/id.js';

describe('HeapSnapshotManager', () => {
afterEach(() => {
Expand All @@ -36,4 +38,187 @@ describe('HeapSnapshotManager', () => {

sinon.assert.calledOnce(disposeSpy);
});

describe('with the example fixture', () => {
const fixturePath = join(
process.cwd(),
'tests/fixtures/example.heapsnapshot',
);
const manager = new HeapSnapshotManager();

after(() => {
manager.dispose();
});

it('caches snapshots by resolved path', async () => {
const first = await manager.getSnapshot(fixturePath);
const second = await manager.getSnapshot(fixturePath);
const viaRelativePath = await manager.getSnapshot(
'tests/fixtures/example.heapsnapshot',
);

assert.strictEqual(second, first);
assert.strictEqual(viaRelativePath, first);
});

it('aggregates classes with totals and stable ids', async () => {
const data = await manager.getAggregates(fixturePath);
const aggregates = Object.values(data.aggregates);
assert.ok(aggregates.length > 0);

let objectCount = 0;
let totalSelfSize = 0;
const ids = new Set<number>();
for (const aggregate of aggregates) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the same code that we have, which means we are not testing anything.
We should assert at hard coded values as the fixture is hard coded as well.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the same code that we have, which means we are not testing anything.
We should assert at hard coded values as the fixture is hard coded as well.

objectCount += aggregate.count;
totalSelfSize += aggregate.self;
const id = aggregate[stableIdSymbol];
assert.ok(typeof id === 'number' && id > 0);
ids.add(id);
}

assert.ok(data.objectCount > 0);
assert.ok(data.totalSelfSize > 0);
assert.strictEqual(data.objectCount, objectCount);
assert.strictEqual(data.totalSelfSize, totalSelfSize);
// Every class gets a distinct id.
assert.strictEqual(ids.size, aggregates.length);
Comment on lines +84 to +85

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not need it handled by line 76


const arrayAggregate = aggregates.find(
aggregate => aggregate.name === 'Array',
);
assert.ok(arrayAggregate);
assert.strictEqual(arrayAggregate.count, 8);
});

it('returns stable class ids that resolve back to class keys', async () => {
const first = await manager.getAggregates(fixturePath);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have a test that showcases that the two are the same so we can just use one.

const second = await manager.getAggregates(fixturePath);

for (const [classKey, aggregate] of Object.entries(first.aggregates)) {
const id = aggregate[stableIdSymbol];
assert.ok(id);
const other = second.aggregates[classKey];
assert.ok(other);
assert.strictEqual(other[stableIdSymbol], id);
assert.strictEqual(
await manager.resolveClassKeyFromId(fixturePath, id),
classKey,
);
assert.strictEqual(
await manager.getOrCreateIdForClassKey(fixturePath, classKey),
id,
);
}
});

it('applies the objectsRetainedByContexts filter', async () => {
const unfiltered = await manager.getAggregates(fixturePath);
const filtered = await manager.getAggregates(
fixturePath,
'objectsRetainedByContexts',
);

assert.ok(filtered.objectCount > 0);
assert.ok(filtered.objectCount < unfiltered.objectCount);
assert.ok(
Object.values(filtered.aggregates).some(
aggregate => aggregate.name === 'Function',
),
);
});

it('rejects attributedToSpecificNativeContext without an objectId', async () => {
await assert.rejects(
manager.getAggregates(fixturePath, 'attributedToSpecificNativeContext'),
{
message:
'objectId is required when filterName is attributedToSpecificNativeContext',
},
);
});

it('reports snapshot statistics', async () => {
const stats = await manager.getStats(fixturePath);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We hand this off to DevTools so this should not be tested here.


assert.strictEqual(stats.total, 1121496);
assert.strictEqual(stats.native.total + stats.v8heap.total, stats.total);
for (const value of [
stats.native.typedArrays,
stats.v8heap.code,
stats.v8heap.jsArrays,
stats.v8heap.strings,
stats.v8heap.system,
]) {
assert.ok(typeof value === 'number' && value >= 0);
}
});

it('exposes static data once the snapshot is loaded', async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We hand this off to DevTools so this should not be tested here.

const staticData = await manager.getStaticData(fixturePath);
assert.ok(staticData);
assert.strictEqual(staticData.nodeCount, 27466);
assert.strictEqual(staticData.rootNodeIndex, 0);
assert.strictEqual(staticData.maxJSObjectId, 54005);

const stats = await manager.getStats(fixturePath);
assert.strictEqual(staticData.totalSize, stats.total);
});

it('computes native context sizes', async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We hand this off to DevTools so this should not be tested here.

const sizes = await manager.getNativeContextSizes(fixturePath);

assert.strictEqual(sizes.nativeContexts.length, 4);
for (const nativeContext of sizes.nativeContexts) {
assert.ok(nativeContext.nodeName.startsWith('system / NativeContext'));
assert.ok(nativeContext.selfSize > 0);
assert.ok(nativeContext.retainedSize > 0);
assert.ok(nativeContext.attributedSize > 0);
}
assert.ok(
sizes.nativeContexts.some(
nativeContext => nativeContext.nodeId === 7249,
),
);
assert.ok(sizes.sharedSize > 0);
assert.ok(sizes.noAttributionSize > 0);
});
});

describe('snapshot lifecycle', () => {
it('tracks and disposes loaded snapshots', async () => {
const manager = new HeapSnapshotManager();
const fixturePath = join(
process.cwd(),
'tests/fixtures/heap-1.heapsnapshot',
);
try {
assert.strictEqual(manager.hasSnapshots(), false);

await manager.getSnapshot(fixturePath);
assert.strictEqual(manager.hasSnapshots(), true);

// disposeSnapshot resolves relative paths like getSnapshot does.
assert.strictEqual(
manager.disposeSnapshot('tests/fixtures/heap-1.heapsnapshot'),
true,
);
assert.strictEqual(manager.hasSnapshots(), false);
assert.strictEqual(manager.disposeSnapshot(fixturePath), false);
} finally {
manager.dispose();
}
});

it('rejects class key lookups for snapshots that are not loaded', async () => {
const manager = new HeapSnapshotManager();
await assert.rejects(
manager.getOrCreateIdForClassKey(
'/nonexistent/missing.heapsnapshot',
'Foo',
),
/Snapshot not loaded/,
);
});
});
});
161 changes: 160 additions & 1 deletion tests/formatters/HeapSnapshotFormatter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@
import assert from 'node:assert';
import {describe, it} from 'node:test';

import {HeapSnapshotFormatter} from '../../src/formatters/HeapSnapshotFormatter.js';
import {
HeapSnapshotFormatter,
isNodeLike,
isEdgeLike,
} from '../../src/formatters/HeapSnapshotFormatter.js';
import {DevTools} from '../../src/third_party/index.js';
import {stableIdSymbol} from '../../src/utils/id.js';

Expand Down Expand Up @@ -78,6 +82,117 @@ describe('HeapSnapshotFormatter', () => {
});
});

describe('isNodeLike', () => {
it('returns true for objects with id and name', () => {
assert.strictEqual(isNodeLike({id: 1, name: 'NodeA'}), true);
});

it('returns true for a full node shape', () => {
assert.strictEqual(
isNodeLike({
id: 1,
name: 'NodeA',
distance: 0,
nodeIndex: 0,
retainedSize: 0,
selfSize: 0,
type: 'object',
canBeQueried: false,
detachedDOMTreeNode: false,
ignored: false,
isAddedNotRemoved: null,
}),
true,
);
});

it('returns false for null and undefined', () => {
assert.strictEqual(isNodeLike(null), false);
assert.strictEqual(isNodeLike(undefined), false);
});

it('returns false for primitives', () => {
assert.strictEqual(isNodeLike(42), false);
assert.strictEqual(isNodeLike('node'), false);
});

it('returns false for an empty object', () => {
assert.strictEqual(isNodeLike({}), false);
});

it('returns false when id or name is missing', () => {
assert.strictEqual(isNodeLike({id: 1}), false);
assert.strictEqual(isNodeLike({name: 'NodeA'}), false);
});

it('returns false for an edge shape', () => {
assert.strictEqual(
isNodeLike({
name: 'edge1',
type: 'property',
node: {id: 1, name: 'NodeA'},
}),
false,
);
});
});

describe('isEdgeLike', () => {
const validEdge = {
name: 'edge1',
type: 'property',
node: {id: 1, name: 'NodeA'},
};

it('returns true for objects with name, type and a node-like node', () => {
assert.strictEqual(isEdgeLike(validEdge), true);
});

it('returns false for null and undefined', () => {
assert.strictEqual(isEdgeLike(null), false);
assert.strictEqual(isEdgeLike(undefined), false);
});

it('returns false for an empty object', () => {
assert.strictEqual(isEdgeLike({}), false);
});

it('returns false when a top-level property is missing', () => {
assert.strictEqual(
isEdgeLike({type: validEdge.type, node: validEdge.node}),
false,
);
assert.strictEqual(
isEdgeLike({name: validEdge.name, node: validEdge.node}),
false,
);
assert.strictEqual(
isEdgeLike({name: validEdge.name, type: validEdge.type}),
false,
);
});

it('returns false when node is not an object', () => {
assert.strictEqual(isEdgeLike({...validEdge, node: null}), false);
assert.strictEqual(isEdgeLike({...validEdge, node: 42}), false);
});

it('returns false when node is missing id or name', () => {
assert.strictEqual(
isEdgeLike({...validEdge, node: {name: 'NodeA'}}),
false,
);
assert.strictEqual(isEdgeLike({...validEdge, node: {id: 1}}), false);
});

it('returns false for a node shape', () => {
assert.strictEqual(
isEdgeLike({id: 1, name: 'NodeA', type: 'object'}),
false,
);
});
});

describe('formatNodes', () => {
it('formats edges correctly', () => {
const mockEdges = [
Expand Down Expand Up @@ -130,6 +245,50 @@ describe('HeapSnapshotFormatter', () => {

assert.strictEqual(result, expected);
});

it('formats nodes correctly', () => {
const mockNodes = [
{
id: 1,
name: 'NodeA',
distance: 2,
nodeIndex: 0,
retainedSize: 2048,
selfSize: 1024,
type: 'object',
canBeQueried: false,
detachedDOMTreeNode: false,
ignored: false,
isAddedNotRemoved: null,
},
{
id: 2,
name: 'NodeB',
distance: 3,
nodeIndex: 1,
retainedSize: 512,
selfSize: 256,
type: 'closure',
canBeQueried: false,
detachedDOMTreeNode: false,
ignored: false,
isAddedNotRemoved: null,
},
];

const result = HeapSnapshotFormatter.formatNodes(mockNodes);
const expected = [
'nodeId,nodeName,type,distance,selfSize,retainedSize',
`1,NodeA,object,2,${formatBytesToKb(1024)},${formatBytesToKb(2048)}`,
`2,NodeB,closure,3,${formatBytesToKb(256)},${formatBytesToKb(512)}`,
].join('\n');

assert.strictEqual(result, expected);
});

it('returns an empty string for an empty array', () => {
assert.strictEqual(HeapSnapshotFormatter.formatNodes([]), '');
});
Comment on lines +289 to +291

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
it('returns an empty string for an empty array', () => {
assert.strictEqual(HeapSnapshotFormatter.formatNodes([]), '');
});

});

describe('formatDiffSummary', () => {
Expand Down