From f34800a9fc0ae68063d01d284d258711c5dbd9ba Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:03:00 -0400 Subject: [PATCH 1/4] test(expo): check native module specs against Swift, Kotlin and the shipped binary surface The shipped surface in shipped-native-api.json comes from the latest release tags. Regenerate it with node packages/expo/scripts/generate-shipped-native-api.mjs after a release. Co-Authored-By: Claude Opus 5.5 --- .changeset/expo-native-module-contract.md | 2 + .../scripts/generate-shipped-native-api.mjs | 24 +++ .../specs/__tests__/nativeContract.test.ts | 16 ++ .../specs/__tests__/nativeModuleContract.ts | 180 ++++++++++++++++++ .../specs/__tests__/shipped-native-api.json | 74 +++++++ 5 files changed, 296 insertions(+) create mode 100644 .changeset/expo-native-module-contract.md create mode 100644 packages/expo/scripts/generate-shipped-native-api.mjs create mode 100644 packages/expo/src/specs/__tests__/nativeContract.test.ts create mode 100644 packages/expo/src/specs/__tests__/nativeModuleContract.ts create mode 100644 packages/expo/src/specs/__tests__/shipped-native-api.json diff --git a/.changeset/expo-native-module-contract.md b/.changeset/expo-native-module-contract.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/expo-native-module-contract.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/expo/scripts/generate-shipped-native-api.mjs b/packages/expo/scripts/generate-shipped-native-api.mjs new file mode 100644 index 00000000000..51890476efa --- /dev/null +++ b/packages/expo/scripts/generate-shipped-native-api.mjs @@ -0,0 +1,24 @@ +import { execFileSync } from 'node:child_process'; +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { NATIVE_MODULES, parseNativeFunctions } from '../src/specs/__tests__/nativeModuleContract.ts'; + +const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim(); +const root = git('rev-parse', '--show-toplevel'); +const shipped = {}; + +for (const { module, platform, release, native } of NATIVE_MODULES) { + const tag = git('tag', '--list', `${release}@*`, '--sort=-v:refname').split('\n')[0]; + if (!tag) { + throw new Error(`No ${release}@* release tag found. Run \`git fetch --tags\` and try again.`); + } + shipped[module] ??= {}; + shipped[module][platform] = Object.fromEntries(parseNativeFunctions(git('show', `${tag}:${native}`), native)); + console.log(`${module} ${platform}: ${tag}`); +} + +writeFileSync( + join(root, 'packages/expo/src/specs/__tests__/shipped-native-api.json'), + `${JSON.stringify(shipped, null, 2)}\n`, +); diff --git a/packages/expo/src/specs/__tests__/nativeContract.test.ts b/packages/expo/src/specs/__tests__/nativeContract.test.ts new file mode 100644 index 00000000000..faf14b33f54 --- /dev/null +++ b/packages/expo/src/specs/__tests__/nativeContract.test.ts @@ -0,0 +1,16 @@ +import { resolve } from 'node:path'; + +import { expect, test } from 'vitest'; + +import { NATIVE_MODULES, nativeModuleContractErrors } from './nativeModuleContract'; +import shippedNativeApi from './shipped-native-api.json'; + +const root = resolve(__dirname, '../../../../..'); + +test.each(NATIVE_MODULES)( + '$spec matches $native and the shipped $platform binary', + ({ module, platform, spec, native }) => { + const shipped = shippedNativeApi[module][platform]; + expect(nativeModuleContractErrors({ root, module, spec, native, shipped })).toEqual([]); + }, +); diff --git a/packages/expo/src/specs/__tests__/nativeModuleContract.ts b/packages/expo/src/specs/__tests__/nativeModuleContract.ts new file mode 100644 index 00000000000..344b92a0a36 --- /dev/null +++ b/packages/expo/src/specs/__tests__/nativeModuleContract.ts @@ -0,0 +1,180 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import ts from 'typescript'; + +const OPENERS: Record = { '(': ')', '<': '>', '[': ']', '{': '}' }; + +function splitTopLevel(text: string) { + const parts: string[] = []; + let depth = 0; + let current = ''; + for (const char of text) { + if (char in OPENERS) { + depth++; + } else if (Object.values(OPENERS).includes(char) && depth > 0) { + depth--; + } + if (char === ',' && depth === 0) { + parts.push(current); + current = ''; + } else { + current += char; + } + } + parts.push(current); + return parts.map(part => part.trim()).filter(Boolean); +} + +function balanced(text: string, start: number) { + let depth = 0; + for (let i = start; i < text.length; i++) { + if (text[i] === '(') { + depth++; + } else if (text[i] === ')') { + depth--; + if (depth === 0) { + return text.slice(start + 1, i); + } + } + } + throw new Error(`Unbalanced parentheses at ${start}`); +} + +function closureParams(source: string, braceIndex: number, language: 'swift' | 'kotlin') { + let i = braceIndex + 1; + while (/\s/.test(source[i])) { + i++; + } + let params: string[]; + if (language === 'swift') { + params = source[i] === '(' ? splitTopLevel(balanced(source, i)) : []; + } else { + const end = source.indexOf('->', i); + params = end === -1 ? [] : splitTopLevel(source.slice(i, end)); + } + return params.filter(param => !/:\s*Promise$/.test(param)); +} + +export const NATIVE_MODULES = [ + { + module: 'ClerkExpo', + platform: 'ios', + release: '@clerk/expo-native-components', + spec: 'packages/expo/src/specs/NativeClerkModule.ts', + native: 'packages/expo-native-components/ios/ClerkExpoModule.swift', + }, + { + module: 'ClerkExpo', + platform: 'android', + release: '@clerk/expo-native-components', + spec: 'packages/expo/src/specs/NativeClerkModule.android.ts', + native: 'packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt', + }, + { + module: 'ClerkGoogleSignIn', + platform: 'ios', + release: '@clerk/expo-google-signin', + spec: 'packages/expo/src/specs/NativeClerkGoogleSignIn.ts', + native: 'packages/expo-google-signin/ios/ClerkGoogleSignInModule.swift', + }, + { + module: 'ClerkGoogleSignIn', + platform: 'android', + release: '@clerk/expo-google-signin', + spec: 'packages/expo/src/specs/NativeClerkGoogleSignIn.android.ts', + native: + 'packages/expo-google-signin/android/src/main/java/expo/modules/clerk/googlesignin/ClerkGoogleSignInModule.kt', + }, + { + module: 'ClerkExpoBiometrics', + platform: 'ios', + release: '@clerk/expo-biometrics', + spec: 'packages/expo-biometrics/src/ClerkExpoBiometricsModule.ts', + native: 'packages/expo-biometrics/ios/ClerkExpoBiometricsModule.swift', + }, + { + module: 'ClerkExpoBiometrics', + platform: 'android', + release: '@clerk/expo-biometrics', + spec: 'packages/expo-biometrics/src/ClerkExpoBiometricsModule.ts', + native: 'packages/expo-biometrics/android/src/main/java/expo/modules/clerk/biometrics/ClerkExpoBiometricsModule.kt', + }, +] as const; + +export function parseNativeFunctions(source: string, file: string) { + const language = file.endsWith('.swift') ? 'swift' : 'kotlin'; + const functions = new Map(); + for (const match of source.matchAll(/\b(?:Async)?Function\("([^"]+)"\)\s*\{/g)) { + functions.set(match[1], closureParams(source, match.index + match[0].length - 1, language).length); + } + return functions; +} + +export function readSpecFunctions(file: string) { + const program = ts.createProgram([file], { noEmit: true, skipLibCheck: true, strict: true }); + const checker = program.getTypeChecker(); + const sourceFile = program.getSourceFile(file)!; + const moduleSymbol = checker.getSymbolAtLocation(sourceFile)!; + const defaultExport = checker.getExportsOfModule(moduleSymbol).find(symbol => symbol.name === 'default')!; + const type = checker.getNonNullableType(checker.getTypeOfSymbolAtLocation(defaultExport, sourceFile)); + const functions = new Map(); + for (const property of checker.getPropertiesOfType(type)) { + if (property.name === 'addListener' || property.name === 'removeListeners') { + continue; + } + const signatures = checker + .getNonNullableType(checker.getTypeOfSymbolAtLocation(property, sourceFile)) + .getCallSignatures(); + if (signatures.length > 0) { + functions.set(property.name, { + arity: Math.max(...signatures.map(signature => signature.parameters.length)), + optional: Boolean(property.flags & ts.SymbolFlags.Optional), + }); + } + } + return functions; +} + +type ContractInput = { + root: string; + module: string; + spec: string; + native: string; + shipped: Record; +}; + +export function nativeModuleContractErrors({ root, module, spec, native, shipped }: ContractInput) { + const specFunctions = readSpecFunctions(join(root, spec)); + const nativeFunctions = parseNativeFunctions(readFileSync(join(root, native), 'utf8'), native); + const errors: string[] = []; + for (const [name, { arity, optional }] of specFunctions) { + if (!nativeFunctions.has(name)) { + errors.push( + `${module}.${name} is declared in ${spec} but ${native} has no Function("${name}")/AsyncFunction("${name}"). Add it natively, or remove it from the spec and from every JS caller.`, + ); + } else if (nativeFunctions.get(name) !== arity) { + errors.push( + `${module}.${name} takes ${arity} argument(s) in ${spec} but ${nativeFunctions.get(name)} in ${native}. Change both sides together.`, + ); + } + if (name in shipped && shipped[name] !== arity) { + errors.push( + `${module}.${name} shipped with ${shipped[name]} argument(s) but ${spec} now passes ${arity}. Apps that get this JS over the air still run the old binary, so keep ${name}(${shipped[name]} args) as is and add a new optional function (as configureWithOptions did for configure).`, + ); + } + if (!(name in shipped) && !optional) { + errors.push( + `${module}.${name} is not in any released binary yet, so OTA JS can reach a binary without it. Declare it optional (${name}?(...)) in ${spec} and feature-detect before calling it. After the release that ships it, run 'node packages/expo/scripts/generate-shipped-native-api.mjs' to refresh shipped-native-api.json from the latest release tags.`, + ); + } + } + for (const name of nativeFunctions.keys()) { + if (!specFunctions.has(name)) { + errors.push( + `${module}.${name} is exposed by ${native} but missing from ${spec}. Declare it in the spec, or delete the native function.`, + ); + } + } + return errors; +} diff --git a/packages/expo/src/specs/__tests__/shipped-native-api.json b/packages/expo/src/specs/__tests__/shipped-native-api.json new file mode 100644 index 00000000000..1415a1cbe62 --- /dev/null +++ b/packages/expo/src/specs/__tests__/shipped-native-api.json @@ -0,0 +1,74 @@ +{ + "ClerkExpo": { + "ios": { + "configure": 2, + "configureWithOptions": 2, + "getClientToken": 0, + "getAuthFlowState": 0, + "syncClientStateFromJs": 4, + "getTrustedDeviceAvailability": 2, + "listTrustedDevices": 0, + "enrollTrustedDevice": 4, + "revokeTrustedDevice": 1, + "signInWithTrustedDevice": 3, + "reverifyWithBiometrics": 3 + }, + "android": { + "configure": 2, + "configureWithOptions": 2, + "getClientToken": 0, + "getAuthFlowState": 0, + "syncClientStateFromJs": 4, + "getTrustedDeviceAvailability": 2, + "listTrustedDevices": 0, + "enrollTrustedDevice": 4, + "revokeTrustedDevice": 1, + "signInWithTrustedDevice": 3, + "reverifyWithBiometrics": 3 + } + }, + "ClerkGoogleSignIn": { + "ios": { + "configure": 1, + "signIn": 1, + "createAccount": 1, + "presentExplicitSignIn": 1, + "signOut": 0 + }, + "android": { + "configure": 1, + "signIn": 1, + "createAccount": 1, + "presentExplicitSignIn": 1, + "signOut": 0 + } + }, + "ClerkExpoBiometrics": { + "ios": { + "getAppIdentifier": 0, + "hashIdentifierHint": 1, + "getAvailability": 0, + "createKey": 1, + "sign": 3, + "hasKey": 1, + "deleteKey": 1, + "listRecords": 0, + "saveRecord": 2, + "deleteRecord": 1, + "ensureInstallationMarker": 0 + }, + "android": { + "getAppIdentifier": 0, + "hashIdentifierHint": 1, + "getAvailability": 0, + "createKey": 1, + "sign": 3, + "hasKey": 1, + "deleteKey": 1, + "listRecords": 0, + "saveRecord": 2, + "deleteRecord": 1, + "ensureInstallationMarker": 0 + } + } +} From 65be0136f599ff77015cd213acdfdc62f06538c0 Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:17:29 -0400 Subject: [PATCH 2/4] docs(expo): point AGENTS.md at the packages that hold the native code Co-Authored-By: Claude Opus 5.5 --- packages/expo/AGENTS.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/expo/AGENTS.md b/packages/expo/AGENTS.md index 4e344934be7..76ef10db792 100644 --- a/packages/expo/AGENTS.md +++ b/packages/expo/AGENTS.md @@ -1,7 +1,8 @@ # @clerk/expo -These rules apply to everything in this package, including the Swift and Kotlin -code under `ios/`, `apple/` and `android/`. They add to the root `AGENTS.md`. +These rules apply to everything in this package, and to the Swift and Kotlin +code under `packages/expo-native-components`, `packages/expo-google-signin` and +`packages/expo-biometrics`. They add to the root `AGENTS.md`. ## Comments From 2e01895c968b81e37df9d247b0d648bc369a5e3a Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:44:15 -0400 Subject: [PATCH 3/4] test(expo): read the shipped native surface from the latest release tag The contract test now reads each native module from the latest @clerk/expo-native-components, @clerk/expo-google-signin and @clerk/expo-biometrics release tag with git, instead of a committed JSON snapshot that had to be regenerated after every release. If a tag is missing, the test fails and prints the git fetch command for it. The unit-test job checks out with depth 1 and no tags, so it now fetches those tags shallowly before the tests run. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 7 ++ .../scripts/generate-shipped-native-api.mjs | 24 ------ .../specs/__tests__/nativeContract.test.ts | 9 +-- .../specs/__tests__/nativeModuleContract.ts | 28 +++++-- .../specs/__tests__/shipped-native-api.json | 74 ------------------- 5 files changed, 34 insertions(+), 108 deletions(-) delete mode 100644 packages/expo/scripts/generate-shipped-native-api.mjs delete mode 100644 packages/expo/src/specs/__tests__/shipped-native-api.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6377781ef65..9919789d6c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -280,6 +280,13 @@ jobs: filter: 'blob:none' show-progress: false + - name: Fetch Expo native release tags + run: | + git fetch --depth=1 --no-tags origin \ + 'refs/tags/@clerk/expo-native-components@*:refs/tags/@clerk/expo-native-components@*' \ + 'refs/tags/@clerk/expo-google-signin@*:refs/tags/@clerk/expo-google-signin@*' \ + 'refs/tags/@clerk/expo-biometrics@*:refs/tags/@clerk/expo-biometrics@*' + - name: Setup id: config uses: $/.github/actions/init-blacksmith diff --git a/packages/expo/scripts/generate-shipped-native-api.mjs b/packages/expo/scripts/generate-shipped-native-api.mjs deleted file mode 100644 index 51890476efa..00000000000 --- a/packages/expo/scripts/generate-shipped-native-api.mjs +++ /dev/null @@ -1,24 +0,0 @@ -import { execFileSync } from 'node:child_process'; -import { writeFileSync } from 'node:fs'; -import { join } from 'node:path'; - -import { NATIVE_MODULES, parseNativeFunctions } from '../src/specs/__tests__/nativeModuleContract.ts'; - -const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim(); -const root = git('rev-parse', '--show-toplevel'); -const shipped = {}; - -for (const { module, platform, release, native } of NATIVE_MODULES) { - const tag = git('tag', '--list', `${release}@*`, '--sort=-v:refname').split('\n')[0]; - if (!tag) { - throw new Error(`No ${release}@* release tag found. Run \`git fetch --tags\` and try again.`); - } - shipped[module] ??= {}; - shipped[module][platform] = Object.fromEntries(parseNativeFunctions(git('show', `${tag}:${native}`), native)); - console.log(`${module} ${platform}: ${tag}`); -} - -writeFileSync( - join(root, 'packages/expo/src/specs/__tests__/shipped-native-api.json'), - `${JSON.stringify(shipped, null, 2)}\n`, -); diff --git a/packages/expo/src/specs/__tests__/nativeContract.test.ts b/packages/expo/src/specs/__tests__/nativeContract.test.ts index faf14b33f54..38ec42559db 100644 --- a/packages/expo/src/specs/__tests__/nativeContract.test.ts +++ b/packages/expo/src/specs/__tests__/nativeContract.test.ts @@ -2,15 +2,14 @@ import { resolve } from 'node:path'; import { expect, test } from 'vitest'; -import { NATIVE_MODULES, nativeModuleContractErrors } from './nativeModuleContract'; -import shippedNativeApi from './shipped-native-api.json'; +import { NATIVE_MODULES, nativeModuleContractErrors, shippedNativeFunctions } from './nativeModuleContract'; const root = resolve(__dirname, '../../../../..'); test.each(NATIVE_MODULES)( - '$spec matches $native and the shipped $platform binary', - ({ module, platform, spec, native }) => { - const shipped = shippedNativeApi[module][platform]; + '$spec matches $native and the latest $release release', + ({ module, release, spec, native }) => { + const shipped = shippedNativeFunctions(root, release, native); expect(nativeModuleContractErrors({ root, module, spec, native, shipped })).toEqual([]); }, ); diff --git a/packages/expo/src/specs/__tests__/nativeModuleContract.ts b/packages/expo/src/specs/__tests__/nativeModuleContract.ts index 344b92a0a36..b65b9f5516c 100644 --- a/packages/expo/src/specs/__tests__/nativeModuleContract.ts +++ b/packages/expo/src/specs/__tests__/nativeModuleContract.ts @@ -1,3 +1,4 @@ +import { execFileSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; @@ -136,12 +137,28 @@ export function readSpecFunctions(file: string) { return functions; } +export function shippedNativeFunctions(root: string, release: string, native: string) { + const tag = execFileSync('git', ['tag', '--list', `${release}@*`, '--sort=-v:refname'], { + cwd: root, + encoding: 'utf8', + }) + .split('\n') + .find(line => /@\d+\.\d+\.\d+$/.test(line)); + if (!tag) { + throw new Error( + `No ${release}@ release tag in this checkout. The native contract test reads the shipped native module from it. Fetch it with: git fetch --depth=1 --no-tags origin 'refs/tags/${release}@*:refs/tags/${release}@*'`, + ); + } + const source = execFileSync('git', ['show', `${tag}:${native}`], { cwd: root, encoding: 'utf8' }); + return parseNativeFunctions(source, native); +} + type ContractInput = { root: string; module: string; spec: string; native: string; - shipped: Record; + shipped: Map; }; export function nativeModuleContractErrors({ root, module, spec, native, shipped }: ContractInput) { @@ -158,14 +175,15 @@ export function nativeModuleContractErrors({ root, module, spec, native, shipped `${module}.${name} takes ${arity} argument(s) in ${spec} but ${nativeFunctions.get(name)} in ${native}. Change both sides together.`, ); } - if (name in shipped && shipped[name] !== arity) { + const shippedArity = shipped.get(name); + if (shippedArity !== undefined && shippedArity !== arity) { errors.push( - `${module}.${name} shipped with ${shipped[name]} argument(s) but ${spec} now passes ${arity}. Apps that get this JS over the air still run the old binary, so keep ${name}(${shipped[name]} args) as is and add a new optional function (as configureWithOptions did for configure).`, + `${module}.${name} shipped with ${shippedArity} argument(s) but ${spec} now passes ${arity}. Apps that get this JS over the air still run the old binary, so keep ${name}(${shippedArity} args) as is and add a new optional function (as configureWithOptions did for configure).`, ); } - if (!(name in shipped) && !optional) { + if (shippedArity === undefined && !optional) { errors.push( - `${module}.${name} is not in any released binary yet, so OTA JS can reach a binary without it. Declare it optional (${name}?(...)) in ${spec} and feature-detect before calling it. After the release that ships it, run 'node packages/expo/scripts/generate-shipped-native-api.mjs' to refresh shipped-native-api.json from the latest release tags.`, + `${module}.${name} is not in any released binary yet, so OTA JS can reach a binary without it. Declare it optional (${name}?(...)) in ${spec} and feature-detect before calling it.`, ); } } diff --git a/packages/expo/src/specs/__tests__/shipped-native-api.json b/packages/expo/src/specs/__tests__/shipped-native-api.json deleted file mode 100644 index 1415a1cbe62..00000000000 --- a/packages/expo/src/specs/__tests__/shipped-native-api.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "ClerkExpo": { - "ios": { - "configure": 2, - "configureWithOptions": 2, - "getClientToken": 0, - "getAuthFlowState": 0, - "syncClientStateFromJs": 4, - "getTrustedDeviceAvailability": 2, - "listTrustedDevices": 0, - "enrollTrustedDevice": 4, - "revokeTrustedDevice": 1, - "signInWithTrustedDevice": 3, - "reverifyWithBiometrics": 3 - }, - "android": { - "configure": 2, - "configureWithOptions": 2, - "getClientToken": 0, - "getAuthFlowState": 0, - "syncClientStateFromJs": 4, - "getTrustedDeviceAvailability": 2, - "listTrustedDevices": 0, - "enrollTrustedDevice": 4, - "revokeTrustedDevice": 1, - "signInWithTrustedDevice": 3, - "reverifyWithBiometrics": 3 - } - }, - "ClerkGoogleSignIn": { - "ios": { - "configure": 1, - "signIn": 1, - "createAccount": 1, - "presentExplicitSignIn": 1, - "signOut": 0 - }, - "android": { - "configure": 1, - "signIn": 1, - "createAccount": 1, - "presentExplicitSignIn": 1, - "signOut": 0 - } - }, - "ClerkExpoBiometrics": { - "ios": { - "getAppIdentifier": 0, - "hashIdentifierHint": 1, - "getAvailability": 0, - "createKey": 1, - "sign": 3, - "hasKey": 1, - "deleteKey": 1, - "listRecords": 0, - "saveRecord": 2, - "deleteRecord": 1, - "ensureInstallationMarker": 0 - }, - "android": { - "getAppIdentifier": 0, - "hashIdentifierHint": 1, - "getAvailability": 0, - "createKey": 1, - "sign": 3, - "hasKey": 1, - "deleteKey": 1, - "listRecords": 0, - "saveRecord": 2, - "deleteRecord": 1, - "ensureInstallationMarker": 0 - } - } -} From 9356b1e1601adbabf7b1a3f60ca3c1a537817f81 Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:59:50 -0400 Subject: [PATCH 4/4] test(expo): check the native contract against every stable release Co-Authored-By: Claude Opus 5.5 --- .../specs/__tests__/nativeContract.test.ts | 11 ++--- .../specs/__tests__/nativeModuleContract.ts | 42 ++++++++++++------- 2 files changed, 30 insertions(+), 23 deletions(-) diff --git a/packages/expo/src/specs/__tests__/nativeContract.test.ts b/packages/expo/src/specs/__tests__/nativeContract.test.ts index 38ec42559db..bf84648d8b9 100644 --- a/packages/expo/src/specs/__tests__/nativeContract.test.ts +++ b/packages/expo/src/specs/__tests__/nativeContract.test.ts @@ -6,10 +6,7 @@ import { NATIVE_MODULES, nativeModuleContractErrors, shippedNativeFunctions } fr const root = resolve(__dirname, '../../../../..'); -test.each(NATIVE_MODULES)( - '$spec matches $native and the latest $release release', - ({ module, release, spec, native }) => { - const shipped = shippedNativeFunctions(root, release, native); - expect(nativeModuleContractErrors({ root, module, spec, native, shipped })).toEqual([]); - }, -); +test.each(NATIVE_MODULES)('$spec matches $native and every $release release', ({ module, release, spec, native }) => { + const releases = shippedNativeFunctions(root, release, native); + expect(nativeModuleContractErrors({ root, module, spec, native, releases })).toEqual([]); +}); diff --git a/packages/expo/src/specs/__tests__/nativeModuleContract.ts b/packages/expo/src/specs/__tests__/nativeModuleContract.ts index b65b9f5516c..8663afdb723 100644 --- a/packages/expo/src/specs/__tests__/nativeModuleContract.ts +++ b/packages/expo/src/specs/__tests__/nativeModuleContract.ts @@ -137,20 +137,27 @@ export function readSpecFunctions(file: string) { return functions; } -export function shippedNativeFunctions(root: string, release: string, native: string) { - const tag = execFileSync('git', ['tag', '--list', `${release}@*`, '--sort=-v:refname'], { +type Release = { tag: string; functions: Map }; + +export function shippedNativeFunctions(root: string, release: string, native: string): Release[] { + const tags = execFileSync('git', ['tag', '--list', `${release}@*`, '--sort=v:refname'], { cwd: root, encoding: 'utf8', }) .split('\n') - .find(line => /@\d+\.\d+\.\d+$/.test(line)); - if (!tag) { + .filter(line => /@\d+\.\d+\.\d+$/.test(line)); + if (tags.length === 0) { throw new Error( - `No ${release}@ release tag in this checkout. The native contract test reads the shipped native module from it. Fetch it with: git fetch --depth=1 --no-tags origin 'refs/tags/${release}@*:refs/tags/${release}@*'`, + `No ${release}@ release tag in this checkout. The native contract test reads the shipped native module from every release. Fetch them with: git fetch --depth=1 --no-tags origin 'refs/tags/${release}@*:refs/tags/${release}@*'`, ); } - const source = execFileSync('git', ['show', `${tag}:${native}`], { cwd: root, encoding: 'utf8' }); - return parseNativeFunctions(source, native); + return tags.map(tag => ({ + tag, + functions: parseNativeFunctions( + execFileSync('git', ['show', `${tag}:${native}`], { cwd: root, encoding: 'utf8' }), + native, + ), + })); } type ContractInput = { @@ -158,10 +165,10 @@ type ContractInput = { module: string; spec: string; native: string; - shipped: Map; + releases: Release[]; }; -export function nativeModuleContractErrors({ root, module, spec, native, shipped }: ContractInput) { +export function nativeModuleContractErrors({ root, module, spec, native, releases }: ContractInput) { const specFunctions = readSpecFunctions(join(root, spec)); const nativeFunctions = parseNativeFunctions(readFileSync(join(root, native), 'utf8'), native); const errors: string[] = []; @@ -175,16 +182,19 @@ export function nativeModuleContractErrors({ root, module, spec, native, shipped `${module}.${name} takes ${arity} argument(s) in ${spec} but ${nativeFunctions.get(name)} in ${native}. Change both sides together.`, ); } - const shippedArity = shipped.get(name); - if (shippedArity !== undefined && shippedArity !== arity) { + const missingFrom = releases.filter(release => !release.functions.has(name)).map(release => release.tag); + if (missingFrom.length > 0 && !optional) { errors.push( - `${module}.${name} shipped with ${shippedArity} argument(s) but ${spec} now passes ${arity}. Apps that get this JS over the air still run the old binary, so keep ${name}(${shippedArity} args) as is and add a new optional function (as configureWithOptions did for configure).`, + `${module}.${name} is missing from ${missingFrom.join(', ')}. Apps built on those releases do not have it, and OTA JS can still reach them. Declare it optional (${name}?(...)) in ${spec} and feature-detect before calling it.`, ); } - if (shippedArity === undefined && !optional) { - errors.push( - `${module}.${name} is not in any released binary yet, so OTA JS can reach a binary without it. Declare it optional (${name}?(...)) in ${spec} and feature-detect before calling it.`, - ); + for (const { tag, functions } of releases) { + const shippedArity = functions.get(name); + if (shippedArity !== undefined && shippedArity !== arity) { + errors.push( + `${module}.${name} takes ${shippedArity} argument(s) in ${tag} but ${spec} now passes ${arity}. Apps built on that release still run that binary, so keep ${name}(${shippedArity} args) as is and add a new optional function that you feature-detect (as configureWithOptions did for configure).`, + ); + } } } for (const name of nativeFunctions.keys()) {