Skip to content
Open
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
61 changes: 59 additions & 2 deletions packages/jest-preset/jest-preset.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,60 @@

const path = require('node:path');

// Package directories whose sources must be transformed rather than ignored.
const RN = '(jest-)?react-native';
const RN_SCOPE = '@react-native(-community)?';

// Yarn pnpm-mode names every store entry `<flat-name>-<protocol>-<hash>`,
// where `<protocol>` is how the package was resolved. `npm` is the only one
// that also carries the version. A package declaring peer dependencies
// resolves as `virtual` regardless of where it came from. Observed under Yarn
// 4.18: `react-native-npm-1000.0.0-30881e83e6`, `react-native-file-d30a842e27`,
// `is-odd-patch-cfbe751c88`. Listing the protocols explicitly rather than
// accepting any word keeps `react-native-reanimated-npm-1.0.0-<hash>` from
// matching with `reanimated` in the protocol position.
const STORE_PROTOCOL = 'virtual|patch|file|portal|link|exec|workspace';
const STORE_SUFFIX = `(-npm-[^\\/]+|-(?:${STORE_PROTOCOL})-[0-9a-f]+)`;

// Locations, one per install layout, where a react-native package directory
// may legitimately sit. Each is an alternative of a single negative lookahead
// applied after `node_modules/`, so anything not listed here stays ignored.
//
// The prefixes are anchored instead of allowing arbitrary leading segments.
// Without that anchoring, a scoped third-party package whose unscoped name is
// exactly `react-native` (`@sentry/react-native`, `@notifee/react-native`) or
// a directory literally named `react-native` nested inside an unrelated
// package would match and be transformed.
const TRANSFORMED_PACKAGE_LAYOUTS = [
// Classic `node_modules/react-native/...`, and pnpm's
// `node_modules/.pnpm/<id>/node_modules/react-native/...` — the same shape
// behind an optional store prefix. The trailing `[\/]` is required: without
// it the package name would also match a longer one it merely prefixes, so
// `react-native-reanimated` would be transformed. Yarn's `-virtual-<hash>`
// entries are deliberately not accepted here — they only ever appear under
// `.store/`, which the next alternative handles.
`(\\.pnpm/([^\\/]+/)?node_modules/)?(${RN}|${RN_SCOPE})[\\/]`,

// Yarn pnpm-mode's content store:
// `node_modules/.store/<flat>-<protocol>-<hash>/package/...`, where `<flat>`
// is the package name with its scope slash flattened to a dash.
//
// The scope segment `(?:-[^-\/]+)*` must keep `-` out of its inner class.
// Allowing it there lets the segment consume dashes itself, which gives a
// dash-separated name exponentially many possible partitions and makes any
// near-miss path under `.store/@react-native-...` backtrack catastrophically.
// Jest tests this pattern against every candidate file path, so a single
// such path would hang the run.
//
// Flattening erases the scope boundary here, so a third-party
// `@react-native-<scope>/*` package (e.g.
// `@react-native-async-storage/async-storage`) is indistinguishable from a
// genuine `@react-native/*` one and is transformed too. Transforming is the
// safe direction — missing one would ship untransformed sources — and the
// cost is performance only, confined to Yarn pnpm-mode.
`\\.store/(${RN}${STORE_SUFFIX}|${RN_SCOPE}(?:-[^-\\/]+)*${STORE_SUFFIX})/package/`,
];

module.exports = {
haste: {
defaultPlatform: 'ios',
Expand All @@ -29,12 +83,15 @@ 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)?)/)',
`node_modules/(?!${TRANSFORMED_PACKAGE_LAYOUTS.join('|')})`,
],
setupFiles: [require.resolve('./jest/setup.js')],
testEnvironment: require.resolve('./jest/react-native-env.js'),
Expand Down
145 changes: 145 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,145 @@
/**
* 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);`,
`const preset = require(isoPreset);`,
`console.log('PRESET_TRANSFORM:' + preset.transform['^.+\\\\.(js|ts|tsx)$']);`,
`console.log('PRESET_LOADED');`,
].join('\n');
const child = spawnSync(process.execPath, ['-e', probeScript], {
cwd: consumerDir,
encoding: 'utf8',
env: childEnv,
});
const stdout = String(child.stdout ?? '');
const childOutput =
`Child output:\n${stdout}\n` +
`Child stderr:\n${String(child.stderr ?? '')}`;
if (child.status !== 0) {
throw new Error(
`Isolated preset failed to load (${RN_ISSUE}).\n${childOutput}`,
);
}

// A zero exit code alone would also be produced by a child that never
// reached the preset, so assert on what it reported. `react-native` must
// resolve from the isolated copy's own scope: that only happens because
// the manifest declares it, which is the regression this test guards.
const read = (label: string): string => {
const match = stdout.match(new RegExp(`^${label}:(.*)$`, 'm'));
if (match == null) {
throw new Error(
`Child never printed ${label} (${RN_ISSUE}).\n${childOutput}`,
);
}
return match[1];
};

expect(read('PROBE')).toBe(
`RESOLVED:${presetRequire.resolve('react-native')}`,
);
// `jest-preset.js` calls `require.resolve('babel-jest')` from its own
// scope; a bare specifier would only have resolved by hoisting.
expect(read('PRESET_TRANSFORM')).toBe(presetRequire.resolve('babel-jest'));
expect(stdout).toContain('PRESET_LOADED');
} finally {
fs.rmSync(scratch, {recursive: true, force: true});
}
});
Loading
Loading