Skip to content

Commit b45c406

Browse files
committed
chore: implement a custom transport for remix tests
1 parent fed27b5 commit b45c406

File tree

2 files changed

+173
-64
lines changed

2 files changed

+173
-64
lines changed
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,35 @@
11
import * as Sentry from '@sentry/remix';
2+
import { createTransport } from '@sentry/core';
3+
4+
// Global storage for captured envelopes - test helpers will read from this
5+
globalThis.__SENTRY_TEST_ENVELOPES__ = globalThis.__SENTRY_TEST_ENVELOPES__ || [];
6+
7+
// Create a custom transport that captures envelopes instead of sending them
8+
function makeTestTransport(options) {
9+
function makeRequest(request) {
10+
// Parse the serialized envelope body the same way the test helper's parseEnvelope does
11+
// The body is a serialized string with newline-separated JSON lines
12+
const bodyStr = typeof request.body === 'string' ? request.body : new TextDecoder().decode(request.body);
13+
// Split by newlines and parse each line as JSON - this matches test helper format
14+
const envelope = bodyStr
15+
.split('\n')
16+
.filter(line => line.trim())
17+
.map(e => JSON.parse(e));
18+
globalThis.__SENTRY_TEST_ENVELOPES__.push(envelope);
19+
20+
// Return a successful response
21+
return Promise.resolve({
22+
statusCode: 200,
23+
headers: {},
24+
});
25+
}
26+
27+
return createTransport(options, makeRequest);
28+
}
229

330
Sentry.init({
431
dsn: 'https://public@dsn.ingest.sentry.io/1337',
532
tracesSampleRate: 1,
633
tracePropagationTargets: ['example.org'],
34+
transport: makeTestTransport,
735
});

packages/remix/test/integration/test/server/utils/helpers.ts

Lines changed: 145 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as http from 'http';
22
import { AddressInfo } from 'net';
33
import * as path from 'path';
4+
import { fileURLToPath } from 'url';
45
import { createRequestHandler } from '@remix-run/express';
56
import { debug } from '@sentry/core';
67
import type { EnvelopeItemType, Event, TransactionEvent } from '@sentry/core';
@@ -12,7 +13,6 @@ import express from 'express';
1213
import type { Express } from 'express';
1314
import type { HttpTerminator } from 'http-terminator';
1415
import { createHttpTerminator } from 'http-terminator';
15-
import nock from 'nock';
1616

1717
type DataCollectorOptions = {
1818
// Optional custom URL
@@ -107,15 +107,25 @@ class TestEnv {
107107
? [options.envelopeType]
108108
: options.envelopeType || (['event'] as EnvelopeItemType[]);
109109

110+
// Use a ref to capture startIndex right before making the request
111+
// The claimed indices mechanism and stopping at count will ensure parallel requests don't interfere
112+
const startIndexRef = { startIndex: null as number | null };
110113
const resProm = this.setupNock(
111114
options.count || 1,
112115
typeof options.endServer === 'undefined' ? true : options.endServer,
113116
envelopeTypeArray,
117+
startIndexRef,
114118
);
115119

116-
// eslint-disable-next-line @typescript-eslint/no-floating-promises
117-
makeRequest(options.method, options.url || this.url, this._axiosConfig);
118-
return resProm;
120+
// Capture startIndex right before making the request
121+
const globalEnvelopesArray = (globalThis as any).__SENTRY_TEST_ENVELOPES__ || [];
122+
startIndexRef.startIndex = globalEnvelopesArray.length;
123+
// Wait for the request to complete so Sentry has time to capture events
124+
await makeRequest(options.method, options.url || this.url, this._axiosConfig);
125+
// Flush Sentry events to ensure they're sent to the transport
126+
await Sentry.flush(2000);
127+
const result = await resProm;
128+
return result;
119129
}
120130

121131
/**
@@ -166,51 +176,104 @@ class TestEnv {
166176
count: number,
167177
endServer: boolean,
168178
envelopeType: EnvelopeItemType[],
179+
startIndexRef?: { startIndex: number | null },
169180
): Promise<Record<string, unknown>[][]> {
170-
return new Promise(resolve => {
181+
return new Promise((resolve, reject) => {
171182
const envelopes: Record<string, unknown>[][] = [];
172-
const mock = nock('https://dsn.ingest.sentry.io')
173-
.persist()
174-
.post('/api/1337/envelope/', body => {
175-
const envelope = parseEnvelope(body);
183+
let timeoutId: NodeJS.Timeout | null = null;
184+
let checkInterval: NodeJS.Timeout | null = null;
185+
186+
// Track the starting length to only count envelopes added after the request is made
187+
// If startIndexRef is provided, use it (set right before request); otherwise capture now
188+
let startIndex: number;
189+
if (startIndexRef) {
190+
// Wait for startIndex to be set (it will be set right before request is made)
191+
const getStartIndex = () => {
192+
if (startIndexRef.startIndex === null) {
193+
const globalEnvelopesArray = (globalThis as any).__SENTRY_TEST_ENVELOPES__ || [];
194+
return globalEnvelopesArray.length;
195+
}
196+
return startIndexRef.startIndex;
197+
};
198+
startIndex = getStartIndex();
199+
} else {
200+
const globalEnvelopesArray = (globalThis as any).__SENTRY_TEST_ENVELOPES__ || [];
201+
startIndex = globalEnvelopesArray.length;
202+
}
176203

177-
if (envelopeType.includes(envelope[1]?.type as EnvelopeItemType)) {
178-
envelopes.push(envelope);
179-
} else {
180-
return false;
204+
// Use a global Set to track which envelope indices have been claimed
205+
// This prevents parallel setupNock instances from matching the same envelopes
206+
if (!(globalThis as any).__SENTRY_TEST_CLAIMED_ENVELOPE_INDICES__) {
207+
(globalThis as any).__SENTRY_TEST_CLAIMED_ENVELOPE_INDICES__ = new Set<number>();
208+
}
209+
const claimedIndices = (globalThis as any).__SENTRY_TEST_CLAIMED_ENVELOPE_INDICES__ as Set<number>;
210+
211+
// Poll for envelopes from the custom transport
212+
const checkForEnvelopes = () => {
213+
// If using ref, wait until it's set (set right before request is made)
214+
if (startIndexRef && startIndexRef.startIndex === null) {
215+
return; // Don't check yet, startIndex hasn't been set
216+
}
217+
218+
const globalEnvelopes = (globalThis as any).__SENTRY_TEST_ENVELOPES__ || [];
219+
220+
// Use the ref value if provided, otherwise use the initial startIndex
221+
const currentStartIndex = startIndexRef?.startIndex ?? startIndex;
222+
223+
// Only check envelopes that were added after the request started
224+
// Check each envelope by its index in the global array
225+
// Stop once we have enough envelopes to avoid claiming more than needed
226+
for (let i = currentStartIndex; i < globalEnvelopes.length && envelopes.length < count; i++) {
227+
// Skip if this envelope index has already been claimed by another setupNock
228+
if (claimedIndices.has(i)) {
229+
continue;
181230
}
182231

183-
if (count === envelopes.length) {
184-
nock.removeInterceptor(mock);
185-
186-
if (endServer) {
187-
// Cleaning nock only before the server is closed,
188-
// not to break tests that use simultaneous requests to the server.
189-
// Ex: Remix scope bleed tests.
190-
nock.cleanAll();
191-
192-
// Abort all pending requests to nock to prevent hanging / flakes.
193-
// See: https://github.com/nock/nock/issues/1118#issuecomment-544126948
194-
nock.abortPendingRequests();
195-
196-
this._closeServer()
197-
.catch(e => {
198-
debug.warn(e);
199-
})
200-
.finally(() => {
201-
resolve(envelopes);
202-
});
203-
} else {
204-
resolve(envelopes);
232+
const envelope = globalEnvelopes[i];
233+
// The parsed envelope format is [header, itemHeader, itemPayload]
234+
// where itemHeader has a 'type' property
235+
const itemHeader = envelope[1];
236+
if (itemHeader && envelopeType.includes(itemHeader.type as EnvelopeItemType)) {
237+
// Check if we've already added this envelope to our local array
238+
if (!envelopes.includes(envelope)) {
239+
// Claim this envelope index so other parallel setupNock instances don't match it
240+
claimedIndices.add(i);
241+
envelopes.push(envelope);
242+
// Stop if we have enough envelopes
243+
if (envelopes.length >= count) {
244+
break;
245+
}
205246
}
206247
}
248+
}
207249

208-
return true;
209-
});
250+
if (count === envelopes.length) {
251+
if (timeoutId) clearTimeout(timeoutId);
252+
if (checkInterval) clearInterval(checkInterval);
253+
254+
if (endServer) {
255+
this._closeServer()
256+
.catch(e => {
257+
debug.warn(e);
258+
})
259+
.finally(() => {
260+
resolve(envelopes);
261+
});
262+
} else {
263+
resolve(envelopes);
264+
}
265+
}
266+
};
210267

211-
mock
212-
.query(true) // accept any query params - used for sentry_key param
213-
.reply(200);
268+
// Check immediately and then poll every 50ms
269+
checkForEnvelopes();
270+
checkInterval = setInterval(checkForEnvelopes, 50);
271+
272+
// Add a timeout to detect if Sentry requests never arrive
273+
timeoutId = setTimeout(() => {
274+
if (checkInterval) clearInterval(checkInterval);
275+
reject(new Error(`Timeout waiting for Sentry envelopes. Expected ${count}, got ${envelopes.length}`));
276+
}, 5000);
214277
});
215278
}
216279

@@ -224,25 +287,27 @@ class TestEnv {
224287
envelopeType: EnvelopeItemType | EnvelopeItemType[];
225288
}): Promise<number> {
226289
return new Promise(resolve => {
227-
let reqCount = 0;
290+
const envelopeTypeArray =
291+
typeof options.envelopeType === 'string' ? [options.envelopeType] : options.envelopeType;
228292

229-
const mock = nock('https://dsn.ingest.sentry.io')
230-
.persist()
231-
.post('/api/1337/envelope/', body => {
232-
const envelope = parseEnvelope(body);
293+
// Track the starting length to only count envelopes added after this call
294+
const globalEnvelopesArray = (globalThis as any).__SENTRY_TEST_ENVELOPES__ || [];
295+
const startIndex = globalEnvelopesArray.length;
233296

234-
if (options.envelopeType.includes(envelope[1]?.type as EnvelopeItemType)) {
297+
setTimeout(() => {
298+
const globalEnvelopes = (globalThis as any).__SENTRY_TEST_ENVELOPES__ || [];
299+
// Only count envelopes that were added after this call started
300+
const newEnvelopes = globalEnvelopes.slice(startIndex);
301+
let reqCount = 0;
302+
303+
for (const envelope of newEnvelopes) {
304+
// The parsed envelope format is [header, itemHeader, itemPayload]
305+
// where itemHeader has a 'type' property
306+
const itemHeader = envelope[1];
307+
if (itemHeader && envelopeTypeArray.includes(itemHeader.type as EnvelopeItemType)) {
235308
reqCount++;
236-
return true;
237309
}
238-
239-
return false;
240-
});
241-
242-
setTimeout(() => {
243-
nock.removeInterceptor(mock);
244-
245-
nock.cleanAll();
310+
}
246311

247312
// eslint-disable-next-line @typescript-eslint/no-floating-promises
248313
this._closeServer().then(() => {
@@ -266,20 +331,36 @@ export class RemixTestEnv extends TestEnv {
266331
}
267332

268333
public static async init(): Promise<RemixTestEnv> {
269-
let serverPort;
270-
const server = await new Promise<http.Server>(async resolve => {
271-
const app = express();
334+
const app = express();
272335

273-
// Vite builds to build/server/index.js instead of build/index.js
274-
app.all('*', createRequestHandler({ build: await import('../../../build/server/index.js') }));
336+
// Import the build module dynamically
337+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
338+
const buildPath = path.resolve(__dirname, '../../../build/server/index.js');
339+
const build = await import(buildPath);
275340

341+
const handler = createRequestHandler({ build });
342+
343+
app.all('*', async (req, res, next) => {
344+
try {
345+
await handler(req, res);
346+
} catch (e) {
347+
next(e);
348+
}
349+
});
350+
351+
return new Promise((resolve, reject) => {
276352
const server = app.listen(0, () => {
277-
serverPort = (server.address() as AddressInfo).port;
278-
resolve(server);
353+
const address = server.address();
354+
if (address && typeof address === 'object') {
355+
resolve(new RemixTestEnv(server, `http://localhost:${address.port}`));
356+
} else {
357+
server.close();
358+
reject(new Error('Failed to start server: could not determine port'));
359+
}
279360
});
280-
});
281361

282-
return new RemixTestEnv(server, `http://localhost:${serverPort}`);
362+
server.on('error', reject);
363+
});
283364
}
284365
}
285366

0 commit comments

Comments
 (0)