@@ -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' )
0 commit comments