Skip to content

Commit 5452091

Browse files
[test optimization] fix cypress TS config auto-instrumentation and OTLP override (#8073)
1 parent ab1659c commit 5452091

3 files changed

Lines changed: 251 additions & 17 deletions

File tree

integration-tests/cypress/cypress.spec.js

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,10 @@ moduleTypes.forEach(({
354354

355355
// These tests require Cypress >=10 features (defineConfig, setupNodeEvents)
356356
const over10It = (version !== '6.7.0') ? it : it.skip
357+
// Cypress <14 shipped an older ts-node ESM loader that doesn't implement the
358+
// current Node.js ESM hooks chain (ERR_LOADER_CHAIN_INCOMPLETE), so TS configs
359+
// under `"type": "module"` can't be loaded at all, regardless of dd-trace.
360+
const over14It = (version === 'latest' || semver.gte(version, '14.0.0')) ? it : it.skip
357361
over10It('is backwards compatible with the old manual plugin approach', async () => {
358362
receiver.setInfoResponse({ endpoints: [] })
359363

@@ -750,6 +754,177 @@ moduleTypes.forEach(({
750754
assert.strictEqual(exitCode, 0, 'cypress process should exit successfully')
751755
})
752756

757+
// Regression guard: when the surrounding package has "type": "module",
758+
// the .ts config is transpiled and loaded as ESM. Cypress's CJS
759+
// addHook path cannot intercept the ESM `import 'cypress'`, so the
760+
// only route to `wrapConfig` is the CLI-wrap path that rewrites
761+
// --config-file to a wrapper. An earlier version bailed out on `.ts`
762+
// here and silently skipped instrumentation — no test_session /
763+
// test_module / test_suite / test spans reached the intake.
764+
//
765+
// Set up the ESM project inside a dedicated subdirectory so Cypress
766+
// resolves `type: module` and the tsconfig only for this test. Using
767+
// the sandbox root would leak cached ts-node / webpack state into
768+
// later tests (Cypress caches based on the project root).
769+
over14It('reports tests with a TypeScript config file under "type": "module"', async () => {
770+
const subprojectDir = path.join(cwd, 'esm-ts-subproject')
771+
fs.rmSync(subprojectDir, { recursive: true, force: true })
772+
fs.mkdirSync(path.join(subprojectDir, 'cypress', 'e2e'), { recursive: true })
773+
fs.writeFileSync(path.join(subprojectDir, 'package.json'), JSON.stringify({
774+
name: 'esm-ts-subproject',
775+
type: 'module',
776+
}, null, 2))
777+
// `module: nodenext` so ts-node transpiles the `.ts` as ESM — real-world
778+
// ESM TS projects already ship this; the default (CommonJS) emit would
779+
// produce `exports is not defined in ES module scope` at runtime.
780+
fs.writeFileSync(path.join(subprojectDir, 'tsconfig.json'), JSON.stringify({
781+
compilerOptions: { module: 'nodenext', moduleResolution: 'nodenext', target: 'ES2022' },
782+
}, null, 2))
783+
// Minimal self-contained config so the subproject doesn't depend on
784+
// anything under the sandbox's `cypress/` tree beyond the support
785+
// file (which wires dd-trace's browser-side hooks via the shared
786+
// `dd-trace` package already installed in the sandbox).
787+
fs.writeFileSync(path.join(subprojectDir, 'cypress.config.ts'), [
788+
"import { defineConfig } from 'cypress'",
789+
'',
790+
'export default defineConfig({',
791+
' defaultCommandTimeout: 1000,',
792+
' e2e: {',
793+
" specPattern: 'cypress/e2e/**/*.cy.js',",
794+
" supportFile: 'cypress/support/e2e.js',",
795+
' },',
796+
' video: false,',
797+
' screenshotOnRunFailure: false,',
798+
'})',
799+
'',
800+
].join('\n'))
801+
fs.mkdirSync(path.join(subprojectDir, 'cypress', 'support'), { recursive: true })
802+
fs.copyFileSync(
803+
path.join(cwd, 'cypress', 'support', 'e2e.js'),
804+
path.join(subprojectDir, 'cypress', 'support', 'e2e.js')
805+
)
806+
// Minimal passing spec so the test is self-contained and doesn't
807+
// depend on the rest of the sandbox's e2e tree.
808+
fs.writeFileSync(path.join(subprojectDir, 'cypress', 'e2e', 'basic-pass.cy.js'), [
809+
'/* eslint-disable */',
810+
"describe('basic pass suite', () => {",
811+
" it('can pass', () => {",
812+
" cy.visit('/')",
813+
" cy.get('.hello-world').should('have.text', 'Hello World')",
814+
' })',
815+
'})',
816+
'',
817+
].join('\n'))
818+
819+
let testOutput = ''
820+
try {
821+
const receiverPromise = receiver
822+
.gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => {
823+
const events = payloads.flatMap(({ payload }) => payload.events)
824+
825+
// Full span hierarchy must be present — not just a stray telemetry span.
826+
const sessionEvents = events.filter(event => event.type === 'test_session_end')
827+
const moduleEvents = events.filter(event => event.type === 'test_module_end')
828+
const suiteEvents = events.filter(event => event.type === 'test_suite_end')
829+
const testEvents = events.filter(event => event.type === 'test')
830+
831+
assert.strictEqual(sessionEvents.length, 1, `one test_session span\n${testOutput}`)
832+
assert.strictEqual(moduleEvents.length, 1, `one test_module span\n${testOutput}`)
833+
assert.ok(suiteEvents.length >= 1, `at least one test_suite span\n${testOutput}`)
834+
835+
const passedTest = testEvents.find(event =>
836+
event.content.resource === 'cypress/e2e/basic-pass.cy.js.basic pass suite can pass'
837+
)
838+
assertObjectContains(passedTest?.content, {
839+
meta: {
840+
[TEST_STATUS]: 'pass',
841+
[TEST_FRAMEWORK]: 'cypress',
842+
},
843+
})
844+
}, 20000)
845+
846+
const envVars = getCiVisAgentlessConfig(receiver.port)
847+
848+
// Run Cypress *from* the subproject so its project root is the
849+
// ESM-configured directory; keeping the original `cwd` would pick
850+
// up the sandbox's own package.json (no `type: module`).
851+
childProcess = exec(
852+
path.join(cwd, 'node_modules/.bin/cypress') + ' run',
853+
{
854+
cwd: subprojectDir,
855+
env: {
856+
...envVars,
857+
CYPRESS_BASE_URL: `http://localhost:${webAppPort}`,
858+
},
859+
}
860+
)
861+
childProcess.stdout?.on('data', (d) => { testOutput += d })
862+
childProcess.stderr?.on('data', (d) => { testOutput += d })
863+
864+
const [[exitCode]] = await Promise.all([
865+
once(childProcess, 'exit'),
866+
receiverPromise,
867+
])
868+
869+
assert.strictEqual(exitCode, 0, `cypress process should exit successfully\n${testOutput}`)
870+
} finally {
871+
fs.rmSync(subprojectDir, { recursive: true, force: true })
872+
}
873+
})
874+
875+
// Regression guard: when OTEL_TRACES_EXPORTER=otlp is set in the
876+
// environment (e.g. by an unrelated OpenTelemetry-instrumented shell),
877+
// the tracer must still ship Test Optimization spans to
878+
// /api/v2/citestcycle instead of silently replacing the Test
879+
// Optimization exporter with OtlpHttpTraceExporter and dropping all
880+
// test_session / test_module / test_suite / test spans.
881+
over10It('keeps Test Optimization exporter when OTEL_TRACES_EXPORTER=otlp is set', async () => {
882+
const receiverPromise = receiver
883+
.gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => {
884+
const events = payloads.flatMap(({ payload }) => payload.events)
885+
886+
const sessionEvents = events.filter(event => event.type === 'test_session_end')
887+
const testEvents = events.filter(event => event.type === 'test')
888+
889+
assert.strictEqual(sessionEvents.length, 1, 'one test_session span must reach citestcycle')
890+
891+
const passedTest = testEvents.find(event =>
892+
event.content.resource === 'cypress/e2e/basic-pass.js.basic pass suite can pass'
893+
)
894+
assertObjectContains(passedTest?.content, {
895+
meta: {
896+
[TEST_STATUS]: 'pass',
897+
[TEST_FRAMEWORK]: 'cypress',
898+
},
899+
})
900+
}, 20000)
901+
902+
const envVars = getCiVisAgentlessConfig(receiver.port)
903+
904+
childProcess = exec(
905+
testCommand,
906+
{
907+
cwd,
908+
env: {
909+
...envVars,
910+
// Simulates a user shell that already exports OTEL_* vars for
911+
// a separate OTEL collector. The Test Optimization exporter
912+
// must win inside isCiVisibility mode.
913+
OTEL_TRACES_EXPORTER: 'otlp',
914+
CYPRESS_BASE_URL: `http://localhost:${webAppPort}`,
915+
SPEC_PATTERN: 'cypress/e2e/basic-pass.js',
916+
},
917+
}
918+
)
919+
920+
const [[exitCode]] = await Promise.all([
921+
once(childProcess, 'exit'),
922+
receiverPromise,
923+
])
924+
925+
assert.strictEqual(exitCode, 0, 'cypress process should exit successfully')
926+
})
927+
753928
over10It('does not modify the user support file and cleans up the injected wrapper', async () => {
754929
const supportFilePath = path.join(cwd, 'cypress/support/e2e.js')
755930
const originalSupportContent = fs.readFileSync(supportFilePath, 'utf8')

packages/datadog-instrumentations/src/cypress-config.js

Lines changed: 68 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -229,29 +229,84 @@ function wrapConfig (config) {
229229
return config
230230
}
231231

232+
/**
233+
* Returns `true` if the nearest package.json walking up from `filePath`
234+
* sets `"type": "module"`. Used to decide whether ambiguous extensions
235+
* (`.js`, `.ts`) are loaded as ESM or CJS.
236+
*
237+
* @param {string} filePath absolute path to a file under the project
238+
* @returns {boolean}
239+
*/
240+
function isUnderEsmPackage (filePath) {
241+
let dir = path.dirname(filePath)
242+
while (true) {
243+
const candidate = path.join(dir, 'package.json')
244+
try {
245+
const pkg = JSON.parse(fs.readFileSync(candidate, 'utf8'))
246+
return pkg && pkg.type === 'module'
247+
} catch { /* no package.json at this level */ }
248+
const parent = path.dirname(dir)
249+
if (parent === dir) return false
250+
dir = parent
251+
}
252+
}
253+
232254
/**
233255
* @param {string} originalConfigFile absolute path to the original config file
234256
* @returns {string} path to the generated wrapper file
235257
*/
236258
function createConfigWrapper (originalConfigFile) {
259+
// Decide the wrapper's module mode (ESM vs CJS). It must match how
260+
// Cypress would interpret the user's original config so that (1) Cypress
261+
// keeps the loader it would have used (notably the ts-node registration
262+
// for `.ts` configs), and (2) the wrapper body parses in that mode.
263+
const originalExt = path.extname(originalConfigFile)
264+
const isEsm = originalExt === '.mjs' || originalExt === '.mts' ||
265+
(originalExt !== '.cjs' && originalExt !== '.cts' && isUnderEsmPackage(originalConfigFile))
266+
267+
// Preserve `.ts`/`.cts`/`.mts` so Cypress keeps ts-node registered for
268+
// the wrapper. For plain JS originals, pick the extension that encodes
269+
// the chosen module mode directly.
270+
let wrapperExt
271+
if (originalExt === '.ts' || originalExt === '.cts' || originalExt === '.mts') {
272+
wrapperExt = originalExt
273+
} else {
274+
wrapperExt = isEsm ? '.mjs' : '.cjs'
275+
}
276+
237277
const wrapperFile = path.join(
238278
path.dirname(originalConfigFile),
239-
`.dd-cypress-config-${process.pid}.mjs`
279+
`.dd-cypress-config-${process.pid}${wrapperExt}`
240280
)
241281

242282
const cypressConfigPath = require.resolve('./cypress-config')
243283

244-
// Always use ESM: it can import both CJS and ESM configs, so it works
245-
// regardless of the original file's extension or "type": "module" in package.json.
246-
// Import cypress-config.js directly (CJS default = module.exports object).
247-
fs.writeFileSync(wrapperFile, [
248-
`import originalConfig from ${JSON.stringify(pathToFileURL(originalConfigFile).href)}`,
249-
`import cypressConfig from ${JSON.stringify(pathToFileURL(cypressConfigPath).href)}`,
250-
'',
251-
'export default cypressConfig.wrapConfig(originalConfig)',
252-
'',
253-
].join('\n'))
254-
284+
// ESM body: `import` default-interops a CJS module (cypress-config.js)
285+
// by exposing its `module.exports` as the default binding, and handles
286+
// both CJS and ESM user configs transparently.
287+
// CJS body: avoids top-level `import` — older Cypress transpiles `.ts`
288+
// configs through CJS ts-node, where `require('file://...')` is not
289+
// supported. Guards against ES-module-default shape so TS-authored
290+
// configs using `export default` still work.
291+
const body = isEsm
292+
? [
293+
`import originalConfig from ${JSON.stringify(pathToFileURL(originalConfigFile).href)}`,
294+
`import cypressConfig from ${JSON.stringify(pathToFileURL(cypressConfigPath).href)}`,
295+
'',
296+
'export default cypressConfig.wrapConfig(originalConfig)',
297+
'',
298+
].join('\n')
299+
: [
300+
`const cypressConfig = require(${JSON.stringify(cypressConfigPath)})`,
301+
`const originalExports = require(${JSON.stringify(originalConfigFile)})`,
302+
'const originalConfig = originalExports && originalExports.__esModule',
303+
' ? originalExports.default',
304+
' : originalExports',
305+
'module.exports = cypressConfig.wrapConfig(originalConfig)',
306+
'',
307+
].join('\n')
308+
309+
fs.writeFileSync(wrapperFile, body)
255310
return wrapperFile
256311
}
257312

@@ -291,10 +346,7 @@ function wrapCliConfigFileOptions (options) {
291346
}
292347
}
293348

294-
// Skip .ts files — Cypress transpiles them internally via its own loader.
295-
// The ESM wrapper can't import .ts directly. The defineConfig shimmer
296-
// handles .ts configs since they're transpiled to CJS by Cypress.
297-
if (!configFilePath || !fs.existsSync(configFilePath) || path.extname(configFilePath) === '.ts') return noop
349+
if (!configFilePath || !fs.existsSync(configFilePath)) return noop
298350

299351
try {
300352
const wrapperFile = createConfigWrapper(configFilePath)

packages/dd-trace/src/proxy.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,14 @@ class Tracer extends NoopProxy {
280280
? require('./standalone').configure(config)
281281
: undefined
282282
let otlpExporter
283-
if (config.otelTracesEnabled) {
283+
// OTEL_TRACES_EXPORTER=otlp should not replace the Test
284+
// Optimization exporter when the tracer is running in Test
285+
// Optimization mode. Test spans (test_session/test_module/
286+
// test_suite/test) belong on the citestcycle endpoint, not on an
287+
// OTLP traces endpoint — otherwise users with OTEL_* vars set in
288+
// their environment (e.g. for a separate telemetry integration)
289+
// silently lose all test spans.
290+
if (config.otelTracesEnabled && !config.isCiVisibility) {
284291
const { buildResourceAttributes, createOtlpTraceExporter } = require('./opentelemetry/trace')
285292
otlpExporter = createOtlpTraceExporter(config, buildResourceAttributes(config))
286293
}

0 commit comments

Comments
 (0)