diff --git a/tests/HeapSnapshotManager.test.ts b/tests/HeapSnapshotManager.test.ts index ee5cb259e..2bd4aca0e 100644 --- a/tests/HeapSnapshotManager.test.ts +++ b/tests/HeapSnapshotManager.test.ts @@ -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(() => { @@ -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(); + for (const aggregate of aggregates) { + 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); + + 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); + 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); + + 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 () => { + 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 () => { + 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/, + ); + }); + }); }); diff --git a/tests/formatters/HeapSnapshotFormatter.test.ts b/tests/formatters/HeapSnapshotFormatter.test.ts index 57d364356..334e3fda2 100644 --- a/tests/formatters/HeapSnapshotFormatter.test.ts +++ b/tests/formatters/HeapSnapshotFormatter.test.ts @@ -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'; @@ -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 = [ @@ -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([]), ''); + }); }); describe('formatDiffSummary', () => {