Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

test: Fix e2e test race condition by buffering events #12739

Merged
merged 7 commits into from
Jul 4, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ jobs:
- 'packages/rollup-utils/**'
- 'packages/utils/**'
- 'packages/types/**'
- 'dev-packages/test-utils/**'
browser: &browser
- *shared
- 'packages/browser/**'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ test('sends a navigation transaction with a parameterized URL', async ({ page })
});

test('sends an INP span', async ({ page }) => {
const inpSpanPromise = waitForEnvelopeItem('react-router-6', item => {
const inpSpanPromise = waitForEnvelopeItem('react-router-6', Date.now(), item => {
return item[0].type === 'span';
});

Expand Down
159 changes: 101 additions & 58 deletions dev-packages/test-utils/src/event-proxy-server.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
/* eslint-disable max-lines */

import * as fs from 'fs';
import * as http from 'http';
import type { AddressInfo } from 'net';
Expand Down Expand Up @@ -30,12 +32,22 @@ interface SentryRequestCallbackData {
sentryResponseStatusCode?: number;
}

interface EventCallbackListener {
(data: string): void;
}

type OnRequest = (
eventCallbackListeners: Set<(data: string) => void>,
eventCallbackListeners: Set<EventCallbackListener>,
proxyRequest: http.IncomingMessage,
proxyRequestBody: string,
eventBuffer: BufferedEvent[],
) => Promise<[number, string, Record<string, string> | undefined]>;

interface BufferedEvent {
timestamp: number;
data: string;
}

/**
* Start a generic proxy server.
* The `onRequest` callback receives the incoming request and the request body,
Expand All @@ -51,7 +63,8 @@ export async function startProxyServer(
},
onRequest?: OnRequest,
): Promise<void> {
const eventCallbackListeners: Set<(data: string) => void> = new Set();
const eventBuffer: BufferedEvent[] = [];
const eventCallbackListeners: Set<EventCallbackListener> = new Set();

const proxyServer = http.createServer((proxyRequest, proxyResponse) => {
const proxyRequestChunks: Uint8Array[] = [];
Expand All @@ -76,15 +89,17 @@ export async function startProxyServer(

const callback: OnRequest =
onRequest ||
(async (eventCallbackListeners, proxyRequest, proxyRequestBody) => {
(async (eventCallbackListeners, proxyRequest, proxyRequestBody, eventBuffer) => {
eventBuffer.push({ data: proxyRequestBody, timestamp: Date.now() });

eventCallbackListeners.forEach(listener => {
listener(proxyRequestBody);
});

return [200, '{}', {}];
});

callback(eventCallbackListeners, proxyRequest, proxyRequestBody)
callback(eventCallbackListeners, proxyRequest, proxyRequestBody, eventBuffer)
.then(([statusCode, responseBody, responseHeaders]) => {
proxyResponse.writeHead(statusCode, responseHeaders);
proxyResponse.write(responseBody, 'utf-8');
Expand All @@ -110,12 +125,24 @@ export async function startProxyServer(
eventCallbackResponse.statusCode = 200;
eventCallbackResponse.setHeader('connection', 'keep-alive');

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const searchParams = new URL(eventCallbackRequest.url!, 'http://justsomerandombasesothattheurlisparseable.com/')
.searchParams;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const listenerTimestamp = Number(searchParams.get('timestamp')!);

const callbackListener = (data: string): void => {
eventCallbackResponse.write(data.concat('\n'), 'utf8');
};

eventCallbackListeners.add(callbackListener);

eventBuffer.forEach(bufferedEvent => {
if (bufferedEvent.timestamp > listenerTimestamp) {
lforst marked this conversation as resolved.
Show resolved Hide resolved
callbackListener(bufferedEvent.data);
}
});

eventCallbackRequest.on('close', () => {
eventCallbackListeners.delete(callbackListener);
});
Expand All @@ -142,7 +169,7 @@ export async function startProxyServer(
* option to this server (like this `tunnel: http://localhost:${port option}/`).
*/
export async function startEventProxyServer(options: EventProxyServerOptions): Promise<void> {
await startProxyServer(options, async (eventCallbackListeners, proxyRequest, proxyRequestBody) => {
await startProxyServer(options, async (eventCallbackListeners, proxyRequest, proxyRequestBody, eventBuffer) => {
const envelopeHeader: EnvelopeItem[0] = JSON.parse(proxyRequestBody.split('\n')[0] as string);

const shouldForwardEventToSentry = options.forwardToSentry != null ? options.forwardToSentry : true;
Expand Down Expand Up @@ -199,8 +226,12 @@ export async function startEventProxyServer(options: EventProxyServerOptions): P
sentryResponseStatusCode: res.status,
};

const dataString = Buffer.from(JSON.stringify(data)).toString('base64');

eventBuffer.push({ data: dataString, timestamp: Date.now() });

eventCallbackListeners.forEach(listener => {
listener(Buffer.from(JSON.stringify(data)).toString('base64'));
listener(dataString);
});

const resHeaders: Record<string, string> = {};
Expand All @@ -221,24 +252,28 @@ export async function waitForPlainRequest(
const eventCallbackServerPort = await retrieveCallbackServerPort(proxyServerName);

return new Promise((resolve, reject) => {
const request = http.request(`http://localhost:${eventCallbackServerPort}/`, {}, response => {
let eventContents = '';

response.on('error', err => {
reject(err);
});
const request = http.request(
`http://localhost:${eventCallbackServerPort}/?timestamp=${Date.now()}`,
{},
response => {
let eventContents = '';

response.on('error', err => {
reject(err);
});

response.on('data', (chunk: Buffer) => {
const chunkString = chunk.toString('utf8');
response.on('data', (chunk: Buffer) => {
const chunkString = chunk.toString('utf8');

eventContents = eventContents.concat(chunkString);
eventContents = eventContents.concat(chunkString);

if (callback(eventContents)) {
response.destroy();
return resolve(eventContents);
}
});
});
if (callback(eventContents)) {
response.destroy();
return resolve(eventContents);
}
});
},
);

request.end();
});
Expand All @@ -247,49 +282,54 @@ export async function waitForPlainRequest(
/** Wait for a request to be sent. */
export async function waitForRequest(
proxyServerName: string,
timestamp: number,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m: Can we make this optional, as third argument, and just default to Date.now()? I guess we'll want this like this basically always? 😅 (same for the other methods)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah made it optional

callback: (eventData: SentryRequestCallbackData) => Promise<boolean> | boolean,
): Promise<SentryRequestCallbackData> {
const eventCallbackServerPort = await retrieveCallbackServerPort(proxyServerName);

return new Promise<SentryRequestCallbackData>((resolve, reject) => {
const request = http.request(`http://localhost:${eventCallbackServerPort}/`, {}, response => {
let eventContents = '';

response.on('error', err => {
reject(err);
});
const request = http.request(
`http://localhost:${eventCallbackServerPort}/?timestamp=${timestamp}`,
{},
response => {
let eventContents = '';

response.on('error', err => {
reject(err);
});

response.on('data', (chunk: Buffer) => {
const chunkString = chunk.toString('utf8');
chunkString.split('').forEach(char => {
if (char === '\n') {
const eventCallbackData: SentryRequestCallbackData = JSON.parse(
Buffer.from(eventContents, 'base64').toString('utf8'),
);
const callbackResult = callback(eventCallbackData);
if (typeof callbackResult !== 'boolean') {
callbackResult.then(
match => {
if (match) {
response.destroy();
resolve(eventCallbackData);
}
},
err => {
throw err;
},
response.on('data', (chunk: Buffer) => {
const chunkString = chunk.toString('utf8');
chunkString.split('').forEach(char => {
if (char === '\n') {
const eventCallbackData: SentryRequestCallbackData = JSON.parse(
Buffer.from(eventContents, 'base64').toString('utf8'),
);
} else if (callbackResult) {
response.destroy();
resolve(eventCallbackData);
const callbackResult = callback(eventCallbackData);
if (typeof callbackResult !== 'boolean') {
callbackResult.then(
match => {
if (match) {
response.destroy();
resolve(eventCallbackData);
}
},
err => {
throw err;
},
);
} else if (callbackResult) {
response.destroy();
resolve(eventCallbackData);
}
eventContents = '';
} else {
eventContents = eventContents.concat(char);
}
eventContents = '';
} else {
eventContents = eventContents.concat(char);
}
});
});
});
});
},
);

request.end();
});
Expand All @@ -298,10 +338,11 @@ export async function waitForRequest(
/** Wait for a specific envelope item to be sent. */
export function waitForEnvelopeItem(
proxyServerName: string,
timestamp: number,
callback: (envelopeItem: EnvelopeItem) => Promise<boolean> | boolean,
): Promise<EnvelopeItem> {
return new Promise((resolve, reject) => {
waitForRequest(proxyServerName, async eventData => {
waitForRequest(proxyServerName, timestamp, async eventData => {
const envelopeItems = eventData.envelope[1];
for (const envelopeItem of envelopeItems) {
if (await callback(envelopeItem)) {
Expand All @@ -319,8 +360,9 @@ export function waitForError(
proxyServerName: string,
callback: (transactionEvent: Event) => Promise<boolean> | boolean,
): Promise<Event> {
const timestamp = Date.now();
return new Promise((resolve, reject) => {
waitForEnvelopeItem(proxyServerName, async envelopeItem => {
waitForEnvelopeItem(proxyServerName, timestamp, async envelopeItem => {
const [envelopeItemHeader, envelopeItemBody] = envelopeItem;
if (envelopeItemHeader.type === 'event' && (await callback(envelopeItemBody as Event))) {
resolve(envelopeItemBody as Event);
Expand All @@ -336,8 +378,9 @@ export function waitForTransaction(
proxyServerName: string,
callback: (transactionEvent: Event) => Promise<boolean> | boolean,
): Promise<Event> {
const timestamp = Date.now();
return new Promise((resolve, reject) => {
waitForEnvelopeItem(proxyServerName, async envelopeItem => {
waitForEnvelopeItem(proxyServerName, timestamp, async envelopeItem => {
const [envelopeItemHeader, envelopeItemBody] = envelopeItem;
if (envelopeItemHeader.type === 'transaction' && (await callback(envelopeItemBody as Event))) {
resolve(envelopeItemBody as Event);
Expand Down
Loading