jest.config.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  1. import type {Config} from '@jest/types';
  2. import path from 'node:path';
  3. import process from 'node:process';
  4. import {execFileSync} from 'node:child_process';
  5. import babelConfig from './babel.config';
  6. const {
  7. CI,
  8. JEST_TEST_BALANCER,
  9. CI_NODE_TOTAL,
  10. CI_NODE_INDEX,
  11. GITHUB_PR_SHA,
  12. GITHUB_PR_REF,
  13. GITHUB_RUN_ID,
  14. GITHUB_RUN_ATTEMPT,
  15. } = process.env;
  16. const IS_MASTER_BRANCH = GITHUB_PR_REF === 'refs/heads/master';
  17. const BALANCE_RESULTS_PATH = path.resolve(
  18. __dirname,
  19. 'tests',
  20. 'js',
  21. 'test-balancer',
  22. 'jest-balance.json'
  23. );
  24. const optionalTags: {
  25. balancer?: boolean;
  26. balancer_strategy?: string;
  27. } = {
  28. balancer: false,
  29. };
  30. if (!!JEST_TEST_BALANCER && !CI) {
  31. throw new Error(
  32. '[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'
  33. );
  34. }
  35. let JEST_TESTS;
  36. // prevents forkbomb as we don't want jest --listTests --json
  37. // to reexec itself here
  38. if (!process.env.JEST_LIST_TESTS_INNER) {
  39. try {
  40. const stdout = execFileSync('yarn', ['-s', 'jest', '--listTests', '--json'], {
  41. stdio: 'pipe',
  42. encoding: 'utf-8',
  43. env: {...process.env, JEST_LIST_TESTS_INNER: '1'},
  44. });
  45. JEST_TESTS = JSON.parse(stdout);
  46. } catch (err) {
  47. if (err.code) {
  48. throw new Error(`err code ${err.code} when spawning process`);
  49. } else {
  50. const {stdout, stderr} = err;
  51. throw new Error(`
  52. error listing jest tests
  53. stdout:
  54. ${stdout}
  55. stderr:
  56. ${stderr}
  57. `);
  58. }
  59. }
  60. }
  61. /**
  62. * In CI we may need to shard our jest tests so that we can parellize the test runs
  63. *
  64. * `JEST_TESTS` is a list of all tests that will run, captured by `jest --listTests --json`
  65. * Then we split up the tests based on the total number of CI instances that will
  66. * be running the tests.
  67. */
  68. let testMatch: string[] | undefined;
  69. function getTestsForGroup(
  70. nodeIndex: number,
  71. nodeTotal: number,
  72. allTests: ReadonlyArray<string>,
  73. testStats: Record<string, number>
  74. ): string[] {
  75. const speculatedSuiteDuration = Object.values(testStats).reduce((a, b) => a + b, 0);
  76. const targetDuration = speculatedSuiteDuration / nodeTotal;
  77. if (speculatedSuiteDuration <= 0) {
  78. throw new Error('Speculated suite duration is <= 0');
  79. }
  80. // We are going to take all of our tests and split them into groups.
  81. // If we have a test without a known duration, we will default it to 2 second
  82. // This is to ensure that we still assign some weight to the tests and still attempt to somewhat balance them.
  83. // 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.
  84. const tests = new Map<string, number>();
  85. const SUITE_P50_DURATION_MS = 1500;
  86. // First, iterate over all of the tests we have stats for.
  87. for (const test in testStats) {
  88. if (testStats[test] <= 0) {
  89. throw new Error(`Test duration is <= 0 for ${test}`);
  90. }
  91. tests.set(test, testStats[test]);
  92. }
  93. // Then, iterate over all of the remaining tests and assign them a default duration.
  94. for (const test of allTests) {
  95. if (tests.has(test)) {
  96. continue;
  97. }
  98. tests.set(test, SUITE_P50_DURATION_MS);
  99. }
  100. // Sanity check to ensure that we have all of our tests accounted for, we need to fail
  101. // if this is not the case as we risk not executing some tests and passing the build.
  102. if (tests.size < allTests.length) {
  103. throw new Error(
  104. `All tests should be accounted for, missing ${allTests.length - tests.size}`
  105. );
  106. }
  107. const groups: string[][] = [];
  108. // We sort files by path so that we try and improve the transformer cache hit rate.
  109. // Colocated domain specific files are likely to require other domain specific files.
  110. const testsSortedByPath = Array.from(tests.entries()).sort((a, b) => {
  111. return a[0].localeCompare(b[0]);
  112. });
  113. for (let group = 0; group < nodeTotal; group++) {
  114. groups[group] = [];
  115. let duration = 0;
  116. // While we are under our target duration and there are tests in the group
  117. while (duration < targetDuration && testsSortedByPath.length > 0) {
  118. // We peek the next item to check that it is not some super long running
  119. // test that may exceed our target duration. For example, if target runtime for each group is
  120. // 10 seconds, we have currently accounted for 9 seconds, and the next test is 5 seconds, we
  121. // want to move that test to the next group so as to avoid a 40% imbalance.
  122. const peek = testsSortedByPath[testsSortedByPath.length - 1];
  123. if (duration + peek[1] > targetDuration && peek[1] > 30_000) {
  124. break;
  125. }
  126. const nextTest = testsSortedByPath.pop();
  127. if (!nextTest) {
  128. throw new TypeError('Received falsy test' + JSON.stringify(nextTest));
  129. }
  130. groups[group].push(nextTest[0]);
  131. duration += nextTest[1];
  132. }
  133. }
  134. // Whatever may be left over will get round robin'd into the groups.
  135. let i = 0;
  136. while (testsSortedByPath.length) {
  137. const nextTest = testsSortedByPath.pop();
  138. if (!nextTest) {
  139. throw new TypeError('Received falsy test' + JSON.stringify(nextTest));
  140. }
  141. groups[i % 4].push(nextTest[0]);
  142. i++;
  143. }
  144. // Make sure we exhausted all tests before proceeding.
  145. if (testsSortedByPath.length > 0) {
  146. throw new Error('All tests should be accounted for');
  147. }
  148. // We need to ensure that everything from jest --listTests is accounted for.
  149. for (const test of allTests) {
  150. if (!tests.has(test)) {
  151. throw new Error(`Test ${test} is not accounted for`);
  152. }
  153. }
  154. if (!groups[nodeIndex]) {
  155. throw new Error(`No tests found for node ${nodeIndex}`);
  156. }
  157. return groups[nodeIndex].map(test => `<rootDir>/${test}`);
  158. }
  159. if (
  160. JEST_TESTS &&
  161. typeof CI_NODE_TOTAL !== 'undefined' &&
  162. typeof CI_NODE_INDEX !== 'undefined'
  163. ) {
  164. let balance: null | Record<string, number> = null;
  165. try {
  166. balance = require(BALANCE_RESULTS_PATH);
  167. } catch (err) {
  168. // Just ignore if balance results doesn't exist
  169. }
  170. // Taken from https://github.com/facebook/jest/issues/6270#issue-326653779
  171. const envTestList: string[] = JEST_TESTS.map(file => file.replace(__dirname, ''));
  172. const nodeTotal = Number(CI_NODE_TOTAL);
  173. const nodeIndex = Number(CI_NODE_INDEX);
  174. if (balance) {
  175. optionalTags.balancer = true;
  176. optionalTags.balancer_strategy = 'by_path';
  177. testMatch = getTestsForGroup(nodeIndex, nodeTotal, envTestList, balance);
  178. } else {
  179. const tests = envTestList.sort((a, b) => b.localeCompare(a));
  180. const length = tests.length;
  181. const size = Math.floor(length / nodeTotal);
  182. const remainder = length % nodeTotal;
  183. const offset = Math.min(nodeIndex, remainder) + nodeIndex * size;
  184. const chunk = size + (nodeIndex < remainder ? 1 : 0);
  185. testMatch = tests.slice(offset, offset + chunk).map(test => '<rootDir>' + test);
  186. }
  187. }
  188. /**
  189. * For performance we don't want to try and compile everything in the
  190. * node_modules, but some packages which use ES6 syntax only NEED to be
  191. * transformed.
  192. */
  193. const ESM_NODE_MODULES = ['screenfull'];
  194. const config: Config.InitialOptions = {
  195. verbose: false,
  196. collectCoverageFrom: [
  197. 'static/app/**/*.{js,jsx,ts,tsx}',
  198. '!static/app/**/*.spec.{js,jsx,ts,tsx}',
  199. ],
  200. coverageReporters: ['html', 'cobertura'],
  201. coverageDirectory: '.artifacts/coverage',
  202. moduleNameMapper: {
  203. '^sentry/(.*)': '<rootDir>/static/app/$1',
  204. '^sentry-fixture/(.*)': '<rootDir>/tests/js/fixtures/$1',
  205. '^sentry-test/(.*)': '<rootDir>/tests/js/sentry-test/$1',
  206. '^sentry-locale/(.*)': '<rootDir>/src/sentry/locale/$1',
  207. '\\.(css|less|png|jpg|mp4)$': '<rootDir>/tests/js/sentry-test/importStyleMock.js',
  208. '\\.(svg)$': '<rootDir>/tests/js/sentry-test/svgMock.js',
  209. // Disable echarts in test, since they're very slow and take time to
  210. // transform
  211. '^echarts/(.*)': '<rootDir>/tests/js/sentry-test/echartsMock.js',
  212. '^zrender/(.*)': '<rootDir>/tests/js/sentry-test/echartsMock.js',
  213. },
  214. setupFiles: [
  215. '<rootDir>/static/app/utils/silence-react-unsafe-warnings.ts',
  216. 'jest-canvas-mock',
  217. ],
  218. setupFilesAfterEnv: [
  219. '<rootDir>/tests/js/setup.ts',
  220. '<rootDir>/tests/js/setupFramework.ts',
  221. ],
  222. testMatch: testMatch || ['<rootDir>/(static|tests/js)/**/?(*.)+(spec|test).[jt]s?(x)'],
  223. testPathIgnorePatterns: ['<rootDir>/tests/sentry/lang/javascript/'],
  224. unmockedModulePathPatterns: [
  225. '<rootDir>/node_modules/react',
  226. '<rootDir>/node_modules/reflux',
  227. ],
  228. transform: {
  229. '^.+\\.jsx?$': ['babel-jest', babelConfig as any],
  230. '^.+\\.tsx?$': ['babel-jest', babelConfig as any],
  231. '^.+\\.pegjs?$': '<rootDir>/tests/js/jest-pegjs-transform.js',
  232. },
  233. transformIgnorePatterns: [
  234. ESM_NODE_MODULES.length
  235. ? `/node_modules/(?!${ESM_NODE_MODULES.join('|')})`
  236. : '/node_modules/',
  237. ],
  238. moduleFileExtensions: ['js', 'ts', 'jsx', 'tsx', 'pegjs'],
  239. globals: {},
  240. testResultsProcessor: JEST_TEST_BALANCER
  241. ? '<rootDir>/tests/js/test-balancer/index.js'
  242. : undefined,
  243. reporters: [
  244. 'default',
  245. [
  246. 'jest-junit',
  247. {
  248. outputDirectory: '.artifacts',
  249. outputName: 'jest.junit.xml',
  250. },
  251. ],
  252. ],
  253. /**
  254. * jest.clearAllMocks() automatically called before each test
  255. * @link - https://jestjs.io/docs/configuration#clearmocks-boolean
  256. */
  257. clearMocks: true,
  258. // To disable the sentry jest integration, set this to 'jsdom'
  259. testEnvironment: '@sentry/jest-environment/jsdom',
  260. testEnvironmentOptions: {
  261. sentryConfig: {
  262. init: {
  263. // jest project under Sentry organization (dev productivity team)
  264. dsn: CI
  265. ? 'https://3fe1dce93e3a4267979ebad67f3de327@o1.ingest.us.sentry.io/4857230'
  266. : false,
  267. // Use production env to reduce sampling of commits on master
  268. environment: CI ? (IS_MASTER_BRANCH ? 'ci:master' : 'ci:pull_request') : 'local',
  269. tracesSampleRate: CI ? 0.75 : 0,
  270. profilesSampleRate: 0,
  271. transportOptions: {keepAlive: true},
  272. },
  273. transactionOptions: {
  274. tags: {
  275. ...optionalTags,
  276. branch: GITHUB_PR_REF,
  277. commit: GITHUB_PR_SHA,
  278. github_run_attempt: GITHUB_RUN_ATTEMPT,
  279. github_actions_run: `https://github.com/getsentry/sentry/actions/runs/${GITHUB_RUN_ID}`,
  280. },
  281. },
  282. },
  283. },
  284. };
  285. export default config;