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/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/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..ffe7c29d 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,281 @@ 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 +` +} + +const getHarnessJobCode = ( + exampleAppName: string, + packageManager: PackageManager, + platform: SupportedPlatform +) => { + 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: '24' +${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` + } + + return ` test: + name: Test iOS Harness + runs-on: macOS-15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24' +${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` +} + +export const harnessWorkflowCode = ( + exampleAppName: string, + packageManager: PackageManager, + platform: SupportedPlatform +) => `name: Run React Native Harness ${platform === SupportedPlatform.ANDROID ? 'Android' : 'iOS'} + +permissions: + contents: read + +on: + push: + branches: + - main + paths: + - '.github/workflows/harness-${platform}.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/harness-${platform}.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: +${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. * @@ -212,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/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..d13abccf 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,28 @@ export class NitroModuleFactory { await writeFile(iosBuildWorkflowPath, iosBuildWorkflowContent, { encoding: 'utf8', }) + + if (!this.config.includeHarness) { + return + } + + const exampleAppName = `${toPascalCase(this.config.packageName)}Example` + const workflowDirectoryPath = path.join( + this.config.cwd, + '.github', + 'workflows' + ) + + 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) } } 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}"