Skip to content

Commit 5a091c2

Browse files
BridgeARszegedi
authored andcommitted
fix(test-optimization): complete test runs after subscriber loss (#9381)
* fix(test-optimization): complete test runs after subscriber loss Finish subscribers can disable themselves while handling a diagnostic-channel publication, leaving no callback owner and hanging the framework run indefinitely. Treat losing the last subscriber as completion while preserving exporter-backed waits and Jest's timeout. * test(test-optimization): cover vitest suite finish and cucumber config-loss The vitest worker suite-finish path (the one that awaits the flush through the shared channel helper) had no dedicated instrumentation spec and no CI job, so its subscriber-loss behavior rode only on end-to-end coverage. Add a real-path spec driving the `@vitest/runner` `startTests` hook, plus `instrumentation-vitest` and `instrumentation-cucumber` jobs alongside the other frameworks. The cucumber spec now pins the `|| {}` guard in getWrappedStart: a library-configuration subscriber that disables itself without responding resolves the request to `undefined`, and the run must still finish with every remote feature treated as disabled. Drive-by fix: * Drop `frameworkVersion` from the vitest worker `testSuiteFinishCh` payload; the suite-finish subscriber never reads it and it is already carried in currentStore. * fix(test-optimization): finish runs after plugin lifecycle errors Mocha configuration requests publish through runStores, so an internal handler error can disable the plugin and leave delayed startup waiting forever. Re-enabling Playwright after test start can leave an active finish subscriber without a span, whose early return similarly never releases the worker.
1 parent 08283a5 commit 5a091c2

23 files changed

Lines changed: 489 additions & 135 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Feature: Programmatic Cucumber run
2+
Scenario: Pass
3+
Then the scenario passes
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
'use strict'
2+
3+
const assert = require('node:assert/strict')
4+
5+
const { Then } = require('@cucumber/cucumber')
6+
7+
Then('the scenario passes', function () {
8+
assert.ok(true)
9+
})
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
'use strict'
2+
3+
const tracer = require('dd-trace')
4+
const { expect, test } = require('@playwright/test')
5+
6+
tracer.use('playwright', false)
7+
8+
test('finishes after the plugin is re-enabled during the test', () => {
9+
tracer.use('playwright', true)
10+
11+
expect(1 + 2).toBe(3)
12+
})
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
'use strict'
2+
3+
const { loadConfiguration, runCucumber } = require('@cucumber/cucumber/api')
4+
5+
const completedMessage = 'programmatic Cucumber run completed'
6+
7+
async function main () {
8+
const { runConfiguration } = await loadConfiguration({
9+
file: false,
10+
provided: {
11+
paths: ['ci-visibility/cucumber-programmatic/features/pass.feature'],
12+
require: ['ci-visibility/cucumber-programmatic/features/support/steps.js'],
13+
},
14+
})
15+
const { success } = await runCucumber(runConfiguration)
16+
17+
process.stdout.write(`${completedMessage}\n`)
18+
if (!success) process.exitCode = 1
19+
}
20+
21+
main().catch((error) => {
22+
process.stderr.write(`${error.stack || error}\n`)
23+
process.exitCode = 1
24+
})

integration-tests/ci-visibility/run-mocha.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ mocha.run((failures) => {
2121
if (process.send) {
2222
process.send('finished')
2323
}
24+
if (process.env.REPORT_RUN_CALLBACK) {
25+
process.stdout.write('programmatic Mocha run completed\n')
26+
}
2427
if (process.env.SHOULD_CHECK_RESULTS && failures > 0) {
2528
process.exit(1)
2629
}

integration-tests/ci-visibility/test-optimization-wrong-init.spec.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ testFrameworks.forEach(({ testFramework, command, expectedOutput, extraTestConte
7676
await receiver.stop()
7777
})
7878

79-
it('does not initialize test optimization plugins if Test Optimization mode is not enabled', async () => {
79+
it('finishes without initializing test optimization plugins if Test Optimization mode is not enabled', async () => {
8080
const eventsPromise = receiver
8181
.gatherPayloadsMaxTimeout(({ url }) => url === '/v0.4/traces', (tracesRequests) => {
8282
const spans = tracesRequests.flatMap(trace => trace.payload).flatMap(request => request)
@@ -121,11 +121,12 @@ testFrameworks.forEach(({ testFramework, command, expectedOutput, extraTestConte
121121
processOutput += chunk.toString()
122122
})
123123

124-
await Promise.all([
124+
const [[exitCode]] = await Promise.all([
125125
once(childProcess, 'exit'),
126126
eventsPromise,
127127
])
128128

129+
assert.strictEqual(exitCode, 0, processOutput)
129130
const reason = 'is not initialized because Test Optimization mode is not enabled.'
130131
const expectedSubstring = `Plugin "${testFramework}" ${reason}`
131132
assert.ok(processOutput.includes(expectedSubstring), `Got: ${inspect(processOutput)}`)

integration-tests/cucumber/cucumber.spec.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,44 @@ describe(`cucumber@${version} commonJS`, () => {
157157
])
158158
})
159159

160+
onlyLatestIt('waits for the final payload before the programmatic run resolves', async () => {
161+
const completionOrder = []
162+
const completedMessage = 'programmatic Cucumber run completed'
163+
receiver.setWaitingTime(500)
164+
165+
const intakePromise = (async () => {
166+
await receiver.payloadReceived(({ url, payload }) => (
167+
url === '/api/v2/citestcycle' &&
168+
payload.events.some(event => event.type === 'test_session_end')
169+
))
170+
completionOrder.push('intake')
171+
})()
172+
173+
childProcess = exec(
174+
'node ./ci-visibility/run-cucumber-programmatic.js',
175+
{
176+
cwd,
177+
env: getCiVisAgentlessConfig(receiver.port),
178+
}
179+
)
180+
childProcess.stdout?.on('data', (chunk) => {
181+
const output = chunk.toString()
182+
testOutput += output
183+
if (output.includes(completedMessage)) completionOrder.push('run')
184+
})
185+
childProcess.stderr?.on('data', (chunk) => {
186+
testOutput += chunk.toString()
187+
})
188+
189+
const [[exitCode]] = await Promise.all([
190+
once(childProcess, 'exit'),
191+
intakePromise,
192+
])
193+
194+
assert.strictEqual(exitCode, 0, testOutput)
195+
assert.deepStrictEqual(completionOrder, ['intake', 'run'], testOutput)
196+
})
197+
160198
context('with APM protocol (old agents)', () => {
161199
it('can report tests', async function () {
162200
receiver.setInfoResponse({ endpoints: [] })

integration-tests/mocha/mocha.spec.js

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,59 @@ describe(`mocha@${MOCHA_VERSION}`, function () {
312312
])
313313
})
314314

315+
/**
316+
* @param {boolean} runInParallel
317+
* @returns {Promise<void>}
318+
*/
319+
async function assertProgrammaticRunWaitsForFinalPayload (runInParallel) {
320+
const completionOrder = []
321+
const completedMessage = 'programmatic Mocha run completed'
322+
receiver.setWaitingTime(500)
323+
324+
const intakePromise = (async () => {
325+
await receiver.payloadReceived(({ url, payload }) => (
326+
url === '/api/v2/citestcycle' &&
327+
payload.events.some(event => event.type === 'test_session_end')
328+
))
329+
completionOrder.push('intake')
330+
})()
331+
332+
childProcess = exec(
333+
runTestsCommand,
334+
{
335+
cwd,
336+
env: {
337+
...getCiVisAgentlessConfig(receiver.port),
338+
REPORT_RUN_CALLBACK: '1',
339+
...(runInParallel && { RUN_IN_PARALLEL: '1' }),
340+
},
341+
}
342+
)
343+
childProcess.stdout?.on('data', (chunk) => {
344+
const output = chunk.toString()
345+
testOutput += output
346+
if (output.includes(completedMessage)) completionOrder.push('run')
347+
})
348+
childProcess.stderr?.on('data', (chunk) => {
349+
testOutput += chunk.toString()
350+
})
351+
352+
const [[exitCode]] = await Promise.all([
353+
once(childProcess, 'exit'),
354+
intakePromise,
355+
])
356+
357+
assert.strictEqual(exitCode, 0, testOutput)
358+
assert.deepStrictEqual(completionOrder, ['intake', 'run'], testOutput)
359+
}
360+
361+
for (const runInParallel of [false, true]) {
362+
const mode = runInParallel ? 'parallel' : 'serial'
363+
onlyLatestIt(`waits for the final payload before invoking the programmatic run callback (${mode})`, async () => {
364+
await assertProgrammaticRunWaitsForFinalPayload(runInParallel)
365+
})
366+
}
367+
315368
const nonLegacyReportingOptions = ['evp proxy', 'agentless']
316369

317370
nonLegacyReportingOptions.forEach((reportingOption) => {

integration-tests/playwright/playwright-reporting.spec.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -814,6 +814,24 @@ versions.forEach((version) => {
814814
})
815815
})
816816

817+
if (version === latest) {
818+
it('finishes if the plugin is re-enabled after test start', async (receiver, run) => {
819+
const proc = run(
820+
'./node_modules/.bin/playwright test -c playwright.config.js',
821+
{
822+
cwd,
823+
env: {
824+
...getCiVisAgentlessConfig(receiver.port),
825+
TEST_DIR: './ci-visibility/playwright-plugin-lifecycle',
826+
},
827+
}
828+
)
829+
830+
const [exitCode] = await once(proc, 'exit')
831+
assert.strictEqual(exitCode, 0)
832+
})
833+
}
834+
817835
const fullyParallelConfigValue = [true, false]
818836

819837
fullyParallelConfigValue.forEach((parallelism) => {

packages/datadog-instrumentations/src/cucumber.js

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const {
2828
} = require('../../dd-trace/src/plugins/util/test')
2929
const { writeCoverageBackfillToCache } = require('../../dd-trace/src/ci-visibility/test-optimization-cache')
3030
const satisfies = require('../../../vendor/dist/semifies')
31+
const { getChannelPromise } = require('./helpers/channel')
3132
const { addHook, channel } = require('./helpers/instrument')
3233

3334
const cucumberWorkerThreadsPatchModule = require.resolve('./cucumber-worker-threads')
@@ -563,12 +564,6 @@ function getErrorFromCucumberResult (cucumberResult) {
563564
return error
564565
}
565566

566-
function getChannelPromise (channelToPublishTo, frameworkVersion = null) {
567-
return new Promise(resolve => {
568-
channelToPublishTo.publish({ onDone: resolve, frameworkVersion })
569-
})
570-
}
571-
572567
function getShouldBeSkippedSuite (pickle, suitesToSkip) {
573568
const testSuitePath = getTestSuitePath(pickle.uri, process.cwd())
574569
const isUnskippable = isMarkedAsUnskippable(pickle)
@@ -1056,7 +1051,7 @@ function getWrappedStart (start, frameworkVersion, isParallel = false, isCoordin
10561051
}
10571052
let errorSkippableRequest
10581053

1059-
const configurationResponse = await getChannelPromise(libraryConfigurationCh, frameworkVersion)
1054+
const configurationResponse = await getChannelPromise(libraryConfigurationCh, { frameworkVersion }) || {}
10601055

10611056
repositoryRoot = configurationResponse.repositoryRoot
10621057
isItrEnabled = configurationResponse.libraryConfig?.isItrEnabled
@@ -1089,7 +1084,7 @@ function getWrappedStart (start, frameworkVersion, isParallel = false, isCoordin
10891084

10901085
if (isKnownTestsEnabled) {
10911086
const currentKnownTestsResponse = knownTestsResponse || await getChannelPromise(knownTestsCh)
1092-
if (currentKnownTestsResponse.err) {
1087+
if (!currentKnownTestsResponse || currentKnownTestsResponse.err) {
10931088
isEarlyFlakeDetectionEnabled = false
10941089
isKnownTestsEnabled = false
10951090
} else {
@@ -1098,7 +1093,9 @@ function getWrappedStart (start, frameworkVersion, isParallel = false, isCoordin
10981093
}
10991094

11001095
if (isSuitesSkippingEnabled) {
1101-
const skippableResponse = skippableSuitesResponse || await getChannelPromise(skippableSuitesCh)
1096+
const skippableResponse = skippableSuitesResponse ||
1097+
await getChannelPromise(skippableSuitesCh) ||
1098+
{ err: true }
11021099

11031100
errorSkippableRequest = skippableResponse.err
11041101
skippableSuites = skippableResponse.skippableSuites ?? []
@@ -1147,7 +1144,7 @@ function getWrappedStart (start, frameworkVersion, isParallel = false, isCoordin
11471144
if (isTestManagementTestsEnabled) {
11481145
const currentTestManagementTestsResponse =
11491146
testManagementTestsResponse || await getChannelPromise(testManagementTestsCh)
1150-
if (currentTestManagementTestsResponse.err) {
1147+
if (!currentTestManagementTestsResponse || currentTestManagementTestsResponse.err) {
11511148
isTestManagementTestsEnabled = false
11521149
} else {
11531150
testManagementTests = currentTestManagementTestsResponse.testManagementTests
@@ -1156,7 +1153,7 @@ function getWrappedStart (start, frameworkVersion, isParallel = false, isCoordin
11561153

11571154
if (isImpactedTestsEnabled) {
11581155
const impactedTestsResponse = await getChannelPromise(modifiedFilesCh)
1159-
if (!impactedTestsResponse.err) {
1156+
if (impactedTestsResponse && !impactedTestsResponse.err) {
11601157
modifiedFiles = impactedTestsResponse.modifiedFiles
11611158
}
11621159
}
@@ -1215,7 +1212,7 @@ function getWrappedStart (start, frameworkVersion, isParallel = false, isCoordin
12151212
global.__coverage__ = fromCoverageMapToCoverage(originalCoverageMap)
12161213
}
12171214

1218-
sessionFinishCh.publish({
1215+
const flushPromise = getChannelPromise(sessionFinishCh, {
12191216
status: success ? 'pass' : 'fail',
12201217
isSuitesSkipped,
12211218
testCodeCoverageLinesTotal,
@@ -1228,9 +1225,11 @@ function getWrappedStart (start, frameworkVersion, isParallel = false, isCoordin
12281225
isTestManagementTestsEnabled,
12291226
isParallel,
12301227
})
1228+
12311229
logTestOptimizationSummary({ attemptToFixExecutions })
12321230
loggedAttemptToFixTests.clear()
12331231
eventDataCollector = null
1232+
await flushPromise
12341233
return result
12351234
}
12361235
}

0 commit comments

Comments
 (0)