From 9ee50f9a27df25af505fe2c07c9ffd1f73b512ec Mon Sep 17 00:00:00 2001 From: Juan Fernandez Date: Fri, 20 Mar 2026 09:34:12 +0100 Subject: [PATCH 01/15] refactor cypress --- .../subproject/cypress.config.js | 9 +- integration-tests/cypress-esm-config.mjs | 52 +-- integration-tests/cypress.config.js | 26 +- integration-tests/cypress/cypress.spec.js | 407 +++++------------- .../datadog-instrumentations/src/cypress.js | 89 +++- 5 files changed, 217 insertions(+), 366 deletions(-) diff --git a/integration-tests/ci-visibility/subproject/cypress.config.js b/integration-tests/ci-visibility/subproject/cypress.config.js index 7d9c2df8db4..3544598d6c0 100644 --- a/integration-tests/ci-visibility/subproject/cypress.config.js +++ b/integration-tests/ci-visibility/subproject/cypress.config.js @@ -1,13 +1,12 @@ 'use strict' -module.exports = { +const { defineConfig } = require('cypress') + +module.exports = defineConfig({ defaultCommandTimeout: 1000, e2e: { - setupNodeEvents (on, config) { - return require('dd-trace/ci/cypress/plugin')(on, config) - }, specPattern: process.env.SPEC_PATTERN || 'cypress/e2e/**/*.cy.js', }, video: false, screenshotOnRunFailure: false, -} +}) diff --git a/integration-tests/cypress-esm-config.mjs b/integration-tests/cypress-esm-config.mjs index 4e36b444ae0..80d351d10f0 100644 --- a/integration-tests/cypress-esm-config.mjs +++ b/integration-tests/cypress-esm-config.mjs @@ -1,45 +1,27 @@ import cypress from 'cypress' +const { defineConfig } = cypress + async function runCypress () { - const results = await cypress.run({ - config: { - defaultCommandTimeout: 1000, - e2e: { - testIsolation: process.env.CYPRESS_TEST_ISOLATION !== 'false', - setupNodeEvents (on, config) { - if (process.env.CYPRESS_ENABLE_INCOMPATIBLE_PLUGIN) { - import('cypress-fail-fast/plugin').then(module => { - module.default(on, config) - }) - } - if (process.env.CYPRESS_ENABLE_AFTER_RUN_CUSTOM) { - on('after:run', (...args) => { - // do custom stuff - // and call after-run at the end - return import('dd-trace/ci/cypress/after-run').then(module => { - module.default(...args) - }) - }) - } - if (process.env.CYPRESS_ENABLE_AFTER_SPEC_CUSTOM) { - on('after:spec', (...args) => { - // do custom stuff - // and call after-spec at the end - return import('dd-trace/ci/cypress/after-spec').then(module => { - module.default(...args) - }) - }) - } - return import('dd-trace/ci/cypress/plugin').then(module => { - return module.default(on, config) + const config = defineConfig({ + defaultCommandTimeout: 1000, + e2e: { + testIsolation: process.env.CYPRESS_TEST_ISOLATION !== 'false', + setupNodeEvents (on, config) { + if (process.env.CYPRESS_ENABLE_INCOMPATIBLE_PLUGIN) { + return import('cypress-fail-fast/plugin').then(module => { + module.default(on, config) }) - }, - specPattern: process.env.SPEC_PATTERN || 'cypress/e2e/**/*.cy.js', + } }, - video: false, - screenshotOnRunFailure: false, + specPattern: process.env.SPEC_PATTERN || 'cypress/e2e/**/*.cy.js', }, + video: false, + screenshotOnRunFailure: false, }) + + const results = await cypress.run({ config }) + if (results.totalFailed !== 0) { process.exit(1) } diff --git a/integration-tests/cypress.config.js b/integration-tests/cypress.config.js index 091320304c9..e9318ad128a 100644 --- a/integration-tests/cypress.config.js +++ b/integration-tests/cypress.config.js @@ -1,36 +1,18 @@ 'use strict' -const ddAfterRun = require('dd-trace/ci/cypress/after-run') -const ddAfterSpec = require('dd-trace/ci/cypress/after-spec') -const cypressFailFast = require('cypress-fail-fast/plugin') -const ddTracePlugin = require('dd-trace/ci/cypress/plugin') +const { defineConfig } = require('cypress') -module.exports = { +module.exports = defineConfig({ defaultCommandTimeout: 1000, e2e: { testIsolation: process.env.CYPRESS_TEST_ISOLATION !== 'false', setupNodeEvents (on, config) { if (process.env.CYPRESS_ENABLE_INCOMPATIBLE_PLUGIN) { - cypressFailFast(on, config) + require('cypress-fail-fast/plugin')(on, config) } - if (process.env.CYPRESS_ENABLE_AFTER_RUN_CUSTOM) { - on('after:run', (...args) => { - // do custom stuff - // and call after-run at the end - return ddAfterRun(...args) - }) - } - if (process.env.CYPRESS_ENABLE_AFTER_SPEC_CUSTOM) { - on('after:spec', (...args) => { - // do custom stuff - // and call after-spec at the end - return ddAfterSpec(...args) - }) - } - return ddTracePlugin(on, config) }, specPattern: process.env.SPEC_PATTERN || 'cypress/e2e/**/*.cy.js', }, video: false, screenshotOnRunFailure: false, -} +}) diff --git a/integration-tests/cypress/cypress.spec.js b/integration-tests/cypress/cypress.spec.js index 4fd0228c17b..b6f2bfe7ce9 100644 --- a/integration-tests/cypress/cypress.spec.js +++ b/integration-tests/cypress/cypress.spec.js @@ -287,10 +287,7 @@ moduleTypes.forEach(({ assert.ok(!('addTagsAfterFailure' in failedTestSpan.meta)) }, 60000) - const { - NODE_OPTIONS, - ...restEnvVars - } = getCiVisEvpProxyConfig(receiver.port) + const envVars = getCiVisEvpProxyConfig(receiver.port) const specToRun = 'cypress/e2e/basic-*.js' @@ -304,7 +301,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: specToRun, }, @@ -318,16 +315,14 @@ moduleTypes.forEach(({ }) over12It('reports correct source file and line for pre-compiled typescript test files', async function () { - const { NODE_OPTIONS, ...restEnvVars } = getCiVisAgentlessConfig(receiver.port) + const envVars = getCiVisAgentlessConfig(receiver.port) try { cleanupPrecompiledSourceLineDist(cwd) // Compile the TypeScript spec to JS + source map so the plugin can resolve // the original TypeScript source file and line via the adjacent .js.map file. - // We intentionally run with NODE_OPTIONS removed because sandboxed CWDs may not - // have local preload paths (e.g. -r ./ci/init) set by outer test environments. - compilePrecompiledTypeScriptSpecs(cwd, restEnvVars) + compilePrecompiledTypeScriptSpecs(cwd, envVars) const receiverPromise = receiver .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { @@ -378,7 +373,7 @@ moduleTypes.forEach(({ childProcess = exec(testCommand, { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: 'cypress/e2e/dist/spec-source-line.cy.js', }, @@ -433,12 +428,12 @@ moduleTypes.forEach(({ }) over12It('uses declaration scanning fallback when invocationDetails line is invalid', async function () { - const { NODE_OPTIONS, ...restEnvVars } = getCiVisAgentlessConfig(receiver.port) + const envVars = getCiVisAgentlessConfig(receiver.port) try { cleanupPrecompiledSourceLineDist(cwd) - compilePrecompiledTypeScriptSpecs(cwd, restEnvVars) + compilePrecompiledTypeScriptSpecs(cwd, envVars) const receiverPromise = receiver .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { @@ -464,7 +459,7 @@ moduleTypes.forEach(({ childProcess = exec(testCommand, { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: 'cypress/e2e/dist/spec-source-line-fallback.cy.js', }, @@ -479,12 +474,12 @@ moduleTypes.forEach(({ over12It('keeps original invocationDetails line when no declaration match is found', async function () { this.timeout(140000) - const { NODE_OPTIONS, ...restEnvVars } = getCiVisAgentlessConfig(receiver.port) + const envVars = getCiVisAgentlessConfig(receiver.port) try { cleanupPrecompiledSourceLineDist(cwd) - compilePrecompiledTypeScriptSpecs(cwd, restEnvVars) + compilePrecompiledTypeScriptSpecs(cwd, envVars) const receiverPromise = receiver .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { @@ -512,7 +507,7 @@ moduleTypes.forEach(({ childProcess = exec(testCommand, { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: 'cypress/e2e/dist/spec-source-line-no-match.cy.js', }, @@ -526,7 +521,7 @@ moduleTypes.forEach(({ }) over12It('uses invocationDetails line directly for plain javascript specs without source maps', async function () { - const { NODE_OPTIONS, ...restEnvVars } = getCiVisAgentlessConfig(receiver.port) + const envVars = getCiVisAgentlessConfig(receiver.port) const receiverPromise = receiver .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { @@ -554,7 +549,7 @@ moduleTypes.forEach(({ childProcess = exec(testCommand, { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: 'cypress/e2e/spec-source-line-invocation.cy.js', }, @@ -608,14 +603,14 @@ moduleTypes.forEach(({ ) }, 60000) - const { NODE_OPTIONS, ...restEnvVars } = getCiVisAgentlessConfig(receiver.port) + const envVars = getCiVisAgentlessConfig(receiver.port) // Run Cypress directly with the TypeScript spec file — no manual compilation step. // Cypress compiles .cy.ts files on the fly via its own preprocessor/bundler. childProcess = exec(testCommand, { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: 'cypress/e2e/spec-source-line.cy.ts', }, @@ -625,46 +620,8 @@ moduleTypes.forEach(({ assert.strictEqual(exitCode, 0, 'cypress process should exit successfully') }) - if (version === '6.7.0') { - // to be removed when we drop support for cypress@6.7.0 - it('logs a warning if using a deprecated version of cypress', async () => { - let stdout = '' - const { - NODE_OPTIONS, - ...restEnvVars - } = getCiVisEvpProxyConfig(receiver.port) - - childProcess = exec( - `${testCommand} --spec cypress/e2e/spec.cy.js`, - { - cwd, - env: { - ...restEnvVars, - CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, - }, - } - ) - - childProcess.stdout?.on('data', (chunk) => { - stdout += chunk.toString() - }) - - await Promise.all([ - once(childProcess, 'exit'), - once(childProcess.stdout, 'end'), - ]) - assert.match( - stdout, - /WARNING: dd-trace support for Cypress<10.2.0 is deprecated and will not be supported in future versions of dd-trace./ - ) - }) - } - it('tags session and children with _dd.ci.library_configuration_error when settings fails 4xx', async () => { - const { - NODE_OPTIONS, // NODE_OPTIONS dd-trace config does not work with cypress - ...restEnvVars - } = getCiVisAgentlessConfig(receiver.port) + const envVars = getCiVisAgentlessConfig(receiver.port) receiver.setSettingsResponseCode(404) const eventsPromise = receiver @@ -684,7 +641,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: 'cypress/e2e/spec.cy.js', }, @@ -696,9 +653,8 @@ moduleTypes.forEach(({ it('does not crash if badly init', async () => { const { - NODE_OPTIONS, // NODE_OPTIONS dd-trace config does not work with cypress DD_CIVISIBILITY_AGENTLESS_URL, - ...restEnvVars + ...envVars } = getCiVisAgentlessConfig(receiver.port) let hasReceivedEvents = false @@ -714,7 +670,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, DD_SITE: '= invalid = url', SPEC_PATTERN: 'cypress/e2e/spec.cy.js', @@ -754,14 +710,15 @@ moduleTypes.forEach(({ it('can run and report tests', async () => { const receiverPromise = receiver .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), payloads => { - const metadataDicts = payloads.flatMap(({ payload }) => payload.metadata) + const ciVisPayloads = payloads.filter(({ payload }) => payload.metadata?.test) + const ciVisMetadataDicts = ciVisPayloads.flatMap(({ payload }) => payload.metadata) - metadataDicts.forEach(metadata => { + ciVisMetadataDicts.forEach(metadata => { for (const testLevel of TEST_LEVEL_EVENT_TYPES) { assert.strictEqual(metadata[testLevel][TEST_SESSION_NAME], 'my-test-session') } }) - const events = payloads.flatMap(({ payload }) => payload.events) + const events = ciVisPayloads.flatMap(({ payload }) => payload.events) const testSessionEvent = events.find(event => event.type === 'test_session_end') const testModuleEvent = events.find(event => event.type === 'test_module_end') @@ -895,17 +852,14 @@ moduleTypes.forEach(({ assert.match(describeHookSuite.content.meta[ERROR_MESSAGE], /error in after hook/) }, 25000) - const { - NODE_OPTIONS, // NODE_OPTIONS dd-trace config does not work with cypress - ...restEnvVars - } = getCiVisEvpProxyConfig(receiver.port) + const envVars = getCiVisEvpProxyConfig(receiver.port) childProcess = exec( testCommand, { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, DD_TAGS: 'test.customtag:customvalue,test.customtag2:customvalue2', DD_TEST_SESSION_NAME: 'my-test-session', @@ -922,10 +876,7 @@ moduleTypes.forEach(({ }) it('can report code coverage if it is available', async () => { - const { - NODE_OPTIONS, // NODE_OPTIONS dd-trace config does not work with cypress - ...restEnvVars - } = getCiVisAgentlessConfig(receiver.port) + const envVars = getCiVisAgentlessConfig(receiver.port) const receiverPromise = receiver.gatherPayloadsMaxTimeout(({ url }) => url === '/api/v2/citestcov', payloads => { const [{ payload: coveragePayloads }] = payloads @@ -952,7 +903,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: 'cypress/e2e/spec.cy.js', }, @@ -974,17 +925,14 @@ moduleTypes.forEach(({ const packfileRequestPromise = receiver .payloadReceived(({ url }) => url.endsWith('/api/v2/git/repository/packfile'), 25000) - const { - NODE_OPTIONS, // NODE_OPTIONS dd-trace config does not work with cypress - ...restEnvVars - } = getCiVisAgentlessConfig(receiver.port) + const envVars = getCiVisAgentlessConfig(receiver.port) childProcess = exec( testCommand, { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: 'cypress/e2e/spec.cy.js', }, @@ -1022,17 +970,14 @@ moduleTypes.forEach(({ assertObjectContains(eventTypes, ['test', 'test_session_end', 'test_module_end', 'test_suite_end']) }, 25000) - const { - NODE_OPTIONS, - ...restEnvVars - } = getCiVisAgentlessConfig(receiver.port) + const envVars = getCiVisAgentlessConfig(receiver.port) childProcess = exec( testCommand, { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: 'cypress/e2e/spec.cy.js', }, @@ -1093,17 +1038,14 @@ moduleTypes.forEach(({ assert.strictEqual(skippableRequest.headers['dd-api-key'], '1') }) - const { - NODE_OPTIONS, - ...restEnvVars - } = getCiVisAgentlessConfig(receiver.port) + const envVars = getCiVisAgentlessConfig(receiver.port) childProcess = exec( testCommand, { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: 'cypress/e2e/{other,spec}.cy.js', }, @@ -1151,17 +1093,14 @@ moduleTypes.forEach(({ assert.strictEqual(notSkippedTest.content.meta[TEST_STATUS], 'pass') }, 25000) - const { - NODE_OPTIONS, - ...restEnvVars - } = getCiVisAgentlessConfig(receiver.port) + const envVars = getCiVisAgentlessConfig(receiver.port) childProcess = exec( testCommand, { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: 'cypress/e2e/other.cy.js', }, @@ -1229,17 +1168,14 @@ moduleTypes.forEach(({ assert.ok(!(TEST_ITR_FORCED_RUN in unskippableFailedTest.content.meta)) }, 25000) - const { - NODE_OPTIONS, - ...restEnvVars - } = getCiVisAgentlessConfig(receiver.port) + const envVars = getCiVisAgentlessConfig(receiver.port) childProcess = exec( testCommand, { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: 'cypress/e2e/{other,spec}.cy.js', }, @@ -1301,17 +1237,14 @@ moduleTypes.forEach(({ assert.ok(!(TEST_ITR_FORCED_RUN in unskippableFailedTest.content.meta)) }, 25000) - const { - NODE_OPTIONS, - ...restEnvVars - } = getCiVisAgentlessConfig(receiver.port) + const envVars = getCiVisAgentlessConfig(receiver.port) childProcess = exec( testCommand, { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: 'cypress/e2e/{other,spec}.cy.js', }, @@ -1357,17 +1290,14 @@ moduleTypes.forEach(({ assert.strictEqual(skippableRequest.headers['dd-api-key'], '1') }) - const { - NODE_OPTIONS, - ...restEnvVars - } = getCiVisAgentlessConfig(receiver.port) + const envVars = getCiVisAgentlessConfig(receiver.port) childProcess = exec( testCommand, { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: 'cypress/e2e/spec.cy.js', }, @@ -1397,17 +1327,14 @@ moduleTypes.forEach(({ }) }, 25000) - const { - NODE_OPTIONS, - ...restEnvVars - } = getCiVisAgentlessConfig(receiver.port) + const envVars = getCiVisAgentlessConfig(receiver.port) childProcess = exec( testCommand, { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: 'cypress/e2e/spec.cy.js', }, @@ -1437,10 +1364,7 @@ moduleTypes.forEach(({ command = `node --loader=${hookFile} ../../cypress-esm-config.mjs` } - const { - NODE_OPTIONS, // NODE_OPTIONS dd-trace config does not work with cypress - ...restEnvVars - } = getCiVisAgentlessConfig(receiver.port) + const envVars = getCiVisAgentlessConfig(receiver.port) const eventsPromise = receiver .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcov'), (payloads) => { @@ -1463,7 +1387,7 @@ moduleTypes.forEach(({ { cwd: `${cwd}/ci-visibility/subproject`, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, }, } @@ -1481,10 +1405,7 @@ moduleTypes.forEach(({ }) it('still reports correct format if there is a plugin incompatibility', async () => { - const { - NODE_OPTIONS, // NODE_OPTIONS dd-trace config does not work with cypress - ...restEnvVars - } = getCiVisEvpProxyConfig(receiver.port) + const envVars = getCiVisEvpProxyConfig(receiver.port) const receiverPromise = receiver .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), payloads => { @@ -1506,7 +1427,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, CYPRESS_ENABLE_INCOMPATIBLE_PLUGIN: '1', SPEC_PATTERN: 'cypress/e2e/spec.cy.js', @@ -1520,45 +1441,6 @@ moduleTypes.forEach(({ ]) }) - it('works if after:run and after:spec are explicitly used', async () => { - const receiverPromise = receiver - .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), payloads => { - const events = payloads.flatMap(({ payload }) => payload.events) - const testSessionEvent = events.find(event => event.type === 'test_session_end') - assert.ok(testSessionEvent) - const testModuleEvent = events.find(event => event.type === 'test_module_end') - assert.ok(testModuleEvent) - const testSuiteEvents = events.filter(event => event.type === 'test_suite_end') - assert.strictEqual(testSuiteEvents.length, 4) - const testEvents = events.filter(event => event.type === 'test') - assert.strictEqual(testEvents.length, 9) - }, 30000) - - const { - NODE_OPTIONS, // NODE_OPTIONS dd-trace config does not work with cypress - ...restEnvVars - } = getCiVisEvpProxyConfig(receiver.port) - - childProcess = exec( - testCommand, - { - cwd, - env: { - ...restEnvVars, - CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, - CYPRESS_ENABLE_AFTER_RUN_CUSTOM: '1', - CYPRESS_ENABLE_AFTER_SPEC_CUSTOM: '1', - SPEC_PATTERN: 'cypress/e2e/{spec,other,hook-describe-error,hook-test-error}.cy.js', - }, - } - ) - - await Promise.all([ - once(childProcess, 'exit'), - receiverPromise, - ]) - }) - context('early flake detection', () => { it('retries new tests', async () => { receiver.setSettings({ @@ -1608,10 +1490,7 @@ moduleTypes.forEach(({ assert.strictEqual(testSession.meta[TEST_EARLY_FLAKE_ENABLED], 'true') }, 25000) - const { - NODE_OPTIONS, // NODE_OPTIONS dd-trace config does not work with cypress - ...restEnvVars - } = getCiVisEvpProxyConfig(receiver.port) + const envVars = getCiVisEvpProxyConfig(receiver.port) const specToRun = 'cypress/e2e/spec.cy.js' @@ -1620,7 +1499,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: specToRun, }, @@ -1653,10 +1532,7 @@ moduleTypes.forEach(({ }, }) - const { - NODE_OPTIONS, // NODE_OPTIONS dd-trace config does not work with cypress - ...restEnvVars - } = getCiVisEvpProxyConfig(receiver.port) + const envVars = getCiVisEvpProxyConfig(receiver.port) const receiverPromise = receiver .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), payloads => { @@ -1681,7 +1557,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: specToRun, DD_CIVISIBILITY_EARLY_FLAKE_DETECTION_ENABLED: 'false', @@ -1710,10 +1586,7 @@ moduleTypes.forEach(({ cypress: {}, }) - const { - NODE_OPTIONS, // NODE_OPTIONS dd-trace config does not work with cypress - ...restEnvVars - } = getCiVisEvpProxyConfig(receiver.port) + const envVars = getCiVisEvpProxyConfig(receiver.port) const receiverPromise = receiver .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), payloads => { @@ -1738,7 +1611,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: 'cypress/e2e/skipped-test.js', }, @@ -1767,10 +1640,7 @@ moduleTypes.forEach(({ cypress: {}, }) - const { - NODE_OPTIONS, // NODE_OPTIONS dd-trace config does not work with cypress - ...restEnvVars - } = getCiVisEvpProxyConfig(receiver.port) + const envVars = getCiVisEvpProxyConfig(receiver.port) // Request module waits before retrying; browser runs are slow — need longer gather timeout const receiverPromise = receiver @@ -1795,7 +1665,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: specToRun, }, @@ -1828,10 +1698,7 @@ moduleTypes.forEach(({ }, }) - const { - NODE_OPTIONS, // NODE_OPTIONS dd-trace config does not work with cypress - ...restEnvVars - } = getCiVisEvpProxyConfig(receiver.port) + const envVars = getCiVisEvpProxyConfig(receiver.port) const receiverPromise = receiver .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), payloads => { @@ -1856,7 +1723,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: specToRun, DD_CIVISIBILITY_EARLY_FLAKE_DETECTION_ENABLED: 'false', @@ -1889,10 +1756,7 @@ moduleTypes.forEach(({ }, }) - const { - NODE_OPTIONS, // NODE_OPTIONS dd-trace config does not work with cypress - ...restEnvVars - } = getCiVisEvpProxyConfig(receiver.port) + const envVars = getCiVisEvpProxyConfig(receiver.port) const receiverPromise = receiver .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), payloads => { @@ -1918,7 +1782,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: specToRun, }, @@ -1975,10 +1839,7 @@ moduleTypes.forEach(({ }) }, 25000) - const { - NODE_OPTIONS, // NODE_OPTIONS dd-trace config does not work with cypress - ...restEnvVars - } = getCiVisEvpProxyConfig(receiver.port) + const envVars = getCiVisEvpProxyConfig(receiver.port) const specToRun = 'cypress/e2e/spec.cy.js' @@ -1987,7 +1848,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: specToRun, CYPRESS_TEST_ISOLATION: 'false', @@ -2075,10 +1936,7 @@ moduleTypes.forEach(({ }) }, 25000) - const { - NODE_OPTIONS, - ...restEnvVars - } = getCiVisEvpProxyConfig(receiver.port) + const envVars = getCiVisEvpProxyConfig(receiver.port) const specToRun = 'cypress/e2e/spec.cy.js' @@ -2087,7 +1945,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: specToRun, }, @@ -2194,10 +2052,7 @@ moduleTypes.forEach(({ assert.equal(testExecutionOrder[9].isRetry, false) }, 30000) - const { - NODE_OPTIONS, // NODE_OPTIONS dd-trace config does not work with cypress - ...restEnvVars - } = getCiVisEvpProxyConfig(receiver.port) + const envVars = getCiVisEvpProxyConfig(receiver.port) const specToRun = 'cypress/e2e/flaky-test-retries.js' @@ -2206,7 +2061,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: specToRun, }, @@ -2252,10 +2107,7 @@ moduleTypes.forEach(({ assert.ok(!tests.some(test => test.meta[TEST_RETRY_REASON] === TEST_RETRY_REASON_TYPES.atr)) }, 25000) - const { - NODE_OPTIONS, // NODE_OPTIONS dd-trace config does not work with cypress - ...restEnvVars - } = getCiVisEvpProxyConfig(receiver.port) + const envVars = getCiVisEvpProxyConfig(receiver.port) const specToRun = 'cypress/e2e/flaky-test-retries.js' @@ -2264,7 +2116,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, DD_CIVISIBILITY_FLAKY_RETRY_ENABLED: 'false', SPEC_PATTERN: specToRun, @@ -2313,10 +2165,7 @@ moduleTypes.forEach(({ ) }, 25000) - const { - NODE_OPTIONS, // NODE_OPTIONS dd-trace config does not work with cypress - ...restEnvVars - } = getCiVisEvpProxyConfig(receiver.port) + const envVars = getCiVisEvpProxyConfig(receiver.port) const specToRun = 'cypress/e2e/flaky-test-retries.js' @@ -2325,7 +2174,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, DD_CIVISIBILITY_FLAKY_RETRY_COUNT: '1', SPEC_PATTERN: specToRun, @@ -2367,10 +2216,7 @@ moduleTypes.forEach(({ assert.strictEqual(lastFailed.meta[TEST_RETRY_REASON], TEST_RETRY_REASON_TYPES.atr) }, 25000) - const { - NODE_OPTIONS, - ...restEnvVars - } = getCiVisEvpProxyConfig(receiver.port) + const envVars = getCiVisEvpProxyConfig(receiver.port) const specToRun = 'cypress/e2e/flaky-test-retries.js' @@ -2379,7 +2225,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, DD_CIVISIBILITY_FLAKY_RETRY_COUNT: '1', SPEC_PATTERN: specToRun, @@ -2417,10 +2263,7 @@ moduleTypes.forEach(({ assert.equal(tests.filter(test => test.meta[TEST_RETRY_REASON] === TEST_RETRY_REASON_TYPES.atr).length, 0) }, 30000) - const { - NODE_OPTIONS, // NODE_OPTIONS dd-trace config does not work with cypress - ...restEnvVars - } = getCiVisEvpProxyConfig(receiver.port) + const envVars = getCiVisEvpProxyConfig(receiver.port) const specToRun = 'cypress/e2e/flaky-test-retries.js' @@ -2429,7 +2272,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: specToRun, CYPRESS_TEST_ISOLATION: 'false', @@ -2456,10 +2299,7 @@ moduleTypes.forEach(({ command = `node --loader=${hookFile} ../../cypress-esm-config.mjs` } - const { - NODE_OPTIONS, // NODE_OPTIONS dd-trace config does not work with cypress - ...restEnvVars - } = getCiVisAgentlessConfig(receiver.port) + const envVars = getCiVisAgentlessConfig(receiver.port) const eventsPromise = receiver .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { @@ -2478,7 +2318,7 @@ moduleTypes.forEach(({ { cwd: `${cwd}/ci-visibility/subproject`, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, }, } @@ -2505,10 +2345,7 @@ moduleTypes.forEach(({ }, }) - const { - NODE_OPTIONS, // NODE_OPTIONS dd-trace config does not work with cypress - ...restEnvVars - } = getCiVisEvpProxyConfig(receiver.port) + const envVars = getCiVisEvpProxyConfig(receiver.port) const receiverPromise = receiver .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), payloads => { @@ -2533,7 +2370,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: specToRun, DD_CIVISIBILITY_EARLY_FLAKE_DETECTION_ENABLED: 'false', @@ -2551,14 +2388,12 @@ moduleTypes.forEach(({ // cy.origin is not available in old versions of Cypress if (version === 'latest') { it('does not crash for multi origin tests', async () => { - const { - NODE_OPTIONS, // NODE_OPTIONS dd-trace config does not work with cypress - ...restEnvVars - } = getCiVisEvpProxyConfig(receiver.port) + const envVars = getCiVisEvpProxyConfig(receiver.port) const receiverPromise = receiver .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), payloads => { const events = payloads.flatMap(({ payload }) => payload.events) + .filter(event => event.type !== 'span') assert.strictEqual(events.length, 4) const test = events.find(event => event.type === 'test').content @@ -2588,7 +2423,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, CYPRESS_BASE_URL_SECOND: `http://localhost:${secondWebAppPort}`, SPEC_PATTERN: specToRun, @@ -2618,17 +2453,14 @@ moduleTypes.forEach(({ }) }, 25000) - const { - NODE_OPTIONS, // NODE_OPTIONS dd-trace config does not work with cypress - ...restEnvVars - } = getCiVisEvpProxyConfig(receiver.port) + const envVars = getCiVisEvpProxyConfig(receiver.port) childProcess = exec( testCommand, { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, DD_SERVICE: 'my-service', SPEC_PATTERN: 'cypress/e2e/spec.cy.js', @@ -2767,10 +2599,7 @@ moduleTypes.forEach(({ isDisabled, }) - const { - NODE_OPTIONS, - ...restEnvVars - } = getCiVisEvpProxyConfig(receiver.port) + const envVars = getCiVisEvpProxyConfig(receiver.port) const specToRun = 'cypress/e2e/attempt-to-fix.js' @@ -2779,7 +2608,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: specToRun, ...extraEnvVars, @@ -2936,10 +2765,7 @@ moduleTypes.forEach(({ const runDisableTest = async (isDisabling, extraEnvVars = {}) => { const testAssertionsPromise = getTestAssertions(isDisabling) - const { - NODE_OPTIONS, - ...restEnvVars - } = getCiVisEvpProxyConfig(receiver.port) + const envVars = getCiVisEvpProxyConfig(receiver.port) const specToRun = 'cypress/e2e/disable.js' @@ -2948,7 +2774,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: specToRun, ...extraEnvVars, @@ -3039,10 +2865,7 @@ moduleTypes.forEach(({ const runQuarantineTest = async (isQuarantining, extraEnvVars = {}) => { const testAssertionsPromise = getTestAssertions(isQuarantining) - const { - NODE_OPTIONS, - ...restEnvVars - } = getCiVisEvpProxyConfig(receiver.port) + const envVars = getCiVisEvpProxyConfig(receiver.port) const specToRun = 'cypress/e2e/quarantine.js' @@ -3051,7 +2874,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: specToRun, ...extraEnvVars, @@ -3110,10 +2933,7 @@ moduleTypes.forEach(({ assert.strictEqual(tests.length, 1) }, 60000) - const { - NODE_OPTIONS, - ...restEnvVars - } = getCiVisEvpProxyConfig(receiver.port) + const envVars = getCiVisEvpProxyConfig(receiver.port) const specToRun = 'cypress/e2e/attempt-to-fix.js' @@ -3122,7 +2942,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: specToRun, DD_TRACE_DEBUG: '1', @@ -3182,10 +3002,7 @@ moduleTypes.forEach(({ }) }, 25000) - const { - NODE_OPTIONS, - ...restEnvVars - } = getCiVisEvpProxyConfig(receiver.port) + const envVars = getCiVisEvpProxyConfig(receiver.port) const specToRun = 'cypress/e2e/attempt-to-fix.js' @@ -3194,7 +3011,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: specToRun, CYPRESS_SHOULD_ALWAYS_PASS: '1', @@ -3273,10 +3090,7 @@ moduleTypes.forEach(({ }) }, 25000) - const { - NODE_OPTIONS, - ...restEnvVars - } = getCiVisEvpProxyConfig(receiver.port) + const envVars = getCiVisEvpProxyConfig(receiver.port) const specToRun = 'cypress/e2e/attempt-to-fix-order.js' @@ -3285,7 +3099,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: specToRun, }, @@ -3314,7 +3128,9 @@ moduleTypes.forEach(({ it('adds capabilities to tests', async () => { const receiverPromise = receiver .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), payloads => { - const metadataDicts = payloads.flatMap(({ payload }) => payload.metadata) + const metadataDicts = payloads + .filter(({ payload }) => payload.metadata?.test) + .flatMap(({ payload }) => payload.metadata) assert.ok(metadataDicts.length > 0) metadataDicts.forEach(metadata => { @@ -3331,10 +3147,7 @@ moduleTypes.forEach(({ }) }, 25000) - const { - NODE_OPTIONS, - ...restEnvVars - } = getCiVisEvpProxyConfig(receiver.port) + const envVars = getCiVisEvpProxyConfig(receiver.port) const specToRun = 'cypress/e2e/spec.cy.js' @@ -3343,7 +3156,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, DD_TEST_SESSION_NAME: 'my-test-session-name', SPEC_PATTERN: specToRun, @@ -3474,10 +3287,7 @@ moduleTypes.forEach(({ ) => { const testAssertionsPromise = getTestAssertions({ isModified, isEfd, isNew }) - const { - NODE_OPTIONS, - ...restEnvVars - } = getCiVisEvpProxyConfig(receiver.port) + const envVars = getCiVisEvpProxyConfig(receiver.port) const specToRun = 'cypress/e2e/impacted-test.js' @@ -3486,7 +3296,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: specToRun, GITHUB_BASE_REF: '', @@ -3593,10 +3403,7 @@ moduleTypes.forEach(({ assert.equal(retriedTests.length, 0) }, 25000) - const { - NODE_OPTIONS, - ...restEnvVars - } = getCiVisEvpProxyConfig(receiver.port) + const envVars = getCiVisEvpProxyConfig(receiver.port) const specToRun = 'cypress/e2e/impacted-test.js' @@ -3605,7 +3412,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: specToRun, GITHUB_BASE_REF: '', @@ -3686,10 +3493,7 @@ moduleTypes.forEach(({ }) }, 25000) - const { - NODE_OPTIONS, - ...restEnvVars - } = getCiVisEvpProxyConfig(receiver.port) + const envVars = getCiVisEvpProxyConfig(receiver.port) const specToRun = 'cypress/e2e/impacted-test-order.js' @@ -3698,7 +3502,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: specToRun, GITHUB_BASE_REF: '', @@ -3724,5 +3528,6 @@ moduleTypes.forEach(({ assert.match(testOutput, /Retrying "impacted test order second test" to detect flakes because it is modified/) }) }) + }) }) diff --git a/packages/datadog-instrumentations/src/cypress.js b/packages/datadog-instrumentations/src/cypress.js index 1d22ffe0a42..c357b1e8ec5 100644 --- a/packages/datadog-instrumentations/src/cypress.js +++ b/packages/datadog-instrumentations/src/cypress.js @@ -1,11 +1,94 @@ 'use strict' +const path = require('node:path') + const { DD_MAJOR } = require('../../../version') const { addHook } = require('./helpers/instrument') -// No handler because this is only useful for testing. -// Cypress plugin does not patch any library. +const noopTask = { + 'dd:testSuiteStart': () => null, + 'dd:beforeEach': () => ({}), + 'dd:afterEach': () => null, + 'dd:addTags': () => null, + 'dd:log': () => null, +} + +// Resolve base path once, using realpath to avoid macOS /var->/private/var symlink +// module-cache mismatches between ci/init.js and this instrumentation. +const basePath = path.resolve(__dirname, '..', '..', '..') + +function wrapSetupNodeEvents (originalSetupNodeEvents) { + return function ddSetupNodeEvents (on, config) { + // Call user's setupNodeEvents first so dd-trace hooks register last + if (originalSetupNodeEvents) { + const result = originalSetupNodeEvents.call(this, on, config) + if (result) { + config = result + } + } + + try { + // Use global._ddtrace to bypass macOS symlink module-cache mismatch. + // The tracer is initialized by ci/init.js via NODE_OPTIONS before this runs. + const tracer = global._ddtrace + + if (!tracer || !tracer._initialized) { + on('task', noopTask) + return config + } + + const NoopTracer = require(path.join(basePath, 'packages', 'dd-trace', 'src', 'noop', 'tracer')) + + if (tracer._tracer instanceof NoopTracer) { + on('task', noopTask) + return config + } + + const cypressPlugin = require(path.join(basePath, 'packages', 'datadog-plugin-cypress', 'src', 'cypress-plugin')) + + // If the user already called the manual plugin (dd-trace/ci/cypress/plugin), + // cypressPlugin._isInit is true. Skip to avoid double registration. + if (cypressPlugin._isInit) { + return config + } + + on('before:run', cypressPlugin.beforeRun.bind(cypressPlugin)) + on('after:spec', cypressPlugin.afterSpec.bind(cypressPlugin)) + on('after:run', cypressPlugin.afterRun.bind(cypressPlugin)) + on('task', cypressPlugin.getTasks()) + + return cypressPlugin.init(tracer, config) + } catch (e) { + // If anything goes wrong, register noop tasks so Cypress can still run + on('task', noopTask) + return config + } + } +} + +function wrapConfig (config) { + if (config?.e2e) { + config.e2e.setupNodeEvents = wrapSetupNodeEvents(config.e2e.setupNodeEvents) + } +} + addHook({ name: 'cypress', versions: DD_MAJOR >= 6 ? ['>=10.2.0'] : ['>=6.7.0'], -}, lib => lib) +}, (cypress) => { + const originalDefineConfig = cypress.defineConfig + cypress.defineConfig = function (config) { + wrapConfig(config) + return originalDefineConfig(config) + } + + const originalRun = cypress.run + cypress.run = function (options) { + if (options?.config) { + wrapConfig(options.config) + } + return originalRun.apply(this, arguments) + } + + return cypress +}) From 07d4cdcf405a1df5afe954a1e0f711ce3a2a2171 Mon Sep 17 00:00:00 2001 From: Juan Fernandez Date: Fri, 20 Mar 2026 11:51:56 +0100 Subject: [PATCH 02/15] compatible with older version --- .../cypress-legacy-plugin.config.js | 20 +++++++++ integration-tests/cypress/cypress.spec.js | 43 +++++++++++++++++++ .../datadog-instrumentations/src/cypress.js | 14 +++++- 3 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 integration-tests/cypress-legacy-plugin.config.js diff --git a/integration-tests/cypress-legacy-plugin.config.js b/integration-tests/cypress-legacy-plugin.config.js new file mode 100644 index 00000000000..d407d48df4e --- /dev/null +++ b/integration-tests/cypress-legacy-plugin.config.js @@ -0,0 +1,20 @@ +'use strict' + +// Backwards compatibility config: uses defineConfig AND the old manual plugin. +// When NODE_OPTIONS is set, the instrumentation wraps defineConfig and injects +// setupNodeEvents. The manual plugin call inside the user's setupNodeEvents sets +// cypressPlugin._isInit = true, so the instrumentation skips its own registration. +const { defineConfig } = require('cypress') +const ddTracePlugin = require('dd-trace/ci/cypress/plugin') + +module.exports = defineConfig({ + defaultCommandTimeout: 1000, + e2e: { + setupNodeEvents (on, config) { + return ddTracePlugin(on, config) + }, + specPattern: process.env.SPEC_PATTERN || 'cypress/e2e/**/*.cy.js', + }, + video: false, + screenshotOnRunFailure: false, +}) diff --git a/integration-tests/cypress/cypress.spec.js b/integration-tests/cypress/cypress.spec.js index b6f2bfe7ce9..edd9b9719cc 100644 --- a/integration-tests/cypress/cypress.spec.js +++ b/integration-tests/cypress/cypress.spec.js @@ -314,6 +314,49 @@ moduleTypes.forEach(({ ]) }) + // cypress-legacy-plugin.config.js uses defineConfig which only exists in Cypress >=10 + const legacyPluginIt = (version !== '6.7.0') ? it : it.skip + legacyPluginIt('is backwards compatible with the old manual plugin approach', async () => { + receiver.setInfoResponse({ endpoints: [] }) + + const receiverPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url === '/v0.4/traces', (payloads) => { + const testSpans = payloads.flatMap(({ payload }) => payload.flatMap(trace => trace)) + + const passedTestSpan = testSpans.find(span => + span.resource === 'cypress/e2e/basic-pass.js.basic pass suite can pass' + ) + + assertObjectContains(passedTestSpan, { + name: 'cypress.test', + type: 'test', + meta: { + [TEST_STATUS]: 'pass', + [TEST_FRAMEWORK]: 'cypress', + }, + }) + }, 60000) + + const envVars = getCiVisEvpProxyConfig(receiver.port) + + childProcess = exec( + './node_modules/.bin/cypress run --config-file cypress-legacy-plugin.config.js', + { + cwd, + env: { + ...envVars, + CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, + SPEC_PATTERN: 'cypress/e2e/basic-pass.js', + }, + } + ) + + await Promise.all([ + once(childProcess, 'exit'), + receiverPromise, + ]) + }) + over12It('reports correct source file and line for pre-compiled typescript test files', async function () { const envVars = getCiVisAgentlessConfig(receiver.port) diff --git a/packages/datadog-instrumentations/src/cypress.js b/packages/datadog-instrumentations/src/cypress.js index c357b1e8ec5..96a808fed3d 100644 --- a/packages/datadog-instrumentations/src/cypress.js +++ b/packages/datadog-instrumentations/src/cypress.js @@ -72,9 +72,11 @@ function wrapConfig (config) { } } +// Cypress >=10 introduced defineConfig and setupNodeEvents. +// Auto-instrumentation wraps these to inject the plugin automatically. addHook({ name: 'cypress', - versions: DD_MAJOR >= 6 ? ['>=10.2.0'] : ['>=6.7.0'], + versions: ['>=10.2.0'], }, (cypress) => { const originalDefineConfig = cypress.defineConfig cypress.defineConfig = function (config) { @@ -92,3 +94,13 @@ addHook({ return cypress }) + +// Cypress <10 uses the old pluginsFile approach. No auto-instrumentation; +// users must use the manual dd-trace/ci/cypress/plugin setup. +// This hook is kept so the plugin system registers Cypress for version tracking. +if (DD_MAJOR < 6) { + addHook({ + name: 'cypress', + versions: ['>=6.7.0 <10.2.0'], + }, lib => lib) +} From 5a8932c8b59fa5741680e7f45e8e15e7b797d2e0 Mon Sep 17 00:00:00 2001 From: Juan Fernandez Date: Fri, 20 Mar 2026 12:20:55 +0100 Subject: [PATCH 03/15] fix --- ci/init.js | 21 +++++++++++++++++++ integration-tests/cypress/cypress.spec.js | 1 - package.json | 2 +- .../datadog-instrumentations/src/cypress.js | 2 +- 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/ci/init.js b/ci/init.js index e3b2a36d403..085d9c3ce10 100644 --- a/ci/init.js +++ b/ci/init.js @@ -7,6 +7,7 @@ const log = require('../packages/dd-trace/src/log') const { getEnvironmentVariable, getValueFromEnvSources } = require('../packages/dd-trace/src/config/helper') const PACKAGE_MANAGERS = ['npm', 'yarn', 'pnpm'] +const CLI_TOOLS = ['cypress'] const DEFAULT_FLUSH_INTERVAL = 5000 const JEST_FLUSH_INTERVAL = 0 const EXPORTER_MAP = { @@ -23,6 +24,21 @@ function isPackageManager () { ) } +function isCliTool () { + // Skip CLI tools like Cypress that spawn child processes we don't want to instrument + if (CLI_TOOLS.some(tool => + process.argv[1]?.endsWith(`bin/${tool}`) || process.argv[1]?.endsWith(`bin/${tool}.js`) + )) { + return true + } + // Skip Electron processes (e.g. Cypress binary) - the config child process + // uses the user's Node.js binary, so process.versions.electron won't be set there + if (process.versions.electron) { + return true + } + return false +} + function detectTestWorkerType () { if (getEnvironmentVariable('JEST_WORKER_ID')) return 'jest' if (getEnvironmentVariable('CUCUMBER_WORKER_ID')) return 'cucumber' @@ -51,6 +67,11 @@ if (!isTestWorker && isPackageManager()) { shouldInit = false } +if (!isTestWorker && isCliTool()) { + log.debug('dd-trace is not initialized in a CLI tool.') + shouldInit = false +} + if (isTestWorker) { baseOptions.telemetry = { enabled: false } baseOptions.experimental = { diff --git a/integration-tests/cypress/cypress.spec.js b/integration-tests/cypress/cypress.spec.js index edd9b9719cc..905b7cb6a30 100644 --- a/integration-tests/cypress/cypress.spec.js +++ b/integration-tests/cypress/cypress.spec.js @@ -3571,6 +3571,5 @@ moduleTypes.forEach(({ assert.match(testOutput, /Retrying "impacted test order second test" to detect flakes because it is modified/) }) }) - }) }) diff --git a/package.json b/package.json index f1372653304..5d24d01b72b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dd-trace", - "version": "6.0.0-pre", + "version": "5.0.0-pre", "description": "Datadog APM tracing client for JavaScript", "main": "index.js", "typings": "index.d.ts", diff --git a/packages/datadog-instrumentations/src/cypress.js b/packages/datadog-instrumentations/src/cypress.js index 96a808fed3d..62ad7b7d0bc 100644 --- a/packages/datadog-instrumentations/src/cypress.js +++ b/packages/datadog-instrumentations/src/cypress.js @@ -58,7 +58,7 @@ function wrapSetupNodeEvents (originalSetupNodeEvents) { on('task', cypressPlugin.getTasks()) return cypressPlugin.init(tracer, config) - } catch (e) { + } catch { // If anything goes wrong, register noop tasks so Cypress can still run on('task', noopTask) return config From a9a8f5d731b2d936d5a2bf6722a9364734f03537 Mon Sep 17 00:00:00 2001 From: Juan Fernandez Date: Fri, 20 Mar 2026 12:38:41 +0100 Subject: [PATCH 04/15] revert package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5d24d01b72b..f1372653304 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dd-trace", - "version": "5.0.0-pre", + "version": "6.0.0-pre", "description": "Datadog APM tracing client for JavaScript", "main": "index.js", "typings": "index.d.ts", From 5459da71cc5595b6eec7b939d238590e38cb10f9 Mon Sep 17 00:00:00 2001 From: Juan Fernandez Date: Mon, 23 Mar 2026 14:16:24 +0100 Subject: [PATCH 05/15] fix support file --- integration-tests/cypress/cypress.spec.js | 14 ++++++- integration-tests/cypress/support/e2e.js | 1 - .../datadog-instrumentations/src/cypress.js | 38 ++++++++++++++++++- 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/integration-tests/cypress/cypress.spec.js b/integration-tests/cypress/cypress.spec.js index 905b7cb6a30..fd818c25804 100644 --- a/integration-tests/cypress/cypress.spec.js +++ b/integration-tests/cypress/cypress.spec.js @@ -339,6 +339,7 @@ moduleTypes.forEach(({ const envVars = getCiVisEvpProxyConfig(receiver.port) + let testOutput = '' childProcess = exec( './node_modules/.bin/cypress run --config-file cypress-legacy-plugin.config.js', { @@ -350,11 +351,22 @@ moduleTypes.forEach(({ }, } ) + childProcess.stdout?.on('data', (d) => { testOutput += d }) + childProcess.stderr?.on('data', (d) => { testOutput += d }) await Promise.all([ once(childProcess, 'exit'), + once(childProcess.stdout, 'end'), + once(childProcess.stderr, 'end'), receiverPromise, - ]) + ]).catch((e) => { + const lines = testOutput.split('\n').filter(l => + l.includes('supportFile') || l.includes('dd-trace') || l.includes('Error') || l.includes('error') + ) + // eslint-disable-next-line no-console + console.log('DEBUG backwards compat output:', JSON.stringify(lines.slice(0, 20))) + throw e + }) }) over12It('reports correct source file and line for pre-compiled typescript test files', async function () { diff --git a/integration-tests/cypress/support/e2e.js b/integration-tests/cypress/support/e2e.js index db40f9db820..22ea16b1f0a 100644 --- a/integration-tests/cypress/support/e2e.js +++ b/integration-tests/cypress/support/e2e.js @@ -2,4 +2,3 @@ if (Cypress.env('ENABLE_INCOMPATIBLE_PLUGIN')) { require('cypress-fail-fast') } -require('dd-trace/ci/cypress/support') diff --git a/packages/datadog-instrumentations/src/cypress.js b/packages/datadog-instrumentations/src/cypress.js index 62ad7b7d0bc..123b2001942 100644 --- a/packages/datadog-instrumentations/src/cypress.js +++ b/packages/datadog-instrumentations/src/cypress.js @@ -1,5 +1,6 @@ 'use strict' +const { readFileSync, writeFileSync } = require('node:fs') const path = require('node:path') const { DD_MAJOR } = require('../../../version') @@ -17,17 +18,50 @@ const noopTask = { // module-cache mismatches between ci/init.js and this instrumentation. const basePath = path.resolve(__dirname, '..', '..', '..') +/** + * Injects dd-trace's browser-side support code into the Cypress support file. + * Prepends a `require('dd-trace/ci/cypress/support')` to the user's support file + * so browser-side hooks (beforeEach, afterEach, retries, etc.) are loaded automatically. + * + * @param {object} config Cypress resolved config object + */ +function injectSupportFile (config) { + const originalSupportFile = config.supportFile + if (!originalSupportFile || originalSupportFile === false) return + + // If the user's support file already loads our support, skip injection. + try { + const content = readFileSync(originalSupportFile, 'utf8') + if (content.includes('dd-trace/ci/cypress/support') || content.includes('datadog-plugin-cypress/src/support')) { + return + } + + // Prepend our support require to the user's support file + const ddSupportRequire = "require('dd-trace/ci/cypress/support')\n" + writeFileSync(originalSupportFile, ddSupportRequire + content) + } catch { + // Can't read/write the file — skip injection to avoid breaking anything + } +} + function wrapSetupNodeEvents (originalSetupNodeEvents) { return function ddSetupNodeEvents (on, config) { - // Call user's setupNodeEvents first so dd-trace hooks register last + // Call user's setupNodeEvents first so dd-trace hooks register last. + // Cypress passes config by reference, so mutations are preserved. + // Only replace config if the user returns a valid config object (has projectRoot). + // This guards against the old manual plugin returning an empty object from cypressPlugin.init(). if (originalSetupNodeEvents) { const result = originalSetupNodeEvents.call(this, on, config) - if (result) { + if (result?.projectRoot) { config = result } } try { + // Always inject the support file, even if the manual plugin was already called. + // This ensures browser-side hooks are loaded regardless of the approach used. + injectSupportFile(config) + // Use global._ddtrace to bypass macOS symlink module-cache mismatch. // The tracer is initialized by ci/init.js via NODE_OPTIONS before this runs. const tracer = global._ddtrace From a9b053ed93a29cdfb636782eb3d6be4dbddef7b9 Mon Sep 17 00:00:00 2001 From: Juan Fernandez Date: Tue, 24 Mar 2026 11:52:28 +0100 Subject: [PATCH 06/15] improve --- .../cypress-custom-after-hooks.config.js | 36 ++++++ integration-tests/cypress/cypress.spec.js | 54 +++++++-- .../datadog-instrumentations/src/cypress.js | 107 ++++++++++++------ 3 files changed, 155 insertions(+), 42 deletions(-) create mode 100644 integration-tests/cypress-custom-after-hooks.config.js diff --git a/integration-tests/cypress-custom-after-hooks.config.js b/integration-tests/cypress-custom-after-hooks.config.js new file mode 100644 index 00000000000..78d271b7b9f --- /dev/null +++ b/integration-tests/cypress-custom-after-hooks.config.js @@ -0,0 +1,36 @@ +'use strict' + +const { defineConfig } = require('cypress') + +module.exports = defineConfig({ + defaultCommandTimeout: 1000, + e2e: { + setupNodeEvents (on, config) { + on('after:spec', (spec, results) => { + // eslint-disable-next-line no-console + console.log('[custom:after:spec]', spec.relative, results.stats.passes) + return new Promise((resolve) => { + setTimeout(() => { + // eslint-disable-next-line no-console + console.log('[custom:after:spec:resolved]') + resolve() + }, 50) + }) + }) + on('after:run', (results) => { + // eslint-disable-next-line no-console + console.log('[custom:after:run]', results.totalPassed) + return new Promise((resolve) => { + setTimeout(() => { + // eslint-disable-next-line no-console + console.log('[custom:after:run:resolved]') + resolve() + }, 50) + }) + }) + }, + specPattern: process.env.SPEC_PATTERN || 'cypress/e2e/**/*.cy.js', + }, + video: false, + screenshotOnRunFailure: false, +}) diff --git a/integration-tests/cypress/cypress.spec.js b/integration-tests/cypress/cypress.spec.js index fd818c25804..d98c491da9a 100644 --- a/integration-tests/cypress/cypress.spec.js +++ b/integration-tests/cypress/cypress.spec.js @@ -339,7 +339,6 @@ moduleTypes.forEach(({ const envVars = getCiVisEvpProxyConfig(receiver.port) - let testOutput = '' childProcess = exec( './node_modules/.bin/cypress run --config-file cypress-legacy-plugin.config.js', { @@ -351,6 +350,44 @@ moduleTypes.forEach(({ }, } ) + + await Promise.all([ + once(childProcess, 'exit'), + receiverPromise, + ]) + }) + + it('custom after:spec and after:run handlers are chained with dd-trace instrumentation', async () => { + const receiverPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { + const ciVisPayloads = payloads.filter(({ payload }) => payload.metadata?.test) + const events = ciVisPayloads.flatMap(({ payload }) => payload.events) + const passedTest = events.find(event => + event.type === 'test' && + event.content.resource === 'cypress/e2e/basic-pass.js.basic pass suite can pass' + ) + assertObjectContains(passedTest?.content, { + meta: { + [TEST_STATUS]: 'pass', + [TEST_FRAMEWORK]: 'cypress', + }, + }) + }, 60000) + + const envVars = getCiVisAgentlessConfig(receiver.port) + + let testOutput = '' + childProcess = exec( + './node_modules/.bin/cypress run --config-file cypress-custom-after-hooks.config.js', + { + cwd, + env: { + ...envVars, + CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, + SPEC_PATTERN: 'cypress/e2e/basic-pass.js', + }, + } + ) childProcess.stdout?.on('data', (d) => { testOutput += d }) childProcess.stderr?.on('data', (d) => { testOutput += d }) @@ -359,14 +396,13 @@ moduleTypes.forEach(({ once(childProcess.stdout, 'end'), once(childProcess.stderr, 'end'), receiverPromise, - ]).catch((e) => { - const lines = testOutput.split('\n').filter(l => - l.includes('supportFile') || l.includes('dd-trace') || l.includes('Error') || l.includes('error') - ) - // eslint-disable-next-line no-console - console.log('DEBUG backwards compat output:', JSON.stringify(lines.slice(0, 20))) - throw e - }) + ]) + + // Verify both dd-trace spans AND the custom handlers ran (including their async resolutions) + assert.match(testOutput, /\[custom:after:spec\]/) + assert.match(testOutput, /\[custom:after:spec:resolved\]/) + assert.match(testOutput, /\[custom:after:run\]/) + assert.match(testOutput, /\[custom:after:run:resolved\]/) }) over12It('reports correct source file and line for pre-compiled typescript test files', async function () { diff --git a/packages/datadog-instrumentations/src/cypress.js b/packages/datadog-instrumentations/src/cypress.js index 123b2001942..94ff1e920d8 100644 --- a/packages/datadog-instrumentations/src/cypress.js +++ b/packages/datadog-instrumentations/src/cypress.js @@ -1,8 +1,8 @@ 'use strict' -const { readFileSync, writeFileSync } = require('node:fs') -const path = require('node:path') +const fs = require('fs') +const shimmer = require('../../datadog-shimmer') const { DD_MAJOR } = require('../../../version') const { addHook } = require('./helpers/instrument') @@ -14,13 +14,9 @@ const noopTask = { 'dd:log': () => null, } -// Resolve base path once, using realpath to avoid macOS /var->/private/var symlink -// module-cache mismatches between ci/init.js and this instrumentation. -const basePath = path.resolve(__dirname, '..', '..', '..') - /** * Injects dd-trace's browser-side support code into the Cypress support file. - * Prepends a `require('dd-trace/ci/cypress/support')` to the user's support file + * Prepends a `require`/`import` of dd-trace's support to the user's support file * so browser-side hooks (beforeEach, afterEach, retries, etc.) are loaded automatically. * * @param {object} config Cypress resolved config object @@ -31,14 +27,16 @@ function injectSupportFile (config) { // If the user's support file already loads our support, skip injection. try { - const content = readFileSync(originalSupportFile, 'utf8') - if (content.includes('dd-trace/ci/cypress/support') || content.includes('datadog-plugin-cypress/src/support')) { - return - } + const content = fs.readFileSync(originalSupportFile, 'utf8') + if (content.includes('dd-trace/ci/cypress/support')) return + + // Use ESM import syntax for .mjs files, CommonJS require for everything else + const isEsm = originalSupportFile.endsWith('.mjs') + const ddSupportLine = isEsm + ? "import 'dd-trace/ci/cypress/support'\n" + : "require('dd-trace/ci/cypress/support')\n" - // Prepend our support require to the user's support file - const ddSupportRequire = "require('dd-trace/ci/cypress/support')\n" - writeFileSync(originalSupportFile, ddSupportRequire + content) + fs.writeFileSync(originalSupportFile, ddSupportLine + content) } catch { // Can't read/write the file — skip injection to avoid breaking anything } @@ -46,12 +44,26 @@ function injectSupportFile (config) { function wrapSetupNodeEvents (originalSetupNodeEvents) { return function ddSetupNodeEvents (on, config) { - // Call user's setupNodeEvents first so dd-trace hooks register last. - // Cypress passes config by reference, so mutations are preserved. + // Intercept after:spec and after:run registrations from user's setupNodeEvents + // so we can chain them with dd-trace's handlers rather than overriding them. + const userAfterSpecHandlers = [] + const userAfterRunHandlers = [] + + const wrappedOn = (event, handler) => { + if (event === 'after:spec') { + userAfterSpecHandlers.push(handler) + } else if (event === 'after:run') { + userAfterRunHandlers.push(handler) + } else { + on(event, handler) + } + } + + // Call user's setupNodeEvents first so user config mutations are applied. // Only replace config if the user returns a valid config object (has projectRoot). // This guards against the old manual plugin returning an empty object from cypressPlugin.init(). if (originalSetupNodeEvents) { - const result = originalSetupNodeEvents.call(this, on, config) + const result = originalSetupNodeEvents.call(this, wrappedOn, config) if (result?.projectRoot) { config = result } @@ -59,36 +71,63 @@ function wrapSetupNodeEvents (originalSetupNodeEvents) { try { // Always inject the support file, even if the manual plugin was already called. - // This ensures browser-side hooks are loaded regardless of the approach used. injectSupportFile(config) - // Use global._ddtrace to bypass macOS symlink module-cache mismatch. - // The tracer is initialized by ci/init.js via NODE_OPTIONS before this runs. + // global._ddtrace is the singleton set by dd-trace/index.js. It is always the + // same object regardless of how dd-trace was required (which path was resolved). + // On macOS, /var -> /private/var symlinks mean the same physical file can be + // cached under two different paths, creating multiple module instances. Using + // the global bypasses module resolution entirely and guarantees we get the one + // tracer that ci/init.js already initialized via NODE_OPTIONS. const tracer = global._ddtrace if (!tracer || !tracer._initialized) { + // Flush user's after:spec/after:run through since we won't be registering ours + for (const h of userAfterSpecHandlers) on('after:spec', h) + for (const h of userAfterRunHandlers) on('after:run', h) on('task', noopTask) return config } - const NoopTracer = require(path.join(basePath, 'packages', 'dd-trace', 'src', 'noop', 'tracer')) + const NoopTracer = require('../../../packages/dd-trace/src/noop/tracer') if (tracer._tracer instanceof NoopTracer) { + for (const h of userAfterSpecHandlers) on('after:spec', h) + for (const h of userAfterRunHandlers) on('after:run', h) on('task', noopTask) return config } - const cypressPlugin = require(path.join(basePath, 'packages', 'datadog-plugin-cypress', 'src', 'cypress-plugin')) + const cypressPlugin = require('../../../packages/datadog-plugin-cypress/src/cypress-plugin') // If the user already called the manual plugin (dd-trace/ci/cypress/plugin), - // cypressPlugin._isInit is true. Skip to avoid double registration. + // cypressPlugin._isInit is true. Re-register their intercepted handlers and skip. if (cypressPlugin._isInit) { + for (const h of userAfterSpecHandlers) on('after:spec', h) + for (const h of userAfterRunHandlers) on('after:run', h) return config } on('before:run', cypressPlugin.beforeRun.bind(cypressPlugin)) - on('after:spec', cypressPlugin.afterSpec.bind(cypressPlugin)) - on('after:run', cypressPlugin.afterRun.bind(cypressPlugin)) + + // Chain user's after:spec handlers with dd-trace's, awaiting each in sequence + on('after:spec', (spec, results) => { + const chain = userAfterSpecHandlers.reduce( + (p, h) => p.then(() => h(spec, results)), + Promise.resolve() + ) + return chain.then(() => cypressPlugin.afterSpec(spec, results)) + }) + + // Chain user's after:run handlers with dd-trace's, awaiting each in sequence + on('after:run', (results) => { + const chain = userAfterRunHandlers.reduce( + (p, h) => p.then(() => h(results)), + Promise.resolve() + ) + return chain.then(() => cypressPlugin.afterRun(results)) + }) + on('task', cypressPlugin.getTasks()) return cypressPlugin.init(tracer, config) @@ -104,6 +143,10 @@ function wrapConfig (config) { if (config?.e2e) { config.e2e.setupNodeEvents = wrapSetupNodeEvents(config.e2e.setupNodeEvents) } + // Also wrap component testing config if present + if (config?.component) { + config.component.setupNodeEvents = wrapSetupNodeEvents(config.component.setupNodeEvents) + } } // Cypress >=10 introduced defineConfig and setupNodeEvents. @@ -112,19 +155,17 @@ addHook({ name: 'cypress', versions: ['>=10.2.0'], }, (cypress) => { - const originalDefineConfig = cypress.defineConfig - cypress.defineConfig = function (config) { + shimmer.wrap(cypress, 'defineConfig', (defineConfig) => function (config) { wrapConfig(config) - return originalDefineConfig(config) - } + return defineConfig(config) + }) - const originalRun = cypress.run - cypress.run = function (options) { + shimmer.wrap(cypress, 'run', (run) => function (options) { if (options?.config) { wrapConfig(options.config) } - return originalRun.apply(this, arguments) - } + return run.apply(this, arguments) + }) return cypress }) From 017ecdbe31f6856daee68cab2e4ff414f007e1d0 Mon Sep 17 00:00:00 2001 From: Juan Fernandez Date: Wed, 25 Mar 2026 13:41:31 +0100 Subject: [PATCH 07/15] fixes --- ci/cypress/polyfills.js | 15 +++++++++++++++ integration-tests/cypress/support/e2e.js | 1 + packages/datadog-instrumentations/src/cypress.js | 9 +++++++-- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/ci/cypress/polyfills.js b/ci/cypress/polyfills.js index 364808c28a5..4ca6e942d13 100644 --- a/ci/cypress/polyfills.js +++ b/ci/cypress/polyfills.js @@ -1,5 +1,20 @@ 'use strict' +// node: prefix for built-in modules (e.g. require('node:path')) was added in +// Node.js 14.18.0 / 16.0.0. Cypress 6.7.0 runs on Node.js 12, so we patch +// Module._resolveFilename to strip the prefix before any dd-trace code loads. +const Module = require('module') +const originalResolveFilename = Module._resolveFilename +Module._resolveFilename = function (request, parent, isMain, options) { + return originalResolveFilename.call( + this, + request.startsWith('node:') ? request.slice(5) : request, + parent, + isMain, + options + ) +} + if (!Object.hasOwn) { Object.defineProperty(Object, 'hasOwn', { // eslint-disable-next-line prefer-object-has-own diff --git a/integration-tests/cypress/support/e2e.js b/integration-tests/cypress/support/e2e.js index 22ea16b1f0a..db40f9db820 100644 --- a/integration-tests/cypress/support/e2e.js +++ b/integration-tests/cypress/support/e2e.js @@ -2,3 +2,4 @@ if (Cypress.env('ENABLE_INCOMPATIBLE_PLUGIN')) { require('cypress-fail-fast') } +require('dd-trace/ci/cypress/support') diff --git a/packages/datadog-instrumentations/src/cypress.js b/packages/datadog-instrumentations/src/cypress.js index 94ff1e920d8..c2c32c8e5d1 100644 --- a/packages/datadog-instrumentations/src/cypress.js +++ b/packages/datadog-instrumentations/src/cypress.js @@ -139,12 +139,17 @@ function wrapSetupNodeEvents (originalSetupNodeEvents) { } } +const DD_WRAPPED = Symbol('dd-trace.cypress.wrapped') + function wrapConfig (config) { - if (config?.e2e) { + if (!config || config[DD_WRAPPED]) return + config[DD_WRAPPED] = true + + if (config.e2e) { config.e2e.setupNodeEvents = wrapSetupNodeEvents(config.e2e.setupNodeEvents) } // Also wrap component testing config if present - if (config?.component) { + if (config.component) { config.component.setupNodeEvents = wrapSetupNodeEvents(config.component.setupNodeEvents) } } From 923803b2c10012e4f6184967894e6a728fd90db0 Mon Sep 17 00:00:00 2001 From: Juan Fernandez Date: Mon, 30 Mar 2026 13:49:39 +0200 Subject: [PATCH 08/15] fix(ci-visibility): use event type filter for custom handler chaining test On CI, early-flush payloads may contain test events without per-level metadata keys, causing the metadata?.test filter to exclude them. Use a simpler event.type === 'test' filter instead. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- integration-tests/cypress/cypress.spec.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/integration-tests/cypress/cypress.spec.js b/integration-tests/cypress/cypress.spec.js index d98c491da9a..02765aecd48 100644 --- a/integration-tests/cypress/cypress.spec.js +++ b/integration-tests/cypress/cypress.spec.js @@ -360,10 +360,10 @@ moduleTypes.forEach(({ it('custom after:spec and after:run handlers are chained with dd-trace instrumentation', async () => { const receiverPromise = receiver .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { - const ciVisPayloads = payloads.filter(({ payload }) => payload.metadata?.test) - const events = ciVisPayloads.flatMap(({ payload }) => payload.events) + const events = payloads + .flatMap(({ payload }) => payload.events) + .filter(event => event.type === 'test') const passedTest = events.find(event => - event.type === 'test' && event.content.resource === 'cypress/e2e/basic-pass.js.basic pass suite can pass' ) assertObjectContains(passedTest?.content, { From ba4b9ae603989af744a1f6dc9c4ca3ec923840c8 Mon Sep 17 00:00:00 2001 From: Juan Fernandez Date: Mon, 30 Mar 2026 15:42:37 +0200 Subject: [PATCH 09/15] fix(ci-visibility): support file injection, async init, after:run/spec chaining - Support file injection uses a temp wrapper in os.tmpdir() with absolute paths instead of mutating the user's support file on disk. Cleaned up via after:run handler. - cypressPlugin.init() Promise is now properly returned so Cypress awaits async config mutations (retries, library config). - User's after:spec/after:run handlers are chained with dd-trace's, awaiting each in sequence (supports async handlers returning Promises). - Also wraps cypress.open() for interactive mode. - Handles async setupNodeEvents (Promise return) from user code. - Adds integration test verifying support file is not modified and wrapper is cleaned up. - Adds CODEOWNERS for new config files. - Removes stale Module._load approach (overwritten by dd-trace's ritm). - Updates ESM config to plain object (wrapped via cypress.run hook). - Fixes legacy plugin config comment. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/CODEOWNERS | 2 + integration-tests/cypress-esm-config.mjs | 32 ++- .../cypress-legacy-plugin.config.js | 4 +- integration-tests/cypress/cypress.spec.js | 59 +++++ .../datadog-instrumentations/src/cypress.js | 232 +++++++++++------- 5 files changed, 221 insertions(+), 108 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 630b16fd130..53f837c079c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -111,7 +111,9 @@ /integration-tests/config-jest-multiproject.js @DataDog/ci-app-libraries /integration-tests/config-jest.js @DataDog/ci-app-libraries /integration-tests/cypress-config.json @DataDog/ci-app-libraries +/integration-tests/cypress-custom-after-hooks.config.js @DataDog/ci-app-libraries /integration-tests/cypress-esm-config.mjs @DataDog/ci-app-libraries +/integration-tests/cypress-legacy-plugin.config.js @DataDog/ci-app-libraries /integration-tests/cypress.config.js @DataDog/ci-app-libraries /integration-tests/my-nyc.config.js @DataDog/ci-app-libraries /integration-tests/playwright.config.js @DataDog/ci-app-libraries diff --git a/integration-tests/cypress-esm-config.mjs b/integration-tests/cypress-esm-config.mjs index 80d351d10f0..b471b043f3b 100644 --- a/integration-tests/cypress-esm-config.mjs +++ b/integration-tests/cypress-esm-config.mjs @@ -1,27 +1,25 @@ import cypress from 'cypress' -const { defineConfig } = cypress - async function runCypress () { - const config = defineConfig({ - defaultCommandTimeout: 1000, - e2e: { - testIsolation: process.env.CYPRESS_TEST_ISOLATION !== 'false', - setupNodeEvents (on, config) { - if (process.env.CYPRESS_ENABLE_INCOMPATIBLE_PLUGIN) { - return import('cypress-fail-fast/plugin').then(module => { - module.default(on, config) - }) - } + const results = await cypress.run({ + config: { + defaultCommandTimeout: 1000, + e2e: { + testIsolation: process.env.CYPRESS_TEST_ISOLATION !== 'false', + setupNodeEvents (on, config) { + if (process.env.CYPRESS_ENABLE_INCOMPATIBLE_PLUGIN) { + return import('cypress-fail-fast/plugin').then(module => { + module.default(on, config) + }) + } + }, + specPattern: process.env.SPEC_PATTERN || 'cypress/e2e/**/*.cy.js', }, - specPattern: process.env.SPEC_PATTERN || 'cypress/e2e/**/*.cy.js', + video: false, + screenshotOnRunFailure: false, }, - video: false, - screenshotOnRunFailure: false, }) - const results = await cypress.run({ config }) - if (results.totalFailed !== 0) { process.exit(1) } diff --git a/integration-tests/cypress-legacy-plugin.config.js b/integration-tests/cypress-legacy-plugin.config.js index d407d48df4e..016ae6ee76c 100644 --- a/integration-tests/cypress-legacy-plugin.config.js +++ b/integration-tests/cypress-legacy-plugin.config.js @@ -2,8 +2,8 @@ // Backwards compatibility config: uses defineConfig AND the old manual plugin. // When NODE_OPTIONS is set, the instrumentation wraps defineConfig and injects -// setupNodeEvents. The manual plugin call inside the user's setupNodeEvents sets -// cypressPlugin._isInit = true, so the instrumentation skips its own registration. +// setupNodeEvents. The manual plugin call sets cypressPlugin._isInit = true, +// so the instrumentation skips its own registration to avoid double hooks. const { defineConfig } = require('cypress') const ddTracePlugin = require('dd-trace/ci/cypress/plugin') diff --git a/integration-tests/cypress/cypress.spec.js b/integration-tests/cypress/cypress.spec.js index 02765aecd48..fcc1483bdd5 100644 --- a/integration-tests/cypress/cypress.spec.js +++ b/integration-tests/cypress/cypress.spec.js @@ -316,6 +316,7 @@ moduleTypes.forEach(({ // cypress-legacy-plugin.config.js uses defineConfig which only exists in Cypress >=10 const legacyPluginIt = (version !== '6.7.0') ? it : it.skip + const autoInjectedSupportIt = (version !== '6.7.0') ? it : it.skip legacyPluginIt('is backwards compatible with the old manual plugin approach', async () => { receiver.setInfoResponse({ endpoints: [] }) @@ -357,6 +358,64 @@ moduleTypes.forEach(({ ]) }) + autoInjectedSupportIt('does not modify the user support file and cleans up the injected wrapper', async () => { + const supportFilePath = path.join(cwd, 'cypress/support/e2e.js') + const originalSupportContent = fs.readFileSync(supportFilePath, 'utf8') + const supportContentWithoutDdTrace = originalSupportContent + .split('\n') + .filter(line => !line.includes("require('dd-trace/ci/cypress/support')")) + .join('\n') + + const getSupportWrappers = () => fs.readdirSync(os.tmpdir()) + .filter(filename => filename.startsWith('dd-cypress-support-')) + .sort() + + fs.writeFileSync(supportFilePath, supportContentWithoutDdTrace) + + const receiverPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { + const events = payloads + .flatMap(({ payload }) => payload.events) + .filter(event => event.type === 'test') + const passedTest = events.find(event => + event.content.resource === 'cypress/e2e/basic-pass.js.basic pass suite can pass' + ) + + assertObjectContains(passedTest?.content, { + meta: { + [TEST_STATUS]: 'pass', + [TEST_FRAMEWORK]: 'cypress', + }, + }) + }, 60000) + + const envVars = getCiVisAgentlessConfig(receiver.port) + const wrapperFilesBefore = getSupportWrappers() + + try { + childProcess = exec(testCommand, { + cwd, + env: { + ...envVars, + CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, + SPEC_PATTERN: 'cypress/e2e/basic-pass.js', + }, + }) + + const [[exitCode]] = await Promise.all([ + once(childProcess, 'exit'), + receiverPromise, + ]) + + assert.strictEqual(exitCode, 0, 'cypress process should exit successfully') + assert.strictEqual(fs.readFileSync(supportFilePath, 'utf8'), supportContentWithoutDdTrace) + assert.doesNotMatch(fs.readFileSync(supportFilePath, 'utf8'), /dd-trace\/ci\/cypress\/support/) + assert.deepStrictEqual(getSupportWrappers(), wrapperFilesBefore) + } finally { + fs.writeFileSync(supportFilePath, originalSupportContent) + } + }) + it('custom after:spec and after:run handlers are chained with dd-trace instrumentation', async () => { const receiverPromise = receiver .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { diff --git a/packages/datadog-instrumentations/src/cypress.js b/packages/datadog-instrumentations/src/cypress.js index c2c32c8e5d1..9182e80a146 100644 --- a/packages/datadog-instrumentations/src/cypress.js +++ b/packages/datadog-instrumentations/src/cypress.js @@ -1,11 +1,15 @@ 'use strict' const fs = require('fs') +const os = require('os') +const path = require('path') const shimmer = require('../../datadog-shimmer') const { DD_MAJOR } = require('../../../version') const { addHook } = require('./helpers/instrument') +const DD_WRAPPED = Symbol('dd-trace.cypress.wrapped') + const noopTask = { 'dd:testSuiteStart': () => null, 'dd:beforeEach': () => ({}), @@ -15,33 +19,139 @@ const noopTask = { } /** - * Injects dd-trace's browser-side support code into the Cypress support file. - * Prepends a `require`/`import` of dd-trace's support to the user's support file - * so browser-side hooks (beforeEach, afterEach, retries, etc.) are loaded automatically. + * Creates a temporary wrapper support file under os.tmpdir() that loads + * dd-trace's browser-side hooks before the user's original support file. + * Returns the wrapper path (for cleanup) or undefined if injection was skipped. * * @param {object} config Cypress resolved config object + * @returns {string|undefined} wrapper file path, or undefined if skipped */ function injectSupportFile (config) { const originalSupportFile = config.supportFile if (!originalSupportFile || originalSupportFile === false) return - // If the user's support file already loads our support, skip injection. try { const content = fs.readFileSync(originalSupportFile, 'utf8') if (content.includes('dd-trace/ci/cypress/support')) return + } catch { + return + } - // Use ESM import syntax for .mjs files, CommonJS require for everything else - const isEsm = originalSupportFile.endsWith('.mjs') - const ddSupportLine = isEsm - ? "import 'dd-trace/ci/cypress/support'\n" - : "require('dd-trace/ci/cypress/support')\n" + // Resolve the absolute path to dd-trace's support file so the wrapper works + // from os.tmpdir() where dd-trace isn't in node_modules. + const ddSupportFile = require.resolve('../../../ci/cypress/support') - fs.writeFileSync(originalSupportFile, ddSupportLine + content) + const ext = path.extname(originalSupportFile) + const wrapperFile = path.join(os.tmpdir(), `dd-cypress-support-${process.pid}${ext}`) + const isEsm = ext === '.mjs' + + const wrapperContent = isEsm + ? `import ${JSON.stringify(ddSupportFile)}\nimport ${JSON.stringify(originalSupportFile)}\n` + : `require(${JSON.stringify(ddSupportFile)})\nrequire(${JSON.stringify(originalSupportFile)})\n` + + try { + fs.writeFileSync(wrapperFile, wrapperContent) + config.supportFile = wrapperFile + return wrapperFile } catch { - // Can't read/write the file — skip injection to avoid breaking anything + // Can't write wrapper — skip injection } } +/** + * Core instrumentation logic called from within setupNodeEvents. + * Registers dd-trace's Cypress hooks (before:run, after:spec, after:run, tasks) + * and injects the support file. Handles chaining with user-registered handlers + * for after:spec/after:run so both the user's code and dd-trace's run in sequence. + * + * @param {Function} on Cypress event registration function + * @param {object} config Cypress resolved config object + * @param {Function[]} userAfterSpecHandlers user's after:spec handlers collected from wrappedOn + * @param {Function[]} userAfterRunHandlers user's after:run handlers collected from wrappedOn + * @returns {object} the config object (possibly modified) + */ +function registerDdTraceHooks (on, config, userAfterSpecHandlers, userAfterRunHandlers) { + const wrapperFile = injectSupportFile(config) + + const cleanupWrapper = () => { + if (wrapperFile) { + try { fs.unlinkSync(wrapperFile) } catch { /* best effort */ } + } + } + + // global._ddtrace is the singleton set by dd-trace/index.js. It is always the + // same object regardless of how dd-trace was required (which path was resolved). + // On macOS, /var -> /private/var symlinks mean the same physical file can be + // cached under two different paths, creating multiple module instances. Using + // the global bypasses module resolution entirely and guarantees we get the one + // tracer that ci/init.js already initialized via NODE_OPTIONS. + const tracer = global._ddtrace + + const registerAfterRunWithCleanup = () => { + on('after:run', (results) => { + const chain = userAfterRunHandlers.reduce( + (p, h) => p.then(() => h(results)), + Promise.resolve() + ) + return chain.finally(cleanupWrapper) + }) + } + + const registerNoopHandlers = () => { + for (const h of userAfterSpecHandlers) on('after:spec', h) + registerAfterRunWithCleanup() + on('task', noopTask) + } + + if (!tracer || !tracer._initialized) { + registerNoopHandlers() + return config + } + + const NoopTracer = require('../../../packages/dd-trace/src/noop/tracer') + + if (tracer._tracer instanceof NoopTracer) { + registerNoopHandlers() + return config + } + + const cypressPlugin = require('../../../packages/datadog-plugin-cypress/src/cypress-plugin') + + // If the user already called the manual plugin (dd-trace/ci/cypress/plugin), + // cypressPlugin._isInit is true. Re-register their intercepted handlers and skip. + if (cypressPlugin._isInit) { + for (const h of userAfterSpecHandlers) on('after:spec', h) + registerAfterRunWithCleanup() + return config + } + + on('before:run', cypressPlugin.beforeRun.bind(cypressPlugin)) + + on('after:spec', (spec, results) => { + const chain = userAfterSpecHandlers.reduce( + (p, h) => p.then(() => h(spec, results)), + Promise.resolve() + ) + return chain.then(() => cypressPlugin.afterSpec(spec, results)) + }) + + on('after:run', (results) => { + const chain = userAfterRunHandlers.reduce( + (p, h) => p.then(() => h(results)), + Promise.resolve() + ) + return chain + .then(() => cypressPlugin.afterRun(results)) + .finally(cleanupWrapper) + }) + + on('task', cypressPlugin.getTasks()) + + // init() returns a Promise — Cypress awaits it for async config mutations + // (e.g. library configuration, retries). Resolve with config so Cypress gets it back. + return Promise.resolve(cypressPlugin.init(tracer, config)).then(() => config) +} + function wrapSetupNodeEvents (originalSetupNodeEvents) { return function ddSetupNodeEvents (on, config) { // Intercept after:spec and after:run registrations from user's setupNodeEvents @@ -59,88 +169,24 @@ function wrapSetupNodeEvents (originalSetupNodeEvents) { } } - // Call user's setupNodeEvents first so user config mutations are applied. - // Only replace config if the user returns a valid config object (has projectRoot). - // This guards against the old manual plugin returning an empty object from cypressPlugin.init(). - if (originalSetupNodeEvents) { - const result = originalSetupNodeEvents.call(this, wrappedOn, config) - if (result?.projectRoot) { - config = result - } - } + // The user's setupNodeEvents may be async (return a Promise). + // We must await it before proceeding so async config mutations land. + const maybePromise = originalSetupNodeEvents + ? originalSetupNodeEvents.call(this, wrappedOn, config) + : undefined - try { - // Always inject the support file, even if the manual plugin was already called. - injectSupportFile(config) - - // global._ddtrace is the singleton set by dd-trace/index.js. It is always the - // same object regardless of how dd-trace was required (which path was resolved). - // On macOS, /var -> /private/var symlinks mean the same physical file can be - // cached under two different paths, creating multiple module instances. Using - // the global bypasses module resolution entirely and guarantees we get the one - // tracer that ci/init.js already initialized via NODE_OPTIONS. - const tracer = global._ddtrace - - if (!tracer || !tracer._initialized) { - // Flush user's after:spec/after:run through since we won't be registering ours - for (const h of userAfterSpecHandlers) on('after:spec', h) - for (const h of userAfterRunHandlers) on('after:run', h) - on('task', noopTask) - return config - } - - const NoopTracer = require('../../../packages/dd-trace/src/noop/tracer') - - if (tracer._tracer instanceof NoopTracer) { - for (const h of userAfterSpecHandlers) on('after:spec', h) - for (const h of userAfterRunHandlers) on('after:run', h) - on('task', noopTask) - return config - } - - const cypressPlugin = require('../../../packages/datadog-plugin-cypress/src/cypress-plugin') - - // If the user already called the manual plugin (dd-trace/ci/cypress/plugin), - // cypressPlugin._isInit is true. Re-register their intercepted handlers and skip. - if (cypressPlugin._isInit) { - for (const h of userAfterSpecHandlers) on('after:spec', h) - for (const h of userAfterRunHandlers) on('after:run', h) - return config - } - - on('before:run', cypressPlugin.beforeRun.bind(cypressPlugin)) - - // Chain user's after:spec handlers with dd-trace's, awaiting each in sequence - on('after:spec', (spec, results) => { - const chain = userAfterSpecHandlers.reduce( - (p, h) => p.then(() => h(spec, results)), - Promise.resolve() - ) - return chain.then(() => cypressPlugin.afterSpec(spec, results)) - }) - - // Chain user's after:run handlers with dd-trace's, awaiting each in sequence - on('after:run', (results) => { - const chain = userAfterRunHandlers.reduce( - (p, h) => p.then(() => h(results)), - Promise.resolve() - ) - return chain.then(() => cypressPlugin.afterRun(results)) + if (maybePromise && typeof maybePromise.then === 'function') { + return maybePromise.then((result) => { + if (result?.projectRoot) config = result + return registerDdTraceHooks(on, config, userAfterSpecHandlers, userAfterRunHandlers) }) - - on('task', cypressPlugin.getTasks()) - - return cypressPlugin.init(tracer, config) - } catch { - // If anything goes wrong, register noop tasks so Cypress can still run - on('task', noopTask) - return config } + + if (maybePromise?.projectRoot) config = maybePromise + return registerDdTraceHooks(on, config, userAfterSpecHandlers, userAfterRunHandlers) } } -const DD_WRAPPED = Symbol('dd-trace.cypress.wrapped') - function wrapConfig (config) { if (!config || config[DD_WRAPPED]) return config[DD_WRAPPED] = true @@ -148,14 +194,15 @@ function wrapConfig (config) { if (config.e2e) { config.e2e.setupNodeEvents = wrapSetupNodeEvents(config.e2e.setupNodeEvents) } - // Also wrap component testing config if present if (config.component) { config.component.setupNodeEvents = wrapSetupNodeEvents(config.component.setupNodeEvents) } } // Cypress >=10 introduced defineConfig and setupNodeEvents. -// Auto-instrumentation wraps these to inject the plugin automatically. +// Auto-instrumentation wraps defineConfig() and cypress.run() to inject the +// plugin automatically. Configs using plain module.exports = { ... } (without +// defineConfig) need to either add defineConfig() or use the manual plugin. addHook({ name: 'cypress', versions: ['>=10.2.0'], @@ -172,6 +219,13 @@ addHook({ return run.apply(this, arguments) }) + shimmer.wrap(cypress, 'open', (open) => function (options) { + if (options?.config) { + wrapConfig(options.config) + } + return open.apply(this, arguments) + }) + return cypress }) From bc5e0ee060658257fb09d2cbceaecdec7cbc46c1 Mon Sep 17 00:00:00 2001 From: Juan Fernandez Date: Wed, 1 Apr 2026 11:56:48 +0200 Subject: [PATCH 10/15] esm support --- .github/CODEOWNERS | 4 + ci/cypress/wrap-config.js | 3 + integration-tests/cypress-auto-esm.config.mjs | 11 + integration-tests/cypress-double-run.js | 31 ++ .../cypress-plain-object-manual.config.js | 16 + .../cypress-return-config.config.js | 20 + integration-tests/cypress/cypress.spec.js | 172 +++++++- .../cypress/e2e/returned-config.cy.js | 6 + .../src/cypress-config.js | 366 ++++++++++++++++++ .../datadog-instrumentations/src/cypress.js | 262 +++---------- .../src/helpers/hooks.js | 2 +- .../src/cypress-plugin.js | 62 ++- 12 files changed, 749 insertions(+), 206 deletions(-) create mode 100644 ci/cypress/wrap-config.js create mode 100644 integration-tests/cypress-auto-esm.config.mjs create mode 100644 integration-tests/cypress-double-run.js create mode 100644 integration-tests/cypress-plain-object-manual.config.js create mode 100644 integration-tests/cypress-return-config.config.js create mode 100644 integration-tests/cypress/e2e/returned-config.cy.js create mode 100644 packages/datadog-instrumentations/src/cypress-config.js diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 53f837c079c..b14c34c5d94 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -112,8 +112,12 @@ /integration-tests/config-jest.js @DataDog/ci-app-libraries /integration-tests/cypress-config.json @DataDog/ci-app-libraries /integration-tests/cypress-custom-after-hooks.config.js @DataDog/ci-app-libraries +/integration-tests/cypress-auto-esm.config.mjs @DataDog/ci-app-libraries +/integration-tests/cypress-double-run.js @DataDog/ci-app-libraries /integration-tests/cypress-esm-config.mjs @DataDog/ci-app-libraries /integration-tests/cypress-legacy-plugin.config.js @DataDog/ci-app-libraries +/integration-tests/cypress-plain-object-manual.config.js @DataDog/ci-app-libraries +/integration-tests/cypress-return-config.config.js @DataDog/ci-app-libraries /integration-tests/cypress.config.js @DataDog/ci-app-libraries /integration-tests/my-nyc.config.js @DataDog/ci-app-libraries /integration-tests/playwright.config.js @DataDog/ci-app-libraries diff --git a/ci/cypress/wrap-config.js b/ci/cypress/wrap-config.js new file mode 100644 index 00000000000..929f53f2035 --- /dev/null +++ b/ci/cypress/wrap-config.js @@ -0,0 +1,3 @@ +'use strict' + +module.exports = require('../../packages/datadog-instrumentations/src/cypress-config').wrapConfig diff --git a/integration-tests/cypress-auto-esm.config.mjs b/integration-tests/cypress-auto-esm.config.mjs new file mode 100644 index 00000000000..ad0f92f07e8 --- /dev/null +++ b/integration-tests/cypress-auto-esm.config.mjs @@ -0,0 +1,11 @@ +import { defineConfig } from 'cypress' + +export default defineConfig({ + defaultCommandTimeout: 1000, + e2e: { + testIsolation: process.env.CYPRESS_TEST_ISOLATION !== 'false', + specPattern: process.env.SPEC_PATTERN || 'cypress/e2e/**/*.cy.js', + }, + video: false, + screenshotOnRunFailure: false, +}) diff --git a/integration-tests/cypress-double-run.js b/integration-tests/cypress-double-run.js new file mode 100644 index 00000000000..d1e6c62a90f --- /dev/null +++ b/integration-tests/cypress-double-run.js @@ -0,0 +1,31 @@ +'use strict' + +const cypress = require('cypress') + +const runOptions = { + config: { + defaultCommandTimeout: 1000, + e2e: { + supportFile: 'cypress/support/e2e.js', + testIsolation: process.env.CYPRESS_TEST_ISOLATION !== 'false', + specPattern: process.env.SPEC_PATTERN || 'cypress/e2e/**/*.cy.js', + }, + video: false, + screenshotOnRunFailure: false, + }, +} + +async function runCypressTwice () { + for (let runNumber = 0; runNumber < 2; runNumber++) { + const results = await cypress.run(runOptions) + if (results.totalFailed !== 0) { + process.exit(1) + } + } +} + +runCypressTwice().catch((error) => { + // eslint-disable-next-line no-console + console.error(error) + process.exit(1) +}) diff --git a/integration-tests/cypress-plain-object-manual.config.js b/integration-tests/cypress-plain-object-manual.config.js new file mode 100644 index 00000000000..c8e76f42994 --- /dev/null +++ b/integration-tests/cypress-plain-object-manual.config.js @@ -0,0 +1,16 @@ +'use strict' + +const ddTracePlugin = require('dd-trace/ci/cypress/plugin') + +module.exports = { + defaultCommandTimeout: 1000, + e2e: { + setupNodeEvents (on, config) { + return ddTracePlugin(on, config) + }, + specPattern: process.env.SPEC_PATTERN || 'cypress/e2e/**/*.cy.js', + supportFile: 'cypress/support/e2e.js', + }, + video: false, + screenshotOnRunFailure: false, +} diff --git a/integration-tests/cypress-return-config.config.js b/integration-tests/cypress-return-config.config.js new file mode 100644 index 00000000000..c5472c8c846 --- /dev/null +++ b/integration-tests/cypress-return-config.config.js @@ -0,0 +1,20 @@ +'use strict' + +const { defineConfig } = require('cypress') + +module.exports = defineConfig({ + defaultCommandTimeout: 1000, + e2e: { + setupNodeEvents () { + return { + env: { + RETURNED_CONFIG_FLAG: 'true', + }, + specPattern: 'cypress/e2e/returned-config.cy.js', + } + }, + specPattern: 'cypress/e2e/basic-fail.js', + }, + video: false, + screenshotOnRunFailure: false, +}) diff --git a/integration-tests/cypress/cypress.spec.js b/integration-tests/cypress/cypress.spec.js index fcc1483bdd5..290c80e738f 100644 --- a/integration-tests/cypress/cypress.spec.js +++ b/integration-tests/cypress/cypress.spec.js @@ -67,7 +67,7 @@ const { } = require('../../packages/dd-trace/src/plugins/util/test') const { DD_HOST_CPU_COUNT } = require('../../packages/dd-trace/src/plugins/util/env') const { ERROR_MESSAGE, ERROR_TYPE, COMPONENT } = require('../../packages/dd-trace/src/constants') -const { DD_MAJOR, NODE_MAJOR } = require('../../version') +const { DD_MAJOR, NODE_MAJOR, NODE_MINOR } = require('../../version') const { resolveSourceLineForTest } = require('../../packages/datadog-plugin-cypress/src/source-map-utils') const RECEIVER_STOP_TIMEOUT = 20000 @@ -75,6 +75,7 @@ const version = process.env.CYPRESS_VERSION const hookFile = 'dd-trace/loader-hook.mjs' const NUM_RETRIES_EFD = 3 const CYPRESS_PRECOMPILED_SPEC_DIST_DIR = 'cypress/e2e/dist' +const REGISTER_SUPPORTS_IMPORT = NODE_MAJOR > 20 || (NODE_MAJOR === 20 && NODE_MINOR >= 6) const over12It = (version === 'latest' || semver.gte(version, '12.0.0')) ? it : it.skip @@ -317,6 +318,10 @@ moduleTypes.forEach(({ // cypress-legacy-plugin.config.js uses defineConfig which only exists in Cypress >=10 const legacyPluginIt = (version !== '6.7.0') ? it : it.skip const autoInjectedSupportIt = (version !== '6.7.0') ? it : it.skip + const esmConfigFileIt = (version !== '6.7.0' && type === 'commonJS' && REGISTER_SUPPORTS_IMPORT) ? it : it.skip + const programmaticDoubleRunIt = (version !== '6.7.0' && type === 'commonJS') ? it : it.skip + const plainObjectManualConfigIt = (version !== '6.7.0' && type === 'commonJS') ? it : it.skip + const returnedConfigIt = (version !== '6.7.0' && type === 'commonJS') ? it : it.skip legacyPluginIt('is backwards compatible with the old manual plugin approach', async () => { receiver.setInfoResponse({ endpoints: [] }) @@ -358,6 +363,134 @@ moduleTypes.forEach(({ ]) }) + esmConfigFileIt('reports tests when using cypress.config.mjs with NODE_OPTIONS', async () => { + const receiverPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { + const events = payloads + .flatMap(({ payload }) => payload.events) + .filter(event => event.type === 'test') + const passedTest = events.find(event => + event.content.resource === 'cypress/e2e/basic-pass.js.basic pass suite can pass' + ) + + assertObjectContains(passedTest?.content, { + meta: { + [TEST_STATUS]: 'pass', + [TEST_FRAMEWORK]: 'cypress', + }, + }) + }, 20000) + + let testOutput = '' + const envVars = getCiVisAgentlessConfig(receiver.port) + + childProcess = exec( + './node_modules/.bin/cypress run --config-file cypress-auto-esm.config.mjs', + { + cwd, + env: { + ...envVars, + NODE_OPTIONS: '--import dd-trace/register.js -r dd-trace/ci/init', + CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, + SPEC_PATTERN: 'cypress/e2e/basic-pass.js', + }, + } + ) + childProcess.stdout?.on('data', (d) => { testOutput += d }) + childProcess.stderr?.on('data', (d) => { testOutput += d }) + + const [[exitCode]] = await Promise.all([ + once(childProcess, 'exit'), + receiverPromise, + ]) + + assert.strictEqual(exitCode, 0, `cypress process should exit successfully\n${testOutput}`) + }) + + programmaticDoubleRunIt('reports tests when cypress.run is called twice in the same process', async () => { + const receiverPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { + const passedTests = payloads + .flatMap(({ payload }) => payload.events) + .filter(event => event.type === 'test') + .filter(event => event.content.resource === 'cypress/e2e/basic-pass.js.basic pass suite can pass') + + assert.strictEqual(passedTests.length, 2) + passedTests.forEach((passedTest) => { + assertObjectContains(passedTest.content, { + meta: { + [TEST_STATUS]: 'pass', + [TEST_FRAMEWORK]: 'cypress', + }, + }) + }) + }, 60000) + + const envVars = getCiVisAgentlessConfig(receiver.port) + + childProcess = exec( + 'node ./cypress-double-run.js', + { + cwd, + env: { + ...envVars, + CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, + SPEC_PATTERN: 'cypress/e2e/basic-pass.js', + }, + } + ) + + const [[exitCode]] = await Promise.all([ + once(childProcess, 'exit'), + receiverPromise, + ]) + + assert.strictEqual(exitCode, 0, 'cypress process should exit successfully') + }) + + plainObjectManualConfigIt( + 'reports tests with a plain-object config when dd-trace is manually configured', + async () => { + const receiverPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { + const events = payloads + .flatMap(({ payload }) => payload.events) + .filter(event => event.type === 'test') + const passedTest = events.find(event => + event.content.resource === 'cypress/e2e/basic-pass.js.basic pass suite can pass' + ) + + assertObjectContains(passedTest?.content, { + meta: { + [TEST_STATUS]: 'pass', + [TEST_FRAMEWORK]: 'cypress', + }, + }) + }, 60000) + + const envVars = getCiVisAgentlessConfig(receiver.port) + + childProcess = exec( + './node_modules/.bin/cypress run --config-file cypress-plain-object-manual.config.js', + { + cwd, + env: { + ...envVars, + CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, + SPEC_PATTERN: 'cypress/e2e/basic-pass.js', + }, + } + ) + + const [[exitCode]] = await Promise.all([ + once(childProcess, 'exit'), + receiverPromise, + ]) + + assert.strictEqual(exitCode, 0, 'cypress process should exit successfully') + } + ) + autoInjectedSupportIt('does not modify the user support file and cleans up the injected wrapper', async () => { const supportFilePath = path.join(cwd, 'cypress/support/e2e.js') const originalSupportContent = fs.readFileSync(supportFilePath, 'utf8') @@ -416,6 +549,43 @@ moduleTypes.forEach(({ } }) + returnedConfigIt('preserves config returned from setupNodeEvents', async () => { + const receiverPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { + const events = payloads + .flatMap(({ payload }) => payload.events) + .filter(event => event.type === 'test') + const passedTest = events.find(event => + event.content.resource === + 'cypress/e2e/returned-config.cy.js.returned config uses env from setupNodeEvents return value' + ) + + assertObjectContains(passedTest?.content, { + meta: { + [TEST_STATUS]: 'pass', + [TEST_FRAMEWORK]: 'cypress', + }, + }) + }, 60000) + + const envVars = getCiVisAgentlessConfig(receiver.port) + + childProcess = exec( + './node_modules/.bin/cypress run --config-file cypress-return-config.config.js', + { + cwd, + env: envVars, + } + ) + + const [[exitCode]] = await Promise.all([ + once(childProcess, 'exit'), + receiverPromise, + ]) + + assert.strictEqual(exitCode, 0, 'cypress process should exit successfully') + }) + it('custom after:spec and after:run handlers are chained with dd-trace instrumentation', async () => { const receiverPromise = receiver .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { diff --git a/integration-tests/cypress/e2e/returned-config.cy.js b/integration-tests/cypress/e2e/returned-config.cy.js new file mode 100644 index 00000000000..b8206ac74ba --- /dev/null +++ b/integration-tests/cypress/e2e/returned-config.cy.js @@ -0,0 +1,6 @@ +/* eslint-disable */ +describe('returned config', () => { + it('uses env from setupNodeEvents return value', () => { + expect(Cypress.env('RETURNED_CONFIG_FLAG')).to.equal('true') + }) +}) diff --git a/packages/datadog-instrumentations/src/cypress-config.js b/packages/datadog-instrumentations/src/cypress-config.js new file mode 100644 index 00000000000..4b5b795be85 --- /dev/null +++ b/packages/datadog-instrumentations/src/cypress-config.js @@ -0,0 +1,366 @@ +'use strict' + +const fs = require('fs') +const os = require('os') +const path = require('path') +const { pathToFileURL } = require('url') + +const { isTrue, isFalse } = require('../../dd-trace/src/util') +const log = require('../../dd-trace/src/log') +const { getEnvironmentVariable, getValueFromEnvSources } = require('../../dd-trace/src/config/helper') + +const DD_CONFIG_WRAPPED = Symbol('dd-trace.cypress.config.wrapped') +const DD_CLI_CONFIG_WRAPPER_FILE = 'dd-cypress-config' +const DEFAULT_FLUSH_INTERVAL = 5000 +const DD_TRACE_PRELOADS = { + 'dd-trace/register.js': require.resolve('../../../register.js'), + 'dd-trace/ci/init': require.resolve('../../../ci/init'), + 'dd-trace/loader-hook.mjs': require.resolve('../../../loader-hook.mjs'), +} + +const noopTask = { + 'dd:testSuiteStart': () => null, + 'dd:beforeEach': () => ({}), + 'dd:afterEach': () => null, + 'dd:addTags': () => null, + 'dd:log': () => null, +} + +/** + * @param {unknown} value + * @returns {boolean} + */ +function isPlainObject (value) { + if (!value || typeof value !== 'object') return false + const prototype = Object.getPrototypeOf(value) + return prototype === Object.prototype || prototype === null +} + +/** + * Cypress allows setupNodeEvents to return partial config fragments that it + * diffs and merges into the resolved config. Preserve that behavior here so + * the wrapper does not drop user-provided config updates. + * + * @param {object} config Cypress resolved config object + * @param {unknown} updatedConfig value returned from setupNodeEvents + * @returns {object} resolved config with returned overrides applied + */ +function mergeReturnedConfig (config, updatedConfig) { + if (!isPlainObject(updatedConfig) || updatedConfig === config) { + return config + } + + const mergedConfig = { ...config } + + for (const [key, value] of Object.entries(updatedConfig)) { + mergedConfig[key] = isPlainObject(value) && isPlainObject(mergedConfig[key]) + ? mergeReturnedConfig(mergedConfig[key], value) + : value + } + + return mergedConfig +} + +/** + * Creates a temporary wrapper support file under os.tmpdir() that loads + * dd-trace's browser-side hooks before the user's original support file. + * Returns the wrapper path (for cleanup) or undefined if injection was skipped. + * + * @param {object} config Cypress resolved config object + * @returns {string|undefined} wrapper file path, or undefined if skipped + */ +function injectSupportFile (config) { + const originalSupportFile = config.supportFile + if (!originalSupportFile || originalSupportFile === false) return + + try { + const content = fs.readFileSync(originalSupportFile, 'utf8') + if (content.includes('dd-trace/ci/cypress/support')) return + } catch { + return + } + + const ddSupportFile = require.resolve('../../../ci/cypress/support') + const ext = path.extname(originalSupportFile) + const wrapperFile = path.join(os.tmpdir(), `dd-cypress-support-${process.pid}${ext}`) + const isEsm = ext === '.mjs' + + const wrapperContent = isEsm + ? `import ${JSON.stringify(ddSupportFile)}\nimport ${JSON.stringify(originalSupportFile)}\n` + : `require(${JSON.stringify(ddSupportFile)})\nrequire(${JSON.stringify(originalSupportFile)})\n` + + try { + fs.writeFileSync(wrapperFile, wrapperContent) + config.supportFile = wrapperFile + return wrapperFile + } catch { + // Can't write wrapper - skip injection + } +} + +/** + * Initializes CI Visibility for the Cypress config/plugin process when Cypress + * strips NODE_OPTIONS before loading an ESM config file in Electron. + * + * This cannot just reuse ci/init directly because ci/init intentionally skips + * CLI tools and Electron processes, and this fallback exists specifically for + * Cypress's config/plugin process after NODE_OPTIONS is removed from that path. + * + * @returns {object|undefined} tracer singleton + */ +function ensureCiVisibilityTracer () { + const tracer = global._ddtrace || require('../../../packages/dd-trace') + + if (tracer?._initialized) { + return tracer + } + + if (isFalse(getValueFromEnvSources('DD_CIVISIBILITY_ENABLED'))) { + return tracer + } + + const isAgentlessEnabled = isTrue(getValueFromEnvSources('DD_CIVISIBILITY_AGENTLESS_ENABLED')) + const options = { + startupLogs: false, + isCiVisibility: true, + flushInterval: DEFAULT_FLUSH_INTERVAL, + } + + if (isAgentlessEnabled) { + if (getValueFromEnvSources('DD_API_KEY')) { + options.experimental = { exporter: 'datadog' } + } else { + log.warn( + 'DD_CIVISIBILITY_AGENTLESS_ENABLED is set, but DD_API_KEY is undefined, so Cypress CI Visibility is disabled.' + ) + return tracer + } + } else { + options.experimental = { exporter: 'agent_proxy' } + } + + tracer.init(options) + tracer.use('fs', false) + tracer.use('child_process', false) + + return tracer +} + +/** + * Registers dd-trace's Cypress hooks (before:run, after:spec, after:run, tasks) + * and injects the support file. Handles chaining with user-registered handlers + * for after:spec/after:run so both the user's code and dd-trace's run in sequence. + * + * @param {Function} on Cypress event registration function + * @param {object} config Cypress resolved config object + * @param {Function[]} userAfterSpecHandlers user's after:spec handlers collected from wrappedOn + * @param {Function[]} userAfterRunHandlers user's after:run handlers collected from wrappedOn + * @returns {object} the config object (possibly modified) + */ +function registerDdTraceHooks (on, config, userAfterSpecHandlers, userAfterRunHandlers) { + const wrapperFile = injectSupportFile(config) + + const cleanupWrapper = () => { + if (wrapperFile) { + try { fs.unlinkSync(wrapperFile) } catch { /* best effort */ } + } + } + + const tracer = ensureCiVisibilityTracer() + + const registerAfterRunWithCleanup = () => { + on('after:run', (results) => { + const chain = userAfterRunHandlers.reduce( + (p, h) => p.then(() => h(results)), + Promise.resolve() + ) + return chain.finally(cleanupWrapper) + }) + } + + const registerNoopHandlers = () => { + for (const h of userAfterSpecHandlers) on('after:spec', h) + registerAfterRunWithCleanup() + on('task', noopTask) + } + + if (!tracer || !tracer._initialized) { + registerNoopHandlers() + return config + } + + const NoopTracer = require('../../../packages/dd-trace/src/noop/tracer') + + if (tracer._tracer instanceof NoopTracer) { + registerNoopHandlers() + return config + } + + const cypressPlugin = require('../../../packages/datadog-plugin-cypress/src/cypress-plugin') + + if (cypressPlugin._isInit) { + for (const h of userAfterSpecHandlers) on('after:spec', h) + registerAfterRunWithCleanup() + return config + } + + on('before:run', cypressPlugin.beforeRun.bind(cypressPlugin)) + + on('after:spec', (spec, results) => { + const chain = userAfterSpecHandlers.reduce( + (p, h) => p.then(() => h(spec, results)), + Promise.resolve() + ) + return chain.then(() => cypressPlugin.afterSpec(spec, results)) + }) + + on('after:run', (results) => { + const chain = userAfterRunHandlers.reduce( + (p, h) => p.then(() => h(results)), + Promise.resolve() + ) + return chain + .then(() => cypressPlugin.afterRun(results)) + .finally(cleanupWrapper) + }) + + on('task', cypressPlugin.getTasks()) + + return Promise.resolve(cypressPlugin.init(tracer, config)).then(() => config) +} + +/** + * @param {Function|undefined} originalSetupNodeEvents + * @returns {Function} + */ +function wrapSetupNodeEvents (originalSetupNodeEvents) { + return function ddSetupNodeEvents (on, config) { + const userAfterSpecHandlers = [] + const userAfterRunHandlers = [] + + const wrappedOn = (event, handler) => { + if (event === 'after:spec') { + userAfterSpecHandlers.push(handler) + } else if (event === 'after:run') { + userAfterRunHandlers.push(handler) + } else { + on(event, handler) + } + } + + const maybePromise = originalSetupNodeEvents + ? originalSetupNodeEvents.call(this, wrappedOn, config) + : undefined + + if (maybePromise && typeof maybePromise.then === 'function') { + return maybePromise.then((result) => { + return registerDdTraceHooks( + on, + mergeReturnedConfig(config, result), + userAfterSpecHandlers, + userAfterRunHandlers + ) + }) + } + + return registerDdTraceHooks( + on, + mergeReturnedConfig(config, maybePromise), + userAfterSpecHandlers, + userAfterRunHandlers + ) + } +} + +/** + * @param {object} config + * @returns {object} + */ +function wrapConfig (config) { + if (!config || config[DD_CONFIG_WRAPPED]) return config + config[DD_CONFIG_WRAPPED] = true + + if (config.e2e) { + config.e2e.setupNodeEvents = wrapSetupNodeEvents(config.e2e.setupNodeEvents) + } + if (config.component) { + config.component.setupNodeEvents = wrapSetupNodeEvents(config.component.setupNodeEvents) + } + + return config +} + +/** + * @param {object|undefined} options + * @returns {{ options: object|undefined, cleanup: Function }} + */ +function wrapCliConfigFileOptions (options) { + if (typeof options?.configFile !== 'string' || path.extname(options.configFile) !== '.mjs') { + return { options, cleanup: () => {} } + } + + const projectRoot = typeof options.project === 'string' ? options.project : process.cwd() + const originalConfigFile = path.isAbsolute(options.configFile) + ? options.configFile + : path.resolve(projectRoot, options.configFile) + const wrapConfigFile = require.resolve('../../../ci/cypress/wrap-config') + const wrapperFile = path.join( + os.tmpdir(), + `${DD_CLI_CONFIG_WRAPPER_FILE}-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.mjs` + ) + + fs.writeFileSync(wrapperFile, [ + `import originalConfig from ${JSON.stringify(pathToFileURL(originalConfigFile).href)}`, + `import wrapConfig from ${JSON.stringify(pathToFileURL(wrapConfigFile).href)}`, + '', + 'export default wrapConfig(originalConfig)', + '', + ].join('\n')) + + return { + options: { + ...options, + configFile: wrapperFile, + }, + cleanup: () => { + try { fs.unlinkSync(wrapperFile) } catch { /* best effort */ } + }, + } +} + +/** + * Rewrite dd-trace preloads to absolute paths so Cypress child processes can + * resolve them even when their cwd is not the project root. + * + * @returns {Function} + */ +function rewriteCliNodeOptions () { + const originalNodeOptions = getEnvironmentVariable('NODE_OPTIONS') + + if (!originalNodeOptions) { + return () => {} + } + + const rewrittenNodeOptions = originalNodeOptions + .split(/\s+/) + .map(part => DD_TRACE_PRELOADS[part] || part) + .join(' ') + + if (rewrittenNodeOptions === originalNodeOptions) { + return () => {} + } + + // eslint-disable-next-line eslint-rules/eslint-process-env + process.env.NODE_OPTIONS = rewrittenNodeOptions + + return () => { + // eslint-disable-next-line eslint-rules/eslint-process-env + process.env.NODE_OPTIONS = originalNodeOptions + } +} + +module.exports = { + rewriteCliNodeOptions, + wrapCliConfigFileOptions, + wrapConfig, +} diff --git a/packages/datadog-instrumentations/src/cypress.js b/packages/datadog-instrumentations/src/cypress.js index 9182e80a146..5353d930919 100644 --- a/packages/datadog-instrumentations/src/cypress.js +++ b/packages/datadog-instrumentations/src/cypress.js @@ -1,201 +1,49 @@ 'use strict' -const fs = require('fs') -const os = require('os') -const path = require('path') - const shimmer = require('../../datadog-shimmer') const { DD_MAJOR } = require('../../../version') const { addHook } = require('./helpers/instrument') +const { + rewriteCliNodeOptions, + wrapCliConfigFileOptions, + wrapConfig, +} = require('./cypress-config') -const DD_WRAPPED = Symbol('dd-trace.cypress.wrapped') - -const noopTask = { - 'dd:testSuiteStart': () => null, - 'dd:beforeEach': () => ({}), - 'dd:afterEach': () => null, - 'dd:addTags': () => null, - 'dd:log': () => null, -} - -/** - * Creates a temporary wrapper support file under os.tmpdir() that loads - * dd-trace's browser-side hooks before the user's original support file. - * Returns the wrapper path (for cleanup) or undefined if injection was skipped. - * - * @param {object} config Cypress resolved config object - * @returns {string|undefined} wrapper file path, or undefined if skipped - */ -function injectSupportFile (config) { - const originalSupportFile = config.supportFile - if (!originalSupportFile || originalSupportFile === false) return - - try { - const content = fs.readFileSync(originalSupportFile, 'utf8') - if (content.includes('dd-trace/ci/cypress/support')) return - } catch { - return - } - - // Resolve the absolute path to dd-trace's support file so the wrapper works - // from os.tmpdir() where dd-trace isn't in node_modules. - const ddSupportFile = require.resolve('../../../ci/cypress/support') - - const ext = path.extname(originalSupportFile) - const wrapperFile = path.join(os.tmpdir(), `dd-cypress-support-${process.pid}${ext}`) - const isEsm = ext === '.mjs' - - const wrapperContent = isEsm - ? `import ${JSON.stringify(ddSupportFile)}\nimport ${JSON.stringify(originalSupportFile)}\n` - : `require(${JSON.stringify(ddSupportFile)})\nrequire(${JSON.stringify(originalSupportFile)})\n` - - try { - fs.writeFileSync(wrapperFile, wrapperContent) - config.supportFile = wrapperFile - return wrapperFile - } catch { - // Can't write wrapper — skip injection - } -} +const DD_API_WRAPPED = Symbol('dd-trace.cypress.api.wrapped') /** - * Core instrumentation logic called from within setupNodeEvents. - * Registers dd-trace's Cypress hooks (before:run, after:spec, after:run, tasks) - * and injects the support file. Handles chaining with user-registered handlers - * for after:spec/after:run so both the user's code and dd-trace's run in sequence. + * Patch a Cypress API object once. ESM loads can expose both a namespace object + * and a separate default export object, so both shapes need wrapping. * - * @param {Function} on Cypress event registration function - * @param {object} config Cypress resolved config object - * @param {Function[]} userAfterSpecHandlers user's after:spec handlers collected from wrappedOn - * @param {Function[]} userAfterRunHandlers user's after:run handlers collected from wrappedOn - * @returns {object} the config object (possibly modified) + * @param {object} cypress Cypress API export object */ -function registerDdTraceHooks (on, config, userAfterSpecHandlers, userAfterRunHandlers) { - const wrapperFile = injectSupportFile(config) - - const cleanupWrapper = () => { - if (wrapperFile) { - try { fs.unlinkSync(wrapperFile) } catch { /* best effort */ } - } - } - - // global._ddtrace is the singleton set by dd-trace/index.js. It is always the - // same object regardless of how dd-trace was required (which path was resolved). - // On macOS, /var -> /private/var symlinks mean the same physical file can be - // cached under two different paths, creating multiple module instances. Using - // the global bypasses module resolution entirely and guarantees we get the one - // tracer that ci/init.js already initialized via NODE_OPTIONS. - const tracer = global._ddtrace - - const registerAfterRunWithCleanup = () => { - on('after:run', (results) => { - const chain = userAfterRunHandlers.reduce( - (p, h) => p.then(() => h(results)), - Promise.resolve() - ) - return chain.finally(cleanupWrapper) +function wrapCypressApi (cypress) { + if (!cypress || cypress[DD_API_WRAPPED]) return + cypress[DD_API_WRAPPED] = true + + if (typeof cypress.defineConfig === 'function') { + shimmer.wrap(cypress, 'defineConfig', (defineConfig) => function (config) { + wrapConfig(config) + return defineConfig(config) }) } - const registerNoopHandlers = () => { - for (const h of userAfterSpecHandlers) on('after:spec', h) - registerAfterRunWithCleanup() - on('task', noopTask) - } - - if (!tracer || !tracer._initialized) { - registerNoopHandlers() - return config - } - - const NoopTracer = require('../../../packages/dd-trace/src/noop/tracer') - - if (tracer._tracer instanceof NoopTracer) { - registerNoopHandlers() - return config - } - - const cypressPlugin = require('../../../packages/datadog-plugin-cypress/src/cypress-plugin') - - // If the user already called the manual plugin (dd-trace/ci/cypress/plugin), - // cypressPlugin._isInit is true. Re-register their intercepted handlers and skip. - if (cypressPlugin._isInit) { - for (const h of userAfterSpecHandlers) on('after:spec', h) - registerAfterRunWithCleanup() - return config - } - - on('before:run', cypressPlugin.beforeRun.bind(cypressPlugin)) - - on('after:spec', (spec, results) => { - const chain = userAfterSpecHandlers.reduce( - (p, h) => p.then(() => h(spec, results)), - Promise.resolve() - ) - return chain.then(() => cypressPlugin.afterSpec(spec, results)) - }) - - on('after:run', (results) => { - const chain = userAfterRunHandlers.reduce( - (p, h) => p.then(() => h(results)), - Promise.resolve() - ) - return chain - .then(() => cypressPlugin.afterRun(results)) - .finally(cleanupWrapper) - }) - - on('task', cypressPlugin.getTasks()) - - // init() returns a Promise — Cypress awaits it for async config mutations - // (e.g. library configuration, retries). Resolve with config so Cypress gets it back. - return Promise.resolve(cypressPlugin.init(tracer, config)).then(() => config) -} - -function wrapSetupNodeEvents (originalSetupNodeEvents) { - return function ddSetupNodeEvents (on, config) { - // Intercept after:spec and after:run registrations from user's setupNodeEvents - // so we can chain them with dd-trace's handlers rather than overriding them. - const userAfterSpecHandlers = [] - const userAfterRunHandlers = [] - - const wrappedOn = (event, handler) => { - if (event === 'after:spec') { - userAfterSpecHandlers.push(handler) - } else if (event === 'after:run') { - userAfterRunHandlers.push(handler) - } else { - on(event, handler) + if (typeof cypress.run === 'function') { + shimmer.wrap(cypress, 'run', (run) => function (options) { + if (options?.config) { + wrapConfig(options.config) } - } - - // The user's setupNodeEvents may be async (return a Promise). - // We must await it before proceeding so async config mutations land. - const maybePromise = originalSetupNodeEvents - ? originalSetupNodeEvents.call(this, wrappedOn, config) - : undefined - - if (maybePromise && typeof maybePromise.then === 'function') { - return maybePromise.then((result) => { - if (result?.projectRoot) config = result - return registerDdTraceHooks(on, config, userAfterSpecHandlers, userAfterRunHandlers) - }) - } - - if (maybePromise?.projectRoot) config = maybePromise - return registerDdTraceHooks(on, config, userAfterSpecHandlers, userAfterRunHandlers) + return run.apply(this, arguments) + }) } -} - -function wrapConfig (config) { - if (!config || config[DD_WRAPPED]) return - config[DD_WRAPPED] = true - if (config.e2e) { - config.e2e.setupNodeEvents = wrapSetupNodeEvents(config.e2e.setupNodeEvents) - } - if (config.component) { - config.component.setupNodeEvents = wrapSetupNodeEvents(config.component.setupNodeEvents) + if (typeof cypress.open === 'function') { + shimmer.wrap(cypress, 'open', (open) => function (options) { + if (options?.config) { + wrapConfig(options.config) + } + return open.apply(this, arguments) + }) } } @@ -207,27 +55,43 @@ addHook({ name: 'cypress', versions: ['>=10.2.0'], }, (cypress) => { - shimmer.wrap(cypress, 'defineConfig', (defineConfig) => function (config) { - wrapConfig(config) - return defineConfig(config) - }) + wrapCypressApi(cypress) + if (cypress?.default && cypress.default !== cypress) { + wrapCypressApi(cypress.default) + } - shimmer.wrap(cypress, 'run', (run) => function (options) { - if (options?.config) { - wrapConfig(options.config) - } - return run.apply(this, arguments) - }) + return cypress +}) - shimmer.wrap(cypress, 'open', (open) => function (options) { - if (options?.config) { - wrapConfig(options.config) +function getCliStartWrapper (start) { + return function ddTraceCliStart (options) { + const restoreNodeOptions = rewriteCliNodeOptions() + const { options: wrappedOptions, cleanup } = wrapCliConfigFileOptions(options) + const result = start.call(this, wrappedOptions) + + if (result && typeof result.then === 'function') { + return result.finally(() => { + cleanup() + restoreNodeOptions() + }) } - return open.apply(this, arguments) - }) - return cypress -}) + cleanup() + restoreNodeOptions() + return result + } +} + +for (const file of ['lib/exec/run.js', 'lib/exec/open.js']) { + addHook({ + name: 'cypress', + versions: ['>=10.2.0'], + file, + }, (cypressExecModule) => { + shimmer.wrap(cypressExecModule, 'start', getCliStartWrapper) + return cypressExecModule + }) +} // Cypress <10 uses the old pluginsFile approach. No auto-instrumentation; // users must use the manual dd-trace/ci/cypress/plugin setup. diff --git a/packages/datadog-instrumentations/src/helpers/hooks.js b/packages/datadog-instrumentations/src/helpers/hooks.js index 3d83b13e406..65062e025ee 100644 --- a/packages/datadog-instrumentations/src/helpers/hooks.js +++ b/packages/datadog-instrumentations/src/helpers/hooks.js @@ -53,7 +53,7 @@ module.exports = { 'cookie-parser': () => require('../cookie-parser'), couchbase: () => require('../couchbase'), crypto: () => require('../crypto'), - cypress: () => require('../cypress'), + cypress: { esmFirst: true, fn: () => require('../cypress') }, 'dd-trace-api': () => require('../dd-trace-api'), dns: () => require('../dns'), elasticsearch: () => require('../elasticsearch'), diff --git a/packages/datadog-plugin-cypress/src/cypress-plugin.js b/packages/datadog-plugin-cypress/src/cypress-plugin.js index 1889ede16f0..74aa4e76902 100644 --- a/packages/datadog-plugin-cypress/src/cypress-plugin.js +++ b/packages/datadog-plugin-cypress/src/cypress-plugin.js @@ -303,10 +303,55 @@ class CypressPlugin { } } + /** + * Resets state that is scoped to a single Cypress run so the singleton plugin + * can be reused safely across multiple programmatic cypress.run() calls. + * + * @returns {void} + */ + resetRunState () { + this._isInit = false + this.finishedTestsByFile = {} + this.testStatuses = {} + this.isTestsSkipped = false + this.isSuitesSkippingEnabled = false + this.isCodeCoverageEnabled = false + this.isFlakyTestRetriesEnabled = false + this.flakyTestRetriesCount = 0 + this.isEarlyFlakeDetectionEnabled = false + this.isKnownTestsEnabled = false + this.earlyFlakeDetectionNumRetries = 0 + this.testsToSkip = [] + this.skippedTests = [] + this.hasForcedToRunSuites = false + this.hasUnskippableSuites = false + this.unskippableSuites = [] + this.knownTests = [] + this.knownTestsByTestSuite = undefined + this.isTestManagementTestsEnabled = false + this.testManagementAttemptToFixRetries = 0 + this.testManagementTests = undefined + this.isImpactedTestsEnabled = false + this.modifiedFiles = [] + this.activeTestSpan = null + this.testSuiteSpan = null + this.testModuleSpan = null + this.testSessionSpan = null + this.command = undefined + this.frameworkVersion = undefined + this.rootDir = undefined + this.itrCorrelationId = undefined + this.isTestIsolationEnabled = undefined + this.rumFlushWaitMillis = undefined + this._pendingRequestErrorTags = [] + this.libraryConfigurationPromise = undefined + } + // Init function returns a promise that resolves with the Cypress configuration // Depending on the received configuration, the Cypress configuration can be modified: // for example, to enable retries for failed tests. init (tracer, cypressConfig) { + this.resetRunState() this._isInit = true this.tracer = tracer this.cypressConfig = cypressConfig @@ -688,20 +733,27 @@ class CypressPlugin { } return new Promise(resolve => { + const finishAfterRun = () => { + this._isInit = false + appClosingTelemetry() + resolve(null) + } + const exporter = this.tracer._tracer._exporter if (!exporter) { - return resolve(null) + finishAfterRun() + return } if (exporter.flush) { exporter.flush(() => { - appClosingTelemetry() - resolve(null) + finishAfterRun() }) } else if (exporter._writer) { exporter._writer.flush(() => { - appClosingTelemetry() - resolve(null) + finishAfterRun() }) + } else { + finishAfterRun() } }) } From 8c0da857660068b36064af6185063b7a1f8cc768 Mon Sep 17 00:00:00 2001 From: Juan Fernandez Date: Wed, 1 Apr 2026 21:30:53 +0200 Subject: [PATCH 11/15] refactor(ci-visibility): simplify cypress instrumentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove `ensureCiVisibilityTracer` duplication — `ci/init` now initializes the tracer in Cypress CLI/Electron processes (removed `isCliTool` skip) - Remove `esmFirst` flag — `defineConfig` shimmer works for ESM configs via ritm because Cypress internally transpiles .mjs to CJS - Remove `rewriteCliNodeOptions` — config wrapper now lives next to the original file instead of in os.tmpdir(), so module resolution works naturally - Remove `run`/`open` shimming — inline config wrapping was belt-and-suspenders since Cypress never calls setupNodeEvents from inline config - Always use ESM (.mjs) wrappers for both config and support files — ESM can import both CJS and ESM, so it works regardless of "type":"module" - Fix CLI hook file paths: lib/exec/run.js → dist/exec/run.js (actual Cypress package structure), handle .default on transpiled module exports - Add ESM variants for all integration tests so every scenario runs for both CJS and ESM module types - Make returned-config test async to cover the promise branch in wrapSetupNodeEvents Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/CODEOWNERS | 5 + ci/init.js | 21 --- .../cypress-custom-after-hooks.config.mjs | 34 ++++ integration-tests/cypress-double-run.mjs | 21 +++ .../cypress-legacy-plugin.config.mjs | 14 ++ .../cypress-plain-object-manual.config.mjs | 14 ++ .../cypress-return-config.config.js | 3 +- .../cypress-return-config.config.mjs | 19 +++ integration-tests/cypress/cypress.spec.js | 57 ++++--- .../src/cypress-config.js | 155 +++++------------- .../datadog-instrumentations/src/cypress.js | 69 ++------ .../src/helpers/hooks.js | 2 +- 12 files changed, 202 insertions(+), 212 deletions(-) create mode 100644 integration-tests/cypress-custom-after-hooks.config.mjs create mode 100644 integration-tests/cypress-double-run.mjs create mode 100644 integration-tests/cypress-legacy-plugin.config.mjs create mode 100644 integration-tests/cypress-plain-object-manual.config.mjs create mode 100644 integration-tests/cypress-return-config.config.mjs diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b14c34c5d94..51b62afe074 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -112,12 +112,17 @@ /integration-tests/config-jest.js @DataDog/ci-app-libraries /integration-tests/cypress-config.json @DataDog/ci-app-libraries /integration-tests/cypress-custom-after-hooks.config.js @DataDog/ci-app-libraries +/integration-tests/cypress-custom-after-hooks.config.mjs @DataDog/ci-app-libraries /integration-tests/cypress-auto-esm.config.mjs @DataDog/ci-app-libraries /integration-tests/cypress-double-run.js @DataDog/ci-app-libraries +/integration-tests/cypress-double-run.mjs @DataDog/ci-app-libraries /integration-tests/cypress-esm-config.mjs @DataDog/ci-app-libraries /integration-tests/cypress-legacy-plugin.config.js @DataDog/ci-app-libraries +/integration-tests/cypress-legacy-plugin.config.mjs @DataDog/ci-app-libraries /integration-tests/cypress-plain-object-manual.config.js @DataDog/ci-app-libraries +/integration-tests/cypress-plain-object-manual.config.mjs @DataDog/ci-app-libraries /integration-tests/cypress-return-config.config.js @DataDog/ci-app-libraries +/integration-tests/cypress-return-config.config.mjs @DataDog/ci-app-libraries /integration-tests/cypress.config.js @DataDog/ci-app-libraries /integration-tests/my-nyc.config.js @DataDog/ci-app-libraries /integration-tests/playwright.config.js @DataDog/ci-app-libraries diff --git a/ci/init.js b/ci/init.js index 085d9c3ce10..e3b2a36d403 100644 --- a/ci/init.js +++ b/ci/init.js @@ -7,7 +7,6 @@ const log = require('../packages/dd-trace/src/log') const { getEnvironmentVariable, getValueFromEnvSources } = require('../packages/dd-trace/src/config/helper') const PACKAGE_MANAGERS = ['npm', 'yarn', 'pnpm'] -const CLI_TOOLS = ['cypress'] const DEFAULT_FLUSH_INTERVAL = 5000 const JEST_FLUSH_INTERVAL = 0 const EXPORTER_MAP = { @@ -24,21 +23,6 @@ function isPackageManager () { ) } -function isCliTool () { - // Skip CLI tools like Cypress that spawn child processes we don't want to instrument - if (CLI_TOOLS.some(tool => - process.argv[1]?.endsWith(`bin/${tool}`) || process.argv[1]?.endsWith(`bin/${tool}.js`) - )) { - return true - } - // Skip Electron processes (e.g. Cypress binary) - the config child process - // uses the user's Node.js binary, so process.versions.electron won't be set there - if (process.versions.electron) { - return true - } - return false -} - function detectTestWorkerType () { if (getEnvironmentVariable('JEST_WORKER_ID')) return 'jest' if (getEnvironmentVariable('CUCUMBER_WORKER_ID')) return 'cucumber' @@ -67,11 +51,6 @@ if (!isTestWorker && isPackageManager()) { shouldInit = false } -if (!isTestWorker && isCliTool()) { - log.debug('dd-trace is not initialized in a CLI tool.') - shouldInit = false -} - if (isTestWorker) { baseOptions.telemetry = { enabled: false } baseOptions.experimental = { diff --git a/integration-tests/cypress-custom-after-hooks.config.mjs b/integration-tests/cypress-custom-after-hooks.config.mjs new file mode 100644 index 00000000000..a4cb02b6e3c --- /dev/null +++ b/integration-tests/cypress-custom-after-hooks.config.mjs @@ -0,0 +1,34 @@ +import { defineConfig } from 'cypress' + +export default defineConfig({ + defaultCommandTimeout: 1000, + e2e: { + setupNodeEvents (on, config) { + on('after:spec', (spec, results) => { + // eslint-disable-next-line no-console + console.log('[custom:after:spec]', spec.relative, results.stats.passes) + return new Promise((resolve) => { + setTimeout(() => { + // eslint-disable-next-line no-console + console.log('[custom:after:spec:resolved]') + resolve() + }, 50) + }) + }) + on('after:run', (results) => { + // eslint-disable-next-line no-console + console.log('[custom:after:run]', results.totalPassed) + return new Promise((resolve) => { + setTimeout(() => { + // eslint-disable-next-line no-console + console.log('[custom:after:run:resolved]') + resolve() + }, 50) + }) + }) + }, + specPattern: process.env.SPEC_PATTERN || 'cypress/e2e/**/*.cy.js', + }, + video: false, + screenshotOnRunFailure: false, +}) diff --git a/integration-tests/cypress-double-run.mjs b/integration-tests/cypress-double-run.mjs new file mode 100644 index 00000000000..dd05e14b46c --- /dev/null +++ b/integration-tests/cypress-double-run.mjs @@ -0,0 +1,21 @@ +import cypress from 'cypress' + +const runOptions = { + config: { + defaultCommandTimeout: 1000, + e2e: { + supportFile: 'cypress/support/e2e.js', + testIsolation: process.env.CYPRESS_TEST_ISOLATION !== 'false', + specPattern: process.env.SPEC_PATTERN || 'cypress/e2e/**/*.cy.js', + }, + video: false, + screenshotOnRunFailure: false, + }, +} + +for (let runNumber = 0; runNumber < 2; runNumber++) { + const results = await cypress.run(runOptions) + if (results.totalFailed !== 0) { + process.exit(1) + } +} diff --git a/integration-tests/cypress-legacy-plugin.config.mjs b/integration-tests/cypress-legacy-plugin.config.mjs new file mode 100644 index 00000000000..faa0cbc8f99 --- /dev/null +++ b/integration-tests/cypress-legacy-plugin.config.mjs @@ -0,0 +1,14 @@ +import { defineConfig } from 'cypress' +import ddTracePlugin from 'dd-trace/ci/cypress/plugin' + +export default defineConfig({ + defaultCommandTimeout: 1000, + e2e: { + setupNodeEvents (on, config) { + return ddTracePlugin(on, config) + }, + specPattern: process.env.SPEC_PATTERN || 'cypress/e2e/**/*.cy.js', + }, + video: false, + screenshotOnRunFailure: false, +}) diff --git a/integration-tests/cypress-plain-object-manual.config.mjs b/integration-tests/cypress-plain-object-manual.config.mjs new file mode 100644 index 00000000000..14a52674e9b --- /dev/null +++ b/integration-tests/cypress-plain-object-manual.config.mjs @@ -0,0 +1,14 @@ +import ddTracePlugin from 'dd-trace/ci/cypress/plugin' + +export default { + defaultCommandTimeout: 1000, + e2e: { + setupNodeEvents (on, config) { + return ddTracePlugin(on, config) + }, + specPattern: process.env.SPEC_PATTERN || 'cypress/e2e/**/*.cy.js', + supportFile: 'cypress/support/e2e.js', + }, + video: false, + screenshotOnRunFailure: false, +} diff --git a/integration-tests/cypress-return-config.config.js b/integration-tests/cypress-return-config.config.js index c5472c8c846..fde4398e7a0 100644 --- a/integration-tests/cypress-return-config.config.js +++ b/integration-tests/cypress-return-config.config.js @@ -5,7 +5,8 @@ const { defineConfig } = require('cypress') module.exports = defineConfig({ defaultCommandTimeout: 1000, e2e: { - setupNodeEvents () { + async setupNodeEvents () { + await new Promise((resolve) => setTimeout(resolve, 50)) return { env: { RETURNED_CONFIG_FLAG: 'true', diff --git a/integration-tests/cypress-return-config.config.mjs b/integration-tests/cypress-return-config.config.mjs new file mode 100644 index 00000000000..25ab995728a --- /dev/null +++ b/integration-tests/cypress-return-config.config.mjs @@ -0,0 +1,19 @@ +import { defineConfig } from 'cypress' + +export default defineConfig({ + defaultCommandTimeout: 1000, + e2e: { + async setupNodeEvents () { + await new Promise((resolve) => setTimeout(resolve, 50)) + return { + env: { + RETURNED_CONFIG_FLAG: 'true', + }, + specPattern: 'cypress/e2e/returned-config.cy.js', + } + }, + specPattern: 'cypress/e2e/basic-fail.js', + }, + video: false, + screenshotOnRunFailure: false, +}) diff --git a/integration-tests/cypress/cypress.spec.js b/integration-tests/cypress/cypress.spec.js index 290c80e738f..c1ef327c682 100644 --- a/integration-tests/cypress/cypress.spec.js +++ b/integration-tests/cypress/cypress.spec.js @@ -67,7 +67,7 @@ const { } = require('../../packages/dd-trace/src/plugins/util/test') const { DD_HOST_CPU_COUNT } = require('../../packages/dd-trace/src/plugins/util/env') const { ERROR_MESSAGE, ERROR_TYPE, COMPONENT } = require('../../packages/dd-trace/src/constants') -const { DD_MAJOR, NODE_MAJOR, NODE_MINOR } = require('../../version') +const { DD_MAJOR, NODE_MAJOR } = require('../../version') const { resolveSourceLineForTest } = require('../../packages/datadog-plugin-cypress/src/source-map-utils') const RECEIVER_STOP_TIMEOUT = 20000 @@ -75,8 +75,6 @@ const version = process.env.CYPRESS_VERSION const hookFile = 'dd-trace/loader-hook.mjs' const NUM_RETRIES_EFD = 3 const CYPRESS_PRECOMPILED_SPEC_DIST_DIR = 'cypress/e2e/dist' -const REGISTER_SUPPORTS_IMPORT = NODE_MAJOR > 20 || (NODE_MAJOR === 20 && NODE_MINOR >= 6) - const over12It = (version === 'latest' || semver.gte(version, '12.0.0')) ? it : it.skip const moduleTypes = [ @@ -315,14 +313,9 @@ moduleTypes.forEach(({ ]) }) - // cypress-legacy-plugin.config.js uses defineConfig which only exists in Cypress >=10 - const legacyPluginIt = (version !== '6.7.0') ? it : it.skip - const autoInjectedSupportIt = (version !== '6.7.0') ? it : it.skip - const esmConfigFileIt = (version !== '6.7.0' && type === 'commonJS' && REGISTER_SUPPORTS_IMPORT) ? it : it.skip - const programmaticDoubleRunIt = (version !== '6.7.0' && type === 'commonJS') ? it : it.skip - const plainObjectManualConfigIt = (version !== '6.7.0' && type === 'commonJS') ? it : it.skip - const returnedConfigIt = (version !== '6.7.0' && type === 'commonJS') ? it : it.skip - legacyPluginIt('is backwards compatible with the old manual plugin approach', async () => { + // These tests require Cypress >=10 features (defineConfig, setupNodeEvents) + const over10It = (version !== '6.7.0') ? it : it.skip + over10It('is backwards compatible with the old manual plugin approach', async () => { receiver.setInfoResponse({ endpoints: [] }) const receiverPromise = receiver @@ -345,8 +338,12 @@ moduleTypes.forEach(({ const envVars = getCiVisEvpProxyConfig(receiver.port) + const legacyConfigFile = type === 'esm' + ? 'cypress-legacy-plugin.config.mjs' + : 'cypress-legacy-plugin.config.js' + childProcess = exec( - './node_modules/.bin/cypress run --config-file cypress-legacy-plugin.config.js', + `./node_modules/.bin/cypress run --config-file ${legacyConfigFile}`, { cwd, env: { @@ -363,7 +360,7 @@ moduleTypes.forEach(({ ]) }) - esmConfigFileIt('reports tests when using cypress.config.mjs with NODE_OPTIONS', async () => { + over10It('reports tests when using cypress.config.mjs with NODE_OPTIONS', async () => { const receiverPromise = receiver .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { const events = payloads @@ -390,7 +387,7 @@ moduleTypes.forEach(({ cwd, env: { ...envVars, - NODE_OPTIONS: '--import dd-trace/register.js -r dd-trace/ci/init', + NODE_OPTIONS: '-r dd-trace/ci/init', CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: 'cypress/e2e/basic-pass.js', }, @@ -407,7 +404,7 @@ moduleTypes.forEach(({ assert.strictEqual(exitCode, 0, `cypress process should exit successfully\n${testOutput}`) }) - programmaticDoubleRunIt('reports tests when cypress.run is called twice in the same process', async () => { + over10It('reports tests when cypress.run is called twice in the same process', async () => { const receiverPromise = receiver .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { const passedTests = payloads @@ -428,8 +425,12 @@ moduleTypes.forEach(({ const envVars = getCiVisAgentlessConfig(receiver.port) + const doubleRunScript = type === 'esm' + ? 'node ./cypress-double-run.mjs' + : 'node ./cypress-double-run.js' + childProcess = exec( - 'node ./cypress-double-run.js', + doubleRunScript, { cwd, env: { @@ -448,7 +449,7 @@ moduleTypes.forEach(({ assert.strictEqual(exitCode, 0, 'cypress process should exit successfully') }) - plainObjectManualConfigIt( + over10It( 'reports tests with a plain-object config when dd-trace is manually configured', async () => { const receiverPromise = receiver @@ -470,8 +471,12 @@ moduleTypes.forEach(({ const envVars = getCiVisAgentlessConfig(receiver.port) + const plainObjectConfigFile = type === 'esm' + ? 'cypress-plain-object-manual.config.mjs' + : 'cypress-plain-object-manual.config.js' + childProcess = exec( - './node_modules/.bin/cypress run --config-file cypress-plain-object-manual.config.js', + `./node_modules/.bin/cypress run --config-file ${plainObjectConfigFile}`, { cwd, env: { @@ -491,7 +496,7 @@ moduleTypes.forEach(({ } ) - autoInjectedSupportIt('does not modify the user support file and cleans up the injected wrapper', async () => { + over10It('does not modify the user support file and cleans up the injected wrapper', async () => { const supportFilePath = path.join(cwd, 'cypress/support/e2e.js') const originalSupportContent = fs.readFileSync(supportFilePath, 'utf8') const supportContentWithoutDdTrace = originalSupportContent @@ -549,7 +554,7 @@ moduleTypes.forEach(({ } }) - returnedConfigIt('preserves config returned from setupNodeEvents', async () => { + over10It('preserves config returned from setupNodeEvents', async () => { const receiverPromise = receiver .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { const events = payloads @@ -570,8 +575,12 @@ moduleTypes.forEach(({ const envVars = getCiVisAgentlessConfig(receiver.port) + const returnConfigFile = type === 'esm' + ? 'cypress-return-config.config.mjs' + : 'cypress-return-config.config.js' + childProcess = exec( - './node_modules/.bin/cypress run --config-file cypress-return-config.config.js', + `./node_modules/.bin/cypress run --config-file ${returnConfigFile}`, { cwd, env: envVars, @@ -606,8 +615,12 @@ moduleTypes.forEach(({ const envVars = getCiVisAgentlessConfig(receiver.port) let testOutput = '' + const customHooksConfigFile = type === 'esm' + ? 'cypress-custom-after-hooks.config.mjs' + : 'cypress-custom-after-hooks.config.js' + childProcess = exec( - './node_modules/.bin/cypress run --config-file cypress-custom-after-hooks.config.js', + `./node_modules/.bin/cypress run --config-file ${customHooksConfigFile}`, { cwd, env: { diff --git a/packages/datadog-instrumentations/src/cypress-config.js b/packages/datadog-instrumentations/src/cypress-config.js index 4b5b795be85..dd059af92ad 100644 --- a/packages/datadog-instrumentations/src/cypress-config.js +++ b/packages/datadog-instrumentations/src/cypress-config.js @@ -5,18 +5,8 @@ const os = require('os') const path = require('path') const { pathToFileURL } = require('url') -const { isTrue, isFalse } = require('../../dd-trace/src/util') -const log = require('../../dd-trace/src/log') -const { getEnvironmentVariable, getValueFromEnvSources } = require('../../dd-trace/src/config/helper') - const DD_CONFIG_WRAPPED = Symbol('dd-trace.cypress.config.wrapped') -const DD_CLI_CONFIG_WRAPPER_FILE = 'dd-cypress-config' -const DEFAULT_FLUSH_INTERVAL = 5000 -const DD_TRACE_PRELOADS = { - 'dd-trace/register.js': require.resolve('../../../register.js'), - 'dd-trace/ci/init': require.resolve('../../../ci/init'), - 'dd-trace/loader-hook.mjs': require.resolve('../../../loader-hook.mjs'), -} +const DD_CLI_CONFIG_WRAPPER_FILE = '.dd-cypress-config' const noopTask = { 'dd:testSuiteStart': () => null, @@ -81,13 +71,11 @@ function injectSupportFile (config) { } const ddSupportFile = require.resolve('../../../ci/cypress/support') - const ext = path.extname(originalSupportFile) - const wrapperFile = path.join(os.tmpdir(), `dd-cypress-support-${process.pid}${ext}`) - const isEsm = ext === '.mjs' + const wrapperFile = path.join(os.tmpdir(), `dd-cypress-support-${process.pid}.mjs`) - const wrapperContent = isEsm - ? `import ${JSON.stringify(ddSupportFile)}\nimport ${JSON.stringify(originalSupportFile)}\n` - : `require(${JSON.stringify(ddSupportFile)})\nrequire(${JSON.stringify(originalSupportFile)})\n` + // Always use ESM: it can import both CJS and ESM support files. + const wrapperContent = + `import ${JSON.stringify(ddSupportFile)}\nimport ${JSON.stringify(originalSupportFile)}\n` try { fs.writeFileSync(wrapperFile, wrapperContent) @@ -98,54 +86,6 @@ function injectSupportFile (config) { } } -/** - * Initializes CI Visibility for the Cypress config/plugin process when Cypress - * strips NODE_OPTIONS before loading an ESM config file in Electron. - * - * This cannot just reuse ci/init directly because ci/init intentionally skips - * CLI tools and Electron processes, and this fallback exists specifically for - * Cypress's config/plugin process after NODE_OPTIONS is removed from that path. - * - * @returns {object|undefined} tracer singleton - */ -function ensureCiVisibilityTracer () { - const tracer = global._ddtrace || require('../../../packages/dd-trace') - - if (tracer?._initialized) { - return tracer - } - - if (isFalse(getValueFromEnvSources('DD_CIVISIBILITY_ENABLED'))) { - return tracer - } - - const isAgentlessEnabled = isTrue(getValueFromEnvSources('DD_CIVISIBILITY_AGENTLESS_ENABLED')) - const options = { - startupLogs: false, - isCiVisibility: true, - flushInterval: DEFAULT_FLUSH_INTERVAL, - } - - if (isAgentlessEnabled) { - if (getValueFromEnvSources('DD_API_KEY')) { - options.experimental = { exporter: 'datadog' } - } else { - log.warn( - 'DD_CIVISIBILITY_AGENTLESS_ENABLED is set, but DD_API_KEY is undefined, so Cypress CI Visibility is disabled.' - ) - return tracer - } - } else { - options.experimental = { exporter: 'agent_proxy' } - } - - tracer.init(options) - tracer.use('fs', false) - tracer.use('child_process', false) - - return tracer -} - /** * Registers dd-trace's Cypress hooks (before:run, after:spec, after:run, tasks) * and injects the support file. Handles chaining with user-registered handlers @@ -166,7 +106,7 @@ function registerDdTraceHooks (on, config, userAfterSpecHandlers, userAfterRunHa } } - const tracer = ensureCiVisibilityTracer() + const tracer = global._ddtrace const registerAfterRunWithCleanup = () => { on('after:run', (results) => { @@ -291,76 +231,65 @@ function wrapConfig (config) { } /** - * @param {object|undefined} options - * @returns {{ options: object|undefined, cleanup: Function }} + * @param {string} originalConfigFile absolute path to the original config file + * @returns {string} path to the generated wrapper file */ -function wrapCliConfigFileOptions (options) { - if (typeof options?.configFile !== 'string' || path.extname(options.configFile) !== '.mjs') { - return { options, cleanup: () => {} } - } - - const projectRoot = typeof options.project === 'string' ? options.project : process.cwd() - const originalConfigFile = path.isAbsolute(options.configFile) - ? options.configFile - : path.resolve(projectRoot, options.configFile) - const wrapConfigFile = require.resolve('../../../ci/cypress/wrap-config') +function createConfigWrapper (originalConfigFile) { const wrapperFile = path.join( - os.tmpdir(), - `${DD_CLI_CONFIG_WRAPPER_FILE}-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.mjs` + path.dirname(originalConfigFile), + `${DD_CLI_CONFIG_WRAPPER_FILE}-${process.pid}.mjs` ) - fs.writeFileSync(wrapperFile, [ + const wrapConfigPath = require.resolve('../../../ci/cypress/wrap-config') + + // Always use ESM: it can import both CJS and ESM configs, so it works + // regardless of the original file's extension or "type": "module" in package.json. + const wrapperContent = [ `import originalConfig from ${JSON.stringify(pathToFileURL(originalConfigFile).href)}`, - `import wrapConfig from ${JSON.stringify(pathToFileURL(wrapConfigFile).href)}`, + `import wrapConfig from ${JSON.stringify(pathToFileURL(wrapConfigPath).href)}`, '', 'export default wrapConfig(originalConfig)', '', - ].join('\n')) + ].join('\n') - return { - options: { - ...options, - configFile: wrapperFile, - }, - cleanup: () => { - try { fs.unlinkSync(wrapperFile) } catch { /* best effort */ } - }, - } + fs.writeFileSync(wrapperFile, wrapperContent) + + return wrapperFile } /** - * Rewrite dd-trace preloads to absolute paths so Cypress child processes can - * resolve them even when their cwd is not the project root. + * Wraps the Cypress config file for a CLI start() call. When an explicit + * configFile is provided, creates a temp wrapper that imports the original + * and passes it through wrapConfig. This handles ESM configs (.mjs) and + * plain-object configs (without defineConfig) that can't be intercepted + * via the defineConfig shimmer. * - * @returns {Function} + * @param {object|undefined} options + * @returns {{ options: object|undefined, cleanup: Function }} */ -function rewriteCliNodeOptions () { - const originalNodeOptions = getEnvironmentVariable('NODE_OPTIONS') +function wrapCliConfigFileOptions (options) { + const noop = { options, cleanup: () => {} } - if (!originalNodeOptions) { - return () => {} - } + if (!options || typeof options.configFile !== 'string') return noop - const rewrittenNodeOptions = originalNodeOptions - .split(/\s+/) - .map(part => DD_TRACE_PRELOADS[part] || part) - .join(' ') + const projectRoot = typeof options.project === 'string' ? options.project : process.cwd() + const configFilePath = path.isAbsolute(options.configFile) + ? options.configFile + : path.resolve(projectRoot, options.configFile) - if (rewrittenNodeOptions === originalNodeOptions) { - return () => {} - } + if (!fs.existsSync(configFilePath)) return noop - // eslint-disable-next-line eslint-rules/eslint-process-env - process.env.NODE_OPTIONS = rewrittenNodeOptions + const wrapperFile = createConfigWrapper(configFilePath) - return () => { - // eslint-disable-next-line eslint-rules/eslint-process-env - process.env.NODE_OPTIONS = originalNodeOptions + return { + options: { ...options, configFile: wrapperFile }, + cleanup: () => { + try { fs.unlinkSync(wrapperFile) } catch { /* best effort */ } + }, } } module.exports = { - rewriteCliNodeOptions, wrapCliConfigFileOptions, wrapConfig, } diff --git a/packages/datadog-instrumentations/src/cypress.js b/packages/datadog-instrumentations/src/cypress.js index 5353d930919..f0d76795e7e 100644 --- a/packages/datadog-instrumentations/src/cypress.js +++ b/packages/datadog-instrumentations/src/cypress.js @@ -4,91 +4,52 @@ const shimmer = require('../../datadog-shimmer') const { DD_MAJOR } = require('../../../version') const { addHook } = require('./helpers/instrument') const { - rewriteCliNodeOptions, wrapCliConfigFileOptions, wrapConfig, } = require('./cypress-config') -const DD_API_WRAPPED = Symbol('dd-trace.cypress.api.wrapped') - -/** - * Patch a Cypress API object once. ESM loads can expose both a namespace object - * and a separate default export object, so both shapes need wrapping. - * - * @param {object} cypress Cypress API export object - */ -function wrapCypressApi (cypress) { - if (!cypress || cypress[DD_API_WRAPPED]) return - cypress[DD_API_WRAPPED] = true - +// Wrap defineConfig() so configs are instrumented when loaded in Cypress's +// config child process. This covers both CLI and programmatic usage with CJS configs. +addHook({ + name: 'cypress', + versions: ['>=10.2.0'], +}, (cypress) => { if (typeof cypress.defineConfig === 'function') { shimmer.wrap(cypress, 'defineConfig', (defineConfig) => function (config) { wrapConfig(config) return defineConfig(config) }) } - - if (typeof cypress.run === 'function') { - shimmer.wrap(cypress, 'run', (run) => function (options) { - if (options?.config) { - wrapConfig(options.config) - } - return run.apply(this, arguments) - }) - } - - if (typeof cypress.open === 'function') { - shimmer.wrap(cypress, 'open', (open) => function (options) { - if (options?.config) { - wrapConfig(options.config) - } - return open.apply(this, arguments) - }) - } -} - -// Cypress >=10 introduced defineConfig and setupNodeEvents. -// Auto-instrumentation wraps defineConfig() and cypress.run() to inject the -// plugin automatically. Configs using plain module.exports = { ... } (without -// defineConfig) need to either add defineConfig() or use the manual plugin. -addHook({ - name: 'cypress', - versions: ['>=10.2.0'], -}, (cypress) => { - wrapCypressApi(cypress) - if (cypress?.default && cypress.default !== cypress) { - wrapCypressApi(cypress.default) - } - return cypress }) +// Wrap the CLI entry points (cypress run / cypress open) to handle config files +// that can't be intercepted via the defineConfig shimmer: ESM configs (.mjs) +// and plain-object configs (without defineConfig). function getCliStartWrapper (start) { return function ddTraceCliStart (options) { - const restoreNodeOptions = rewriteCliNodeOptions() const { options: wrappedOptions, cleanup } = wrapCliConfigFileOptions(options) const result = start.call(this, wrappedOptions) if (result && typeof result.then === 'function') { - return result.finally(() => { - cleanup() - restoreNodeOptions() - }) + return result.finally(cleanup) } cleanup() - restoreNodeOptions() return result } } -for (const file of ['lib/exec/run.js', 'lib/exec/open.js']) { +for (const file of ['dist/exec/run.js', 'dist/exec/open.js']) { addHook({ name: 'cypress', versions: ['>=10.2.0'], file, }, (cypressExecModule) => { - shimmer.wrap(cypressExecModule, 'start', getCliStartWrapper) + const target = cypressExecModule.default || cypressExecModule + if (typeof target.start === 'function') { + shimmer.wrap(target, 'start', getCliStartWrapper) + } return cypressExecModule }) } diff --git a/packages/datadog-instrumentations/src/helpers/hooks.js b/packages/datadog-instrumentations/src/helpers/hooks.js index 65062e025ee..3d83b13e406 100644 --- a/packages/datadog-instrumentations/src/helpers/hooks.js +++ b/packages/datadog-instrumentations/src/helpers/hooks.js @@ -53,7 +53,7 @@ module.exports = { 'cookie-parser': () => require('../cookie-parser'), couchbase: () => require('../couchbase'), crypto: () => require('../crypto'), - cypress: { esmFirst: true, fn: () => require('../cypress') }, + cypress: () => require('../cypress'), 'dd-trace-api': () => require('../dd-trace-api'), dns: () => require('../dns'), elasticsearch: () => require('../elasticsearch'), From 0b71a931ac6e7d0878a6104a46aeb72dcbfc4e10 Mon Sep 17 00:00:00 2001 From: Juan Fernandez Date: Wed, 1 Apr 2026 23:16:26 +0200 Subject: [PATCH 12/15] fix(ci-visibility): support all cypress versions and ESM test variants - Hook both lib/exec/{run,open}.js (Cypress 10-14) and dist/exec/{run,open}.js (Cypress 15+) so the CLI wrapper fires across all supported versions - Add ESM variants of all integration test configs and scripts so every scenario runs for both CJS and ESM module types - Add plain-object auto-instrumentation test (no defineConfig, no manual plugin) - Fix ESM config imports to include .js extension for dd-trace/ci/cypress/plugin - Revert ci/init.js to master (no isCliTool/ensureCiVisibilityTracer needed) Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/CODEOWNERS | 2 + .../cypress-legacy-plugin.config.mjs | 2 +- .../cypress-plain-object-auto.config.js | 13 +++++ .../cypress-plain-object-auto.config.mjs | 11 +++++ .../cypress-plain-object-manual.config.mjs | 2 +- integration-tests/cypress/cypress.spec.js | 47 +++++++++++++++++++ .../datadog-instrumentations/src/cypress.js | 4 +- 7 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 integration-tests/cypress-plain-object-auto.config.js create mode 100644 integration-tests/cypress-plain-object-auto.config.mjs diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 51b62afe074..aa34876fe06 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -119,6 +119,8 @@ /integration-tests/cypress-esm-config.mjs @DataDog/ci-app-libraries /integration-tests/cypress-legacy-plugin.config.js @DataDog/ci-app-libraries /integration-tests/cypress-legacy-plugin.config.mjs @DataDog/ci-app-libraries +/integration-tests/cypress-plain-object-auto.config.js @DataDog/ci-app-libraries +/integration-tests/cypress-plain-object-auto.config.mjs @DataDog/ci-app-libraries /integration-tests/cypress-plain-object-manual.config.js @DataDog/ci-app-libraries /integration-tests/cypress-plain-object-manual.config.mjs @DataDog/ci-app-libraries /integration-tests/cypress-return-config.config.js @DataDog/ci-app-libraries diff --git a/integration-tests/cypress-legacy-plugin.config.mjs b/integration-tests/cypress-legacy-plugin.config.mjs index faa0cbc8f99..23d8791a826 100644 --- a/integration-tests/cypress-legacy-plugin.config.mjs +++ b/integration-tests/cypress-legacy-plugin.config.mjs @@ -1,5 +1,5 @@ import { defineConfig } from 'cypress' -import ddTracePlugin from 'dd-trace/ci/cypress/plugin' +import ddTracePlugin from 'dd-trace/ci/cypress/plugin.js' export default defineConfig({ defaultCommandTimeout: 1000, diff --git a/integration-tests/cypress-plain-object-auto.config.js b/integration-tests/cypress-plain-object-auto.config.js new file mode 100644 index 00000000000..4c5bf96a93b --- /dev/null +++ b/integration-tests/cypress-plain-object-auto.config.js @@ -0,0 +1,13 @@ +'use strict' + +// Plain object config without defineConfig and without manual plugin. +// Relies solely on the CLI wrapper to inject setupNodeEvents. +module.exports = { + defaultCommandTimeout: 1000, + e2e: { + specPattern: process.env.SPEC_PATTERN || 'cypress/e2e/**/*.cy.js', + supportFile: 'cypress/support/e2e.js', + }, + video: false, + screenshotOnRunFailure: false, +} diff --git a/integration-tests/cypress-plain-object-auto.config.mjs b/integration-tests/cypress-plain-object-auto.config.mjs new file mode 100644 index 00000000000..e43e70e2026 --- /dev/null +++ b/integration-tests/cypress-plain-object-auto.config.mjs @@ -0,0 +1,11 @@ +// Plain object config without defineConfig and without manual plugin. +// Relies solely on the CLI wrapper to inject setupNodeEvents. +export default { + defaultCommandTimeout: 1000, + e2e: { + specPattern: process.env.SPEC_PATTERN || 'cypress/e2e/**/*.cy.js', + supportFile: 'cypress/support/e2e.js', + }, + video: false, + screenshotOnRunFailure: false, +} diff --git a/integration-tests/cypress-plain-object-manual.config.mjs b/integration-tests/cypress-plain-object-manual.config.mjs index 14a52674e9b..c4925f3c3ca 100644 --- a/integration-tests/cypress-plain-object-manual.config.mjs +++ b/integration-tests/cypress-plain-object-manual.config.mjs @@ -1,4 +1,4 @@ -import ddTracePlugin from 'dd-trace/ci/cypress/plugin' +import ddTracePlugin from 'dd-trace/ci/cypress/plugin.js' export default { defaultCommandTimeout: 1000, diff --git a/integration-tests/cypress/cypress.spec.js b/integration-tests/cypress/cypress.spec.js index c1ef327c682..025a7e54e48 100644 --- a/integration-tests/cypress/cypress.spec.js +++ b/integration-tests/cypress/cypress.spec.js @@ -496,6 +496,53 @@ moduleTypes.forEach(({ } ) + over10It( + 'auto-instruments a plain-object config without defineConfig or manual plugin', + async () => { + const receiverPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { + const events = payloads + .flatMap(({ payload }) => payload.events) + .filter(event => event.type === 'test') + const passedTest = events.find(event => + event.content.resource === 'cypress/e2e/basic-pass.js.basic pass suite can pass' + ) + + assertObjectContains(passedTest?.content, { + meta: { + [TEST_STATUS]: 'pass', + [TEST_FRAMEWORK]: 'cypress', + }, + }) + }, 20000) + + const envVars = getCiVisAgentlessConfig(receiver.port) + + const plainObjectAutoConfigFile = type === 'esm' + ? 'cypress-plain-object-auto.config.mjs' + : 'cypress-plain-object-auto.config.js' + + childProcess = exec( + `./node_modules/.bin/cypress run --config-file ${plainObjectAutoConfigFile}`, + { + cwd, + env: { + ...envVars, + CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, + SPEC_PATTERN: 'cypress/e2e/basic-pass.js', + }, + } + ) + + const [[exitCode]] = await Promise.all([ + once(childProcess, 'exit'), + receiverPromise, + ]) + + assert.strictEqual(exitCode, 0, 'cypress process should exit successfully') + } + ) + over10It('does not modify the user support file and cleans up the injected wrapper', async () => { const supportFilePath = path.join(cwd, 'cypress/support/e2e.js') const originalSupportContent = fs.readFileSync(supportFilePath, 'utf8') diff --git a/packages/datadog-instrumentations/src/cypress.js b/packages/datadog-instrumentations/src/cypress.js index f0d76795e7e..35b7af8745c 100644 --- a/packages/datadog-instrumentations/src/cypress.js +++ b/packages/datadog-instrumentations/src/cypress.js @@ -40,7 +40,9 @@ function getCliStartWrapper (start) { } } -for (const file of ['dist/exec/run.js', 'dist/exec/open.js']) { +// Cypress 10-14 ships lib/exec/{run,open}.js, Cypress 15+ ships dist/exec/{run,open}.js. +// Hook both so the CLI wrapper fires across all supported versions. +for (const file of ['lib/exec/run.js', 'lib/exec/open.js', 'dist/exec/run.js', 'dist/exec/open.js']) { addHook({ name: 'cypress', versions: ['>=10.2.0'], From 92b02d4cd2886adf4a74ead42264fb9dfd2db44a Mon Sep 17 00:00:00 2001 From: Juan Fernandez Date: Thu, 2 Apr 2026 00:17:48 +0200 Subject: [PATCH 13/15] fix(ci-visibility): complete cypress instrumentation coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Resolve default config file when no --config-file is passed, enabling plain-object configs (without defineConfig) in the standard cypress run flow - Skip .ts config files — Cypress transpiles them internally, ESM wrapper can't import .ts directly - Hook both lib/exec/{run,open}.js (Cypress 10-14) and dist/exec/{run,open}.js (Cypress 15+) so the CLI wrapper fires across all supported versions - Respect configFile: false — don't override with a resolved default - Add comments to programmatic test scripts clarifying that instrumentation works via the default config file, not inline configs - Add TypeScript config regression test - Add plain-object default config (no --config-file) regression test - Fix ESM config imports to include .js extension for dd-trace/ci/cypress/plugin Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/CODEOWNERS | 1 + integration-tests/cypress-double-run.js | 4 + integration-tests/cypress-double-run.mjs | 4 + integration-tests/cypress-esm-config.mjs | 4 + .../cypress-typescript.config.ts | 11 +++ integration-tests/cypress/cypress.spec.js | 97 ++++++++++++++++++- .../src/cypress-config.js | 29 +++++- 7 files changed, 144 insertions(+), 6 deletions(-) create mode 100644 integration-tests/cypress-typescript.config.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index aa34876fe06..0f02392a6df 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -125,6 +125,7 @@ /integration-tests/cypress-plain-object-manual.config.mjs @DataDog/ci-app-libraries /integration-tests/cypress-return-config.config.js @DataDog/ci-app-libraries /integration-tests/cypress-return-config.config.mjs @DataDog/ci-app-libraries +/integration-tests/cypress-typescript.config.ts @DataDog/ci-app-libraries /integration-tests/cypress.config.js @DataDog/ci-app-libraries /integration-tests/my-nyc.config.js @DataDog/ci-app-libraries /integration-tests/playwright.config.js @DataDog/ci-app-libraries diff --git a/integration-tests/cypress-double-run.js b/integration-tests/cypress-double-run.js index d1e6c62a90f..acd5ace5ea3 100644 --- a/integration-tests/cypress-double-run.js +++ b/integration-tests/cypress-double-run.js @@ -1,5 +1,9 @@ 'use strict' +// Tests that cypress.run() works twice in the same process (resetRunState). +// Instrumentation works via the default cypress.config.js in the project +// (which uses defineConfig), NOT via the inline config below — Cypress +// does not call setupNodeEvents from inline config objects. const cypress = require('cypress') const runOptions = { diff --git a/integration-tests/cypress-double-run.mjs b/integration-tests/cypress-double-run.mjs index dd05e14b46c..a4e6d2a87d3 100644 --- a/integration-tests/cypress-double-run.mjs +++ b/integration-tests/cypress-double-run.mjs @@ -1,3 +1,7 @@ +// Tests that cypress.run() works twice in the same process (resetRunState). +// Instrumentation works via the default cypress.config.js in the project +// (which uses defineConfig), NOT via the inline config below — Cypress +// does not call setupNodeEvents from inline config objects. import cypress from 'cypress' const runOptions = { diff --git a/integration-tests/cypress-esm-config.mjs b/integration-tests/cypress-esm-config.mjs index b471b043f3b..e835d1636a2 100644 --- a/integration-tests/cypress-esm-config.mjs +++ b/integration-tests/cypress-esm-config.mjs @@ -1,3 +1,7 @@ +// Programmatic ESM entry point for the 'esm' module type tests. +// Instrumentation works via the default cypress.config.js in the project +// (which uses defineConfig), NOT via the inline setupNodeEvents below — +// Cypress does not call setupNodeEvents from inline config objects. import cypress from 'cypress' async function runCypress () { diff --git a/integration-tests/cypress-typescript.config.ts b/integration-tests/cypress-typescript.config.ts new file mode 100644 index 00000000000..35111b34608 --- /dev/null +++ b/integration-tests/cypress-typescript.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'cypress' + +export default defineConfig({ + defaultCommandTimeout: 1000, + e2e: { + specPattern: process.env.SPEC_PATTERN || 'cypress/e2e/**/*.cy.js', + supportFile: 'cypress/support/e2e.js', + }, + video: false, + screenshotOnRunFailure: false, +}) diff --git a/integration-tests/cypress/cypress.spec.js b/integration-tests/cypress/cypress.spec.js index 025a7e54e48..35ce129a830 100644 --- a/integration-tests/cypress/cypress.spec.js +++ b/integration-tests/cypress/cypress.spec.js @@ -404,7 +404,7 @@ moduleTypes.forEach(({ assert.strictEqual(exitCode, 0, `cypress process should exit successfully\n${testOutput}`) }) - over10It('reports tests when cypress.run is called twice in the same process', async () => { + over10It('reports tests when cypress.run is called twice (multi-run state reset)', async () => { const receiverPromise = receiver .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { const passedTests = payloads @@ -543,6 +543,101 @@ moduleTypes.forEach(({ } ) + over10It( + 'auto-instruments a plain-object default config (no --config-file)', + async () => { + const originalConfig = path.join(cwd, 'cypress.config.js') + const backupConfig = path.join(cwd, 'cypress.config.js.bak') + const plainObjectConfig = path.join(cwd, 'cypress-plain-object-auto.config.js') + + // Replace default cypress.config.js with the plain-object config + fs.renameSync(originalConfig, backupConfig) + fs.copyFileSync(plainObjectConfig, originalConfig) + + try { + const receiverPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { + const events = payloads + .flatMap(({ payload }) => payload.events) + .filter(event => event.type === 'test') + const passedTest = events.find(event => + event.content.resource === 'cypress/e2e/basic-pass.js.basic pass suite can pass' + ) + + assertObjectContains(passedTest?.content, { + meta: { + [TEST_STATUS]: 'pass', + [TEST_FRAMEWORK]: 'cypress', + }, + }) + }, 20000) + + const envVars = getCiVisAgentlessConfig(receiver.port) + + childProcess = exec( + './node_modules/.bin/cypress run', + { + cwd, + env: { + ...envVars, + CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, + SPEC_PATTERN: 'cypress/e2e/basic-pass.js', + }, + } + ) + + const [[exitCode]] = await Promise.all([ + once(childProcess, 'exit'), + receiverPromise, + ]) + + assert.strictEqual(exitCode, 0, 'cypress process should exit successfully') + } finally { + fs.renameSync(backupConfig, originalConfig) + } + } + ) + + over10It('reports tests with a TypeScript config file', async () => { + const receiverPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { + const events = payloads + .flatMap(({ payload }) => payload.events) + .filter(event => event.type === 'test') + const passedTest = events.find(event => + event.content.resource === 'cypress/e2e/basic-pass.js.basic pass suite can pass' + ) + + assertObjectContains(passedTest?.content, { + meta: { + [TEST_STATUS]: 'pass', + [TEST_FRAMEWORK]: 'cypress', + }, + }) + }, 20000) + + const envVars = getCiVisAgentlessConfig(receiver.port) + + childProcess = exec( + './node_modules/.bin/cypress run --config-file cypress-typescript.config.ts', + { + cwd, + env: { + ...envVars, + CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, + SPEC_PATTERN: 'cypress/e2e/basic-pass.js', + }, + } + ) + + const [[exitCode]] = await Promise.all([ + once(childProcess, 'exit'), + receiverPromise, + ]) + + assert.strictEqual(exitCode, 0, 'cypress process should exit successfully') + }) + over10It('does not modify the user support file and cleans up the injected wrapper', async () => { const supportFilePath = path.join(cwd, 'cypress/support/e2e.js') const originalSupportContent = fs.readFileSync(supportFilePath, 'utf8') diff --git a/packages/datadog-instrumentations/src/cypress-config.js b/packages/datadog-instrumentations/src/cypress-config.js index dd059af92ad..053d0afb28c 100644 --- a/packages/datadog-instrumentations/src/cypress-config.js +++ b/packages/datadog-instrumentations/src/cypress-config.js @@ -270,14 +270,33 @@ function createConfigWrapper (originalConfigFile) { function wrapCliConfigFileOptions (options) { const noop = { options, cleanup: () => {} } - if (!options || typeof options.configFile !== 'string') return noop + if (!options) return noop const projectRoot = typeof options.project === 'string' ? options.project : process.cwd() - const configFilePath = path.isAbsolute(options.configFile) - ? options.configFile - : path.resolve(projectRoot, options.configFile) + let configFilePath + + if (options.configFile === false) { + // configFile: false means "no config file" — respect Cypress's semantics + return noop + } else if (typeof options.configFile === 'string') { + configFilePath = path.isAbsolute(options.configFile) + ? options.configFile + : path.resolve(projectRoot, options.configFile) + } else { + // No explicit --config-file: resolve the default cypress.config.{js,ts,cjs,mjs} + for (const ext of ['.js', '.ts', '.cjs', '.mjs']) { + const candidate = path.join(projectRoot, `cypress.config${ext}`) + if (fs.existsSync(candidate)) { + configFilePath = candidate + break + } + } + } - if (!fs.existsSync(configFilePath)) return noop + // Skip .ts files — Cypress transpiles them internally via its own loader. + // The ESM wrapper can't import .ts directly. The defineConfig shimmer + // handles .ts configs since they're transpiled to CJS by Cypress. + if (!configFilePath || !fs.existsSync(configFilePath) || path.extname(configFilePath) === '.ts') return noop const wrapperFile = createConfigWrapper(configFilePath) From 64bd146c997d0ea5ca94a26feb2b214ce6d39f20 Mon Sep 17 00:00:00 2001 From: Juan Fernandez Date: Thu, 2 Apr 2026 11:20:32 +0200 Subject: [PATCH 14/15] fix test --- integration-tests/cypress/cypress.spec.js | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/integration-tests/cypress/cypress.spec.js b/integration-tests/cypress/cypress.spec.js index f62ec30e8a0..03507ccd813 100644 --- a/integration-tests/cypress/cypress.spec.js +++ b/integration-tests/cypress/cypress.spec.js @@ -2845,11 +2845,6 @@ moduleTypes.forEach(({ 25000 ) - const { - NODE_OPTIONS, // NODE_OPTIONS dd-trace config does not work with cypress - ...restEnvVars - } = getCiVisEvpProxyConfig(receiver.port) - const specToRun = 'cypress/e2e/dynamic-name-test.cy.js' childProcess = exec( @@ -2857,7 +2852,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...getCiVisEvpProxyConfig(receiver.port), CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: specToRun, }, From 561a2bc5ea3f3912b81fd28ac1b0533b76e757ad Mon Sep 17 00:00:00 2001 From: Juan Fernandez Date: Thu, 2 Apr 2026 20:34:47 +0200 Subject: [PATCH 15/15] fix(ci-visibility): clean up cypress public API and restore test coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Revert ci/cypress/polyfills.js to master (Module._resolveFilename patch not needed for Cypress >=10) - Remove ci/cypress/wrap-config.js — ESM wrapper imports cypress-config.js directly, no CJS bridge needed in the public API path - Restore dd-trace/ci/cypress/after-run and after-spec manual API coverage via CYPRESS_ENABLE_AFTER_RUN_CUSTOM/AFTER_SPEC_CUSTOM/MANUAL_PLUGIN env vars in cypress.config.js - Add regression test for explicit after:run/after:spec with manual plugin Co-Authored-By: Claude Opus 4.6 (1M context) --- ci/cypress/polyfills.js | 15 -------- ci/cypress/wrap-config.js | 3 -- integration-tests/cypress.config.js | 15 ++++++++ integration-tests/cypress/cypress.spec.js | 35 +++++++++++++++++++ .../src/cypress-config.js | 7 ++-- 5 files changed, 54 insertions(+), 21 deletions(-) delete mode 100644 ci/cypress/wrap-config.js diff --git a/ci/cypress/polyfills.js b/ci/cypress/polyfills.js index 4ca6e942d13..364808c28a5 100644 --- a/ci/cypress/polyfills.js +++ b/ci/cypress/polyfills.js @@ -1,20 +1,5 @@ 'use strict' -// node: prefix for built-in modules (e.g. require('node:path')) was added in -// Node.js 14.18.0 / 16.0.0. Cypress 6.7.0 runs on Node.js 12, so we patch -// Module._resolveFilename to strip the prefix before any dd-trace code loads. -const Module = require('module') -const originalResolveFilename = Module._resolveFilename -Module._resolveFilename = function (request, parent, isMain, options) { - return originalResolveFilename.call( - this, - request.startsWith('node:') ? request.slice(5) : request, - parent, - isMain, - options - ) -} - if (!Object.hasOwn) { Object.defineProperty(Object, 'hasOwn', { // eslint-disable-next-line prefer-object-has-own diff --git a/ci/cypress/wrap-config.js b/ci/cypress/wrap-config.js deleted file mode 100644 index 929f53f2035..00000000000 --- a/ci/cypress/wrap-config.js +++ /dev/null @@ -1,3 +0,0 @@ -'use strict' - -module.exports = require('../../packages/datadog-instrumentations/src/cypress-config').wrapConfig diff --git a/integration-tests/cypress.config.js b/integration-tests/cypress.config.js index e9318ad128a..3c665d78524 100644 --- a/integration-tests/cypress.config.js +++ b/integration-tests/cypress.config.js @@ -10,6 +10,21 @@ module.exports = defineConfig({ if (process.env.CYPRESS_ENABLE_INCOMPATIBLE_PLUGIN) { require('cypress-fail-fast/plugin')(on, config) } + if (process.env.CYPRESS_ENABLE_AFTER_RUN_CUSTOM) { + const ddAfterRun = require('dd-trace/ci/cypress/after-run') + on('after:run', (...args) => { + return ddAfterRun(...args) + }) + } + if (process.env.CYPRESS_ENABLE_AFTER_SPEC_CUSTOM) { + const ddAfterSpec = require('dd-trace/ci/cypress/after-spec') + on('after:spec', (...args) => { + return ddAfterSpec(...args) + }) + } + if (process.env.CYPRESS_ENABLE_MANUAL_PLUGIN) { + return require('dd-trace/ci/cypress/plugin')(on, config) + } }, specPattern: process.env.SPEC_PATTERN || 'cypress/e2e/**/*.cy.js', }, diff --git a/integration-tests/cypress/cypress.spec.js b/integration-tests/cypress/cypress.spec.js index 03507ccd813..0ef24c27c78 100644 --- a/integration-tests/cypress/cypress.spec.js +++ b/integration-tests/cypress/cypress.spec.js @@ -790,6 +790,41 @@ moduleTypes.forEach(({ assert.match(testOutput, /\[custom:after:run:resolved\]/) }) + // Tests the old manual API: dd-trace/ci/cypress/after-run and after-spec + // used alongside the manual plugin, without NODE_OPTIONS auto-instrumentation. + over10It('works if after:run and after:spec are explicitly used with the manual plugin', async () => { + const receiverPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { + const events = payloads.flatMap(({ payload }) => payload.events) + const testSessionEvent = events.find(event => event.type === 'test_session_end') + assert.ok(testSessionEvent) + const testEvents = events.filter(event => event.type === 'test') + assert.ok(testEvents.length > 0) + }, 30000) + + const envVars = getCiVisAgentlessConfig(receiver.port) + + childProcess = exec( + testCommand, + { + cwd, + env: { + ...envVars, + CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, + CYPRESS_ENABLE_AFTER_RUN_CUSTOM: '1', + CYPRESS_ENABLE_AFTER_SPEC_CUSTOM: '1', + CYPRESS_ENABLE_MANUAL_PLUGIN: '1', + SPEC_PATTERN: 'cypress/e2e/basic-pass.js', + }, + } + ) + + await Promise.all([ + once(childProcess, 'exit'), + receiverPromise, + ]) + }) + over12It('reports correct source file and line for pre-compiled typescript test files', async function () { const envVars = getCiVisAgentlessConfig(receiver.port) diff --git a/packages/datadog-instrumentations/src/cypress-config.js b/packages/datadog-instrumentations/src/cypress-config.js index 053d0afb28c..c2feb0cfc79 100644 --- a/packages/datadog-instrumentations/src/cypress-config.js +++ b/packages/datadog-instrumentations/src/cypress-config.js @@ -240,15 +240,16 @@ function createConfigWrapper (originalConfigFile) { `${DD_CLI_CONFIG_WRAPPER_FILE}-${process.pid}.mjs` ) - const wrapConfigPath = require.resolve('../../../ci/cypress/wrap-config') + const cypressConfigPath = require.resolve('./cypress-config') // Always use ESM: it can import both CJS and ESM configs, so it works // regardless of the original file's extension or "type": "module" in package.json. + // Import cypress-config.js directly (CJS default = module.exports object). const wrapperContent = [ `import originalConfig from ${JSON.stringify(pathToFileURL(originalConfigFile).href)}`, - `import wrapConfig from ${JSON.stringify(pathToFileURL(wrapConfigPath).href)}`, + `import cypressConfig from ${JSON.stringify(pathToFileURL(cypressConfigPath).href)}`, '', - 'export default wrapConfig(originalConfig)', + 'export default cypressConfig.wrapConfig(originalConfig)', '', ].join('\n')