diff --git a/packages/jest-preset/jest-preset.js b/packages/jest-preset/jest-preset.js index 720a73efdca4..83aef1e9f112 100644 --- a/packages/jest-preset/jest-preset.js +++ b/packages/jest-preset/jest-preset.js @@ -29,12 +29,24 @@ module.exports = { }, resolver: require.resolve('./jest/resolver.js'), transform: { - '^.+\\.(js|ts|tsx)$': 'babel-jest', + // Resolve from the preset's own scope so strict-isolation installs + // (pnpm / Yarn pnpm-mode) find the transformer without relying on + // hoisting or a consumer devDependency. + '^.+\\.(js|ts|tsx)$': require.resolve('babel-jest'), '^.+\\.(bmp|gif|jpg|jpeg|mp4|png|psd|svg|webp)$': require.resolve('./jest/assetFileTransformer.js'), }, transformIgnorePatterns: [ - 'node_modules/(?!((jest-)?react-native|@react-native(-community)?)/)', + // Match react-native packages at any nesting depth so pnpm + // (node_modules/.pnpm//node_modules/...) and Yarn pnpm-mode + // (node_modules/.store//package/...) layouts still transform + // preset and react-native sources instead of ignoring them as opaque + // node_modules. A trailing `-virtual-` synthetic suffix (Yarn, + // e.g. @react-native-jest-preset-virtual-2eb6229c53) is allowed; real + // `-suffix` packages such as react-native-reanimated, react-native-svg, + // @react-native-async-storage/async-storage, or + // react-native-virtual-* stay ignored, exactly as before. + 'node_modules/(?!([^\\/]*[\\/])*((jest-)?react-native|@react-native(-community)?)(([^\\/]*-virtual-[0-9a-f]+)?[\\/]|$))', ], setupFiles: [require.resolve('./jest/setup.js')], testEnvironment: require.resolve('./jest/react-native-env.js'), diff --git a/packages/jest-preset/jest/__tests__/preset-react-native-dep-test.js b/packages/jest-preset/jest/__tests__/preset-react-native-dep-test.js new file mode 100644 index 000000000000..c17fc91b57d1 --- /dev/null +++ b/packages/jest-preset/jest/__tests__/preset-react-native-dep-test.js @@ -0,0 +1,121 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + * @format + */ + +import {spawnSync} from 'node:child_process'; +import fs from 'node:fs'; +import {createRequire} from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; + +const RN_ISSUE = 'https://github.com/react/react-native/issues/56641'; + +test(`isolated preset loads when the consumer provides react-native (${RN_ISSUE})`, () => { + const presetDir = path.resolve(__dirname, '..', '..'); + const presetRequire = createRequire(path.join(presetDir, 'package.json')); + const scratch = fs.mkdtempSync( + path.join(os.tmpdir(), 'rn-jest-preset-56641-'), + ); + try { + const isoDir = path.join(scratch, 'isolated-preset'); + const consumerDir = path.join(scratch, 'consumer'); + fs.mkdirSync(consumerDir, {recursive: true}); + + // Mimic pnpm/Yarn pnpm-mode isolation: copy the preset so bare + // specifiers resolve from the copy, which sees only what a package + // manager would install there. Exclude node_modules: an open-source + // Yarn install can create a per-package one, and if it contained + // react-native or babel-jest the copy would inherit it and the test + // would pass when it should fail. + fs.cpSync(presetDir, isoDir, { + recursive: true, + filter: src => !src.split(path.sep).includes('node_modules'), + }); + + // Mirror a package-manager install of declared dependencies into the + // isolated copy. This intentionally provides nothing beyond what the + // manifest declares: every dependency, peer, and optional peer is + // linked from the repo, so `require.resolve` from the copy sees exactly + // the declared surface (notably `babel-jest`, needed by `jest-preset.js` + // itself, as well as `react-native` when declared). + const pkg = JSON.parse( + fs.readFileSync(path.join(presetDir, 'package.json'), 'utf8'), + ); + const declared = new Set([ + ...Object.keys(pkg.dependencies ?? {}), + ...Object.keys(pkg.peerDependencies ?? {}), + ...Object.keys(pkg.optionalDependencies ?? {}), + ]); + for (const name of declared) { + const target = path.join(isoDir, 'node_modules', name); + fs.mkdirSync(path.dirname(target), {recursive: true}); + const depDir = path.dirname( + presetRequire.resolve(`${name}/package.json`), + ); + fs.symlinkSync(depDir, target, 'dir'); + } + + // A consuming project always has its own copy. + const consumerRnDir = path.dirname( + presetRequire.resolve('react-native/package.json'), + ); + const consumerTarget = path.join( + consumerDir, + 'node_modules', + 'react-native', + ); + fs.mkdirSync(path.dirname(consumerTarget), {recursive: true}); + fs.symlinkSync(consumerRnDir, consumerTarget, 'dir'); + + // Strip resolution-affecting env so the child is genuinely isolated: + // inherited lookup paths can otherwise make the copy resolve more + // than the directory layout alone provides. + const childEnv: {[string]: string} = {}; + for (const key of Object.keys(process.env)) { + if (key === 'NODE_PATH' || key === 'NODE_OPTIONS') { + continue; + } + const value = process.env[key]; + if (value != null) { + childEnv[key] = value; + } + } + + // The child probes what the isolated copy can actually resolve, prints + // the outcome, then loads the preset. Both probes use the isolated + // copy as the resolution scope. + const isoPreset = path.join(isoDir, 'jest-preset.js'); + const probeScript = [ + `const isoDir = ${JSON.stringify(isoDir)};`, + `const isoPreset = ${JSON.stringify(isoPreset)};`, + `console.log('CHILD_NODE_PATH:' + (process.env.NODE_PATH ?? '(unset)'));`, + `console.log('LOOKUP:' + JSON.stringify(require('module')._nodeModulePaths(isoDir)));`, + `let probe;`, + `try { probe = 'RESOLVED:' + require.resolve('react-native', {paths: [isoDir]}); } catch (e) { probe = 'UNREACHABLE:' + e.code + ':' + String(e.message).split('\\n')[0]; }`, + `console.log('PROBE:' + probe);`, + `require(isoPreset);`, + `console.log('PRESET_LOADED');`, + ].join('\n'); + const child = spawnSync(process.execPath, ['-e', probeScript], { + cwd: consumerDir, + encoding: 'utf8', + env: childEnv, + }); + const stdout = String(child.stdout ?? ''); + if (child.status !== 0) { + throw new Error( + `Isolated preset failed to load (${RN_ISSUE}).\n` + + `Child output:\n${stdout}\n` + + `Child stderr:\n${String(child.stderr ?? '')}`, + ); + } + } finally { + fs.rmSync(scratch, {recursive: true, force: true}); + } +}); diff --git a/packages/jest-preset/jest/__tests__/preset-transform-isolation-test.js b/packages/jest-preset/jest/__tests__/preset-transform-isolation-test.js new file mode 100644 index 000000000000..55fe6d58b445 --- /dev/null +++ b/packages/jest-preset/jest/__tests__/preset-transform-isolation-test.js @@ -0,0 +1,87 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +import fs from 'node:fs'; +import {createRequire} from 'node:module'; +import path from 'node:path'; + +const RN_ISSUE = 'https://github.com/react/react-native/issues/56641'; + +const presetDir = path.resolve(__dirname, '..', '..'); +const presetRequire = createRequire(path.join(presetDir, 'package.json')); + +describe(`preset transform survives strict isolation (${RN_ISSUE})`, () => { + const preset = require('../../jest-preset'); + + test('JS transformer resolves from the preset scope', () => { + const jsTransform = preset.transform['^.+\\.(js|ts|tsx)$']; + // A bare 'babel-jest' specifier only resolves by hoisting or a consumer + // devDependency. Under pnpm / Yarn pnpm-mode the consumer scope does not + // see the preset's dependencies, so the transformer must resolve from + // the preset's own scope. + expect(jsTransform).toBe(presetRequire.resolve('babel-jest')); + expect(fs.existsSync(jsTransform)).toBe(true); + }); + + test('transformIgnorePatterns covers pnpm/Yarn layouts without widening', () => { + const re = new RegExp(preset.transformIgnorePatterns[0]); + // [path, shouldBeIgnored]. pnpm (.pnpm//node_modules) and Yarn + // pnpm-mode (.store//package) layouts must transform preset and + // react-native sources; real `-suffix` packages must stay ignored exactly + // as before. + const cases: Array<[string, boolean]> = [ + ['/app/node_modules/@react-native/jest-preset/jest/setup.js', false], + ['/app/node_modules/react-native/Libraries/AppState/AppState.js', false], + ['/app/node_modules/lodash/lodash.js', true], + ['/app/node_modules/react-native-reanimated/lib/index.js', true], + ['/app/node_modules/react-native-svg/lib/index.js', true], + [ + '/app/node_modules/@react-native-async-storage/async-storage/lib/index.js', + true, + ], + ['/app/node_modules/react-native-virtualized-view/lib/index.js', true], + ['/app/node_modules/react-native-virtual-joystick/lib/index.js', true], + ['/app/node_modules/react-native-virtual-keyboard/lib/index.js', true], + ['/app/node_modules/react-native-virtual-list/lib/index.js', true], + [ + '/tmp/x/node_modules/.pnpm/@react-native+jest-preset@file+preset_abc/node_modules/@react-native/jest-preset/jest/setup.js', + false, + ], + [ + '/tmp/x/node_modules/.pnpm/react-native@1000.0.0/node_modules/react-native/Libraries/AppState/AppState.js', + false, + ], + [ + '/tmp/x/node_modules/.pnpm/lodash@4.17.21/node_modules/lodash/lodash.js', + true, + ], + [ + '/tmp/x/node_modules/.pnpm/react-native-reanimated@1.0.0/node_modules/react-native-reanimated/lib/index.js', + true, + ], + [ + '/tmp/x/node_modules/.pnpm/react-native-svg@1.0.0/node_modules/react-native-svg/lib/index.js', + true, + ], + [ + '/tmp/x/node_modules/.pnpm/@react-native-async-storage+async-storage@1.0.0/node_modules/@react-native-async-storage/async-storage/lib/index.js', + true, + ], + [ + '/tmp/x/node_modules/.store/@react-native-jest-preset-virtual-abc123/package/jest/mock.js', + false, + ], + ['/tmp/x/packages/app/__tests__/App.test.js', false], + ]; + for (const [file, shouldIgnore] of cases) { + expect(re.test(file)).toBe(shouldIgnore); + } + }); +}); diff --git a/packages/jest-preset/package.json b/packages/jest-preset/package.json index bc3f36a83450..5af767029b2b 100644 --- a/packages/jest-preset/package.json +++ b/packages/jest-preset/package.json @@ -28,6 +28,7 @@ "!**/__tests__/**" ], "dependencies": { + "@babel/runtime": "^7.25.0", "@jest/create-cache-key-function": "^29.7.0", "@react-native/js-polyfills": "0.87.0-main", "babel-jest": "^29.7.0", @@ -35,6 +36,8 @@ "regenerator-runtime": "^0.13.2" }, "peerDependencies": { - "react": "^19.2.3" + "@babel/core": "^7.25.2", + "react": "^19.2.3", + "react-native": "1000.0.0" } }