diff --git a/packages/cli-tools/package.json b/packages/cli-tools/package.json index 08f6905cb5..22b4f4b03c 100644 --- a/packages/cli-tools/package.json +++ b/packages/cli-tools/package.json @@ -7,7 +7,6 @@ "access": "public" }, "dependencies": { - "@react-native-community/cli-doctor": "12.0.0-alpha.11", "appdirsjs": "^1.2.4", "chalk": "^4.1.2", "find-up": "^5.0.0", diff --git a/packages/cli-tools/src/__tests__/resolveTransitiveDeps.test.ts b/packages/cli-tools/src/__tests__/resolveTransitiveDeps.test.ts new file mode 100644 index 0000000000..95cb9748e8 --- /dev/null +++ b/packages/cli-tools/src/__tests__/resolveTransitiveDeps.test.ts @@ -0,0 +1,267 @@ +import path from 'path'; +import prompts from 'prompts'; +import {cleanup, getTempDirectory, writeFiles} from '../../../../jest/helpers'; +import { + DependencyData, + calculateWorkingVersion, + collectDependencies, + filterInstalledPeers, + filterNativeDependencies, + findDependencyPath, + getMissingPeerDepsForYarn, + resolveTransitiveDeps, +} from '../resolveTransitiveDeps'; +import logger from '../logger'; + +jest.mock('execa', () => { + return {sync: jest.fn()}; +}); + +jest.mock('prompts', () => ({prompt: jest.fn()})); + +jest.mock('../logger', () => ({ + isVerbose: jest.fn(), + warn: jest.fn(), + log: jest.fn(), +})); + +const mockFetchJson = jest.fn(); + +jest.mock('npm-registry-fetch', () => ({ + json: mockFetchJson, +})); + +const rootPackageJson = { + name: 'App', + version: '1.0.0', + dependencies: { + 'react-native': '0.72.4', + '@react-navigation/stack': '^6.3.17', + }, +}; + +const stackPackageJson = { + name: '@react-navigation/stack', + version: '6.3.17', + dependencies: { + '@react-navigation/elements': '^1.3.18', + 'react-native-gesture-handler': '^1.10.3', + }, + peerDependencies: { + react: '*', + 'react-native': '*', + 'react-native-gesture-handler': '>= 1.0.0', + }, +}; + +const elementsPackageJson = { + name: '@react-navigation/elements', + version: '1.3.18', + peerDependencies: { + react: '*', + 'react-native': '*', + 'react-native-safe-area-view': '*', + }, +}; + +const gestureHandlerPackageJson = { + name: 'react-native-gesture-handler', + version: '1.10.3', +}; + +const DIR = getTempDirectory('root_test'); + +const createTempFiles = (rest?: Record) => { + writeFiles(DIR, { + 'package.json': JSON.stringify(rootPackageJson), + 'node_modules/@react-navigation/stack/package.json': JSON.stringify( + stackPackageJson, + ), + 'node_modules/@react-navigation/elements/package.json': JSON.stringify( + elementsPackageJson, + ), + 'node_modules/react-native-gesture-handler/package.json': JSON.stringify( + gestureHandlerPackageJson, + ), + 'node_modules/react-native-gesture-handler/ios/Podfile': '', + ...rest, + }); +}; + +beforeEach(async () => { + await cleanup(DIR); + jest.resetAllMocks(); +}); + +describe('calculateWorkingVersion', () => { + it('should return the highest matching version for all ranges', () => { + const workingVersion = calculateWorkingVersion( + ['*', '>=2.2.0', '>=2.0.0'], + ['1.9.0', '2.0.0', '2.2.0', '3.0.0'], + ); + + expect(workingVersion).toBe('3.0.0'); + }); + + it('should return null if no version matches all ranges', () => { + const workingVersion = calculateWorkingVersion( + ['*', '>=2.2.0', '^1.0.0-alpha'], + ['1.9.0', '2.0.0', '2.1.0'], + ); + + expect(workingVersion).toBe(null); + }); +}); + +describe('findDependencyPath', () => { + it('should return the path to the dependency if it is in top-level node_modules', () => { + writeFiles(DIR, { + 'package.json': JSON.stringify(rootPackageJson), + 'node_modules/@react-navigation/stack/package.json': JSON.stringify( + stackPackageJson, + ), + }); + + const dependencyPath = findDependencyPath( + '@react-navigation/stack', + DIR, + path.join(DIR, 'node_modules', '@react-navigation/stack'), + ); + + expect(dependencyPath).toBe( + path.join(DIR, 'node_modules', '@react-navigation/stack'), + ); + }); + + it('should return the path to the nested node_modules if package is installed here', () => { + writeFiles(DIR, { + 'package.json': JSON.stringify(rootPackageJson), + 'node_modules/@react-navigation/stack/node_modules/react-native-gesture-handler/package.json': + '{}', + }); + + const dependencyPath = findDependencyPath( + 'react-native-gesture-handler', + DIR, + path.join(DIR, 'node_modules', '@react-navigation/stack'), + ); + + expect(dependencyPath).toBe( + path.join( + DIR, + 'node_modules', + '@react-navigation/stack', + 'node_modules', + 'react-native-gesture-handler', + ), + ); + }); +}); + +describe('collectDependencies', () => { + beforeEach(() => { + createTempFiles(); + }); + + it('should recursively get all dependencies', () => { + const dependencies = collectDependencies(DIR); + + expect(dependencies.size).toBe(4); + }); + + it('should collect peer dependencies of a dependency', () => { + const dependencies = collectDependencies(DIR); + const stackDependency = dependencies.get( + '@react-navigation/stack', + ) as DependencyData; + const peerNames = Object.keys(stackDependency.peerDependencies); + + expect(peerNames).toContain('react'); + expect(peerNames).toContain('react-native'); + expect(peerNames).toContain('react-native-gesture-handler'); + }); +}); + +describe('filterNativeDependencies', () => { + it('should return only dependencies with peer dependencies containing native code', () => { + createTempFiles({ + 'node_modules/react-native-safe-area-view/ios/Podfile': '{}', + }); + const dependencies = collectDependencies(DIR); + const filtered = filterNativeDependencies(DIR, dependencies); + expect(filtered.keys()).toContain('@react-navigation/stack'); + expect(filtered.keys()).toContain('@react-navigation/elements'); + }); +}); + +describe('filterInstalledPeers', () => { + it('should return only dependencies with peer dependencies that are installed', () => { + createTempFiles(); + const dependencies = collectDependencies(DIR); + const libsWithNativeDeps = filterNativeDependencies(DIR, dependencies); + const nonInstalledPeers = filterInstalledPeers(DIR, libsWithNativeDeps); + + expect(Object.keys(nonInstalledPeers)).toContain('@react-navigation/stack'); + expect(Object.keys(nonInstalledPeers['@react-navigation/stack'])).toContain( + 'react-native-gesture-handler', + ); + }); +}); + +describe('getMissingPeerDepsForYarn', () => { + it('should return an array of peer dependencies to install', () => { + createTempFiles(); + + const dependencies = getMissingPeerDepsForYarn(DIR); + expect(dependencies.values()).toContain('react'); + expect(dependencies.values()).toContain('react-native-gesture-handler'); + expect(dependencies.values()).toContain('react-native-safe-area-view'); + }); +}); + +describe('resolveTransitiveDeps', () => { + it('should display list of missing peer dependencies if there are any', async () => { + createTempFiles(); + prompts.prompt.mockReturnValue({}); + await resolveTransitiveDeps(DIR); + expect(logger.warn).toHaveBeenCalledWith( + 'Looks like you are missing some of the peer dependencies of your libraries:\n', + ); + }); + + it('should not display list if there are no missing peer dependencies', async () => { + writeFiles(DIR, { + 'package.json': JSON.stringify(rootPackageJson), + }); + + await resolveTransitiveDeps(DIR); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('should prompt user to install missing peer dependencies', async () => { + createTempFiles(); + prompts.prompt.mockReturnValue({}); + await resolveTransitiveDeps(DIR); + expect(prompts.prompt).toHaveBeenCalledWith({ + type: 'confirm', + name: 'install', + message: + 'Do you want to install them now? The matching versions will be added as project dependencies and become visible for autolinking.', + }); + }); + + it('should install missing peer dependencies if user confirms', async () => { + createTempFiles(); + prompts.prompt.mockReturnValue({install: true}); + mockFetchJson.mockReturnValueOnce({ + versions: { + '2.0.0': {}, + '2.1.0': {}, + }, + }); + + const resolveDeps = await resolveTransitiveDeps(DIR); + + expect(resolveDeps).toBe(true); + }); +}); diff --git a/packages/cli-tools/src/resolveTransitiveDeps.ts b/packages/cli-tools/src/resolveTransitiveDeps.ts index 5d2b1c0453..be877d9391 100644 --- a/packages/cli-tools/src/resolveTransitiveDeps.ts +++ b/packages/cli-tools/src/resolveTransitiveDeps.ts @@ -10,7 +10,7 @@ import generateFileHash from './generateFileHash'; import {getLoader} from './loader'; import logger from './logger'; -interface DependencyData { +export interface DependencyData { path: string; version: string; peerDependencies: {[key: string]: string}; @@ -25,16 +25,18 @@ function writeFile(filePath: string, content: string) { fs.writeFileSync(filePath, content, {encoding: 'utf8'}); } -async function fetchAvailableVersions(packageName: string): Promise { +export async function fetchAvailableVersions( + packageName: string, +): Promise { const response = await fetch.json(`/${packageName}`); return Object.keys(response.versions || {}); } -async function calculateWorkingVersion( +export function calculateWorkingVersion( ranges: string[], availableVersions: string[], -): Promise { +): string | null { const sortedVersions = availableVersions .filter((version) => ranges.every((range) => semver.satisfies(version, range)), @@ -44,7 +46,7 @@ async function calculateWorkingVersion( return sortedVersions.length > 0 ? sortedVersions[0] : null; } -function findDependencyPath( +export function findDependencyPath( dependencyName: string, rootPath: string, parentPath: string, @@ -62,7 +64,7 @@ function findDependencyPath( return dependencyPath; } -function collectDependencies(root: string): Map { +export function collectDependencies(root: string): Map { const dependencies = new Map(); const checkDependency = (dependencyPath: string) => { @@ -117,7 +119,7 @@ function collectDependencies(root: string): Map { return dependencies; } -function filterNativeDependencies( +export function filterNativeDependencies( root: string, dependencies: Map, ) { @@ -149,7 +151,7 @@ function filterNativeDependencies( return depsWithNativePeers; } -function filterInstalledPeers( +export function filterInstalledPeers( root: string, peers: Map>, ) { @@ -174,7 +176,7 @@ function filterInstalledPeers( return data; } -function findPeerDepsToInstall( +export function findPeerDepsToInstall( root: string, dependencies: Map, ) { @@ -196,7 +198,7 @@ function findPeerDepsToInstall( return peerDependencies; } -async function getMissingPeerDepsForYarn(root: string) { +export function getMissingPeerDepsForYarn(root: string) { const dependencies = collectDependencies(root); const depsToInstall = findPeerDepsToInstall(root, dependencies); @@ -204,8 +206,8 @@ async function getMissingPeerDepsForYarn(root: string) { } // install peer deps with yarn without making any changes to package.json and yarn.lock -async function yarnSilentInstallPeerDeps(root: string) { - const dependenciesToInstall = await getMissingPeerDepsForYarn(root); +export function yarnSilentInstallPeerDeps(root: string) { + const dependenciesToInstall = getMissingPeerDepsForYarn(root); const packageJsonPath = path.join(root, 'package.json'); const lockfilePath = path.join(root, 'yarn.lock'); @@ -226,7 +228,7 @@ async function yarnSilentInstallPeerDeps(root: string) { logger.error('yarn.lock is missing'); return; } - const loader = getLoader({text: 'Verifying dependencies...'}); + const loader = getLoader({text: 'Looking for peer dependencies...'}); loader.start(); try { @@ -242,7 +244,7 @@ async function yarnSilentInstallPeerDeps(root: string) { } } -async function findPeerDepsForAutolinking(root: string) { +function findPeerDepsForAutolinking(root: string) { const deps = collectDependencies(root); const nonEmptyPeers = filterNativeDependencies(root, deps); const nonInstalledPeers = filterInstalledPeers(root, nonEmptyPeers); @@ -250,7 +252,7 @@ async function findPeerDepsForAutolinking(root: string) { return nonInstalledPeers; } -async function promptForMissingPeerDependencies( +export async function promptForMissingPeerDependencies( dependencies: Record>, ): Promise { logger.warn( @@ -277,7 +279,6 @@ async function promptForMissingPeerDependencies( message: 'Do you want to install them now? The matching versions will be added as project dependencies and become visible for autolinking.', }); - return install; } @@ -302,10 +303,7 @@ async function getPackagesVersion( for (const packageName in packageToRanges) { const ranges = packageToRanges[packageName]; const availableVersions = await fetchAvailableVersions(packageName); - const workingVersion = await calculateWorkingVersion( - ranges, - availableVersions, - ); + const workingVersion = calculateWorkingVersion(ranges, availableVersions); if (workingVersion !== null) { workingVersions[packageName] = workingVersion; @@ -349,16 +347,13 @@ function installMissingPackages( } } -async function resolveTransitiveDeps() { - const root = process.cwd(); +export async function resolveTransitiveDeps(root: string) { const isYarn = isUsingYarn(root); - if (isYarn) { - await yarnSilentInstallPeerDeps(root); + yarnSilentInstallPeerDeps(root); } - const missingPeerDependencies = await findPeerDepsForAutolinking(root); - + const missingPeerDependencies = findPeerDepsForAutolinking(root); if (Object.keys(missingPeerDependencies).length > 0) { const installDeps = await promptForMissingPeerDependencies( missingPeerDependencies, @@ -393,9 +388,10 @@ async function resolvePodsInstallation() { } export default async function checkTransitiveDependencies() { + const root = process.cwd(); const packageJsonPath = path.join(process.cwd(), 'package.json'); const preInstallHash = generateFileHash(packageJsonPath); - const areTransitiveDepsInstalled = await resolveTransitiveDeps(); + const areTransitiveDepsInstalled = await resolveTransitiveDeps(root); const postInstallHash = generateFileHash(packageJsonPath); if (