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 a2be2a74011..16be0053302 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 @@ -11,6 +11,7 @@ 'use strict'; const { + configureAppForSwift, findXcodeProjectDirectory, runIosPrebuild, runPodDeintegrate, @@ -19,6 +20,19 @@ const { // Mock child_process module jest.mock('child_process'); +// Mock fs module +jest.mock('fs'); + +// Mock path module for absolute paths +jest.mock('path', () => { + const actualPath = jest.requireActual('path'); + return { + ...actualPath, + join: jest.fn((...args) => args.join('/')), + relative: jest.fn((from, to) => to.replace(from + '/', '')), + }; +}); + // Mock console methods - disable React Native's strict console checking const originalConsole = global.console; @@ -468,3 +482,272 @@ describe('runPodDeintegrate', () => { ); }); }); + +describe('configureAppForSwift', () => { + let mockFs; + let mockPath; + let mockConsoleLog; + + beforeEach(() => { + // Setup mocks + mockFs = require('fs'); + mockPath = require('path'); + mockConsoleLog = console.log; + + // Clear and reset all mocks completely + jest.clearAllMocks(); + jest.resetAllMocks(); + + // Set up fresh mock implementations + mockFs.existsSync = jest.fn(); + mockFs.mkdirSync = jest.fn(); + mockFs.unlinkSync = jest.fn(); + mockFs.linkSync = jest.fn(); + mockFs.symlinkSync = jest.fn(); + mockFs.writeFileSync = jest.fn(); + + // Mock path.join to return realistic paths + mockPath.join.mockImplementation((...args) => args.join('/')); + + // Mock path.relative to return realistic relative paths + mockPath.relative.mockImplementation((from, to) => { + // Simple implementation for tests + return to.replace(from + '/', ''); + }); + }); + + it('should configure app for Swift integration successfully', async () => { + // Setup + const reactNativePath = '/path/to/react-native'; + + // Mock file system calls + mockFs.existsSync + .mockReturnValueOnce(true) // reactIncludesReactPath exists + .mockReturnValueOnce(false) // destUmbrellaPath doesn't exist + .mockReturnValueOnce(true); // sourceUmbrellaPath exists + + mockFs.mkdirSync.mockImplementation(() => {}); + mockFs.symlinkSync.mockImplementation(() => {}); + mockFs.writeFileSync.mockImplementation(() => {}); + + // Execute + await configureAppForSwift(reactNativePath); + + // Assert file system calls + expect(mockFs.existsSync).toHaveBeenCalledWith( + '/path/to/react-native/React/includes/React', + ); + expect(mockFs.existsSync).toHaveBeenCalledWith( + '/path/to/react-native/React/includes/React/React-umbrella.h', + ); + expect(mockFs.existsSync).toHaveBeenCalledWith( + '/path/to/react-native/scripts/ios-prebuild/React-umbrella.h', + ); + + expect(mockFs.symlinkSync).toHaveBeenCalledWith( + '/path/to/react-native/scripts/ios-prebuild/React-umbrella.h', + '/path/to/react-native/React/includes/React/React-umbrella.h', + ); + + expect(mockFs.linkSync).not.toHaveBeenCalled(); + + // Verify module.modulemap content + const expectedModuleMapContent = `framework module React { + umbrella header "/path/to/react-native/React/includes/React/React-umbrella.h" + export * + module * { export * } +} +`; + expect(mockFs.writeFileSync).toHaveBeenCalledWith( + '/path/to/react-native/React/includes/module.modulemap', + expectedModuleMapContent, + 'utf8', + ); + + // Verify console output + expect(mockConsoleLog).toHaveBeenCalledWith( + 'Configuring app for Swift integration...', + ); + expect(mockConsoleLog).toHaveBeenCalledWith( + '✓ Created hardlink: React-umbrella.h -> scripts/ios-prebuild/React-umbrella.h', + ); + expect(mockConsoleLog).toHaveBeenCalledWith( + '✓ Generated module.modulemap file', + ); + expect(mockConsoleLog).toHaveBeenCalledWith( + '✓ App configured for Swift integration', + ); + }); + + it('should remove existing hardlink before creating new one', async () => { + // Setup + const reactNativePath = '/path/to/react-native'; + + mockFs.existsSync + .mockReturnValueOnce(true) // reactIncludesReactPath exists + .mockReturnValueOnce(true) // destUmbrellaPath exists (should be removed) + .mockReturnValueOnce(true); // sourceUmbrellaPath exists + + mockFs.mkdirSync.mockImplementation(() => {}); + mockFs.unlinkSync.mockImplementation(() => {}); + mockFs.linkSync.mockImplementation(() => {}); + mockFs.writeFileSync.mockImplementation(() => {}); + + // Execute + await configureAppForSwift(reactNativePath); + + // Assert + expect(mockFs.unlinkSync).toHaveBeenCalledWith( + '/path/to/react-native/React/includes/React/React-umbrella.h', + ); + }); + + it('should handle different React Native paths', async () => { + // Setup + const reactNativePath = '/Users/developer/react-native'; + + mockFs.existsSync + .mockReturnValueOnce(true) + .mockReturnValueOnce(false) + .mockReturnValueOnce(true); + + mockFs.linkSync.mockImplementation(() => {}); + mockFs.writeFileSync.mockImplementation(() => {}); + + // Execute + await configureAppForSwift(reactNativePath); + + // Assert + expect(mockFs.symlinkSync).toHaveBeenCalledWith( + '/Users/developer/react-native/scripts/ios-prebuild/React-umbrella.h', + '/Users/developer/react-native/React/includes/React/React-umbrella.h', + ); + + expect(mockFs.linkSync).not.toHaveBeenCalled(); + }); + + it('should throw error when source umbrella header does not exist', async () => { + // Setup + const reactNativePath = '/path/to/react-native'; + + mockFs.existsSync + .mockReturnValueOnce(true) // reactIncludesReactPath exists + .mockReturnValueOnce(false) // destUmbrellaPath doesn't exist + .mockReturnValueOnce(false); // sourceUmbrellaPath doesn't exist + + // Execute & Assert + await expect(configureAppForSwift(reactNativePath)).rejects.toThrow( + 'Swift configuration failed: Source umbrella header not found: /path/to/react-native/scripts/ios-prebuild/React-umbrella.h', + ); + + expect(mockConsoleLog).toHaveBeenCalledWith( + 'Configuring app for Swift integration...', + ); + expect(mockConsoleLog).not.toHaveBeenCalledWith( + '✓ App configured for Swift integration', + ); + }); + + it('should throw error when hardlink creation fails', async () => { + // Setup + const reactNativePath = '/path/to/react-native'; + + mockFs.existsSync + .mockReturnValueOnce(true) + .mockReturnValueOnce(false) + .mockReturnValueOnce(true); + + mockFs.symlinkSync.mockImplementation(() => { + throw new Error('Permission denied'); + }); + + // Execute & Assert + await expect(configureAppForSwift(reactNativePath)).rejects.toThrow( + 'Swift configuration failed: Permission denied', + ); + }); + + it('should throw error when module.modulemap write fails', async () => { + // Setup + const reactNativePath = '/path/to/react-native'; + + mockFs.existsSync + .mockReturnValueOnce(true) + .mockReturnValueOnce(false) + .mockReturnValueOnce(true); + + mockFs.symlinkSync.mockImplementation(() => {}); + mockFs.writeFileSync.mockImplementation(() => { + throw new Error('Disk full'); + }); + + // Execute & Assert + await expect(configureAppForSwift(reactNativePath)).rejects.toThrow( + 'Swift configuration failed: Disk full', + ); + }); + + it('should throw error when directory creation fails', async () => { + // Setup + const reactNativePath = '/path/to/react-native'; + + mockFs.existsSync.mockReturnValueOnce(false); // reactIncludesReactPath doesn't exist + + mockFs.mkdirSync.mockImplementation(() => { + throw new Error('Permission denied'); + }); + + // Execute & Assert + await expect(configureAppForSwift(reactNativePath)).rejects.toThrow( + 'Swift configuration failed: Permission denied', + ); + }); + + it('should handle file unlink errors gracefully', async () => { + // Setup + const reactNativePath = '/path/to/react-native'; + + mockFs.existsSync + .mockReturnValueOnce(true) + .mockReturnValueOnce(true) // destUmbrellaPath exists + .mockReturnValueOnce(true); + + mockFs.unlinkSync.mockImplementation(() => { + throw new Error('File in use'); + }); + + // Execute & Assert + await expect(configureAppForSwift(reactNativePath)).rejects.toThrow( + 'Swift configuration failed: File in use', + ); + }); + + it('should generate correct module.modulemap content with absolute path', async () => { + // Setup + const reactNativePath = '/custom/path/to/react-native'; + + mockFs.existsSync + .mockReturnValueOnce(true) + .mockReturnValueOnce(false) + .mockReturnValueOnce(true); + + mockFs.linkSync.mockImplementation(() => {}); + mockFs.writeFileSync.mockImplementation(() => {}); + + // Execute + await configureAppForSwift(reactNativePath); + + // Assert module.modulemap content with correct absolute path + const expectedModuleMapContent = `framework module React { + umbrella header "/custom/path/to/react-native/React/includes/React/React-umbrella.h" + export * + module * { export * } +} +`; + expect(mockFs.writeFileSync).toHaveBeenCalledWith( + '/custom/path/to/react-native/React/includes/module.modulemap', + expectedModuleMapContent, + 'utf8', + ); + }); +}); diff --git a/packages/react-native/scripts/swiftpm/prepare-app-utils.js b/packages/react-native/scripts/swiftpm/prepare-app-utils.js index 3a15fda0c0d..30b08df95d9 100644 --- a/packages/react-native/scripts/swiftpm/prepare-app-utils.js +++ b/packages/react-native/scripts/swiftpm/prepare-app-utils.js @@ -9,6 +9,7 @@ */ const {execSync} = require('child_process'); +const fs = require('fs'); const path = require('path'); /** @@ -85,8 +86,88 @@ async function runIosPrebuild( throw new Error(`iOS prebuild failed: ${error.message}`); } } + +/** + * Configure app for Swift integration + */ +async function configureAppForSwift( + reactNativePath /*: string */, +) /*: Promise */ { + try { + console.log('Configuring app for Swift integration...'); + + // 1. Create hardlink from React-umbrella.h to React-umbrella.h + const sourceUmbrellaPath = path.join( + reactNativePath, + 'scripts', + 'ios-prebuild', + 'React-umbrella.h', + ); + const reactIncludesReactPath = path.join( + reactNativePath, + 'React', + 'includes', + 'React', + ); + const destUmbrellaPath = path.join( + reactIncludesReactPath, + 'React-umbrella.h', + ); + + // Ensure the React/includes/React directory exists + if (!fs.existsSync(reactIncludesReactPath)) { + fs.mkdirSync(reactIncludesReactPath, {recursive: true}); + } + + // Remove existing hardlink if it exists + if (fs.existsSync(destUmbrellaPath)) { + fs.unlinkSync(destUmbrellaPath); + } + + // Create hardlink for umbrella header + if (fs.existsSync(sourceUmbrellaPath)) { + fs.symlinkSync(sourceUmbrellaPath, destUmbrellaPath); + console.log( + `✓ Created hardlink: React-umbrella.h -> ${path.relative( + reactNativePath, + sourceUmbrellaPath, + )}`, + ); + } else { + throw new Error( + `Source umbrella header not found: ${sourceUmbrellaPath}`, + ); + } + + // 2. Generate module.modulemap file + const reactIncludesPath = path.join(reactNativePath, 'React', 'includes'); + const moduleMapPath = path.join(reactIncludesPath, 'module.modulemap'); + const absoluteUmbrellaPath = path.join( + reactNativePath, + 'React', + 'includes', + 'React', + 'React-umbrella.h', + ); + const moduleMapContent = `framework module React { + umbrella header "${absoluteUmbrellaPath}" + export * + module * { export * } +} +`; + + fs.writeFileSync(moduleMapPath, moduleMapContent, 'utf8'); + console.log('✓ Generated module.modulemap file'); + + console.log('✓ App configured for Swift integration'); + } catch (error) { + throw new Error(`Swift configuration failed: ${error.message}`); + } +} + module.exports = { findXcodeProjectDirectory, runPodDeintegrate, runIosPrebuild, + configureAppForSwift, };