-
Notifications
You must be signed in to change notification settings - Fork 3.5k
test: expand heap snapshot formatter and manager coverage #2522
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<number>(); | ||
| for (const aggregate of aggregates) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
| 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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 () => { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 () => { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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/, | ||
| ); | ||
| }); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -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([]), ''); | ||||||||
| }); | ||||||||
|
Comment on lines
+289
to
+291
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||
| }); | ||||||||
|
|
||||||||
| describe('formatDiffSummary', () => { | ||||||||
|
|
||||||||
There was a problem hiding this comment.
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.