diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..3f767487 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,202 @@ +name: "E2E 🧪" + +# Runs the example app's E2E suites: the Maestro flows in example/e2e/flows on +# an iOS simulator and an Android emulator, and the Playwright tests in +# example/e2e/web against the web build. +# +# The apps are release builds, so the JS bundle is embedded and no Metro server +# has to run next to the simulator or emulator. +# +# Flows tagged `sandbox` open the real PayPal flow, which needs a Braintree +# sandbox tokenization key with PayPal enabled. It comes from the +# PAYPAL_SANDBOX_TOKENIZATION_KEY secret, and those flows are skipped when the +# secret is not available, as on pull requests from forks. + +on: + push: + branches: + - main + pull_request: + +concurrency: + group: e2e-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + MAESTRO_VERSION: "2.12.0" + +jobs: + ios: + name: iOS (Maestro) + runs-on: macos-latest + timeout-minutes: 60 + env: + EXPO_PUBLIC_PAYPAL_CLIENT_TOKEN: ${{ secrets.PAYPAL_SANDBOX_TOKENIZATION_KEY }} + HAS_SANDBOX_KEY: ${{ secrets.PAYPAL_SANDBOX_TOKENIZATION_KEY != '' }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - name: Setup mise + uses: jdx/mise-action@b44d12e7d0d8ee7461bb893e1b1993500433185b # v5 + - name: Setup pnpm + uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6 + with: + cache: true + # For Maestro. + - name: Setup JDK + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6 + with: + distribution: temurin + java-version: "17" + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Install Maestro + run: | + curl -fsSL "https://get.maestro.mobile.dev" | bash + echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" + # An iPhone on the newest iOS runtime the runner has, rather than a fixed + # model that a later runner image may no longer ship. + - name: Boot a simulator + run: | + UDID=$(xcrun simctl list devices available --json | node -e ' + const { devices } = JSON.parse(require("fs").readFileSync(0, "utf8")); + const phones = Object.entries(devices) + .filter(([runtime]) => runtime.includes("iOS")) + .sort(([a], [b]) => a.localeCompare(b, undefined, { numeric: true })) + .flatMap(([, list]) => list.filter((d) => d.name.startsWith("iPhone"))); + if (!phones.length) { console.error("No iPhone simulator available"); process.exit(1); } + console.log(phones[phones.length - 1].udid);') + xcrun simctl boot "$UDID" + echo "UDID=$UDID" >> "$GITHUB_ENV" + - name: Prebuild example app + run: pnpm exec expo prebuild --platform ios --no-install + working-directory: example + - name: Install pods + run: pod install + working-directory: example/ios + - name: Build example app + run: >- + xcodebuild + -workspace reactnativepaypalexample.xcworkspace + -scheme reactnativepaypalexample + -configuration Release + -sdk iphonesimulator + -destination "id=$UDID" + -derivedDataPath build + -quiet + working-directory: example/ios + - name: Install example app + run: xcrun simctl install "$UDID" example/ios/build/Build/Products/Release-iphonesimulator/reactnativepaypalexample.app + - name: Run E2E flows + run: pnpm e2e + - name: Run sandbox E2E flows + if: env.HAS_SANDBOX_KEY == 'true' + run: pnpm e2e:sandbox + - name: Upload Maestro results + if: failure() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: maestro-ios + path: ~/.maestro/tests + + android: + name: Android (Maestro) + runs-on: ubuntu-latest + timeout-minutes: 60 + env: + EXPO_PUBLIC_PAYPAL_CLIENT_TOKEN: ${{ secrets.PAYPAL_SANDBOX_TOKENIZATION_KEY }} + HAS_SANDBOX_KEY: ${{ secrets.PAYPAL_SANDBOX_TOKENIZATION_KEY != '' }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - name: Setup mise + uses: jdx/mise-action@b44d12e7d0d8ee7461bb893e1b1993500433185b # v5 + - name: Setup pnpm + uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6 + with: + cache: true + - name: Setup JDK + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6 + with: + distribution: temurin + java-version: "17" + - name: Setup Android SDK + uses: android-actions/setup-android@be39fa834029ff78f1a44aa3bb0819b8fc2bd8fd # v4 + - name: Setup Gradle + uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6 + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Prebuild example app + run: pnpm exec expo prebuild --platform android --no-install + working-directory: example + # Only for the emulator's ABI: React Native's C++ for all four is most of + # the build time. Release builds are signed with the debug keystore. + - name: Build example app + run: ./gradlew --console=plain :app:assembleRelease -PreactNativeArchitectures=x86_64 + working-directory: example/android + - name: Install Maestro + run: | + curl -fsSL "https://get.maestro.mobile.dev" | bash + echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" + - 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 + # Each line of `script` runs in its own shell, so the sandbox step is a + # one-line `if`. + # + # The google_apis image's setup app often hangs on a CI first boot, and + # its "isn't responding" dialog then covers the app under test, so such + # dialogs are hidden. The logcat is kept for failures. + - name: Run E2E flows + uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 + with: + api-level: 35 + arch: x86_64 + target: google_apis + profile: pixel_7 + script: | + adb shell settings put global hide_error_dialogs 1 + adb logcat -c && (adb logcat -v time > logcat.txt &) + adb install example/android/app/build/outputs/apk/release/app-release.apk + pnpm e2e + if [ "$HAS_SANDBOX_KEY" = "true" ]; then pnpm e2e:sandbox; fi + - name: Upload Maestro results + if: failure() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: maestro-android + path: | + ~/.maestro/tests + logcat.txt + + web: + name: Web (Playwright) + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - name: Setup mise + uses: jdx/mise-action@b44d12e7d0d8ee7461bb893e1b1993500433185b # v5 + - name: Setup pnpm + uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6 + with: + cache: true + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Install Chromium + run: pnpm exec playwright install --with-deps chromium + working-directory: example + - name: Run E2E tests + run: pnpm e2e:web + - name: Upload Playwright traces + if: failure() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: playwright-traces + path: example/test-results diff --git a/CLAUDE.md b/CLAUDE.md index a7abdcf8..3b8e62f3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,12 +11,16 @@ Node comes from `.tool-versions` (`mise install`); run `corepack enable` once so - `pnpm lint`: oxlint (type-aware) and `oxfmt --check`. `pnpm lint:fix` autofixes and formats. - `pnpm lint:kotlin`: detekt on `android/src`. Needs a JDK on the path or `JAVA_HOME`. - `pnpm test`: Jest (`--passWithNoTests`). One file: `pnpm test plugin/__tests__/withPaypal.test.js`; one test: `pnpm test -t ""`. +- `pnpm e2e`: the Maestro flows in `example/e2e/flows` against the example app on the booted simulator or emulator. `pnpm e2e:sandbox` runs the flows tagged `sandbox`, which need a Braintree sandbox key. `pnpm e2e:web`: Playwright against the web build. - `pnpm run docs`: typedoc into `docs/`. Spell out `run`: `pnpm docs` is a built-in pnpm command. - `pnpm example start|ios|android|web`: run the example app (`start` is `expo start --dev-client`; `ios` and `android` are `expo run:ios` and `expo run:android`). ## Testing changes -- Jest covers only the config plugin (`plugin/__tests__/withPaypal.test.js`). The Swift and Kotlin code has no automated tests. +- Jest covers only the config plugin (`plugin/__tests__/withPaypal.test.js`). Kotlin unit tests (`android/src/test`) run in the Android workflow; the Swift code has none. +- E2E: Maestro flows in `example/e2e/flows` (shared steps in `example/e2e/subflows`) and Playwright tests in `example/e2e/web`, run by `.github/workflows/e2e.yml` on release builds. The flows tap the example's buttons by `testID` and match its `status` line (`:`, e.g. `concurrent:Failed,Failed`), so keep `example/App.tsx` and the flows in sync. A behaviour change should come with a flow. +- Flows tagged `sandbox` open the real PayPal flow. They need a Braintree sandbox tokenization key with PayPal enabled, built in as `EXPO_PUBLIC_PAYPAL_CLIENT_TOKEN` (`example/.env.local` locally, the `PAYPAL_SANDBOX_TOKENIZATION_KEY` secret on CI, where they are skipped without it). Rebuild the app after changing it. +- Maestro targets whichever device is booted: if a physical phone is also connected, pass `--device`. A debug build needs `pnpm example start` running. - The example app is the check for iOS and Android. It needs a development build, because the native module is not in Expo Go: `pnpm example ios` or `pnpm example android` builds and installs one, and `pnpm example start` serves JS to it. Metro bundles the library from `src/`, so edits there reload without a rebuild; native changes need a new `ios` or `android` run. - PayPal does not work on web. `pnpm example web` only checks that importing the package does not break a web bundle (see the comment in `.github/workflows/web.yml`). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bd2e1724..4427b94b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -61,6 +61,30 @@ Remember to add tests for your change if possible. Run the unit tests by: pnpm test ``` +### End-to-end tests + +The E2E tests drive the example app. The [Maestro](https://maestro.dev/) flows in `example/e2e/flows` run on iOS and Android, and the [Playwright](https://playwright.dev/) tests in `example/e2e/web` run against the web build. CI runs them all from `.github/workflows/e2e.yml`. + +To run the Maestro flows, [install Maestro](https://docs.maestro.dev/getting-started/installing-maestro) (it needs a JDK), install the example app on a simulator or emulator with `pnpm example ios` or `pnpm example android`, keep `pnpm example start` running, and run: + +```sh +pnpm e2e +``` + +The flows tagged `sandbox` open the real PayPal flow, so they need a [Braintree sandbox](https://sandbox.braintreegateway.com/) tokenization key for a merchant with PayPal enabled. Put it in `example/.env.local` (git-ignored), rebuild the app, and run them with `pnpm e2e:sandbox`: + +```sh +EXPO_PUBLIC_PAYPAL_CLIENT_TOKEN=sandbox_xxxxxxxx_xxxxxxxxxxxxxxxx +``` + +On CI the key comes from the `PAYPAL_SANDBOX_TOKENIZATION_KEY` repository secret, and the sandbox flows are skipped without it. + +To run the web tests, install Chromium once with `pnpm example exec playwright install chromium`, then: + +```sh +pnpm e2e:web +``` + To edit the Swift files, run `pnpm example ios` once to generate the native project, then open `example/ios/reactnativepaypalexample.xcworkspace` in Xcode and find the source files at `Pods > Development Pods > react-native-paypal`. To edit the Kotlin files, run `pnpm example android` once to generate the native project, then open `example/android` in Android Studio and find the source files at `reactnativepaypal` under `Android`. @@ -104,6 +128,8 @@ The `package.json` file contains various scripts for common tasks: - `pnpm lint:fix`: fix lint and formatting errors. - `pnpm lint:kotlin`: lint the Kotlin files with detekt. - `pnpm test`: run unit tests with Jest. +- `pnpm e2e`: run the Maestro E2E flows against the installed example app (`pnpm e2e:sandbox` for the flows that need a Braintree sandbox key). +- `pnpm e2e:web`: run the Playwright E2E tests against the web build. - `pnpm run docs`: generate the API reference with typedoc. - `pnpm example start`: start the Metro server for the example app. - `pnpm example android`: run the example app on Android. diff --git a/android/build.gradle b/android/build.gradle index 0258ba83..6dcd73ca 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -18,6 +18,9 @@ android { defaultConfig { versionCode 1 versionName packageJson.version + // Applied to the app's R8 run, which Expo's template turns on for release + // builds. See the file for why. + consumerProguardFiles 'consumer-rules.pro' } lint { abortOnError false diff --git a/android/consumer-rules.pro b/android/consumer-rules.pro new file mode 100644 index 00000000..c921e28c --- /dev/null +++ b/android/consumer-rules.pro @@ -0,0 +1,8 @@ +# Braintree depends on WorkManager, which resolves to Room 2.6.1. Room 2.6's own +# rule, `-keep class * extends androidx.room.RoomDatabase`, names no members, and +# R8's full mode (the AGP default) then keeps the class but not its no-arg +# constructor. WorkManager creates its database through that constructor by +# reflection, so a minified release build crashed at startup with +# `NoSuchMethodException: androidx.work.impl.WorkDatabase_Impl. []`, +# before any JavaScript ran. Later Room versions keep the constructor themselves. +-keep class * extends androidx.room.RoomDatabase { (); } diff --git a/example/.gitignore b/example/.gitignore index 45e0b96a..a3603ab6 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -37,3 +37,8 @@ npm-debug.* # generated native folders /ios /android + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ diff --git a/example/App.tsx b/example/App.tsx index 7f358962..1339bb67 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -1,57 +1,114 @@ import { requestBillingAgreement, - RequestBillingAgreementOptions, - RequestBillingAgreementResponse, + type RequestBillingAgreementOptions, + type RequestBillingAgreementResponse, } from "@bounceapp/react-native-paypal" import { useState } from "react" import { Button, ScrollView, StyleSheet, Text, View } from "react-native" import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context" -// Placeholder values: with a real client token and an App Link served by your -// domain, the PayPal flow would complete. -const options: RequestBillingAgreementOptions = { - clientToken: "CLIENT_TOKEN", +// A Braintree sandbox tokenization key or client token, from example/.env.local +// (see CONTRIBUTING.md). Without one, "Pay with PayPal" resolves with an +// authorization error. With one, the PayPal flow opens, but only completes if +// your domain also serves the App Link below. +const clientToken = + process.env.EXPO_PUBLIC_PAYPAL_CLIENT_TOKEN || "INVALID_CLIENT_TOKEN" + +// Well formed, so Braintree fetches its configuration before failing on the +// unknown merchant. That network round trip keeps a request in flight long +// enough for a second one to reach the native module. +const UNKNOWN_TOKENIZATION_KEY = "sandbox_00000000_0000000000000000" + +const optionsFor = (token: string): RequestBillingAgreementOptions => ({ + clientToken: token, appLinkReturnUrl: "https://example.com/paypal", hasUserLocationConsent: false, billingAgreementDescription: "Example billing agreement", localeCode: "en_US", shippingAddressRequired: false, displayName: "Example app", +}) + +type Scenario = { + id: string + title: string + run: () => Promise } +// The E2E flows in e2e/ tap these by id and read the outcome from the status +// line, so keep the ids and the status format in sync with them. +const scenarios: Scenario[] = [ + { + id: "pay", + title: "Pay with PayPal", + run: () => Promise.all([requestBillingAgreement(optionsFor(clientToken))]), + }, + { + id: "invalid-token", + title: "Invalid client token", + run: () => + Promise.all([ + requestBillingAgreement(optionsFor("INVALID_CLIENT_TOKEN")), + ]), + }, + { + id: "concurrent", + title: "Two requests at once", + run: () => + Promise.all([ + requestBillingAgreement(optionsFor(UNKNOWN_TOKENIZATION_KEY)), + requestBillingAgreement(optionsFor(UNKNOWN_TOKENIZATION_KEY)), + ]), + }, +] + +const outcome = (response: RequestBillingAgreementResponse) => + response.error ? response.error.code : "Success" + export default function App() { - const [loading, setLoading] = useState(false) + const [running, setRunning] = useState(false) + // ":", e.g. + // "concurrent:Failed,Failed". + const [status, setStatus] = useState("idle") const [logs, setLogs] = useState([]) + // One line per entry, so the E2E flows can match an entry with a regex. const log = (message: string) => setLogs(previous => [ `${new Date().toLocaleTimeString()} ${message}`, ...previous, ]) - const onPress = async () => { - setLoading(true) - log("requestBillingAgreement called") + const run = async ({ id, run }: Scenario) => { + setRunning(true) + setStatus(`${id}:running`) + log(`${id}: started`) try { - const res: RequestBillingAgreementResponse = - await requestBillingAgreement(options) - - if (res.error) { - log(`error result: ${JSON.stringify(res.error, null, 4)}`) - } else { - log(`payload: ${JSON.stringify(res.payload, null, 4)}`) - } + const responses = await run() + responses.forEach((response, index) => { + const label = `${id} #${index + 1}` + if (response.error) { + log(`${label} ${response.error.code}: ${response.error.message}`) + } else { + log(`${label} Success: ${JSON.stringify(response.payload)}`) + } + }) + setStatus(`${id}:${responses.map(outcome).join(",")}`) } catch (error) { - log(`threw: ${String(error)}`) + log(`${id} threw: ${String(error)}`) + setStatus(`${id}:threw`) } - setLoading(false) + setRunning(false) } return ( + + {status} + {logs.map((entry, index) => ( @@ -60,11 +117,15 @@ export default function App() { ))} -