11import * as http from 'http' ;
22import { AddressInfo } from 'net' ;
3+ import * as path from 'path' ;
34import { createRequestHandler } from '@remix-run/express' ;
5+ /* eslint-disable @typescript-eslint/no-unsafe-member-access */
6+ import * as Sentry from '@sentry/node' ;
7+ import type { EnvelopeItemType } from '@sentry/types' ;
8+ import { logger } from '@sentry/utils' ;
9+ import type { AxiosRequestConfig } from 'axios' ;
10+ import axios from 'axios' ;
411import express from 'express' ;
5- import { TestEnv } from '../../../../../../../dev-packages/node-integration-tests/utils' ;
12+ import type { Express } from 'express' ;
13+ import type { HttpTerminator } from 'http-terminator' ;
14+ import { createHttpTerminator } from 'http-terminator' ;
15+ import nock from 'nock' ;
616
717export * from '../../../../../../../dev-packages/node-integration-tests/utils' ;
818
19+ type DataCollectorOptions = {
20+ // Optional custom URL
21+ url ?: string ;
22+
23+ // The expected amount of requests to the envelope endpoint.
24+ // If the amount of sent requests is lower than `count`, this function will not resolve.
25+ count ?: number ;
26+
27+ // The method of the request.
28+ method ?: 'get' | 'post' ;
29+
30+ // Whether to stop the server after the requests have been intercepted
31+ endServer ?: boolean ;
32+
33+ // Type(s) of the envelopes to capture
34+ envelopeType ?: EnvelopeItemType | EnvelopeItemType [ ] ;
35+ } ;
36+
37+ async function makeRequest (
38+ method : 'get' | 'post' = 'get' ,
39+ url : string ,
40+ axiosConfig ?: AxiosRequestConfig ,
41+ ) : Promise < void > {
42+ try {
43+ if ( method === 'get' ) {
44+ await axios . get ( url , axiosConfig ) ;
45+ } else {
46+ await axios . post ( url , axiosConfig ) ;
47+ }
48+ } catch ( e ) {
49+ // We sometimes expect the request to fail, but not the test.
50+ // So, we do nothing.
51+ logger . warn ( e ) ;
52+ }
53+ }
54+
55+ class TestEnv {
56+ private _axiosConfig : AxiosRequestConfig | undefined = undefined ;
57+ private _terminator : HttpTerminator ;
58+
59+ public constructor ( public readonly server : http . Server , public readonly url : string ) {
60+ this . server = server ;
61+ this . url = url ;
62+ this . _terminator = createHttpTerminator ( { server : this . server , gracefulTerminationTimeout : 0 } ) ;
63+ }
64+
65+ /**
66+ * Starts a test server and returns the TestEnv instance
67+ *
68+ * @param {string } testDir
69+ * @param {string } [serverPath]
70+ * @param {string } [scenarioPath]
71+ * @return {* } {Promise<string>}
72+ */
73+ public static async init ( testDir : string , serverPath ?: string , scenarioPath ?: string ) : Promise < TestEnv > {
74+ const defaultServerPath = path . resolve ( process . cwd ( ) , 'utils' , 'defaults' , 'server' ) ;
75+
76+ const [ server , url ] = await new Promise < [ http . Server , string ] > ( resolve => {
77+ // eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-unsafe-member-access
78+ const app = require ( serverPath || defaultServerPath ) . default as Express ;
79+
80+ app . get ( '/test' , ( _req , res ) => {
81+ try {
82+ require ( scenarioPath || `${ testDir } /scenario` ) ;
83+ } finally {
84+ res . status ( 200 ) . end ( ) ;
85+ }
86+ } ) ;
87+
88+ const server = app . listen ( 0 , ( ) => {
89+ const url = `http://localhost:${ ( server . address ( ) as AddressInfo ) . port } /test` ;
90+ resolve ( [ server , url ] ) ;
91+ } ) ;
92+ } ) ;
93+
94+ return new TestEnv ( server , url ) ;
95+ }
96+
97+ /**
98+ * Intercepts and extracts up to a number of requests containing Sentry envelopes.
99+ *
100+ * @param {DataCollectorOptions } options
101+ * @returns The intercepted envelopes.
102+ */
103+ public async getMultipleEnvelopeRequest ( options : DataCollectorOptions ) : Promise < Record < string , unknown > [ ] [ ] > {
104+ const envelopeTypeArray =
105+ typeof options . envelopeType === 'string'
106+ ? [ options . envelopeType ]
107+ : options . envelopeType || ( [ 'event' ] as EnvelopeItemType [ ] ) ;
108+
109+ const resProm = this . setupNock (
110+ options . count || 1 ,
111+ typeof options . endServer === 'undefined' ? true : options . endServer ,
112+ envelopeTypeArray ,
113+ ) ;
114+
115+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
116+ makeRequest ( options . method , options . url || this . url , this . _axiosConfig ) ;
117+ return resProm ;
118+ }
119+
120+ /**
121+ * Intercepts and extracts a single request containing a Sentry envelope
122+ *
123+ * @param {DataCollectorOptions } options
124+ * @returns The extracted envelope.
125+ */
126+ public async getEnvelopeRequest ( options ?: DataCollectorOptions ) : Promise < Array < Record < string , unknown > > > {
127+ const requests = await this . getMultipleEnvelopeRequest ( { ...options , count : 1 } ) ;
128+
129+ if ( ! requests [ 0 ] ) {
130+ throw new Error ( 'No requests found' ) ;
131+ }
132+
133+ return requests [ 0 ] ;
134+ }
135+
136+ /**
137+ * Sends a get request to given URL, with optional headers. Returns the response.
138+ * Ends the server instance and flushes the Sentry event queue.
139+ *
140+ * @param {Record<string, string> } [headers]
141+ * @return {* } {Promise<any>}
142+ */
143+ public async getAPIResponse (
144+ url ?: string ,
145+ headers : Record < string , string > = { } ,
146+ endServer : boolean = true ,
147+ ) : Promise < unknown > {
148+ try {
149+ const { data } = await axios . get ( url || this . url , {
150+ headers,
151+ // KeepAlive false to work around a Node 20 bug with ECONNRESET: https://github.com/axios/axios/issues/5929
152+ httpAgent : new http . Agent ( { keepAlive : false } ) ,
153+ } ) ;
154+ return data ;
155+ } finally {
156+ await Sentry . flush ( ) ;
157+
158+ if ( endServer ) {
159+ this . server . close ( ) ;
160+ }
161+ }
162+ }
163+
164+ public async setupNock (
165+ count : number ,
166+ endServer : boolean ,
167+ envelopeType : EnvelopeItemType [ ] ,
168+ ) : Promise < Record < string , unknown > [ ] [ ] > {
169+ return new Promise ( resolve => {
170+ const envelopes : Record < string , unknown > [ ] [ ] = [ ] ;
171+ const mock = nock ( 'https://dsn.ingest.sentry.io' )
172+ . persist ( )
173+ . post ( '/api/1337/envelope/' , body => {
174+ const envelope = parseEnvelope ( body ) ;
175+
176+ if ( envelopeType . includes ( envelope [ 1 ] ?. type as EnvelopeItemType ) ) {
177+ envelopes . push ( envelope ) ;
178+ } else {
179+ return false ;
180+ }
181+
182+ if ( count === envelopes . length ) {
183+ nock . removeInterceptor ( mock ) ;
184+
185+ if ( endServer ) {
186+ // Cleaning nock only before the server is closed,
187+ // not to break tests that use simultaneous requests to the server.
188+ // Ex: Remix scope bleed tests.
189+ nock . cleanAll ( ) ;
190+
191+ // Abort all pending requests to nock to prevent hanging / flakes.
192+ // See: https://github.com/nock/nock/issues/1118#issuecomment-544126948
193+ nock . abortPendingRequests ( ) ;
194+
195+ this . _closeServer ( )
196+ . catch ( e => {
197+ logger . warn ( e ) ;
198+ } )
199+ . finally ( ( ) => {
200+ resolve ( envelopes ) ;
201+ } ) ;
202+ } else {
203+ resolve ( envelopes ) ;
204+ }
205+ }
206+
207+ return true ;
208+ } ) ;
209+
210+ mock
211+ . query ( true ) // accept any query params - used for sentry_key param
212+ . reply ( 200 ) ;
213+ } ) ;
214+ }
215+
216+ public setAxiosConfig ( axiosConfig : AxiosRequestConfig ) : void {
217+ this . _axiosConfig = axiosConfig ;
218+ }
219+
220+ public async countEnvelopes ( options : {
221+ url ?: string ;
222+ timeout ?: number ;
223+ envelopeType : EnvelopeItemType | EnvelopeItemType [ ] ;
224+ } ) : Promise < number > {
225+ return new Promise ( resolve => {
226+ let reqCount = 0 ;
227+
228+ const mock = nock ( 'https://dsn.ingest.sentry.io' )
229+ . persist ( )
230+ . post ( '/api/1337/envelope/' , body => {
231+ const envelope = parseEnvelope ( body ) ;
232+
233+ if ( options . envelopeType . includes ( envelope [ 1 ] ?. type as EnvelopeItemType ) ) {
234+ reqCount ++ ;
235+ return true ;
236+ }
237+
238+ return false ;
239+ } ) ;
240+
241+ setTimeout (
242+ ( ) => {
243+ nock . removeInterceptor ( mock ) ;
244+
245+ nock . cleanAll ( ) ;
246+
247+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
248+ this . _closeServer ( ) . then ( ( ) => {
249+ resolve ( reqCount ) ;
250+ } ) ;
251+ } ,
252+ options . timeout || 1000 ,
253+ ) ;
254+ } ) ;
255+ }
256+
257+ private _closeServer ( ) : Promise < void > {
258+ return this . _terminator . terminate ( ) ;
259+ }
260+ }
261+
9262export class RemixTestEnv extends TestEnv {
10263 private constructor ( public readonly server : http . Server , public readonly url : string ) {
11264 super ( server , url ) ;
@@ -27,3 +280,7 @@ export class RemixTestEnv extends TestEnv {
27280 return new RemixTestEnv ( server , `http://localhost:${ serverPort } ` ) ;
28281 }
29282}
283+
284+ const parseEnvelope = ( body : string ) : Array < Record < string , unknown > > => {
285+ return body . split ( '\n' ) . map ( e => JSON . parse ( e ) ) ;
286+ } ;
0 commit comments