Skip to content

Commit 4b4de37

Browse files
feat(test): add stress-testing to known flaky tests reruns
1 parent d0c0daf commit 4b4de37

File tree

11 files changed

+194
-30
lines changed

11 files changed

+194
-30
lines changed

knip.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ const testingEntryPoints = [
3131
'static/**/*.spec.{js,ts,tsx}',
3232
'static/**/*.snapshots.tsx',
3333
'tests/js/**/*.spec.{js,ts,tsx}',
34+
'tests/js/sentry-test/isKnownFlake/index.ts',
3435
// jest uses this
3536
'tests/js/test-balancer/index.js',
3637
];

tests/js/sentry-test/isKnownFlake.d.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
declare namespace jest {
22
interface It {
33
/**
4-
* Marks a test as a known flake. When the RERUN_KNOWN_FLAKY_TESTS env var
5-
* is set (via the "Frontend: Rerun Flaky Tests" PR label), the test runs
6-
* 50x to validate that a fix is stable. Otherwise it runs once, normally.
4+
* When RERUN_KNOWN_FLAKY_TESTS is "true" (set by the "Frontend: Rerun Flaky
5+
* Tests" PR label), the test runs several times under each stress profile.
6+
* Otherwise it runs once, behaving identically to a normal `it()`.
77
*
88
* Available globally — no import needed.
99
*/
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import {withDelayedFetch} from './withDelayedFetch';
2+
import {withMainThreadCpuLoad} from './withMainThreadCpuLoad';
3+
import {withMemoryPressure} from './withMemoryPressure';
4+
import {withMicrotaskChurn} from './withMicrotaskChurn';
5+
import {withRealWallClockDelay} from './withRealWallClockDelay';
6+
7+
export const flakeStressProfiles = [
8+
['mainThreadCpu', withMainThreadCpuLoad],
9+
['microtaskChurn', withMicrotaskChurn],
10+
['realWallClock', withRealWallClockDelay],
11+
['delayedFetch', withDelayedFetch],
12+
['memoryPressure', withMemoryPressure],
13+
] as const;
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export async function invokeProvidesCallback(
2+
fn: jest.ProvidesCallback,
3+
thisArg: unknown
4+
) {
5+
await (fn as (this: unknown) => unknown).call(thisArg);
6+
}
7+
8+
export function delay(ms: number) {
9+
return new Promise(resolve => setTimeout(resolve, ms));
10+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/* eslint-disable jest/no-export -- setup helper: only imported by tests/js/setup.ts */
2+
import {flakeStressProfiles} from './flakeStress';
3+
4+
const flakyRunsTotal = 50;
5+
6+
const flakyRunsPerProfile = flakyRunsTotal / flakeStressProfiles.length;
7+
8+
/**
9+
* it.isKnownFlake — wraps a known-flaky test for stress-testing in CI.
10+
*
11+
* When RERUN_KNOWN_FLAKY_TESTS is "true" (set by the "Frontend: Rerun Flaky
12+
* Tests" PR label), the test runs several times under each stress profile.
13+
* Otherwise it runs once, behaving identically to a normal `it()`.
14+
*/
15+
export function isKnownFlake(name: string, fn: jest.ProvidesCallback, timeout?: number) {
16+
/* eslint-disable jest/valid-title -- describe titles include dynamic profile labels */
17+
if (process.env.RERUN_KNOWN_FLAKY_TESTS !== 'true') {
18+
it(name, fn, timeout);
19+
return;
20+
}
21+
22+
for (const [label, wrapper] of flakeStressProfiles) {
23+
describe(`[flaky rerun ${label} x${flakyRunsPerProfile}] ${name}`, () => {
24+
for (let i = 1; i <= flakyRunsPerProfile; i++) {
25+
it(`run ${i}/${flakyRunsPerProfile}`, wrapper(fn), timeout);
26+
}
27+
});
28+
}
29+
/* eslint-enable jest/valid-title */
30+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import {delay, invokeProvidesCallback} from './flakeStressUtils';
2+
3+
const fetchDelayMs = 5;
4+
5+
/**
6+
* Simulates a slow network by adding delays to global fetch calls.
7+
*/
8+
export function withDelayedFetch(fn: jest.ProvidesCallback): jest.ProvidesCallback {
9+
const originalFetch = globalThis.fetch;
10+
11+
return function wrapped(this: unknown) {
12+
return (async () => {
13+
if (typeof originalFetch !== 'function') {
14+
await invokeProvidesCallback(fn, this);
15+
return;
16+
}
17+
18+
globalThis.fetch = (async (...args: Parameters<typeof fetch>) => {
19+
await delay(fetchDelayMs);
20+
return originalFetch.apply(globalThis, args);
21+
}) as typeof fetch;
22+
23+
try {
24+
await invokeProvidesCallback(fn, this);
25+
} finally {
26+
globalThis.fetch = originalFetch;
27+
}
28+
})();
29+
};
30+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import {invokeProvidesCallback} from './flakeStressUtils';
2+
3+
const mainThreadIntervalMs = 4;
4+
const mainThreadSpinIterations = 200_000;
5+
6+
/**
7+
* Simulates an overloaded main thread by doing busy-work on a fixed interval.
8+
*/
9+
export function withMainThreadCpuLoad(fn: jest.ProvidesCallback): jest.ProvidesCallback {
10+
return function wrapped(this: unknown) {
11+
const spin = () => {
12+
let sink = 0;
13+
for (let i = 0; i < mainThreadSpinIterations; i++) {
14+
sink ^= i;
15+
}
16+
sink.toLocaleString();
17+
};
18+
19+
const id = setInterval(spin, mainThreadIntervalMs);
20+
21+
return (async () => {
22+
try {
23+
await invokeProvidesCallback(fn, this);
24+
} finally {
25+
clearInterval(id);
26+
}
27+
})();
28+
};
29+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import {invokeProvidesCallback} from './flakeStressUtils';
2+
3+
const memoryPressureBytes = 8 * 1024 * 1024;
4+
5+
/**
6+
* Simulates low available memory by retaining a large buffer of bytes data.
7+
*/
8+
export function withMemoryPressure(fn: jest.ProvidesCallback): jest.ProvidesCallback {
9+
return function wrapped(this: unknown) {
10+
return (async () => {
11+
const hog = new Uint8Array(memoryPressureBytes);
12+
hog[0] = 1;
13+
14+
try {
15+
await invokeProvidesCallback(fn, this);
16+
} finally {
17+
hog.fill(0);
18+
}
19+
})();
20+
};
21+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import {invokeProvidesCallback} from './flakeStressUtils';
2+
3+
const microtaskIntervalMs = 5;
4+
const microtasksPerTick = 50;
5+
6+
/**
7+
* Simulates a busy machine by continuously queueing microtasks.
8+
*/
9+
export function withMicrotaskChurn(fn: jest.ProvidesCallback): jest.ProvidesCallback {
10+
return function wrapped(this: unknown) {
11+
const id = setInterval(() => {
12+
for (let i = 0; i < microtasksPerTick; i++) {
13+
queueMicrotask(() => {});
14+
}
15+
}, microtaskIntervalMs);
16+
17+
return (async () => {
18+
try {
19+
await invokeProvidesCallback(fn, this);
20+
} finally {
21+
clearInterval(id);
22+
}
23+
})();
24+
};
25+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import {delay, invokeProvidesCallback} from './flakeStressUtils';
2+
3+
const wallClockTickDelayMs = 3;
4+
const wallClockPaddingMs = 5;
5+
6+
/**
7+
* Simulates a slow machine with many timers by continuously running no-op async delays.
8+
*/
9+
export function withRealWallClockDelay(fn: jest.ProvidesCallback): jest.ProvidesCallback {
10+
return function wrapped(this: unknown) {
11+
return (async () => {
12+
const abortController = new AbortController();
13+
14+
const background = (async () => {
15+
while (!abortController.signal.aborted) {
16+
await delay(wallClockTickDelayMs);
17+
}
18+
})();
19+
20+
try {
21+
await delay(wallClockPaddingMs);
22+
await invokeProvidesCallback(fn, this);
23+
await delay(wallClockPaddingMs);
24+
} finally {
25+
abortController.abort();
26+
await background.catch(() => {});
27+
}
28+
})();
29+
};
30+
}

0 commit comments

Comments
 (0)