1- import type { ChildProcess } from 'child_process' ;
2- import { spawn } from 'child_process' ;
1+ import { spawn , spawnSync } from 'child_process' ;
32import { join } from 'path' ;
43import type { Envelope , EnvelopeItemType , Event , SerializedSession } from '@sentry/types' ;
54import axios from 'axios' ;
@@ -30,14 +29,17 @@ export function assertSentryTransaction(actual: Event, expected: Partial<Event>)
3029 } ) ;
3130}
3231
33- const CHILD_PROCESSES = new Set < ChildProcess > ( ) ;
32+ const CLEANUP_STEPS = new Set < VoidFunction > ( ) ;
3433
3534export function cleanupChildProcesses ( ) : void {
36- for ( const child of CHILD_PROCESSES ) {
37- child . kill ( ) ;
35+ for ( const step of CLEANUP_STEPS ) {
36+ step ( ) ;
3837 }
38+ CLEANUP_STEPS . clear ( ) ;
3939}
4040
41+ process . on ( 'exit' , cleanupChildProcesses ) ;
42+
4143/** Promise only resolves when fn returns true */
4244async function waitFor ( fn : ( ) => boolean , timeout = 10_000 ) : Promise < void > {
4345 let remaining = timeout ;
@@ -50,6 +52,58 @@ async function waitFor(fn: () => boolean, timeout = 10_000): Promise<void> {
5052 }
5153}
5254
55+ type VoidFunction = ( ) => void ;
56+
57+ interface DockerOptions {
58+ /**
59+ * The working directory to run docker compose in
60+ */
61+ workingDirectory : string [ ] ;
62+ /**
63+ * The strings to look for in the output to know that the docker compose is ready for the test to be run
64+ */
65+ readyMatches : string [ ] ;
66+ }
67+
68+ /**
69+ * Runs docker compose up and waits for the readyMatches to appear in the output
70+ *
71+ * Returns a function that can be called to docker compose down
72+ */
73+ async function runDockerCompose ( options : DockerOptions ) : Promise < VoidFunction > {
74+ return new Promise ( ( resolve , reject ) => {
75+ const cwd = join ( ...options . workingDirectory ) ;
76+ const close = ( ) : void => {
77+ spawnSync ( 'docker' , [ 'compose' , 'down' , '--volumes' ] , { cwd } ) ;
78+ } ;
79+
80+ // ensure we're starting fresh
81+ close ( ) ;
82+
83+ const child = spawn ( 'docker' , [ 'compose' , 'up' ] , { cwd } ) ;
84+
85+ const timeout = setTimeout ( ( ) => {
86+ close ( ) ;
87+ reject ( new Error ( 'Timed out waiting for docker-compose' ) ) ;
88+ } , 60_000 ) ;
89+
90+ function newData ( data : Buffer ) : void {
91+ const text = data . toString ( 'utf8' ) ;
92+
93+ for ( const match of options . readyMatches ) {
94+ if ( text . includes ( match ) ) {
95+ child . stdout . removeAllListeners ( ) ;
96+ clearTimeout ( timeout ) ;
97+ resolve ( close ) ;
98+ }
99+ }
100+ }
101+
102+ child . stdout . on ( 'data' , newData ) ;
103+ child . stderr . on ( 'data' , newData ) ;
104+ } ) ;
105+ }
106+
53107type Expected =
54108 | {
55109 event : Partial < Event > | ( ( event : Event ) => void ) ;
@@ -70,6 +124,7 @@ export function createRunner(...paths: string[]) {
70124 const flags : string [ ] = [ ] ;
71125 const ignored : EnvelopeItemType [ ] = [ ] ;
72126 let withSentryServer = false ;
127+ let dockerOptions : DockerOptions | undefined ;
73128 let ensureNoErrorOutput = false ;
74129
75130 if ( testPath . endsWith ( '.ts' ) ) {
@@ -93,6 +148,10 @@ export function createRunner(...paths: string[]) {
93148 ignored . push ( ...types ) ;
94149 return this ;
95150 } ,
151+ withDockerCompose : function ( options : DockerOptions ) {
152+ dockerOptions = options ;
153+ return this ;
154+ } ,
96155 ensureNoErrorOutput : function ( ) {
97156 ensureNoErrorOutput = true ;
98157 return this ;
@@ -182,80 +241,94 @@ export function createRunner(...paths: string[]) {
182241 ? createBasicSentryServer ( newEnvelope )
183242 : Promise . resolve ( undefined ) ;
184243
244+ const dockerStartup : Promise < VoidFunction | undefined > = dockerOptions
245+ ? runDockerCompose ( dockerOptions )
246+ : Promise . resolve ( undefined ) ;
247+
248+ const startup = Promise . all ( [ dockerStartup , serverStartup ] ) ;
249+
185250 // eslint-disable-next-line @typescript-eslint/no-floating-promises
186- serverStartup . then ( mockServerPort => {
187- const env = mockServerPort
188- ? { ...process . env , SENTRY_DSN : `http://public@localhost:${ mockServerPort } /1337` }
189- : process . env ;
251+ startup
252+ . then ( ( [ dockerChild , mockServerPort ] ) => {
253+ if ( dockerChild ) {
254+ CLEANUP_STEPS . add ( dockerChild ) ;
255+ }
190256
191- // eslint-disable-next-line no-console
192- if ( process . env . DEBUG ) console . log ( 'starting scenario' , testPath , flags , env . SENTRY_DSN ) ;
257+ const env = mockServerPort
258+ ? { ...process . env , SENTRY_DSN : `http://public@localhost:${ mockServerPort } /1337` }
259+ : process . env ;
193260
194- child = spawn ( 'node' , [ ...flags , testPath ] , { env } ) ;
261+ // eslint-disable-next-line no-console
262+ if ( process . env . DEBUG ) console . log ( 'starting scenario' , testPath , flags , env . SENTRY_DSN ) ;
195263
196- CHILD_PROCESSES . add ( child ) ;
264+ child = spawn ( 'node' , [ ... flags , testPath ] , { env } ) ;
197265
198- if ( ensureNoErrorOutput ) {
199- child . stderr . on ( 'data' , ( data : Buffer ) => {
200- const output = data . toString ( ) ;
201- complete ( new Error ( `Expected no error output but got: '${ output } '` ) ) ;
266+ CLEANUP_STEPS . add ( ( ) => {
267+ child ?. kill ( ) ;
202268 } ) ;
203- }
204-
205- child . on ( 'close' , ( ) => {
206- hasExited = true ;
207269
208270 if ( ensureNoErrorOutput ) {
209- complete ( ) ;
271+ child . stderr . on ( 'data' , ( data : Buffer ) => {
272+ const output = data . toString ( ) ;
273+ complete ( new Error ( `Expected no error output but got: '${ output } '` ) ) ;
274+ } ) ;
210275 }
211- } ) ;
212276
213- // Pass error to done to end the test quickly
214- child . on ( 'error' , e => {
215- // eslint-disable-next-line no-console
216- if ( process . env . DEBUG ) console . log ( 'scenario error' , e ) ;
217- complete ( e ) ;
218- } ) ;
219-
220- function tryParseEnvelopeFromStdoutLine ( line : string ) : void {
221- // Lines can have leading '[something] [{' which we need to remove
222- const cleanedLine = line . replace ( / ^ .* ?] \[ { " / , '[{"' ) ;
223-
224- // See if we have a port message
225- if ( cleanedLine . startsWith ( '{"port":' ) ) {
226- const { port } = JSON . parse ( cleanedLine ) as { port : number } ;
227- scenarioServerPort = port ;
228- return ;
229- }
277+ child . on ( 'close' , ( ) => {
278+ hasExited = true ;
230279
231- // Skip any lines that don't start with envelope JSON
232- if ( ! cleanedLine . startsWith ( '[{' ) ) {
233- return ;
234- }
280+ if ( ensureNoErrorOutput ) {
281+ complete ( ) ;
282+ }
283+ } ) ;
235284
236- try {
237- const envelope = JSON . parse ( cleanedLine ) as Envelope ;
238- newEnvelope ( envelope ) ;
239- } catch ( _ ) {
240- //
241- }
242- }
285+ // Pass error to done to end the test quickly
286+ child . on ( 'error' , e => {
287+ // eslint-disable-next-line no-console
288+ if ( process . env . DEBUG ) console . log ( 'scenario error' , e ) ;
289+ complete ( e ) ;
290+ } ) ;
243291
244- let buffer = Buffer . alloc ( 0 ) ;
245- child . stdout . on ( 'data' , ( data : Buffer ) => {
246- // This is horribly memory inefficient but it's only for tests
247- buffer = Buffer . concat ( [ buffer , data ] ) ;
292+ function tryParseEnvelopeFromStdoutLine ( line : string ) : void {
293+ // Lines can have leading '[something] [{' which we need to remove
294+ const cleanedLine = line . replace ( / ^ .* ?] \[ { " / , '[{"' ) ;
248295
249- let splitIndex = - 1 ;
250- while ( ( splitIndex = buffer . indexOf ( 0xa ) ) >= 0 ) {
251- const line = buffer . subarray ( 0 , splitIndex ) . toString ( ) ;
252- buffer = Buffer . from ( buffer . subarray ( splitIndex + 1 ) ) ;
253- // eslint-disable-next-line no-console
254- if ( process . env . DEBUG ) console . log ( 'line' , line ) ;
255- tryParseEnvelopeFromStdoutLine ( line ) ;
296+ // See if we have a port message
297+ if ( cleanedLine . startsWith ( '{"port":' ) ) {
298+ const { port } = JSON . parse ( cleanedLine ) as { port : number } ;
299+ scenarioServerPort = port ;
300+ return ;
301+ }
302+
303+ // Skip any lines that don't start with envelope JSON
304+ if ( ! cleanedLine . startsWith ( '[{' ) ) {
305+ return ;
306+ }
307+
308+ try {
309+ const envelope = JSON . parse ( cleanedLine ) as Envelope ;
310+ newEnvelope ( envelope ) ;
311+ } catch ( _ ) {
312+ //
313+ }
256314 }
257- } ) ;
258- } ) ;
315+
316+ let buffer = Buffer . alloc ( 0 ) ;
317+ child . stdout . on ( 'data' , ( data : Buffer ) => {
318+ // This is horribly memory inefficient but it's only for tests
319+ buffer = Buffer . concat ( [ buffer , data ] ) ;
320+
321+ let splitIndex = - 1 ;
322+ while ( ( splitIndex = buffer . indexOf ( 0xa ) ) >= 0 ) {
323+ const line = buffer . subarray ( 0 , splitIndex ) . toString ( ) ;
324+ buffer = Buffer . from ( buffer . subarray ( splitIndex + 1 ) ) ;
325+ // eslint-disable-next-line no-console
326+ if ( process . env . DEBUG ) console . log ( 'line' , line ) ;
327+ tryParseEnvelopeFromStdoutLine ( line ) ;
328+ }
329+ } ) ;
330+ } )
331+ . catch ( e => complete ( e ) ) ;
259332
260333 return {
261334 childHasExited : function ( ) : boolean {
0 commit comments