From 0355f24a2c7db68d6ba0688eaa1e0e6f781d81dd Mon Sep 17 00:00:00 2001 From: ahmdshrif Date: Thu, 27 Aug 2026 09:14:11 +0300 Subject: [PATCH] fix(doctor): await .xcode.env copies so failures are reported runAutomaticFix passed an async callback to Array#forEach, so it returned before any copy had run. The loader reported success immediately, and a failing copy rejected outside the surrounding try/catch, surfacing as an unhandled promise rejection instead of loader.fail. Await the copies with Promise.all so the fix reports the real outcome. --- .../healthchecks/__tests__/xcodeEnv.test.ts | 103 ++++++++++++++++++ .../src/tools/healthchecks/xcodeEnv.ts | 22 ++-- 2 files changed, 115 insertions(+), 10 deletions(-) create mode 100644 packages/cli-doctor/src/tools/healthchecks/__tests__/xcodeEnv.test.ts diff --git a/packages/cli-doctor/src/tools/healthchecks/__tests__/xcodeEnv.test.ts b/packages/cli-doctor/src/tools/healthchecks/__tests__/xcodeEnv.test.ts new file mode 100644 index 000000000..4026686c9 --- /dev/null +++ b/packages/cli-doctor/src/tools/healthchecks/__tests__/xcodeEnv.test.ts @@ -0,0 +1,103 @@ +import xcodeEnv from '../xcodeEnv'; +import {NoopLoader} from '@react-native-community/cli-tools'; +import {findPodfilePaths} from '@react-native-community/cli-platform-apple'; +import fs from 'fs'; + +jest.mock('@react-native-community/cli-platform-apple', () => ({ + findPodfilePaths: jest.fn(), +})); + +jest.mock('@react-native-community/cli-tools', () => { + const actual = jest.requireActual('@react-native-community/cli-tools'); + return { + ...actual, + findProjectRoot: jest.fn(() => '/project'), + resolveNodeModuleDir: jest.fn( + () => '/project/node_modules/react-native/template/ios', + ), + }; +}); + +jest.mock('fs', () => ({ + existsSync: jest.fn(() => false), + copyFile: jest.fn(), +})); + +const config: any = { + root: '/project', + project: {ios: {sourceDir: '/project/ios'}}, +}; + +describe('xcodeEnv healthcheck runAutomaticFix', () => { + beforeEach(() => { + jest.clearAllMocks(); + (fs.existsSync as jest.Mock).mockReturnValue(false); + }); + + it('waits for every .xcode.env copy to finish before reporting success', async () => { + (findPodfilePaths as jest.Mock).mockReturnValue([ + 'Podfile', + 'nested/Podfile', + ]); + + const completed: string[] = []; + (fs.copyFile as unknown as jest.Mock).mockImplementation( + (_src: string, dest: string, callback: (err: Error | null) => void) => { + setTimeout(() => { + completed.push(dest); + callback(null); + }, 10); + }, + ); + + const loader = new NoopLoader(); + const succeed = jest.spyOn(loader, 'succeed'); + + await xcodeEnv.runAutomaticFix({loader, config} as any); + + // Both copies must have actually finished by the time the fix resolves. + expect(completed).toHaveLength(2); + expect(succeed).toHaveBeenCalled(); + }); + + it('fails the loader when a copy rejects instead of reporting success', async () => { + (findPodfilePaths as jest.Mock).mockReturnValue(['Podfile']); + + (fs.copyFile as unknown as jest.Mock).mockImplementation( + (_src: string, _dest: string, callback: (err: Error | null) => void) => { + setTimeout(() => callback(new Error('EACCES: permission denied')), 10); + }, + ); + + const loader = new NoopLoader(); + const succeed = jest.spyOn(loader, 'succeed'); + const fail = jest.spyOn(loader, 'fail'); + + await xcodeEnv.runAutomaticFix({loader, config} as any); + + expect(fail).toHaveBeenCalled(); + expect(succeed).not.toHaveBeenCalled(); + }); + + it('does not copy over an existing .xcode.env file', async () => { + (findPodfilePaths as jest.Mock).mockReturnValue([ + 'Podfile', + 'nested/Podfile', + ]); + (fs.existsSync as jest.Mock).mockImplementation((p: string) => + p.startsWith('/project/ios/nested'), + ); + (fs.copyFile as unknown as jest.Mock).mockImplementation( + (_src: string, _dest: string, callback: (err: Error | null) => void) => + callback(null), + ); + + const loader = new NoopLoader(); + await xcodeEnv.runAutomaticFix({loader, config} as any); + + expect(fs.copyFile).toHaveBeenCalledTimes(1); + expect((fs.copyFile as unknown as jest.Mock).mock.calls[0][1]).toBe( + '/project/ios/.xcode.env', + ); + }); +}); diff --git a/packages/cli-doctor/src/tools/healthchecks/xcodeEnv.ts b/packages/cli-doctor/src/tools/healthchecks/xcodeEnv.ts index 2ac85fbca..370ab1788 100644 --- a/packages/cli-doctor/src/tools/healthchecks/xcodeEnv.ts +++ b/packages/cli-doctor/src/tools/healthchecks/xcodeEnv.ts @@ -61,16 +61,18 @@ export default { const iosFolderPath = config?.project.ios?.sourceDir ?? ''; - findPodfilePaths(iosFolderPath) - .map((podfilePath) => - removeLastPathComponent(path.join(iosFolderPath, podfilePath)), - ) - // avoid overriding existing .xcode.env - .filter(pathDoesNotHaveXcodeEnvFile) - .forEach(async (pathString: string) => { - const destFilePath = path.join(pathString, xcodeEnvFile); - await copyFileAsync(src, destFilePath); - }); + await Promise.all( + findPodfilePaths(iosFolderPath) + .map((podfilePath) => + removeLastPathComponent(path.join(iosFolderPath, podfilePath)), + ) + // avoid overriding existing .xcode.env + .filter(pathDoesNotHaveXcodeEnvFile) + .map((pathString: string) => { + const destFilePath = path.join(pathString, xcodeEnvFile); + return copyFileAsync(src, destFilePath); + }), + ); loader.succeed('.xcode.env file have been created!'); } catch (e) { loader.fail(e as any);