diff --git a/packages/debugger/LICENSE b/packages/debugger/LICENSE new file mode 100644 index 0000000000..e6d7fbc979 --- /dev/null +++ b/packages/debugger/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2019-Present Datadog, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/debugger/README.md b/packages/debugger/README.md new file mode 100644 index 0000000000..6ae00a8092 --- /dev/null +++ b/packages/debugger/README.md @@ -0,0 +1,34 @@ +# Browser Live Debugger + +Datadog Live Debugger enables you to capture function execution snapshots, evaluate conditions, and collect runtime data from your application without modifying source code. + +See the [dedicated Datadog documentation][1] for more details. + +## Usage + +To start collecting data, add [`@datadog/browser-debugger`][2] to your `package.json` file, then initialize it with: + +```js +import { datadogDebugger } from '@datadog/browser-debugger' + +datadogDebugger.init({ + clientToken: '', + site: '', + service: 'my-web-application', + // env: 'production', + // version: '1.0.0', +}) +``` + +If [Datadog RUM][3] is also initialized on the page, debugger snapshots automatically include RUM context (session, view, user action) without any additional configuration. + +## Troubleshooting + +Need help? Contact [Datadog Support][4]. + + + +[1]: https://docs.datadoghq.com/tracing/live_debugger/ +[2]: https://www.npmjs.com/package/@datadog/browser-debugger +[3]: https://docs.datadoghq.com/real_user_monitoring/browser +[4]: https://docs.datadoghq.com/help/ diff --git a/packages/debugger/package.json b/packages/debugger/package.json new file mode 100644 index 0000000000..c65d01b6ab --- /dev/null +++ b/packages/debugger/package.json @@ -0,0 +1,38 @@ +{ + "name": "@datadog/browser-debugger", + "version": "6.32.0", + "license": "Apache-2.0", + "main": "cjs/entries/main.js", + "module": "esm/entries/main.js", + "types": "cjs/entries/main.d.ts", + "files": [ + "bundle/**/*.js", + "cjs", + "esm", + "src", + "!src/**/*.spec.ts", + "!src/**/*.specHelper.ts" + ], + "scripts": { + "build": "node ../../scripts/build/build-package.ts --modules --bundle datadog-debugger.js", + "build:bundle": "node ../../scripts/build/build-package.ts --bundle datadog-debugger.js", + "prepack": "yarn build" + }, + "devDependencies": { + "acorn": "8.16.0" + }, + "dependencies": { + "@datadog/browser-core": "6.32.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/DataDog/browser-sdk.git", + "directory": "packages/debugger" + }, + "volta": { + "extends": "../../package.json" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/debugger/src/domain/activeEntries.ts b/packages/debugger/src/domain/activeEntries.ts new file mode 100644 index 0000000000..569fdc2728 --- /dev/null +++ b/packages/debugger/src/domain/activeEntries.ts @@ -0,0 +1,31 @@ +import type { StackFrame } from './stacktrace' + +export interface ActiveEntry { + start: number + timestamp?: number + message?: string + entry?: { + arguments: Record + } + stack?: StackFrame[] + duration?: number + return?: { + arguments?: Record + locals?: Record + throwable?: { + message: string + stacktrace: StackFrame[] + } + } + exception?: Error +} + +export const active = new Map>() + +export function clearActiveEntries(probeId?: string): void { + if (probeId !== undefined) { + active.delete(probeId) + } else { + active.clear() + } +} diff --git a/packages/debugger/src/domain/api.spec.ts b/packages/debugger/src/domain/api.spec.ts new file mode 100644 index 0000000000..13feea94d7 --- /dev/null +++ b/packages/debugger/src/domain/api.spec.ts @@ -0,0 +1,684 @@ +import { registerCleanupTask } from '@datadog/browser-core/test' +import { onEntry, onReturn, onThrow, initDebuggerTransport, resetDebuggerTransport } from './api' +import { addProbe, removeProbe, getProbes, clearProbes } from './probes' +import type { Probe } from './probes' + +describe('api', () => { + let mockBatchAdd: jasmine.Spy + let mockRumGetInternalContext: jasmine.Spy + + beforeEach(() => { + clearProbes() + + mockBatchAdd = jasmine.createSpy('batchAdd') + initDebuggerTransport({ service: 'test-service', env: 'test-env' } as any, { add: mockBatchAdd } as any) + + // Mock DD_RUM global for context + mockRumGetInternalContext = jasmine.createSpy('getInternalContext').and.returnValue({ + session_id: 'test-session', + view: { id: 'test-view' }, + user_action: { id: 'test-action' }, + application_id: 'test-app-id', + }) + ;(window as any).DD_RUM = { + version: '1.0.0', + getInternalContext: mockRumGetInternalContext, + } + ;(window as any).DD_DEBUGGER = { + version: '0.0.1', + } + + registerCleanupTask(() => { + delete (window as any).DD_RUM + delete (window as any).DD_DEBUGGER + resetDebuggerTransport() + clearProbes() + }) + }) + + describe('onEntry and onReturn', () => { + it('should capture this inside arguments.fields', () => { + const probe: Probe = { + id: 'test-probe', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'testMethod' }, + template: 'Test message', + captureSnapshot: true, + capture: { maxReferenceDepth: 1 }, + sampling: { snapshotsPerSecond: 5000 }, + evaluateAt: 'ENTRY', + } + addProbe(probe) + + const self = { name: 'testObj' } + const args = { a: 1, b: 2 } + const probes = getProbes('TestClass;testMethod')! + onEntry(probes, self, args) + onReturn(probes, 'result', self, args, {}) + + const payload = mockBatchAdd.calls.mostRecent().args[0] + const snapshot = payload.debugger.snapshot + + // Verify entry.arguments structure - now flat + expect(snapshot.captures.entry.arguments).toEqual({ + a: { type: 'number', value: '1' }, + b: { type: 'number', value: '2' }, + this: { + type: 'Object', + fields: { + name: { type: 'string', value: 'testObj' }, + }, + }, + }) + + // Verify return.arguments structure - now flat + expect(snapshot.captures.return.arguments).toEqual({ + a: { type: 'number', value: '1' }, + b: { type: 'number', value: '2' }, + this: { + type: 'Object', + fields: { + name: { type: 'string', value: 'testObj' }, + }, + }, + }) + + // Verify return.locals structure - also flat + expect(snapshot.captures.return.locals['@return']).toEqual({ + type: 'string', + value: 'result', + }) + }) + + it('should capture entry and return for simple probe', () => { + const probe: Probe = { + id: 'test-probe', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'testMethod' }, + template: 'Test message', + captureSnapshot: true, + capture: { maxReferenceDepth: 1 }, + sampling: { snapshotsPerSecond: 5000 }, + evaluateAt: 'ENTRY', + } + addProbe(probe) + + const self = { name: 'test' } + const args = { arg1: 'value1', arg2: 42 } + + const probes = getProbes('TestClass;testMethod')! + onEntry(probes, self, args) + const result = onReturn(probes, 'returnValue', self, args, {}) + + expect(result).toBe('returnValue') + expect(mockBatchAdd).toHaveBeenCalledTimes(1) + + const payload = mockBatchAdd.calls.mostRecent().args[0] + expect(payload.message).toBe('Test message') + expect(payload.debugger.snapshot).toEqual( + jasmine.objectContaining({ id: jasmine.any(String), captures: jasmine.any(Object) }) + ) + }) + + it('should skip probe if sampling budget exceeded', () => { + // Use a very low sampling rate to ensure budget is exceeded + const probe: Probe = { + id: 'test-probe', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'budgetTest' }, + template: 'Test', + captureSnapshot: true, + capture: { maxReferenceDepth: 1 }, + sampling: { snapshotsPerSecond: 0.5 }, // 0.5 per second = 2000ms between samples + evaluateAt: 'ENTRY', + } + addProbe(probe) + + const probes = getProbes('TestClass;budgetTest')! + // First call should work + onEntry(probes, {}, {}) + onReturn(probes, null, {}, {}, {}) + expect(mockBatchAdd).toHaveBeenCalledTimes(1) + + // Second immediate call should be skipped (less than 2000ms passed) + onEntry(probes, {}, {}) + onReturn(probes, null, {}, {}, {}) + + // Still only one call because sampling budget not refreshed + expect(mockBatchAdd).toHaveBeenCalledTimes(1) + }) + + it('should evaluate condition at ENTRY', () => { + const probe: Probe = { + id: 'test-probe', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'conditionEntry' }, + when: { + dsl: 'x > 5', + json: { gt: [{ ref: 'x' }, 5] }, + }, + template: 'Condition passed', + captureSnapshot: false, + capture: {}, + sampling: {}, + evaluateAt: 'ENTRY', + } + addProbe(probe) + + let probes = getProbes('TestClass;conditionEntry')! + // Should fire when condition passes + onEntry(probes, {}, { x: 10 }) + onReturn(probes, null, {}, { x: 10 }, {}) + expect(mockBatchAdd).toHaveBeenCalledTimes(1) + + clearProbes() + addProbe(probe) + mockBatchAdd.calls.reset() + + probes = getProbes('TestClass;conditionEntry')! + // Should not fire when condition fails + onEntry(probes, {}, { x: 3 }) + onReturn(probes, null, {}, { x: 3 }, {}) + expect(mockBatchAdd).not.toHaveBeenCalled() + }) + + it('should evaluate condition at EXIT with @return', () => { + const probe: Probe = { + id: 'test-probe', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'conditionExit' }, + when: { + dsl: '@return > 10', + json: { gt: [{ ref: '@return' }, 10] }, + }, + template: 'Return value check', + captureSnapshot: false, + capture: {}, + sampling: {}, + evaluateAt: 'EXIT', + } + addProbe(probe) + + let probes = getProbes('TestClass;conditionExit')! + // Should fire when return value > 10 + onEntry(probes, {}, {}) + onReturn(probes, 15, {}, {}, {}) + expect(mockBatchAdd).toHaveBeenCalledTimes(1) + + clearProbes() + addProbe(probe) + mockBatchAdd.calls.reset() + + probes = getProbes('TestClass;conditionExit')! + // Should not fire when return value <= 10 + onEntry(probes, {}, {}) + onReturn(probes, 5, {}, {}, {}) + expect(mockBatchAdd).not.toHaveBeenCalled() + }) + + // TODO: Validate that this test is actually correct + it('should capture entry snapshot only for ENTRY evaluation with no condition', () => { + const probe: Probe = { + id: 'test-probe', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'entrySnapshot' }, + template: 'Test', + captureSnapshot: true, + capture: { maxReferenceDepth: 1 }, + sampling: {}, + evaluateAt: 'ENTRY', + } + addProbe(probe) + + const probes = getProbes('TestClass;entrySnapshot')! + onEntry(probes, { name: 'obj' }, { arg: 'value' }) + onReturn(probes, 'result', { name: 'obj' }, { arg: 'value' }, { local: 'data' }) + + const payload = mockBatchAdd.calls.mostRecent().args[0] + const snapshot = payload.debugger.snapshot + expect(snapshot.captures).toEqual({ + entry: { + arguments: { + arg: { type: 'string', value: 'value' }, + this: { type: 'Object', fields: { name: { type: 'string', value: 'obj' } } }, + }, + }, + return: { + arguments: { + arg: { type: 'string', value: 'value' }, + this: { type: 'Object', fields: { name: { type: 'string', value: 'obj' } } }, + }, + locals: { + local: { type: 'string', value: 'data' }, + '@return': { type: 'string', value: 'result' }, + }, + }, + }) + }) + + it('should only capture return snapshot for EXIT evaluation with condition', () => { + const probe: Probe = { + id: 'test-probe', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'exitSnapshot' }, + when: { + dsl: '@return === true', + json: { eq: [{ ref: '@return' }, true] }, + }, + template: 'Test', + captureSnapshot: true, + capture: { maxReferenceDepth: 1 }, + sampling: {}, + evaluateAt: 'EXIT', + } + addProbe(probe) + + const probes = getProbes('TestClass;exitSnapshot')! + onEntry(probes, {}, { arg: 'value' }) + onReturn(probes, true, {}, { arg: 'value' }, {}) + + const payload = mockBatchAdd.calls.mostRecent().args[0] + const snapshot = payload.debugger.snapshot + expect(snapshot.captures.entry).toBeUndefined() + expect(snapshot.captures.return).toBeDefined() + }) + + it('should include duration in snapshot', () => { + const probe: Probe = { + id: 'test-probe', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'durationTest' }, + template: 'Test', + captureSnapshot: true, + capture: { maxReferenceDepth: 1 }, + sampling: {}, + evaluateAt: 'ENTRY', + } + addProbe(probe) + + const probes = getProbes('TestClass;durationTest')! + onEntry(probes, {}, {}) + + // Simulate some time passing + const startTime = performance.now() + while (performance.now() - startTime < 10) { + // Wait + } + + onReturn(probes, null, {}, {}, {}) + + const payload = mockBatchAdd.calls.mostRecent().args[0] + const snapshot = payload.debugger.snapshot + expect(snapshot.duration).toBeGreaterThan(0) + expect(snapshot.duration).toBeGreaterThanOrEqual(10000000) // Should be in nanoseconds (>= 10ms) + }) + + it('should omit trace correlation when no active span context is available', () => { + const probe: Probe = { + id: 'test-probe', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'rumContext' }, + template: 'Test', + segments: [{ str: 'Test' }], + captureSnapshot: false, + capture: {}, + sampling: {}, + evaluateAt: 'ENTRY', + } + addProbe(probe) + + const probes = getProbes('TestClass;rumContext')! + onEntry(probes, {}, {}) + onReturn(probes, null, {}, {}, {}) + + const payload = mockBatchAdd.calls.mostRecent().args[0] + expect(payload.dd).toBeUndefined() + }) + + it('should include trace correlation when active span context is available', () => { + mockRumGetInternalContext.and.returnValue({ + session_id: 'test-session', + view: { id: 'test-view' }, + user_action: { id: 'test-action' }, + application_id: 'test-app-id', + trace_id: 'test-trace', + span_id: 'test-span', + }) + + const probe: Probe = { + id: 'test-probe', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'traceContext' }, + template: 'Test', + segments: [{ str: 'Test' }], + captureSnapshot: false, + capture: {}, + sampling: {}, + evaluateAt: 'ENTRY', + } + addProbe(probe) + + const probes = getProbes('TestClass;traceContext')! + onEntry(probes, {}, {}) + onReturn(probes, null, {}, {}, {}) + + const payload = mockBatchAdd.calls.mostRecent().args[0] + expect(payload.dd).toEqual({ + trace_id: 'test-trace', + span_id: 'test-span', + }) + }) + }) + + describe('onThrow', () => { + it('should capture this inside arguments.fields for exceptions', () => { + const probe: Probe = { + id: 'test-probe', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'throwTest' }, + template: 'Test', + captureSnapshot: true, + capture: { maxReferenceDepth: 1 }, + sampling: {}, + evaluateAt: 'ENTRY', + } + addProbe(probe) + + const self = { name: 'testObj' } + const args = { a: 1, b: 2 } + const error = new Error('Test error') + const probes = getProbes('TestClass;throwTest')! + onEntry(probes, self, args) + onThrow(probes, error, self, args) + + const payload = mockBatchAdd.calls.mostRecent().args[0] + const snapshot = payload.debugger.snapshot + + // Verify return.arguments structure - now flat + expect(snapshot.captures.return.arguments).toEqual({ + a: { type: 'number', value: '1' }, + b: { type: 'number', value: '2' }, + this: { + type: 'Object', + fields: { + name: { type: 'string', value: 'testObj' }, + }, + }, + }) + + // Verify throwable is still present + expect(snapshot.captures.return.throwable).toEqual({ + message: 'Test error', + stacktrace: jasmine.any(Array), + }) + for (const frame of snapshot.captures.return.throwable.stacktrace) { + expect(frame).toEqual( + jasmine.objectContaining({ + fileName: jasmine.any(String), + function: jasmine.any(String), + lineNumber: jasmine.any(Number), + columnNumber: jasmine.any(Number), + }) + ) + } + }) + + it('should capture exception details', () => { + const probe: Probe = { + id: 'test-probe', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'throwTest' }, + template: 'Test', + captureSnapshot: true, + capture: { maxReferenceDepth: 1 }, + sampling: {}, + evaluateAt: 'ENTRY', + } + addProbe(probe) + + const probes = getProbes('TestClass;throwTest')! + const error = new Error('Test error') + onEntry(probes, {}, { arg: 'value' }) + onThrow(probes, error, {}, { arg: 'value' }) + + expect(mockBatchAdd).toHaveBeenCalledTimes(1) + + const payload = mockBatchAdd.calls.mostRecent().args[0] + const snapshot = payload.debugger.snapshot + expect(snapshot.captures.return.throwable).toEqual({ + message: 'Test error', + stacktrace: jasmine.any(Array), + }) + for (const frame of snapshot.captures.return.throwable.stacktrace) { + expect(frame).toEqual( + jasmine.objectContaining({ + fileName: jasmine.any(String), + function: jasmine.any(String), + lineNumber: jasmine.any(Number), + columnNumber: jasmine.any(Number), + }) + ) + } + }) + + it('should evaluate EXIT condition with @exception', () => { + const probe: Probe = { + id: 'test-probe', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'exceptionCondition' }, + when: { + dsl: '@exception.message', + json: { getmember: [{ ref: '@exception' }, 'message'] }, + }, + template: 'Exception captured', + captureSnapshot: false, + capture: { maxReferenceDepth: 1 }, + sampling: {}, + evaluateAt: 'EXIT', + } + addProbe(probe) + + const probes = getProbes('TestClass;exceptionCondition')! + const error = new Error('Test error') + onEntry(probes, {}, {}) + onThrow(probes, error, {}, {}) + + expect(mockBatchAdd).toHaveBeenCalledTimes(1) + }) + + it('should handle onThrow without preceding onEntry', () => { + const probe: Probe = { + id: 'test-probe', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'throwWithoutEntry' }, + template: 'Test', + captureSnapshot: true, + capture: { maxReferenceDepth: 1 }, + sampling: {}, + evaluateAt: 'ENTRY', + } + addProbe(probe) + + const probes = getProbes('TestClass;throwWithoutEntry')! + const error = new Error('Test error') + onThrow(probes, error, {}, {}) + + expect(mockBatchAdd).not.toHaveBeenCalled() + }) + }) + + describe('global snapshot budget', () => { + it('should respect global snapshot rate limit', () => { + const probes: Probe[] = [] + for (let i = 0; i < 30; i++) { + const probe: Probe = { + id: `probe-${i}`, + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: `method${i}` }, + template: 'Test', + captureSnapshot: true, + capture: {}, + sampling: { snapshotsPerSecond: 5000 }, + evaluateAt: 'ENTRY', + } + addProbe(probe) + probes.push(probe) + } + + // Try to fire 30 probes rapidly + for (let i = 0; i < 30; i++) { + const probes = getProbes(`TestClass;method${i}`)! + onEntry(probes, {}, {}) + onReturn(probes, null, {}, {}, {}) + } + + // Should only get 25 calls (global limit) + expect(mockBatchAdd).toHaveBeenCalledTimes(25) + }) + }) + + describe('active entries cleanup', () => { + function createProbe(id: string, methodName: string): Probe { + return { + id, + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName }, + template: 'Test', + captureSnapshot: false, + capture: {}, + sampling: { snapshotsPerSecond: 5000 }, + evaluateAt: 'ENTRY', + } + } + + it('should discard in-flight entries when a probe is removed', () => { + const probe = createProbe('cleanup-probe', 'cleanupTest') + addProbe(probe) + + const probes = getProbes('TestClass;cleanupTest')! + onEntry(probes, {}, {}) + + removeProbe('cleanup-probe') + addProbe(probe) + + const newProbes = getProbes('TestClass;cleanupTest')! + onReturn(newProbes, null, {}, {}, {}) + + expect(mockBatchAdd).not.toHaveBeenCalled() + }) + + it('should discard in-flight entries when all probes are cleared', () => { + const probe = createProbe('cleanup-probe', 'clearAllTest') + addProbe(probe) + + const probes = getProbes('TestClass;clearAllTest')! + onEntry(probes, {}, {}) + + clearProbes() + addProbe(probe) + + const newProbes = getProbes('TestClass;clearAllTest')! + onReturn(newProbes, null, {}, {}, {}) + + expect(mockBatchAdd).not.toHaveBeenCalled() + }) + + it('should not leak active entries after onReturn completes', () => { + const probe = createProbe('leak-probe', 'leakTest') + addProbe(probe) + + const probes = getProbes('TestClass;leakTest')! + onEntry(probes, {}, {}) + onReturn(probes, null, {}, {}, {}) + expect(mockBatchAdd).toHaveBeenCalledTimes(1) + + mockBatchAdd.calls.reset() + + // A second onReturn without onEntry should not produce a snapshot + onReturn(probes, null, {}, {}, {}) + expect(mockBatchAdd).not.toHaveBeenCalled() + }) + + it('should not leak active entries after onThrow completes', () => { + const probe = createProbe('throw-leak-probe', 'throwLeakTest') + addProbe(probe) + + const probes = getProbes('TestClass;throwLeakTest')! + onEntry(probes, {}, {}) + onThrow(probes, new Error('test'), {}, {}) + expect(mockBatchAdd).toHaveBeenCalledTimes(1) + + mockBatchAdd.calls.reset() + + // A second onThrow without onEntry should not produce a snapshot + onThrow(probes, new Error('test'), {}, {}) + expect(mockBatchAdd).not.toHaveBeenCalled() + }) + }) + + describe('error handling', () => { + it('should handle missing DD_RUM gracefully', () => { + delete (window as any).DD_RUM + + const probe: Probe = { + id: 'test-probe', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'errorHandling' }, + template: 'Test', + captureSnapshot: false, + capture: {}, + sampling: {}, + evaluateAt: 'ENTRY', + } + addProbe(probe) + + const probes = getProbes('TestClass;errorHandling')! + expect(() => { + onEntry(probes, {}, {}) + onReturn(probes, null, {}, {}, {}) + }).not.toThrow() + + expect(mockBatchAdd).toHaveBeenCalledTimes(1) + }) + + it('should handle uninitialized debugger transport gracefully', () => { + resetDebuggerTransport() + + const probe: Probe = { + id: 'test-probe', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'errorHandling' }, + template: 'Test', + captureSnapshot: false, + capture: {}, + sampling: {}, + evaluateAt: 'ENTRY', + } + addProbe(probe) + + const probes = getProbes('TestClass;errorHandling')! + expect(() => { + onEntry(probes, {}, {}) + onReturn(probes, null, {}, {}, {}) + }).not.toThrow() + }) + }) +}) diff --git a/packages/debugger/src/domain/api.ts b/packages/debugger/src/domain/api.ts new file mode 100644 index 0000000000..b823ef1ccb --- /dev/null +++ b/packages/debugger/src/domain/api.ts @@ -0,0 +1,349 @@ +import type { Batch, Context, RumInternalContext } from '@datadog/browser-core' + +import { timeStampNow, display, buildTag, generateUUID, getGlobalObject } from '@datadog/browser-core' +import type { BrowserWindow, DebuggerInitConfiguration } from '../entries/main' +import { capture, captureFields } from './capture' +import type { InitializedProbe } from './probes' +import { checkGlobalSnapshotBudget } from './probes' +import type { ActiveEntry } from './activeEntries' +import { active } from './activeEntries' +import { captureStackTrace, parseStackTrace } from './stacktrace' +import { evaluateProbeMessage } from './template' +import { evaluateProbeCondition } from './condition' + +interface Rum { + getInternalContext?: () => RumInternalContext | undefined +} + +interface TraceCorrelationContext extends Context { + trace_id: string + span_id: string +} + +// Cache hostname at module initialization since it won't change during the app lifetime +const globalObj = getGlobalObject() // eslint-disable-line local-rules/disallow-side-effects +const hostname = 'location' in globalObj ? globalObj.location.hostname : 'unknown' + +const threadName = detectThreadName() // eslint-disable-line local-rules/disallow-side-effects + +let debuggerBatch: Batch | undefined +let debuggerConfig: DebuggerInitConfiguration | undefined + +export function initDebuggerTransport(config: DebuggerInitConfiguration, batch: Batch): void { + debuggerConfig = config + debuggerBatch = batch +} + +export function resetDebuggerTransport(): void { + debuggerBatch = undefined + debuggerConfig = undefined + active.clear() +} + +/** + * Called when entering an instrumented function + * + * @param probes - Array of probes for this function + * @param self - The 'this' context + * @param args - Function arguments + */ +export function onEntry(probes: InitializedProbe[], self: any, args: Record): void { + const start = performance.now() + + // TODO: A lot of repeated work performed for each probe that could be shared between probes + for (const probe of probes) { + let stack = active.get(probe.id) // TODO: Should we use the functionId instead? + if (!stack) { + stack = [] + active.set(probe.id, stack) + } + + // Skip if sampling budget is exceeded + if ( + start - probe.lastCaptureMs < probe.msBetweenSampling || + !checkGlobalSnapshotBudget(start, probe.captureSnapshot) + ) { + stack.push(null) + continue + } + + // Update last capture time + probe.lastCaptureMs = start + + let timestamp: number | undefined + let message: string | undefined + if (probe.evaluateAt === 'ENTRY') { + // Build context for condition and message evaluation + const context = { ...args, this: self } + + // Check condition - if it fails, don't evaluate or capture anything + if (!evaluateProbeCondition(probe, context)) { + // Still push to stack so onReturn/onThrow can pop it, but mark as skipped + stack.push(null) + continue + } + + timestamp = timeStampNow() + message = evaluateProbeMessage(probe, context) + } + + // Special case for evaluateAt=EXIT with a condition: we only capture the return snapshot + const shouldCaptureEntrySnapshot = probe.captureSnapshot && (probe.evaluateAt === 'ENTRY' || !probe.condition) + const entry = shouldCaptureEntrySnapshot + ? { + arguments: { + ...captureFields(args, probe.capture), + this: capture(self, probe.capture), + }, + } + : undefined + + stack.push({ + start, + timestamp, + message, + entry, + stack: probe.captureSnapshot ? captureStackTrace(1) : undefined, + }) + } +} + +/** + * Called when exiting an instrumented function normally + * + * @param probes - Array of probes for this function + * @param value - Return value + * @param self - The 'this' context + * @param args - Function arguments + * @param locals - Local variables + * @returns The return value (passed through) + */ +export function onReturn( + probes: InitializedProbe[], + value: any, + self: any, + args: Record, + locals: Record +): any { + const end = performance.now() + + // TODO: A lot of repeated work performed for each probe that could be shared between probes + for (const probe of probes) { + const stack = active.get(probe.id) // TODO: Should we use the functionId instead? + if (!stack) { + continue // TODO: This shouldn't be possible, do we need it? Should we warn? + } + const result = stack.pop() + if (stack.length === 0) { + active.delete(probe.id) + } + if (!result) { + continue + } + + result.duration = end - result.start + + if (probe.evaluateAt === 'EXIT') { + result.timestamp = timeStampNow() + + const context = { + ...args, + ...locals, + this: self, + $dd_duration: result.duration, + $dd_return: value, + } + + if (!evaluateProbeCondition(probe, context)) { + continue + } + + result.message = evaluateProbeMessage(probe, context) + } + + result.return = probe.captureSnapshot + ? { + arguments: { + ...captureFields(args, probe.capture), + this: capture(self, probe.capture), + }, + locals: { + ...captureFields(locals, probe.capture), + '@return': capture(value, probe.capture), + }, + } + : undefined + + sendDebuggerSnapshot(probe, result) + } + + return value +} + +/** + * Called when exiting an instrumented function via exception + * + * @param probes - Array of probes for this function + * @param error - The thrown error + * @param self - The 'this' context + * @param args - Function arguments + */ +export function onThrow(probes: InitializedProbe[], error: Error, self: any, args: Record): void { + const end = performance.now() + + // TODO: A lot of repeated work performed for each probe that could be shared between probes + for (const probe of probes) { + const stack = active.get(probe.id) // TODO: Should we use the functionId instead? + if (!stack) { + continue // TODO: This shouldn't be possible, do we need it? Should we warn? + } + const result = stack.pop() + if (stack.length === 0) { + active.delete(probe.id) + } + if (!result) { + continue + } + + result.duration = end - result.start + result.exception = error + + if (probe.evaluateAt === 'EXIT') { + result.timestamp = timeStampNow() + + const context = { + ...args, + this: self, + $dd_duration: result.duration, + $dd_exception: error, + } + + if (!evaluateProbeCondition(probe, context)) { + continue + } + + result.message = evaluateProbeMessage(probe, context) + } + + result.return = { + arguments: probe.captureSnapshot + ? { + ...captureFields(args, probe.capture), + this: capture(self, probe.capture), + } + : undefined, + throwable: { + message: error.message, + stacktrace: parseStackTrace(error), + }, + } + + sendDebuggerSnapshot(probe, result) + } +} + +/** + * Send a debugger snapshot to Datadog via the debugger's own transport. + * + * @param probe - The probe that was executed + * @param result - The result of the probe execution + */ +function sendDebuggerSnapshot(probe: InitializedProbe, result: ActiveEntry): void { + if (!debuggerBatch || !debuggerConfig) { + display.warn('Debugger transport is not initialized. Make sure DD_DEBUGGER.init() has been called.') + return + } + + const snapshot = { + id: generateUUID(), + timestamp: result.timestamp!, + probe: { + id: probe.id, + version: probe.version, + location: { + // TODO: Are our hardcoded where.* keys correct according to the spec? + method: probe.where.methodName, + type: probe.where.typeName, + }, + }, + stack: result.stack, + language: 'javascript', + duration: result.duration! * 1e6, // to nanoseconds + captures: + result.entry || result.return + ? { + entry: result.entry, + return: result.return, + } + : undefined, + } + + const debuggerApi = globalObj.DD_DEBUGGER! + + // TODO: Fill out logger with the right information + const logger = { + name: probe.where.typeName, + method: probe.where.methodName, + version: debuggerApi.version, + // thread_id: 1, + thread_name: threadName, + } + + // Get the RUM internal context for trace correlation + const dd = getTraceCorrelationContext() + + const ddtags = [ + buildTag('sdk_version', debuggerApi.version), + buildTag('env', debuggerConfig.env), + buildTag('service', debuggerConfig.service), + buildTag('version', debuggerConfig.version), + buildTag('debugger_version', debuggerApi.version), + buildTag('host_name', hostname), + ] + + const payload: Context = { + message: result.message || '', + hostname, + service: debuggerConfig.service, + ddtags: ddtags.join(','), + logger, + ...(dd ? { dd } : {}), + debugger: { snapshot }, + } + + debuggerBatch.add(payload) +} + +function detectThreadName() { + if (typeof window !== 'undefined' && typeof document !== 'undefined') { + return 'main' + } + if (typeof ServiceWorkerGlobalScope !== 'undefined' && self instanceof ServiceWorkerGlobalScope) { + return 'service-worker' + } + if (typeof importScripts === 'function') { + return 'web-worker' + } + return 'unknown' +} + +function getTraceCorrelationContext(): TraceCorrelationContext | undefined { + const rumContext = globalObj.DD_RUM?.getInternalContext?.() + const traceId = getStringContextValue(rumContext, 'trace_id') + const spanId = getStringContextValue(rumContext, 'span_id') + + return traceId && spanId + ? { + trace_id: traceId, + span_id: spanId, + } + : undefined +} + +function getStringContextValue(context: Context | undefined, key: string): string | undefined { + const value = context?.[key] + return typeof value === 'string' && value !== '' ? value : undefined +} + +declare const ServiceWorkerGlobalScope: typeof EventTarget +declare function importScripts(...urls: string[]): void diff --git a/packages/debugger/src/domain/capture.spec.ts b/packages/debugger/src/domain/capture.spec.ts new file mode 100644 index 0000000000..dfb4a5f795 --- /dev/null +++ b/packages/debugger/src/domain/capture.spec.ts @@ -0,0 +1,556 @@ +import { capture, captureFields } from './capture' + +describe('capture', () => { + const defaultOpts = { + maxReferenceDepth: 3, + maxCollectionSize: 100, + maxFieldCount: 20, + maxLength: 255, + } + + describe('primitive types', () => { + it('should capture null', () => { + const result = capture(null, defaultOpts) + expect(result).toEqual({ type: 'null', isNull: true }) + }) + + it('should capture undefined', () => { + const result = capture(undefined, defaultOpts) + expect(result).toEqual({ type: 'undefined' }) + }) + + it('should capture boolean', () => { + expect(capture(true, defaultOpts)).toEqual({ type: 'boolean', value: 'true' }) + expect(capture(false, defaultOpts)).toEqual({ type: 'boolean', value: 'false' }) + }) + + it('should capture number', () => { + expect(capture(42, defaultOpts)).toEqual({ type: 'number', value: '42' }) + expect(capture(3.14, defaultOpts)).toEqual({ type: 'number', value: '3.14' }) + expect(capture(NaN, defaultOpts)).toEqual({ type: 'number', value: 'NaN' }) + expect(capture(Infinity, defaultOpts)).toEqual({ type: 'number', value: 'Infinity' }) + }) + + it('should capture string', () => { + const result = capture('hello', defaultOpts) + expect(result).toEqual({ type: 'string', value: 'hello' }) + }) + + it('should capture bigint', () => { + if (typeof BigInt === 'undefined') { + pending('BigInt is not supported in this browser') + return + } + const result = capture(BigInt(123), defaultOpts) + expect(result).toEqual({ type: 'bigint', value: '123' }) + }) + + it('should capture symbol', () => { + if (!('description' in Symbol.prototype)) { + pending('Symbol.description is not supported in this browser') + return + } + const sym = Symbol('test') + const result = capture(sym, defaultOpts) + expect(result).toEqual({ type: 'symbol', value: 'test' }) + }) + + it('should capture symbol without description', () => { + const sym = Symbol() + const result = capture(sym, defaultOpts) + expect(result).toEqual({ type: 'symbol', value: '' }) + }) + }) + + describe('string truncation', () => { + it('should truncate long strings', () => { + const longString = 'a'.repeat(300) + const result = capture(longString, { ...defaultOpts, maxLength: 10 }) + + expect(result).toEqual({ + type: 'string', + value: 'aaaaaaaaaa', + truncated: true, + size: 300, + }) + }) + + it('should not truncate strings under maxLength', () => { + const result = capture('short', { ...defaultOpts, maxLength: 10 }) + expect(result).toEqual({ type: 'string', value: 'short' }) + }) + }) + + describe('built-in objects', () => { + it('should capture Date', () => { + const date = new Date('2024-01-01T00:00:00.000Z') + const result = capture(date, defaultOpts) + expect(result).toEqual({ type: 'Date', value: '2024-01-01T00:00:00.000Z' }) + }) + + it('should capture invalid Date without throwing', () => { + const date = new Date('invalid') + const result = capture(date, defaultOpts) + expect(result).toEqual({ type: 'Date', value: 'Invalid Date' }) + }) + + it('should capture RegExp', () => { + const regex = /test/gi + const result = capture(regex, defaultOpts) + expect(result).toEqual({ type: 'RegExp', value: '/test/gi' }) + }) + + it('should capture Error', () => { + const error = new Error('test error') + const result = capture(error, defaultOpts) as any + + expect(result).toEqual({ + type: 'Error', + fields: { + message: { type: 'string', value: 'test error' }, + name: { type: 'string', value: 'Error' }, + stack: { type: 'string', value: jasmine.any(String), truncated: true, size: error.stack!.length }, + }, + }) + }) + + it('should capture custom Error types', () => { + class CustomError extends Error { + constructor(message: string) { + super(message) + this.name = 'CustomError' + } + } + const error = new CustomError('custom error') + const result = capture(error, defaultOpts) as any + + expect(result.type).toBe('CustomError') + expect(result.fields.name).toEqual({ type: 'string', value: 'CustomError' }) + }) + + it('should capture Error with cause', () => { + const cause = new Error('cause error') + // @ts-expect-error - cause is not a valid argument for Error constructor + const error = new Error('main error', { cause }) + if ((error as any).cause === undefined) { + pending('Error cause is not supported in this browser') + return + } + const result = capture(error, defaultOpts) as any + + expect(result.fields.cause).toEqual({ + type: 'Error', + fields: { + message: { type: 'string', value: 'cause error' }, + name: { type: 'string', value: 'Error' }, + stack: { type: 'string', value: jasmine.any(String), truncated: true, size: cause.stack!.length }, + }, + }) + }) + + it('should capture Promise', () => { + const promise = Promise.resolve(42) + const result = capture(promise, defaultOpts) + expect(result).toEqual({ type: 'Promise', notCapturedReason: 'Promise state cannot be inspected' }) + }) + }) + + describe('arrays', () => { + it('should capture array', () => { + const arr = [1, 'two', true] + const result = capture(arr, defaultOpts) as any + + expect(result.type).toBe('Array') + expect(result.elements).toEqual([ + { type: 'number', value: '1' }, + { type: 'string', value: 'two' }, + { type: 'boolean', value: 'true' }, + ]) + }) + + it('should truncate large arrays', () => { + const arr = Array(200).fill(1) + const result = capture(arr, { ...defaultOpts, maxCollectionSize: 3 }) as any + + expect(result.type).toBe('Array') + expect(result.elements.length).toBe(3) + expect(result.notCapturedReason).toBe('collectionSize') + expect(result.size).toBe(200) + }) + + it('should handle nested arrays', () => { + const arr = [ + [1, 2], + [3, 4], + ] + const result = capture(arr, defaultOpts) as any + + expect(result.type).toBe('Array') + expect(result.elements[0].type).toBe('Array') + expect(result.elements[0].elements).toEqual([ + { type: 'number', value: '1' }, + { type: 'number', value: '2' }, + ]) + }) + }) + + describe('Map and Set', () => { + it('should capture Map', () => { + const map = new Map([ + ['key1', 'value1'], + ['key2', 42], + ]) + const result = capture(map, defaultOpts) as any + + expect(result.type).toBe('Map') + expect(result.entries).toEqual([ + [ + { type: 'string', value: 'key1' }, + { type: 'string', value: 'value1' }, + ], + [ + { type: 'string', value: 'key2' }, + { type: 'number', value: '42' }, + ], + ]) + }) + + it('should truncate large Maps', () => { + const map = new Map() + for (let i = 0; i < 200; i++) { + map.set(`key${i}`, i) + } + const result = capture(map, { ...defaultOpts, maxCollectionSize: 3 }) as any + + expect(result.entries.length).toBe(3) + expect(result.notCapturedReason).toBe('collectionSize') + expect(result.size).toBe(200) + }) + + it('should capture Set', () => { + const set = new Set([1, 'two', true]) + const result = capture(set, defaultOpts) as any + + expect(result.type).toBe('Set') + expect(result.elements).toEqual([ + { type: 'number', value: '1' }, + { type: 'string', value: 'two' }, + { type: 'boolean', value: 'true' }, + ]) + }) + + it('should truncate large Sets', () => { + const set = new Set() + for (let i = 0; i < 200; i++) { + set.add(i) + } + const result = capture(set, { ...defaultOpts, maxCollectionSize: 3 }) as any + + expect(result.elements.length).toBe(3) + expect(result.notCapturedReason).toBe('collectionSize') + expect(result.size).toBe(200) + }) + + it('should handle WeakMap', () => { + const weakMap = new WeakMap() + const result = capture(weakMap, defaultOpts) + expect(result).toEqual({ type: 'WeakMap', notCapturedReason: 'WeakMap contents cannot be enumerated' }) + }) + + it('should handle WeakSet', () => { + const weakSet = new WeakSet() + const result = capture(weakSet, defaultOpts) + expect(result).toEqual({ type: 'WeakSet', notCapturedReason: 'WeakSet contents cannot be enumerated' }) + }) + }) + + describe('objects', () => { + it('should capture plain object', () => { + const obj = { a: 1, b: 'two' } + const result = capture(obj, defaultOpts) as any + + expect(result.type).toBe('Object') + expect(result.fields.a).toEqual({ type: 'number', value: '1' }) + expect(result.fields.b).toEqual({ type: 'string', value: 'two' }) + }) + + it('should capture nested objects', () => { + const obj = { outer: { inner: 'value' } } + const result = capture(obj, defaultOpts) as any + + expect(result.fields.outer.type).toBe('Object') + expect(result.fields.outer.fields.inner).toEqual({ type: 'string', value: 'value' }) + }) + + it('should respect maxReferenceDepth', () => { + const obj = { level1: { level2: { level3: { level4: 'deep' } } } } + const result = capture(obj, { ...defaultOpts, maxReferenceDepth: 2 }) as any + + expect(result.fields.level1.fields.level2.notCapturedReason).toBe('depth') + }) + + it('should truncate objects with many fields', () => { + const obj: any = {} + for (let i = 0; i < 30; i++) { + obj[`field${i}`] = i + } + const result = capture(obj, { ...defaultOpts, maxFieldCount: 5 }) as any + + expect(Object.keys(result.fields).length).toBe(5) + expect(result.notCapturedReason).toBe('fieldCount') + expect(result.size).toBe(30) + }) + + it('should handle objects with symbol keys', () => { + if (!('description' in Symbol.prototype)) { + pending('Symbol.description is not supported in this browser') + return + } + const sym = Symbol('test') + const obj = { [sym]: 'value' } + const result = capture(obj, defaultOpts) as any + + expect(result.fields.test).toEqual({ type: 'string', value: 'value' }) + }) + + it('should escape dots in field names', () => { + const obj = { 'field.with.dots': 'value' } + const result = capture(obj, defaultOpts) as any + + expect(result.fields.field_with_dots).toEqual({ type: 'string', value: 'value' }) + }) + + it('should handle getters that throw', () => { + const obj = { + get throwing() { + throw new Error('getter error') + }, + } + const result = capture(obj, defaultOpts) as any + + expect(result.fields.throwing).toEqual({ + type: 'undefined', + notCapturedReason: 'Error accessing property', + }) + }) + + it('should capture custom class instances', () => { + class MyClass { + public field = 'value' + } + const instance = new MyClass() + const result = capture(instance, defaultOpts) as any + + expect(result.type).toBe('MyClass') + expect(result.fields.field).toEqual({ type: 'string', value: 'value' }) + }) + }) + + describe('functions', () => { + it('should capture function', () => { + function myFunc() {} // eslint-disable-line @typescript-eslint/no-empty-function + const result = capture(myFunc, defaultOpts) as any + + expect(result.type).toBe('Function') + }) + + it('should capture class as class', () => { + class MyClass {} + const result = capture(MyClass, defaultOpts) + + expect(result.type).toBe('class MyClass') + }) + + it('should capture anonymous class', () => { + const AnonymousClass = class {} + const result = capture(AnonymousClass, defaultOpts) + + expect(result.type).toBe('class') + }) + + it('should respect depth for functions', () => { + function myFunc() {} // eslint-disable-line @typescript-eslint/no-empty-function + const result = capture(myFunc, { ...defaultOpts, maxReferenceDepth: 0 }) + + expect(result).toEqual({ type: 'Function', notCapturedReason: 'depth' }) + }) + }) + + describe('binary data', () => { + it('should capture ArrayBuffer', () => { + const buffer = new ArrayBuffer(16) + const result = capture(buffer, defaultOpts) + + expect(result).toEqual({ + type: 'ArrayBuffer', + value: '[ArrayBuffer(16)]', + }) + }) + + it('should capture SharedArrayBuffer', () => { + if (typeof SharedArrayBuffer === 'undefined') { + // Skip test if SharedArrayBuffer is not available + return + } + const buffer = new SharedArrayBuffer(16) + const result = capture(buffer, defaultOpts) + + expect(result).toEqual({ + type: 'SharedArrayBuffer', + value: '[SharedArrayBuffer(16)]', + }) + }) + + it('should capture DataView', () => { + const buffer = new ArrayBuffer(16) + const view = new DataView(buffer, 4, 8) + const result = capture(view, defaultOpts) as any + + expect(result.type).toBe('DataView') + expect(result.fields.byteLength).toEqual({ type: 'number', value: '8' }) + expect(result.fields.byteOffset).toEqual({ type: 'number', value: '4' }) + expect(result.fields.buffer).toEqual({ type: 'ArrayBuffer', value: '[ArrayBuffer(16)]' }) + }) + + it('should capture Uint8Array', () => { + const arr = new Uint8Array([1, 2, 3]) + const result = capture(arr, defaultOpts) as any + + expect(result.type).toBe('Uint8Array') + expect(result.elements).toEqual([ + { type: 'number', value: '1' }, + { type: 'number', value: '2' }, + { type: 'number', value: '3' }, + ]) + expect(result.fields.byteLength).toEqual({ type: 'number', value: '3' }) + expect(result.fields.length).toEqual({ type: 'number', value: '3' }) + }) + + it('should truncate large TypedArrays', () => { + const arr = new Uint8Array(200) + const result = capture(arr, { ...defaultOpts, maxCollectionSize: 3 }) as any + + expect(result.elements.length).toBe(3) + expect(result.notCapturedReason).toBe('collectionSize') + expect(result.size).toBe(200) + }) + }) + + describe('circular references', () => { + it('should handle circular references by respecting depth limit', () => { + const obj: any = { name: 'root' } + obj.self = obj + const result = capture(obj, { ...defaultOpts, maxReferenceDepth: 1 }) as any + + expect(result.fields.name).toEqual({ type: 'string', value: 'root' }) + expect(result.fields.self.notCapturedReason).toBe('depth') + }) + }) +}) + +describe('captureFields', () => { + const defaultOpts = { + maxReferenceDepth: 3, + maxCollectionSize: 100, + maxFieldCount: 20, + maxLength: 255, + } + + it('should return fields directly without wrapper', () => { + const obj = { a: 1, b: 'hello', c: true } + const result = captureFields(obj, defaultOpts) + + // Should be Record, not CapturedValue + expect(result).toEqual({ + a: { type: 'number', value: '1' }, + b: { type: 'string', value: 'hello' }, + c: { type: 'boolean', value: 'true' }, + }) + + // Should NOT have type/fields wrapper + expect((result as any).type).toBeUndefined() + expect((result as any).fields).toBeUndefined() + }) + + it('should capture nested objects in fields', () => { + const obj = { + name: 'test', + nested: { value: 42 }, + } + const result = captureFields(obj, defaultOpts) + + expect(result).toEqual({ + name: { type: 'string', value: 'test' }, + nested: { + type: 'Object', + fields: { + value: { type: 'number', value: '42' }, + }, + }, + }) + }) + + it('should respect maxFieldCount', () => { + const obj = { a: 1, b: 2, c: 3, d: 4, e: 5 } + const result = captureFields(obj, { ...defaultOpts, maxFieldCount: 3 }) + + const keys = Object.keys(result) + expect(keys.length).toBe(3) + }) + + it('should respect maxReferenceDepth', () => { + const obj = { + level1: { + level2: { + level3: 'deep', + }, + }, + } + const result = captureFields(obj, { ...defaultOpts, maxReferenceDepth: 2 }) + + expect(result.level1).toEqual({ + type: 'Object', + fields: { + level2: { + type: 'Object', + notCapturedReason: 'depth', + }, + }, + }) + }) + + it('should handle properties with dots in names', () => { + const obj = { 'some.property': 'value' } + const result = captureFields(obj, defaultOpts) + + expect(result['some_property']).toEqual({ type: 'string', value: 'value' }) + }) + + it('should handle symbol keys', () => { + if (!('description' in Symbol.prototype)) { + pending('Symbol.description is not supported in this browser') + return + } + const sym = Symbol('test') + const obj = { [sym]: 'symbolValue' } + const result = captureFields(obj, defaultOpts) + + expect(result.test).toEqual({ type: 'string', value: 'symbolValue' }) + }) + + it('should handle property access errors', () => { + const obj = {} + Object.defineProperty(obj, 'throwing', { + get() { + throw new Error('Access denied') + }, + enumerable: true, + }) + const result = captureFields(obj, defaultOpts) + + expect(result.throwing).toEqual({ + type: 'undefined', + notCapturedReason: 'Error accessing property', + }) + }) +}) diff --git a/packages/debugger/src/domain/capture.ts b/packages/debugger/src/domain/capture.ts new file mode 100644 index 0000000000..24234571fa --- /dev/null +++ b/packages/debugger/src/domain/capture.ts @@ -0,0 +1,497 @@ +export interface CaptureOptions { + maxReferenceDepth?: number + maxCollectionSize?: number + maxFieldCount?: number + maxLength?: number +} + +export interface CapturedValue { + type: string + value?: string + isNull?: boolean + truncated?: boolean + size?: number + notCapturedReason?: string + fields?: Record + elements?: CapturedValue[] + entries?: Array<[CapturedValue, CapturedValue]> +} + +const hasReplaceAll = typeof (String.prototype as any).replaceAll === 'function' +const replaceDots = hasReplaceAll + ? (str: string) => (str as string & { replaceAll: (s: string, r: string) => string }).replaceAll('.', '_') + : (str: string) => str.replace(/\./g, '_') + +const DEFAULT_MAX_REFERENCE_DEPTH = 3 +const DEFAULT_MAX_COLLECTION_SIZE = 100 +const DEFAULT_MAX_FIELD_COUNT = 20 +const DEFAULT_MAX_LENGTH = 255 + +/** + * Capture the value of the given object with configurable limits + * + * @param value - The value to capture + * @param opts - The capture options + * @param opts.maxReferenceDepth - The maximum depth of references to capture + * @param opts.maxCollectionSize - The maximum size of collections to capture + * @param opts.maxFieldCount - The maximum number of fields to capture + * @param opts.maxLength - The maximum length of strings to capture + * @returns The captured value representation + */ +export function capture( + value: unknown, + { + maxReferenceDepth = DEFAULT_MAX_REFERENCE_DEPTH, + maxCollectionSize = DEFAULT_MAX_COLLECTION_SIZE, + maxFieldCount = DEFAULT_MAX_FIELD_COUNT, + maxLength = DEFAULT_MAX_LENGTH, + }: CaptureOptions +): CapturedValue { + return captureValue(value, 0, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength) +} + +/** + * Capture the fields of an object directly without the outer CapturedValue wrapper + * + * @param obj - The object to capture + * @param opts - The capture options + * @param opts.maxReferenceDepth - The maximum depth of references to capture + * @param opts.maxCollectionSize - The maximum size of collections to capture + * @param opts.maxFieldCount - The maximum number of fields to capture + * @param opts.maxLength - The maximum length of strings to capture + * @returns A record mapping property names to their captured values + */ +export function captureFields( + obj: object, + { + maxReferenceDepth = DEFAULT_MAX_REFERENCE_DEPTH, + maxCollectionSize = DEFAULT_MAX_COLLECTION_SIZE, + maxFieldCount = DEFAULT_MAX_FIELD_COUNT, + maxLength = DEFAULT_MAX_LENGTH, + }: CaptureOptions +): Record { + return captureObjectPropertiesFields(obj, 0, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength) +} + +function captureValue( + value: unknown, + depth: number, + maxReferenceDepth: number, + maxCollectionSize: number, + maxFieldCount: number, + maxLength: number +): CapturedValue { + // Handle null first as typeof null === 'object' + if (value === null) { + return { type: 'null', isNull: true } + } + + const type = typeof value + + switch (type) { + case 'undefined': + return { type: 'undefined' } + case 'boolean': + return { type: 'boolean', value: String(value) } // eslint-disable-line @typescript-eslint/no-base-to-string + case 'number': + return { type: 'number', value: String(value) } // eslint-disable-line @typescript-eslint/no-base-to-string + case 'string': + return captureString(value as string, maxLength) + case 'symbol': + return { type: 'symbol', value: (value as symbol).description || '' } + case 'bigint': + return { type: 'bigint', value: String(value) } // eslint-disable-line @typescript-eslint/no-base-to-string + case 'function': + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + return captureFunction(value as Function, depth, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength) + case 'object': + return captureObject(value as object, depth, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength) + default: + return { type: String(type), notCapturedReason: 'Unsupported type' } + } +} + +function captureString(str: string, maxLength: number): CapturedValue { + const size = str.length + + if (size <= maxLength) { + return { type: 'string', value: str } + } + + return { + type: 'string', + value: str.slice(0, maxLength), + truncated: true, + size, + } +} + +function captureFunction( + fn: Function, // eslint-disable-line @typescript-eslint/no-unsafe-function-type + depth: number, + maxReferenceDepth: number, + maxCollectionSize: number, + maxFieldCount: number, + maxLength: number +): CapturedValue { + // Check if it's a class by converting to string and checking for 'class' keyword + const fnStr = Function.prototype.toString.call(fn) + const classMatch = fnStr.match(/^class\s([^{]*)/) + + if (classMatch !== null) { + // This is a class + const className = classMatch[1].trim() + return { type: className ? `class ${className}` : 'class' } + } + + // This is a function - serialize it as an object with its properties + if (depth >= maxReferenceDepth) { + return { type: 'Function', notCapturedReason: 'depth' } + } + + return captureObjectProperties( + fn as any, + 'Function', + depth, + maxReferenceDepth, + maxCollectionSize, + maxFieldCount, + maxLength + ) +} + +function captureObject( + obj: object, + depth: number, + maxReferenceDepth: number, + maxCollectionSize: number, + maxFieldCount: number, + maxLength: number +): CapturedValue { + if (depth >= maxReferenceDepth) { + return { type: (obj as any).constructor?.name ?? 'Object', notCapturedReason: 'depth' } + } + + // Built-in objects with specialized serialization + if (obj instanceof Date) { + try { + return { type: 'Date', value: obj.toISOString() } + } catch { + return { type: 'Date', value: String(obj) } + } + } + if (obj instanceof RegExp) { + return { type: 'RegExp', value: obj.toString() } + } + if (obj instanceof Error) { + return captureError(obj, depth, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength) + } + if (obj instanceof Promise) { + return { type: 'Promise', notCapturedReason: 'Promise state cannot be inspected' } + } + + // Collections + if (Array.isArray(obj)) { + return captureArray(obj, depth, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength) + } + if (obj instanceof Map) { + return captureMap(obj, depth, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength) + } + if (obj instanceof Set) { + return captureSet(obj, depth, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength) + } + if (obj instanceof WeakMap) { + return { type: 'WeakMap', notCapturedReason: 'WeakMap contents cannot be enumerated' } + } + if (obj instanceof WeakSet) { + return { type: 'WeakSet', notCapturedReason: 'WeakSet contents cannot be enumerated' } + } + + // Binary data + if (obj instanceof ArrayBuffer) { + return captureArrayBuffer(obj) + } + if (typeof SharedArrayBuffer !== 'undefined' && obj instanceof SharedArrayBuffer) { + return captureSharedArrayBuffer(obj) + } + if (obj instanceof DataView) { + return captureDataView(obj) + } + if (ArrayBuffer.isView(obj) && !(obj instanceof DataView)) { + return captureTypedArray(obj as TypedArray, depth, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength) + } + + // Custom objects + const typeName = (obj as any).constructor?.name ?? 'Object' + return captureObjectProperties(obj, typeName, depth, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength) +} + +function captureObjectPropertiesFields( + obj: any, + depth: number, + maxReferenceDepth: number, + maxCollectionSize: number, + maxFieldCount: number, + maxLength: number +): Record { + const keys = Object.getOwnPropertyNames(obj) + const symbolKeys = Object.getOwnPropertySymbols(obj) + const allKeys: Array = (keys as Array).concat(symbolKeys) + + const keysToCapture = allKeys.slice(0, maxFieldCount) + + const fields: Record = {} + for (const key of keysToCapture) { + const keyStr = String(key) + const keyName = + typeof key === 'symbol' ? key.description || key.toString() : keyStr.includes('.') ? replaceDots(keyStr) : keyStr + + try { + const propValue = obj[key] + fields[keyName] = captureValue( + propValue, + depth + 1, + maxReferenceDepth, + maxCollectionSize, + maxFieldCount, + maxLength + ) + } catch { + // Handle getters that throw or other access errors + fields[keyName] = { type: 'undefined', notCapturedReason: 'Error accessing property' } + } + } + + return fields +} + +function captureObjectProperties( + obj: any, + typeName: string, + depth: number, + maxReferenceDepth: number, + maxCollectionSize: number, + maxFieldCount: number, + maxLength: number +): CapturedValue { + const keys = Object.getOwnPropertyNames(obj) + const symbolKeys = Object.getOwnPropertySymbols(obj) + const allKeys: Array = (keys as Array).concat(symbolKeys) + const totalFields = allKeys.length + + const fields = captureObjectPropertiesFields( + obj, + depth, + maxReferenceDepth, + maxCollectionSize, + maxFieldCount, + maxLength + ) + + const result: CapturedValue = { type: typeName, fields } + + if (totalFields > maxFieldCount) { + result.notCapturedReason = 'fieldCount' + result.size = totalFields + } + + return result +} + +function captureArray( + arr: unknown[], + depth: number, + maxReferenceDepth: number, + maxCollectionSize: number, + maxFieldCount: number, + maxLength: number +): CapturedValue { + const totalSize = arr.length + const itemsToCapture = Math.min(totalSize, maxCollectionSize) + + const elements: CapturedValue[] = [] + for (let i = 0; i < itemsToCapture; i++) { + elements.push(captureValue(arr[i], depth + 1, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength)) + } + + const result: CapturedValue = { type: 'Array', elements } + + if (totalSize > maxCollectionSize) { + result.notCapturedReason = 'collectionSize' + result.size = totalSize + } + + return result +} + +function captureMap( + map: Map, + depth: number, + maxReferenceDepth: number, + maxCollectionSize: number, + maxFieldCount: number, + maxLength: number +): CapturedValue { + const totalSize = map.size + const entriesToCapture = Math.min(totalSize, maxCollectionSize) + + const entries: Array<[CapturedValue, CapturedValue]> = [] + let count = 0 + for (const [key, value] of map) { + if (count >= entriesToCapture) { + break + } + entries.push([ + captureValue(key, depth + 1, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength), + captureValue(value, depth + 1, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength), + ]) + count++ + } + + const result: CapturedValue = { type: 'Map', entries } + + if (totalSize > maxCollectionSize) { + result.notCapturedReason = 'collectionSize' + result.size = totalSize + } + + return result +} + +function captureSet( + set: Set, + depth: number, + maxReferenceDepth: number, + maxCollectionSize: number, + maxFieldCount: number, + maxLength: number +): CapturedValue { + const totalSize = set.size + const itemsToCapture = Math.min(totalSize, maxCollectionSize) + + const elements: CapturedValue[] = [] + let count = 0 + for (const value of set) { + if (count >= itemsToCapture) { + break + } + elements.push(captureValue(value, depth + 1, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength)) + count++ + } + + const result: CapturedValue = { type: 'Set', elements } + + if (totalSize > maxCollectionSize) { + result.notCapturedReason = 'collectionSize' + result.size = totalSize + } + + return result +} + +function captureError( + err: Error, + depth: number, + maxReferenceDepth: number, + maxCollectionSize: number, + maxFieldCount: number, + maxLength: number +): CapturedValue { + const typeName = (err as any).constructor?.name ?? 'Error' + const fields: Record = { + message: captureValue(err.message, depth + 1, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength), + name: captureValue(err.name, depth + 1, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength), + } + + if (err.stack !== undefined) { + fields.stack = captureValue(err.stack, depth + 1, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength) + } + + if ((err as any).cause !== undefined) { + fields.cause = captureValue( + (err as any).cause, + depth + 1, + maxReferenceDepth, + maxCollectionSize, + maxFieldCount, + maxLength + ) + } + + return { type: typeName, fields } +} + +function captureArrayBuffer(buffer: ArrayBuffer): CapturedValue { + return { + type: 'ArrayBuffer', + value: `[ArrayBuffer(${buffer.byteLength})]`, + } +} + +function captureSharedArrayBuffer(buffer: SharedArrayBuffer): CapturedValue { + return { + type: 'SharedArrayBuffer', + value: `[SharedArrayBuffer(${buffer.byteLength})]`, + } +} + +function captureDataView(view: DataView): CapturedValue { + return { + type: 'DataView', + fields: { + byteLength: { type: 'number', value: String(view.byteLength) }, + byteOffset: { type: 'number', value: String(view.byteOffset) }, + buffer: { type: 'ArrayBuffer', value: `[ArrayBuffer(${view.buffer.byteLength})]` }, + }, + } +} + +type TypedArray = + | Int8Array + | Uint8Array + | Uint8ClampedArray + | Int16Array + | Uint16Array + | Int32Array + | Uint32Array + | Float32Array + | Float64Array + | BigInt64Array + | BigUint64Array + +function captureTypedArray( + typedArray: TypedArray, + depth: number, + maxReferenceDepth: number, + maxCollectionSize: number, + maxFieldCount: number, + maxLength: number +): CapturedValue { + const typeName = typedArray.constructor?.name ?? 'TypedArray' + const totalSize = typedArray.length + const itemsToCapture = Math.min(totalSize, maxCollectionSize) + + const elements: CapturedValue[] = [] + for (let i = 0; i < itemsToCapture; i++) { + elements.push( + captureValue(typedArray[i], depth + 1, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength) + ) + } + + const result: CapturedValue = { + type: typeName, + elements, + fields: { + byteLength: { type: 'number', value: String(typedArray.byteLength) }, + byteOffset: { type: 'number', value: String(typedArray.byteOffset) }, + length: { type: 'number', value: String(typedArray.length) }, + }, + } + + if (totalSize > maxCollectionSize) { + result.notCapturedReason = 'collectionSize' + result.size = totalSize + } + + return result +} diff --git a/packages/debugger/src/domain/condition.spec.ts b/packages/debugger/src/domain/condition.spec.ts new file mode 100644 index 0000000000..1312c8fde6 --- /dev/null +++ b/packages/debugger/src/domain/condition.spec.ts @@ -0,0 +1,178 @@ +import { display } from '@datadog/browser-core' +import { evaluateProbeCondition, compileCondition } from './condition' + +describe('condition', () => { + let displayErrorSpy: jasmine.Spy + + beforeEach(() => { + displayErrorSpy = spyOn(display, 'error') + }) + + describe('evaluateProbeCondition', () => { + it('should return true when probe has no condition', () => { + const probe: any = {} + const result = evaluateProbeCondition(probe, {}) + + expect(result).toBe(true) + }) + + it('should return true for simple true condition', () => { + const probe: any = { + condition: compileCondition('true'), + } + const result = evaluateProbeCondition(probe, {}) + + expect(result).toBe(true) + }) + + it('should return false for simple false condition', () => { + const probe: any = { + condition: compileCondition('false'), + } + const result = evaluateProbeCondition(probe, {}) + + expect(result).toBe(false) + }) + + it('should evaluate condition with context variables', () => { + const probe: any = { + condition: compileCondition('x > 5'), + } + + expect(evaluateProbeCondition(probe, { x: 10 })).toBe(true) + expect(evaluateProbeCondition(probe, { x: 3 })).toBe(false) + }) + + it('should evaluate complex conditions', () => { + const probe: any = { + condition: compileCondition('x > 5 && y < 20'), + } + + expect(evaluateProbeCondition(probe, { x: 10, y: 15 })).toBe(true) + expect(evaluateProbeCondition(probe, { x: 3, y: 15 })).toBe(false) + expect(evaluateProbeCondition(probe, { x: 10, y: 25 })).toBe(false) + }) + + it('should evaluate conditions with string operations', () => { + const probe: any = { + condition: compileCondition('name === "John"'), + } + + expect(evaluateProbeCondition(probe, { name: 'John' })).toBe(true) + expect(evaluateProbeCondition(probe, { name: 'Jane' })).toBe(false) + }) + + it('should evaluate conditions with multiple variables', () => { + const probe: any = { + condition: compileCondition('a + b === 10'), + } + + expect(evaluateProbeCondition(probe, { a: 5, b: 5 })).toBe(true) + expect(evaluateProbeCondition(probe, { a: 3, b: 4 })).toBe(false) + }) + + it('should coerce non-boolean results to boolean', () => { + const probe: any = { + condition: compileCondition('x'), + } + + expect(evaluateProbeCondition(probe, { x: 1 })).toBe(true) + expect(evaluateProbeCondition(probe, { x: 0 })).toBe(false) + expect(evaluateProbeCondition(probe, { x: 'hello' })).toBe(true) + expect(evaluateProbeCondition(probe, { x: '' })).toBe(false) + expect(evaluateProbeCondition(probe, { x: null })).toBe(false) + expect(evaluateProbeCondition(probe, { x: undefined })).toBe(false) + }) + + it('should handle condition evaluation errors gracefully', () => { + const probe: any = { + id: 'test-probe', + condition: compileCondition('nonExistent.property'), + } + + // Should return true (fire probe) when condition evaluation fails + const result = evaluateProbeCondition(probe, {}) + expect(result).toBe(true) + + // Should log error + expect(displayErrorSpy).toHaveBeenCalledWith( + jasmine.stringContaining('Failed to evaluate condition for probe test-probe'), + jasmine.any(Error) + ) + }) + + it('should handle syntax errors in condition', () => { + const probe: any = { + condition: compileCondition('invalid syntax !!!'), + } + + const result = evaluateProbeCondition(probe, {}) + expect(result).toBe(true) + expect(displayErrorSpy).toHaveBeenCalled() + }) + + it('should handle conditions with special variables', () => { + const probe: any = { + condition: compileCondition('$dd_return > 0'), + } + + expect(evaluateProbeCondition(probe, { $dd_return: 10 })).toBe(true) + expect(evaluateProbeCondition(probe, { $dd_return: -5 })).toBe(false) + }) + + it('should handle conditions with this context', () => { + const probe: any = { + condition: compileCondition('this.value === 42'), + } + + expect(evaluateProbeCondition(probe, { this: { value: 42 } })).toBe(true) + expect(evaluateProbeCondition(probe, { this: { value: 10 } })).toBe(false) + }) + + it('should handle array operations', () => { + const probe: any = { + condition: compileCondition('arr.length > 0'), + } + + expect(evaluateProbeCondition(probe, { arr: [1, 2, 3] })).toBe(true) + expect(evaluateProbeCondition(probe, { arr: [] })).toBe(false) + }) + + it('should handle object property checks', () => { + const probe: any = { + condition: compileCondition('obj.hasOwnProperty("key")'), + } + + expect(evaluateProbeCondition(probe, { obj: { key: 'value' } })).toBe(true) + expect(evaluateProbeCondition(probe, { obj: {} })).toBe(false) + }) + + it('should handle typeof checks', () => { + const probe: any = { + condition: compileCondition('typeof value === "number"'), + } + + expect(evaluateProbeCondition(probe, { value: 42 })).toBe(true) + expect(evaluateProbeCondition(probe, { value: 'string' })).toBe(false) + }) + + it('should handle nested property access', () => { + const probe: any = { + condition: compileCondition('user.profile.age >= 18'), + } + + expect(evaluateProbeCondition(probe, { user: { profile: { age: 25 } } })).toBe(true) + expect(evaluateProbeCondition(probe, { user: { profile: { age: 15 } } })).toBe(false) + }) + + it('should handle null/undefined checks', () => { + const probe: any = { + condition: compileCondition('value !== null && value !== undefined'), + } + + expect(evaluateProbeCondition(probe, { value: 42 })).toBe(true) + expect(evaluateProbeCondition(probe, { value: null })).toBe(false) + expect(evaluateProbeCondition(probe, { value: undefined })).toBe(false) + }) + }) +}) diff --git a/packages/debugger/src/domain/condition.ts b/packages/debugger/src/domain/condition.ts new file mode 100644 index 0000000000..20b805990f --- /dev/null +++ b/packages/debugger/src/domain/condition.ts @@ -0,0 +1,73 @@ +import { display } from '@datadog/browser-core' + +export interface CompiledCondition { + evaluate: (contextKeys: string[]) => (...args: any[]) => boolean + clearCache: () => void +} + +export interface ProbeWithCondition { + id: string + condition?: CompiledCondition +} + +/** + * Pre-compile a condition expression into a cached function factory. + * + * The returned `evaluate` method accepts the runtime context keys (e.g. `['x', 'y']`) and + * returns a Function whose parameters match those keys. Context values are passed positionally + * at call time via `fn.call(thisValue, ...contextValues)`. + * + * Because `new Function()` is expensive (it parses and compiles JS source), we cache the + * resulting Function objects keyed by context keys. For ENTRY probes there is always exactly one + * cache entry. For EXIT probes there can be two — one for the normal-return path and one for the + * exception path — since they provide different context variables. + */ +export function compileCondition(condition: string): CompiledCondition { + const fnBody = `return ${condition}` + const functionCache = new Map boolean>() + + return { + evaluate: (contextKeys: string[]) => { + const cacheKey = contextKeys.join(',') + let fn = functionCache.get(cacheKey) + if (!fn) { + // eslint-disable-next-line no-new-func, @typescript-eslint/no-implied-eval + fn = new Function(...contextKeys, fnBody) as (...args: any[]) => boolean + functionCache.set(cacheKey, fn) + } + return fn + }, + clearCache: () => { + functionCache.clear() + }, + } +} + +/** + * Evaluate probe condition to determine if probe should fire + * + * @param probe - Probe configuration + * @param context - Runtime context with variables + * @returns True if condition passes (or no condition), false otherwise + */ +export function evaluateProbeCondition(probe: ProbeWithCondition, context: Record): boolean { + // If no condition, probe always fires + if (!probe.condition) { + return true + } + + try { + // Separate 'this' from other context variables + const { this: thisValue, ...otherContext } = context + const contextKeys = Object.keys(otherContext) + const contextValues = Object.values(otherContext) + + const fn = probe.condition.evaluate(contextKeys) + return Boolean(fn.call(thisValue, ...contextValues)) + } catch (e) { + // If condition evaluation fails, log error and let probe fire + // TODO: Handle error properly + display.error(`Failed to evaluate condition for probe ${probe.id}:`, e) + return true + } +} diff --git a/packages/debugger/src/domain/deliveryApi.spec.ts b/packages/debugger/src/domain/deliveryApi.spec.ts new file mode 100644 index 0000000000..d9dcfaa07f --- /dev/null +++ b/packages/debugger/src/domain/deliveryApi.spec.ts @@ -0,0 +1,263 @@ +import { display, getGlobalObject } from '@datadog/browser-core' +import { registerCleanupTask, mockClock, replaceMockable } from '@datadog/browser-core/test' +import type { Clock } from '@datadog/browser-core/test' +import { getProbes, clearProbes } from './probes' +import type { Probe } from './probes' +import { startDeliveryApiPolling, stopDeliveryApiPolling, clearDeliveryApiState } from './deliveryApi' +import type { DeliveryApiConfiguration } from './deliveryApi' + +describe('deliveryApi', () => { + let fetchSpy: jasmine.Spy + let clock: Clock + + function makeConfig(overrides: Partial = {}): DeliveryApiConfiguration { + return { + service: 'test-service', + env: 'staging', + version: '1.0.0', + pollInterval: 5000, + ...overrides, + } + } + + function respondWith(data: object, status = 200) { + fetchSpy.and.returnValue( + Promise.resolve({ + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(data), + text: () => Promise.resolve(JSON.stringify(data)), + }) + ) + } + + beforeEach(() => { + clock = mockClock() + clearProbes() + clearDeliveryApiState() + fetchSpy = spyOn(window, 'fetch') + respondWith({ nextCursor: '', updates: [], deletions: [] }) + + registerCleanupTask(() => { + stopDeliveryApiPolling() + clearDeliveryApiState() + clearProbes() + }) + }) + + describe('startDeliveryApiPolling', () => { + it('should not start polling when location is not available', () => { + replaceMockable(getGlobalObject, (() => ({})) as unknown as typeof getGlobalObject) + startDeliveryApiPolling(makeConfig()) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it('should make an initial POST request to the delivery API', () => { + startDeliveryApiPolling(makeConfig()) + + expect(fetchSpy).toHaveBeenCalledTimes(1) + const [url, options] = fetchSpy.calls.mostRecent().args + expect(url).toBe('/api/ui/debugger/probe-delivery') + expect(options.method).toBe('POST') + expect(options.credentials).toBe('same-origin') + expect(options.headers['Content-Type']).toBe('application/json; charset=utf-8') + expect(options.headers['Accept']).toBe('application/vnd.datadog.debugger-probes+json; version=1') + }) + + it('should send the correct request body', () => { + startDeliveryApiPolling(makeConfig()) + + const [, options] = fetchSpy.calls.mostRecent().args + const body = JSON.parse(options.body) + expect(body).toEqual({ + service: 'test-service', + clientName: 'browser', + clientVersion: jasmine.stringMatching(/.+/), + env: 'staging', + serviceVersion: '1.0.0', + }) + }) + + it('should not include nextCursor in the first request', () => { + startDeliveryApiPolling(makeConfig()) + + const [, options] = fetchSpy.calls.mostRecent().args + const body = JSON.parse(options.body) + expect(body.nextCursor).toBeUndefined() + }) + + it('should warn if polling is already started', () => { + const warnSpy = spyOn(display, 'warn') + startDeliveryApiPolling(makeConfig()) + startDeliveryApiPolling(makeConfig()) + + expect(warnSpy).toHaveBeenCalledWith(jasmine.stringMatching(/already started/)) + }) + + it('should add probes from the updates array', async () => { + respondWith({ + nextCursor: 'cursor-1', + updates: [makeProbe({ id: 'probe-1', version: 1 })], + deletions: [], + }) + + startDeliveryApiPolling(makeConfig()) + await flushPromises() + + const probes = getProbes('test.js;testMethod') + expect(probes).toBeDefined() + expect(probes!.length).toBe(1) + expect(probes![0].id).toBe('probe-1') + }) + + it('should remove probes listed in deletions', async () => { + // First poll: add the probe via the delivery API + respondWith({ + nextCursor: 'cursor-1', + updates: [makeProbe({ id: 'probe-to-delete', version: 1 })], + deletions: [], + }) + + startDeliveryApiPolling(makeConfig()) + await flushPromises() + expect(getProbes('test.js;testMethod')).toBeDefined() + + // Second poll: delete it + respondWith({ + nextCursor: 'cursor-2', + updates: [], + deletions: ['probe-to-delete'], + }) + + clock.tick(5000) + await flushPromises() + + expect(getProbes('test.js;testMethod')).toBeUndefined() + }) + + it('should send nextCursor in subsequent requests', async () => { + respondWith({ + nextCursor: 'cursor-abc', + updates: [], + deletions: [], + }) + + startDeliveryApiPolling(makeConfig()) + await flushPromises() + + // Tick to trigger next poll + respondWith({ nextCursor: 'cursor-def', updates: [], deletions: [] }) + clock.tick(5000) + + expect(fetchSpy).toHaveBeenCalledTimes(2) + const [, options] = fetchSpy.calls.mostRecent().args + const body = JSON.parse(options.body) + expect(body.nextCursor).toBe('cursor-abc') + }) + + it('should update existing probes when they appear in updates again', async () => { + respondWith({ + nextCursor: 'cursor-1', + updates: [makeProbe({ id: 'probe-1', version: 1 })], + deletions: [], + }) + + startDeliveryApiPolling(makeConfig()) + await flushPromises() + + respondWith({ + nextCursor: 'cursor-2', + updates: [makeProbe({ id: 'probe-1', version: 2 })], + deletions: [], + }) + + clock.tick(5000) + await flushPromises() + + const probes = getProbes('test.js;testMethod') + expect(probes).toBeDefined() + expect(probes!.length).toBe(1) + expect(probes![0].version).toBe(2) + }) + + it('should log an error when the response is not ok', async () => { + const errorSpy = spyOn(display, 'error') + respondWith({}, 500) + + startDeliveryApiPolling(makeConfig()) + await flushPromises() + + expect(errorSpy).toHaveBeenCalledWith(jasmine.stringMatching(/failed with status 500/), jasmine.any(String)) + }) + + it('should log an error when fetch throws', async () => { + const errorSpy = spyOn(display, 'error') + fetchSpy.and.returnValue(Promise.reject(new Error('network error'))) + + startDeliveryApiPolling(makeConfig()) + await flushPromises() + + expect(errorSpy).toHaveBeenCalledWith(jasmine.stringMatching(/poll error/), jasmine.any(Error)) + }) + + it('should poll at the configured interval', () => { + respondWith({ nextCursor: '', updates: [], deletions: [] }) + + startDeliveryApiPolling(makeConfig({ pollInterval: 3000 })) + expect(fetchSpy).toHaveBeenCalledTimes(1) + + clock.tick(3000) + expect(fetchSpy).toHaveBeenCalledTimes(2) + + clock.tick(3000) + expect(fetchSpy).toHaveBeenCalledTimes(3) + }) + + it('should default to 60 second polling interval', () => { + respondWith({ nextCursor: '', updates: [], deletions: [] }) + + startDeliveryApiPolling(makeConfig({ pollInterval: undefined })) + expect(fetchSpy).toHaveBeenCalledTimes(1) + + clock.tick(59_999) + expect(fetchSpy).toHaveBeenCalledTimes(1) + + clock.tick(1) + expect(fetchSpy).toHaveBeenCalledTimes(2) + }) + }) + + describe('stopDeliveryApiPolling', () => { + it('should stop the polling interval', () => { + respondWith({ nextCursor: '', updates: [], deletions: [] }) + + startDeliveryApiPolling(makeConfig()) + expect(fetchSpy).toHaveBeenCalledTimes(1) + + stopDeliveryApiPolling() + clock.tick(5000) + expect(fetchSpy).toHaveBeenCalledTimes(1) + }) + }) +}) + +async function flushPromises() { + for (let i = 0; i < 10; i++) { + await Promise.resolve() + } +} + +function makeProbe(overrides: Partial = {}): Probe { + return { + id: 'probe-1', + version: 1, + type: 'LOG_PROBE', + where: { typeName: 'test.js', methodName: 'testMethod' }, + template: 'Test message', + captureSnapshot: false, + capture: {}, + sampling: {}, + evaluateAt: 'ENTRY', + ...overrides, + } +} diff --git a/packages/debugger/src/domain/deliveryApi.ts b/packages/debugger/src/domain/deliveryApi.ts new file mode 100644 index 0000000000..709d75e670 --- /dev/null +++ b/packages/debugger/src/domain/deliveryApi.ts @@ -0,0 +1,146 @@ +import type { TimeoutId } from '@datadog/browser-core' +import { display, fetch, getGlobalObject, mockable, setInterval, clearInterval } from '@datadog/browser-core' +import { addProbe, removeProbe } from './probes' +import type { Probe } from './probes' + +declare const __BUILD_ENV__SDK_VERSION__: string + +const DELIVERY_API_PATH = '/api/ui/debugger/probe-delivery' +const DEFAULT_HEADERS: Record = { + 'Content-Type': 'application/json; charset=utf-8', + Accept: 'application/vnd.datadog.debugger-probes+json; version=1', +} + +export interface DeliveryApiConfiguration { + service: string + env?: string + version?: string + pollInterval?: number +} + +interface DeliveryApiResponse { + nextCursor: string + updates: Probe[] + deletions: string[] +} + +let pollIntervalId: TimeoutId | undefined +let currentCursor: string | undefined +let knownProbeIds = new Set() + +/** + * Start polling the Datadog Delivery API for probe updates. + * + * This is designed for dogfooding the Live Debugger inside the Datadog web UI, + * where the user is already authenticated via session cookies (ValidUser auth). + * Requests are same-origin, so no explicit domain is needed. + */ +export function startDeliveryApiPolling(config: DeliveryApiConfiguration): void { + if (!('location' in mockable(getGlobalObject)())) { + return + } + + if (pollIntervalId !== undefined) { + display.warn('Debugger: Delivery API polling already started') + return + } + + const pollInterval = config.pollInterval || 60_000 + + const baseRequestBody = { + service: config.service, + clientName: 'browser', + clientVersion: __BUILD_ENV__SDK_VERSION__, + env: config.env, + serviceVersion: config.version, + } + + const poll = async () => { + try { + const body: Record = { ...baseRequestBody } + if (currentCursor) { + body.nextCursor = currentCursor + } + + const response = await fetch(DELIVERY_API_PATH, { + method: 'POST', + headers: { ...DEFAULT_HEADERS }, + body: JSON.stringify(body), + credentials: 'same-origin', + }) + + if (!response.ok) { + // TODO: Remove response body logging once dogfooding is complete + let errorBody = '' + try { + errorBody = await response.text() + } catch { + // ignore + } + display.error(`Debugger: Delivery API poll failed with status ${response.status}`, errorBody) + return + } + + const data: DeliveryApiResponse = await response.json() + + if (data.nextCursor) { + currentCursor = data.nextCursor + } + + for (const probeId of data.deletions || []) { + if (knownProbeIds.has(probeId)) { + try { + removeProbe(probeId) + knownProbeIds.delete(probeId) + } catch (err) { + display.error(`Debugger: Failed to remove probe ${probeId}:`, err as Error) + } + } + } + + for (const probe of data.updates || []) { + if (!probe.id) { + continue + } + + if (knownProbeIds.has(probe.id)) { + try { + removeProbe(probe.id) + } catch { + // Probe may have been removed by a deletion in the same response + } + } + + try { + addProbe(probe) + knownProbeIds.add(probe.id) + } catch (err) { + display.error(`Debugger: Failed to add probe ${probe.id}:`, err as Error) + } + } + } catch (err) { + display.error('Debugger: Delivery API poll error:', err as Error) + } + } + + void poll() + + // eslint-disable-next-line @typescript-eslint/no-misused-promises + pollIntervalId = setInterval(poll, pollInterval) +} + +export function stopDeliveryApiPolling(): void { + if (pollIntervalId !== undefined) { + clearInterval(pollIntervalId) + pollIntervalId = undefined + } +} + +export function clearDeliveryApiState(): void { + currentCursor = undefined + knownProbeIds = new Set() + if (pollIntervalId !== undefined) { + clearInterval(pollIntervalId) + pollIntervalId = undefined + } +} diff --git a/packages/debugger/src/domain/expression.spec.ts b/packages/debugger/src/domain/expression.spec.ts new file mode 100644 index 0000000000..582d5877ff --- /dev/null +++ b/packages/debugger/src/domain/expression.spec.ts @@ -0,0 +1,321 @@ +import * as acorn from 'acorn' +import { + literals, + references, + propertyAccess, + sizes, + equality, + stringManipulation, + stringComparison, + logicalOperators, + collectionOperations, + membershipAndMatching, + typeAndDefinitionChecks, +} from '../../test' +import type { TestCase } from '../../test' +import { OLDEST_BROWSER_ECMA_VERSION } from '../../../../test/unit/browsers.conf' + +import { compile } from './expression' +import { compileSegments } from './template' + +// Flatten all test cases into a single array +const testCases: TestCase[] = [ + ...literals, + ...references, + ...propertyAccess, + ...sizes, + ...equality, + ...stringManipulation, + ...stringComparison, + ...logicalOperators, + ...collectionOperations, + ...membershipAndMatching, + ...typeAndDefinitionChecks, +] + +describe('Expression language', () => { + describe('condition compilation', () => { + const testNameCounts = new Map() + + for (const testCase of testCases) { + let before: (() => void) | undefined + let ast: any + let vars: Record = {} + let suffix: string | undefined + let expected: any + let execute = true + + if (Array.isArray(testCase)) { + ;[ast, vars, expected] = testCase + } else { + // Allow for more expressive test cases in situations where the default tuple is not enough + ;({ before, ast, vars = {}, suffix, expected, execute = true } = testCase) + } + + const baseName = generateTestCaseName(ast, vars, expected, suffix, execute) + const uniqueName = makeUniqueName(baseName, testNameCounts) + + it(uniqueName, () => { + if (before) { + before() + } + + if (execute === false) { + if (expected instanceof Error) { + expect(() => compile(ast)).toThrowError(expected.constructor as new (...args: any[]) => Error) + } else { + expect(compile(ast)).toBe(expected) + } + return + } + + const compiledResult = compile(ast) + const compiledCode = typeof compiledResult === 'string' ? compiledResult : String(compiledResult) + const code = suffix + ? `const result = (() => { + return ${compiledCode} + })() + ${suffix} + return result` + : `return ${compiledCode}` + + // Create a function with the vars as parameters + // eslint-disable-next-line no-new-func, @typescript-eslint/no-implied-eval + const fn = new Function(...Object.keys(vars), code) + const args = Object.values(vars) + + if (expected instanceof Error) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call + expect(() => fn(...args)).toThrowError(expected.constructor as new (...args: any[]) => Error) + } else { + const result = runWithDebug(fn, args) + if (expected !== null && typeof expected === 'object') { + expect(result).toEqual(expected) + } else { + expect(result).toBe(expected) + } + } + }) + } + }) + + // Keep some specific tests for additional coverage + describe('literal optimization', () => { + it('should not wrap literal numbers in coercion guards', () => { + const result = compile({ gt: [{ ref: 'x' }, 10] }) + // The right side should be just "10", not wrapped in a guard function + expect(result).toContain('> 10') + expect(result).not.toMatch(/> \(\(val\) => \{/) + }) + + it('should wrap non-literal values in coercion guards', () => { + const result = compile({ gt: [{ ref: 'x' }, { ref: 'y' }] }) + // Both sides should be wrapped + expect(result).toContain('((val) => {') + }) + + it('should handle literal booleans without wrapping', () => { + const result = compile({ gt: [{ ref: 'x' }, true] }) + // Boolean true evaluates, but shouldn't be wrapped for gt since it's not a number + // Actually, booleans get coerced, so they should still be wrapped + expect(result).toContain('>') + }) + + it('should handle literal null without wrapping', () => { + const result = compile({ gt: [{ ref: 'x' }, null] }) + expect(result).toContain('>') + }) + }) + + describe('evaluation edge cases', () => { + it('should evaluate literal comparisons correctly', () => { + const x = 15 // eslint-disable-line @typescript-eslint/no-unused-vars + const compiled = compile({ gt: [{ ref: 'x' }, 10] }) + const code = typeof compiled === 'string' ? compiled : String(compiled) + const result = eval(code) // eslint-disable-line no-eval + expect(result).toBe(true) + }) + + it('should handle literal in left position', () => { + const x = 5 // eslint-disable-line @typescript-eslint/no-unused-vars + const compiled = compile({ gt: [10, { ref: 'x' }] }) + const code = typeof compiled === 'string' ? compiled : String(compiled) + const result = eval(code) // eslint-disable-line no-eval + expect(result).toBe(true) + }) + + it('should handle both literals', () => { + const compiled = compile({ gt: [20, 10] }) + const code = typeof compiled === 'string' ? compiled : String(compiled) + const result = eval(code) // eslint-disable-line no-eval + expect(result).toBe(true) + }) + }) +}) + +// Validate that all compiled expressions produce JavaScript compatible with the oldest target browser. +// The expression compiler generates code as strings evaluated at runtime via new Function(), +// bypassing TypeScript compilation and webpack transpilation. This test catches usage of syntax +// not supported by older browsers (e.g., optional chaining ?., optional catch binding, nullish coalescing ??). +describe('browser compatibility of generated code', () => { + function assertECMAVersionCompatible(code: string, params: string[] = []) { + const functionCode = `function f(${params.join(', ')}) { ${code} }` + try { + acorn.parse(functionCode, { ecmaVersion: OLDEST_BROWSER_ECMA_VERSION, sourceType: 'script' }) + } catch (e: unknown) { + fail( + `Generated code is not ES${OLDEST_BROWSER_ECMA_VERSION}-compatible: ${(e as Error).message}\n\nGenerated code:\n${code}` + ) + } + } + + describe('expressions', () => { + const testNameCounts = new Map() + + for (const testCase of testCases) { + let ast: any + let vars: Record = {} + let suffix: string | undefined + let execute = true + + if (Array.isArray(testCase)) { + ;[ast, vars] = testCase + } else { + ;({ ast, vars = {}, suffix, execute = true } = testCase) + } + + if (execute === false) { + continue + } + + const baseName = JSON.stringify(ast) + const uniqueName = makeUniqueName(baseName, testNameCounts) + + it(uniqueName, () => { + const compiledResult = compile(ast) + const compiledCode = typeof compiledResult === 'string' ? compiledResult : String(compiledResult) + const code = suffix + ? `const result = (() => { + return ${compiledCode} + })() + ${suffix} + return result` + : `return ${compiledCode}` + + assertECMAVersionCompatible(code, Object.keys(vars)) + }) + } + }) + + describe('template segments', () => { + it('should generate code compatible with the oldest target browser for compiled segments', () => { + const segmentsCode = compileSegments([{ str: 'Hello ' }, { dsl: 'name', json: { ref: 'name' } }, { str: '!' }]) + + assertECMAVersionCompatible(`return ${segmentsCode}`, ['$dd_inspect', 'name']) + }) + + it('should generate code compatible with the oldest target browser for segments with complex expressions', () => { + const segmentsCode = compileSegments([{ dsl: 'obj.field', json: { getmember: [{ ref: 'obj' }, 'field'] } }]) + + assertECMAVersionCompatible(`return ${segmentsCode}`, ['$dd_inspect', 'obj']) + }) + }) +}) + +function makeUniqueName(baseName: string, testNameCounts: Map): string { + const count = testNameCounts.get(baseName) || 0 + testNameCounts.set(baseName, count + 1) + + if (count === 0) { + return baseName + } + + return `${baseName} [#${count + 1}]` +} + +function generateTestCaseName( + ast: any, + vars: Record, + expected: any, + suffix?: string, + execute?: boolean +): string { + const code = Object.entries(vars) + .map(([key, value]) => `${key} = ${serialize(value)}`) + .join('; ') + + const expectedStr = expected instanceof Error ? expected.constructor.name : serialize(expected) + let name = `${JSON.stringify(ast)} + "${code}" => ${expectedStr}` + + // Add suffix to make test names unique when present + if (suffix) { + name += ` (with: ${suffix.replace(/\n/g, ' ').substring(0, 50)})` + } + + // Indicate when compilation is tested without execution + if (execute === false) { + name += ' [compile-only]' + } + + return name +} + +function serialize(value: any): string { + try { + if (value === undefined) { + return 'undefined' + } + if (typeof value === 'function') { + return 'function' + } + if (typeof value === 'symbol') { + return value.toString() + } + + // Distinguish between primitive strings and String objects + if (typeof value === 'string') { + return JSON.stringify(value) + } + if (value instanceof String) { + return `String(${JSON.stringify(value.valueOf())})` + } + + // Handle other objects with constructor names for better distinction + if (value && typeof value === 'object') { + const constructorName = value.constructor?.name + if (constructorName && constructorName !== 'Object' && constructorName !== 'Array') { + // For built-in types like Set, Map, WeakSet, etc., show constructor name + if (['Set', 'Map', 'WeakSet', 'WeakMap', 'Int16Array', 'Int32Array', 'RegExp'].includes(constructorName)) { + return `${constructorName}(${JSON.stringify(value).substring(0, 50)})` + } + // For custom objects, just show the constructor name + return `${constructorName}{}` + } + } + + return JSON.stringify(value) + } catch { + // Some values are not serializable to JSON, so we fall back to stringification + const str = String(value) + return str.length > 50 ? `${str.substring(0, 50)}…` : str + } +} + +// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type +function runWithDebug(fn: Function, args: any[] = []): any { + try { + return fn(...args) // eslint-disable-line @typescript-eslint/no-unsafe-call + } catch (e) { + // Output the compiled expression for easier debugging + // eslint-disable-next-line no-console + console.log( + [ + 'Compiled expression:', + '--------------------------------------------------------------------------------', + fn.toString(), + '--------------------------------------------------------------------------------', + ].join('\n') + ) + throw e + } +} diff --git a/packages/debugger/src/domain/expression.ts b/packages/debugger/src/domain/expression.ts new file mode 100644 index 0000000000..86919d0eef --- /dev/null +++ b/packages/debugger/src/domain/expression.ts @@ -0,0 +1,349 @@ +/** + * DSL expression language compiler for Live Debugger SDK. + * Compiles DSL expressions into executable JavaScript code. + * Used by both conditions and template segments. + * Adapted from dd-trace-js/packages/dd-trace/src/debugger/devtools_client/condition.js + */ + +const identifierRegex = /^[@a-zA-Z_$][\w$]*$/ + +// The following identifiers have purposefully not been included in this list: +// - The reserved words `this` and `super` as they can have valid use cases as `ref` values +// - The literals `undefined` and `Infinity` as they can be useful as `ref` values, especially to check if a +// variable is `undefined`. +// - The following future reserved words in older standards, as they can now be used safely: +// `abstract`, `boolean`, `byte`, `char`, `double`, `final`, `float`, `goto`, `int`, `long`, `native`, `short`, +// `synchronized`, `throws`, `transient`, `volatile`. +const reservedWords = new Set([ + // Reserved words + 'break', + 'case', + 'catch', + 'class', + 'const', + 'continue', + 'debugger', + 'default', + 'delete', + 'do', + 'else', + 'export', + 'extends', + 'false', + 'finally', + 'for', + 'function', + 'if', + 'import', + 'in', + 'instanceof', + 'new', + 'null', + 'return', + 'switch', + 'throw', + 'true', + 'try', + 'typeof', + 'var', + 'void', + 'while', + 'with', + + // Reserved in strict mode + 'let', + 'static', + 'yield', + + // Reserved in module code or async function bodies: + 'await', + + // Future reserved words + 'enum', + + // Future reserved words in strict mode + 'implements', + 'interface', + 'package', + 'private', + 'protected', + 'public', + + // Literals + 'NaN', +]) + +const PRIMITIVE_TYPES = new Set(['string', 'number', 'bigint', 'boolean', 'undefined', 'symbol', 'null']) + +export type ExpressionNode = + | null + | string + | number + | boolean + | { not: ExpressionNode } + | { len: ExpressionNode } + | { count: ExpressionNode } + | { isEmpty: ExpressionNode } + | { isDefined: ExpressionNode } + | { instanceof: [ExpressionNode, string] } + | { ref: string } + | { eq: ExpressionNode[] } + | { ne: ExpressionNode[] } + | { gt: ExpressionNode[] } + | { ge: ExpressionNode[] } + | { lt: ExpressionNode[] } + | { le: ExpressionNode[] } + | { any: ExpressionNode[] } // eslint-disable-line id-denylist + | { all: ExpressionNode[] } + | { and: ExpressionNode[] } + | { or: ExpressionNode[] } + | { startsWith: ExpressionNode[] } + | { endsWith: ExpressionNode[] } + | { contains: ExpressionNode[] } + | { matches: ExpressionNode[] } + | { filter: ExpressionNode[] } + | { substring: ExpressionNode[] } + | { getmember: ExpressionNode[] } + | { index: ExpressionNode[] } + +/** + * Compile a DSL expression node to JavaScript code + * + * @param node - DSL expression node + * @returns Compiled JavaScript code as a string, or raw primitive values + */ +export function compile(node: ExpressionNode): string | number | boolean | null { + if (node === null || typeof node === 'number' || typeof node === 'boolean') { + return node + } else if (typeof node === 'string') { + return JSON.stringify(node) + } + + const [type, value] = Object.entries(node)[0] + + if (type === 'not') { + return `!(${compile(value as ExpressionNode)})` + } else if (type === 'len' || type === 'count') { + return getSize(compile(value as ExpressionNode) as string) + } else if (type === 'isEmpty') { + return `${getSize(compile(value as ExpressionNode) as string)} === 0` + } else if (type === 'isDefined') { + return `(() => { + try { + ${compile(value as ExpressionNode)} + return true + } catch (e) { + return false + } + })()` + } else if (type === 'instanceof') { + const [target, typeName] = value as [ExpressionNode, string] + return isPrimitiveType(typeName) + ? `(typeof ${compile(target)} === '${typeName}')` + : `Function.prototype[Symbol.hasInstance].call(${assertIdentifier(typeName)}, ${compile(target)})` + } else if (type === 'ref') { + const refValue = value as string + if (refValue.startsWith('@')) { + return `$dd_${refValue.slice(1)}` + } + return assertIdentifier(refValue) + } else if (Array.isArray(value)) { + const args = value.map((v) => compile(v as ExpressionNode)) + switch (type) { + case 'eq': + return `(${args[0]}) === (${args[1]})` + case 'ne': + return `(${args[0]}) !== (${args[1]})` + case 'gt': + return `${guardAgainstCoercionSideEffects(args[0])} > ${guardAgainstCoercionSideEffects(args[1])}` + case 'ge': + return `${guardAgainstCoercionSideEffects(args[0])} >= ${guardAgainstCoercionSideEffects(args[1])}` + case 'lt': + return `${guardAgainstCoercionSideEffects(args[0])} < ${guardAgainstCoercionSideEffects(args[1])}` + case 'le': + return `${guardAgainstCoercionSideEffects(args[0])} <= ${guardAgainstCoercionSideEffects(args[1])}` + case 'any': + return iterateOn('some', args[0] as string, args[1] as string) + case 'all': + return iterateOn('every', args[0] as string, args[1] as string) + case 'and': + return `(${args.join(') && (')})` + case 'or': + return `(${args.join(') || (')})` + case 'startsWith': + return `String.prototype.startsWith.call(${assertString(args[0])}, ${assertString(args[1])})` + case 'endsWith': + return `String.prototype.endsWith.call(${assertString(args[0])}, ${assertString(args[1])})` + case 'contains': + return `((obj, elm) => { + if (${isString('obj')}) { + return String.prototype.includes.call(obj, elm) + } else if (Array.isArray(obj)) { + return Array.prototype.includes.call(obj, elm) + } else if (${isTypedArray('obj')}) { + return Object.getPrototypeOf(Int8Array.prototype).includes.call(obj, elm) + } else if (${isInstanceOf('Set', 'obj')}) { + return Set.prototype.has.call(obj, elm) + } else if (${isInstanceOf('WeakSet', 'obj')}) { + return WeakSet.prototype.has.call(obj, elm) + } else if (${isInstanceOf('Map', 'obj')}) { + return Map.prototype.has.call(obj, elm) + } else if (${isInstanceOf('WeakMap', 'obj')}) { + return WeakMap.prototype.has.call(obj, elm) + } else { + throw new TypeError('Variable does not support contains') + } + })(${args[0]}, ${args[1]})` + case 'matches': + return `((str, regex) => { + if (${isString('str')}) { + const regexIsString = ${isString('regex')} + if (regexIsString || Object.getPrototypeOf(regex) === RegExp.prototype) { + return RegExp.prototype.test.call(regexIsString ? new RegExp(regex) : regex, str) + } else { + throw new TypeError('Regular expression must be either a string or an instance of RegExp') + } + } else { + throw new TypeError('Variable is not a string') + } + })(${args[0]}, ${args[1]})` + case 'filter': + return `(($dd_var) => { + return ${isIterableCollection('$dd_var')} + ? Array.from($dd_var).filter(($dd_it) => ${args[1]}) + : Object.entries($dd_var).reduce((acc, [$dd_key, $dd_value]) => { + if (${args[1]}) acc[$dd_key] = $dd_value + return acc + }, {}) + })(${args[0]})` + case 'substring': + return `((str) => { + if (${isString('str')}) { + return String.prototype.substring.call(str, ${args[1]}, ${args[2]}) + } else { + throw new TypeError('Variable is not a string') + } + })(${args[0]})` + case 'getmember': + return accessProperty(args[0] as string, args[1] as string, false) + case 'index': + return accessProperty(args[0] as string, args[1] as string, true) + } + } + + throw new TypeError(`Unknown AST node type: ${type}`) +} + +function iterateOn(fnName: string, variable: string, callbackCode: string): string { + return `(($dd_val) => { + return ${isIterableCollection('$dd_val')} + ? Array.from($dd_val).${fnName}(($dd_it) => ${callbackCode}) + : Object.entries($dd_val).${fnName}(([$dd_key, $dd_value]) => ${callbackCode}) + })(${variable})` +} + +function isString(variable: string): string { + return `(typeof ${variable} === 'string' || ${variable} instanceof String)` +} + +function isPrimitiveType(type: string): boolean { + return PRIMITIVE_TYPES.has(type) +} + +function isIterableCollection(variable: string): string { + return ( + `(${isArrayOrTypedArray(variable)} || ${isInstanceOf('Set', variable)} || ` + + `${isInstanceOf('WeakSet', variable)})` + ) +} + +function isArrayOrTypedArray(variable: string): string { + return `(Array.isArray(${variable}) || ${isTypedArray(variable)})` +} + +function isTypedArray(variable: string): string { + return `(${variable} instanceof Object.getPrototypeOf(Int8Array))` +} + +function isInstanceOf(type: string, variable: string): string { + return `(${variable} instanceof ${type})` +} + +function getSize(variable: string): string { + return `((val) => { + if (${isString('val')} || ${isArrayOrTypedArray('val')}) { + return ${guardAgainstPropertyAccessSideEffects('val', '"length"')} + } else if (${isInstanceOf('Set', 'val')} || ${isInstanceOf('Map', 'val')}) { + return ${guardAgainstPropertyAccessSideEffects('val', '"size"')} + } else if (${isInstanceOf('WeakSet', 'val')} || ${isInstanceOf('WeakMap', 'val')}) { + throw new TypeError('Cannot get size of WeakSet or WeakMap') + } else if (typeof val === 'object' && val !== null) { + return Object.keys(val).length + } else { + throw new TypeError('Cannot get length of variable') + } + })(${variable})` +} + +function accessProperty(variable: string, keyOrIndex: string, allowMapAccess: boolean): string { + return `((val, key) => { + if (${isInstanceOf('Map', 'val')}) { + ${allowMapAccess ? 'return Map.prototype.get.call(val, key)' : "throw new Error('Accessing a Map is not allowed')"} + } else if (${isInstanceOf('WeakMap', 'val')}) { + ${allowMapAccess ? 'return WeakMap.prototype.get.call(val, key)' : "throw new Error('Accessing a WeakMap is not allowed')"} + } else if (${isInstanceOf('Set', 'val')} || ${isInstanceOf('WeakSet', 'val')}) { + throw new Error('Accessing a Set or WeakSet is not allowed') + } else { + return ${guardAgainstPropertyAccessSideEffects('val', 'key')} + } + })(${variable}, ${keyOrIndex})` +} + +function guardAgainstPropertyAccessSideEffects(variable: string, propertyName: string): string { + return `((val, key) => { + const desc = Object.getOwnPropertyDescriptor(val, key); + if (desc && desc.get !== undefined) { + throw new Error('Possibility of side effect') + } else { + return val[key] + } + })(${variable}, ${propertyName})` +} + +function guardAgainstCoercionSideEffects(variable: string | number | boolean | null): string { + // shortcut if we're comparing number literals + if (typeof variable === 'number') { + return String(variable) + } + + return `((val) => { + if ( + typeof val === 'object' && val !== null && ( + val[Symbol.toPrimitive] !== undefined || + val.valueOf !== Object.prototype.valueOf || + val.toString !== Object.prototype.toString + ) + ) { + throw new Error('Possibility of side effect due to coercion methods') + } else { + return val + } + })(${variable})` +} + +function assertString(variable: string | number | boolean | null): string { + return `((val) => { + if (${isString('val')}) { + return val + } else { + throw new TypeError('Variable is not a string') + } + })(${variable})` +} + +function assertIdentifier(value: string): string { + if (!identifierRegex.test(value) || reservedWords.has(value)) { + throw new SyntaxError(`Illegal identifier: ${value}`) + } + return value +} diff --git a/packages/debugger/src/domain/probes.spec.ts b/packages/debugger/src/domain/probes.spec.ts new file mode 100644 index 0000000000..dd1c79be01 --- /dev/null +++ b/packages/debugger/src/domain/probes.spec.ts @@ -0,0 +1,471 @@ +import { display } from '@datadog/browser-core' +import { registerCleanupTask } from '@datadog/browser-core/test' +import { initializeProbe, getProbes, addProbe, removeProbe, checkGlobalSnapshotBudget, clearProbes } from './probes' +import type { Probe } from './probes' + +interface TemplateWithCache { + createFunction: (params: string[]) => (...args: any[]) => any + clearCache?: () => void +} + +describe('probes', () => { + beforeEach(() => { + clearProbes() + + registerCleanupTask(() => clearProbes()) + }) + + describe('addProbe and getProbes', () => { + it('should add and retrieve a probe', () => { + const probe: Probe = { + id: 'test-probe-1', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'test.js', methodName: 'testMethod' }, + template: 'Test message', + captureSnapshot: false, + capture: {}, + sampling: {}, + evaluateAt: 'ENTRY', + } + + addProbe(probe) + const retrieved = getProbes('test.js;testMethod') + + expect(retrieved).toEqual([ + jasmine.objectContaining({ + id: 'test-probe-1', + templateRequiresEvaluation: false, + }), + ]) + }) + + it('should return undefined for non-existent probe', () => { + const retrieved = getProbes('non-existent') + expect(retrieved).toBeUndefined() + }) + }) + + describe('removeProbe', () => { + it('should remove a probe', () => { + const probe: Probe = { + id: 'test-probe-1', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'testMethod' }, + template: 'Test', + captureSnapshot: false, + capture: {}, + sampling: {}, + evaluateAt: 'ENTRY', + } + + addProbe(probe) + expect(getProbes('TestClass;testMethod')).toBeDefined() + + removeProbe('test-probe-1') + expect(getProbes('TestClass;testMethod')).toBeUndefined() + }) + + it('should clear function cache when removing probe with dynamic template', () => { + const probe: Probe = { + id: 'test-probe-1', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'cacheTest' }, + template: '', + segments: [{ dsl: 'x', json: { ref: 'x' } }], + captureSnapshot: false, + capture: {}, + sampling: {}, + evaluateAt: 'ENTRY', + } + + addProbe(probe) + const retrieved = getProbes('TestClass;cacheTest') + const template = retrieved![0].template as TemplateWithCache + + // Create some cached functions + template.createFunction(['x', 'y']) + template.createFunction(['x', 'z']) + + // Spy on clearCache method + const clearCacheSpy = jasmine.createSpy('clearCache') + template.clearCache = clearCacheSpy + + removeProbe('test-probe-1') + + // Verify clearCache was called + expect(clearCacheSpy).toHaveBeenCalled() + }) + + it('should handle removing probe with static template without errors', () => { + const probe: Probe = { + id: 'test-probe-1', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'staticTest' }, + template: 'Static message', + captureSnapshot: false, + capture: {}, + sampling: {}, + evaluateAt: 'ENTRY', + } + + addProbe(probe) + + // Should not throw when removing probe with static template (no clearCache method) + expect(() => removeProbe('test-probe-1')).not.toThrow() + }) + }) + + describe('initializeProbe', () => { + it('should initialize probe with static template', () => { + const probe: Probe = { + id: 'test-probe-1', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'initStatic' }, + template: 'Static message', + captureSnapshot: false, + capture: {}, + sampling: {}, + evaluateAt: 'ENTRY', + } + + initializeProbe(probe) + + expect(probe).toEqual( + jasmine.objectContaining({ + templateRequiresEvaluation: false, + template: 'Static message', + msBetweenSampling: jasmine.any(Number), + lastCaptureMs: -Infinity, + }) + ) + }) + + it('should initialize probe with dynamic template', () => { + const probe: Probe = { + id: 'test-probe-1', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'initDynamic' }, + template: '', + segments: [{ str: 'Value: ' }, { dsl: 'x', json: { ref: 'x' } }], + captureSnapshot: false, + capture: {}, + sampling: {}, + evaluateAt: 'ENTRY', + } + + initializeProbe(probe) + + expect(probe).toEqual( + jasmine.objectContaining({ + templateRequiresEvaluation: true, + template: { + createFunction: jasmine.any(Function), + clearCache: jasmine.any(Function), + }, + }) + ) + expect(probe.segments).toBeUndefined() // Should be deleted after initialization + }) + + it('should compile condition when present', () => { + const probe: Probe = { + id: 'test-probe-1', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'conditionCompile' }, + when: { + dsl: 'x > 5', + json: { gt: [{ ref: 'x' }, 5] }, + }, + template: 'Message', + captureSnapshot: false, + capture: {}, + sampling: {}, + evaluateAt: 'EXIT', + } + + initializeProbe(probe) + + expect(probe.condition).toEqual( + jasmine.objectContaining({ + evaluate: jasmine.any(Function), + clearCache: jasmine.any(Function), + }) + ) + }) + + it('should handle condition compilation errors', () => { + const displayErrorSpy = spyOn(display, 'error') + const probe: Probe = { + id: 'test-probe-1', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'conditionError' }, + when: { + dsl: 'invalid', + json: { invalidOp: 'bad' } as any, + }, + template: 'Message', + captureSnapshot: false, + capture: {}, + sampling: {}, + evaluateAt: 'EXIT', + } + + initializeProbe(probe) + + expect(displayErrorSpy).toHaveBeenCalledWith( + jasmine.stringContaining('Cannot compile condition'), + jasmine.any(Error) + ) + }) + + it('should calculate msBetweenSampling for snapshot probes', () => { + const probe: Probe = { + id: 'test-probe-1', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'samplingCalc' }, + template: 'Message', + captureSnapshot: true, + capture: {}, + sampling: { snapshotsPerSecond: 10 }, + evaluateAt: 'ENTRY', + } + + initializeProbe(probe) + + expect(probe.msBetweenSampling).toBe(100) // 1000ms / 10 = 100ms + }) + + it('should use default sampling rate for snapshot probes without explicit rate', () => { + const probe: Probe = { + id: 'test-probe-1', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'samplingDefault' }, + template: 'Message', + captureSnapshot: true, + capture: {}, + sampling: {}, + evaluateAt: 'ENTRY', + } + + initializeProbe(probe) + + expect(probe.msBetweenSampling).toBe(1000) // 1 snapshot per second by default + }) + + it('should use high default sampling rate for non-snapshot probes', () => { + const probe: Probe = { + id: 'test-probe-1', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'samplingHigh' }, + template: 'Message', + captureSnapshot: false, + capture: {}, + sampling: {}, + evaluateAt: 'ENTRY', + } + + initializeProbe(probe) + + expect(probe.msBetweenSampling).toBeLessThan(1) // 5000 per second = 0.2ms + }) + + it('should cache compiled functions by context keys', () => { + const probe: Probe = { + id: 'test-probe-1', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'cacheKeys' }, + template: '', + segments: [{ dsl: 'x', json: { ref: 'x' } }], + captureSnapshot: false, + capture: {}, + sampling: {}, + evaluateAt: 'ENTRY', + } + + initializeProbe(probe) + + const template = probe.template as TemplateWithCache + const fn1 = template.createFunction(['x', 'y']) + const fn2 = template.createFunction(['x', 'y']) + + // Should return the same cached function + expect(fn1).toBe(fn2) + + const fn3 = template.createFunction(['x', 'z']) + // Different keys should create different function + expect(fn1).not.toBe(fn3) + }) + }) + + describe('clearProbes', () => { + it('should clear all probes', () => { + const probe1: Probe = { + id: 'test-probe-1', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'clear1' }, + template: 'Test 1', + captureSnapshot: false, + capture: {}, + sampling: {}, + evaluateAt: 'ENTRY', + } + + const probe2: Probe = { + id: 'test-probe-2', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'clear2' }, + template: 'Test 2', + captureSnapshot: false, + capture: {}, + sampling: {}, + evaluateAt: 'ENTRY', + } + + addProbe(probe1) + addProbe(probe2) + + clearProbes() + + expect(getProbes('TestClass;clear1')).toBeUndefined() + expect(getProbes('TestClass;clear2')).toBeUndefined() + }) + + it('should clear function caches for all probes with dynamic templates', () => { + const probe1: Probe = { + id: 'test-probe-1', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'clearCache1' }, + template: '', + segments: [{ dsl: 'x', json: { ref: 'x' } }], + captureSnapshot: false, + capture: {}, + sampling: {}, + evaluateAt: 'ENTRY', + } + + const probe2: Probe = { + id: 'test-probe-2', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'clearCache2' }, + template: '', + segments: [{ dsl: 'y', json: { ref: 'y' } }], + captureSnapshot: false, + capture: {}, + sampling: {}, + evaluateAt: 'ENTRY', + } + + const probe3: Probe = { + id: 'test-probe-3', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'clearCache3' }, + template: 'Static message', + captureSnapshot: false, + capture: {}, + sampling: {}, + evaluateAt: 'ENTRY', + } + + addProbe(probe1) + addProbe(probe2) + addProbe(probe3) + + const template1 = getProbes('TestClass;clearCache1')![0].template as TemplateWithCache + const template2 = getProbes('TestClass;clearCache2')![0].template as TemplateWithCache + + // Create some cached functions + template1.createFunction(['x']) + template2.createFunction(['y']) + + // Spy on clearCache methods + const clearCache1Spy = jasmine.createSpy('clearCache1') + const clearCache2Spy = jasmine.createSpy('clearCache2') + template1.clearCache = clearCache1Spy + template2.clearCache = clearCache2Spy + + clearProbes() + + // Verify clearCache was called for both dynamic template probes + expect(clearCache1Spy).toHaveBeenCalled() + expect(clearCache2Spy).toHaveBeenCalled() + }) + }) + + describe('checkGlobalSnapshotBudget', () => { + it('should allow non-snapshot probes without limit', () => { + for (let i = 0; i < 100; i++) { + expect(checkGlobalSnapshotBudget(Date.now(), false)).toBe(true) + } + }) + + it('should allow snapshots within global budget', () => { + const now = Date.now() + for (let i = 0; i < 25; i++) { + expect(checkGlobalSnapshotBudget(now + i, true)).toBe(true) + } + }) + + it('should reject snapshots beyond global budget', () => { + const now = Date.now() + // Use up the budget + for (let i = 0; i < 25; i++) { + checkGlobalSnapshotBudget(now + i, true) + } + + // Next one should be rejected + expect(checkGlobalSnapshotBudget(now + 26, true)).toBe(false) + }) + + it('should reset budget after time window', () => { + const now = Date.now() + + // Use up the budget + for (let i = 0; i < 25; i++) { + checkGlobalSnapshotBudget(now + i, true) + } + + // Should be rejected + expect(checkGlobalSnapshotBudget(now + 100, true)).toBe(false) + + // After 1 second, should allow again + expect(checkGlobalSnapshotBudget(now + 1100, true)).toBe(true) + }) + + it('should track budget correctly across time windows', () => { + const baseTime = Date.now() + + // First window - use 20 snapshots + for (let i = 0; i < 20; i++) { + expect(checkGlobalSnapshotBudget(baseTime + i, true)).toBe(true) + } + + // Still within same window - 5 more should work + for (let i = 0; i < 5; i++) { + expect(checkGlobalSnapshotBudget(baseTime + 500 + i, true)).toBe(true) + } + + // Now at limit + expect(checkGlobalSnapshotBudget(baseTime + 600, true)).toBe(false) + + // New window + expect(checkGlobalSnapshotBudget(baseTime + 1500, true)).toBe(true) + }) + }) +}) diff --git a/packages/debugger/src/domain/probes.ts b/packages/debugger/src/domain/probes.ts new file mode 100644 index 0000000000..c8e06b6cb9 --- /dev/null +++ b/packages/debugger/src/domain/probes.ts @@ -0,0 +1,272 @@ +import { display } from '@datadog/browser-core' +import { clearActiveEntries } from './activeEntries' +import { compile } from './expression' +import { compileCondition } from './condition' +import type { CompiledCondition } from './condition' +import { templateRequiresEvaluation, compileSegments } from './template' +import type { TemplateSegment, CompiledTemplate } from './template' +import type { CaptureOptions } from './capture' + +// Sampling rate limits +const MAX_SNAPSHOTS_PER_SECOND_GLOBALLY = 25 +const MAX_SNAPSHOTS_PER_SECOND_PER_PROBE = 1 +const MAX_NON_SNAPSHOTS_PER_SECOND_PER_PROBE = 5000 + +// Global snapshot rate limiting +let globalSnapshotSamplingRateWindowStart = 0 +let snapshotsSampledWithinTheLastSecond = 0 + +export interface ProbeWhere { + typeName?: string + methodName?: string + sourceFile?: string + lines?: string[] +} + +export interface ProbeWhen { + dsl: string + json: any +} + +export interface ProbeSampling { + snapshotsPerSecond?: number +} + +export interface Probe { + id: string + version: number + type: string + where: ProbeWhere + when?: ProbeWhen + template: string | CompiledTemplate + segments?: TemplateSegment[] + captureSnapshot: boolean + capture: CaptureOptions + sampling: ProbeSampling + evaluateAt: 'ENTRY' | 'EXIT' + location?: { + file?: string + lines?: string[] + method?: string + } +} + +export interface InitializedProbe extends Probe { + templateRequiresEvaluation: boolean + functionId: string + condition?: CompiledCondition + msBetweenSampling: number + lastCaptureMs: number +} + +// Pre-populate with a placeholder key to help V8 optimize property lookups. +// Removing this shows a much larger performance overhead. +// Benchmarks show that using an object is much faster than a Map. +const activeProbes: Record = { + // @ts-expect-error - Pre-populate with a placeholder key to help V8 optimize property lookups. + __placeholder__: undefined, +} +const probeIdToFunctionId: Record = { + // @ts-expect-error - Pre-populate with a placeholder key to help V8 optimize property lookups. + __placeholder__: undefined, +} + +/** + * Add a probe to the registry + * + * @param probe - The probe configuration + */ +export function addProbe(probe: Probe): void { + initializeProbe(probe) + let probes = activeProbes[probe.functionId] + if (!probes) { + probes = [] + activeProbes[probe.functionId] = probes + } + probes.push(probe) + probeIdToFunctionId[probe.id] = probe.functionId +} + +/** + * Get initialized probes by function ID + * + * @param functionId - The probe function ID + * @returns The initialized probes + */ +export function getProbes(functionId: string): InitializedProbe[] | undefined { + return activeProbes[functionId] +} + +/** + * Get all active probes across all functions + * + * @returns Array of all active probes + */ +export function getAllProbes(): InitializedProbe[] { + const allProbes: InitializedProbe[] = [] + for (const probes of Object.values(activeProbes)) { + if (probes) { + allProbes.push(...probes) + } + } + return allProbes +} + +/** + * Remove a probe from the registry + * + * @param id - The probe ID + */ +export function removeProbe(id: string): void { + const functionId = probeIdToFunctionId[id] + if (!functionId) { + throw new Error(`Probe with id ${id} not found`) + } + const probes = activeProbes[functionId] + if (!probes) { + throw new Error(`Probes with function id ${functionId} not found`) + } + for (let i = 0; i < probes.length; i++) { + const probe = probes[i] + if (probe.id === id) { + if (typeof probe.template === 'object' && probe.template !== null && probe.template.clearCache) { + probe.template.clearCache() + } + if (typeof probe.condition === 'object' && probe.condition !== null && probe.condition.clearCache) { + probe.condition.clearCache() + } + probes.splice(i, 1) + clearActiveEntries(id) + break + } + } + delete probeIdToFunctionId[id] + if (probes.length === 0) { + delete activeProbes[functionId] + } +} + +/** + * Clear all probes (useful for testing) + */ +export function clearProbes(): void { + for (const probes of Object.values(activeProbes)) { + if (probes) { + for (const probe of probes) { + if (typeof probe.template === 'object' && probe.template !== null && probe.template.clearCache) { + probe.template.clearCache() + } + if (typeof probe.condition === 'object' && probe.condition !== null && probe.condition.clearCache) { + probe.condition.clearCache() + } + } + } + } + for (const functionId of Object.keys(activeProbes)) { + if (functionId !== '__placeholder__') { + delete activeProbes[functionId] + } + } + for (const probeId of Object.keys(probeIdToFunctionId)) { + if (probeId !== '__placeholder__') { + delete probeIdToFunctionId[probeId] + } + } + clearActiveEntries() + globalSnapshotSamplingRateWindowStart = 0 + snapshotsSampledWithinTheLastSecond = 0 +} + +/** + * Check global snapshot sampling budget + * + * @param now - Current timestamp in milliseconds + * @param captureSnapshot - Whether this probe captures snapshots + * @returns True if within budget, false if rate limited + */ +export function checkGlobalSnapshotBudget(now: number, captureSnapshot: boolean): boolean { + // Only enforce global budget for probes that capture snapshots + if (!captureSnapshot) { + return true + } + + // Reset counter if a second has passed + // This algorithm is not a perfect sliding window, but it's quick and easy + if (now - globalSnapshotSamplingRateWindowStart > 1000) { + snapshotsSampledWithinTheLastSecond = 1 + globalSnapshotSamplingRateWindowStart = now + return true + } + + // Check if we've exceeded the global limit + if (snapshotsSampledWithinTheLastSecond >= MAX_SNAPSHOTS_PER_SECOND_GLOBALLY) { + return false + } + + // Increment counter and allow + snapshotsSampledWithinTheLastSecond++ + return true +} + +/** + * Initialize a probe by preprocessing template segments, conditions, and sampling + * + * @param probe - The probe configuration + */ +export function initializeProbe(probe: Probe): asserts probe is InitializedProbe { + // TODO: Add support for anonymous functions (Currently only uniquely named functions are supported) + ;(probe as InitializedProbe).functionId = `${probe.where.typeName};${probe.where.methodName}` + + // Compile condition if present + try { + if (probe.when?.json) { + ;(probe as InitializedProbe).condition = compileCondition(String(compile(probe.when.json))) + } + } catch (err) { + // TODO: Handle error properly + display.error( + `Cannot compile condition expression: ${probe.when!.dsl} (probe: ${probe.id}, version: ${probe.version})`, + err as Error + ) + } + + // Optimize for fast calculations when probe is hit + ;(probe as InitializedProbe).templateRequiresEvaluation = templateRequiresEvaluation(probe.segments) + if ((probe as InitializedProbe).templateRequiresEvaluation) { + const segmentsCode = compileSegments(probe.segments!) + + // Pre-build the function body so we avoid rebuilding this string on every probe hit. + // The actual Function is created at runtime because the parameter names (context keys) + // aren't known until call time. For ENTRY probes there is exactly one set of keys; for + // EXIT probes there can be two (normal-return vs exception path). + const fnBodyTemplate = `return ${segmentsCode};` + + // Cache compiled functions by context keys to avoid recreating them + const functionCache = new Map any[]>() + + // Store the template with a factory that caches functions + probe.template = { + createFunction: (contextKeys: string[]) => { + const cacheKey = contextKeys.join(',') + let fn = functionCache.get(cacheKey) + if (!fn) { + // eslint-disable-next-line no-new-func, @typescript-eslint/no-implied-eval + fn = new Function('$dd_inspect', ...contextKeys, fnBodyTemplate) as (...args: any[]) => any[] + functionCache.set(cacheKey, fn) + } + return fn + }, + clearCache: () => { + functionCache.clear() + }, + } + } + delete probe.segments + + // Optimize for fast calculations when probe is hit - calculate sampling budget + const snapshotsPerSecond = + probe.sampling?.snapshotsPerSecond ?? + (probe.captureSnapshot ? MAX_SNAPSHOTS_PER_SECOND_PER_PROBE : MAX_NON_SNAPSHOTS_PER_SECOND_PER_PROBE) + ;(probe as InitializedProbe).msBetweenSampling = (1 / snapshotsPerSecond) * 1000 // Convert to milliseconds + ;(probe as InitializedProbe).lastCaptureMs = -Infinity // Initialize to -Infinity to allow first call +} diff --git a/packages/debugger/src/domain/stacktrace.spec.ts b/packages/debugger/src/domain/stacktrace.spec.ts new file mode 100644 index 0000000000..46fcdb0415 --- /dev/null +++ b/packages/debugger/src/domain/stacktrace.spec.ts @@ -0,0 +1,210 @@ +import { captureStackTrace, parseStackTrace } from './stacktrace' + +describe('stacktrace', () => { + describe('parseStackTrace', () => { + it('should parse Chrome/V8 stack trace format with function names', () => { + const error = { + stack: `Error: test error + at myFunction (http://example.com/app.js:42:10) + at anotherFunction (http://example.com/app.js:100:5)`, + } as Error + + const result = parseStackTrace(error) + + expect(result).toEqual([ + { + fileName: 'http://example.com/app.js', + function: 'myFunction', + lineNumber: 42, + columnNumber: 10, + }, + { + fileName: 'http://example.com/app.js', + function: 'anotherFunction', + lineNumber: 100, + columnNumber: 5, + }, + ]) + }) + + it('should parse Chrome/V8 stack trace format without function names', () => { + const error = { + stack: `Error: test error + at http://example.com/app.js:42:10 + at http://example.com/app.js:100:5`, + } as Error + + const result = parseStackTrace(error) + + expect(result).toEqual([ + { + fileName: 'http://example.com/app.js', + function: '', + lineNumber: 42, + columnNumber: 10, + }, + { + fileName: 'http://example.com/app.js', + function: '', + lineNumber: 100, + columnNumber: 5, + }, + ]) + }) + + it('should parse Firefox stack trace format', () => { + const error = { + stack: `test error +myFunction@http://example.com/app.js:42:10 +anotherFunction@http://example.com/app.js:100:5`, + } as Error + + const result = parseStackTrace(error) + + expect(result).toEqual([ + { + fileName: 'http://example.com/app.js', + function: 'myFunction', + lineNumber: 42, + columnNumber: 10, + }, + { + fileName: 'http://example.com/app.js', + function: 'anotherFunction', + lineNumber: 100, + columnNumber: 5, + }, + ]) + }) + + it('should skip frames when skipFrames is specified', () => { + const error = { + stack: `Error: test error + at frameToSkip (http://example.com/app.js:10:5) + at myFunction (http://example.com/app.js:42:10) + at anotherFunction (http://example.com/app.js:100:5)`, + } as Error + + const result = parseStackTrace(error, 1) + + expect(result).toEqual([ + { + fileName: 'http://example.com/app.js', + function: 'myFunction', + lineNumber: 42, + columnNumber: 10, + }, + { + fileName: 'http://example.com/app.js', + function: 'anotherFunction', + lineNumber: 100, + columnNumber: 5, + }, + ]) + }) + + it('should return empty array when error has no stack', () => { + const error = {} as Error + + const result = parseStackTrace(error) + + expect(result).toEqual([]) + }) + + it('should handle empty stack string', () => { + const error = { stack: '' } as Error + + const result = parseStackTrace(error) + + expect(result).toEqual([]) + }) + + it('should skip malformed stack lines', () => { + const error = { + stack: `Error: test error + at myFunction (http://example.com/app.js:42:10) + some malformed line without proper format + at anotherFunction (http://example.com/app.js:100:5)`, + } as Error + + const result = parseStackTrace(error) + + expect(result).toEqual([ + { + fileName: 'http://example.com/app.js', + function: 'myFunction', + lineNumber: 42, + columnNumber: 10, + }, + { + fileName: 'http://example.com/app.js', + function: 'anotherFunction', + lineNumber: 100, + columnNumber: 5, + }, + ]) + }) + + it('should handle file paths with spaces', () => { + const error = { + stack: `Error: test error + at myFunction (http://example.com/my app.js:42:10)`, + } as Error + + const result = parseStackTrace(error) + + expect(result).toEqual([ + { + fileName: 'http://example.com/my app.js', + function: 'myFunction', + lineNumber: 42, + columnNumber: 10, + }, + ]) + }) + }) + + describe('captureStackTrace', () => { + it('should capture current stack trace', () => { + const result = captureStackTrace() + + expect(result.length).toBeGreaterThan(0) + expect(result[0]).toEqual( + jasmine.objectContaining({ + fileName: jasmine.any(String), + function: jasmine.any(String), + lineNumber: jasmine.any(Number), + columnNumber: jasmine.any(Number), + }) + ) + }) + + it('should skip frames when specified', () => { + function testFunction(skipFrames = 0) { + return captureStackTrace(skipFrames) + } + + function wrapperFunction() { + return testFunction() + } + + const resultWithoutSkip = wrapperFunction() + const resultWithSkip = testFunction(1) + + // When skipping frames, we should have fewer frames + expect(resultWithSkip.length).toBeLessThan(resultWithoutSkip.length) + }) + + it('should skip captureStackTrace itself and error creation', () => { + function namedFunction() { + return captureStackTrace() + } + + const result = namedFunction() + + // The first frame should be namedFunction, not captureStackTrace + // (Note: exact function name matching depends on browser/minification) + expect(result.length).toBeGreaterThan(0) + }) + }) +}) diff --git a/packages/debugger/src/domain/stacktrace.ts b/packages/debugger/src/domain/stacktrace.ts new file mode 100644 index 0000000000..b582a6632e --- /dev/null +++ b/packages/debugger/src/domain/stacktrace.ts @@ -0,0 +1,63 @@ +import type { StackTrace } from '@datadog/browser-core' +import { computeStackTrace } from '@datadog/browser-core' + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -- `type` is needed for implicit index signature compatibility with Context +export type StackFrame = { + fileName: string + function: string + lineNumber: number + columnNumber: number +} + +/** + * Capture the current stack trace + * + * @param skipFrames - Number of frames to skip from the top of the stack (default: 0) + * @returns Array of stack frames + */ +export function captureStackTrace(skipFrames = 0): StackFrame[] { + const error = new Error() + const stackTrace = computeStackTrace(error) + + // Skip this helper itself so callers get their own frame first. + return mapStackFrames(stackTrace.stack, 1 + skipFrames) +} + +/** + * Parse a stack trace from an Error object + * + * @param error - Error object with stack property + * @param skipFrames - Number of frames to skip from the top of the parsed stack (default: 0) + * @returns Array of stack frames + */ +export function parseStackTrace(error: Error, skipFrames = 0): StackFrame[] { + return mapStackFrames(computeStackTrace(error).stack, skipFrames) +} + +function mapStackFrame(frame: StackTrace['stack'][number]): StackFrame | undefined { + if (!frame.url || frame.line === undefined || frame.column === undefined) { + return + } + + return { + fileName: frame.url.trim(), + function: frame.func === '?' || !frame.func ? '' : frame.func.trim(), + lineNumber: frame.line, + columnNumber: frame.column, + } +} + +function mapStackFrames(stack: StackTrace['stack'], skipFrames = 0): StackFrame[] { + return stack.reduce((result, frame, index) => { + if (index < skipFrames) { + return result + } + + const mappedFrame = mapStackFrame(frame) + if (mappedFrame) { + result.push(mappedFrame) + } + + return result + }, []) +} diff --git a/packages/debugger/src/domain/template.spec.ts b/packages/debugger/src/domain/template.spec.ts new file mode 100644 index 0000000000..b8de8c45c8 --- /dev/null +++ b/packages/debugger/src/domain/template.spec.ts @@ -0,0 +1,434 @@ +import { templateRequiresEvaluation, compileSegments, evaluateProbeMessage, browserInspect } from './template' + +describe('template', () => { + describe('templateRequiresEvaluation', () => { + it('should return false for undefined segments', () => { + expect(templateRequiresEvaluation(undefined)).toBe(false) + }) + + it('should return false for segments with only static strings', () => { + const segments = [{ str: 'hello' }, { str: ' world' }] + expect(templateRequiresEvaluation(segments)).toBe(false) + }) + + it('should return true for segments with DSL expressions', () => { + const segments = [{ str: 'Value: ' }, { dsl: 'x', json: { ref: 'x' } }] + expect(templateRequiresEvaluation(segments)).toBe(true) + }) + + it('should return true if any segment has DSL', () => { + const segments = [{ str: 'hello' }, { dsl: 'x', json: { ref: 'x' } }, { str: 'world' }] + expect(templateRequiresEvaluation(segments)).toBe(true) + }) + }) + + describe('compileSegments', () => { + it('should compile static string segments', () => { + const segments = [{ str: 'hello' }, { str: ' world' }] + const result = compileSegments(segments) + + expect(result).toBe('["hello"," world"]') + }) + + it('should compile DSL expression segments', () => { + const segments = [{ str: 'Value: ' }, { dsl: 'x', json: { ref: 'x' } }] + const result = compileSegments(segments) + + expect(result).toContain('(() => {') + expect(result).toContain('try {') + expect(result).toContain('catch (e) {') + }) + + it('should compile mixed static and dynamic segments', () => { + const segments = [ + { str: 'x=' }, + { dsl: 'x', json: { ref: 'x' } }, + { str: ', y=' }, + { dsl: 'y', json: { ref: 'y' } }, + ] + const result = compileSegments(segments) + + expect(result).toContain('"x="') + expect(result).toContain('(() => {') + expect(result).toContain('", y="') + }) + + it('should handle errors in DSL evaluation', () => { + const segments = [{ dsl: 'badExpr', json: { ref: 'nonExistent' } }] + const code = compileSegments(segments) + + // The compiled code should have error handling + expect(code).toContain('catch (e)') + expect(code).toContain('message') + }) + }) + + describe('browserInspect', () => { + it('should inspect null', () => { + expect(browserInspect(null)).toBe('null') + }) + + it('should inspect undefined', () => { + expect(browserInspect(undefined)).toBe('undefined') + }) + + it('should inspect strings', () => { + expect(browserInspect('hello')).toBe('hello') + }) + + it('should inspect numbers', () => { + expect(browserInspect(42)).toBe('42') + expect(browserInspect(3.14)).toBe('3.14') + }) + + it('should inspect booleans', () => { + expect(browserInspect(true)).toBe('true') + expect(browserInspect(false)).toBe('false') + }) + + it('should inspect bigint', () => { + if (typeof BigInt === 'undefined') { + pending('BigInt is not supported in this browser') + return + } + expect(browserInspect(BigInt(123))).toBe('123n') + }) + + it('should inspect symbols', () => { + const sym = Symbol('test') + expect(browserInspect(sym)).toContain('Symbol(test)') + }) + + it('should inspect functions', () => { + function myFunc() {} // eslint-disable-line @typescript-eslint/no-empty-function + const result = browserInspect(myFunc) + expect(result).toBe('[Function: myFunc]') + }) + + it('should inspect anonymous functions', () => { + const result = browserInspect(() => {}) // eslint-disable-line @typescript-eslint/no-empty-function + expect(result).toContain('[Function:') + }) + + it('should inspect plain objects', () => { + const result = browserInspect({ a: 1, b: 2 }) + expect(result).toBe('{"a":1,"b":2}') + }) + + it('should inspect arrays', () => { + const result = browserInspect([1, 2, 3]) + expect(result).toBe('[1,2,3]') + }) + + it('should handle circular references gracefully', () => { + const obj: any = { name: 'test' } + obj.self = obj + const result = browserInspect(obj) + // Should either return [Object] or handle the error + expect(result).toBeTruthy() + }) + + it('should handle objects without constructor', () => { + const obj = Object.create(null) + const result = browserInspect(obj) + expect(result).toBe('{}') + }) + + describe('limits', () => { + describe('maxStringLength (8KB)', () => { + it('should truncate very long strings', () => { + const longString = 'a'.repeat(10000) + const result = browserInspect(longString) + expect(result).toBe(`${'a'.repeat(8192)}…`) + }) + + it('should not truncate strings shorter than 8KB', () => { + const shortString = 'a'.repeat(100) + const result = browserInspect(shortString) + expect(result).toBe(shortString) + }) + }) + + describe('maxArrayLength (3)', () => { + it('should truncate arrays longer than 3 elements', () => { + const longArray = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + const result = browserInspect(longArray) + expect(result).toBe('[1,2,3, ... 7 more items]') + }) + + it('should not truncate arrays with 3 or fewer elements', () => { + const shortArray = [1, 2, 3] + const result = browserInspect(shortArray) + expect(result).toBe('[1,2,3]') + }) + + it('should handle empty arrays', () => { + const result = browserInspect([]) + expect(result).toBe('[]') + }) + }) + + describe('depth (0)', () => { + it('should fully stringify plain objects (depth limit applies to arrays)', () => { + const nested = { a: { b: { c: { d: 'deep' } } } } + const result = browserInspect(nested) + // Objects are fully stringified via JSON.stringify + expect(result).toBe('{"a":{"b":{"c":{"d":"deep"}}}}') + }) + + it('should show root array but collapse nested arrays at depth 0', () => { + const nested = [[['deep']]] + const result = browserInspect(nested) + expect(result).toBe('[[Array]]') + }) + + it('should show array structure but collapse nested objects in arrays', () => { + const nested = [{ a: 1 }, { b: 2 }, { c: 3 }] + const result = browserInspect(nested) + expect(result).toBe('[[Object],[Object],[Object]]') + }) + }) + + describe('combined limits', () => { + it('should apply both maxStringLength and maxArrayLength', () => { + const data = ['a'.repeat(10000), 'b'.repeat(10000), 'c'.repeat(10000), 'd'.repeat(10000)] + const result = browserInspect(data) + expect(result).toContain(`${'a'.repeat(8192)}…`) + expect(result).toContain(`${'b'.repeat(8192)}…`) + expect(result).toContain(`${'c'.repeat(8192)}…`) + expect(result).toContain('1 more items') + }) + + it('should respect depth with maxArrayLength', () => { + const nested = [{ a: 1 }, { b: 2 }, { c: 3 }, { d: 4 }] + const result = browserInspect(nested) + expect(result).toBe('[[Object],[Object],[Object], ... 1 more items]') + }) + }) + }) + }) + + describe('evaluateProbeMessage', () => { + it('should return static template string when no evaluation needed', () => { + const probe: any = { + templateRequiresEvaluation: false, + template: 'Static message', + } + const result = evaluateProbeMessage(probe, {}) + expect(result).toBe('Static message') + }) + + it('should evaluate template with simple expressions', () => { + const template: any = { + createFunction: (keys: string[]) => { + expect(keys).toEqual(['x', 'y']) + // eslint-disable-next-line camelcase, @typescript-eslint/no-unused-vars + return ($dd_inspect: any, x: number, y: number) => [`x=${x}, y=${y}`] + }, + } + + const probe: any = { + templateRequiresEvaluation: true, + template, + } + + const result = evaluateProbeMessage(probe, { x: 10, y: 20 }) + expect(result).toBe('x=10, y=20') + }) + + it('should handle segments with static and dynamic parts', () => { + const template: any = { + createFunction: + (keys: string[]) => + // eslint-disable-next-line camelcase, @typescript-eslint/no-unused-vars + ($dd_inspect: any, ...values: any[]) => { + const context: any = {} + keys.forEach((key, i) => { + context[key] = values[i] + }) + return ['Value: ', String(context.value)] + }, + } + + const probe: any = { + templateRequiresEvaluation: true, + template, + } + + const result = evaluateProbeMessage(probe, { value: 42 }) + expect(result).toBe('Value: 42') + }) + + it('should handle error objects in segments', () => { + const template: any = { + createFunction: () => () => [{ expr: 'bad.expr', message: 'TypeError: Cannot read property' }], + } + + const probe: any = { + templateRequiresEvaluation: true, + template, + } + + const result = evaluateProbeMessage(probe, {}) + expect(result).toBe('{TypeError: Cannot read property}') + }) + + it('should handle non-string segments', () => { + const template: any = { + createFunction: () => () => [42, ' ', true, ' ', null], + } + + const probe: any = { + templateRequiresEvaluation: true, + template, + } + + const result = evaluateProbeMessage(probe, {}) + expect(result).toBe('42 true null') + }) + + it('should handle templates with this context', () => { + const segments = [ + { str: 'Method called on ' }, + { dsl: 'this.name', json: { getmember: [{ ref: 'this' }, 'name'] } }, + { str: ' with arg=' }, + { dsl: 'a', json: { ref: 'a' } }, + ] + + // Compile the segments like initializeProbe does + const segmentsCode = compileSegments(segments) + const fnBodyTemplate = `return ${segmentsCode};` + const functionCache = new Map any[]>() + + const template = { + createFunction: (contextKeys: string[]) => { + const cacheKey = contextKeys.join(',') + let fn = functionCache.get(cacheKey) + if (!fn) { + // eslint-disable-next-line no-new-func, @typescript-eslint/no-implied-eval + fn = new Function('$dd_inspect', ...contextKeys, fnBodyTemplate) as (...args: any[]) => any[] + functionCache.set(cacheKey, fn) + } + return fn + }, + } + + const probe: any = { + templateRequiresEvaluation: true, + template, + } + + const context = { + this: { name: 'MyClass' }, + a: 42, + } + + const result = evaluateProbeMessage(probe, context) + expect(result).toBe('Method called on MyClass with arg=42') + }) + + it('should handle templates without this context', () => { + const segments = [{ str: 'Simple message with ' }, { dsl: 'a', json: { ref: 'a' } }] + + // Compile the segments like initializeProbe does + const segmentsCode = compileSegments(segments) + const fnBodyTemplate = `return ${segmentsCode};` + const functionCache = new Map any[]>() + + const template = { + createFunction: (contextKeys: string[]) => { + const cacheKey = contextKeys.join(',') + let fn = functionCache.get(cacheKey) + if (!fn) { + // eslint-disable-next-line no-new-func, @typescript-eslint/no-implied-eval + fn = new Function('$dd_inspect', ...contextKeys, fnBodyTemplate) as (...args: any[]) => any[] + functionCache.set(cacheKey, fn) + } + return fn + }, + } + + const probe: any = { + templateRequiresEvaluation: true, + template, + } + + // Context without 'this' + const context = { + a: 42, + } + + const result = evaluateProbeMessage(probe, context) + expect(result).toBe('Simple message with 42') + }) + + it('should handle template evaluation errors', () => { + const template: any = { + createFunction: () => () => { + throw new Error('Evaluation failed') + }, + } + + const probe: any = { + templateRequiresEvaluation: true, + template, + } + + const result = evaluateProbeMessage(probe, {}) + expect(result).toBe('{Error: Evaluation failed}') + }) + + it('should truncate long messages', () => { + const longMessage = 'a'.repeat(10000) + const template: any = { + createFunction: () => () => [longMessage], + } + + const probe: any = { + templateRequiresEvaluation: true, + template, + } + + const result = evaluateProbeMessage(probe, {}) + expect(result.length).toBeLessThanOrEqual(8192 + 1) // 8KB + ellipsis + expect(result).toContain('…') + }) + + it('should use browserInspect for object values', () => { + const template: any = { + // eslint-disable-next-line camelcase + createFunction: () => ($dd_inspect: (value: any, options: any) => string, obj: any) => [ + 'Object: ', + $dd_inspect(obj, {}), + ], + } + + const probe: any = { + templateRequiresEvaluation: true, + template, + } + + const result = evaluateProbeMessage(probe, { obj: { a: 1, b: 2 } }) + expect(result).toBe('Object: {"a":1,"b":2}') + }) + }) + + describe('integration', () => { + it('should compile and evaluate complete template', () => { + const segments = [ + { str: 'User ' }, + { dsl: 'name', json: { ref: 'name' } }, + { str: ' has ' }, + { dsl: 'count', json: { ref: 'count' } }, + { str: ' items' }, + ] + + const compiledCode = compileSegments(segments) + expect(compiledCode).toBeTruthy() + + // The compiled code would be used to create a function + // This demonstrates the flow even though the actual function creation + // happens in the probe initialization + }) + }) +}) diff --git a/packages/debugger/src/domain/template.ts b/packages/debugger/src/domain/template.ts new file mode 100644 index 0000000000..783ab43f94 --- /dev/null +++ b/packages/debugger/src/domain/template.ts @@ -0,0 +1,248 @@ +/** + * Template compilation and evaluation utilities for Live Debugger SDK. + */ + +import { compile } from './expression' + +const MAX_MESSAGE_LENGTH = 8 * 1024 // 8KB + +export interface TemplateSegment { + str?: string + dsl?: string + json?: any +} + +export interface CompiledTemplate { + createFunction: (keys: string[]) => (...args: any[]) => any[] + clearCache?: () => void +} + +// Options for browserInspect - controls how values are stringified +const INSPECT_MAX_ARRAY_LENGTH = 3 +const INSPECT_MAX_STRING_LENGTH = 8 * 1024 // 8KB + +/** + * Check if template segments require runtime evaluation + * + * @param segments - Array of segment objects + * @returns True if segments contain expressions to evaluate + */ +export function templateRequiresEvaluation(segments: TemplateSegment[] | undefined): boolean { + if (segments === undefined) { + return false + } + for (const { dsl } of segments) { + if (dsl !== undefined) { + return true + } + } + return false +} + +/** + * Compile template segments into executable code + * + * @param segments - Array of segment objects with str (static) or dsl/json (dynamic) + * @returns Compiled JavaScript code that returns an array + */ +export function compileSegments(segments: TemplateSegment[]): string { + let segmentsCode = '[' + for (let i = 0; i < segments.length; i++) { + const { str, dsl, json } = segments[i] + segmentsCode += + str === undefined + ? `(() => { + try { + const result = ${compile(json)} + return typeof result === 'string' ? result : $dd_inspect(result) + } catch (e) { + return { expr: ${JSON.stringify(dsl)}, message: \`\${e.name}: \${e.message}\` } + } + })()` + : JSON.stringify(str) + if (i !== segments.length - 1) { + segmentsCode += ',' + } + } + segmentsCode += ']' + + // Return the compiled array code (not the function yet - that's done with context) + return segmentsCode +} + +/** + * Browser-compatible inspect function for template segment evaluation + * + * @param value - Value to inspect + * @returns String representation of the value + */ +// TODO: Should we use a 3rd party library instead of implementing our own? +export function browserInspect(value: unknown): string { + return browserInspectInternal(value) +} + +function browserInspectInternal(value: unknown, depthExceeded: boolean = false): string { + if (value === null) { + return 'null' + } + if (value === undefined) { + return 'undefined' + } + + if (typeof value === 'string') { + if (value.length > INSPECT_MAX_STRING_LENGTH) { + return `${value.slice(0, INSPECT_MAX_STRING_LENGTH)}…` + } + return value + } + + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value) + } + if (typeof value === 'bigint') { + return `${value}n` + } + if (typeof value === 'symbol') { + return value.toString() + } + if (typeof value === 'function') { + return `[Function: ${value.name || 'anonymous'}]` + } + + // Handle arrays + if (Array.isArray(value)) { + // Special case: if depth is exceeded AND the array contains arrays, collapse entirely + if (depthExceeded && value.length > 0 && Array.isArray(value[0])) { + return '[Array]' + } + + if (value.length > INSPECT_MAX_ARRAY_LENGTH) { + const truncated = value.slice(0, INSPECT_MAX_ARRAY_LENGTH) + const remaining = value.length - INSPECT_MAX_ARRAY_LENGTH + const items = truncated.map((item) => inspectValueInternal(item, true)).join(',') + return `[${items}, ... ${remaining} more items]` + } + // Recursively inspect array items with increased depth + const items = value.map((item) => inspectValueInternal(item, true)).join(',') + return `[${items}]` + } + + // Handle objects + if (depthExceeded) { + return '[Object]' + } + + try { + // Create custom replacer to handle maxStringLength in nested values + const replacer = (_key: string, val: unknown) => { + if (typeof val === 'string' && val.length > INSPECT_MAX_STRING_LENGTH) { + return `${val.slice(0, INSPECT_MAX_STRING_LENGTH)}…` + } + return val + } + return JSON.stringify(value, replacer, 0) + } catch { + return `[${(value as any).constructor?.name || 'Object'}]` + } +} + +/** + * Helper function to inspect a value + * Used for recursive inspection of array/object elements + */ +function inspectValueInternal(value: unknown, depthExceeded: boolean = false): string { + if (value === null) { + return 'null' + } + if (value === undefined) { + return 'undefined' + } + if (typeof value === 'string') { + // For nested strings in arrays, we need to quote them like JSON + const str = value.length > INSPECT_MAX_STRING_LENGTH ? `${value.slice(0, INSPECT_MAX_STRING_LENGTH)}…` : value + return JSON.stringify(str) + } + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value) + } + if (typeof value === 'bigint') { + return `${value}n` + } + + // For nested objects/arrays, check depth + if (depthExceeded) { + if (Array.isArray(value)) { + return '[Array]' + } + if (typeof value === 'object') { + return '[Object]' + } + } + + // Recursively inspect with browserInspectInternal + return browserInspectInternal(value, true) +} + +/** + * Evaluate compiled template with runtime context + * + * @param compiledTemplate - Template object with createFunction factory + * @param context - Runtime context with variables + * @returns Array of segment results (strings or error objects) + */ +function evalCompiledTemplate(compiledTemplate: CompiledTemplate, context: Record): any[] { + // Separate 'this' from other context variables + const { this: thisValue, ...otherContext } = context + const contextKeys = Object.keys(otherContext) + const contextValues = Object.values(otherContext) + + // Create function with dynamic parameters (function body was pre-built during initialization) + const fn = compiledTemplate.createFunction(contextKeys) + + // Execute with browserInspect and context values, binding 'this' context + return fn.call(thisValue, browserInspect, ...contextValues) +} + +export interface ProbeWithTemplate { + templateRequiresEvaluation: boolean + template: CompiledTemplate | string +} + +/** + * Evaluate probe message from template and runtime result + * + * @param probe - Probe configuration + * @param context - Runtime execution context + * @returns Evaluated and truncated message + */ +export function evaluateProbeMessage(probe: ProbeWithTemplate, context: Record): string { + let message = '' + + if (probe.templateRequiresEvaluation) { + try { + const segments = evalCompiledTemplate(probe.template as CompiledTemplate, context) + message = segments + .map((seg) => { + if (typeof seg === 'string') { + return seg + } else if (seg && typeof seg === 'object' && seg.expr) { + // Error object from template evaluation + return `{${seg.message}}` + } + return String(seg) + }) + .join('') + } catch (e) { + message = `{Error: ${(e as Error).message}}` + } + } else { + message = probe.template as string + } + + // Truncate message if it exceeds maximum length + if (message.length > MAX_MESSAGE_LENGTH) { + message = `${message.slice(0, MAX_MESSAGE_LENGTH)}…` + } + + return message +} diff --git a/packages/debugger/src/entries/main.spec.ts b/packages/debugger/src/entries/main.spec.ts new file mode 100644 index 0000000000..c75239d842 --- /dev/null +++ b/packages/debugger/src/entries/main.spec.ts @@ -0,0 +1,11 @@ +import { datadogDebugger } from './main' + +describe('datadogDebugger', () => { + it('should only expose init, version, and onReady', () => { + expect(datadogDebugger).toEqual({ + init: jasmine.any(Function), + version: jasmine.any(String), + onReady: jasmine.any(Function), + }) + }) +}) diff --git a/packages/debugger/src/entries/main.ts b/packages/debugger/src/entries/main.ts new file mode 100644 index 0000000000..4862c28030 --- /dev/null +++ b/packages/debugger/src/entries/main.ts @@ -0,0 +1,130 @@ +/** + * Datadog Browser Live Debugger SDK + * Provides live debugger capabilities for browser applications. + * + * @packageDocumentation + * @see [Live Debugger Documentation](https://docs.datadoghq.com/tracing/live_debugger/) + */ + +import { defineGlobal, getGlobalObject, makePublicApi } from '@datadog/browser-core' +import type { PublicApi, Site } from '@datadog/browser-core' +import { onEntry, onReturn, onThrow, initDebuggerTransport } from '../domain/api' +import { startDeliveryApiPolling } from '../domain/deliveryApi' +import { getProbes } from '../domain/probes' +import { startDebuggerBatch } from '../transport/startDebuggerBatch' + +/** + * Configuration options for initializing the Live Debugger SDK + */ +export interface DebuggerInitConfiguration { + /** + * The client token for Datadog. Required for authenticating your application with Datadog. + * + * @category Authentication + */ + clientToken: string + + /** + * The Datadog site to send data to + * + * @category Transport + * @defaultValue 'datadoghq.com' + */ + site?: Site + + /** + * The service name for your application + * + * @category Data Collection + */ + service: string + + /** + * The application's environment (e.g., prod, staging) + * + * @category Data Collection + */ + env?: string + + /** + * The application's version + * + * @category Data Collection + */ + version?: string + + /** + * Polling interval in milliseconds for fetching probe updates + * + * @category Delivery API + * @defaultValue 60000 + */ + pollInterval?: number +} + +/** + * Public API for the Live Debugger browser SDK. + * + * @category Main + */ +export interface DebuggerPublicApi extends PublicApi { + /** + * Initialize the Live Debugger SDK + * + * @category Init + * @param initConfiguration - Configuration options + * @example + * ```ts + * datadogDebugger.init({ + * clientToken: '', + * service: 'my-app', + * site: 'datadoghq.com', + * env: 'production' + * }) + * ``` + */ + init: (initConfiguration: DebuggerInitConfiguration) => void +} + +/** + * Create the public API for the Live Debugger + */ +function makeDebuggerPublicApi(): DebuggerPublicApi { + return makePublicApi({ + init: (initConfiguration: DebuggerInitConfiguration) => { + // Initialize debugger's own transport + const batch = startDebuggerBatch(initConfiguration) + initDebuggerTransport(initConfiguration, batch) + + // Expose internal hooks on globalThis for instrumented code + if (typeof globalThis !== 'undefined') { + ;(globalThis as any).$dd_entry = onEntry + ;(globalThis as any).$dd_return = onReturn + ;(globalThis as any).$dd_throw = onThrow + ;(globalThis as any).$dd_probes = getProbes + } + + startDeliveryApiPolling({ + service: initConfiguration.service, + env: initConfiguration.env, + version: initConfiguration.version, + pollInterval: initConfiguration.pollInterval, + }) + }, + }) +} + +/** + * The global Live Debugger instance. Use this to call Live Debugger methods. + * + * @category Main + * @see {@link DebuggerPublicApi} + * @see [Live Debugger Documentation](https://docs.datadoghq.com/tracing/live_debugger/) + */ +export const datadogDebugger = makeDebuggerPublicApi() + +export interface BrowserWindow extends Window { + DD_DEBUGGER?: DebuggerPublicApi +} + +defineGlobal(getGlobalObject(), 'DD_DEBUGGER', datadogDebugger) diff --git a/packages/debugger/src/transport/startDebuggerBatch.ts b/packages/debugger/src/transport/startDebuggerBatch.ts new file mode 100644 index 0000000000..ce4517f10e --- /dev/null +++ b/packages/debugger/src/transport/startDebuggerBatch.ts @@ -0,0 +1,53 @@ +import type { InitConfiguration, PageMayExitEvent, Batch } from '@datadog/browser-core' +import { + addEventListener, + createBatch, + createFlushController, + createHttpRequest, + createIdentityEncoder, + computeTransportConfiguration, + Observable, + PageExitReason, + display, +} from '@datadog/browser-core' + +export function startDebuggerBatch(initConfiguration: InitConfiguration): Batch { + const { debuggerEndpointBuilder } = computeTransportConfiguration({ ...initConfiguration, source: 'dd_debugger' }) + + const batch = createBatch({ + encoder: createIdentityEncoder(), + request: createHttpRequest([debuggerEndpointBuilder], (error) => display.error('Debugger transport error:', error)), + flushController: createFlushController({ + pageMayExitObservable: createSimplePageMayExitObservable(), + sessionExpireObservable: new Observable(), + }), + }) + + return batch +} + +function createSimplePageMayExitObservable(): Observable { + return new Observable((observable) => { + if (typeof window === 'undefined') { + return + } + + const onVisibilityChange = () => { + if (document.visibilityState === 'hidden') { + observable.notify({ reason: PageExitReason.HIDDEN }) + } + } + + const onBeforeUnload = () => { + observable.notify({ reason: PageExitReason.UNLOADING }) + } + + const visibilityListener = addEventListener({}, window, 'visibilitychange', onVisibilityChange, { capture: true }) + const unloadListener = addEventListener({}, window, 'beforeunload', onBeforeUnload) + + return () => { + visibilityListener.stop() + unloadListener.stop() + } + }) +} diff --git a/packages/debugger/test/expressionTestCases.ts b/packages/debugger/test/expressionTestCases.ts new file mode 100644 index 0000000000..b18becbf0c --- /dev/null +++ b/packages/debugger/test/expressionTestCases.ts @@ -0,0 +1,820 @@ +/** + * Test case definitions for the expression compiler. + * Adapted from dd-trace-js/packages/dd-trace/test/debugger/devtools_client/condition-test-cases.js + */ + +import type { ExpressionNode } from '../src/domain/expression' + +export type VariableBindings = Record + +export type TestCaseTuple = [ExpressionNode, VariableBindings, unknown] +export interface TestCaseObject { + ast: ExpressionNode + vars?: VariableBindings + expected?: unknown + execute?: boolean + before?: () => void + suffix?: string +} +export type TestCase = TestCaseTuple | TestCaseObject + +class CustomObject {} +class HasInstanceSideEffect { + static [Symbol.hasInstance](): boolean { + throw new Error('This should never throw!') + } +} +const weakKey = { weak: 'key' } +const objectWithToPrimitiveSymbol = Object.create(Object.prototype, { + [Symbol.toPrimitive]: { + value: () => { + throw new Error('This should never throw!') + }, + }, +}) +class EvilRegex extends RegExp { + exec(_string: string): RegExpExecArray | null { + throw new Error('This should never throw!') + } +} + +export const literals: TestCase[] = [ + [null, {}, null], + [42, {}, 42], + [true, {}, true], + ['foo', {}, 'foo'], +] + +export const references: TestCase[] = [ + [{ ref: 'foo' }, { foo: 42 }, 42], + [{ ref: 'foo' }, {}, new ReferenceError('foo is not defined')], + + // Reserved words, but we allow them as they can be useful + [{ ref: 'this' }, {}, globalThis], // Unless bound, `this` defaults to the global object + { ast: { ref: 'super' }, expected: 'super', execute: false }, + + // Literals, but we allow them as they can be useful + [{ ref: 'undefined' }, {}, undefined], + [{ ref: 'Infinity' }, {}, Infinity], + + // Old standard reserved words, no need to disallow them + [{ ref: 'abstract' }, { abstract: 42 }, 42], + + // Input sanitization + { + ast: { ref: 'break' }, + vars: { foo: { bar: 42 } }, + expected: new SyntaxError('Illegal identifier: break'), + execute: false, + }, + { + ast: { ref: 'let' }, + vars: { foo: { bar: 42 } }, + expected: new SyntaxError('Illegal identifier: let'), + execute: false, + }, + { + ast: { ref: 'await' }, + vars: { foo: { bar: 42 } }, + expected: new SyntaxError('Illegal identifier: await'), + execute: false, + }, + { + ast: { ref: 'enum' }, + vars: { foo: { bar: 42 } }, + expected: new SyntaxError('Illegal identifier: enum'), + execute: false, + }, + { + ast: { ref: 'implements' }, + vars: { foo: { bar: 42 } }, + expected: new SyntaxError('Illegal identifier: implements'), + execute: false, + }, + { ast: { ref: 'NaN' }, expected: new SyntaxError('Illegal identifier: NaN'), execute: false }, + { + ast: { ref: 'foo.bar' }, + vars: { foo: { bar: 42 } }, + expected: new SyntaxError('Illegal identifier: foo.bar'), + execute: false, + }, + { + ast: { ref: 'foo()' }, + vars: { foo: () => undefined }, + expected: new SyntaxError('Illegal identifier: foo()'), + execute: false, + }, + { + ast: { ref: 'foo; bar' }, + vars: { foo: 1, bar: 2 }, + expected: new SyntaxError('Illegal identifier: foo; bar'), + execute: false, + }, + { + ast: { ref: 'foo\nbar' }, + vars: { foo: 1, bar: 2 }, + expected: new SyntaxError('Illegal identifier: foo\nbar'), + execute: false, + }, + { + ast: { ref: 'throw new Error()' }, + expected: new SyntaxError('Illegal identifier: throw new Error()'), + execute: false, + }, +] + +export const propertyAccess: TestCase[] = [ + [{ getmember: [{ ref: 'obj' }, 'foo'] }, { obj: { foo: 'test-me' } }, 'test-me'], + [{ getmember: [{ getmember: [{ ref: 'obj' }, 'foo'] }, 'bar'] }, { obj: { foo: { bar: 'test-me' } } }, 'test-me'], + [ + { getmember: [{ ref: 'set' }, 'foo'] }, + { set: new Set(['foo', 'bar']) }, + new Error('Accessing a Set or WeakSet is not allowed'), + ], + [ + { getmember: [{ ref: 'wset' }, { ref: 'key' }] }, + { key: weakKey, wset: new WeakSet([weakKey]) }, + new Error('Accessing a Set or WeakSet is not allowed'), + ], + [ + { getmember: [{ ref: 'map' }, 'foo'] }, + { map: new Map([['foo', 'bar']]) }, + new Error('Accessing a Map is not allowed'), + ], + [ + { getmember: [{ ref: 'wmap' }, { ref: 'key' }] }, + { key: weakKey, wmap: new WeakMap([[weakKey, 'bar']]) }, + new Error('Accessing a WeakMap is not allowed'), + ], + [ + { getmember: [{ ref: 'obj' }, 'getter'] }, + { + obj: Object.create(Object.prototype, { + getter: { + get() { + return 'x' + }, + }, + }), + }, + new Error('Possibility of side effect'), + ], + + [{ index: [{ ref: 'arr' }, 1] }, { arr: ['foo', 'bar'] }, 'bar'], + [{ index: [{ ref: 'arr' }, 100] }, { arr: ['foo', 'bar'] }, undefined], // Should throw according to spec + [{ index: [{ ref: 'obj' }, 'foo'] }, { obj: { foo: 'bar' } }, 'bar'], + [{ index: [{ ref: 'obj' }, 'bar'] }, { obj: { foo: 'bar' } }, undefined], // Should throw according to spec + [ + { index: [{ ref: 'set' }, 'foo'] }, + { set: new Set(['foo']) }, + new Error('Accessing a Set or WeakSet is not allowed'), + ], + [ + { index: [{ ref: 'set' }, 'bar'] }, + { set: new Set(['foo']) }, + new Error('Accessing a Set or WeakSet is not allowed'), + ], + [{ index: [{ ref: 'map' }, 'foo'] }, { map: new Map([['foo', 'bar']]) }, 'bar'], + [{ index: [{ ref: 'map' }, 'bar'] }, { map: new Map([['foo', 'bar']]) }, undefined], // Should throw according to spec + [{ index: [{ ref: 'wmap' }, { ref: 'key' }] }, { key: weakKey, wmap: new WeakMap([[weakKey, 'bar']]) }, 'bar'], + [ + { index: [{ ref: 'wmap' }, { ref: 'key' }] }, + { key: {}, wmap: new WeakMap([[weakKey, 'bar']]) }, + undefined, // Should throw according to spec + ], + [ + { index: [{ ref: 'set' }, { ref: 'key' }] }, + { key: weakKey, set: new WeakSet([weakKey]) }, + new Error('Accessing a Set or WeakSet is not allowed'), + ], + [ + { index: [{ ref: 'set' }, { ref: 'key' }] }, + { key: {}, set: new WeakSet([weakKey]) }, + new Error('Accessing a Set or WeakSet is not allowed'), + ], + [ + { index: [{ ref: 'obj' }, 'getter'] }, + { + obj: Object.create(Object.prototype, { + getter: { + get() { + return 'x' + }, + }, + }), + }, + new Error('Possibility of side effect'), + ], +] + +export const sizes: TestCase[] = [ + [{ len: { ref: 'str' } }, { str: 'hello' }, 5], + [{ len: { ref: 'str' } }, { str: String('hello') }, 5], + [{ len: { ref: 'str' } }, { str: new String('hello') }, 5], // eslint-disable-line no-new-wrappers + [{ len: { ref: 'arr' } }, { arr: [1, 2, 3] }, 3], + [{ len: { ref: 'set' } }, { set: new Set([1, 2]) }, 2], + [ + { len: { ref: 'set' } }, + { set: overloadPropertyWithGetter(new Set([1, 2]), 'size') }, + new Error('Possibility of side effect'), + ], + [{ len: { ref: 'map' } }, { map: new Map([[1, 2]]) }, 1], + [ + { len: { ref: 'map' } }, + { map: overloadPropertyWithGetter(new Map([[1, 2]]), 'size') }, + new Error('Possibility of side effect'), + ], + [{ len: { ref: 'wset' } }, { wset: new WeakSet([weakKey]) }, new TypeError('Cannot get size of WeakSet or WeakMap')], + [ + { len: { ref: 'wmap' } }, + { wmap: new WeakMap([[weakKey, 2]]) }, + new TypeError('Cannot get size of WeakSet or WeakMap'), + ], + [{ len: { getmember: [{ ref: 'obj' }, 'arr'] } }, { obj: { arr: Array(10).fill(0) } }, 10], + [{ len: { getmember: [{ ref: 'obj' }, 'tarr'] } }, { obj: { tarr: new Int16Array([10, 20, 30]) } }, 3], + [ + { len: { getmember: [{ ref: 'obj' }, 'tarr'] } }, + { obj: { tarr: overloadPropertyWithGetter(new Int16Array([10, 20, 30]), 'length') } }, + new Error('Possibility of side effect'), + ], + [{ len: { ref: 'pojo' } }, { pojo: { a: 1, b: 2, c: 3 } }, 3], + [ + { len: { getmember: [{ ref: 'obj' }, 'unknownProp'] } }, + { obj: {} }, + new TypeError('Cannot get length of variable'), + ], + [{ len: { ref: 'invalid' } }, {}, new ReferenceError('invalid is not defined')], + + // `count` should be implemented as a synonym for `len`, so we shouldn't need to test it as thoroughly + [{ count: { ref: 'str' } }, { str: 'hello' }, 5], + [{ count: { ref: 'arr' } }, { arr: [1, 2, 3] }, 3], + + [{ isEmpty: { ref: 'str' } }, { str: '' }, true], + [{ isEmpty: { ref: 'str' } }, { str: 'hello' }, false], + [{ isEmpty: { ref: 'str' } }, { str: String('') }, true], + [{ isEmpty: { ref: 'str' } }, { str: String('hello') }, false], + [{ isEmpty: { ref: 'str' } }, { str: new String('') }, true], // eslint-disable-line no-new-wrappers + [{ isEmpty: { ref: 'str' } }, { str: new String('hello') }, false], // eslint-disable-line no-new-wrappers + [{ isEmpty: { ref: 'arr' } }, { arr: [] }, true], + [{ isEmpty: { ref: 'arr' } }, { arr: [1, 2, 3] }, false], + [{ isEmpty: { ref: 'tarr' } }, { tarr: new Int32Array(0) }, true], + [{ isEmpty: { ref: 'tarr' } }, { tarr: new Int32Array([1, 2, 3]) }, false], + [{ isEmpty: { ref: 'set' } }, { set: new Set() }, true], + [{ isEmpty: { ref: 'set' } }, { set: new Set([1, 2, 3]) }, false], + [{ isEmpty: { ref: 'map' } }, { map: new Map() }, true], + [ + { isEmpty: { ref: 'map' } }, + { + map: new Map([ + ['a', 1], + ['b', 2], + ]), + }, + false, + ], + [{ isEmpty: { ref: 'obj' } }, { obj: new WeakSet() }, new TypeError('Cannot get size of WeakSet or WeakMap')], +] + +export const equality: TestCase[] = [ + [{ eq: [{ ref: 'str' }, 'foo'] }, { str: 'foo' }, true], + [{ eq: [{ ref: 'str' }, 'foo'] }, { str: 'bar' }, false], + [{ eq: [{ ref: 'str' }, 'foo'] }, { str: String('foo') }, true], + [{ eq: [{ ref: 'str' }, 'foo'] }, { str: String('bar') }, false], + // TODO: Is this the expected behavior? + [{ eq: [{ ref: 'str' }, 'foo'] }, { str: new String('foo') }, false], // eslint-disable-line no-new-wrappers + [{ eq: [{ ref: 'bool' }, true] }, { bool: true }, true], + [{ eq: [{ ref: 'nil' }, null] }, { nil: null }, true], + [{ eq: [{ ref: 'foo' }, { ref: 'undefined' }] }, { foo: undefined }, true], + [{ eq: [{ ref: 'foo' }, { ref: 'undefined' }] }, { foo: null }, false], + [{ eq: [{ ref: 'nan' }, { ref: 'nan' }] }, { nan: NaN }, false], + [{ eq: [{ getmember: [{ ref: 'obj' }, 'foo'] }, { ref: 'undefined' }] }, { obj: { foo: undefined } }, true], + [{ eq: [{ getmember: [{ ref: 'obj' }, 'foo'] }, { ref: 'undefined' }] }, { obj: {} }, true], + [{ eq: [{ getmember: [{ ref: 'obj' }, 'foo'] }, { ref: 'undefined' }] }, { obj: { foo: null } }, false], + [{ eq: [{ or: [true, false] }, { and: [true, false] }] }, {}, false], + + [{ ne: [{ ref: 'str' }, 'foo'] }, { str: 'foo' }, false], + [{ ne: [{ ref: 'str' }, 'foo'] }, { str: 'bar' }, true], + [{ ne: [{ ref: 'str' }, 'foo'] }, { str: String('foo') }, false], + [{ ne: [{ ref: 'str' }, 'foo'] }, { str: String('bar') }, true], + // TODO: Is this the expected behavior? + [{ ne: [{ ref: 'str' }, 'foo'] }, { str: new String('foo') }, true], // eslint-disable-line no-new-wrappers + [{ ne: [{ ref: 'bool' }, true] }, { bool: true }, false], + [{ ne: [{ ref: 'nil' }, null] }, { nil: null }, false], + [{ ne: [{ or: [false, true] }, { and: [true, false] }] }, {}, true], + + [{ gt: [{ ref: 'num' }, 42] }, { num: 43 }, true], + [{ gt: [{ ref: 'num' }, 42] }, { num: 42 }, false], + [{ gt: [{ ref: 'num' }, 42] }, { num: 41 }, false], + [{ gt: [{ ref: 'str' }, 'a'] }, { str: 'b' }, true], + [{ gt: [{ ref: 'str' }, 'a'] }, { str: 'a' }, false], + [{ gt: [{ ref: 'str' }, 'b'] }, { str: 'a' }, false], + [{ gt: [{ or: [2, 0] }, { and: [1, 1] }] }, {}, true], + { ast: { gt: [1, 2] }, expected: '1 > 2', execute: false }, + [ + { gt: [{ ref: 'obj' }, 5] }, + { obj: objectWithToPrimitiveSymbol }, + new Error('Possibility of side effect due to coercion methods'), + ], + [ + { gt: [5, { ref: 'obj' }] }, + { obj: objectWithToPrimitiveSymbol }, + new Error('Possibility of side effect due to coercion methods'), + ], + [ + { gt: [{ ref: 'obj' }, 5] }, + { + obj: { + valueOf() { + throw new Error('This should never throw!') + }, + }, + }, + new Error('Possibility of side effect due to coercion methods'), + ], + [ + { gt: [5, { ref: 'obj' }] }, + { + obj: { + valueOf() { + throw new Error('This should never throw!') + }, + }, + }, + new Error('Possibility of side effect due to coercion methods'), + ], + [ + { gt: [{ ref: 'obj' }, 5] }, + { + obj: { + toString() { + throw new Error('This should never throw!') + }, + }, + }, + new Error('Possibility of side effect due to coercion methods'), + ], + [ + { gt: [5, { ref: 'obj' }] }, + { + obj: { + toString() { + throw new Error('This should never throw!') + }, + }, + }, + new Error('Possibility of side effect due to coercion methods'), + ], + + [{ ge: [{ ref: 'num' }, 42] }, { num: 43 }, true], + [{ ge: [{ ref: 'num' }, 42] }, { num: 42 }, true], + [{ ge: [{ ref: 'num' }, 42] }, { num: 41 }, false], + [{ ge: [{ ref: 'str' }, 'a'] }, { str: 'b' }, true], + [{ ge: [{ ref: 'str' }, 'a'] }, { str: 'a' }, true], + [{ ge: [{ ref: 'str' }, 'b'] }, { str: 'a' }, false], + [{ ge: [{ or: [1, 0] }, { and: [1, 2] }] }, {}, false], + { ast: { ge: [1, 2] }, expected: '1 >= 2', execute: false }, + [ + { ge: [{ ref: 'obj' }, 5] }, + { obj: objectWithToPrimitiveSymbol }, + new Error('Possibility of side effect due to coercion methods'), + ], + [ + { ge: [{ ref: 'obj' }, 5] }, + { + obj: { + valueOf() { + throw new Error('This should never throw!') + }, + }, + }, + new Error('Possibility of side effect due to coercion methods'), + ], + [ + { ge: [{ ref: 'obj' }, 5] }, + { + obj: { + toString() { + throw new Error('This should never throw!') + }, + }, + }, + new Error('Possibility of side effect due to coercion methods'), + ], + + [{ lt: [{ ref: 'num' }, 42] }, { num: 43 }, false], + [{ lt: [{ ref: 'num' }, 42] }, { num: 42 }, false], + [{ lt: [{ ref: 'num' }, 42] }, { num: 41 }, true], + [{ lt: [{ ref: 'str' }, 'a'] }, { str: 'b' }, false], + [{ lt: [{ ref: 'str' }, 'a'] }, { str: 'a' }, false], + [{ lt: [{ ref: 'str' }, 'b'] }, { str: 'a' }, true], + [{ lt: [{ or: [1, 0] }, { and: [1, 0] }] }, {}, false], + { ast: { lt: [1, 2] }, expected: '1 < 2', execute: false }, + [ + { lt: [{ ref: 'obj' }, 5] }, + { obj: objectWithToPrimitiveSymbol }, + new Error('Possibility of side effect due to coercion methods'), + ], + [ + { lt: [{ ref: 'obj' }, 5] }, + { + obj: { + valueOf() { + throw new Error('This should never throw!') + }, + }, + }, + new Error('Possibility of side effect due to coercion methods'), + ], + [ + { lt: [{ ref: 'obj' }, 5] }, + { + obj: { + toString() { + throw new Error('This should never throw!') + }, + }, + }, + new Error('Possibility of side effect due to coercion methods'), + ], + + [{ le: [{ ref: 'num' }, 42] }, { num: 43 }, false], + [{ le: [{ ref: 'num' }, 42] }, { num: 42 }, true], + [{ le: [{ ref: 'num' }, 42] }, { num: 41 }, true], + [{ le: [{ ref: 'str' }, 'a'] }, { str: 'b' }, false], + [{ le: [{ ref: 'str' }, 'a'] }, { str: 'a' }, true], + [{ le: [{ ref: 'str' }, 'b'] }, { str: 'a' }, true], + [{ le: [{ or: [2, 0] }, { and: [1, 1] }] }, {}, false], + { ast: { le: [1, 2] }, expected: '1 <= 2', execute: false }, + [ + { le: [{ ref: 'obj' }, 5] }, + { obj: objectWithToPrimitiveSymbol }, + new Error('Possibility of side effect due to coercion methods'), + ], + [ + { le: [{ ref: 'obj' }, 5] }, + { + obj: { + valueOf() { + throw new Error('This should never throw!') + }, + }, + }, + new Error('Possibility of side effect due to coercion methods'), + ], + [ + { le: [{ ref: 'obj' }, 5] }, + { + obj: { + toString() { + throw new Error('This should never throw!') + }, + }, + }, + new Error('Possibility of side effect due to coercion methods'), + ], +] + +export const stringManipulation: TestCase[] = [ + [{ substring: [{ ref: 'str' }, 4, 7] }, { str: 'hello world' }, 'hello world'.substring(4, 7)], + [{ substring: [{ ref: 'str' }, 4] }, { str: 'hello world' }, 'hello world'.substring(4)], + [{ substring: [{ ref: 'str' }, 4, 4] }, { str: 'hello world' }, 'hello world'.substring(4, 4)], + [{ substring: [{ ref: 'str' }, 7, 4] }, { str: 'hello world' }, 'hello world'.substring(7, 4)], + [{ substring: [{ ref: 'str' }, -1, 100] }, { str: 'hello world' }, 'hello world'.substring(-1, 100)], + [{ substring: [{ ref: 'invalid' }, 4, 7] }, { invalid: {} }, new TypeError('Variable is not a string')], + [{ substring: [{ ref: 'str' }, 4, 7] }, { str: String('hello world') }, 'hello world'.substring(4, 7)], + // eslint-disable-next-line no-new-wrappers + [{ substring: [{ ref: 'str' }, 4, 7] }, { str: new String('hello world') }, 'hello world'.substring(4, 7)], + [ + { substring: [{ ref: 'str' }, 4, 7] }, + { str: overloadMethod(new String('hello world'), 'substring') }, // eslint-disable-line no-new-wrappers + 'hello world'.substring(4, 7), + ], + [ + { substring: [{ ref: 'str' }, 4, 7] }, + { str: new (createClassWithOverloadedMethodInPrototypeChain(String, 'substring'))('hello world') }, + 'hello world'.substring(4, 7), + ], +] + +export const stringComparison: TestCase[] = [ + [{ startsWith: [{ ref: 'str' }, 'hello'] }, { str: 'hello world!' }, true], + [{ startsWith: [{ ref: 'str' }, 'world'] }, { str: 'hello world!' }, false], + [{ startsWith: [{ ref: 'str' }, { ref: 'prefix' }] }, { str: 'hello world!', prefix: 'hello' }, true], + [{ startsWith: [{ getmember: [{ ref: 'obj' }, 'str'] }, 'hello'] }, { obj: { str: 'hello world!' } }, true], + [{ startsWith: [{ ref: 'str' }, 'hello'] }, { str: String('hello world!') }, true], + [{ startsWith: [{ ref: 'str' }, 'world'] }, { str: String('hello world!') }, false], + // eslint-disable-next-line no-new-wrappers + [{ startsWith: [{ ref: 'str' }, 'hello'] }, { str: new String('hello world!') }, true], + // eslint-disable-next-line no-new-wrappers + [{ startsWith: [{ ref: 'str' }, 'world'] }, { str: new String('hello world!') }, false], + // eslint-disable-next-line no-new-wrappers + [{ startsWith: [{ ref: 'str' }, 'hello'] }, { str: overloadMethod(new String('hello world!'), 'startsWith') }, true], + [ + { startsWith: [{ ref: 'str' }, 'hello'] }, + { + str: Object.create({ + startsWith() { + throw new Error('This should never throw!') + }, + }), + }, + new TypeError('Variable is not a string'), + ], + [ + { startsWith: ['hello world!', { ref: 'str' }] }, + { + str: { + toString() { + throw new Error('This should never throw!') + }, + }, + }, + new TypeError('Variable is not a string'), + ], + [ + { startsWith: [{ ref: 'str' }, 'hello'] }, + { str: new (createClassWithOverloadedMethodInPrototypeChain(String, 'startsWith'))('hello world!') }, + true, + ], + + [{ endsWith: [{ ref: 'str' }, 'hello'] }, { str: 'hello world!' }, false], + [{ endsWith: [{ ref: 'str' }, 'world!'] }, { str: 'hello world!' }, true], + [{ endsWith: [{ ref: 'str' }, { ref: 'suffix' }] }, { str: 'hello world!', suffix: 'world!' }, true], + [{ endsWith: [{ getmember: [{ ref: 'obj' }, 'str'] }, 'world!'] }, { obj: { str: 'hello world!' } }, true], + [{ endsWith: [{ ref: 'str' }, 'hello'] }, { str: String('hello world!') }, false], + [{ endsWith: [{ ref: 'str' }, 'world!'] }, { str: String('hello world!') }, true], + // eslint-disable-next-line no-new-wrappers + [{ endsWith: [{ ref: 'str' }, 'hello'] }, { str: new String('hello world!') }, false], + // eslint-disable-next-line no-new-wrappers + [{ endsWith: [{ ref: 'str' }, 'world!'] }, { str: new String('hello world!') }, true], + // eslint-disable-next-line no-new-wrappers + [{ endsWith: [{ ref: 'str' }, 'world!'] }, { str: overloadMethod(new String('hello world!'), 'endsWith') }, true], + [ + { endsWith: [{ ref: 'str' }, 'hello'] }, + { + str: Object.create({ + endsWith() { + throw new Error('This should never throw!') + }, + }), + }, + new TypeError('Variable is not a string'), + ], + [ + { endsWith: ['hello world!', { ref: 'str' }] }, + { + str: { + toString() { + throw new Error('This should never throw!') + }, + }, + }, + new TypeError('Variable is not a string'), + ], + [ + { endsWith: [{ ref: 'str' }, 'world!'] }, + { str: new (createClassWithOverloadedMethodInPrototypeChain(String, 'endsWith'))('hello world!') }, + true, + ], +] + +/* eslint-disable id-denylist -- `any` and `all` are expression language operator names, not JS identifiers */ +export const logicalOperators: TestCase[] = [ + [{ any: [{ ref: 'arr' }, { isEmpty: { ref: '@it' } }] }, { arr: ['foo', 'bar', ''] }, true], + [{ any: [{ ref: 'arr' }, { isEmpty: { ref: '@it' } }] }, { arr: ['foo', 'bar', 'baz'] }, false], + [{ any: [{ ref: 'obj' }, { isEmpty: { ref: '@value' } }] }, { obj: { 0: 'foo', 1: 'bar', 2: '' } }, true], + [{ any: [{ ref: 'obj' }, { isEmpty: { ref: '@value' } }] }, { obj: { 0: 'foo', 1: 'bar', 2: 'baz' } }, false], + [{ any: [{ ref: 'obj' }, { isEmpty: { ref: '@key' } }] }, { obj: { foo: 0, bar: 1, '': 2 } }, true], + [{ any: [{ ref: 'obj' }, { isEmpty: { ref: '@key' } }] }, { obj: { foo: 0, bar: 1, baz: 2 } }, false], + + [{ all: [{ ref: 'arr' }, { isEmpty: { ref: '@it' } }] }, { arr: ['foo', ''] }, false], + [{ all: [{ ref: 'arr' }, { isEmpty: { ref: '@it' } }] }, { arr: ['', ''] }, true], + [{ all: [{ ref: 'obj' }, { isEmpty: { ref: '@value' } }] }, { obj: { 0: 'foo', 1: '' } }, false], + [{ all: [{ ref: 'obj' }, { isEmpty: { ref: '@value' } }] }, { obj: { 0: '', 1: '' } }, true], + [{ all: [{ ref: 'obj' }, { isEmpty: { ref: '@key' } }] }, { obj: { foo: 0 } }, false], + [{ all: [{ ref: 'obj' }, { isEmpty: { ref: '@key' } }] }, { obj: { '': 0 } }, true], + + [{ or: [{ ref: 'bar' }, { ref: 'foo' }] }, { bar: 42 }, 42], + [{ or: [{ ref: 'bar' }, { ref: 'foo' }] }, { bar: 0 }, new ReferenceError('foo is not defined')], + + [{ and: [{ ref: 'bar' }, { ref: 'foo' }] }, { bar: 0 }, 0], + [{ and: [{ ref: 'bar' }, { ref: 'foo' }] }, { bar: 42 }, new ReferenceError('foo is not defined')], +] +/* eslint-enable id-denylist */ + +export const collectionOperations: TestCase[] = [ + [{ filter: [{ ref: 'arr' }, { not: { isEmpty: { ref: '@it' } } }] }, { arr: ['foo', 'bar', ''] }, ['foo', 'bar']], + [{ filter: [{ ref: 'tarr' }, { gt: [{ ref: '@it' }, 15] }] }, { tarr: new Int16Array([10, 20, 30]) }, [20, 30]], + [ + { filter: [{ ref: 'set' }, { not: { isEmpty: { ref: '@it' } } }] }, + { set: new Set(['foo', 'bar', '']) }, + ['foo', 'bar'], + ], + [ + { filter: [{ ref: 'obj' }, { not: { isEmpty: { ref: '@value' } } }] }, + { obj: { 1: 'foo', 2: 'bar', 3: '' } }, + { 1: 'foo', 2: 'bar' }, + ], + [ + { filter: [{ ref: 'obj' }, { not: { isEmpty: { ref: '@key' } } }] }, + { obj: { foo: 1, bar: 2, '': 3 } }, + { foo: 1, bar: 2 }, + ], +] + +export const membershipAndMatching: TestCase[] = [ + [{ contains: [{ ref: 'str' }, 'world'] }, { str: 'hello world!' }, true], + [{ contains: [{ ref: 'str' }, 'missing'] }, { str: 'hello world!' }, false], + [{ contains: [{ ref: 'str' }, 'world'] }, { str: String('hello world!') }, true], + [{ contains: [{ ref: 'str' }, 'missing'] }, { str: String('hello world!') }, false], + // eslint-disable-next-line no-new-wrappers + [{ contains: [{ ref: 'str' }, 'world'] }, { str: new String('hello world!') }, true], + // eslint-disable-next-line no-new-wrappers + [{ contains: [{ ref: 'str' }, 'missing'] }, { str: new String('hello world!') }, false], + // eslint-disable-next-line no-new-wrappers + [{ contains: [{ ref: 'str' }, 'world'] }, { str: overloadMethod(new String('hello world!'), 'includes') }, true], + [{ contains: [{ ref: 'arr' }, 'foo'] }, { arr: ['foo', 'bar'] }, true], + [{ contains: [{ ref: 'arr' }, 'missing'] }, { arr: ['foo', 'bar'] }, false], + [{ contains: [{ ref: 'arr' }, 'foo'] }, { arr: overloadMethod(['foo', 'bar'], 'includes') }, true], + [{ contains: [{ ref: 'tarr' }, 10] }, { tarr: new Int16Array([10, 20]) }, true], + [{ contains: [{ ref: 'tarr' }, 30] }, { tarr: new Int16Array([10, 20]) }, false], + [{ contains: [{ ref: 'tarr' }, 10] }, { tarr: overloadMethod(new Int16Array([10, 20]), 'includes') }, true], + [{ contains: [{ ref: 'set' }, 'foo'] }, { set: new Set(['foo', 'bar']) }, true], + [{ contains: [{ ref: 'set' }, 'missing'] }, { set: new Set(['foo', 'bar']) }, false], + [{ contains: [{ ref: 'set' }, 'foo'] }, { set: overloadMethod(new Set(['foo', 'bar']), 'has') }, true], + [{ contains: [{ ref: 'wset' }, { ref: 'key' }] }, { key: weakKey, wset: new WeakSet([weakKey]) }, true], + [{ contains: [{ ref: 'wset' }, { ref: 'key' }] }, { key: {}, wset: new WeakSet([weakKey]) }, false], + [ + { contains: [{ ref: 'wset' }, { ref: 'key' }] }, + { key: weakKey, wset: overloadMethod(new WeakSet([weakKey]), 'has') }, + true, + ], + [{ contains: [{ ref: 'map' }, 'foo'] }, { map: new Map([['foo', 'bar']]) }, true], + [{ contains: [{ ref: 'map' }, 'missing'] }, { map: new Map([['foo', 'bar']]) }, false], + [{ contains: [{ ref: 'map' }, 'foo'] }, { map: overloadMethod(new Map([['foo', 'bar']]), 'has') }, true], + [{ contains: [{ ref: 'wmap' }, { ref: 'key' }] }, { key: weakKey, wmap: new WeakMap([[weakKey, 'bar']]) }, true], + [{ contains: [{ ref: 'wmap' }, { ref: 'key' }] }, { key: {}, wmap: new WeakMap([[weakKey, 'bar']]) }, false], + [ + { contains: [{ ref: 'wmap' }, { ref: 'key' }] }, + { key: weakKey, wmap: overloadMethod(new WeakMap([[weakKey, 'bar']]), 'has') }, + true, + ], + [{ contains: [{ ref: 'obj' }, 'foo'] }, { obj: { foo: 'bar' } }, new TypeError('Variable does not support contains')], + [ + { contains: [{ ref: 'obj' }, 'missing'] }, + { obj: { foo: 'bar' } }, + new TypeError('Variable does not support contains'), + ], + [ + { contains: [{ ref: 'str' }, 'world'] }, + { str: new (createClassWithOverloadedMethodInPrototypeChain(String, 'includes'))('hello world!') }, + true, + ], + [ + { contains: [{ ref: 'arr' }, 'foo'] }, + { arr: new (createClassWithOverloadedMethodInPrototypeChain(Array, 'includes'))('foo', 'bar') }, + true, + ], + [ + { contains: [{ ref: 'tarr' }, 10] }, + { tarr: new (createClassWithOverloadedMethodInPrototypeChain(Int32Array, 'includes'))([10, 20]) }, + true, + ], + [ + { contains: [{ ref: 'set' }, 'foo'] }, + { set: new (createClassWithOverloadedMethodInPrototypeChain(Set, 'has'))(['foo', 'bar']) }, + true, + ], + [ + { contains: [{ ref: 'map' }, 'foo'] }, + { map: new (createClassWithOverloadedMethodInPrototypeChain(Map, 'has'))([['foo', 'bar']]) }, + true, + ], + + [{ matches: [{ ref: 'foo' }, '[0-9]+'] }, { foo: '42' }, true], + [{ matches: [{ ref: 'foo' }, '[0-9]+'] }, { foo: String('42') }, true], + // eslint-disable-next-line no-new-wrappers + [{ matches: [{ ref: 'foo' }, '[0-9]+'] }, { foo: new String('42') }, true], + // eslint-disable-next-line no-new-wrappers + [{ matches: [{ ref: 'foo' }, '[0-9]+'] }, { foo: overloadMethod(new String('42'), 'match') }, true], + [{ matches: [{ ref: 'foo' }, '[0-9]+'] }, { foo: {} }, new TypeError('Variable is not a string')], + [{ matches: [{ ref: 'foo' }, { ref: 'regex' }] }, { foo: '42', regex: /[0-9]+/ }, true], + [{ matches: [{ ref: 'foo' }, { ref: 'regex' }] }, { foo: '42', regex: overloadMethod(/[0-9]+/, 'test') }, true], + [{ matches: [{ ref: 'foo' }, { ref: 'regex' }] }, { foo: '42', regex: String('[0-9]+') }, true], + // eslint-disable-next-line no-new-wrappers + [{ matches: [{ ref: 'foo' }, { ref: 'regex' }] }, { foo: '42', regex: new String('[0-9]+') }, true], + [ + { matches: [{ ref: 'foo' }, { ref: 'regex' }] }, + { foo: '42', regex: overloadMethod(new String('[0-9]+'), 'match') }, // eslint-disable-line no-new-wrappers + true, + ], + [ + { matches: [{ ref: 'foo' }, { ref: 'regex' }] }, + { foo: '42', regex: overloadMethod({}, Symbol.match) }, + new TypeError('Regular expression must be either a string or an instance of RegExp'), + ], + [{ matches: [{ ref: 'foo' }, { ref: 'regex' }] }, { foo: '42', regex: overloadMethod(/[0-9]+/, Symbol.match) }, true], + [ + { matches: [{ ref: 'foo' }, '[0-9]+'] }, + { foo: new (createClassWithOverloadedMethodInPrototypeChain(String, 'match'))('42') }, + true, + ], + [ + { matches: ['42', { ref: 'regex' }] }, + { regex: new EvilRegex('[0-9]+') }, + new TypeError('Regular expression must be either a string or an instance of RegExp'), + ], +] + +export const typeAndDefinitionChecks: TestCase[] = [ + // Primitive types + [{ instanceof: [{ ref: 'foo' }, 'string'] }, { foo: 'foo' }, true], + [{ instanceof: [{ ref: 'foo' }, 'number'] }, { foo: 42 }, true], + [{ instanceof: [{ ref: 'foo' }, 'number'] }, { foo: '42' }, false], + { + ast: { instanceof: [{ ref: 'foo' }, 'bigint'] }, + vars: { foo: typeof BigInt !== 'undefined' ? BigInt(42) : undefined }, + expected: typeof BigInt !== 'undefined' ? true : undefined, + before: () => { + if (typeof BigInt === 'undefined') { + pending('BigInt is not supported in this browser') + } + }, + }, + [{ instanceof: [{ ref: 'foo' }, 'boolean'] }, { foo: false }, true], + [{ instanceof: [{ ref: 'foo' }, 'boolean'] }, { foo: 0 }, false], + [{ instanceof: [{ ref: 'foo' }, 'undefined'] }, { foo: undefined }, true], + [{ instanceof: [{ ref: 'foo' }, 'symbol'] }, { foo: Symbol('foo') }, true], + [{ instanceof: [{ ref: 'foo' }, 'null'] }, { foo: null }, false], // typeof null is 'object' + + // Objects + [{ instanceof: [{ ref: 'bar' }, 'Object'] }, { bar: {} }, true], + [{ instanceof: [{ ref: 'bar' }, 'Error'] }, { bar: new Error() }, true], + [{ instanceof: [{ ref: 'bar' }, 'Error'] }, { bar: {} }, false], + [{ instanceof: [{ ref: 'bar' }, 'CustomObject'] }, { bar: new CustomObject(), CustomObject }, true], + [ + { instanceof: [{ ref: 'bar' }, 'HasInstanceSideEffect'] }, + { bar: new HasInstanceSideEffect(), HasInstanceSideEffect }, + true, + ], + { + ast: { instanceof: [{ ref: 'foo' }, 'foo.bar'] }, + expected: new SyntaxError('Illegal identifier: foo.bar'), + execute: false, + }, + + [{ isDefined: { ref: 'foo' } }, { bar: 42 }, false], + [{ isDefined: { ref: 'bar' } }, { bar: 42 }, true], + [{ isDefined: { ref: 'bar' } }, { bar: undefined }, true], + { ast: { isDefined: { ref: 'foo' } }, suffix: 'const foo = undefined', expected: false }, + { ast: { isDefined: { ref: 'foo' } }, suffix: 'const foo = 42', expected: false }, + { ast: { isDefined: { ref: 'foo' } }, suffix: 'let foo', expected: false }, + { ast: { isDefined: { ref: 'foo' } }, suffix: 'let foo = undefined', expected: false }, + { ast: { isDefined: { ref: 'foo' } }, suffix: 'let foo = 42', expected: false }, + { ast: { isDefined: { ref: 'foo' } }, suffix: 'var foo', expected: true }, // var is hoisted + { ast: { isDefined: { ref: 'foo' } }, suffix: 'var foo = undefined', expected: true }, // var is hoisted + { ast: { isDefined: { ref: 'foo' } }, suffix: 'var foo = 42', expected: true }, // var is hoisted + { ast: { isDefined: { ref: 'foo' } }, suffix: 'function foo () {}', expected: true }, // function is hoisted + { ast: { isDefined: { ref: 'foo' } }, suffix: '', expected: false }, +] + +/** + * Define a getter on the provided object that throws on access. + */ +function overloadPropertyWithGetter(obj: T, propName: string): T { + Object.defineProperty(obj, propName, { + get() { + throw new Error('This should never throw!') + }, + }) + return obj +} + +/** + * Overwrite a method/property on the object with a throwing function. + */ +function overloadMethod(obj: T, methodName: PropertyKey): T { + ;(obj as any)[methodName] = () => { + throw new Error('This should never throw!') + } + return obj +} + +/** + * Create a subclass of the given built-in where the given property/method is overloaded + * in the prototype chain to throw, and return a further subclass constructor. + */ +function createClassWithOverloadedMethodInPrototypeChain any>( + Builtin: T, + propName: PropertyKey +): T { + class Klass extends Builtin { + [propName](): void { + throw new Error('This should never throw!') + } + } + + class SubKlass extends Klass {} + + return SubKlass as T +} diff --git a/packages/debugger/test/index.ts b/packages/debugger/test/index.ts new file mode 100644 index 0000000000..cc01b08bd6 --- /dev/null +++ b/packages/debugger/test/index.ts @@ -0,0 +1 @@ +export * from './expressionTestCases'