From 08ca7d9c8391ae9e7cc90f393c33ae83b2007c94 Mon Sep 17 00:00:00 2001 From: Patrick Kabwe Date: Thu, 19 Mar 2026 13:44:41 +0200 Subject: [PATCH 01/22] feat: add optional react-native-harness scaffolding --- README.md | 1 + docs/docs/commands.md | 1 + docs/docs/usage/create-a-nitro-module.md | 29 +++ src/cli/create.ts | 27 +++ src/cli/index.ts | 4 + src/code-snippets/code.js.ts | 275 ++++++++++++++++++++++ src/constants.ts | 22 +- src/file-generators/cpp-file-generator.ts | 1 - src/generate-nitro-package.ts | 167 +++++++++++++ src/types.ts | 4 + src/utils.ts | 6 - test-local.sh | 10 +- 12 files changed, 535 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 4a248f79..3eb26e16 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ A CLI tool that simplifies creating React Native modules powered by Nitro Module - ๐Ÿ“š TypeScript support out of the box - ๐Ÿ”ง Zero configuration required - โš™๏ธ Automated ios/android build with GitHub Actions +- ๐Ÿงช Optional React Native Harness setup for native Android and iOS tests - ๐Ÿ“ฆ Semantic Release ## ๐Ÿ“– Documentation diff --git a/docs/docs/commands.md b/docs/docs/commands.md index 738a9573..a33ed563 100644 --- a/docs/docs/commands.md +++ b/docs/docs/commands.md @@ -25,6 +25,7 @@ Options: --platforms comma-separated platforms to target --langs comma-separated languages to generate -d, --module-dir directory to create the module in + --include-harness include React Native Harness setup in the example app -e, --skip-example skip example app generation -i, --skip-install skip installing dependencies --ci run in CI mode diff --git a/docs/docs/usage/create-a-nitro-module.md b/docs/docs/usage/create-a-nitro-module.md index 3b266845..6b64e30b 100644 --- a/docs/docs/usage/create-a-nitro-module.md +++ b/docs/docs/usage/create-a-nitro-module.md @@ -36,6 +36,35 @@ To create a Nitro Module along with an example app, use the following command. T +## With React Native Harness + +If you want the generated example app to include React Native Harness for native Android and iOS tests, pass the `--include-harness` flag. + + + + ```bash + bun create nitro-module@latest my-awesome-module --include-harness + ``` + + + ```bash + npx create-nitro-module@latest my-awesome-module --include-harness + ``` + + + ```bash + yarn create nitro-module@latest my-awesome-module --include-harness + ``` + + + ```bash + pnpm create nitro-module@latest my-awesome-module --include-harness + ``` + + + +The generated `example` app will include Harness config, sample native test files, package scripts, and a GitHub Actions workflow for the selected platforms. + ## Without example app If you prefer to create a Nitro Module without an example app, use the following command. This will generate only the module, without any additional example app. diff --git a/src/cli/create.ts b/src/cli/create.ts index 7a45302d..44f22aa6 100644 --- a/src/cli/create.ts +++ b/src/cli/create.ts @@ -186,6 +186,12 @@ export const createModule = async ( } } + if (options.skipExample && options.includeHarness) { + throw new Error( + 'React Native Harness requires the generated example app. Remove --skip-example or omit --include-harness.' + ) + } + if ( options.packageType && ![Nitro.Module, Nitro.View].includes(options.packageType) @@ -213,6 +219,7 @@ export const createModule = async ( spinner, packageType, finalPackageName: 'react-native-' + packageName.toLowerCase(), + includeHarness: answers.includeHarness, skipInstall: options.skipInstall, skipExample: options.skipExample, }) @@ -254,8 +261,10 @@ export const createModule = async ( console.log( generateInstructions({ + includeHarness: answers.includeHarness, moduleName: `react-native-${packageName.toLowerCase()}`, pm: answers.pm, + platforms: answers.platforms, skipExample: options.skipExample, skipInstall: options.skipInstall, }) @@ -350,6 +359,7 @@ const getUserAnswers = async ( description: `${kleur.yellow(`react-native-${name}`)} is a react native package built with Nitro`, platforms, packageType, + includeHarness: options.includeHarness === true, platformLangs: parsePlatformLangsOption( options.langs, platforms, @@ -479,6 +489,22 @@ const getUserAnswers = async ( ], }) }, + includeHarness: async () => { + if (options?.skipExample) { + return false + } + + if (options?.includeHarness === true) { + return true + } + + return p.confirm({ + message: kleur.cyan( + 'Include React Native Harness for native Android and iOS tests?' + ), + initialValue: false, + }) + }, packageNameConfirmation: async ({ results }) => { const packageName = results.packageName if (!packageName) { @@ -511,6 +537,7 @@ const getUserAnswers = async ( packageType: group.packageType, platforms: group.platforms, platformLangs: group.platformLangs as PlatformLangMap, + includeHarness: group.includeHarness as boolean, pm: group.pm, description: group.description as string, } diff --git a/src/cli/index.ts b/src/cli/index.ts index 6afdb16a..063961b8 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -17,6 +17,10 @@ program '-d, --module-dir ', 'directory to create the module in' ) + .option( + '--include-harness', + 'include React Native Harness setup in the example app' + ) .option('-e, --skip-example', 'skip example app generation') .option('-i, --skip-install', 'skip installing dependencies') .option('--ci', 'run in CI mode') diff --git a/src/code-snippets/code.js.ts b/src/code-snippets/code.js.ts index bbc3e4fc..9cd60dd4 100644 --- a/src/code-snippets/code.js.ts +++ b/src/code-snippets/code.js.ts @@ -1,4 +1,5 @@ import { toPascalCase } from '../utils' +import { Nitro, type PackageManager, SupportedPlatform } from '../types' export const appExampleCode = ( moduleName: string, @@ -154,6 +155,280 @@ export const exampleTsConfig = (finalModuleName: string) => `{ } }` +type HarnessConfigParams = { + androidBundleId: string | null + appRegistryComponentName: string + defaultRunner: SupportedPlatform + entryPoint: string + iosBundleId: string | null +} + +const getHarnessRunnerConfig = ( + platform: SupportedPlatform, + androidBundleId: string | null, + iosBundleId: string | null +) => { + if (platform === SupportedPlatform.ANDROID) { + if (androidBundleId == null) { + throw new Error('Android bundle id is required for Harness config') + } + + return `androidPlatform({ + name: 'android', + device: androidEmulator('Pixel_8_API_35'), + bundleId: '${androidBundleId}', + })` + } + + if (iosBundleId == null) { + throw new Error('iOS bundle id is required for Harness config') + } + + return `applePlatform({ + name: 'ios', + device: appleSimulator('iPhone 16', '18.0'), + bundleId: '${iosBundleId}', + })` +} + +export const harnessConfigCode = ({ + androidBundleId, + appRegistryComponentName, + defaultRunner, + entryPoint, + iosBundleId, +}: HarnessConfigParams) => { + const imports = [ + ...(androidBundleId == null + ? [] + : [ + "import { androidEmulator, androidPlatform } from '@react-native-harness/platform-android'", + ]), + ...(iosBundleId == null + ? [] + : [ + "import { applePlatform, appleSimulator } from '@react-native-harness/platform-apple'", + ]), + ].join('\n') + const runners = [ + ...(androidBundleId == null + ? [] + : [ + getHarnessRunnerConfig( + SupportedPlatform.ANDROID, + androidBundleId, + iosBundleId + ), + ]), + ...(iosBundleId == null + ? [] + : [ + getHarnessRunnerConfig( + SupportedPlatform.IOS, + androidBundleId, + iosBundleId + ), + ]), + ].join(',\n ') + + return `${imports} + +const config = { + entryPoint: '${entryPoint}', + appRegistryComponentName: '${appRegistryComponentName}', + runners: [ + ${runners} + ], + defaultRunner: '${defaultRunner}', +} + +export default config +` +} + +export const harnessJestConfigCode = () => `export default { + preset: 'react-native', + rootDir: '.', + testMatch: ['/harness/**/*.harness.ts'], +} +` + +export const harnessTestCode = ( + moduleName: string, + finalModuleName: string, + funcName: string, + packageType: Nitro +) => `/// +import { ${toPascalCase(moduleName)} } from '${finalModuleName}' + +describe('${toPascalCase(moduleName)}', () => { + it('loads the native implementation', () => { + ${ + packageType === Nitro.Module + ? `expect(${toPascalCase(moduleName)}.${funcName}(1, 2)).toBe(3)` + : `expect(${toPascalCase(moduleName)}).toBeDefined()` + } + }) +}) +` + +const getPackageManagerRunCommand = ( + packageManager: PackageManager, + scriptName: string +) => { + if (packageManager === 'yarn') { + return `yarn ${scriptName}` + } + + return `${packageManager} run ${scriptName}` +} + +const getPackageManagerSetupStep = (packageManager: PackageManager) => { + if (packageManager !== 'bun') { + return '' + } + + return ` - uses: oven-sh/setup-bun@v2 +` +} + +export const harnessWorkflowCode = ( + exampleAppName: string, + packageManager: PackageManager, + platforms: SupportedPlatform[] +) => { + const jobs = [ + ...(platforms.includes(SupportedPlatform.ANDROID) + ? [` test-android: + name: Test Android Harness + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' +${getPackageManagerSetupStep(packageManager)} + + - name: Install dependencies + run: ${packageManager} install + + - name: Setup JDK 17 + uses: actions/setup-java@v5 + with: + distribution: 'zulu' + java-version: '17' + cache: 'gradle' + + - name: Build Android app + working-directory: example/android + run: ./gradlew assembleDebug --no-daemon --build-cache + + - name: Run React Native Harness + uses: callstackincubator/react-native-harness/actions/android@v1.0.0 + with: + app: example/android/app/build/outputs/apk/debug/app-debug.apk + runner: android + projectRoot: example`] + : []), + ...(platforms.includes(SupportedPlatform.IOS) + ? [` test-ios: + name: Test iOS Harness + runs-on: macOS-15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' +${getPackageManagerSetupStep(packageManager)} + + - name: Install dependencies + run: ${packageManager} install + + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: 16.4 + + - name: Install Pods + working-directory: example + run: ${getPackageManagerRunCommand(packageManager, 'pod')} + + - name: Build iOS app + working-directory: example/ios + run: | + set -o pipefail && xcodebuild \ + CC=clang CPLUSPLUS=clang++ LD=clang LDPLUSPLUS=clang++ \ + -derivedDataPath build -UseModernBuildSystem=YES \ + -workspace ${exampleAppName}.xcworkspace \ + -scheme ${exampleAppName} \ + -sdk iphonesimulator \ + -configuration Debug \ + -destination 'platform=iOS Simulator,name=iPhone 16' \ + build \ + CODE_SIGNING_ALLOWED=NO + + - name: Run React Native Harness + uses: callstackincubator/react-native-harness/actions/ios@v1.0.0 + with: + app: example/ios/build/Build/Products/Debug-iphonesimulator/${exampleAppName}.app + runner: ios + projectRoot: example`] + : []), + ].join('\n\n') + + return `name: Run React Native Harness + +permissions: + contents: read + +on: + push: + branches: + - main + paths: + - '.github/workflows/react-native-harness.yml' + - 'example/**' + - 'android/**' + - 'ios/**' + - 'cpp/**' + - 'src/**' + - 'nitrogen/**' + - '*.podspec' + - 'package.json' + - 'bun.lock' + - 'pnpm-lock.yaml' + - 'package-lock.json' + - 'yarn.lock' + - 'react-native.config.js' + - 'nitro.json' + pull_request: + paths: + - '.github/workflows/react-native-harness.yml' + - 'example/**' + - 'android/**' + - 'ios/**' + - 'cpp/**' + - 'src/**' + - 'nitrogen/**' + - '*.podspec' + - 'package.json' + - 'bun.lock' + - 'pnpm-lock.yaml' + - 'package-lock.json' + - 'yarn.lock' + - 'react-native.config.js' + - 'nitro.json' + workflow_dispatch: + +concurrency: + group: \${{ github.workflow }}-\${{ github.ref }} + cancel-in-progress: true + +jobs: +${jobs} +` +} + export const postScript = (moduleName: string, isHybridView: boolean) => `/** * @file This script is auto-generated by create-nitro-module and should not be edited. * diff --git a/src/constants.ts b/src/constants.ts index 2c456958..cf83bfc3 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,5 +1,5 @@ import kleur from 'kleur' -import type { InstructionsParams } from './types' +import { SupportedPlatform, type InstructionsParams } from './types' export const SUPPORTED_PLATFORMS = ['ios', 'android'] @@ -47,8 +47,10 @@ export const NITRO_GRAPHIC = ` โ””โ”€โ”˜` export const generateInstructions = ({ + includeHarness, moduleName, pm, + platforms, skipInstall, skipExample, }: InstructionsParams) => ` @@ -86,6 +88,24 @@ ${ ${kleur.green(`${pm} run ios|android`)} ${kleur.dim('# Run your example app')}` } +${ + skipExample || !includeHarness + ? '' + : `\n\nRun your React Native Harness tests: + + ${kleur.green('cd example')} + ${[ + platforms.includes(SupportedPlatform.ANDROID) + ? `${kleur.green(`${pm} run test:harness:android`)} ${kleur.dim('# Run native tests on Android')}` + : null, + platforms.includes(SupportedPlatform.IOS) + ? `${kleur.green(`${pm} run test:harness:ios`)} ${kleur.dim('# Run native tests on iOS')}` + : null, + ] + .filter(Boolean) + .join('\n ')}` +} + ${kleur.yellow('Pro Tips:')} ${kleur.dim('โ€ข iOS:')} Open ${kleur.green('example/ios/example.xcworkspace')} in Xcode for native debugging. Make sure to run ${kleur.green(`${pm} pod`)} first in the example directory ${kleur.dim('โ€ข Android:')} Open ${kleur.green('example/android')} in Android Studio diff --git a/src/file-generators/cpp-file-generator.ts b/src/file-generators/cpp-file-generator.ts index d026c8bd..563e6b67 100644 --- a/src/file-generators/cpp-file-generator.ts +++ b/src/file-generators/cpp-file-generator.ts @@ -14,7 +14,6 @@ export class CppFileGenerator implements FileGenerator { constructor(private fileGenerators: FileGenerator[]) {} async generate(config: GenerateModuleConfig): Promise { - await createFolder(config.cwd, 'cpp') await this.generateCppCodeFiles(config) for (const generator of this.fileGenerators) { diff --git a/src/generate-nitro-package.ts b/src/generate-nitro-package.ts index 175d52b4..013051da 100644 --- a/src/generate-nitro-package.ts +++ b/src/generate-nitro-package.ts @@ -10,6 +10,10 @@ import { appExampleCode, babelConfig, exampleTsConfig, + harnessConfigCode, + harnessJestConfigCode, + harnessTestCode, + harnessWorkflowCode, metroConfig, } from './code-snippets/code.js' import { @@ -117,6 +121,9 @@ export class NitroModuleFactory { await this.createExampleApp() await this.configureExamplePackageJson() await this.syncExampleAppConfigurations() + if (this.config.includeHarness) { + await this.setupReactNativeHarness() + } await this.setupWorkflows() await this.gitInit() this.config.spinner.stop(kleur.cyan(messages.generating + 'Done')) @@ -398,6 +405,58 @@ export class NitroModuleFactory { 'babel-plugin-module-resolver': '^5.0.2', } + if (this.config.includeHarness) { + const [ + reactNativeHarnessVersion, + androidHarnessVersion, + appleHarnessVersion, + ] = await Promise.all([ + this.getLatestVersion('react-native-harness'), + this.getLatestVersion('@react-native-harness/platform-android'), + this.getLatestVersion('@react-native-harness/platform-apple'), + ]) + + exampleAppPackageJson.scripts = { + ...exampleAppPackageJson.scripts, + ...(this.config.platforms.includes(SupportedPlatform.ANDROID) + ? { + 'test:harness:android': + 'react-native-harness --config jest.harness.config.mjs --harnessRunner android', + } + : {}), + ...(this.config.platforms.includes(SupportedPlatform.IOS) + ? { + 'test:harness:ios': + 'react-native-harness --config jest.harness.config.mjs --harnessRunner ios', + } + : {}), + } + + exampleAppPackageJson.devDependencies = { + ...exampleAppPackageJson.devDependencies, + 'react-native-harness': + reactNativeHarnessVersion != null + ? `^${reactNativeHarnessVersion}` + : '^1.0.0', + ...(this.config.platforms.includes(SupportedPlatform.ANDROID) + ? { + '@react-native-harness/platform-android': + androidHarnessVersion != null + ? `^${androidHarnessVersion}` + : '^1.0.0', + } + : {}), + ...(this.config.platforms.includes(SupportedPlatform.IOS) + ? { + '@react-native-harness/platform-apple': + appleHarnessVersion != null + ? `^${appleHarnessVersion}` + : '^1.0.0', + } + : {}), + } + } + packagesToRemoveFromExampleApp.forEach(pkg => { delete exampleAppPackageJson.devDependencies[pkg] }) @@ -523,6 +582,90 @@ export class NitroModuleFactory { } } + private async getExampleIOSBundleId() { + const exampleAppName = `${toPascalCase(this.config.packageName)}Example` + const projectFilePath = path.join( + this.config.cwd, + 'example', + 'ios', + `${exampleAppName}.xcodeproj`, + 'project.pbxproj' + ) + const projectFileContent = await readFile(projectFilePath, { + encoding: 'utf8', + }) + const bundleIdMatch = projectFileContent.match( + /PRODUCT_BUNDLE_IDENTIFIER = ([^;]+);/ + ) + + if (bundleIdMatch == null) { + throw new Error( + `Failed to resolve iOS bundle identifier for React Native Harness from ${projectFilePath}` + ) + } + + return bundleIdMatch[1].trim().replaceAll('"', '') + } + + private async setupReactNativeHarness() { + const exampleAppName = `${toPascalCase(this.config.packageName)}Example` + const androidBundleId = this.config.platforms.includes( + SupportedPlatform.ANDROID + ) + ? `com.${replaceHyphen(this.config.packageName)}example` + : null + const iosBundleId = this.config.platforms.includes( + SupportedPlatform.IOS + ) + ? await this.getExampleIOSBundleId() + : null + const defaultRunner = this.config.platforms.includes( + SupportedPlatform.ANDROID + ) + ? SupportedPlatform.ANDROID + : SupportedPlatform.IOS + + await createFolder(this.config.cwd, path.join('example', 'harness')) + + await Promise.all([ + writeFile( + path.join(this.config.cwd, 'example', 'rn-harness.config.mjs'), + harnessConfigCode({ + androidBundleId, + appRegistryComponentName: exampleAppName, + defaultRunner, + entryPoint: './index.js', + iosBundleId, + }), + { encoding: 'utf8' } + ), + writeFile( + path.join( + this.config.cwd, + 'example', + 'jest.harness.config.mjs' + ), + harnessJestConfigCode(), + { encoding: 'utf8' } + ), + writeFile( + path.join( + this.config.cwd, + 'example', + 'harness', + `${this.config.packageName}.harness.ts` + ), + harnessTestCode( + this.config.packageName, + this.config.finalPackageName, + `${this.config.funcName}`, + this.config.packageType + ), + { encoding: 'utf8' } + ), + ]) + } + private async installDependenciesAndRunCodegen() { await execAsync(`${this.config.pm} install`, { cwd: this.config.cwd }) const packageManager = @@ -563,5 +706,29 @@ export class NitroModuleFactory { await writeFile(iosBuildWorkflowPath, iosBuildWorkflowContent, { encoding: 'utf8', }) + + if (!this.config.includeHarness) { + return + } + + const exampleAppName = `${toPascalCase(this.config.packageName)}Example` + const harnessWorkflowPath = path.join( + this.config.cwd, + '.github', + 'workflows', + 'react-native-harness.yml' + ) + + await writeFile( + harnessWorkflowPath, + harnessWorkflowCode( + exampleAppName, + this.config.pm, + this.config.platforms + ), + { + encoding: 'utf8', + } + ) } } diff --git a/src/types.ts b/src/types.ts index e0fed772..72724d42 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,6 +9,7 @@ export interface UserAnswers { platforms: SupportedPlatform[] packageType: Nitro platformLangs: PlatformLangMap + includeHarness: boolean pm: PackageManager } @@ -31,6 +32,7 @@ export type PlatformLang = { export type CreateModuleOptions = { ci?: boolean + includeHarness?: boolean langs?: string moduleDir?: string platforms?: string @@ -79,8 +81,10 @@ export const PLATFORM_LANGUAGE_MAP: Record = } export type InstructionsParams = { + includeHarness?: boolean moduleName: string pm: string + platforms: SupportedPlatform[] skipInstall?: boolean skipExample?: boolean } diff --git a/src/utils.ts b/src/utils.ts index aa3eb0b4..5b0f4a6e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -24,8 +24,6 @@ type AutolinkingConfig = { [key: string]: AutolinkingEntry } -export const LANGS = ['c++', 'swift', 'kotlin'] as const - export const validatePackageName = (input: string): string => { if (input.length === 0) { return 'Package name is required' @@ -116,10 +114,6 @@ export const generateAutolinking = ( return { [moduleName]: entry } } -export const validateTemplate = (answer: string[]) => { - return answer.length > 0 || 'You must choose at least one template' -} - export const dirExist = async (dir: string) => { try { await access(dir) diff --git a/test-local.sh b/test-local.sh index 90ed67f4..321e2d8d 100755 --- a/test-local.sh +++ b/test-local.sh @@ -53,6 +53,9 @@ sleep 1 send \x20 send \r +# Module type (Default to Nitro Module) +expect "๐Ÿ“ฆ Select module type:" {send \r} + # Language selection expect "๐Ÿ’ป Select programming languages:" sleep 1 @@ -66,9 +69,8 @@ send \r # Package manager expect "๐Ÿ“ฆ Select package manager:" {send \r} - -# Module type (Default to Nitro Module) -expect "๐Ÿ“ฆ Select module type:" {send \r} +# React Native Harness (Default to no) +expect "Include React Native Harness for native Android and iOS tests?" {send "n\r"} # Confirm package name expect "โœจ Your package name will be called:" {send "y\r"} @@ -111,4 +113,4 @@ else fi cleanup -echo -e "${GREEN}โœ… Test completed${NC}" \ No newline at end of file +echo -e "${GREEN}โœ… Test completed${NC}" From cd1f67f2b4e1fad1518bd566b4ff6a69808c7a79 Mon Sep 17 00:00:00 2001 From: Patrick Kabwe Date: Thu, 19 Mar 2026 14:02:16 +0200 Subject: [PATCH 02/22] chore: formatting --- eslint.config.js | 2 +- src/code-snippets/code.js.ts | 41 ++++++++++++++++++----------------- src/generate-nitro-package.ts | 25 ++++++++++----------- 3 files changed, 34 insertions(+), 34 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 39365f5b..3c38a45e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -8,7 +8,7 @@ export default defineConfig([ js.configs.recommended, nodePlugin.configs['flat/recommended-script'], { - files: ['src/*.ts'], + files: ['src/**/*.ts'], languageOptions: { parser: tsParser, parserOptions: { diff --git a/src/code-snippets/code.js.ts b/src/code-snippets/code.js.ts index 9cd60dd4..ffe7c29d 100644 --- a/src/code-snippets/code.js.ts +++ b/src/code-snippets/code.js.ts @@ -292,21 +292,20 @@ const getPackageManagerSetupStep = (packageManager: PackageManager) => { ` } -export const harnessWorkflowCode = ( +const getHarnessJobCode = ( exampleAppName: string, packageManager: PackageManager, - platforms: SupportedPlatform[] + platform: SupportedPlatform ) => { - const jobs = [ - ...(platforms.includes(SupportedPlatform.ANDROID) - ? [` test-android: + if (platform === SupportedPlatform.ANDROID) { + return ` test: name: Test Android Harness runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: '22' + node-version: '24' ${getPackageManagerSetupStep(packageManager)} - name: Install dependencies @@ -328,17 +327,17 @@ ${getPackageManagerSetupStep(packageManager)} with: app: example/android/app/build/outputs/apk/debug/app-debug.apk runner: android - projectRoot: example`] - : []), - ...(platforms.includes(SupportedPlatform.IOS) - ? [` test-ios: + projectRoot: example` + } + + return ` test: name: Test iOS Harness runs-on: macOS-15 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: '22' + node-version: '24' ${getPackageManagerSetupStep(packageManager)} - name: Install dependencies @@ -372,11 +371,14 @@ ${getPackageManagerSetupStep(packageManager)} with: app: example/ios/build/Build/Products/Debug-iphonesimulator/${exampleAppName}.app runner: ios - projectRoot: example`] - : []), - ].join('\n\n') + projectRoot: example` +} - return `name: Run React Native Harness +export const harnessWorkflowCode = ( + exampleAppName: string, + packageManager: PackageManager, + platform: SupportedPlatform +) => `name: Run React Native Harness ${platform === SupportedPlatform.ANDROID ? 'Android' : 'iOS'} permissions: contents: read @@ -386,7 +388,7 @@ on: branches: - main paths: - - '.github/workflows/react-native-harness.yml' + - '.github/workflows/harness-${platform}.yml' - 'example/**' - 'android/**' - 'ios/**' @@ -403,7 +405,7 @@ on: - 'nitro.json' pull_request: paths: - - '.github/workflows/react-native-harness.yml' + - '.github/workflows/harness-${platform}.yml' - 'example/**' - 'android/**' - 'ios/**' @@ -425,9 +427,8 @@ concurrency: cancel-in-progress: true jobs: -${jobs} +${getHarnessJobCode(exampleAppName, packageManager, platform)} ` -} export const postScript = (moduleName: string, isHybridView: boolean) => `/** * @file This script is auto-generated by create-nitro-module and should not be edited. @@ -487,7 +488,7 @@ const androidWorkaround = async () => { ) if (res.some((r) => r.status === 'rejected')) { - throw new Error(\`Error updating view manager files: \$\{res\}\`) + throw new Error('Error updating view manager files: ' + JSON.stringify(res)) } ` : '' diff --git a/src/generate-nitro-package.ts b/src/generate-nitro-package.ts index 013051da..d13abccf 100644 --- a/src/generate-nitro-package.ts +++ b/src/generate-nitro-package.ts @@ -712,23 +712,22 @@ export class NitroModuleFactory { } const exampleAppName = `${toPascalCase(this.config.packageName)}Example` - const harnessWorkflowPath = path.join( + const workflowDirectoryPath = path.join( this.config.cwd, '.github', - 'workflows', - 'react-native-harness.yml' + 'workflows' ) - await writeFile( - harnessWorkflowPath, - harnessWorkflowCode( - exampleAppName, - this.config.pm, - this.config.platforms - ), - { - encoding: 'utf8', - } + const workflowWrites = this.config.platforms.map(platform => + writeFile( + path.join(workflowDirectoryPath, `harness-${platform}.yml`), + harnessWorkflowCode(exampleAppName, this.config.pm, platform), + { + encoding: 'utf8', + } + ) ) + + await Promise.all(workflowWrites) } } From 9ec8597d75bc9ff680ed2bfa19380f87f129b518 Mon Sep 17 00:00:00 2001 From: Patrick Kabwe Date: Tue, 12 May 2026 08:50:48 +0200 Subject: [PATCH 03/22] refactor: rename moduleName to modulePath --- src/cli/create.ts | 60 +++++++++++++++++++++++++---------- src/constants.ts | 4 +-- src/generate-nitro-package.ts | 55 ++++++++++++++++++++++++++------ src/types.ts | 2 +- 4 files changed, 92 insertions(+), 29 deletions(-) diff --git a/src/cli/create.ts b/src/cli/create.ts index 44f22aa6..2e78c575 100644 --- a/src/cli/create.ts +++ b/src/cli/create.ts @@ -170,6 +170,22 @@ const parsePlatformLangsOption = ( return getPlatformLangMap(platforms, langs) } +const getFinalPackageName = (packageName: string) => + `react-native-${packageName.toLowerCase()}` + +const getTargetModulePath = (moduleBaseDir: string, packageName: string) => + path.resolve(moduleBaseDir, getFinalPackageName(packageName)) + +const getInstructionsModulePath = (modulePath: string) => { + const relativePath = path.relative(process.cwd(), modulePath) + + if (relativePath.length === 0 || relativePath.startsWith('..')) { + return modulePath + } + + return relativePath +} + export const createModule = async ( packageName: string, options: CreateModuleOptions @@ -177,12 +193,15 @@ export const createModule = async ( let packageType = Nitro.Module let moduleFactory: NitroModuleFactory | null = null let spinnerStarted = false + let shouldCleanupModulePath = false + let targetModulePath: string | null = null const spinner = p.spinner() try { if (options.moduleDir) { - const moduleDirExists = await dirExist(options.moduleDir) + const moduleDirPath = path.resolve(options.moduleDir) + const moduleDirExists = await dirExist(moduleDirPath) if (!moduleDirExists) { - mkdirSync(options.moduleDir, { recursive: true }) + mkdirSync(moduleDirPath, { recursive: true }) } } @@ -208,6 +227,12 @@ export const createModule = async ( const answers = await getUserAnswers(packageName, usedPm, options) packageName = answers.packageName packageType = answers.packageType + const finalPackageName = getFinalPackageName(packageName) + const moduleBaseDir = + options.moduleDir != null + ? path.resolve(options.moduleDir) + : process.cwd() + targetModulePath = getTargetModulePath(moduleBaseDir, packageName) moduleFactory = new NitroModuleFactory({ description: answers.description, @@ -215,22 +240,24 @@ export const createModule = async ( packageName, platforms: answers.platforms, pm: answers.pm, - cwd: options.moduleDir || process.cwd(), + cwd: moduleBaseDir, spinner, packageType, - finalPackageName: 'react-native-' + packageName.toLowerCase(), + finalPackageName, includeHarness: answers.includeHarness, skipInstall: options.skipInstall, skipExample: options.skipExample, }) - const modulePath = path.join( - process.cwd(), - `react-native-${packageName.toLowerCase()}` - ) - const dirExists = await dirExist(modulePath) + const dirExists = await dirExist(targetModulePath) if (dirExists) { + if (options.ci) { + throw new Error( + `Target directory already exists: ${targetModulePath}. Remove it or choose a different module name or --module-dir.` + ) + } + const confirm = await p.confirm({ message: 'Looks like the directory with the same name already exists.' + @@ -245,11 +272,14 @@ export const createModule = async ( if (p.isCancel(confirm)) { process.exit(1) } else if (confirm) { - rmSync(modulePath, { recursive: true, force: true }) + rmSync(targetModulePath, { recursive: true, force: true }) + shouldCleanupModulePath = true } else { console.log(kleur.red('Cancelled')) process.exit(1) } + } else { + shouldCleanupModulePath = true } spinner.start( @@ -262,7 +292,7 @@ export const createModule = async ( console.log( generateInstructions({ includeHarness: answers.includeHarness, - moduleName: `react-native-${packageName.toLowerCase()}`, + modulePath: getInstructionsModulePath(targetModulePath), pm: answers.pm, platforms: answers.platforms, skipExample: options.skipExample, @@ -276,12 +306,8 @@ export const createModule = async ( ) ) } catch (error) { - if (packageName) { - const modulePath = path.join( - process.cwd(), - `react-native-${packageName.toLowerCase()}` - ) - rmSync(modulePath, { recursive: true, force: true }) + if (shouldCleanupModulePath && targetModulePath != null) { + rmSync(targetModulePath, { recursive: true, force: true }) } if (spinnerStarted) { spinner.stop( diff --git a/src/constants.ts b/src/constants.ts index cf83bfc3..59ba4e4a 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -48,7 +48,7 @@ export const NITRO_GRAPHIC = ` export const generateInstructions = ({ includeHarness, - moduleName, + modulePath, pm, platforms, skipInstall, @@ -58,7 +58,7 @@ ${kleur.cyan().bold(NITRO_GRAPHIC)} ${kleur.red().bold('Next steps:')} -${kleur.green(`cd ${moduleName}`)} +${kleur.green(`cd ${modulePath}`)} ${ !skipInstall ? '' diff --git a/src/generate-nitro-package.ts b/src/generate-nitro-package.ts index d13abccf..ea2c3206 100644 --- a/src/generate-nitro-package.ts +++ b/src/generate-nitro-package.ts @@ -53,6 +53,7 @@ import { const execAsync = util.promisify(exec) const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) +type NitroDependencyName = 'nitrogen' | 'react-native-nitro-modules' export class NitroModuleFactory { private nitroModulesVersion: string | null = null @@ -135,6 +136,47 @@ export class NitroModuleFactory { } } + private getTemplateDevDependencyVersion(pkg: NitroDependencyName): string { + const version = templatePackageJson.devDependencies[pkg] + + if (typeof version !== 'string' || version.length === 0) { + throw new Error( + `Missing template devDependency version for package "${pkg}"` + ) + } + + return version + } + + private async resolveNitroDependencyVersions(): Promise<{ + nitrogenVersion: string + nitroModulesVersion: string + }> { + const defaultNitrogenVersion = + this.getTemplateDevDependencyVersion('nitrogen') + const defaultNitroModulesVersion = this.getTemplateDevDependencyVersion( + 'react-native-nitro-modules' + ) + + if (this.config.skipInstall) { + return { + nitrogenVersion: defaultNitrogenVersion, + nitroModulesVersion: defaultNitroModulesVersion, + } + } + + const [nitroModulesVersion, nitrogenVersion] = await Promise.all([ + this.getLatestVersion('react-native-nitro-modules'), + this.getLatestVersion('nitrogen'), + ]) + + return { + nitrogenVersion: nitrogenVersion ?? defaultNitrogenVersion, + nitroModulesVersion: + nitroModulesVersion ?? defaultNitroModulesVersion, + } + } + private async getLatestVersion(pkg: string): Promise { try { const { stdout } = await execAsync(`npm view ${pkg} version`) @@ -217,24 +259,19 @@ export class NitroModuleFactory { : undefined, } - // Resolve and pin latest Nitro tools to concrete versions const nitrogen = 'nitrogen' const nitroModules = 'react-native-nitro-modules' - const [nitroModulesVersion, nitrogenVersion] = await Promise.all([ - this.getLatestVersion(nitroModules), - this.getLatestVersion(nitrogen), - ]) + const { nitrogenVersion, nitroModulesVersion } = + await this.resolveNitroDependencyVersions() this.nitroModulesVersion = nitroModulesVersion newWorkspacePackageJsonFile.devDependencies = { ...newWorkspacePackageJsonFile.devDependencies, [nitroModules]: nitroModulesVersion ?? - newWorkspacePackageJsonFile.devDependencies?.[nitroModules] ?? - templatePackageJson.devDependencies[nitroModules], + newWorkspacePackageJsonFile.devDependencies?.[nitroModules], [nitrogen]: nitrogenVersion ?? - newWorkspacePackageJsonFile.devDependencies?.[nitrogen] ?? - templatePackageJson.devDependencies[nitrogen], + newWorkspacePackageJsonFile.devDependencies?.[nitrogen], } newWorkspacePackageJsonFile.keywords = [ diff --git a/src/types.ts b/src/types.ts index 72724d42..4623778e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -82,7 +82,7 @@ export const PLATFORM_LANGUAGE_MAP: Record = export type InstructionsParams = { includeHarness?: boolean - moduleName: string + modulePath: string pm: string platforms: SupportedPlatform[] skipInstall?: boolean From d1064f0b194fca748adfe68eb18c5beeeccf9269 Mon Sep 17 00:00:00 2001 From: Patrick Kabwe Date: Tue, 12 May 2026 10:04:41 +0200 Subject: [PATCH 04/22] fix: generate harness jest config as default jest config --- .github/actions/ios-build-xcode/action.yml | 2 +- .../template/.github/workflows/ios-build.yml | 2 +- assets/template/gitignore | 3 ++- scripts/e2e-maestro.sh | 8 +++---- src/code-snippets/code.js.ts | 22 ++++++++++++------- src/generate-nitro-package.ts | 16 +++++--------- test-local.sh | 2 +- 7 files changed, 29 insertions(+), 26 deletions(-) diff --git a/.github/actions/ios-build-xcode/action.yml b/.github/actions/ios-build-xcode/action.yml index 96e8705c..2a17a7ac 100644 --- a/.github/actions/ios-build-xcode/action.yml +++ b/.github/actions/ios-build-xcode/action.yml @@ -49,7 +49,7 @@ runs: -scheme "${SCHEME_NAME}" \ -sdk iphonesimulator \ -configuration "${{ inputs.mode }}" \ - -destination 'platform=iOS Simulator,name=iPhone 16' \ + -destination 'platform=iOS Simulator,name=iPhone 17' \ build \ CODE_SIGNING_ALLOWED=NO | xcpretty; then echo "xcodebuild succeeded on attempt $attempt" diff --git a/assets/template/.github/workflows/ios-build.yml b/assets/template/.github/workflows/ios-build.yml index ddc8b3cd..c63c0e1f 100644 --- a/assets/template/.github/workflows/ios-build.yml +++ b/assets/template/.github/workflows/ios-build.yml @@ -94,6 +94,6 @@ jobs: -scheme $$exampleApp$$ \ -sdk iphonesimulator \ -configuration Debug \ - -destination 'platform=iOS Simulator,name=iPhone 16' \ + -destination 'platform=iOS Simulator,name=iPhone 17' \ build \ CODE_SIGNING_ALLOWED=NO | xcpretty diff --git a/assets/template/gitignore b/assets/template/gitignore index 9d7c7e7a..a717acae 100644 --- a/assets/template/gitignore +++ b/assets/template/gitignore @@ -71,7 +71,8 @@ android/keystores/debug.keystore # Expo .expo/ +.harness # generated by bob lib/ -tsconfig.tsbuildinfo \ No newline at end of file +tsconfig.tsbuildinfo diff --git a/scripts/e2e-maestro.sh b/scripts/e2e-maestro.sh index 0fa39461..666f0a19 100755 --- a/scripts/e2e-maestro.sh +++ b/scripts/e2e-maestro.sh @@ -64,9 +64,9 @@ if [ "$PLATFORM" == "ios" ]; then SCHEME="$WORKSPACE_NAME" fi - # Get iPhone 16 simulator ID dynamically - iphone16Id=$(xcrun simctl list devices | grep "iPhone 16 (" | grep -E '\(Booted\)|\(Shutdown\)' | head -1 | grep -E -o '\([0-9A-F-]{36}\)' | tr -d '()') - echo "๐Ÿ“ฑ Using iPhone 16 simulator with ID: $iphone16Id" + # Get iPhone 17 simulator ID dynamically + iphone16Id=$(xcrun simctl list devices | grep "iPhone 17 (" | grep -E '\(Booted\)|\(Shutdown\)' | head -1 | grep -E -o '\([0-9A-F-]{36}\)' | tr -d '()') + echo "๐Ÿ“ฑ Using iPhone 17 simulator with ID: $iphone16Id" # Build the app with optimizations and pretty output export USE_CCACHE=1 @@ -114,7 +114,7 @@ if [ "$PLATFORM" == "ios" ]; then fi # Launch the simulator if not already booted - if ! xcrun simctl list devices | grep "$iphone16Id" | grep -q "Booted"; then + if ! xcrun simctl list devices | grep "$iphone17Id" | grep -q "Booted"; then echo "๐Ÿš€ Booting simulator..." xcrun simctl boot $iphone16Id else diff --git a/src/code-snippets/code.js.ts b/src/code-snippets/code.js.ts index ffe7c29d..e31a848f 100644 --- a/src/code-snippets/code.js.ts +++ b/src/code-snippets/code.js.ts @@ -186,7 +186,7 @@ const getHarnessRunnerConfig = ( return `applePlatform({ name: 'ios', - device: appleSimulator('iPhone 16', '18.0'), + device: appleSimulator('iPhone 17', '26.5'), bundleId: '${iosBundleId}', })` } @@ -246,10 +246,16 @@ export default config ` } -export const harnessJestConfigCode = () => `export default { - preset: 'react-native', - rootDir: '.', - testMatch: ['/harness/**/*.harness.ts'], +export const harnessJestConfigCode = () => `module.exports = { + projects: [ + { + displayName: 'react-native-harness', + preset: 'react-native-harness', + testMatch: [ + '/__tests__/**/*.(test|spec|harness).(js|jsx|ts|tsx)', + ], + }, + ], } ` @@ -258,7 +264,7 @@ export const harnessTestCode = ( finalModuleName: string, funcName: string, packageType: Nitro -) => `/// +) => `import { describe, it, expect } from 'react-native-harness' import { ${toPascalCase(moduleName)} } from '${finalModuleName}' describe('${toPascalCase(moduleName)}', () => { @@ -346,7 +352,7 @@ ${getPackageManagerSetupStep(packageManager)} - name: Setup Xcode uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: 16.4 + xcode-version: 26.5 - name: Install Pods working-directory: example @@ -362,7 +368,7 @@ ${getPackageManagerSetupStep(packageManager)} -scheme ${exampleAppName} \ -sdk iphonesimulator \ -configuration Debug \ - -destination 'platform=iOS Simulator,name=iPhone 16' \ + -destination 'platform=iOS Simulator,name=iPhone 17' \ build \ CODE_SIGNING_ALLOWED=NO diff --git a/src/generate-nitro-package.ts b/src/generate-nitro-package.ts index ea2c3206..e459e0cb 100644 --- a/src/generate-nitro-package.ts +++ b/src/generate-nitro-package.ts @@ -426,7 +426,7 @@ export class NitroModuleFactory { exampleAppPackageJson.scripts = { ...exampleAppPackageJson.scripts, - ios: "react-native run-ios --simulator='iPhone 16'", + ios: "react-native run-ios --simulator='iPhone 17'", start: 'react-native start --reset-cache', pod: 'bundle install && bundle exec pod install --project-directory=ios', } @@ -458,13 +458,13 @@ export class NitroModuleFactory { ...(this.config.platforms.includes(SupportedPlatform.ANDROID) ? { 'test:harness:android': - 'react-native-harness --config jest.harness.config.mjs --harnessRunner android', + 'react-native-harness --harnessRunner android', } : {}), ...(this.config.platforms.includes(SupportedPlatform.IOS) ? { 'test:harness:ios': - 'react-native-harness --config jest.harness.config.mjs --harnessRunner ios', + 'react-native-harness --harnessRunner ios', } : {}), } @@ -662,7 +662,7 @@ export class NitroModuleFactory { ? SupportedPlatform.ANDROID : SupportedPlatform.IOS - await createFolder(this.config.cwd, path.join('example', 'harness')) + await createFolder(this.config.cwd, path.join('example', '__tests__')) await Promise.all([ writeFile( @@ -677,11 +677,7 @@ export class NitroModuleFactory { { encoding: 'utf8' } ), writeFile( - path.join( - this.config.cwd, - 'example', - 'jest.harness.config.mjs' - ), + path.join(this.config.cwd, 'example', 'jest.config.js'), harnessJestConfigCode(), { encoding: 'utf8' } ), @@ -689,7 +685,7 @@ export class NitroModuleFactory { path.join( this.config.cwd, 'example', - 'harness', + '__tests__', `${this.config.packageName}.harness.ts` ), harnessTestCode( diff --git a/test-local.sh b/test-local.sh index 321e2d8d..a0c4665c 100755 --- a/test-local.sh +++ b/test-local.sh @@ -99,7 +99,7 @@ if [ -d "react-native-test-module" ]; then -scheme TestModuleExample \ -sdk iphonesimulator \ -configuration Debug \ - -destination 'platform=iOS Simulator,name=iPhone 16' build + -destination 'platform=iOS Simulator,name=iPhone 17' build cd ../android ./gradlew assembleDebug --no-daemon From f9e21a85f92dc23be61c28ff470fe292afb4e6e1 Mon Sep 17 00:00:00 2001 From: Patrick Kabwe Date: Tue, 12 May 2026 10:37:55 +0200 Subject: [PATCH 05/22] fix: use harness for package runtime tests --- .github/actions/setup-maestro/action.yml | 18 --- .github/workflows/ci-packages.yml | 101 ++++-------- e2e-tests/module.e2e.yaml | 4 - e2e-tests/view.e2e.yaml | 5 - package.json | 4 +- scripts/e2e-maestro.sh | 191 ----------------------- 6 files changed, 33 insertions(+), 290 deletions(-) delete mode 100644 .github/actions/setup-maestro/action.yml delete mode 100644 e2e-tests/module.e2e.yaml delete mode 100644 e2e-tests/view.e2e.yaml delete mode 100755 scripts/e2e-maestro.sh diff --git a/.github/actions/setup-maestro/action.yml b/.github/actions/setup-maestro/action.yml deleted file mode 100644 index fedd25a4..00000000 --- a/.github/actions/setup-maestro/action.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: 'Setup Maestro' -description: 'Installs Maestro CLI' -runs: - using: 'composite' - steps: - - run: | - echo "Installing Maestro CLI..." - export MAESTRO_VERSION=1.40.0; curl -Ls "https://get.maestro.mobile.dev" | bash - - # Add Maestro to PATH for subsequent steps - export PATH="$PATH":"$HOME/.maestro/bin" - echo "$HOME/.maestro/bin" >> $GITHUB_PATH - - # Verify installation - maestro --version || echo "Maestro installation verification failed" - - echo "Maestro installation complete!" - shell: bash diff --git a/.github/workflows/ci-packages.yml b/.github/workflows/ci-packages.yml index f50cb386..97d40678 100644 --- a/.github/workflows/ci-packages.yml +++ b/.github/workflows/ci-packages.yml @@ -75,8 +75,8 @@ jobs: generation: ${{ steps.set-matrix.outputs.generation }} ios-build: ${{ steps.set-matrix.outputs.ios_build }} android-build: ${{ steps.set-matrix.outputs.android_build }} - ios-e2e: ${{ steps.set-matrix.outputs.ios_e2e }} - android-e2e: ${{ steps.set-matrix.outputs.android_e2e }} + ios-harness: ${{ steps.set-matrix.outputs.ios_harness }} + android-harness: ${{ steps.set-matrix.outputs.android_harness }} steps: - name: Build workflow matrices id: set-matrix @@ -202,18 +202,18 @@ jobs: ) ) ) - const iosE2E = scenarios + const iosHarness = scenarios .filter(item => item.runs_ios) - .map(item => enrich(item, { pm: 'bun', mode: 'Release' })) - const androidE2E = scenarios + .map(item => enrich(item, { pm: 'bun', mode: 'Debug' })) + const androidHarness = scenarios .filter(item => item.runs_android) - .map(item => enrich(item, { pm: 'bun', mode: 'Release' })) + .map(item => enrich(item, { pm: 'bun', mode: 'Debug' })) console.log(`generation=${JSON.stringify({ include: generation })}`) console.log(`ios_build=${JSON.stringify({ include: iosBuild })}`) console.log(`android_build=${JSON.stringify({ include: androidBuild })}`) - console.log(`ios_e2e=${JSON.stringify({ include: iosE2E })}`) - console.log(`android_e2e=${JSON.stringify({ include: androidE2E })}`) + console.log(`ios_harness=${JSON.stringify({ include: iosHarness })}`) + console.log(`android_harness=${JSON.stringify({ include: androidHarness })}`) NODE generate-packages: @@ -264,6 +264,7 @@ jobs: run: | ${{ matrix.pm }} create nitro-module test-${{ matrix.package_type }}-${{ matrix.scenario }} \ --skip-install \ + --include-harness \ --ci \ --package-type ${{ matrix.package_type }} \ --platforms ${{ matrix.platforms }} \ @@ -517,13 +518,13 @@ jobs: android-dir: ${{ env.WORKING_DIR }}/example/android mode: ${{ matrix.mode }} - e2e-android: - name: Android E2E - ${{ matrix.package_type }} - ${{ matrix.scenario }} + harness-android: + name: Android Harness - ${{ matrix.package_type }} - ${{ matrix.scenario }} needs: [test-android-build, define-matrix] runs-on: ubuntu-latest strategy: fail-fast: false - matrix: ${{ fromJson(needs.define-matrix.outputs.android-e2e) }} + matrix: ${{ fromJson(needs.define-matrix.outputs.android-harness) }} env: WORKING_DIR: ${{ github.workspace }}/${{ matrix.package_dir }} steps: @@ -546,10 +547,6 @@ jobs: name: test-${{ matrix.package_type }}-${{ matrix.scenario }}-${{ matrix.pm }} path: ${{ env.WORKING_DIR }} - - name: List package structure - working-directory: ${{ env.WORKING_DIR }} - run: find . -type f | head -30 - - name: Setup Bun uses: oven-sh/setup-bun@v2 with: @@ -585,10 +582,13 @@ jobs: - name: Setup Android SDK uses: android-actions/setup-android@v3 - - name: Install Maestro CLI - uses: ./.github/actions/setup-maestro + - name: Build Android app + uses: ./.github/actions/android-gradle-build + with: + android-dir: ${{ env.WORKING_DIR }}/example/android + mode: ${{ matrix.mode }} - - name: Run Android Emulator and E2E Tests + - name: Run Android Emulator and Harness Tests uses: reactivecircus/android-emulator-runner@v2 with: api-level: 35 @@ -599,18 +599,17 @@ jobs: disable-animations: true script: | adb wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done' - ${{ matrix.pm }} android:e2e ${{ env.WORKING_DIR }}/example ${{ matrix.package_type }} + cd "${{ env.WORKING_DIR }}/example" + ${{ matrix.pm }} run test:harness:android - e2e-ios: - name: iOS E2E - ${{ matrix.package_type }} - ${{ matrix.scenario }} + harness-ios: + name: iOS Harness - ${{ matrix.package_type }} - ${{ matrix.scenario }} needs: [test-ios-build, define-matrix] runs-on: macOS-15 strategy: fail-fast: false - matrix: ${{ fromJson(needs.define-matrix.outputs.ios-e2e) }} + matrix: ${{ fromJson(needs.define-matrix.outputs.ios-harness) }} env: - MAESTRO_DRIVER_STARTUP_TIMEOUT: 300_000 - MAESTRO_CLI_ANALYSIS_NOTIFICATION_DISABLED: true WORKING_DIR: ${{ github.workspace }}/${{ matrix.package_dir }} steps: - name: Checkout repository @@ -624,16 +623,12 @@ jobs: - name: Create working directory run: mkdir -p ${{ env.WORKING_DIR }} - - name: Download generated module + - name: Download generated package uses: actions/download-artifact@v8 with: name: test-${{ matrix.package_type }}-${{ matrix.scenario }}-${{ matrix.pm }} path: ${{ env.WORKING_DIR }} - - name: List package structure - working-directory: ${{ env.WORKING_DIR }} - run: find . -type f | head -30 - - name: Setup Bun uses: oven-sh/setup-bun@v2 with: @@ -647,7 +642,7 @@ jobs: restore-keys: | ${{ runner.os }}-bun- - - name: Install Dependencies + - name: Install dependencies uses: ./.github/actions/install-deps with: pm: ${{ matrix.pm }} @@ -671,44 +666,12 @@ jobs: working-directory: ${{ env.WORKING_DIR }}/example/ios run: pod install - - name: Setup ccache - run: | - brew install ccache - ccache --version - ccache --zero-stats - - - name: Cache ccache - uses: actions/cache@v5 + - name: Build iOS app + uses: ./.github/actions/ios-build-xcode with: - path: ~/Library/Caches/ccache - key: ${{ runner.os }}-ccache-${{ matrix.package_type }}-${{ matrix.scenario }}-${{ github.sha }} - restore-keys: | - ${{ runner.os }}-ccache-${{ matrix.package_type }}-${{ matrix.scenario }}- - ${{ runner.os }}-ccache- - - - name: Configure ccache - run: | - ccache --set-config=max_size=2G - ccache --set-config=compression=true - ccache --set-config=compression_level=6 - - - name: Install Maestro CLI - uses: ./.github/actions/setup-maestro - - - name: Run tests - env: - USE_CCACHE: 1 - CCACHE_DIR: ~/Library/Caches/ccache - run: ${{ matrix.pm }} ios:e2e ${{ env.WORKING_DIR }}/example ${{ matrix.package_type }} - - - name: Print ccache statistics - if: always() - run: ccache --show-stats + ios-dir: ${{ env.WORKING_DIR }}/example/ios + mode: ${{ matrix.mode }} - - name: Upload test artifacts - if: always() - uses: actions/upload-artifact@v7 - with: - name: maestro-artifacts-ios-${{ matrix.package_type }}-${{ matrix.scenario }} - path: e2e-artifacts - include-hidden-files: true + - name: Run iOS Harness Tests + working-directory: ${{ env.WORKING_DIR }}/example + run: ${{ matrix.pm }} run test:harness:ios diff --git a/e2e-tests/module.e2e.yaml b/e2e-tests/module.e2e.yaml deleted file mode 100644 index 235eb3a0..00000000 --- a/e2e-tests/module.e2e.yaml +++ /dev/null @@ -1,4 +0,0 @@ -appId: ${APP_ID} ---- -- launchApp -- assertVisible: '3' diff --git a/e2e-tests/view.e2e.yaml b/e2e-tests/view.e2e.yaml deleted file mode 100644 index 7c083a93..00000000 --- a/e2e-tests/view.e2e.yaml +++ /dev/null @@ -1,5 +0,0 @@ -appId: ${APP_ID} ---- -- launchApp -- assertVisible: - id: ${MODULE_ID} diff --git a/package.json b/package.json index 42e4239c..91e21a87 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,7 @@ "release": "bun run build && bun semantic-release", "lint": "eslint --fix ", "format": "prettier --write ", - "commitlint": "commitlint --edit", - "ios:e2e": "bash scripts/e2e-maestro.sh ios", - "android:e2e": "bash scripts/e2e-maestro.sh android" + "commitlint": "commitlint --edit" }, "files": [ "lib", diff --git a/scripts/e2e-maestro.sh b/scripts/e2e-maestro.sh deleted file mode 100755 index 666f0a19..00000000 --- a/scripts/e2e-maestro.sh +++ /dev/null @@ -1,191 +0,0 @@ -#!/bin/bash - -trap 'exit' INT - -# Save the script directory (project root) -SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && cd .. && pwd )" - -PLATFORM=${1:-} -EXAMPLE_DIR=${2:-} -PACKAGE_TYPE=${3:-} - -echo "๐Ÿš€ Running e2e video recording for $PLATFORM" -echo "๐Ÿ“‚ Project root: $SCRIPT_DIR" - -# Validate passed platform -case $PLATFORM in - ios | android ) - ;; - - *) - echo "Error! You must pass either 'android' or 'ios'" - echo "" - exit 1 - ;; -esac - - -APP_ID="" -SCHEME="" - -if [ "$PACKAGE_TYPE" != "module" ] && [ "$PACKAGE_TYPE" != "view" ]; then - echo "Error! You must pass either 'module' or 'view'" - echo "" - exit 1 -fi - -PACKAGE_ROOT_NAME="$(basename "$(cd "$EXAMPLE_DIR/.." && pwd)")" -PACKAGE_NAME="${PACKAGE_ROOT_NAME#react-native-}" -APP_ID="com.${PACKAGE_NAME//-/}example" - -to_pascal_case() { - local input="$1" - local result="" - local word="" - - input="${input//-/ }" - input="${input//_/ }" - - for word in $input; do - local lower_word="${word,,}" - result+="${lower_word^}" - done - - printf '%s' "$result" -} - -SCHEME="$(to_pascal_case "$PACKAGE_NAME")Example" - -if [ "$PLATFORM" == "ios" ]; then - cd "$EXAMPLE_DIR/ios" - - WORKSPACE_NAME="$(find . -maxdepth 1 -name "*.xcworkspace" ! -name "Pods.xcworkspace" -exec basename {} .xcworkspace \; | head -1)" - if [ -n "$WORKSPACE_NAME" ]; then - SCHEME="$WORKSPACE_NAME" - fi - - # Get iPhone 17 simulator ID dynamically - iphone16Id=$(xcrun simctl list devices | grep "iPhone 17 (" | grep -E '\(Booted\)|\(Shutdown\)' | head -1 | grep -E -o '\([0-9A-F-]{36}\)' | tr -d '()') - echo "๐Ÿ“ฑ Using iPhone 17 simulator with ID: $iphone16Id" - - # Build the app with optimizations and pretty output - export USE_CCACHE=1 - # Configure ccache if available (optional optimization) - if command -v ccache >/dev/null 2>&1; then - export CCACHE_DIR="${CCACHE_DIR:-$HOME/Library/Caches/ccache}" - mkdir -p "$CCACHE_DIR" - export PATH="/opt/homebrew/bin:$PATH" - echo "โœ… ccache is available" - echo "๐Ÿ“ฆ ccache directory: $CCACHE_DIR" - ccache --max-size=2G 2>/dev/null || true - else - echo "โš ๏ธ ccache not found (optional). Install with: brew install ccache" - fi - - buildCmd="xcodebuild \ - -workspace $SCHEME.xcworkspace \ - -scheme $SCHEME \ - -configuration Release \ - -destination id=$iphone16Id \ - -derivedDataPath build \ - -jobs $(sysctl -n hw.ncpu) \ - ONLY_ACTIVE_ARCH=YES \ - ARCHS=arm64 \ - VALID_ARCHS=arm64 \ - EXCLUDED_ARCHS=x86_64 \ - CODE_SIGNING_ALLOWED=NO" - - echo "๐Ÿ”จ Building iOS app..." - echo $buildCmd - # Check if xcpretty is available - if command -v xcpretty >/dev/null 2>&1; then - set -o pipefail && $buildCmd | xcpretty - if [ $? -ne 0 ]; then - echo "โŒ iOS build failed!" - exit 1 - fi - else - echo "โš ๏ธ xcpretty not found. Install with: gem install xcpretty" - $buildCmd - if [ $? -ne 0 ]; then - echo "โŒ iOS build failed!" - exit 1 - fi - fi - - # Launch the simulator if not already booted - if ! xcrun simctl list devices | grep "$iphone17Id" | grep -q "Booted"; then - echo "๐Ÿš€ Booting simulator..." - xcrun simctl boot $iphone16Id - else - echo "โœ… Simulator already booted" - fi - # Wait for 10 seconds - sleep 10 - - # Find and install the built app - APP_PATH=$(find build/Build/Products/Release-iphonesimulator -name "*.app" | head -1) - echo "๐Ÿ“ฒ Installing app from: $APP_PATH" - xcrun simctl install $iphone16Id "$APP_PATH" - - # Return to project root - cd "$SCRIPT_DIR" -else - cd "$EXAMPLE_DIR/android" - chmod +x ./gradlew - - # Build with optimizations and pretty output - echo "๐Ÿ”จ Building Android app..." - ./gradlew assembleRelease --no-daemon --build-cache --parallel --console=rich - if [ $? -ne 0 ]; then - echo "โŒ Android build failed!" - exit 1 - fi - APK_PATH="app/build/outputs/apk/release/app-release.apk" - - # Install the APK - echo "๐Ÿ“ฒ Installing APK: $APK_PATH" - adb install -r $APK_PATH - - # Stop Gradle daemon to free up memory - echo "๐Ÿงน Stopping Gradle daemon..." - ./gradlew --stop - - # Return to project root - cd "$SCRIPT_DIR" -fi - -echo "๐Ÿ“‚ Script directory: $(pwd)" -echo "" - -test_file="e2e-tests/$PACKAGE_TYPE.e2e.yaml" - -echo "๐ŸŽฌ Using flow file for recording: $test_file" - -if [ ! -f "$test_file" ]; then - echo "โŒ Error! Flow file not found: $test_file" - echo "" - exit 1 -fi - -# Create output directory for videos -mkdir -p e2e-artifacts - -recordCmd="maestro record \"$test_file\" -e APP_ID=$APP_ID -e MODULE_ID=$PACKAGE_NAME --local" -echo "๐ŸŽฏ Recording test video: $recordCmd" -echo "๐Ÿ“ฑ APP_ID: $APP_ID" -echo "๐Ÿ†” MODULE_ID: $PACKAGE_NAME" - - -if ! eval "$recordCmd --debug-output e2e-artifacts/$PACKAGE_TYPE"; then - echo "Recording ${test_file} failed. Retrying in 30 seconds..." - sleep 30 - if ! eval "$recordCmd --debug-output e2e-artifacts/$PACKAGE_TYPE-retry-1"; then - echo "Recording ${test_file} failed again. Retrying for the last time in 120 seconds..." - sleep 120 - if ! eval "$recordCmd --debug-output e2e-artifacts/$PACKAGE_TYPE-retry-2"; then - echo "Recording ${test_file} failed again. Exiting..." - exit 1 - fi - fi -fi From 4439871173b334882b765a7e9f77b5c6fa38d340 Mon Sep 17 00:00:00 2001 From: Patrick Kabwe Date: Tue, 12 May 2026 11:19:14 +0200 Subject: [PATCH 06/22] fix: use harness for generated package tests --- .github/workflows/ci-packages.yml | 194 +---------------------- src/cli/create.ts | 29 +++- src/cli/index.ts | 4 + src/code-snippets/code.js.ts | 42 ++++- src/constants.ts | 18 ++- src/generate-nitro-package.ts | 245 +++++++++++++++++++++++++----- src/types.ts | 7 +- 7 files changed, 295 insertions(+), 244 deletions(-) diff --git a/.github/workflows/ci-packages.yml b/.github/workflows/ci-packages.yml index 97d40678..7caed886 100644 --- a/.github/workflows/ci-packages.yml +++ b/.github/workflows/ci-packages.yml @@ -73,8 +73,6 @@ jobs: runs-on: ubuntu-latest outputs: generation: ${{ steps.set-matrix.outputs.generation }} - ios-build: ${{ steps.set-matrix.outputs.ios_build }} - android-build: ${{ steps.set-matrix.outputs.android_build }} ios-harness: ${{ steps.set-matrix.outputs.ios_harness }} android-harness: ${{ steps.set-matrix.outputs.android_harness }} steps: @@ -184,24 +182,6 @@ jobs: const generation = pms.flatMap(pm => scenarios.map(item => enrich(item, { pm })) ) - const iosBuild = pms.flatMap(pm => - scenarios - .filter(item => item.runs_ios) - .flatMap(item => - ['Debug', 'Release'].map(mode => - enrich(item, { pm, mode }) - ) - ) - ) - const androidBuild = pms.flatMap(pm => - scenarios - .filter(item => item.runs_android) - .flatMap(item => - ['Debug', 'Release'].map(mode => - enrich(item, { pm, mode }) - ) - ) - ) const iosHarness = scenarios .filter(item => item.runs_ios) .map(item => enrich(item, { pm: 'bun', mode: 'Debug' })) @@ -210,8 +190,6 @@ jobs: .map(item => enrich(item, { pm: 'bun', mode: 'Debug' })) console.log(`generation=${JSON.stringify({ include: generation })}`) - console.log(`ios_build=${JSON.stringify({ include: iosBuild })}`) - console.log(`android_build=${JSON.stringify({ include: androidBuild })}`) console.log(`ios_harness=${JSON.stringify({ include: iosHarness })}`) console.log(`android_harness=${JSON.stringify({ include: androidHarness })}`) NODE @@ -350,177 +328,9 @@ jobs: if-no-files-found: error retention-days: 7 - test-ios-build: - name: Test iOS Build - ${{ matrix.pm }} - ${{ matrix.package_type }} - ${{ matrix.scenario }} (${{ matrix.mode }}) - needs: [generate-packages, define-matrix] - runs-on: macOS-latest - strategy: - fail-fast: false - matrix: ${{ fromJson(needs.define-matrix.outputs.ios-build) }} - env: - WORKING_DIR: ${{ github.workspace }}/${{ matrix.package_dir }} - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Create working directory - run: mkdir -p ${{ env.WORKING_DIR }} - - - name: Download generated package - uses: actions/download-artifact@v8 - with: - name: test-${{ matrix.package_type }}-${{ matrix.scenario }}-${{ matrix.pm }} - path: ${{ env.WORKING_DIR }} - - - name: List package structure - working-directory: ${{ env.WORKING_DIR }} - run: | - echo "Package structure:" - find . -type f -name "*.json" -o -name "*.js" -o -name "*.ts" | head -20 - - - name: Setup Xcode - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: 16.4 - - - name: Setup Ruby and CocoaPods - uses: ruby/setup-ruby@v1 - with: - ruby-version: '3.2' - bundler-cache: true - - - name: Setup Node.js - if: matrix.pm == 'yarn' - uses: actions/setup-node@v6 - with: - node-version: 22.x - - - name: Setup Yarn - if: matrix.pm == 'yarn' - uses: ./.github/actions/setup-yarn - with: - working-directory: ${{ env.WORKING_DIR }} - - - name: Setup Bun.js - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - - name: Install package dependencies - uses: ./.github/actions/install-deps - with: - pm: ${{ matrix.pm }} - working-directory: ${{ env.WORKING_DIR }} - - - name: Run codegen and build - uses: ./.github/actions/run-codegen-build - with: - pm: ${{ matrix.pm }} - working-directory: ${{ env.WORKING_DIR }} - - - name: Cache CocoaPods - uses: actions/cache@v5 - with: - path: | - ~/.cocoapods/repos - ${{ env.WORKING_DIR }}/example/ios/Pods - key: ${{ runner.os }}-pods-${{ hashFiles(format('{0}/example/ios/Podfile.lock', env.WORKING_DIR)) }} - restore-keys: | - ${{ runner.os }}-pods- - - - name: Install CocoaPods dependencies - working-directory: ${{ env.WORKING_DIR }}/example - run: ${{ matrix.pm }} pod - - - name: Build iOS project - uses: ./.github/actions/ios-build-xcode - with: - ios-dir: ${{ env.WORKING_DIR }}/example/ios - mode: ${{ matrix.mode }} - - test-android-build: - name: Test Android Build - ${{ matrix.pm }} - ${{ matrix.package_type }} - ${{ matrix.scenario }} (${{ matrix.mode }}) - needs: [generate-packages, define-matrix] - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: ${{ fromJson(needs.define-matrix.outputs.android-build) }} - env: - WORKING_DIR: ${{ github.workspace }}/${{ matrix.package_dir }} - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Create working directory - run: mkdir -p ${{ env.WORKING_DIR }} - - - name: Download generated package - uses: actions/download-artifact@v8 - with: - name: test-${{ matrix.package_type }}-${{ matrix.scenario }}-${{ matrix.pm }} - path: ${{ env.WORKING_DIR }} - - - name: List package structure - working-directory: ${{ env.WORKING_DIR }} - run: | - echo "Package structure:" - find . -type f -name "*.json" -o -name "*.js" -o -name "*.ts" | head -20 - - - name: Setup Node.js - if: matrix.pm == 'yarn' - uses: actions/setup-node@v6 - with: - node-version: 22.x - - - name: Setup Yarn - if: matrix.pm == 'yarn' - uses: ./.github/actions/setup-yarn - with: - working-directory: ${{ env.WORKING_DIR }} - - - name: Setup Bun.js - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - - name: Install package dependencies - uses: ./.github/actions/install-deps - with: - pm: ${{ matrix.pm }} - working-directory: ${{ env.WORKING_DIR }} - - - name: Run codegen and build - uses: ./.github/actions/run-codegen-build - with: - pm: ${{ matrix.pm }} - working-directory: ${{ env.WORKING_DIR }} - - - name: Setup Java for Android builds - uses: actions/setup-java@v5 - with: - distribution: 'zulu' - java-version: '17' - cache: 'gradle' - - - name: Cache Gradle - uses: actions/cache@v5 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles(format('{0}/example/android/**/*.gradle*', env.WORKING_DIR)) }} - restore-keys: | - ${{ runner.os }}-gradle- - - - name: Build Android project - uses: ./.github/actions/android-gradle-build - with: - android-dir: ${{ env.WORKING_DIR }}/example/android - mode: ${{ matrix.mode }} - harness-android: name: Android Harness - ${{ matrix.package_type }} - ${{ matrix.scenario }} - needs: [test-android-build, define-matrix] + needs: [generate-packages, define-matrix] runs-on: ubuntu-latest strategy: fail-fast: false @@ -604,7 +414,7 @@ jobs: harness-ios: name: iOS Harness - ${{ matrix.package_type }} - ${{ matrix.scenario }} - needs: [test-ios-build, define-matrix] + needs: [generate-packages, define-matrix] runs-on: macOS-15 strategy: fail-fast: false diff --git a/src/cli/create.ts b/src/cli/create.ts index 2e78c575..27a4e325 100644 --- a/src/cli/create.ts +++ b/src/cli/create.ts @@ -240,11 +240,12 @@ export const createModule = async ( packageName, platforms: answers.platforms, pm: answers.pm, - cwd: moduleBaseDir, + cwd: targetModulePath, spinner, packageType, finalPackageName, includeHarness: answers.includeHarness, + monorepo: answers.monorepo, skipInstall: options.skipInstall, skipExample: options.skipExample, }) @@ -292,7 +293,17 @@ export const createModule = async ( console.log( generateInstructions({ includeHarness: answers.includeHarness, + monorepo: answers.monorepo, modulePath: getInstructionsModulePath(targetModulePath), + packagePath: getInstructionsModulePath( + answers.monorepo + ? path.join( + targetModulePath, + 'packages', + finalPackageName + ) + : targetModulePath + ), pm: answers.pm, platforms: answers.platforms, skipExample: options.skipExample, @@ -385,6 +396,7 @@ const getUserAnswers = async ( description: `${kleur.yellow(`react-native-${name}`)} is a react native package built with Nitro`, platforms, packageType, + monorepo: options.monorepo === true, includeHarness: options.includeHarness === true, platformLangs: parsePlatformLangsOption( options.langs, @@ -465,6 +477,20 @@ const getUserAnswers = async ( initialValue: Nitro.Module, }) }, + monorepo: async () => { + if (options?.monorepo === true) { + return true + } + + return p.confirm({ + message: kleur.cyan( + 'Use a packages/ workspace layout for a monorepo?' + ), + initialValue: false, + active: 'yes', + inactive: 'no', + }) + }, platformLangs: async ({ results }) => { if (!results.platforms || !results.packageType) { throw new Error('Missing required selections') @@ -561,6 +587,7 @@ const getUserAnswers = async ( return { packageName: group.packageName, packageType: group.packageType, + monorepo: group.monorepo as boolean, platforms: group.platforms, platformLangs: group.platformLangs as PlatformLangMap, includeHarness: group.includeHarness as boolean, diff --git a/src/cli/index.ts b/src/cli/index.ts index 063961b8..958fa116 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -21,6 +21,10 @@ program '--include-harness', 'include React Native Harness setup in the example app' ) + .option( + '--monorepo', + 'create a monorepo workspace with the package inside packages/' + ) .option('-e, --skip-example', 'skip example app generation') .option('-i, --skip-install', 'skip installing dependencies') .option('--ci', 'run in CI mode') diff --git a/src/code-snippets/code.js.ts b/src/code-snippets/code.js.ts index e31a848f..6eecf457 100644 --- a/src/code-snippets/code.js.ts +++ b/src/code-snippets/code.js.ts @@ -264,19 +264,49 @@ export const harnessTestCode = ( finalModuleName: string, funcName: string, packageType: Nitro -) => `import { describe, it, expect } from 'react-native-harness' +) => { + if (packageType === Nitro.Module) { + return `import { describe, it, expect } from 'react-native-harness' import { ${toPascalCase(moduleName)} } from '${finalModuleName}' describe('${toPascalCase(moduleName)}', () => { - it('loads the native implementation', () => { - ${ - packageType === Nitro.Module - ? `expect(${toPascalCase(moduleName)}.${funcName}(1, 2)).toBe(3)` - : `expect(${toPascalCase(moduleName)}).toBeDefined()` + it('calls the native implementation', () => { + expect(${toPascalCase(moduleName)}.${funcName}(1, 2)).toBe(3) + }) +}) +` } + + return `import React from 'react' +import { StyleSheet } from 'react-native' +import { describe, it, expect, render } from 'react-native-harness' +import { screen } from '@react-native-harness/ui' +import { ${toPascalCase(moduleName)} } from '${finalModuleName}' + +describe('${toPascalCase(moduleName)}', () => { + it('renders the native view', async () => { + await render( + <${toPascalCase(moduleName)} + isRed={true} + style={styles.view} + testID="${moduleName}" + /> + ) + + const view = await screen.findByTestId('${moduleName}') + + expect(view.nativeId).toBeDefined() }) }) + +const styles = StyleSheet.create({ + view: { + width: 200, + height: 200, + }, +}) ` +} const getPackageManagerRunCommand = ( packageManager: PackageManager, diff --git a/src/constants.ts b/src/constants.ts index 59ba4e4a..96ed7210 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -48,7 +48,9 @@ export const NITRO_GRAPHIC = ` export const generateInstructions = ({ includeHarness, + monorepo, modulePath, + packagePath, pm, platforms, skipInstall, @@ -70,20 +72,20 @@ ${ Begin development: ${kleur.cyan('Define your module:')} - ${kleur.white('src/specs/')} ${kleur.dim('# Define your module specifications. e.g. src/specs/myModule.nitro.ts')} + ${kleur.white(`${monorepo ? `${packagePath}/` : ''}src/specs/`)} ${kleur.dim('# Define your module specifications. e.g. src/specs/myModule.nitro.ts')} ${kleur.green(`${pm} run codegen`)} ${kleur.dim('# Generates native interfaces from TypeScript definitions')} ${kleur.cyan('Implement native code:')} - ${kleur.white('ios/')} ${kleur.dim('# iOS native implementation using swift')} - ${kleur.white('android/')} ${kleur.dim('# Android native implementation using kotlin')} - ${kleur.white('cpp/')} ${kleur.dim('# C++ native implementation. Shareable between iOS and Android (Will be generated if c++ was selected)')} + ${kleur.white(`${monorepo ? `${packagePath}/` : ''}ios/`)} ${kleur.dim('# iOS native implementation using swift')} + ${kleur.white(`${monorepo ? `${packagePath}/` : ''}android/`)} ${kleur.dim('# Android native implementation using kotlin')} + ${kleur.white(`${monorepo ? `${packagePath}/` : ''}cpp/`)} ${kleur.dim('# C++ native implementation. Shareable between iOS and Android (Will be generated if c++ was selected)')} ${ skipExample ? '' : `Run your example app to test the package: - ${kleur.green('cd example')} + ${kleur.green(`cd ${monorepo ? `${packagePath}/example` : 'example'}`)} ${kleur.green(`${pm} run pod`)} ${kleur.dim('# Install CocoaPods dependencies (iOS)')} ${kleur.green(`${pm} run ios|android`)} ${kleur.dim('# Run your example app')}` } @@ -93,7 +95,7 @@ ${ ? '' : `\n\nRun your React Native Harness tests: - ${kleur.green('cd example')} + ${kleur.green(`cd ${monorepo ? `${packagePath}/example` : 'example'}`)} ${[ platforms.includes(SupportedPlatform.ANDROID) ? `${kleur.green(`${pm} run test:harness:android`)} ${kleur.dim('# Run native tests on Android')}` @@ -107,8 +109,8 @@ ${ } ${kleur.yellow('Pro Tips:')} -${kleur.dim('โ€ข iOS:')} Open ${kleur.green('example/ios/example.xcworkspace')} in Xcode for native debugging. Make sure to run ${kleur.green(`${pm} pod`)} first in the example directory -${kleur.dim('โ€ข Android:')} Open ${kleur.green('example/android')} in Android Studio +${kleur.dim('โ€ข iOS:')} Open ${kleur.green(`${monorepo ? `${packagePath}/` : ''}example/ios/example.xcworkspace`)} in Xcode for native debugging. Make sure to run ${kleur.green(`${pm} pod`)} first in the example directory +${kleur.dim('โ€ข Android:')} Open ${kleur.green(`${monorepo ? `${packagePath}/` : ''}example/android`)} in Android Studio ${kleur.dim('โ€ข Metro:')} Clear cache with ${kleur.green(`${pm} start`)} if needed ${kleur.yellow('Need help?')} Create an issue: ${kleur.blue().underline('https://github.com/patrickkabwe/create-nitro-module/issues')} diff --git a/src/generate-nitro-package.ts b/src/generate-nitro-package.ts index e459e0cb..65da7d45 100644 --- a/src/generate-nitro-package.ts +++ b/src/generate-nitro-package.ts @@ -54,9 +54,16 @@ const execAsync = util.promisify(exec) const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) type NitroDependencyName = 'nitrogen' | 'react-native-nitro-modules' +type PackageJson = Record & { + name?: string + private?: boolean + scripts?: Record + workspaces?: string[] +} export class NitroModuleFactory { private nitroModulesVersion: string | null = null + private workspaceRoot: string private androidGenerator: AndroidFileGenerator private iosGenerator: IOSFileGenerator @@ -74,10 +81,15 @@ export class NitroModuleFactory { this.config.funcName = 'sum' this.config.prefix = 'react-native-' this.config.finalPackageName = `${this.config.prefix}${this.config.packageName}` - this.config.cwd = path.join( - this.config.cwd, - this.config.finalPackageName - ) + this.workspaceRoot = this.config.cwd + const packageDir = this.config.monorepo + ? path.join( + this.config.cwd, + 'packages', + this.config.finalPackageName + ) + : this.config.cwd + this.config.cwd = packageDir } async createNitroModule() { @@ -115,6 +127,12 @@ export class NitroModuleFactory { await this.copyNitroTemplateFiles() await this.replaceNitroJsonPlaceholders() await this.updatePackageJsonConfig(this.config.skipExample) + if (this.config.monorepo) { + await this.createWorkspaceRoot() + if (this.config.pm === 'yarn') { + await this.configureYarnWorkspace() + } + } await this.updateTemplateFiles() if (!this.config.skipExample) { @@ -193,6 +211,70 @@ export class NitroModuleFactory { } } + private getWorkspaceRunCommand(scriptName: string): string { + const packageWorkspacePath = `packages/${this.config.finalPackageName}` + + if (this.config.pm === 'yarn') { + return `yarn --cwd ${packageWorkspacePath} ${scriptName}` + } + + if (this.config.pm === 'pnpm') { + return `pnpm --dir ${packageWorkspacePath} run ${scriptName}` + } + + if (this.config.pm === 'npm') { + return `npm --prefix ${packageWorkspacePath} run ${scriptName}` + } + + return `bun --cwd ${packageWorkspacePath} run ${scriptName}` + } + + private async createWorkspaceRoot(): Promise { + const workspaces = this.config.skipExample + ? ['packages/*'] + : ['packages/*', 'packages/*/example'] + const rootPackageJson: PackageJson = { + name: `${this.config.finalPackageName}-monorepo`, + private: true, + scripts: { + build: this.getWorkspaceRunCommand('build'), + codegen: this.getWorkspaceRunCommand('codegen'), + }, + workspaces, + } + + await writeFile( + path.join(this.workspaceRoot, 'package.json'), + JSON.stringify(rootPackageJson, null, 2), + { encoding: 'utf8' } + ) + + if (this.config.pm !== 'pnpm') { + return + } + + await writeFile( + path.join(this.workspaceRoot, 'pnpm-workspace.yaml'), + `packages:\n${workspaces.map(workspace => ` - ${workspace}`).join('\n')}\n`, + { encoding: 'utf8' } + ) + } + + private async configureYarnWorkspace(): Promise { + const yarnCwd = this.config.monorepo + ? this.workspaceRoot + : this.config.cwd + await execAsync('corepack enable', { cwd: yarnCwd }) + await execAsync('yarn set version 4.6.0', { cwd: yarnCwd }) + await execAsync('yarn config set enableImmutableInstalls false', { + cwd: yarnCwd, + }) + await execAsync('yarn config set nodeLinker node-modules', { + cwd: yarnCwd, + }) + await execAsync('corepack disable', { cwd: yarnCwd }) + } + private async replaceNitroJsonPlaceholders() { const nitroJsonContent = await readFile( path.join(this.config.cwd, 'nitro.json'), @@ -280,35 +362,34 @@ export class NitroModuleFactory { ] if (this.config.pm === 'yarn') { - await execAsync('corepack enable', { cwd: this.config.cwd }) - await execAsync('yarn set version 4.6.0', { cwd: this.config.cwd }) - await execAsync('yarn config set enableImmutableInstalls false', { - cwd: this.config.cwd, - }) - await execAsync('yarn config set nodeLinker node-modules', { - cwd: this.config.cwd, - }) - await execAsync('corepack disable', { cwd: this.config.cwd }) + if (!this.config.monorepo) { + await this.configureYarnWorkspace() + } } else if (this.config.pm === 'pnpm') { const workspaceDirs = ['example'] const yamlContent = `packages:\n${workspaceDirs.map(d => ` - ${d}`).join('\n')}\n` - const WORKSPACE_FILENAME = 'pnpm-workspace.yaml' - await writeFile( - path.join(this.config.cwd, WORKSPACE_FILENAME), - yamlContent, - { encoding: 'utf8' } - ) + if (!this.config.monorepo) { + const WORKSPACE_FILENAME = 'pnpm-workspace.yaml' + await writeFile( + path.join(this.config.cwd, WORKSPACE_FILENAME), + yamlContent, + { encoding: 'utf8' } + ) + } const NPMRC_FILENAME = '.npmrc' await writeFile( - path.join(this.config.cwd, NPMRC_FILENAME), + path.join( + this.config.monorepo ? this.workspaceRoot : this.config.cwd, + NPMRC_FILENAME + ), 'node-linker=hoisted', { encoding: 'utf8' } ) delete newWorkspacePackageJsonFile.workspaces } - if (skipExample) { + if (skipExample || this.config.monorepo) { delete newWorkspacePackageJsonFile.workspaces } await writeFile( @@ -374,8 +455,23 @@ export class NitroModuleFactory { [__dirname, '..', 'assets', 'template'], filesToCopy ) + if (this.config.monorepo) { + await rename( + path.join(this.config.cwd, '.github'), + path.join(this.workspaceRoot, '.github') + ) + if (this.config.pm === 'bun') { + await rename( + path.join(this.config.cwd, 'bunfig.toml'), + path.join(this.workspaceRoot, 'bunfig.toml') + ) + } + } const oldGitIgnorePath = path.join(this.config.cwd, 'gitignore') - const newGitIgnorePath = path.join(this.config.cwd, '.gitignore') + const newGitIgnorePath = path.join( + this.config.monorepo ? this.workspaceRoot : this.config.cwd, + '.gitignore' + ) await rename(oldGitIgnorePath, newGitIgnorePath) } @@ -447,10 +543,14 @@ export class NitroModuleFactory { reactNativeHarnessVersion, androidHarnessVersion, appleHarnessVersion, + uiHarnessVersion, ] = await Promise.all([ this.getLatestVersion('react-native-harness'), this.getLatestVersion('@react-native-harness/platform-android'), this.getLatestVersion('@react-native-harness/platform-apple'), + this.config.packageType === Nitro.View + ? this.getLatestVersion('@react-native-harness/ui') + : Promise.resolve(null), ]) exampleAppPackageJson.scripts = { @@ -491,6 +591,14 @@ export class NitroModuleFactory { : '^1.0.0', } : {}), + ...(this.config.packageType === Nitro.View + ? { + '@react-native-harness/ui': + uiHarnessVersion != null + ? `^${uiHarnessVersion}` + : '^1.0.0', + } + : {}), } } @@ -686,7 +794,9 @@ export class NitroModuleFactory { this.config.cwd, 'example', '__tests__', - `${this.config.packageName}.harness.ts` + `${this.config.packageName}.harness.${ + this.config.packageType === Nitro.View ? 'tsx' : 'ts' + }` ), harnessTestCode( this.config.packageName, @@ -700,7 +810,9 @@ export class NitroModuleFactory { } private async installDependenciesAndRunCodegen() { - await execAsync(`${this.config.pm} install`, { cwd: this.config.cwd }) + await execAsync(`${this.config.pm} install`, { + cwd: this.config.monorepo ? this.workspaceRoot : this.config.cwd, + }) const packageManager = this.config.pm === 'npm' ? 'npx --yes' : this.config.pm const codegenCommand = `${packageManager} nitrogen --logLevel="debug" && ${this.config.pm} run build${Object.values(this.config.platformLangs).includes(SupportedLang.KOTLIN) ? ' && node post-script.js' : ''}` @@ -708,24 +820,37 @@ export class NitroModuleFactory { } private async gitInit() { - await execAsync('git init', { cwd: this.config.cwd }) - await execAsync('git add .', { cwd: this.config.cwd }) + const gitCwd = this.config.monorepo + ? this.workspaceRoot + : this.config.cwd + await execAsync('git init', { cwd: gitCwd }) + await execAsync('git add .', { cwd: gitCwd }) await execAsync('git commit -m "initial commit"', { - cwd: this.config.cwd, + cwd: gitCwd, }) } private async setupWorkflows() { + const workflowRoot = this.config.monorepo + ? this.workspaceRoot + : this.config.cwd const iosBuildWorkflowPath = path.join( - this.config.cwd, + workflowRoot, '.github', 'workflows', 'ios-build.yml' ) + const androidBuildWorkflowPath = path.join( + workflowRoot, + '.github', + 'workflows', + 'android-build.yml' + ) - const iosBuildWorkflow = await readFile(iosBuildWorkflowPath, { - encoding: 'utf8', - }) + const [iosBuildWorkflow, androidBuildWorkflow] = await Promise.all([ + readFile(iosBuildWorkflowPath, { encoding: 'utf8' }), + readFile(androidBuildWorkflowPath, { encoding: 'utf8' }), + ]) const iosBuildReplacements = { $$exampleApp$$: `${toPascalCase(this.config.packageName)}Example`, @@ -736,9 +861,22 @@ export class NitroModuleFactory { replacements: iosBuildReplacements, }) - await writeFile(iosBuildWorkflowPath, iosBuildWorkflowContent, { - encoding: 'utf8', - }) + await Promise.all([ + writeFile( + iosBuildWorkflowPath, + this.getWorkflowContent(iosBuildWorkflowContent), + { + encoding: 'utf8', + } + ), + writeFile( + androidBuildWorkflowPath, + this.getWorkflowContent(androidBuildWorkflow), + { + encoding: 'utf8', + } + ), + ]) if (!this.config.includeHarness) { return @@ -746,7 +884,7 @@ export class NitroModuleFactory { const exampleAppName = `${toPascalCase(this.config.packageName)}Example` const workflowDirectoryPath = path.join( - this.config.cwd, + workflowRoot, '.github', 'workflows' ) @@ -754,7 +892,13 @@ export class NitroModuleFactory { const workflowWrites = this.config.platforms.map(platform => writeFile( path.join(workflowDirectoryPath, `harness-${platform}.yml`), - harnessWorkflowCode(exampleAppName, this.config.pm, platform), + this.getWorkflowContent( + harnessWorkflowCode( + exampleAppName, + this.config.pm, + platform + ) + ), { encoding: 'utf8', } @@ -763,4 +907,33 @@ export class NitroModuleFactory { await Promise.all(workflowWrites) } + + private getWorkflowContent(content: string): string { + if (!this.config.monorepo) { + return content + } + + const packagePath = `packages/${this.config.finalPackageName}` + const replacements: Record = { + 'example/': `${packagePath}/example/`, + 'cpp/**': `${packagePath}/cpp/**`, + 'android/**': `${packagePath}/android/**`, + 'ios/**': `${packagePath}/ios/**`, + 'src/**': `${packagePath}/src/**`, + 'nitrogen/**': `${packagePath}/nitrogen/**`, + "'*.podspec'": `'${packagePath}/*.podspec'`, + "'package.json'": `'${packagePath}/package.json'`, + "'react-native.config.js'": `'${packagePath}/react-native.config.js'`, + "'nitro.json'": `'${packagePath}/nitro.json'`, + 'working-directory: example': `working-directory: ${packagePath}/example`, + 'projectRoot: example': `projectRoot: ${packagePath}/example`, + 'app: example/': `app: ${packagePath}/example/`, + } + + return Object.entries(replacements).reduce( + (workflowContent, [search, value]) => + workflowContent.replaceAll(search, value), + content + ) + } } diff --git a/src/types.ts b/src/types.ts index 4623778e..4b0b77ce 100644 --- a/src/types.ts +++ b/src/types.ts @@ -10,6 +10,7 @@ export interface UserAnswers { packageType: Nitro platformLangs: PlatformLangMap includeHarness: boolean + monorepo: boolean pm: PackageManager } @@ -39,6 +40,7 @@ export type CreateModuleOptions = { skipExample?: boolean skipInstall?: boolean packageType?: Nitro + monorepo?: boolean } export type PackageManager = Exclude< @@ -63,7 +65,8 @@ export type GenerateModuleConfig = { packageType: Nitro packageName: string finalPackageName: string -} & Omit + monorepo: boolean +} & Omit export interface FileGenerator { /** @@ -82,7 +85,9 @@ export const PLATFORM_LANGUAGE_MAP: Record = export type InstructionsParams = { includeHarness?: boolean + monorepo: boolean modulePath: string + packagePath: string pm: string platforms: SupportedPlatform[] skipInstall?: boolean From 6fffa2caa38397b50dcabb96c0f5b2ed473f9f46 Mon Sep 17 00:00:00 2001 From: Patrick Kabwe Date: Tue, 12 May 2026 11:32:04 +0200 Subject: [PATCH 07/22] fix: use harness for generated package tests --- .github/actions/ios-build-xcode/action.yml | 8 ++++---- src/code-snippets/code.js.ts | 6 +++--- src/generate-nitro-package.ts | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/actions/ios-build-xcode/action.yml b/.github/actions/ios-build-xcode/action.yml index 2a17a7ac..c053b42c 100644 --- a/.github/actions/ios-build-xcode/action.yml +++ b/.github/actions/ios-build-xcode/action.yml @@ -18,7 +18,7 @@ runs: set -euo pipefail gem install xcpretty cd "${{ inputs.ios-dir }}" - + set -o pipefail WORKSPACE_NAME="$(find . -maxdepth 1 -name '*.xcworkspace' ! -name 'Pods.xcworkspace' -exec basename {} .xcworkspace \; | head -1)" @@ -34,10 +34,10 @@ runs: fi echo "Building workspace ${WORKSPACE_NAME}.xcworkspace with scheme ${SCHEME_NAME}" - + max_retries=3 attempt=1 - + while [ "$attempt" -le "$max_retries" ] do echo "xcodebuild attempt $attempt of $max_retries" @@ -49,7 +49,7 @@ runs: -scheme "${SCHEME_NAME}" \ -sdk iphonesimulator \ -configuration "${{ inputs.mode }}" \ - -destination 'platform=iOS Simulator,name=iPhone 17' \ + -destination 'platform=iOS Simulator,name=iPhone 16' \ build \ CODE_SIGNING_ALLOWED=NO | xcpretty; then echo "xcodebuild succeeded on attempt $attempt" diff --git a/src/code-snippets/code.js.ts b/src/code-snippets/code.js.ts index 6eecf457..a9ac680e 100644 --- a/src/code-snippets/code.js.ts +++ b/src/code-snippets/code.js.ts @@ -186,7 +186,7 @@ const getHarnessRunnerConfig = ( return `applePlatform({ name: 'ios', - device: appleSimulator('iPhone 17', '26.5'), + device: appleSimulator('iPhone 16', '18.0'), bundleId: '${iosBundleId}', })` } @@ -382,7 +382,7 @@ ${getPackageManagerSetupStep(packageManager)} - name: Setup Xcode uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: 26.5 + xcode-version: 16.4 - name: Install Pods working-directory: example @@ -398,7 +398,7 @@ ${getPackageManagerSetupStep(packageManager)} -scheme ${exampleAppName} \ -sdk iphonesimulator \ -configuration Debug \ - -destination 'platform=iOS Simulator,name=iPhone 17' \ + -destination 'platform=iOS Simulator,name=iPhone 16' \ build \ CODE_SIGNING_ALLOWED=NO diff --git a/src/generate-nitro-package.ts b/src/generate-nitro-package.ts index 65da7d45..ef9c8d0f 100644 --- a/src/generate-nitro-package.ts +++ b/src/generate-nitro-package.ts @@ -522,7 +522,7 @@ export class NitroModuleFactory { exampleAppPackageJson.scripts = { ...exampleAppPackageJson.scripts, - ios: "react-native run-ios --simulator='iPhone 17'", + ios: "react-native run-ios --simulator='iPhone 16'", start: 'react-native start --reset-cache', pod: 'bundle install && bundle exec pod install --project-directory=ios', } From eb4a70968854370c79fddf4b8a5b7638cadf4599 Mon Sep 17 00:00:00 2001 From: Patrick Kabwe Date: Sat, 16 May 2026 07:58:44 +0200 Subject: [PATCH 08/22] Update ci-packages.yml --- .github/workflows/ci-packages.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/ci-packages.yml b/.github/workflows/ci-packages.yml index 7caed886..829f3bc9 100644 --- a/.github/workflows/ci-packages.yml +++ b/.github/workflows/ci-packages.yml @@ -304,6 +304,23 @@ jobs: exit 1 fi + - name: Verify generated harness scripts + shell: bash + run: | + cd "${{ matrix.package_dir }}" + + if [ "${{ matrix.runs_android }}" = "true" ] && ! grep -q '"test:harness:android"' example/package.json; then + echo "Missing Android Harness script in example/package.json" + cat example/package.json + exit 1 + fi + + if [ "${{ matrix.runs_ios }}" = "true" ] && ! grep -q '"test:harness:ios"' example/package.json; then + echo "Missing iOS Harness script in example/package.json" + cat example/package.json + exit 1 + fi + - name: Test package.json content shell: bash run: | From d4db6cdeb6fc6911ad29fe899e55150ee3b00e11 Mon Sep 17 00:00:00 2001 From: Patrick Kabwe Date: Sat, 16 May 2026 08:54:29 +0200 Subject: [PATCH 09/22] update harness workflow and package scripts --- src/code-snippets/code.js.ts | 15 +++++++++------ src/constants.ts | 11 +---------- src/generate-nitro-package.ts | 13 +------------ 3 files changed, 11 insertions(+), 28 deletions(-) diff --git a/src/code-snippets/code.js.ts b/src/code-snippets/code.js.ts index a9ac680e..3861b4e6 100644 --- a/src/code-snippets/code.js.ts +++ b/src/code-snippets/code.js.ts @@ -167,7 +167,7 @@ const getHarnessRunnerConfig = ( platform: SupportedPlatform, androidBundleId: string | null, iosBundleId: string | null -) => { +): string => { if (platform === SupportedPlatform.ANDROID) { if (androidBundleId == null) { throw new Error('Android bundle id is required for Harness config') @@ -197,7 +197,7 @@ export const harnessConfigCode = ({ defaultRunner, entryPoint, iosBundleId, -}: HarnessConfigParams) => { +}: HarnessConfigParams): string => { const imports = [ ...(androidBundleId == null ? [] @@ -240,6 +240,7 @@ const config = { ${runners} ], defaultRunner: '${defaultRunner}', + bridgeTimeout: 300000, } export default config @@ -359,11 +360,12 @@ ${getPackageManagerSetupStep(packageManager)} run: ./gradlew assembleDebug --no-daemon --build-cache - name: Run React Native Harness - uses: callstackincubator/react-native-harness/actions/android@v1.0.0 + uses: callstackincubator/react-native-harness@v1.0.0 with: app: example/android/app/build/outputs/apk/debug/app-debug.apk runner: android - projectRoot: example` + projectRoot: example + packageManager: ${packageManager}` } return ` test: @@ -403,11 +405,12 @@ ${getPackageManagerSetupStep(packageManager)} CODE_SIGNING_ALLOWED=NO - name: Run React Native Harness - uses: callstackincubator/react-native-harness/actions/ios@v1.0.0 + uses: callstackincubator/react-native-harness@v1.0.0 with: app: example/ios/build/Build/Products/Debug-iphonesimulator/${exampleAppName}.app runner: ios - projectRoot: example` + projectRoot: example + packageManager: ${packageManager}` } export const harnessWorkflowCode = ( diff --git a/src/constants.ts b/src/constants.ts index 96ed7210..e71c4d76 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -96,16 +96,7 @@ ${ : `\n\nRun your React Native Harness tests: ${kleur.green(`cd ${monorepo ? `${packagePath}/example` : 'example'}`)} - ${[ - platforms.includes(SupportedPlatform.ANDROID) - ? `${kleur.green(`${pm} run test:harness:android`)} ${kleur.dim('# Run native tests on Android')}` - : null, - platforms.includes(SupportedPlatform.IOS) - ? `${kleur.green(`${pm} run test:harness:ios`)} ${kleur.dim('# Run native tests on iOS')}` - : null, - ] - .filter(Boolean) - .join('\n ')}` + ${kleur.green(`${pm} run test:harness`)} ${kleur.dim(`# Run native tests with the ${platforms.includes(SupportedPlatform.ANDROID) ? SupportedPlatform.ANDROID : SupportedPlatform.IOS} runner`)}` } ${kleur.yellow('Pro Tips:')} diff --git a/src/generate-nitro-package.ts b/src/generate-nitro-package.ts index ef9c8d0f..599aef77 100644 --- a/src/generate-nitro-package.ts +++ b/src/generate-nitro-package.ts @@ -555,18 +555,7 @@ export class NitroModuleFactory { exampleAppPackageJson.scripts = { ...exampleAppPackageJson.scripts, - ...(this.config.platforms.includes(SupportedPlatform.ANDROID) - ? { - 'test:harness:android': - 'react-native-harness --harnessRunner android', - } - : {}), - ...(this.config.platforms.includes(SupportedPlatform.IOS) - ? { - 'test:harness:ios': - 'react-native-harness --harnessRunner ios', - } - : {}), + 'test:harness': 'react-native-harness', } exampleAppPackageJson.devDependencies = { From c71b337231a1e6d0001aed625c11e9d2147ab877 Mon Sep 17 00:00:00 2001 From: Patrick Kabwe Date: Sun, 5 Jul 2026 13:46:28 +0200 Subject: [PATCH 10/22] feat: mono repo --- src/code-snippets/code.js.ts | 63 ++++++++-- src/constants.ts | 32 ++++- src/generate-nitro-package.ts | 230 +++++++++++++++++++++++----------- src/types.ts | 4 +- 4 files changed, 234 insertions(+), 95 deletions(-) diff --git a/src/code-snippets/code.js.ts b/src/code-snippets/code.js.ts index 3861b4e6..92bbd2b6 100644 --- a/src/code-snippets/code.js.ts +++ b/src/code-snippets/code.js.ts @@ -105,9 +105,9 @@ export const ${toPascalCase(moduleName)} = getHostComponent<${toPascalCase(modul export type ${toPascalCase(moduleName)}Ref = HybridRef<${toPascalCase(moduleName)}Props, ${toPascalCase(moduleName)}Methods> ` -export const metroConfig = `const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config'); +export const metroConfig = (packageRelativePath = '..') => `const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config'); const path = require('path'); -const root = path.resolve(__dirname, '..'); +const root = path.resolve(__dirname, '${packageRelativePath}'); /** * Metro configuration @@ -121,8 +121,8 @@ const config = { module.exports = mergeConfig(getDefaultConfig(__dirname), config);` -export const babelConfig = `const path = require('path'); -const pak = require('../package.json'); +export const babelConfig = (packageRelativePath = '..') => `const path = require('path'); +const pak = require('${packageRelativePath}/package.json'); module.exports = api => { api.cache(true); @@ -134,7 +134,7 @@ module.exports = api => { { extensions: ['.js', '.ts', '.json', '.jsx', '.tsx'], alias: { - [pak.name]: path.join(__dirname, '../', pak.source), + [pak.name]: path.join(__dirname, '${packageRelativePath}', pak.source), }, }, ], @@ -142,7 +142,30 @@ module.exports = api => { }; };` -export const exampleTsConfig = (finalModuleName: string) => `{ +export const exampleReactNativeConfig = (packageRelativePath = '..') => `const path = require('path') +const pkg = require('${packageRelativePath}/package.json') + +/** + * @type {import('@react-native-community/cli-types').Config} + */ +module.exports = { + project: { + ios: { + automaticPodsInstallation: true, + }, + }, + dependencies: { + [pkg.name]: { + root: path.join(__dirname, '${packageRelativePath}'), + }, + }, +} +` + +export const exampleTsConfig = ( + finalModuleName: string, + packageRelativePath = '..' +) => `{ "extends": "@react-native/typescript-config", "include": ["**/*.ts", "**/*.tsx"], "exclude": ["**/node_modules", "**/Pods"], @@ -150,7 +173,7 @@ export const exampleTsConfig = (finalModuleName: string) => `{ "strict": true, "baseUrl": ".", "paths": { - "${finalModuleName}": ["../src"] + "${finalModuleName}": ["${packageRelativePath}/src"] } } }` @@ -329,10 +352,25 @@ const getPackageManagerSetupStep = (packageManager: PackageManager) => { ` } +const getHarnessCodegenBuildStep = ( + packageManager: PackageManager, + monorepo: boolean +) => { + if (!monorepo) { + return '' + } + + return ` + - name: Run codegen and build + run: ${getPackageManagerRunCommand(packageManager, 'codegen')} && ${getPackageManagerRunCommand(packageManager, 'build')} +` +} + const getHarnessJobCode = ( exampleAppName: string, packageManager: PackageManager, - platform: SupportedPlatform + platform: SupportedPlatform, + monorepo = false ) => { if (platform === SupportedPlatform.ANDROID) { return ` test: @@ -347,7 +385,7 @@ ${getPackageManagerSetupStep(packageManager)} - name: Install dependencies run: ${packageManager} install - +${getHarnessCodegenBuildStep(packageManager, monorepo)} - name: Setup JDK 17 uses: actions/setup-java@v5 with: @@ -380,7 +418,7 @@ ${getPackageManagerSetupStep(packageManager)} - name: Install dependencies run: ${packageManager} install - +${getHarnessCodegenBuildStep(packageManager, monorepo)} - name: Setup Xcode uses: maxim-lobanov/setup-xcode@v1 with: @@ -416,7 +454,8 @@ ${getPackageManagerSetupStep(packageManager)} export const harnessWorkflowCode = ( exampleAppName: string, packageManager: PackageManager, - platform: SupportedPlatform + platform: SupportedPlatform, + monorepo = false ) => `name: Run React Native Harness ${platform === SupportedPlatform.ANDROID ? 'Android' : 'iOS'} permissions: @@ -466,7 +505,7 @@ concurrency: cancel-in-progress: true jobs: -${getHarnessJobCode(exampleAppName, packageManager, platform)} +${getHarnessJobCode(exampleAppName, packageManager, platform, monorepo)} ` export const postScript = (moduleName: string, isHybridView: boolean) => `/** diff --git a/src/constants.ts b/src/constants.ts index e71c4d76..7598d257 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -33,6 +33,29 @@ export const packagesToRemoveFromExampleApp = [ export const foldersToRemoveFromExampleApp = ['__tests__'] +const getHarnessInstructions = ( + monorepo: boolean, + pm: string, + platforms: SupportedPlatform[] +) => { + if (monorepo) { + const commands = [ + ...(platforms.includes(SupportedPlatform.ANDROID) + ? [`${pm} run test:harness:android`] + : []), + ...(platforms.includes(SupportedPlatform.IOS) + ? [`${pm} run test:harness:ios`] + : []), + ] + + return commands + .map(command => ` ${kleur.green(command)}`) + .join('\n') + } + + return ` ${kleur.green('cd example')}\n ${kleur.green(`${pm} run test:harness`)}` +} + export const NITRO_GRAPHIC = ` โ”Œโ”€โ”€โ”€โ”€โ”€โ” โ”‚ โฒ๏ธ | @@ -85,7 +108,7 @@ ${ ? '' : `Run your example app to test the package: - ${kleur.green(`cd ${monorepo ? `${packagePath}/example` : 'example'}`)} + ${kleur.green('cd example')} ${kleur.green(`${pm} run pod`)} ${kleur.dim('# Install CocoaPods dependencies (iOS)')} ${kleur.green(`${pm} run ios|android`)} ${kleur.dim('# Run your example app')}` } @@ -95,13 +118,12 @@ ${ ? '' : `\n\nRun your React Native Harness tests: - ${kleur.green(`cd ${monorepo ? `${packagePath}/example` : 'example'}`)} - ${kleur.green(`${pm} run test:harness`)} ${kleur.dim(`# Run native tests with the ${platforms.includes(SupportedPlatform.ANDROID) ? SupportedPlatform.ANDROID : SupportedPlatform.IOS} runner`)}` +${getHarnessInstructions(monorepo, pm, platforms)}` } ${kleur.yellow('Pro Tips:')} -${kleur.dim('โ€ข iOS:')} Open ${kleur.green(`${monorepo ? `${packagePath}/` : ''}example/ios/example.xcworkspace`)} in Xcode for native debugging. Make sure to run ${kleur.green(`${pm} pod`)} first in the example directory -${kleur.dim('โ€ข Android:')} Open ${kleur.green(`${monorepo ? `${packagePath}/` : ''}example/android`)} in Android Studio +${kleur.dim('โ€ข iOS:')} Open ${kleur.green('example/ios/example.xcworkspace')} in Xcode for native debugging. Make sure to run ${kleur.green(`${pm} pod`)} first in the example directory +${kleur.dim('โ€ข Android:')} Open ${kleur.green('example/android')} in Android Studio ${kleur.dim('โ€ข Metro:')} Clear cache with ${kleur.green(`${pm} start`)} if needed ${kleur.yellow('Need help?')} Create an issue: ${kleur.blue().underline('https://github.com/patrickkabwe/create-nitro-module/issues')} diff --git a/src/generate-nitro-package.ts b/src/generate-nitro-package.ts index 599aef77..9e51007d 100644 --- a/src/generate-nitro-package.ts +++ b/src/generate-nitro-package.ts @@ -9,6 +9,7 @@ import { androidSettingsGradleCode } from './code-snippets/code.kotlin' import { appExampleCode, babelConfig, + exampleReactNativeConfig, exampleTsConfig, harnessConfigCode, harnessJestConfigCode, @@ -92,6 +93,18 @@ export class NitroModuleFactory { this.config.cwd = packageDir } + private get exampleDir(): string { + return this.config.monorepo + ? path.join(this.workspaceRoot, 'example') + : path.join(this.config.cwd, 'example') + } + + private get packageRelativeFromExample(): string { + return this.config.monorepo + ? path.posix.join('..', 'packages', this.config.finalPackageName) + : '..' + } + async createNitroModule() { await createFolder(this.config.cwd) @@ -134,6 +147,9 @@ export class NitroModuleFactory { } } await this.updateTemplateFiles() + if (this.config.monorepo) { + await this.configureMonorepoReleaseConfig() + } if (!this.config.skipExample) { this.config.spinner.message(messages.generating) @@ -226,19 +242,62 @@ export class NitroModuleFactory { return `npm --prefix ${packageWorkspacePath} run ${scriptName}` } - return `bun --cwd ${packageWorkspacePath} run ${scriptName}` + return `bun run --cwd ${packageWorkspacePath} ${scriptName}` + } + + private getExampleRunCommand(scriptName: string): string { + if (this.config.pm === 'yarn') { + return `yarn --cwd example ${scriptName}` + } + + if (this.config.pm === 'pnpm') { + return `pnpm --dir example run ${scriptName}` + } + + if (this.config.pm === 'npm') { + return `npm --prefix example run ${scriptName}` + } + + return `bun run --cwd example ${scriptName}` } private async createWorkspaceRoot(): Promise { const workspaces = this.config.skipExample ? ['packages/*'] - : ['packages/*', 'packages/*/example'] + : ['packages/*', 'example'] const rootPackageJson: PackageJson = { - name: `${this.config.finalPackageName}-monorepo`, + name: this.config.finalPackageName, private: true, scripts: { build: this.getWorkspaceRunCommand('build'), codegen: this.getWorkspaceRunCommand('codegen'), + release: this.getWorkspaceRunCommand('release'), + ...(this.config.includeHarness && !this.config.skipExample + ? { + 'test:harness': + this.getExampleRunCommand('test:harness'), + ...(this.config.platforms.includes( + SupportedPlatform.ANDROID + ) + ? { + 'test:harness:android': + this.getExampleRunCommand( + 'test:harness:android' + ), + } + : {}), + ...(this.config.platforms.includes( + SupportedPlatform.IOS + ) + ? { + 'test:harness:ios': + this.getExampleRunCommand( + 'test:harness:ios' + ), + } + : {}), + } + : {}), }, workspaces, } @@ -302,11 +361,12 @@ export class NitroModuleFactory { } private getPostCodegenScript() { - let script = `${this.config.pm} --cwd example pod` + const examplePath = this.config.monorepo ? '../example' : 'example' + let script = `${this.config.pm} --cwd ${examplePath} pod` if (this.config.pm === 'npm') { - script = `${this.config.pm} --prefix example run pod` + script = `${this.config.pm} --prefix ${examplePath} run pod` } else if (this.config.pm === 'pnpm') { - script = `pnpm --filter ./example pod` + script = `pnpm --dir ${examplePath} run pod` } return script } @@ -432,6 +492,25 @@ export class NitroModuleFactory { }) } + private async configureMonorepoReleaseConfig(): Promise { + const releaseConfigPath = path.join(this.config.cwd, 'release.config.cjs') + const releaseConfig = await readFile(releaseConfigPath, { + encoding: 'utf8', + }) + const gitAssets = this.config.skipExample + ? "['package.json', 'CHANGELOG.md']" + : "['package.json', 'CHANGELOG.md', '../example/package.json']" + + await writeFile( + releaseConfigPath, + releaseConfig.replace( + /assets:\s*\[[^\]]+\]/, + `assets: ${gitAssets}` + ), + { encoding: 'utf8' } + ) + } + private async copyNitroTemplateFiles() { const filesToCopy = [ '.watchmanconfig', @@ -491,10 +570,12 @@ export class NitroModuleFactory { --package-name com.${replaceHyphen(this.config.packageName)}example \ --directory example --skip-install --skip-git-init --version ${reactNativeVersion}` - await execAsync(args, { cwd: this.config.cwd }) + await execAsync(args, { + cwd: this.config.monorepo ? this.workspaceRoot : this.config.cwd, + }) // Setup App.tsx - const appPath = path.join(this.config.cwd, 'example', 'App.tsx') + const appPath = path.join(this.exampleDir, 'App.tsx') await writeFile( appPath, appExampleCode( @@ -508,11 +589,7 @@ export class NitroModuleFactory { } private async configureExamplePackageJson() { - const packageJsonPath = path.join( - this.config.cwd, - 'example', - 'package.json' - ) + const packageJsonPath = path.join(this.exampleDir, 'package.json') const examplePackageJsonStr = await readFile(packageJsonPath, { encoding: 'utf8', }) @@ -530,6 +607,9 @@ export class NitroModuleFactory { const nitroKey = `react-native-nitro-modules` exampleAppPackageJson.dependencies = { ...exampleAppPackageJson.dependencies, + ...(this.config.monorepo + ? { [this.config.finalPackageName]: '*' } + : {}), [nitroKey]: this.nitroModulesVersion ?? '*', } @@ -556,6 +636,18 @@ export class NitroModuleFactory { exampleAppPackageJson.scripts = { ...exampleAppPackageJson.scripts, 'test:harness': 'react-native-harness', + ...(this.config.platforms.includes(SupportedPlatform.ANDROID) + ? { + 'test:harness:android': + 'react-native-harness --harnessRunner android', + } + : {}), + ...(this.config.platforms.includes(SupportedPlatform.IOS) + ? { + 'test:harness:ios': + 'react-native-harness --harnessRunner ios', + } + : {}), } exampleAppPackageJson.devDependencies = { @@ -603,50 +695,25 @@ export class NitroModuleFactory { } private async syncExampleAppConfigurations() { + const packageRelativePath = this.packageRelativeFromExample const reactNativeConfigPath = path.join( - this.config.cwd, - 'example', + this.exampleDir, 'react-native.config.js' ) - const replacements = { - [JS_PACKAGE_NAME_TAG]: this.config.finalPackageName, - } - - const reactNativeConfig = await replacePlaceholder({ - filePath: path.join( - __dirname, - '..', - 'assets', - 'react-native.config.js' - ), - replacements, - }) + const reactNativeConfig = exampleReactNativeConfig(packageRelativePath) // Setup metro.config.js - const metroConfigPath = path.join( - this.config.cwd, - 'example', - 'metro.config.js' - ) + const metroConfigPath = path.join(this.exampleDir, 'metro.config.js') // Setup babel.config.js - const babelConfigPath = path.join( - this.config.cwd, - 'example', - 'babel.config.js' - ) + const babelConfigPath = path.join(this.exampleDir, 'babel.config.js') // Setup tsconfig.json - const tsConfigPath = path.join( - this.config.cwd, - 'example', - 'tsconfig.json' - ) + const tsConfigPath = path.join(this.exampleDir, 'tsconfig.json') const androidSettingsGradlePath = path.join( - this.config.cwd, - 'example', + this.exampleDir, 'android', 'settings.gradle' ) @@ -658,8 +725,7 @@ export class NitroModuleFactory { ) const androidBuildGradlePath = path.join( - this.config.cwd, - 'example', + this.exampleDir, 'android', 'app', 'build.gradle' @@ -669,7 +735,7 @@ export class NitroModuleFactory { encoding: 'utf8', }) - const gradleReplacements = { + const gradleReplacements: Record = { '// reactNativeDir = file("../../node_modules/react-native")': 'reactNativeDir = file("../../../node_modules/react-native")', '// codegenDir = file("../../node_modules/@react-native/codegen")': @@ -687,11 +753,14 @@ export class NitroModuleFactory { const filesToWrite = [ { saveTo: reactNativeConfigPath, data: reactNativeConfig }, - { saveTo: metroConfigPath, data: metroConfig }, - { saveTo: babelConfigPath, data: babelConfig }, + { saveTo: metroConfigPath, data: metroConfig(packageRelativePath) }, + { saveTo: babelConfigPath, data: babelConfig(packageRelativePath) }, { saveTo: tsConfigPath, - data: exampleTsConfig(this.config.finalPackageName), + data: exampleTsConfig( + this.config.finalPackageName, + packageRelativePath + ), }, { saveTo: androidSettingsGradlePath, @@ -709,7 +778,7 @@ export class NitroModuleFactory { ) for (const folder of foldersToRemoveFromExampleApp) { - await rm(path.join(this.config.cwd, 'example', folder), { + await rm(path.join(this.exampleDir, folder), { recursive: true, force: true, }) @@ -719,8 +788,7 @@ export class NitroModuleFactory { private async getExampleIOSBundleId() { const exampleAppName = `${toPascalCase(this.config.packageName)}Example` const projectFilePath = path.join( - this.config.cwd, - 'example', + this.exampleDir, 'ios', `${exampleAppName}.xcodeproj`, 'project.pbxproj' @@ -759,11 +827,11 @@ export class NitroModuleFactory { ? SupportedPlatform.ANDROID : SupportedPlatform.IOS - await createFolder(this.config.cwd, path.join('example', '__tests__')) + await createFolder(this.exampleDir, '__tests__') await Promise.all([ writeFile( - path.join(this.config.cwd, 'example', 'rn-harness.config.mjs'), + path.join(this.exampleDir, 'rn-harness.config.mjs'), harnessConfigCode({ androidBundleId, appRegistryComponentName: exampleAppName, @@ -774,14 +842,13 @@ export class NitroModuleFactory { { encoding: 'utf8' } ), writeFile( - path.join(this.config.cwd, 'example', 'jest.config.js'), + path.join(this.exampleDir, 'jest.config.js'), harnessJestConfigCode(), { encoding: 'utf8' } ), writeFile( path.join( - this.config.cwd, - 'example', + this.exampleDir, '__tests__', `${this.config.packageName}.harness.${ this.config.packageType === Nitro.View ? 'tsx' : 'ts' @@ -885,7 +952,8 @@ export class NitroModuleFactory { harnessWorkflowCode( exampleAppName, this.config.pm, - platform + platform, + this.config.monorepo ) ), { @@ -903,26 +971,36 @@ export class NitroModuleFactory { } const packagePath = `packages/${this.config.finalPackageName}` - const replacements: Record = { - 'example/': `${packagePath}/example/`, + const packagePathReplacements: Record = { 'cpp/**': `${packagePath}/cpp/**`, 'android/**': `${packagePath}/android/**`, 'ios/**': `${packagePath}/ios/**`, 'src/**': `${packagePath}/src/**`, 'nitrogen/**': `${packagePath}/nitrogen/**`, - "'*.podspec'": `'${packagePath}/*.podspec'`, - "'package.json'": `'${packagePath}/package.json'`, - "'react-native.config.js'": `'${packagePath}/react-native.config.js'`, - "'nitro.json'": `'${packagePath}/nitro.json'`, - 'working-directory: example': `working-directory: ${packagePath}/example`, - 'projectRoot: example': `projectRoot: ${packagePath}/example`, - 'app: example/': `app: ${packagePath}/example/`, - } - - return Object.entries(replacements).reduce( - (workflowContent, [search, value]) => - workflowContent.replaceAll(search, value), - content - ) + 'nitrogen/generated/shared/**': `${packagePath}/nitrogen/generated/shared/**`, + 'nitrogen/generated/android/**': `${packagePath}/nitrogen/generated/android/**`, + 'nitrogen/generated/ios/**': `${packagePath}/nitrogen/generated/ios/**`, + '*.podspec': `${packagePath}/*.podspec`, + 'package.json': `${packagePath}/package.json`, + 'react-native.config.js': `${packagePath}/react-native.config.js`, + 'nitro.json': `${packagePath}/nitro.json`, + } + + return content + .split('\n') + .map(line => { + const pathMatch = line.match(/^\s*-\s+'([^']+)'\s*$/) + if (pathMatch == null) { + return line + } + + const replacement = packagePathReplacements[pathMatch[1]] + if (replacement == null) { + return line + } + + return line.replace(pathMatch[1], replacement) + }) + .join('\n') } } diff --git a/src/types.ts b/src/types.ts index 4b0b77ce..0aee006e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,5 @@ -import * as p from '@clack/prompts' -import { detectPackageManager } from './utils' +import type * as p from '@clack/prompts' +import type { detectPackageManager } from './utils' export type PlatformLangMap = Partial> From 5add5cd8074834972258bf646512aa8fbbefe178 Mon Sep 17 00:00:00 2001 From: Patrick Kabwe Date: Sun, 5 Jul 2026 13:46:52 +0200 Subject: [PATCH 11/22] chore: formatting --- src/code-snippets/code.js.ts | 12 +++++++++--- src/constants.ts | 4 +--- src/generate-nitro-package.ts | 5 ++++- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/code-snippets/code.js.ts b/src/code-snippets/code.js.ts index 92bbd2b6..75c994fc 100644 --- a/src/code-snippets/code.js.ts +++ b/src/code-snippets/code.js.ts @@ -105,7 +105,9 @@ export const ${toPascalCase(moduleName)} = getHostComponent<${toPascalCase(modul export type ${toPascalCase(moduleName)}Ref = HybridRef<${toPascalCase(moduleName)}Props, ${toPascalCase(moduleName)}Methods> ` -export const metroConfig = (packageRelativePath = '..') => `const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config'); +export const metroConfig = ( + packageRelativePath = '..' +) => `const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config'); const path = require('path'); const root = path.resolve(__dirname, '${packageRelativePath}'); @@ -121,7 +123,9 @@ const config = { module.exports = mergeConfig(getDefaultConfig(__dirname), config);` -export const babelConfig = (packageRelativePath = '..') => `const path = require('path'); +export const babelConfig = ( + packageRelativePath = '..' +) => `const path = require('path'); const pak = require('${packageRelativePath}/package.json'); module.exports = api => { @@ -142,7 +146,9 @@ module.exports = api => { }; };` -export const exampleReactNativeConfig = (packageRelativePath = '..') => `const path = require('path') +export const exampleReactNativeConfig = ( + packageRelativePath = '..' +) => `const path = require('path') const pkg = require('${packageRelativePath}/package.json') /** diff --git a/src/constants.ts b/src/constants.ts index 7598d257..1e2f2c2d 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -48,9 +48,7 @@ const getHarnessInstructions = ( : []), ] - return commands - .map(command => ` ${kleur.green(command)}`) - .join('\n') + return commands.map(command => ` ${kleur.green(command)}`).join('\n') } return ` ${kleur.green('cd example')}\n ${kleur.green(`${pm} run test:harness`)}` diff --git a/src/generate-nitro-package.ts b/src/generate-nitro-package.ts index 9e51007d..fadba277 100644 --- a/src/generate-nitro-package.ts +++ b/src/generate-nitro-package.ts @@ -493,7 +493,10 @@ export class NitroModuleFactory { } private async configureMonorepoReleaseConfig(): Promise { - const releaseConfigPath = path.join(this.config.cwd, 'release.config.cjs') + const releaseConfigPath = path.join( + this.config.cwd, + 'release.config.cjs' + ) const releaseConfig = await readFile(releaseConfigPath, { encoding: 'utf8', }) From c9ac1f2ee258e28cf7b118fc7fb9939c13408c72 Mon Sep 17 00:00:00 2001 From: Patrick Kabwe Date: Sun, 5 Jul 2026 14:39:10 +0200 Subject: [PATCH 12/22] fix: discover harness targets dynamically --- .github/actions/ios-build-xcode/action.yml | 3 +- package.json | 2 +- src/code-snippets/code.js.ts | 92 ++++++++++++++++++++-- src/generate-nitro-package.ts | 2 +- 4 files changed, 90 insertions(+), 9 deletions(-) diff --git a/.github/actions/ios-build-xcode/action.yml b/.github/actions/ios-build-xcode/action.yml index c053b42c..67856b27 100644 --- a/.github/actions/ios-build-xcode/action.yml +++ b/.github/actions/ios-build-xcode/action.yml @@ -42,14 +42,13 @@ runs: do echo "xcodebuild attempt $attempt of $max_retries" - if xcodebuild \ + if xcodebuild \ CC=clang CPLUSPLUS=clang++ LD=clang LDPLUSPLUS=clang++ \ -derivedDataPath build -UseModernBuildSystem=YES \ -workspace "${WORKSPACE_NAME}.xcworkspace" \ -scheme "${SCHEME_NAME}" \ -sdk iphonesimulator \ -configuration "${{ inputs.mode }}" \ - -destination 'platform=iOS Simulator,name=iPhone 16' \ build \ CODE_SIGNING_ALLOWED=NO | xcpretty; then echo "xcodebuild succeeded on attempt $attempt" diff --git a/package.json b/package.json index 91e21a87..5589ecca 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,6 @@ "typescript": "^5.9.3" }, "engines": { - "node": ">=22.14.0" + "node": ">=24.3.0" } } diff --git a/src/code-snippets/code.js.ts b/src/code-snippets/code.js.ts index 75c994fc..c70cf327 100644 --- a/src/code-snippets/code.js.ts +++ b/src/code-snippets/code.js.ts @@ -192,6 +192,83 @@ type HarnessConfigParams = { iosBundleId: string | null } +const getIosHarnessDeviceResolverCode = () => `const resolveIosDevice = async () => { + const targets = await getAppleRunTargets() + const simulatorTargets = targets.filter(target => target.platform === 'ios' && target.type === 'emulator') + const preferredName = process.env.HARNESS_IOS_SIMULATOR_NAME + const preferredVersion = process.env.HARNESS_IOS_SIMULATOR_VERSION + + if (preferredName != null || preferredVersion != null) { + const preferredTarget = simulatorTargets.find(target => { + if (preferredName != null && target.device.name !== preferredName) { + return false + } + + if (preferredVersion != null && target.device.systemVersion !== preferredVersion) { + return false + } + + return true + }) + + if (preferredTarget == null) { + throw new Error( + \`No iOS simulator matched HARNESS_IOS_SIMULATOR_NAME=\${preferredName ?? 'unset'} and HARNESS_IOS_SIMULATOR_VERSION=\${preferredVersion ?? 'unset'}. Available simulators: \${simulatorTargets.map(target => \`\${target.device.name} (\${target.device.systemVersion})\`).join(', ') || 'none'}\` + ) + } + + return appleSimulator( + preferredTarget.device.name, + preferredTarget.device.systemVersion + ) + } + + const defaultTarget = simulatorTargets[0] + if (defaultTarget == null) { + throw new Error( + \`No available iOS simulators were found for React Native Harness. Available run targets: \${targets.map(target => \`\${target.name} (\${target.description})\`).join(', ') || 'none'}\` + ) + } + + return appleSimulator( + defaultTarget.device.name, + defaultTarget.device.systemVersion + ) +} + +const iosDevice = await resolveIosDevice() +` + +const getAndroidHarnessDeviceResolverCode = () => `const resolveAndroidDevice = async () => { + const targets = await getAndroidRunTargets() + const emulatorTargets = targets.filter(target => target.platform === 'android' && target.type === 'emulator') + const preferredName = process.env.HARNESS_ANDROID_EMULATOR_NAME + + if (preferredName != null) { + const preferredTarget = emulatorTargets.find(target => target.device.name === preferredName) + + if (preferredTarget == null) { + throw new Error( + \`No Android emulator matched HARNESS_ANDROID_EMULATOR_NAME=\${preferredName}. Available emulators: \${emulatorTargets.map(target => target.device.name).join(', ') || 'none'}\` + ) + } + + return androidEmulator(preferredTarget.device.name) + } + + const defaultTarget = emulatorTargets[0] + if (defaultTarget == null) { + throw new Error( + \`No available Android emulators were found for React Native Harness. Available run targets: \${targets.map(target => \`\${target.name} (\${target.description})\`).join(', ') || 'none'}\` + ) + } + + return androidEmulator(defaultTarget.device.name) +} + +const androidDevice = await resolveAndroidDevice() +` + const getHarnessRunnerConfig = ( platform: SupportedPlatform, androidBundleId: string | null, @@ -204,7 +281,7 @@ const getHarnessRunnerConfig = ( return `androidPlatform({ name: 'android', - device: androidEmulator('Pixel_8_API_35'), + device: androidDevice, bundleId: '${androidBundleId}', })` } @@ -215,7 +292,7 @@ const getHarnessRunnerConfig = ( return `applePlatform({ name: 'ios', - device: appleSimulator('iPhone 16', '18.0'), + device: iosDevice, bundleId: '${iosBundleId}', })` } @@ -231,14 +308,18 @@ export const harnessConfigCode = ({ ...(androidBundleId == null ? [] : [ - "import { androidEmulator, androidPlatform } from '@react-native-harness/platform-android'", + "import { androidEmulator, androidPlatform, getRunTargets as getAndroidRunTargets } from '@react-native-harness/platform-android'", ]), ...(iosBundleId == null ? [] : [ - "import { applePlatform, appleSimulator } from '@react-native-harness/platform-apple'", + "import { applePlatform, appleSimulator, getRunTargets as getAppleRunTargets } from '@react-native-harness/platform-apple'", ]), ].join('\n') + const deviceResolvers = [ + ...(iosBundleId == null ? [] : [getIosHarnessDeviceResolverCode()]), + ...(androidBundleId == null ? [] : [getAndroidHarnessDeviceResolverCode()]), + ].join('\n') const runners = [ ...(androidBundleId == null ? [] @@ -262,6 +343,8 @@ export const harnessConfigCode = ({ return `${imports} +${deviceResolvers} + const config = { entryPoint: '${entryPoint}', appRegistryComponentName: '${appRegistryComponentName}', @@ -444,7 +527,6 @@ ${getHarnessCodegenBuildStep(packageManager, monorepo)} -scheme ${exampleAppName} \ -sdk iphonesimulator \ -configuration Debug \ - -destination 'platform=iOS Simulator,name=iPhone 16' \ build \ CODE_SIGNING_ALLOWED=NO diff --git a/src/generate-nitro-package.ts b/src/generate-nitro-package.ts index fadba277..c86c852b 100644 --- a/src/generate-nitro-package.ts +++ b/src/generate-nitro-package.ts @@ -602,7 +602,7 @@ export class NitroModuleFactory { exampleAppPackageJson.scripts = { ...exampleAppPackageJson.scripts, - ios: "react-native run-ios --simulator='iPhone 16'", + ios: 'react-native run-ios', start: 'react-native start --reset-cache', pod: 'bundle install && bundle exec pod install --project-directory=ios', } From b3720afde7a084cd0a431cac750f866efc6da303 Mon Sep 17 00:00:00 2001 From: Patrick Kabwe Date: Sun, 5 Jul 2026 14:39:15 +0200 Subject: [PATCH 13/22] Update code.js.ts --- src/code-snippets/code.js.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/code-snippets/code.js.ts b/src/code-snippets/code.js.ts index c70cf327..069c58a7 100644 --- a/src/code-snippets/code.js.ts +++ b/src/code-snippets/code.js.ts @@ -192,7 +192,8 @@ type HarnessConfigParams = { iosBundleId: string | null } -const getIosHarnessDeviceResolverCode = () => `const resolveIosDevice = async () => { +const getIosHarnessDeviceResolverCode = + () => `const resolveIosDevice = async () => { const targets = await getAppleRunTargets() const simulatorTargets = targets.filter(target => target.platform === 'ios' && target.type === 'emulator') const preferredName = process.env.HARNESS_IOS_SIMULATOR_NAME @@ -239,7 +240,8 @@ const getIosHarnessDeviceResolverCode = () => `const resolveIosDevice = async () const iosDevice = await resolveIosDevice() ` -const getAndroidHarnessDeviceResolverCode = () => `const resolveAndroidDevice = async () => { +const getAndroidHarnessDeviceResolverCode = + () => `const resolveAndroidDevice = async () => { const targets = await getAndroidRunTargets() const emulatorTargets = targets.filter(target => target.platform === 'android' && target.type === 'emulator') const preferredName = process.env.HARNESS_ANDROID_EMULATOR_NAME @@ -318,7 +320,9 @@ export const harnessConfigCode = ({ ].join('\n') const deviceResolvers = [ ...(iosBundleId == null ? [] : [getIosHarnessDeviceResolverCode()]), - ...(androidBundleId == null ? [] : [getAndroidHarnessDeviceResolverCode()]), + ...(androidBundleId == null + ? [] + : [getAndroidHarnessDeviceResolverCode()]), ].join('\n') const runners = [ ...(androidBundleId == null From 7417a26820bc81fe2cc0c7c89cd969339e919d54 Mon Sep 17 00:00:00 2001 From: Patrick Kabwe Date: Sun, 5 Jul 2026 18:30:01 +0200 Subject: [PATCH 14/22] fix: pass build artifacts to harness runners --- .github/workflows/ci-packages.yml | 12 ++++++++++-- src/generate-nitro-package.ts | 29 +++++++++++++++++++++++++++-- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci-packages.yml b/.github/workflows/ci-packages.yml index 679a2012..6bb22fa0 100644 --- a/.github/workflows/ci-packages.yml +++ b/.github/workflows/ci-packages.yml @@ -623,7 +623,7 @@ jobs: script: | adb wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done' cd "${{ env.WORKING_DIR }}/example" - ${{ matrix.pm }} run test:harness:android + HARNESS_APP_PATH="${{ env.WORKING_DIR }}/example/android/app/build/outputs/apk/debug/app-debug.apk" ${{ matrix.pm }} run test:harness:android harness-ios: name: iOS Harness - ${{ matrix.package_type }} - ${{ matrix.scenario }} @@ -697,4 +697,12 @@ jobs: - name: Run iOS Harness Tests working-directory: ${{ env.WORKING_DIR }}/example - run: ${{ matrix.pm }} run test:harness:ios + shell: bash + run: | + APP_PATH="$(find ios/build/Build/Products/Debug-iphonesimulator -maxdepth 1 -type d -name '*.app' | head -1)" + if [ -z "$APP_PATH" ]; then + echo "No built iOS app bundle found in ios/build/Build/Products/Debug-iphonesimulator" + exit 1 + fi + + HARNESS_APP_PATH="$APP_PATH" ${{ matrix.pm }} run test:harness:ios diff --git a/src/generate-nitro-package.ts b/src/generate-nitro-package.ts index c86c852b..6b7fafe0 100644 --- a/src/generate-nitro-package.ts +++ b/src/generate-nitro-package.ts @@ -597,6 +597,7 @@ export class NitroModuleFactory { encoding: 'utf8', }) const exampleAppPackageJson = JSON.parse(examplePackageJsonStr) + const exampleAppName = `${toPascalCase(this.config.packageName)}Example` exampleAppPackageJson.name = `${this.config.finalPackageName}-example` @@ -642,13 +643,37 @@ export class NitroModuleFactory { ...(this.config.platforms.includes(SupportedPlatform.ANDROID) ? { 'test:harness:android': - 'react-native-harness --harnessRunner android', + [ + 'if [ -z "${HARNESS_APP_PATH:-}" ]; then', + ' set -euo pipefail', + ' (cd android && ./gradlew assembleDebug --no-daemon --build-cache)', + ' HARNESS_APP_PATH="$(find android/app/build/outputs/apk/debug -maxdepth 1 -type f -name "*.apk" | head -1)"', + ' if [ -z "${HARNESS_APP_PATH}" ]; then', + ' echo "Unable to locate the built Android app bundle."', + ' exit 1', + ' fi', + ' export HARNESS_APP_PATH', + 'fi', + 'react-native-harness --harnessRunner android', + ].join('\n'), } : {}), ...(this.config.platforms.includes(SupportedPlatform.IOS) ? { 'test:harness:ios': - 'react-native-harness --harnessRunner ios', + [ + 'if [ -z "${HARNESS_APP_PATH:-}" ]; then', + ' set -euo pipefail', + ` xcodebuild CC=clang CPLUSPLUS=clang++ LD=clang LDPLUSPLUS=clang++ -derivedDataPath build -UseModernBuildSystem=YES -workspace ${exampleAppName}.xcworkspace -scheme ${exampleAppName} -sdk iphonesimulator -configuration Debug build CODE_SIGNING_ALLOWED=NO`, + ' HARNESS_APP_PATH="$(find ios/build/Build/Products/Debug-iphonesimulator -maxdepth 1 -type d -name "*.app" | head -1)"', + ' if [ -z "${HARNESS_APP_PATH}" ]; then', + ' echo "Unable to locate the built iOS app bundle."', + ' exit 1', + ' fi', + ' export HARNESS_APP_PATH', + 'fi', + 'react-native-harness --harnessRunner ios', + ].join('\n'), } : {}), } From 95e2680f910247738d801c413910dbab0374c47c Mon Sep 17 00:00:00 2001 From: Patrick Kabwe Date: Sun, 5 Jul 2026 18:30:28 +0200 Subject: [PATCH 15/22] fix: pass build artifacts to harness runners --- src/generate-nitro-package.ts | 54 +++++++++++++++++------------------ 1 file changed, 26 insertions(+), 28 deletions(-) diff --git a/src/generate-nitro-package.ts b/src/generate-nitro-package.ts index 6b7fafe0..7e838fda 100644 --- a/src/generate-nitro-package.ts +++ b/src/generate-nitro-package.ts @@ -642,38 +642,36 @@ export class NitroModuleFactory { 'test:harness': 'react-native-harness', ...(this.config.platforms.includes(SupportedPlatform.ANDROID) ? { - 'test:harness:android': - [ - 'if [ -z "${HARNESS_APP_PATH:-}" ]; then', - ' set -euo pipefail', - ' (cd android && ./gradlew assembleDebug --no-daemon --build-cache)', - ' HARNESS_APP_PATH="$(find android/app/build/outputs/apk/debug -maxdepth 1 -type f -name "*.apk" | head -1)"', - ' if [ -z "${HARNESS_APP_PATH}" ]; then', - ' echo "Unable to locate the built Android app bundle."', - ' exit 1', - ' fi', - ' export HARNESS_APP_PATH', - 'fi', - 'react-native-harness --harnessRunner android', - ].join('\n'), + 'test:harness:android': [ + 'if [ -z "${HARNESS_APP_PATH:-}" ]; then', + ' set -euo pipefail', + ' (cd android && ./gradlew assembleDebug --no-daemon --build-cache)', + ' HARNESS_APP_PATH="$(find android/app/build/outputs/apk/debug -maxdepth 1 -type f -name "*.apk" | head -1)"', + ' if [ -z "${HARNESS_APP_PATH}" ]; then', + ' echo "Unable to locate the built Android app bundle."', + ' exit 1', + ' fi', + ' export HARNESS_APP_PATH', + 'fi', + 'react-native-harness --harnessRunner android', + ].join('\n'), } : {}), ...(this.config.platforms.includes(SupportedPlatform.IOS) ? { - 'test:harness:ios': - [ - 'if [ -z "${HARNESS_APP_PATH:-}" ]; then', - ' set -euo pipefail', - ` xcodebuild CC=clang CPLUSPLUS=clang++ LD=clang LDPLUSPLUS=clang++ -derivedDataPath build -UseModernBuildSystem=YES -workspace ${exampleAppName}.xcworkspace -scheme ${exampleAppName} -sdk iphonesimulator -configuration Debug build CODE_SIGNING_ALLOWED=NO`, - ' HARNESS_APP_PATH="$(find ios/build/Build/Products/Debug-iphonesimulator -maxdepth 1 -type d -name "*.app" | head -1)"', - ' if [ -z "${HARNESS_APP_PATH}" ]; then', - ' echo "Unable to locate the built iOS app bundle."', - ' exit 1', - ' fi', - ' export HARNESS_APP_PATH', - 'fi', - 'react-native-harness --harnessRunner ios', - ].join('\n'), + 'test:harness:ios': [ + 'if [ -z "${HARNESS_APP_PATH:-}" ]; then', + ' set -euo pipefail', + ` xcodebuild CC=clang CPLUSPLUS=clang++ LD=clang LDPLUSPLUS=clang++ -derivedDataPath build -UseModernBuildSystem=YES -workspace ${exampleAppName}.xcworkspace -scheme ${exampleAppName} -sdk iphonesimulator -configuration Debug build CODE_SIGNING_ALLOWED=NO`, + ' HARNESS_APP_PATH="$(find ios/build/Build/Products/Debug-iphonesimulator -maxdepth 1 -type d -name "*.app" | head -1)"', + ' if [ -z "${HARNESS_APP_PATH}" ]; then', + ' echo "Unable to locate the built iOS app bundle."', + ' exit 1', + ' fi', + ' export HARNESS_APP_PATH', + 'fi', + 'react-native-harness --harnessRunner ios', + ].join('\n'), } : {}), } From d639b68a367885269d6f6b6ea683f14505e4c298 Mon Sep 17 00:00:00 2001 From: Patrick Kabwe Date: Sun, 5 Jul 2026 18:48:44 +0200 Subject: [PATCH 16/22] fix: make harness config platform-specific --- src/code-snippets/code.js.ts | 150 ++++++----------------------------- 1 file changed, 23 insertions(+), 127 deletions(-) diff --git a/src/code-snippets/code.js.ts b/src/code-snippets/code.js.ts index 069c58a7..450751d8 100644 --- a/src/code-snippets/code.js.ts +++ b/src/code-snippets/code.js.ts @@ -192,113 +192,6 @@ type HarnessConfigParams = { iosBundleId: string | null } -const getIosHarnessDeviceResolverCode = - () => `const resolveIosDevice = async () => { - const targets = await getAppleRunTargets() - const simulatorTargets = targets.filter(target => target.platform === 'ios' && target.type === 'emulator') - const preferredName = process.env.HARNESS_IOS_SIMULATOR_NAME - const preferredVersion = process.env.HARNESS_IOS_SIMULATOR_VERSION - - if (preferredName != null || preferredVersion != null) { - const preferredTarget = simulatorTargets.find(target => { - if (preferredName != null && target.device.name !== preferredName) { - return false - } - - if (preferredVersion != null && target.device.systemVersion !== preferredVersion) { - return false - } - - return true - }) - - if (preferredTarget == null) { - throw new Error( - \`No iOS simulator matched HARNESS_IOS_SIMULATOR_NAME=\${preferredName ?? 'unset'} and HARNESS_IOS_SIMULATOR_VERSION=\${preferredVersion ?? 'unset'}. Available simulators: \${simulatorTargets.map(target => \`\${target.device.name} (\${target.device.systemVersion})\`).join(', ') || 'none'}\` - ) - } - - return appleSimulator( - preferredTarget.device.name, - preferredTarget.device.systemVersion - ) - } - - const defaultTarget = simulatorTargets[0] - if (defaultTarget == null) { - throw new Error( - \`No available iOS simulators were found for React Native Harness. Available run targets: \${targets.map(target => \`\${target.name} (\${target.description})\`).join(', ') || 'none'}\` - ) - } - - return appleSimulator( - defaultTarget.device.name, - defaultTarget.device.systemVersion - ) -} - -const iosDevice = await resolveIosDevice() -` - -const getAndroidHarnessDeviceResolverCode = - () => `const resolveAndroidDevice = async () => { - const targets = await getAndroidRunTargets() - const emulatorTargets = targets.filter(target => target.platform === 'android' && target.type === 'emulator') - const preferredName = process.env.HARNESS_ANDROID_EMULATOR_NAME - - if (preferredName != null) { - const preferredTarget = emulatorTargets.find(target => target.device.name === preferredName) - - if (preferredTarget == null) { - throw new Error( - \`No Android emulator matched HARNESS_ANDROID_EMULATOR_NAME=\${preferredName}. Available emulators: \${emulatorTargets.map(target => target.device.name).join(', ') || 'none'}\` - ) - } - - return androidEmulator(preferredTarget.device.name) - } - - const defaultTarget = emulatorTargets[0] - if (defaultTarget == null) { - throw new Error( - \`No available Android emulators were found for React Native Harness. Available run targets: \${targets.map(target => \`\${target.name} (\${target.description})\`).join(', ') || 'none'}\` - ) - } - - return androidEmulator(defaultTarget.device.name) -} - -const androidDevice = await resolveAndroidDevice() -` - -const getHarnessRunnerConfig = ( - platform: SupportedPlatform, - androidBundleId: string | null, - iosBundleId: string | null -): string => { - if (platform === SupportedPlatform.ANDROID) { - if (androidBundleId == null) { - throw new Error('Android bundle id is required for Harness config') - } - - return `androidPlatform({ - name: 'android', - device: androidDevice, - bundleId: '${androidBundleId}', - })` - } - - if (iosBundleId == null) { - throw new Error('iOS bundle id is required for Harness config') - } - - return `applePlatform({ - name: 'ios', - device: iosDevice, - bundleId: '${iosBundleId}', - })` -} - export const harnessConfigCode = ({ androidBundleId, appRegistryComponentName, @@ -310,45 +203,48 @@ export const harnessConfigCode = ({ ...(androidBundleId == null ? [] : [ - "import { androidEmulator, androidPlatform, getRunTargets as getAndroidRunTargets } from '@react-native-harness/platform-android'", + "import { androidEmulator, androidPlatform } from '@react-native-harness/platform-android'", ]), ...(iosBundleId == null ? [] : [ - "import { applePlatform, appleSimulator, getRunTargets as getAppleRunTargets } from '@react-native-harness/platform-apple'", + "import { applePlatform, appleSimulator } from '@react-native-harness/platform-apple'", ]), ].join('\n') - const deviceResolvers = [ - ...(iosBundleId == null ? [] : [getIosHarnessDeviceResolverCode()]), - ...(androidBundleId == null - ? [] - : [getAndroidHarnessDeviceResolverCode()]), - ].join('\n') const runners = [ ...(androidBundleId == null ? [] : [ - getHarnessRunnerConfig( - SupportedPlatform.ANDROID, - androidBundleId, - iosBundleId - ), + `androidPlatform({ + name: 'android', + device: androidEmulator(process.env.AVD_NAME ?? 'Pixel_7_API_36', { + apiLevel: Number(process.env.DEVICE_API_LEVEL ?? '36'), + profile: process.env.DEVICE_PROFILE ?? 'pixel_7', + diskSize: process.env.AVD_DISK_SIZE ?? '1G', + heapSize: process.env.AVD_HEAP_SIZE ?? '1G', + snapshot: { + enabled: process.env.CI === 'true', + }, + }), + bundleId: '${androidBundleId}', + })`, ]), ...(iosBundleId == null ? [] : [ - getHarnessRunnerConfig( - SupportedPlatform.IOS, - androidBundleId, - iosBundleId - ), + `applePlatform({ + name: 'ios', + device: appleSimulator( + process.env.DEVICE_MODEL ?? 'iPhone 17 Pro', + process.env.IOS_VERSION ?? '26.5' + ), + bundleId: '${iosBundleId}', + })`, ]), ].join(',\n ') return `${imports} -${deviceResolvers} - const config = { entryPoint: '${entryPoint}', appRegistryComponentName: '${appRegistryComponentName}', From f4d258d7de71e1e1f65c187f256eef6b4d63a89e Mon Sep 17 00:00:00 2001 From: Patrick Kabwe Date: Sun, 5 Jul 2026 19:07:56 +0200 Subject: [PATCH 17/22] fix: align ios harness xcode setup with nitro --- .github/workflows/ci-packages.yml | 9 ++++++++- src/generate-nitro-package.ts | 13 ++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-packages.yml b/.github/workflows/ci-packages.yml index 6bb22fa0..cac064d0 100644 --- a/.github/workflows/ci-packages.yml +++ b/.github/workflows/ci-packages.yml @@ -699,10 +699,17 @@ jobs: working-directory: ${{ env.WORKING_DIR }}/example shell: bash run: | + IOS_SIMULATOR="$(xcrun simctl list devices available --json | node -e 'const fs = require("node:fs"); const input = fs.readFileSync(0, "utf8"); const data = JSON.parse(input); const candidates = Object.entries(data.devices).flatMap(([runtime, devices]) => { const version = runtime.match(/iOS-(\d+(?:-\d+)*)$/)?.[1]?.replaceAll("-", "."); if (version == null) return []; return devices.filter(device => device.isAvailable === true && device.name.startsWith("iPhone")).map(device => ({ name: device.name, version })); }); candidates.sort((a, b) => b.version.localeCompare(a.version, undefined, { numeric: true }) || b.name.localeCompare(a.name, undefined, { numeric: true })); const selected = candidates[0]; if (selected == null) process.exit(1); console.log(`${selected.name}|${selected.version}`);' || true)" + if [ -z "$IOS_SIMULATOR" ]; then + echo "Unable to resolve an available iOS simulator." + xcrun simctl list devices available + exit 1 + fi + APP_PATH="$(find ios/build/Build/Products/Debug-iphonesimulator -maxdepth 1 -type d -name '*.app' | head -1)" if [ -z "$APP_PATH" ]; then echo "No built iOS app bundle found in ios/build/Build/Products/Debug-iphonesimulator" exit 1 fi - HARNESS_APP_PATH="$APP_PATH" ${{ matrix.pm }} run test:harness:ios + DEVICE_MODEL="${IOS_SIMULATOR%%|*}" IOS_VERSION="${IOS_SIMULATOR#*|}" HARNESS_APP_PATH="$APP_PATH" ${{ matrix.pm }} run test:harness:ios diff --git a/src/generate-nitro-package.ts b/src/generate-nitro-package.ts index 7e838fda..82e49c01 100644 --- a/src/generate-nitro-package.ts +++ b/src/generate-nitro-package.ts @@ -660,8 +660,19 @@ export class NitroModuleFactory { ...(this.config.platforms.includes(SupportedPlatform.IOS) ? { 'test:harness:ios': [ + 'set -euo pipefail', + 'if [ -z "${DEVICE_MODEL:-}" ] || [ -z "${IOS_VERSION:-}" ]; then', + ' IOS_SIMULATOR="$(xcrun simctl list devices available --json | node -e \'const fs = require("node:fs"); const input = fs.readFileSync(0, "utf8"); const data = JSON.parse(input); const candidates = Object.entries(data.devices).flatMap(([runtime, devices]) => { const version = runtime.match(/iOS-(\\d+(?:-\\d+)*)$/)?.[1]?.replaceAll("-", "."); if (version == null) return []; return devices.filter(device => device.isAvailable === true && device.name.startsWith("iPhone")).map(device => ({ name: device.name, version })); }); candidates.sort((a, b) => b.version.localeCompare(a.version, undefined, { numeric: true }) || b.name.localeCompare(a.name, undefined, { numeric: true })); const selected = candidates[0]; if (selected == null) process.exit(1); console.log(`${selected.name}|${selected.version}`);\' || true)"', + ' if [ -z "${IOS_SIMULATOR}" ]; then', + ' echo "Unable to resolve an available iOS simulator."', + ' xcrun simctl list devices available', + ' exit 1', + ' fi', + ' DEVICE_MODEL="${IOS_SIMULATOR%%|*}"', + ' IOS_VERSION="${IOS_SIMULATOR#*|}"', + ' export DEVICE_MODEL IOS_VERSION', + 'fi', 'if [ -z "${HARNESS_APP_PATH:-}" ]; then', - ' set -euo pipefail', ` xcodebuild CC=clang CPLUSPLUS=clang++ LD=clang LDPLUSPLUS=clang++ -derivedDataPath build -UseModernBuildSystem=YES -workspace ${exampleAppName}.xcworkspace -scheme ${exampleAppName} -sdk iphonesimulator -configuration Debug build CODE_SIGNING_ALLOWED=NO`, ' HARNESS_APP_PATH="$(find ios/build/Build/Products/Debug-iphonesimulator -maxdepth 1 -type d -name "*.app" | head -1)"', ' if [ -z "${HARNESS_APP_PATH}" ]; then', From d2548ae8fa2501b9bb9a8a846e85d135e3e00b4c Mon Sep 17 00:00:00 2001 From: Patrick Kabwe Date: Sun, 5 Jul 2026 19:07:59 +0200 Subject: [PATCH 18/22] fix: align ios harness xcode setup with nitro --- .github/workflows/ci-packages.yml | 18 +++++++----------- src/code-snippets/code.js.ts | 8 +++----- src/generate-nitro-package.ts | 4 +++- 3 files changed, 13 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci-packages.yml b/.github/workflows/ci-packages.yml index cac064d0..fde87557 100644 --- a/.github/workflows/ci-packages.yml +++ b/.github/workflows/ci-packages.yml @@ -376,7 +376,7 @@ jobs: test-ios-build: name: Test iOS Build - ${{ matrix.pm }} - ${{ matrix.package_type }} - ${{ matrix.scenario }} (${{ matrix.mode }}) needs: [generate-packages, define-matrix] - runs-on: macOS-latest + runs-on: macos-26 strategy: fail-fast: false matrix: ${{ fromJson(needs.define-matrix.outputs.ios-build) }} @@ -401,10 +401,8 @@ jobs: echo "Package structure:" find . -type f -name "*.json" -o -name "*.js" -o -name "*.ts" | head -20 - - name: Setup Xcode - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: 16.4 + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_26.5.app/Contents/Developer - name: Setup Ruby and CocoaPods uses: ruby/setup-ruby@v1 @@ -628,7 +626,7 @@ jobs: harness-ios: name: iOS Harness - ${{ matrix.package_type }} - ${{ matrix.scenario }} needs: [generate-packages, define-matrix] - runs-on: macOS-15 + runs-on: macos-26 strategy: fail-fast: false matrix: ${{ fromJson(needs.define-matrix.outputs.ios-harness) }} @@ -638,10 +636,8 @@ jobs: - name: Checkout repository uses: actions/checkout@v7 - - name: Setup Xcode - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: 16.4 + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_26.5.app/Contents/Developer - name: Create working directory run: mkdir -p ${{ env.WORKING_DIR }} @@ -699,7 +695,7 @@ jobs: working-directory: ${{ env.WORKING_DIR }}/example shell: bash run: | - IOS_SIMULATOR="$(xcrun simctl list devices available --json | node -e 'const fs = require("node:fs"); const input = fs.readFileSync(0, "utf8"); const data = JSON.parse(input); const candidates = Object.entries(data.devices).flatMap(([runtime, devices]) => { const version = runtime.match(/iOS-(\d+(?:-\d+)*)$/)?.[1]?.replaceAll("-", "."); if (version == null) return []; return devices.filter(device => device.isAvailable === true && device.name.startsWith("iPhone")).map(device => ({ name: device.name, version })); }); candidates.sort((a, b) => b.version.localeCompare(a.version, undefined, { numeric: true }) || b.name.localeCompare(a.name, undefined, { numeric: true })); const selected = candidates[0]; if (selected == null) process.exit(1); console.log(`${selected.name}|${selected.version}`);' || true)" + IOS_SIMULATOR="$(xcrun simctl list devices available --json | IOS_VERSION=26.5 node -e 'const fs = require("node:fs"); const input = fs.readFileSync(0, "utf8"); const data = JSON.parse(input); const desiredVersion = process.env.IOS_VERSION; const candidates = Object.entries(data.devices).flatMap(([runtime, devices]) => { const version = runtime.match(/iOS-(\d+(?:-\d+)*)$/)?.[1]?.replaceAll("-", "."); if (version == null || (desiredVersion != null && version !== desiredVersion)) return []; return devices.filter(device => device.isAvailable === true && device.name.startsWith("iPhone")).map(device => ({ name: device.name, version })); }); candidates.sort((a, b) => b.version.localeCompare(a.version, undefined, { numeric: true }) || b.name.localeCompare(a.name, undefined, { numeric: true })); const selected = candidates[0]; if (selected == null) process.exit(1); console.log(`${selected.name}|${selected.version}`);' || true)" if [ -z "$IOS_SIMULATOR" ]; then echo "Unable to resolve an available iOS simulator." xcrun simctl list devices available diff --git a/src/code-snippets/code.js.ts b/src/code-snippets/code.js.ts index 450751d8..fba56c30 100644 --- a/src/code-snippets/code.js.ts +++ b/src/code-snippets/code.js.ts @@ -397,7 +397,7 @@ ${getHarnessCodegenBuildStep(packageManager, monorepo)} return ` test: name: Test iOS Harness - runs-on: macOS-15 + runs-on: macos-26 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -408,10 +408,8 @@ ${getPackageManagerSetupStep(packageManager)} - name: Install dependencies run: ${packageManager} install ${getHarnessCodegenBuildStep(packageManager, monorepo)} - - name: Setup Xcode - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: 16.4 + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_26.5.app/Contents/Developer - name: Install Pods working-directory: example diff --git a/src/generate-nitro-package.ts b/src/generate-nitro-package.ts index 82e49c01..cc309bc1 100644 --- a/src/generate-nitro-package.ts +++ b/src/generate-nitro-package.ts @@ -662,7 +662,9 @@ export class NitroModuleFactory { 'test:harness:ios': [ 'set -euo pipefail', 'if [ -z "${DEVICE_MODEL:-}" ] || [ -z "${IOS_VERSION:-}" ]; then', - ' IOS_SIMULATOR="$(xcrun simctl list devices available --json | node -e \'const fs = require("node:fs"); const input = fs.readFileSync(0, "utf8"); const data = JSON.parse(input); const candidates = Object.entries(data.devices).flatMap(([runtime, devices]) => { const version = runtime.match(/iOS-(\\d+(?:-\\d+)*)$/)?.[1]?.replaceAll("-", "."); if (version == null) return []; return devices.filter(device => device.isAvailable === true && device.name.startsWith("iPhone")).map(device => ({ name: device.name, version })); }); candidates.sort((a, b) => b.version.localeCompare(a.version, undefined, { numeric: true }) || b.name.localeCompare(a.name, undefined, { numeric: true })); const selected = candidates[0]; if (selected == null) process.exit(1); console.log(`${selected.name}|${selected.version}`);\' || true)"', + ' IOS_VERSION="${IOS_VERSION:-26.5}"', + ' export IOS_VERSION', + ' IOS_SIMULATOR="$(xcrun simctl list devices available --json | node -e \'const fs = require("node:fs"); const input = fs.readFileSync(0, "utf8"); const data = JSON.parse(input); const desiredVersion = process.env.IOS_VERSION; const candidates = Object.entries(data.devices).flatMap(([runtime, devices]) => { const version = runtime.match(/iOS-(\\d+(?:-\\d+)*)$/)?.[1]?.replaceAll("-", "."); if (version == null || (desiredVersion != null && version !== desiredVersion)) return []; return devices.filter(device => device.isAvailable === true && device.name.startsWith("iPhone")).map(device => ({ name: device.name, version })); }); candidates.sort((a, b) => b.version.localeCompare(a.version, undefined, { numeric: true }) || b.name.localeCompare(a.name, undefined, { numeric: true })); const selected = candidates[0]; if (selected == null) process.exit(1); console.log(`${selected.name}|${selected.version}`);\' || true)"', ' if [ -z "${IOS_SIMULATOR}" ]; then', ' echo "Unable to resolve an available iOS simulator."', ' xcrun simctl list devices available', From 3f58ecff212991f27d0e44ccc2fbcf791745fe4f Mon Sep 17 00:00:00 2001 From: Patrick Kabwe Date: Sun, 5 Jul 2026 19:16:43 +0200 Subject: [PATCH 19/22] fix: align android harness workflow with repo layout --- .github/workflows/ci-packages.yml | 45 +++++++++++++------------------ src/code-snippets/code.js.ts | 20 +++++++++++--- 2 files changed, 35 insertions(+), 30 deletions(-) diff --git a/.github/workflows/ci-packages.yml b/.github/workflows/ci-packages.yml index fde87557..f860346b 100644 --- a/.github/workflows/ci-packages.yml +++ b/.github/workflows/ci-packages.yml @@ -547,18 +547,17 @@ jobs: fail-fast: false matrix: ${{ fromJson(needs.define-matrix.outputs.android-harness) }} env: + AVD_DISK_SIZE: 1G + AVD_HEAP_SIZE: 1G + AVD_NAME: Pixel_7_API_36 + DEVICE_API_LEVEL: '36' + DEVICE_ARCH: x86_64 + DEVICE_PROFILE: pixel_7 WORKING_DIR: ${{ github.workspace }}/${{ matrix.package_dir }} steps: - name: Checkout repository uses: actions/checkout@v7 - - name: Enable KVM (Android emulator) - if: runner.os == 'Linux' - run: | - echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules - sudo udevadm control --reload-rules - sudo udevadm trigger --name-match=kvm - - name: Create working directory run: mkdir -p ${{ env.WORKING_DIR }} @@ -600,28 +599,22 @@ jobs: java-version: '17' cache: 'gradle' - - name: Setup Android SDK - uses: android-actions/setup-android@v4 - - name: Build Android app - uses: ./.github/actions/android-gradle-build + working-directory: ${{ env.WORKING_DIR }}/example/android + run: ./gradlew :app:assembleDebug --no-daemon --build-cache -PreactNativeArchitectures=${{ env.DEVICE_ARCH }} + + - name: Run React Native Harness + uses: callstackincubator/react-native-harness@v1.2.0 with: - android-dir: ${{ env.WORKING_DIR }}/example/android - mode: ${{ matrix.mode }} + app: android/app/build/outputs/apk/debug/app-debug.apk + runner: android + projectRoot: ${{ env.WORKING_DIR }}/example + packageManager: ${{ matrix.pm }} - - name: Run Android Emulator and Harness Tests - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: 35 - target: google_apis - arch: x86_64 - profile: Galaxy Nexus - emulator-options: -no-snapshot -memory 4096 -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none - disable-animations: true - script: | - adb wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done' - cd "${{ env.WORKING_DIR }}/example" - HARNESS_APP_PATH="${{ env.WORKING_DIR }}/example/android/app/build/outputs/apk/debug/app-debug.apk" ${{ matrix.pm }} run test:harness:android + - name: Stop Gradle + if: always() + working-directory: ${{ env.WORKING_DIR }}/example/android + run: ./gradlew --stop harness-ios: name: iOS Harness - ${{ matrix.package_type }} - ${{ matrix.scenario }} diff --git a/src/code-snippets/code.js.ts b/src/code-snippets/code.js.ts index fba56c30..cc8a3981 100644 --- a/src/code-snippets/code.js.ts +++ b/src/code-snippets/code.js.ts @@ -365,6 +365,13 @@ const getHarnessJobCode = ( return ` test: name: Test Android Harness runs-on: ubuntu-latest + env: + AVD_DISK_SIZE: 1G + AVD_HEAP_SIZE: 1G + AVD_NAME: Pixel_7_API_36 + DEVICE_API_LEVEL: '36' + DEVICE_ARCH: x86_64 + DEVICE_PROFILE: pixel_7 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -384,15 +391,20 @@ ${getHarnessCodegenBuildStep(packageManager, monorepo)} - name: Build Android app working-directory: example/android - run: ./gradlew assembleDebug --no-daemon --build-cache + run: ./gradlew :app:assembleDebug --no-daemon --build-cache -PreactNativeArchitectures=\${{ env.DEVICE_ARCH }} - name: Run React Native Harness - uses: callstackincubator/react-native-harness@v1.0.0 + uses: callstackincubator/react-native-harness@v1.2.0 with: - app: example/android/app/build/outputs/apk/debug/app-debug.apk + app: android/app/build/outputs/apk/debug/app-debug.apk runner: android projectRoot: example - packageManager: ${packageManager}` + packageManager: ${packageManager} + + - name: Stop Gradle + if: always() + working-directory: example/android + run: ./gradlew --stop` } return ` test: From b40e28d0f1faabd1ea3428e828fb4f85c2ab6b32 Mon Sep 17 00:00:00 2001 From: Patrick Kabwe Date: Sun, 5 Jul 2026 19:58:56 +0200 Subject: [PATCH 20/22] fix: ensure android harness gradlew is executable --- .github/workflows/ci-packages.yml | 8 ++++++-- src/code-snippets/code.js.ts | 8 ++++++-- src/generate-nitro-package.ts | 1 + 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci-packages.yml b/.github/workflows/ci-packages.yml index f860346b..f6a1619f 100644 --- a/.github/workflows/ci-packages.yml +++ b/.github/workflows/ci-packages.yml @@ -601,7 +601,9 @@ jobs: - name: Build Android app working-directory: ${{ env.WORKING_DIR }}/example/android - run: ./gradlew :app:assembleDebug --no-daemon --build-cache -PreactNativeArchitectures=${{ env.DEVICE_ARCH }} + run: | + chmod +x ./gradlew + ./gradlew :app:assembleDebug --no-daemon --build-cache -PreactNativeArchitectures=${{ env.DEVICE_ARCH }} - name: Run React Native Harness uses: callstackincubator/react-native-harness@v1.2.0 @@ -614,7 +616,9 @@ jobs: - name: Stop Gradle if: always() working-directory: ${{ env.WORKING_DIR }}/example/android - run: ./gradlew --stop + run: | + chmod +x ./gradlew + ./gradlew --stop harness-ios: name: iOS Harness - ${{ matrix.package_type }} - ${{ matrix.scenario }} diff --git a/src/code-snippets/code.js.ts b/src/code-snippets/code.js.ts index cc8a3981..b3eea886 100644 --- a/src/code-snippets/code.js.ts +++ b/src/code-snippets/code.js.ts @@ -391,7 +391,9 @@ ${getHarnessCodegenBuildStep(packageManager, monorepo)} - name: Build Android app working-directory: example/android - run: ./gradlew :app:assembleDebug --no-daemon --build-cache -PreactNativeArchitectures=\${{ env.DEVICE_ARCH }} + run: | + chmod +x ./gradlew + ./gradlew :app:assembleDebug --no-daemon --build-cache -PreactNativeArchitectures=\${{ env.DEVICE_ARCH }} - name: Run React Native Harness uses: callstackincubator/react-native-harness@v1.2.0 @@ -404,7 +406,9 @@ ${getHarnessCodegenBuildStep(packageManager, monorepo)} - name: Stop Gradle if: always() working-directory: example/android - run: ./gradlew --stop` + run: | + chmod +x ./gradlew + ./gradlew --stop` } return ` test: diff --git a/src/generate-nitro-package.ts b/src/generate-nitro-package.ts index cc309bc1..87c31e94 100644 --- a/src/generate-nitro-package.ts +++ b/src/generate-nitro-package.ts @@ -645,6 +645,7 @@ export class NitroModuleFactory { 'test:harness:android': [ 'if [ -z "${HARNESS_APP_PATH:-}" ]; then', ' set -euo pipefail', + ' chmod +x android/gradlew', ' (cd android && ./gradlew assembleDebug --no-daemon --build-cache)', ' HARNESS_APP_PATH="$(find android/app/build/outputs/apk/debug -maxdepth 1 -type f -name "*.apk" | head -1)"', ' if [ -z "${HARNESS_APP_PATH}" ]; then', From 9a6fb709f81474f9d2e87cb2f71937dc7a72267f Mon Sep 17 00:00:00 2001 From: Patrick Kabwe Date: Sun, 5 Jul 2026 21:14:35 +0200 Subject: [PATCH 21/22] test: cover generated harness and build workflows --- .github/workflows/ci-packages.yml | 197 +----------------------------- e2e/harness-workflows.test.ts | 179 +++++++++++++++++++++++++++ package.json | 2 + src/generate-nitro-package.ts | 10 +- 4 files changed, 188 insertions(+), 200 deletions(-) create mode 100644 e2e/harness-workflows.test.ts diff --git a/.github/workflows/ci-packages.yml b/.github/workflows/ci-packages.yml index f6a1619f..4006039a 100644 --- a/.github/workflows/ci-packages.yml +++ b/.github/workflows/ci-packages.yml @@ -68,13 +68,14 @@ jobs: node lib/cli/index.js --help node lib/cli/index.js create --help + - name: Run E2E tests + run: bun run test:e2e + define-matrix: name: Define Package Matrices runs-on: ubuntu-latest outputs: generation: ${{ steps.set-matrix.outputs.generation }} - ios-build: ${{ steps.set-matrix.outputs.ios_build }} - android-build: ${{ steps.set-matrix.outputs.android_build }} ios-harness: ${{ steps.set-matrix.outputs.ios_harness }} android-harness: ${{ steps.set-matrix.outputs.android_harness }} steps: @@ -184,30 +185,6 @@ jobs: const generation = pms.flatMap(pm => scenarios.map(item => enrich(item, { pm })) ) - const iosBuild = pms.flatMap(pm => - scenarios - .filter(item => item.runs_ios) - .flatMap(item => - ['Debug', 'Release'].map(mode => - enrich(item, { pm, mode }) - ) - ) - ) - const androidBuild = pms.flatMap(pm => - scenarios - .filter(item => item.runs_android) - .flatMap(item => - ['Debug', 'Release'].map(mode => - enrich(item, { pm, mode }) - ) - ) - ) - const iosE2E = scenarios - .filter(item => item.runs_ios) - .map(item => enrich(item, { pm: 'bun', mode: 'Release' })) - const androidE2E = scenarios - .filter(item => item.runs_android) - .map(item => enrich(item, { pm: 'bun', mode: 'Release' })) const iosHarness = scenarios .filter(item => item.runs_ios) .map(item => enrich(item, { pm: 'bun', mode: 'Debug' })) @@ -216,8 +193,6 @@ jobs: .map(item => enrich(item, { pm: 'bun', mode: 'Debug' })) console.log(`generation=${JSON.stringify({ include: generation })}`) - console.log(`ios_build=${JSON.stringify({ include: iosBuild })}`) - console.log(`android_build=${JSON.stringify({ include: androidBuild })}`) console.log(`ios_harness=${JSON.stringify({ include: iosHarness })}`) console.log(`android_harness=${JSON.stringify({ include: androidHarness })}`) NODE @@ -373,172 +348,6 @@ jobs: if-no-files-found: error retention-days: 7 - test-ios-build: - name: Test iOS Build - ${{ matrix.pm }} - ${{ matrix.package_type }} - ${{ matrix.scenario }} (${{ matrix.mode }}) - needs: [generate-packages, define-matrix] - runs-on: macos-26 - strategy: - fail-fast: false - matrix: ${{ fromJson(needs.define-matrix.outputs.ios-build) }} - env: - WORKING_DIR: ${{ github.workspace }}/${{ matrix.package_dir }} - steps: - - name: Checkout repository - uses: actions/checkout@v7 - - - name: Create working directory - run: mkdir -p ${{ env.WORKING_DIR }} - - - name: Download generated package - uses: actions/download-artifact@v8 - with: - name: test-${{ matrix.package_type }}-${{ matrix.scenario }}-${{ matrix.pm }} - path: ${{ env.WORKING_DIR }} - - - name: List package structure - working-directory: ${{ env.WORKING_DIR }} - run: | - echo "Package structure:" - find . -type f -name "*.json" -o -name "*.js" -o -name "*.ts" | head -20 - - - name: Select Xcode - run: sudo xcode-select -s /Applications/Xcode_26.5.app/Contents/Developer - - - name: Setup Ruby and CocoaPods - uses: ruby/setup-ruby@v1 - with: - ruby-version: '3.2' - bundler-cache: true - - - name: Setup Node.js - if: matrix.pm == 'yarn' - uses: actions/setup-node@v6 - with: - node-version: 22.x - - - name: Setup Yarn - if: matrix.pm == 'yarn' - uses: ./.github/actions/setup-yarn - with: - working-directory: ${{ env.WORKING_DIR }} - - - name: Setup Bun.js - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - - name: Install package dependencies - uses: ./.github/actions/install-deps - with: - pm: ${{ matrix.pm }} - working-directory: ${{ env.WORKING_DIR }} - - - name: Run codegen and build - uses: ./.github/actions/run-codegen-build - with: - pm: ${{ matrix.pm }} - working-directory: ${{ env.WORKING_DIR }} - - - name: Cache CocoaPods - uses: actions/cache@v5 - with: - path: | - ~/.cocoapods/repos - ${{ env.WORKING_DIR }}/example/ios/Pods - key: ${{ runner.os }}-pods-${{ hashFiles(format('{0}/example/ios/Podfile.lock', env.WORKING_DIR)) }} - restore-keys: | - ${{ runner.os }}-pods- - - - name: Install CocoaPods dependencies - working-directory: ${{ env.WORKING_DIR }}/example - run: ${{ matrix.pm }} pod - - - name: Build iOS project - uses: ./.github/actions/ios-build-xcode - with: - ios-dir: ${{ env.WORKING_DIR }}/example/ios - mode: ${{ matrix.mode }} - - test-android-build: - name: Test Android Build - ${{ matrix.pm }} - ${{ matrix.package_type }} - ${{ matrix.scenario }} (${{ matrix.mode }}) - needs: [generate-packages, define-matrix] - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: ${{ fromJson(needs.define-matrix.outputs.android-build) }} - env: - WORKING_DIR: ${{ github.workspace }}/${{ matrix.package_dir }} - steps: - - name: Checkout repository - uses: actions/checkout@v7 - - - name: Create working directory - run: mkdir -p ${{ env.WORKING_DIR }} - - - name: Download generated package - uses: actions/download-artifact@v8 - with: - name: test-${{ matrix.package_type }}-${{ matrix.scenario }}-${{ matrix.pm }} - path: ${{ env.WORKING_DIR }} - - - name: List package structure - working-directory: ${{ env.WORKING_DIR }} - run: | - echo "Package structure:" - find . -type f -name "*.json" -o -name "*.js" -o -name "*.ts" | head -20 - - - name: Setup Node.js - if: matrix.pm == 'yarn' - uses: actions/setup-node@v6 - with: - node-version: 22.x - - - name: Setup Yarn - if: matrix.pm == 'yarn' - uses: ./.github/actions/setup-yarn - with: - working-directory: ${{ env.WORKING_DIR }} - - - name: Setup Bun.js - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - - name: Install package dependencies - uses: ./.github/actions/install-deps - with: - pm: ${{ matrix.pm }} - working-directory: ${{ env.WORKING_DIR }} - - - name: Run codegen and build - uses: ./.github/actions/run-codegen-build - with: - pm: ${{ matrix.pm }} - working-directory: ${{ env.WORKING_DIR }} - - - name: Setup Java for Android builds - uses: actions/setup-java@v5 - with: - distribution: 'zulu' - java-version: '17' - cache: 'gradle' - - - name: Cache Gradle - uses: actions/cache@v5 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles(format('{0}/example/android/**/*.gradle*', env.WORKING_DIR)) }} - restore-keys: | - ${{ runner.os }}-gradle- - - - name: Build Android project - uses: ./.github/actions/android-gradle-build - with: - android-dir: ${{ env.WORKING_DIR }}/example/android - mode: ${{ matrix.mode }} - harness-android: name: Android Harness - ${{ matrix.package_type }} - ${{ matrix.scenario }} needs: [generate-packages, define-matrix] diff --git a/e2e/harness-workflows.test.ts b/e2e/harness-workflows.test.ts new file mode 100644 index 00000000..3955dfc4 --- /dev/null +++ b/e2e/harness-workflows.test.ts @@ -0,0 +1,179 @@ +import { afterAll, describe, expect, test } from 'bun:test' +import { execFile } from 'node:child_process' +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { promisify } from 'node:util' + +type GeneratedProject = { + readonly packageName: string + readonly rootDir: string +} + +type WorkflowExpectation = { + readonly androidBuildWorkflowPath: string + readonly iosBuildWorkflowPath: string + readonly harnessWorkflowPath: string + readonly rootDir: string +} + +const execFileAsync = promisify(execFile) +const generatedRoots: string[] = [] + +const createProject = async ( + packageName: string, + monorepo: boolean +): Promise => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), 'nitro-cli-e2e-')) + generatedRoots.push(rootDir) + + const args = [ + 'lib/cli/index.js', + packageName, + '--module-dir', + rootDir, + '--platforms', + 'ios,android', + '--langs', + 'swift,kotlin', + '--include-harness', + '--skip-install', + '--ci', + ] + + if (monorepo) { + args.push('--monorepo') + } + + await execFileAsync('node', args, { + cwd: path.resolve(import.meta.dir, '..'), + env: { + ...process.env, + CI: 'true', + }, + maxBuffer: 1024 * 1024 * 20, + }) + + return { + packageName, + rootDir: path.join(rootDir, `react-native-${packageName}`), + } +} + +const readText = async (filePath: string): Promise => + await readFile(filePath, { encoding: 'utf8' }) + +const assertWorkflowFiles = async (rootDir: string): Promise => { + const workflowDir = path.join(rootDir, '.github', 'workflows') + const workflowFiles = await readdir(workflowDir) + + expect(workflowFiles.toSorted()).toEqual([ + 'android-build.yml', + 'harness-android.yml', + 'harness-ios.yml', + 'ios-build.yml', + 'release.yml', + ]) +} + +const assertHarnessScripts = async (rootDir: string): Promise => { + const examplePackageJson = JSON.parse( + await readText(path.join(rootDir, 'example', 'package.json')) + ) as { readonly scripts?: Record } + const scripts = examplePackageJson.scripts + + expect(scripts?.['test:harness']).toBe('react-native-harness') + expect(scripts?.['test:harness:android']).toContain( + 'chmod +x android/gradlew' + ) + expect(scripts?.['test:harness:android']).toContain( + 'react-native-harness --harnessRunner android' + ) + expect(scripts?.['test:harness:ios']).toContain( + 'react-native-harness --harnessRunner ios' + ) +} + +const assertHarnessWorkflowContent = async ( + expectation: WorkflowExpectation +): Promise => { + const androidBuildWorkflow = await readText( + path.join( + expectation.rootDir, + '.github', + 'workflows', + 'android-build.yml' + ) + ) + const iosBuildWorkflow = await readText( + path.join(expectation.rootDir, '.github', 'workflows', 'ios-build.yml') + ) + const androidWorkflow = await readText( + path.join( + expectation.rootDir, + '.github', + 'workflows', + 'harness-android.yml' + ) + ) + const iosWorkflow = await readText( + path.join( + expectation.rootDir, + '.github', + 'workflows', + 'harness-ios.yml' + ) + ) + + expect(androidWorkflow).toContain( + 'uses: callstackincubator/react-native-harness@v1.2.0' + ) + expect(androidWorkflow).toContain('chmod +x ./gradlew') + expect(androidWorkflow).toContain('projectRoot: example') + expect(iosWorkflow).toContain( + 'uses: callstackincubator/react-native-harness@v1.0.0' + ) + expect(iosWorkflow).toContain('projectRoot: example') + expect(androidWorkflow).toContain(expectation.harnessWorkflowPath) + expect(iosWorkflow).toContain(expectation.harnessWorkflowPath) + expect(androidBuildWorkflow).toContain(expectation.androidBuildWorkflowPath) + expect(iosBuildWorkflow).toContain(expectation.iosBuildWorkflowPath) + expect(iosBuildWorkflow).not.toContain('$$exampleApp$$') +} + +afterAll(async () => { + await Promise.all( + generatedRoots.map(rootDir => + rm(rootDir, { recursive: true, force: true }) + ) + ) +}) + +describe('React Native Harness workflow generation', () => { + test('generates build and harness workflows for the default project layout', async () => { + const project = await createProject('rootharness', false) + + await assertWorkflowFiles(project.rootDir) + await assertHarnessScripts(project.rootDir) + await assertHarnessWorkflowContent({ + androidBuildWorkflowPath: 'android/**', + harnessWorkflowPath: 'src/**', + iosBuildWorkflowPath: 'ios/**', + rootDir: project.rootDir, + }) + }, 120_000) + + test('generates build and harness workflows for the monorepo project layout', async () => { + const project = await createProject('monoharness', true) + const packagePath = `packages/react-native-${project.packageName}` + + await assertWorkflowFiles(project.rootDir) + await assertHarnessScripts(project.rootDir) + await assertHarnessWorkflowContent({ + androidBuildWorkflowPath: `${packagePath}/android/**`, + harnessWorkflowPath: `${packagePath}/src/**`, + iosBuildWorkflowPath: `${packagePath}/ios/**`, + rootDir: project.rootDir, + }) + }, 120_000) +}) diff --git a/package.json b/package.json index 5589ecca..bc064c47 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "scripts": { "dev": "bun typecheck && bun src/cli/index.ts", "build": "rm -rf lib && bun typecheck && tsup src", + "test:e2e": "bun test e2e", + "test:e2e:build": "bun run build && bun run test:e2e", "typecheck": "tsc --noEmit", "prerelease": "rm -rf ./lib/assets", "release": "bun run build && bun semantic-release", diff --git a/src/generate-nitro-package.ts b/src/generate-nitro-package.ts index 87c31e94..267a0732 100644 --- a/src/generate-nitro-package.ts +++ b/src/generate-nitro-package.ts @@ -948,13 +948,12 @@ export class NitroModuleFactory { readFile(androidBuildWorkflowPath, { encoding: 'utf8' }), ]) - const iosBuildReplacements = { - $$exampleApp$$: `${toPascalCase(this.config.packageName)}Example`, - } - + const exampleAppName = `${toPascalCase(this.config.packageName)}Example` const iosBuildWorkflowContent = await replacePlaceholder({ data: iosBuildWorkflow, - replacements: iosBuildReplacements, + replacements: { + $$exampleApp$$: exampleAppName, + }, }) await Promise.all([ @@ -978,7 +977,6 @@ export class NitroModuleFactory { return } - const exampleAppName = `${toPascalCase(this.config.packageName)}Example` const workflowDirectoryPath = path.join( workflowRoot, '.github', From 3a9910a48c2466813d1000e9438a39d067a7a6cc Mon Sep 17 00:00:00 2001 From: Patrick Kabwe Date: Mon, 6 Jul 2026 08:31:34 +0200 Subject: [PATCH 22/22] test: isolate git identity in e2e generation --- e2e/harness-workflows.test.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/e2e/harness-workflows.test.ts b/e2e/harness-workflows.test.ts index 3955dfc4..cf32b915 100644 --- a/e2e/harness-workflows.test.ts +++ b/e2e/harness-workflows.test.ts @@ -1,6 +1,6 @@ import { afterAll, describe, expect, test } from 'bun:test' import { execFile } from 'node:child_process' -import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises' +import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { promisify } from 'node:util' @@ -26,6 +26,18 @@ const createProject = async ( ): Promise => { const rootDir = await mkdtemp(path.join(os.tmpdir(), 'nitro-cli-e2e-')) generatedRoots.push(rootDir) + const gitConfigPath = path.join(rootDir, 'gitconfig') + + await writeFile( + gitConfigPath, + [ + '[user]', + ' name = Nitro CLI E2E', + ' email = e2e@example.com', + '', + ].join('\n'), + { encoding: 'utf8' } + ) const args = [ 'lib/cli/index.js', @@ -50,6 +62,8 @@ const createProject = async ( env: { ...process.env, CI: 'true', + GIT_CONFIG_GLOBAL: gitConfigPath, + GIT_CONFIG_NOSYSTEM: '1', }, maxBuffer: 1024 * 1024 * 20, })