diff --git a/CHANGELOG.md b/CHANGELOG.md index a00e3f1a..3126f8c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- fix(react-native): Add `use_modular_headers!` to the iOS `Podfile` so `pod install` succeeds with the Swift-based RNSentry pod - feat(react-native): Prompt for Logs first in the feature selection flow ([#1308](https://github.com/getsentry/sentry-wizard/pull/1308)) - fix(react-native): Use User Feedback Widget terminology in the setup flow - ref(react-router): Install SDK package with version `@^10` ([#1303](https://github.com/getsentry/sentry-wizard/pull/1303)) diff --git a/e2e-tests/tests/react-native.test.ts b/e2e-tests/tests/react-native.test.ts index 0fdc9e1c..b1adb0d0 100644 --- a/e2e-tests/tests/react-native.test.ts +++ b/e2e-tests/tests/react-native.test.ts @@ -11,8 +11,7 @@ import { afterAll, beforeAll, describe, test, expect } from 'vitest'; //@ts-expect-error - clifty is ESM only import { KEYS, withEnv } from 'clifty'; -// TODO: remove skipIf when macOS CI is fixed -describe.skipIf(process.platform === 'darwin')('ReactNative', () => { +describe('ReactNative', () => { const integration = Integration.reactNative; let wizardExitCode: number; const { projectDir, cleanup } = createIsolatedTestEnv( @@ -147,6 +146,10 @@ defaults.url=https://sentry.io/`, ); }); + test('ios/Podfile is updated with use_modular_headers!', () => { + checkFileContents(`${projectDir}/ios/Podfile`, `use_modular_headers!`); + }); + test('build.gradle is updated correctly', () => { checkFileContents( `${projectDir}/android/app/build.gradle`, diff --git a/src/react-native/cocoapod.ts b/src/react-native/cocoapod.ts index ac5a7686..b4220608 100644 --- a/src/react-native/cocoapod.ts +++ b/src/react-native/cocoapod.ts @@ -10,6 +10,68 @@ export function usesCocoaPod(projPath: string): boolean { return fs.existsSync(path.join(projPath, 'Podfile')); } +/** + * Ensures `use_modular_headers!` is present in the Podfile. + * + * As of `@sentry/react-native` 8.19.0 the `RNSentry` pod ships Swift code (the + * `RNSentryInternal` bridge over `SentrySDK.internal`, part of the migration to + * the prebuilt `Sentry.xcframework`). CocoaPods refuses to integrate a Swift pod + * that depends on a non-modular Objective-C pod (e.g. `React-hermes` on React + * Native versions that don't modularize it by default) unless the Podfile opts + * into module maps. Without it, `pod install` fails with: + * + * [!] The following Swift pods cannot yet be integrated as static libraries: + * The Swift pod `RNSentry` depends upon `React-hermes`, which does not define + * modules. [...] you may set `use_modular_headers!` globally in your Podfile + * + * The SDK's own docs instruct users to add this line manually; we do it for them. + * + * @returns `true` if the Podfile already had (or now has) `use_modular_headers!`, + * `false` if there is no Podfile to patch. + */ +export function addModularHeaders(projPath: string): boolean { + const podfile = path.join(projPath, 'Podfile'); + + if (!fs.existsSync(podfile)) { + return false; + } + + const podContent = fs.readFileSync(podfile, 'utf8'); + + if (/^\s*use_modular_headers!/m.test(podContent)) { + // Already opted into modular headers, nothing to do. + return true; + } + + // Insert above the first `target '...' do` block, matching the placement the + // SDK docs recommend. The `^\s*` anchor avoids matching commented-out targets. + const targetMatch = /^([ \t]*)target\s+['"][^'"]+['"]\s+do/m.exec(podContent); + + let newFileContent: string; + if (targetMatch) { + const insertIndex = targetMatch.index; + newFileContent = + podContent.slice(0, insertIndex) + + 'use_modular_headers!\n\n' + + podContent.slice(insertIndex); + } else { + // No target block found, append to the end as a safe fallback. + const separator = + podContent.endsWith('\n') || podContent.length === 0 ? '' : '\n'; + newFileContent = `${podContent}${separator}use_modular_headers!\n`; + } + + fs.writeFileSync(podfile, newFileContent, 'utf8'); + + clack.log.step( + `Added ${chalk.cyan('use_modular_headers!')} to the iOS ${chalk.cyan( + 'Podfile', + )} (required by the Sentry React Native SDK).`, + ); + + return true; +} + export async function addCocoaPods(projPath: string): Promise { const podfile = path.join(projPath, 'Podfile'); diff --git a/src/react-native/react-native-wizard.ts b/src/react-native/react-native-wizard.ts index 443433bd..e8bb8e9c 100644 --- a/src/react-native/react-native-wizard.ts +++ b/src/react-native/react-native-wizard.ts @@ -6,7 +6,7 @@ import * as fs from 'fs'; import * as Sentry from '@sentry/node'; import { platform } from 'os'; -import { podInstall } from './cocoapod'; +import { addModularHeaders, podInstall, usesCocoaPod } from './cocoapod'; import { traceStep, withTelemetry } from '../telemetry'; import { offerProjectScopedMcpConfig } from '../utils/clack/mcp-config'; import { @@ -349,6 +349,13 @@ async function patchXcodeFiles(config: RNCliSetupConfigContent) { gitignore: false, }); + // The RNSentry pod is a Swift pod and requires `use_modular_headers!` in the + // Podfile to integrate as a static library. Patch it before `pod install` so + // the install succeeds now and any later manual `pod install` keeps working. + if (usesCocoaPod('ios')) { + traceStep('add-modular-headers', () => addModularHeaders('ios')); + } + if (platform() === 'darwin' && (await confirmPodInstall())) { await traceStep('pod-install', () => podInstall('ios')); } diff --git a/test/react-native/cocoapod.test.ts b/test/react-native/cocoapod.test.ts index f7970566..11186109 100644 --- a/test/react-native/cocoapod.test.ts +++ b/test/react-native/cocoapod.test.ts @@ -4,6 +4,7 @@ import * as os from 'os'; import * as path from 'path'; import { addCocoaPods, + addModularHeaders, podInstall, usesCocoaPod, } from '../../src/react-native/cocoapod'; @@ -218,6 +219,108 @@ describe('cocoapod', () => { }); }); + describe('addModularHeaders', () => { + describe('Podfile does not exist', () => { + it('should return false and not create a Podfile', () => { + // -- Arrange -- + const projPath = fs.mkdtempSync(path.join(os.tmpdir(), 'project')); + + // -- Act -- + const result = addModularHeaders(projPath); + + // -- Assert -- + expect(result).toBe(false); + expect(fs.existsSync(path.join(projPath, 'Podfile'))).toBe(false); + }); + }); + + describe('Podfile already includes use_modular_headers!', () => { + const variations = [ + { case: 'simple', content: 'use_modular_headers!\n' }, + { case: 'leading-space', content: ' use_modular_headers!\n' }, + { + case: 'within-target', + content: `target 'App' do\n use_modular_headers!\nend\n`, + }, + ]; + for (const variation of variations) { + it(`should not change the Podfile for ${variation.case}`, () => { + // -- Arrange -- + const projPath = fs.mkdtempSync(path.join(os.tmpdir(), 'project')); + const podfile = path.join(projPath, 'Podfile'); + fs.writeFileSync(podfile, variation.content, 'utf8'); + + // -- Act -- + const result = addModularHeaders(projPath); + + // -- Assert -- + expect(result).toBe(true); + expect(fs.readFileSync(podfile, 'utf8')).toBe(variation.content); + }); + } + }); + + describe('Podfile includes a target block', () => { + it('should insert use_modular_headers! above the target block', () => { + // -- Arrange -- + const projPath = fs.mkdtempSync(path.join(os.tmpdir(), 'project')); + const podfile = path.join(projPath, 'Podfile'); + fs.writeFileSync( + podfile, + `platform :ios, min_ios_version_supported\n\ntarget 'App' do\n use_react_native!\nend\n`, + 'utf8', + ); + + // -- Act -- + const result = addModularHeaders(projPath); + + // -- Assert -- + expect(result).toBe(true); + expect(fs.readFileSync(podfile, 'utf8')).toBe( + `platform :ios, min_ios_version_supported\n\nuse_modular_headers!\n\ntarget 'App' do\n use_react_native!\nend\n`, + ); + }); + + it('should ignore commented-out target blocks', () => { + // -- Arrange -- + const projPath = fs.mkdtempSync(path.join(os.tmpdir(), 'project')); + const podfile = path.join(projPath, 'Podfile'); + fs.writeFileSync( + podfile, + `# target 'Commented' do\ntarget 'App' do\nend\n`, + 'utf8', + ); + + // -- Act -- + const result = addModularHeaders(projPath); + + // -- Assert -- + expect(result).toBe(true); + expect(fs.readFileSync(podfile, 'utf8')).toBe( + `# target 'Commented' do\nuse_modular_headers!\n\ntarget 'App' do\nend\n`, + ); + }); + }); + + describe('Podfile has no target block', () => { + it('should append use_modular_headers! to the end', () => { + // -- Arrange -- + const projPath = fs.mkdtempSync(path.join(os.tmpdir(), 'project')); + const podfile = path.join(projPath, 'Podfile'); + fs.writeFileSync(podfile, `platform :ios, '15.1'`, 'utf8'); + + // -- Act -- + const result = addModularHeaders(projPath); + + // -- Assert -- + expect(result).toBe(true); + expect(fs.readFileSync(podfile, 'utf8')).toBe( + `platform :ios, '15.1'\nuse_modular_headers!\n`, + ); + }); + }); + }); + describe('podInstall', () => { let workDir: string;