Skip to content

Commit 6138aa6

Browse files
chore: add --changedSince as MERGE_BASE to Jest in PR CI
Made-with: Cursor
1 parent a22692b commit 6138aa6

5 files changed

Lines changed: 86 additions & 5 deletions

File tree

.github/workflows/frontend.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,10 @@ jobs:
127127
steps:
128128
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
129129
name: Checkout sentry
130+
with:
131+
# PRs need history so we can compute merge base for Jest --changedSince.
132+
# 100 is an arbitrary depth that will get most reasonable PRs' commits.
133+
fetch-depth: ${{ github.event_name == 'pull_request' && '100' || '1' }}
130134

131135
- uses: ./.github/actions/setup-node-pnpm
132136

@@ -143,6 +147,26 @@ jobs:
143147
search_artifacts: true # Search for the last workflow run whose stored the artifact we're looking for
144148
if_no_artifact_found: warn # Can be one of: "fail", "warn", "ignore"
145149

150+
# On PRs, HEAD is the merge commit; its parents (HEAD^1, HEAD^2) are base and head.
151+
# Merge base of those two is what Jest --changedSince needs. Checkout uses fetch-depth: 100 for PRs.
152+
- name: Get merge base for changedSince (PRs only)
153+
id: merge_base
154+
if: github.event_name == 'pull_request'
155+
run: |
156+
MERGE_BASE=$(git merge-base HEAD^1 HEAD^2 2>/dev/null) || true
157+
if [ -n "$MERGE_BASE" ]; then
158+
CHANGED=$(git diff --name-only "$MERGE_BASE" HEAD)
159+
if echo "$CHANGED" | grep -qv '/'; then
160+
echo "Root-level file changed — running full test suite"
161+
MERGE_BASE=""
162+
else
163+
echo "Merge base: $MERGE_BASE (Jest will use --changedSince)"
164+
fi
165+
else
166+
echo "Merge base: could not compute, will run full test suite"
167+
fi
168+
echo "merge_base=${MERGE_BASE:-}" >> "$GITHUB_OUTPUT"
169+
146170
- name: jest
147171
env:
148172
GITHUB_PR_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
@@ -158,6 +182,7 @@ jobs:
158182
#
159183
# This quiets up the logs quite a bit.
160184
DEBUG_PRINT_LIMIT: 0
185+
MERGE_BASE: ${{ steps.merge_base.outputs.merge_base }}
161186
run: pnpm run test-ci --forceExit
162187

163188
form-field-registry:

eslint.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -928,6 +928,7 @@ export default typescript.config([
928928
name: 'files/jest related',
929929
files: [
930930
'tests/js/jest-pegjs-transform.js',
931+
'tests/js/sentry-test/jest-environment.js',
931932
'tests/js/sentry-test/mocks/*',
932933
'tests/js/sentry-test/loadFixtures.ts',
933934
'tests/js/setup.ts',

jest.config.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,18 @@ let JEST_TESTS: string[] | undefined;
6969
// to reexec itself here
7070
if (CI && !process.env.JEST_LIST_TESTS_INNER) {
7171
try {
72-
const stdout = execFileSync('pnpm', ['exec', 'jest', '--listTests', '--json'], {
72+
const listTestArguments = ['exec', 'jest', '--listTests', '--json'];
73+
74+
if (process.env.MERGE_BASE) {
75+
console.log('MERGE_BASE detected:', process.env.MERGE_BASE);
76+
listTestArguments.push(
77+
'--changedSince',
78+
process.env.MERGE_BASE,
79+
'--passWithNoTests'
80+
);
81+
}
82+
83+
const stdout = execFileSync('pnpm', listTestArguments, {
7384
stdio: 'pipe',
7485
encoding: 'utf-8',
7586
env: {...process.env, JEST_LIST_TESTS_INNER: '1'},
@@ -108,6 +119,10 @@ function getTestsForGroup(
108119
allTests: ReadonlyArray<string>,
109120
testStats: Record<string, number>
110121
): string[] {
122+
if (allTests.length === 0) {
123+
return [];
124+
}
125+
111126
const speculatedSuiteDuration = Object.values(testStats).reduce((a, b) => a + b, 0);
112127
const targetDuration = speculatedSuiteDuration / nodeTotal;
113128

@@ -122,8 +137,13 @@ function getTestsForGroup(
122137
const tests = new Map<string, number>();
123138
const SUITE_P50_DURATION_MS = 1500;
124139

140+
const allTestsSet = new Set(allTests);
141+
125142
// First, iterate over all of the tests we have stats for.
126143
Object.entries(testStats).forEach(([test, duration]) => {
144+
if (!allTestsSet.has(test)) {
145+
return;
146+
}
127147
if (duration <= 0) {
128148
throw new Error(`Test duration is <= 0 for ${test}`);
129149
}
@@ -199,8 +219,8 @@ function getTestsForGroup(
199219
}
200220
}
201221

202-
if (!groups[nodeIndex]) {
203-
throw new Error(`No tests found for node ${nodeIndex}`);
222+
if (!groups[nodeIndex]?.length) {
223+
return ['<rootDir>/__no_tests_for_this_shard__'];
204224
}
205225
return groups[nodeIndex].map(test => `<rootDir>/${test}`);
206226
}
@@ -285,6 +305,7 @@ const config: Config.InitialOptions = {
285305
// window/cookies state.
286306
'@sentry/toolbar': '<rootDir>/tests/js/sentry-test/mocks/sentryToolbarMock.js',
287307
},
308+
passWithNoTests: !!process.env.MERGE_BASE,
288309
setupFiles: [
289310
'<rootDir>/static/app/utils/silence-react-unsafe-warnings.ts',
290311
'jest-canvas-mock',
@@ -333,8 +354,7 @@ const config: Config.InitialOptions = {
333354
*/
334355
clearMocks: true,
335356

336-
// To disable the sentry jest integration, set this to 'jsdom'
337-
testEnvironment: '@sentry/jest-environment/jsdom',
357+
testEnvironment: '<rootDir>/tests/js/sentry-test/jest-environment.js',
338358
testEnvironmentOptions: {
339359
globalsCleanup: 'on',
340360
sentryConfig: {
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
const SentryEnvironment = require('@sentry/jest-environment/jsdom');
2+
3+
// @sentry/jest-environment mutates config.projectConfig.testEnvironmentOptions
4+
// .sentryConfig.init in-place (pushing integrations and calling Sentry.init).
5+
// When Jest runs in-band (≤1 test, e.g. via --changedSince), those mutations
6+
// create circular references that crash ScriptTransformer's config serialisation.
7+
// Deep-cloning sentryConfig isolates the mutation from the original config object.
8+
class SafeSentryEnvironment extends SentryEnvironment {
9+
/** @param {import('@jest/environment').JestEnvironmentConfig} config @param {import('@jest/environment').EnvironmentContext} context */
10+
constructor(config, context) {
11+
const sentryConfig = config.projectConfig.testEnvironmentOptions?.sentryConfig;
12+
if (sentryConfig) {
13+
config = {
14+
...config,
15+
projectConfig: {
16+
...config.projectConfig,
17+
testEnvironmentOptions: {
18+
...config.projectConfig.testEnvironmentOptions,
19+
sentryConfig: structuredClone(sentryConfig),
20+
},
21+
},
22+
};
23+
}
24+
super(config, context);
25+
}
26+
}
27+
28+
module.exports = SafeSentryEnvironment;
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
declare module '@sentry/jest-environment/jsdom' {
2+
// eslint-disable-next-line import/no-extraneous-dependencies -- transitive dep of jest
3+
import type {JestEnvironment} from '@jest/environment';
4+
5+
const SentryEnvironment: typeof JestEnvironment;
6+
export = SentryEnvironment;
7+
}

0 commit comments

Comments
 (0)