1
1
import * as http from 'http' ;
2
2
import { AddressInfo } from 'net' ;
3
+ import * as path from 'path' ;
3
4
import { 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' ;
4
11
import 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' ;
6
16
7
17
export * from '../../../../../../../dev-packages/node-integration-tests/utils' ;
8
18
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
+
9
262
export class RemixTestEnv extends TestEnv {
10
263
private constructor ( public readonly server : http . Server , public readonly url : string ) {
11
264
super ( server , url ) ;
@@ -27,3 +280,7 @@ export class RemixTestEnv extends TestEnv {
27
280
return new RemixTestEnv ( server , `http://localhost:${ serverPort } ` ) ;
28
281
}
29
282
}
283
+
284
+ const parseEnvelope = ( body : string ) : Array < Record < string , unknown > > => {
285
+ return body . split ( '\n' ) . map ( e => JSON . parse ( e ) ) ;
286
+ } ;
0 commit comments