jest.config.ts 10 KB

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