From ab35417263d5ccba3419cfd7aea7a3493b3ed462 Mon Sep 17 00:00:00 2001 From: George Fean <37250288+GFean@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:39:04 +0400 Subject: [PATCH 1/2] feat(cli)!: modernize CLI dependencies and improve command help Upgrade pinned CLI dependencies, preserve CommonJS and mobile import boundaries, and add packed runtime compatibility checks. Keep plist metadata XML-only and recognize the verified implicit iOS Hermes default in React Native 0.81.x templates. BREAKING CHANGE: Node.js 22.12.0 or newer is required to install Bundle Drop, including SDK-only consumers. Node 20 is no longer supported. --- .github/workflows/ci.yml | 42 ++ README.md | 20 +- jest.config.cjs | 15 + package.json | 14 +- scripts/check-cli-compatibility.cjs | 79 +++ scripts/check-cli-isolation.cjs | 23 +- scripts/check-package-contents.cjs | 2 + scripts/jest-resolver.cjs | 8 + src/CLI/cli.ts | 39 +- src/CLI/scripts/sight-cli.ts | 2 +- src/CLI/scripts/sight-ota.ts | 20 +- src/CLI/scripts/upload-cli.ts | 14 +- src/CLI/utils/read-xml-plist.ts | 20 + src/manager/updateCheck.ts | 3 +- src/scripts/bundle.ts | 4 + src/scripts/iosHermesDefault.ts | 124 +++++ src/tests/CLI/cli.test.ts | 110 +++- src/tests/CLI/logo.test.ts | 10 + src/tests/CLI/scripts/sight-ota.test.ts | 39 +- src/tests/CLI/scripts/upload-cli.test.ts | 35 ++ src/tests/CLI/utils/read-xml-plist.test.ts | 73 +++ src/tests/fixtures/ios-hermes-default/LICENSE | 21 + .../fixtures/ios-hermes-default/jsengine.rb | 55 ++ .../ios-hermes-default/project.pbxproj | 471 ++++++++++++++++ .../ios-hermes-default/react_native_pods.rb | 525 ++++++++++++++++++ src/tests/manager/updateCheck.test.ts | 25 +- src/tests/scripts/cli-isolation.test.ts | 43 ++ src/tests/scripts/ios-hermes-default.test.ts | 250 +++++++++ tsconfig.json | 4 +- yarn.lock | 65 ++- 30 files changed, 2058 insertions(+), 97 deletions(-) create mode 100644 scripts/check-cli-compatibility.cjs create mode 100644 scripts/jest-resolver.cjs create mode 100644 src/CLI/utils/read-xml-plist.ts create mode 100644 src/scripts/iosHermesDefault.ts create mode 100644 src/tests/CLI/utils/read-xml-plist.test.ts create mode 100644 src/tests/fixtures/ios-hermes-default/LICENSE create mode 100644 src/tests/fixtures/ios-hermes-default/jsengine.rb create mode 100644 src/tests/fixtures/ios-hermes-default/project.pbxproj create mode 100644 src/tests/fixtures/ios-hermes-default/react_native_pods.rb create mode 100644 src/tests/scripts/cli-isolation.test.ts create mode 100644 src/tests/scripts/ios-hermes-default.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b57c7e3..bc7f481 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,48 @@ jobs: - name: Run JavaScript verification gate run: yarn verify:quick + - name: Pack the verified CLI + run: | + mkdir -p "$RUNNER_TEMP/cli-package" + npm pack --ignore-scripts --pack-destination "$RUNNER_TEMP/cli-package" + + - name: Share the package with runtime checks + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: cli-compatibility-package + path: ${{ runner.temp }}/cli-package/*.tgz + retention-days: 3 + + cli-compatibility: + name: CLI (${{ matrix.os }}, Node ${{ matrix.node }}) + needs: javascript + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + node: 22.12.0 + - os: ubuntu-latest + node: 22.13.0 + - os: ubuntu-latest + node: 24.15.0 + - os: windows-latest + node: 22.12.0 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ matrix.node }} + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: cli-compatibility-package + path: ${{ runner.temp }}/cli-package + - name: Test the installed package with native Node imports + shell: bash + run: node scripts/check-cli-compatibility.cjs "$RUNNER_TEMP"/cli-package/*.tgz + android: name: Android runs-on: ubuntu-latest diff --git a/README.md b/README.md index 9500f6b..d0ddc10 100644 --- a/README.md +++ b/README.md @@ -54,9 +54,18 @@ runtime identity. Native changes still require a new App Store or Play Store bui | Bare React Native | React Native 0.71 and newer | | Platforms | iOS and Android | | Architectures | Legacy architecture and New Architecture | -| Node.js | 20.19.4 or newer | +| Node.js | 22.12.0 or newer | | React | 17 or newer | +Node 20 is no longer supported. The Node requirement applies to installation of +the package, including projects that use only the mobile SDK. Development and +package builds use Node 22.13.0, as specified in `.nvmrc`. + +Run `bundle-drop --help` to browse commands grouped into Setup, Analysis, +Releases, and Account. Existing command names and flags remain unchanged. +For iOS metadata, `--plist-file` and Sight accept XML Info.plist files; binary +and OpenStep property lists are not supported. + ## Installation ### Expo @@ -115,6 +124,15 @@ canonical packaging. Each revision uses its own JavaScript engine configuration: Hermes is compiled with project-local tooling when enabled. JavaScript attribution and raw asset sizes remain separate; they are not bytecode attribution. +Bare iOS upload and Sight honor explicit Bundle Drop and native Hermes settings first. +For the standard React Native **0.81.x** template, they also recognize the installed +React Native helpers' implicit Hermes default when those helpers match the verified +0.81.5 files. This conservative fallback is skipped +for custom engine configuration, community JSC indicators, or unfamiliar templates +and versions. For a custom project, set `hermesBytecode: { ios: true }` (or `false` +for JavaScript) in `bundle.drop.config.js` to match the engine in your native app. +The fallback does not evaluate Ruby or change your Podfile. + Measurement adds compilation and archive compression after the JavaScript build, once per side in a comparison. Expo's Hermes path also performs a separate bytecode export to preserve its upload-specific minification behavior. It requires a resolvable app/runtime identity. When that diff --git a/jest.config.cjs b/jest.config.cjs index 6090033..6ae44c4 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -5,7 +5,22 @@ module.exports = { roots: ['/src'], testMatch: ['/src/tests/**/*.test.ts'], clearMocks: true, + resolver: '/scripts/jest-resolver.cjs', + transform: { + '^.+\\.[jt]sx?$': ['ts-jest', { + tsconfig: { + allowJs: true, + isolatedModules: true, + module: 'CommonJS', + moduleResolution: 'Node', + }, + }], + }, + transformIgnorePatterns: [ + '/node_modules/(?!(commander|chalk|figures|is-unicode-supported|plist)/)', + ], moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', '^react-native$': '/src/tests/mocks/modules/react-native.ts', '^react-native/Libraries/Image/AssetRegistry$': '/src/tests/mocks/modules/assetRegistry.ts', '^bundle-drop-config$': '/src/tests/mocks/modules/bundle-drop-config.ts', diff --git a/package.json b/package.json index 8f68340..9fec7a4 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,8 @@ "clean": "rimraf lib dist", "build": "npm run clean && tsc -p tsconfig.build.json && node scripts/make-cli-executable.js", "test": "jest --runInBand", + "typecheck": "tsc --noEmit -p tsconfig.json", + "test:cli:compat": "node scripts/check-cli-compatibility.cjs", "test:coverage": "jest --runInBand --coverage", "coverage:check": "node scripts/check-coverage-thresholds.cjs", "coverage:gate": "yarn test:coverage && yarn coverage:check", @@ -50,7 +52,7 @@ "codeql:local:fast": "node scripts/run-codeql-local.cjs fast", "codeql:local:full": "node scripts/run-codeql-local.cjs full", "codeql:local:compare": "node scripts/run-codeql-local.cjs compare", - "verify:quick": "yarn build && yarn coverage:gate && yarn test:expo:plugin && yarn package:check", + "verify:quick": "yarn typecheck && yarn build && yarn coverage:gate && yarn test:expo:plugin && yarn package:check", "verify:native": "yarn test:android && yarn test:ios", "verify:release": "yarn verify:quick && yarn verify:native && yarn audit:production", "prepack": "npm run build", @@ -162,7 +164,7 @@ } }, "engines": { - "node": ">=20.19.4" + "node": ">=22.12.0" }, "resolutions": { "diff@^4.0.1": "4.0.4", @@ -171,13 +173,13 @@ "dependencies": { "adm-zip": "0.6.1", "axios": "1.20.0", - "chalk": "4.1.2", - "commander": "7.2.0", + "chalk": "6.0.0", + "commander": "15.0.0", "diff": "9.0.0", - "figures": "3.2.0", + "figures": "6.1.0", "form-data": "4.0.6", "fs-extra": "11.4.0", - "plist": "3.1.1", + "plist": "5.0.0", "prompts": "2.4.2" }, "publishConfig": { diff --git a/scripts/check-cli-compatibility.cjs b/scripts/check-cli-compatibility.cjs new file mode 100644 index 0000000..aedd9c9 --- /dev/null +++ b/scripts/check-cli-compatibility.cjs @@ -0,0 +1,79 @@ +#!/usr/bin/env node +// Test the published file layout and native ESM interop, outside Jest and this +// checkout's node_modules. Run after an explicit build, or supply a tarball. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const root = path.resolve(__dirname, '..'); +const nodeDirectory = path.dirname(process.execPath); +const npmCli = [ + path.join(nodeDirectory, 'node_modules/npm/bin/npm-cli.js'), + path.resolve(nodeDirectory, '../lib/node_modules/npm/bin/npm-cli.js'), +].find(file => fs.existsSync(file)); +assert.ok(npmCli, 'npm must be installed alongside the Node executable'); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'bundle-drop-cli-compat-')); +const env = { ...process.env }; +delete env.NODE_PATH; +delete env.NODE_OPTIONS; +delete env.FORCE_COLOR; +delete env.NO_COLOR; + +function run(args, cwd, expectedStatus = 0, extraEnv = {}) { + const result = spawnSync(process.execPath, args, { + cwd, env: { ...env, ...extraEnv }, encoding: 'utf8', + timeout: 180_000, maxBuffer: 8 * 1024 * 1024, + }); + assert.ifError(result.error); + assert.equal(result.status, expectedStatus, `${args.join(' ')}\n${result.stdout}\n${result.stderr}`); + return result.stdout + result.stderr; +} + +try { + let tarball = process.argv[2] && path.resolve(process.argv[2]); + if (!tarball) { + const packed = run([npmCli, 'pack', '--ignore-scripts', '--json', '--pack-destination', temporary], root); + tarball = path.join(temporary, JSON.parse(packed)[0].filename); + } + const fixture = path.join(temporary, 'consumer'); + fs.mkdirSync(fixture); + fs.writeFileSync(path.join(fixture, 'package.json'), JSON.stringify({ + name: 'bundle-drop-cli-compatibility', version: '1.0.0', private: true, + })); + // This is a CLI-only consumer: mobile peer dependencies are deliberately not + // installed. Real mobile peer compatibility is exercised by Metro fixtures. + run([npmCli, 'install', tarball, '--ignore-scripts', '--omit=peer', '--no-audit', '--no-fund'], fixture); + const installed = path.join(fixture, 'node_modules/@gfean/react-native-bundle-drop'); + const cli = path.join(installed, 'lib/CLI/cli.js'); + const help = run([cli, '--help'], fixture, 0, { FORCE_COLOR: '0' }); + for (const group of ['Setup', 'Analysis', 'Releases', 'Account']) assert.ok(help.includes(group), group); + assert.ok(!/\u001b\[/.test(help), 'FORCE_COLOR=0 must suppress ANSI colors'); + for (const command of ['init', 'sync', 'doctor', 'sight', 'upload', 'eas-receipt', 'login', 'logout', 'whoami']) { + assert.ok(run([cli, command, '--help'], fixture).includes('Usage:'), command); + } + assert.ok(run([cli, 'help', 'sight'], fixture).includes('--compare')); + const version = JSON.parse(fs.readFileSync(path.join(installed, 'package.json'))).version; + assert.ok(run([cli, '--cli-version'], fixture).includes(version)); + assert.match(run([cli, 'sight', '--does-not-exist'], fixture, 1), /unknown option/); + assert.match(run([cli, 'upload'], fixture, 1), /missing required argument/); + assert.match(run([cli, 'sight', '--fetch'], fixture, 1), /require --compare/); + assert.match(run([cli, 'sight', '--compare', '', '--platform', 'ios'], fixture, 1), /requires a Git ref/); + for (const level of ['1', '3']) assert.match(run([cli, '--help'], fixture, 0, { FORCE_COLOR: level }), /\u001b\[/); + assert.ok(!/\u001b\[/.test(run([cli, '--help'], fixture, 0, { NO_COLOR: '1' }))); + + const xmlFile = path.join(fixture, 'Info with spaces.plist'); + fs.writeFileSync(xmlFile, 'CFBundleShortVersionString1.2.3'); + const reader = path.join(installed, 'lib/CLI/utils/read-xml-plist.js'); + const probe = `const assert=require('node:assert/strict');require(process.argv[1]).readXmlPlist(process.argv[2]).then(value=>assert.equal(value.CFBundleShortVersionString,'1.2.3')).catch(error=>{console.error(error);process.exitCode=1;});`; + run(['-e', probe, reader, xmlFile], fixture); + for (const contents of ['bplist00invalid', '{ CFBundleShortVersionString = "1.2.3"; }', '']) { + fs.writeFileSync(xmlFile, contents); + const reject = `require(process.argv[1]).readXmlPlist(process.argv[2]).then(()=>{process.exitCode=1},()=>{});`; + run(['-e', reject, reader, xmlFile], fixture); + } + console.log(`Packed CLI compatibility passed: ${process.platform}, Node ${process.version}, package ${version}`); +} finally { + fs.rmSync(temporary, { recursive: true, force: true }); +} diff --git a/scripts/check-cli-isolation.cjs b/scripts/check-cli-isolation.cjs index 2b1ece9..3672f49 100644 --- a/scripts/check-cli-isolation.cjs +++ b/scripts/check-cli-isolation.cjs @@ -4,8 +4,9 @@ const path = require('path'); const ts = require('typescript'); const forbidden = /(?:^|\/)CLI\/scripts\/sight-(?:compare(?:\/|$)|(?:artifacts|assets|ota|session|cli)\.)/; +const cliDependencies = new Set(['chalk', 'commander', 'figures', 'plist']); -function relativeModules(file) { +function importedModules(file) { const source = ts.createSourceFile(file, fs.readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true); const modules = []; function visit(node) { @@ -22,7 +23,7 @@ function relativeModules(file) { ts.forEachChild(node, visit); } visit(source); - return modules.filter(name => name.startsWith('.')); + return modules; } function checkCliIsolation(root, entries) { @@ -30,14 +31,22 @@ function checkCliIsolation(root, entries) { function visit(file, chain) { if (visited.has(file)) return; const relative = path.relative(root, file).split(path.sep).join('/'); - if (forbidden.test(relative)) throw new Error(`Sight CLI code is reachable from an SDK/tooling entrypoint: ${[...chain, relative].join(' -> ')}`); + const sight = forbidden.test(relative); + if (sight || relative.includes('CLI/utils/read-xml-plist.')) throw new Error(`${sight ? 'Sight CLI' : 'CLI'} code is reachable from an SDK/tooling entrypoint: ${[...chain, relative].join(' -> ')}`); visited.add(file); - for (const name of relativeModules(file)) { + for (const name of importedModules(file)) { + if (cliDependencies.has(name.split('/')[0])) { + throw new Error(`CLI dependency is reachable from an SDK/tooling entrypoint: ${[...chain, relative, name].join(' -> ')}`); + } + if (!name.startsWith('.')) continue; const base = path.resolve(path.dirname(file), name); + const stem = base.replace(/\.[cm]?js$/, ''); const declaration = file.endsWith('.d.ts'); const candidates = declaration - ? [base + '.d.ts', path.join(base, 'index.d.ts'), base] - : [base + '.js', base + '.cjs', base + '.mjs', base + '.ts', base + '.tsx', path.join(base, 'index.js'), path.join(base, 'index.ts'), path.join(base, 'index.tsx'), base]; + ? [stem + '.d.ts', path.join(base, 'index.d.ts'), base] + : /\.tsx?$/.test(file) + ? [stem + '.ts', stem + '.tsx', base, path.join(base, 'index.ts'), path.join(base, 'index.tsx'), path.join(base, 'index.js')] + : [base, base + '.js', base + '.cjs', base + '.mjs', path.join(base, 'index.js')]; const resolved = candidates.find(candidate => fs.existsSync(candidate) && fs.statSync(candidate).isFile()); if (resolved && /\.(?:[cm]?js|tsx?)$/.test(resolved)) visit(resolved, [...chain, relative]); } @@ -53,5 +62,5 @@ if (require.main === module) { 'lib/index.js', 'lib/bootstrap.js', 'lib/index.d.ts', 'lib/metro.js', 'lib/metro.d.ts', 'app.plugin.js', 'src/index.tsx', 'src/bootstrap.ts', 'src/metro.ts', ]); - console.log(`CLI isolation check passed: ${modules.length} SDK/tooling modules inspected; no Sight comparison imports.`); + console.log(`CLI isolation check passed: ${modules.length} SDK/tooling modules inspected; no Sight or CLI dependency imports.`); } diff --git a/scripts/check-package-contents.cjs b/scripts/check-package-contents.cjs index bc0b35d..747337a 100644 --- a/scripts/check-package-contents.cjs +++ b/scripts/check-package-contents.cjs @@ -171,6 +171,8 @@ const expectedScripts = new Set([ 'clean', 'build', 'test', + 'typecheck', + 'test:cli:compat', 'test:coverage', 'coverage:check', 'coverage:gate', diff --git a/scripts/jest-resolver.cjs b/scripts/jest-resolver.cjs new file mode 100644 index 0000000..9b285ae --- /dev/null +++ b/scripts/jest-resolver.cjs @@ -0,0 +1,8 @@ +// Plist exposes only an ESM import entry. Production uses native import(); +// Jest transforms that entry for its CommonJS test environment. +module.exports = (request, options) => options.defaultResolver( + request, + request === 'plist' + ? { ...options, conditions: [...(options.conditions || []), 'import'] } + : options, +); diff --git a/src/CLI/cli.ts b/src/CLI/cli.ts index 06ada29..2ecc75f 100644 --- a/src/CLI/cli.ts +++ b/src/CLI/cli.ts @@ -2,7 +2,7 @@ import axios from 'axios'; import chalk from 'chalk'; -import { Command } from 'commander'; +import { Argument, Command, Option } from 'commander'; import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -155,7 +155,11 @@ export const buildProgram = () => { program .name('bundle-drop') - .version(pkg.version || '0.0.0', '-v, --cli-version', 'Show CLI version'); + .version(pkg.version || '0.0.0', '-v, --cli-version', 'Show CLI version') + // Preserve the CLI's existing handling of extra arguments in shell wrappers. + .allowExcessArguments(true) + .configureHelp({ styleTitle: title => chalk.bold.cyan(title) }) + .showHelpAfterError('Run "bundle-drop --help" for usage.'); program.addHelpText('beforeAll', logo); @@ -163,7 +167,6 @@ export const buildProgram = () => { 'before', ` ${chalk.magentaBright('Ship OTA Updates with Confidence\n')} -${chalk.bold.cyan('Available Commands:')} `, ); @@ -187,6 +190,7 @@ ${chalk.gray('CI/CD →')} ${chalk.underline.gray(DOCS_CI_CD_URL)}\n`, program .command('sight') + .helpGroup('Analysis:') .option('--compare ', 'Compare the working tree with an explicit Git baseline') .option('--fetch', 'Refresh the explicitly named remote branch before comparing') .option('--include ', 'Copy an ignored local input identically into both builds (repeatable)', @@ -214,6 +218,7 @@ ${chalk.bold('Examples:')} program .command('upload ') + .helpGroup('Releases:') .option('--plist-file ', 'Path to Info.plist for iOS') .option( '--version ', @@ -246,6 +251,7 @@ ${chalk.gray('CI/CD docs →')} ${chalk.underline.gray(DOCS_CI_CD_URL)} program .command('logout') + .helpGroup('Account:') .description('Log out of the CLI') .action(() => { const tokenPath = getTokenPath(); @@ -260,13 +266,15 @@ ${chalk.gray('CI/CD docs →')} ${chalk.underline.gray(DOCS_CI_CD_URL)} program .command('login') + .helpGroup('Account:') .description('Log in to the CLI') .action(() => require('../CLI/scripts/login-cli').default()); program .command('init') + .helpGroup('Setup:') .option('--token ', 'Personal Access Token (alternative to `bundle-drop login`)') - .option('--project-type ', 'Force project type: expo or bare') + .addOption(new Option('--project-type ', 'Force project type: expo or bare').choices(['expo', 'bare'])) .option('--dry-run', 'Preview setup and AI context without changing files') .option( '--migrate-code-push', @@ -285,9 +293,6 @@ ${chalk.gray('CI/CD docs →')} ${chalk.underline.gray(DOCS_CI_CD_URL)} prebuild?: boolean; yes?: boolean; }) => { - if (options.projectType && !['expo', 'bare'].includes(options.projectType)) { - throw new Error('--project-type must be expo or bare.'); - } if (options.token) { const { detectProjectType } = require('../expo'); const projectType = detectProjectType({ @@ -417,6 +422,7 @@ ${chalk.gray('CI/CD docs →')} ${chalk.underline.gray(DOCS_CI_CD_URL)} program .command('sync') + .helpGroup('Setup:') .option('--token ', 'Personal Access Token (alternative to `bundle-drop login`)') .option('--dry-run', 'Validate and preview bootstrap synchronization without writing') .description('Refresh the package-managed runtime delivery bootstrap') @@ -461,8 +467,9 @@ ${chalk.gray('CI/CD docs →')} ${chalk.underline.gray(DOCS_CI_CD_URL)} program .command('doctor') - .option('--platform ', 'Limit checks to ios or android') - .option('--project-type ', 'Force project type: expo or bare') + .helpGroup('Setup:') + .addOption(new Option('--platform ', 'Limit checks to ios or android').choices(['ios', 'android'])) + .addOption(new Option('--project-type ', 'Force project type: expo or bare').choices(['expo', 'bare'])) .description('Validate Bundle Drop setup, runtime identity, and OTA startup ownership') .addHelpText( 'after', @@ -474,17 +481,13 @@ ${chalk.bold('Examples:')} `, ) .action(async (options: { platform?: 'ios' | 'android'; projectType?: ProjectType }) => { - if (options.platform && !['ios', 'android'].includes(options.platform)) { - throw new Error('--platform must be ios or android.'); - } - if (options.projectType && !['expo', 'bare'].includes(options.projectType)) { - throw new Error('--project-type must be expo or bare.'); - } await require('./scripts/doctor').runDoctor(options); }); program - .command('eas-receipt ') + .command('eas-receipt') + .helpGroup('Releases:') + .addArgument(new Argument('', 'Platform of the finished EAS build').choices(['ios', 'android'])) .requiredOption('--build-id ', 'Exact finished EAS application build ID') .option('--output ', 'Receipt path; defaults under .bundle-drop') .description('Create an authenticated Expo build receipt from official EAS metadata') @@ -492,9 +495,6 @@ ${chalk.bold('Examples:')} platform: string, options: { buildId: string; output?: string }, ) => { - if (platform !== 'ios' && platform !== 'android') { - throw new Error('eas-receipt platform must be ios or android.'); - } const receiptPath = await require('./scripts/expo/write-eas-build-receipt') .writeEasBuildReceipt({ projectRoot: process.cwd(), @@ -507,6 +507,7 @@ ${chalk.bold('Examples:')} program .command('whoami') + .helpGroup('Account:') .description('Show currently logged in user') .action(() => { const authState = readStoredAuthData(); diff --git a/src/CLI/scripts/sight-cli.ts b/src/CLI/scripts/sight-cli.ts index 86d681f..98ff3ca 100644 --- a/src/CLI/scripts/sight-cli.ts +++ b/src/CLI/scripts/sight-cli.ts @@ -86,7 +86,7 @@ export async function runSightCommand(options: SightCommandOptions): Promise { if (options.platform === 'android') { const files = ['build.gradle', 'build.gradle.kts'].map(name => path.join(options.projectRoot, 'android', 'app', name)).filter(file => fs.existsSync(file)); if (files.length !== 1) return undefined; @@ -76,14 +76,16 @@ function bareAppVersion(options: MeasureSightOtaOptions): string | undefined { for (const item of fs.readdirSync(ios, { withFileTypes: true })) { if (item.isDirectory() && !['Pods', 'build'].includes(item.name)) candidates.push(path.join(ios, item.name, 'Info.plist')); } - const documents = candidates.filter(file => fs.existsSync(file)).map(file => - plist.parse(fs.readFileSync(insideSnapshot(options, file), 'utf8')) as Record); - const applications = documents.filter(document => document.CFBundlePackageType === 'APPL'); + const documents = await Promise.all(candidates.filter(file => fs.existsSync(file)).map(file => + readXmlPlist(insideSnapshot(options, file)))); + const applications = documents.filter(document => + Object.prototype.hasOwnProperty.call(document, 'CFBundlePackageType') && document.CFBundlePackageType === 'APPL'); // Test bundles and extensions have their own versions, unrelated to the app's OTA identity. // Older app plists may omit the package type; use those only when no explicit app exists. const appDocuments = applications.length > 0 ? applications : documents.filter(document => - document.CFBundlePackageType === undefined && document.NSExtension === undefined); - const versions = appDocuments.map(document => iosVersion(options, document.CFBundleShortVersionString)); + !Object.prototype.hasOwnProperty.call(document, 'CFBundlePackageType') && !Object.prototype.hasOwnProperty.call(document, 'NSExtension')); + const versions = appDocuments.map(document => iosVersion(options, + Object.prototype.hasOwnProperty.call(document, 'CFBundleShortVersionString') ? document.CFBundleShortVersionString : undefined)); if (!versions.length || !versions.every(concreteVersion) || new Set(versions).size !== 1) return undefined; return versions[0] as string; } @@ -146,11 +148,11 @@ export async function measureInstalledSightOta(options: MeasureSightOtaOptions): return unavailable(`Bundle Drop has no runtime version configured for ${options.platform}. Set runtimeVersion.${options.platform} in bundle.drop.config.js.`); } reason = `The native ${options.platform} app version could not be resolved unambiguously for Bundle Drop OTA packaging.`; - const version = bareAppVersion(options); + const version = await bareAppVersion(options); if (!version) return unavailable(reason); appVersion = version; runtimeVersion = runtime; - hermes = shouldCompileHermesBytecode(config, options.platform, options.projectRoot); + hermes = shouldCompileHermesBytecode(config, options.platform, options.projectRoot, options.snapshotRoot); } const bundlePath = path.join(options.workDirectory, 'main.jsbundle'); let assetsDirectory = options.assetsDirectory; diff --git a/src/CLI/scripts/upload-cli.ts b/src/CLI/scripts/upload-cli.ts index a1650c1..ea2f520 100644 --- a/src/CLI/scripts/upload-cli.ts +++ b/src/CLI/scripts/upload-cli.ts @@ -3,9 +3,9 @@ import { spawnSync } from 'child_process'; import FormData from 'form-data'; import * as fs from 'fs'; import * as path from 'path'; -import * as plist from 'plist'; import { log, startLoadingStatus } from '../utils/ui'; +import { readXmlPlist } from '../utils/read-xml-plist'; import { resolveIosPlistVersion } from '../../scripts/resolve-ios-version'; import { assertExpoUpdatesDoesNotOwnStartup, @@ -292,13 +292,17 @@ export async function runUpload( } try { - const plistContent = fs.readFileSync(options.plistFile, 'utf-8'); - const parsed = plist.parse(plistContent) as any; - const rawVersion: string | undefined = parsed.CFBundleShortVersionString; - if (!rawVersion) { + const parsed = await readXmlPlist(options.plistFile); + const rawVersion = Object.prototype.hasOwnProperty.call(parsed, 'CFBundleShortVersionString') + ? parsed.CFBundleShortVersionString + : undefined; + if (rawVersion === undefined) { log.error('❌ Could not find CFBundleShortVersionString in plist'); process.exit(1); } + if (typeof rawVersion !== 'string' || !rawVersion.trim()) { + throw new Error('CFBundleShortVersionString must be a non-empty string.'); + } const resolved = resolveIosPlistVersion(rawVersion, projectRoot); if (!resolved) { process.exit(1); diff --git a/src/CLI/utils/read-xml-plist.ts b/src/CLI/utils/read-xml-plist.ts new file mode 100644 index 0000000..7482690 --- /dev/null +++ b/src/CLI/utils/read-xml-plist.ts @@ -0,0 +1,20 @@ +import fs from 'node:fs'; + +/** Read native XML metadata without enabling Plist's binary/OpenStep autodetection. */ +export async function readXmlPlist(file: string): Promise> { + const xml = fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''); + if (!xml.trimStart().startsWith('<')) { + throw new Error('Info.plist must be an XML property list.'); + } + + const { parse } = await import('plist'); + const document = parse(xml); + if ( + document === null || + typeof document !== 'object' || + Object.getPrototypeOf(document) !== Object.prototype + ) { + throw new Error('Info.plist must contain a dictionary.'); + } + return document as Record; +} diff --git a/src/manager/updateCheck.ts b/src/manager/updateCheck.ts index 5860fed..0f3dfab 100644 --- a/src/manager/updateCheck.ts +++ b/src/manager/updateCheck.ts @@ -110,7 +110,8 @@ async function readResolveContext(channelName: string): Promise require('../native/fs') as typeof import('../native/fs')) .then(module => module.default.supportsXdelta()) .catch(() => false); return { diff --git a/src/scripts/bundle.ts b/src/scripts/bundle.ts index 18de4bd..21c4da9 100644 --- a/src/scripts/bundle.ts +++ b/src/scripts/bundle.ts @@ -8,6 +8,7 @@ import { BUNDLE_MANIFEST } from '../manifest/bundleManifest'; import { buildCanonicalArtifact } from './canonicalArtifact'; import { findProjectRoot } from './projectRoot'; import { resolveModuleFrom, type ModuleResolver } from './resolveModule'; +import { detectImplicitIosHermes } from './iosHermesDefault'; export { findProjectRoot }; @@ -204,6 +205,7 @@ export const shouldCompileHermesBytecode = ( cfg: Record, platform: string, projectRoot: string, + snapshotRoot?: string, ): boolean => { const explicit = readPlatformSetting(cfg.hermesBytecode, platform) ?? @@ -214,6 +216,8 @@ export const shouldCompileHermesBytecode = ( const detected = detectHermesFromNativeProject(projectRoot, platform); if (detected !== undefined) return detected; + if (platform === 'ios' && detectImplicitIosHermes(projectRoot, snapshotRoot)) return true; + if (explicit === 'auto') { console.warn( `⚠️ Could not detect Hermes for ${platform}; set hermesBytecode.${platform} in bundle.drop.config.js to opt in explicitly.`, diff --git a/src/scripts/iosHermesDefault.ts b/src/scripts/iosHermesDefault.ts new file mode 100644 index 0000000..4fff496 --- /dev/null +++ b/src/scripts/iosHermesDefault.ts @@ -0,0 +1,124 @@ +import fs from 'fs'; +import path from 'path'; +import { createHash } from 'crypto'; + +// This recognizes a known template, not arbitrary Ruby configuration. Unknown +// layouts must keep using the existing detector or an explicit SDK setting. +const engineMention = /hermes|jsc|javascriptcore|js[_-]?engine/i; +function withoutUnusedTemplateFramework(text: string): string { + // The stock Xcode template lists JavaScriptCore in its navigator but does not + // link it. Ignore that exact unused reference, not linked JSC or engine flags. + const reference = text.match(/^\s*([A-F0-9]{24}) \/\* JavaScriptCore\.framework \*\/ = \{isa = PBXFileReference; lastKnownFileType = wrapper\.framework; name = JavaScriptCore\.framework; path = System\/Library\/Frameworks\/JavaScriptCore\.framework; sourceTree = SDKROOT; \};$/m); + if (!reference || text.split(reference[1]).length !== 3) return text; + return text.replace(reference[0], '').replace( + new RegExp(`^\\s*${reference[1]} /\\* JavaScriptCore\\.framework \\*/,\\s*$`, 'm'), '', + ); +} +// RN 0.81.5's shipped helpers. Deliberately decline other implementations rather +// than interpret Ruby or assume that a package version proves engine semantics. +const helperHashes = { + 'scripts/react_native_pods.rb': 'bd090959a5ff31edcd1c391a44a4e5f342969a33e11f9d4ecd457bda2a2eef73', + 'scripts/cocoapods/jsengine.rb': '4bc4b49e4ab5adf1fc2000e276822cb31af7819a5d9ce1a635563cb7cc527106', +}; +const compactRuby = (text: string): string => text + .replace(/^\s*#.*$/gm, '') + .replace(/\s+/g, ' ') + .replace(/"/g, "'") + .trim(); + +const standardLoader = compactRuby(`require Pod::Executable.execute_command('node', ['-p', + 'require.resolve( + "react-native/scripts/react_native_pods.rb", + {paths: [process.argv[1]]}, + )', __dir__]).strip`); +const standardArguments = compactRuby(` + :path => config[:reactNativePath], + :app_path => "#{Pod::Config.instance.installation_root}/.." +`); + +/** Only infer the installed 0.81.x template's default after explicit detection. */ +export function detectImplicitIosHermes(projectRoot: string, snapshotRoot?: string): true | undefined { + if (process.env.USE_HERMES !== undefined || process.env.USE_THIRD_PARTY_JSC !== undefined) return undefined; + + try { + const boundary = snapshotRoot ? fs.realpathSync(snapshotRoot) : undefined; + function checked(file: string): string { + const resolved = fs.realpathSync(file); + if (boundary) { + const relative = path.relative(boundary, resolved); + if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + throw new Error('Hermes configuration resolved outside the comparison snapshot.'); + } + } + return resolved; + } + function read(file: string): string { + return fs.readFileSync(checked(file), 'utf8'); + } + const app = JSON.parse(read(path.join(projectRoot, 'package.json'))); + const dependencies = { ...app.dependencies, ...app.devDependencies, ...app.optionalDependencies, ...app.peerDependencies }; + if (!dependencies['react-native'] || Object.keys(dependencies).some(name => /jsc|javascriptcore/i.test(name))) return undefined; + if (['js', 'cjs', 'mjs', 'ts'].some(extension => fs.existsSync(path.join(projectRoot, `react-native.config.${extension}`)))) return undefined; + + const ios = path.join(projectRoot, 'ios'); + if (fs.existsSync(path.join(ios, 'node_modules/react-native'))) return undefined; + const podfile = read(path.join(ios, 'Podfile')); + const nativeFiles = ['.xcode.env', '.xcode.env.local', 'Podfile.properties.json'] + .map(name => path.join(ios, name)); + for (const name of fs.readdirSync(checked(ios))) { + if (name.endsWith('.xcodeproj')) nativeFiles.push(path.join(ios, name, 'project.pbxproj')); + if (name.endsWith('.xcconfig')) nativeFiles.push(path.join(ios, name)); + } + const nativeContents = nativeFiles.filter(file => fs.existsSync(file)).map(file => { + const contents = read(file); + return file.endsWith('project.pbxproj') ? withoutUnusedTemplateFramework(contents) : contents; + }); + if ([podfile, ...nativeContents].some(text => engineMention.test(text))) return undefined; + // The stock template already references CocoaPods' generated debug/release + // configs before pods are installed. Other referenced configs are custom. + for (const text of nativeContents) { + for (const line of text.split('\n').filter(line => line.includes('baseConfigurationReference'))) { + if (!/\/\* Pods-[\w-]+\.(?:debug|release)\.xcconfig \*\//.test(line)) return undefined; + } + } + + const ruby = compactRuby(podfile); + const calls = [...ruby.matchAll(/\buse_react_native!\s*\(([^()]*)\)/g)]; + if (!ruby.includes(standardLoader) || calls.length !== 1 || + [...ruby.matchAll(/\buse_react_native!/g)].length !== 1 || + compactRuby(calls[0][1]) !== standardArguments || + !/\bconfig = use_native_modules! use_react_native!/.test(ruby) || + [...ruby.matchAll(/\bconfig\s*=/g)].length !== 1 || + /\bconfig\s*(?:\[[^\]]*\]\s*=|\.)/.test(ruby) || + /\b(?:def|eval|load|require_relative)\b/.test(ruby) || + [...ruby.matchAll(/\brequire\b(?!\.)/g)].length !== 1) return undefined; + + // Search only the consumer's node_modules ancestry: never NODE_PATH or the + // CLI's own React Native dependency. Hoisted and symlinked installs work too. + let directory = path.resolve(projectRoot); + let manifest: string | undefined; + while (true) { + const candidate = path.join(directory, 'node_modules/react-native/package.json'); + if (fs.existsSync(candidate)) { + manifest = checked(candidate); + break; + } + const parent = path.dirname(directory); + if (parent === directory || (boundary && fs.realpathSync(directory) === boundary)) break; + directory = parent; + } + if (!manifest) return undefined; + const rn = JSON.parse(read(manifest)); + if (rn.name !== 'react-native' || typeof rn.version !== 'string' || !/^0\.81\.\d+$/.test(rn.version)) return undefined; + + const root = path.dirname(manifest); + for (const [file, expectedHash] of Object.entries(helperHashes)) { + const source = read(path.join(root, file)).replace(/\r\n/g, '\n'); + if (createHash('sha256').update(source).digest('hex') !== expectedHash) return undefined; + } + return true; + } catch { + // Missing, unreadable, or unfamiliar inputs provide no evidence of a default. + return undefined; + } +} diff --git a/src/tests/CLI/cli.test.ts b/src/tests/CLI/cli.test.ts index e4e30ef..3103ddb 100644 --- a/src/tests/CLI/cli.test.ts +++ b/src/tests/CLI/cli.test.ts @@ -58,8 +58,16 @@ describe('CLI/cli', () => { let homedirSpy: jest.SpyInstance; let consoleLogSpy: jest.SpyInstance; - const parseCommand = async (...args: string[]) => { + const buildTestProgram = (write: (value: string) => void = () => undefined) => { const program = buildProgram(); + for (const command of [program, ...program.commands]) { + command.exitOverride().configureOutput({ writeOut: write, writeErr: write }); + } + return program; + }; + + const parseCommand = async (...args: string[]) => { + const program = buildTestProgram(); await program.parseAsync(['node', 'bundle-drop', ...args]); }; @@ -162,7 +170,8 @@ describe('CLI/cli', () => { error = caught as Error; } - expect(error?.message).toBe('--project-type must be expo or bare.'); + expect(error?.message).toContain("argument 'unknown' is invalid"); + expect(error?.message).toContain('Allowed choices are expo, bare.'); expect(error?.message).not.toContain('Manual setup'); expect(mockInitConfig).not.toHaveBeenCalled(); expect(mockRunPostInitPrompts).not.toHaveBeenCalled(); @@ -176,11 +185,12 @@ describe('CLI/cli', () => { })); await expect(parseCommand('doctor', '--platform', 'web')).rejects.toThrow( - '--platform must be ios or android', + 'Allowed choices are ios, android.', ); await expect(parseCommand('doctor', '--project-type', 'unknown')).rejects.toThrow( - '--project-type must be expo or bare', + 'Allowed choices are expo, bare.', ); + expect(mockRunDoctor).toHaveBeenCalledTimes(1); }); it('routes Sight options to the local analysis command', async () => { @@ -218,6 +228,67 @@ describe('CLI/cli', () => { }), expect.anything()); }); + it('preserves default browser opening and ignores legacy extra arguments after the option terminator', async () => { + await parseCommand('sight', '--', '--no-open', 'wrapper-argument'); + + expect(mockRunSightCommand).toHaveBeenCalledWith( + expect.objectContaining({ open: true, include: [] }), + expect.anything(), + ); + await parseCommand('upload', 'ios', 'wrapper-argument', '--version', '1.2.3'); + expect(mockUpload).toHaveBeenCalledWith( + 'ios', expect.objectContaining({ version: '1.2.3' }), expect.anything(), + ); + }); + + it('preserves paths and release notes passed as individual shell arguments', async () => { + await parseCommand( + 'upload', 'ios', '--plist-file', './ios/My App/Info.plist', + '--release-notes', 'Fix login\nKeep the existing session.', + ); + + expect(mockUpload).toHaveBeenCalledWith('ios', expect.objectContaining({ + plistFile: './ios/My App/Info.plist', + releaseNotes: 'Fix login\nKeep the existing session.', + }), expect.anything()); + }); + + it.each([ + ['upload'], + ['eas-receipt', '--build-id', 'build-123'], + ['eas-receipt', 'ios'], + ['sight', '--compare'], + ])('rejects incomplete command arguments before running an action: %s', async (...args) => { + await expect(parseCommand(...args)).rejects.toMatchObject({ exitCode: 1 }); + expect(mockUpload).not.toHaveBeenCalled(); + expect(mockWriteEasBuildReceipt).not.toHaveBeenCalled(); + expect(mockRunSightCommand).not.toHaveBeenCalled(); + }); + + it.each([ + { args: ['sigth'], suggestion: 'sight' }, + { args: ['sight', '--platfrom', 'ios'], suggestion: '--platform' }, + ])('suggests corrections without running an action for $args', async ({ args, suggestion }) => { + let output = ''; + const program = buildTestProgram(value => { output += value; }); + + await expect(program.parseAsync(['node', 'bundle-drop', ...args])).rejects.toMatchObject({ exitCode: 1 }); + expect(output).toContain(`Did you mean ${suggestion}?`); + expect(output).toContain('Run "bundle-drop --help" for usage.'); + expect(mockRunSightCommand).not.toHaveBeenCalled(); + }); + + it.each(['-v', '--cli-version'])('keeps %s separate from the upload app version', async flag => { + let output = ''; + const program = buildTestProgram(value => { output += value; }); + + await expect(program.parseAsync(['node', 'bundle-drop', flag])).rejects.toMatchObject({ + code: 'commander.version', exitCode: 0, + }); + expect(output.trim()).toBe(program.version()); + expect(mockUpload).not.toHaveBeenCalled(); + }); + it('includes doctor in top-level and command-specific help', () => { const program = buildProgram(); let topLevelHelp = ''; @@ -228,6 +299,14 @@ describe('CLI/cli', () => { expect(topLevelHelp).toContain('bundle-drop sight'); expect(topLevelHelp).not.toContain('init-native'); expect(topLevelHelp).not.toContain('init-metro'); + expect(topLevelHelp).not.toContain('Available Commands:'); + expect(topLevelHelp).toContain('React Native Bundle Drop CLI'); + for (const heading of ['Setup:', 'Analysis:', 'Releases:', 'Account:']) { + expect(topLevelHelp).toContain(heading); + } + for (const name of ['init', 'sync', 'doctor', 'sight', 'upload', 'eas-receipt', 'login', 'logout', 'whoami']) { + expect(topLevelHelp).toMatch(new RegExp(`\\b${name}\\b`)); + } const doctorCommand = program.commands.find(command => command.name() === 'doctor'); expect(doctorCommand).toBeDefined(); @@ -272,7 +351,8 @@ describe('CLI/cli', () => { 'web', '--build-id', '11111111-1111-4111-8111-111111111111', - )).rejects.toThrow('platform must be ios or android'); + )).rejects.toThrow('Allowed choices are ios, android.'); + expect(mockWriteEasBuildReceipt).toHaveBeenCalledTimes(1); } finally { cwdSpy.mockRestore(); } @@ -752,6 +832,26 @@ describe('CLI/cli', () => { } }); + it('uses process arguments and reports non-Error command rejections as failures', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + const originalArgv = process.argv; + const originalExitCode = process.exitCode; + process.argv = ['node', 'bundle-drop', 'login']; + mockLogin.mockRejectedValueOnce('Authentication was cancelled.'); + + try { + await runCli(); + + expect(mockLogin).toHaveBeenCalledTimes(1); + expect(consoleErrorSpy).toHaveBeenCalledWith('❌ Authentication was cancelled.'); + expect(process.exitCode).toBe(1); + } finally { + process.argv = originalArgv; + process.exitCode = originalExitCode; + consoleErrorSpy.mockRestore(); + } + }); + it('normalizes non-Error setup failures before linking to manual setup', async () => { const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); const originalExitCode = process.exitCode; diff --git a/src/tests/CLI/logo.test.ts b/src/tests/CLI/logo.test.ts index aa173dd..6a4a891 100644 --- a/src/tests/CLI/logo.test.ts +++ b/src/tests/CLI/logo.test.ts @@ -37,4 +37,14 @@ describe('buildBundleDropLogo', () => { it('keeps the mono silhouette to block glyphs and whitespace only', () => { expect(BUNDLE_DROP_LOGO_MONO).toMatch(/^[\s\u2580\u2584\u2588]+$/); }); + + it('renders a plain logo when terminal colors are disabled', () => { + chalk.level = 0; + + const logo = buildBundleDropLogo(); + + expect(logo).toContain(BUNDLE_DROP_LOGO_MONO); + expect(logo).toContain('React Native Bundle Drop CLI'); + expect(logo).not.toContain('\u001b['); + }); }); diff --git a/src/tests/CLI/scripts/sight-ota.test.ts b/src/tests/CLI/scripts/sight-ota.test.ts index a03e05f..796a959 100644 --- a/src/tests/CLI/scripts/sight-ota.test.ts +++ b/src/tests/CLI/scripts/sight-ota.test.ts @@ -2,7 +2,6 @@ import childProcess from 'child_process'; import fs from 'fs'; import os from 'os'; import path from 'path'; -import plist from 'plist'; import { measureInstalledSightOta, measureSightOta, type MeasureSightOtaOptions } from '../../../CLI/scripts/sight-ota'; import { resolveExpoBuildIdentity } from '../../../expo/buildIdentity'; import { runComparisonProcess } from '../../../CLI/scripts/sight-compare/process'; @@ -14,6 +13,7 @@ jest.mock('../../../CLI/scripts/sight-compare/process', () => ({ })); const magic = Buffer.from([0xc6, 0x1f, 0xbc, 0x03, 0xc1, 0x03, 0x19, 0x1f]); +const xmlPlist = (fields: string): string => `${fields}`; describe('Sight local OTA measurements', () => { let root: string; @@ -123,7 +123,9 @@ describe('Sight local OTA measurements', () => { it.each(['literal', 'variable', 'mixed', 'unresolved', 'non-string', 'missing', 'empty-project', 'invalid-setting'])('handles iOS version identity %s conservatively', async scenario => { options.platform = 'ios'; if (scenario !== 'missing') { - write(path.join(root, 'ios/App/Info.plist'), plist.build({ CFBundleShortVersionString: scenario === 'literal' ? '1.2.3' : scenario === 'non-string' ? 123 : '$(MARKETING_VERSION)' })); + const version = scenario === 'non-string' ? '123' + : `${scenario === 'literal' ? '1.2.3' : '$(MARKETING_VERSION)'}`; + write(path.join(root, 'ios/App/Info.plist'), xmlPlist(`CFBundleShortVersionString${version}`)); fs.mkdirSync(path.join(root, 'ios/Pods')); write(path.join(root, 'ios/note.txt'), 'not a project'); fs.mkdirSync(path.join(root, 'ios/Empty.xcodeproj')); @@ -139,29 +141,44 @@ describe('Sight local OTA measurements', () => { options.platform = 'ios'; fs.mkdirSync(path.join(root, 'ios')); expect((await measureInstalledSightOta(options)).status).toBe('unavailable'); - write(path.join(root, 'ios/Info.plist'), plist.build({ CFBundleShortVersionString: '1.0.0' })); - write(path.join(root, 'ios/Other/Info.plist'), plist.build({ CFBundleShortVersionString: '2.0.0' })); + write(path.join(root, 'ios/Info.plist'), xmlPlist('CFBundleShortVersionString1.0.0')); + write(path.join(root, 'ios/Other/Info.plist'), xmlPlist('CFBundleShortVersionString2.0.0')); expect((await measureInstalledSightOta(options)).status).toBe('unavailable'); }); + it.each([ + ['OpenStep', '{ CFBundleShortVersionString = "1.2.3"; }'], + ['binary', Buffer.from('bplist00\0\0')], + ['array', ''], + ['empty version', 'CFBundleShortVersionString'], + ])('keeps unsupported iOS metadata unavailable: %s', async (_name, contents) => { + options.platform = 'ios'; + write(path.join(root, 'ios/App/Info.plist'), contents); + expect(await measureInstalledSightOta(options)).toEqual({ + status: 'unavailable', + reason: 'The native ios app version could not be resolved unambiguously for Bundle Drop OTA packaging.', + }); + expect(fs.existsSync(path.join(options.workDirectory, 'bundle-ios.zip'))).toBe(false); + }); + it('uses the application version rather than test and extension plist versions', async () => { options.platform = 'ios'; - write(path.join(root, 'ios/App/Info.plist'), plist.build({ CFBundlePackageType: 'APPL', CFBundleShortVersionString: '4.7.9' })); - write(path.join(root, 'ios/AppTests/Info.plist'), plist.build({ CFBundlePackageType: 'BNDL', CFBundleShortVersionString: '2.7.71' })); - write(path.join(root, 'ios/WalletExtension/Info.plist'), plist.build({ NSExtension: { NSExtensionPointIdentifier: 'wallet' } })); - write(path.join(root, 'ios/WalletExtensionAuth/Info.plist'), plist.build({ NSExtension: { NSExtensionPointIdentifier: 'wallet-auth' } })); + write(path.join(root, 'ios/App/Info.plist'), xmlPlist('CFBundlePackageTypeAPPLCFBundleShortVersionString4.7.9')); + write(path.join(root, 'ios/AppTests/Info.plist'), xmlPlist('CFBundlePackageTypeBNDLCFBundleShortVersionString2.7.71')); + write(path.join(root, 'ios/WalletExtension/Info.plist'), xmlPlist('NSExtensionNSExtensionPointIdentifierwallet')); + write(path.join(root, 'ios/WalletExtensionAuth/Info.plist'), xmlPlist('NSExtensionNSExtensionPointIdentifierwallet-auth')); expect((await measureInstalledSightOta(options)).status).toBe('available'); const manifest = JSON.parse(fs.readFileSync(path.join(options.workDirectory, 'bundle-manifest.json'), 'utf8')); expect(manifest.version).toBe('4.7.9'); - write(path.join(root, 'ios/AnotherApp/Info.plist'), plist.build({ CFBundlePackageType: 'APPL', CFBundleShortVersionString: '5.0.0' })); + write(path.join(root, 'ios/AnotherApp/Info.plist'), xmlPlist('CFBundlePackageTypeAPPLCFBundleShortVersionString5.0.0')); expect((await measureInstalledSightOta(options)).status).toBe('unavailable'); }); it('does not use test or extension versions when an application plist is absent', async () => { options.platform = 'ios'; - write(path.join(root, 'ios/Tests/Info.plist'), plist.build({ CFBundlePackageType: 'BNDL', CFBundleShortVersionString: '1.0.0' })); - write(path.join(root, 'ios/Extension/Info.plist'), plist.build({ NSExtension: {}, CFBundleShortVersionString: '1.0.0' })); + write(path.join(root, 'ios/Tests/Info.plist'), xmlPlist('CFBundlePackageTypeBNDLCFBundleShortVersionString1.0.0')); + write(path.join(root, 'ios/Extension/Info.plist'), xmlPlist('NSExtensionCFBundleShortVersionString1.0.0')); expect((await measureInstalledSightOta(options)).status).toBe('unavailable'); }); diff --git a/src/tests/CLI/scripts/upload-cli.test.ts b/src/tests/CLI/scripts/upload-cli.test.ts index 6b0c3e1..5da6b8a 100644 --- a/src/tests/CLI/scripts/upload-cli.test.ts +++ b/src/tests/CLI/scripts/upload-cli.test.ts @@ -461,6 +461,41 @@ describe('CLI/scripts/upload-cli', () => { ); }); + it('uses an explicit iOS version without reading a supplied plist', async () => { + writeConfig(`module.exports = { + serverUrl: 'https://api.example.com', org: { slug: 'alpha-org' }, project: { slug: 'demo-app' }, + runtimeVersion: { ios: '1.0.0' }, + };`); + prepareDist('ios'); + await upload('ios', { + version: '3.4.5', plistFile: path.join(tempProjectDir, 'missing.plist'), + channel: 'Beta', token: 'override-token', + }); + expect(formInstances[0].entries).toContainEqual({ name: 'version', value: '3.4.5' }); + }); + + it.each(['123', '', ' ', '']) + ('rejects invalid iOS version metadata before bundling: %s', async value => { + const exitSpy = mockProcessExit(); + try { + writeConfig(`module.exports = { + serverUrl: 'https://api.example.com', org: { slug: 'alpha-org' }, project: { slug: 'demo-app' }, + runtimeVersion: { ios: '1.0.0' }, + };`); + const plistFile = path.join(tempProjectDir, 'Info.plist'); + fs.writeFileSync(plistFile, `CFBundleShortVersionString${value}`); + await expect(upload('ios', { plistFile, channel: 'Beta', token: 'override-token' })) + .rejects.toMatchObject({ code: 1 }); + expect(mockLog.error).toHaveBeenCalledWith( + '❌ Failed to parse Info.plist: CFBundleShortVersionString must be a non-empty string.', + ); + expect(mockExecSync).not.toHaveBeenCalled(); + expect(mockAxiosNodePost).not.toHaveBeenCalled(); + } finally { + exitSpy.mockRestore(); + } + }); + it('rejects uploads when the manifest runtimeVersion is missing', async () => { const exitSpy = mockProcessExit(); diff --git a/src/tests/CLI/utils/read-xml-plist.test.ts b/src/tests/CLI/utils/read-xml-plist.test.ts new file mode 100644 index 0000000..998875e --- /dev/null +++ b/src/tests/CLI/utils/read-xml-plist.test.ts @@ -0,0 +1,73 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { readXmlPlist } from '../../../CLI/utils/read-xml-plist'; + +describe('XML Info.plist metadata reader', () => { + let directory: string; + let file: string; + + beforeEach(() => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), 'bundle-drop-xml-plist-')); + file = path.join(directory, 'Info.plist'); + }); + + afterEach(() => fs.rmSync(directory, { recursive: true, force: true })); + + it('reads a real XML dictionary while preserving Xcode placeholders and Unicode', async () => { + fs.writeFileSync(file, '\uFEFF\n' + + '' + + 'CFBundleShortVersionString$(MARKETING_VERSION)' + + 'CFBundleDisplayNameქართული & English' + + ''); + expect(await readXmlPlist(file)).toEqual({ + CFBundleShortVersionString: '$(MARKETING_VERSION)', + CFBundleDisplayName: 'ქართული & English', + }); + }); + + it.each([ + ['binary', Buffer.from('bplist00\0\0\0')], + ['OpenStep', '{ CFBundleShortVersionString = "1.2.3"; }'], + ['empty', ''], + ])('rejects %s before format autodetection', async (_name, contents) => { + fs.writeFileSync(file, contents); + await expect(readXmlPlist(file)).rejects.toThrow('must be an XML property list'); + }); + + it.each(['', '1.2.3', '', 'YQ==', '2026-01-01T00:00:00Z']) + ('rejects a non-dictionary XML root: %s', async root => { + fs.writeFileSync(file, `${root}`); + await expect(readXmlPlist(file)).rejects.toThrow('must contain a dictionary'); + }); + + it('rejects malformed XML', async () => { + const diagnostic = jest.spyOn(console, 'error').mockImplementation(() => undefined); + try { + fs.writeFileSync(file, 'version1.2.3'); + await expect(readXmlPlist(file)).rejects.toThrow(); + } finally { + diagnostic.mockRestore(); + } + }); + + it('does not accept prototype-derived metadata', async () => { + fs.writeFileSync(file, '__proto__' + + 'CFBundleShortVersionString9.9.9'); + await expect(readXmlPlist(file)).rejects.toThrow(); + expect(Object.prototype.hasOwnProperty.call(Object.prototype, 'CFBundleShortVersionString')).toBe(false); + }); + + it('reads nested XML dictionaries without repeated exponential parsing', async () => { + const depth = 32; + fs.writeFileSync(file, '' + 'nested'.repeat(depth) + + 'value' + ''.repeat(depth) + ''); + let value: unknown = await readXmlPlist(file); + for (let i = 0; i < depth; i += 1) value = (value as Record).nested; + expect(value).toBe('value'); + }); + + it('preserves filesystem errors', async () => { + await expect(readXmlPlist(file)).rejects.toMatchObject({ code: 'ENOENT' }); + }); +}); diff --git a/src/tests/fixtures/ios-hermes-default/LICENSE b/src/tests/fixtures/ios-hermes-default/LICENSE new file mode 100644 index 0000000..b93be90 --- /dev/null +++ b/src/tests/fixtures/ios-hermes-default/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Meta Platforms, Inc. and affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/tests/fixtures/ios-hermes-default/jsengine.rb b/src/tests/fixtures/ios-hermes-default/jsengine.rb new file mode 100644 index 0000000..69508dc --- /dev/null +++ b/src/tests/fixtures/ios-hermes-default/jsengine.rb @@ -0,0 +1,55 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +require_relative './utils.rb' + +# It sets up the Hermes. +# +# @parameter react_native_path: relative path to react-native +# @parameter fabric_enabled: whether Fabirc is enabled +def setup_hermes!(react_native_path: "../node_modules/react-native") + react_native_dir = Pod::Config.instance.installation_root.join(react_native_path) + # This `:tag => hermestag` below is only to tell CocoaPods to update hermes-engine when React Native version changes. + # We have custom logic to compute the source for hermes-engine. See sdks/hermes-engine/* + hermestag_file = File.join(react_native_dir, "sdks", ".hermesversion") + hermestag = File.exist?(hermestag_file) ? File.read(hermestag_file).strip : '' + pod 'hermes-engine', :podspec => "#{react_native_path}/sdks/hermes-engine/hermes-engine.podspec", :tag => hermestag + pod 'React-hermes', :path => "#{react_native_path}/ReactCommon/hermes" +end + +def use_third_party_jsc + return ENV['USE_THIRD_PARTY_JSC'] == '1' +end + +# use Hermes is the default. The only other option is the third-party JSC +# if the 3rd party JSC is not true, we always want to use Hermes. +def use_hermes + return !use_third_party_jsc() +end + +def use_hermes_flags + return "-DUSE_HERMES=1" +end + +def use_third_party_jsc_flags + return "-DUSE_THIRD_PARTY_JSC=1" +end + +def js_engine_flags() + if use_hermes() + return use_hermes_flags() + else + return use_third_party_jsc_flags() + end +end + +# Utility function to depend on JS engine based on the environment variable. +def depend_on_js_engine(s) + if use_hermes() + s.dependency 'hermes-engine' + elsif use_third_party_jsc() + s.dependency 'React-jsc' + end +end diff --git a/src/tests/fixtures/ios-hermes-default/project.pbxproj b/src/tests/fixtures/ios-hermes-default/project.pbxproj new file mode 100644 index 0000000..9626082 --- /dev/null +++ b/src/tests/fixtures/ios-hermes-default/project.pbxproj @@ -0,0 +1,471 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 0C80B921A6F3F58F76C31292 /* libPods-PocketDemo.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5DCACB8F33CDC322A6C60F78 /* libPods-PocketDemo.a */; }; + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; + 761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; }; + 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 13B07F961A680F5B00A75B9A /* PocketDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PocketDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = PocketDemo/Images.xcassets; sourceTree = ""; }; + 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = PocketDemo/Info.plist; sourceTree = ""; }; + 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = PrivacyInfo.xcprivacy; path = PocketDemo/PrivacyInfo.xcprivacy; sourceTree = ""; }; + 3B4392A12AC88292D35C810B /* Pods-PocketDemo.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-PocketDemo.debug.xcconfig"; path = "Target Support Files/Pods-PocketDemo/Pods-PocketDemo.debug.xcconfig"; sourceTree = ""; }; + 5709B34CF0A7D63546082F79 /* Pods-PocketDemo.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-PocketDemo.release.xcconfig"; path = "Target Support Files/Pods-PocketDemo/Pods-PocketDemo.release.xcconfig"; sourceTree = ""; }; + 5DCACB8F33CDC322A6C60F78 /* libPods-PocketDemo.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-PocketDemo.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = PocketDemo/AppDelegate.swift; sourceTree = ""; }; + 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = PocketDemo/LaunchScreen.storyboard; sourceTree = ""; }; + ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 13B07F8C1A680F5B00A75B9A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 0C80B921A6F3F58F76C31292 /* libPods-PocketDemo.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 13B07FAE1A68108700A75B9A /* PocketDemo */ = { + isa = PBXGroup; + children = ( + 13B07FB51A68108700A75B9A /* Images.xcassets */, + 761780EC2CA45674006654EE /* AppDelegate.swift */, + 13B07FB61A68108700A75B9A /* Info.plist */, + 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */, + 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */, + ); + name = PocketDemo; + sourceTree = ""; + }; + 2D16E6871FA4F8E400B85C8A /* Frameworks */ = { + isa = PBXGroup; + children = ( + ED297162215061F000B7C4FE /* JavaScriptCore.framework */, + 5DCACB8F33CDC322A6C60F78 /* libPods-PocketDemo.a */, + ); + name = Frameworks; + sourceTree = ""; + }; + 832341AE1AAA6A7D00B99B32 /* Libraries */ = { + isa = PBXGroup; + children = ( + ); + name = Libraries; + sourceTree = ""; + }; + 83CBB9F61A601CBA00E9B192 = { + isa = PBXGroup; + children = ( + 13B07FAE1A68108700A75B9A /* PocketDemo */, + 832341AE1AAA6A7D00B99B32 /* Libraries */, + 83CBBA001A601CBA00E9B192 /* Products */, + 2D16E6871FA4F8E400B85C8A /* Frameworks */, + BBD78D7AC51CEA395F1C20DB /* Pods */, + ); + indentWidth = 2; + sourceTree = ""; + tabWidth = 2; + usesTabs = 0; + }; + 83CBBA001A601CBA00E9B192 /* Products */ = { + isa = PBXGroup; + children = ( + 13B07F961A680F5B00A75B9A /* PocketDemo.app */, + ); + name = Products; + sourceTree = ""; + }; + BBD78D7AC51CEA395F1C20DB /* Pods */ = { + isa = PBXGroup; + children = ( + 3B4392A12AC88292D35C810B /* Pods-PocketDemo.debug.xcconfig */, + 5709B34CF0A7D63546082F79 /* Pods-PocketDemo.release.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 13B07F861A680F5B00A75B9A /* PocketDemo */ = { + isa = PBXNativeTarget; + buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "PocketDemo" */; + buildPhases = ( + C38B50BA6285516D6DCD4F65 /* [CP] Check Pods Manifest.lock */, + 13B07F871A680F5B00A75B9A /* Sources */, + 13B07F8C1A680F5B00A75B9A /* Frameworks */, + 13B07F8E1A680F5B00A75B9A /* Resources */, + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, + 00EEFC60759A1932668264C0 /* [CP] Embed Pods Frameworks */, + E235C05ADACE081382539298 /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = PocketDemo; + productName = PocketDemo; + productReference = 13B07F961A680F5B00A75B9A /* PocketDemo.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 83CBB9F71A601CBA00E9B192 /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1210; + TargetAttributes = { + 13B07F861A680F5B00A75B9A = { + LastSwiftMigration = 1120; + }; + }; + }; + buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "PocketDemo" */; + compatibilityVersion = "Xcode 12.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 83CBB9F61A601CBA00E9B192; + productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 13B07F861A680F5B00A75B9A /* PocketDemo */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 13B07F8E1A680F5B00A75B9A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */, + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "$(SRCROOT)/.xcode.env.local", + "$(SRCROOT)/.xcode.env", + ); + name = "Bundle React Native code and images"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "set -e\n\nWITH_ENVIRONMENT=\"$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh\"\nREACT_NATIVE_XCODE=\"$REACT_NATIVE_PATH/scripts/react-native-xcode.sh\"\n\n/bin/sh -c \"$WITH_ENVIRONMENT $REACT_NATIVE_XCODE\"\n"; + }; + 00EEFC60759A1932668264C0 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-PocketDemo/Pods-PocketDemo-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-PocketDemo/Pods-PocketDemo-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-PocketDemo/Pods-PocketDemo-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + C38B50BA6285516D6DCD4F65 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-PocketDemo-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + E235C05ADACE081382539298 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-PocketDemo/Pods-PocketDemo-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-PocketDemo/Pods-PocketDemo-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-PocketDemo/Pods-PocketDemo-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 13B07F871A680F5B00A75B9A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 761780ED2CA45674006654EE /* AppDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 13B07F941A680F5B00A75B9A /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 3B4392A12AC88292D35C810B /* Pods-PocketDemo.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = 1; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = PocketDemo/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_NAME = PocketDemo; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 13B07F951A680F5B00A75B9A /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 5709B34CF0A7D63546082F79 /* Pods-PocketDemo.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = PocketDemo/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_NAME = PocketDemo; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; + 83CBBA201A601CBA00E9B192 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "c++20"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_SYMBOLS_PRIVATE_EXTERN = NO; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + /usr/lib/swift, + "$(inherited)", + ); + LIBRARY_SEARCH_PATHS = ( + "\"$(SDKROOT)/usr/lib/swift\"", + "\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"", + "\"$(inherited)\"", + ); + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + OTHER_CPLUSPLUSFLAGS = ( + "$(OTHER_CFLAGS)", + "-DFOLLY_NO_CONFIG", + "-DFOLLY_MOBILE=1", + "-DFOLLY_USE_LIBCPP=1", + "-DFOLLY_CFG_NO_COROUTINES=1", + "-DFOLLY_HAVE_CLOCK_GETTIME=1", + ); + SDKROOT = iphoneos; + }; + name = Debug; + }; + 83CBBA211A601CBA00E9B192 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "c++20"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = YES; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + /usr/lib/swift, + "$(inherited)", + ); + LIBRARY_SEARCH_PATHS = ( + "\"$(SDKROOT)/usr/lib/swift\"", + "\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"", + "\"$(inherited)\"", + ); + MTL_ENABLE_DEBUG_INFO = NO; + OTHER_CPLUSPLUSFLAGS = ( + "$(OTHER_CFLAGS)", + "-DFOLLY_NO_CONFIG", + "-DFOLLY_MOBILE=1", + "-DFOLLY_USE_LIBCPP=1", + "-DFOLLY_CFG_NO_COROUTINES=1", + "-DFOLLY_HAVE_CLOCK_GETTIME=1", + ); + SDKROOT = iphoneos; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "PocketDemo" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 13B07F941A680F5B00A75B9A /* Debug */, + 13B07F951A680F5B00A75B9A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "PocketDemo" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 83CBBA201A601CBA00E9B192 /* Debug */, + 83CBBA211A601CBA00E9B192 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */; +} diff --git a/src/tests/fixtures/ios-hermes-default/react_native_pods.rb b/src/tests/fixtures/ios-hermes-default/react_native_pods.rb new file mode 100644 index 0000000..e9f5a89 --- /dev/null +++ b/src/tests/fixtures/ios-hermes-default/react_native_pods.rb @@ -0,0 +1,525 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +require 'json' +require 'open3' +require 'pathname' +require_relative './react_native_pods_utils/script_phases.rb' +require_relative './cocoapods/jsengine.rb' +require_relative './cocoapods/rndependencies.rb' +require_relative './cocoapods/rncore.rb' +require_relative './cocoapods/fabric.rb' +require_relative './cocoapods/codegen.rb' +require_relative './cocoapods/codegen_utils.rb' +require_relative './cocoapods/utils.rb' +require_relative './cocoapods/new_architecture.rb' +require_relative './cocoapods/local_podspec_patch.rb' +require_relative './cocoapods/runtime.rb' +require_relative './cocoapods/helpers.rb' +require_relative './cocoapods/privacy_manifest_utils.rb' +require_relative './cocoapods/spm.rb' +require_relative './cocoapods/rncore.rb' +# Importing to expose use_native_modules! +require_relative './cocoapods/autolinking.rb' + +$CODEGEN_OUTPUT_DIR = 'build/generated/ios' +$CODEGEN_COMPONENT_DIR = 'react/renderer/components' +$CODEGEN_MODULE_DIR = '.' + +$START_TIME = Time.now.to_i + +def min_ios_version_supported + return Helpers::Constants.min_ios_version_supported +end + +# This function returns the min supported OS versions supported by React Native +# By using this function, you won't have to manually change your Podfile +# when we change the minimum version supported by the framework. +def min_supported_versions + return { :ios => min_ios_version_supported } +end + +# This function prepares the project for React Native, before processing +# all the target exposed by the framework. +def prepare_react_native_project! + # Temporary solution to suppress duplicated GUID error & master specs repo warning. + # Can be removed once we move to generate files outside pod install. + install! 'cocoapods', :deterministic_uuids => false, :warn_for_unused_master_specs_repo => false + + ReactNativePodsUtils.create_xcode_env_if_missing +end + +# Function that setup all the react native dependencies +#  +# Parameters +# - path: path to react_native installation. +# - fabric_enabled: whether fabric should be enabled or not. +# - new_arch_enabled: whether the new architecture should be enabled or not. +# - :production [DEPRECATED] whether the dependencies must be installed to target a Debug or a Release build. +# - hermes_enabled: whether Hermes should be enabled or not. +# - app_path: path to the React Native app. Required by the New Architecture. +# - config_file_dir: directory of the `package.json` file, required by the New Architecture. +def use_react_native! ( + path: "../node_modules/react-native", + fabric_enabled: false, + new_arch_enabled: NewArchitectureHelper.new_arch_enabled, + production: false, # deprecated + hermes_enabled: true, # deprecated. Hermes is the default engine and JSC has been moved to community support + app_path: '..', + config_file_dir: '', + privacy_file_aggregation_enabled: true +) + error_if_try_to_use_jsc_from_core() + + hermes_enabled= true + # Set the app_path as env variable so the podspecs can access it. + ENV['APP_PATH'] = app_path + ENV['REACT_NATIVE_PATH'] = path + + # We set RCT_SKIP_CODEGEN to true, if the user wants to skip the running Codegen step from Cocoapods. + # This is needed as part of our migration away from cocoapods + ENV['RCT_SKIP_CODEGEN'] = ENV['RCT_SKIP_CODEGEN'] == '1' || ENV['RCT_IGNORE_PODS_DEPRECATION'] == '1' ? '1' : '0' + + ReactNativePodsUtils.check_minimum_required_xcode() + + # Current target definition is provided by Cocoapods and it refers to the target + # that has invoked the `use_react_native!` function. + ReactNativePodsUtils.detect_use_frameworks(current_target_definition) + + CodegenUtils.clean_up_build_folder(path, $CODEGEN_OUTPUT_DIR) + + # We are relying on this flag also in third parties libraries to proper install dependencies. + # Better to rely and enable this environment flag if the new architecture is turned on using flags. + relative_path_from_current = Pod::Config.instance.installation_root.relative_path_from(Pathname.pwd) + react_native_version = NewArchitectureHelper.extract_react_native_version(File.join(relative_path_from_current, path)) + fabric_enabled = fabric_enabled || NewArchitectureHelper.new_arch_enabled + + ENV['RCT_FABRIC_ENABLED'] = fabric_enabled ? "1" : "0" + ENV['RCT_AGGREGATE_PRIVACY_FILES'] = privacy_file_aggregation_enabled ? "1" : "0" + ENV["RCT_NEW_ARCH_ENABLED"] = new_arch_enabled ? "1" : "0" + + prefix = path + + ReactNativePodsUtils.warn_if_not_on_arm64() + + # Update ReactNativeDependencies so that we can easily switch between source and prebuilt + ReactNativeDependenciesUtils.setup_react_native_dependencies(prefix, react_native_version) + + # Update ReactNativeCoreUtils so that we can easily switch between source and prebuilt + ReactNativeCoreUtils.setup_rncore(prefix, react_native_version) + + Pod::UI.puts "Configuring the target with the #{new_arch_enabled ? "New" : "Legacy"} Architecture\n" + + # The Pods which should be included in all projects + pod 'FBLazyVector', :path => "#{prefix}/Libraries/FBLazyVector" + pod 'RCTRequired', :path => "#{prefix}/Libraries/Required" + pod 'RCTTypeSafety', :path => "#{prefix}/Libraries/TypeSafety", :modular_headers => true + pod 'React', :path => "#{prefix}/" + if !ReactNativeCoreUtils.build_rncore_from_source() + pod 'React-Core-prebuilt', :podspec => "#{prefix}/React-Core-prebuilt.podspec", :modular_headers => true + end + pod 'React-Core', :path => "#{prefix}/" + pod 'React-CoreModules', :path => "#{prefix}/React/CoreModules" + pod 'React-RCTRuntime', :path => "#{prefix}/React/Runtime" + pod 'React-RCTAppDelegate', :path => "#{prefix}/Libraries/AppDelegate" + pod 'React-RCTActionSheet', :path => "#{prefix}/Libraries/ActionSheetIOS" + pod 'React-RCTAnimation', :path => "#{prefix}/Libraries/NativeAnimation" + pod 'React-RCTBlob', :path => "#{prefix}/Libraries/Blob" + pod 'React-RCTImage', :path => "#{prefix}/Libraries/Image" + pod 'React-RCTLinking', :path => "#{prefix}/Libraries/LinkingIOS" + pod 'React-RCTNetwork', :path => "#{prefix}/Libraries/Network" + pod 'React-RCTSettings', :path => "#{prefix}/Libraries/Settings" + pod 'React-RCTText', :path => "#{prefix}/Libraries/Text" + pod 'React-RCTVibration', :path => "#{prefix}/Libraries/Vibration" + pod 'React-Core/RCTWebSocket', :path => "#{prefix}/" + pod 'React-cxxreact', :path => "#{prefix}/ReactCommon/cxxreact" + pod 'React-debug', :path => "#{prefix}/ReactCommon/react/debug" + pod 'React-utils', :path => "#{prefix}/ReactCommon/react/utils" + pod 'React-featureflags', :path => "#{prefix}/ReactCommon/react/featureflags" + pod 'React-featureflagsnativemodule', :path => "#{prefix}/ReactCommon/react/nativemodule/featureflags" + pod 'React-microtasksnativemodule', :path => "#{prefix}/ReactCommon/react/nativemodule/microtasks" + pod 'React-idlecallbacksnativemodule', :path => "#{prefix}/ReactCommon/react/nativemodule/idlecallbacks" + pod 'React-domnativemodule', :path => "#{prefix}/ReactCommon/react/nativemodule/dom" + pod 'React-defaultsnativemodule', :path => "#{prefix}/ReactCommon/react/nativemodule/defaults" + pod 'React-Mapbuffer', :path => "#{prefix}/ReactCommon" + pod 'React-jserrorhandler', :path => "#{prefix}/ReactCommon/jserrorhandler" + pod 'RCTDeprecation', :path => "#{prefix}/ReactApple/Libraries/RCTFoundation/RCTDeprecation" + pod 'React-RCTFBReactNativeSpec', :path => "#{prefix}/React" + pod 'React-jsi', :path => "#{prefix}/ReactCommon/jsi" + + if hermes_enabled + setup_hermes!(:react_native_path => prefix) + end + + pod 'React-jsiexecutor', :path => "#{prefix}/ReactCommon/jsiexecutor" + pod 'React-jsinspector', :path => "#{prefix}/ReactCommon/jsinspector-modern" + pod 'React-jsitooling', :path => "#{prefix}/ReactCommon/jsitooling" + pod 'React-jsinspectorcdp', :path => "#{prefix}/ReactCommon/jsinspector-modern/cdp" + pod 'React-jsinspectornetwork', :path => "#{prefix}/ReactCommon/jsinspector-modern/network" + pod 'React-jsinspectortracing', :path => "#{prefix}/ReactCommon/jsinspector-modern/tracing" + + pod 'React-callinvoker', :path => "#{prefix}/ReactCommon/callinvoker" + pod 'React-performancetimeline', :path => "#{prefix}/ReactCommon/react/performance/timeline" + pod 'React-timing', :path => "#{prefix}/ReactCommon/react/timing" + pod 'React-runtimeexecutor', :path => "#{prefix}/ReactCommon/runtimeexecutor" + pod 'React-runtimescheduler', :path => "#{prefix}/ReactCommon/react/renderer/runtimescheduler" + pod 'React-renderercss', :path => "#{prefix}/ReactCommon/react/renderer/css" + pod 'React-rendererdebug', :path => "#{prefix}/ReactCommon/react/renderer/debug" + pod 'React-rendererconsistency', :path => "#{prefix}/ReactCommon/react/renderer/consistency" + pod 'React-perflogger', :path => "#{prefix}/ReactCommon/reactperflogger" + pod 'React-oscompat', :path => "#{prefix}/ReactCommon/oscompat" + pod 'React-logger', :path => "#{prefix}/ReactCommon/logger" + pod 'ReactCommon/turbomodule/core', :path => "#{prefix}/ReactCommon", :modular_headers => true + pod 'React-NativeModulesApple', :path => "#{prefix}/ReactCommon/react/nativemodule/core/platform/ios", :modular_headers => true + pod 'Yoga', :path => "#{prefix}/ReactCommon/yoga", :modular_headers => true + setup_fabric!(:react_native_path => prefix) + setup_bridgeless!(:react_native_path => prefix, :use_hermes => hermes_enabled) + + if ReactNativeDependenciesUtils.build_react_native_deps_from_source() + pod 'DoubleConversion', :podspec => "#{prefix}/third-party-podspecs/DoubleConversion.podspec" + pod 'glog', :podspec => "#{prefix}/third-party-podspecs/glog.podspec" + pod 'boost', :podspec => "#{prefix}/third-party-podspecs/boost.podspec" + pod 'fast_float', :podspec => "#{prefix}/third-party-podspecs/fast_float.podspec" + pod 'fmt', :podspec => "#{prefix}/third-party-podspecs/fmt.podspec", :modular_headers => true + pod 'RCT-Folly', :podspec => "#{prefix}/third-party-podspecs/RCT-Folly.podspec", :modular_headers => true + pod 'SocketRocket', "~> #{Helpers::Constants::socket_rocket_config[:version]}", :modular_headers => true + else + # Install prebuilt React Native Core and React Native Dependencies + ReactNativeCoreUtils.rncore_log("Using React Native Core and React Native Dependencies prebuilt versions.") + pod 'ReactNativeDependencies', :podspec => "#{prefix}/third-party-podspecs/ReactNativeDependencies.podspec", :modular_headers => true + + if !ReactNativeCoreUtils.build_rncore_from_source() + pod 'React-Core-prebuilt', :podspec => "#{prefix}/React-Core-prebuilt.podspec", :modular_headers => true + end + end + + pod 'ReactCodegen', :path => $CODEGEN_OUTPUT_DIR, :modular_headers => true + pod 'ReactAppDependencyProvider', :path => $CODEGEN_OUTPUT_DIR, :modular_headers => true + # Not needed, but run_codegen expects this to be set. + folly_config = get_folly_config() + run_codegen!( + app_path, + config_file_dir, + :new_arch_enabled => NewArchitectureHelper.new_arch_enabled, + :disable_codegen => ENV['DISABLE_CODEGEN'] == '1', + :react_native_path => prefix, + :fabric_enabled => fabric_enabled, + :hermes_enabled => hermes_enabled, + :codegen_output_dir => $CODEGEN_OUTPUT_DIR, + :package_json_file => File.join(__dir__, "..", "package.json"), + :folly_version => folly_config[:version] + ) + + pods_to_update = LocalPodspecPatch.pods_to_update(:react_native_path => prefix) + if !pods_to_update.empty? + if Pod::Lockfile.public_instance_methods.include?(:detect_changes_with_podfile) + Pod::Lockfile.prepend(LocalPodspecPatch) + else + Pod::UI.warn "Automatically updating #{pods_to_update.join(", ")} has failed, please run `pod update #{pods_to_update.join(" ")} --no-repo-update` manually to fix the issue." + end + end +end + +# Getter to retrieve the folly flags in case contributors need to apply them manually. +# +# Returns: the folly compiler flags +def folly_flags() + return NewArchitectureHelper.folly_compiler_flags +end + +# Resolve the spec for use with the USE_FRAMEWORKS environment variable. To avoid each podspec +# to manually specify the header mappings and module name, we can use this helper function. +# This helper will also resolve header mappings if we're building from source. Precompiled +# React-Core will not generate frameworks since their podspec files only contains the +# header files and no source code - so header_mappings should be the same as for without USE_FRAMEWORKS +# +# Parameters: +# - s: the spec to modify +# - header_mappings_dir: the directory to map headers when building Pod header structure +# - module_name: the name of the module when exposed to swift +def resolve_use_frameworks(spec, header_mappings_dir: nil, module_name: nil) + ReactNativePodsUtils.resolve_use_frameworks(spec, :header_mappings_dir => header_mappings_dir, :module_name => module_name) +end + +# Add a dependency to a spec, making sure that the HEADER_SERACH_PATHS are set properly. +# This function automate the requirement to specify the HEADER_SEARCH_PATHS which was error prone +# and hard to pull out properly to begin with. +# Secondly, it prepares the podspec to work also with other platforms, because this function is +# able to generate search paths that are compatible with macOS and other platform if specified by +# the $RN_PLATFORMS variable. +# To generate Header Search Paths for multiple platforms, define in your Podfile or Ruby infra a +# $RN_PLATFORMS static variable with the list of supported platforms, for example: +# `$RN_PLATFORMS = ["iOS", "macOS"]` +# +# Parameters: +# - spec: the spec that needs to be modified +# - pod_name: the name of the dependency we had to add to the spec +# - additional_framework_paths: additional sub paths we had to add to the HEADER_SEARCH_PATH +# - framework_name: the name of the framework in case it is different from the pod_name +# - version: the version of the pod_name the spec needs to depend on +# - base_dir: Base directory from where we need to start looking. Defaults to PODS_CONFIGURATION_BUILD_DIR +def add_dependency(spec, pod_name, subspec: nil, additional_framework_paths: [], framework_name: nil, version: nil, base_dir: "PODS_CONFIGURATION_BUILD_DIR") + fixed_framework_name = framework_name != nil ? framework_name : pod_name.gsub("-", "_") # frameworks can't have "-" in their name + ReactNativePodsUtils.add_dependency(spec, pod_name, base_dir, fixed_framework_name, :additional_paths => additional_framework_paths, :version => version) +end + +# This function generates an array of HEADER_SEARCH_PATH that can be added to the HEADER_SEARCH_PATH property when use_frameworks! is enabled +# +# Parameters: +# - pod_name: the name of the dependency we had to add to the spec +# - additional_framework_paths: additional sub paths we had to add to the HEADER_SEARCH_PATH +# - framework_name: the name of the framework in case it is different from the pod_name +# - base_dir: Base directory from where we need to start looking. Defaults to PODS_CONFIGURATION_BUILD_DIR +# - include_base_folder: whether the array must include the base import path or only the additional_framework_paths +def create_header_search_path_for_frameworks(pod_name, additional_framework_paths: [], framework_name: nil, base_dir: "PODS_CONFIGURATION_BUILD_DIR", include_base_folder: true) + fixed_framework_name = framework_name != nil ? framework_name : pod_name.gsub("-", "_") + return ReactNativePodsUtils.create_header_search_path_for_frameworks(base_dir, pod_name, fixed_framework_name, additional_framework_paths, include_base_folder) +end + +# This function can be used by library developer to prepare their modules for the New Architecture. +# It passes the Folly Flags to the module, it configures the search path and installs some New Architecture specific dependencies. +# +# Parameters: +# - spec: The spec that has to be configured with the New Architecture code +# - new_arch_enabled: Whether the module should install dependencies for the new architecture +def install_modules_dependencies(spec, new_arch_enabled: NewArchitectureHelper.new_arch_enabled) + folly_config = get_folly_config() + NewArchitectureHelper.install_modules_dependencies(spec, new_arch_enabled, folly_config[:version]) +end + +# This function is used by podspecs that needs to use the prebuilt sources for React Native. +# It returns the sources to use for the podspec. +# +# Parameters: +# - original_sources: The original sources of the podspec +# - sources_for_prebuilds: The sources to use for prebuilt pods +# +# Returns: The sources to use for the podspec +def podspec_sources(original_sources, sources_for_prebuilds) + if ReactNativeCoreUtils.build_rncore_from_source() + return original_sources + else + return sources_for_prebuilds + end +end + + +# This function can be used by library developer to declare a SwiftPackageManager dependency. +# +# Parameters: +# - spec: The spec the Swift Package Manager dependency has to be added to +# - url: The URL of the Swift Package Manager dependency +# - requirement: The version requirement of the Swift Package Manager dependency (eg. ` {kind: 'upToNextMajorVersion', minimumVersion: '5.9.1'},`) +# - products: The product/target of the Swift Package Manager dependency (eg. AlamofireDynamic) +def spm_dependency(spec, url:, requirement:, products:) + SPM.dependency(spec, url: url, requirement: requirement, products: products) +end + +# It returns the default flags. +# deprecated. +def get_default_flags() + warn 'get_default_flags is deprecated. Please remove the keys from the `use_react_native!` function' + warn 'if you are using the default already and pass the value you need in case you don\'t want the default' + return ReactNativePodsUtils.get_default_flags() +end + +# This method returns an hash with the folly version, folly git url and the folly compiler flags +# that can be used to configure libraries. +# In this way, we can update those values in react native, and all the libraries will benefit +# from it. +# @return an hash with the `:version`, `:git` and `:compiler_flags` fields. +def get_folly_config() + return Helpers::Constants.folly_config +end + +# This method returns an hash with the glog git url +# that can be used to configure libraries. +# @return an hash with the `:git` field. +def get_glog_config() + return Helpers::Constants.glog_config +end + +# This method returns an hash with the fast_float git url +# that can be used to configure libraries. +# @return an hash with the `:git` field. +def get_fast_float_config() + return Helpers::Constants.fast_float_config +end + +# This method returns an hash with the fmt git url +# that can be used to configure libraries. +# @return an hash with the `:git` field. +def get_fmt_config() + return Helpers::Constants.fmt_config +end + +# This method returns an hash with the double conversion git url +# that can be used to configure libraries. +# @return an hash with the `:git` field. +def get_double_conversion_config() + return Helpers::Constants.double_conversion_config +end + +# This method returns an hash with the double conversion git url +# that can be used to configure libraries. +# @return an hash with the `:git` field. +def get_boost_config() + return Helpers::Constants.boost_config +end + +# This method returns an hash with the socket rocket version +# @return an hash with the `:version` field. +def get_socket_rocket_config() + return Helpers::Constants.socket_rocket_config +end + +# This method can be used to set the glog config +# that can be used to configure libraries. +def set_folly_config(folly_config) + Helpers::Constants.set_folly_config(folly_config) +end + +# This method can be used to set the glog config +# that can be used to configure libraries. +def set_glog_config(glog_config) + Helpers::Constants.set_glog_config(glog_config) +end + +# This method can be used to set the fast_float config +# that can be used to configure libraries. +def set_fast_float_config(fmt_config) + Helpers::Constants.set_fast_float_config(fast_float_config) +end + +# This method can be used to set the fmt config +# that can be used to configure libraries. +def set_fmt_config(fmt_config) + Helpers::Constants.set_fmt_config(fmt_config) +end + +# This method can be used to set the double conversion config +# that can be used to configure libraries. +def set_double_conversion_config(double_conversion_config) + Helpers::Constants.set_double_conversion_config(double_conversion_config) +end + +# This method can be used to set the boost config +# that can be used to configure libraries. +def set_boost_config(boost_config) + Helpers::Constants.set_boost_config(boost_config) +end + +# This method can be used to set the socket rocket config +# that can be used to configure libraries. +def set_socket_rocket_config(socket_rocket_config) + Helpers::Constants.set_socket_rocket_config(socket_rocket_config) +end + +def rct_cxx_language_standard() + return Helpers::Constants.cxx_language_standard +end + +def print_jsc_removal_message() + puts '' + puts '=============== JavaScriptCore is being moved ==============='.yellow + puts 'JavaScriptCore has been removed from React Native. It can now be'.yellow + puts 'installed from `@react-native-community/javascriptcore`'.yellow + puts 'See: https://github.com/react-native-community/javascriptcore'.yellow + puts '============================================================='.yellow + puts '' +end + +def print_cocoapods_deprecation_message() + if ENV["RCT_IGNORE_PODS_DEPRECATION"] == "1" + return + end + + puts '' + puts '==================== DEPRECATION NOTICE ====================='.yellow + puts 'Calling `pod install` directly is deprecated in React Native'.yellow + puts 'because we are moving away from Cocoapods toward alternative'.yellow + puts 'solutions to build the project.'.yellow + puts '* If you are using Expo, please run:'.yellow + puts '`npx expo run:ios`'.yellow + puts '* If you are using the Community CLI, please run:'.yellow + puts '`yarn ios`'.yellow + puts '============================================================='.yellow + puts '' + +end + +def error_if_try_to_use_jsc_from_core() + explicitly_not_use_hermes = ENV['USE_HERMES'] != nil && ENV['USE_HERMES'] == '0' + not_use_3rd_party_jsc = ENV['USE_THIRD_PARTY_JSC'] == nil || ENV['USE_THIRD_PARTY_JSC'] == '0' + if (explicitly_not_use_hermes && not_use_3rd_party_jsc) + message = "Hermes is the default engine and JSC has been moved to community support.\n" + + "Please remove the USE_HERMES=0, as it is not supported anymore.\n" + + "If you want to use JSC, you can install it from `@react-native-community/javascriptcore`.\n" + + "See: https://github.com/react-native-community/javascriptcore" + puts message.red + exit() + end +end + +# Function that executes after React Native has been installed to configure some flags and build settings. +# +# Parameters +# - installer: the Cocoapod object that allows to customize the project. +# - react_native_path: path to React Native. +# - mac_catalyst_enabled: whether we are running the Pod on a Mac Catalyst project or not. +# - enable_hermes_profiler: whether the hermes profiler should be turned on in Release mode +def react_native_post_install( + installer, + react_native_path = "../node_modules/react-native", + mac_catalyst_enabled: false, + ccache_enabled: ENV['USE_CCACHE'] == '1' +) + ReactNativePodsUtils.turn_off_resource_bundle_react_core(installer) + + ReactNativePodsUtils.apply_mac_catalyst_patches(installer) if mac_catalyst_enabled + + privacy_file_aggregation_enabled = ENV['RCT_AGGREGATE_PRIVACY_FILES'] == '1' + + if use_hermes() + ReactNativePodsUtils.set_gcc_preprocessor_definition_for_React_hermes(installer) + end + ReactNativePodsUtils.set_gcc_preprocessor_definition_for_debugger(installer) + + ReactNativePodsUtils.fix_library_search_paths(installer) + ReactNativePodsUtils.update_search_paths(installer) + ReactNativePodsUtils.set_build_setting(installer, build_setting: "USE_HERMES", value: use_hermes()) + ReactNativePodsUtils.set_build_setting(installer, build_setting: "REACT_NATIVE_PATH", value: File.join("${PODS_ROOT}", "..", react_native_path)) + ReactNativePodsUtils.set_build_setting(installer, build_setting: "SWIFT_ACTIVE_COMPILATION_CONDITIONS", value: ['$(inherited)', 'DEBUG'], config_name: "Debug") + + ReactNativePodsUtils.set_ccache_compiler_and_linker_build_settings(installer, react_native_path, ccache_enabled) + ReactNativePodsUtils.updateOSDeploymentTarget(installer) + ReactNativePodsUtils.set_dynamic_frameworks_flags(installer) + ReactNativePodsUtils.add_ndebug_flag_to_pods_in_release(installer) + + if !ReactNativeCoreUtils.build_rncore_from_source() + # In XCode 26 we need to revert the new setting SWIFT_ENABLE_EXPLICIT_MODULES when building + # with precompiled binaries. + ReactNativePodsUtils.set_build_setting(installer, build_setting: "SWIFT_ENABLE_EXPLICIT_MODULES", value: "NO") + end + + SPM.apply_on_post_install(installer) + + if privacy_file_aggregation_enabled + PrivacyManifestUtils.add_aggregated_privacy_manifest(installer) + else + PrivacyManifestUtils.add_privacy_manifest_if_needed(installer) + end + + NewArchitectureHelper.set_clang_cxx_language_standard_if_needed(installer) + NewArchitectureHelper.modify_flags_for_new_architecture(installer, NewArchitectureHelper.new_arch_enabled) + NewArchitectureHelper.set_RCTNewArchEnabled_in_info_plist(installer, NewArchitectureHelper.new_arch_enabled) + + if !use_hermes() && !use_third_party_jsc() + print_jsc_removal_message() + end + + print_cocoapods_deprecation_message + Pod::UI.puts "Pod install took #{Time.now.to_i - $START_TIME} [s] to run".green +end diff --git a/src/tests/manager/updateCheck.test.ts b/src/tests/manager/updateCheck.test.ts index 1f4702c..d6243e3 100644 --- a/src/tests/manager/updateCheck.test.ts +++ b/src/tests/manager/updateCheck.test.ts @@ -1242,8 +1242,21 @@ describe('manager/updateCheck', () => { }); }); - it('advertises asset-only when native xdelta support probing fails', async () => { - mockSupportsXdelta.mockRejectedValueOnce(new Error('native bridge unavailable')); + it('does not probe native patch capabilities when the update module is loaded', () => { + jest.isolateModules(() => { + const { mockSupportsXdelta: probe } = require('../mocks/native/fs'); + require('../../manager/updateCheck'); + expect(probe).not.toHaveBeenCalled(); + }); + }); + + it.each([ + ['available', () => Promise.resolve(true), ['xdelta3-vcdiff', 'asset-only-v1']], + ['unavailable', () => Promise.resolve(false), ['asset-only-v1']], + ['synchronous native error', () => { throw new Error('native bridge unavailable'); }, ['asset-only-v1']], + ['rejected native promise', () => Promise.reject(new Error('native bridge unavailable')), ['asset-only-v1']], + ] as const)('advertises the supported patch algorithms after probing: %s', async (_scenario, probe, algorithms) => { + mockSupportsXdelta.mockImplementationOnce(probe); setMockFile(INSTALL_ID_PATH, 'install-no-xdelta'); mockPostOtaResolve.mockResolvedValue({ data: { @@ -1252,14 +1265,18 @@ describe('manager/updateCheck', () => { }, } as never); - await checkForUpdate('General'); + const check = checkForUpdate('General'); + expect(mockSupportsXdelta).not.toHaveBeenCalled(); + expect(mockPostOtaResolve).not.toHaveBeenCalled(); + await expect(check).resolves.toMatchObject({ action: 'NOOP', upToDate: true }); + expect(mockSupportsXdelta).toHaveBeenCalledTimes(1); expect(mockPostOtaResolve).toHaveBeenCalledWith( 'bundle-drop-app', expect.objectContaining({ transport: { manifestVersion: 1, - patchAlgorithms: ['asset-only-v1'], + patchAlgorithms: algorithms, supportsContentAddressedAssets: true, }, }), diff --git a/src/tests/scripts/cli-isolation.test.ts b/src/tests/scripts/cli-isolation.test.ts new file mode 100644 index 0000000..6ffc6b8 --- /dev/null +++ b/src/tests/scripts/cli-isolation.test.ts @@ -0,0 +1,43 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const { checkCliIsolation } = require('../../../scripts/check-cli-isolation.cjs'); + +describe('CLI import boundary', () => { + let root: string; + beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-boundary-')); }); + afterEach(() => { fs.rmSync(root, { recursive: true, force: true }); }); + + function write(name: string, contents: string) { + const file = path.join(root, name); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, contents); + } + + it.each(['chalk', 'commander', 'figures', 'plist'])('rejects reachable %s imports', name => { + write('index.js', 'require("./helper.js");'); + write('helper.js', `require(${JSON.stringify(name)});`); + expect(() => checkCliIsolation(root, ['index.js'])).toThrow(`helper.js -> ${name}`); + }); + + it('follows explicit JavaScript extensions through source files', () => { + write('index.ts', 'export * from "./helper.js";'); + write('helper.ts', 'import chalk from "chalk";'); + write('helper.js', ''); + expect(() => checkCliIsolation(root, ['index.ts'])).toThrow('helper.ts -> chalk'); + }); + + it('follows declaration imports instead of empty runtime counterparts', () => { + write('index.d.ts', 'export type { Metadata } from "./helper.js";'); + write('helper.d.ts', 'export type { Metadata } from "./CLI/utils/read-xml-plist.js";'); + write('helper.js', ''); + write('CLI/utils/read-xml-plist.d.ts', 'export type Metadata = {};'); + expect(() => checkCliIsolation(root, ['index.d.ts'])).toThrow('CLI/utils/read-xml-plist.d.ts'); + }); + + it('permits ordinary mobile dependencies', () => { + write('index.ts', 'import React from "react"; import { View } from "react-native";'); + expect(checkCliIsolation(root, ['index.ts'])).toEqual(['index.ts']); + }); +}); diff --git a/src/tests/scripts/ios-hermes-default.test.ts b/src/tests/scripts/ios-hermes-default.test.ts new file mode 100644 index 0000000..b31c35b --- /dev/null +++ b/src/tests/scripts/ios-hermes-default.test.ts @@ -0,0 +1,250 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { detectImplicitIosHermes } from '../../scripts/iosHermesDefault'; +import { shouldCompileHermesBytecode } from '../../scripts/bundle'; + +const standardPodfile = ` +require Pod::Executable.execute_command('node', ['-p', + 'require.resolve( + "react-native/scripts/react_native_pods.rb", + {paths: [process.argv[1]]}, + )', __dir__]).strip +platform :ios, min_ios_version_supported +prepare_react_native_project! +target 'Demo' do + config = use_native_modules! + use_react_native!( + :path => config[:reactNativePath], + :app_path => "#{Pod::Config.instance.installation_root}/.." + ) + post_install do |installer| + react_native_post_install(installer, config[:reactNativePath], :mac_catalyst_enabled => false) + end +end +`; + +// Unmodified React Native 0.81.5 helpers, including their upstream license notices. +const podsHelper = fs.readFileSync(path.join(__dirname, '../fixtures/ios-hermes-default/react_native_pods.rb'), 'utf8'); +const engineHelper = fs.readFileSync(path.join(__dirname, '../fixtures/ios-hermes-default/jsengine.rb'), 'utf8'); +const stockXcodeProject = fs.readFileSync(path.join(__dirname, '../fixtures/ios-hermes-default/project.pbxproj'), 'utf8'); + +describe('implicit iOS Hermes default', () => { + let root: string; + const environmentKeys = ['USE_HERMES', 'USE_THIRD_PARTY_JSC']; + let savedEnvironment: Array<[string, string | undefined]>; + + const write = (relativePath: string, contents: string) => { + const filename = path.join(root, relativePath); + fs.mkdirSync(path.dirname(filename), { recursive: true }); + fs.writeFileSync(filename, contents); + }; + const installReactNative = (version = '0.81.5') => { + write('package.json', JSON.stringify({ dependencies: { 'react-native': version } })); + write('node_modules/react-native/package.json', JSON.stringify({ name: 'react-native', version })); + write('node_modules/react-native/scripts/react_native_pods.rb', podsHelper); + write('node_modules/react-native/scripts/cocoapods/jsengine.rb', engineHelper); + }; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'bundle-drop-ios-hermes-')); + savedEnvironment = environmentKeys.map(key => [key, process.env[key]]); + for (const key of environmentKeys) delete process.env[key]; + installReactNative(); + write('ios/Podfile', standardPodfile); + }); + + afterEach(() => { + for (const [key, value] of savedEnvironment) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + fs.rmSync(root, { recursive: true, force: true }); + }); + + it.each(['0.81.0', '0.81.5', '0.81.99'])('recognizes the verified default on stable %s', version => { + installReactNative(version); + expect(detectImplicitIosHermes(root)).toBe(true); + expect(shouldCompileHermesBytecode({}, 'ios', root)).toBe(true); + }); + + it.each(['0.80.9', '0.82.0', '0.81.0-rc.0', '0.81.5-custom', '0.81.5+custom', 'invalid'])('does not extrapolate to %s', version => { + installReactNative(version); + expect(detectImplicitIosHermes(root)).toBeUndefined(); + }); + + it('recognizes the complete stock Xcode template with an unused JavaScriptCore navigator reference', () => { + write('ios/Demo.xcodeproj/project.pbxproj', stockXcodeProject); + expect(detectImplicitIosHermes(root)).toBe(true); + }); + + it('declines JavaScriptCore when the template reference is actually linked', () => { + write('ios/Demo.xcodeproj/project.pbxproj', stockXcodeProject.replace('/* Begin PBXBuildFile section */', + '/* Begin PBXBuildFile section */\nAAAAAAAAAAAAAAAAAAAAAAAA /* JavaScriptCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = ED297162215061F000B7C4FE /* JavaScriptCore.framework */; };')); + expect(detectImplicitIosHermes(root)).toBeUndefined(); + }); + + it('declines nonstandard JavaScriptCore references instead of discarding them', () => { + write('ios/Demo.xcodeproj/project.pbxproj', stockXcodeProject.replace('sourceTree = SDKROOT;', 'sourceTree = "";')); + expect(detectImplicitIosHermes(root)).toBeUndefined(); + }); + + it('recognizes the same upstream helpers with Windows line endings', () => { + write('node_modules/react-native/scripts/react_native_pods.rb', podsHelper.replace(/\r?\n/g, '\r\n')); + write('node_modules/react-native/scripts/cocoapods/jsengine.rb', engineHelper.replace(/\r?\n/g, '\r\n')); + expect(detectImplicitIosHermes(root)).toBe(true); + }); + + it.each(environmentKeys.flatMap(key => ['0', '1', 'false', ''].map(value => [key, value])))('declines an environment override %s=%s', (key, value) => { + process.env[key] = value; + expect(detectImplicitIosHermes(root)).toBeUndefined(); + }); + + it.each([ + ['ios/.xcode.env', 'export USE_HERMES=0'], + ['ios/.xcode.env.local', 'export USE_THIRD_PARTY_JSC=1'], + ['ios/Build.xcconfig', 'USE_HERMES = $(CUSTOM_ENGINE)'], + ['ios/Demo.xcodeproj/project.pbxproj', 'USE_THIRD_PARTY_JSC = YES;'], + ['react-native.config.js', "module.exports = { reactNativePath: '../custom-native' };"], + ])('declines unresolved project configuration in %s', (filename, contents) => { + write(filename, contents); + expect(detectImplicitIosHermes(root)).toBeUndefined(); + }); + + it('accepts the stock CocoaPods build configuration references before pods are installed', () => { + write('ios/Demo.xcodeproj/project.pbxproj', ` + 123456 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = ABCDEF /* Pods-Demo.debug.xcconfig */; + }; + 654321 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = FEDCBA /* Pods-Demo.release.xcconfig */; + }; + `); + expect(detectImplicitIosHermes(root)).toBe(true); + }); + + it.each([ + 'baseConfigurationReference = ABCDEF /* Custom.release.xcconfig */;', + 'baseConfigurationReference = ABCDEF;', + ])('declines custom or unresolved base configuration: %s', reference => { + write('ios/Demo.xcodeproj/project.pbxproj', reference); + expect(detectImplicitIosHermes(root)).toBeUndefined(); + }); + + it('declines an iOS-local React Native that shadows the consumer installation', () => { + write('ios/node_modules/react-native/package.json', JSON.stringify({ name: 'react-native', version: '0.81.5' })); + expect(detectImplicitIosHermes(root)).toBeUndefined(); + }); + + it.each([ + '{}', + '{invalid json', + ])('declines an app without a readable React Native declaration: %s', contents => { + write('package.json', contents); + expect(detectImplicitIosHermes(root)).toBeUndefined(); + }); + + it.each([ + { name: 'custom-react-native', version: '0.81.5' }, + { name: 'react-native', version: 81 }, + ])('declines an unexpected resolved package identity %j', manifest => { + write('node_modules/react-native/package.json', JSON.stringify(manifest)); + expect(detectImplicitIosHermes(root)).toBeUndefined(); + }); + + it.each(['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'])('declines community JSC in %s', section => { + write('package.json', JSON.stringify({ dependencies: { 'react-native': '0.81.5' }, [section]: { 'react-native': '0.81.5', '@react-native-community/javascriptcore': '1.0.0' } })); + expect(detectImplicitIosHermes(root)).toBeUndefined(); + }); + + it.each([ + standardPodfile.replace(':path => config[:reactNativePath]', ':path => "../custom-native"'), + standardPodfile.replace('config = use_native_modules!', 'config = custom_native_modules!'), + standardPodfile.replace('config = use_native_modules!', 'config = use_native_modules!\n config[:reactNativePath] = "../custom-native"'), + standardPodfile.replace('react-native/scripts/react_native_pods.rb', 'custom-native/scripts/react_native_pods.rb'), + standardPodfile + '\nuse_react_native!(:path => config[:reactNativePath])', + standardPodfile.replace(':app_path =>', ':hermes_enabled => ENV["ENGINE"],\n :app_path =>'), + standardPodfile + '\nENV["USE_THIRD_PARTY_JSC"] = "1"', + standardPodfile + '\nconfig = custom_config', + standardPodfile + '\nconfig.merge!({reactNativePath: "../custom-native"})', + standardPodfile + '\nrequire_relative "./custom_setup"', + standardPodfile + '\nrequire "custom_setup"', + standardPodfile + '\nuse_react_native! :path => config[:reactNativePath]', + ])('declines customized Podfile engine resolution', podfile => { + write('ios/Podfile', podfile); + expect(detectImplicitIosHermes(root)).toBeUndefined(); + }); + + it.each([ + ['scripts/react_native_pods.rb', podsHelper.replace('hermes_enabled: true', 'hermes_enabled: false')], + ['scripts/react_native_pods.rb', podsHelper.replace('hermes_enabled= true', 'hermes_enabled= false')], + ['scripts/cocoapods/jsengine.rb', engineHelper.replace('return !use_third_party_jsc()', 'return false')], + ['scripts/cocoapods/jsengine.rb', engineHelper.replace("== '1'", "!= '0'")], + ])('declines modified helper semantics in %s', (filename, contents) => { + write(`node_modules/react-native/${filename}`, contents); + expect(detectImplicitIosHermes(root)).toBeUndefined(); + }); + + it.each(['ios/Podfile', 'package.json', 'node_modules/react-native/package.json', 'node_modules/react-native/scripts/react_native_pods.rb', 'node_modules/react-native/scripts/cocoapods/jsengine.rb'])('declines a missing %s', filename => { + fs.rmSync(path.join(root, filename)); + expect(detectImplicitIosHermes(root)).toBeUndefined(); + }); + + it('does not fall back to the SDK installation when the app has no React Native', () => { + fs.rmSync(path.join(root, 'node_modules'), { recursive: true }); + expect(detectImplicitIosHermes(root)).toBeUndefined(); + }); + + it('rejects a dependency resolved outside the captured snapshot', () => { + const snapshot = path.join(root, 'snapshot'); + fs.mkdirSync(path.join(snapshot, 'ios'), { recursive: true }); + fs.writeFileSync(path.join(snapshot, 'package.json'), JSON.stringify({ dependencies: { 'react-native': '0.81.5' } })); + fs.writeFileSync(path.join(snapshot, 'ios/Podfile'), standardPodfile); + expect(detectImplicitIosHermes(snapshot, snapshot)).toBeUndefined(); + }); + + it('resolves workspace dependencies inside the snapshot', () => { + const app = path.join(root, 'apps/demo'); + fs.mkdirSync(path.join(app, 'ios'), { recursive: true }); + fs.writeFileSync(path.join(app, 'package.json'), JSON.stringify({ dependencies: { 'react-native': '0.81.5' } })); + fs.writeFileSync(path.join(app, 'ios/Podfile'), standardPodfile); + expect(detectImplicitIosHermes(app, root)).toBe(true); + }); + + it('checks the real dependency path when node_modules uses a symlink', () => { + const snapshot = path.join(root, 'snapshot'); + fs.mkdirSync(path.join(snapshot, 'ios'), { recursive: true }); + fs.mkdirSync(path.join(snapshot, 'node_modules')); + fs.writeFileSync(path.join(snapshot, 'package.json'), JSON.stringify({ dependencies: { 'react-native': '0.81.5' } })); + fs.writeFileSync(path.join(snapshot, 'ios/Podfile'), standardPodfile); + fs.symlinkSync(path.join(root, 'node_modules/react-native'), path.join(snapshot, 'node_modules/react-native'), 'junction'); + expect(detectImplicitIosHermes(snapshot, snapshot)).toBeUndefined(); + }); + + it('rechecks installed helpers instead of caching a previous detection', () => { + expect(detectImplicitIosHermes(root, root)).toBe(true); + write('node_modules/react-native/package.json', JSON.stringify({ name: 'react-native', version: '0.80.0' })); + expect(detectImplicitIosHermes(root, root)).toBeUndefined(); + }); + + it('preserves explicit Bundle Drop options ahead of implicit and native defaults', () => { + expect(shouldCompileHermesBytecode({ hermesBytecode: false }, 'ios', root)).toBe(false); + expect(shouldCompileHermesBytecode({ hermes: { ios: false } }, 'ios', root)).toBe(false); + write('ios/Podfile', standardPodfile.replace(':app_path =>', ':hermes_enabled => false,\n :app_path =>')); + expect(shouldCompileHermesBytecode({ hermesBytecode: { ios: true } }, 'ios', root)).toBe(true); + }); + + it.each([true, false])('preserves native Hermes %s declarations', enabled => { + write('ios/Podfile', standardPodfile.replace(':app_path =>', `:hermes_enabled => ${enabled},\n :app_path =>`)); + expect(shouldCompileHermesBytecode({}, 'ios', root)).toBe(enabled); + }); + + it('does not use the iOS fallback for Android', () => { + expect(shouldCompileHermesBytecode({}, 'android', root)).toBe(false); + write('android/gradle.properties', 'hermesEnabled=true'); + expect(shouldCompileHermesBytecode({}, 'android', root)).toBe(true); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 5ca64a6..6ae6999 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { "target": "ES2020", - "module": "commonjs", - "moduleResolution": "node", + "module": "Node20", + "moduleResolution": "Node16", "outDir": "lib", "rootDir": "src", "declaration": true, diff --git a/yarn.lock b/yarn.lock index a1f5f43..3f8750e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -654,17 +654,17 @@ __metadata: "@types/react-native": ^0.73.0 adm-zip: 0.6.1 axios: 1.20.0 - chalk: 4.1.2 - commander: 7.2.0 + chalk: 6.0.0 + commander: 15.0.0 diff: 9.0.0 expo-autolinking-sdk55: "npm:expo-modules-autolinking@55.0.8" expo-autolinking-sdk56: "npm:expo-modules-autolinking@56.0.9" expo-autolinking-sdk57: "npm:expo-modules-autolinking@57.0.2" - figures: 3.2.0 + figures: 6.1.0 form-data: 4.0.6 fs-extra: 11.4.0 jest: ^29.7.0 - plist: 3.1.1 + plist: 5.0.0 prompts: 2.4.2 react: ^18.3.1 react-native: ">=0.71.0" @@ -2057,7 +2057,14 @@ __metadata: languageName: node linkType: hard -"chalk@npm:4.1.2, chalk@npm:^4.0.0, chalk@npm:^4.1.0, chalk@npm:^4.1.2": +"chalk@npm:6.0.0": + version: 6.0.0 + resolution: "chalk@npm:6.0.0" + checksum: d7f2d436a04357b3905ef253befc175e3cab68c4731bbbef40eb412d1dea2d5e853995364cdaf8c67fd0e84d051e767c089898788866136119c8664aae1a06bb + languageName: node + linkType: hard + +"chalk@npm:^4.0.0, chalk@npm:^4.1.0, chalk@npm:^4.1.2": version: 4.1.2 resolution: "chalk@npm:4.1.2" dependencies: @@ -2250,10 +2257,10 @@ __metadata: languageName: node linkType: hard -"commander@npm:7.2.0, commander@npm:^7.2.0": - version: 7.2.0 - resolution: "commander@npm:7.2.0" - checksum: 53501cbeee61d5157546c0bef0fedb6cdfc763a882136284bed9a07225f09a14b82d2a84e7637edfd1a679fb35ed9502fd58ef1d091e6287f60d790147f68ddc +"commander@npm:15.0.0": + version: 15.0.0 + resolution: "commander@npm:15.0.0" + checksum: e8aaa24591ae09389e83320431d47b44d9b03abe754586b689020b6bb6fc950a0089e9379099efa2e63b6b7c0e1e216bcf6b485754da7a54a74840077f3c6d2b languageName: node linkType: hard @@ -2271,6 +2278,13 @@ __metadata: languageName: node linkType: hard +"commander@npm:^7.2.0": + version: 7.2.0 + resolution: "commander@npm:7.2.0" + checksum: 53501cbeee61d5157546c0bef0fedb6cdfc763a882136284bed9a07225f09a14b82d2a84e7637edfd1a679fb35ed9502fd58ef1d091e6287f60d790147f68ddc + languageName: node + linkType: hard + "concat-map@npm:0.0.1": version: 0.0.1 resolution: "concat-map@npm:0.0.1" @@ -2594,13 +2608,6 @@ __metadata: languageName: node linkType: hard -"escape-string-regexp@npm:^1.0.5": - version: 1.0.5 - resolution: "escape-string-regexp@npm:1.0.5" - checksum: 6092fda75c63b110c706b6a9bfde8a612ad595b628f0bd2147eea1d3406723020810e591effc7db1da91d80a71a737a313567c5abb3813e8d9c71f4aa595b410 - languageName: node - linkType: hard - "escape-string-regexp@npm:^2.0.0": version: 2.0.0 resolution: "escape-string-regexp@npm:2.0.0" @@ -2795,12 +2802,12 @@ __metadata: languageName: node linkType: hard -"figures@npm:3.2.0": - version: 3.2.0 - resolution: "figures@npm:3.2.0" +"figures@npm:6.1.0": + version: 6.1.0 + resolution: "figures@npm:6.1.0" dependencies: - escape-string-regexp: ^1.0.5 - checksum: 85a6ad29e9aca80b49b817e7c89ecc4716ff14e3779d9835af554db91bac41c0f289c418923519392a1e582b4d10482ad282021330cd045bb7b80c84152f2a2b + is-unicode-supported: ^2.0.0 + checksum: 35c81239d4fa40b75c2c7c010833b0bc8861c27187e4c9388fca1d9731103ec9989b70ee3b664ef426ddd9abe02ec5f4fd973424aa8c6fd3ea5d3bf57a2d01b4 languageName: node linkType: hard @@ -3407,6 +3414,13 @@ __metadata: languageName: node linkType: hard +"is-unicode-supported@npm:^2.0.0": + version: 2.1.0 + resolution: "is-unicode-supported@npm:2.1.0" + checksum: f254e3da6b0ab1a57a94f7273a7798dd35d1d45b227759f600d0fa9d5649f9c07fa8d3c8a6360b0e376adf916d151ec24fc9a50c5295c58bae7ca54a76a063f9 + languageName: node + linkType: hard + "is-wsl@npm:^1.1.0": version: 1.1.0 resolution: "is-wsl@npm:1.1.0" @@ -5199,14 +5213,13 @@ __metadata: languageName: node linkType: hard -"plist@npm:3.1.1": - version: 3.1.1 - resolution: "plist@npm:3.1.1" +"plist@npm:5.0.0": + version: 5.0.0 + resolution: "plist@npm:5.0.0" dependencies: "@xmldom/xmldom": ^0.9.10 - base64-js: ^1.5.1 xmlbuilder: ^15.1.1 - checksum: 775b2befb6df97b145193e5c241dc63929c58c89673eebc51e06b4b5b3403d15d921140278644ddf6f51a6936d46450d35903a84fc4cba071c46d9c33141817d + checksum: e495209e27b5eb9bd56910d34c6446677f75f7779ec867faf239449dd340a055a761c9214af39be852bbfcdc9323bce3c306ba909fcd18fff96e39a33633e069 languageName: node linkType: hard From e8cd6927e52a40430d48b59318c9061f3bd3236f Mon Sep 17 00:00:00 2001 From: George Fean <37250288+GFean@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:22:36 +0400 Subject: [PATCH 2/2] fix(cli): preserve Node 20 compatibility after modernization --- .github/workflows/ci.yml | 6 +++-- CONTRIBUTING.md | 4 +++- README.md | 6 ++--- jest.config.cjs | 2 +- package.json | 8 +++---- yarn.lock | 47 +++++++++++++++++----------------------- 6 files changed, 35 insertions(+), 38 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc7f481..7f9abfa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,13 +83,15 @@ jobs: matrix: include: - os: ubuntu-latest - node: 22.12.0 + node: 20.19.4 + - os: ubuntu-latest + node: 22.0.0 - os: ubuntu-latest node: 22.13.0 - os: ubuntu-latest node: 24.15.0 - os: windows-latest - node: 22.12.0 + node: 20.19.4 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ee4461d..a1c73b6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,8 @@ tests, and implementation changes are welcome. ## Before You Start -- Use Node.js 20.19.4 or newer. +- Use Node.js 22.13.0 for development, matching `.nvmrc`. The package requires + Node.js 20.19.4 or newer. - Search existing issues before opening a new one. - Use [GitHub Discussions](https://github.com/GFean/react-native-bundle-drop/discussions) for questions and early-stage ideas. @@ -21,6 +22,7 @@ major feature, architectural change, breaking change, or backend-contract change Install dependencies and build the package: ```bash +nvm use corepack yarn install yarn build ``` diff --git a/README.md b/README.md index d0ddc10..c3914d9 100644 --- a/README.md +++ b/README.md @@ -54,11 +54,11 @@ runtime identity. Native changes still require a new App Store or Play Store bui | Bare React Native | React Native 0.71 and newer | | Platforms | iOS and Android | | Architectures | Legacy architecture and New Architecture | -| Node.js | 22.12.0 or newer | +| Node.js | 20.19.4 or newer | | React | 17 or newer | -Node 20 is no longer supported. The Node requirement applies to installation of -the package, including projects that use only the mobile SDK. Development and +The Node requirement applies to installation of the package, including projects +that use only the mobile SDK. Development and package builds use Node 22.13.0, as specified in `.nvmrc`. Run `bundle-drop --help` to browse commands grouped into Setup, Analysis, diff --git a/jest.config.cjs b/jest.config.cjs index 6ae44c4..3fca414 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -17,7 +17,7 @@ module.exports = { }], }, transformIgnorePatterns: [ - '/node_modules/(?!(commander|chalk|figures|is-unicode-supported|plist)/)', + '/node_modules/(?!(plist)/)', ], moduleNameMapper: { '^(\\.{1,2}/.*)\\.js$': '$1', diff --git a/package.json b/package.json index 9fec7a4..cf79795 100644 --- a/package.json +++ b/package.json @@ -164,7 +164,7 @@ } }, "engines": { - "node": ">=22.12.0" + "node": ">=20.19.4" }, "resolutions": { "diff@^4.0.1": "4.0.4", @@ -173,10 +173,10 @@ "dependencies": { "adm-zip": "0.6.1", "axios": "1.20.0", - "chalk": "6.0.0", - "commander": "15.0.0", + "chalk": "4.1.2", + "commander": "14.0.3", "diff": "9.0.0", - "figures": "6.1.0", + "figures": "3.2.0", "form-data": "4.0.6", "fs-extra": "11.4.0", "plist": "5.0.0", diff --git a/yarn.lock b/yarn.lock index 3f8750e..b63cbff 100644 --- a/yarn.lock +++ b/yarn.lock @@ -654,13 +654,13 @@ __metadata: "@types/react-native": ^0.73.0 adm-zip: 0.6.1 axios: 1.20.0 - chalk: 6.0.0 - commander: 15.0.0 + chalk: 4.1.2 + commander: 14.0.3 diff: 9.0.0 expo-autolinking-sdk55: "npm:expo-modules-autolinking@55.0.8" expo-autolinking-sdk56: "npm:expo-modules-autolinking@56.0.9" expo-autolinking-sdk57: "npm:expo-modules-autolinking@57.0.2" - figures: 6.1.0 + figures: 3.2.0 form-data: 4.0.6 fs-extra: 11.4.0 jest: ^29.7.0 @@ -2057,14 +2057,7 @@ __metadata: languageName: node linkType: hard -"chalk@npm:6.0.0": - version: 6.0.0 - resolution: "chalk@npm:6.0.0" - checksum: d7f2d436a04357b3905ef253befc175e3cab68c4731bbbef40eb412d1dea2d5e853995364cdaf8c67fd0e84d051e767c089898788866136119c8664aae1a06bb - languageName: node - linkType: hard - -"chalk@npm:^4.0.0, chalk@npm:^4.1.0, chalk@npm:^4.1.2": +"chalk@npm:4.1.2, chalk@npm:^4.0.0, chalk@npm:^4.1.0, chalk@npm:^4.1.2": version: 4.1.2 resolution: "chalk@npm:4.1.2" dependencies: @@ -2257,10 +2250,10 @@ __metadata: languageName: node linkType: hard -"commander@npm:15.0.0": - version: 15.0.0 - resolution: "commander@npm:15.0.0" - checksum: e8aaa24591ae09389e83320431d47b44d9b03abe754586b689020b6bb6fc950a0089e9379099efa2e63b6b7c0e1e216bcf6b485754da7a54a74840077f3c6d2b +"commander@npm:14.0.3": + version: 14.0.3 + resolution: "commander@npm:14.0.3" + checksum: b8440159124aefbb02784a8996cd0481d8ce503d8d3cd413f4289d06db7e912c36b511277c2faba62f59152dd9ff79239d75e3b9400d9402851878994201d429 languageName: node linkType: hard @@ -2608,6 +2601,13 @@ __metadata: languageName: node linkType: hard +"escape-string-regexp@npm:^1.0.5": + version: 1.0.5 + resolution: "escape-string-regexp@npm:1.0.5" + checksum: 6092fda75c63b110c706b6a9bfde8a612ad595b628f0bd2147eea1d3406723020810e591effc7db1da91d80a71a737a313567c5abb3813e8d9c71f4aa595b410 + languageName: node + linkType: hard + "escape-string-regexp@npm:^2.0.0": version: 2.0.0 resolution: "escape-string-regexp@npm:2.0.0" @@ -2802,12 +2802,12 @@ __metadata: languageName: node linkType: hard -"figures@npm:6.1.0": - version: 6.1.0 - resolution: "figures@npm:6.1.0" +"figures@npm:3.2.0": + version: 3.2.0 + resolution: "figures@npm:3.2.0" dependencies: - is-unicode-supported: ^2.0.0 - checksum: 35c81239d4fa40b75c2c7c010833b0bc8861c27187e4c9388fca1d9731103ec9989b70ee3b664ef426ddd9abe02ec5f4fd973424aa8c6fd3ea5d3bf57a2d01b4 + escape-string-regexp: ^1.0.5 + checksum: 85a6ad29e9aca80b49b817e7c89ecc4716ff14e3779d9835af554db91bac41c0f289c418923519392a1e582b4d10482ad282021330cd045bb7b80c84152f2a2b languageName: node linkType: hard @@ -3414,13 +3414,6 @@ __metadata: languageName: node linkType: hard -"is-unicode-supported@npm:^2.0.0": - version: 2.1.0 - resolution: "is-unicode-supported@npm:2.1.0" - checksum: f254e3da6b0ab1a57a94f7273a7798dd35d1d45b227759f600d0fa9d5649f9c07fa8d3c8a6360b0e376adf916d151ec24fc9a50c5295c58bae7ca54a76a063f9 - languageName: node - linkType: hard - "is-wsl@npm:^1.1.0": version: 1.1.0 resolution: "is-wsl@npm:1.1.0"