diff --git a/assets/template/package.json b/assets/template/package.json index cc80725..3127d2f 100644 --- a/assets/template/package.json +++ b/assets/template/package.json @@ -59,7 +59,7 @@ "@types/react": "19.2.0", "nitrogen": "^0.37.1", "react": "19.2.3", - "react-native": "0.86.0", + "react-native": "0.87.1", "react-native-builder-bob": "^0.43.0", "react-native-nitro-modules": "^0.37.1", "conventional-changelog-conventionalcommits": "^10.2.1", diff --git a/e2e/harness-workflows.test.ts b/e2e/harness-workflows.test.ts index df90b69..39f6eb4 100644 --- a/e2e/harness-workflows.test.ts +++ b/e2e/harness-workflows.test.ts @@ -17,12 +17,17 @@ type WorkflowExpectation = { readonly rootDir: string } +type PackageJson = { + readonly devDependencies?: Record +} + const execFileAsync = promisify(execFile) const generatedRoots: string[] = [] const createProject = async ( packageName: string, - monorepo: boolean + monorepo: boolean, + packageType: 'module' | 'view' ): Promise => { const rootDir = await mkdtemp(path.join(os.tmpdir(), 'nitro-cli-e2e-')) generatedRoots.push(rootDir) @@ -51,6 +56,8 @@ const createProject = async ( '--include-harness', '--skip-install', '--ci', + '--package-type', + packageType, ] if (monorepo) { @@ -97,17 +104,28 @@ const assertHarnessScripts = async (rootDir: string): Promise => { 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( + expect(scripts?.['test:harness:android']).toBe( 'react-native-harness --harnessRunner android' ) - expect(scripts?.['test:harness:ios']).toContain( + expect(scripts?.['test:harness:ios']).toBe( 'react-native-harness --harnessRunner ios' ) } +const assertTemplateDependencyVersions = async ( + rootDir: string, + packagePath: string +): Promise => { + const packageJson = JSON.parse( + await readText(path.join(rootDir, packagePath, 'package.json')) + ) as PackageJson + const devDependencies = packageJson.devDependencies + + expect(devDependencies?.nitrogen).toBe('^0.37.1') + expect(devDependencies?.['react-native']).toBe('0.87.1') + expect(devDependencies?.['react-native-nitro-modules']).toBe('^0.37.1') +} + const assertHarnessWorkflowContent = async ( expectation: WorkflowExpectation ): Promise => { @@ -155,6 +173,36 @@ const assertHarnessWorkflowContent = async ( expect(iosBuildWorkflow).not.toContain('$$exampleApp$$') } +const assertHarnessConfigContent = async (rootDir: string): Promise => { + const harnessConfig = await readText( + path.join(rootDir, 'example', 'rn-harness.config.mjs') + ) + + expect(harnessConfig).toContain('platformReadyTimeout: 600000') + expect(harnessConfig).toContain('bridgeTimeout: 300000') +} + +const assertHarnessViewTestContent = async ( + rootDir: string, + harnessFileName: string, + testID: string +): Promise => { + const harnessTest = await readText( + path.join( + rootDir, + 'example', + '__tests__', + `${harnessFileName}.harness.tsx` + ) + ) + + expect(harnessTest).toContain( + "import { StyleSheet, View } from 'react-native'" + ) + expect(harnessTest).toContain('collapsable={false}') + expect(harnessTest).toContain(`testID="${testID}"`) +} + afterAll(async () => { await Promise.all( generatedRoots.map(rootDir => @@ -165,10 +213,12 @@ afterAll(async () => { describe('React Native Harness workflow generation', () => { test('generates build and harness workflows for the default project layout', async () => { - const project = await createProject('rootharness', false) + const project = await createProject('rootharness', false, 'module') await assertWorkflowFiles(project.rootDir) await assertHarnessScripts(project.rootDir) + await assertTemplateDependencyVersions(project.rootDir, '.') + await assertHarnessConfigContent(project.rootDir) await assertHarnessWorkflowContent({ androidBuildWorkflowPath: 'android/**', harnessWorkflowPath: 'src/**', @@ -178,11 +228,13 @@ describe('React Native Harness workflow generation', () => { }, 120_000) test('generates build and harness workflows for the monorepo project layout', async () => { - const project = await createProject('monoharness', true) + const project = await createProject('monoharness', true, 'module') const packagePath = `packages/react-native-${project.packageName}` await assertWorkflowFiles(project.rootDir) await assertHarnessScripts(project.rootDir) + await assertTemplateDependencyVersions(project.rootDir, packagePath) + await assertHarnessConfigContent(project.rootDir) await assertHarnessWorkflowContent({ androidBuildWorkflowPath: `${packagePath}/android/**`, harnessWorkflowPath: `${packagePath}/src/**`, @@ -190,4 +242,14 @@ describe('React Native Harness workflow generation', () => { rootDir: project.rootDir, }) }, 120_000) + + test('generates stable harness test queries for native views', async () => { + const project = await createProject('viewharness', false, 'view') + + await assertHarnessViewTestContent( + project.rootDir, + 'viewharness', + 'viewharness' + ) + }, 120_000) }) diff --git a/src/code-snippets/code.js.ts b/src/code-snippets/code.js.ts index ec86acc..5a40049 100644 --- a/src/code-snippets/code.js.ts +++ b/src/code-snippets/code.js.ts @@ -252,6 +252,7 @@ const config = { ${runners} ], defaultRunner: '${defaultRunner}', + platformReadyTimeout: 600000, bridgeTimeout: 300000, } @@ -291,7 +292,7 @@ describe('${toPascalCase(moduleName)}', () => { } return `import React from 'react' -import { StyleSheet } from 'react-native' +import { StyleSheet, View } from 'react-native' import { describe, it, expect, render } from 'react-native-harness' import { screen } from '@react-native-harness/ui' import { ${toPascalCase(moduleName)} } from '${finalModuleName}' @@ -299,11 +300,12 @@ 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}" - /> + + <${toPascalCase(moduleName)} + isRed={true} + style={styles.view} + /> + ) const view = await screen.findByTestId('${moduleName}') diff --git a/src/code-snippets/code.kotlin.ts b/src/code-snippets/code.kotlin.ts index a7073ee..304a229 100644 --- a/src/code-snippets/code.kotlin.ts +++ b/src/code-snippets/code.kotlin.ts @@ -57,11 +57,9 @@ class Hybrid${toPascalCase(moduleName)}( override val view: View = View(context) // Props - private var _isRed = false - override var isRed: Boolean - get() = _isRed + override var isRed: Boolean = false set(value) { - _isRed = value + field = value view.setBackgroundColor(if (value) Color.RED else Color.BLACK) } } diff --git a/src/generate-nitro-package.ts b/src/generate-nitro-package.ts index 029a6dd..68cc549 100644 --- a/src/generate-nitro-package.ts +++ b/src/generate-nitro-package.ts @@ -692,7 +692,6 @@ export class NitroModuleFactory { encoding: 'utf8', }) const exampleAppPackageJson = JSON.parse(examplePackageJsonStr) - const exampleAppName = `${toPascalCase(this.config.packageName)}Example` exampleAppPackageJson.name = `${this.config.finalPackageName}-example` @@ -737,50 +736,14 @@ 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', - ' 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', - ' echo "Unable to locate the built Android app bundle."', - ' exit 1', - ' fi', - ' export HARNESS_APP_PATH', - 'fi', + 'test:harness:android': 'react-native-harness --harnessRunner android', - ].join('\n'), } : {}), ...(this.config.platforms.includes(SupportedPlatform.IOS) ? { - 'test:harness:ios': [ - 'set -euo pipefail', - 'if [ -z "${DEVICE_MODEL:-}" ] || [ -z "${IOS_VERSION:-}" ]; then', - ' 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', - ' exit 1', - ' fi', - ' DEVICE_MODEL="${IOS_SIMULATOR%%|*}"', - ' IOS_VERSION="${IOS_SIMULATOR#*|}"', - ' export DEVICE_MODEL IOS_VERSION', - 'fi', - 'if [ -z "${HARNESS_APP_PATH:-}" ]; then', - ` 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', + 'test:harness:ios': 'react-native-harness --harnessRunner ios', - ].join('\n'), } : {}), }