From 569323874c872b6ebd0f137fd2a6373a52c0ad48 Mon Sep 17 00:00:00 2001 From: Thomas Watson Date: Fri, 4 Oct 2024 09:52:39 +0200 Subject: [PATCH] [DI] Add ability to take state snapshot feature (#4549) Take a "snapshot" of the variables that are in scope when a probe is hit (except the global scope, which intentionally have been omitted since it's too noisy): - For each variable in scope, we traverse objects and their properties up to `maxReferenceDepth` deep (default is 3 levels). - Strings are truncated to `maxLength` (default is 255 characters). - Binary data is converted to strings with appropriate escaping of non printable characters (the `maxLength` limit is also applied) Out of scope: - Information about `this` is not captured. - maxCollectionSize limit - maxFieldCount limit - Special handling for snapshots larger than 1MB (e.g. snapshot pruning or something simpler) - PII redaction --- integration-tests/debugger/index.spec.js | 169 ++++- .../debugger/target-app/index.js | 35 + .../src/debugger/devtools_client/index.js | 48 +- .../debugger/devtools_client/remote_config.js | 2 +- .../devtools_client/snapshot/collector.js | 153 +++++ .../devtools_client/snapshot/index.js | 30 + .../devtools_client/snapshot/processor.js | 241 +++++++ packages/dd-trace/test/.eslintrc.json | 6 +- .../devtools_client/_inspected_file.js | 158 +++++ .../debugger/devtools_client/snapshot.spec.js | 601 ++++++++++++++++++ 10 files changed, 1434 insertions(+), 9 deletions(-) create mode 100644 packages/dd-trace/src/debugger/devtools_client/snapshot/collector.js create mode 100644 packages/dd-trace/src/debugger/devtools_client/snapshot/index.js create mode 100644 packages/dd-trace/src/debugger/devtools_client/snapshot/processor.js create mode 100644 packages/dd-trace/test/debugger/devtools_client/_inspected_file.js create mode 100644 packages/dd-trace/test/debugger/devtools_client/snapshot.spec.js diff --git a/integration-tests/debugger/index.spec.js b/integration-tests/debugger/index.spec.js index 111d4a6c6cd..613c4eeb695 100644 --- a/integration-tests/debugger/index.spec.js +++ b/integration-tests/debugger/index.spec.js @@ -12,7 +12,7 @@ const { ACKNOWLEDGED, ERROR } = require('../../packages/dd-trace/src/appsec/remo const { version } = require('../../package.json') const probeFile = 'debugger/target-app/index.js' -const probeLineNo = 9 +const probeLineNo = 14 const pollInterval = 1 describe('Dynamic Instrumentation', function () { @@ -275,7 +275,7 @@ describe('Dynamic Instrumentation', function () { }) describe('input messages', function () { - it('should capture and send expected snapshot when a log line probe is triggered', function (done) { + it('should capture and send expected payload when a log line probe is triggered', function (done) { agent.on('debugger-diagnostics', ({ payload }) => { if (payload.debugger.diagnostics.status === 'INSTALLED') { axios.get('/foo') @@ -392,6 +392,171 @@ describe('Dynamic Instrumentation', function () { agent.addRemoteConfig(rcConfig) }) + + describe('with snapshot', () => { + beforeEach(() => { + // Trigger the breakpoint once probe is successfully installed + agent.on('debugger-diagnostics', ({ payload }) => { + if (payload.debugger.diagnostics.status === 'INSTALLED') { + axios.get('/foo') + } + }) + }) + + it('should capture a snapshot', (done) => { + agent.on('debugger-input', ({ payload: { 'debugger.snapshot': { captures } } }) => { + assert.deepEqual(Object.keys(captures), ['lines']) + assert.deepEqual(Object.keys(captures.lines), [String(probeLineNo)]) + + const { locals } = captures.lines[probeLineNo] + const { request, fastify, getSomeData } = locals + delete locals.request + delete locals.fastify + delete locals.getSomeData + + // from block scope + assert.deepEqual(locals, { + nil: { type: 'null', isNull: true }, + undef: { type: 'undefined' }, + bool: { type: 'boolean', value: 'true' }, + num: { type: 'number', value: '42' }, + bigint: { type: 'bigint', value: '42' }, + str: { type: 'string', value: 'foo' }, + lstr: { + type: 'string', + // eslint-disable-next-line max-len + value: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor i', + truncated: true, + size: 445 + }, + sym: { type: 'symbol', value: 'Symbol(foo)' }, + regex: { type: 'RegExp', value: '/bar/i' }, + arr: { + type: 'Array', + elements: [ + { type: 'number', value: '1' }, + { type: 'number', value: '2' }, + { type: 'number', value: '3' } + ] + }, + obj: { + type: 'Object', + fields: { + foo: { + type: 'Object', + fields: { + baz: { type: 'number', value: '42' }, + nil: { type: 'null', isNull: true }, + undef: { type: 'undefined' }, + deep: { + type: 'Object', + fields: { nested: { type: 'Object', notCapturedReason: 'depth' } } + } + } + }, + bar: { type: 'boolean', value: 'true' } + } + }, + emptyObj: { type: 'Object', fields: {} }, + fn: { + type: 'Function', + fields: { + length: { type: 'number', value: '0' }, + name: { type: 'string', value: 'fn' } + } + }, + p: { + type: 'Promise', + fields: { + '[[PromiseState]]': { type: 'string', value: 'fulfilled' }, + '[[PromiseResult]]': { type: 'undefined' } + } + } + }) + + // from local scope + // There's no reason to test the `request` object 100%, instead just check its fingerprint + assert.deepEqual(Object.keys(request), ['type', 'fields']) + assert.equal(request.type, 'Request') + assert.deepEqual(request.fields.id, { type: 'string', value: 'req-1' }) + assert.deepEqual(request.fields.params, { + type: 'NullObject', fields: { name: { type: 'string', value: 'foo' } } + }) + assert.deepEqual(request.fields.query, { type: 'Object', fields: {} }) + assert.deepEqual(request.fields.body, { type: 'undefined' }) + + // from closure scope + // There's no reason to test the `fastify` object 100%, instead just check its fingerprint + assert.deepEqual(Object.keys(fastify), ['type', 'fields']) + assert.equal(fastify.type, 'Object') + + assert.deepEqual(getSomeData, { + type: 'Function', + fields: { + length: { type: 'number', value: '0' }, + name: { type: 'string', value: 'getSomeData' } + } + }) + + done() + }) + + agent.addRemoteConfig(generateRemoteConfig({ captureSnapshot: true })) + }) + + it('should respect maxReferenceDepth', (done) => { + agent.on('debugger-input', ({ payload: { 'debugger.snapshot': { captures } } }) => { + const { locals } = captures.lines[probeLineNo] + delete locals.request + delete locals.fastify + delete locals.getSomeData + + assert.deepEqual(locals, { + nil: { type: 'null', isNull: true }, + undef: { type: 'undefined' }, + bool: { type: 'boolean', value: 'true' }, + num: { type: 'number', value: '42' }, + bigint: { type: 'bigint', value: '42' }, + str: { type: 'string', value: 'foo' }, + lstr: { + type: 'string', + // eslint-disable-next-line max-len + value: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor i', + truncated: true, + size: 445 + }, + sym: { type: 'symbol', value: 'Symbol(foo)' }, + regex: { type: 'RegExp', value: '/bar/i' }, + arr: { type: 'Array', notCapturedReason: 'depth' }, + obj: { type: 'Object', notCapturedReason: 'depth' }, + emptyObj: { type: 'Object', notCapturedReason: 'depth' }, + fn: { type: 'Function', notCapturedReason: 'depth' }, + p: { type: 'Promise', notCapturedReason: 'depth' } + }) + + done() + }) + + agent.addRemoteConfig(generateRemoteConfig({ captureSnapshot: true, capture: { maxReferenceDepth: 0 } })) + }) + + it('should respect maxLength', (done) => { + agent.on('debugger-input', ({ payload: { 'debugger.snapshot': { captures } } }) => { + const { locals } = captures.lines[probeLineNo] + + assert.deepEqual(locals.lstr, { + type: 'string', + value: 'Lorem ipsu', + truncated: true, + size: 445 + }) + + done() + }) + + agent.addRemoteConfig(generateRemoteConfig({ captureSnapshot: true, capture: { maxLength: 10 } })) + }) + }) }) describe('race conditions', () => { diff --git a/integration-tests/debugger/target-app/index.js b/integration-tests/debugger/target-app/index.js index d0e1b7fb6dd..dd7f5e6328a 100644 --- a/integration-tests/debugger/target-app/index.js +++ b/integration-tests/debugger/target-app/index.js @@ -5,10 +5,17 @@ const Fastify = require('fastify') const fastify = Fastify() +// Since line probes have hardcoded line numbers, we want to try and keep the line numbers from changing within the +// `handler` function below when making changes to this file. This is achieved by calling `getSomeData` and keeping all +// variable names on the same line as much as possible. fastify.get('/:name', function handler (request) { + // eslint-disable-next-line no-unused-vars + const { nil, undef, bool, num, bigint, str, lstr, sym, regex, arr, obj, emptyObj, fn, p } = getSomeData() return { hello: request.params.name } }) +// WARNING: Breakpoints present above this line - Any changes to the lines above might influence tests! + fastify.listen({ port: process.env.APP_PORT }, (err) => { if (err) { fastify.log.error(err) @@ -16,3 +23,31 @@ fastify.listen({ port: process.env.APP_PORT }, (err) => { } process.send({ port: process.env.APP_PORT }) }) + +function getSomeData () { + return { + nil: null, + undef: undefined, + bool: true, + num: 42, + bigint: 42n, + str: 'foo', + // eslint-disable-next-line max-len + lstr: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.', + sym: Symbol('foo'), + regex: /bar/i, + arr: [1, 2, 3], + obj: { + foo: { + baz: 42, + nil: null, + undef: undefined, + deep: { nested: { obj: { that: { goes: { on: { forever: true } } } } } } + }, + bar: true + }, + emptyObj: {}, + fn: () => {}, + p: Promise.resolve() + } +} diff --git a/packages/dd-trace/src/debugger/devtools_client/index.js b/packages/dd-trace/src/debugger/devtools_client/index.js index e1100f99ab7..aa19c14ef64 100644 --- a/packages/dd-trace/src/debugger/devtools_client/index.js +++ b/packages/dd-trace/src/debugger/devtools_client/index.js @@ -3,9 +3,10 @@ const { randomUUID } = require('crypto') const { breakpoints } = require('./state') const session = require('./session') +const { getLocalStateForCallFrame } = require('./snapshot') const send = require('./send') const { getScriptUrlFromId } = require('./state') -const { ackEmitting } = require('./status') +const { ackEmitting, ackError } = require('./status') const { parentThreadId } = require('./config') const log = require('../../log') const { version } = require('../../../../../package.json') @@ -20,9 +21,33 @@ const threadName = parentThreadId === 0 ? 'MainThread' : `WorkerThread:${parentT session.on('Debugger.paused', async ({ params }) => { const start = process.hrtime.bigint() const timestamp = Date.now() - const probes = params.hitBreakpoints.map((id) => breakpoints.get(id)) + + let captureSnapshotForProbe = null + let maxReferenceDepth, maxLength + const probes = params.hitBreakpoints.map((id) => { + const probe = breakpoints.get(id) + if (probe.captureSnapshot) { + captureSnapshotForProbe = probe + maxReferenceDepth = highestOrUndefined(probe.capture.maxReferenceDepth, maxReferenceDepth) + maxLength = highestOrUndefined(probe.capture.maxLength, maxLength) + } + return probe + }) + + let processLocalState + if (captureSnapshotForProbe !== null) { + try { + // TODO: Create unique states for each affected probe based on that probes unique `capture` settings (DEBUG-2863) + processLocalState = await getLocalStateForCallFrame(params.callFrames[0], { maxReferenceDepth, maxLength }) + } catch (err) { + // TODO: This error is not tied to a specific probe, but to all probes with `captureSnapshot: true`. + // However, in 99,99% of cases, there will be just a single probe, so I guess this simplification is ok? + ackError(err, captureSnapshotForProbe) // TODO: Ok to continue after sending ackError? + } + } + await session.post('Debugger.resume') - const diff = process.hrtime.bigint() - start // TODO: Should this be recored as telemetry? + const diff = process.hrtime.bigint() - start // TODO: Recored as telemetry (DEBUG-2858) log.debug(`Finished processing breakpoints - main thread paused for: ${Number(diff) / 1000000} ms`) @@ -47,7 +72,7 @@ session.on('Debugger.paused', async ({ params }) => { } }) - // TODO: Send multiple probes in one HTTP request as an array + // TODO: Send multiple probes in one HTTP request as an array (DEBUG-2848) for (const probe of probes) { const snapshot = { id: randomUUID(), @@ -61,10 +86,23 @@ session.on('Debugger.paused', async ({ params }) => { language: 'javascript' } - // TODO: Process template + if (probe.captureSnapshot) { + const state = processLocalState() + if (state) { + snapshot.captures = { + lines: { [probe.location.lines[0]]: { locals: state } } + } + } + } + + // TODO: Process template (DEBUG-2628) send(probe.template, logger, snapshot, (err) => { if (err) log.error(err) else ackEmitting(probe) }) } }) + +function highestOrUndefined (num, max) { + return num === undefined ? max : Math.max(num, max ?? 0) +} diff --git a/packages/dd-trace/src/debugger/devtools_client/remote_config.js b/packages/dd-trace/src/debugger/devtools_client/remote_config.js index 50d6976ef82..8a7d7386e33 100644 --- a/packages/dd-trace/src/debugger/devtools_client/remote_config.js +++ b/packages/dd-trace/src/debugger/devtools_client/remote_config.js @@ -92,7 +92,7 @@ async function processMsg (action, probe) { await addBreakpoint(probe) break case 'modify': - // TODO: Can we modify in place? + // TODO: Modify existing probe instead of removing it (DEBUG-2817) await removeBreakpoint(probe) await addBreakpoint(probe) break diff --git a/packages/dd-trace/src/debugger/devtools_client/snapshot/collector.js b/packages/dd-trace/src/debugger/devtools_client/snapshot/collector.js new file mode 100644 index 00000000000..0a8848ce5e5 --- /dev/null +++ b/packages/dd-trace/src/debugger/devtools_client/snapshot/collector.js @@ -0,0 +1,153 @@ +'use strict' + +const session = require('../session') + +const LEAF_SUBTYPES = new Set(['date', 'regexp']) +const ITERABLE_SUBTYPES = new Set(['map', 'set', 'weakmap', 'weakset']) + +module.exports = { + getRuntimeObject: getObject +} + +// TODO: Can we speed up thread pause time by calling mutiple Runtime.getProperties in parallel when possible? +// The most simple solution would be to swich from an async/await approach to a callback based approach, in which case +// each lookup will just finish in its own time and traverse the child nodes when the event loop allows it. +// Alternatively, use `Promise.all` or something like that, but the code would probably be more complex. + +async function getObject (objectId, maxDepth, depth = 0) { + const { result, privateProperties } = await session.post('Runtime.getProperties', { + objectId, + ownProperties: true // exclude inherited properties + }) + + if (privateProperties) result.push(...privateProperties) + + return traverseGetPropertiesResult(result, maxDepth, depth) +} + +async function traverseGetPropertiesResult (props, maxDepth, depth) { + // TODO: Decide if we should filter out non-enumerable properties or not: + // props = props.filter((e) => e.enumerable) + + if (depth >= maxDepth) return props + + for (const prop of props) { + if (prop.value === undefined) continue + const { value: { type, objectId, subtype } } = prop + if (type === 'object') { + if (objectId === undefined) continue // if `subtype` is "null" + if (LEAF_SUBTYPES.has(subtype)) continue // don't waste time with these subtypes + prop.value.properties = await getObjectProperties(subtype, objectId, maxDepth, depth) + } else if (type === 'function') { + prop.value.properties = await getFunctionProperties(objectId, maxDepth, depth + 1) + } + } + + return props +} + +async function getObjectProperties (subtype, objectId, maxDepth, depth) { + if (ITERABLE_SUBTYPES.has(subtype)) { + return getIterable(objectId, maxDepth, depth) + } else if (subtype === 'promise') { + return getInternalProperties(objectId, maxDepth, depth) + } else if (subtype === 'proxy') { + return getProxy(objectId, maxDepth, depth) + } else if (subtype === 'arraybuffer') { + return getArrayBuffer(objectId, maxDepth, depth) + } else { + return getObject(objectId, maxDepth, depth + 1) + } +} + +// TODO: The following extra information from `internalProperties` might be relevant to include for functions: +// - Bound function: `[[TargetFunction]]`, `[[BoundThis]]` and `[[BoundArgs]]` +// - Non-bound function: `[[FunctionLocation]]`, and `[[Scopes]]` +async function getFunctionProperties (objectId, maxDepth, depth) { + let { result } = await session.post('Runtime.getProperties', { + objectId, + ownProperties: true // exclude inherited properties + }) + + // For legacy reasons (I assume) functions has a `prototype` property besides the internal `[[Prototype]]` + result = result.filter(({ name }) => name !== 'prototype') + + return traverseGetPropertiesResult(result, maxDepth, depth) +} + +async function getIterable (objectId, maxDepth, depth) { + const { internalProperties } = await session.post('Runtime.getProperties', { + objectId, + ownProperties: true // exclude inherited properties + }) + + let entry = internalProperties[1] + if (entry.name !== '[[Entries]]') { + // Currently `[[Entries]]` is the last of 2 elements, but in case this ever changes, fall back to searching + entry = internalProperties.findLast(({ name }) => name === '[[Entries]]') + } + + // Skip the `[[Entries]]` level and go directly to the content of the iterable + const { result } = await session.post('Runtime.getProperties', { + objectId: entry.value.objectId, + ownProperties: true // exclude inherited properties + }) + + return traverseGetPropertiesResult(result, maxDepth, depth) +} + +async function getInternalProperties (objectId, maxDepth, depth) { + const { internalProperties } = await session.post('Runtime.getProperties', { + objectId, + ownProperties: true // exclude inherited properties + }) + + // We want all internal properties except the prototype + const props = internalProperties.filter(({ name }) => name !== '[[Prototype]]') + + return traverseGetPropertiesResult(props, maxDepth, depth) +} + +async function getProxy (objectId, maxDepth, depth) { + const { internalProperties } = await session.post('Runtime.getProperties', { + objectId, + ownProperties: true // exclude inherited properties + }) + + // TODO: If we do not skip the proxy wrapper, we can add a `revoked` boolean + let entry = internalProperties[1] + if (entry.name !== '[[Target]]') { + // Currently `[[Target]]` is the last of 2 elements, but in case this ever changes, fall back to searching + entry = internalProperties.findLast(({ name }) => name === '[[Target]]') + } + + // Skip the `[[Target]]` level and go directly to the target of the Proxy + const { result } = await session.post('Runtime.getProperties', { + objectId: entry.value.objectId, + ownProperties: true // exclude inherited properties + }) + + return traverseGetPropertiesResult(result, maxDepth, depth) +} + +// Support for ArrayBuffer is a bit trickly because the internal structure stored in `internalProperties` is not +// documented and is not straight forward. E.g. ArrayBuffer(3) will internally contain both Int8Array(3) and +// UInt8Array(3), whereas ArrayBuffer(8) internally contains both Int8Array(8), Uint8Array(8), Int16Array(4), and +// Int32Array(2) - all representing the same data in different ways. +async function getArrayBuffer (objectId, maxDepth, depth) { + const { internalProperties } = await session.post('Runtime.getProperties', { + objectId, + ownProperties: true // exclude inherited properties + }) + + // Use Uint8 to make it easy to convert to a string later. + const entry = internalProperties.find(({ name }) => name === '[[Uint8Array]]') + + // Skip the `[[Uint8Array]]` level and go directly to the content of the ArrayBuffer + const { result } = await session.post('Runtime.getProperties', { + objectId: entry.value.objectId, + ownProperties: true // exclude inherited properties + }) + + return traverseGetPropertiesResult(result, maxDepth, depth) +} diff --git a/packages/dd-trace/src/debugger/devtools_client/snapshot/index.js b/packages/dd-trace/src/debugger/devtools_client/snapshot/index.js new file mode 100644 index 00000000000..add097ac755 --- /dev/null +++ b/packages/dd-trace/src/debugger/devtools_client/snapshot/index.js @@ -0,0 +1,30 @@ +'use strict' + +const { getRuntimeObject } = require('./collector') +const { processRawState } = require('./processor') + +const DEFAULT_MAX_REFERENCE_DEPTH = 3 +const DEFAULT_MAX_LENGTH = 255 + +module.exports = { + getLocalStateForCallFrame +} + +async function getLocalStateForCallFrame ( + callFrame, + { maxReferenceDepth = DEFAULT_MAX_REFERENCE_DEPTH, maxLength = DEFAULT_MAX_LENGTH } = {} +) { + const rawState = [] + let processedState = null + + for (const scope of callFrame.scopeChain) { + if (scope.type === 'global') continue // The global scope is too noisy + rawState.push(...await getRuntimeObject(scope.object.objectId, maxReferenceDepth)) + } + + // Deplay calling `processRawState` so the caller gets a chance to resume the main thread before processing `rawState` + return () => { + processedState = processedState ?? processRawState(rawState, maxLength) + return processedState + } +} diff --git a/packages/dd-trace/src/debugger/devtools_client/snapshot/processor.js b/packages/dd-trace/src/debugger/devtools_client/snapshot/processor.js new file mode 100644 index 00000000000..2cac9ef0b1c --- /dev/null +++ b/packages/dd-trace/src/debugger/devtools_client/snapshot/processor.js @@ -0,0 +1,241 @@ +'use strict' + +module.exports = { + processRawState: processProperties +} + +// Matches classes in source code, no matter how it's written: +// - Named: class MyClass {} +// - Anonymous: class {} +// - Named, with odd whitespace: class\n\t MyClass\n{} +// - Anonymous, with odd whitespace: class\n{} +const CLASS_REGEX = /^class\s([^{]*)/ + +function processProperties (props, maxLength) { + const result = {} + + for (const prop of props) { + // TODO: Hack to avoid periods in keys, as EVP doesn't support that. A better solution can be implemented later + result[prop.name.replaceAll('.', '_')] = getPropertyValue(prop, maxLength) + } + + return result +} + +function getPropertyValue (prop, maxLength) { + // Special case for getters and setters which does not have a value property + if ('get' in prop) { + const hasGet = prop.get.type !== 'undefined' + const hasSet = prop.set.type !== 'undefined' + if (hasGet && hasSet) return { type: 'getter/setter' } + if (hasGet) return { type: 'getter' } + if (hasSet) return { type: 'setter' } + } + + switch (prop.value?.type) { + case 'object': + return getObjectValue(prop.value, maxLength) + case 'function': + return toFunctionOrClass(prop.value, maxLength) + case undefined: // TODO: Add test for when a prop has no value. I think it's if it's defined after the breakpoint? + case 'undefined': + return { type: 'undefined' } + case 'string': + return toString(prop.value.value, maxLength) + case 'number': + return { type: 'number', value: prop.value.description } // use `descripton` to get it as string + case 'boolean': + return { type: 'boolean', value: prop.value.value === true ? 'true' : 'false' } + case 'symbol': + return { type: 'symbol', value: prop.value.description } + case 'bigint': + return { type: 'bigint', value: prop.value.description.slice(0, -1) } // remove trailing `n` + default: + // As of this writing, the Chrome DevTools Protocol doesn't allow any other types than the ones listed above, but + // in the future new ones might be added. + return { type: prop.value.type, notCapturedReason: 'Unsupported property type' } + } +} + +function getObjectValue (obj, maxLength) { + switch (obj.subtype) { + case undefined: + return toObject(obj.className, obj.properties, maxLength) + case 'array': + return toArray(obj.className, obj.properties, maxLength) + case 'null': + return { type: 'null', isNull: true } + // case 'node': // TODO: What does this subtype represent? + case 'regexp': + return { type: obj.className, value: obj.description } + case 'date': + // TODO: This looses millisecond resolution, as that's not retained in the `.toString()` representation contained + // in the `description` field. Unfortunately that's all we get from the Chrome DevTools Protocol. + return { type: obj.className, value: `${new Date(obj.description).toISOString().slice(0, -5)}Z` } + case 'map': + return toMap(obj.className, obj.properties, maxLength) + case 'set': + return toSet(obj.className, obj.properties, maxLength) + case 'weakmap': + return toMap(obj.className, obj.properties, maxLength) + case 'weakset': + return toSet(obj.className, obj.properties, maxLength) + // case 'iterator': // TODO: I've not been able to trigger this subtype + case 'generator': + // Use `subtype` instead of `className` to make it obvious it's a generator + return toObject(obj.subtype, obj.properties, maxLength) + case 'error': + // TODO: Convert stack trace to array to avoid string trunctation or disable truncation in this case? + return toObject(obj.className, obj.properties, maxLength) + case 'proxy': + // Use `desciption` instead of `className` as the `type` to get type of target object (`Proxy(Error)` vs `proxy`) + return toObject(obj.description, obj.properties, maxLength) + case 'promise': + return toObject(obj.className, obj.properties, maxLength) + case 'typedarray': + return toArray(obj.className, obj.properties, maxLength) + case 'arraybuffer': + return toArrayBuffer(obj.className, obj.properties, maxLength) + // case 'dataview': // TODO: Looks like the internal ArrayBuffer is only accessible via the `buffer` getter + // case 'webassemblymemory': // TODO: Looks like the internal ArrayBuffer is only accessible via the `buffer` getter + // case 'wasmvalue': // TODO: I've not been able to trigger this subtype + default: + // As of this writing, the Chrome DevTools Protocol doesn't allow any other subtypes than the ones listed above, + // but in the future new ones might be added. + return { type: obj.subtype, notCapturedReason: 'Unsupported object type' } + } +} + +function toFunctionOrClass (value, maxLength) { + const classMatch = value.description.match(CLASS_REGEX) + + if (classMatch === null) { + // This is a function + // TODO: Would it make sense to detect if it's an arrow function or not? + return toObject(value.className, value.properties, maxLength) + } else { + // This is a class + const className = classMatch[1].trim() + return { type: className ? `class ${className}` : 'class' } + } +} + +function toString (str, maxLength) { + const size = str.length + + if (size <= maxLength) { + return { type: 'string', value: str } + } + + return { + type: 'string', + value: str.substr(0, maxLength), + truncated: true, + size + } +} + +function toObject (type, props, maxLength) { + if (props === undefined) return notCapturedDepth(type) + return { type, fields: processProperties(props, maxLength) } +} + +function toArray (type, elements, maxLength) { + if (elements === undefined) return notCapturedDepth(type) + + // Perf: Create array of expected size in advance (expect that it contains only one non-enumrable element) + const expectedLength = elements.length - 1 + const result = { type, elements: new Array(expectedLength) } + + let i = 0 + for (const elm of elements) { + if (elm.enumerable === false) continue // the value of the `length` property should not be part of the array + result.elements[i++] = getPropertyValue(elm, maxLength) + } + + // Safe-guard in case there were more than one non-enumerable element + if (i < expectedLength) result.elements.length = i + + return result +} + +function toMap (type, pairs, maxLength) { + if (pairs === undefined) return notCapturedDepth(type) + + // Perf: Create array of expected size in advance (expect that it contains only one non-enumrable element) + const expectedLength = pairs.length - 1 + const result = { type, entries: new Array(expectedLength) } + + let i = 0 + for (const pair of pairs) { + if (pair.enumerable === false) continue // the value of the `length` property should not be part of the map + // The following code is based on assumptions made when researching the output of the Chrome DevTools Protocol. + // There doesn't seem to be any documentation to back it up: + // + // `pair.value` is a special wrapper-object with subtype `internal#entry`. This can be skipped and we can go + // directly to its children, of which there will always be exactly two, the first containing the key, and the + // second containing the value of this entry of the Map. + const key = getPropertyValue(pair.value.properties[0], maxLength) + const val = getPropertyValue(pair.value.properties[1], maxLength) + result.entries[i++] = [key, val] + } + + // Safe-guard in case there were more than one non-enumerable element + if (i < expectedLength) result.entries.length = i + + return result +} + +function toSet (type, values, maxLength) { + if (values === undefined) return notCapturedDepth(type) + + // Perf: Create array of expected size in advance (expect that it contains only one non-enumrable element) + const expectedLength = values.length - 1 + const result = { type, elements: new Array(expectedLength) } + + let i = 0 + for (const value of values) { + if (value.enumerable === false) continue // the value of the `length` property should not be part of the set + // The following code is based on assumptions made when researching the output of the Chrome DevTools Protocol. + // There doesn't seem to be any documentation to back it up: + // + // `value.value` is a special wrapper-object with subtype `internal#entry`. This can be skipped and we can go + // directly to its children, of which there will always be exactly one, which contain the actual value in this entry + // of the Set. + result.elements[i++] = getPropertyValue(value.value.properties[0], maxLength) + } + + // Safe-guard in case there were more than one non-enumerable element + if (i < expectedLength) result.elements.length = i + + return result +} + +function toArrayBuffer (type, bytes, maxLength) { + if (bytes === undefined) return notCapturedDepth(type) + + const size = bytes.length + + if (size > maxLength) { + return { + type, + value: arrayBufferToString(bytes, maxLength), + truncated: true, + size: bytes.length + } + } else { + return { type, value: arrayBufferToString(bytes, size) } + } +} + +function arrayBufferToString (bytes, size) { + const buf = Buffer.allocUnsafe(size) + for (let i = 0; i < size; i++) { + buf[i] = bytes[i].value.value + } + return buf.toString() +} + +function notCapturedDepth (type) { + return { type, notCapturedReason: 'depth' } +} diff --git a/packages/dd-trace/test/.eslintrc.json b/packages/dd-trace/test/.eslintrc.json index ed8a9ff7a87..3a9e197c393 100644 --- a/packages/dd-trace/test/.eslintrc.json +++ b/packages/dd-trace/test/.eslintrc.json @@ -2,8 +2,12 @@ "extends": [ "../../../.eslintrc.json" ], + "parserOptions": { + "ecmaVersion": 2022 + }, "env": { - "mocha": true + "mocha": true, + "es2022": true }, "globals": { "expect": true, diff --git a/packages/dd-trace/test/debugger/devtools_client/_inspected_file.js b/packages/dd-trace/test/debugger/devtools_client/_inspected_file.js new file mode 100644 index 00000000000..c7c27cd207b --- /dev/null +++ b/packages/dd-trace/test/debugger/devtools_client/_inspected_file.js @@ -0,0 +1,158 @@ +'use strict' + +function getPrimitives (a1 = 1, a2 = 2) { + // eslint-disable-next-line no-unused-vars + const { undef, nil, bool, num, bigint, str, sym } = get().primitives + return 'my return value' +} + +function getComplextTypes (a1 = 1, a2 = 2) { + // eslint-disable-next-line no-unused-vars, max-len + const { oblit, obnew, arr, regex, date, map, set, wmap, wset, gen, err, fn, bfn, afn, cls, acls, prox, custProx, pPen, pRes, pRej, tarr, ab, sab, circular, hidden } = get().complexTypes + return 'my return value' +} + +function getNestedObj (a1 = 1, a2 = 2) { + // eslint-disable-next-line no-unused-vars + const { myNestedObj } = get().nested + return 'my return value' +} + +// WARNING: Breakpoints present above this line - Any changes to the lines above might influence tests! + +// References to objects used in WeakMap/WeakSet objects to ensure that they are not garbage collected during testing +const ref = { + wmo1: { a: 1 }, + wmo2: { b: 3 }, + wso1: { a: 1 }, + wso2: { a: 2 }, + wso3: { a: 3 } +} + +// warp it all in a single function to avoid spamming the closure scope with a lot of variables (makes testing simpler) +function get () { + const bigint = BigInt(Number.MAX_SAFE_INTEGER) * 2n + + let e, g + const oblit = { + a: 1, + 'b.b': 2, + [Symbol('c')]: 3, + // Has no side-effect + // TODO: At some point it would be great to detect this and get the value, + // though currently we can neither detect it, nor execute the getter. + get d () { + return 4 + }, + // Has side-effect: We should never try to fetch this! + get e () { + e = Math.random() + return e + }, + // Only setter + set f (v) {}, // eslint-disable-line accessor-pairs + // Both getter and setter + get g () { return g }, + set g (x) { g = x } + } + + function fnWithProperties (a, b) {} + fnWithProperties.foo = { bar: 42 } + + class MyClass { + #secret = 42 + constructor () { + this.foo = this.#secret + } + } + + function * makeIterator () { + yield 1 + yield 2 + } + const gen = makeIterator() + gen.foo = 42 + + class CustomError extends Error { + constructor (...args) { + super(...args) + this.foo = 42 + } + } + const err = new CustomError('boom!') + + const buf1 = Buffer.from('IBM') + const buf2 = Buffer.from('hello\x01\x02\x03world') + + const arrayBuffer = new ArrayBuffer(buf1.length) + const sharedArrayBuffer = new SharedArrayBuffer(buf2.length) + + const typedArray = new Int8Array(arrayBuffer) + for (let i = 0; i < buf1.length; i++) typedArray[i] = buf1[i] - 1 + + const sharedTypedArray = new Int8Array(sharedArrayBuffer) + for (let i = 0; i < buf2.length; i++) sharedTypedArray[i] = buf2[i] + + const result = { + primitives: { + undef: undefined, + nil: null, + bool: true, + num: 42, + bigint, + str: 'foo', + sym: Symbol('foo') + }, + complexTypes: { + oblit, + obnew: new MyClass(), + arr: [1, 2, 3], + regex: /foo/, + date: new Date('2024-09-20T07:22:59.998Z'), + map: new Map([[1, 2], [3, 4]]), + set: new Set([[1, 2], 3, 4]), + wmap: new WeakMap([[ref.wmo1, 2], [ref.wmo2, 4]]), + wset: new WeakSet([ref.wso1, ref.wso2, ref.wso3]), + gen, + err, + fn: fnWithProperties, + bfn: fnWithProperties.bind(new MyClass(), 1, 2), + afn: () => { return 42 }, + cls: MyClass, + acls: class + {}, // eslint-disable-line indent, brace-style + prox: new Proxy({ target: true }, { get () { return false } }), + custProx: new Proxy(new MyClass(), { get () { return false } }), + pPen: new Promise(() => {}), + pRes: Promise.resolve('resolved value'), + pRej: Promise.reject('rejected value'), // eslint-disable-line prefer-promise-reject-errors + tarr: typedArray, // TODO: Should we test other TypedArray's? + ab: arrayBuffer, + sab: sharedArrayBuffer + }, + nested: { + myNestedObj: { + deepObj: { foo: { foo: { foo: { foo: { foo: true } } } } }, + deepArr: [[[[[42]]]]] + } + } + } + + result.complexTypes.circular = result.complexTypes + + Object.defineProperty(result.complexTypes, 'hidden', { + value: 'secret', + enumerable: false + }) + + // ensure we don't get an unhandled promise rejection error + result.complexTypes.pRej.catch(() => {}) + + return result +} + +module.exports = { + getPrimitives, + getComplextTypes, + getNestedObj +} diff --git a/packages/dd-trace/test/debugger/devtools_client/snapshot.spec.js b/packages/dd-trace/test/debugger/devtools_client/snapshot.spec.js new file mode 100644 index 00000000000..ce099ee00e3 --- /dev/null +++ b/packages/dd-trace/test/debugger/devtools_client/snapshot.spec.js @@ -0,0 +1,601 @@ +'use strict' + +require('../../setup/mocha') + +const NODE_20_PLUS = require('semver').gte(process.version, '20.0.0') + +const inspector = require('../../../src/debugger/devtools_client/inspector_promises_polyfill') +const session = new inspector.Session() +session.connect() + +session['@noCallThru'] = true +proxyquire('../src/debugger/devtools_client/snapshot/collector', { + '../session': session +}) + +const { getPrimitives, getComplextTypes, getNestedObj } = require('./_inspected_file') +const { getLocalStateForCallFrame } = require('../../../src/debugger/devtools_client/snapshot') + +let scriptId + +describe('debugger -> devtools client -> snapshot.getLocalStateForCallFrame', function () { + beforeEach(async function () { + scriptId = new Promise((resolve) => { + session.on('Debugger.scriptParsed', ({ params }) => { + if (params.url.endsWith('/_inspected_file.js')) { + session.removeAllListeners('Debugger.scriptParsed') // TODO: Can we do this in prod code? + resolve(params.scriptId) + } + }) + }) + + await session.post('Debugger.enable') + }) + + afterEach(async function () { + await session.post('Debugger.disable') + }) + + it('should return expected object for primitives', async function () { + session.once('Debugger.paused', async ({ params }) => { + expect(params.hitBreakpoints.length).to.eq(1) + + const state = (await getLocalStateForCallFrame(params.callFrames[0]))() + + expect(Object.keys(state).length).to.equal(11) + + // from block scope + expect(state).to.have.deep.property('undef', { type: 'undefined' }) + expect(state).to.have.deep.property('nil', { type: 'null', isNull: true }) + expect(state).to.have.deep.property('bool', { type: 'boolean', value: 'true' }) + expect(state).to.have.deep.property('num', { type: 'number', value: '42' }) + expect(state).to.have.deep.property('bigint', { type: 'bigint', value: '18014398509481982' }) + expect(state).to.have.deep.property('str', { type: 'string', value: 'foo' }) + expect(state).to.have.deep.property('sym', { type: 'symbol', value: 'Symbol(foo)' }) + + // from local scope + expect(state).to.have.deep.property('a1', { type: 'number', value: '1' }) + expect(state).to.have.deep.property('a2', { type: 'number', value: '2' }) + + // from closure scope + expect(state).to.have.deep.property('ref', { + type: 'Object', + fields: { + wmo1: { type: 'Object', fields: { a: { type: 'number', value: '1' } } }, + wmo2: { type: 'Object', fields: { b: { type: 'number', value: '3' } } }, + wso1: { type: 'Object', fields: { a: { type: 'number', value: '1' } } }, + wso2: { type: 'Object', fields: { a: { type: 'number', value: '2' } } }, + wso3: { type: 'Object', fields: { a: { type: 'number', value: '3' } } } + } + }) + expect(state).to.have.deep.property('get', { + type: 'Function', + fields: { + length: { type: 'number', value: '0' }, + name: { type: 'string', value: 'get' } + } + }) + }) + + await setBreakpointOnLine(6) + getPrimitives() + }) + + describe('should return expected object for complex types', function () { + let state + + beforeEach(async function () { + let resolve + const localState = new Promise((_resolve) => { resolve = _resolve }) + + session.once('Debugger.paused', async ({ params }) => { + expect(params.hitBreakpoints.length).to.eq(1) + + resolve((await getLocalStateForCallFrame(params.callFrames[0]))()) + }) + + await setBreakpointOnLine(12) + getComplextTypes() + + state = await localState + }) + + it('should contain expected properties from local and closure scope', function () { + expect(Object.keys(state).length).to.equal(30) + + // from block scope + // ... tested individually in the remaining it-blocks inside this describe-block + + // from local scope + expect(state).to.have.deep.property('a1', { type: 'number', value: '1' }) + expect(state).to.have.deep.property('a2', { type: 'number', value: '2' }) + + // from closure scope + expect(state).to.have.deep.property('ref', { + type: 'Object', + fields: { + wmo1: { type: 'Object', fields: { a: { type: 'number', value: '1' } } }, + wmo2: { type: 'Object', fields: { b: { type: 'number', value: '3' } } }, + wso1: { type: 'Object', fields: { a: { type: 'number', value: '1' } } }, + wso2: { type: 'Object', fields: { a: { type: 'number', value: '2' } } }, + wso3: { type: 'Object', fields: { a: { type: 'number', value: '3' } } } + } + }) + expect(state).to.have.deep.property('get', { + type: 'Function', + fields: { + length: { type: 'number', value: '0' }, + name: { type: 'string', value: 'get' } + } + }) + }) + + it('object literal', function () { + expect(state).to.have.deep.property('oblit', { + type: 'Object', + fields: { + a: { type: 'number', value: '1' }, + b_b: { type: 'number', value: '2' }, + 'Symbol(c)': { type: 'number', value: '3' }, + d: { type: 'getter' }, + e: { type: 'getter' }, + f: { type: 'setter' }, + g: { type: 'getter/setter' } + } + }) + }) + + it('custom object from class', function () { + expect(state).to.have.deep.property('obnew', { + type: 'MyClass', + fields: { + foo: { type: 'number', value: '42' }, + '#secret': { type: 'number', value: '42' } + } + }) + }) + + it('Array', function () { + expect(state).to.have.deep.property('arr', { + type: 'Array', + elements: [ + { type: 'number', value: '1' }, + { type: 'number', value: '2' }, + { type: 'number', value: '3' } + ] + }) + }) + + it('RegExp', function () { + expect(state).to.have.deep.property('regex', { type: 'RegExp', value: '/foo/' }) + }) + + it('Date', function () { + expect(state).to.have.deep.property('date', { + type: 'Date', + value: '2024-09-20T07:22:59Z' // missing milliseconds due to API limitation (should have been `998`) + }) + }) + + it('Map', function () { + expect(state).to.have.deep.property('map', { + type: 'Map', + entries: [ + [{ type: 'number', value: '1' }, { type: 'number', value: '2' }], + [{ type: 'number', value: '3' }, { type: 'number', value: '4' }] + ] + }) + }) + + it('Set', function () { + expect(state).to.have.deep.property('set', { + type: 'Set', + elements: [ + { + type: 'Array', + elements: [ + { type: 'number', value: '1' }, + { type: 'number', value: '2' } + ] + }, + { type: 'number', value: '3' }, + { type: 'number', value: '4' } + ] + }) + }) + + it('WeakMap', function () { + expect(state).to.have.property('wmap') + expect(state.wmap).to.have.keys('type', 'entries') + expect(state.wmap.entries).to.be.an('array') + state.wmap.entries = state.wmap.entries.sort((a, b) => a[1].value - b[1].value) + expect(state).to.have.deep.property('wmap', { + type: 'WeakMap', + entries: [[ + { type: 'Object', fields: { a: { type: 'number', value: '1' } } }, + { type: 'number', value: '2' } + ], [ + { type: 'Object', fields: { b: { type: 'number', value: '3' } } }, + { type: 'number', value: '4' } + ]] + }) + }) + + it('WeakSet', function () { + expect(state).to.have.property('wset') + expect(state.wset).to.have.keys('type', 'elements') + expect(state.wset.elements).to.be.an('array') + state.wset.elements = state.wset.elements.sort((a, b) => a.fields.a.value - b.fields.a.value) + expect(state).to.have.deep.property('wset', { + type: 'WeakSet', + elements: [ + { type: 'Object', fields: { a: { type: 'number', value: '1' } } }, + { type: 'Object', fields: { a: { type: 'number', value: '2' } } }, + { type: 'Object', fields: { a: { type: 'number', value: '3' } } } + ] + }) + }) + + it('Generator', function () { + expect(state).to.have.deep.property('gen', { + type: 'generator', + fields: { foo: { type: 'number', value: '42' } } + }) + }) + + it('Error', function () { + expect(state).to.have.property('err') + expect(state.err).to.have.keys('type', 'fields') + expect(state.err).to.have.property('type', 'CustomError') + expect(state.err.fields).to.be.an('object') + expect(state.err.fields).to.have.keys('stack', 'message', 'foo') + expect(state.err.fields).to.deep.include({ + message: { type: 'string', value: 'boom!' }, + foo: { type: 'number', value: '42' } + }) + expect(state.err.fields.stack).to.have.keys('type', 'value', 'truncated', 'size') + expect(state.err.fields.stack.value).to.be.a('string') + expect(state.err.fields.stack.value).to.match(/^Error: boom!/) + expect(state.err.fields.stack.size).to.be.a('number') + expect(state.err.fields.stack.size).to.above(255) + expect(state.err.fields.stack).to.deep.include({ + type: 'string', + truncated: true + }) + }) + + it('Function', function () { + expect(state).to.have.deep.property('fn', { + type: 'Function', + fields: { + foo: { + type: 'Object', + fields: { bar: { type: 'number', value: '42' } } + }, + length: { type: 'number', value: '2' }, + name: { type: 'string', value: 'fnWithProperties' } + } + }) + }) + + it('Bound function', function () { + expect(state).to.have.deep.property('bfn', { + type: 'Function', + fields: { + length: { type: 'number', value: '0' }, + name: { type: 'string', value: 'bound fnWithProperties' } + } + }) + }) + + it('Arrow function', function () { + expect(state).to.have.deep.property('afn', { + type: 'Function', + fields: { + length: { type: 'number', value: '0' }, + name: { type: 'string', value: 'afn' } + } + }) + }) + + it('Class', function () { + expect(state).to.have.deep.property('cls', { type: 'class MyClass' }) + }) + + it('Anonymous class', function () { + expect(state).to.have.deep.property('acls', { type: 'class' }) + }) + + it('Proxy for object literal', function () { + expect(state).to.have.deep.property('prox', { + type: NODE_20_PLUS ? 'Proxy(Object)' : 'Proxy', + fields: { + target: { type: 'boolean', value: 'true' } + } + }) + }) + + it('Proxy for custom class', function () { + expect(state).to.have.deep.property('custProx', { + type: NODE_20_PLUS ? 'Proxy(MyClass)' : 'Proxy', + fields: { + foo: { type: 'number', value: '42' } + } + }) + }) + + it('Promise: Pending', function () { + expect(state).to.have.deep.property('pPen', { + type: 'Promise', + fields: { + '[[PromiseState]]': { type: 'string', value: 'pending' }, + '[[PromiseResult]]': { type: 'undefined' } + } + }) + }) + + it('Promise: Resolved', function () { + expect(state).to.have.deep.property('pRes', { + type: 'Promise', + fields: { + '[[PromiseState]]': { type: 'string', value: 'fulfilled' }, + '[[PromiseResult]]': { type: 'string', value: 'resolved value' } + } + }) + }) + + it('Promise: Rejected', function () { + expect(state).to.have.deep.property('pRej', { + type: 'Promise', + fields: { + '[[PromiseState]]': { type: 'string', value: 'rejected' }, + '[[PromiseResult]]': { type: 'string', value: 'rejected value' } + } + }) + }) + + it('TypedArray', function () { + expect(state).to.have.deep.property('tarr', { + type: 'Int8Array', + elements: [ + { type: 'number', value: '72' }, + { type: 'number', value: '65' }, + { type: 'number', value: '76' } + ] + }) + }) + + it('ArrayBuffer', function () { + expect(state).to.have.deep.property('ab', { + type: 'ArrayBuffer', + value: 'HAL' + }) + }) + + it('SharedArrayBuffer', function () { + expect(state).to.have.deep.property('sab', { + type: 'SharedArrayBuffer', + value: 'hello\x01\x02\x03world' + }) + }) + + it('circular reference in object', function () { + expect(state).to.have.property('circular') + expect(state.circular).to.have.property('type', 'Object') + expect(state.circular).to.have.property('fields') + // For the circular field, just check that at least one of the expected properties are present + expect(state.circular.fields).to.deep.include({ + regex: { type: 'RegExp', value: '/foo/' } + }) + }) + + it('non-enumerable property', function () { + expect(state).to.have.deep.property('hidden', { type: 'string', value: 'secret' }) + }) + }) + + it('should return expected object for nested objects with maxReferenceDepth: 1', async function () { + session.once('Debugger.paused', async ({ params }) => { + expect(params.hitBreakpoints.length).to.eq(1) + + const state = (await getLocalStateForCallFrame(params.callFrames[0], { maxReferenceDepth: 1 }))() + + expect(Object.keys(state).length).to.equal(5) + + // from block scope + expect(state).to.have.property('myNestedObj') + expect(state.myNestedObj).to.have.property('type', 'Object') + expect(state.myNestedObj).to.have.property('fields') + expect(Object.keys(state.myNestedObj).length).to.equal(2) + + expect(state.myNestedObj.fields).to.have.deep.property('deepObj', { + type: 'Object', notCapturedReason: 'depth' + }) + + expect(state.myNestedObj.fields).to.have.deep.property('deepArr', { + type: 'Array', notCapturedReason: 'depth' + }) + + // from local scope + expect(state).to.have.deep.property('a1', { type: 'number', value: '1' }) + expect(state).to.have.deep.property('a2', { type: 'number', value: '2' }) + + // from closure scope + expect(state).to.have.deep.property('ref', { + type: 'Object', + fields: { + wmo1: { type: 'Object', notCapturedReason: 'depth' }, + wmo2: { type: 'Object', notCapturedReason: 'depth' }, + wso1: { type: 'Object', notCapturedReason: 'depth' }, + wso2: { type: 'Object', notCapturedReason: 'depth' }, + wso3: { type: 'Object', notCapturedReason: 'depth' } + } + }) + expect(state).to.have.deep.property('get', { + type: 'Function', + fields: { + length: { type: 'number', value: '0' }, + name: { type: 'string', value: 'get' } + } + }) + }) + + await setBreakpointOnLine(18) + getNestedObj() + }) + + it('should return expected object for nested objects with maxReferenceDepth: 5', async function () { + session.once('Debugger.paused', async ({ params }) => { + expect(params.hitBreakpoints.length).to.eq(1) + + const state = (await getLocalStateForCallFrame(params.callFrames[0], { maxReferenceDepth: 5 }))() + + expect(Object.entries(state).length).to.equal(5) + + // from block scope + expect(state).to.have.property('myNestedObj') + expect(state.myNestedObj).to.have.property('type', 'Object') + expect(state.myNestedObj).to.have.property('fields') + expect(Object.entries(state.myNestedObj).length).to.equal(2) + + expect(state.myNestedObj.fields).to.have.deep.property('deepObj', { + type: 'Object', + fields: { + foo: { + type: 'Object', + fields: { + foo: { + type: 'Object', + fields: { + foo: { + type: 'Object', + fields: { + foo: { type: 'Object', notCapturedReason: 'depth' } + } + } + } + } + } + } + } + }) + + expect(state.myNestedObj.fields).to.have.deep.property('deepArr', { + type: 'Array', + elements: [{ + type: 'Array', + elements: [{ + type: 'Array', + elements: [{ + type: 'Array', + elements: [{ type: 'Array', notCapturedReason: 'depth' }] + }] + }] + }] + }) + + // from local scope + expect(state).to.have.deep.property('a1', { type: 'number', value: '1' }) + expect(state).to.have.deep.property('a2', { type: 'number', value: '2' }) + + // from closure scope + expect(state).to.have.deep.property('ref', { + type: 'Object', + fields: { + wmo1: { type: 'Object', fields: { a: { type: 'number', value: '1' } } }, + wmo2: { type: 'Object', fields: { b: { type: 'number', value: '3' } } }, + wso1: { type: 'Object', fields: { a: { type: 'number', value: '1' } } }, + wso2: { type: 'Object', fields: { a: { type: 'number', value: '2' } } }, + wso3: { type: 'Object', fields: { a: { type: 'number', value: '3' } } } + } + }) + expect(state).to.have.deep.property('get', { + type: 'Function', + fields: { + length: { type: 'number', value: '0' }, + name: { type: 'string', value: 'get' } + } + }) + }) + + await setBreakpointOnLine(18) + getNestedObj() + }) + + it('should return expected object for nested objects if maxReferenceDepth is missing', async function () { + session.once('Debugger.paused', async ({ params }) => { + expect(params.hitBreakpoints.length).to.eq(1) + + const state = (await getLocalStateForCallFrame(params.callFrames[0]))() + + expect(Object.entries(state).length).to.equal(5) + + // from block scope + expect(state).to.have.property('myNestedObj') + expect(state.myNestedObj).to.have.property('type', 'Object') + expect(state.myNestedObj).to.have.property('fields') + expect(Object.entries(state.myNestedObj).length).to.equal(2) + + expect(state.myNestedObj.fields).to.have.deep.property('deepObj', { + type: 'Object', + fields: { + foo: { + type: 'Object', + fields: { + foo: { + type: 'Object', + notCapturedReason: 'depth' + } + } + } + } + }) + + expect(state.myNestedObj.fields).to.have.deep.property('deepArr', { + type: 'Array', + elements: [{ + type: 'Array', + elements: [{ + type: 'Array', + notCapturedReason: 'depth' + }] + }] + }) + + // from local scope + expect(state).to.have.deep.property('a1', { type: 'number', value: '1' }) + expect(state).to.have.deep.property('a2', { type: 'number', value: '2' }) + + // from closure scope + expect(state).to.have.deep.property('ref', { + type: 'Object', + fields: { + wmo1: { type: 'Object', fields: { a: { type: 'number', value: '1' } } }, + wmo2: { type: 'Object', fields: { b: { type: 'number', value: '3' } } }, + wso1: { type: 'Object', fields: { a: { type: 'number', value: '1' } } }, + wso2: { type: 'Object', fields: { a: { type: 'number', value: '2' } } }, + wso3: { type: 'Object', fields: { a: { type: 'number', value: '3' } } } + } + }) + expect(state).to.have.deep.property('get', { + type: 'Function', + fields: { + length: { type: 'number', value: '0' }, + name: { type: 'string', value: 'get' } + } + }) + }) + + await setBreakpointOnLine(18) + getNestedObj() + }) +}) + +async function setBreakpointOnLine (line) { + await session.post('Debugger.setBreakpoint', { + location: { + scriptId: await scriptId, + lineNumber: line - 1 // Beware! lineNumber is zero-indexed + } + }) +}