diff --git a/packages/react-native/scripts/swiftpm/__tests__/prepare-app-utils-test.js b/packages/react-native/scripts/swiftpm/__tests__/prepare-app-utils-test.js new file mode 100644 index 000000000000..a2be2a740111 --- /dev/null +++ b/packages/react-native/scripts/swiftpm/__tests__/prepare-app-utils-test.js @@ -0,0 +1,470 @@ +/** + * 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 + */ + +'use strict'; + +const { + findXcodeProjectDirectory, + runIosPrebuild, + runPodDeintegrate, +} = require('../prepare-app-utils'); + +// Mock child_process module +jest.mock('child_process'); + +// Mock console methods - disable React Native's strict console checking +const originalConsole = global.console; + +beforeAll(() => { + global.console = { + ...originalConsole, + log: jest.fn(), + warn: jest.fn(), + }; +}); + +afterAll(() => { + global.console = originalConsole; +}); + +describe('findXcodeProjectDirectory', () => { + let mockExecSync; + + beforeEach(() => { + // Setup mock + const childProcess = require('child_process'); + mockExecSync = childProcess.execSync; + + // Reset all mocks + jest.clearAllMocks(); + }); + + it('should find Xcode project directory successfully', () => { + // Setup + const appPath = '/path/to/app'; + const xcodeProjectName = 'MyApp.xcodeproj'; + const mockResult = '/path/to/app/ios/MyApp.xcodeproj'; + + mockExecSync.mockReturnValue(mockResult + '\n'); + + // Execute + const result = findXcodeProjectDirectory(appPath, xcodeProjectName); + + // Assert + expect(result).toBe('/path/to/app/ios'); + expect(mockExecSync).toHaveBeenCalledWith( + `find "${appPath}" -name "${xcodeProjectName}" -type d -print`, + {encoding: 'utf8'}, + ); + expect(mockExecSync).toHaveBeenCalledTimes(1); + }); + + it('should find Xcode project in nested subdirectory', () => { + // Setup + const appPath = '/Users/developer/ReactNativeApp'; + const xcodeProjectName = 'ReactNativeApp.xcodeproj'; + const mockResult = + '/Users/developer/ReactNativeApp/ios/sub/ReactNativeApp.xcodeproj'; + + mockExecSync.mockReturnValue(mockResult + '\n'); + + // Execute + const result = findXcodeProjectDirectory(appPath, xcodeProjectName); + + // Assert + expect(result).toBe('/Users/developer/ReactNativeApp/ios/sub'); + expect(mockExecSync).toHaveBeenCalledWith( + `find "${appPath}" -name "${xcodeProjectName}" -type d -print`, + {encoding: 'utf8'}, + ); + }); + + it('should handle project found at root level', () => { + // Setup + const appPath = '/path/to/project'; + const xcodeProjectName = 'RootProject.xcodeproj'; + const mockResult = '/path/to/project/RootProject.xcodeproj'; + + mockExecSync.mockReturnValue(mockResult + '\n'); + + // Execute + const result = findXcodeProjectDirectory(appPath, xcodeProjectName); + + // Assert + expect(result).toBe('/path/to/project'); + expect(mockExecSync).toHaveBeenCalledWith( + `find "${appPath}" -name "${xcodeProjectName}" -type d -print`, + {encoding: 'utf8'}, + ); + }); + + it('should handle paths with spaces in directory names', () => { + // Setup + const appPath = '/path/to/my app'; + const xcodeProjectName = 'My App.xcodeproj'; + const mockResult = '/path/to/my app/ios folder/My App.xcodeproj'; + + mockExecSync.mockReturnValue(mockResult + '\n'); + + // Execute + const result = findXcodeProjectDirectory(appPath, xcodeProjectName); + + // Assert + expect(result).toBe('/path/to/my app/ios folder'); + expect(mockExecSync).toHaveBeenCalledWith( + `find "${appPath}" -name "${xcodeProjectName}" -type d -print`, + {encoding: 'utf8'}, + ); + }); + + it('should throw error when Xcode project is not found', () => { + // Setup + const appPath = '/path/to/app'; + const xcodeProjectName = 'NonExistent.xcodeproj'; + + mockExecSync.mockReturnValue(''); + + // Execute & Assert + expect(() => findXcodeProjectDirectory(appPath, xcodeProjectName)).toThrow( + `Xcode project 'NonExistent.xcodeproj' not found in '/path/to/app' or its subdirectories`, + ); + + expect(mockExecSync).toHaveBeenCalledWith( + `find "${appPath}" -name "${xcodeProjectName}" -type d -print`, + {encoding: 'utf8'}, + ); + }); + + it('should throw error when find command returns only whitespace', () => { + // Setup + const appPath = '/path/to/app'; + const xcodeProjectName = 'Missing.xcodeproj'; + + mockExecSync.mockReturnValue(' \n \t '); + + // Execute & Assert + expect(() => findXcodeProjectDirectory(appPath, xcodeProjectName)).toThrow( + `Xcode project 'Missing.xcodeproj' not found in '/path/to/app' or its subdirectories`, + ); + }); + + it('should properly escape quotes in app path', () => { + // Setup + const appPath = '/path/to/app with "quotes"'; + const xcodeProjectName = 'MyApp.xcodeproj'; + const mockResult = '/path/to/app with "quotes"/ios/MyApp.xcodeproj'; + + mockExecSync.mockReturnValue(mockResult + '\n'); + + // Execute + const result = findXcodeProjectDirectory(appPath, xcodeProjectName); + + // Assert + expect(result).toBe('/path/to/app with "quotes"/ios'); + expect(mockExecSync).toHaveBeenCalledWith( + `find "${appPath}" -name "${xcodeProjectName}" -type d -print`, + {encoding: 'utf8'}, + ); + }); +}); + +describe('runIosPrebuild', () => { + let mockExecSync; + let mockConsoleLog; + let originalProcessEnv; + + beforeEach(() => { + // Setup mocks + const childProcess = require('child_process'); + mockExecSync = childProcess.execSync; + + mockConsoleLog = console.log; + + // Store original process.env to restore later + originalProcessEnv = process.env; + + // Reset all mocks + jest.clearAllMocks(); + }); + + afterEach(() => { + // Restore original process.env + process.env = originalProcessEnv; + }); + + it('should run iOS prebuild successfully with nightly versions', async () => { + // Setup + const reactNativePath = '/path/to/react-native'; + + mockExecSync.mockReturnValue(undefined); + + // Execute + await runIosPrebuild(reactNativePath); + + // Assert + expect(mockExecSync).toHaveBeenCalledWith('node scripts/ios-prebuild -s', { + cwd: reactNativePath, + env: { + ...originalProcessEnv, + RN_DEP_VERSION: 'nightly', + HERMES_VERSION: 'nightly', + }, + stdio: 'inherit', + }); + expect(mockExecSync).toHaveBeenCalledTimes(1); + expect(mockConsoleLog).toHaveBeenCalledWith( + 'Running iOS prebuild with nightly versions...', + ); + expect(mockConsoleLog).toHaveBeenCalledWith('✓ iOS prebuild completed'); + expect(mockConsoleLog).toHaveBeenCalledTimes(2); + }); + + it('should handle different React Native paths', async () => { + // Setup + const reactNativePath = '/Users/developer/react-native'; + + mockExecSync.mockReturnValue(undefined); + + // Execute + await runIosPrebuild(reactNativePath); + + // Assert + expect(mockExecSync).toHaveBeenCalledWith('node scripts/ios-prebuild -s', { + cwd: reactNativePath, + env: { + ...originalProcessEnv, + RN_DEP_VERSION: 'nightly', + HERMES_VERSION: 'nightly', + }, + stdio: 'inherit', + }); + }); + + it('should handle paths with spaces correctly', async () => { + // Setup + const reactNativePath = '/path/to/react native project'; + + mockExecSync.mockReturnValue(undefined); + + // Execute + await runIosPrebuild(reactNativePath); + + // Assert + expect(mockExecSync).toHaveBeenCalledWith('node scripts/ios-prebuild -s', { + cwd: reactNativePath, + env: { + ...originalProcessEnv, + RN_DEP_VERSION: 'nightly', + HERMES_VERSION: 'nightly', + }, + stdio: 'inherit', + }); + }); + + it('should throw error when iOS prebuild fails', async () => { + // Setup + const reactNativePath = '/path/to/react-native'; + const mockError = new Error('Build failed'); + + mockExecSync.mockImplementation(() => { + throw mockError; + }); + + // Execute & Assert + await expect(runIosPrebuild(reactNativePath)).rejects.toThrow( + 'iOS prebuild failed: Build failed', + ); + + expect(mockExecSync).toHaveBeenCalledWith('node scripts/ios-prebuild -s', { + cwd: reactNativePath, + env: { + ...originalProcessEnv, + RN_DEP_VERSION: 'nightly', + HERMES_VERSION: 'nightly', + }, + stdio: 'inherit', + }); + expect(mockConsoleLog).toHaveBeenCalledWith( + 'Running iOS prebuild with nightly versions...', + ); + expect(mockConsoleLog).not.toHaveBeenCalledWith('✓ iOS prebuild completed'); + }); + + it('should handle script not found error', async () => { + // Setup + const reactNativePath = '/path/to/react-native'; + const mockError = new Error('Cannot find module scripts/ios-prebuild'); + + mockExecSync.mockImplementation(() => { + throw mockError; + }); + + // Execute & Assert + await expect(runIosPrebuild(reactNativePath)).rejects.toThrow( + 'iOS prebuild failed: Cannot find module scripts/ios-prebuild', + ); + }); + + it('should handle root directory path', async () => { + // Setup + const reactNativePath = '/'; + + mockExecSync.mockReturnValue(undefined); + + // Execute + await runIosPrebuild(reactNativePath); + + // Assert + expect(mockExecSync).toHaveBeenCalledWith('node scripts/ios-prebuild -s', { + cwd: '/', + env: { + ...originalProcessEnv, + RN_DEP_VERSION: 'nightly', + HERMES_VERSION: 'nightly', + }, + stdio: 'inherit', + }); + }); +}); + +describe('runPodDeintegrate', () => { + let mockExecSync; + let mockConsoleLog; + let mockConsoleWarn; + + beforeEach(() => { + // Setup mocks + const childProcess = require('child_process'); + mockExecSync = childProcess.execSync; + + mockConsoleLog = console.log; + mockConsoleWarn = console.warn; + + // Reset all mocks + jest.clearAllMocks(); + }); + + it('should run pod deintegrate successfully', async () => { + // Setup + const appIosPath = '/path/to/app/ios'; + + mockExecSync.mockReturnValue(undefined); + + // Execute + await runPodDeintegrate(appIosPath); + + // Assert + expect(mockExecSync).toHaveBeenCalledWith('pod deintegrate', { + cwd: appIosPath, + stdio: 'inherit', + }); + expect(mockExecSync).toHaveBeenCalledTimes(1); + expect(mockConsoleLog).toHaveBeenCalledWith( + 'Running pod deintegrate in: /path/to/app/ios', + ); + expect(mockConsoleLog).toHaveBeenCalledWith('✓ Pod deintegrate completed'); + expect(mockConsoleLog).toHaveBeenCalledTimes(2); + expect(mockConsoleWarn).not.toHaveBeenCalled(); + }); + + it('should handle different iOS directory paths', async () => { + // Setup + const appIosPath = '/Users/developer/MyApp/ios'; + + mockExecSync.mockReturnValue(undefined); + + // Execute + await runPodDeintegrate(appIosPath); + + // Assert + expect(mockExecSync).toHaveBeenCalledWith('pod deintegrate', { + cwd: appIosPath, + stdio: 'inherit', + }); + expect(mockConsoleLog).toHaveBeenCalledWith( + 'Running pod deintegrate in: /Users/developer/MyApp/ios', + ); + expect(mockConsoleLog).toHaveBeenCalledWith('✓ Pod deintegrate completed'); + }); + + it('should handle paths with spaces correctly', async () => { + // Setup + const appIosPath = '/path/to/my app/ios folder'; + + mockExecSync.mockReturnValue(undefined); + + // Execute + await runPodDeintegrate(appIosPath); + + // Assert + expect(mockExecSync).toHaveBeenCalledWith('pod deintegrate', { + cwd: appIosPath, + stdio: 'inherit', + }); + expect(mockConsoleLog).toHaveBeenCalledWith( + 'Running pod deintegrate in: /path/to/my app/ios folder', + ); + expect(mockConsoleLog).toHaveBeenCalledWith('✓ Pod deintegrate completed'); + }); + + it('should handle pod deintegrate command failure gracefully', async () => { + // Setup + const appIosPath = '/path/to/app/ios'; + const mockError = new Error('No Podfile.lock found'); + + mockExecSync.mockImplementation(() => { + throw mockError; + }); + + // Execute + await runPodDeintegrate(appIosPath); + + // Assert + expect(mockExecSync).toHaveBeenCalledWith('pod deintegrate', { + cwd: appIosPath, + stdio: 'inherit', + }); + expect(mockConsoleLog).toHaveBeenCalledWith( + 'Running pod deintegrate in: /path/to/app/ios', + ); + expect(mockConsoleLog).not.toHaveBeenCalledWith( + '✓ Pod deintegrate completed', + ); + expect(mockConsoleWarn).toHaveBeenCalledWith( + '⚠️ Pod deintegrate failed (this might be expected if no Podfile.lock exists)', + ); + expect(mockConsoleWarn).toHaveBeenCalledTimes(1); + }); + + it('should handle command not found error', async () => { + // Setup + const appIosPath = '/path/to/app/ios'; + const mockError = new Error('command not found: pod'); + + mockExecSync.mockImplementation(() => { + throw mockError; + }); + + // Execute + await runPodDeintegrate(appIosPath); + + // Assert + expect(mockExecSync).toHaveBeenCalledWith('pod deintegrate', { + cwd: appIosPath, + stdio: 'inherit', + }); + expect(mockConsoleLog).toHaveBeenCalledWith( + 'Running pod deintegrate in: /path/to/app/ios', + ); + expect(mockConsoleWarn).toHaveBeenCalledWith( + '⚠️ Pod deintegrate failed (this might be expected if no Podfile.lock exists)', + ); + }); +}); diff --git a/packages/react-native/scripts/swiftpm/prepare-app-utils.js b/packages/react-native/scripts/swiftpm/prepare-app-utils.js new file mode 100644 index 000000000000..3a15fda0c0d9 --- /dev/null +++ b/packages/react-native/scripts/swiftpm/prepare-app-utils.js @@ -0,0 +1,92 @@ +/** + * 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-local + * @format + */ + +const {execSync} = require('child_process'); +const path = require('path'); + +/** + * Find the directory containing the Xcode project within the app path + * @param {string} appPath - The root app path to search in + * @param {string} xcodeProjectName - The name of the Xcode project file (e.g., 'HelloWorld.xcodeproj') + * @returns {string} - The path to the directory containing the Xcode project + */ +function findXcodeProjectDirectory( + appPath /*: string */, + xcodeProjectName /*: string */, +) /*: string */ { + try { + // Use find command to search for the Xcode project + const findCommand = `find "${appPath}" -name "${xcodeProjectName}" -type d -print`; + const result = execSync(findCommand, {encoding: 'utf8'}).trim(); + + if (!result) { + throw new Error( + `Xcode project '${xcodeProjectName}' not found in '${appPath}' or its subdirectories`, + ); + } + + // Return the directory containing the Xcode project (parent of the .xcodeproj file) + return path.dirname(result); + } catch (error) { + throw new Error( + `Failed to find Xcode project '${xcodeProjectName}': ${error.message}`, + ); + } +} +/** + * Run pod deintegrate from app directory + */ +async function runPodDeintegrate( + appIosPath /*: string */, +) /*: Promise */ { + try { + console.log(`Running pod deintegrate in: ${appIosPath}`); + execSync('pod deintegrate', { + cwd: appIosPath, + stdio: 'inherit', + }); + console.log('✓ Pod deintegrate completed'); + } catch (error) { + console.warn( + '⚠️ Pod deintegrate failed (this might be expected if no Podfile.lock exists)', + ); + } +} + +/** + * Run iOS prebuild with environment variables + */ +async function runIosPrebuild( + reactNativePath /*: string */, +) /*: Promise */ { + console.log('Running iOS prebuild with nightly versions...'); + + const env = { + ...process.env, + RN_DEP_VERSION: 'nightly', + HERMES_VERSION: 'nightly', + }; + + try { + execSync('node scripts/ios-prebuild -s', { + cwd: reactNativePath, + env: env, + stdio: 'inherit', + }); + console.log('✓ iOS prebuild completed'); + } catch (error) { + throw new Error(`iOS prebuild failed: ${error.message}`); + } +} +module.exports = { + findXcodeProjectDirectory, + runPodDeintegrate, + runIosPrebuild, +};