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 a2be2a740111..6df8c34434cc 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,14 +11,29 @@ 'use strict'; const { + configureAppForSwift, findXcodeProjectDirectory, runIosPrebuild, runPodDeintegrate, + setBuildFromSource, } = require('../prepare-app-utils'); // 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; @@ -175,6 +190,237 @@ describe('findXcodeProjectDirectory', () => { }); }); +describe('setBuildFromSource', () => { + let mockFs; + let mockPath; + let mockConsoleLog; + let mockConsoleWarn; + + beforeEach(() => { + // Setup mocks + mockFs = require('fs'); + mockPath = require('path'); + mockConsoleLog = console.log; + mockConsoleWarn = console.warn; + + // Clear and reset all mocks completely + jest.clearAllMocks(); + jest.resetAllMocks(); + + // Set up fresh mock implementations + mockFs.existsSync = jest.fn(); + mockFs.readFileSync = jest.fn(); + mockFs.writeFileSync = jest.fn(); + + // Mock path.join to return realistic paths + mockPath.join.mockImplementation((...args) => args.join('/')); + }); + + it('should update BUILD_FROM_SOURCE from false to true successfully', async () => { + // Setup + const reactNativePath = '/path/to/react-native'; + const mockPackageSwiftContent = ` +// Package.swift +import PackageDescription + +let BUILD_FROM_SOURCE = false + +let package = Package( + name: "ReactNative", + platforms: [.iOS(.v13)], + // rest of package +) +`; + + const expectedUpdatedContent = ` +// Package.swift +import PackageDescription + +let BUILD_FROM_SOURCE = true + +let package = Package( + name: "ReactNative", + platforms: [.iOS(.v13)], + // rest of package +) +`; + + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(mockPackageSwiftContent); + mockFs.writeFileSync.mockImplementation(() => {}); + + // Execute + await setBuildFromSource(reactNativePath); + + // Assert + expect(mockFs.existsSync).toHaveBeenCalledWith( + '/path/to/react-native/Package.swift', + ); + expect(mockFs.readFileSync).toHaveBeenCalledWith( + '/path/to/react-native/Package.swift', + 'utf8', + ); + expect(mockFs.writeFileSync).toHaveBeenCalledWith( + '/path/to/react-native/Package.swift', + expectedUpdatedContent, + 'utf8', + ); + expect(mockConsoleLog).toHaveBeenCalledWith( + 'Updating BUILD_FROM_SOURCE in: /path/to/react-native/Package.swift', + ); + expect(mockConsoleLog).toHaveBeenCalledWith( + '✓ BUILD_FROM_SOURCE set to true in Package.swift', + ); + expect(mockConsoleWarn).not.toHaveBeenCalled(); + }); + + it('should handle when BUILD_FROM_SOURCE is already true', async () => { + // Setup + const reactNativePath = '/path/to/react-native'; + const mockPackageSwiftContent = ` +// Package.swift +import PackageDescription + +let BUILD_FROM_SOURCE = true + +let package = Package( + name: "ReactNative", + platforms: [.iOS(.v13)], + // rest of package +) +`; + + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(mockPackageSwiftContent); + mockFs.writeFileSync.mockImplementation(() => {}); + + // Execute + await setBuildFromSource(reactNativePath); + + // Assert + expect(mockFs.existsSync).toHaveBeenCalledWith( + '/path/to/react-native/Package.swift', + ); + expect(mockFs.readFileSync).toHaveBeenCalledWith( + '/path/to/react-native/Package.swift', + 'utf8', + ); + expect(mockFs.writeFileSync).not.toHaveBeenCalled(); // Should not write when already true + expect(mockConsoleLog).toHaveBeenCalledWith( + 'Updating BUILD_FROM_SOURCE in: /path/to/react-native/Package.swift', + ); + expect(mockConsoleLog).toHaveBeenCalledWith( + '✓ BUILD_FROM_SOURCE is already set to true in Package.swift', + ); + expect(mockConsoleWarn).not.toHaveBeenCalled(); + }); + + it('should warn when BUILD_FROM_SOURCE declaration is not found', async () => { + // Setup + const reactNativePath = '/path/to/react-native'; + const mockPackageSwiftContent = ` +// Package.swift +import PackageDescription + +let package = Package( + name: "ReactNative", + platforms: [.iOS(.v13)], + // rest of package without BUILD_FROM_SOURCE +) +`; + + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(mockPackageSwiftContent); + mockFs.writeFileSync.mockImplementation(() => {}); + + // Execute + await setBuildFromSource(reactNativePath); + + // Assert + expect(mockFs.existsSync).toHaveBeenCalledWith( + '/path/to/react-native/Package.swift', + ); + expect(mockFs.readFileSync).toHaveBeenCalledWith( + '/path/to/react-native/Package.swift', + 'utf8', + ); + expect(mockFs.writeFileSync).not.toHaveBeenCalled(); // Should not write when declaration not found + expect(mockConsoleLog).toHaveBeenCalledWith( + 'Updating BUILD_FROM_SOURCE in: /path/to/react-native/Package.swift', + ); + expect(mockConsoleWarn).toHaveBeenCalledWith( + '⚠️ BUILD_FROM_SOURCE declaration not found in Package.swift', + ); + }); + + it('should throw error when Package.swift does not exist', async () => { + // Setup + const reactNativePath = '/path/to/react-native'; + + mockFs.existsSync.mockReturnValue(false); + + // Execute & Assert + await expect(setBuildFromSource(reactNativePath)).rejects.toThrow( + 'Package.swift not found at: /path/to/react-native/Package.swift', + ); + + expect(mockFs.existsSync).toHaveBeenCalledWith( + '/path/to/react-native/Package.swift', + ); + expect(mockFs.readFileSync).not.toHaveBeenCalled(); + expect(mockFs.writeFileSync).not.toHaveBeenCalled(); + expect(mockConsoleLog).not.toHaveBeenCalled(); + }); + + it('should handle multiple BUILD_FROM_SOURCE occurrences', async () => { + // Setup + const reactNativePath = '/path/to/react-native'; + const mockPackageSwiftContent = ` +// Package.swift +import PackageDescription + +let BUILD_FROM_SOURCE = false +// Some comment about BUILD_FROM_SOURCE = false +let anotherVar = "BUILD_FROM_SOURCE = false in string" + +let package = Package( + name: "ReactNative", + platforms: [.iOS(.v13)], + // Another BUILD_FROM_SOURCE = false comment +) +`; + + const expectedUpdatedContent = ` +// Package.swift +import PackageDescription + +let BUILD_FROM_SOURCE = true +// Some comment about BUILD_FROM_SOURCE = false +let anotherVar = "BUILD_FROM_SOURCE = false in string" + +let package = Package( + name: "ReactNative", + platforms: [.iOS(.v13)], + // Another BUILD_FROM_SOURCE = false comment +) +`; + + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(mockPackageSwiftContent); + mockFs.writeFileSync.mockImplementation(() => {}); + + // Execute + await setBuildFromSource(reactNativePath); + + // Assert - should replace only the declaration, not comments or strings + expect(mockFs.writeFileSync).toHaveBeenCalledWith( + '/path/to/react-native/Package.swift', + expectedUpdatedContent, + 'utf8', + ); + }); +}); + describe('runIosPrebuild', () => { let mockExecSync; let mockConsoleLog; @@ -468,3 +714,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 3a15fda0c0d9..902094a145bd 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,130 @@ 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}`); + } +} + +/** + * Set BUILD_FROM_SOURCE to true in Package.swift + */ +async function setBuildFromSource( + reactNativePath /*: string */, +) /*: Promise */ { + const packageSwiftPath = path.join(reactNativePath, 'Package.swift'); + + if (!fs.existsSync(packageSwiftPath)) { + throw new Error(`Package.swift not found at: ${packageSwiftPath}`); + } + + try { + console.log(`Updating BUILD_FROM_SOURCE in: ${packageSwiftPath}`); + + const content = fs.readFileSync(packageSwiftPath, 'utf8'); + + // Check if BUILD_FROM_SOURCE = false exists and replace it with true + if (content.includes('let BUILD_FROM_SOURCE = false')) { + const updatedContent = content.replace( + /let BUILD_FROM_SOURCE = false/g, + 'let BUILD_FROM_SOURCE = true', + ); + fs.writeFileSync(packageSwiftPath, updatedContent, 'utf8'); + console.log('✓ BUILD_FROM_SOURCE set to true in Package.swift'); + } else if (content.includes('let BUILD_FROM_SOURCE = true')) { + console.log( + '✓ BUILD_FROM_SOURCE is already set to true in Package.swift', + ); + } else { + console.warn( + '⚠️ BUILD_FROM_SOURCE declaration not found in Package.swift', + ); + } + } catch (error) { + throw new Error( + `Failed to update BUILD_FROM_SOURCE in Package.swift: ${error.message}`, + ); + } +} + module.exports = { findXcodeProjectDirectory, runPodDeintegrate, runIosPrebuild, + configureAppForSwift, + setBuildFromSource, };