From 916f8100d96dbbe371954970ddc35f6696ea624b Mon Sep 17 00:00:00 2001 From: Shahroz Khan Date: Tue, 25 Aug 2026 10:58:06 -0400 Subject: [PATCH 01/23] test: add React Native scene notification E2E --- .github/workflows/react-native-scene-e2e.yml | 59 ++++++++ .maestro/README.md | 23 +++ .maestro/fixtures/customerio_scene_cold.apns | 16 ++ .maestro/fixtures/customerio_scene_warm.apns | 16 ++ .maestro/fixtures/react-native-scene/App.tsx | 34 +++++ .../react-native-scene/AppDelegate.swift | 17 +++ .../react-native-scene/SceneDelegate.swift | 44 ++++++ .../fixtures/react-native-scene/configure.rb | 12 ++ .../react-native-scene/configure_podfile.rb | 49 ++++++ .maestro/run_scene_push.sh | 143 ++++++++++++++++++ .maestro/scene_push_open.yaml | 10 ++ .maestro/scene_push_prepare.yaml | 10 ++ .maestro/scene_push_warm.yaml | 10 ++ 13 files changed, 443 insertions(+) create mode 100644 .github/workflows/react-native-scene-e2e.yml create mode 100644 .maestro/README.md create mode 100644 .maestro/fixtures/customerio_scene_cold.apns create mode 100644 .maestro/fixtures/customerio_scene_warm.apns create mode 100644 .maestro/fixtures/react-native-scene/App.tsx create mode 100644 .maestro/fixtures/react-native-scene/AppDelegate.swift create mode 100644 .maestro/fixtures/react-native-scene/SceneDelegate.swift create mode 100644 .maestro/fixtures/react-native-scene/configure.rb create mode 100644 .maestro/fixtures/react-native-scene/configure_podfile.rb create mode 100755 .maestro/run_scene_push.sh create mode 100644 .maestro/scene_push_open.yaml create mode 100644 .maestro/scene_push_prepare.yaml create mode 100644 .maestro/scene_push_warm.yaml diff --git a/.github/workflows/react-native-scene-e2e.yml b/.github/workflows/react-native-scene-e2e.yml new file mode 100644 index 00000000..90d7f5d3 --- /dev/null +++ b/.github/workflows/react-native-scene-e2e.yml @@ -0,0 +1,59 @@ +name: React Native scene routing E2E + +on: + pull_request: + paths: + - '.github/workflows/react-native-scene-e2e.yml' + - '.maestro/**' + - 'ios/wrappers/CustomerIOReactNativeDeepLinkRouter.swift' + - 'ios/wrappers/NativeCustomerIO.swift' + - 'ios/wrappers/liveactivities/NativeLiveActivities.swift' + schedule: + - cron: '47 7 * * *' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + route-notifications: + name: RN scene notification routing on Xcode 27 + runs-on: xcode-27 + timeout-minutes: 60 + + steps: + - name: Check out React Native SDK + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Verify Xcode 27 toolchain + uses: customerio/mobile-ci-tools/github-actions/ios/report-toolchain/v1@main + with: + expected-xcode-major: '27' + expected-ios-sdk-major: '27' + + - name: Set up Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '24' + cache: npm + + - name: Set up Ruby + uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0 + with: + ruby-version: '3.4' + + - name: Install Maestro + shell: bash + run: | + curl -Ls https://get.maestro.mobile.dev | bash + echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" + + - name: Test warm and terminated notification routing + shell: bash + env: + E2E_SIMULATOR_NAME: iPhone 17 Pro + run: .maestro/run_scene_push.sh diff --git a/.maestro/README.md b/.maestro/README.md new file mode 100644 index 00000000..65d98a4d --- /dev/null +++ b/.maestro/README.md @@ -0,0 +1,23 @@ +# React Native iOS scene push E2E + +`run_scene_push.sh` generates the scene-capable React Native 0.88 host that the +wrapper supports, installs the current wrapper package into it, and runs +black-box Maestro notification-tap tests from terminated and warm app states. +The JavaScript listener is registered before `CustomerIO.initialize`, and each +assertion requires the exact Customer.io destination to reach React Native +Linking. + +The scene host installs the native bridge before React Native starts. This is +what lets the wrapper buffer a terminated-app notification URL until the +JavaScript listener and `CustomerIO.initialize` are ready. + +The checked-in payload uses `simctl push` so the destination is deterministic +and does not require changing a shared Customer.io campaign. It validates the +client routing path, not backend `sent`, `delivered`, or `opened` metrics. Those +metrics remain a separate remote E2E lane using a real workspace and APNs. + +Run with a booted iPhone simulator: + +```bash +.maestro/run_scene_push.sh +``` diff --git a/.maestro/fixtures/customerio_scene_cold.apns b/.maestro/fixtures/customerio_scene_cold.apns new file mode 100644 index 00000000..8161ce76 --- /dev/null +++ b/.maestro/fixtures/customerio_scene_cold.apns @@ -0,0 +1,16 @@ +{ + "aps": { + "alert": { + "title": "Customer.io scene routing", + "body": "Open React Native cold destination" + }, + "sound": "default" + }, + "CIO-Delivery-ID": "maestro-scene-route", + "CIO-Delivery-Token": "maestro-scene-token", + "CIO": { + "push": { + "link": "https://customer.io/react-native-scene-e2e/cold" + } + } +} diff --git a/.maestro/fixtures/customerio_scene_warm.apns b/.maestro/fixtures/customerio_scene_warm.apns new file mode 100644 index 00000000..657bce9b --- /dev/null +++ b/.maestro/fixtures/customerio_scene_warm.apns @@ -0,0 +1,16 @@ +{ + "aps": { + "alert": { + "title": "Customer.io scene routing", + "body": "Open React Native warm destination" + }, + "sound": "default" + }, + "CIO-Delivery-ID": "maestro-scene-route-warm", + "CIO-Delivery-Token": "maestro-scene-token-warm", + "CIO": { + "push": { + "link": "https://customer.io/react-native-scene-e2e/warm" + } + } +} diff --git a/.maestro/fixtures/react-native-scene/App.tsx b/.maestro/fixtures/react-native-scene/App.tsx new file mode 100644 index 00000000..40a407f9 --- /dev/null +++ b/.maestro/fixtures/react-native-scene/App.tsx @@ -0,0 +1,34 @@ +import React, { useEffect, useState } from 'react'; +import { Linking, Text, View } from 'react-native'; +import { CioRegion, CustomerIO } from 'customerio-reactnative'; + +export default function App(): React.JSX.Element { + const [receivedUrl, setReceivedUrl] = useState(null); + const [initialized, setInitialized] = useState(false); + + useEffect(() => { + const subscription = Linking.addEventListener('url', ({ url }) => { + setReceivedUrl(url); + }); + + CustomerIO.initialize({ + cdpApiKey: 'scene-e2e-key', + region: CioRegion.US, + }).then(() => setInitialized(true)); + CustomerIO.pushMessaging.showPromptForPushNotifications({ + ios: { sound: true, badge: true }, + }); + + return () => subscription.remove(); + }, []); + + return ( + + + Customer.io React Native scene E2E{' '} + {initialized ? 'ready' : 'initializing'} + + {receivedUrl && Received: {receivedUrl}} + + ); +} diff --git a/.maestro/fixtures/react-native-scene/AppDelegate.swift b/.maestro/fixtures/react-native-scene/AppDelegate.swift new file mode 100644 index 00000000..9ecd38f7 --- /dev/null +++ b/.maestro/fixtures/react-native-scene/AppDelegate.swift @@ -0,0 +1,17 @@ +import CioMessagingPushAPN +import UIKit + +@main +class AppDelegateWithCioIntegration: CioAppDelegateWrapper {} + +class AppDelegate: UIResponder, UIApplicationDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + MessagingPushAPN.initialize( + withConfig: MessagingPushConfigBuilder().build() + ) + return true + } +} diff --git a/.maestro/fixtures/react-native-scene/SceneDelegate.swift b/.maestro/fixtures/react-native-scene/SceneDelegate.swift new file mode 100644 index 00000000..d6a42bdc --- /dev/null +++ b/.maestro/fixtures/react-native-scene/SceneDelegate.swift @@ -0,0 +1,44 @@ +import React +import ReactAppDependencyProvider +import React_RCTAppDelegate +import UIKit +import customerio_reactnative + +class SceneDelegate: RCTDefaultReactNativeFactoryDelegate, UIWindowSceneDelegate { + var window: UIWindow? + var reactNativeFactory: RCTReactNativeFactory? + + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + guard let windowScene = scene as? UIWindowScene else { return } + + NativeCustomerIO.configureSceneDeepLinkRouting() + dependencyProvider = RCTAppDependencyProvider() + reactNativeFactory = RCTReactNativeFactory(delegate: self) + window = UIWindow(windowScene: windowScene) + reactNativeFactory?.startReactNative( + withModuleName: "CioRnSceneHost", + in: window, + launchOptions: NativeLiveActivities.reactNativeLaunchOptions( + from: connectionOptions + ) + ) + } + + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + for context in URLContexts { + NativeLiveActivities.handleAndRouteWidgetUrl(context.url) + } + } + + func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { + RCTLinkingManager.scene(scene, continue: userActivity) + } + + override func bundleURL() -> URL? { + Bundle.main.url(forResource: "main", withExtension: "jsbundle") + } +} diff --git a/.maestro/fixtures/react-native-scene/configure.rb b/.maestro/fixtures/react-native-scene/configure.rb new file mode 100644 index 00000000..413309fd --- /dev/null +++ b/.maestro/fixtures/react-native-scene/configure.rb @@ -0,0 +1,12 @@ +require 'xcodeproj' + +project_path, app_name = ARGV +project = Xcodeproj::Project.open(project_path) +target = project.targets.find { |candidate| candidate.name == app_name } +raise "missing application target #{app_name}" unless target + +group = project.main_group.find_subpath(app_name, true) +scene_delegate = group.files.find { |file| File.basename(file.path) == 'SceneDelegate.swift' } +scene_delegate ||= group.new_file("#{app_name}/SceneDelegate.swift") +target.source_build_phase.add_file_reference(scene_delegate, true) +project.save diff --git a/.maestro/fixtures/react-native-scene/configure_podfile.rb b/.maestro/fixtures/react-native-scene/configure_podfile.rb new file mode 100644 index 00000000..6beac462 --- /dev/null +++ b/.maestro/fixtures/react-native-scene/configure_podfile.rb @@ -0,0 +1,49 @@ +podfile_path = ARGV.fetch(0) +podfile = File.read(podfile_path) + +helper = <<~'RUBY' + def node_resolve(script) + Pod::Executable.execute_command('node', ['-p', + "require.resolve('#{script}', {paths: [process.argv[1]]})", + __dir__ + ]).strip + end + + customer_io_package_root = File.dirname(node_resolve('customerio-reactnative/package.json')) + require File.join(customer_io_package_root, 'ios', 'cocoapods_deployment_target') + + customer_io_minimum_ios_version = CustomerIO::CocoaPodsDeploymentTarget.maximum( + min_ios_version_supported, + '15.0' + ) +RUBY + +platform_line = "platform :ios, min_ios_version_supported\n" +raise 'missing React Native platform declaration' unless podfile.include?(platform_line) + +wrapper_subspecs = " pod 'customerio-reactnative', :path => '../node_modules/customerio-reactnative', :subspecs => ['apn', 'liveactivities']\n" +post_install_line = " post_install do |installer|\n" +raise 'missing React Native post_install block' unless podfile.include?(post_install_line) + +post_install_end = <<~'RUBY' + ) + end + end +RUBY +normalizer = <<~'RUBY' + ) + + CustomerIO::CocoaPodsDeploymentTarget.normalize!( + installer, + minimum_ios_version: customer_io_minimum_ios_version + ) + end + end +RUBY +raise 'unexpected React Native post_install block' unless podfile.end_with?(post_install_end) + +podfile.sub!(platform_line, "#{helper}\n#{platform_line}") +podfile.sub!(post_install_line, "#{wrapper_subspecs}\n#{post_install_line}") +podfile.delete_suffix!(post_install_end) +podfile << normalizer +File.write(podfile_path, podfile) diff --git a/.maestro/run_scene_push.sh b/.maestro/run_scene_push.sh new file mode 100755 index 00000000..b40ef4d3 --- /dev/null +++ b/.maestro/run_scene_push.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +FIXTURE_DIR="$REPO_ROOT/.maestro/fixtures/react-native-scene" +APP_NAME="CioRnSceneHost" +APP_ID="org.reactjs.native.example.CioRnSceneHost" +RN_CLI_VERSION="20.2.0" +RN_TEMPLATE_VERSION="0.87.0" +RN_VERSION="0.88.0-nightly-20260823-0c7f63a4e" +DEVELOPER_DIR="${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer}" +export DEVELOPER_DIR + +device_id="${E2E_DEVICE_ID:-}" +if [[ -z "$device_id" ]]; then + device_id="$(xcrun simctl list devices booted -j | jq -r \ + '[.devices[][] | select(.state == "Booted") | select(.name | startswith("iPhone"))][0].udid // empty')" +fi +if [[ -z "$device_id" ]]; then + simulator_name="${E2E_SIMULATOR_NAME:-iPhone 17 Pro}" + device_id="$(xcrun simctl list devices available -j | jq -r --arg name "$simulator_name" \ + '[.devices[][] | select(.name == $name)][0].udid // empty')" + if [[ -z "$device_id" ]]; then + echo "error: no available '$simulator_name' simulator; set E2E_DEVICE_ID or E2E_SIMULATOR_NAME" >&2 + exit 2 + fi + xcrun simctl boot "$device_id" >/dev/null 2>&1 || true +fi +xcrun simctl bootstatus "$device_id" -b + +for command in bundle jq maestro node npm npx pod ruby xcodebuild xcrun; do + command -v "$command" >/dev/null 2>&1 || { + echo "error: required command '$command' is not installed" >&2 + exit 2 + } +done + +created_host_parent=false +if [[ -n "${CIO_E2E_HOST_PARENT:-}" ]]; then + host_parent="$CIO_E2E_HOST_PARENT" +else + host_parent="$(mktemp -d "${TMPDIR:-/tmp}/cio-rn-scene-e2e.XXXXXX")" + created_host_parent=true +fi +host="$host_parent/$APP_NAME" +package_dir="$host_parent/package" +derived_data="$host_parent/derived-data" +push_pid="" +cleanup() { + if [[ -n "$push_pid" ]]; then + kill "$push_pid" >/dev/null 2>&1 || true + wait "$push_pid" 2>/dev/null || true + fi + if [[ "$created_host_parent" == true && -d "$host_parent" ]]; then + find "$host_parent" -depth -delete + fi +} +trap cleanup EXIT +mkdir -p "$package_dir" + +cd "$REPO_ROOT" +npm ci +package_name="$(npm pack --silent --pack-destination "$package_dir" | tail -n 1)" + +cd "$host_parent" +npx "@react-native-community/cli@$RN_CLI_VERSION" init "$APP_NAME" \ + --version "$RN_VERSION" \ + --template "@react-native-community/template@$RN_TEMPLATE_VERSION" \ + --skip-install +cd "$host" +npm pkg set \ + "dependencies.react-native=$RN_VERSION" \ + "dependencies.@react-native/new-app-screen=$RN_VERSION" +npm pkg set \ + "devDependencies.@react-native/babel-preset=$RN_VERSION" \ + "devDependencies.@react-native/eslint-config=$RN_VERSION" \ + "devDependencies.@react-native/jest-preset=$RN_VERSION" \ + "devDependencies.@react-native/metro-config=$RN_VERSION" \ + "devDependencies.@react-native/typescript-config=$RN_VERSION" +npm install +npm install "$package_dir/$package_name" + +cp "$FIXTURE_DIR/App.tsx" App.tsx +cp "$FIXTURE_DIR/AppDelegate.swift" "ios/$APP_NAME/AppDelegate.swift" +cp "$FIXTURE_DIR/SceneDelegate.swift" "ios/$APP_NAME/SceneDelegate.swift" + +plist="ios/$APP_NAME/Info.plist" +/usr/libexec/PlistBuddy -c 'Delete :UIApplicationSceneManifest' "$plist" >/dev/null 2>&1 || true +/usr/libexec/PlistBuddy -c 'Add :UIApplicationSceneManifest dict' "$plist" +/usr/libexec/PlistBuddy -c 'Add :UIApplicationSceneManifest:UISceneConfigurations dict' "$plist" +/usr/libexec/PlistBuddy -c 'Add :UIApplicationSceneManifest:UISceneConfigurations:UIWindowSceneSessionRoleApplication array' "$plist" +/usr/libexec/PlistBuddy -c 'Add :UIApplicationSceneManifest:UISceneConfigurations:UIWindowSceneSessionRoleApplication:0 dict' "$plist" +/usr/libexec/PlistBuddy -c 'Add :UIApplicationSceneManifest:UISceneConfigurations:UIWindowSceneSessionRoleApplication:0:UISceneConfigurationName string Default Configuration' "$plist" +# shellcheck disable=SC2016 +/usr/libexec/PlistBuddy -c 'Add :UIApplicationSceneManifest:UISceneConfigurations:UIWindowSceneSessionRoleApplication:0:UISceneDelegateClassName string $(PRODUCT_MODULE_NAME).SceneDelegate' "$plist" + +bundle install +bundle exec ruby "$FIXTURE_DIR/configure_podfile.rb" ios/Podfile +bundle exec ruby "$FIXTURE_DIR/configure.rb" "ios/$APP_NAME.xcodeproj" "$APP_NAME" +bundle exec pod install --project-directory=ios + +node node_modules/react-native/scripts/bundle.js \ + --platform ios \ + --dev false \ + --entry-file index.js \ + --bundle-output "ios/main.jsbundle" \ + --assets-dest ios + +xcodebuild -quiet \ + -workspace "ios/$APP_NAME.xcworkspace" \ + -scheme "$APP_NAME" \ + -configuration Release \ + -destination "platform=iOS Simulator,id=$device_id" \ + -derivedDataPath "$derived_data" \ + CODE_SIGNING_ALLOWED=NO \ + build + +app_path="$derived_data/Build/Products/Release-iphonesimulator/$APP_NAME.app" +test -d "$app_path" +xcrun simctl uninstall "$device_id" "$APP_ID" >/dev/null 2>&1 || true +xcrun simctl install "$device_id" "$app_path" + +cd "$REPO_ROOT" +maestro --device "$device_id" test .maestro/scene_push_prepare.yaml +xcrun simctl terminate "$device_id" "$APP_ID" +( + sleep 10 + xcrun simctl push "$device_id" "$APP_ID" .maestro/fixtures/customerio_scene_cold.apns +) & +push_pid=$! +maestro --device "$device_id" test .maestro/scene_push_open.yaml +wait "$push_pid" +push_pid="" + +( + sleep 10 + xcrun simctl push "$device_id" "$APP_ID" .maestro/fixtures/customerio_scene_warm.apns +) & +push_pid=$! +maestro --device "$device_id" test .maestro/scene_push_warm.yaml +wait "$push_pid" +push_pid="" diff --git a/.maestro/scene_push_open.yaml b/.maestro/scene_push_open.yaml new file mode 100644 index 00000000..762a0f23 --- /dev/null +++ b/.maestro/scene_push_open.yaml @@ -0,0 +1,10 @@ +appId: org.reactjs.native.example.CioRnSceneHost +name: React Native UIScene cold push routing, open +--- +- pressKey: Home +- tapOn: + id: "NotificationShortLookView" + retryTapIfNoChange: false +- extendedWaitUntil: + visible: "Received: https://customer.io/react-native-scene-e2e/cold" + timeout: 15000 diff --git a/.maestro/scene_push_prepare.yaml b/.maestro/scene_push_prepare.yaml new file mode 100644 index 00000000..4c8180f8 --- /dev/null +++ b/.maestro/scene_push_prepare.yaml @@ -0,0 +1,10 @@ +appId: org.reactjs.native.example.CioRnSceneHost +name: React Native UIScene push routing, prepare +--- +- launchApp: + clearState: false + permissions: + notifications: allow +- extendedWaitUntil: + visible: "Customer.io React Native scene E2E ready" + timeout: 15000 diff --git a/.maestro/scene_push_warm.yaml b/.maestro/scene_push_warm.yaml new file mode 100644 index 00000000..2dcb35d3 --- /dev/null +++ b/.maestro/scene_push_warm.yaml @@ -0,0 +1,10 @@ +appId: org.reactjs.native.example.CioRnSceneHost +name: React Native UIScene warm push routing +--- +- pressKey: Home +- tapOn: + id: "NotificationShortLookView" + retryTapIfNoChange: false +- extendedWaitUntil: + visible: "Received: https://customer.io/react-native-scene-e2e/warm" + timeout: 15000 From 51efeaabf36fac65afb286e98faea85f00c53938 Mon Sep 17 00:00:00 2001 From: Shahroz Khan Date: Tue, 25 Aug 2026 11:30:17 -0400 Subject: [PATCH 02/23] test: remove scene notification timing race --- .github/workflows/react-native-scene-e2e.yml | 4 +- .maestro/run_scene_push.sh | 70 ++++++++++++++------ 2 files changed, 49 insertions(+), 25 deletions(-) diff --git a/.github/workflows/react-native-scene-e2e.yml b/.github/workflows/react-native-scene-e2e.yml index 90d7f5d3..49bf856a 100644 --- a/.github/workflows/react-native-scene-e2e.yml +++ b/.github/workflows/react-native-scene-e2e.yml @@ -5,9 +5,6 @@ on: paths: - '.github/workflows/react-native-scene-e2e.yml' - '.maestro/**' - - 'ios/wrappers/CustomerIOReactNativeDeepLinkRouter.swift' - - 'ios/wrappers/NativeCustomerIO.swift' - - 'ios/wrappers/liveactivities/NativeLiveActivities.swift' schedule: - cron: '47 7 * * *' workflow_dispatch: @@ -49,6 +46,7 @@ jobs: - name: Install Maestro shell: bash run: | + export MAESTRO_VERSION=2.6.0 curl -Ls https://get.maestro.mobile.dev | bash echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" diff --git a/.maestro/run_scene_push.sh b/.maestro/run_scene_push.sh index b40ef4d3..5aff1818 100755 --- a/.maestro/run_scene_push.sh +++ b/.maestro/run_scene_push.sh @@ -9,7 +9,10 @@ APP_ID="org.reactjs.native.example.CioRnSceneHost" RN_CLI_VERSION="20.2.0" RN_TEMPLATE_VERSION="0.87.0" RN_VERSION="0.88.0-nightly-20260823-0c7f63a4e" -DEVELOPER_DIR="${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer}" +DEVELOPER_DIR="${DEVELOPER_DIR:-$(xcode-select -p)}" +if [[ "$DEVELOPER_DIR" == */CommandLineTools && -d /Applications/Xcode.app/Contents/Developer ]]; then + DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer +fi export DEVELOPER_DIR device_id="${E2E_DEVICE_ID:-}" @@ -46,11 +49,15 @@ fi host="$host_parent/$APP_NAME" package_dir="$host_parent/package" derived_data="$host_parent/derived-data" -push_pid="" +flow_log="" +flow_pid="" cleanup() { - if [[ -n "$push_pid" ]]; then - kill "$push_pid" >/dev/null 2>&1 || true - wait "$push_pid" 2>/dev/null || true + if [[ -n "$flow_pid" ]]; then + kill "$flow_pid" >/dev/null 2>&1 || true + wait "$flow_pid" 2>/dev/null || true + fi + if [[ -n "$flow_log" ]]; then + rm -f "$flow_log" fi if [[ "$created_host_parent" == true && -d "$host_parent" ]]; then find "$host_parent" -depth -delete @@ -59,6 +66,40 @@ cleanup() { trap cleanup EXIT mkdir -p "$package_dir" +run_notification_flow() { + local flow="$1" + local payload="$2" + local ready=false + + flow_log="$(mktemp "${TMPDIR:-/tmp}/cio-rn-maestro-flow.XXXXXX")" + maestro --device "$device_id" test "$flow" > >(tee "$flow_log") 2>&1 & + flow_pid=$! + + for _ in {1..240}; do + if grep -q 'Press Home key.*COMPLETED' "$flow_log"; then + ready=true + break + fi + if ! kill -0 "$flow_pid" >/dev/null 2>&1; then + wait "$flow_pid" + flow_pid="" + return 1 + fi + sleep 0.5 + done + + if [[ "$ready" != true ]]; then + echo "error: Maestro did not reach the Home screen before notification injection" >&2 + return 1 + fi + + xcrun simctl push "$device_id" "$APP_ID" "$payload" + wait "$flow_pid" + flow_pid="" + rm -f "$flow_log" + flow_log="" +} + cd "$REPO_ROOT" npm ci package_name="$(npm pack --silent --pack-destination "$package_dir" | tail -n 1)" @@ -124,20 +165,5 @@ xcrun simctl install "$device_id" "$app_path" cd "$REPO_ROOT" maestro --device "$device_id" test .maestro/scene_push_prepare.yaml xcrun simctl terminate "$device_id" "$APP_ID" -( - sleep 10 - xcrun simctl push "$device_id" "$APP_ID" .maestro/fixtures/customerio_scene_cold.apns -) & -push_pid=$! -maestro --device "$device_id" test .maestro/scene_push_open.yaml -wait "$push_pid" -push_pid="" - -( - sleep 10 - xcrun simctl push "$device_id" "$APP_ID" .maestro/fixtures/customerio_scene_warm.apns -) & -push_pid=$! -maestro --device "$device_id" test .maestro/scene_push_warm.yaml -wait "$push_pid" -push_pid="" +run_notification_flow .maestro/scene_push_open.yaml .maestro/fixtures/customerio_scene_cold.apns +run_notification_flow .maestro/scene_push_warm.yaml .maestro/fixtures/customerio_scene_warm.apns From 1e1dfb71908a740c668a829edce53fde1c397bd1 Mon Sep 17 00:00:00 2001 From: Shahroz Khan Date: Tue, 25 Aug 2026 11:48:22 -0400 Subject: [PATCH 03/23] test: harden React Native scene E2E diagnostics --- .github/workflows/react-native-scene-e2e.yml | 10 ++++ .maestro/README.md | 2 +- .maestro/fixtures/react-native-scene/App.tsx | 16 +++++-- .maestro/run_scene_push.sh | 48 ++++++++++++++++---- .maestro/scene_push_open.yaml | 4 ++ .maestro/scene_push_warm.yaml | 4 ++ 6 files changed, 69 insertions(+), 15 deletions(-) diff --git a/.github/workflows/react-native-scene-e2e.yml b/.github/workflows/react-native-scene-e2e.yml index 49bf856a..b33a9e51 100644 --- a/.github/workflows/react-native-scene-e2e.yml +++ b/.github/workflows/react-native-scene-e2e.yml @@ -19,6 +19,7 @@ concurrency: jobs: route-notifications: name: RN scene notification routing on Xcode 27 + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository runs-on: xcode-27 timeout-minutes: 60 @@ -55,3 +56,12 @@ jobs: env: E2E_SIMULATOR_NAME: iPhone 17 Pro run: .maestro/run_scene_push.sh + + - name: Upload Maestro diagnostics + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: react-native-scene-maestro-diagnostics + path: ${{ runner.temp }}/react-native-scene-maestro-* + if-no-files-found: ignore + retention-days: 7 diff --git a/.maestro/README.md b/.maestro/README.md index 65d98a4d..67c5ca3b 100644 --- a/.maestro/README.md +++ b/.maestro/README.md @@ -16,7 +16,7 @@ and does not require changing a shared Customer.io campaign. It validates the client routing path, not backend `sent`, `delivered`, or `opened` metrics. Those metrics remain a separate remote E2E lane using a real workspace and APNs. -Run with a booted iPhone simulator: +Run with Maestro 2.6.0 and a booted iPhone simulator: ```bash .maestro/run_scene_push.sh diff --git a/.maestro/fixtures/react-native-scene/App.tsx b/.maestro/fixtures/react-native-scene/App.tsx index 40a407f9..04f3aeb3 100644 --- a/.maestro/fixtures/react-native-scene/App.tsx +++ b/.maestro/fixtures/react-native-scene/App.tsx @@ -5,6 +5,7 @@ import { CioRegion, CustomerIO } from 'customerio-reactnative'; export default function App(): React.JSX.Element { const [receivedUrl, setReceivedUrl] = useState(null); const [initialized, setInitialized] = useState(false); + const [failure, setFailure] = useState(null); useEffect(() => { const subscription = Linking.addEventListener('url', ({ url }) => { @@ -14,10 +15,16 @@ export default function App(): React.JSX.Element { CustomerIO.initialize({ cdpApiKey: 'scene-e2e-key', region: CioRegion.US, - }).then(() => setInitialized(true)); - CustomerIO.pushMessaging.showPromptForPushNotifications({ - ios: { sound: true, badge: true }, - }); + }) + .then(() => + CustomerIO.pushMessaging.showPromptForPushNotifications({ + ios: { sound: true, badge: true }, + }) + ) + .then(() => setInitialized(true)) + .catch((error: unknown) => { + setFailure(error instanceof Error ? error.message : String(error)); + }); return () => subscription.remove(); }, []); @@ -28,6 +35,7 @@ export default function App(): React.JSX.Element { Customer.io React Native scene E2E{' '} {initialized ? 'ready' : 'initializing'} + {failure && Initialization failed: {failure}} {receivedUrl && Received: {receivedUrl}} ); diff --git a/.maestro/run_scene_push.sh b/.maestro/run_scene_push.sh index 5aff1818..b59e85ec 100755 --- a/.maestro/run_scene_push.sh +++ b/.maestro/run_scene_push.sh @@ -15,6 +15,13 @@ if [[ "$DEVELOPER_DIR" == */CommandLineTools && -d /Applications/Xcode.app/Conte fi export DEVELOPER_DIR +for command in bundle jq maestro node npm npx pod ruby xcodebuild xcrun; do + command -v "$command" >/dev/null 2>&1 || { + echo "error: required command '$command' is not installed" >&2 + exit 2 + } +done + device_id="${E2E_DEVICE_ID:-}" if [[ -z "$device_id" ]]; then device_id="$(xcrun simctl list devices booted -j | jq -r \ @@ -32,13 +39,6 @@ if [[ -z "$device_id" ]]; then fi xcrun simctl bootstatus "$device_id" -b -for command in bundle jq maestro node npm npx pod ruby xcodebuild xcrun; do - command -v "$command" >/dev/null 2>&1 || { - echo "error: required command '$command' is not installed" >&2 - exit 2 - } -done - created_host_parent=false if [[ -n "${CIO_E2E_HOST_PARENT:-}" ]]; then host_parent="$CIO_E2E_HOST_PARENT" @@ -51,6 +51,7 @@ package_dir="$host_parent/package" derived_data="$host_parent/derived-data" flow_log="" flow_pid="" +installed_app=false cleanup() { if [[ -n "$flow_pid" ]]; then kill "$flow_pid" >/dev/null 2>&1 || true @@ -59,6 +60,10 @@ cleanup() { if [[ -n "$flow_log" ]]; then rm -f "$flow_log" fi + if [[ "$installed_app" == true ]]; then + xcrun simctl terminate "$device_id" "$APP_ID" >/dev/null 2>&1 || true + xcrun simctl uninstall "$device_id" "$APP_ID" >/dev/null 2>&1 || true + fi if [[ "$created_host_parent" == true && -d "$host_parent" ]]; then find "$host_parent" -depth -delete fi @@ -70,9 +75,23 @@ run_notification_flow() { local flow="$1" local payload="$2" local ready=false + local status=0 + local flow_name="${flow##*/}" + local maestro_args=(--device "$device_id" test "$flow") + + flow_name="${flow_name%.yaml}" + if [[ -n "${RUNNER_TEMP:-}" ]]; then + maestro_args=( + --device "$device_id" + test + --debug-output "$RUNNER_TEMP/react-native-scene-maestro-$flow_name" + --flatten-debug-output + "$flow" + ) + fi flow_log="$(mktemp "${TMPDIR:-/tmp}/cio-rn-maestro-flow.XXXXXX")" - maestro --device "$device_id" test "$flow" > >(tee "$flow_log") 2>&1 & + maestro "${maestro_args[@]}" > >(tee "$flow_log") 2>&1 & flow_pid=$! for _ in {1..240}; do @@ -81,7 +100,12 @@ run_notification_flow() { break fi if ! kill -0 "$flow_pid" >/dev/null 2>&1; then - wait "$flow_pid" + if wait "$flow_pid"; then + echo "error: Maestro completed before reaching the Home screen" >&2 + else + status=$? + echo "error: Maestro exited before reaching the Home screen (status $status)" >&2 + fi flow_pid="" return 1 fi @@ -94,7 +118,10 @@ run_notification_flow() { fi xcrun simctl push "$device_id" "$APP_ID" "$payload" - wait "$flow_pid" + if ! wait "$flow_pid"; then + flow_pid="" + return 1 + fi flow_pid="" rm -f "$flow_log" flow_log="" @@ -161,6 +188,7 @@ app_path="$derived_data/Build/Products/Release-iphonesimulator/$APP_NAME.app" test -d "$app_path" xcrun simctl uninstall "$device_id" "$APP_ID" >/dev/null 2>&1 || true xcrun simctl install "$device_id" "$app_path" +installed_app=true cd "$REPO_ROOT" maestro --device "$device_id" test .maestro/scene_push_prepare.yaml diff --git a/.maestro/scene_push_open.yaml b/.maestro/scene_push_open.yaml index 762a0f23..23486f3f 100644 --- a/.maestro/scene_push_open.yaml +++ b/.maestro/scene_push_open.yaml @@ -2,6 +2,10 @@ appId: org.reactjs.native.example.CioRnSceneHost name: React Native UIScene cold push routing, open --- - pressKey: Home +- extendedWaitUntil: + visible: + id: "NotificationShortLookView" + timeout: 30000 - tapOn: id: "NotificationShortLookView" retryTapIfNoChange: false diff --git a/.maestro/scene_push_warm.yaml b/.maestro/scene_push_warm.yaml index 2dcb35d3..2fa64797 100644 --- a/.maestro/scene_push_warm.yaml +++ b/.maestro/scene_push_warm.yaml @@ -2,6 +2,10 @@ appId: org.reactjs.native.example.CioRnSceneHost name: React Native UIScene warm push routing --- - pressKey: Home +- extendedWaitUntil: + visible: + id: "NotificationShortLookView" + timeout: 30000 - tapOn: id: "NotificationShortLookView" retryTapIfNoChange: false From 8aa7d134dcdfe4cd68fbd75224525f93ac16ed17 Mon Sep 17 00:00:00 2001 From: Shahroz Khan Date: Tue, 25 Aug 2026 12:07:05 -0400 Subject: [PATCH 04/23] test: honor configured E2E simulator --- .maestro/run_scene_push.sh | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.maestro/run_scene_push.sh b/.maestro/run_scene_push.sh index b59e85ec..d3e2039c 100755 --- a/.maestro/run_scene_push.sh +++ b/.maestro/run_scene_push.sh @@ -23,12 +23,17 @@ for command in bundle jq maestro node npm npx pod ruby xcodebuild xcrun; do done device_id="${E2E_DEVICE_ID:-}" -if [[ -z "$device_id" ]]; then +simulator_name="${E2E_SIMULATOR_NAME:-}" +if [[ -z "$device_id" && -n "$simulator_name" ]]; then + device_id="$(xcrun simctl list devices available -j | jq -r --arg name "$simulator_name" \ + '[.devices[][] | select(.name == $name)][0].udid // empty')" +fi +if [[ -z "$device_id" && -z "$simulator_name" ]]; then device_id="$(xcrun simctl list devices booted -j | jq -r \ '[.devices[][] | select(.state == "Booted") | select(.name | startswith("iPhone"))][0].udid // empty')" fi if [[ -z "$device_id" ]]; then - simulator_name="${E2E_SIMULATOR_NAME:-iPhone 17 Pro}" + simulator_name="${simulator_name:-iPhone 17 Pro}" device_id="$(xcrun simctl list devices available -j | jq -r --arg name "$simulator_name" \ '[.devices[][] | select(.name == $name)][0].udid // empty')" if [[ -z "$device_id" ]]; then From 98b8f9ca00bf1289637a99ac597dbe3387e185c4 Mon Sep 17 00:00:00 2001 From: Shahroz Khan Date: Tue, 25 Aug 2026 12:51:38 -0400 Subject: [PATCH 05/23] test: boot resolved E2E simulator --- .maestro/run_scene_push.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.maestro/run_scene_push.sh b/.maestro/run_scene_push.sh index d3e2039c..1e8294fc 100755 --- a/.maestro/run_scene_push.sh +++ b/.maestro/run_scene_push.sh @@ -40,8 +40,8 @@ if [[ -z "$device_id" ]]; then echo "error: no available '$simulator_name' simulator; set E2E_DEVICE_ID or E2E_SIMULATOR_NAME" >&2 exit 2 fi - xcrun simctl boot "$device_id" >/dev/null 2>&1 || true fi +xcrun simctl boot "$device_id" >/dev/null 2>&1 || true xcrun simctl bootstatus "$device_id" -b created_host_parent=false From 3cc4b07b05fb51787612a596df9e513d183c9b2c Mon Sep 17 00:00:00 2001 From: Shahroz Khan Date: Tue, 25 Aug 2026 15:45:27 -0400 Subject: [PATCH 06/23] test: add React Native remote push E2E (MBL-2233) --- .gitignore | 3 +- .maestro/.env.example | 1 + .maestro/README.md | 20 +++- .maestro/configure_nse_env.rb | 16 +++ .maestro/remote_push.yaml | 141 +++++++++++++++++++++++ .maestro/run_remote_push.sh | 209 ++++++++++++++++++++++++++++++++++ example/src/screens/home.tsx | 5 +- example/src/screens/login.tsx | 3 + example/src/screens/track.tsx | 4 + 9 files changed, 399 insertions(+), 3 deletions(-) create mode 100644 .maestro/.env.example create mode 100644 .maestro/configure_nse_env.rb create mode 100644 .maestro/remote_push.yaml create mode 100755 .maestro/run_remote_push.sh diff --git a/.gitignore b/.gitignore index 56587e6a..6c5b69f3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ eslint_report.json +.maestro/.env # Config files env.js @@ -95,4 +96,4 @@ api-extractor-output/temp/ ## iOS Example app ## This file will be auto linked based ## on the target app -ios/Google-Service-Info.plist \ No newline at end of file +ios/Google-Service-Info.plist diff --git a/.maestro/.env.example b/.maestro/.env.example new file mode 100644 index 00000000..2a7486ab --- /dev/null +++ b/.maestro/.env.example @@ -0,0 +1 @@ +MAESTRO_APP_API_KEY=paste-app-api-key-here diff --git a/.maestro/README.md b/.maestro/README.md index 67c5ca3b..05eb390d 100644 --- a/.maestro/README.md +++ b/.maestro/README.md @@ -14,10 +14,28 @@ JavaScript listener and `CustomerIO.initialize` are ready. The checked-in payload uses `simctl push` so the destination is deterministic and does not require changing a shared Customer.io campaign. It validates the client routing path, not backend `sent`, `delivered`, or `opened` metrics. Those -metrics remain a separate remote E2E lane using a real workspace and APNs. +metrics are covered separately by `run_remote_push.sh`, which uses the existing +APN sample, its notification service extension, a real workspace, and APNs. Run with Maestro 2.6.0 and a booted iPhone simulator: ```bash .maestro/run_scene_push.sh ``` + +Run the remote APNs loop after configuring the APN sample's ignored +`example/src/env.ts` file. The runner gives the notification service extension +the same APN workspace key for the build and restores its ignored config when +the run ends. Put the `Mobile SDK Maestro E2E` App API key from the same +`Mobile: React Native` workspace in `.maestro/.env` as shown by +`.maestro/.env.example`, then run: + +```bash +.maestro/run_remote_push.sh +``` + +The remote flow identifies a fresh customer in the `Mobile: React Native` +workspace, sends the `jason` event used by its existing `push_notif_test` +automation (campaign 15), taps the real system notification, and requires +matching backend `delivered` and `opened` metrics. It is intentionally local or +trusted-CI only; pull requests do not receive the required workspace credential. diff --git a/.maestro/configure_nse_env.rb b/.maestro/configure_nse_env.rb new file mode 100644 index 00000000..d36b760d --- /dev/null +++ b/.maestro/configure_nse_env.rb @@ -0,0 +1,16 @@ +#!/usr/bin/env ruby + +app_env_path, template_path, output_path = ARGV +abort 'usage: configure_nse_env.rb APP_ENV TEMPLATE OUTPUT' unless output_path + +app_env = File.read(app_env_path) +primary_config = app_env.match(/primary:\s*\{(?.*?)\}\s+satisfies/m) +abort 'APN sample primary config was not found' unless primary_config + +api_key = primary_config[:config].match(/API_KEY:\s*'(?[^']+)'/)&.[](:value) +abort 'APN sample CDP API key was not found' if api_key.nil? || api_key.empty? || api_key.include?('<') + +template = File.read(template_path) +abort 'notification service extension template placeholder was not found' unless template.include?('') + +File.write(output_path, template.sub('', api_key)) diff --git a/.maestro/remote_push.yaml b/.maestro/remote_push.yaml new file mode 100644 index 00000000..562f3255 --- /dev/null +++ b/.maestro/remote_push.yaml @@ -0,0 +1,141 @@ +appId: ${APP_ID} +name: React Native APNs delivery and opened attribution +--- +- runScript: + file: ../scripts/setup_run.js + +- launchApp: + clearState: false + +- extendedWaitUntil: + visible: + id: "login_button" + timeout: 15000 +- tapOn: + id: "first_name_input" +- inputText: "Maestro Campaign Tester" +- tapOn: + id: "email_input" +- inputText: ${output.email} +- hideKeyboard +- runFlow: + when: + visible: "Get 20% off your first order" + commands: + - tapOn: "✕" +- tapOn: + id: "login_button" + +- runFlow: + when: + visible: "Get 20% off your first order" + commands: + - tapOn: "✕" + +- scrollUntilVisible: + element: + id: "push_permission_button" + direction: DOWN + timeout: 10000 +- tapOn: + id: "push_permission_button" +- tapOn: + text: "^Allow$" + optional: true +- extendedWaitUntil: + visible: "Push notifications enabled successfully" + timeout: 10000 + +- retry: + maxRetries: 1 + commands: + - runFlow: + when: + visible: + id: "BackButton" + commands: + - tapOn: + id: "BackButton" + - extendedWaitUntil: + visible: + id: "custom_event_button" + timeout: 15000 + - tapOn: + id: "custom_event_button" +- extendedWaitUntil: + visible: + id: "event_name_input" + timeout: 10000 +- tapOn: + id: "event_name_input" +- inputText: "jason" +- tapOn: + id: "property_name_input" +- inputText: "run_id" +- tapOn: + id: "property_value_input" +- inputText: ${output.run_id} +- hideKeyboard +- scrollUntilVisible: + element: + id: "send_event_button" + direction: DOWN + timeout: 10000 +- tapOn: + id: "send_event_button" +- extendedWaitUntil: + visible: "Event sent successfully" + timeout: 10000 +- pressKey: Home + +- runScript: + file: ../scripts/assert_customer_activity.js + env: + RUN_EMAIL: ${output.email} + EXPECTED_ACTIVITY_TYPE: "event" + EXPECTED_ACTIVITY_NAME: "jason" + EXPECTED_PROPERTY_NAME: "run_id" + EXPECTED_PROPERTY_VALUE: ${output.run_id} + MIN_TIMESTAMP_SECONDS: ${output.started_at_seconds} + MAX_WAIT_MS: "45000" +- assertTrue: ${output.assert_ok === "true"} + +- runScript: + file: ../scripts/assert_message_delivered.js + env: + RUN_EMAIL: ${output.email} + EXPECTED_TYPE: "push" + MIN_METRIC: "delivered" + CAMPAIGN_ID: "15" + MAX_WAIT_MS: "60000" +- assertTrue: ${output.assert_ok === "true"} +- assertTrue: ${output.message_id.length > 0} + +- swipe: + start: 20%, 1% + end: 20%, 90% + duration: 900 +- waitForAnimationToEnd: + timeout: 2000 +- extendedWaitUntil: + visible: + id: "NotificationShortLookView" + timeout: 15000 +- tapOn: + id: "NotificationShortLookView" + retryTapIfNoChange: false +- extendedWaitUntil: + visible: + id: "send_event_button" + timeout: 15000 + +- runScript: + file: ../scripts/assert_message_delivered.js + env: + RUN_EMAIL: ${output.email} + EXPECTED_TYPE: "push" + MIN_METRIC: "opened" + CAMPAIGN_ID: "15" + EXPECTED_MESSAGE_ID: ${output.message_id} + MAX_WAIT_MS: "60000" +- assertTrue: ${output.assert_ok === "true"} diff --git a/.maestro/run_remote_push.sh b/.maestro/run_remote_push.sh new file mode 100755 index 00000000..6aec9a29 --- /dev/null +++ b/.maestro/run_remote_push.sh @@ -0,0 +1,209 @@ +#!/usr/bin/env bash + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +MAESTRO_DIR="$REPO_ROOT/.maestro" +SAMPLE_ROOT="$REPO_ROOT/example" +APP_ID="io.customer.ami" +MOBILE_E2E_REF="7c7912eedc96fdd623dcb8a7c0d9111feae56d39" +DEVELOPER_DIR="${DEVELOPER_DIR:-$(xcode-select -p)}" +if [[ "$DEVELOPER_DIR" == */CommandLineTools && -d /Applications/Xcode.app/Contents/Developer ]]; then + DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer +fi +export DEVELOPER_DIR +export PUSH_PROVIDER=apn + +if ! bundle _2.6.9_ --version >/dev/null 2>&1; then + if [[ "${CIO_E2E_RUBY_READY:-false}" != "true" ]] && command -v mise >/dev/null 2>&1; then + exec env CIO_E2E_RUBY_READY=true mise x ruby@3.4 -- "$0" "$@" + fi + echo "error: Ruby 3.4 with Bundler 2.6.9 is required" >&2 + exit 2 +fi + +if [[ -f "$MAESTRO_DIR/.env" ]]; then + set -a + # shellcheck disable=SC1091 + source "$MAESTRO_DIR/.env" + set +a +fi +: "${MAESTRO_APP_API_KEY:=${MAESTRO_EXT_API_KEY:-}}" +: "${MAESTRO_EXT_API_BASE_URL:=https://api.customer.io/v1}" +export MAESTRO_APP_API_KEY MAESTRO_EXT_API_BASE_URL + +die() { + echo "error: $*" >&2 + exit 2 +} + +for required_command in bundle curl git jq maestro node npm plutil python3 ruby xcodebuild xcrun; do + command -v "$required_command" >/dev/null 2>&1 || die "required command '$required_command' is not installed" +done + +[[ -n "${MAESTRO_APP_API_KEY:-}" ]] || die "MAESTRO_APP_API_KEY is missing; set it or create .maestro/.env" +[[ "$MAESTRO_APP_API_KEY" != *paste-* ]] || die "MAESTRO_APP_API_KEY still contains a placeholder" + +app_env="$SAMPLE_ROOT/src/env.ts" +nse_env="$SAMPLE_ROOT/ios/NotificationServiceExtension/Env.swift" +[[ -f "$app_env" ]] || die "APN sample config is missing: $app_env" + +backend_status="$(curl -sS --retry 3 --retry-all-errors --retry-delay 2 --connect-timeout 10 \ + -o /dev/null -w '%{http_code}' \ + -H "Authorization: Bearer $MAESTRO_APP_API_KEY" \ + "$MAESTRO_EXT_API_BASE_URL/customers?email=maestro-doctor-no-match%40cio.test" || true)" +[[ "$backend_status" == "200" ]] || die "Customer.io App API preflight returned HTTP ${backend_status:-unreachable}" + +campaign_response="$(curl -sS --retry 3 --retry-all-errors --retry-delay 2 --connect-timeout 10 \ + -w $'\n%{http_code}' \ + -H "Authorization: Bearer $MAESTRO_APP_API_KEY" \ + "$MAESTRO_EXT_API_BASE_URL/campaigns/15" || true)" +campaign_status="${campaign_response##*$'\n'}" +campaign_body="${campaign_response%$'\n'*}" +[[ "$campaign_status" == "200" ]] || \ + die "App API key cannot access campaign 15 in the Mobile: React Native workspace (HTTP ${campaign_status:-unreachable})" +campaign_name="$(printf '%s' "$campaign_body" | jq -r '.campaign.name // .name // empty' 2>/dev/null || true)" +[[ "$campaign_name" == "push_notif_test" ]] || \ + die "campaign 15 is not the expected Mobile: React Native push_notif_test automation" + +device_id="${E2E_DEVICE_ID:-}" +simulator_name="${E2E_SIMULATOR_NAME:-}" +if [[ -z "$device_id" && -n "$simulator_name" ]]; then + device_id="$(xcrun simctl list devices available -j | jq -r --arg name "$simulator_name" \ + '[.devices[][] | select(.name == $name)][0].udid // empty')" +fi +if [[ -z "$device_id" ]]; then + device_id="$(xcrun simctl list devices booted -j | jq -r \ + '[.devices[][] | select(.state == "Booted") | select(.name | startswith("iPhone"))][0].udid // empty')" +fi +if [[ -z "$device_id" ]]; then + simulator_name="${simulator_name:-iPhone 17 Pro}" + device_id="$(xcrun simctl list devices available -j | jq -r --arg name "$simulator_name" \ + '[.devices[][] | select(.name == $name)][0].udid // empty')" +fi +[[ -n "$device_id" ]] || die "no available iPhone simulator; set E2E_DEVICE_ID or E2E_SIMULATOR_NAME" +xcrun simctl boot "$device_id" >/dev/null 2>&1 || true +xcrun simctl bootstatus "$device_id" -b + +temp_base="${TMPDIR:-/tmp}" +temp_base="${temp_base%/}" +run_root="$(mktemp -d "$temp_base/cio-rn-remote-push.XXXXXX")" +harness="$run_root/mobile-e2e" +derived_data="$run_root/derived-data" +artifacts="${CIO_E2E_ARTIFACT_DIR:-$run_root/maestro-artifacts}" +installed_app=false +nse_env_generated=false +nse_env_backup="$run_root/Env.swift.original" +package_lock="$SAMPLE_ROOT/package-lock.json" +package_lock_backup="$run_root/example-package-lock.json" +gemfile_lock="$SAMPLE_ROOT/Gemfile.lock" +gemfile_lock_backup="$run_root/example-Gemfile.lock" +cp "$package_lock" "$package_lock_backup" +cp "$gemfile_lock" "$gemfile_lock_backup" +# shellcheck disable=SC2329 +cleanup() { + set +e + if [[ -d "$harness" && -d "$artifacts" ]]; then + python3 "$harness/scripts/redact_artifacts.py" "$artifacts" >/dev/null 2>&1 || true + fi + if [[ "$installed_app" == true && "${CIO_E2E_KEEP_APP:-false}" != "true" ]]; then + xcrun simctl terminate "$device_id" "$APP_ID" >/dev/null 2>&1 || true + xcrun simctl uninstall "$device_id" "$APP_ID" >/dev/null 2>&1 || true + fi + if [[ "$nse_env_generated" == true ]]; then + if [[ -f "$nse_env_backup" ]]; then + cp "$nse_env_backup" "$nse_env" + else + rm -f -- "$nse_env" + fi + fi + cp "$package_lock_backup" "$package_lock" + cp "$gemfile_lock_backup" "$gemfile_lock" + case "$run_root" in + "$temp_base"/cio-rn-remote-push.*) rm -rf -- "$run_root" ;; + esac +} +trap cleanup EXIT + +ruby "$MAESTRO_DIR/configure_nse_env.rb" \ + "$app_env" \ + "$SAMPLE_ROOT/ios/NotificationServiceExtension/Env.swift.sample" \ + "$run_root/Env.swift.generated" +if [[ -f "$nse_env" ]]; then + cp "$nse_env" "$nse_env_backup" +fi +nse_env_generated=true +cp "$run_root/Env.swift.generated" "$nse_env" + +IOS_CDP_API_KEY="$(ruby -e 'print File.read(ARGV[0])[/CDP_API_KEY\s*=\s*"([^"]+)"/, 1].to_s' "$run_root/Env.swift.generated")" +IOS_SITE_ID="$(ruby -e ' + text = File.read(ARGV[0]) + primary = text[/primary:\s*\{(.*?)\}\s+satisfies/m, 1].to_s + print primary[/SITE_ID:\s*'"'"'([^'"'"']+)'"'"'/, 1].to_s +' "$app_env")" +[[ -n "$IOS_CDP_API_KEY" && -n "$IOS_SITE_ID" ]] || die "APN sample redaction values could not be resolved" +export IOS_CDP_API_KEY IOS_SITE_ID + +git init -q "$harness" +git -C "$harness" remote add origin https://github.com/customerio/mobile-e2e.git +git -C "$harness" fetch -q --depth 1 origin "$MOBILE_E2E_REF" +git -C "$harness" checkout -q --detach FETCH_HEAD +[[ "$(git -C "$harness" rev-parse HEAD)" == "$MOBILE_E2E_REF" ]] || die "mobile-e2e harness checkout does not match the reviewed commit" +cp "$MAESTRO_DIR/remote_push.yaml" "$harness/flows/reactnative_remote_push.yaml" + +cd "$REPO_ROOT" +npm ci +cd "$SAMPLE_ROOT" +bundle install +npm run ci:install_ios + +xcodebuild -quiet \ + -workspace ios/SampleApp.xcworkspace \ + -scheme SampleApp \ + -configuration Release \ + -destination "platform=iOS Simulator,id=$device_id" \ + -derivedDataPath "$derived_data" \ + CODE_SIGN_IDENTITY=- \ + CODE_SIGNING_ALLOWED=YES \ + CODE_SIGNING_REQUIRED=YES \ + build >"$run_root/xcodebuild.log" 2>&1 || { + if ruby -e ' + key = ENV.fetch("IOS_CDP_API_KEY") + site_id = ENV.fetch("IOS_SITE_ID") + path = ARGV[0] + redacted = File.read(path).gsub(key, "[REDACTED]").gsub(site_id, "[REDACTED]") + File.write(path, redacted) + ' "$run_root/xcodebuild.log"; then + tail -100 "$run_root/xcodebuild.log" >&2 + else + echo "React Native APN sample build failed; log withheld because credential redaction failed" >&2 + fi + die "React Native APN sample build failed" + } +echo "React Native APN sample build succeeded" + +app_path="$derived_data/Build/Products/Release-iphonesimulator/SampleApp.app" +[[ -d "$app_path" ]] || die "built sample app was not found at $app_path" +simulator_entitlements="$derived_data/Build/Intermediates.noindex/SampleApp.build/Release-iphonesimulator/SampleApp.build/SampleApp.app-Simulated.xcent" +[[ -f "$simulator_entitlements" ]] || die "built sample app simulator entitlements were not found" +[[ "$(plutil -extract aps-environment raw "$simulator_entitlements")" == "development" ]] || \ + die "built sample app is missing the development APNs simulator entitlement" +xcrun simctl uninstall "$device_id" "$APP_ID" >/dev/null 2>&1 || true +xcrun simctl install "$device_id" "$app_path" +installed_app=true + +mkdir -p "$artifacts" +set +e +maestro --device "$device_id" test \ + --debug-output "$artifacts" \ + --flatten-debug-output \ + -e "APP_ID=$APP_ID" \ + -e "MAESTRO_APP_API_KEY=$MAESTRO_APP_API_KEY" \ + -e "MAESTRO_EXT_API_BASE_URL=$MAESTRO_EXT_API_BASE_URL" \ + "$harness/flows/reactnative_remote_push.yaml" +result=$? +set -e + +python3 "$harness/scripts/redact_artifacts.py" "$artifacts" +python3 "$harness/scripts/redact_artifacts.py" "$artifacts" --check +exit "$result" diff --git a/example/src/screens/home.tsx b/example/src/screens/home.tsx index 281af2f5..e191a4dd 100644 --- a/example/src/screens/home.tsx +++ b/example/src/screens/home.tsx @@ -40,6 +40,7 @@ export const HomeScreen = ({ }} />