From 1c3686e9bde170762ce906f3a6d95bd54b3496e8 Mon Sep 17 00:00:00 2001 From: Riccardo Cipolleschi Date: Thu, 9 Oct 2025 16:48:30 -0700 Subject: [PATCH 1/3] Add script to find the directory that contains the Xcodeproj (#53669) Summary: ## Context When configuring an app to build with SwiftPM from source, there is a sequence of operations we need to run in order to prepare the project correctly. ## Changed Add a function that given the root of the app and the name of the xcodeproject file, can return the path to the Xcode project file ## Changelog: [Internal] - Reviewed By: cortinico Differential Revision: D81778456 --- .../__tests__/prepare-app-utils-test.js | 157 ++++++++++++++++++ .../scripts/swiftpm/prepare-app-utils.js | 46 +++++ 2 files changed, 203 insertions(+) create mode 100644 packages/react-native/scripts/swiftpm/__tests__/prepare-app-utils-test.js create mode 100644 packages/react-native/scripts/swiftpm/prepare-app-utils.js 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..890c6ce9494b --- /dev/null +++ b/packages/react-native/scripts/swiftpm/__tests__/prepare-app-utils-test.js @@ -0,0 +1,157 @@ +/** + * 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} = require('../prepare-app-utils'); + +// Mock child_process module +jest.mock('child_process'); + +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'}, + ); + }); +}); 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..f6ceb619dc7a --- /dev/null +++ b/packages/react-native/scripts/swiftpm/prepare-app-utils.js @@ -0,0 +1,46 @@ +/** + * 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}`, + ); + } +} + +module.exports = { + findXcodeProjectDirectory, +}; From f56499f702b55f345a4c51be7ef4e6bad2375560 Mon Sep 17 00:00:00 2001 From: Riccardo Cipolleschi Date: Thu, 9 Oct 2025 16:48:30 -0700 Subject: [PATCH 2/3] Add function to run pod deintegrate (#53706) Summary: ## Context When configuring an app to build with SwiftPM from source, there is a sequence of operations we need to run in order to prepare the project correctly. ## Changed Add a function that runs `pod deintegrate` to remove remainings of cocoapods ## Changelog: [Internal] - Reviewed By: cortinico Differential Revision: D81778468 --- .../__tests__/prepare-app-utils-test.js | 155 +++++++++++++++++- .../scripts/swiftpm/prepare-app-utils.js | 20 +++ 2 files changed, 174 insertions(+), 1 deletion(-) 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 index 890c6ce9494b..9b97d897b2c1 100644 --- a/packages/react-native/scripts/swiftpm/__tests__/prepare-app-utils-test.js +++ b/packages/react-native/scripts/swiftpm/__tests__/prepare-app-utils-test.js @@ -10,11 +10,29 @@ 'use strict'; -const {findXcodeProjectDirectory} = require('../prepare-app-utils'); +const { + findXcodeProjectDirectory, + 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; @@ -155,3 +173,138 @@ describe('findXcodeProjectDirectory', () => { ); }); }); + +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 index f6ceb619dc7a..4333e884fe9c 100644 --- a/packages/react-native/scripts/swiftpm/prepare-app-utils.js +++ b/packages/react-native/scripts/swiftpm/prepare-app-utils.js @@ -40,7 +40,27 @@ function findXcodeProjectDirectory( ); } } +/** + * 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)', + ); + } +} module.exports = { findXcodeProjectDirectory, + runPodDeintegrate, }; From 4b43dad614156dd99034fd143e2e0210f8093c83 Mon Sep 17 00:00:00 2001 From: Riccardo Cipolleschi Date: Thu, 9 Oct 2025 16:48:30 -0700 Subject: [PATCH 3/3] Add configure iOS prebuilds (hermes and dependencies) (#53671) Summary: ## Context When configuring an app to build with SwiftPM from source, there is a sequence of operations we need to run in order to prepare the project correctly. ## Changed Add a function that prepares the prebuilds for ios so we can leverage them when building from source ## Changelog: [Internal] - Reviewed By: cortinico Differential Revision: D81778467 --- .../__tests__/prepare-app-utils-test.js | 160 ++++++++++++++++++ .../scripts/swiftpm/prepare-app-utils.js | 26 +++ 2 files changed, 186 insertions(+) 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 index 9b97d897b2c1..a2be2a740111 100644 --- a/packages/react-native/scripts/swiftpm/__tests__/prepare-app-utils-test.js +++ b/packages/react-native/scripts/swiftpm/__tests__/prepare-app-utils-test.js @@ -12,6 +12,7 @@ const { findXcodeProjectDirectory, + runIosPrebuild, runPodDeintegrate, } = require('../prepare-app-utils'); @@ -174,6 +175,165 @@ describe('findXcodeProjectDirectory', () => { }); }); +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; diff --git a/packages/react-native/scripts/swiftpm/prepare-app-utils.js b/packages/react-native/scripts/swiftpm/prepare-app-utils.js index 4333e884fe9c..3a15fda0c0d9 100644 --- a/packages/react-native/scripts/swiftpm/prepare-app-utils.js +++ b/packages/react-native/scripts/swiftpm/prepare-app-utils.js @@ -60,7 +60,33 @@ async function runPodDeintegrate( } } +/** + * 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, };