Skip to content

Commit 4f81eaf

Browse files
authored
refactor(test): improve type safety for spawned process URLs (#7219)
- Split `spawnProc` into two functions with distinct return types: - `spawnProc`: expects long-running processes, always returns SpawnedProcess with `url` - `spawnProcAndExpectExit`: expects clean exit, may return void - Make `url` property a lazy getter that throws if accessed before port message - Ensures TypeScript knows `proc.url` is always defined for long-running processes
1 parent 3e204e4 commit 4f81eaf

54 files changed

Lines changed: 295 additions & 177 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

integration-tests/helpers/index.js

Lines changed: 161 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -172,26 +172,50 @@ function assertTelemetryPoints (pid, msgs, expectedTelemetryPoints) {
172172
}
173173

174174
/**
175-
* @typedef {childProcess.ChildProcess & { url: string }} SpawnedProcess
175+
* @typedef {childProcess.ChildProcess & {
176+
* url: string,
177+
* stdout: NodeJS.ReadableStream,
178+
* stderr: NodeJS.ReadableStream
179+
* }} SpawnedProcess
176180
*/
177181

178182
/**
179183
* Spawns a Node.js script in a child process and returns a promise that resolves when the process is ready.
180184
*
185+
* This function expects the spawned process to stay alive (e.g., a server). If the process exits
186+
* (even with code 0), the promise will reject with an error.
187+
*
188+
* For processes that are expected to run and exit cleanly, use `spawnProcAndExpectExit` instead.
189+
*
181190
* @param {string|URL} filename - The filename of the Node.js script to spawn in a child process.
182191
* @param {childProcess.ForkOptions} [options] - The options to pass to the child process.
183192
* @param {(data: Buffer) => void} [stdioHandler] - A function that's called with one data argument to handle the
184193
* standard output of the child process. If not provided, the output will be logged to the console.
185194
* @param {(data: Buffer) => void} [stderrHandler] - A function that's called with one data argument to handle the
186195
* standard error of the child process. If not provided, the error will be logged to the console.
187-
* @returns {Promise<SpawnedProcess|void>} A promise that resolves when the process is either ready or terminated
188-
* without an error. If the process is terminated without an error, the promise will resolve with `undefined`. The
189-
* returned process will have a `url` property if the process didn't terminate.
196+
* @returns {Promise<SpawnedProcess>} A promise that resolves with a SpawnedProcess when the process is ready.
197+
* The returned `SpawnedProcess` will have a `url` property that can be accessed to get the server URL.
198+
* Note: Accessing `url` before the spawned process sends its port message will throw an error.
190199
*/
191200
function spawnProc (filename, options = {}, stdioHandler, stderrHandler) {
192-
const proc = fork(filename, { ...options, stdio: 'pipe' })
201+
const proc = spawnProcImpl(filename, options, stdioHandler, stderrHandler)
202+
203+
let urlValue
204+
Object.defineProperty(proc, 'url', {
205+
get () {
206+
if (urlValue === undefined) {
207+
throw new Error('Process URL is not available yet. The spawned process has not sent a port message.')
208+
}
209+
return urlValue
210+
},
211+
set (value) {
212+
urlValue = value
213+
},
214+
enumerable: true,
215+
configurable: true
216+
})
193217

194-
return /** @type {Promise<SpawnedProcess|void>} */ (new Promise((resolve, reject) => {
218+
return new Promise((resolve, reject) => {
195219
proc
196220
.on('message', ({ port }) => {
197221
if (typeof port !== 'number' && typeof port !== 'string') {
@@ -200,30 +224,74 @@ function spawnProc (filename, options = {}, stdioHandler, stderrHandler) {
200224
proc.url = `http://localhost:${port}`
201225
resolve(proc)
202226
})
227+
.on('error', reject)
228+
.on('exit', code => {
229+
reject(new Error(`Process exited with status code ${code}.`))
230+
})
231+
})
232+
}
233+
234+
/**
235+
* Spawns a Node.js script in a child process that is expected to run and exit cleanly.
236+
*
237+
* This function expects the process to complete and exit with code 0, in which case the promise resolves
238+
* with `undefined`. Use this for short-lived processes like validation scripts or tests that run to completion.
239+
*
240+
* For long-running processes (like servers) that should not exit, use `spawnProc` instead.
241+
*
242+
* @param {string|URL} filename - The filename of the Node.js script to spawn in a child process.
243+
* @param {childProcess.ForkOptions} [options] - The options to pass to the child process.
244+
* @param {(data: Buffer) => void} [stdioHandler] - A function that's called with one data argument to handle the
245+
* standard output of the child process. If not provided, the output will be logged to the console.
246+
* @param {(data: Buffer) => void} [stderrHandler] - A function that's called with one data argument to handle the
247+
* standard error of the child process. If not provided, the error will be logged to the console.
248+
* @returns {Promise<void>} A promise that resolves when the process exits with code 0.
249+
*/
250+
function spawnProcAndExpectExit (filename, options = {}, stdioHandler, stderrHandler) {
251+
const proc = spawnProcImpl(filename, options, stdioHandler, stderrHandler)
252+
253+
return new Promise((resolve, reject) => {
254+
proc
203255
.on('error', reject)
204256
.on('exit', code => {
205257
if (code !== 0) {
206258
return reject(new Error(`Process exited with status code ${code}.`))
207259
}
208260
resolve()
209261
})
262+
})
263+
}
210264

211-
proc.stdout.on('data', data => {
212-
if (stdioHandler) {
213-
stdioHandler(data)
214-
}
215-
// eslint-disable-next-line no-console
216-
if (!options.silent) console.log(data.toString())
217-
})
265+
/**
266+
* Internal implementation for spawnProc and spawnProcAndAllowExit.
267+
*
268+
* @param {string|URL} filename
269+
* @param {childProcess.ForkOptions} options
270+
* @param {(data: Buffer) => void} [stdioHandler]
271+
* @param {(data: Buffer) => void} [stderrHandler]
272+
* @returns {SpawnedProcess}
273+
*/
274+
function spawnProcImpl (filename, options, stdioHandler, stderrHandler) {
275+
// Cast to SpawnedProcess type - when stdio is 'pipe', stdout/stderr are guaranteed non-null
276+
const proc = /** @type {SpawnedProcess} */ (fork(filename, { ...options, stdio: 'pipe' }))
218277

219-
proc.stderr.on('data', data => {
220-
if (stderrHandler) {
221-
stderrHandler(data)
222-
}
223-
// eslint-disable-next-line no-console
224-
if (!options.silent) console.error(data.toString())
225-
})
226-
}))
278+
proc.stdout.on('data', data => {
279+
if (stdioHandler) {
280+
stdioHandler(data)
281+
}
282+
// eslint-disable-next-line no-console
283+
if (!options.silent) console.log(data.toString())
284+
})
285+
286+
proc.stderr.on('data', data => {
287+
if (stderrHandler) {
288+
stderrHandler(data)
289+
}
290+
// eslint-disable-next-line no-console
291+
if (!options.silent) console.error(data.toString())
292+
})
293+
294+
return proc
227295
}
228296

229297
function log (...args) {
@@ -562,25 +630,24 @@ function checkSpansForServiceName (spans, name) {
562630
}
563631

564632
/**
565-
* @overload
566-
* @param {string} cwd
567-
* @param {string} serverFile
568-
* @param {string|number} agentPort
569-
* @param {Record<string, string|undefined>} [additionalEnvArgs]
633+
* @typedef {Record<string, string|undefined>} AdditionalEnvArgs
570634
*/
635+
571636
/**
637+
* Prepares spawn options for plugin integration tests.
638+
*
572639
* @param {string} cwd
573640
* @param {string} serverFile
574641
* @param {string|number} agentPort
642+
* @param {AdditionalEnvArgs} [additionalEnvArgs]
643+
* @param {string[]} [execArgv]
575644
* @param {(data: Buffer) => void} [stdioHandler]
576-
* @param {Record<string, string|undefined>} [additionalEnvArgs]
645+
* @returns {{ filename: string, options: childProcess.ForkOptions,
646+
* stdioHandler: ((data: Buffer) => void) | undefined }}
577647
*/
578-
async function spawnPluginIntegrationTestProc (
579-
cwd, serverFile, agentPort, stdioHandler, additionalEnvArgs) {
580-
if (typeof stdioHandler !== 'function' && !additionalEnvArgs) {
581-
additionalEnvArgs = stdioHandler
582-
stdioHandler = undefined
583-
}
648+
function preparePluginIntegrationTestSpawnOptions (
649+
cwd, serverFile, agentPort, additionalEnvArgs, execArgv, stdioHandler
650+
) {
584651
additionalEnvArgs = { ...additionalEnvArgs }
585652

586653
let NODE_OPTIONS = `--loader=${hookFile}`
@@ -593,17 +660,65 @@ async function spawnPluginIntegrationTestProc (
593660
delete additionalEnvArgs.NODE_OPTIONS
594661
}
595662

596-
let env = /** @type {Record<string, string|undefined>} */ ({
597-
NODE_OPTIONS,
598-
DD_TRACE_AGENT_PORT: String(agentPort),
599-
DD_TRACE_FLUSH_INTERVAL: '0'
600-
})
601-
env = { ...process.env, ...env, ...additionalEnvArgs }
602-
return spawnProc(path.join(cwd, serverFile), {
603-
cwd,
604-
env,
605-
execArgv: additionalEnvArgs.execArgv
606-
}, stdioHandler)
663+
return {
664+
filename: path.join(cwd, serverFile),
665+
options: {
666+
cwd,
667+
env: {
668+
...process.env,
669+
NODE_OPTIONS,
670+
DD_TRACE_AGENT_PORT: String(agentPort),
671+
DD_TRACE_FLUSH_INTERVAL: '0',
672+
...additionalEnvArgs
673+
},
674+
execArgv
675+
},
676+
stdioHandler
677+
}
678+
}
679+
680+
/**
681+
* Spawns a plugin integration test process that runs a long-lived server.
682+
*
683+
* The spawned process should call `process.send({ port })` to signal it's ready.
684+
* Use this for tests that spawn HTTP servers or other long-running processes.
685+
*
686+
* For short-lived scripts that run and exit, use `spawnPluginIntegrationTestProcAndExpectExit` instead.
687+
*
688+
* @param {string} cwd
689+
* @param {string} serverFile
690+
* @param {string|number} agentPort
691+
* @param {AdditionalEnvArgs} [additionalEnvArgs]
692+
* @param {string[]} [execArgv]
693+
* @param {(data: Buffer) => void} [stdioHandler]
694+
*/
695+
function spawnPluginIntegrationTestProc (cwd, serverFile, agentPort, additionalEnvArgs, execArgv, stdioHandler) {
696+
const { filename, options, stdioHandler: handler } =
697+
preparePluginIntegrationTestSpawnOptions(cwd, serverFile, agentPort, additionalEnvArgs, execArgv, stdioHandler)
698+
return spawnProc(filename, options, handler)
699+
}
700+
701+
/**
702+
* Spawns a plugin integration test process that is expected to run and exit cleanly.
703+
*
704+
* Use this for short-lived test scripts that run instrumented code and exit (e.g., making a
705+
* fetch request, DNS lookup, etc.) rather than starting a long-running server.
706+
*
707+
* For tests that spawn a server which should stay alive, use `spawnPluginIntegrationTestProc` instead.
708+
*
709+
* @param {string} cwd
710+
* @param {string} serverFile
711+
* @param {string|number} agentPort
712+
* @param {AdditionalEnvArgs} [additionalEnvArgs]
713+
* @param {string[]} [execArgv]
714+
* @param {(data: Buffer) => void} [stdioHandler]
715+
*/
716+
function spawnPluginIntegrationTestProcAndExpectExit (
717+
cwd, serverFile, agentPort, additionalEnvArgs, execArgv, stdioHandler
718+
) {
719+
const { filename, options, stdioHandler: handler } =
720+
preparePluginIntegrationTestSpawnOptions(cwd, serverFile, agentPort, additionalEnvArgs, execArgv, stdioHandler)
721+
return spawnProcAndExpectExit(filename, options, handler)
607722
}
608723

609724
/**
@@ -741,6 +856,7 @@ module.exports = {
741856
assertObjectContains,
742857
assertUUID,
743858
spawnProc,
859+
spawnProcAndExpectExit,
744860
telemetryForwarder,
745861
assertTelemetryPoints,
746862
runAndCheckWithTelemetry,
@@ -750,6 +866,7 @@ module.exports = {
750866
getCiVisEvpProxyConfig,
751867
checkSpansForServiceName,
752868
spawnPluginIntegrationTestProc,
869+
spawnPluginIntegrationTestProcAndExpectExit,
753870
useEnv,
754871
setShouldKill,
755872
sandboxCwd,

integration-tests/startup.spec.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const semver = require('semver')
99
const {
1010
FakeAgent,
1111
spawnProc,
12+
spawnProcAndExpectExit,
1213
sandboxCwd,
1314
useSandbox,
1415
curlAndAssertMessage
@@ -238,7 +239,7 @@ execArgvs.forEach(({ execArgv, skip, optional = true }) => {
238239

239240
context('with unsupported module', () => {
240241
it('skips the unsupported module', async () => {
241-
await spawnProc(unsupportedTestFile, {
242+
await spawnProcAndExpectExit(unsupportedTestFile, {
242243
cwd,
243244
execArgv
244245
})

packages/datadog-plugin-ai/test/integration-test/client.spec.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const {
77
FakeAgent,
88
sandboxCwd,
99
useSandbox,
10-
spawnPluginIntegrationTestProc
10+
spawnPluginIntegrationTestProcAndExpectExit
1111
} = require('../../../../integration-tests/helpers')
1212
const { withVersions } = require('../../../dd-trace/test/setup/mocha')
1313

@@ -57,7 +57,7 @@ describe('esm', () => {
5757
assert.fail('No ai spans found')
5858
})
5959

60-
proc = await spawnPluginIntegrationTestProc(sandboxCwd(), 'server.mjs', agent.port, null, {
60+
proc = await spawnPluginIntegrationTestProcAndExpectExit(sandboxCwd(), 'server.mjs', agent.port, {
6161
NODE_OPTIONS: '--import dd-trace/initialize.mjs'
6262
})
6363

packages/datadog-plugin-amqp10/test/integration-test/client.spec.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const {
77
sandboxCwd,
88
useSandbox,
99
checkSpansForServiceName,
10-
spawnPluginIntegrationTestProc
10+
spawnPluginIntegrationTestProcAndExpectExit
1111
} = require('../../../../integration-tests/helpers')
1212
const { withVersions } = require('../../../dd-trace/test/setup/mocha')
1313
describe('esm', () => {
@@ -34,7 +34,7 @@ describe('esm', () => {
3434
assert.strictEqual(checkSpansForServiceName(payload, 'amqp.send'), true)
3535
})
3636

37-
proc = await spawnPluginIntegrationTestProc(sandboxCwd(), 'server.mjs', agent.port)
37+
proc = await spawnPluginIntegrationTestProcAndExpectExit(sandboxCwd(), 'server.mjs', agent.port)
3838

3939
await res
4040
}).timeout(20000)

packages/datadog-plugin-amqplib/test/integration-test/client.spec.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ const assert = require('node:assert/strict')
55
const {
66
FakeAgent,
77
checkSpansForServiceName,
8-
spawnPluginIntegrationTestProc,
8+
spawnPluginIntegrationTestProcAndExpectExit,
99
sandboxCwd,
1010
useSandbox,
1111
varySandbox
@@ -42,7 +42,7 @@ describe('esm', () => {
4242
assert.strictEqual(checkSpansForServiceName(payload, 'amqp.command'), true)
4343
})
4444

45-
proc = await spawnPluginIntegrationTestProc(sandboxCwd(), variants[variant], agent.port)
45+
proc = await spawnPluginIntegrationTestProcAndExpectExit(sandboxCwd(), variants[variant], agent.port)
4646

4747
await res
4848
}).timeout(20000)

packages/datadog-plugin-anthropic/test/integration-test/client.spec.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ const {
88
sandboxCwd,
99
useSandbox,
1010
checkSpansForServiceName,
11-
spawnPluginIntegrationTestProc
11+
spawnPluginIntegrationTestProcAndExpectExit
1212
} = require('../../../../integration-tests/helpers')
1313
const { withVersions } = require('../../../dd-trace/test/setup/mocha')
1414

@@ -39,7 +39,7 @@ describe('esm', () => {
3939
assert.strictEqual(checkSpansForServiceName(payload, 'anthropic.request'), true)
4040
})
4141

42-
proc = await spawnPluginIntegrationTestProc(sandboxCwd(), 'server.mjs', agent.port, null, {
42+
proc = await spawnPluginIntegrationTestProcAndExpectExit(sandboxCwd(), 'server.mjs', agent.port, {
4343
NODE_OPTIONS: '--import dd-trace/initialize.mjs',
4444
ANTHROPIC_API_KEY: '<not-a-real-key>'
4545
})

packages/datadog-plugin-aws-sdk/test/integration-test/client.spec.js

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const {
77
sandboxCwd,
88
useSandbox,
99
checkSpansForServiceName,
10-
spawnPluginIntegrationTestProc
10+
spawnPluginIntegrationTestProcAndExpectExit
1111
} = require('../../../../integration-tests/helpers')
1212
const { withVersions } = require('../../../dd-trace/test/setup/mocha')
1313
describe('esm', () => {
@@ -34,12 +34,10 @@ describe('esm', () => {
3434
assert.strictEqual(checkSpansForServiceName(payload, 'aws.request'), true)
3535
})
3636

37-
proc = await spawnPluginIntegrationTestProc(sandboxCwd(), 'server.mjs', agent.port, undefined,
38-
{
39-
AWS_SECRET_ACCESS_KEY: '0000000000/00000000000000000000000000000',
40-
AWS_ACCESS_KEY_ID: '00000000000000000000'
41-
}
42-
)
37+
proc = await spawnPluginIntegrationTestProcAndExpectExit(sandboxCwd(), 'server.mjs', agent.port, {
38+
AWS_SECRET_ACCESS_KEY: '0000000000/00000000000000000000000000000',
39+
AWS_ACCESS_KEY_ID: '00000000000000000000'
40+
})
4341

4442
await res
4543
}).timeout(20000)

0 commit comments

Comments
 (0)