diff --git a/.changeset/fuzzy-cats-build.md b/.changeset/fuzzy-cats-build.md new file mode 100644 index 000000000..341b210ba --- /dev/null +++ b/.changeset/fuzzy-cats-build.md @@ -0,0 +1,5 @@ +--- +'@rock-js/plugin-brownfield-ios': patch +--- + +Respect the requested iOS destination when packaging XCFramework slices and report a clear error when the expected framework product is missing. diff --git a/packages/plugin-brownfield-ios/src/__tests__/packageIosAction.test.ts b/packages/plugin-brownfield-ios/src/__tests__/packageIosAction.test.ts new file mode 100644 index 000000000..bd70358ef --- /dev/null +++ b/packages/plugin-brownfield-ios/src/__tests__/packageIosAction.test.ts @@ -0,0 +1,169 @@ +import path from 'node:path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + buildApp: vi.fn(), + copyHermesXcframework: vi.fn(), + copyReactXcframeworks: vi.fn(), + existsSync: vi.fn(), + mergeFrameworks: vi.fn(), +})); + +vi.mock('node:fs', () => ({ + default: { existsSync: mocks.existsSync }, +})); + +vi.mock('@rock-js/platform-apple-helpers', () => ({ + buildApp: mocks.buildApp, + genericDestinations: { + ios: { + device: 'generic/platform=iOS', + simulator: 'generic/platform=iOS Simulator', + }, + }, + getBuildOptions: vi.fn(() => []), + getBuildPaths: vi.fn(() => ({ + derivedDataDir: '/derived-data', + packageDir: '/package', + })), + getValidProjectConfig: vi.fn(() => ({ sourceDir: '/project/ios' })), + mergeFrameworks: mocks.mergeFrameworks, +})); + +vi.mock('@rock-js/tools', () => ({ + colorLink: vi.fn((value: string) => value), + intro: vi.fn(), + logger: { log: vi.fn(), setVerbose: vi.fn() }, + outro: vi.fn(), + relativeToCwd: vi.fn((value: string) => value), + RockError: class RockError extends Error {}, +})); + +vi.mock('../lib/copyHermesXcframework.js', () => ({ + copyHermesXcframework: mocks.copyHermesXcframework, +})); + +vi.mock('../lib/copyReactXcframeworks.js', () => ({ + copyReactXcframeworks: mocks.copyReactXcframeworks, +})); + +const { packageIosAction } = await import('../lib/pluginBrownfieldIos.js'); + +const context = { + projectRoot: '/project', + reactNativePath: '/project/node_modules/react-native', + reactNativeVersion: '0.86.0', + usePrebuiltRNCore: false, +}; + +describe('packageIosAction destinations', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.buildApp.mockResolvedValue({ + appPath: '/derived-data/Build/Products/Debug/Brownie.app', + scheme: 'Brownie', + }); + mocks.mergeFrameworks.mockResolvedValue(undefined); + }); + + it('packages only the simulator slice when a simulator is requested', async () => { + mocks.existsSync.mockImplementation((filePath: string) => + filePath.includes('Debug-iphonesimulator'), + ); + + await packageIosAction( + { + destination: ['simulator'], + installPods: true, + newArch: true, + }, + context, + ); + + expect(mocks.mergeFrameworks).toHaveBeenNthCalledWith(1, { + sourceDir: '/project/ios', + frameworkPaths: [ + path.join( + '/derived-data/Build/Products/Debug-iphonesimulator', + 'Brownie.framework', + ), + ], + outputPath: '/package/Brownie.xcframework', + }); + expect(mocks.mergeFrameworks).toHaveBeenNthCalledWith(2, { + sourceDir: '/project/ios', + frameworkPaths: [ + path.join( + '/derived-data/Build/Products/Debug-iphonesimulator/ReactBrownfield', + 'ReactBrownfield.framework', + ), + ], + outputPath: '/package/ReactBrownfield.xcframework', + }); + }); + + it('uses the available slice for destinations that do not identify an SDK', async () => { + mocks.existsSync.mockImplementation((filePath: string) => + filePath.includes('Debug-iphoneos'), + ); + + await packageIosAction( + { + destination: ['id=00008110-0012345678901234'], + installPods: true, + newArch: true, + }, + context, + ); + + expect(mocks.mergeFrameworks).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + frameworkPaths: [ + '/derived-data/Build/Products/Debug-iphoneos/Brownie.framework', + ], + }), + ); + }); + + it('reports the searched paths when no framework product was built', async () => { + mocks.existsSync.mockReturnValue(false); + + await expect( + packageIosAction( + { + destination: ['device'], + installPods: true, + newArch: true, + }, + context, + ), + ).rejects.toThrow( + 'Could not find a build product for Brownie in the Debug configuration', + ); + + expect(mocks.mergeFrameworks).not.toHaveBeenCalled(); + }); + + it('reports when the build does not resolve a framework scheme', async () => { + mocks.buildApp.mockResolvedValue({ + appPath: '/derived-data/Build/Products/Debug/Brownie.app', + scheme: undefined, + }); + + await expect( + packageIosAction( + { + destination: ['device'], + installPods: true, + newArch: true, + }, + context, + ), + ).rejects.toThrow( + 'Could not determine the framework name. Pass --scheme with your brownfield framework scheme.', + ); + + expect(mocks.mergeFrameworks).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/plugin-brownfield-ios/src/lib/pluginBrownfieldIos.ts b/packages/plugin-brownfield-ios/src/lib/pluginBrownfieldIos.ts index 1dbe3343e..c79052391 100644 --- a/packages/plugin-brownfield-ios/src/lib/pluginBrownfieldIos.ts +++ b/packages/plugin-brownfield-ios/src/lib/pluginBrownfieldIos.ts @@ -1,3 +1,4 @@ +import fs from 'node:fs'; import path from 'node:path'; import type { IOSProjectConfig } from '@react-native-community/cli-types'; import type { PluginApi, PluginOutput } from '@rock-js/config'; @@ -10,10 +11,95 @@ import { getValidProjectConfig, mergeFrameworks, } from '@rock-js/platform-apple-helpers'; -import { colorLink, intro, logger, outro, relativeToCwd } from '@rock-js/tools'; +import { + colorLink, + intro, + logger, + outro, + relativeToCwd, + RockError, +} from '@rock-js/tools'; import { copyHermesXcframework } from './copyHermesXcframework.js'; import { copyReactXcframeworks } from './copyReactXcframeworks.js'; +type AppleSdk = 'iphoneos' | 'iphonesimulator'; + +const ALL_SDKS: AppleSdk[] = ['iphoneos', 'iphonesimulator']; + +// `--destination` narrows which slices Xcode emits, so the merges below must only +// look at the slices that were actually built. +function resolveSdksForDestination(destination: string): AppleSdk[] { + const normalized = destination.trim().toLowerCase(); + + if (normalized === 'device') { + return ['iphoneos']; + } + + if (normalized === 'simulator' || normalized.includes('simulator')) { + return ['iphonesimulator']; + } + + if (normalized.includes('platform=ios')) { + return ['iphoneos']; + } + + return ALL_SDKS; +} + +function resolveDestinationSdks(destinations: string[]): AppleSdk[] { + const sdks = new Set(destinations.flatMap(resolveSdksForDestination)); + + return ALL_SDKS.filter((sdk) => sdks.has(sdk)); +} + +// `mergeFrameworks` also accepts a directory holding only the static library, which +// it wraps into a temporary framework. +function hasBuildProduct(directoryPath: string, frameworkName: string) { + return ( + fs.existsSync(path.join(directoryPath, `${frameworkName}.framework`)) || + fs.existsSync(path.join(directoryPath, `lib${frameworkName}.a`)) + ); +} + +function collectFrameworkPaths({ + productsPath, + configuration, + sdks, + frameworkName, + productSubDir, +}: { + productsPath: string; + configuration: string; + sdks: AppleSdk[]; + frameworkName: string; + productSubDir?: string; +}): string[] { + const searchedDirectories = sdks.map((sdk) => + path.join( + productsPath, + `${configuration}-${sdk}`, + ...(productSubDir ? [productSubDir] : []), + ), + ); + + const frameworkPaths = searchedDirectories + .filter((directoryPath) => hasBuildProduct(directoryPath, frameworkName)) + .map((directoryPath) => + path.join(directoryPath, `${frameworkName}.framework`), + ); + + if (frameworkPaths.length === 0) { + throw new RockError( + `Could not find a build product for ${frameworkName} in the ${configuration} configuration. ` + + `Looked for ${frameworkName}.framework or lib${frameworkName}.a in:\n` + + searchedDirectories.map((dir) => ` - ${dir}`).join('\n') + + `\nIf the build produced an .app instead of a framework, the wrong scheme was built; pass --scheme with your brownfield framework scheme.`, + ); + } + + return frameworkPaths; +} + const buildOptions = getBuildOptions({ platformName: 'ios' }); export const packageIosAction = async ( @@ -44,6 +130,7 @@ export const packageIosAction = async ( genericDestinations.ios.device, genericDestinations.ios.simulator, ]; + const sdks = resolveDestinationSdks(destination); const buildFolder = args.buildFolder ?? getBuildPaths('ios').derivedDataDir; const configuration = args.configuration ?? 'Debug'; @@ -59,6 +146,13 @@ export const packageIosAction = async ( pluginConfig, skipCache, }); + + if (!scheme) { + throw new RockError( + 'Could not determine the framework name. Pass --scheme with your brownfield framework scheme.', + ); + } + logger.log(`Build available at: ${colorLink(relativeToCwd(appPath))}`); // 2) Merge the .framework outputs of the framework target @@ -73,38 +167,25 @@ export const packageIosAction = async ( await mergeFrameworks({ sourceDir, - frameworkPaths: [ - path.join( - productsPath, - `${configuration}-iphoneos`, - `${scheme}.framework`, - ), - path.join( - productsPath, - `${configuration}-iphonesimulator`, - `${scheme}.framework`, - ), - ], + frameworkPaths: collectFrameworkPaths({ + productsPath, + configuration, + sdks, + frameworkName: scheme, + }), outputPath: path.join(frameworkTargetOutputDir, `${scheme}.xcframework`), }); // 3) Merge React Native Brownfield paths await mergeFrameworks({ sourceDir, - frameworkPaths: [ - path.join( - productsPath, - `${configuration}-iphoneos`, - 'ReactBrownfield', - 'ReactBrownfield.framework', - ), - path.join( - productsPath, - `${configuration}-iphonesimulator`, - 'ReactBrownfield', - 'ReactBrownfield.framework', - ), - ], + frameworkPaths: collectFrameworkPaths({ + productsPath, + configuration, + sdks, + frameworkName: 'ReactBrownfield', + productSubDir: 'ReactBrownfield', + }), outputPath: path.join( frameworkTargetOutputDir, 'ReactBrownfield.xcframework',