diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a05f12967e03..88b987174efb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -39,7 +39,7 @@ jobs: # Per-shard key: each shard only instruments the ~1/8 of suites it runs, so a single # shared key freezes one shard's partial cache for all shards. The hash refreshes the # cache when dependencies or Babel config change; restore-keys warms it in between. - key: ${{ runner.os }}-jest-${{ matrix.chunk }}-${{ hashFiles('package-lock.json', 'patches/**', 'babel.config.js') }} + key: ${{ runner.os }}-jest-${{ matrix.chunk }}-${{ hashFiles('package-lock.json', 'patches/**', 'babel.config.js', 'jest.config.js', 'config/babel/oxcJestTransformer.js', 'config/babel/reactCompilerConfig.js') }} restore-keys: ${{ runner.os }}-jest-${{ matrix.chunk }}- - name: Jest tests diff --git a/config/babel/oxcJestTransformer.js b/config/babel/oxcJestTransformer.js index 33a1f167c35f..bd6917a0a11b 100644 --- a/config/babel/oxcJestTransformer.js +++ b/config/babel/oxcJestTransformer.js @@ -1,10 +1,15 @@ const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); -const esbuild = require('esbuild'); +const babel = require('@babel/core'); const {transformSync} = require('oxc-transform-react'); const babelJest = require('babel-jest'); +const BABEL_CORE_VERSION = require('@babel/core/package.json').version; +const BLOCK_SCOPING_PLUGIN_VERSION = require('@babel/plugin-transform-block-scoping/package.json').version; +const DYNAMIC_IMPORT_PLUGIN_VERSION = require('@babel/plugin-transform-dynamic-import/package.json').version; +const CJS_PLUGIN_VERSION = require('@babel/plugin-transform-modules-commonjs/package.json').version; +const JEST_HOIST_VERSION = require('babel-plugin-jest-hoist/package.json').version; const OXC_TRANSFORM_REACT_VERSION = require('oxc-transform-react/package.json').version; const BaseReactCompilerConfig = require('./reactCompilerConfig'); @@ -14,6 +19,7 @@ const NODE_MODULES_RE = /[/\\]node_modules[/\\]/; const TESTS_RE = /[/\\]tests[/\\]/; const JEST_SETUP_RE = /[/\\]jest[/\\]/; const MOCKS_RE = /[/\\]__mocks__[/\\]/; +const JEST_HOIST_RE = /\bjest\s*\.\s*(mock|unmock|deepUnmock|disableAutomock|enableAutomock)\b/; const TRANSFORMER_SOURCE = fs.readFileSync(__filename); const REACT_COMPILER_CONFIG_KEY = JSON.stringify(BaseReactCompilerConfig); @@ -24,6 +30,14 @@ const REACT_COMPILER_OPTIONS = { eslintSuppressionRules: [], }; +const CJS_PLUGIN_OPTIONS = {loose: true, strictMode: false}; +const CJS_PLUGINS = [ + '@babel/plugin-transform-block-scoping', + '@babel/plugin-transform-dynamic-import', + ['@babel/plugin-transform-modules-commonjs', CJS_PLUGIN_OPTIONS], +]; +const CJS_AND_HOIST_PLUGINS = [...CJS_PLUGINS, 'babel-plugin-jest-hoist']; + function getLang(filename) { const ext = path.extname(filename).slice(1); if (ext === 'tsx') { @@ -36,7 +50,33 @@ function getLang(filename) { } function shouldUseOxc(filename) { - return !NODE_MODULES_RE.test(filename) && !TESTS_RE.test(filename) && !JEST_SETUP_RE.test(filename) && !MOCKS_RE.test(filename); + return !NODE_MODULES_RE.test(filename); +} + +function shouldRunReactCompiler(filename) { + return !TESTS_RE.test(filename) && !JEST_SETUP_RE.test(filename) && !MOCKS_RE.test(filename); +} + +function toCommonJS(code, sourcePath, inputSourceMap) { + const plugins = JEST_HOIST_RE.test(code) ? CJS_AND_HOIST_PLUGINS : CJS_PLUGINS; + const result = babel.transformSync(code, { + filename: sourcePath, + ast: false, + code: true, + babelrc: false, + configFile: false, + compact: false, + sourceType: 'module', + sourceMaps: true, + inputSourceMap: inputSourceMap ?? undefined, + plugins, + }); + + if (!result?.code) { + return {code, map: inputSourceMap}; + } + + return {code: result.code, map: result.map ?? inputSourceMap}; } function processWithOxc(sourceText, sourcePath) { @@ -44,22 +84,14 @@ function processWithOxc(sourceText, sourcePath) { lang: getLang(sourcePath), sourcemap: true, jsx: {runtime: 'automatic', development: true}, - reactCompiler: REACT_COMPILER_OPTIONS, + reactCompiler: shouldRunReactCompiler(sourcePath) ? REACT_COMPILER_OPTIONS : false, }); if (oxcResult.fatal || !oxcResult.code) { return null; } - const cjs = esbuild.transformSync(oxcResult.code, { - loader: 'js', - format: 'cjs', - supported: {'dynamic-import': false}, - sourcefile: sourcePath, - sourcemap: true, - }); - - return {code: cjs.code, map: cjs.map}; + return toCommonJS(oxcResult.code, sourcePath, oxcResult.map); } module.exports = { @@ -76,15 +108,24 @@ module.exports = { .update(sourcePath) .update(TRANSFORMER_SOURCE) .update(REACT_COMPILER_CONFIG_KEY) - .update(esbuild.version) + .update(JSON.stringify(CJS_PLUGIN_OPTIONS)) + .update(BABEL_CORE_VERSION) + .update(BLOCK_SCOPING_PLUGIN_VERSION) + .update(DYNAMIC_IMPORT_PLUGIN_VERSION) + .update(CJS_PLUGIN_VERSION) + .update(JEST_HOIST_VERSION) .update(OXC_TRANSFORM_REACT_VERSION) .digest('hex'); }, process(sourceText, sourcePath, transformOptions) { if (shouldUseOxc(sourcePath)) { - const result = processWithOxc(sourceText, sourcePath); - if (result) { - return result; + try { + const result = processWithOxc(sourceText, sourcePath); + if (result) { + return result; + } + } catch { + // Fall through to babel-jest for syntax OXC or the CJS pass cannot parse. } } diff --git a/jest.config.js b/jest.config.js index 14b6e257de98..f69f876a124b 100644 --- a/jest.config.js +++ b/jest.config.js @@ -18,10 +18,11 @@ module.exports = { `/?(*.)+(spec|test).${testFileExtension}`, ], transform: { - // Reassure re-transforms ~7k files under `--max-opt=1` (V8 sparkplug only), which - // makes Babel ~half of each measure job. OXC + esbuild is native and stays fast - // without TurboFan. Test files stay on babel-jest so `jest.mock` is still hoisted. - '^.+\\.[jt]sx?$': isPerfTestRun ? '/config/babel/oxcJestTransformer.js' : 'babel-jest', + // OXC compiles TS/JSX (and React Compiler on app sources). A small Babel pass then + // emits loose CommonJS, lowers import(), and hoists jest.mock so test files no + // longer need a babel-jest split. Native OXC stays fast under Reassure's + // `--max-opt=1` (V8 sparkplug only). + '^.+\\.[jt]sx?$': '/config/babel/oxcJestTransformer.js', '^.+\\.svg?$': 'jest-transformer-svg', }, transformIgnorePatterns: [ diff --git a/tests/tooling/oxcTransformer.test.ts b/tests/tooling/oxcTransformer.test.ts index 85d92cc27dbb..6f212b14e8ba 100644 --- a/tests/tooling/oxcTransformer.test.ts +++ b/tests/tooling/oxcTransformer.test.ts @@ -3,7 +3,7 @@ import {describe, expect, it} from 'bun:test'; import {createRequire} from 'node:module'; import path from 'node:path'; -type TransformResult = {code: string}; +type TransformResult = {code: string; map?: {sources?: string[]}}; type OxcTransformer = { process: (sourceText: string, sourcePath: string, transformOptions: unknown) => TransformResult; @@ -31,10 +31,10 @@ describe('oxcTransformer', () => { } `; const result = oxcTransformer.process(source, path.resolve('src/libs/math.ts'), transformOptions); - expect(result.code).toContain('module.exports'); - expect(result.code).toContain('add: () => add'); + expect(result.code).toContain('exports.add = add'); expect(result.code).not.toMatch(/^export /m); expect(result.code).not.toContain(': number'); + expect(result.map?.sources?.some((mapSource) => mapSource.endsWith('math.ts'))).toBe(true); }); it('runs React Compiler on app components', () => { @@ -48,15 +48,41 @@ describe('oxcTransformer', () => { expect(result.code).toContain('jsxDEV'); }); - it('leaves test files on babel-jest so jest.mock is hoisted', () => { + it.each(['tests/unit/Hello.test.tsx', 'jest/setup.tsx', '__mocks__/Hello.tsx'])('skips React Compiler on %s', (relativePath) => { + const source = ` + export function Hello({name}: {name: string}) { + return
{name.toUpperCase()}
; + } + `; + const result = oxcTransformer.process(source, path.resolve(relativePath), transformOptions); + expect(result.code).not.toMatch(/compiler-runtime|_c\(/); + expect(result.code).toContain('jsxDEV'); + }); + + it('lowers const in jest.mock factories so circular imports do not TDZ', () => { + const source = ` + const mockedReportID = '1'; + jest.mock('./foo', () => ({ + parseReportRouteParams: () => ({reportID: mockedReportID}), + })); + export const x = mockedReportID; + `; + const result = oxcTransformer.process(source, path.resolve('tests/unit/Hello.test.ts'), transformOptions); + expect(result.code).toMatch(/var mockedReportID/); + expect(result.code).not.toMatch(/\bconst mockedReportID\b/); + }); + + it('hoists jest.mock above require() after CJS conversion', () => { const source = ` import foo from './foo'; jest.mock('./foo'); - export const x = 1; + export const x = foo; `; const result = oxcTransformer.process(source, path.resolve('tests/perf-test/Hello.perf-test.tsx'), transformOptions); expect(result.code).toContain('_getJestObj().mock("./foo")'); - expect(result.code.indexOf('_getJestObj().mock')).toBeLessThan(result.code.indexOf('exports.x')); + expect(result.code).toMatch(/require\(['"]\.\/foo['"]\)/); + expect(result.code.indexOf('_getJestObj().mock')).toBeLessThan(result.code.search(/require\(['"]\.\/foo['"]\)/)); + expect(result.code).toContain('exports.x'); }); it('lowers dynamic import() so Jest still owns the module graph', () => {