From 7cda7374ffc4494b389b32fb3a68a88c647f2019 Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:31:54 -0400 Subject: [PATCH 1/4] test(expo): build the verify fixture as a standalone app and reuse a stored native build Co-Authored-By: Claude Opus 5.5 --- integration/expo-native/README.md | 8 +- .../expo-native/bin/boot-ios-simulators.sh | 25 + integration/expo-native/src/fixture.ts | 170 ++++++- integration/expo-native/src/host.ts | 46 +- integration/expo-native/src/native-build.ts | 429 ++++++++++++++++++ .../expo-native/test/native-build.test.ts | 337 ++++++++++++++ 6 files changed, 990 insertions(+), 25 deletions(-) create mode 100755 integration/expo-native/bin/boot-ios-simulators.sh create mode 100644 integration/expo-native/src/native-build.ts create mode 100644 integration/expo-native/test/native-build.test.ts diff --git a/integration/expo-native/README.md b/integration/expo-native/README.md index f8dd66d3aca..bf701f93695 100644 --- a/integration/expo-native/README.md +++ b/integration/expo-native/README.md @@ -13,17 +13,19 @@ You need Node 24.8 or newer on 24, Xcode and a booted iOS simulator, or the Andr ```sh cd integration/expo-native npm ci +node src/fixture.ts ios export CLERK_E2E_PLATFORM=ios xcrun simctl list devices booted export CLERK_E2E_DEVICE= -export CLERK_E2E_APP_PATH= -export CLERK_E2E_DEV_SERVER=http://localhost:8081 +export CLERK_E2E_APP_PATH= export CLERK_PUBLISHABLE_KEY=pk_test_... export CLERK_SECRET_KEY=sk_test_... npx e2e run specs/golden/custom-flow-sign-in/complete.e2e.ts ``` -The test app is built from `integration/templates/expo-native`. `integration/expo-native/bin/control-clerk-expo up --platform ios` builds it as a dev client, leaves it under `.verify/builds/`, and starts the Metro server that the dev client loads its bundle from. The `metro` line that `up` prints has the port. `CLERK_E2E_APP_PATH` is the `.app` or `.apk` in that directory, and `CLERK_E2E_DEV_SERVER` is `http://localhost:`. After `up` ends, no CLI process is running, and Metro stays up until `down`. +`node src/fixture.ts ios` runs `pnpm install`, builds the packages the test app uses, and builds the test app in `integration/templates/expo-native` as a standalone app. A standalone app carries its JavaScript, so the run needs no Metro server. `node src/fixture.ts android` builds the APK for an emulator. + +To run against a dev client instead, `integration/expo-native/bin/control-clerk-expo up --platform ios` builds one, leaves it under `.verify/builds/`, and starts the Metro server it loads its bundle from. The `metro` line that `up` prints has the port. Set `CLERK_E2E_APP_PATH` to the `.app` or `.apk` in that directory and `CLERK_E2E_DEV_SERVER` to `http://localhost:`. After `up` ends, no CLI process is running, and Metro stays up until `down`. `--video on`, `--retries`, `--grep`, and `--output` are e2e's own flags. e2e writes its report and its screenshots to `.e2e/`, which git ignores. diff --git a/integration/expo-native/bin/boot-ios-simulators.sh b/integration/expo-native/bin/boot-ios-simulators.sh new file mode 100755 index 00000000000..3a3aad13b0b --- /dev/null +++ b/integration/expo-native/bin/boot-ios-simulators.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Usage: ./boot-ios-simulators.sh wait +set -euo pipefail + +wait_ready() { + local udid key + IFS=, read -r -a udids <<< "${CLERK_TEST_DEVICES:?CLERK_TEST_DEVICES is required}" + for udid in "${udids[@]}"; do + xcrun simctl spawn "$udid" defaults write com.apple.UIKit UIAnimationDragCoefficient -float 0.01 || true + xcrun simctl spawn "$udid" defaults write -g ApplePersistenceIgnoreState -bool YES || true + xcrun simctl spawn "$udid" defaults write com.apple.keyboard.ContinuousPath -bool NO || true + xcrun simctl spawn "$udid" defaults write com.apple.keyboard.AutoCapitalization -bool NO || true + xcrun simctl spawn "$udid" defaults write com.apple.keyboard.AutoCorrection -bool NO || true + xcrun simctl spawn "$udid" defaults write com.apple.keyboard.Prediction -bool NO || true + # the keyboard tutorial sheets have their own Continue button that steals taps + for key in DidShowContinuousPathIntroduction DidShowGestureKeyboardIntroduction KeyboardDidShowProductivityTutorial UIKeyboardDidShowInternationalInfoIntroduction; do + xcrun simctl spawn "$udid" defaults write com.apple.keyboard.preferences "$key" -bool YES || true + done + done +} + +case "${1:-}" in + wait) wait_ready ;; + *) echo "usage: $0 wait" >&2; exit 2 ;; +esac diff --git a/integration/expo-native/src/fixture.ts b/integration/expo-native/src/fixture.ts index 91bcf309112..a280005cb33 100644 --- a/integration/expo-native/src/fixture.ts +++ b/integration/expo-native/src/fixture.ts @@ -1,15 +1,27 @@ import { spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; import { cpSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { basename, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { run } from './core/exec.ts'; import { VerifyFailure, type Platform } from './core/types.ts'; +import { + embedJavaScript, + expoFingerprint, + installedNativeInputs, + keepNativeBuild, + keptNativeBuild, + nativeFingerprint, + type ExpoFingerprint, + type KeptNativeBuild, +} from './native-build.ts'; import { resolveJavaHome, sdkRoot } from './platform/android/sdk.ts'; export const IOS_PRODUCT = 'ClerkExpoNativeBuildFixture'; export const WORKTREE = fileURLToPath(new URL('../../../', import.meta.url)); export const FIXTURE = join(WORKTREE, 'integration', 'templates', 'expo-native'); -export type BuildProduct = 'dev-client'; +export type BuildProduct = 'dev-client' | 'standalone'; interface Recipe { readonly configuration: 'Debug' | 'Release'; @@ -29,6 +41,13 @@ const RECIPES: Readonly> = { expoPackages: [...EXPO_PACKAGES, 'expo-dev-client'], label: 'dev client', }, + standalone: { + configuration: 'Release', + gradle: [':app:createBundleReleaseJsAndAssets', '--rerun', 'assembleRelease'], + apk: join('release', 'app-release.apk'), + expoPackages: EXPO_PACKAGES, + label: 'JS embedded', + }, }; const SHARED_NATIVE_INPUTS = [ @@ -54,10 +73,30 @@ const PLATFORM_NATIVE_INPUTS: Readonly> = { android: ['packages/expo/android', 'packages/expo-google-signin/android', 'packages/expo-biometrics/android'], }; +const BUNDLE_INPUTS = [ + '.npmrc', + 'package.json', + 'pnpm-lock.yaml', + 'pnpm-workspace.yaml', + 'tsconfig.json', + 'turbo.json', + 'packages/clerk-js', + 'packages/expo', + 'packages/expo-biometrics', + 'packages/expo-google-signin', + 'packages/react', + 'packages/shared', + 'integration/templates/expo-native', +] as const; + export function nativeInputs(platform: Platform): readonly string[] { return [...PLATFORM_NATIVE_INPUTS[platform], ...SHARED_NATIVE_INPUTS]; } +export function buildInputs(platform: Platform, product: BuildProduct): readonly string[] { + return product === 'standalone' ? [...nativeInputs(platform), ...BUNDLE_INPUTS] : nativeInputs(platform); +} + export function artifact(platform: Platform, product: BuildProduct, fixture: string = FIXTURE): string { const recipe = RECIPES[product]; return platform === 'ios' @@ -121,6 +160,7 @@ export interface FixtureBuild { readonly product: BuildProduct; readonly nativeKey: string; readonly buildPackages: boolean; + readonly nativeCache?: string | null; readonly progress: (line: string) => void; } @@ -128,10 +168,23 @@ export interface FixtureSite { readonly worktree: string; readonly fixture: string; readonly must: typeof mustStep; + readonly toolchain?: (platform: Platform) => Promise; + readonly expoFingerprint?: ExpoFingerprint; +} + +async function installedToolchain(platform: Platform): Promise { + if (platform === 'android') return 'gradle'; + const xcode = await run('xcodebuild', ['-version']); + return xcode.code === 0 ? xcode.stdout.trim() : 'no xcodebuild'; } const THIS_CHECKOUT: FixtureSite = { worktree: WORKTREE, fixture: FIXTURE, must: mustStep }; +export function nativeCacheDir(env: NodeJS.ProcessEnv = process.env, worktree: string = WORKTREE): string | null { + const asked = env.VERIFY_NATIVE_CACHE; + return asked === undefined || asked === '' ? null : resolve(worktree, asked); +} + const nativeProjectMarker = (site: FixtureSite, platform: Platform) => join(site.fixture, platform, '.verify-native-project'); @@ -142,9 +195,17 @@ export function nativeProjectIsCurrent(site: FixtureSite, platform: Platform, wa ); } -async function generateNativeProject(site: FixtureSite, build: FixtureBuild, wanted: string): Promise { - const { platform, progress } = build; - rmSync(nativeProjectMarker(site, platform), { force: true }); +const dependenciesMarker = (site: FixtureSite) => join(site.fixture, 'node_modules', '.verify-dependencies'); + +function dependenciesOf(site: FixtureSite, product: BuildProduct): string { + const template = createHash('sha256') + .update(readFileSync(join(site.fixture, 'package.sdk-57.json'))) + .digest('hex'); + return `${product} ${template}`; +} + +async function installDependencies(site: FixtureSite, product: BuildProduct): Promise { + rmSync(dependenciesMarker(site), { force: true }); cpSync(join(site.fixture, 'package.sdk-57.json'), join(site.fixture, 'package.json')); await site.must( 'pnpm add the workspace packages', @@ -157,7 +218,50 @@ async function generateNativeProject(site: FixtureSite, build: FixtureBuild, wan ], site.fixture, ); - await site.must('expo install', 'pnpm', ['expo', 'install', ...RECIPES[build.product].expoPackages], site.fixture); + await site.must('expo install', 'pnpm', ['expo', 'install', ...RECIPES[product].expoPackages], site.fixture); + writeFileSync(dependenciesMarker(site), dependenciesOf(site, product)); +} + +async function ensureDependencies(site: FixtureSite, product: BuildProduct): Promise { + const marker = dependenciesMarker(site); + if (existsSync(marker) && readFileSync(marker, 'utf8') === dependenciesOf(site, product)) return; + await installDependencies(site, product); +} + +export async function locateNativeBuild( + cache: string, + platform: Platform, + site: FixtureSite = THIS_CHECKOUT, + note: (line: string) => void = () => undefined, +): Promise { + await ensureDependencies(site, 'standalone'); + const { installed, wholeLockfile } = await installedNativeInputs( + site.fixture, + platform, + site.expoFingerprint ?? expoFingerprint, + ); + if (wholeLockfile !== null) + note(`build native fingerprint covers the whole pnpm-lock.yaml, because ${wholeLockfile}`); + const fingerprint = await nativeFingerprint({ + platform, + worktree: site.worktree, + inputs: nativeInputs(platform), + files: [fileURLToPath(import.meta.url), fileURLToPath(new URL('./native-build.ts', import.meta.url))], + toolchain: await (site.toolchain ?? installedToolchain)(platform), + installed, + }); + return keptNativeBuild(cache, platform, fingerprint, basename(artifact(platform, 'standalone', site.fixture))); +} + +async function generateNativeProject( + site: FixtureSite, + build: FixtureBuild, + wanted: string, + dependencies: 'install' | 'installed', +): Promise { + const { platform, progress } = build; + rmSync(nativeProjectMarker(site, platform), { force: true }); + if (dependencies === 'install') await installDependencies(site, build.product); progress(`build expo prebuild --clean --platform ${platform}`); await site.must('expo prebuild', 'pnpm', ['expo', 'prebuild', '--clean', '--platform', platform], site.fixture); writeFileSync(nativeProjectMarker(site, platform), wanted); @@ -184,11 +288,33 @@ export async function buildFixture(build: FixtureBuild, site: FixtureSite = THIS site.worktree, ); } + const cache = product === 'standalone' ? (build.nativeCache ?? null) : null; + const kept = cache === null ? null : await locateNativeBuild(cache, platform, site, progress); + if (kept !== null && existsSync(kept.app)) { + const into = artifact(platform, product, site.fixture); + progress(`build native ${kept.id} an app built from these native inputs is kept, so no native build runs`); + const java = platform === 'android' ? resolveJavaHome() : null; + if (java !== null && !java.ok) throw new VerifyFailure('NOT_READY', java.detail, java.fix); + const embedded = await embedJavaScript({ + platform, + fixture: site.fixture, + from: kept.app, + into, + iosExecutable: IOS_PRODUCT, + android: java === null ? null : { sdk: sdkRoot(), javaHome: java.home }, + must: site.must, + }); + if (embedded.ok) { + progress(`build bundle ${embedded.bundle} exported from this checkout and embedded in that app`); + return into; + } + progress(`build native ${kept.id} not reused: ${embedded.why}`); + } const wanted = `${product} ${build.nativeKey}`; if (nativeProjectIsCurrent(site, platform, wanted)) { progress(`build the ${platform} project was generated from these native inputs, so expo prebuild is skipped`); } else { - await generateNativeProject(site, build, wanted); + await generateNativeProject(site, build, wanted, kept === null ? 'install' : 'installed'); } if (platform === 'ios') { progress(`build xcodebuild ${recipe.configuration} (${recipe.label})`); @@ -227,5 +353,33 @@ export async function buildFixture(build: FixtureBuild, site: FixtureSite = THIS }, ); } - return artifact(platform, product, site.fixture); + const built = artifact(platform, product, site.fixture); + if (kept !== null) { + keepNativeBuild(kept, built); + progress(`build native ${kept.id} kept in ${relative(site.worktree, kept.dir)} for later builds`); + } + return built; +} + +async function printKeptNativeBuild(platform: Platform): Promise { + const cache = nativeCacheDir(); + if (cache === null) throw new VerifyFailure('USAGE', 'VERIFY_NATIVE_CACHE is not set', 'set it to a directory'); + const kept = await locateNativeBuild(cache, platform, THIS_CHECKOUT, line => console.error(line)); + console.log(`id=${kept.id}`); + console.log(`dir=${relative(WORKTREE, kept.dir)}`); + console.log(`app=${basename(kept.app)}`); +} + +if (import.meta.main) { + const [verb, platform] = process.argv.slice(2); + if (verb !== 'native' || (platform !== 'ios' && platform !== 'android')) { + console.error('usage: fixture.ts native ios|android'); + process.exit(2); + } + try { + await printKeptNativeBuild(platform); + } catch (error) { + console.error((error as Error).message); + process.exit(1); + } } diff --git a/integration/expo-native/src/host.ts b/integration/expo-native/src/host.ts index 4a8bb643425..270bf4f2bac 100644 --- a/integration/expo-native/src/host.ts +++ b/integration/expo-native/src/host.ts @@ -11,19 +11,29 @@ import { writeFileSync, } from 'node:fs'; import { createRequire } from 'node:module'; -import { join, relative } from 'node:path'; +import { basename, join, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; import { isRunning, run, sleep } from './core/exec.ts'; import { LOCAL_POOL, VerifyFailure, + type BackendKind, type HostAdapter, type Platform, type RuntimeProcess, type ScratchPath, } from './core/types.ts'; import { takeSlotLock } from './core/workspace.ts'; -import { FIXTURE, IOS_PRODUCT, WORKTREE, buildFixture, mustStep, nativeInputs } from './fixture.ts'; +import { + FIXTURE, + IOS_PRODUCT, + WORKTREE, + buildFixture, + buildInputs, + mustStep, + nativeCacheDir, + type BuildProduct, +} from './fixture.ts'; import { APP_ID, app } from '../specs/app.ts'; import { confirmServed, @@ -443,9 +453,20 @@ async function ensureServed( return current; } -function keepBuild(platform: Platform, built: string, into: string): string { +export function productFor(backend: BackendKind, env: NodeJS.ProcessEnv = process.env): BuildProduct { + const asked = env.VERIFY_LOCAL_BUILD; + if (asked === undefined || asked === '' || asked === 'dev-client') return 'dev-client'; + if (asked === 'standalone') return 'standalone'; + throw new VerifyFailure( + 'USAGE', + `VERIFY_LOCAL_BUILD=${asked} is not dev-client or standalone`, + 'unset VERIFY_LOCAL_BUILD or set it to standalone', + ); +} + +function keepBuild(built: string, into: string): string { mkdirSync(into, { recursive: true }); - const path = join(into, platform === 'ios' ? `${IOS_PRODUCT}.app` : 'app-debug.apk'); + const path = join(into, basename(built)); rmSync(path, { recursive: true, force: true }); cpSync(built, path, { recursive: true, verbatimSymlinks: true }); return path; @@ -457,31 +478,28 @@ export const host: HostAdapter = { platforms: ['ios', 'android'], githubRepo: GITHUB_REPO, appId: app.id, - buildInputs: platform => nativeInputs(platform), + buildInputs: (platform, backend) => buildInputs(platform, productFor(backend)), async build(platform, key, into, progress) { - if (process.platform !== 'darwin') - throw new VerifyFailure( - 'UNSUPPORTED', - `the expo-native fixture is built locally on macOS only, and this machine runs ${process.platform}`, - 'run this command on a Mac', - ); + const product = productFor('local'); const path = await withFixtureLock(progress, async () => { - const watching = readRuntime('watch') !== null; + const watching = product === 'dev-client' && readRuntime('watch') !== null; if (watching) progress('build the running watch build keeps packages/expo/dist current, so turbo build is skipped'); else stopRuntime(); const built = await buildFixture({ platform, - product: 'dev-client', + product, nativeKey: key, buildPackages: !watching, + nativeCache: nativeCacheDir(), progress, }); - return keepBuild(platform, built, into); + return keepBuild(built, into); }); return { platform, key, appId: APP_ID, path: path as ScratchPath, source: 'local' }; }, async runtime(lease, progress) { + if (productFor(lease.backend) === 'standalone') return { devServer: null, processes: [] }; const port = metroPort(lease); return withCleanup( stopRuntime, diff --git a/integration/expo-native/src/native-build.ts b/integration/expo-native/src/native-build.ts new file mode 100644 index 00000000000..57707874f5e --- /dev/null +++ b/integration/expo-native/src/native-build.ts @@ -0,0 +1,429 @@ +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + chmodSync, + closeSync, + copyFileSync, + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + openSync, + readdirSync, + readFileSync, + readSync, + renameSync, + rmSync, +} from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { basename, delimiter, dirname, join } from 'node:path'; +import { run } from './core/exec.ts'; +import { VerifyFailure, type Platform } from './core/types.ts'; + +export type Must = ( + what: string, + command: string, + args: readonly string[], + cwd: string, + env?: Readonly>, +) => Promise; + +export interface FingerprintInputs { + readonly platform: Platform; + readonly worktree: string; + readonly inputs: readonly string[]; + readonly files: readonly string[]; + readonly toolchain: string; + readonly installed: string; +} + +export async function nativeFingerprint(spec: FingerprintInputs): Promise { + const listed = await run( + 'git', + ['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', ...spec.inputs], + { + cwd: spec.worktree, + }, + ); + if (listed.code !== 0) + throw new VerifyFailure( + 'NOT_READY', + `git ls-files failed: ${listed.stderr.trim()}`, + 'run {cli} from inside a git worktree', + ); + const hash = createHash('sha256'); + hash.update(spec.platform).update('\0').update(spec.toolchain).update('\0').update(spec.installed).update('\0'); + const tracked = [...new Set(listed.stdout.split('\0').filter(file => file.length > 0))].sort(); + for (const file of tracked) { + const path = join(spec.worktree, file); + hash.update(file).update('\0'); + hash.update(existsSync(path) ? readFileSync(path) : 'deleted').update('\0'); + } + for (const file of spec.files) { + hash.update(basename(file)).update('\0'); + hash.update(existsSync(file) ? readFileSync(file) : 'missing').update('\0'); + } + return hash.digest('hex').slice(0, 16); +} + +export type ExpoFingerprint = (fixture: string, platform: Platform) => Promise; + +const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; + +export const expoFingerprint: ExpoFingerprint = async (fixture, platform) => { + const fromFixture = createRequire(join(fixture, 'package.json')); + const fromExpo = createRequire(fromFixture.resolve('expo/package.json')); + const tool: unknown = fromExpo('@expo/fingerprint'); + if (!isRecord(tool) || typeof tool.createFingerprintAsync !== 'function') + throw new Error('it has no createFingerprintAsync'); + return tool.createFingerprintAsync(fixture, { + platforms: [platform], + ignorePaths: ['ios/**/*', 'android/**/*'], + silent: true, + }); +}; + +type NativeSources = { readonly ok: true; readonly sources: string } | { readonly ok: false; readonly why: string }; + +const PNPM_PEERS = /(node_modules\/\.pnpm\/(?:@[^/+@]+\+)?[^/@]+@[^/_]+)_[^/]*(?=\/)/g; + +function nativeSources(result: unknown, platform: Platform): NativeSources { + if (!isRecord(result) || !Array.isArray(result.sources)) return { ok: false, why: 'it returned no sources' }; + const lines: string[] = []; + const named = new Set(); + for (const source of result.sources) { + if (!isRecord(source)) return { ok: false, why: 'it returned a source that is not an object' }; + const { type, id, filePath, hash, contents } = source; + if (type === 'contents' && typeof id === 'string' && (typeof contents === 'string' || Buffer.isBuffer(contents))) { + named.add(id); + lines.push(`contents ${id} ${contents.toString()}`); + } else if ( + (type === 'file' || type === 'dir') && + typeof filePath === 'string' && + (typeof hash === 'string' || hash === null) + ) { + if (hash !== null) lines.push(`${type} ${filePath} ${hash}`); + } else { + return { ok: false, why: `it returned a ${String(type)} source this CLI cannot read` }; + } + } + for (const id of ['expoConfig', `expoAutolinkingConfig:${platform}`]) + if (!named.has(id)) return { ok: false, why: `it left out ${id}` }; + return { + ok: true, + sources: lines + .map(line => line.replace(PNPM_PEERS, '$1')) + .sort() + .join('\n'), + }; +} + +const LOCKED_PACKAGE = /^ {2}'?((?:@[^/@\s]+\/)?[^@\s'/]+)@([^:'(\s]+)/gm; +const BUILDS_THE_NATIVE_PROJECT = /^(?:@expo\/|@react-native[^/]*\/|expo(?:-|$)|react-native(?:-|$)|hermes-)/; + +function nativeToolVersions(lockfile: string): readonly string[] { + const versions = new Set(); + for (const [, name = '', version = ''] of lockfile.matchAll(LOCKED_PACKAGE)) + if (BUILDS_THE_NATIVE_PROJECT.test(name)) versions.add(`${name}@${version}`); + return [...versions].sort(); +} + +export interface InstalledNativeInputs { + readonly installed: string; + readonly wholeLockfile: string | null; +} + +export async function installedNativeInputs( + fixture: string, + platform: Platform, + expo: ExpoFingerprint, +): Promise { + const file = join(fixture, 'pnpm-lock.yaml'); + if (!existsSync(file)) + throw new VerifyFailure( + 'NOT_READY', + `${file} is missing, so nothing says which packages the fixture installed`, + 'delete integration/templates/expo-native/node_modules, then rerun {cli} up', + ); + const lockfile = readFileSync(file, 'utf8'); + const tools = nativeToolVersions(lockfile); + const read: NativeSources = + tools.length === 0 + ? { ok: false, why: 'pnpm-lock.yaml names no Expo or React Native package' } + : await expo(fixture, platform).then( + result => { + const sources = nativeSources(result, platform); + return sources.ok ? sources : { ok: false, why: `@expo/fingerprint cannot be relied on: ${sources.why}` }; + }, + (error: unknown) => ({ + ok: false, + why: `@expo/fingerprint did not run: ${error instanceof Error ? error.message : String(error)}`, + }), + ); + return read.ok + ? { installed: ['native packages', ...tools, read.sources].join('\n'), wholeLockfile: null } + : { installed: `whole lockfile\n${lockfile}`, wholeLockfile: read.why }; +} + +export interface KeptNativeBuild { + readonly id: string; + readonly dir: string; + readonly app: string; +} + +export function keptNativeBuild( + cache: string, + platform: Platform, + fingerprint: string, + appName: string, +): KeptNativeBuild { + const id = `${platform}-${fingerprint}`; + return { id, dir: join(cache, id), app: join(cache, id, appName) }; +} + +export function keepNativeBuild(kept: KeptNativeBuild, built: string): void { + const incoming = `${kept.dir}.incoming`; + rmSync(incoming, { recursive: true, force: true }); + mkdirSync(incoming, { recursive: true }); + cpSync(built, join(incoming, basename(kept.app)), { recursive: true, verbatimSymlinks: true }); + rmSync(kept.dir, { recursive: true, force: true }); + renameSync(incoming, kept.dir); +} + +const HERMES_MAGIC = 'c61fbc03c103191f'; + +function hermesVersion(bundle: Buffer): number | null { + if (bundle.length < 12 || bundle.subarray(0, 8).toString('hex') !== HERMES_MAGIC) return null; + return bundle.readUInt32LE(8); +} + +function head(file: string, bytes: number): Buffer { + const buffer = Buffer.alloc(bytes); + const fd = openSync(file, 'r'); + try { + return buffer.subarray(0, readSync(fd, buffer, 0, bytes, 0)); + } finally { + closeSync(fd); + } +} + +const ANDROID_BUNDLE = 'assets/index.android.bundle'; +const IOS_BUNDLE = 'main.jsbundle'; + +function apkEntry(apk: string, entry: string): Buffer | null { + try { + return execFileSync('unzip', ['-p', apk, entry], { + maxBuffer: 256 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'ignore'], + }); + } catch { + return null; + } +} + +function embeddedBundle(platform: Platform, app: string): Buffer | null { + if (platform === 'android') return apkEntry(app, ANDROID_BUNDLE); + const file = join(app, IOS_BUNDLE); + return existsSync(file) ? readFileSync(file) : null; +} + +function filesUnder(dir: string): readonly string[] { + if (!existsSync(dir)) return []; + return readdirSync(dir, { recursive: true, withFileTypes: true }) + .filter(entry => entry.isFile()) + .map(entry => join(entry.parentPath, entry.name)); +} + +function hermesc(fixture: string): string { + const fromFixture = createRequire(join(fixture, 'package.json')); + const fromReactNative = createRequire(fromFixture.resolve('react-native/package.json')); + const os = process.platform === 'darwin' ? 'osx-bin' : process.platform === 'win32' ? 'win64-bin' : 'linux64-bin'; + return join(dirname(fromReactNative.resolve('hermes-compiler/package.json')), 'hermesc', os, 'hermesc'); +} + +const PAGE_ALIGNING_BUILD_TOOLS = 35; + +function pageAligningBuildTools(sdk: string): string | null { + const root = join(sdk, 'build-tools'); + const versions = existsSync(root) + ? readdirSync(root).sort((a, b) => b.localeCompare(a, undefined, { numeric: true })) + : []; + const found = versions.find( + version => Number.parseInt(version, 10) >= PAGE_ALIGNING_BUILD_TOOLS && existsSync(join(root, version, 'zipalign')), + ); + return found === undefined ? null : join(root, found); +} + +interface ApkTools { + readonly buildTools: string; + readonly javaHome: string; +} + +export interface Embed { + readonly platform: Platform; + readonly fixture: string; + readonly from: string; + readonly into: string; + readonly iosExecutable: string; + readonly android: { readonly sdk: string; readonly javaHome: string } | null; + readonly must: Must; +} + +export type Embedded = { readonly ok: true; readonly bundle: string } | { readonly ok: false; readonly why: string }; + +async function signApk(embed: Embed, apk: ApkTools, work: string, bundle: string): Promise { + const tools = apk.buildTools; + const env = { + JAVA_HOME: apk.javaHome, + PATH: `${join(apk.javaHome, 'bin')}${delimiter}${process.env.PATH ?? ''}`, + }; + const stage = join(work, 'stage'); + mkdirSync(join(stage, 'assets'), { recursive: true }); + copyFileSync(bundle, join(stage, ANDROID_BUNDLE)); + const edited = join(work, 'edited.apk'); + const aligned = join(work, 'aligned.apk'); + copyFileSync(embed.from, edited); + await embed.must('zip -d', 'zip', ['-q', '-d', edited, ANDROID_BUNDLE], work); + await embed.must('zip', 'zip', ['-q', '-0', '-X', edited, ANDROID_BUNDLE], stage); + await embed.must('zipalign', join(tools, 'zipalign'), ['-f', '-P', '16', '4', edited, aligned], work); + let keystore = join(embed.fixture, 'android', 'app', 'debug.keystore'); + if (!existsSync(keystore)) { + keystore = join(work, 'debug.keystore'); + await embed.must( + 'keytool', + join(apk.javaHome, 'bin', 'keytool'), + [ + '-genkeypair', + '-keystore', + keystore, + '-storepass', + 'android', + '-keypass', + 'android', + '-alias', + 'androiddebugkey', + '-keyalg', + 'RSA', + '-keysize', + '2048', + '-validity', + '30', + '-dname', + 'CN=Android Debug,O=Android,C=US', + ], + work, + env, + ); + } + mkdirSync(dirname(embed.into), { recursive: true }); + rmSync(embed.into, { force: true }); + await embed.must( + 'apksigner', + join(tools, 'apksigner'), + [ + 'sign', + '--ks', + keystore, + '--ks-pass', + 'pass:android', + '--ks-key-alias', + 'androiddebugkey', + '--key-pass', + 'pass:android', + '--out', + embed.into, + aligned, + ], + work, + env, + ); +} + +function placeInApp(embed: Embed, bundle: string, assets: string): void { + rmSync(embed.into, { recursive: true, force: true }); + mkdirSync(dirname(embed.into), { recursive: true }); + cpSync(embed.from, embed.into, { recursive: true, verbatimSymlinks: true }); + copyFileSync(bundle, join(embed.into, IOS_BUNDLE)); + rmSync(join(embed.into, 'assets'), { recursive: true, force: true }); + if (existsSync(join(assets, 'assets'))) + cpSync(join(assets, 'assets'), join(embed.into, 'assets'), { recursive: true }); + const executables = [ + join(embed.into, embed.iosExecutable), + ...filesUnder(join(embed.into, 'Frameworks')).filter( + file => basename(dirname(file)) === `${basename(file)}.framework`, + ), + ]; + for (const file of executables) if (existsSync(file)) chmodSync(file, 0o755); +} + +export async function embedJavaScript(embed: Embed): Promise { + const before = embeddedBundle(embed.platform, embed.from); + const wanted = before === null ? null : hermesVersion(before); + if (wanted === null) return { ok: false, why: 'it holds no Hermes bundle to replace' }; + const buildTools = embed.android === null ? null : pageAligningBuildTools(embed.android.sdk); + const apk = embed.android === null || buildTools === null ? null : { buildTools, javaHome: embed.android.javaHome }; + if (embed.platform === 'android' && apk === null) + return { + ok: false, + why: `the Android SDK has no build-tools ${PAGE_ALIGNING_BUILD_TOOLS} or newer, which aligning the APK again needs`, + }; + const work = mkdtempSync(join(tmpdir(), 'verify-expo-embed-')); + try { + const manifest = JSON.parse(readFileSync(join(embed.fixture, 'package.json'), 'utf8')) as { main?: string }; + const source = join(work, 'bundle.js'); + const assets = join(work, 'assets'); + const compiled = join(work, 'bundle.hbc'); + mkdirSync(assets); + await embed.must( + 'expo export:embed', + 'pnpm', + [ + 'expo', + 'export:embed', + '--platform', + embed.platform, + '--dev', + 'false', + '--reset-cache', + '--entry-file', + join(embed.fixture, manifest.main ?? 'index.js'), + '--bundle-output', + source, + '--assets-dest', + assets, + '--minify', + 'false', + ], + embed.fixture, + ); + await embed.must( + 'hermesc', + hermesc(embed.fixture), + ['-w', '-emit-binary', '-max-diagnostic-width=80', '-O', '-out', compiled, source], + work, + ); + const made = hermesVersion(head(compiled, 12)); + if (made !== wanted) + return { ok: false, why: `it runs Hermes bytecode ${wanted}, and this checkout compiles ${made ?? 'none'}` }; + if (apk !== null && embed.platform === 'android') { + if (filesUnder(assets).length > 0) + return { ok: false, why: 'this checkout bundles image assets, which an APK takes only from a resource build' }; + await signApk(embed, apk, work, compiled); + } else { + placeInApp(embed, compiled, assets); + } + const sha = (bytes: Buffer) => createHash('sha256').update(bytes).digest('hex'); + const after = embeddedBundle(embed.platform, embed.into); + const expected = sha(readFileSync(compiled)); + if (after === null || sha(after) !== expected) + throw new VerifyFailure( + 'BUILD_FAILED', + `${embed.into} does not hold the bundle that was just made from this checkout`, + 'unset VERIFY_NATIVE_CACHE, then rerun {cli} up', + ); + return { ok: true, bundle: expected.slice(0, 12) }; + } finally { + rmSync(work, { recursive: true, force: true }); + } +} diff --git a/integration/expo-native/test/native-build.test.ts b/integration/expo-native/test/native-build.test.ts new file mode 100644 index 00000000000..eed14a7f590 --- /dev/null +++ b/integration/expo-native/test/native-build.test.ts @@ -0,0 +1,337 @@ +import '../testing/git-env.ts'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { describe, it } from 'node:test'; +import { artifact, buildFixture, locateNativeBuild, nativeCacheDir, type FixtureSite } from '../src/fixture.ts'; +import { VerifyFailure } from '../src/core/types.ts'; +import { nativeFingerprint } from '../src/native-build.ts'; + +const hermes = (version: number, body: string): Buffer => { + const header = Buffer.alloc(12); + Buffer.from('c61fbc03c103191f', 'hex').copy(header); + header.writeUInt32LE(version, 8); + return Buffer.concat([header, Buffer.from(body)]); +}; + +const lockfile = (versions: Readonly>): string => + [ + "lockfileVersion: '9.0'", + 'packages:', + ...Object.entries(versions).map(([name, version]) => ` ${name.startsWith('@') ? `'${name}@${version}'` : `${name}@${version}`}:`), + 'snapshots:', + ...Object.entries(versions).map(([name, version]) => ` '${name}@${version}(@babel/core@7.29.7)': {}`), + ].join('\n'); + +const LOCKED = { + expo: '57.0.23', + 'expo-modules-core': '57.0.21', + '@expo/config-plugins': '57.0.10', + 'react-native': '0.86.0', + 'electron-to-chromium': '1.5.447', + '@babel/core': '7.29.7', +}; + +interface Linked { + readonly modulesCore?: string; + readonly babel?: string; + readonly config?: string; + readonly clerkIos?: string; + readonly generated?: boolean; +} + +const expoSources = ({ + modulesCore = '57.0.21', + babel = '7.29.7', + config = 'portrait', + clerkIos = 'aa', + generated = false, +}: Linked = {}) => { + const core = `node_modules/.pnpm/expo-modules-core@${modulesCore}_@babel+core@${babel}_react@19.2.8/node_modules/expo-modules-core`; + return { + hash: `${modulesCore} ${babel} ${config} ${clerkIos}`, + sources: [ + { type: 'dir', filePath: '../../../packages/expo/ios', reasons: ['expoAutolinkingIos'], hash: clerkIos }, + { type: 'dir', filePath: core, reasons: ['expoAutolinkingIos'], hash: null }, + { + type: 'contents', + id: 'expoAutolinkingConfig:ios', + contents: JSON.stringify({ modules: [{ packageName: 'expo-modules-core', pods: [{ podspecDir: core }] }] }), + reasons: ['expoAutolinkingIos'], + hash: `${modulesCore} ${babel}`, + }, + { type: 'contents', id: 'expoConfig', contents: JSON.stringify({ orientation: config }), reasons: ['expoConfig'], hash: config }, + ...(generated ? [{ type: 'dir', filePath: 'ios', reasons: ['bareNativeDir'], hash: null }] : []), + ], + }; +}; + +interface Checkout { + readonly site: FixtureSite; + readonly cache: string; + readonly steps: string[]; + readonly write: (file: string, content: string) => void; + expo: unknown; + nativeBytecode: number; + compilerBytecode: number; +} + +function checkout(): Checkout { + const worktree = mkdtempSync(join(tmpdir(), 'verify-expo-native-')); + const fixture = join(worktree, 'integration', 'templates', 'expo-native'); + const write = (file: string, content: string) => { + mkdirSync(dirname(join(worktree, file)), { recursive: true }); + writeFileSync(join(worktree, file), content); + }; + execFileSync('git', ['init', '-q', worktree]); + mkdirSync(join(worktree, 'node_modules')); + write('integration/templates/expo-native/package.sdk-57.json', '{"main":"index.js"}'); + write('integration/templates/expo-native/.gitignore', '/ios/\n/node_modules/\n/package.json\n/pnpm-lock.yaml\n'); + write('integration/templates/expo-native/index.js', 'screen one'); + write('packages/expo/ios/ClerkExpo.swift', 'native one'); + write('packages/expo/src/index.ts', 'js one'); + const steps: string[] = []; + const app = artifact('ios', 'standalone', fixture); + const state: Checkout = { + site: { + worktree, + fixture, + must: async () => undefined, + toolchain: async () => 'Xcode 26.4', + expoFingerprint: async () => { + if (state.expo instanceof Error) throw state.expo; + return state.expo; + }, + }, + cache: join(worktree, 'cache'), + steps, + write, + expo: expoSources(), + nativeBytecode: 98, + compilerBytecode: 98, + }; + const must: FixtureSite['must'] = async (what, _command, args) => { + steps.push(what); + const js = readFileSync(join(worktree, 'packages/expo/src/index.ts'), 'utf8'); + if (what === 'pnpm add the workspace packages') { + for (const name of ['react-native', 'hermes-compiler']) { + mkdirSync(join(fixture, 'node_modules', name), { recursive: true }); + writeFileSync(join(fixture, 'node_modules', name, 'package.json'), '{}'); + } + writeFileSync(join(fixture, 'pnpm-lock.yaml'), lockfile(LOCKED)); + } + if (what === 'expo prebuild') { + rmSync(join(fixture, 'ios'), { recursive: true, force: true }); + mkdirSync(join(fixture, 'ios')); + } + if (what === 'xcodebuild') { + mkdirSync(app, { recursive: true }); + writeFileSync(join(app, 'main.jsbundle'), hermes(state.nativeBytecode, `built with ${js}`)); + writeFileSync(join(app, 'ClerkExpoNativeBuildFixture'), 'binary'); + } + if (what === 'expo export:embed') writeFileSync(args[args.indexOf('--bundle-output') + 1]!, js); + if (what === 'hermesc') + writeFileSync(args[args.indexOf('-out') + 1]!, hermes(state.compilerBytecode, `embedded ${readFileSync(args.at(-1)!, 'utf8')}`)); + }; + return Object.assign(state, { site: { ...state.site, must } }); +} + +const build = (at: Checkout, nativeCache: string | null = at.cache, nativeKey = "key") => + buildFixture( + { platform: "ios", product: "standalone", nativeKey, buildPackages: false, nativeCache, progress: () => undefined }, + at.site, + ); +const bundleOf = (app: string) => readFileSync(join(app, 'main.jsbundle')).subarray(12).toString(); +const NATIVE_BUILD = ['pnpm add the workspace packages', 'expo install', 'expo prebuild', 'xcodebuild']; +const EMBED = ['expo export:embed', 'hermesc']; + +describe('the native build that is kept between builds', () => { + it('is named by the native inputs, the packages that decide the native build, and the toolchain, and by nothing JS-only', async () => { + const at = checkout(); + const notes: string[] = []; + const id = async (site: FixtureSite = at.site) => (await locateNativeBuild(at.cache, 'ios', site, line => notes.push(line))).id; + const lock = (versions: Readonly>) => + writeFileSync(join(at.site.fixture, 'pnpm-lock.yaml'), lockfile({ ...LOCKED, ...versions })); + const first = await id(); + assert.match(first, /^ios-[0-9a-f]{16}$/); + const seen = new Set([first]); + const holds = async (what: string) => assert.equal(await id(), first, what); + const moves = async (what: string, site?: FixtureSite) => { + const next = await id(site); + assert.equal(seen.has(next), false, what); + seen.add(next); + }; + + at.write('packages/expo/src/index.ts', 'js two'); + at.write('integration/templates/expo-native/index.js', 'screen two'); + await holds('a JS edit'); + lock({ 'electron-to-chromium': '1.5.446' }); + await holds('another version of a JS-only package'); + lock({ '@babel/core': '7.29.6' }); + at.expo = expoSources({ babel: '7.29.6' }); + await holds('another version of a JS-only package that pnpm names in the directory of a native one'); + lock({}); + at.expo = expoSources({ generated: true }); + await holds('a native project that expo prebuild generated, which Expo lists and does not hash'); + at.expo = expoSources(); + + lock({ expo: '57.0.24' }); + await moves('another expo'); + lock({ '@expo/config-plugins': '57.0.9' }); + await moves('another version of a package that generates the native project and links no native code'); + lock({}); + at.expo = expoSources({ modulesCore: '57.0.20' }); + await moves('another version of a linked native package'); + at.expo = expoSources({ config: 'landscape' }); + await moves('another app config'); + at.expo = expoSources({ clerkIos: 'bb' }); + await moves('other native files in a linked workspace package'); + at.expo = expoSources(); + at.write('packages/expo/ios/ClerkExpo.swift', 'native two'); + await moves('a native edit'); + await moves('another Xcode', { ...at.site, toolchain: async () => 'Xcode 26.5' }); + assert.deepEqual(notes, []); + }); + + it('is named by the whole lockfile, and says why, when what Expo reports cannot be relied on', async () => { + const at = checkout(); + const notes: string[] = []; + const id = async () => (await locateNativeBuild(at.cache, 'ios', at.site, line => notes.push(line))).id; + const lock = (versions: Readonly>) => + writeFileSync(join(at.site.fixture, 'pnpm-lock.yaml'), lockfile({ ...LOCKED, ...versions })); + const narrow = await id(); + const android = expoSources().sources.map(source => + source.id === 'expoAutolinkingConfig:ios' ? { ...source, id: 'expoAutolinkingConfig:android' } : source, + ); + const unreliable: readonly (readonly [unknown, RegExp])[] = [ + [new Error("Cannot find module 'expo/package.json'"), /because @expo\/fingerprint did not run: Cannot find module 'expo\/package.json'$/], + [{ sources: android }, /because @expo\/fingerprint cannot be relied on: it left out expoAutolinkingConfig:ios$/], + [{ sources: [...expoSources().sources, { type: 'archive', filePath: 'a.zip', hash: 'cc' }] }, /it returned a archive source this CLI cannot read$/], + [{ hash: 'only a hash' }, /it returned no sources$/], + ]; + for (const [reported, why] of unreliable) { + at.expo = reported; + lock({}); + const wide = await id(); + assert.notEqual(wide, narrow); + assert.match(notes.at(-1) ?? '', /^build native fingerprint covers the whole pnpm-lock\.yaml, because /); + assert.match(notes.at(-1) ?? '', why); + lock({ 'electron-to-chromium': '1.5.446' }); + assert.notEqual(await id(), wide); + } + at.expo = expoSources(); + writeFileSync(join(at.site.fixture, 'pnpm-lock.yaml'), "lockfileVersion: '12.0'\n"); + const unread = await id(); + assert.match(notes.at(-1) ?? '', /because pnpm-lock\.yaml names no Expo or React Native package$/); + assert.notEqual(unread, narrow); + rmSync(join(at.site.fixture, 'pnpm-lock.yaml')); + await assert.rejects(id(), (error: VerifyFailure) => error.code === 'NOT_READY' && /pnpm-lock\.yaml is missing/.test(error.message)); + }); + + it('reads the fingerprint from the files as they are on disk, tracked or not', async () => { + const at = checkout(); + const inputs = { platform: 'ios' as const, worktree: at.site.worktree, inputs: ['packages/expo/ios'], files: [], toolchain: '', installed: '' }; + const untracked = await nativeFingerprint(inputs); + at.write('packages/expo/ios/New.swift', 'added'); + assert.notEqual(await nativeFingerprint(inputs), untracked); + }); + + it('is built once, and a later build with new JS only exports the bundle and puts it in that app', async () => { + const at = checkout(); + const app = await build(at); + assert.deepEqual(at.steps, NATIVE_BUILD); + assert.equal(bundleOf(app), 'built with js one'); + const kept = await locateNativeBuild(at.cache, 'ios', at.site); + assert.equal(existsSync(kept.app), true); + + at.steps.length = 0; + at.write('packages/expo/src/index.ts', 'js two'); + const rebuilt = await build(at); + assert.deepEqual(at.steps, EMBED); + assert.equal(rebuilt, app); + assert.equal(bundleOf(rebuilt), 'embedded js two'); + assert.equal(readFileSync(join(rebuilt, 'ClerkExpoNativeBuildFixture'), 'utf8'), 'binary'); + assert.equal(bundleOf(kept.app), 'built with js one'); + }); + + it('takes an app that something else put in the cache for the same fingerprint', async () => { + const at = checkout(); + const kept = await locateNativeBuild(at.cache, 'ios', at.site); + mkdirSync(kept.app, { recursive: true }); + writeFileSync(join(kept.app, 'main.jsbundle'), hermes(98, 'built elsewhere with other JS')); + at.steps.length = 0; + assert.equal(bundleOf(await build(at)), 'embedded js one'); + assert.deepEqual(at.steps, EMBED); + }); + + it('builds natively again when the native inputs changed', async () => { + const at = checkout(); + await build(at); + at.steps.length = 0; + at.write('packages/expo/ios/ClerkExpo.swift', 'native two'); + await build(at, at.cache, 'key-2'); + assert.deepEqual(at.steps, ['expo prebuild', 'xcodebuild']); + }); + + it('builds natively when the kept app runs another Hermes bytecode than this checkout compiles, and keeps the new app', async () => { + const at = checkout(); + await build(at); + at.steps.length = 0; + at.compilerBytecode = 99; + at.nativeBytecode = 99; + const lines: string[] = []; + const app = await buildFixture( + { platform: 'ios', product: 'standalone', nativeKey: 'key', buildPackages: false, nativeCache: at.cache, progress: line => lines.push(line) }, + at.site, + ); + assert.deepEqual(at.steps, [...EMBED, 'xcodebuild']); + assert.match(lines.join('\n'), /not reused: it runs Hermes bytecode 98, and this checkout compiles 99/); + assert.equal(bundleOf(app), 'built with js one'); + assert.equal(bundleOf((await locateNativeBuild(at.cache, 'ios', at.site)).app), 'built with js one'); + }); + + it('builds natively when the kept app holds no bundle', async () => { + const at = checkout(); + const kept = await locateNativeBuild(at.cache, 'ios', at.site); + mkdirSync(kept.app, { recursive: true }); + at.steps.length = 0; + await build(at); + assert.deepEqual(at.steps, ['expo prebuild', 'xcodebuild']); + }); + + it('installs the standalone packages again after a dev client build installed its own', async () => { + const at = checkout(); + const standalone = (await locateNativeBuild(at.cache, 'ios', at.site)).id; + await buildFixture( + { platform: 'ios', product: 'dev-client', nativeKey: 'key', buildPackages: false, progress: () => undefined }, + at.site, + ); + at.steps.length = 0; + assert.equal((await locateNativeBuild(at.cache, 'ios', at.site)).id, standalone); + assert.deepEqual(at.steps, ['pnpm add the workspace packages', 'expo install']); + at.steps.length = 0; + await locateNativeBuild(at.cache, 'ios', at.site); + assert.deepEqual(at.steps, []); + }); + + it('is not used without VERIFY_NATIVE_CACHE, and never for a dev client', async () => { + assert.equal(nativeCacheDir({}), null); + assert.equal(nativeCacheDir({ VERIFY_NATIVE_CACHE: '' }), null); + assert.equal(nativeCacheDir({ VERIFY_NATIVE_CACHE: '.verify/native' }, '/work'), '/work/.verify/native'); + assert.equal(nativeCacheDir({ VERIFY_NATIVE_CACHE: '/abs/native' }, '/work'), '/abs/native'); + const at = checkout(); + await build(at, null); + assert.deepEqual(at.steps, NATIVE_BUILD); + assert.equal(existsSync(at.cache), false); + at.steps.length = 0; + await buildFixture( + { platform: 'ios', product: 'dev-client', nativeKey: 'key', buildPackages: false, nativeCache: at.cache, progress: () => undefined }, + at.site, + ); + assert.deepEqual(at.steps, NATIVE_BUILD); + assert.equal(existsSync(at.cache), false); + rmSync(at.site.worktree, { recursive: true, force: true }); + }); +}); From b8b32bad056c370e7c865a997890bc9a18ff3914 Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:31:57 -0400 Subject: [PATCH 2/4] ci(repo): run the Expo end-to-end tests on pull requests Co-Authored-By: Claude Opus 5.5 --- .changeset/expo-verify-ci.md | 2 + .github/actionlint.yaml | 10 ++ .github/workflows/ci.yml | 1 - .github/workflows/verify-e2e.yml | 290 +++++++++++++++++++++++++++++++ 4 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 .changeset/expo-verify-ci.md create mode 100644 .github/workflows/verify-e2e.yml diff --git a/.changeset/expo-verify-ci.md b/.changeset/expo-verify-ci.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/expo-verify-ci.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index 50e8c080f05..17668c977ca 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -7,3 +7,13 @@ self-hosted-runner: labels: - blacksmith-8vcpu-ubuntu-2204 - blacksmith-6vcpu-macos-26 + +paths: + # actionlint 1.7.x predates GitHub's background steps and rejects them: + # https://github.com/rhysd/actionlint/issues/693 + # Remove once actionlint supports those keys. Until then, a step in this file + # with neither `run` nor `uses` is not reported either. + .github/workflows/verify-e2e.yml: + ignore: + - 'unexpected key "background" for step to run shell command' + - 'step must run script with "run" section or run action with "uses" section' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a032601372e..5cc3ff55bfc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,7 +121,6 @@ jobs: .github/workflows/ci.yml .github/actionlint.yaml integration/templates/expo-native - integration/tests/expo-native packages/expo packages/expo-biometrics packages/expo-google-signin diff --git a/.github/workflows/verify-e2e.yml b/.github/workflows/verify-e2e.yml new file mode 100644 index 00000000000..bf723e0e10d --- /dev/null +++ b/.github/workflows/verify-e2e.yml @@ -0,0 +1,290 @@ +name: Verify end-to-end tests + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + branches: + - main + paths: + - 'integration/expo-native/**' + - '!integration/expo-native/**/*.md' + - '.github/workflows/verify-e2e.yml' + - 'integration/templates/expo-native/**' + - 'packages/expo/**' + - 'packages/expo-biometrics/**' + - 'packages/expo-google-signin/**' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: verify-e2e-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + plan: + if: ${{ github.head_ref != 'changeset-release/main' }} + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + run: ${{ steps.plan.outputs.run }} + steps: + - name: Decide whether the end-to-end tests can run + id: plan + env: + HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + HAS_PLATFORM_API_KEY: ${{ secrets.MOBILE_VERIFICATION_PLATFORM_API_KEY != '' }} + DRAFT: ${{ github.event.pull_request.draft == true }} + run: | + set -euo pipefail + + skip_reason="" + if [ "$DRAFT" = "true" ]; then + skip_reason="The end-to-end tests did not run. This pull request is a draft. They run when it is marked ready for review." + elif [ "$HEAD_REPOSITORY" != "$GITHUB_REPOSITORY" ]; then + skip_reason="The end-to-end tests did not run. They need a repository secret, and this pull request comes from a fork ($HEAD_REPOSITORY)." + elif [ "$HAS_PLATFORM_API_KEY" != "true" ]; then + skip_reason="The end-to-end tests did not run. The MOBILE_VERIFICATION_PLATFORM_API_KEY secret is not set for this repository." + fi + + if [ -n "$skip_reason" ]; then + echo "::notice title=End-to-end tests skipped::$skip_reason" + echo "run=false" >> "$GITHUB_OUTPUT" + else + echo "run=true" >> "$GITHUB_OUTPUT" + fi + + e2e-tests: + needs: plan + if: needs.plan.outputs.run == 'true' + runs-on: ${{ matrix.runner }} + timeout-minutes: 75 + permissions: + contents: read + actions: read + pull-requests: write + strategy: + fail-fast: false + matrix: + include: + - platform: ios + runner: ${{ vars.VERIFY_CI_RUNNER_IOS || 'macos-26' }} + - platform: android + runner: ${{ vars.VERIFY_CI_RUNNER_ANDROID || 'ubuntu-24.04' }} + env: + PACKAGE: integration/expo-native + CLI: integration/expo-native/bin/control-clerk-expo + PLATFORM: ${{ matrix.platform }} + VERIFY_LOCAL_BUILD: standalone + VERIFY_NATIVE_CACHE: integration/expo-native/.verify/native + VERIFY_THROWAWAY_HOURS: '2' + steps: + - name: Checkout repo + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24.15.0 + cache: ${{ matrix.platform == 'android' && 'pnpm' || '' }} + + - name: Set up JDK 21 + if: matrix.platform == 'android' + uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4 + with: + distribution: temurin + java-version: 21 + + - name: Enable KVM + if: matrix.platform == 'android' + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Prepare the device image + id: device-image + background: true + timeout-minutes: 15 + env: + IOS_RUNTIME: com.apple.CoreSimulator.SimRuntime.iOS-26-5 + run: | + set -euo pipefail + if [ "$PLATFORM" = "ios" ]; then + if ! xcrun simctl list runtimes available -j | jq -e --arg id "$IOS_RUNTIME" '.runtimes[] | select(.identifier == $id)' > /dev/null; then + echo "::error::This runner image has no $IOS_RUNTIME. It has: $(xcrun simctl list runtimes available -j | jq -r '[.runtimes[].identifier] | join(", ")')" + exit 1 + fi + udid="$(xcrun simctl create "Clerk Verify Template iOS" "iPhone 17 Pro" "$IOS_RUNTIME")" + xcrun simctl boot "$udid" + xcrun simctl bootstatus "$udid" -b + CLERK_TEST_DEVICES="$udid" integration/expo-native/bin/boot-ios-simulators.sh wait + xcrun simctl shutdown "$udid" + else + IMAGE="system-images;android-36;google_apis;x86_64" + for attempt in 1 2 3; do + if "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --install "$IMAGE" emulator platform-tools < /dev/null > "$RUNNER_TEMP/sdkmanager.log" 2>&1; then + break + fi + echo "::warning::sdkmanager could not install $IMAGE on attempt $attempt" + grep -v '^\[' "$RUNNER_TEMP/sdkmanager.log" | tail -20 || true + if [ "$attempt" = "3" ]; then + exit 1 + fi + done + echo "$ANDROID_HOME/platform-tools" >> "$GITHUB_PATH" + fi + + - name: Install monorepo dependencies + run: pnpm install --frozen-lockfile + + - name: Install the test package + id: install + run: npm ci --prefix "$PACKAGE" --no-audit --no-fund + + - name: Name the native build that this commit needs + id: native + run: node "$PACKAGE/src/fixture.ts" native "$PLATFORM" >> "$GITHUB_OUTPUT" + + - name: Fetch a native build that an earlier run made + id: kept + timeout-minutes: 5 + env: + GH_TOKEN: ${{ github.token }} + BRANCH: ${{ github.head_ref || github.ref_name }} + NATIVE_ID: ${{ steps.native.outputs.id }} + NATIVE_DIR: ${{ steps.native.outputs.dir }} + NATIVE_APP: ${{ steps.native.outputs.app }} + run: | + set -euo pipefail + + fetch() { + local name="verify-expo-native-$NATIVE_ID" artifact unpacked + artifact="$(gh api "repos/$GITHUB_REPOSITORY/actions/artifacts?name=$name&per_page=100" --jq "[.artifacts[] | select(.expired == false and .workflow_run.head_repository_id == .workflow_run.repository_id and .workflow_run.head_branch == \$ENV.BRANCH)] | sort_by(.created_at) | last | .id // empty")" || return 1 + if [ -z "$artifact" ]; then + return 1 + fi + unpacked="$(mktemp -d "$RUNNER_TEMP/native-build.XXXXXX")" || return 1 + gh api "repos/$GITHUB_REPOSITORY/actions/artifacts/$artifact/zip" > "$unpacked.zip" || return 1 + unzip -q "$unpacked.zip" -d "$unpacked" || return 1 + mkdir -p "$NATIVE_DIR" || return 1 + if [ "$PLATFORM" = "ios" ]; then + mv "$unpacked" "$NATIVE_DIR/$NATIVE_APP" || return 1 + else + mv "$unpacked/$NATIVE_APP" "$NATIVE_DIR/$NATIVE_APP" || return 1 + fi + echo "Took the native build from artifact $artifact ($name)." + } + + if fetch; then + echo "found=kept" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "No native build of this branch is kept for $NATIVE_ID, so this job builds it." + echo "found=none" >> "$GITHUB_OUTPUT" + + - name: Wait for the device image + wait: device-image + + - name: Build the fixture and boot a device + timeout-minutes: 30 + env: + CLERK_PLATFORM_API_KEY: ${{ secrets.MOBILE_VERIFICATION_PLATFORM_API_KEY }} + run: | + "$CLI" up --platform "$PLATFORM" --backend local + + - name: Keep the native build for later runs + if: ${{ !cancelled() && steps.kept.outputs.found == 'none' }} + continue-on-error: true + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: verify-expo-native-${{ steps.native.outputs.id }} + path: ${{ steps.native.outputs.dir }}/${{ steps.native.outputs.app }} + include-hidden-files: true + retention-days: 7 + if-no-files-found: warn + + - name: Cache the XCTest runner + if: matrix.platform == 'ios' + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ~/.agent-device/apple-runner + key: verify-agent-device-${{ runner.os }}-${{ hashFiles('integration/expo-native/package-lock.json') }} + + - name: Compile the XCTest runner before the first spec launches + if: matrix.platform == 'ios' + timeout-minutes: 20 + run: | + set -euo pipefail + udid="$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).deviceId' "$PACKAGE/.verify/leases/ios.json")" + AGENT_DEVICE_STATE_DIR="$GITHUB_WORKSPACE/$PACKAGE/.verify/agent-device" \ + "$PACKAGE/node_modules/.bin/agent-device" prepare ios-runner --platform ios --udid "$udid" --timeout 900000 \ + || echo "::warning::prepare ios-runner failed; the run starts its own agent-device daemon, and the first test waits for the XCTest runner" + + - name: Launch the app once before the first spec + if: matrix.platform == 'ios' + timeout-minutes: 5 + run: | + "$CLI" screen --platform ios > /dev/null || echo "::warning::the first launch failed here; the first spec pays for the cold start" + + - name: Run the golden specs + timeout-minutes: 30 + env: + CLERK_PLATFORM_API_KEY: ${{ secrets.MOBILE_VERIFICATION_PLATFORM_API_KEY }} + GITHUB_TOKEN: ${{ github.token }} + run: | + "$CLI" run --all --platform "$PLATFORM" --backend local --retries 1 --github-report + + - name: Release the device and delete the Clerk application + if: always() && steps.install.outcome == 'success' + env: + CLERK_PLATFORM_API_KEY: ${{ secrets.MOBILE_VERIFICATION_PLATFORM_API_KEY }} + run: | + "$CLI" down + + - name: Check that every run is sealed + id: evidence + if: ${{ !cancelled() && steps.install.outcome == 'success' }} + run: | + node -e ' + const fs = require("fs"); + const path = require("path"); + const runs = path.join(process.env.PACKAGE, ".verify", "runs"); + const ids = fs.existsSync(runs) ? fs.readdirSync(runs) : []; + let upload = ids.length > 0; + for (const id of ids) { + let record = null; + try { record = JSON.parse(fs.readFileSync(path.join(runs, id, "run.json"), "utf8")); } catch {} + if (record === null || record.sealed !== true || record.tainted.length > 0) { + upload = false; + console.log("::warning::Run " + id + " was not sealed, or a file in it holds a secret value. No evidence is uploaded."); + } + } + fs.appendFileSync(process.env.GITHUB_OUTPUT, "upload=" + upload + "\n"); + ' + + - name: Upload the evidence + if: ${{ !cancelled() && steps.evidence.outputs.upload == 'true' }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: verify-e2e-${{ matrix.platform }} + overwrite: true + include-hidden-files: true + path: | + ${{ env.PACKAGE }}/.verify/runs/*/run.json + ${{ env.PACKAGE }}/.verify/runs/*/app.log + ${{ env.PACKAGE }}/.verify/runs/*/driver + ${{ env.PACKAGE }}/.verify/runs/*/video.mp4 + ${{ env.PACKAGE }}/.verify/runs/*/screenshots + ${{ env.PACKAGE }}/.verify/runs/*/e2e*/report.json + ${{ env.PACKAGE }}/.verify/runs/*/e2e*/junit.xml + ${{ env.PACKAGE }}/.verify/runs/*/e2e*/summary.md + ${{ env.PACKAGE }}/.verify/runs/*/e2e*/failures + ${{ env.PACKAGE }}/.verify/runs/*/e2e*/artifacts + retention-days: 3 + if-no-files-found: warn From c1e3a6b1d7e544bc39e2cda1c733da7e9355d0f8 Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:32:00 -0400 Subject: [PATCH 3/4] test(expo): delete the older Expo tests, their home in the fixture, and their workflow steps Co-Authored-By: Claude Opus 5.5 --- .github/workflows/expo-native-build.yml | 192 +----------------- .gitignore | 1 - integration/e2e.expo-native.config.ts | 15 -- integration/templates/expo-native/App.tsx | 179 +--------------- .../templates/expo-native/verify/launch.ts | 8 +- .../tests/expo-native/auth-view.e2e.ts | 22 -- .../tests/expo-native/boot-ios-simulators.sh | 57 ------ integration/tests/expo-native/fixtures.ts | 20 -- .../tests/expo-native/native-modules.e2e.ts | 12 -- .../tests/expo-native/page-objects/app.ts | 29 --- .../expo-native/page-objects/authView.ts | 78 ------- .../expo-native/page-objects/gestures.ts | 53 ----- .../tests/expo-native/page-objects/index.ts | 16 -- .../expo-native/page-objects/userButton.ts | 18 -- .../expo-native/page-objects/userProfile.ts | 27 --- .../tests/expo-native/session-sync.e2e.ts | 30 --- integration/tests/expo-native/types.ts | 4 - .../tests/expo-native/user-profile.e2e.ts | 40 ---- integration/tests/expo-native/users.ts | 37 ---- package.json | 4 - pnpm-lock.yaml | 138 +------------ scripts/resolve-instance-keys.mjs | 51 ----- 22 files changed, 20 insertions(+), 1011 deletions(-) delete mode 100644 integration/e2e.expo-native.config.ts delete mode 100644 integration/tests/expo-native/auth-view.e2e.ts delete mode 100755 integration/tests/expo-native/boot-ios-simulators.sh delete mode 100644 integration/tests/expo-native/fixtures.ts delete mode 100644 integration/tests/expo-native/native-modules.e2e.ts delete mode 100644 integration/tests/expo-native/page-objects/app.ts delete mode 100644 integration/tests/expo-native/page-objects/authView.ts delete mode 100644 integration/tests/expo-native/page-objects/gestures.ts delete mode 100644 integration/tests/expo-native/page-objects/index.ts delete mode 100644 integration/tests/expo-native/page-objects/userButton.ts delete mode 100644 integration/tests/expo-native/page-objects/userProfile.ts delete mode 100644 integration/tests/expo-native/session-sync.e2e.ts delete mode 100644 integration/tests/expo-native/types.ts delete mode 100644 integration/tests/expo-native/user-profile.e2e.ts delete mode 100644 integration/tests/expo-native/users.ts delete mode 100644 scripts/resolve-instance-keys.mjs diff --git a/.github/workflows/expo-native-build.yml b/.github/workflows/expo-native-build.yml index baba13fb3cd..5c83b6dc756 100644 --- a/.github/workflows/expo-native-build.yml +++ b/.github/workflows/expo-native-build.yml @@ -7,9 +7,7 @@ on: - main paths: - '.github/workflows/expo-native-build.yml' - - 'integration/e2e.expo-native.config.ts' - 'integration/templates/expo-native/**' - - 'integration/tests/expo-native/**' - 'packages/expo/**' - 'packages/expo-biometrics/**' - 'packages/expo-google-signin/**' @@ -24,22 +22,15 @@ concurrency: env: FIXTURE_DIR: integration/templates/expo-native - E2E_DIR: integration/tests/expo-native FIXTURE_PUBLISHABLE_KEY: pk_test_ZHVtbXkuY2xlcmsuYWNjb3VudHMuZGV2JA SDK_PACK_DIR: /tmp/clerk-expo-pack - E2E_INSTANCE_NAME: clerkstage-with-native-components - BAPI_URL: https://api.clerkstage.dev - IOS_SIMULATORS: '2' - E2E_TELEMETRY_DISABLED: '1' jobs: native-build: if: ${{ github.head_ref != 'changeset-release/main' && (github.event_name == 'workflow_dispatch' || github.event.pull_request.draft == false) }} - name: Native ${{ matrix.run-e2e == true && 'E2E' || 'Build' }} (${{ matrix.platform }}, sdk ${{ matrix.expo-sdk }}) + name: Native Build (${{ matrix.platform }}, sdk ${{ matrix.expo-sdk }}) runs-on: ${{ matrix.runner }} - timeout-minutes: ${{ matrix.run-e2e == true && 60 || 45 }} - env: - CLERK_TEST_USERNAME_PREFIX: e2e_${{ github.run_id }}_${{ matrix.platform }}_ + timeout-minutes: 45 strategy: fail-fast: false matrix: @@ -59,11 +50,9 @@ jobs: - expo-sdk: 57 platform: android runner: blacksmith-8vcpu-ubuntu-2204 - run-e2e: true - expo-sdk: 57 platform: ios runner: blacksmith-6vcpu-macos-26 - run-e2e: true steps: - name: Checkout repo @@ -107,18 +96,18 @@ jobs: with: path: ${{ steps.native-build-key.outputs.artifact }} # Bump the version when native build commands change. - key: expo-native-build-v1-${{ runner.os }}-${{ matrix.expo-sdk }}-${{ matrix.platform }}-${{ matrix.run-e2e == true && 'e2e' || 'build' }}-${{ env.E2E_INSTANCE_NAME }}-${{ steps.native-build-key.outputs.hash }} + key: expo-native-build-v1-${{ runner.os }}-${{ matrix.expo-sdk }}-${{ matrix.platform }}-${{ steps.native-build-key.outputs.hash }} - - if: steps.native-build-cache.outputs.cache-hit != 'true' || matrix.run-e2e == true + - if: steps.native-build-cache.outputs.cache-hit != 'true' uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - - if: steps.native-build-cache.outputs.cache-hit != 'true' || matrix.run-e2e == true + - if: steps.native-build-cache.outputs.cache-hit != 'true' uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 24.15.0 cache: pnpm - name: Install monorepo dependencies - if: steps.native-build-cache.outputs.cache-hit != 'true' || matrix.run-e2e == true + if: steps.native-build-cache.outputs.cache-hit != 'true' run: pnpm install --frozen-lockfile - name: Build and pack Clerk packages @@ -135,7 +124,6 @@ jobs: working-directory: ${{ env.FIXTURE_DIR }} env: EXPO_SDK: ${{ matrix.expo-sdk }} - RUN_E2E: ${{ matrix.run-e2e == true && 'true' || 'false' }} run: | cp "package.sdk-$EXPO_SDK.json" package.json pnpm install --no-frozen-lockfile @@ -144,49 +132,13 @@ jobs: GOOGLE_SIGNIN_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-google-signin-*.tgz)" BIOMETRICS_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-biometrics-*.tgz)" pnpm add "$SDK_TARBALL" "$GOOGLE_SIGNIN_TARBALL" "$BIOMETRICS_TARBALL" -w - # expo-dev-client makes even release builds boot into the dev - # launcher (unreachable Metro in CI), which stalls every e2e - # flow on a blank screen. Skip it on e2e jobs only. - DEV_CLIENT="expo-dev-client" - if [ "$RUN_E2E" = "true" ]; then DEV_CLIENT=""; fi - pnpm expo install expo-auth-session expo-constants expo-crypto $DEV_CLIENT expo-secure-store expo-web-browser - if [ "$RUN_E2E" = "true" ]; then - REACT_VERSION=$(node -p 'require("react/package.json").version') - REACT_DOM_VERSION=$(node -p 'require("react-dom/package.json").version') - if [ "$REACT_VERSION" != "$REACT_DOM_VERSION" ]; then - echo "::error::React version mismatch: react@$REACT_VERSION, react-dom@$REACT_DOM_VERSION" - exit 1 - fi - fi - - - name: Check e2e secret availability - id: gate - if: matrix.run-e2e == true - env: - KEYS: ${{ secrets.INTEGRATION_STAGING_INSTANCE_KEYS }} - run: | - if [ -n "$KEYS" ]; then - echo "ok=true" >> "$GITHUB_OUTPUT" - else - echo "ok=false" >> "$GITHUB_OUTPUT" - echo "::notice::INTEGRATION_STAGING_INSTANCE_KEYS unavailable (fork PR?); running build-only." - fi - - - name: Resolve Clerk instance keys - id: keys - if: steps.gate.outputs.ok == 'true' - continue-on-error: true - env: - INTEGRATION_STAGING_INSTANCE_KEYS: ${{ secrets.INTEGRATION_STAGING_INSTANCE_KEYS }} - run: node scripts/resolve-instance-keys.mjs INTEGRATION_STAGING_INSTANCE_KEYS "$E2E_INSTANCE_NAME" + pnpm expo install expo-auth-session expo-constants expo-crypto expo-dev-client expo-secure-store expo-web-browser - name: Write fixture .env if: steps.native-build-cache.outputs.cache-hit != 'true' working-directory: ${{ env.FIXTURE_DIR }} - env: - E2E_PK: ${{ steps.keys.outputs.pk }} run: | - echo "EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=${E2E_PK:-$FIXTURE_PUBLISHABLE_KEY}" > .env + echo "EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=$FIXTURE_PUBLISHABLE_KEY" > .env - name: Set up JDK 17 if: matrix.platform == 'android' @@ -239,131 +191,3 @@ jobs: with: path: ${{ steps.native-build-key.outputs.artifact }} key: ${{ steps.native-build-cache.outputs.cache-primary-key }} - - - name: Boot iOS simulators - if: matrix.platform == 'ios' && matrix.run-e2e == true && steps.keys.outputs.pk != '' - run: ${{ env.E2E_DIR }}/boot-ios-simulators.sh boot "$IOS_SIMULATORS" - - - name: Cache iOS automation runner - if: matrix.platform == 'ios' && steps.keys.outputs.pk != '' - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 - with: - path: ~/.agent-device - key: agent-device-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: agent-device-${{ runner.os }}- - - - name: Run iOS e2e - id: run_e2e_ios - if: matrix.platform == 'ios' && steps.keys.outputs.sk != '' - env: - CLERK_SECRET_KEY: ${{ steps.keys.outputs.sk }} - CLERK_API_URL: ${{ env.BAPI_URL }} - run: | - "$E2E_DIR/boot-ios-simulators.sh" wait - mkdir -p "$RUNNER_TEMP/e2e-device-logs" - log_pids=() - trap 'kill "${log_pids[@]}" 2>/dev/null || true; wait "${log_pids[@]}" 2>/dev/null || true' EXIT - for udid in ${CLERK_TEST_DEVICES//,/ }; do - xcrun simctl install "$udid" "$FIXTURE_DIR/ios/build/Build/Products/Release-iphonesimulator/ClerkExpoNativeBuildFixture.app" - xcrun simctl spawn "$udid" log stream --style compact \ - --predicate 'processImagePath CONTAINS "ClerkExpoNativeBuildFixture"' \ - > "$RUNNER_TEMP/e2e-device-logs/$udid-console.log" 2>&1 & - log_pids+=($!) - done - pnpm test:integration:expo-native:ios --reporter list,junit,markdown - - - name: Enable KVM - if: matrix.platform == 'android' && steps.keys.outputs.sk != '' - run: | - echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules - sudo udevadm control --reload-rules - sudo udevadm trigger --name-match=kvm - - - name: AVD cache - id: avd-cache - if: matrix.platform == 'android' && steps.keys.outputs.sk != '' - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 - with: - path: | - ~/.android/avd/* - ~/.android/adb* - key: avd-34-google_apis-x86_64-v1 - - - name: Create AVD snapshot - if: matrix.platform == 'android' && steps.keys.outputs.sk != '' && steps.avd-cache.outputs.cache-hit != 'true' - uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2 - with: - api-level: 34 - target: google_apis - arch: x86_64 - force-avd-creation: false - emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim - disable-animations: false - script: echo "Generated AVD snapshot for caching." - - - name: Run Android e2e - id: run_e2e_android - if: matrix.platform == 'android' && steps.keys.outputs.sk != '' - uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2 - env: - CLERK_SECRET_KEY: ${{ steps.keys.outputs.sk }} - CLERK_API_URL: ${{ env.BAPI_URL }} - with: - api-level: 34 - target: google_apis - arch: x86_64 - force-avd-creation: false - emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim - disable-animations: true - # The action runs each script line in a separate sh -c; the folded - # scalar (>-) plus && keeps everything in one shell invocation. - script: >- - mkdir -p "$RUNNER_TEMP/e2e-device-logs" && - adb install -r "$FIXTURE_DIR/android/app/build/outputs/apk/release/app-release.apk" && - (adb logcat -v threadtime > "$RUNNER_TEMP/e2e-device-logs/logcat.log" 2>&1 &) && - pnpm test:integration:expo-native:android --reporter list,junit,markdown - - - name: Delete leftover test users - if: always() && steps.keys.outputs.sk != '' - env: - CLERK_SECRET_KEY: ${{ steps.keys.outputs.sk }} - run: | - curl -fsS -G "$BAPI_URL/v1/users" --data-urlencode "limit=100" --data-urlencode "query=$CLERK_TEST_USERNAME_PREFIX" -H "Authorization: Bearer $CLERK_SECRET_KEY" | - jq -r --arg p "$CLERK_TEST_USERNAME_PREFIX" '.[] | select((.username // "") | startswith($p)) | .id' | - while read -r user_id; do - curl -fsS --retry 3 --retry-delay 1 -o /dev/null -X DELETE "$BAPI_URL/v1/users/$user_id" -H "Authorization: Bearer $CLERK_SECRET_KEY" || - echo "::warning::Could not delete leftover test user $user_id" - done - - - name: Upload e2e artifacts - if: always() && steps.keys.outputs.sk != '' - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: e2e-${{ matrix.platform }} - include-hidden-files: true - path: | - integration/.e2e - ${{ runner.temp }}/e2e-device-logs - if-no-files-found: warn - retention-days: 7 - - - name: Report e2e outcome - if: always() && steps.keys.outputs.sk != '' - env: - IOS_OUTCOME: ${{ steps.run_e2e_ios.outcome }} - ANDROID_OUTCOME: ${{ steps.run_e2e_android.outcome }} - run: | - outcome="$IOS_OUTCOME" - [ "$outcome" = "skipped" ] && outcome="$ANDROID_OUTCOME" - echo "## Native e2e (${{ matrix.platform }}): $outcome" >> "$GITHUB_STEP_SUMMARY" - if [ -f integration/.e2e/summary.md ]; then - cat integration/.e2e/summary.md >> "$GITHUB_STEP_SUMMARY" - fi - if [ "$outcome" = "failure" ]; then - echo "::error::Native e2e failed. See the e2e-${{ matrix.platform }} artifact." - fi - flaky=$(jq -r '[.run.results[] | select(.status == "flaky") | .file] | join(", ")' integration/.e2e/report.json 2>/dev/null || true) - if [ -n "$flaky" ]; then - echo "::error::Flaky flow(s), failed then passed on the retry: $flaky" - exit 1 - fi diff --git a/.gitignore b/.gitignore index f0aef8bbd7a..24d1b33d3a8 100644 --- a/.gitignore +++ b/.gitignore @@ -67,7 +67,6 @@ packages/*/examples/*/package-lock.json packages/*/examples/*/yarn.lock /test-results/ /playwright-report/ -/integration/.e2e/ /playwright/.cache/ # integration testing diff --git a/integration/e2e.expo-native.config.ts b/integration/e2e.expo-native.config.ts deleted file mode 100644 index 88c47c58e57..00000000000 --- a/integration/e2e.expo-native.config.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { mobile } from '@e2e-dev/mobile'; -import type { E2EConfig } from 'e2e'; - -const app = { bundleId: 'com.clerk.exponativebuildfixture' }; -const devices = process.env.CLERK_TEST_DEVICES?.split(','); - -export default { - tests: 'tests/expo-native/*.e2e.ts', - targets: [ - { name: 'ios', engine: mobile({ platform: 'ios', device: devices }), app }, - { name: 'android', engine: mobile({ platform: 'android', device: devices }), app }, - ], - workers: devices?.length ?? 1, - timeout: 300_000, -} satisfies E2EConfig; diff --git a/integration/templates/expo-native/App.tsx b/integration/templates/expo-native/App.tsx index 7c8802393e7..cd31678f24d 100644 --- a/integration/templates/expo-native/App.tsx +++ b/integration/templates/expo-native/App.tsx @@ -1,183 +1,18 @@ -import { ClerkProvider, useAuth, useUser } from '@clerk/expo'; -import { AuthView, UserButton, UserProfileView } from '@clerk/expo/native'; -import { tokenCache } from '@clerk/expo/token-cache'; -import { useState } from 'react'; -import { Button, LogBox, Modal, StyleSheet, Text, View } from 'react-native'; +import { type ReactElement } from 'react'; +import { LogBox } from 'react-native'; -import { BiometricAvailabilityButton } from './components/BiometricAvailabilityButton'; -import { GoogleSignInButton } from './components/GoogleSignInButton'; import { readVerifyLaunch } from './verify/launch'; import { logRequests, VerifyHost } from './verify/VerifyHost'; -const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY; const verifyLaunch = readVerifyLaunch(); -if (verifyLaunch) { +if (verifyLaunch.runId !== null) { LogBox.ignoreAllLogs(); - if (verifyLaunch.debugLogging) { - logRequests(); - } -} else if (!publishableKey) { - throw new Error('Missing EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY'); } - -function NativeBuildFixture() { - const { isLoaded, isSignedIn, signOut } = useAuth({ treatPendingAsSignedOut: false }); - const { user } = useUser(); - const [isAuthOpen, setIsAuthOpen] = useState(false); - const [isProfileOpen, setIsProfileOpen] = useState(false); - const [signOutResult, setSignOutResult] = useState(null); - - if (isProfileOpen) { - return ( - - - - Rehosted RN body - - ), - }, - ]} - isDismissible={false} - onHostBack={() => setIsProfileOpen(false)} - /> - - - ); - } - - return ( - - - Clerk Expo Native Fixture - {isSignedIn && } - - - {isLoaded ? `signed ${isSignedIn ? 'in' : 'out'}` : 'loading'} - {user?.id && {user.id}} - {signOutResult && {`sign out: ${signOutResult}`}} -