Files
sentinel/jest.config.ts
T
2024-02-15 17:01:50 +00:00

304 lines
9.9 KiB
TypeScript

/* eslint-env node */
import type {Config} from '@jest/types';
import path from 'node:path';
import process from 'node:process';
import babelConfig from './babel.config';
const {
CI,
JEST_TESTS,
JEST_TEST_BALANCER,
CI_NODE_TOTAL,
CI_NODE_INDEX,
GITHUB_PR_SHA,
GITHUB_PR_REF,
GITHUB_RUN_ID,
GITHUB_RUN_ATTEMPT,
} = process.env;
const IS_MASTER_BRANCH = GITHUB_PR_REF === 'refs/heads/master';
const BALANCE_RESULTS_PATH = path.resolve(
__dirname,
'tests',
'js',
'test-balancer',
'jest-balance.json'
);
const optionalTags: {
balancer?: boolean;
balancer_strategy?: string;
} = {
balancer: false,
};
if (!!JEST_TEST_BALANCER && !CI) {
throw new Error(
'[Operation only allowed in CI]: Jest test balancer should never be ran manually as you risk skewing the numbers - please trigger the automated github workflow at https://github.com/getsentry/sentry/actions/workflows/jest-balance.yml'
);
}
/**
* In CI we may need to shard our jest tests so that we can parellize the test runs
*
* `JEST_TESTS` is a list of all tests that will run, captured by `jest --listTests`
* Then we split up the tests based on the total number of CI instances that will
* be running the tests.
*/
let testMatch: string[] | undefined;
function getTestsForGroup(
nodeIndex: number,
nodeTotal: number,
allTests: ReadonlyArray<string>,
testStats: Record<string, number>
): string[] {
const speculatedSuiteDuration = Object.values(testStats).reduce((a, b) => a + b, 0);
const targetDuration = speculatedSuiteDuration / nodeTotal;
if (speculatedSuiteDuration <= 0) {
throw new Error('Speculated suite duration is <= 0');
}
// We are going to take all of our tests and split them into groups.
// If we have a test without a known duration, we will default it to 2 second
// This is to ensure that we still assign some weight to the tests and still attempt to somewhat balance them.
// The 1.5s default is selected as a p50 value of all of our JS tests in CI (as of 2022-10-26) taken from our sentry performance monitoring.
const tests = new Map<string, number>();
const SUITE_P50_DURATION_MS = 1500;
// First, iterate over all of the tests we have stats for.
for (const test in testStats) {
if (testStats[test] <= 0) {
throw new Error(`Test duration is <= 0 for ${test}`);
}
tests.set(test, testStats[test]);
}
// Then, iterate over all of the remaining tests and assign them a default duration.
for (const test of allTests) {
if (tests.has(test)) {
continue;
}
tests.set(test, SUITE_P50_DURATION_MS);
}
// Sanity check to ensure that we have all of our tests accounted for, we need to fail
// if this is not the case as we risk not executing some tests and passing the build.
if (tests.size < allTests.length) {
throw new Error(
`All tests should be accounted for, missing ${allTests.length - tests.size}`
);
}
const groups: string[][] = [];
// We sort files by path so that we try and improve the transformer cache hit rate.
// Colocated domain specific files are likely to require other domain specific files.
const testsSortedByPath = Array.from(tests.entries()).sort((a, b) => {
// WidgetBuilder tests are a special case as they can sometimes take a long time to run (3-5 minutes)
// As such, we want to ensure that they are ran in the same group as other widget builder tests.
// We do this by sorting them by the path of the widget builder test which ensures they are started by the first job
// in the CI group and that all of the tests actually run in the same group.
if (a[0].includes('widgetBuilder')) {
return -1;
}
if (b[0].includes('widgetBuilder')) {
return 1;
}
return a[0].localeCompare(b[0]);
});
for (let group = 0; group < nodeTotal; group++) {
groups[group] = [];
let duration = 0;
// While we are under our target duration and there are tests in the group
while (duration < targetDuration && testsSortedByPath.length > 0) {
// We peek the next item to check that it is not some super long running
// test that may exceed our target duration. For example, if target runtime for each group is
// 10 seconds, we have currently accounted for 9 seconds, and the next test is 5 seconds, we
// want to move that test to the next group so as to avoid a 40% imbalance.
const peek = testsSortedByPath[testsSortedByPath.length - 1];
if (duration + peek[1] > targetDuration && peek[1] > 30_000) {
break;
}
const nextTest = testsSortedByPath.pop();
if (!nextTest) {
throw new TypeError('Received falsy test' + JSON.stringify(nextTest));
}
groups[group].push(nextTest[0]);
duration += nextTest[1];
}
}
// Whatever may be left over will get round robin'd into the groups.
let i = 0;
while (testsSortedByPath.length) {
const nextTest = testsSortedByPath.pop();
if (!nextTest) {
throw new TypeError('Received falsy test' + JSON.stringify(nextTest));
}
groups[i % 4].push(nextTest[0]);
i++;
}
// Make sure we exhausted all tests before proceeding.
if (testsSortedByPath.length > 0) {
throw new Error('All tests should be accounted for');
}
// We need to ensure that everything from jest --listTests is accounted for.
for (const test of allTests) {
if (!tests.has(test)) {
throw new Error(`Test ${test} is not accounted for`);
}
}
if (!groups[nodeIndex]) {
throw new Error(`No tests found for node ${nodeIndex}`);
}
return groups[nodeIndex].map(test => `<rootDir>/${test}`);
}
if (
JEST_TESTS &&
typeof CI_NODE_TOTAL !== 'undefined' &&
typeof CI_NODE_INDEX !== 'undefined'
) {
let balance: null | Record<string, number> = null;
try {
balance = require(BALANCE_RESULTS_PATH);
} catch (err) {
// Just ignore if balance results doesn't exist
}
// Taken from https://github.com/facebook/jest/issues/6270#issue-326653779
const envTestList: string[] = JSON.parse(JEST_TESTS).map(file =>
file.replace(__dirname, '')
);
const nodeTotal = Number(CI_NODE_TOTAL);
const nodeIndex = Number(CI_NODE_INDEX);
if (balance) {
optionalTags.balancer = true;
optionalTags.balancer_strategy = 'by_path';
testMatch = getTestsForGroup(nodeIndex, nodeTotal, envTestList, balance);
} else {
const tests = envTestList.sort((a, b) => b.localeCompare(a));
const length = tests.length;
const size = Math.floor(length / nodeTotal);
const remainder = length % nodeTotal;
const offset = Math.min(nodeIndex, remainder) + nodeIndex * size;
const chunk = size + (nodeIndex < remainder ? 1 : 0);
testMatch = tests.slice(offset, offset + chunk).map(test => '<rootDir>' + test);
}
}
/**
* For performance we don't want to try and compile everything in the
* node_modules, but some packages which use ES6 syntax only NEED to be
* transformed.
*/
const ESM_NODE_MODULES = [];
const config: Config.InitialOptions = {
verbose: false,
collectCoverageFrom: [
'static/app/**/*.{js,jsx,ts,tsx}',
'!static/app/**/*.spec.{js,jsx,ts,tsx}',
],
coverageReporters: ['html', 'cobertura'],
coverageDirectory: '.artifacts/coverage',
moduleNameMapper: {
'^sentry/(.*)': '<rootDir>/static/app/$1',
'^sentry-fixture/(.*)': '<rootDir>/fixtures/js-stubs/$1',
'^sentry-test/(.*)': '<rootDir>/tests/js/sentry-test/$1',
'^sentry-locale/(.*)': '<rootDir>/src/sentry/locale/$1',
'\\.(css|less|png|jpg|mp4)$': '<rootDir>/tests/js/sentry-test/importStyleMock.js',
'\\.(svg)$': '<rootDir>/tests/js/sentry-test/svgMock.js',
// Disable echarts in test, since they're very slow and take time to
// transform
'^echarts/(.*)': '<rootDir>/tests/js/sentry-test/echartsMock.js',
'^zrender/(.*)': '<rootDir>/tests/js/sentry-test/echartsMock.js',
},
setupFiles: [
'<rootDir>/static/app/utils/silence-react-unsafe-warnings.ts',
'jest-canvas-mock',
],
setupFilesAfterEnv: [
'<rootDir>/tests/js/setup.ts',
'<rootDir>/tests/js/setupFramework.ts',
'@testing-library/jest-dom/extend-expect',
],
testMatch: testMatch || ['<rootDir>/static/**/?(*.)+(spec|test).[jt]s?(x)'],
testPathIgnorePatterns: ['<rootDir>/tests/sentry/lang/javascript/'],
unmockedModulePathPatterns: [
'<rootDir>/node_modules/react',
'<rootDir>/node_modules/reflux',
],
transform: {
'^.+\\.jsx?$': ['babel-jest', babelConfig as any],
'^.+\\.tsx?$': ['babel-jest', babelConfig as any],
'^.+\\.pegjs?$': '<rootDir>/tests/js/jest-pegjs-transform.js',
},
transformIgnorePatterns: [
ESM_NODE_MODULES.length
? `/node_modules/(?!${ESM_NODE_MODULES.join('|')})`
: '/node_modules/',
],
moduleFileExtensions: ['js', 'ts', 'jsx', 'tsx'],
globals: {},
testResultsProcessor: JEST_TEST_BALANCER
? '<rootDir>/tests/js/test-balancer/index.js'
: undefined,
reporters: [
'default',
[
'jest-junit',
{
outputDirectory: '.artifacts',
outputName: 'jest.junit.xml',
},
],
],
/**
* jest.clearAllMocks() automatically called before each test
* @link - https://jestjs.io/docs/configuration#clearmocks-boolean
*/
clearMocks: true,
testEnvironment: 'jsdom',
testEnvironmentOptions: {
sentryConfig: {
init: {
// jest project under Sentry organization (dev productivity team)
dsn: 'https://[email protected]/4857230',
// Use production env to reduce sampling of commits on master
environment: CI ? (IS_MASTER_BRANCH ? 'ci:master' : 'ci:pull_request') : 'local',
tracesSampleRate: CI ? 1 : 0.5,
profilesSampleRate: 0.1,
transportOptions: {keepAlive: true},
},
transactionOptions: {
tags: {
...optionalTags,
branch: GITHUB_PR_REF,
commit: GITHUB_PR_SHA,
github_run_attempt: GITHUB_RUN_ATTEMPT,
github_actions_run: `https://github.com/getsentry/sentry/actions/runs/${GITHUB_RUN_ID}`,
},
},
},
},
};
export default config;