diff --git a/.agents/commands/review.md b/.agents/commands/review.md index 828a8601..c2c23a5f 100644 --- a/.agents/commands/review.md +++ b/.agents/commands/review.md @@ -4,20 +4,26 @@ Review all commits on the current branch since diverging from main. ## Prerequisites -**IMPORTANT**: Before starting the review, check if this is a fresh context/session: +**IMPORTANT**: A reviewer should not be the same "person" who wrote the code. Before starting, check if this is a fresh context/session: -- If there is prior conversation history in this session (e.g., you helped write the code being reviewed), STOP immediately -- Inform the user: "Code reviews should be done in a fresh context to avoid bias. Please start a new agent session and run /review there." -- A reviewer should not be the same "person" who wrote the code +- **If there is prior conversation history in this session** (e.g., you helped write the code being reviewed), do NOT review it yourself — your context is biased. Instead, spawn a fresh reviewer: + - Launch a subagent (synchronously — `run_in_background: false`) with a prompt telling it to perform the full review defined in the **Instructions** section below on the current branch, and to return its findings as: a summary, positives, and a severity-ranked list of issues (each with `file:line`, description, and a proposed action). Paste the Instructions criteria into the prompt so the subagent doesn't need to re-read this file. + - Pick the subagent type by what the diff actually touches: `crypto-specialist` for crypto/algorithm changes, `cpp-specialist` for `cpp/`, `typescript-specialist` for TS-only changes, `general-purpose` otherwise. Spawn more than one in parallel when the diff spans domains. + - **Scope the prompt to THIS branch's actual changes, not the generic template.** Before spawning, run `git diff --stat origin/main...HEAD` (three dots — see step 3) and put the concrete context into the prompt: what the branch does, which subsystem it touches (this repo spans C++/OpenSSL under `packages/react-native-quick-crypto/cpp`, the TypeScript package under `src/`, the Expo config plugin, the `example/` RN app and its test suites, `docs/`, and CI workflows — name the ones actually changed), and the list of changed files. Tell the subagent to open and review **every** changed file, including non-C++ ones (`.ts`, `.tsx`, podspec, Gradle, `.github/workflows`, `.agents/`), and to validate **each** changed toolchain rather than assuming one check covers the diff. + - The subagent starts with a clean context and did NOT write the code, so its review is unbiased. + - When it returns, relay its review to the user verbatim, then run the **Follow-up** fix-plan step yourself (the subagent can't interact with the user). +- **If this is a fresh context** (no prior history — you did not write this code), perform the review directly. ## Instructions -When activated (in a fresh session), perform a full code review of the commits since branching from main: +When activated, perform a full code review of the commits since branching from main: -1. **Get the commits**: Run `git log main..HEAD --oneline` to see all commits on this branch -2. **Get the full diff**: Run `git diff main..HEAD` to see all changes -3. **For each file changed**, read enough context to understand the changes -4. **Review for**: +1. **Sync the base**: Run `git fetch origin` first. Local `main` is often stale (a PR merged upstream but not pulled locally), which silently drags already-merged commits into the review scope and balloons the diff. Use `origin/main` as the base for everything below. +2. **Get the commits**: Run `git log origin/main..HEAD --oneline` to see all commits on this branch +3. **Get the full diff**: Run `git diff origin/main...HEAD` to see all changes. **Three dots.** For `diff`, two dots compares the two endpoints, so anything merged into `origin/main` since the branch point shows up as an inverse diff — as if this branch deleted it. Three dots diffs against the merge-base, which is what you want. Note that step 2's `git log` takes **two** dots: the same syntax means different things for `log` and `diff`. +4. **Also check the working tree**: run `git status --short` and `git diff`. Uncommitted changes are in scope — review them alongside the commits, and say which findings apply to uncommitted work. +5. **For each file changed**, read enough context to understand the changes +6. **Review for**: - Correctness and logic errors - Consistency with existing patterns in the codebase - TypeScript best practices @@ -26,13 +32,13 @@ When activated (in a fresh session), perform a full code review of the commits s - Potential bugs or edge cases - Missing error handling - Code clarity and maintainability -5. **Provide a structured review** with: +7. **Provide a structured review** with: - Summary of what the branch does - Positives (what's done well) - Issues & suggestions (ranked by severity) - Recommended actions (if any) -Run `bun tsc` to verify the code compiles. +Verify the toolchains the diff actually touches: `bun tsc` for TypeScript, `bun run test` (in `packages/react-native-quick-crypto` — `run` matters, bare `bun test` is Bun's own runner, not the package's jest) if node-side code changed, `clang-format --dry-run --Werror` for C++. C++ runtime behavior and example-app test suites can only be validated by the user running `bun ios` / `bun android` — flag that rather than claiming it passes. ## Follow-up @@ -46,4 +52,8 @@ After presenting the review, present a **fix plan table** for the user to approv - **Skip**: Not worth changing (explain why) - **Ask**: Ambiguous, needs user input on approach -**Wait for the user to approve the plan** (they may want to skip or modify items). Then apply only the approved fixes. Run `bun tsc` after all fixes are applied to verify everything is clean. +**Wait for the user to approve the plan** (they may want to skip or modify items). They may reply with "approve" / "approve all" to accept everything, override individual rows ("skip #3", "fix #5 differently"), or ask clarifying questions. + +Once approved, apply only the approved fixes, then re-run the same toolchain checks to verify everything is clean. + +Then commit the approved fixes (via `/commit`) — don't leave review changes sitting in the working tree. Commit only; pushing and opening PRs still need explicit permission. diff --git a/.github/workflows/validate-js.yml b/.github/workflows/validate-js.yml index 77860f50..c5645d3b 100644 --- a/.github/workflows/validate-js.yml +++ b/.github/workflows/validate-js.yml @@ -8,6 +8,7 @@ on: - '.github/workflows/validate-js.yml' - 'bun.lock' - 'packages/react-native-quick-crypto/src/**' + - 'packages/react-native-quick-crypto/test/**' - 'packages/react-native-quick-crypto/*.json' - 'packages/react-native-quick-crypto/*.*s' - 'packages/react-native-quick-crypto/bun.lock' @@ -21,6 +22,7 @@ on: - '.github/workflows/validate-js.yml' - 'bun.lock' - 'packages/react-native-quick-crypto/src/**' + - 'packages/react-native-quick-crypto/test/**' - 'packages/react-native-quick-crypto/*.json' - 'packages/react-native-quick-crypto/*.*s' - 'packages/react-native-quick-crypto/bun.lock' @@ -70,6 +72,11 @@ jobs: cd packages/react-native-quick-crypto bun circular + - name: Run tests + run: | + cd packages/react-native-quick-crypto + bun run test + audit_runtime_deps: name: Audit runtime deps (bun audit) runs-on: ubuntu-latest @@ -82,6 +89,14 @@ jobs: # workspace's dev/peer tooling. Workspace-level `bun audit` walks through optional # peers (expo, react-native, etc.) which surface ~70 advisories that never reach a # consumer's runtime bundle. Phase 5.1 baseline: zero advisories in the runtime tree. + # + # `bun audit` resolves peers from the registry regardless of what is installed, so + # neither `--omit=peer` nor `[install] peer = false` keeps react-native's own tree + # out of the graph — only `--ignore` does. These two are metro (react-native's + # bundler, dev-time only) reached via the react-native peer of + # react-native-quick-base64; they are not in a consumer's runtime bundle. Ignores + # are per-advisory, so anything new still fails this job. Drop them once + # react-native ships a metro with image-size >= 2.0.3. - name: Audit runtime dependencies run: | mkdir -p /tmp/rnqc-runtime-audit @@ -89,7 +104,9 @@ jobs: bun -e "const pkg=require('$GITHUB_WORKSPACE/packages/react-native-quick-crypto/package.json'); require('fs').writeFileSync('package.json', JSON.stringify({name:'rnqc-runtime-audit',version:'0.0.0',dependencies:pkg.dependencies},null,2));" cat package.json bun install --no-summary - bun audit --audit-level=high + bun audit --audit-level=high \ + --ignore=GHSA-w3rx-r6r6-pgpr \ + --ignore=GHSA-5p2g-fcmc-qvqq lint_js: name: JS Lint (eslint, prettier) diff --git a/bun.lock b/bun.lock index 90f877d0..45f8fbc1 100644 --- a/bun.lock +++ b/bun.lock @@ -96,6 +96,7 @@ "util": "0.12.5", }, "devDependencies": { + "@babel/runtime": "7.28.4", "@types/jest": "29.5.11", "@types/node": "24.3.0", "@types/react": "18.3.3", @@ -103,7 +104,6 @@ "del-cli": "7.0.0", "dpdm": "^4.0.1", "expo": "^54.0.25", - "expo-build-properties": "^1.0.0", "jest": "29.7.0", "nitrogen": "0.33.2", "react-native-builder-bob": "0.40.15", @@ -111,7 +111,6 @@ }, "peerDependencies": { "expo": ">=48.0.0", - "expo-build-properties": "*", "react": "*", "react-native": "*", "react-native-nitro-modules": ">=0.31.2", @@ -119,7 +118,6 @@ }, "optionalPeers": [ "expo", - "expo-build-properties", ], }, }, @@ -1293,8 +1291,6 @@ "expo-asset": ["expo-asset@12.0.12", "", { "dependencies": { "@expo/image-utils": "^0.8.8", "expo-constants": "~18.0.12" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-CsXFCQbx2fElSMn0lyTdRIyKlSXOal6ilLJd+yeZ6xaC7I9AICQgscY5nj0QcwgA+KYYCCEQEBndMsmj7drOWQ=="], - "expo-build-properties": ["expo-build-properties@1.0.10", "", { "dependencies": { "ajv": "^8.11.0", "semver": "^7.6.0" }, "peerDependencies": { "expo": "*" } }, "sha512-mFCZbrbrv0AP5RB151tAoRzwRJelqM7bCJzCkxpu+owOyH+p/rFC/q7H5q8B9EpVWj8etaIuszR+gKwohpmu1Q=="], - "expo-constants": ["expo-constants@18.0.13", "", { "dependencies": { "@expo/config": "~12.0.13", "@expo/env": "~2.0.8" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ=="], "expo-file-system": ["expo-file-system@19.0.21", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg=="], @@ -1329,8 +1325,6 @@ "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], - "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], - "fast-wrap-ansi": ["fast-wrap-ansi@0.2.0", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w=="], "fast-xml-parser": ["fast-xml-parser@4.5.3", "", { "dependencies": { "strnum": "^1.1.1" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig=="], @@ -2731,8 +2725,6 @@ "eslint-plugin-react/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "expo-build-properties/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], - "expo-modules-autolinking/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -3099,8 +3091,6 @@ "eslint-plugin-jest/@typescript-eslint/utils/eslint-scope": ["eslint-scope@5.1.1", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" } }, "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw=="], - "expo-build-properties/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "hash-base/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], diff --git a/packages/react-native-quick-crypto/package.json b/packages/react-native-quick-crypto/package.json index c7a1e84f..3a6d449f 100644 --- a/packages/react-native-quick-crypto/package.json +++ b/packages/react-native-quick-crypto/package.json @@ -114,6 +114,7 @@ "util": "0.12.5" }, "devDependencies": { + "@babel/runtime": "7.28.4", "@types/jest": "29.5.11", "@types/node": "24.3.0", "@types/react": "18.3.3", @@ -121,7 +122,6 @@ "del-cli": "7.0.0", "dpdm": "^4.0.1", "expo": "^54.0.25", - "expo-build-properties": "^1.0.0", "jest": "29.7.0", "nitrogen": "0.33.2", "react-native-builder-bob": "0.40.15", @@ -132,15 +132,11 @@ "react-native": "*", "react-native-nitro-modules": ">=0.31.2", "react-native-quick-base64": ">=3.0.0", - "expo": ">=48.0.0", - "expo-build-properties": "*" + "expo": ">=48.0.0" }, "peerDependenciesMeta": { "expo": { "optional": true - }, - "expo-build-properties": { - "optional": true } }, "release-it": { diff --git a/packages/react-native-quick-crypto/src/expo-plugin/withXCode.ts b/packages/react-native-quick-crypto/src/expo-plugin/withXCode.ts index f0c167fc..71e90b81 100644 --- a/packages/react-native-quick-crypto/src/expo-plugin/withXCode.ts +++ b/packages/react-native-quick-crypto/src/expo-plugin/withXCode.ts @@ -1,19 +1,120 @@ import type { ConfigPlugin } from 'expo/config-plugins'; import type { ConfigProps } from './@types'; -import { withBuildProperties } from 'expo-build-properties'; -import { withDangerousMod } from 'expo/config-plugins'; +import { + IOSConfig, + withDangerousMod, + withPodfileProperties, + withXcodeProject, +} from 'expo/config-plugins'; import fs from 'fs'; import path from 'path'; +const MIN_IOS_DEPLOYMENT_TARGET = '16.4'; +const MIN_PARTS = MIN_IOS_DEPLOYMENT_TARGET.split('.').map(Number); + +/** + * True when `version` is absent or below our minimum, i.e. we should raise it. + * Compared component-wise: '16.10' and '16.4.1' are both above '16.4'. + */ +export function needsRaising(version: string | undefined): boolean { + if (!version) return true; + + const parts = version.split('.').map(Number); + if (!parts.every(Number.isFinite)) { + console.warn( + `[react-native-quick-crypto] leaving unparseable iOS deployment target "${version}" alone`, + ); + return false; + } + + for (let i = 0; i < Math.max(parts.length, MIN_PARTS.length); i++) { + const diff = (parts[i] ?? 0) - (MIN_PARTS[i] ?? 0); + if (diff !== 0) return diff < 0; + } + return false; +} + +const POST_INSTALL_PATCH = ` # react-native-quick-crypto: floor the pods' deployment target, never lower it + # https://github.com/mrousavy/nitro/issues/422#issuecomment-2545988256 + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + current = config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'].to_s + if !Gem::Version.correct?(current) || + Gem::Version.new(current) < Gem::Version.new('${MIN_IOS_DEPLOYMENT_TARGET}') + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '${MIN_IOS_DEPLOYMENT_TARGET}' + end + end + end`; + +/** + * Fallback for Podfiles whose post_install doesn't already pin a deployment + * target. No-op when one is already pinned - that's the app's call, not ours. + */ +export function patchPodfile(contents: string): string { + const alreadyPinned = + /\.build_settings\s*\[\s*['"]IPHONEOS_DEPLOYMENT_TARGET['"]\s*\]\s*=/.test( + contents, + ); + if (alreadyPinned) return contents; + + const patched = contents.replace( + /(post_install\s+do\s+\|installer\|[\s\S]*?)(\r?\n\s\send\s*)$/m, + `$1\n\n${POST_INSTALL_PATCH}\n$2`, + ); + if (patched === contents) { + console.warn( + '[react-native-quick-crypto] no post_install block found in the Podfile; skipping the iOS deployment target floor', + ); + } + return patched; +} + /** * Workaround for some jank XCode releases that break React Native native modules * * see: https://github.com/mrousavy/nitro/issues/422#issuecomment-2545988256 + * + * Raises the iOS deployment target to our minimum wherever it is lower. Reads + * the value at mod time rather than from static `plugins` config, so it floors + * apps that set a higher target by any means and never lowers one. (#1066) */ export const withXCode: ConfigPlugin = config => { - // Use expo-build-properties to bump iOS deployment target - config = withBuildProperties(config, { ios: { deploymentTarget: '16.4' } }); - // Patch the generated Podfile fallback to ensure platform is always 16.4 + // what the generated Podfile reads for `platform :ios` + config = withPodfileProperties(config, podfileConfig => { + if (needsRaising(podfileConfig.modResults['ios.deploymentTarget'])) { + podfileConfig.modResults['ios.deploymentTarget'] = + MIN_IOS_DEPLOYMENT_TARGET; + } + return podfileConfig; + }); + + // the app target in the generated Xcode project + config = withXcodeProject(config, xcodeConfig => { + const { Target, XcodeUtils } = IOSConfig; + const listIds = Target.getNativeTargets(xcodeConfig.modResults) + .filter(([, target]) => + Target.isTargetOfType(target, Target.TargetType.APPLICATION), + ) + .map(([, target]) => target.buildConfigurationList); + + for (const listId of listIds) { + const configurations = XcodeUtils.getBuildConfigurationsForListId( + xcodeConfig.modResults, + listId, + ); + for (const [, { buildSettings }] of configurations) { + // only floor an explicit target - writing one that was inherited from + // the project level could itself be a downgrade + const current = buildSettings?.IPHONEOS_DEPLOYMENT_TARGET; + if (current && needsRaising(String(current))) { + buildSettings.IPHONEOS_DEPLOYMENT_TARGET = MIN_IOS_DEPLOYMENT_TARGET; + } + } + } + return xcodeConfig; + }); + + // pods that don't inherit the platform from the Podfile config = withDangerousMod(config, [ 'ios', modConfig => { @@ -21,35 +122,13 @@ export const withXCode: ConfigPlugin = config => { modConfig.modRequest.platformProjectRoot, 'Podfile', ); - let contents = fs.readFileSync(podfilePath, 'utf-8'); - - // Check if the IPHONEOS_DEPLOYMENT_TARGET setting is already present - // We search for the key being assigned, e.g., config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = - const deploymentTargetSettingExists = - /\.build_settings\s*\[\s*['"]IPHONEOS_DEPLOYMENT_TARGET['"]\s*\]\s*=/.test( - contents, - ); - - if (!deploymentTargetSettingExists) { - // IPHONEOS_DEPLOYMENT_TARGET setting not found, proceed to add it. - contents = contents.replace( - /(post_install\s+do\s+\|installer\|[\s\S]*?)(\r?\n\s\send\s*)$/m, - `$1 - - # Expo Build Properties: force deployment target - # https://github.com/mrousavy/nitro/issues/422#issuecomment-2545988256 - installer.pods_project.targets.each do |target| - target.build_configurations.each do |config| - config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.4' - end - end -$2`, - ); - } - - fs.writeFileSync(podfilePath, contents); + fs.writeFileSync( + podfilePath, + patchPodfile(fs.readFileSync(podfilePath, 'utf-8')), + ); return modConfig; }, ]); + return config; }; diff --git a/packages/react-native-quick-crypto/test/withXCode.test.ts b/packages/react-native-quick-crypto/test/withXCode.test.ts new file mode 100644 index 00000000..581b5e9b --- /dev/null +++ b/packages/react-native-quick-crypto/test/withXCode.test.ts @@ -0,0 +1,117 @@ +import { execFileSync } from 'child_process'; +import { needsRaising, patchPodfile } from '../src/expo-plugin/withXCode'; + +const PODFILE = `require File.join(File.dirname(\`node --print "require.resolve('expo/package.json')"\`), "scripts/autolinking") + +platform :ios, podfile_properties['ios.deploymentTarget'] || '15.1' + +target 'example' do + use_expo_modules! + + post_install do |installer| + react_native_post_install(installer, config[:reactNativePath]) + end +end +`; + +test('needsRaising floors anything below the minimum', () => { + expect(needsRaising(undefined)).toBe(true); + expect(needsRaising('')).toBe(true); + expect(needsRaising('15.1')).toBe(true); + expect(needsRaising('9.0')).toBe(true); +}); + +test('needsRaising leaves the minimum and anything above it alone', () => { + expect(needsRaising('16.4')).toBe(false); + expect(needsRaising('17.0')).toBe(false); + expect(needsRaising('26.0')).toBe(false); +}); + +test('needsRaising compares component-wise, not lexically or as a float', () => { + expect(needsRaising('16.10')).toBe(false); + expect(needsRaising('16.4.1')).toBe(false); + expect(needsRaising('16.3.9')).toBe(true); +}); + +test('needsRaising leaves an unparseable target alone', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + expect(needsRaising('17.0-beta')).toBe(false); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); +}); + +test('patchPodfile injects the floor into post_install', () => { + const patched = patchPodfile(PODFILE); + expect(patched).toMatch(/Gem::Version\.new\('16\.4'\)/); + expect(patched).toMatch(/react_native_post_install/); +}); + +test('patchPodfile leaves a Podfile that already pins a target alone', () => { + const pinned = PODFILE.replace( + 'react_native_post_install(installer, config[:reactNativePath])', + "config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '17.0'", + ); + expect(patchPodfile(pinned)).toBe(pinned); +}); + +test('patchPodfile warns instead of silently no-oping when post_install is missing', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const noPostInstall = "platform :ios, '15.1'\n"; + expect(patchPodfile(noPostInstall)).toBe(noPostInstall); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); +}); + +// The injected block is Ruby generated from a template string - nothing else in +// the toolchain parses it, so check it against a real interpreter when present. +const ruby = (() => { + try { + execFileSync('ruby', ['-e', '']); + return test; + } catch { + return test.skip; + } +})(); + +ruby('the injected Ruby is syntactically valid', () => { + execFileSync('ruby', ['-c', '-'], { input: patchPodfile(PODFILE) }); +}); + +ruby('the injected Ruby raises low targets and preserves high ones', () => { + const block = patchPodfile(PODFILE) + .split('installer.pods_project.targets.each do |target|')[1] + ?.split('\n end\n end')[0] + ?.split('target.build_configurations.each do |config|')[1]; + + const result = execFileSync( + 'ruby', + [ + '-e', + ` + require 'json' + settings = JSON.parse(ARGV[0]) + puts(settings.map { |s| + config = Struct.new(:build_settings).new(s) + ${block} + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] + }.to_json) + `, + JSON.stringify([ + { IPHONEOS_DEPLOYMENT_TARGET: '15.1' }, + { IPHONEOS_DEPLOYMENT_TARGET: '16.10' }, + { IPHONEOS_DEPLOYMENT_TARGET: '17.0.1' }, + { IPHONEOS_DEPLOYMENT_TARGET: 'garbage' }, + {}, + ]), + ], + { encoding: 'utf-8' }, + ); + + expect(JSON.parse(result)).toEqual([ + '16.4', // raised + '16.10', // preserved - float compare would have downgraded this + '17.0.1', // preserved - unquoted interpolation would have been a syntax error + '16.4', // unparseable, floored + '16.4', // absent, floored + ]); +});