Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fuzzy-cats-build.md
Original file line number Diff line number Diff line change
@@ -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.
169 changes: 169 additions & 0 deletions packages/plugin-brownfield-ios/src/__tests__/packageIosAction.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
135 changes: 108 additions & 27 deletions packages/plugin-brownfield-ios/src/lib/pluginBrownfieldIos.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 (
Expand Down Expand Up @@ -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';
Expand All @@ -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
Expand All @@ -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',
Expand Down
Loading