mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
304 lines
9.9 KiB
TypeScript
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;
|