Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions packages/jest-preset/jest-preset.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/<hash>/node_modules/...) and Yarn pnpm-mode
// (node_modules/.store/<virtual>/package/...) layouts still transform
// preset and react-native sources instead of ignoring them as opaque
// node_modules. A trailing `-virtual-<hexhash>` 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'),
Expand Down
121 changes: 121 additions & 0 deletions packages/jest-preset/jest/__tests__/preset-react-native-dep-test.js
Original file line number Diff line number Diff line change
@@ -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});
}
});
Original file line number Diff line number Diff line change
@@ -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/<hash>/node_modules) and Yarn
// pnpm-mode (.store/<virtual>/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);
}
});
});
5 changes: 4 additions & 1 deletion packages/jest-preset/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,16 @@
"!**/__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",
"jest-environment-node": "^29.7.0",
"regenerator-runtime": "^0.13.2"
},
"peerDependencies": {
"react": "^19.2.3"
"@babel/core": "^7.25.2",
"react": "^19.2.3",
"react-native": "1000.0.0"
}
}
Loading