Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
85 changes: 85 additions & 0 deletions .github/workflows/frontend-optional.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,11 @@ jobs:
testable_rules_changed: ${{ steps.changes.outputs.testable_rules_changed }}
typecheckable_rules_changed: ${{ steps.changes.outputs.typecheckable_rules_changed }}
frontend_all: ${{ steps.changes.outputs.frontend_all }}
merge_base: ${{ steps.merge_base.outputs.merge_base }}
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
with:
fetch-depth: 100

- name: Check for frontend file changes
uses: dorny/paths-filter@0bc4621a3135347011ad047f9ecf449bf72ce2bd # v3.0.0
Expand All @@ -36,6 +39,88 @@ jobs:
filters: .github/file-filters.yml
list-files: shell

# On PRs, HEAD is the merge commit; its parents (HEAD^1, HEAD^2) are base and head.
# Merge base of those two is what Jest --changedSince needs.
# If merge base can't be computed or non-frontend files changed, output is empty
# and the optional Jest job will be skipped entirely.
- name: Get merge base for changedSince
id: merge_base
run: |
MERGE_BASE=$(git merge-base HEAD^1 HEAD^2 2>/dev/null) || true
if [ -n "$MERGE_BASE" ]; then
CHANGED=$(git diff --name-only "$MERGE_BASE" HEAD^2)
if echo "$CHANGED" | grep -qvE '^static/'; then
echo "Non-frontend file changed — skipping optional Jest"
MERGE_BASE=""
else
echo "Merge base: $MERGE_BASE (Jest will use --changedSince)"
fi
else
echo "Could not compute merge base — skipping optional Jest"
fi
echo "merge_base=${MERGE_BASE:-}" >> "$GITHUB_OUTPUT"

# This job intentionally mirrors `frontend-jest-tests` in frontend.yml.
# Our intent is to try it out for a few weeks and see if it's stable.
frontend-jest-tests-changed-only:
if: >-
needs.files-changed.outputs.merge_base != '' &&
(needs.files-changed.outputs.testable_rules_changed == 'true' || needs.files-changed.outputs.testable_modified == 'true')
needs: [files-changed]
name: Jest
# If you change the runs-on image, you must also change the runner in jest-balance.yml
# so that the balancer runs in the same environment as the tests.
runs-on: ubuntu-24.04
timeout-minutes: 30
strategy:
# This helps not having to run multiple jobs because one fails, thus, reducing resource usage
# and reducing the risk that one of many runs would turn red again (read: intermittent tests)
fail-fast: false
matrix:
# XXX: When updating this, make sure you also update CI_NODE_TOTAL.
instance: [0, 1, 2, 3]

steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
name: Checkout sentry
with:
# PRs need history so we can compute merge base for Jest --changedSince.
# 100 is an arbitrary depth that will get most reasonable PRs' commits.
fetch-depth: ${{ github.event_name == 'pull_request' && '100' || '1' }}

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

- name: Download jest-balance.json
id: download-artifact
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
with:
workflow: 38531594 # jest-balancer.yml
workflow_conclusion: success # The conclusion of the workflow we're looking for
branch: master # The branch we're looking for
name: jest-balance.json # Artifact name
name_is_regexp: false
path: tests/js/test-balancer/ # Directory where to extract artifact(s), defaults to the current directory
search_artifacts: true # Search for the last workflow run whose stored the artifact we're looking for
if_no_artifact_found: warn # Can be one of: "fail", "warn", "ignore"

- name: jest
env:
GITHUB_PR_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
GITHUB_PR_REF: ${{ github.event.pull_request.head.ref || github.ref }}
# XXX: CI_NODE_TOTAL must be hardcoded to the length of strategy.matrix.instance.
# Otherwise, if there are other things in the matrix, using strategy.job-total
# wouldn't be correct.
CI_NODE_TOTAL: 4
CI_NODE_INDEX: ${{ matrix.instance }}
# Disable testing-library from printing out any of of the DOM to
# stdout. No one actually looks through this in CI, they're just
# going to run it locally.
#
# This quiets up the logs quite a bit.
DEBUG_PRINT_LIMIT: 0
MERGE_BASE: ${{ needs.files-changed.outputs.merge_base }}
run: pnpm run test-ci --forceExit

typescript-native:
if: needs.files-changed.outputs.frontend_all == 'true'
needs: files-changed
Expand Down
1 change: 1 addition & 0 deletions eslint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -928,6 +928,7 @@ export default typescript.config([
name: 'files/jest related',
files: [
'tests/js/jest-pegjs-transform.js',
'tests/js/sentry-test/jest-environment.js',
'tests/js/sentry-test/mocks/*',
'tests/js/sentry-test/loadFixtures.ts',
'tests/js/setup.ts',
Expand Down
30 changes: 25 additions & 5 deletions jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,18 @@ let JEST_TESTS: string[] | undefined;
// to reexec itself here
if (CI && !process.env.JEST_LIST_TESTS_INNER) {
try {
const stdout = execFileSync('pnpm', ['exec', 'jest', '--listTests', '--json'], {
const listTestArguments = ['exec', 'jest', '--listTests', '--json'];

if (process.env.MERGE_BASE) {
console.log('MERGE_BASE detected:', process.env.MERGE_BASE);
listTestArguments.push(
'--changedSince',
process.env.MERGE_BASE,
'--passWithNoTests'
);
}

const stdout = execFileSync('pnpm', listTestArguments, {
stdio: 'pipe',
encoding: 'utf-8',
env: {...process.env, JEST_LIST_TESTS_INNER: '1'},
Expand Down Expand Up @@ -108,6 +119,10 @@ function getTestsForGroup(
allTests: ReadonlyArray<string>,
testStats: Record<string, number>
): string[] {
if (allTests.length === 0) {
return [];
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Shard balancing uses unfiltered total, breaking distribution

Medium Severity

speculatedSuiteDuration is computed from the full testStats (all ~1,861 suites), but the new filtering at lines 143–146 correctly restricts the tests Map to only the allTests subset from --changedSince. Since targetDuration is massively inflated relative to the actual filtered tests' total duration, the balancing loop stuffs every test into the first shard while shards 1–3 get nothing. This defeats parallelization for any small-to-medium --changedSince result set.

Additional Locations (1)
Fix in Cursor Fix in Web

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Hmm, interesting. I don't think parallelization at those result set scales really matters much, right? Running 10 suites vs 100 is pretty quick.

const speculatedSuiteDuration = Object.values(testStats).reduce((a, b) => a + b, 0);
const targetDuration = speculatedSuiteDuration / nodeTotal;

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

const allTestsSet = new Set(allTests);

// First, iterate over all of the tests we have stats for.
Object.entries(testStats).forEach(([test, duration]) => {
if (!allTestsSet.has(test)) {
return;
}
if (duration <= 0) {
throw new Error(`Test duration is <= 0 for ${test}`);
}
Expand Down Expand Up @@ -199,8 +219,8 @@ function getTestsForGroup(
}
}

if (!groups[nodeIndex]) {
throw new Error(`No tests found for node ${nodeIndex}`);
if (!groups[nodeIndex]?.length) {
return ['<rootDir>/__no_tests_for_this_shard__'];
}
return groups[nodeIndex].map(test => `<rootDir>/${test}`);
}
Expand Down Expand Up @@ -285,6 +305,7 @@ const config: Config.InitialOptions = {
// window/cookies state.
'@sentry/toolbar': '<rootDir>/tests/js/sentry-test/mocks/sentryToolbarMock.js',
},
passWithNoTests: !!process.env.MERGE_BASE,
setupFiles: [
'<rootDir>/static/app/utils/silence-react-unsafe-warnings.ts',
'jest-canvas-mock',
Expand Down Expand Up @@ -333,8 +354,7 @@ const config: Config.InitialOptions = {
*/
clearMocks: true,

// To disable the sentry jest integration, set this to 'jsdom'
testEnvironment: '@sentry/jest-environment/jsdom',
testEnvironment: '<rootDir>/tests/js/sentry-test/jest-environment.js',
testEnvironmentOptions: {
globalsCleanup: 'on',
sentryConfig: {
Expand Down
28 changes: 28 additions & 0 deletions tests/js/sentry-test/jest-environment.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
const SentryEnvironment = require('@sentry/jest-environment/jsdom');

// @sentry/jest-environment mutates config.projectConfig.testEnvironmentOptions
// .sentryConfig.init in-place (pushing integrations and calling Sentry.init).
// When Jest runs in-band (≤1 test, e.g. via --changedSince), those mutations
// create circular references that crash ScriptTransformer's config serialisation.
// Deep-cloning sentryConfig isolates the mutation from the original config object.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[Question] Is there any context around @sentry/jest-environment that's relevant here? My instinct is to treat "it crashes when running in-band" as a bug.

Example where it crashed from running in-band: https://github.com/getsentry/sentry/actions/runs/23297657610/job/67749510053?pr=110624

MERGE_BASE detected: 78fe0def3474f2811e7bad0da2a5d456c03785ee
 PASS  static/app/components/banner.spec.tsx (5.319 s)
TypeError: Converting circular structure to JSON
    at stringify (/home/runner/work/sentry/sentry/node_modules/.pnpm/fast-json-stable-stringify@2.1.0/node_modules/fast-json-stable-stringify/index.js:42:19)
    at stringify (/home/runner/work/sentry/sentry/node_modules/.pnpm/fast-json-stable-stringify@2.1.0/node_modules/fast-json-stable-stringify/index.js:50:25)
    at stringify (/home/runner/work/sentry/sentry/node_modules/.pnpm/fast-json-stable-stringify@2.1.0/node_modules/fast-json-stable-stringify/index.js:50:25)
    at stringify (/home/runner/work/sentry/sentry/node_modules/.pnpm/fast-json-stable-stringify@2.1.0/node_modules/fast-json-stable-stringify/index.js:50:25)
    ...

Aside: I noticed https://www.npmjs.com/package/@sentry/jest-environment was last published 9 months ago for 6.1.0, but the linked repo https://github.com/billyvg/jest-sentry-environment was last committed 5 years ago for 1.3.0 and is archived. https://github.com/getsentry/jest-sentry-environment is the new place.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

class SafeSentryEnvironment extends SentryEnvironment {
/** @param {import('@jest/environment').JestEnvironmentConfig} config @param {import('@jest/environment').EnvironmentContext} context */
constructor(config, context) {
const sentryConfig = config.projectConfig.testEnvironmentOptions?.sentryConfig;
if (sentryConfig) {
Comment on lines +8 to +12

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

i'd try to add these to https://github.com/getsentry/jest-sentry-environment and publish a new version

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

config = {
...config,
projectConfig: {
...config.projectConfig,
testEnvironmentOptions: {
...config.projectConfig.testEnvironmentOptions,
sentryConfig: structuredClone(sentryConfig),
},
},
};
}
super(config, context);
}
}

module.exports = SafeSentryEnvironment;
7 changes: 7 additions & 0 deletions tests/js/sentry-test/sentry-jest-environment.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
declare module '@sentry/jest-environment/jsdom' {
// eslint-disable-next-line import/no-extraneous-dependencies -- transitive dep of jest
import type {JestEnvironment} from '@jest/environment';

const SentryEnvironment: typeof JestEnvironment;
export = SentryEnvironment;
}
Loading