diff --git a/eslint.config.mjs b/eslint.config.mjs index e572ce6e2bd..33a8ab6a773 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -41,6 +41,7 @@ export default [ '**/versions', // This is effectively a node_modules tree. '**/acmeair-nodejs', // We don't own this. '**/vendor', // Generally, we didn't author this code. + 'integration-tests/debugger/target-app/source-map-support/index.js', // Generated 'integration-tests/esbuild/out.js', // Generated 'integration-tests/esbuild/aws-sdk-out.js', // Generated 'packages/dd-trace/src/appsec/blocked_templates.js', // TODO Why is this ignored? diff --git a/integration-tests/debugger/basic.spec.js b/integration-tests/debugger/basic.spec.js index f51278bc2ee..d8d9debea25 100644 --- a/integration-tests/debugger/basic.spec.js +++ b/integration-tests/debugger/basic.spec.js @@ -535,7 +535,7 @@ function assertBasicInputPayload (t, payload) { service: 'node', message: 'Hello World!', logger: { - name: t.breakpoint.file, + name: t.breakpoint.deployedFile, method: 'fooHandler', version, thread_name: 'MainThread' @@ -544,7 +544,7 @@ function assertBasicInputPayload (t, payload) { probe: { id: t.rcConfig.config.id, version: 0, - location: { file: t.breakpoint.file, lines: [String(t.breakpoint.line)] } + location: { file: t.breakpoint.deployedFile, lines: [String(t.breakpoint.line)] } }, language: 'javascript' } diff --git a/integration-tests/debugger/source-map-support.spec.js b/integration-tests/debugger/source-map-support.spec.js new file mode 100644 index 00000000000..232d07a7a3e --- /dev/null +++ b/integration-tests/debugger/source-map-support.spec.js @@ -0,0 +1,27 @@ +'use strict' + +const { assert } = require('chai') +const { setup } = require('./utils') + +describe('Dynamic Instrumentation', function () { + describe('source map support', function () { + const t = setup({ + testApp: 'target-app/source-map-support/index.js', + testAppSource: 'target-app/source-map-support/index.ts' + }) + + beforeEach(t.triggerBreakpoint) + + it('should support source maps', function (done) { + t.agent.on('debugger-input', ({ payload: [{ 'debugger.snapshot': { probe: { location } } }] }) => { + assert.deepEqual(location, { + file: 'target-app/source-map-support/index.ts', + lines: ['9'] + }) + done() + }) + + t.agent.addRemoteConfig(t.rcConfig) + }) + }) +}) diff --git a/integration-tests/debugger/target-app/source-map-support/index.js b/integration-tests/debugger/target-app/source-map-support/index.js new file mode 100644 index 00000000000..d0eff097384 --- /dev/null +++ b/integration-tests/debugger/target-app/source-map-support/index.js @@ -0,0 +1,13 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +require('dd-trace/init'); +var node_http_1 = require("node:http"); +var server = (0, node_http_1.createServer)(function (req, res) { + // Blank lines below to ensure line numbers in transpiled file differ from original file + res.end('hello world'); // BREAKPOINT: / +}); +server.listen(process.env.APP_PORT, function () { + var _a; + (_a = process.send) === null || _a === void 0 ? void 0 : _a.call(process, { port: process.env.APP_PORT }); +}); +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/integration-tests/debugger/target-app/source-map-support/index.js.map b/integration-tests/debugger/target-app/source-map-support/index.js.map new file mode 100644 index 00000000000..c246badc05b --- /dev/null +++ b/integration-tests/debugger/target-app/source-map-support/index.js.map @@ -0,0 +1 @@ +{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";;AAAA,OAAO,CAAC,eAAe,CAAC,CAAA;AAExB,uCAAwC;AAExC,IAAM,MAAM,GAAG,IAAA,wBAAY,EAAC,UAAC,GAAG,EAAE,GAAG;IACnC,wFAAwF;IAGxF,GAAG,CAAC,GAAG,CAAC,aAAa,CAAC,CAAA,CAAC,gBAAgB;AACzC,CAAC,CAAC,CAAA;AAEF,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,QAAQ,EAAE;;IAClC,MAAA,OAAO,CAAC,IAAI,wDAAG,EAAE,IAAI,EAAE,OAAO,CAAC,GAAG,CAAC,QAAQ,EAAE,CAAC,CAAA;AAChD,CAAC,CAAC,CAAA"} \ No newline at end of file diff --git a/integration-tests/debugger/target-app/source-map-support/index.ts b/integration-tests/debugger/target-app/source-map-support/index.ts new file mode 100644 index 00000000000..a11c267f708 --- /dev/null +++ b/integration-tests/debugger/target-app/source-map-support/index.ts @@ -0,0 +1,14 @@ +require('dd-trace/init') + +import { createServer } from 'node:http' + +const server = createServer((req, res) => { + // Blank lines below to ensure line numbers in transpiled file differ from original file + + + res.end('hello world') // BREAKPOINT: / +}) + +server.listen(process.env.APP_PORT, () => { + process.send?.({ port: process.env.APP_PORT }) +}) diff --git a/integration-tests/debugger/utils.js b/integration-tests/debugger/utils.js index 9f5175d84fc..f273dcfca2a 100644 --- a/integration-tests/debugger/utils.js +++ b/integration-tests/debugger/utils.js @@ -18,9 +18,13 @@ module.exports = { setup } -function setup ({ env, testApp } = {}) { +function setup ({ env, testApp, testAppSource } = {}) { let sandbox, cwd, appPort - const breakpoints = getBreakpointInfo({ file: testApp, stackIndex: 1 }) // `1` to disregard the `setup` function + const breakpoints = getBreakpointInfo({ + deployedFile: testApp, + sourceFile: testAppSource, + stackIndex: 1 // `1` to disregard the `setup` function + }) const t = { breakpoint: breakpoints[0], breakpoints, @@ -71,7 +75,7 @@ function setup ({ env, testApp } = {}) { sandbox = await createSandbox(['fastify']) // TODO: Make this dynamic cwd = sandbox.folder // The sandbox uses the `integration-tests` folder as its root - t.appFile = join(cwd, 'debugger', breakpoints[0].file) + t.appFile = join(cwd, 'debugger', breakpoints[0].deployedFile) }) after(async function () { @@ -110,8 +114,8 @@ function setup ({ env, testApp } = {}) { return t } -function getBreakpointInfo ({ file, stackIndex = 0 }) { - if (!file) { +function getBreakpointInfo ({ deployedFile, sourceFile = deployedFile, stackIndex = 0 } = {}) { + if (!deployedFile) { // First, get the filename of file that called this function const testFile = new Error().stack .split('\n')[stackIndex + 2] // +2 to skip this function + the first line, which is the error message @@ -120,17 +124,17 @@ function getBreakpointInfo ({ file, stackIndex = 0 }) { .split(':')[0] // Then, find the corresponding file in which the breakpoint(s) exists - file = join('target-app', basename(testFile).replace('.spec', '')) + deployedFile = sourceFile = join('target-app', basename(testFile).replace('.spec', '')) } // Finally, find the line number(s) of the breakpoint(s) - const lines = readFileSync(join(__dirname, file), 'utf8').split('\n') + const lines = readFileSync(join(__dirname, sourceFile), 'utf8').split('\n') const result = [] for (let i = 0; i < lines.length; i++) { const index = lines[i].indexOf(BREAKPOINT_TOKEN) if (index !== -1) { const url = lines[i].slice(index + BREAKPOINT_TOKEN.length + 1).trim() - result.push({ file, line: i + 1, url }) + result.push({ sourceFile, deployedFile, line: i + 1, url }) } } diff --git a/packages/datadog-plugin-cucumber/src/index.js b/packages/datadog-plugin-cucumber/src/index.js index 1c0cc85a26e..3620e063f0f 100644 --- a/packages/datadog-plugin-cucumber/src/index.js +++ b/packages/datadog-plugin-cucumber/src/index.js @@ -47,6 +47,7 @@ const { const id = require('../../dd-trace/src/id') const BREAKPOINT_HIT_GRACE_PERIOD_MS = 200 +const BREAKPOINT_SET_GRACE_PERIOD_MS = 200 const isCucumberWorker = !!process.env.CUCUMBER_WORKER_ID function getTestSuiteTags (testSuiteSpan) { @@ -251,7 +252,12 @@ class CucumberPlugin extends CiPlugin { const { file, line, stackIndex } = probeInformation this.runningTestProbe = { file, line } this.testErrorStackIndex = stackIndex - // TODO: we're not waiting for setProbePromise to be resolved, so there might be race conditions + const waitUntil = Date.now() + BREAKPOINT_SET_GRACE_PERIOD_MS + while (Date.now() < waitUntil) { + // TODO: To avoid a race condition, we should wait until `probeInformation.setProbePromise` has resolved. + // However, Cucumber doesn't have a mechanism for waiting asyncrounously here, so for now, we'll have to + // fall back to a fixed syncronous delay. + } } } span.setTag(TEST_STATUS, 'fail') diff --git a/packages/datadog-plugin-mocha/src/index.js b/packages/datadog-plugin-mocha/src/index.js index 5918a3a5db5..7152aafe8b3 100644 --- a/packages/datadog-plugin-mocha/src/index.js +++ b/packages/datadog-plugin-mocha/src/index.js @@ -48,6 +48,8 @@ const { const id = require('../../dd-trace/src/id') const log = require('../../dd-trace/src/log') +const BREAKPOINT_SET_GRACE_PERIOD_MS = 200 + function getTestSuiteLevelVisibilityTags (testSuiteSpan) { const testSuiteSpanContext = testSuiteSpan.context() const suiteTags = { @@ -279,7 +281,12 @@ class MochaPlugin extends CiPlugin { this.runningTestProbe = { file, line } this.testErrorStackIndex = stackIndex test._ddShouldWaitForHitProbe = true - // TODO: we're not waiting for setProbePromise to be resolved, so there might be race conditions + const waitUntil = Date.now() + BREAKPOINT_SET_GRACE_PERIOD_MS + while (Date.now() < waitUntil) { + // TODO: To avoid a race condition, we should wait until `probeInformation.setProbePromise` has resolved. + // However, Mocha doesn't have a mechanism for waiting asyncrounously here, so for now, we'll have to + // fall back to a fixed syncronous delay. + } } } diff --git a/packages/dd-trace/src/ci-visibility/dynamic-instrumentation/worker/index.js b/packages/dd-trace/src/ci-visibility/dynamic-instrumentation/worker/index.js index de41291da73..9701baf82cf 100644 --- a/packages/dd-trace/src/ci-visibility/dynamic-instrumentation/worker/index.js +++ b/packages/dd-trace/src/ci-visibility/dynamic-instrumentation/worker/index.js @@ -1,5 +1,5 @@ 'use strict' -const path = require('path') + const { workerData: { breakpointSetChannel, @@ -8,10 +8,11 @@ const { } } = require('worker_threads') const { randomUUID } = require('crypto') -const sourceMap = require('source-map') // TODO: move debugger/devtools_client/session to common place const session = require('../../../debugger/devtools_client/session') +// TODO: move debugger/devtools_client/source-maps to common place +const { getSourceMappedLine } = require('../../../debugger/devtools_client/source-maps') // TODO: move debugger/devtools_client/snapshot to common place const { getLocalStateForCallFrame } = require('../../../debugger/devtools_client/snapshot') // TODO: move debugger/devtools_client/state to common place @@ -98,17 +99,21 @@ async function addBreakpoint (probe) { throw new Error(`No loaded script found for ${file}`) } - const [path, scriptId, sourceMapURL] = script + const { url, scriptId, sourceMapURL, source } = script - log.warn(`Adding breakpoint at ${path}:${line}`) + log.warn(`Adding breakpoint at ${url}:${line}`) let lineNumber = line - if (sourceMapURL && sourceMapURL.startsWith('data:')) { + if (sourceMapURL) { try { - lineNumber = await processScriptWithInlineSourceMap({ file, line, sourceMapURL }) + lineNumber = await getSourceMappedLine(url, source, line, sourceMapURL) } catch (err) { - log.error('Error processing script with inline source map', err) + log.error('Error processing script with source map', err) + } + if (lineNumber === null) { + log.error('Could not find generated position for %s:%s', url, line) + lineNumber = line } } @@ -123,7 +128,7 @@ async function addBreakpoint (probe) { breakpointIdToProbe.set(breakpointId, probe) probeIdToBreakpointId.set(probe.id, breakpointId) } catch (e) { - log.error(`Error setting breakpoint at ${path}:${line}:`, e) + log.error('Error setting breakpoint at %s:%s', url, line, e) } } @@ -131,43 +136,3 @@ function start () { sessionStarted = true return session.post('Debugger.enable') // return instead of await to reduce number of promises created } - -async function processScriptWithInlineSourceMap (params) { - const { file, line, sourceMapURL } = params - - // Extract the base64-encoded source map - const base64SourceMap = sourceMapURL.split('base64,')[1] - - // Decode the base64 source map - const decodedSourceMap = Buffer.from(base64SourceMap, 'base64').toString('utf8') - - // Parse the source map - const consumer = await new sourceMap.SourceMapConsumer(decodedSourceMap) - - let generatedPosition - - // Map to the generated position. We'll attempt with the full file path first, then with the basename. - // TODO: figure out why sometimes the full path doesn't work - generatedPosition = consumer.generatedPositionFor({ - source: file, - line, - column: 0 - }) - if (generatedPosition.line === null) { - generatedPosition = consumer.generatedPositionFor({ - source: path.basename(file), - line, - column: 0 - }) - } - - consumer.destroy() - - // If we can't find the line, just return the original line - if (generatedPosition.line === null) { - log.error(`Could not find generated position for ${file}:${line}`) - return line - } - - return generatedPosition.line -} diff --git a/packages/dd-trace/src/debugger/devtools_client/breakpoints.js b/packages/dd-trace/src/debugger/devtools_client/breakpoints.js index a93f587a5b4..5f4e764d6b8 100644 --- a/packages/dd-trace/src/debugger/devtools_client/breakpoints.js +++ b/packages/dd-trace/src/debugger/devtools_client/breakpoints.js @@ -1,5 +1,6 @@ 'use strict' +const { getSourceMappedLine } = require('./source-maps') const session = require('./session') const { MAX_SNAPSHOTS_PER_SECOND_PER_PROBE, MAX_NON_SNAPSHOTS_PER_SECOND_PER_PROBE } = require('./defaults') const { findScriptFromPartialPath, probes, breakpoints } = require('./state') @@ -16,7 +17,7 @@ async function addBreakpoint (probe) { if (!sessionStarted) await start() const file = probe.where.sourceFile - const line = Number(probe.where.lines[0]) // Tracer doesn't support multiple-line breakpoints + let line = Number(probe.where.lines[0]) // Tracer doesn't support multiple-line breakpoints // Optimize for sending data to /debugger/v1/input endpoint probe.location = { file, lines: [String(line)] } @@ -34,11 +35,15 @@ async function addBreakpoint (probe) { // not continue untill all scripts have been parsed? const script = findScriptFromPartialPath(file) if (!script) throw new Error(`No loaded script found for ${file} (probe: ${probe.id}, version: ${probe.version})`) - const [path, scriptId] = script + const { url, scriptId, sourceMapURL, source } = script + + if (sourceMapURL) { + line = await getSourceMappedLine(url, source, line, sourceMapURL) + } log.debug( '[debugger:devtools_client] Adding breakpoint at %s:%d (probe: %s, version: %d)', - path, line, probe.id, probe.version + url, line, probe.id, probe.version ) const { breakpointId } = await session.post('Debugger.setBreakpoint', { @@ -66,7 +71,7 @@ async function removeBreakpoint ({ id }) { probes.delete(id) breakpoints.delete(breakpointId) - if (breakpoints.size === 0) await stop() + if (breakpoints.size === 0) return stop() // return instead of await to reduce number of promises created } async function start () { diff --git a/packages/dd-trace/src/debugger/devtools_client/source-maps.js b/packages/dd-trace/src/debugger/devtools_client/source-maps.js new file mode 100644 index 00000000000..79d89b62672 --- /dev/null +++ b/packages/dd-trace/src/debugger/devtools_client/source-maps.js @@ -0,0 +1,50 @@ +'use strict' + +const { join, dirname } = require('path') +const { readFileSync } = require('fs') +const { readFile } = require('fs/promises') +const { SourceMapConsumer } = require('source-map') + +const cache = new Map() +let cacheTimer = null + +const self = module.exports = { + async loadSourceMap (dir, url) { + if (url.startsWith('data:')) return loadInlineSourceMap(url) + const path = join(dir, url) + if (cache.has(path)) return cache.get(path) + return cacheIt(path, JSON.parse(await readFile(path, 'utf8'))) + }, + + loadSourceMapSync (dir, url) { + if (url.startsWith('data:')) return loadInlineSourceMap(url) + const path = join(dir, url) + if (cache.has(path)) return cache.get(path) + return cacheIt(path, JSON.parse(readFileSync(path, 'utf8'))) + }, + + async getSourceMappedLine (url, source, line, sourceMapURL) { + const dir = dirname(new URL(url).pathname) + return await SourceMapConsumer.with( + await self.loadSourceMap(dir, sourceMapURL), + null, + (consumer) => consumer.generatedPositionFor({ source, line, column: 0 }).line + ) + } +} + +function cacheIt (key, value) { + clearTimeout(cacheTimer) + cacheTimer = setTimeout(function () { + // Optimize for app boot, where a lot of reads might happen + // Clear cache a few seconds after it was last used + cache.clear() + }, 10_000).unref() + cache.set(key, value) + return value +} + +function loadInlineSourceMap (data) { + data = data.slice(data.indexOf('base64,') + 7) + return JSON.parse(Buffer.from(data, 'base64').toString('utf8')) +} diff --git a/packages/dd-trace/src/debugger/devtools_client/state.js b/packages/dd-trace/src/debugger/devtools_client/state.js index 4c16f336233..389bc6591b6 100644 --- a/packages/dd-trace/src/debugger/devtools_client/state.js +++ b/packages/dd-trace/src/debugger/devtools_client/state.js @@ -1,10 +1,13 @@ 'use strict' +const { join, dirname } = require('path') +const { loadSourceMapSync } = require('./source-maps') const session = require('./session') +const log = require('../../log') const WINDOWS_DRIVE_LETTER_REGEX = /[a-zA-Z]/ -const scriptIds = [] +const loadedScripts = [] const scriptUrls = new Map() module.exports = { @@ -15,18 +18,17 @@ module.exports = { * Find the script to inspect based on a partial or absolute path. Handles both Windows and POSIX paths. * * @param {string} path - Partial or absolute path to match against loaded scripts - * @returns {[string, string, string | undefined] | null} - Array containing [url, scriptId, sourceMapURL] - * or null if no match + * @returns {Object | null} - Object containing `url`, `scriptId`, `sourceMapURL`, and `source` - or null if no match */ findScriptFromPartialPath (path) { if (!path) return null // This shouldn't happen, but better safe than sorry path = path.toLowerCase() - const bestMatch = new Array(3) + const bestMatch = { url: null, scriptId: null, sourceMapURL: null, source: null } let maxMatchLength = -1 - for (const [url, scriptId, sourceMapURL] of scriptIds) { + for (const { url, sourceUrl, scriptId, sourceMapURL, source } of loadedScripts) { let i = url.length - 1 let j = path.length - 1 let matchLength = 0 @@ -75,12 +77,13 @@ module.exports = { // If we found a valid match and it's better than our previous best if (atBoundary && ( lastBoundaryPos > maxMatchLength || - (lastBoundaryPos === maxMatchLength && url.length < bestMatch[0].length) // Prefer shorter paths + (lastBoundaryPos === maxMatchLength && url.length < bestMatch.url.length) // Prefer shorter paths )) { maxMatchLength = lastBoundaryPos - bestMatch[0] = url - bestMatch[1] = scriptId - bestMatch[2] = sourceMapURL + bestMatch.url = sourceUrl || url + bestMatch.scriptId = scriptId + bestMatch.sourceMapURL = sourceMapURL + bestMatch.source = source } } @@ -112,6 +115,31 @@ module.exports = { session.on('Debugger.scriptParsed', ({ params }) => { scriptUrls.set(params.scriptId, params.url) if (params.url.startsWith('file:')) { - scriptIds.push([params.url, params.scriptId, params.sourceMapURL]) + if (params.sourceMapURL) { + const dir = dirname(new URL(params.url).pathname) + let sources + try { + sources = loadSourceMapSync(dir, params.sourceMapURL).sources + } catch (err) { + if (typeof params.sourceMapURL === 'string' && params.sourceMapURL.startsWith('data:')) { + log.error('[debugger:devtools_client] could not load inline source map for "%s"', params.url, err) + } else { + log.error('[debugger:devtools_client] could not load source map "%s" from "%s" for "%s"', + params.sourceMapURL, dir, params.url, err) + } + return + } + for (const source of sources) { + // TODO: Take source map `sourceRoot` into account? + loadedScripts.push({ + ...params, + sourceUrl: params.url, + url: new URL(join(dir, source), 'file:').href, + source + }) + } + } else { + loadedScripts.push(params) + } } }) diff --git a/packages/dd-trace/test/debugger/devtools_client/source-maps.spec.js b/packages/dd-trace/test/debugger/devtools_client/source-maps.spec.js new file mode 100644 index 00000000000..d87a96f35d6 --- /dev/null +++ b/packages/dd-trace/test/debugger/devtools_client/source-maps.spec.js @@ -0,0 +1,168 @@ +'use strict' + +require('../../setup/mocha') + +const parsedSourceMap = { + version: 3, + file: 'index.js', + sourceRoot: '', + sources: ['index.ts'], + names: [], + mappings: ';AAAA,MAAM,UAAU,GAAG,IAAI,CAAC;AACxB,OAAO,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC' +} +const dir = '/foo' +const sourceMapURL = 'index.map.js' +const rawSourceMap = JSON.stringify(parsedSourceMap) +const inlineSourceMap = `data:application/json;base64,${Buffer.from(rawSourceMap).toString('base64')}` + +describe('source map utils', function () { + let loadSourceMap, loadSourceMapSync, getSourceMappedLine, readFileSync, readFile + + describe('basic', function () { + beforeEach(function () { + readFileSync = sinon.stub().returns(rawSourceMap) + readFile = sinon.stub().resolves(rawSourceMap) + + const sourceMaps = proxyquire('../src/debugger/devtools_client/source-maps', { + fs: { readFileSync }, + 'fs/promises': { readFile } + }) + + loadSourceMap = sourceMaps.loadSourceMap + loadSourceMapSync = sourceMaps.loadSourceMapSync + getSourceMappedLine = sourceMaps.getSourceMappedLine + }) + + describe('loadSourceMap', function () { + it('should return parsed inline source map', async function () { + const sourceMap = await loadSourceMap(dir, inlineSourceMap) + expect(sourceMap).to.deep.equal(parsedSourceMap) + expect(readFile).to.not.have.been.called + }) + + it('should throw is inline source map is invalid', function (done) { + loadSourceMap(dir, inlineSourceMap.slice(0, -10)) + .then(() => { + done(new Error('Should not resolve promise')) + }) + .catch(() => { + done() + }) + }) + + it('should return parsed source map', async function () { + const sourceMap = await loadSourceMap(dir, sourceMapURL) + expect(sourceMap).to.deep.equal(parsedSourceMap) + expect(readFile).to.have.been.calledOnceWith('/foo/index.map.js', 'utf8') + }) + }) + + describe('loadSourceMapSync', function () { + it('should return parsed inline source map', function () { + const sourceMap = loadSourceMapSync(dir, inlineSourceMap) + expect(sourceMap).to.deep.equal(parsedSourceMap) + expect(readFileSync).to.not.have.been.called + }) + + it('should throw if inline source map is invalid', function () { + expect(() => { + loadSourceMapSync(dir, inlineSourceMap.slice(0, -10)) + }).to.throw() + }) + + it('should return parsed source map', function () { + const sourceMap = loadSourceMapSync(dir, sourceMapURL) + expect(sourceMap).to.deep.equal(parsedSourceMap) + expect(readFileSync).to.have.been.calledOnceWith('/foo/index.map.js', 'utf8') + }) + }) + + describe('getSourceMappedLine', function () { + const url = `file://${dir}/${parsedSourceMap.file}` + const source = parsedSourceMap.sources[0] + const line = 1 + + it('should return expected line for inline source map', async function () { + const result = await getSourceMappedLine(url, source, line, sourceMapURL) + expect(result).to.equal(2) + }) + + it('should return expected line for non-inline source map', async function () { + const result = await getSourceMappedLine(url, source, line, inlineSourceMap) + expect(result).to.equal(2) + }) + }) + }) + + describe('cache', function () { + let clock + + function setup () { + clock = sinon.useFakeTimers() + readFileSync = sinon.stub().returns(rawSourceMap) + readFile = sinon.stub().resolves(rawSourceMap) + + const sourceMaps = proxyquire('../src/debugger/devtools_client/source-maps', { + fs: { readFileSync }, + 'fs/promises': { readFile } + }) + + loadSourceMap = sourceMaps.loadSourceMap + loadSourceMapSync = sourceMaps.loadSourceMapSync + } + + function teardown () { + clock.restore() + } + + describe('loadSourceMap', function () { + before(setup) + + after(teardown) + + it('should read from disk on the fist call', async function () { + const sourceMap = await loadSourceMap(dir, sourceMapURL) + expect(sourceMap).to.deep.equal(parsedSourceMap) + expect(readFile.callCount).to.equal(1) + }) + + it('should not read from disk on the second call', async function () { + const sourceMap = await loadSourceMap(dir, sourceMapURL) + expect(sourceMap).to.deep.equal(parsedSourceMap) + expect(readFile.callCount).to.equal(1) + }) + + it('should clear cache after 10 seconds', async function () { + clock.tick(10_000) + const sourceMap = await loadSourceMap(dir, sourceMapURL) + expect(sourceMap).to.deep.equal(parsedSourceMap) + expect(readFile.callCount).to.equal(2) + }) + }) + + describe('loadSourceMapSync', function () { + before(setup) + + after(teardown) + + it('should read from disk on the fist call', function () { + const sourceMap = loadSourceMapSync(dir, sourceMapURL) + expect(sourceMap).to.deep.equal(parsedSourceMap) + expect(readFileSync.callCount).to.equal(1) + }) + + it('should not read from disk on the second call', function () { + const sourceMap = loadSourceMapSync(dir, sourceMapURL) + expect(sourceMap).to.deep.equal(parsedSourceMap) + expect(readFileSync.callCount).to.equal(1) + }) + + it('should clear cache after 10 seconds', function () { + clock.tick(10_000) + const sourceMap = loadSourceMapSync(dir, sourceMapURL) + expect(sourceMap).to.deep.equal(parsedSourceMap) + expect(readFileSync.callCount).to.equal(2) + }) + }) + }) +}) diff --git a/packages/dd-trace/test/debugger/devtools_client/state.spec.js b/packages/dd-trace/test/debugger/devtools_client/state.spec.js index dced50d51e3..133b0b72049 100644 --- a/packages/dd-trace/test/debugger/devtools_client/state.spec.js +++ b/packages/dd-trace/test/debugger/devtools_client/state.spec.js @@ -14,6 +14,14 @@ describe('findScriptFromPartialPath', function () { before(function () { state = proxyquire('../src/debugger/devtools_client/state', { + './source-maps': proxyquire('../src/debugger/devtools_client/source-maps', { + fs: { + // Mock reading the source map file + readFileSync: () => JSON.stringify({ + sources: ['index.ts'] + }) + } + }), './session': { '@noCallThru': true, on (event, listener) { @@ -32,6 +40,15 @@ describe('findScriptFromPartialPath', function () { // The same, but in reverse order to ensure this doesn't influence the result listener({ params: { scriptId: 'should-match-shortest-b', url: 'file:///bar/index.js' } }) listener({ params: { scriptId: 'should-not-match-longest-b', url: 'file:///node_modules/bar/index.js' } }) + + // Test case for source maps + listener({ + params: { + scriptId: 'should-match-source-mapped', + url: 'file:///source-mapped/index.js', + sourceMapURL: 'index.js.map' + } + }) } } } @@ -117,7 +134,7 @@ describe('findScriptFromPartialPath', function () { function testPath (path) { return function () { const result = state.findScriptFromPartialPath(path) - expect(result).to.deep.equal([url, scriptId, undefined]) + expect(result).to.deep.equal({ url, scriptId, sourceMapURL: undefined, source: undefined }) } } }) @@ -126,15 +143,33 @@ describe('findScriptFromPartialPath', function () { describe('multiple partial matches', function () { it('should match the longest partial match', function () { const result = state.findScriptFromPartialPath('server/index.js') - expect(result).to.deep.equal(['file:///server/index.js', 'should-match', undefined]) + expect(result).to.deep.equal({ + url: 'file:///server/index.js', scriptId: 'should-match', sourceMapURL: undefined, source: undefined + }) }) it('should match the shorter of two equal length partial matches', function () { const result1 = state.findScriptFromPartialPath('foo/index.js') - expect(result1).to.deep.equal(['file:///foo/index.js', 'should-match-shortest-a', undefined]) + expect(result1).to.deep.equal({ + url: 'file:///foo/index.js', scriptId: 'should-match-shortest-a', sourceMapURL: undefined, source: undefined + }) const result2 = state.findScriptFromPartialPath('bar/index.js') - expect(result2).to.deep.equal(['file:///bar/index.js', 'should-match-shortest-b', undefined]) + expect(result2).to.deep.equal({ + url: 'file:///bar/index.js', scriptId: 'should-match-shortest-b', sourceMapURL: undefined, source: undefined + }) + }) + }) + + describe('source maps', function () { + it('should match the source map path', function () { + const result = state.findScriptFromPartialPath('source-mapped/index.ts') + expect(result).to.deep.equal({ + url: 'file:///source-mapped/index.js', + scriptId: 'should-match-source-mapped', + sourceMapURL: 'index.js.map', + source: 'index.ts' + }) }) }) diff --git a/packages/dd-trace/test/debugger/devtools_client/utils.js b/packages/dd-trace/test/debugger/devtools_client/utils.js index 2da3216cea1..5d0ca8fb1fe 100644 --- a/packages/dd-trace/test/debugger/devtools_client/utils.js +++ b/packages/dd-trace/test/debugger/devtools_client/utils.js @@ -28,7 +28,7 @@ function generateProbeConfig (breakpoint, overrides = {}) { version: 0, type: 'LOG_PROBE', language: 'javascript', - where: { sourceFile: breakpoint.file, lines: [String(breakpoint.line)] }, + where: { sourceFile: breakpoint.sourceFile, lines: [String(breakpoint.line)] }, tags: [], template: 'Hello World!', segments: [{ str: 'Hello World!' }],