diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 0d5eaf21ca5..456e06f3ea7 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -111,7 +111,21 @@ /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-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-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 +/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/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-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-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-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.js b/integration-tests/cypress-double-run.js new file mode 100644 index 00000000000..acd5ace5ea3 --- /dev/null +++ b/integration-tests/cypress-double-run.js @@ -0,0 +1,35 @@ +'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 = { + 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-double-run.mjs b/integration-tests/cypress-double-run.mjs new file mode 100644 index 00000000000..a4e6d2a87d3 --- /dev/null +++ b/integration-tests/cypress-double-run.mjs @@ -0,0 +1,25 @@ +// 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 = { + 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-esm-config.mjs b/integration-tests/cypress-esm-config.mjs index 4e36b444ae0..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 () { @@ -8,31 +12,10 @@ async function runCypress () { testIsolation: process.env.CYPRESS_TEST_ISOLATION !== 'false', setupNodeEvents (on, config) { if (process.env.CYPRESS_ENABLE_INCOMPATIBLE_PLUGIN) { - import('cypress-fail-fast/plugin').then(module => { + return 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) - }) }, specPattern: process.env.SPEC_PATTERN || 'cypress/e2e/**/*.cy.js', }, @@ -40,6 +23,7 @@ async function runCypress () { screenshotOnRunFailure: false, }, }) + 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 new file mode 100644 index 00000000000..016ae6ee76c --- /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 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') + +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-legacy-plugin.config.mjs b/integration-tests/cypress-legacy-plugin.config.mjs new file mode 100644 index 00000000000..23d8791a826 --- /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.js' + +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-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.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-plain-object-manual.config.mjs b/integration-tests/cypress-plain-object-manual.config.mjs new file mode 100644 index 00000000000..c4925f3c3ca --- /dev/null +++ b/integration-tests/cypress-plain-object-manual.config.mjs @@ -0,0 +1,14 @@ +import ddTracePlugin from 'dd-trace/ci/cypress/plugin.js' + +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 new file mode 100644 index 00000000000..fde4398e7a0 --- /dev/null +++ b/integration-tests/cypress-return-config.config.js @@ -0,0 +1,21 @@ +'use strict' + +const { defineConfig } = require('cypress') + +module.exports = 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-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-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.config.js b/integration-tests/cypress.config.js index 091320304c9..3c665d78524 100644 --- a/integration-tests/cypress.config.js +++ b/integration-tests/cypress.config.js @@ -1,36 +1,33 @@ '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) { + const ddAfterRun = require('dd-trace/ci/cypress/after-run') 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) { + const ddAfterSpec = require('dd-trace/ci/cypress/after-spec') on('after:spec', (...args) => { - // do custom stuff - // and call after-spec at the end return ddAfterSpec(...args) }) } - return ddTracePlugin(on, config) + 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', }, video: false, screenshotOnRunFailure: false, -} +}) diff --git a/integration-tests/cypress/cypress.spec.js b/integration-tests/cypress/cypress.spec.js index f6642405c4e..0ef24c27c78 100644 --- a/integration-tests/cypress/cypress.spec.js +++ b/integration-tests/cypress/cypress.spec.js @@ -76,7 +76,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 over12It = (version === 'latest' || semver.gte(version, '12.0.0')) ? it : it.skip const moduleTypes = [ @@ -288,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' @@ -305,7 +301,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: specToRun, }, @@ -318,17 +314,526 @@ moduleTypes.forEach(({ ]) }) + // 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 + .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) + + const legacyConfigFile = type === 'esm' + ? 'cypress-legacy-plugin.config.mjs' + : 'cypress-legacy-plugin.config.js' + + childProcess = exec( + `./node_modules/.bin/cypress run --config-file ${legacyConfigFile}`, + { + cwd, + env: { + ...envVars, + CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, + SPEC_PATTERN: 'cypress/e2e/basic-pass.js', + }, + } + ) + + await Promise.all([ + once(childProcess, 'exit'), + receiverPromise, + ]) + }) + + 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 + .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: '-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}`) + }) + + 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 + .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) + + const doubleRunScript = type === 'esm' + ? 'node ./cypress-double-run.mjs' + : 'node ./cypress-double-run.js' + + childProcess = exec( + doubleRunScript, + { + 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( + '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) + + 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 ${plainObjectConfigFile}`, + { + 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( + '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( + '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') + 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) + } + }) + + over10It('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) + + const returnConfigFile = type === 'esm' + ? 'cypress-return-config.config.mjs' + : 'cypress-return-config.config.js' + + childProcess = exec( + `./node_modules/.bin/cypress run --config-file ${returnConfigFile}`, + { + 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) => { + 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) + + 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 ${customHooksConfigFile}`, + { + 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 }) + + await Promise.all([ + once(childProcess, 'exit'), + once(childProcess.stdout, 'end'), + once(childProcess.stderr, 'end'), + receiverPromise, + ]) + + // 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\]/) + }) + + // 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 { 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) => { @@ -379,7 +884,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', }, @@ -434,12 +939,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) => { @@ -465,7 +970,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', }, @@ -480,12 +985,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) => { @@ -513,7 +1018,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', }, @@ -527,7 +1032,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) => { @@ -555,7 +1060,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', }, @@ -609,14 +1114,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', }, @@ -626,46 +1131,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 @@ -685,7 +1152,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: 'cypress/e2e/spec.cy.js', }, @@ -697,9 +1164,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 @@ -715,7 +1181,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, DD_SITE: '= invalid = url', SPEC_PATTERN: 'cypress/e2e/spec.cy.js', @@ -755,14 +1221,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') @@ -896,17 +1363,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', @@ -923,10 +1387,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 @@ -953,7 +1414,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: 'cypress/e2e/spec.cy.js', }, @@ -975,17 +1436,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', }, @@ -1023,17 +1481,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', }, @@ -1094,17 +1549,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', }, @@ -1152,17 +1604,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', }, @@ -1230,17 +1679,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', }, @@ -1302,17 +1748,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', }, @@ -1358,17 +1801,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', }, @@ -1398,17 +1838,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', }, @@ -1438,10 +1875,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) => { @@ -1464,7 +1898,7 @@ moduleTypes.forEach(({ { cwd: `${cwd}/ci-visibility/subproject`, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, }, } @@ -1482,10 +1916,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 => { @@ -1507,7 +1938,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', @@ -1521,45 +1952,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({ @@ -1609,10 +2001,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' @@ -1621,7 +2010,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: specToRun, }, @@ -1654,10 +2043,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 => { @@ -1682,7 +2068,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: specToRun, DD_CIVISIBILITY_EARLY_FLAKE_DETECTION_ENABLED: 'false', @@ -1711,10 +2097,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 => { @@ -1739,7 +2122,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: 'cypress/e2e/skipped-test.js', }, @@ -1768,10 +2151,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 @@ -1796,7 +2176,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: specToRun, }, @@ -1829,10 +2209,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 => { @@ -1857,7 +2234,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: specToRun, DD_CIVISIBILITY_EARLY_FLAKE_DETECTION_ENABLED: 'false', @@ -1890,10 +2267,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 => { @@ -1919,7 +2293,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: specToRun, }, @@ -1976,10 +2350,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' @@ -1988,7 +2359,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: specToRun, CYPRESS_TEST_ISOLATION: 'false', @@ -2076,10 +2447,7 @@ moduleTypes.forEach(({ }) }, 25000) - const { - NODE_OPTIONS, - ...restEnvVars - } = getCiVisEvpProxyConfig(receiver.port) + const envVars = getCiVisEvpProxyConfig(receiver.port) const specToRun = 'cypress/e2e/spec.cy.js' @@ -2088,7 +2456,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: specToRun, }, @@ -2195,10 +2563,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' @@ -2207,7 +2572,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: specToRun, }, @@ -2253,10 +2618,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' @@ -2265,7 +2627,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, DD_CIVISIBILITY_FLAKY_RETRY_ENABLED: 'false', SPEC_PATTERN: specToRun, @@ -2314,10 +2676,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' @@ -2326,7 +2685,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, DD_CIVISIBILITY_FLAKY_RETRY_COUNT: '1', SPEC_PATTERN: specToRun, @@ -2368,10 +2727,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' @@ -2380,7 +2736,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, DD_CIVISIBILITY_FLAKY_RETRY_COUNT: '1', SPEC_PATTERN: specToRun, @@ -2418,10 +2774,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' @@ -2430,7 +2783,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: specToRun, CYPRESS_TEST_ISOLATION: 'false', @@ -2457,10 +2810,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) => { @@ -2479,7 +2829,7 @@ moduleTypes.forEach(({ { cwd: `${cwd}/ci-visibility/subproject`, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, }, } @@ -2530,11 +2880,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( @@ -2542,7 +2887,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...getCiVisEvpProxyConfig(receiver.port), CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: specToRun, }, @@ -2573,10 +2918,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 => { @@ -2601,7 +2943,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: specToRun, DD_CIVISIBILITY_EARLY_FLAKE_DETECTION_ENABLED: 'false', @@ -2619,14 +2961,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 @@ -2656,7 +2996,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, CYPRESS_BASE_URL_SECOND: `http://localhost:${secondWebAppPort}`, SPEC_PATTERN: specToRun, @@ -2686,17 +3026,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', @@ -2835,10 +3172,7 @@ moduleTypes.forEach(({ isDisabled, }) - const { - NODE_OPTIONS, - ...restEnvVars - } = getCiVisEvpProxyConfig(receiver.port) + const envVars = getCiVisEvpProxyConfig(receiver.port) const specToRun = 'cypress/e2e/attempt-to-fix.js' @@ -2847,7 +3181,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: specToRun, ...extraEnvVars, @@ -3004,10 +3338,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' @@ -3016,7 +3347,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: specToRun, ...extraEnvVars, @@ -3107,10 +3438,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' @@ -3119,7 +3447,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: specToRun, ...extraEnvVars, @@ -3178,10 +3506,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' @@ -3190,7 +3515,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: specToRun, DD_TRACE_DEBUG: '1', @@ -3250,10 +3575,7 @@ moduleTypes.forEach(({ }) }, 25000) - const { - NODE_OPTIONS, - ...restEnvVars - } = getCiVisEvpProxyConfig(receiver.port) + const envVars = getCiVisEvpProxyConfig(receiver.port) const specToRun = 'cypress/e2e/attempt-to-fix.js' @@ -3262,7 +3584,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: specToRun, CYPRESS_SHOULD_ALWAYS_PASS: '1', @@ -3341,10 +3663,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' @@ -3353,7 +3672,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: specToRun, }, @@ -3382,7 +3701,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 => { @@ -3399,10 +3720,7 @@ moduleTypes.forEach(({ }) }, 25000) - const { - NODE_OPTIONS, - ...restEnvVars - } = getCiVisEvpProxyConfig(receiver.port) + const envVars = getCiVisEvpProxyConfig(receiver.port) const specToRun = 'cypress/e2e/spec.cy.js' @@ -3411,7 +3729,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, DD_TEST_SESSION_NAME: 'my-test-session-name', SPEC_PATTERN: specToRun, @@ -3542,10 +3860,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' @@ -3554,7 +3869,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: specToRun, GITHUB_BASE_REF: '', @@ -3661,10 +3976,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' @@ -3673,7 +3985,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: specToRun, GITHUB_BASE_REF: '', @@ -3754,10 +4066,7 @@ moduleTypes.forEach(({ }) }, 25000) - const { - NODE_OPTIONS, - ...restEnvVars - } = getCiVisEvpProxyConfig(receiver.port) + const envVars = getCiVisEvpProxyConfig(receiver.port) const specToRun = 'cypress/e2e/impacted-test-order.js' @@ -3766,7 +4075,7 @@ moduleTypes.forEach(({ { cwd, env: { - ...restEnvVars, + ...envVars, CYPRESS_BASE_URL: `http://localhost:${webAppPort}`, SPEC_PATTERN: specToRun, GITHUB_BASE_REF: '', 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..c2feb0cfc79 --- /dev/null +++ b/packages/datadog-instrumentations/src/cypress-config.js @@ -0,0 +1,315 @@ +'use strict' + +const fs = require('fs') +const os = require('os') +const path = require('path') +const { pathToFileURL } = require('url') + +const DD_CONFIG_WRAPPED = Symbol('dd-trace.cypress.config.wrapped') +const DD_CLI_CONFIG_WRAPPER_FILE = '.dd-cypress-config' + +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 wrapperFile = path.join(os.tmpdir(), `dd-cypress-support-${process.pid}.mjs`) + + // 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) + config.supportFile = wrapperFile + return wrapperFile + } catch { + // Can't write wrapper - skip injection + } +} + +/** + * 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 = 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 (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 {string} originalConfigFile absolute path to the original config file + * @returns {string} path to the generated wrapper file + */ +function createConfigWrapper (originalConfigFile) { + const wrapperFile = path.join( + path.dirname(originalConfigFile), + `${DD_CLI_CONFIG_WRAPPER_FILE}-${process.pid}.mjs` + ) + + 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 cypressConfig from ${JSON.stringify(pathToFileURL(cypressConfigPath).href)}`, + '', + 'export default cypressConfig.wrapConfig(originalConfig)', + '', + ].join('\n') + + fs.writeFileSync(wrapperFile, wrapperContent) + + return wrapperFile +} + +/** + * 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. + * + * @param {object|undefined} options + * @returns {{ options: object|undefined, cleanup: Function }} + */ +function wrapCliConfigFileOptions (options) { + const noop = { options, cleanup: () => {} } + + if (!options) return noop + + const projectRoot = typeof options.project === 'string' ? options.project : process.cwd() + 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 + } + } + } + + // 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) + + return { + options: { ...options, configFile: wrapperFile }, + cleanup: () => { + try { fs.unlinkSync(wrapperFile) } catch { /* best effort */ } + }, + } +} + +module.exports = { + wrapCliConfigFileOptions, + wrapConfig, +} diff --git a/packages/datadog-instrumentations/src/cypress.js b/packages/datadog-instrumentations/src/cypress.js index 1d22ffe0a42..35b7af8745c 100644 --- a/packages/datadog-instrumentations/src/cypress.js +++ b/packages/datadog-instrumentations/src/cypress.js @@ -1,11 +1,67 @@ 'use strict' +const shimmer = require('../../datadog-shimmer') const { DD_MAJOR } = require('../../../version') const { addHook } = require('./helpers/instrument') +const { + wrapCliConfigFileOptions, + wrapConfig, +} = require('./cypress-config') -// No handler because this is only useful for testing. -// Cypress plugin does not patch any library. +// 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: DD_MAJOR >= 6 ? ['>=10.2.0'] : ['>=6.7.0'], -}, lib => lib) + versions: ['>=10.2.0'], +}, (cypress) => { + if (typeof cypress.defineConfig === 'function') { + shimmer.wrap(cypress, 'defineConfig', (defineConfig) => function (config) { + wrapConfig(config) + return defineConfig(config) + }) + } + 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 { options: wrappedOptions, cleanup } = wrapCliConfigFileOptions(options) + const result = start.call(this, wrappedOptions) + + if (result && typeof result.then === 'function') { + return result.finally(cleanup) + } + + cleanup() + return result + } +} + +// 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'], + file, + }, (cypressExecModule) => { + const target = cypressExecModule.default || cypressExecModule + if (typeof target.start === 'function') { + shimmer.wrap(target, '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. +// 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) +} diff --git a/packages/datadog-plugin-cypress/src/cypress-plugin.js b/packages/datadog-plugin-cypress/src/cypress-plugin.js index 015a6307024..52bb099345e 100644 --- a/packages/datadog-plugin-cypress/src/cypress-plugin.js +++ b/packages/datadog-plugin-cypress/src/cypress-plugin.js @@ -307,10 +307,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 @@ -694,20 +739,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() } }) }