From 85cf4a4b552358e4a067720e048dfd90cbb0a1b8 Mon Sep 17 00:00:00 2001 From: Braxton Ward Date: Tue, 15 Sep 2026 11:49:16 -0600 Subject: [PATCH 01/17] feat(appium): add an Appium test environment app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a standalone test app so the shared conformance suite in mobile-conformance-tests can run against the React Native wrapper, alongside the native Android and iOS test apps it already drives. The suite drives a test app through a fixed contract that React Native cannot reach on its own, and expo prebuild regenerates android/ and ios/ on every run, so an activity or view controller implementing it cannot be checked in. The config plugin injects the native pieces at prebuild time instead: - Android: a module exposing the launch intent's TRANSACT_* extras to JS, a manifest-declared .TransactCommandReceiver (the suite addresses it by explicit component, which a runtime-registered receiver cannot satisfy), singleTask launch mode, and the package registration in MainApplication. - iOS: an Objective-C module reading the TRANSACT_* process environment variables the suite launches with — Metro inlines process.env at build time, so JS cannot read them. Added to the target's Sources phase, since a source file Xcode does not know about is never compiled and RCT_EXPORT_MODULE would never run. Everything native derives from expo.android.package, so changing the id in app.json is sufficient. The app claims its own identifiers rather than the native test app's, so both install side by side and the suite selects one with ANDROID_APP_PACKAGE / IOS_BUNDLE_ID. Log lines use the same tag and wording as the native Android test app so the suite's waitForLogs matches unchanged. On iOS the specs assert on native UI, so the app renders the PauseStatus element and the Task Completed, handoff and RESPOND! alerts. Requires a standalone build: a dev-client build sits at the dev launcher with no JS loaded once Appium reinstalls the app, because installing resets the storage holding the Metro bundle URL. The eas.json "appium" profile builds one. Known React Native bridge gaps are documented in the README, including the pause/resume and fragment-flow APIs the bridge does not expose. The harness logs each unsupported flow so a failing spec says why rather than timing out. --- appium-test-environment/.gitignore | 8 + appium-test-environment/App.tsx | 337 ++++++++++++++++++ appium-test-environment/README.md | 111 ++++++ appium-test-environment/app.json | 32 ++ appium-test-environment/eas.json | 25 ++ appium-test-environment/index.ts | 5 + appium-test-environment/package.json | 26 ++ .../plugin/src/android/AppiumHarnessModule.kt | 128 +++++++ .../src/android/AppiumHarnessPackage.kt | 14 + .../src/android/TransactCommandReceiver.kt | 46 +++ .../plugin/src/ios/AppiumHarness.m | 52 +++ .../plugin/withAppiumTestEnvironment.js | 212 +++++++++++ appium-test-environment/src/harness.ts | 49 +++ appium-test-environment/src/launchConfig.ts | 109 ++++++ appium-test-environment/tsconfig.json | 8 + package.json | 6 +- yarn.lock | 22 ++ 17 files changed, 1188 insertions(+), 2 deletions(-) create mode 100644 appium-test-environment/.gitignore create mode 100644 appium-test-environment/App.tsx create mode 100644 appium-test-environment/README.md create mode 100644 appium-test-environment/app.json create mode 100644 appium-test-environment/eas.json create mode 100644 appium-test-environment/index.ts create mode 100644 appium-test-environment/package.json create mode 100644 appium-test-environment/plugin/src/android/AppiumHarnessModule.kt create mode 100644 appium-test-environment/plugin/src/android/AppiumHarnessPackage.kt create mode 100644 appium-test-environment/plugin/src/android/TransactCommandReceiver.kt create mode 100644 appium-test-environment/plugin/src/ios/AppiumHarness.m create mode 100644 appium-test-environment/plugin/withAppiumTestEnvironment.js create mode 100644 appium-test-environment/src/harness.ts create mode 100644 appium-test-environment/src/launchConfig.ts create mode 100644 appium-test-environment/tsconfig.json diff --git a/appium-test-environment/.gitignore b/appium-test-environment/.gitignore new file mode 100644 index 0000000..f93d12c --- /dev/null +++ b/appium-test-environment/.gitignore @@ -0,0 +1,8 @@ +# Generated by expo prebuild — the config plugin recreates the native pieces. +# Anchored so they only match the generated project roots, not the plugin's own +# plugin/src/android and plugin/src/ios sources. +/android/ +/ios/ + +node_modules/ +.expo/ diff --git a/appium-test-environment/App.tsx b/appium-test-environment/App.tsx new file mode 100644 index 0000000..b084a87 --- /dev/null +++ b/appium-test-environment/App.tsx @@ -0,0 +1,337 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Alert, Linking, Platform, StyleSheet, Text, View } from 'react-native'; +import { StatusBar } from 'expo-status-bar'; +import { Atomic } from '@atomicfi/transact-react-native'; +import { + buildConfig, + buildEnvironment, + isLaunchable, +} from './src/launchConfig'; +import type { LaunchExtras } from './src/launchConfig'; +import { + getLaunchExtras, + harnessEvents, + isHarnessAvailable, + log, +} from './src/harness'; + +/** + * Appium test environment for the React Native Transact SDK. + * + * Presents no UI of its own beyond a status line — the conformance suite launches this app with + * TRANSACT_* intent extras and it immediately presents Transact with that configuration, matching + * what the native Android and iOS test apps do. + */ + +const stringify = (value: unknown) => { + try { + return JSON.stringify(value); + } catch { + return String(value); + } +}; + +export default function App() { + const [status, setStatus] = useState('Waiting for a launch intent…'); + // The iOS specs read this by accessibility id and expect the text 'paused' after + // `atomictest://pause`, mirroring the native iOS test app's PauseStatus label. + const [pauseStatus, setPauseStatus] = useState(''); + const lastLaunchId = useRef(null); + // Set when a custom flow hides Transact, cleared once the confirmation alert is presented. + const pendingDismissAlert = useRef(null); + + /** + * Present the custom-flow confirmation alert once Transact has dismissed. + * + * The close/cleanup callbacks fire as dismissal *starts*, so presenting synchronously from them + * puts the alert up while Transact still owns the screen and it is silently dropped. Scheduling + * it a beat later lets the dismissal finish first. The flag is cleared only when the alert is + * actually presented, so an early call cannot swallow it. + */ + const showPendingDismissAlert = useCallback((delayMs: number = 800) => { + if (!pendingDismissAlert.current) return; + + setTimeout(() => { + const title = pendingDismissAlert.current; + if (!title) return; + pendingDismissAlert.current = null; + log(`Presenting dismiss alert: ${title}`); + Alert.alert(title, undefined, [{ text: 'Okay' }]); + }, delayMs); + }, []); + + const launch = useCallback( + (extras: LaunchExtras) => { + if (!isLaunchable(extras)) { + const message = + 'Missing TRANSACT_PUBLIC_TOKEN, TRANSACT_URL, TRANSACT_PRODUCT_TYPE or TRANSACT_SCOPE_TYPE ' + + 'in the intent extras. This app launches Transact from those parameters; if the Appium ' + + 'suite is starting up, the run should relaunch with them shortly.'; + log(`transact-launch-error:missing-config`); + setStatus(message); + return; + } + + const launchId = extras.TRANSACT_LAUNCH_ID ?? null; + if (launchId && launchId === lastLaunchId.current) { + log(`Ignoring duplicate launch: ${launchId}`); + return; + } + lastLaunchId.current = launchId; + + const config = buildConfig(extras); + const environment = buildEnvironment(extras); + log(`Config: ${stringify(config)}`); + setStatus('Transact launched'); + + // The native test apps implement these; the RN bridge has no equivalent on Android, so say so + // rather than launching and letting the spec time out with no explanation. + const customFlow = extras.TRANSACT_CUSTOM_FLOW?.toUpperCase(); + if (customFlow) { + log(`Custom flow requested: ${customFlow}`); + if (customFlow === 'FRAGMENT_FLOW') { + log( + 'Custom flow FRAGMENT_FLOW is not supported: the React Native SDK presents Transact itself and exposes no fragment host.' + ); + } else if ( + customFlow === 'DISMISS_ON_AUTH_STATUS_UPDATE_AUTHENTICATED' && + Platform.OS === 'android' + ) { + log( + 'Custom flow DISMISS_ON_AUTH_STATUS_UPDATE_AUTHENTICATED is not supported on Android: Atomic.hideTransact() is iOS-only.' + ); + } + } + + Atomic.transact({ + config: config as any, + environment, + // The native Config ORs debug into webContentsDebuggingEnabled, which is what exposes the + // Transact WebView to Appium as a WEBVIEW context. + setDebug: true, + onLaunch: () => { + log('RECEIVER launch'); + log('callback:Launch'); + }, + onInteraction: (interaction: any) => { + log(`RECEIVER interaction ${stringify(interaction)}`); + log('callback:Interaction'); + }, + onAuthStatusUpdate: (update: any) => { + const state = String(update?.status ?? stringify(update)); + log(`RECEIVER auth status updated ${state}`); + + if ( + state.toUpperCase() === 'AUTHENTICATED' && + customFlow === 'DISMISS_ON_AUTH_STATUS_UPDATE_AUTHENTICATED' + ) { + // Atomic.hideTransact() is iOS-only in the bridge, so this custom flow only works here. + if (Platform.OS === 'ios') { + log('Hiding Transact on AUTHENTICATED'); + Atomic.hideTransact(); + // The native iOS test app then shows an alert titled with the custom flow name, which + // is what the spec asserts on (`~DISMISS_ON_AUTH_STATUS_UPDATE_AUTHENTICATED`). + // + // It has to wait until Transact is actually gone: RN presents alerts from the topmost + // view controller, so one fired while Transact is still dismissing is silently dropped. + // Driven off the close/cleanup callbacks rather than a fixed delay, with a backstop in + // case neither fires. + pendingDismissAlert.current = customFlow; + // Backstop in case neither close nor cleanup fires. + setTimeout(() => showPendingDismissAlert(0), 3000); + } + } + }, + onTaskStatusUpdate: (update: any) => { + const state = update?.status ?? stringify(update); + log(`RECEIVER task status updated ${state}`); + // The iOS deferred-payment spec waits for an alert titled exactly 'Task Completed'. + if ( + Platform.OS === 'ios' && + String(state).toUpperCase() === 'COMPLETED' + ) { + Alert.alert( + 'Task Completed', + `company: ${update?.company?.name ?? 'unknown'}` + ); + } + }, + onDataRequest: (request: any) => { + if ( + ( + extras.TRANSACT_DEFERRED_PAYMENT_METHOD_STRATEGY || '' + ).toLowerCase() !== 'sdk' + ) { + return undefined; + } + log(`RECEIVER data request ${stringify(request?.fields ?? request)}`); + + const response = { + card: { number: '4111222233334444', expiry: '12/29', cvv: '444' }, + identity: { + firstName: 'first', + lastName: 'last', + zipCode: '12345', + address: 'somewhere', + city: 'someplace', + state: 'UT', + }, + }; + + if (Platform.OS === 'ios') { + // The native iOS test app gates the response behind an alert so the spec can prove the + // request reached the host app; the spec taps '~RESPOND!'. The alert is informational + // here — the bridge's onDataRequest is synchronous, so the response is returned either + // way and tapping it is what the spec waits on. + Alert.alert('Data request', 'Respond to the data request', [ + { text: 'RESPOND!' }, + ]); + } + + // The RN bridge only round-trips this response on iOS; on Android the value returned here + // does not reach the SDK, so deferred-payment specs cannot complete. + log('Sent data response'); + return response; + }, + onClose: (data: any) => { + log(`RECEIVER close ${stringify(data)}`); + showPendingDismissAlert(); + }, + onFinish: (data: any) => { + log(`RECEIVER finish ${stringify(data)}`); + if (data?.handoff) { + log(`Finished with Handoff: ${data.handoff}`); + // The iOS handoff spec looks for an element labelled with this exact string. + if (Platform.OS === 'ios') { + Alert.alert(`Finished with Handoff: ${data.handoff}`); + } + } + }, + onCleanup: () => { + log('callback:Cleanup'); + showPendingDismissAlert(); + }, + onError: (error: any) => { + log(`RECEIVER error ${stringify(error)}`); + }, + }); + + log(`transact-launch:${launchId ?? 'no-launch-id'}`); + }, + [showPendingDismissAlert] + ); + + useEffect(() => { + if (!isHarnessAvailable) { + setStatus( + 'Native harness module unavailable — run a build that includes the config plugin.' + ); + return; + } + + // Cold start: the extras the activity was launched with. + getLaunchExtras().then((extras) => { + if (Object.keys(extras).length > 0) { + launch(extras as LaunchExtras); + } + }); + + // Warm start: `singleTask` delivers a relaunch to the running activity. + const launchSub = harnessEvents?.addListener( + 'AppiumHarnessLaunch', + (event: { launchId?: string; extras?: Record }) => { + if (event?.extras) { + launch(event.extras as LaunchExtras); + } + } + ); + + // iOS receives commands as `atomictest://` deep links rather than broadcasts. + const handleCommandUrl = (url: string | null) => { + if (!url) return; + const command = url.replace(/^atomictest:\/\//, '').split(/[/?]/)[0]; + if (!command) return; + + log(`RECEIVER command ${command}`); + + if (command === 'pause' || command === 'resume') { + // The native iOS test app calls Atomic.pauseTransact() / resumeTransact() here. The React + // Native bridge exposes neither, so the status label reports the gap instead of silently + // leaving the spec to time out on a label that never changes. + setPauseStatus('pause-unsupported'); + log( + `Command ${command} is not supported: the React Native SDK exposes no pause/resume API.` + ); + } + }; + + Linking.getInitialURL().then(handleCommandUrl); + const urlSub = Linking.addEventListener('url', (event) => + handleCommandUrl(event.url) + ); + + const commandSub = harnessEvents?.addListener( + 'AppiumHarnessCommand', + (event: { command?: string; extras?: Record }) => { + log(`RECEIVER command ${event?.command} ${stringify(event?.extras)}`); + if ( + event?.command === 'PAUSE_TRANSACT' || + event?.command === 'RESUME_TRANSACT' + ) { + // The RN bridge exposes no pause/resume; logged so specs asserting on the broadcast + // still see it arrive, and so the gap is visible rather than silent. + log( + `Command ${event.command} is not supported by the React Native SDK` + ); + } + } + ); + + return () => { + launchSub?.remove(); + commandSub?.remove(); + urlSub.remove(); + }; + }, [launch]); + + return ( + + + + {pauseStatus} + + AppiumTestEnvironment + {status} + + ); +} + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + backgroundColor: '#ffffff', + flex: 1, + justifyContent: 'center', + padding: 32, + }, + pauseStatus: { + color: '#444444', + fontSize: 12, + position: 'absolute', + top: 8, + }, + status: { + color: '#444444', + fontSize: 14, + textAlign: 'center', + }, + title: { + fontSize: 18, + fontWeight: '600', + marginBottom: 12, + }, +}); diff --git a/appium-test-environment/README.md b/appium-test-environment/README.md new file mode 100644 index 0000000..4505bbe --- /dev/null +++ b/appium-test-environment/README.md @@ -0,0 +1,111 @@ +# AppiumTestEnvironment (React Native) + +The React Native counterpart to `atomic-transact-android/AppiumTestEnvironment` and the iOS test +app. It exists so the shared conformance suite in +[`mobile-conformance-tests`](https://github.com/atomicfi/mobile-conformance-tests) can run against +the React Native wrapper **without any changes to the suite**. + +It presents no UI beyond a status line: the suite launches it with `TRANSACT_*` intent extras and it +immediately presents Transact with that configuration. + +## The contract + +The suite drives the native test apps in three ways, none of which React Native can reach on its +own. `plugin/withAppiumTestEnvironment.js` injects the native pieces at prebuild time. + +| What the suite does | How it's satisfied here | +| --- | --- | +| Starts `/.MainActivity` with `TRANSACT_*` extras | `app.json` sets the package id; `AppiumHarnessModule.getLaunchExtras()` reads the intent | +| Relaunches with fresh extras | Activity is `singleTask`; the module emits `AppiumHarnessLaunch` on resume, deduped by `TRANSACT_LAUNCH_ID` | +| Broadcasts to `.TransactCommandReceiver` by explicit component | Manifest-declared receiver relays to JS (a runtime-registered receiver is not addressable by component) | +| Greps logcat for `AppiumTestEnvironment` | `AppiumHarnessModule.log()` writes under that tag — RN's own `console.log` would land under `ReactNativeJS` | +| Looks for the home screen text `AppiumTestEnvironment` | Rendered by `App.tsx` | + +On iOS the contract is different: parameters arrive as **process environment variables** (via +`mobile:launchApp`), and the specs assert on native UI rather than logs. + +| What the suite does | How it's satisfied here | +| --- | --- | +| Passes `TRANSACT_*` as launch environment variables | `AppiumHarness.m` reads `NSProcessInfo` — Metro inlines `process.env` at build time, so JS cannot | +| Sends `atomictest://pause` / `resume` deep links | `expo.scheme` registers `atomictest`; RN `Linking` handles them | +| Reads the `PauseStatus` element | Rendered by `App.tsx` | +| Waits for alerts titled `Task Completed` / `Finished with Handoff: …`, taps `RESPOND!` | `Alert.alert` — RN renders a real `UIAlertController`, so XCUITest sees those labels | + +`android/` and `ios/` are gitignored — the plugin recreates all of it on every prebuild. + +## Building + +The suite needs a **standalone** build: no dev client, no Metro. A dev-client build would sit at the +dev launcher with no JS loaded once Appium reinstalls the APK (installing resets the app storage +that holds the bundle URL). + +Android, locally: + +```bash +yarn appium-app prebuild +cd android && ./gradlew :app:assembleRelease -x lint +# -> android/app/build/outputs/apk/release/app-release.apk +``` + +iOS, locally: + +```bash +npx expo prebuild --clean --platform ios +cd ios && LANG=en_US.UTF-8 pod install +xcodebuild -workspace AppiumTestEnvironment.xcworkspace -scheme AppiumTestEnvironment \ + -configuration Release -sdk iphonesimulator -destination 'id=' \ + -derivedDataPath ./build CODE_SIGNING_ALLOWED=NO build +# -> ios/build/Build/Products/Release-iphonesimulator/AppiumTestEnvironment.app +``` + +`LANG` on the `pod install` matters: without a UTF-8 locale CocoaPods dies in Ruby's unicode +normalization (`Unicode Normalization not appropriate for ASCII-8BIT`) before it reads the Podfile. + +Via EAS (the `appium` profile is standalone — `developmentClient: false`, internal distribution, +APK rather than AAB so Appium can install it): + +```bash +yarn appium-app build:android +``` + +## Running the conformance suite against it + +```bash +cd mobile-conformance-tests/appium + +# android +ANDROID_APP_PACKAGE=com.atomicfi.appiumtestenvironment.rn \ + ANDROID_APP_PATH=/path/to/app-release.apk \ + npm run wdio:android + +# ios +IOS_BUNDLE_ID=com.atomicfi.AppiumTestEnvironment.rn \ + IOS_APP_PATH=/path/to/AppiumTestEnvironment.app \ + npm run wdio:ios +``` + +This app has its own identifier (`com.atomicfi.appiumtestenvironment.rn`), so it installs alongside +the native test app rather than replacing it, and `ANDROID_APP_PACKAGE` selects which one a run +drives. Everything native is derived from `expo.android.package` by the config plugin — the Kotlin +package, the broadcast action, and the manifest-relative receiver name — so changing the id in +`app.json` is sufficient. + +## Known gaps + +These are React Native bridge limitations, not harness defects. In each case the harness logs that +the feature is unsupported so a failing spec says why rather than timing out silently. + +- **Pause/resume** (`pauseTransact.e2e.ts`) — the bridge exposes no equivalent of the native SDK's + pause/resume. Affects both platforms. +- **`FRAGMENT_FLOW`** (`c1PaylinkFlow.e2e.ts`) — the RN SDK presents Transact itself and exposes no + fragment host. +- **`DISMISS_ON_AUTH_STATUS_UPDATE_AUTHENTICATED`** (`authStatusUpdate.e2e.ts`) — unsupported on + Android (`Atomic.hideTransact()` is iOS-only). On iOS it is implemented but **not yet working**: + the spec looks for an alert titled with the custom flow name after Transact hides, and that alert + is not reliably appearing. It passed once and has failed consistently since. Unresolved — the + payload shape and the bridge's `onAuthStatusUpdate` wiring were both checked and look correct, so + the next step is capturing the app's console during an Appium-driven run (`log stream` does not + capture the harness's NSLog output; `simctl launch --console-pty` does, but Appium owns the + launch) to confirm whether the callback fires and `hideTransact` runs at all. +- **Deferred payment data response** (`deferredPayment.e2e.ts`) — the `onDataRequest` response only + round-trips on iOS; on Android the returned value never reaches the SDK. diff --git a/appium-test-environment/app.json b/appium-test-environment/app.json new file mode 100644 index 0000000..c620950 --- /dev/null +++ b/appium-test-environment/app.json @@ -0,0 +1,32 @@ +{ + "expo": { + "name": "AppiumTestEnvironment", + "slug": "atomic-appium-test-environment", + "version": "1.0.0", + "orientation": "portrait", + "userInterfaceStyle": "light", + "scheme": "atomictest", + "ios": { + "supportsTablet": true, + "bundleIdentifier": "com.atomicfi.AppiumTestEnvironment.rn", + "infoPlist": { + "ITSAppUsesNonExemptEncryption": false + } + }, + "android": { + "package": "com.atomicfi.appiumtestenvironment.rn" + }, + "plugins": [ + "./plugin/withAppiumTestEnvironment", + [ + "expo-build-properties", + { + "android": { + "kotlinVersion": "2.1.20", + "usesCleartextTraffic": true + } + } + ] + ] + } +} diff --git a/appium-test-environment/eas.json b/appium-test-environment/eas.json new file mode 100644 index 0000000..459c183 --- /dev/null +++ b/appium-test-environment/eas.json @@ -0,0 +1,25 @@ +{ + "cli": { + "version": ">= 7.8.0" + }, + "build": { + "appium": { + "developmentClient": false, + "distribution": "internal", + "channel": "appium", + "android": { + "buildType": "apk", + "gradleCommand": ":app:assembleRelease" + }, + "ios": { + "simulator": true + } + }, + "appium-device": { + "extends": "appium", + "ios": { + "simulator": false + } + } + } +} diff --git a/appium-test-environment/index.ts b/appium-test-environment/index.ts new file mode 100644 index 0000000..ce8f207 --- /dev/null +++ b/appium-test-environment/index.ts @@ -0,0 +1,5 @@ +import { registerRootComponent } from 'expo'; + +import App from './App'; + +registerRootComponent(App); diff --git a/appium-test-environment/package.json b/appium-test-environment/package.json new file mode 100644 index 0000000..6980903 --- /dev/null +++ b/appium-test-environment/package.json @@ -0,0 +1,26 @@ +{ + "name": "TransactAppiumTestEnvironment", + "version": "1.0.0", + "main": "index.ts", + "private": true, + "scripts": { + "start": "expo start", + "android": "expo run:android", + "prebuild": "expo prebuild --clean", + "build:android": "eas build --platform android --profile appium", + "build:ios": "eas build --platform ios --profile appium" + }, + "dependencies": { + "@atomicfi/transact-react-native": "link:..", + "expo": "~57.0.18", + "expo-build-properties": "~57.0.15", + "expo-status-bar": "~57.0.0", + "react": "19.2.3", + "react-native": "0.86.3" + }, + "devDependencies": { + "@babel/core": "^7.29.0", + "@types/react": "~19.2.14", + "typescript": "~6.0.3" + } +} diff --git a/appium-test-environment/plugin/src/android/AppiumHarnessModule.kt b/appium-test-environment/plugin/src/android/AppiumHarnessModule.kt new file mode 100644 index 0000000..7490f4d --- /dev/null +++ b/appium-test-environment/plugin/src/android/AppiumHarnessModule.kt @@ -0,0 +1,128 @@ +package com.atomicfi.appiumtestenvironment + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Build +import android.util.Log +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.LifecycleEventListener +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.WritableMap +import com.facebook.react.modules.core.DeviceEventManagerModule + +/** + * Bridges the Appium conformance contract to JS. + * + * The suite launches this app with TRANSACT_* intent extras and broadcasts commands to + * [TransactCommandReceiver]. Neither is visible to React Native on its own, so this module exposes + * the launch extras, forwards new intents and commands as events, and writes log lines under the + * `AppiumTestEnvironment` tag the suite's `waitForLogs` helper matches on. + */ +class AppiumHarnessModule(private val reactContext: ReactApplicationContext) : + ReactContextBaseJavaModule(reactContext), LifecycleEventListener { + + companion object { + const val TAG = "AppiumTestEnvironment" + private const val EVENT_LAUNCH = "AppiumHarnessLaunch" + private const val EVENT_COMMAND = "AppiumHarnessCommand" + } + + private var commandReceiver: BroadcastReceiver? = null + + override fun getName() = "AppiumHarness" + + init { + reactContext.addLifecycleEventListener(this) + TransactCommandReceiver.onCommand = { command, extras -> emitCommand(command, extras) } + } + + /** Extras from the intent that launched (or most recently resumed) the activity. */ + @ReactMethod + fun getLaunchExtras(promise: Promise) { + try { + promise.resolve(currentExtras()) + } catch (error: Exception) { + promise.reject("launch_extras_failed", error.message, error) + } + } + + /** Write to logcat under the tag the conformance suite greps for. */ + @ReactMethod + fun log(message: String) { + Log.d(TAG, message) + } + + // RN requires these for NativeEventEmitter; the events are emitted from native. + @ReactMethod + fun addListener(eventName: String) = Unit + + @ReactMethod + fun removeListeners(count: Int) = Unit + + private fun currentExtras(): WritableMap { + val map = Arguments.createMap() + val intent = reactContext.currentActivity?.intent ?: return map + val extras = intent.extras ?: return map + + for (key in extras.keySet()) { + if (!key.startsWith("TRANSACT_")) continue + val value = extras.getString(key) + if (value != null) { + map.putString(key, value) + } + } + + return map + } + + private fun emit(event: String, payload: WritableMap) { + reactContext + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit(event, payload) + } + + private fun emitCommand(command: String, extras: Map) { + val payload = Arguments.createMap() + payload.putString("command", command) + val extrasMap = Arguments.createMap() + for ((key, value) in extras) { + extrasMap.putString(key, value) + } + payload.putMap("extras", extrasMap) + Log.d(TAG, "Received command $command $extras") + emit(EVENT_COMMAND, payload) + } + + /** + * A `singleTask` relaunch delivers fresh extras through onNewIntent rather than a new activity, + * so the JS side is told to re-read them. Registered here rather than in MainActivity so the + * plugin does not have to patch the generated activity. + */ + override fun onHostResume() { + val activity = reactContext.currentActivity ?: return + val launchId = activity.intent?.getStringExtra("TRANSACT_LAUNCH_ID") ?: return + val payload = Arguments.createMap() + payload.putString("launchId", launchId) + payload.putMap("extras", currentExtras()) + emit(EVENT_LAUNCH, payload) + } + + override fun onHostPause() = Unit + + override fun onHostDestroy() { + commandReceiver?.let { + try { + reactContext.unregisterReceiver(it) + } catch (error: IllegalArgumentException) { + Log.w(TAG, "Command receiver already unregistered") + } + } + commandReceiver = null + TransactCommandReceiver.onCommand = null + } +} diff --git a/appium-test-environment/plugin/src/android/AppiumHarnessPackage.kt b/appium-test-environment/plugin/src/android/AppiumHarnessPackage.kt new file mode 100644 index 0000000..f7c3bac --- /dev/null +++ b/appium-test-environment/plugin/src/android/AppiumHarnessPackage.kt @@ -0,0 +1,14 @@ +package com.atomicfi.appiumtestenvironment + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +class AppiumHarnessPackage : ReactPackage { + override fun createNativeModules(reactContext: ReactApplicationContext): List = + listOf(AppiumHarnessModule(reactContext)) + + override fun createViewManagers(reactContext: ReactApplicationContext): List> = + emptyList() +} diff --git a/appium-test-environment/plugin/src/android/TransactCommandReceiver.kt b/appium-test-environment/plugin/src/android/TransactCommandReceiver.kt new file mode 100644 index 0000000..832c3f9 --- /dev/null +++ b/appium-test-environment/plugin/src/android/TransactCommandReceiver.kt @@ -0,0 +1,46 @@ +package com.atomicfi.appiumtestenvironment + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log + +/** + * Receives the conformance suite's command broadcasts. + * + * The suite addresses this class by explicit component + * (`am broadcast -a .COMMAND -n /.TransactCommandReceiver --es command PAUSE_TRANSACT`), + * so it has to exist as a manifest-declared class — a receiver registered at runtime is not + * addressable that way. Commands are handed to [AppiumHarnessModule] when React Native is up. + */ +class TransactCommandReceiver : BroadcastReceiver() { + + companion object { + /** Set by [AppiumHarnessModule] while React Native is running. */ + var onCommand: ((String, Map) -> Unit)? = null + } + + override fun onReceive(context: Context, intent: Intent) { + val command = intent.getStringExtra("command") + if (command == null) { + Log.w(AppiumHarnessModule.TAG, "Broadcast without a command extra") + return + } + + val extras = mutableMapOf() + intent.extras?.let { bundle -> + for (key in bundle.keySet()) { + if (key == "command") continue + bundle.getString(key)?.let { extras[key] = it } + } + } + + val handler = onCommand + if (handler == null) { + Log.w(AppiumHarnessModule.TAG, "Dropping command $command — React Native not running yet") + return + } + + handler(command, extras) + } +} diff --git a/appium-test-environment/plugin/src/ios/AppiumHarness.m b/appium-test-environment/plugin/src/ios/AppiumHarness.m new file mode 100644 index 0000000..632d935 --- /dev/null +++ b/appium-test-environment/plugin/src/ios/AppiumHarness.m @@ -0,0 +1,52 @@ +#import + +/** + * Bridges the Appium conformance contract to JS on iOS. + * + * The suite launches this app via `mobile:launchApp` with the TRANSACT_* values passed as process + * environment variables. React Native cannot read those — Metro inlines `process.env` at build + * time — so they are read here from NSProcessInfo and handed to JS. + * + * The interface is declared inline rather than in a header so the plugin has a single file to add + * to the target's Sources phase; a separate header would also need adding to the project, and + * Xcode would copy it into the app bundle as a resource. + */ +@interface AppiumHarness : NSObject +@end + +@implementation AppiumHarness + +RCT_EXPORT_MODULE(); + ++ (BOOL)requiresMainQueueSetup +{ + return NO; +} + +/** The TRANSACT_* process environment variables the suite launched this app with. */ +RCT_EXPORT_METHOD(getLaunchExtras + : (RCTPromiseResolveBlock)resolve reject + : (RCTPromiseRejectBlock)reject) +{ + NSMutableDictionary *extras = [NSMutableDictionary dictionary]; + NSDictionary *environment = [[NSProcessInfo processInfo] environment]; + + for (NSString *key in environment) { + if ([key hasPrefix:@"TRANSACT_"]) { + extras[key] = environment[key]; + } + } + + resolve(extras); +} + +/** + * Log under the same tag the Android harness uses. The iOS specs assert on UI rather than logs, so + * this is for diagnosing runs rather than for any assertion. + */ +RCT_EXPORT_METHOD(log : (NSString *)message) +{ + NSLog(@"AppiumTestEnvironment: %@", message); +} + +@end diff --git a/appium-test-environment/plugin/withAppiumTestEnvironment.js b/appium-test-environment/plugin/withAppiumTestEnvironment.js new file mode 100644 index 0000000..6d0e3fc --- /dev/null +++ b/appium-test-environment/plugin/withAppiumTestEnvironment.js @@ -0,0 +1,212 @@ +/** + * Expo config plugin for the Appium test environment. + * + * The shared conformance suite (mobile-conformance-tests) drives the native Android test app by: + * - starting `com.atomicfi.appiumtestenvironment/.MainActivity` with TRANSACT_* intent extras + * - broadcasting to an explicitly-named `.TransactCommandReceiver` component + * - matching logcat lines containing `AppiumTestEnvironment` + * + * None of that is reachable from JS on its own, and `android/` is regenerated by every prebuild, so + * this plugin injects the native pieces at prebuild time: a React Native module that reads the + * launch intent's extras and relays commands, the manifest-declared broadcast receiver the suite + * addresses by name, and the module's registration in MainApplication. + */ + +const { + AndroidConfig, + IOSConfig, + withAndroidManifest, + withMainApplication, + withDangerousMod, + withXcodeProject, +} = require('@expo/config-plugins'); +const fs = require('fs'); +const path = require('path'); + +/** The package the Kotlin sources are authored under; rewritten to the app's own on copy. */ +const SOURCE_PACKAGE = 'com.atomicfi.appiumtestenvironment'; +const SOURCE_DIR = path.join(__dirname, 'src', 'android'); +const IOS_SOURCE_DIR = path.join(__dirname, 'src', 'ios'); +const IOS_SOURCES = ['AppiumHarness.m']; + +/** + * The app's Android applicationId. Everything native is derived from it: manifest-relative class + * names (`.TransactCommandReceiver`) resolve against it, and the conformance suite builds both the + * broadcast action and the target component from the same id — so they have to agree. + */ +const androidPackage = (config) => { + const value = config.android?.package; + if (!value) { + throw new Error( + 'withAppiumTestEnvironment: expo.android.package must be set in app.json.' + ); + } + return value; +}; + +/** Copy the Kotlin sources into the generated project, under the harness package. */ +const withHarnessSources = (config) => + withDangerousMod(config, [ + 'android', + (config) => { + const pkg = androidPackage(config); + const destination = path.join( + config.modRequest.platformProjectRoot, + 'app', + 'src', + 'main', + 'java', + ...pkg.split('.') + ); + fs.mkdirSync(destination, { recursive: true }); + + for (const file of fs.readdirSync(SOURCE_DIR)) { + const source = fs.readFileSync(path.join(SOURCE_DIR, file), 'utf8'); + fs.writeFileSync( + path.join(destination, file), + source.replace(`package ${SOURCE_PACKAGE}`, `package ${pkg}`), + 'utf8' + ); + } + + return config; + }, + ]); + +/** + * Declare the broadcast receiver. The suite targets it by explicit component + * (`-n /.TransactCommandReceiver`), which only resolves against a manifest entry — a + * runtime-registered receiver is not addressable that way. + */ +const withCommandReceiver = (config) => + withAndroidManifest(config, (config) => { + const application = AndroidConfig.Manifest.getMainApplicationOrThrow( + config.modResults + ); + + application.receiver = (application.receiver || []).filter( + (item) => item.$?.['android:name'] !== '.TransactCommandReceiver' + ); + + application.receiver.push({ + '$': { + 'android:name': '.TransactCommandReceiver', + 'android:exported': 'true', + }, + 'intent-filter': [ + { + action: [ + { $: { 'android:name': `${androidPackage(config)}.COMMAND` } }, + ], + }, + ], + }); + + return config; + }); + +/** + * `singleTask` matches the native test app: relaunching with fresh extras delivers onNewIntent to + * the existing instance rather than stacking activities. + */ +const withSingleTaskLaunchMode = (config) => + withAndroidManifest(config, (config) => { + const activity = AndroidConfig.Manifest.getMainActivityOrThrow( + config.modResults + ); + activity.$['android:launchMode'] = 'singleTask'; + return config; + }); + +/** Register the harness package with React Native. */ +const withPackageRegistration = (config) => + withMainApplication(config, (config) => { + const contents = config.modResults.contents; + + if (contents.includes('AppiumHarnessPackage()')) { + return config; + } + + // The Expo template leaves a commented example inside `PackageList(this).packages.apply { }` + // for exactly this purpose; append the real registration after it. + const marker = '// add(MyReactNativePackage())'; + + if (!contents.includes(marker)) { + throw new Error( + 'withAppiumTestEnvironment: could not find the manual package list in MainApplication — the Expo template changed, update this plugin.' + ); + } + + config.modResults.contents = contents.replace( + marker, + `${marker}\n add(${androidPackage(config)}.AppiumHarnessPackage())` + ); + return config; + }); + +/** Copy the Objective-C module into the generated iOS project, next to the app's own sources. */ +const withIosHarnessSources = (config) => + withDangerousMod(config, [ + 'ios', + (config) => { + const projectName = config.modRequest.projectName; + if (!projectName) { + throw new Error( + 'withAppiumTestEnvironment: no iOS project name available.' + ); + } + + const destination = path.join( + config.modRequest.platformProjectRoot, + projectName + ); + fs.mkdirSync(destination, { recursive: true }); + + for (const file of IOS_SOURCES) { + fs.copyFileSync( + path.join(IOS_SOURCE_DIR, file), + path.join(destination, file) + ); + } + + return config; + }, + ]); + +/** + * Add the module to the app target. Copying the files is not enough — a source file Xcode does not + * know about is never compiled, and `RCT_EXPORT_MODULE` would never run, leaving + * `NativeModules.AppiumHarness` undefined at runtime with no build error to explain it. + */ +const withIosBuildSources = (config) => + withXcodeProject(config, (config) => { + const project = config.modResults; + const projectName = config.modRequest.projectName; + + for (const file of IOS_SOURCES) { + const filePath = `${projectName}/${file}`; + + if (project.hasFile(filePath)) { + continue; + } + + IOSConfig.XcodeUtils.addBuildSourceFileToGroup({ + filepath: filePath, + groupName: projectName, + project, + verbose: false, + }); + } + + return config; + }); + +module.exports = (config) => { + config = withHarnessSources(config); + config = withCommandReceiver(config); + config = withSingleTaskLaunchMode(config); + config = withPackageRegistration(config); + config = withIosHarnessSources(config); + config = withIosBuildSources(config); + return config; +}; diff --git a/appium-test-environment/src/harness.ts b/appium-test-environment/src/harness.ts new file mode 100644 index 0000000..5c25fbf --- /dev/null +++ b/appium-test-environment/src/harness.ts @@ -0,0 +1,49 @@ +import { NativeEventEmitter, NativeModules, Platform } from 'react-native'; + +export interface HarnessNativeModule { + getLaunchExtras(): Promise>; + log(message: string): void; +} + +const native = NativeModules.AppiumHarness as HarnessNativeModule | undefined; + +/** + * Log under the `AppiumTestEnvironment` tag. + * + * Routed through the native module rather than console.log because the Android specs grep logcat: + * React Native's own console bridge logs everything under `ReactNativeJS`, which never matches. + */ +export const log = (message: string) => { + if (native) { + native.log(message); + } else { + console.log(`AppiumTestEnvironment: ${message}`); + } +}; + +/** + * Launch parameters the suite supplied — intent extras on Android, process environment variables + * on iOS. The native module normalizes both to the same TRANSACT_* map. + */ +export const getLaunchExtras = async (): Promise> => { + if (!native) return {}; + try { + return await native.getLaunchExtras(); + } catch (error) { + log(`Failed to read launch extras: ${String(error)}`); + return {}; + } +}; + +/** + * Android-only. The Android module emits relaunch and broadcast-command events; on iOS the suite + * sends commands as `atomictest://` deep links, which RN's Linking handles, and the iOS module + * exposes no events — constructing an emitter for it would warn about the missing + * addListener/removeListeners. + */ +export const harnessEvents = + native && Platform.OS === 'android' + ? new NativeEventEmitter(NativeModules.AppiumHarness) + : null; + +export const isHarnessAvailable = Boolean(native); diff --git a/appium-test-environment/src/launchConfig.ts b/appium-test-environment/src/launchConfig.ts new file mode 100644 index 0000000..2569778 --- /dev/null +++ b/appium-test-environment/src/launchConfig.ts @@ -0,0 +1,109 @@ +import { Environment, Product, Scope } from '@atomicfi/transact-react-native'; +import type { TransactEnvironment } from '@atomicfi/transact-react-native'; + +/** + * The TRANSACT_* intent extras the conformance suite launches this app with. Mirrors the contract + * implemented by the native Android test app (atomic-transact-android/AppiumTestEnvironment). + */ +export interface LaunchExtras { + TRANSACT_LAUNCH_ID?: string; + TRANSACT_PUBLIC_TOKEN?: string; + TRANSACT_URL?: string; + TRANSACT_PRODUCT_TYPE?: string; + TRANSACT_SCOPE_TYPE?: string; + TRANSACT_DEEPLINK?: string; + TRANSACT_HANDOFF?: string; + TRANSACT_CUSTOM_FLOW?: string; + TRANSACT_DEFERRED_PAYMENT_METHOD_STRATEGY?: string; + TRANSACT_ACTION_ID?: string; +} + +const PRODUCTS: Record = { + deposit: Product.DEPOSIT, + verify: Product.VERIFY, + identify: Product.IDENTIFY, + switch: Product.SWITCH, + withhold: Product.WITHHOLD, + present: Product.PRESENT, + manage: Product.MANAGE, +}; + +const SCOPES: Record = { + 'user-link': Scope.USERLINK, + 'user_link': Scope.USERLINK, + 'employer-link': Scope.EMPLOYERLINK, + 'employer_link': Scope.EMPLOYERLINK, + 'pay-link': Scope.PAYLINK, + 'pay_link': Scope.PAYLINK, +}; + +/** The suite base64-encodes the deeplink JSON, matching what the native app decodes. */ +export function decodeDeeplink( + encoded?: string +): Record | null { + if (!encoded) return null; + + try { + // The payload is ASCII JSON (step/companyId/connectorId), so atob's binary string is the JSON + // as-is; no UTF-8 decoding step is needed. + return JSON.parse(globalThis.atob(encoded)); + } catch { + return null; + } +} + +export function isLaunchable(extras: LaunchExtras): boolean { + return Boolean( + extras.TRANSACT_PUBLIC_TOKEN && + extras.TRANSACT_URL && + extras.TRANSACT_PRODUCT_TYPE && + extras.TRANSACT_SCOPE_TYPE + ); +} + +export function buildEnvironment(extras: LaunchExtras): TransactEnvironment { + if (extras.TRANSACT_URL) { + return Environment.custom( + extras.TRANSACT_URL, + process.env.EXPO_PUBLIC_API_URL || 'https://api.atomicfi.com' + ); + } + return Environment.sandbox; +} + +export function buildConfig(extras: LaunchExtras): Record { + const product = + PRODUCTS[(extras.TRANSACT_PRODUCT_TYPE || '').toLowerCase()] || + Product.DEPOSIT; + const scope = + SCOPES[(extras.TRANSACT_SCOPE_TYPE || '').toLowerCase()] || Scope.USERLINK; + + const config: Record = { + publicToken: extras.TRANSACT_PUBLIC_TOKEN, + scope, + tasks: [ + extras.TRANSACT_ACTION_ID + ? { operation: 'action', action: { id: extras.TRANSACT_ACTION_ID } } + : { operation: product }, + ], + }; + + const deeplink = decodeDeeplink(extras.TRANSACT_DEEPLINK); + if (deeplink) { + config.deeplink = deeplink; + } + + const handoff = extras.TRANSACT_HANDOFF?.split(',') + .map((value) => value.trim()) + .filter(Boolean); + if (handoff && handoff.length > 0) { + config.handoff = handoff; + } + + if (extras.TRANSACT_DEFERRED_PAYMENT_METHOD_STRATEGY) { + config.deferredPaymentMethodStrategy = + extras.TRANSACT_DEFERRED_PAYMENT_METHOD_STRATEGY.toLowerCase(); + } + + return config; +} diff --git a/appium-test-environment/tsconfig.json b/appium-test-environment/tsconfig.json new file mode 100644 index 0000000..3a6c2ea --- /dev/null +++ b/appium-test-environment/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules", "android", "ios"] +} diff --git a/package.json b/package.json index d902a57..dca0abb 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,8 @@ "lint": "eslint \"**/*.{js,ts,tsx}\"", "clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib", "prepare": "bob build && cp package.json lib/package.json", - "prepack": "bob build && cp package.json lib/package.json" + "prepack": "bob build && cp package.json lib/package.json", + "appium-app": "yarn workspace TransactAppiumTestEnvironment" }, "keywords": [ "react-native", @@ -103,7 +104,8 @@ } }, "workspaces": [ - "example" + "example", + "appium-test-environment" ], "resolutions": { "@eslint/eslintrc/js-yaml": "^4.3.1", diff --git a/yarn.lock b/yarn.lock index 90a6c48..fd35b3a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -21,6 +21,12 @@ __metadata: languageName: node linkType: hard +"@atomicfi/transact-react-native@link:..::locator=TransactAppiumTestEnvironment%40workspace%3Aappium-test-environment": + version: 0.0.0-use.local + resolution: "@atomicfi/transact-react-native@link:..::locator=TransactAppiumTestEnvironment%40workspace%3Aappium-test-environment" + languageName: node + linkType: soft + "@atomicfi/transact-react-native@link:..::locator=example%40workspace%3Aexample": version: 0.0.0-use.local resolution: "@atomicfi/transact-react-native@link:..::locator=example%40workspace%3Aexample" @@ -3870,6 +3876,22 @@ __metadata: languageName: node linkType: hard +"TransactAppiumTestEnvironment@workspace:appium-test-environment": + version: 0.0.0-use.local + resolution: "TransactAppiumTestEnvironment@workspace:appium-test-environment" + dependencies: + "@atomicfi/transact-react-native": "link:.." + "@babel/core": "npm:^7.29.0" + "@types/react": "npm:~19.2.14" + expo: "npm:~57.0.18" + expo-build-properties: "npm:~57.0.15" + expo-status-bar: "npm:~57.0.0" + react: "npm:19.2.3" + react-native: "npm:0.86.3" + typescript: "npm:~6.0.3" + languageName: unknown + linkType: soft + "abbrev@npm:^3.0.0": version: 3.0.1 resolution: "abbrev@npm:3.0.1" From b08239791ba58ffe6f2ec074e4cd1e9987ce4399 Mon Sep 17 00:00:00 2001 From: Braxton Ward Date: Tue, 15 Sep 2026 11:53:38 -0600 Subject: [PATCH 02/17] chore(appium): set the EAS owner to the atomicfi org Without an owner, `eas init` creates the project under whichever personal account is logged in rather than the organization. Matches example/app.json. --- appium-test-environment/app.json | 1 + 1 file changed, 1 insertion(+) diff --git a/appium-test-environment/app.json b/appium-test-environment/app.json index c620950..976bc94 100644 --- a/appium-test-environment/app.json +++ b/appium-test-environment/app.json @@ -2,6 +2,7 @@ "expo": { "name": "AppiumTestEnvironment", "slug": "atomic-appium-test-environment", + "owner": "atomicfi", "version": "1.0.0", "orientation": "portrait", "userInterfaceStyle": "light", From 1ac0ce9debdf4312cd9fe150e9a2ff29bf1fc69d Mon Sep 17 00:00:00 2001 From: Braxton Ward Date: Tue, 15 Sep 2026 12:06:17 -0600 Subject: [PATCH 03/17] fix: correct AGENTS.md casing in gitignore The pattern read AGENTS.MD while the file is AGENTS.md. macOS is case-insensitive and core.ignorecase is true, so git matched it locally, but case-sensitive tooling did not: EAS Build saw the file as untracked and failed the upload with "Detected inconsistent filename casing between your local filesystem and git". --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 398a37a..f03db8a 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,7 @@ # AGENTS CLAUDE.md -AGENTS.MD +AGENTS.md # Cursor .cursor/ From c65c91ffeb93bab8ce292ac029e5204374ff2495 Mon Sep 17 00:00:00 2001 From: Braxton Ward Date: Tue, 15 Sep 2026 12:08:21 -0600 Subject: [PATCH 04/17] chore(appium): set cli.appVersionSource in eas.json Silences the "not set, but it will be required in the future" warning. "local" keeps the version in app config rather than having EAS manage and auto-increment it remotely, which suits a test app that is never submitted to a store. --- appium-test-environment/eas.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/appium-test-environment/eas.json b/appium-test-environment/eas.json index 459c183..50adccd 100644 --- a/appium-test-environment/eas.json +++ b/appium-test-environment/eas.json @@ -1,6 +1,7 @@ { "cli": { - "version": ">= 7.8.0" + "version": ">= 7.8.0", + "appVersionSource": "local" }, "build": { "appium": { From 5a1ef950604c9a6c3e6bf7fe72d97ed65d98f0a7 Mon Sep 17 00:00:00 2001 From: Braxton Ward Date: Tue, 15 Sep 2026 12:11:06 -0600 Subject: [PATCH 05/17] chore(appium): drop the EAS Update channel from the build profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "appium" profile set a channel, which requires EAS Update: running a build installed expo-updates and wrote updates.url and runtimeVersion into app.json. None of that is wanted here — Appium reinstalls the app on every run, so there is nothing for OTA updates to do, and expo-updates is what fails the release build in the example app (expo-updates:kspReleaseKotlin). Remove the channel, the update config and the dependency; keep the EAS projectId. Verified the release build and the conformance suite still pass without it. --- appium-test-environment/app.json | 7 ++++++- appium-test-environment/eas.json | 1 - 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/appium-test-environment/app.json b/appium-test-environment/app.json index 976bc94..0533267 100644 --- a/appium-test-environment/app.json +++ b/appium-test-environment/app.json @@ -28,6 +28,11 @@ } } ] - ] + ], + "extra": { + "eas": { + "projectId": "c2ae6425-b3b2-4a8b-bbf1-9ccf4e835c74" + } + } } } diff --git a/appium-test-environment/eas.json b/appium-test-environment/eas.json index 50adccd..66739e3 100644 --- a/appium-test-environment/eas.json +++ b/appium-test-environment/eas.json @@ -7,7 +7,6 @@ "appium": { "developmentClient": false, "distribution": "internal", - "channel": "appium", "android": { "buildType": "apk", "gradleCommand": ":app:assembleRelease" From 427b8c9ae27ecdac9227fcbaa5578b08d1e80e02 Mon Sep 17 00:00:00 2001 From: Braxton Ward Date: Tue, 15 Sep 2026 13:42:14 -0600 Subject: [PATCH 06/17] fix(appium): emit harness logs through os_log on iOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NSLog only reaches a console attached to the process, so an Appium-driven run — where Appium owns the launch — showed no harness output at all, which made the authStatusUpdate failure impossible to diagnose. os_log is readable after the fact with xcrun simctl spawn log show --last 5m \ --predicate 'subsystem == "com.atomicfi.appium"' NSLog is kept so `simctl launch --console-pty` still shows the same lines. --- .../plugin/src/ios/AppiumHarness.m | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/appium-test-environment/plugin/src/ios/AppiumHarness.m b/appium-test-environment/plugin/src/ios/AppiumHarness.m index 632d935..cd58f19 100644 --- a/appium-test-environment/plugin/src/ios/AppiumHarness.m +++ b/appium-test-environment/plugin/src/ios/AppiumHarness.m @@ -1,4 +1,5 @@ #import +#import /** * Bridges the Appium conformance contract to JS on iOS. @@ -43,9 +44,21 @@ + (BOOL)requiresMainQueueSetup /** * Log under the same tag the Android harness uses. The iOS specs assert on UI rather than logs, so * this is for diagnosing runs rather than for any assertion. + * + * Emitted through os_log as well as NSLog: NSLog only reaches a console attached to the process, so + * an Appium-driven run (where Appium owns the launch) shows nothing. os_log is readable live with + * + * xcrun simctl spawn log stream --predicate 'subsystem == "com.atomicfi.appium"' */ RCT_EXPORT_METHOD(log : (NSString *)message) { + static os_log_t harnessLog; + static dispatch_once_t once; + dispatch_once(&once, ^{ + harnessLog = os_log_create("com.atomicfi.appium", "AppiumTestEnvironment"); + }); + + os_log(harnessLog, "AppiumTestEnvironment: %{public}@", message); NSLog(@"AppiumTestEnvironment: %@", message); } From d79f59d6242df82de727c57dd8c32d1452ce9eeb Mon Sep 17 00:00:00 2001 From: Braxton Ward Date: Tue, 15 Sep 2026 15:26:51 -0600 Subject: [PATCH 07/17] fix(appium): queue alerts so the dismiss alert is visible to XCUITest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit authStatusUpdate was failing on iOS because two alerts were presented within a second of each other: the custom flow's dismiss confirmation, then "Task Completed". iOS shows only the topmost alert and the spec matches on it by title, so the dismiss alert was on screen but underneath — invisible to XCUITest. Serialize alerts through a queue, presenting the next only once the current one is acknowledged, which is what the native iOS test app does with its nextAlertPresentation. Also present the dismiss alert promptly after hiding rather than waiting on onClose/onCleanup. hideTransact is a hide, not a close: it emits neither callback, and the task keeps running — Transact re-presents itself for the summary shortly after — so waiting on them meant the alert only ever came from a timeout, by which point it could be covered. Verified 3/3 on iOS in ~23s, matching the native test app. The README gains a "Gotchas" section covering those two, plus two traps found along the way: the atomictest:// scheme collides with the native iOS test app, and RN's Linking never receives those URLs because the Expo AppDelegate short-circuits openURL before RCTLinkingManager. --- appium-test-environment/App.tsx | 62 +++++++++++++++++++------------ appium-test-environment/README.md | 30 +++++++++++---- 2 files changed, 60 insertions(+), 32 deletions(-) diff --git a/appium-test-environment/App.tsx b/appium-test-environment/App.tsx index b084a87..46c2daa 100644 --- a/appium-test-environment/App.tsx +++ b/appium-test-environment/App.tsx @@ -37,29 +37,47 @@ export default function App() { // `atomictest://pause`, mirroring the native iOS test app's PauseStatus label. const [pauseStatus, setPauseStatus] = useState(''); const lastLaunchId = useRef(null); - // Set when a custom flow hides Transact, cleared once the confirmation alert is presented. - const pendingDismissAlert = useRef(null); /** - * Present the custom-flow confirmation alert once Transact has dismissed. + * Alerts are presented one at a time. * - * The close/cleanup callbacks fire as dismissal *starts*, so presenting synchronously from them - * puts the alert up while Transact still owns the screen and it is silently dropped. Scheduling - * it a beat later lets the dismissal finish first. The flag is cleared only when the alert is - * actually presented, so an early call cannot swallow it. + * iOS shows only the topmost alert, and the specs match on it by title — so a second alert + * presented while one is up hides the first from XCUITest. In the auth-dismiss flow the task + * completes moments after Transact hides, so the "Task Completed" alert would land on top of the + * dismiss alert the spec is waiting for. The native iOS test app queues alerts the same way, + * presenting the next only once the current one is acknowledged. */ - const showPendingDismissAlert = useCallback((delayMs: number = 800) => { - if (!pendingDismissAlert.current) return; + const alertQueue = useRef<{ title: string; message?: string }[]>([]); + const alertShowing = useRef(false); - setTimeout(() => { - const title = pendingDismissAlert.current; - if (!title) return; - pendingDismissAlert.current = null; - log(`Presenting dismiss alert: ${title}`); - Alert.alert(title, undefined, [{ text: 'Okay' }]); - }, delayMs); + const presentNextAlert = useCallback(() => { + if (alertShowing.current) return; + + const next = alertQueue.current.shift(); + if (!next) return; + + alertShowing.current = true; + log(`Presenting alert: ${next.title}`); + Alert.alert(next.title, next.message, [ + { + text: 'Okay', + onPress: () => { + alertShowing.current = false; + presentNextAlert(); + }, + }, + ]); }, []); + const enqueueAlert = useCallback( + (title: string, message?: string) => { + alertQueue.current.push({ title, message }); + // A short delay lets a dismissal animation finish; presenting into one is dropped by UIKit. + setTimeout(presentNextAlert, 300); + }, + [presentNextAlert] + ); + const launch = useCallback( (extras: LaunchExtras) => { if (!isLaunchable(extras)) { @@ -136,9 +154,7 @@ export default function App() { // view controller, so one fired while Transact is still dismissing is silently dropped. // Driven off the close/cleanup callbacks rather than a fixed delay, with a backstop in // case neither fires. - pendingDismissAlert.current = customFlow; - // Backstop in case neither close nor cleanup fires. - setTimeout(() => showPendingDismissAlert(0), 3000); + enqueueAlert(customFlow); } } }, @@ -150,7 +166,7 @@ export default function App() { Platform.OS === 'ios' && String(state).toUpperCase() === 'COMPLETED' ) { - Alert.alert( + enqueueAlert( 'Task Completed', `company: ${update?.company?.name ?? 'unknown'}` ); @@ -195,7 +211,6 @@ export default function App() { }, onClose: (data: any) => { log(`RECEIVER close ${stringify(data)}`); - showPendingDismissAlert(); }, onFinish: (data: any) => { log(`RECEIVER finish ${stringify(data)}`); @@ -203,13 +218,12 @@ export default function App() { log(`Finished with Handoff: ${data.handoff}`); // The iOS handoff spec looks for an element labelled with this exact string. if (Platform.OS === 'ios') { - Alert.alert(`Finished with Handoff: ${data.handoff}`); + enqueueAlert(`Finished with Handoff: ${data.handoff}`); } } }, onCleanup: () => { log('callback:Cleanup'); - showPendingDismissAlert(); }, onError: (error: any) => { log(`RECEIVER error ${stringify(error)}`); @@ -218,7 +232,7 @@ export default function App() { log(`transact-launch:${launchId ?? 'no-launch-id'}`); }, - [showPendingDismissAlert] + [enqueueAlert] ); useEffect(() => { diff --git a/appium-test-environment/README.md b/appium-test-environment/README.md index 4505bbe..60bfd2f 100644 --- a/appium-test-environment/README.md +++ b/appium-test-environment/README.md @@ -99,13 +99,27 @@ the feature is unsupported so a failing spec says why rather than timing out sil pause/resume. Affects both platforms. - **`FRAGMENT_FLOW`** (`c1PaylinkFlow.e2e.ts`) — the RN SDK presents Transact itself and exposes no fragment host. -- **`DISMISS_ON_AUTH_STATUS_UPDATE_AUTHENTICATED`** (`authStatusUpdate.e2e.ts`) — unsupported on - Android (`Atomic.hideTransact()` is iOS-only). On iOS it is implemented but **not yet working**: - the spec looks for an alert titled with the custom flow name after Transact hides, and that alert - is not reliably appearing. It passed once and has failed consistently since. Unresolved — the - payload shape and the bridge's `onAuthStatusUpdate` wiring were both checked and look correct, so - the next step is capturing the app's console during an Appium-driven run (`log stream` does not - capture the harness's NSLog output; `simctl launch --console-pty` does, but Appium owns the - launch) to confirm whether the callback fires and `hideTransact` runs at all. +- **`DISMISS_ON_AUTH_STATUS_UPDATE_AUTHENTICATED`** (`authStatusUpdate.e2e.ts`) — works on iOS, + unsupported on Android: `Atomic.hideTransact()` is iOS-only in the bridge. The Android harness + logs that when the flow is requested. + +## Gotchas worth knowing + +Things that cost real debugging time here: + +- **`hideTransact` is a hide, not a close.** It emits no `onClose`/`onCleanup`, and the task keeps + running — Transact re-presents itself afterwards to show the summary. Do not treat the absence of + a close callback as evidence that it failed. +- **Alerts must be queued.** iOS shows only the topmost alert and the specs match on it by title, so + a second alert presented while one is up hides the first from XCUITest. The auth-dismiss flow and + the task-completed alert land within a second of each other. `enqueueAlert` serializes them; the + native iOS test app does the same thing with its `nextAlertPresentation`. +- **The `atomictest://` scheme collides with the native iOS test app**, which registers it too. With + both installed, `simctl openurl` routes to whichever iOS picks, so deep-link commands can land in + the wrong app. Anything relying on those (`pauseTransact`) needs a distinct scheme first. +- **RN's `Linking` does not receive those URLs anyway.** The generated `AppDelegate` does + `super.application(app, open:options:) || RCTLinkingManager.application(...)`, so when Expo's + implementation handles the URL and returns true, `RCTLinkingManager` is never called and JS sees + no `url` event. A command channel here needs an Expo AppDelegate subscriber or `expo-linking`. - **Deferred payment data response** (`deferredPayment.e2e.ts`) — the `onDataRequest` response only round-trips on iOS; on Android the returned value never reaches the SDK. From 20425e86c0e9833825c112ccc934fe92abb9ae43 Mon Sep 17 00:00:00 2001 From: Braxton Ward Date: Tue, 15 Sep 2026 15:58:20 -0600 Subject: [PATCH 08/17] chore(appium): add local build scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build:local:android / build:local:ios run prebuild then Gradle or xcodebuild directly and print the artifact path. They encode two things that are easy to get wrong by hand: JDK 17 (newer JDKs fail inside the Kotlin Gradle plugin) and a UTF-8 locale for anything invoking CocoaPods (pod install otherwise dies in Ruby's unicode normalization before reading the Podfile). Also add build:eas:*:local alongside the cloud builds. EAS is the better choice when the artifact needs to match CI, since it is the same pipeline — but it archives the project through git, so uncommitted changes are excluded and it is a clean build every time. The direct scripts stay for iterating. Both direct scripts verified end to end, and the resulting artifacts pass specs on iOS and Android. The eas --local Android build also succeeds; its artifact lands as a timestamped file in the app directory, now gitignored. --- appium-test-environment/.gitignore | 5 +++ appium-test-environment/README.md | 46 +++++++++++++++------------- appium-test-environment/package.json | 11 +++++-- 3 files changed, 39 insertions(+), 23 deletions(-) diff --git a/appium-test-environment/.gitignore b/appium-test-environment/.gitignore index f93d12c..10242da 100644 --- a/appium-test-environment/.gitignore +++ b/appium-test-environment/.gitignore @@ -6,3 +6,8 @@ node_modules/ .expo/ + +# `eas build --local` drops its artifact here, named with a timestamp. +build-*.apk +build-*.tar.gz +build-*.ipa diff --git a/appium-test-environment/README.md b/appium-test-environment/README.md index 60bfd2f..abce238 100644 --- a/appium-test-environment/README.md +++ b/appium-test-environment/README.md @@ -36,38 +36,42 @@ On iOS the contract is different: parameters arrive as **process environment var ## Building The suite needs a **standalone** build: no dev client, no Metro. A dev-client build would sit at the -dev launcher with no JS loaded once Appium reinstalls the APK (installing resets the app storage -that holds the bundle URL). +dev launcher with no JS loaded once Appium reinstalls the app, because installing resets the app +storage that holds the bundle URL. -Android, locally: +### Local toolchain (fast, use while iterating) ```bash -yarn appium-app prebuild -cd android && ./gradlew :app:assembleRelease -x lint -# -> android/app/build/outputs/apk/release/app-release.apk +yarn appium-app build:local:android # -> android/app/build/outputs/apk/release/app-release.apk +yarn appium-app build:local:ios # -> ios/build/Build/Products/Release-iphonesimulator/AppiumTestEnvironment.app ``` -iOS, locally: +Each runs `expo prebuild --clean` and then Gradle or xcodebuild, printing the artifact path. Two +things they handle that are easy to get wrong by hand: -```bash -npx expo prebuild --clean --platform ios -cd ios && LANG=en_US.UTF-8 pod install -xcodebuild -workspace AppiumTestEnvironment.xcworkspace -scheme AppiumTestEnvironment \ - -configuration Release -sdk iphonesimulator -destination 'id=' \ - -derivedDataPath ./build CODE_SIGNING_ALLOWED=NO build -# -> ios/build/Build/Products/Release-iphonesimulator/AppiumTestEnvironment.app -``` - -`LANG` on the `pod install` matters: without a UTF-8 locale CocoaPods dies in Ruby's unicode -normalization (`Unicode Normalization not appropriate for ASCII-8BIT`) before it reads the Podfile. +- **JDK 17** is pinned via `/usr/libexec/java_home -v 17`. Newer JDKs fail inside the Kotlin Gradle + plugin with an internal compiler error. +- **A UTF-8 locale** is set for anything that runs CocoaPods. Without it `pod install` dies in + Ruby's unicode normalization (`Unicode Normalization not appropriate for ASCII-8BIT`) before it + even reads the Podfile. -Via EAS (the `appium` profile is standalone — `developmentClient: false`, internal distribution, -APK rather than AAB so Appium can install it): +### EAS ```bash -yarn appium-app build:android +yarn appium-app build:eas:android # cloud +yarn appium-app build:eas:ios +yarn appium-app build:eas:android:local # same pipeline, on this machine +yarn appium-app build:eas:ios:local ``` +Prefer the EAS builds when you care about producing what CI produces — same pipeline, no drift. + +**`--local` builds from git, not your working tree.** EAS archives the project through the VCS, so +uncommitted changes are silently excluded and you can end up testing stale code. (It is also what +surfaces filename-casing mismatches: a `.gitignore` entry whose case does not match the file is +invisible on macOS but fails the EAS upload.) Commit first, or use the local-toolchain scripts while +iterating. They are also clean builds each time, where xcodebuild reuses `derivedDataPath`. + ## Running the conformance suite against it ```bash diff --git a/appium-test-environment/package.json b/appium-test-environment/package.json index 6980903..0f66122 100644 --- a/appium-test-environment/package.json +++ b/appium-test-environment/package.json @@ -7,8 +7,15 @@ "start": "expo start", "android": "expo run:android", "prebuild": "expo prebuild --clean", - "build:android": "eas build --platform android --profile appium", - "build:ios": "eas build --platform ios --profile appium" + "prebuild:android": "expo prebuild --clean --platform android", + "prebuild:ios": "LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 expo prebuild --clean --platform ios", + "pods": "cd ios && LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 pod install", + "build:local:android": "yarn prebuild:android && cd android && JAVA_HOME=\"$(/usr/libexec/java_home -v 17)\" ./gradlew :app:assembleRelease -x lint -x lintVitalAnalyzeRelease -x lintVitalRelease && echo \"APK: $(pwd)/app/build/outputs/apk/release/app-release.apk\"", + "build:local:ios": "yarn prebuild:ios && cd ios && xcodebuild -workspace AppiumTestEnvironment.xcworkspace -scheme AppiumTestEnvironment -configuration Release -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' -derivedDataPath ./build CODE_SIGNING_ALLOWED=NO build && echo \"APP: $(pwd)/build/Build/Products/Release-iphonesimulator/AppiumTestEnvironment.app\"", + "build:eas:android": "eas build --platform android --profile appium", + "build:eas:ios": "eas build --platform ios --profile appium", + "build:eas:android:local": "eas build --platform android --profile appium --local", + "build:eas:ios:local": "eas build --platform ios --profile appium --local" }, "dependencies": { "@atomicfi/transact-react-native": "link:..", From f3f61b4022fbbb0f8e09f2b34e2ddf0ca80940af Mon Sep 17 00:00:00 2001 From: Braxton Ward Date: Wed, 16 Sep 2026 12:05:37 -0600 Subject: [PATCH 09/17] fix(appium): send handoff as a string, and document that it does not work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bridge's Config types handoff as a string and passes the config through verbatim, so send the comma-separated value as-is rather than the array the native SDKs take. This does not make the handoff spec pass: a handoff value has no effect through the bridge on either platform — Transact runs past the handoff point and neither onFinish carrying a handoff nor onCleanup fires, with either shape. Recorded in the README's known gaps for the SDK side to pick up. --- appium-test-environment/README.md | 4 ++++ appium-test-environment/src/launchConfig.ts | 8 ++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/appium-test-environment/README.md b/appium-test-environment/README.md index abce238..046fa21 100644 --- a/appium-test-environment/README.md +++ b/appium-test-environment/README.md @@ -125,5 +125,9 @@ Things that cost real debugging time here: `super.application(app, open:options:) || RCTLinkingManager.application(...)`, so when Expo's implementation handles the URL and returns true, `RCTLinkingManager` is never called and JS sees no `url` event. A command channel here needs an Expo AppDelegate subscriber or `expo-linking`. +- **Handoff** (`handoff.e2e.ts`) — a `handoff` config value does not take effect through the bridge + on either platform: Transact runs past the handoff point, and neither `onFinish` carrying a + handoff nor `onCleanup` ever fires. Tried both the array the native SDKs take and the plain string + the bridge's `Config` type declares; no difference. Needs a look on the SDK side. - **Deferred payment data response** (`deferredPayment.e2e.ts`) — the `onDataRequest` response only round-trips on iOS; on Android the returned value never reaches the SDK. diff --git a/appium-test-environment/src/launchConfig.ts b/appium-test-environment/src/launchConfig.ts index 2569778..9300417 100644 --- a/appium-test-environment/src/launchConfig.ts +++ b/appium-test-environment/src/launchConfig.ts @@ -93,10 +93,10 @@ export function buildConfig(extras: LaunchExtras): Record { config.deeplink = deeplink; } - const handoff = extras.TRANSACT_HANDOFF?.split(',') - .map((value) => value.trim()) - .filter(Boolean); - if (handoff && handoff.length > 0) { + // The bridge types handoff as a string and passes the config through verbatim, so send the + // comma-separated value as-is rather than the array the native SDKs take. + const handoff = extras.TRANSACT_HANDOFF?.trim(); + if (handoff) { config.handoff = handoff; } From 59098ec839b787b4e0fb0371a2bbb6901eb77305 Mon Sep 17 00:00:00 2001 From: Braxton Ward Date: Wed, 16 Sep 2026 12:50:11 -0600 Subject: [PATCH 10/17] ci: build the Appium test app and run conformance e2e on PRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces build.yml. That workflow built the example app with EAS on every PR and uploaded the artifact, but nothing ever ran against it — the build either worked or it didn't. Now that the repo has an Appium test environment, a PR can run the shared conformance suite instead. appium-e2e.yml keeps build.yml's lint job unchanged — same workflow name and job id, so existing required status checks keep matching — then builds appium-test-environment for both platforms and hands each upload to mobile-conformance-tests' appium_aws.yml, the same path the native SDK repos use. Each test job names the app it is driving, since the suite defaults to the native test apps, and sets TARGET_APP_KIND so the specs covering APIs this wrapper does not expose report as skipped rather than failing (SDK-370, SDK-784, SDK-785, SDK-786). iOS builds with the appium-device profile: Device Farm runs on real devices, so it needs a signed .ipa rather than the simulator build the local scripts produce. --- .github/workflows/appium-e2e.yml | 131 ++++++++++++ .../build-upload-appium-test-app.yml | 136 +++++++++++++ .github/workflows/build.yml | 187 ------------------ appium-test-environment/README.md | 13 ++ 4 files changed, 280 insertions(+), 187 deletions(-) create mode 100644 .github/workflows/appium-e2e.yml create mode 100644 .github/workflows/build-upload-appium-test-app.yml delete mode 100644 .github/workflows/build.yml diff --git a/.github/workflows/appium-e2e.yml b/.github/workflows/appium-e2e.yml new file mode 100644 index 0000000..3ed7410 --- /dev/null +++ b/.github/workflows/appium-e2e.yml @@ -0,0 +1,131 @@ +name: React Native + +on: + pull_request: + branches: [master] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + NODE_OPTIONS: --openssl-legacy-provider + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: 🏗 Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + + - name: 🏗 Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version-file: '.nvmrc' + cache: 'yarn' + + - name: 📦 Get yarn cache directory path + id: yarn-cache-dir-path + run: echo "dir=$(yarn config get cacheFolder)" >> "$GITHUB_OUTPUT" + + - name: 📦 Setup yarn cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 + with: + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: 📦 Install dependencies + run: yarn install --immutable + + # The preparation workflow fixes Expo Dependabot PRs. Keep this job + # read-only so an unaligned SHA fails and the aligned follow-up passes. + - name: 🩺 Check Dependabot Expo alignment + if: >- + ${{ + github.event.pull_request.user.login == 'dependabot[bot]' && + ( + startsWith(github.event.pull_request.head.ref, 'dependabot/npm_and_yarn/expo-compatible-patches-') || + startsWith(github.event.pull_request.head.ref, 'dependabot/npm_and_yarn/expo-sdk-') + ) + }} + run: yarn deps:expo:check + + - name: 🧪 Run TypeScript check + run: yarn tsc + + - name: 🧹 Run ESLint + run: yarn lint + + - name: ✅ Run unit tests + run: yarn test + + # Builds require Expo credentials, and the e2e runs require AWS. Keep all + # secrets out of dependency-PR code, including follow-up commits made by the + # aligner app. + build-upload-android: + name: Build & Upload Android + needs: lint + if: github.event.pull_request.user.login != 'dependabot[bot]' + uses: ./.github/workflows/build-upload-appium-test-app.yml + with: + platform: android + app_name: AppiumTestEnvironment-rn-${{ github.run_id }}.apk + secrets: + CONFORMANCE_REPO_PULL_KEY: ${{ secrets.CONFORMANCE_REPO_PULL_KEY }} + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + + build-upload-ios: + name: Build & Upload iOS + needs: lint + if: github.event.pull_request.user.login != 'dependabot[bot]' + uses: ./.github/workflows/build-upload-appium-test-app.yml + with: + platform: ios + app_name: AppiumTestEnvironment-rn-${{ github.run_id }}.ipa + secrets: + CONFORMANCE_REPO_PULL_KEY: ${{ secrets.CONFORMANCE_REPO_PULL_KEY }} + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + EXPO_APPLE_ID: ${{ secrets.EXPO_APPLE_ID }} + EXPO_APPLE_PASSWORD: ${{ secrets.EXPO_APPLE_PASSWORD }} + EXPO_TEAM_ID: ${{ secrets.EXPO_TEAM_ID }} + + # The suite defaults to the native test apps, so each run has to name the + # React Native app it is driving. TARGET_APP_KIND also gates the specs that + # cover APIs this wrapper does not expose (SDK-370, SDK-784, SDK-785, SDK-786). + test-android: + name: Test Android + needs: build-upload-android + uses: atomicfi/mobile-conformance-tests/.github/workflows/appium_aws.yml@main + with: + app_arn: ${{ needs.build-upload-android.outputs.upload_arn }} + test_variant: android + test_name: react-native-android + ref: main + target_app_kind: react-native + android_app_package: com.atomicfi.appiumtestenvironment.rn + secrets: + MOLECULAR_BANK_API_KEY: ${{ secrets.MOLECULAR_BANK_API_KEY }} + MOLECULAR_BANK_API_SECRET: ${{ secrets.MOLECULAR_BANK_API_SECRET }} + GITHUB_DEPLOY_KEY: ${{ secrets.CONFORMANCE_REPO_PULL_KEY }} + + test-ios: + name: Test iOS + needs: build-upload-ios + uses: atomicfi/mobile-conformance-tests/.github/workflows/appium_aws.yml@main + with: + app_arn: ${{ needs.build-upload-ios.outputs.upload_arn }} + test_variant: ios + test_name: react-native-ios + ref: main + target_app_kind: react-native + ios_bundle_id: com.atomicfi.AppiumTestEnvironment.rn + secrets: + MOLECULAR_BANK_API_KEY: ${{ secrets.MOLECULAR_BANK_API_KEY }} + MOLECULAR_BANK_API_SECRET: ${{ secrets.MOLECULAR_BANK_API_SECRET }} + GITHUB_DEPLOY_KEY: ${{ secrets.CONFORMANCE_REPO_PULL_KEY }} diff --git a/.github/workflows/build-upload-appium-test-app.yml b/.github/workflows/build-upload-appium-test-app.yml new file mode 100644 index 0000000..1c5c46c --- /dev/null +++ b/.github/workflows/build-upload-appium-test-app.yml @@ -0,0 +1,136 @@ +name: Build & Upload Appium Test App + +on: + workflow_call: + inputs: + platform: + description: "android or ios" + required: true + type: string + app_name: + description: "Name to give the upload in Device Farm" + required: true + type: string + cleanup_previous: + description: "Delete other uploads in the Device Farm project that share this app_name" + required: false + default: false + type: boolean + outputs: + upload_arn: + description: "ARN of the uploaded app in AWS Device Farm" + value: ${{ jobs.upload.outputs.upload_arn }} + secrets: + CONFORMANCE_REPO_PULL_KEY: + required: true + EXPO_TOKEN: + required: true + EXPO_APPLE_ID: + required: false + EXPO_APPLE_PASSWORD: + required: false + EXPO_TEAM_ID: + required: false + +jobs: + build: + name: Build AppiumTestEnvironment (${{ inputs.platform }}) + # Device Farm runs on real devices, so iOS needs a signed .ipa rather than the simulator build + # the local `appium` profile produces. That build has to happen on macOS. + runs-on: ${{ inputs.platform == 'ios' && 'macos-latest' || 'ubuntu-latest' }} + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + EXPO_APPLE_ID: ${{ secrets.EXPO_APPLE_ID }} + EXPO_APPLE_PASSWORD: ${{ secrets.EXPO_APPLE_PASSWORD }} + EXPO_TEAM_ID: ${{ secrets.EXPO_TEAM_ID }} + + steps: + - name: 🏗 Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: 🏗 Select Xcode + if: inputs.platform == 'ios' + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 + with: + xcode-version: latest-stable + + - name: 🏗 Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version-file: '.nvmrc' + cache: 'yarn' + + - name: 📦 Get yarn cache directory path + id: yarn-cache-dir-path + run: echo "dir=$(yarn config get cacheFolder)" >> "$GITHUB_OUTPUT" + + - name: 📦 Setup yarn cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 + with: + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: 📦 Install dependencies + run: yarn install --immutable + + - name: 📦 Setup Gradle cache + if: inputs.platform == 'android' + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: 📦 Setup CocoaPods cache + if: inputs.platform == 'ios' + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 + with: + path: | + ~/Library/Caches/CocoaPods + ~/.cocoapods/repos + key: ${{ runner.os }}-cocoapods-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-cocoapods- + + - name: 📱 Build Appium test app + working-directory: ./appium-test-environment + run: | + export NODE_OPTIONS="--openssl-legacy-provider --max_old_space_size=4096" + if [ "$PLATFORM" = "ios" ]; then + yarn dlx eas-cli@latest build --platform ios --profile appium-device \ + --local --non-interactive --output="./$APP_NAME" + else + yarn dlx eas-cli@latest build --platform android --profile appium \ + --local --non-interactive --output="./$APP_NAME" + fi + env: + PLATFORM: ${{ inputs.platform }} + APP_NAME: ${{ inputs.app_name }} + # CocoaPods dies in Ruby's unicode normalization without a UTF-8 locale. + LANG: en_US.UTF-8 + LC_ALL: en_US.UTF-8 + + - name: 📦 Upload build artifact to GitHub + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: ${{ inputs.app_name }} + path: ./appium-test-environment/${{ inputs.app_name }} + retention-days: 7 + + upload: + name: Upload to Device Farm (${{ inputs.platform }}) + needs: build + uses: atomicfi/mobile-conformance-tests/.github/workflows/upload_aws_test_app.yml@main + with: + artifact_name: ${{ inputs.app_name }} + app_type: ${{ inputs.platform == 'ios' && 'IOS_APP' || 'ANDROID_APP' }} + app_name: ${{ inputs.app_name }} + cleanup_previous: ${{ inputs.cleanup_previous }} + ref: main + secrets: + GITHUB_DEPLOY_KEY: ${{ secrets.CONFORMANCE_REPO_PULL_KEY }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index c48eee3..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,187 +0,0 @@ -name: React Native - -on: - pull_request: - branches: [master] - -permissions: - contents: read - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number }} - cancel-in-progress: true - -env: - NODE_OPTIONS: --openssl-legacy-provider - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - name: 🏗 Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - - - name: 🏗 Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 - with: - node-version-file: '.nvmrc' - cache: 'yarn' - - - name: 📦 Get yarn cache directory path - id: yarn-cache-dir-path - run: echo "dir=$(yarn config get cacheFolder)" >> "$GITHUB_OUTPUT" - - - name: 📦 Setup yarn cache - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 - with: - path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- - - - name: 📦 Install dependencies - run: yarn install --immutable - - # The preparation workflow fixes Expo Dependabot PRs. Keep this job - # read-only so an unaligned SHA fails and the aligned follow-up passes. - - name: 🩺 Check Dependabot Expo alignment - if: >- - ${{ - github.event.pull_request.user.login == 'dependabot[bot]' && - ( - startsWith(github.event.pull_request.head.ref, 'dependabot/npm_and_yarn/expo-compatible-patches-') || - startsWith(github.event.pull_request.head.ref, 'dependabot/npm_and_yarn/expo-sdk-') - ) - }} - run: yarn deps:expo:check - - - name: 🧪 Run TypeScript check - run: yarn tsc - - - name: 🧹 Run ESLint - run: yarn lint - - - name: ✅ Run unit tests - run: yarn test - - build-android: - needs: lint - # These builds require Expo credentials. Keep all secrets out of - # dependency-PR code, including follow-up commits made by the aligner app. - if: github.event.pull_request.user.login != 'dependabot[bot]' - runs-on: ubuntu-latest - env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} - steps: - - name: 🏗 Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - - - name: 🏗 Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 - with: - node-version-file: '.nvmrc' - cache: 'yarn' - - - name: 📦 Get yarn cache directory path - id: yarn-cache-dir-path - run: echo "dir=$(yarn config get cacheFolder)" >> "$GITHUB_OUTPUT" - - - name: 📦 Setup yarn cache - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 - with: - path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- - - - name: 📦 Install dependencies - run: yarn install --immutable - - - name: 📦 Setup Gradle cache - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-gradle- - - - name: 📱 Build Android Dev .apk - working-directory: ./example - run: | - export NODE_OPTIONS="--openssl-legacy-provider --max_old_space_size=4096" - yarn dlx eas-cli@latest build --platform android --profile development --local --non-interactive --output=./app-dev.apk - env: - NODE_ENV: development - - - name: 📦 Upload build artifact to GitHub - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a - with: - name: app-builds-android - path: ./example/app-dev.apk - retention-days: 7 - - build-ios: - needs: lint - if: github.event.pull_request.user.login != 'dependabot[bot]' - runs-on: macos-latest - env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} - EXPO_APPLE_ID: ${{ secrets.EXPO_APPLE_ID }} - EXPO_APPLE_PASSWORD: ${{ secrets.EXPO_APPLE_PASSWORD }} - EXPO_TEAM_ID: ${{ secrets.EXPO_TEAM_ID }} - steps: - - name: 🏗 Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - - - name: 🏗 Select Xcode (Expo SDK 55 requires >=26.0) - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 - with: - xcode-version: latest-stable - - - name: 🏗 Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 - with: - node-version-file: '.nvmrc' - cache: 'yarn' - - - name: 📦 Get yarn cache directory path - id: yarn-cache-dir-path - run: echo "dir=$(yarn config get cacheFolder)" >> "$GITHUB_OUTPUT" - - - name: 📦 Setup yarn cache - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 - with: - path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- - - - name: 📦 Install dependencies - run: yarn install --immutable - - - name: 📦 Setup CocoaPods cache - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 - with: - path: | - ~/Library/Caches/CocoaPods - ~/.cocoapods/repos - key: ${{ runner.os }}-cocoapods-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-cocoapods- - - - name: 📱 Build iOS Dev .app - working-directory: ./example - run: | - export NODE_OPTIONS="--openssl-legacy-provider --max_old_space_size=4096" - yarn dlx eas-cli@latest build --platform ios --profile development --local --non-interactive --output=./app-ios-dev.app - env: - NODE_ENV: development - - - name: 📦 Upload build artifact to GitHub - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a - with: - name: app-builds-ios - path: ./example/app-ios-dev.app - retention-days: 7 diff --git a/appium-test-environment/README.md b/appium-test-environment/README.md index 046fa21..90ff18f 100644 --- a/appium-test-environment/README.md +++ b/appium-test-environment/README.md @@ -72,6 +72,19 @@ surfaces filename-casing mismatches: a `.gitignore` entry whose case does not ma invisible on macOS but fails the EAS upload.) Commit first, or use the local-toolchain scripts while iterating. They are also clean builds each time, where xcodebuild reuses `derivedDataPath`. +## CI + +`.github/workflows/appium-e2e.yml` runs on every pull request: lint/typecheck/unit tests, then +builds this app for both platforms with `eas build --local` and runs the conformance suite against +it on AWS Device Farm — the same path the native SDK repos use. + +Each test job passes `target_app_kind: react-native` plus this app's identifier, because the suite +defaults to the native test apps. `TARGET_APP_KIND` also gates the specs covering APIs this wrapper +does not expose (SDK-370, SDK-784, SDK-785, SDK-786), so they report as skipped rather than failing. + +Device Farm runs on real devices, so iOS builds with the `appium-device` profile (a signed `.ipa`) +rather than the simulator build the local scripts produce. + ## Running the conformance suite against it ```bash From 9c2de6ad0a0dd3bc73d5e6077a9eb5e61c0d45c3 Mon Sep 17 00:00:00 2001 From: Braxton Ward Date: Wed, 16 Sep 2026 12:59:02 -0600 Subject: [PATCH 11/17] ci: point the conformance workflows at the branch under test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TEMPORARY: both the reusable-workflow refs and the `ref` inputs point at braxton/sdk-774-wrapper-test-targets instead of main. The `ref` inputs matter as much as the `uses` refs — `ref` decides which mobile-conformance-tests checkout supplies the specs, and target-app.ts plus the React Native skips only exist on that branch. Left on main, the run would build the right app and then execute specs that cannot target it. Revert all six to main once the conformance branch merges. --- .github/workflows/appium-e2e.yml | 8 ++++---- .github/workflows/build-upload-appium-test-app.yml | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/appium-e2e.yml b/.github/workflows/appium-e2e.yml index 3ed7410..64801b4 100644 --- a/.github/workflows/appium-e2e.yml +++ b/.github/workflows/appium-e2e.yml @@ -101,12 +101,12 @@ jobs: test-android: name: Test Android needs: build-upload-android - uses: atomicfi/mobile-conformance-tests/.github/workflows/appium_aws.yml@main + uses: atomicfi/mobile-conformance-tests/.github/workflows/appium_aws.yml@braxton/sdk-774-wrapper-test-targets with: app_arn: ${{ needs.build-upload-android.outputs.upload_arn }} test_variant: android test_name: react-native-android - ref: main + ref: braxton/sdk-774-wrapper-test-targets target_app_kind: react-native android_app_package: com.atomicfi.appiumtestenvironment.rn secrets: @@ -117,12 +117,12 @@ jobs: test-ios: name: Test iOS needs: build-upload-ios - uses: atomicfi/mobile-conformance-tests/.github/workflows/appium_aws.yml@main + uses: atomicfi/mobile-conformance-tests/.github/workflows/appium_aws.yml@braxton/sdk-774-wrapper-test-targets with: app_arn: ${{ needs.build-upload-ios.outputs.upload_arn }} test_variant: ios test_name: react-native-ios - ref: main + ref: braxton/sdk-774-wrapper-test-targets target_app_kind: react-native ios_bundle_id: com.atomicfi.AppiumTestEnvironment.rn secrets: diff --git a/.github/workflows/build-upload-appium-test-app.yml b/.github/workflows/build-upload-appium-test-app.yml index 1c5c46c..8af3084 100644 --- a/.github/workflows/build-upload-appium-test-app.yml +++ b/.github/workflows/build-upload-appium-test-app.yml @@ -125,12 +125,12 @@ jobs: upload: name: Upload to Device Farm (${{ inputs.platform }}) needs: build - uses: atomicfi/mobile-conformance-tests/.github/workflows/upload_aws_test_app.yml@main + uses: atomicfi/mobile-conformance-tests/.github/workflows/upload_aws_test_app.yml@braxton/sdk-774-wrapper-test-targets with: artifact_name: ${{ inputs.app_name }} app_type: ${{ inputs.platform == 'ios' && 'IOS_APP' || 'ANDROID_APP' }} app_name: ${{ inputs.app_name }} cleanup_previous: ${{ inputs.cleanup_previous }} - ref: main + ref: braxton/sdk-774-wrapper-test-targets secrets: GITHUB_DEPLOY_KEY: ${{ secrets.CONFORMANCE_REPO_PULL_KEY }} From e6d678ecf754535abac43a3eb064daf91017886f Mon Sep 17 00:00:00 2001 From: Braxton Ward Date: Wed, 16 Sep 2026 13:08:28 -0600 Subject: [PATCH 12/17] fix(ci): grant id-token so the Device Farm workflows can authenticate The run failed before any job started: the caller declared only `contents: read`, while appium_aws.yml and upload_aws_test_app.yml both request `id-token: write` for AWS OIDC. A called workflow cannot be granted permissions its caller lacks, so the workflow file was rejected outright. --- .github/workflows/appium-e2e.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/appium-e2e.yml b/.github/workflows/appium-e2e.yml index 64801b4..fd1ece9 100644 --- a/.github/workflows/appium-e2e.yml +++ b/.github/workflows/appium-e2e.yml @@ -5,8 +5,12 @@ on: branches: [master] workflow_dispatch: +# id-token is for the AWS OIDC auth in the Device Farm upload/test workflows. A called workflow +# cannot be granted permissions its caller lacks, so declaring only `contents: read` here fails +# validation before any job starts. permissions: contents: read + id-token: write concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} From e5db4a42e0bdca4e2fd9d00165bbf9de8dd76ea3 Mon Sep 17 00:00:00 2001 From: Braxton Ward Date: Wed, 16 Sep 2026 13:12:04 -0600 Subject: [PATCH 13/17] fix(ci): pin the called conformance workflows by SHA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `gh workflow run` surfaced the real error, which the Actions UI only showed as a generic workflow-file problem: error parsing called workflow -> "...appium_aws.yml@braxton/sdk-774-wrapper-test-targets": workflow was not found. The file is present at that ref — GitHub's reusable-workflow resolver does not handle a branch name containing slashes in `owner/repo/path@ref`. Pin the three `uses` refs to the branch head SHA instead, which is unambiguous. The `ref` inputs stay on the branch name: those are passed to actions/checkout, which handles slashes fine. Still temporary — repoint all of it to main once the conformance branch merges. --- .github/workflows/appium-e2e.yml | 4 ++-- .github/workflows/build-upload-appium-test-app.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/appium-e2e.yml b/.github/workflows/appium-e2e.yml index fd1ece9..19f597a 100644 --- a/.github/workflows/appium-e2e.yml +++ b/.github/workflows/appium-e2e.yml @@ -105,7 +105,7 @@ jobs: test-android: name: Test Android needs: build-upload-android - uses: atomicfi/mobile-conformance-tests/.github/workflows/appium_aws.yml@braxton/sdk-774-wrapper-test-targets + uses: atomicfi/mobile-conformance-tests/.github/workflows/appium_aws.yml@607b6fcdc6e862ecba8c69b49b9b14f67e6ce2d7 with: app_arn: ${{ needs.build-upload-android.outputs.upload_arn }} test_variant: android @@ -121,7 +121,7 @@ jobs: test-ios: name: Test iOS needs: build-upload-ios - uses: atomicfi/mobile-conformance-tests/.github/workflows/appium_aws.yml@braxton/sdk-774-wrapper-test-targets + uses: atomicfi/mobile-conformance-tests/.github/workflows/appium_aws.yml@607b6fcdc6e862ecba8c69b49b9b14f67e6ce2d7 with: app_arn: ${{ needs.build-upload-ios.outputs.upload_arn }} test_variant: ios diff --git a/.github/workflows/build-upload-appium-test-app.yml b/.github/workflows/build-upload-appium-test-app.yml index 8af3084..91a32a7 100644 --- a/.github/workflows/build-upload-appium-test-app.yml +++ b/.github/workflows/build-upload-appium-test-app.yml @@ -125,7 +125,7 @@ jobs: upload: name: Upload to Device Farm (${{ inputs.platform }}) needs: build - uses: atomicfi/mobile-conformance-tests/.github/workflows/upload_aws_test_app.yml@braxton/sdk-774-wrapper-test-targets + uses: atomicfi/mobile-conformance-tests/.github/workflows/upload_aws_test_app.yml@607b6fcdc6e862ecba8c69b49b9b14f67e6ce2d7 with: artifact_name: ${{ inputs.app_name }} app_type: ${{ inputs.platform == 'ios' && 'IOS_APP' || 'ANDROID_APP' }} From 58b332ae9847efec33362fd6ec5bb28828e0671b Mon Sep 17 00:00:00 2001 From: Braxton Ward Date: Wed, 16 Sep 2026 13:58:16 -0600 Subject: [PATCH 14/17] ci: run the conformance e2e on an emulator and simulator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Device Farm approach cannot work from here: this repo is public and mobile-conformance-tests is private, and a public repo cannot consume a private repo's reusable workflows. GitHub reports that as "workflow was not found", which is why the first runs failed before any job started. Run the suite on the runners instead. The private suite is checked out with a read-only deploy key — that direction is allowed — and each job builds the test app locally, boots an emulator or simulator, and runs against it. Two things this buys beyond unblocking CI: - No Apple credentials. A simulator build needs no signing, so the iOS job needs no EXPO_APPLE_* secrets on a public repo. - No Expo token. The local build scripts drive prebuild plus Gradle/xcodebuild directly, so EAS is not in the loop. Also make build:local:android honour an existing JAVA_HOME: it looked the JDK up with /usr/libexec/java_home, which only exists on macOS and would have failed on the Linux runner. Drops build-upload-appium-test-app.yml, which only existed to push builds to Device Farm. --- .github/workflows/appium-e2e.yml | 250 +++++++++++++----- .../build-upload-appium-test-app.yml | 136 ---------- appium-test-environment/package.json | 2 +- 3 files changed, 187 insertions(+), 201 deletions(-) delete mode 100644 .github/workflows/build-upload-appium-test-app.yml diff --git a/.github/workflows/appium-e2e.yml b/.github/workflows/appium-e2e.yml index 19f597a..f120036 100644 --- a/.github/workflows/appium-e2e.yml +++ b/.github/workflows/appium-e2e.yml @@ -5,12 +5,8 @@ on: branches: [master] workflow_dispatch: -# id-token is for the AWS OIDC auth in the Device Farm upload/test workflows. A called workflow -# cannot be granted permissions its caller lacks, so declaring only `contents: read` here fails -# validation before any job starts. permissions: contents: read - id-token: write concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -69,67 +65,193 @@ jobs: - name: ✅ Run unit tests run: yarn test - # Builds require Expo credentials, and the e2e runs require AWS. Keep all - # secrets out of dependency-PR code, including follow-up commits made by the - # aligner app. - build-upload-android: - name: Build & Upload Android + # The conformance suite lives in a private repo, so it is checked out with a read-only deploy + # key rather than called as a reusable workflow: this repo is public, and a public repo cannot + # consume a private repo's workflows. + # + # The suite's wdio:*:rn scripts supply TARGET_APP_KIND and this app's identifiers, so nothing + # here needs to repeat them. TARGET_APP_KIND also gates the specs covering APIs this wrapper does + # not expose (SDK-370, SDK-784, SDK-785, SDK-786) so they report as skipped rather than failing. + e2e-android: + name: E2E Android (emulator) needs: lint if: github.event.pull_request.user.login != 'dependabot[bot]' - uses: ./.github/workflows/build-upload-appium-test-app.yml - with: - platform: android - app_name: AppiumTestEnvironment-rn-${{ github.run_id }}.apk - secrets: - CONFORMANCE_REPO_PULL_KEY: ${{ secrets.CONFORMANCE_REPO_PULL_KEY }} - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} - - build-upload-ios: - name: Build & Upload iOS + runs-on: ubuntu-latest + steps: + - name: 🏗 Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + + - name: 🏗 Checkout conformance tests + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + repository: atomicfi/mobile-conformance-tests + ref: braxton/sdk-774-wrapper-test-targets + ssh-key: ${{ secrets.CONFORMANCE_REPO_PULL_KEY }} + path: conformance + + - name: 🏗 Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version-file: '.nvmrc' + cache: 'yarn' + + - name: 🏗 Set up JDK 17 + uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + with: + distribution: temurin + java-version: 17 + + - name: 📦 Install dependencies + run: yarn install --immutable + + - name: 📦 Setup Gradle cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: 📱 Build the Appium test app + run: yarn appium-app build:local:android + + - name: 📦 Install the conformance suite + working-directory: ./conformance/appium + run: npm ci --include=dev + + - name: 🏗 Install Appium and the UiAutomator2 driver + run: | + npm install -g appium@2 + appium driver install uiautomator2 + + # Hardware acceleration for the emulator. + - name: 🏗 Enable KVM + 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: 🧪 Run the conformance suite + uses: reactivecircus/android-emulator-runner@62dbb605bba737720e10b196cb4220d374026a6d # v2.33.0 + with: + api-level: 34 + target: google_apis + arch: x86_64 + profile: pixel_6 + # Transact runs in a WebView, so the emulator needs to be a Google APIs image; the + # default AOSP image ships no WebView provider Chromedriver can attach to. + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim + disable-animations: true + script: | + appium --allow-cors --allow-insecure=uiautomator2:chromedriver_autodownload,uiautomator2:adb_shell > appium.log 2>&1 & + until curl -sf http://127.0.0.1:4723/status > /dev/null; do sleep 2; done + cd conformance/appium && npm run wdio:android:rn + env: + ATOMIC_API_KEY: ${{ secrets.MOLECULAR_BANK_API_KEY }} + ATOMIC_API_SECRET: ${{ secrets.MOLECULAR_BANK_API_SECRET }} + ANDROID_DEVICE_NAME: emulator-5554 + ANDROID_APP_PATH: ${{ github.workspace }}/appium-test-environment/android/app/build/outputs/apk/release/app-release.apk + + - name: 📦 Upload Appium server log + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: appium-log-android + path: ./appium.log + retention-days: 7 + if-no-files-found: ignore + + e2e-ios: + name: E2E iOS (simulator) needs: lint if: github.event.pull_request.user.login != 'dependabot[bot]' - uses: ./.github/workflows/build-upload-appium-test-app.yml - with: - platform: ios - app_name: AppiumTestEnvironment-rn-${{ github.run_id }}.ipa - secrets: - CONFORMANCE_REPO_PULL_KEY: ${{ secrets.CONFORMANCE_REPO_PULL_KEY }} - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} - EXPO_APPLE_ID: ${{ secrets.EXPO_APPLE_ID }} - EXPO_APPLE_PASSWORD: ${{ secrets.EXPO_APPLE_PASSWORD }} - EXPO_TEAM_ID: ${{ secrets.EXPO_TEAM_ID }} - - # The suite defaults to the native test apps, so each run has to name the - # React Native app it is driving. TARGET_APP_KIND also gates the specs that - # cover APIs this wrapper does not expose (SDK-370, SDK-784, SDK-785, SDK-786). - test-android: - name: Test Android - needs: build-upload-android - uses: atomicfi/mobile-conformance-tests/.github/workflows/appium_aws.yml@607b6fcdc6e862ecba8c69b49b9b14f67e6ce2d7 - with: - app_arn: ${{ needs.build-upload-android.outputs.upload_arn }} - test_variant: android - test_name: react-native-android - ref: braxton/sdk-774-wrapper-test-targets - target_app_kind: react-native - android_app_package: com.atomicfi.appiumtestenvironment.rn - secrets: - MOLECULAR_BANK_API_KEY: ${{ secrets.MOLECULAR_BANK_API_KEY }} - MOLECULAR_BANK_API_SECRET: ${{ secrets.MOLECULAR_BANK_API_SECRET }} - GITHUB_DEPLOY_KEY: ${{ secrets.CONFORMANCE_REPO_PULL_KEY }} - - test-ios: - name: Test iOS - needs: build-upload-ios - uses: atomicfi/mobile-conformance-tests/.github/workflows/appium_aws.yml@607b6fcdc6e862ecba8c69b49b9b14f67e6ce2d7 - with: - app_arn: ${{ needs.build-upload-ios.outputs.upload_arn }} - test_variant: ios - test_name: react-native-ios - ref: braxton/sdk-774-wrapper-test-targets - target_app_kind: react-native - ios_bundle_id: com.atomicfi.AppiumTestEnvironment.rn - secrets: - MOLECULAR_BANK_API_KEY: ${{ secrets.MOLECULAR_BANK_API_KEY }} - MOLECULAR_BANK_API_SECRET: ${{ secrets.MOLECULAR_BANK_API_SECRET }} - GITHUB_DEPLOY_KEY: ${{ secrets.CONFORMANCE_REPO_PULL_KEY }} + runs-on: macos-latest + env: + # CocoaPods dies in Ruby's unicode normalization without a UTF-8 locale. + LANG: en_US.UTF-8 + LC_ALL: en_US.UTF-8 + steps: + - name: 🏗 Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + + - name: 🏗 Checkout conformance tests + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + repository: atomicfi/mobile-conformance-tests + ref: braxton/sdk-774-wrapper-test-targets + ssh-key: ${{ secrets.CONFORMANCE_REPO_PULL_KEY }} + path: conformance + + - name: 🏗 Select Xcode + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 + with: + xcode-version: latest-stable + + - name: 🏗 Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version-file: '.nvmrc' + cache: 'yarn' + + - name: 📦 Install dependencies + run: yarn install --immutable + + - name: 📦 Setup CocoaPods cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 + with: + path: | + ~/Library/Caches/CocoaPods + ~/.cocoapods/repos + key: ${{ runner.os }}-cocoapods-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-cocoapods- + + # A simulator build needs no signing, which is why this runs on a public repo with no Apple + # credentials. + - name: 📱 Build the Appium test app + run: yarn appium-app build:local:ios + + - name: 📦 Install the conformance suite + working-directory: ./conformance/appium + run: npm ci --include=dev + + - name: 🏗 Install Appium and the XCUITest driver + run: | + npm install -g appium@2 + appium driver install xcuitest + + - name: 📱 Boot a simulator + run: | + UDID=$(xcrun simctl list devices available -j \ + | jq -r '[.devices | to_entries[] | select(.key | test("iOS")) | .value[] | select(.name | test("^iPhone 1[0-9]"))] | last | .udid') + RUNTIME=$(xcrun simctl list devices available -j \ + | jq -r '[.devices | to_entries[] | select(.key | test("iOS")) | select([.value[] | .udid] | index("'"$UDID"'")) | .key] | last') + VERSION=$(echo "$RUNTIME" | sed -E 's/.*iOS-([0-9]+)-([0-9]+)$/\1.\2/') + echo "Booting $UDID (iOS $VERSION)" + xcrun simctl boot "$UDID" + xcrun simctl bootstatus "$UDID" -b + echo "IOS_UDID=$UDID" >> "$GITHUB_ENV" + echo "IOS_PLATFORM_VERSION=$VERSION" >> "$GITHUB_ENV" + + - name: 🧪 Run the conformance suite + working-directory: ./conformance/appium + run: | + appium --allow-cors > "$GITHUB_WORKSPACE/appium.log" 2>&1 & + until curl -sf http://127.0.0.1:4723/status > /dev/null; do sleep 2; done + npm run wdio:ios:rn + env: + ATOMIC_API_KEY: ${{ secrets.MOLECULAR_BANK_API_KEY }} + ATOMIC_API_SECRET: ${{ secrets.MOLECULAR_BANK_API_SECRET }} + IOS_APP_PATH: ${{ github.workspace }}/appium-test-environment/ios/build/Build/Products/Release-iphonesimulator/AppiumTestEnvironment.app + + - name: 📦 Upload Appium server log + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: appium-log-ios + path: ./appium.log + retention-days: 7 + if-no-files-found: ignore diff --git a/.github/workflows/build-upload-appium-test-app.yml b/.github/workflows/build-upload-appium-test-app.yml deleted file mode 100644 index 91a32a7..0000000 --- a/.github/workflows/build-upload-appium-test-app.yml +++ /dev/null @@ -1,136 +0,0 @@ -name: Build & Upload Appium Test App - -on: - workflow_call: - inputs: - platform: - description: "android or ios" - required: true - type: string - app_name: - description: "Name to give the upload in Device Farm" - required: true - type: string - cleanup_previous: - description: "Delete other uploads in the Device Farm project that share this app_name" - required: false - default: false - type: boolean - outputs: - upload_arn: - description: "ARN of the uploaded app in AWS Device Farm" - value: ${{ jobs.upload.outputs.upload_arn }} - secrets: - CONFORMANCE_REPO_PULL_KEY: - required: true - EXPO_TOKEN: - required: true - EXPO_APPLE_ID: - required: false - EXPO_APPLE_PASSWORD: - required: false - EXPO_TEAM_ID: - required: false - -jobs: - build: - name: Build AppiumTestEnvironment (${{ inputs.platform }}) - # Device Farm runs on real devices, so iOS needs a signed .ipa rather than the simulator build - # the local `appium` profile produces. That build has to happen on macOS. - runs-on: ${{ inputs.platform == 'ios' && 'macos-latest' || 'ubuntu-latest' }} - env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} - EXPO_APPLE_ID: ${{ secrets.EXPO_APPLE_ID }} - EXPO_APPLE_PASSWORD: ${{ secrets.EXPO_APPLE_PASSWORD }} - EXPO_TEAM_ID: ${{ secrets.EXPO_TEAM_ID }} - - steps: - - name: 🏗 Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - - name: 🏗 Select Xcode - if: inputs.platform == 'ios' - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 - with: - xcode-version: latest-stable - - - name: 🏗 Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 - with: - node-version-file: '.nvmrc' - cache: 'yarn' - - - name: 📦 Get yarn cache directory path - id: yarn-cache-dir-path - run: echo "dir=$(yarn config get cacheFolder)" >> "$GITHUB_OUTPUT" - - - name: 📦 Setup yarn cache - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 - with: - path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- - - - name: 📦 Install dependencies - run: yarn install --immutable - - - name: 📦 Setup Gradle cache - if: inputs.platform == 'android' - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-gradle- - - - name: 📦 Setup CocoaPods cache - if: inputs.platform == 'ios' - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 - with: - path: | - ~/Library/Caches/CocoaPods - ~/.cocoapods/repos - key: ${{ runner.os }}-cocoapods-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-cocoapods- - - - name: 📱 Build Appium test app - working-directory: ./appium-test-environment - run: | - export NODE_OPTIONS="--openssl-legacy-provider --max_old_space_size=4096" - if [ "$PLATFORM" = "ios" ]; then - yarn dlx eas-cli@latest build --platform ios --profile appium-device \ - --local --non-interactive --output="./$APP_NAME" - else - yarn dlx eas-cli@latest build --platform android --profile appium \ - --local --non-interactive --output="./$APP_NAME" - fi - env: - PLATFORM: ${{ inputs.platform }} - APP_NAME: ${{ inputs.app_name }} - # CocoaPods dies in Ruby's unicode normalization without a UTF-8 locale. - LANG: en_US.UTF-8 - LC_ALL: en_US.UTF-8 - - - name: 📦 Upload build artifact to GitHub - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: ${{ inputs.app_name }} - path: ./appium-test-environment/${{ inputs.app_name }} - retention-days: 7 - - upload: - name: Upload to Device Farm (${{ inputs.platform }}) - needs: build - uses: atomicfi/mobile-conformance-tests/.github/workflows/upload_aws_test_app.yml@607b6fcdc6e862ecba8c69b49b9b14f67e6ce2d7 - with: - artifact_name: ${{ inputs.app_name }} - app_type: ${{ inputs.platform == 'ios' && 'IOS_APP' || 'ANDROID_APP' }} - app_name: ${{ inputs.app_name }} - cleanup_previous: ${{ inputs.cleanup_previous }} - ref: braxton/sdk-774-wrapper-test-targets - secrets: - GITHUB_DEPLOY_KEY: ${{ secrets.CONFORMANCE_REPO_PULL_KEY }} diff --git a/appium-test-environment/package.json b/appium-test-environment/package.json index 0f66122..beeeda9 100644 --- a/appium-test-environment/package.json +++ b/appium-test-environment/package.json @@ -10,7 +10,7 @@ "prebuild:android": "expo prebuild --clean --platform android", "prebuild:ios": "LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 expo prebuild --clean --platform ios", "pods": "cd ios && LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 pod install", - "build:local:android": "yarn prebuild:android && cd android && JAVA_HOME=\"$(/usr/libexec/java_home -v 17)\" ./gradlew :app:assembleRelease -x lint -x lintVitalAnalyzeRelease -x lintVitalRelease && echo \"APK: $(pwd)/app/build/outputs/apk/release/app-release.apk\"", + "build:local:android": "yarn prebuild:android && cd android && JAVA_HOME=\"${JAVA_HOME:-$(/usr/libexec/java_home -v 17)}\" ./gradlew :app:assembleRelease -x lint -x lintVitalAnalyzeRelease -x lintVitalRelease && echo \"APK: $(pwd)/app/build/outputs/apk/release/app-release.apk\"", "build:local:ios": "yarn prebuild:ios && cd ios && xcodebuild -workspace AppiumTestEnvironment.xcworkspace -scheme AppiumTestEnvironment -configuration Release -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' -derivedDataPath ./build CODE_SIGNING_ALLOWED=NO build && echo \"APP: $(pwd)/build/Build/Products/Release-iphonesimulator/AppiumTestEnvironment.app\"", "build:eas:android": "eas build --platform android --profile appium", "build:eas:ios": "eas build --platform ios --profile appium", From 17107255061f13670237d89fbb2ea22e84689fb4 Mon Sep 17 00:00:00 2001 From: Braxton Ward Date: Wed, 16 Sep 2026 14:08:54 -0600 Subject: [PATCH 15/17] fix(ci): pin the Appium server and driver versions The driver install failed on both jobs: 'uiautomator2' cannot be installed because the server version it requires (^3.0.0-rc.2) does not meet the currently installed one (2.19.0) `appium driver install ` resolves to the newest driver, which now wants an Appium 3 server. Pin the server and both drivers to the versions this suite is verified against locally: appium 2.19.0, uiautomator2 4.2.7, xcuitest 9.10.1. --- .github/workflows/appium-e2e.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/appium-e2e.yml b/.github/workflows/appium-e2e.yml index f120036..3295ef5 100644 --- a/.github/workflows/appium-e2e.yml +++ b/.github/workflows/appium-e2e.yml @@ -121,10 +121,12 @@ jobs: working-directory: ./conformance/appium run: npm ci --include=dev + # Pinned to the versions this suite is verified against. `appium driver install uiautomator2` + # unpinned resolves to a driver that requires an Appium 3 server and fails against 2.x. - name: 🏗 Install Appium and the UiAutomator2 driver run: | - npm install -g appium@2 - appium driver install uiautomator2 + npm install -g appium@2.19.0 + appium driver install uiautomator2@4.2.7 # Hardware acceleration for the emulator. - name: 🏗 Enable KVM @@ -218,10 +220,12 @@ jobs: working-directory: ./conformance/appium run: npm ci --include=dev + # Pinned for the same reason as the Android job: an unpinned xcuitest driver requires an + # Appium 3 server. - name: 🏗 Install Appium and the XCUITest driver run: | - npm install -g appium@2 - appium driver install xcuitest + npm install -g appium@2.19.0 + appium driver install xcuitest@9.10.1 - name: 📱 Boot a simulator run: | From db8a81ecc944936234f2fa0404f224583911b0a2 Mon Sep 17 00:00:00 2001 From: Braxton Ward Date: Wed, 16 Sep 2026 14:32:14 -0600 Subject: [PATCH 16/17] fix(ci): install current Android SDK tools and prebuild WebDriverAgent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both e2e jobs reached the run step and failed for environment reasons rather than test failures. Android never started an emulator: the runner's sdkmanager is too old for the current SDK metadata ("SDK XML version 4"), so the emulator download failed with "Error on ZipFile unknown archive". Install current command-line tools first. iOS built the app, booted the simulator and started Appium, then timed out creating a session — it was compiling WebDriverAgent, which takes longer than the 120s timeout, three times over. Build WDA in its own step, and raise WDIO_CONNECTION_RETRY_TIMEOUT to 5 minutes on both jobs for the cold first session. --- .github/workflows/appium-e2e.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/appium-e2e.yml b/.github/workflows/appium-e2e.yml index 3295ef5..64227bd 100644 --- a/.github/workflows/appium-e2e.yml +++ b/.github/workflows/appium-e2e.yml @@ -128,6 +128,14 @@ jobs: npm install -g appium@2.19.0 appium driver install uiautomator2@4.2.7 + # The runner image ships an sdkmanager too old for the current SDK metadata ("SDK XML + # version 4"), which makes the emulator-runner action fail to download the emulator with + # "Error on ZipFile unknown archive". Installing current command-line tools first avoids it. + - name: 🏗 Setup Android SDK + uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3.2.2 + with: + packages: 'tools platform-tools emulator' + # Hardware acceleration for the emulator. - name: 🏗 Enable KVM run: | @@ -154,6 +162,7 @@ jobs: env: ATOMIC_API_KEY: ${{ secrets.MOLECULAR_BANK_API_KEY }} ATOMIC_API_SECRET: ${{ secrets.MOLECULAR_BANK_API_SECRET }} + WDIO_CONNECTION_RETRY_TIMEOUT: 300000 ANDROID_DEVICE_NAME: emulator-5554 ANDROID_APP_PATH: ${{ github.workspace }}/appium-test-environment/android/app/build/outputs/apk/release/app-release.apk @@ -240,6 +249,11 @@ jobs: echo "IOS_UDID=$UDID" >> "$GITHUB_ENV" echo "IOS_PLATFORM_VERSION=$VERSION" >> "$GITHUB_ENV" + # Compiling WebDriverAgent takes longer than the session-creation timeout, so the first + # session times out if it has to build WDA itself. Build it up front instead. + - name: 🏗 Prebuild WebDriverAgent + run: appium driver run xcuitest build-wda --sim-udid "$IOS_UDID" + - name: 🧪 Run the conformance suite working-directory: ./conformance/appium run: | @@ -249,6 +263,7 @@ jobs: env: ATOMIC_API_KEY: ${{ secrets.MOLECULAR_BANK_API_KEY }} ATOMIC_API_SECRET: ${{ secrets.MOLECULAR_BANK_API_SECRET }} + WDIO_CONNECTION_RETRY_TIMEOUT: 300000 IOS_APP_PATH: ${{ github.workspace }}/appium-test-environment/ios/build/Build/Products/Release-iphonesimulator/AppiumTestEnvironment.app - name: 📦 Upload Appium server log From abac72827ef76d9f3e08306d3aa5db2ee7cf92a8 Mon Sep 17 00:00:00 2001 From: Braxton Ward Date: Thu, 17 Sep 2026 09:21:31 -0600 Subject: [PATCH 17/17] fix(ci): stop asking for the removed Android tools package The setup-android action's default package list includes the legacy `tools` package, which Google has removed from the SDK repository, so sdkmanager exited 1 and the emulator never started. Only platform-tools and emulator are needed. The iOS job now also tells the driver its simulator is intentionally headless, so it stops restarting the simulator and timing out on the reboot. --- .github/workflows/appium-e2e.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/appium-e2e.yml b/.github/workflows/appium-e2e.yml index 64227bd..a4bf467 100644 --- a/.github/workflows/appium-e2e.yml +++ b/.github/workflows/appium-e2e.yml @@ -134,7 +134,9 @@ jobs: - name: 🏗 Setup Android SDK uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3.2.2 with: - packages: 'tools platform-tools emulator' + # The action's default package list includes the legacy `tools` package, which Google has + # removed from the repository; asking for it fails the whole step. + packages: 'platform-tools emulator' # Hardware acceleration for the emulator. - name: 🏗 Enable KVM @@ -264,6 +266,10 @@ jobs: ATOMIC_API_KEY: ${{ secrets.MOLECULAR_BANK_API_KEY }} ATOMIC_API_SECRET: ${{ secrets.MOLECULAR_BANK_API_SECRET }} WDIO_CONNECTION_RETRY_TIMEOUT: 300000 + # The runner has no visible Simulator window. Without this the driver restarts the + # simulator to make one appear and then times out waiting for that reboot. + IOS_HEADLESS: 'true' + IOS_SIMULATOR_STARTUP_TIMEOUT: 300000 IOS_APP_PATH: ${{ github.workspace }}/appium-test-environment/ios/build/Build/Products/Release-iphonesimulator/AppiumTestEnvironment.app - name: 📦 Upload Appium server log