From fe6611a8a2e9831c48b73bbcc18df74a9bb6a96c Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 19 May 2026 10:12:12 -0700 Subject: [PATCH 01/25] feat(appium): add native Android SDK support --- appium/scripts/run-all.sh | 17 ++++-- appium/scripts/run-local.sh | 101 ++++++++++++++++++++++++++++++++++-- 2 files changed, 110 insertions(+), 8 deletions(-) diff --git a/appium/scripts/run-all.sh b/appium/scripts/run-all.sh index c3f6efe..3a79c20 100755 --- a/appium/scripts/run-all.sh +++ b/appium/scripts/run-all.sh @@ -15,7 +15,7 @@ warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } error() { echo -e "${RED}[ERROR]${NC} $*"; } # ── Args (forwarded as-is to run-local.sh) ──────────────────────────────────── -ALL_SDKS=(cordova capacitor dotnet expo flutter react-native unity) +ALL_SDKS=(cordova capacitor dotnet expo flutter react-native unity android) EXTRA_ARGS=() PLATFORM_FILTER="" @@ -41,13 +41,15 @@ for arg in "$@"; do Usage: $0 [OPTIONS] Runs the Appium E2E suite across every SDK/platform combo by delegating -to run-local.sh. Combos: cordova, react-native, flutter, dotnet, expo, -unity on ios + android. +to run-local.sh. Combos: cordova, capacitor, react-native, flutter, dotnet, +expo, unity on ios + android, plus android (native) on android only. Options: --platform=ios|android Only run combos for the given platform (default: both) --sdks=LIST Comma-separated SDKs to run (default: all) - Valid: cordova, react-native, flutter, dotnet, expo, unity + Valid: cordova, capacitor, react-native, flutter, + dotnet, expo, unity, android + Note: 'android' (native) only runs on --platform=android. --bail Stop after the first failing combo Options forwarded to run-local.sh: @@ -94,6 +96,13 @@ BAILED=0 for platform in "${PLATFORMS[@]}"; do for sdk in "${SDKS[@]}"; do + # Native Android demo only exists for Android; silently skip the iOS + # combo when iterating both platforms so the matrix stays clean. When + # the user explicitly requested `--sdks=android --platform=ios`, fall + # through and let run-local.sh emit the real error. + if [[ "$sdk" == "android" && "$platform" == "ios" && -z "$PLATFORM_FILTER" ]]; then + continue + fi label="${sdk} / ${platform}" echo "" echo -e "${BOLD}━━━ Running: ${label} ━━━${NC}" diff --git a/appium/scripts/run-local.sh b/appium/scripts/run-local.sh index 241264c..8ff3e05 100755 --- a/appium/scripts/run-local.sh +++ b/appium/scripts/run-local.sh @@ -62,7 +62,9 @@ via flags or env vars. Options: --platform=P ios | android - --sdk=S flutter | react-native | cordova | capacitor | dotnet | expo | unity + --sdk=S flutter | react-native | cordova | capacitor | dotnet | expo | unity | android + android = native Android (OneSignal-Android-SDK/examples/demo); + only valid with --platform=android. --device=NAME Device/simulator/AVD name (default: iPhone 17 / Samsung Galaxy S26) --appium-port=N Appium server port (default: 4723). Use unique values when running multiple sessions in parallel on the same host. @@ -98,6 +100,9 @@ Env vars (set in .env or export): UNITY_PATH Path to Unity Editor binary (default: /Applications/Unity/Hub/Editor/6000.4.6f1/Unity.app/Contents/MacOS/Unity) UNITY_IOS_SIM_ARCH Unity iOS simulator arch (default: host arch) + ANDROID_DIR Native Android SDK repo root (default: ../../OneSignal-Android-SDK) + ANDROID_FLAVOR Native Android product flavor (default: gms; also: huawei) + ANDROID_BUILD_TYPE Native Android build type (default: debug; also: release) OS_VERSION Platform version (default: 26.2 / 16) IOS_SIMULATOR iOS simulator name (default: iPhone 17) IOS_RUNTIME simctl runtime id (default: iOS-26-2) @@ -152,8 +157,15 @@ prompt_choice() { done } +# --sdk=android implies --platform=android (the native demo only targets +# Android), so resolve PLATFORM first to skip the platform prompt when the +# user only passed --sdk=android. +if [[ "${SDK_TYPE:-}" == "android" && -z "${PLATFORM:-}" ]]; then + PLATFORM="android" +fi + prompt_choice PLATFORM "Select platform:" ios android -prompt_choice SDK_TYPE "Select SDK type:" flutter react-native cordova capacitor dotnet expo unity +prompt_choice SDK_TYPE "Select SDK type:" flutter react-native cordova capacitor dotnet expo unity android case "$PLATFORM" in ios|android) ;; @@ -161,10 +173,14 @@ case "$PLATFORM" in esac case "$SDK_TYPE" in - flutter|react-native|cordova|capacitor|dotnet|expo|unity) ;; - *) error "SDK_TYPE must be 'flutter', 'react-native', 'cordova', 'capacitor', 'dotnet', 'expo', or 'unity', got '$SDK_TYPE'" ;; + flutter|react-native|cordova|capacitor|dotnet|expo|unity|android) ;; + *) error "SDK_TYPE must be 'flutter', 'react-native', 'cordova', 'capacitor', 'dotnet', 'expo', 'unity', or 'android', got '$SDK_TYPE'" ;; esac +if [[ "$SDK_TYPE" == "android" && "$PLATFORM" != "android" ]]; then + error "--sdk=android only supports --platform=android" +fi + # ── Real-device validation + signing setup ──────────────────────────────────── # When --device-real is set, we need a physical-device build and codesigning # inputs. Centralised here so the rest of the script stays simulator-shaped @@ -174,6 +190,7 @@ if [[ "$IOS_REAL_DEVICE" == true ]]; then [[ "$PLATFORM" == "ios" ]] || error "--device-real only supports --platform=ios" case "$SDK_TYPE" in cordova|capacitor|react-native|expo) ;; + android) error "--device-real not applicable to --sdk=android (native Android)" ;; flutter|dotnet) error "--device-real not yet supported for $SDK_TYPE — patch run-local.sh's build_${SDK_TYPE//-/_}_ios to invoke the device build" ;; esac [[ -n "$UDID" ]] || error "--device-real requires --udid= (or UDID env). Find via: xcrun devicectl list devices" @@ -293,6 +310,22 @@ elif [[ "$SDK_TYPE" == "unity" ]]; then else APP_PATH="${APP_PATH:-$DEMO_DIR/Build/Android/onesignal-demo.apk}" fi +elif [[ "$SDK_TYPE" == "android" ]]; then + ANDROID_DIR="${ANDROID_DIR:-$SDK_ROOT/OneSignal-Android-SDK}" + [[ -d "$ANDROID_DIR" ]] || error "Native Android SDK not found at $ANDROID_DIR — set ANDROID_DIR in .env" + DEMO_DIR="$ANDROID_DIR/examples/demo" + ANDROID_FLAVOR="${ANDROID_FLAVOR:-gms}" + ANDROID_BUILD_TYPE="${ANDROID_BUILD_TYPE:-debug}" + case "$ANDROID_FLAVOR" in + gms|huawei) ;; + *) error "ANDROID_FLAVOR must be 'gms' or 'huawei', got '$ANDROID_FLAVOR'" ;; + esac + case "$ANDROID_BUILD_TYPE" in + debug|release) ;; + *) error "ANDROID_BUILD_TYPE must be 'debug' or 'release', got '$ANDROID_BUILD_TYPE'" ;; + esac + # Gradle emits per-flavor/type APKs under app/build/outputs/apk///. + APP_PATH="${APP_PATH:-$DEMO_DIR/app/build/outputs/apk/${ANDROID_FLAVOR}/${ANDROID_BUILD_TYPE}/app-${ANDROID_FLAVOR}-${ANDROID_BUILD_TYPE}.apk}" fi # ── Platform defaults ──────────────────────────────────────────────────────── @@ -1373,6 +1406,64 @@ build_unity_android() { info "App built: $APP_PATH" } +write_android_demo_strings_xml() { + # Native Android demo reads onesignal_app_id from strings.xml at app start + # (see MainApplication.kt). There's no .env hook like the hybrid demos, so + # we sed-replace the value in-place when an override is provided. The file + # is tracked, so this leaves a working-tree change — restored by the caller + # via a stash of `app/src/main/res/values/strings.xml` only when needed. + local strings_xml="$DEMO_DIR/app/src/main/res/values/strings.xml" + if [[ -z "${ONESIGNAL_APP_ID:-}" ]]; then + warn "ONESIGNAL_APP_ID not set — using checked-in default in $strings_xml" + return + fi + + if grep -q "${ONESIGNAL_APP_ID}" "$strings_xml"; then + info "onesignal_app_id already set in strings.xml" + return + fi + + info "Setting onesignal_app_id in strings.xml..." + local tmp + tmp=$(mktemp) + # macOS sed needs an extension arg with -i; using a temp file keeps the + # script portable across BSD/GNU sed without juggling that. + sed "s|[^<]*|${ONESIGNAL_APP_ID}|" \ + "$strings_xml" > "$tmp" + mv "$tmp" "$strings_xml" +} + +build_android_native() { + write_android_demo_strings_xml + + # Building from OneSignalSDK/ (not examples/demo/) so the demo's :app + # transitively pulls in local SDK source via settings.gradle dependency + # substitution. This is the whole point of --sdk=android for SDK dev: + # changes under OneSignal-Android-SDK/OneSignalSDK/onesignal/ get exercised. + # See OneSignalSDK/settings.gradle for the substitution rules. + local sdk_dir="$ANDROID_DIR/OneSignalSDK" + [[ -x "$sdk_dir/gradlew" ]] || error "gradlew not found or not executable at $sdk_dir/gradlew" + + # SDK_VERSION is required by settings.gradle; pull it from gradle.properties + # (defaults to whatever the local repo is on, e.g. 5.9.2) so callers don't + # have to keep it in sync. + local sdk_version + sdk_version=$(grep -E "^SDK_VERSION=" "$sdk_dir/gradle.properties" 2>/dev/null | head -1 | cut -d= -f2 | tr -d '[:space:]') + [[ -n "$sdk_version" ]] || error "Could not read SDK_VERSION from $sdk_dir/gradle.properties" + + # Capitalize flavor + buildType to assemble the Gradle task name + # (assemble, e.g. assembleGmsDebug). + local flavor_cap="$(tr '[:lower:]' '[:upper:]' <<< "${ANDROID_FLAVOR:0:1}")${ANDROID_FLAVOR:1}" + local type_cap="$(tr '[:lower:]' '[:upper:]' <<< "${ANDROID_BUILD_TYPE:0:1}")${ANDROID_BUILD_TYPE:1}" + local task="assemble${flavor_cap}${type_cap}" + + info "Building :app:$task with local SDK source (SDK_VERSION=$sdk_version)..." + (cd "$sdk_dir" && ./gradlew ":app:$task" "-PSDK_VERSION=$sdk_version") + + [[ -f "$APP_PATH" ]] || error ".apk not found after build at $APP_PATH" + info "App built: $APP_PATH" +} + build_app() { if [[ "$SKIP_BUILD" == true ]]; then if [[ "$PLATFORM" == "ios" && ! -d "$APP_PATH" ]] || [[ "$PLATFORM" == "android" && ! -f "$APP_PATH" ]]; then @@ -1424,6 +1515,8 @@ build_app() { else build_unity_android fi + elif [[ "$SDK_TYPE" == "android" ]]; then + build_android_native fi } From 1b145f485efa3d8b5190f72092835ccb6925d7a8 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 19 May 2026 11:28:14 -0700 Subject: [PATCH 02/25] docs(demo): clarify build guide structure --- demo/build.md | 98 +++++++++++++++++++++++++++------------------------ 1 file changed, 52 insertions(+), 46 deletions(-) diff --git a/demo/build.md b/demo/build.md index ba7259d..e41d4b4 100644 --- a/demo/build.md +++ b/demo/build.md @@ -2,6 +2,11 @@ Prompts and requirements to build the OneSignal {{PLATFORM}} Sample App from scratch. +> `{{PLATFORM}}` is a placeholder. Each wrapper SDK's `examples/build.md` +> extends this document and instructs readers to substitute the platform +> name (`Android`, `iOS`, `Flutter`, `Capacitor`, `React Native`, +> `Cordova`, `.NET MAUI`) before use. + --- ## Phase 1: Initial Setup @@ -10,7 +15,10 @@ Prompts and requirements to build the OneSignal {{PLATFORM}} Sample App from scr Create a new {{PLATFORM}} project at `examples/demo/` (relative to the SDK repo root). -- Clean architecture: platform-idiomatic state container that calls the OneSignal SDK directly — a `useOneSignal` hook for React (react-native, react), Cordova, and Capacitor; an `AppViewModel` for .NET MAUI (C#) and Flutter. No repository wrapper layer. +- Clean architecture: a platform-idiomatic state container that calls the + OneSignal SDK directly. No repository wrapper layer. + - `useOneSignal` hook for React, React Native, Cordova, Capacitor + - `AppViewModel` for .NET MAUI (C#) and Flutter - App name: "OneSignal Demo" - Top app bar: centered title with OneSignal logo SVG + "{{PLATFORM}}" text - Android package name / iOS bundle identifier: `com.onesignal.example` @@ -37,7 +45,7 @@ Reference the OneSignal SDK from the parent repo using a local path/file depende ### Prompt 1.3 - OneSignal SDK Operations -Call the OneSignal SDK directly from the state container — a `useOneSignal` hook for React (react-native, react), Cordova, and Capacitor; an `AppViewModel` for .NET MAUI (C#) and Flutter. Do not introduce a repository/wrapper layer. The state container should expose the operations below as actions/methods: +The state container (see Prompt 1.1) calls the OneSignal SDK directly and exposes the operations below as actions/methods: - **User**: loginUser(externalUserId) -> async, logoutUser() -> async - **Aliases**: addAlias(label, id), addAliases(map) @@ -92,7 +100,7 @@ hasApiKey: ### Prompt 1.5 - SDK Observers -Initialize before UI renders: +**1. Initialize before UI renders.** Cached consent flags must be applied before `initialize`; IAM/location flags must be restored after: ``` OneSignal.Debug.setLogLevel(verbose) @@ -100,6 +108,9 @@ OneSignal.consentRequired(cachedConsentRequired) OneSignal.consentGiven(cachedPrivacyConsent) OneSignal.initialize(appId) +OneSignal.InAppMessages.setPaused(cachedIamPaused) +OneSignal.Location.setShared(cachedLocationShared) + // iOS only OneSignal.LiveActivities.setupDefault({ enablePushToStart: true, @@ -107,20 +118,18 @@ OneSignal.LiveActivities.setupDefault({ }) ``` -Register listeners: +**2. Register SDK lifecycle/click listeners.** These belong with the SDK init, not the state container: - InAppMessages: willDisplay, didDisplay, willDismiss, didDismiss, click - Notifications: click, foregroundWillDisplay -Restore cached SDK states: IAM paused status, location shared status. - -Register observers in state management layer: +**3. Register state-layer observers.** Wire these in the state container so they can mutate UI state: - Push subscription change - Notification permission change -- User state change -> log the new onesignalId/externalId, and when `onesignalId` is non-null, trigger `fetchUserDataFromApi()` so the post-login fetch runs once the SDK has actually assigned an id (see Phase 3.1). When `onesignalId` is null (logout), skip the fetch — the logout path already clears local lists. +- User state change — log the new `onesignalId`/`externalId`. When `onesignalId` is non-null, call `fetchUserDataFromApi()` so the post-login fetch runs once the SDK has actually assigned an id (see Phase 3.1). When `onesignalId` is null (logout), skip the fetch — the logout path already cleared local lists. -Clean up listeners on teardown (if platform requires it). +Clean up listeners on teardown if the platform requires it. --- @@ -205,7 +214,7 @@ Separate SectionCard titled "User": 3. CENTER MODAL - crop-square icon, trigger: "iam_type" = "center_modal" 4. FULL SCREEN - fullscreen icon, trigger: "iam_type" = "full_screen" - Styling: primary (red) background, white text, icon on LEFT, full width, left-aligned, UPPERCASE -- On tap: adds trigger, shows "Sent In-App Message: {type}", upserts `iam_type` in Triggers list +- On tap: upserts `iam_type` in Triggers list. No snackbar (silent action — see Prompt 7.6) ### Prompt 2.6 - Aliases Section @@ -305,15 +314,7 @@ Widget extension requirements: - Status-based theming: preparing (orange), on_the_way (blue), delivered (green) - If the file `examples/demo/ios/OneSignalWidget/OneSignalWidgetLiveActivity.swift` already exists, replace its contents with the shared reference implementation at `https://raw.githubusercontent.com/OneSignal/sdk-shared/main/demo/LiveActivity.swift` -Environment / API key setup: - -- `.env` file with two variables: - - `ONESIGNAL_APP_ID=your-onesignal-app-id` (overrides default app ID; falls back to default if empty or missing) - - `ONESIGNAL_API_KEY=your-onesignal-api-key` (required for Live Activity update/end) - - `E2E_MODE=true` (optional, masks sensitive IDs in the UI for deterministic Appium screenshots) -- Provide `.env.example` with placeholder values and a comment noting the default app ID -- Add `.env` to `.gitignore` -- `hasApiKey()` on the API service checks that the key is present and not the placeholder +For `.env` setup and `hasApiKey()` semantics see the Configuration section at the bottom of this guide. ### Prompt 2.15 - Secondary Screen @@ -417,7 +418,7 @@ All dialog fields EMPTY by default. Appium enters: | Dialog | Fields | | ------------------- | ---------------------------------------------------- | | Login | External User Id = "test" | -| Add Alias | Key = "Test", Value = "Value" | +| Add Alias | Label = "Test", ID = "Value" | | Add Email | Email = "test@onesignal.com" | | Add SMS | SMS = "123-456-5678" | | Add Tag | Key = "Test", Value = "Value" | @@ -430,7 +431,16 @@ Add Multiple dialogs use the same values for the first row and support multiple ### Prompt 6.2 - Accessibility Identifiers (Appium) -Use the platform's accessibility/test ID mechanism (e.g. `Semantics(identifier:)` in Flutter, `accessibilityIdentifier` in iOS, `testID` in React Native, `data-testid` in Cordova/Capacitor web, `AutomationId` in .NET MAUI). These identifiers allow Appium to locate elements reliably and MUST match exactly across platforms — the shared Appium suite under `sdk-shared/appium/tests/` selects elements by these ids. +Use the platform's native accessibility/test ID mechanism. The ids MUST match exactly across platforms — the shared Appium suite under `sdk-shared/appium/tests/` selects elements by these ids. + +| Platform | Mechanism | +| ----------------------- | ------------------------ | +| Android (native Kotlin) | `Modifier.testTag(...)` | +| iOS (native Swift) | `accessibilityIdentifier`| +| Flutter | `Semantics(identifier:)` | +| React Native | `testID` | +| Capacitor / Cordova | `data-testid` | +| .NET MAUI | `AutomationId` | **Scroll view**: `main_scroll_view` @@ -534,25 +544,13 @@ Confirm buttons on the shared SingleInput, SinglePair, MultiPair and MultiSelect --- -## Phase 7: Implementation Details - -### Alias Management - -Hybrid approach: fetched from API on start/login, local adds are immediate (SDK syncs async), fresh data from API on next launch. +## Phase 7: Architecture -### Notification Permission - -Auto-request in home screen's init/mount lifecycle. PROMPT PUSH button as fallback if denied. Hidden once granted. Push "Enabled" toggle disabled until permission granted. - ---- - -## Phase 8: Architecture - -### Prompt 8.1 - State Management +### Prompt 7.1 - State Management Single state container at app root. Holds all UI state with public getters. Exposes action methods that update state and notify UI. Implementation is platform-idiomatic: a `useOneSignal` hook for React (react-native, react), Cordova, and Capacitor; an `AppViewModel` for .NET MAUI (C#) and Flutter. The state container calls the OneSignal SDK directly (no repository wrapper) and depends only on `PreferencesService` and `OneSignalApiService`. Initialize SDK before rendering. Fetch tooltips in background. -### Prompt 8.2 - Reusable Components +### Prompt 7.2 - Reusable Components - **SectionCard**: card with title, optional info icon, content slot, onInfoTap callback, optional `sectionKey` for accessibility identifiers (generates `{sectionKey}_section` on the container and `{sectionKey}_info_icon` on the info button) - **ToggleRow**: label, optional description, toggle control, optional `semanticsLabel` for accessibility identifier @@ -562,7 +560,7 @@ Single state container at app root. Holds all UI state with public getters. Expo - SingleInputDialog, PairInputDialog (same row), MultiPairInputDialog (dynamic rows, dividers, X to delete, batch submit), MultiSelectRemoveDialog (checkboxes, batch remove) - LoginDialog, OutcomeDialog, TrackEventDialog, CustomNotificationDialog, TooltipDialog -### Prompt 8.3 - MultiPairInputDialog +### Prompt 7.3 - MultiPairInputDialog Shared by Aliases, Tags, and Triggers ADD MULTIPLE buttons. @@ -572,7 +570,7 @@ Shared by Aliases, Tags, and Triggers ADD MULTIPLE buttons. - "Add All" disabled until all fields filled, validates on every change - Submits as batch via SDK bulk APIs -### Prompt 8.4 - MultiSelectRemoveDialog +### Prompt 7.4 - MultiSelectRemoveDialog Shared by Tags and Triggers REMOVE buttons. @@ -580,13 +578,13 @@ Shared by Tags and Triggers REMOVE buttons. - "Remove (N)" button shows selected count, disabled when none - Returns selected keys list -### Prompt 8.5 - Theme +### Prompt 7.5 - Theme All styling defined in: `https://raw.githubusercontent.com/OneSignal/sdk-shared/refs/heads/main/demo/styles.md` Implement theme constants/tokens mapping style reference to the platform's theming system. -### Prompt 8.6 - Feedback Messages (SnackBar/Toast) +### Prompt 7.6 - Feedback Messages (SnackBar/Toast) Feedback messages are shown directly from the UI layer (not centralized in the state management layer). Use a `BuildContext` extension or helper that calls the platform's transient message API (SnackBar/Toast). The extension should hide the current message before showing a new one. Show snackbars from UI widget callbacks after awaiting the action, using a context-mounted check before displaying. @@ -607,14 +605,22 @@ Logging: ## Configuration -Default app id: `77e32082-ea27-42e3-a898-c72e141824ef` (used when `ONESIGNAL_APP_ID` env var is empty or missing) +### Environment variables (`.env`) + +| Variable | Required | Default | Purpose | +| ----------------------------- | ------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | +| `ONESIGNAL_APP_ID` | No | `77e32082-ea27-42e3-a898-c72e141824ef` | OneSignal App ID. Loaded at startup from `.env`, NOT from local preferences. Falls back to default when empty or missing. | +| `ONESIGNAL_API_KEY` | Live Activities only | — | REST API key. Required for Live Activity update/end. `hasApiKey()` returns true when set and not the placeholder; UPDATE / END buttons disable when false. | +| `E2E_MODE` | No | `false` | Masks sensitive IDs in the UI (App ID, Push ID) for deterministic Appium screenshots. | +| `ONESIGNAL_ANDROID_CHANNEL_ID`| No (Send-Sound only) | `b3b015d9-c050-4042-8548-dcc34aa44aa4` | Notification channel for the WITH SOUND payload. | -App ID is loaded from the `.env` file's `ONESIGNAL_APP_ID` variable at startup, NOT from local preferences. If the env var is empty or absent, fall back to the default app ID above. +- Provide `.env.example` with placeholder values and a comment noting the default app ID. +- Add `.env` to `.gitignore`. -REST API key is NOT required for the fetchUser endpoint. +### Identifiers -REST API key IS required for Live Activity update/end operations. Store in `.env` as `ONESIGNAL_API_KEY`. Disable update/end buttons when not configured. +Android package name / iOS bundle identifier MUST be `com.onesignal.example` so the checked-in `google-services.json` and `agconnect-services.json` keep working without regeneration. -Android channel ID is optional for the WITH SOUND notification. Load from `.env` as `ONESIGNAL_ANDROID_CHANNEL_ID`; if empty or absent, fall back to `b3b015d9-c050-4042-8548-dcc34aa44aa4`. +### REST API authentication -Identifiers MUST be `com.onesignal.example` to work with existing `google-services.json` and `agconnect-services.json`. +The `fetchUser` endpoint does NOT require an API key. All other authenticated endpoints (currently just Live Activity update/end) use `Authorization: Key {ONESIGNAL_API_KEY}`. From 68f7e061c1396d1991998d70ebe5ec465fd0e44e Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 19 May 2026 12:16:04 -0700 Subject: [PATCH 03/25] refactor(appium): pass app ID via Gradle props --- appium/scripts/run-local.sh | 44 ++++++++++--------------------- appium/tests/helpers/selectors.ts | 7 +++-- 2 files changed, 19 insertions(+), 32 deletions(-) diff --git a/appium/scripts/run-local.sh b/appium/scripts/run-local.sh index 8ff3e05..cfc84f9 100755 --- a/appium/scripts/run-local.sh +++ b/appium/scripts/run-local.sh @@ -1406,36 +1406,7 @@ build_unity_android() { info "App built: $APP_PATH" } -write_android_demo_strings_xml() { - # Native Android demo reads onesignal_app_id from strings.xml at app start - # (see MainApplication.kt). There's no .env hook like the hybrid demos, so - # we sed-replace the value in-place when an override is provided. The file - # is tracked, so this leaves a working-tree change — restored by the caller - # via a stash of `app/src/main/res/values/strings.xml` only when needed. - local strings_xml="$DEMO_DIR/app/src/main/res/values/strings.xml" - if [[ -z "${ONESIGNAL_APP_ID:-}" ]]; then - warn "ONESIGNAL_APP_ID not set — using checked-in default in $strings_xml" - return - fi - - if grep -q "${ONESIGNAL_APP_ID}" "$strings_xml"; then - info "onesignal_app_id already set in strings.xml" - return - fi - - info "Setting onesignal_app_id in strings.xml..." - local tmp - tmp=$(mktemp) - # macOS sed needs an extension arg with -i; using a temp file keeps the - # script portable across BSD/GNU sed without juggling that. - sed "s|[^<]*|${ONESIGNAL_APP_ID}|" \ - "$strings_xml" > "$tmp" - mv "$tmp" "$strings_xml" -} - build_android_native() { - write_android_demo_strings_xml - # Building from OneSignalSDK/ (not examples/demo/) so the demo's :app # transitively pulls in local SDK source via settings.gradle dependency # substitution. This is the whole point of --sdk=android for SDK dev: @@ -1457,8 +1428,21 @@ build_android_native() { local type_cap="$(tr '[:lower:]' '[:upper:]' <<< "${ANDROID_BUILD_TYPE:0:1}")${ANDROID_BUILD_TYPE:1}" local task="assemble${flavor_cap}${type_cap}" + # Demo reads ONESIGNAL_APP_ID / ONESIGNAL_ANDROID_CHANNEL_ID / E2E_MODE from + # `BuildConfig.*` (see examples/demo/app/build.gradle.kts:demoOverride). Pass + # them as Gradle -P props so the CLI value wins over examples/demo/local.properties. + local -a gradle_args=("-PSDK_VERSION=$sdk_version" "-PE2E_MODE=true") + if [[ -n "${ONESIGNAL_APP_ID:-}" ]]; then + gradle_args+=("-PONESIGNAL_APP_ID=$ONESIGNAL_APP_ID") + else + warn "ONESIGNAL_APP_ID not set — demo will fall back to its built-in default" + fi + if [[ -n "${ANDROID_CHANNEL_ID:-}" ]]; then + gradle_args+=("-PONESIGNAL_ANDROID_CHANNEL_ID=$ANDROID_CHANNEL_ID") + fi + info "Building :app:$task with local SDK source (SDK_VERSION=$sdk_version)..." - (cd "$sdk_dir" && ./gradlew ":app:$task" "-PSDK_VERSION=$sdk_version") + (cd "$sdk_dir" && ./gradlew ":app:$task" "${gradle_args[@]}") [[ -f "$APP_PATH" ]] || error ".apk not found after build at $APP_PATH" info "App built: $APP_PATH" diff --git a/appium/tests/helpers/selectors.ts b/appium/tests/helpers/selectors.ts index 6472869..f72a040 100644 --- a/appium/tests/helpers/selectors.ts +++ b/appium/tests/helpers/selectors.ts @@ -254,12 +254,15 @@ function withElementInteractionFixes(el * Select an element by its cross-platform test ID. * * iOS native / RN / Compose all surface as Appium accessibility id (`~`) on iOS. - * On Android the mapping varies by SDK: + * On Android every SDK is normalized to resource-id (`id=`): * - Flutter Semantics(identifier:) → resource-id (`id=`) * - React Native testID → resource-id (`id=`) under Fabric/new arch; the old * bridge surfaced it as content-desc but new arch sets it as the view tag, * which UiAutomator2 exposes via resource-id. - * - Native Android Compose testTag → accessibility id (`~`) + * - Native Android Compose testTag → resource-id (`id=`) *only* when the + * demo opts in with `Modifier.semantics { testTagsAsResourceId = true }` + * on (or above) the root composable. Without that opt-in the tag stays a + * Compose-only Semantics property and is invisible to UiAutomator2. * - .NET MAUI AutomationId → resource-id (`id=`), but namespaced as * `:id/`. The wdio Android config disables locator * autocompletion to dodge a Flutter quirk, so for dotnet we re-enable From b2cf54c8b66baaab8fea4cc7a790bbecc6cb07bc Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 19 May 2026 13:25:08 -0700 Subject: [PATCH 04/25] refactor(appium): trim comments and skip Android fast path --- appium/tests/helpers/app.ts | 348 +++++------------------------- appium/tests/helpers/selectors.ts | 46 +--- 2 files changed, 61 insertions(+), 333 deletions(-) diff --git a/appium/tests/helpers/app.ts b/appium/tests/helpers/app.ts index 72b6bee..92550fe 100644 --- a/appium/tests/helpers/app.ts +++ b/appium/tests/helpers/app.ts @@ -16,44 +16,28 @@ export const isWebViewSDK = sdkType === 'capacitor' || sdkType === 'cordova'; export const isBrowserStack = Boolean(process.env.BROWSERSTACK_USERNAME); export const isUnitySDK = sdkType === 'unity'; const isFlutterSDK = sdkType === 'flutter'; +const isNativeAndroidSDK = sdkType === 'android'; export function isBrowserStackIos(): boolean { return isBrowserStack && getPlatform() === 'ios'; } -/** - * Scroll the main content area in the given direction using native scroll APIs. - * Targets the main_scroll_view element to avoid scrolling the log view. - */ +/** Swipe the main content, not the log panel. */ async function swipeMainContent(direction: 'up' | 'down', distance: 'small' | 'normal' = 'normal') { const distances = { small: 0.2, normal: 0.33 }; const { width, height } = await driver.getWindowSize(); const swipeArea = height * 0.8; const swipeDistance = swipeArea * distances[distance]; - // Coordinates must be integers in WebView contexts (Capacitor/Cordova), - // where chromedriver enforces W3C `actions` typing strictly. Native - // UiAutomator2/XCUITest tolerate floats but rounding is harmless there. - // Unity (both platforms): anchor in the left section-padding gutter - // (x≈10pt; sections pad 16pt). Center-anchored swipes can land - // PointerDown on a button and trigger AccessibilityBridge's E2E tap - // fallback before the drag generates enough PointerMove distance to - // cancel it. Other SDKs route swipes through native scroll containers - // that don't dispatch into our element handlers, so center is fine. + // Round coords for WebViews. Unity swipes in the left gutter to avoid taps. const swipeX = isUnitySDK ? 10 : Math.round(width / 2); const startY = Math.round(direction === 'down' ? height * 0.85 : height * 0.15); const endY = Math.round(direction === 'down' ? startY - swipeDistance : startY + swipeDistance); - // Slower drag on Flutter stays under the fling threshold; momentum - // otherwise carries the target past the viewport between polls. + // Slow Flutter drags to avoid fling momentum. const moveDurationMs = isFlutterSDK ? 700 : 300; - // Hard-bound the W3C pointer chain. If we ever end up swiping against a - // stale/closed WebView window handle (e.g. a leftover IAM banner), the - // chromedriver `actions` endpoint can stop responding indefinitely. Without - // this guard a single stuck swipe would consume ~3 minutes per test until - // wdio's session DELETE timeout fires. A failed swipe naturally surfaces as - // a test failure via scrollToEl, which is the correct signal. + // Bound pointer actions; stale WebView handles can otherwise hang. const SWIPE_TIMEOUT_MS = 5_000; const action = browser .action('pointer', { parameters: { pointerType: 'touch' } }) @@ -82,28 +66,7 @@ async function swipeMainContent(direction: 'up' | 'down', distance: 'small' | 'n } } -/** - * Scroll to the given testId, returning a handle to the element. - * - * Strategy is SDK-specific to avoid the cost and flakiness of full pointer-swipe - * loops where the driver provides a faster primitive: - * - * - Capacitor / Cordova: DOM `scrollIntoView({block: 'center'})` inside the - * WebView. One round-trip, no pointer chain, no chromedriver staleness. - * - Native Android (RN, .NET MAUI, expo, native): UiAutomator2's - * `UiScrollable.scrollIntoView` walks the first scrollable forward until - * the resource-id matches, in a single driver call. - * - Native iOS (RN, .NET MAUI, expo, native): pages `main_scroll_view` - * downward via XCUITest's directional `mobile: scroll`, checking - * visibility between pages. - * - Flutter: Skia-canvas rendering means lazy children aren't in the native - * a11y tree until they're realised, so neither UiScrollable nor - * `mobile: scroll` can find them. Always uses the swipe loop. - * - * Native fast paths only run for downward searches. `direction: 'up'` falls - * through to the swipe loop, which also catches any case where a fast path - * silently failed to land the element. - */ +/** Scroll to a test id using the fastest reliable SDK-specific path. */ export async function scrollToEl( identifier: string, opts: { @@ -111,9 +74,7 @@ export async function scrollToEl( maxScrolls?: number; } = {}, ) { - // Safety net for `direction: 'up'` and any case where a native fast path - // didn't land the element. Flutter has no fast path and uses slower swipes - // (see `swipeMainContent`), so it needs a higher cap. + // Swipe loop is the fallback and handles upward searches. const { direction = 'down', maxScrolls = 30 } = opts; const platform = getPlatform(); @@ -127,14 +88,8 @@ export async function scrollToEl( return byTestId(identifier); } - // Native fast path: pre-warms the scroll view so the loop below either - // returns immediately or has very little work left. Unity opts out on - // both platforms: iOS `mobile: scroll` synthesizes a center-anchored - // touch sequence that reproduces the accidental-tap problem we avoid in - // `swipeMainContent`, and on Android `UiScrollable` doesn't see UI - // Toolkit content rendered into the Unity SurfaceView. Falling through - // to the swipe loop costs ~200ms but is reliable. - if (direction === 'down' && !isFlutterSDK && !isUnitySDK) { + // Skip fast paths that target the wrong scroll container or a11y tree. + if (direction === 'down' && !isFlutterSDK && !isUnitySDK && !isNativeAndroidSDK) { if (platform === 'android') { await tryNativeScrollAndroid(identifier); } else { @@ -148,27 +103,13 @@ export async function scrollToEl( return await scrollExtraIfNeeded(el, () => byTestId(identifier)); } await swipeMainContent(direction); - // Let Flutter realize freshly scrolled-in widgets before the next poll. + // Let Flutter realize newly visible widgets. if (isFlutterSDK) await driver.pause(250); } throw new Error(`Element "${identifier}" not found after ${maxScrolls} scrolls`); } -/** - * UiAutomator2 `UiScrollable.scrollIntoView` finds the first scrollable - * container and pages forward until a child's resource-id matches. - * - * Resource-id namespacing differs by SDK: - * - Flutter / RN / Capacitor / Cordova / Expo: ids are bare (`my_button`). - * - .NET MAUI: ids are package-prefixed (`com.onesignal.example:id/my_button`). - * - * Appium's `id=` strategy hides this via the `disableIdLocatorAutocompletion` - * setting (see `wdio.android.conf.ts`), but inline `UiSelector` strings bypass - * that setting and need the full id directly. - * - * Returns a boolean (rather than throwing) so the caller can transparently - * fall back to the swipe loop when the element isn't reachable forward. - */ +/** Android native scroll fast path; returns false so callers can fall back. */ async function tryNativeScrollAndroid(id: string): Promise { try { const fullId = @@ -183,23 +124,7 @@ async function tryNativeScrollAndroid(id: string): Promise { } } -/** - * XCUITest's match-based `mobile: scroll` modes (`predicateString`, `name`, - * `toVisible`) call WDA's internal `scrollToVisible`, which is hard-capped by - * `maxScrollCellCount` (default 25). Hitting the cap surfaces as "Failed to - * perform scroll with visible cell due to max scroll count reached", and the - * cap is reached *slowly* (~1s per attempt × 25 = ~25s before failure), which - * is enough to blow the 60s mocha hook budget on deep scroll views. - * - * Instead, drive the scroll view directionally and check visibility ourselves - * between pages. `mobile: scroll { direction }` has no internal cap, each call - * is ~200ms, and we stop the moment the element appears. - * - * `byTestId` on iOS uses accessibility-id, which XCUITest exposes as `name`, - * so this works for every iOS SDK that lands testIds via accessibility - * identifiers (RN, .NET MAUI, expo, native iOS — Flutter is skipped by the - * caller because lazy widgets aren't in the a11y tree to find). - */ +/** iOS native scroll fast path; avoids WDA match-scroll caps. */ async function tryNativeScrollIos(id: string): Promise { try { const main = await byTestId('main_scroll_view'); @@ -218,26 +143,7 @@ async function tryNativeScrollIos(id: string): Promise { } } -/** - * If the element sits in the bottom portion of the viewport, swipe a small - * amount in the same direction so it lands further into safe territory, then - * re-fetch the (potentially staled) handle. - * - * `scrollIntoView` on native Appium has no notion of centering — it stops the - * moment the element first becomes visible, which on a downward scroll means - * the element lands at the bottom edge. Sitting there risks the tap being - * intercepted by snackbars, keyboard insets, or system gesture areas, and any - * modal that opens from the tap can race against those overlays before its - * accessibility tree fully registers. - */ -/** - * XCUITest's `isDisplayed` does a hit-test against the standard UIView - * hierarchy, which doesn't include Flutter-rendered widgets exposed only via - * the Semantics tree. The element still has a valid frame in the a11y tree, - * but `_AXVisible` returns false (false negative). For Flutter we fall back - * to a rect-in-viewport check so we don't scroll past elements that are - * actually on screen. - */ +/** Flutter can be visible in a11y even when `isDisplayed()` lies. */ async function isVisibleInViewport( el: { isDisplayed(): Promise; @@ -271,14 +177,7 @@ async function scrollExtraIfNeeded Promise, ): Promise { - // Coordinate units differ by platform: iOS XCUITest reports points, Android - // UiAutomator2 reports physical pixels. A fixed pixel threshold (e.g. 100) - // is enough on iOS but only ~33dp on a density-3 Android phone — well inside - // a Material Snackbar (48–68dp + 16dp margin, plus a 24dp gesture-bar inset - // on edge-to-edge devices). 12% of the viewport gives a unit-agnostic safe - // margin (~96dp on density-3 Android, ~102pt on iPhone) that clears - // two-line snackbars, gesture bars, and keyboard insets on every device we - // run on. + // Nudge edge-visible elements away from snackbars and system gestures. try { const { y } = await el.getLocation(); const { height } = await driver.getWindowSize(); @@ -292,23 +191,12 @@ async function scrollExtraIfNeeded { const main = $('[data-testid="main_scroll_view"]'); if (await main.isExisting().catch(() => false)) return true; } catch { - /* ignore closed/stale IAM windows */ + /* ignore stale IAM windows */ } } return false; } -/** - * Switch to NATIVE_APP context. Used by callers that need to interact with - * system dialogs/native gestures while a WebView SDK has us parked in - * `WEBVIEW_*`. Pair with `ensureMainWebViewContext()` once the native step - * is done. No-op for native SDKs. - */ +/** Switch hybrid SDKs to native context for system UI. */ export async function switchToNativeContext() { if (!isWebViewSDK) return; @@ -488,13 +346,7 @@ export async function switchToNativeContext() { } } -/** - * Wait for the app to fully launch and the home screen to be visible. - * - * Accepts the notification permission dialog if present. Safe to call multiple - * times: on iOS the prompt only appears on first launch after install, and - * `allowNotifications` no-ops when the permission button isn't visible. - */ +/** Wait for launch, permissions, and home-screen readiness. */ export async function waitForAppReady(opts: { skipLogin?: boolean } = {}) { const { skipLogin = false } = opts; @@ -506,17 +358,12 @@ export async function waitForAppReady(opts: { skipLogin?: boolean } = {}) { await ensureMainWebViewContext(); const mainScroll = await byTestId('main_scroll_view'); - // Generous timeout to accommodate slow cold-starts (notably .NET MAUI on - // Android, where session creation returns before the app is on screen). + // .NET Android can return a session before the app is ready. await mainScroll.waitForDisplayed({ timeout: 30_000 }); if (skipLogin) return; - // Want to login the user so we can clean up/delete its data on the next rerun. - // `loggedIn` is module-local (worker-scoped) on purpose: each WDIO worker - // runs in its own Node process and drives one device, so the cache reflects - // that device's state. - // Browserstack runs each test in a new session, so we need to set the loggedIn flag to false. + // Keep cleanup addressable by logging into the test user once per worker. const loggedIn = await getValue('loggedIn'); if (!loggedIn) { const testUserId = getTestExternalId(); @@ -529,41 +376,26 @@ export async function waitForAppReady(opts: { skipLogin?: boolean } = {}) { } } -/** - * Tap the login button, enter an external user ID, and confirm. - */ +/** Login through the app UI. */ export async function loginUser(externalUserId: string) { const userIdInput = await openModal('login_user_button', 'login_user_id_input'); await userIdInput.setValue(externalUserId); await confirmModal('singleinput_confirm_button'); } -/** - * Tap the logout button. - */ +/** Logout through the app UI. */ export async function logoutUser() { const logoutButton = await byTestId('logout_user_button'); await logoutButton.click(); } -/** - * Toggle the push-enabled switch. - */ +/** Toggle push enabled. */ export async function togglePushEnabled() { const toggle = await byTestId('push_enabled_toggle'); await toggle.click(); } -/** - * Tap a modal's confirm button and wait for the modal to dismiss. - * - * On Android, RN `Modal` opens in a separate window; querying the underlying - * activity (e.g. via `scrollToEl`) returns NoSuchElement until the modal's - * close animation finishes. Using the confirm button as a sentinel — wait - * until it is no longer displayed — gives a deterministic close signal and - * removes the timing flake from "click confirm, then immediately interact - * with what's behind the modal". - */ +/** Tap a modal confirm button. */ export async function confirmModal(buttonTestId: string, timeoutMs = 5_000) { const btn = await byTestId(buttonTestId); await btn.click(); @@ -582,13 +414,7 @@ export async function waitForDisappear(testId: string, timeoutMs = 5_000) { ); } -/** - * Tap a button expected to open a modal/dialog and wait for one of its - * elements (`expectedTestId`) to appear. Unity UI Toolkit can still be - * settling layout/a11y state after scrolls or prior modal teardown, so Unity - * waits for the trigger position to stabilize and retries the tap once if - * the modal sentinel does not appear. Other SDKs keep the single-tap path. - */ +/** Open a modal and wait for its sentinel element. */ export async function openModal(triggerTestId: string, expectedTestId: string, timeoutMs = 5_000) { const open = async () => { const trigger = await scrollToEl(triggerTestId); @@ -601,7 +427,7 @@ export async function openModal(triggerTestId: string, expectedTestId: string, t return expected; }; - if (!isUnitySDK) return open(); + if (!isUnitySDK && !isNativeAndroidSDK) return open(); return retryOnce(open); } @@ -634,9 +460,7 @@ async function waitForStablePosition( } } -/** - * Add a single tag via the UI. - */ +/** Add a single tag through the UI. */ export async function addTag(key: string, value: string) { const addButton = await byTestId('add_tag_button'); await addButton.click(); @@ -651,12 +475,7 @@ export async function addTag(key: string, value: string) { await confirmModal('tag_confirm_button'); } -/** - * Assert that a key-value pair is displayed in the UI. - * Uses `${sectionId}_pair_key_${key}` / `${sectionId}_pair_value_${key}` semantics on PairItem. - * - * Keys must be unique within the section (no duplicate keys in one list). - */ +/** Assert a unique key-value pair appears in a section. */ export async function expectPairInSection(sectionId: string, key: string, value: string) { await scrollToEl(`${sectionId}_section`, { direction: 'up' }); @@ -670,14 +489,9 @@ export async function expectPairInSection(sectionId: string, key: string, value: expect(valueText).toContain(value); } -/** - * Lock the iOS screen and wake it to reveal the lock screen (with notifications). - */ +/** Lock and wake iOS to reveal lock-screen notifications. */ export async function lockScreen() { - // SpringBoard interaction requires NATIVE_APP context. Hybrid SDKs - // (Cordova/Capacitor) park the driver in WEBVIEW_*, where -ios predicate - // queries used by callers (e.g. live-activity probes on the lock screen) - // would fail. Pair with returnToApp() to restore the WebView context. + // SpringBoard queries require native context. await switchToNativeContext(); await driver.updateSettings({ defaultActiveApplication: 'com.apple.springboard' }); await driver.lock(); @@ -685,9 +499,7 @@ export async function lockScreen() { await driver.execute('mobile: pressButton', { name: 'home' }); } -/** - * Return to the app from SpringBoard / lock screen. - */ +/** Return to the app from system UI. */ export async function returnToApp() { const caps = driver.capabilities as Record; const platform = getPlatform(); @@ -706,22 +518,14 @@ export async function returnToApp() { await ensureMainWebViewContext(); - // Bridge invalidates WDA / UiAutomator2 caches in OnApplicationFocus(true); - // this poll covers the gap before Unity's player loop dispatches it. + // Wait for Unity's focus bridge to refresh driver caches. if (isUnitySDK) { const root = await byTestId('main_scroll_view'); await root.waitForDisplayed({ timeout: 3_000 }); } } -/** - * Expand a collapsed notification row in the Android shade. - * - * AOSP auto-expands a single shade entry; Samsung One UI does not. We try - * the public framework chevron id first, then a content-desc match, and - * finally a pinch-open gesture anchored on the title for OEM templates that - * rename or hide the chevron. - */ +/** Expand an Android notification row when OEMs keep it collapsed. */ async function expandNotificationRow(title: string): Promise { const byId = await $('//*[@resource-id="android:id/expand_button"]'); if (await byId.isDisplayed().catch(() => false)) { @@ -729,19 +533,14 @@ async function expandNotificationRow(title: string): Promise { return; } - // Chevron content-desc varies by locale ("Expand", "Expand button", etc.) - // but consistently contains "xpand" in English builds; matching on the - // substring keeps us off brittle exact-string selectors. + // Match "Expand" variants without relying on exact text. const byDesc = await $('//*[contains(@content-desc, "xpand")]'); if (await byDesc.isDisplayed().catch(() => false)) { await byDesc.click(); return; } - // Last resort: pinch-open anchored on the notification row to trigger the - // framework's expand gesture. Works on any OEM template since it doesn't - // rely on a specific view id. We anchor on the title's nearest sizeable - // ancestor so the gesture has enough surface to register. + // Last resort: pinch-open the row near its title. const row = await $(`//*[@text="${title}"]/ancestor::android.widget.FrameLayout[1]`); const target = (await row.isDisplayed().catch(() => false)) ? row @@ -755,18 +554,7 @@ async function expandNotificationRow(title: string): Promise { .catch(() => {}); } -/** - * Wait for a notification to be received. - * - * Android: opens the notification shade, verifies the title (and optionally - * body) are visible, then closes the shade. - * - * iOS: asserts against the foreground notification banner that SpringBoard - * overlays on the app while it's in foreground. No home press, no - * Notification Center swipe, no lock-screen path. Requires the SDK demo's - * `notificationWillDisplay` handler to allow display (the OneSignal demos - * default to this). - */ +/** Wait for a notification in Android shade or iOS foreground banner. */ export async function waitForNotification(opts: { title: string; body?: string; @@ -777,10 +565,7 @@ export async function waitForNotification(opts: { const platform = getPlatform(); if (platform === 'android') { - // Notification shade queries run against SystemUI, which requires the - // NATIVE_APP context. WebView SDKs (Capacitor/Cordova) are parked in a - // `WEBVIEW_*` context by `waitForAppReady`, so swap to native for the - // shade work and restore the WebView at the end. + // SystemUI queries require native context. await switchToNativeContext(); try { await driver.openNotifications(); @@ -794,14 +579,10 @@ export async function waitForNotification(opts: { } if (expectImage) { - // Target the BigPictureStyle attachment specifically. The shade is - // full of ImageViews (small icon, expand chevron, status bar), so - // matching by resource-id avoids false positives. + // Target the BigPictureStyle image, not any shade ImageView. const image = await $('//*[@resource-id="android:id/big_picture"]'); - // Samsung's One UI keeps shade entries collapsed by default, while - // AOSP auto-expands a single entry. If the big picture isn't already - // inflated, expand the row before asserting. + // Some OEMs keep rows collapsed. if (!(await image.isDisplayed().catch(() => false))) { await expandNotificationRow(title); } @@ -816,11 +597,7 @@ export async function waitForNotification(opts: { return; } - // iOS: query the foreground banner SpringBoard renders over the app. - // Native predicate selectors (`-ios predicate string:...`) only resolve in - // NATIVE_APP, and the banner lives in SpringBoard's UI tree, so we point - // `defaultActiveApplication` at SpringBoard for the query and restore the - // app on the way out. + // iOS banners live under SpringBoard. const caps = driver.capabilities as Record; const bundleId = (caps['bundleId'] ?? caps['appium:bundleId']) as string; await switchToNativeContext(); @@ -834,8 +611,7 @@ export async function waitForNotification(opts: { await banner.waitForDisplayed({ timeout: timeoutMs }); if (expectImage) { - // Long-press to expand the banner; the attachment renders as a new - // XCUIElementTypeImage on top of the existing app icon. + // Attachment appears as an extra image after expanding. const before = await driver.findElements('-ios class chain', '**/XCUIElementTypeImage'); await driver.execute('mobile: touchAndHold', { elementId: banner.elementId, @@ -850,7 +626,7 @@ export async function waitForNotification(opts: { ); } - // dismiss the banner + // Dismiss the banner. await banner.click(); } finally { if (bundleId) { @@ -868,7 +644,7 @@ export async function checkNotification(opts: { }) { const button = await scrollToEl(opts.buttonId); - // webview goes through flows really quick so need to pause a bit + // Let hybrid SDKs settle before the notification flow. if (isWebViewSDK) await driver.pause(3_000); await button.click(); await waitForNotification({ @@ -959,11 +735,7 @@ async function hasVisibleIamContent(expectedTitle?: string): Promise { return (await title.getText().catch(() => '')) === expectedTitle; } -/** - * The first tap is intermittently swallowed on iOS (all SDKs) and on - * Flutter Android by a leftover IAM container window. If no WebView - * appears in 2.5s, re-tap. Native Android (non-Flutter) doesn't need this. - */ +/** Tap an IAM trigger and retry where overlay teardown can swallow taps. */ async function tapIamTrigger(buttonId: string) { if (isWebViewSDK) { await scrollToEl(buttonId); @@ -1019,33 +791,23 @@ export async function checkInAppMessage(opts: { await driver.switchContext('NATIVE_APP'); if (getPlatform() === 'ios') { - // iOS can hold the dismissed IAM's WKWebView for several seconds - // before GC, so use a generous wait independent of `timeoutMs`. + // iOS can keep dismissed IAM WebViews around briefly. await driver.waitUntil(async () => !(await isWebViewVisible()), { timeout: 15_000, timeoutMsg: 'IAM webview still visible after closing', }); - // The IAM container UIView hosting the WKWebView can outlive the WebView - // itself by a few hundred ms (dismiss animation), intercepting both - // accessibility hit-tests and pointer events. Wait for the home-screen - // scroll view to become hit-testable again before returning, so the next - // step's swipes/queries don't race the teardown. + // Wait for the app UI to be hit-testable again. if (!isWebViewSDK) { const main = await byTestId('main_scroll_view'); await main.waitForDisplayed({ timeout: timeoutMs }).catch(() => { - /* best-effort; caller will surface real failure */ + /* best-effort */ }); } } await ensureMainWebViewContext(); } -/** - * Asserts a transient snackbar/toast appears with the expected text - * - * Cordova/Capacitor render Ionic `` elements whose visible text is - * inside a shadow root, so we compare the host element's message property. - */ +/** Assert a snackbar/toast appears with the expected text. */ export async function expectSnackbar(text: string, timeoutMs = 5_000) { if (sdkType === 'cordova' || sdkType === 'capacitor') { await browser.waitUntil( diff --git a/appium/tests/helpers/selectors.ts b/appium/tests/helpers/selectors.ts index f72a040..cdb752b 100644 --- a/appium/tests/helpers/selectors.ts +++ b/appium/tests/helpers/selectors.ts @@ -150,10 +150,7 @@ export async function getToggleState(el: { }): Promise { const sdkType = getSdkType(); - // Capacitor/Cordova render the toggle as an custom element in - // the WebView. `getAttribute('checked')` doesn't reliably return 'true' for - // a reflected boolean attribute, so prefer ARIA semantics with a - // presence-based fallback on the boolean `checked` attribute. + // Ionic toggles expose state most reliably through ARIA. if (sdkType === 'capacitor' || sdkType === 'cordova') { const ariaChecked = await el.getAttribute('aria-checked'); if (ariaChecked !== null) return ariaChecked === 'true'; @@ -166,12 +163,7 @@ export async function getToggleState(el: { return (await el.getAttribute('checked')) === 'true'; } -/** - * Polls `getToggleState(el)` until it equals `expected`. Use this instead of a - * synchronous `expect(await getToggleState(el)).toBe(...)` after a click, since - * Ionic's reflects `aria-checked` only after Stencil's next render - * tick and WebDriver clicks on the host can take a beat to register. - */ +/** Poll until a toggle reaches the expected state. */ export async function expectToggleState( el: { getAttribute(name: string): Promise }, expected: boolean, @@ -203,8 +195,7 @@ type ElementWithInteractionMethods = { setValue(value: string): Promise; }; -// Centralized element shims: Unity gets raw center taps, while Flutter Android -// keeps its text fallback and focus-before-setValue behavior. +// Centralized SDK-specific element shims. function withElementInteractionFixes(el: T): T { const isFlutterAndroid = getPlatform() === 'android' && getSdkType() === 'flutter'; const isUnity = getSdkType() === 'unity'; @@ -250,31 +241,13 @@ function withElementInteractionFixes(el }); } -/** - * Select an element by its cross-platform test ID. - * - * iOS native / RN / Compose all surface as Appium accessibility id (`~`) on iOS. - * On Android every SDK is normalized to resource-id (`id=`): - * - Flutter Semantics(identifier:) → resource-id (`id=`) - * - React Native testID → resource-id (`id=`) under Fabric/new arch; the old - * bridge surfaced it as content-desc but new arch sets it as the view tag, - * which UiAutomator2 exposes via resource-id. - * - Native Android Compose testTag → resource-id (`id=`) *only* when the - * demo opts in with `Modifier.semantics { testTagsAsResourceId = true }` - * on (or above) the root composable. Without that opt-in the tag stays a - * Compose-only Semantics property and is invisible to UiAutomator2. - * - .NET MAUI AutomationId → resource-id (`id=`), but namespaced as - * `:id/`. The wdio Android config disables locator - * autocompletion to dodge a Flutter quirk, so for dotnet we re-enable - * it (see wdio.android.conf.ts) and short ids match transparently. - * Capacitor uses `data-testid` as a CSS attribute inside a WebView. - */ +/** Select by shared test id: WebView CSS, Android id, iOS accessibility id. */ export async function byTestId(id: string) { const sdkType = getSdkType(); const platform = getPlatform(); if (sdkType === 'capacitor' || sdkType === 'cordova') return $(`[data-testid="${id}"]`); - // Resolve the chainable first so awaiting the Proxy doesn't unwrap past it. + // Resolve before proxying. if (platform === 'android') { const el = await $(`id=${id}`); return withElementInteractionFixes(el); @@ -283,14 +256,7 @@ export async function byTestId(id: string) { return withElementInteractionFixes(el); } -/** - * Select an element by visible text content. - * Use partial: true to match elements that contain the text. - * - * Flutter on Android renders text into the `content-desc` attribute (via - * Semantics), not the `text` attribute that UiSelector().text() looks at, - * so we fall back to an XPath that matches either attribute. - */ +/** Select by visible text; partial=true allows contains matching. */ export async function byText(identifier: string, partial = false) { const platform = getPlatform(); const sdkType = getSdkType(); From 5a2a59742bfa9f022badd18d8b450a15afe9a9d1 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 19 May 2026 13:28:23 -0700 Subject: [PATCH 05/25] refactor(appium): simplify scroll helpers and remove unused fns --- appium/tests/helpers/app.ts | 43 +++++++++---------------------------- 1 file changed, 10 insertions(+), 33 deletions(-) diff --git a/appium/tests/helpers/app.ts b/appium/tests/helpers/app.ts index 92550fe..24421c9 100644 --- a/appium/tests/helpers/app.ts +++ b/appium/tests/helpers/app.ts @@ -99,7 +99,7 @@ export async function scrollToEl( for (let i = 0; i < maxScrolls; i++) { const el = await byTestId(identifier); - if (await isVisibleInViewport(el, sdkType)) { + if (await isVisibleInViewport(el)) { return await scrollExtraIfNeeded(el, () => byTestId(identifier)); } await swipeMainContent(direction); @@ -109,8 +109,8 @@ export async function scrollToEl( throw new Error(`Element "${identifier}" not found after ${maxScrolls} scrolls`); } -/** Android native scroll fast path; returns false so callers can fall back. */ -async function tryNativeScrollAndroid(id: string): Promise { +/** Android native scroll fast path. */ +async function tryNativeScrollAndroid(id: string): Promise { try { const fullId = sdkType === 'dotnet' ? `${process.env.BUNDLE_ID || 'com.onesignal.example'}:id/${id}` : id; @@ -118,28 +118,27 @@ async function tryNativeScrollAndroid(id: string): Promise { `new UiScrollable(new UiSelector().scrollable(true).instance(0))` + `.scrollIntoView(new UiSelector().resourceId("${fullId}"))`; const result = await $(`android=${sel}`); - return await result.isExisting(); + await result.isExisting(); } catch { - return false; + // Fall back to swipe loop. } } /** iOS native scroll fast path; avoids WDA match-scroll caps. */ -async function tryNativeScrollIos(id: string): Promise { +async function tryNativeScrollIos(id: string): Promise { try { const main = await byTestId('main_scroll_view'); - if (!(await main.isExisting())) return false; + if (!(await main.isExisting())) return; for (let i = 0; i < 30; i++) { const el = await byTestId(id); - if (await el.isDisplayed().catch(() => false)) return true; + if (await el.isDisplayed().catch(() => false)) return; await driver.execute('mobile: scroll', { elementId: main.elementId, direction: 'down', }); } - return false; } catch { - return false; + // Fall back to swipe loop. } } @@ -151,7 +150,6 @@ async function isVisibleInViewport( getLocation(): Promise<{ x: number; y: number }>; getSize(): Promise<{ width: number; height: number }>; }, - sdk: string, ): Promise { if (await el.isDisplayed().catch(() => false)) return true; if (!isFlutterSDK) return false; @@ -389,14 +387,8 @@ export async function logoutUser() { await logoutButton.click(); } -/** Toggle push enabled. */ -export async function togglePushEnabled() { - const toggle = await byTestId('push_enabled_toggle'); - await toggle.click(); -} - /** Tap a modal confirm button. */ -export async function confirmModal(buttonTestId: string, timeoutMs = 5_000) { +export async function confirmModal(buttonTestId: string) { const btn = await byTestId(buttonTestId); await btn.click(); } @@ -460,21 +452,6 @@ async function waitForStablePosition( } } -/** Add a single tag through the UI. */ -export async function addTag(key: string, value: string) { - const addButton = await byTestId('add_tag_button'); - await addButton.click(); - - const keyInput = await byTestId('tag_key_input'); - await keyInput.waitForDisplayed({ timeout: 5_000 }); - await keyInput.setValue(key); - - const valueInput = await byTestId('tag_value_input'); - await valueInput.setValue(value); - - await confirmModal('tag_confirm_button'); -} - /** Assert a unique key-value pair appears in a section. */ export async function expectPairInSection(sectionId: string, key: string, value: string) { await scrollToEl(`${sectionId}_section`, { direction: 'up' }); From f530f431d80f3d58b72f116fc1ed38d6c1d308ea Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 19 May 2026 13:29:20 -0700 Subject: [PATCH 06/25] refactor(appium): derive SdkType from const array --- appium/tests/helpers/selectors.ts | 33 ++++++++++++------------------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/appium/tests/helpers/selectors.ts b/appium/tests/helpers/selectors.ts index cdb752b..45cf9f2 100644 --- a/appium/tests/helpers/selectors.ts +++ b/appium/tests/helpers/selectors.ts @@ -1,15 +1,4 @@ -type SdkType = - | 'android' - | 'capacitor' - | 'cordova' - | 'dotnet' - | 'expo' - | 'flutter' - | 'ios' - | 'react-native' - | 'unity'; - -const VALID_SDK_TYPES = new Set([ +const VALID_SDK_TYPES = [ 'android', 'capacitor', 'cordova', @@ -19,7 +8,10 @@ const VALID_SDK_TYPES = new Set([ 'ios', 'react-native', 'unity', -]); +] as const; + +type SdkType = (typeof VALID_SDK_TYPES)[number]; +const VALID_SDK_TYPE_SET = new Set(VALID_SDK_TYPES); type Platform = 'ios' | 'android'; @@ -178,19 +170,21 @@ export async function expectToggleState( export function getSdkType(): SdkType { const sdkType = process.env.SDK_TYPE; - if (sdkType && VALID_SDK_TYPES.has(sdkType)) { - return sdkType as SdkType; + if (isSdkType(sdkType)) { + return sdkType; } throw new Error( - `SDK_TYPE env var must be one of: ${[...VALID_SDK_TYPES].join(', ')}. Got: ${sdkType}`, + `SDK_TYPE env var must be one of: ${VALID_SDK_TYPES.join(', ')}. Got: ${sdkType}`, ); } +function isSdkType(value: string | undefined): value is SdkType { + return typeof value === 'string' && VALID_SDK_TYPE_SET.has(value); +} + type ElementWithInteractionMethods = { click(): Promise; getAttribute(name: string): Promise; - getLocation(): Promise<{ x: number; y: number }>; - getSize(): Promise<{ width: number; height: number }>; getText(): Promise; setValue(value: string): Promise; }; @@ -198,8 +192,7 @@ type ElementWithInteractionMethods = { // Centralized SDK-specific element shims. function withElementInteractionFixes(el: T): T { const isFlutterAndroid = getPlatform() === 'android' && getSdkType() === 'flutter'; - const isUnity = getSdkType() === 'unity'; - if (!isFlutterAndroid && !isUnity) { + if (!isFlutterAndroid) { return el; } From 3e8578da74ad7f45633ba4fb7236c2ff0510d19f Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 19 May 2026 13:46:36 -0700 Subject: [PATCH 07/25] docs(demo): update test ID and input field docs --- demo/build.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/demo/build.md b/demo/build.md index e41d4b4..d857173 100644 --- a/demo/build.md +++ b/demo/build.md @@ -435,7 +435,7 @@ Use the platform's native accessibility/test ID mechanism. The ids MUST match ex | Platform | Mechanism | | ----------------------- | ------------------------ | -| Android (native Kotlin) | `Modifier.testTag(...)` | +| Android (native Kotlin) | `Modifier.testTag(...)` with `testTagsAsResourceId = true` | | iOS (native Swift) | `accessibilityIdentifier`| | Flutter | `Semantics(identifier:)` | | React Native | `testID` | @@ -476,7 +476,7 @@ Section keys: `app`, `user`, `push`, `send_push`, `iam`, `send_iam`, `aliases`, | `add_tag_button`, `add_multiple_tags_button`, `remove_tags_button` | Tags section actions | | `add_trigger_button`, `add_multiple_triggers_button`, `remove_triggers_button`, `clear_triggers_button` | Triggers section actions | | `send_outcome_button` | Send Outcome (opens dialog) | -| `track_event_button` | Track Event (opens dialog) | +| `track_event_button` | Custom Events / Track Event action (opens dialog) | | `prompt_location_button`, `check_location_button` | Location section actions | | `start_live_activity_button`, `update_live_activity_button`, `end_live_activity_button` | Live Activities section actions (iOS only) | | `next_screen_button` | Bottom NEXT SCREEN navigation button | @@ -506,6 +506,7 @@ Confirm buttons on the shared SingleInput, SinglePair, MultiPair and MultiSelect | `multiselect_confirm_button` | Confirm on the MultiSelectRemove dialog | | `remove_checkbox_{key}` | Checkbox in MultiSelectRemove dialog (one per item) | | `login_user_id_input` | Login External User Id field | +| `login_user_jwt_input` | Login JWT field | | `alias_label_input` | Add Alias label field | | `alias_id_input` | Add Alias ID field | | `email_input` | Add Email field | From 09c06e7c9f184122b56624efd1fc8d3227ca5892 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 19 May 2026 13:59:36 -0700 Subject: [PATCH 08/25] refactor(appium): remove native scroll fast paths --- appium/scripts/run-all.sh | 2 +- appium/tests/helpers/app.ts | 45 +------------------------------------ 2 files changed, 2 insertions(+), 45 deletions(-) diff --git a/appium/scripts/run-all.sh b/appium/scripts/run-all.sh index 3a79c20..b7316f1 100755 --- a/appium/scripts/run-all.sh +++ b/appium/scripts/run-all.sh @@ -15,7 +15,7 @@ warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } error() { echo -e "${RED}[ERROR]${NC} $*"; } # ── Args (forwarded as-is to run-local.sh) ──────────────────────────────────── -ALL_SDKS=(cordova capacitor dotnet expo flutter react-native unity android) +ALL_SDKS=(android cordova capacitor dotnet expo flutter react-native unity) EXTRA_ARGS=() PLATFORM_FILTER="" diff --git a/appium/tests/helpers/app.ts b/appium/tests/helpers/app.ts index 24421c9..e62f0af 100644 --- a/appium/tests/helpers/app.ts +++ b/appium/tests/helpers/app.ts @@ -76,7 +76,6 @@ export async function scrollToEl( ) { // Swipe loop is the fallback and handles upward searches. const { direction = 'down', maxScrolls = 30 } = opts; - const platform = getPlatform(); if (isWebViewSDK) { const el = await byTestId(identifier); @@ -88,15 +87,6 @@ export async function scrollToEl( return byTestId(identifier); } - // Skip fast paths that target the wrong scroll container or a11y tree. - if (direction === 'down' && !isFlutterSDK && !isUnitySDK && !isNativeAndroidSDK) { - if (platform === 'android') { - await tryNativeScrollAndroid(identifier); - } else { - await tryNativeScrollIos(identifier); - } - } - for (let i = 0; i < maxScrolls; i++) { const el = await byTestId(identifier); if (await isVisibleInViewport(el)) { @@ -109,39 +99,6 @@ export async function scrollToEl( throw new Error(`Element "${identifier}" not found after ${maxScrolls} scrolls`); } -/** Android native scroll fast path. */ -async function tryNativeScrollAndroid(id: string): Promise { - try { - const fullId = - sdkType === 'dotnet' ? `${process.env.BUNDLE_ID || 'com.onesignal.example'}:id/${id}` : id; - const sel = - `new UiScrollable(new UiSelector().scrollable(true).instance(0))` + - `.scrollIntoView(new UiSelector().resourceId("${fullId}"))`; - const result = await $(`android=${sel}`); - await result.isExisting(); - } catch { - // Fall back to swipe loop. - } -} - -/** iOS native scroll fast path; avoids WDA match-scroll caps. */ -async function tryNativeScrollIos(id: string): Promise { - try { - const main = await byTestId('main_scroll_view'); - if (!(await main.isExisting())) return; - for (let i = 0; i < 30; i++) { - const el = await byTestId(id); - if (await el.isDisplayed().catch(() => false)) return; - await driver.execute('mobile: scroll', { - elementId: main.elementId, - direction: 'down', - }); - } - } catch { - // Fall back to swipe loop. - } -} - /** Flutter can be visible in a11y even when `isDisplayed()` lies. */ async function isVisibleInViewport( el: { @@ -419,7 +376,7 @@ export async function openModal(triggerTestId: string, expectedTestId: string, t return expected; }; - if (!isUnitySDK && !isNativeAndroidSDK) return open(); + if (!isUnitySDK) return open(); return retryOnce(open); } From fc67e9ce286ec4fba2ca2fc7d348caeb78da8ed8 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 19 May 2026 14:07:09 -0700 Subject: [PATCH 09/25] feat(appium): add --quiet/-q flag to suppress INFO logs --- appium/scripts/run-all.sh | 3 ++- appium/scripts/run-local.sh | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/appium/scripts/run-all.sh b/appium/scripts/run-all.sh index b7316f1..74f9442 100755 --- a/appium/scripts/run-all.sh +++ b/appium/scripts/run-all.sh @@ -23,7 +23,7 @@ SDKS_FILTER="" BAIL=0 for arg in "$@"; do case "$arg" in - --skip-build|--skip-device|--skip-reset|--skip) + --skip-build|--skip-device|--skip-reset|--skip|--quiet|-q) EXTRA_ARGS+=("$arg") ;; --spec=*) EXTRA_ARGS+=("$arg") ;; @@ -58,6 +58,7 @@ Options forwarded to run-local.sh: --skip-reset Keep existing app data --skip Shortcut for --skip-build --skip-device --skip-reset --spec=GLOB Spec glob to run (default: full suite, grouped into one session) + -q, --quiet Hide run-local [INFO] log lines -h, --help Show this help Exits non-zero if any combo fails. Prints a summary at the end. diff --git a/appium/scripts/run-local.sh b/appium/scripts/run-local.sh index cfc84f9..b857bbb 100755 --- a/appium/scripts/run-local.sh +++ b/appium/scripts/run-local.sh @@ -18,7 +18,7 @@ GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' -info() { echo -e "${GREEN}[INFO]${NC} $*"; } +info() { [[ "${QUIET:-false}" == true ]] || echo -e "${GREEN}[INFO]${NC} $*"; } warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; } @@ -30,6 +30,7 @@ SKIP_BUILD=false SKIP_DEVICE=false SKIP_RESET=false SPEC="" +QUIET=false ANDROID_CHANNEL_ID=7ec2ece9-c538-4656-9516-1316f48a005c IOS_REAL_DEVICE=false UDID="${UDID:-}" @@ -50,6 +51,7 @@ for arg in "$@"; do --skip-device) SKIP_DEVICE=true ;; --skip-reset) SKIP_RESET=true ;; --spec=*) SPEC="${arg#--spec=}" ;; + --quiet|-q) QUIET=true ;; --help|-h) cat < Date: Tue, 19 May 2026 14:13:20 -0700 Subject: [PATCH 10/25] refactor(appium): always await webview visibility --- appium/tests/helpers/app.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/appium/tests/helpers/app.ts b/appium/tests/helpers/app.ts index e62f0af..b244cab 100644 --- a/appium/tests/helpers/app.ts +++ b/appium/tests/helpers/app.ts @@ -677,7 +677,6 @@ async function tapIamTrigger(buttonId: string) { } else { await (await scrollToEl(buttonId)).click(); } - if (getPlatform() === 'android' && !isFlutterSDK && !isWebViewSDK) return; try { await driver.waitUntil(() => isWebViewVisible(), { timeout: 2_500 }); } catch { From a856d1b67eb8e86195144beab11656ea3abb11c9 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 19 May 2026 14:23:07 -0700 Subject: [PATCH 11/25] refactor(appium): auto-correct platform for android sdk --- appium/scripts/run-all.sh | 23 ++++++++++++++++------- appium/scripts/run-local.sh | 5 +++-- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/appium/scripts/run-all.sh b/appium/scripts/run-all.sh index 74f9442..6854599 100755 --- a/appium/scripts/run-all.sh +++ b/appium/scripts/run-all.sh @@ -49,7 +49,7 @@ Options: --sdks=LIST Comma-separated SDKs to run (default: all) Valid: cordova, capacitor, react-native, flutter, dotnet, expo, unity, android - Note: 'android' (native) only runs on --platform=android. + Note: 'android' (native) skips --platform=ios. --bail Stop after the first failing combo Options forwarded to run-local.sh: @@ -94,14 +94,17 @@ fi declare -a RESULTS FAILED=0 BAILED=0 +SKIPPED=0 for platform in "${PLATFORMS[@]}"; do for sdk in "${SDKS[@]}"; do - # Native Android demo only exists for Android; silently skip the iOS - # combo when iterating both platforms so the matrix stays clean. When - # the user explicitly requested `--sdks=android --platform=ios`, fall - # through and let run-local.sh emit the real error. - if [[ "$sdk" == "android" && "$platform" == "ios" && -z "$PLATFORM_FILTER" ]]; then + # Native Android demo only exists for Android. + if [[ "$sdk" == "android" && "$platform" == "ios" ]]; then + if [[ -n "$PLATFORM_FILTER" ]]; then + warn "--sdk=android only runs on --platform=android; skipping --platform=ios" + RESULTS+=("SKIP ${sdk} / ${platform}") + SKIPPED=$((SKIPPED + 1)) + fi continue fi label="${sdk} / ${platform}" @@ -128,6 +131,8 @@ echo -e "${BOLD}━━━ Summary ━━━${NC}" for line in "${RESULTS[@]}"; do if [[ "$line" == PASS* ]]; then echo -e " ${GREEN}${line}${NC}" + elif [[ "$line" == SKIP* ]]; then + echo -e " ${YELLOW}${line}${NC}" else echo -e " ${RED}${line}${NC}" fi @@ -144,4 +149,8 @@ if (( FAILED > 0 )); then fi echo "" -info "All combos passed" +if (( SKIPPED > 0 )); then + info "No combos failed" +else + info "All combos passed" +fi diff --git a/appium/scripts/run-local.sh b/appium/scripts/run-local.sh index b857bbb..678b1a5 100755 --- a/appium/scripts/run-local.sh +++ b/appium/scripts/run-local.sh @@ -66,7 +66,7 @@ Options: --platform=P ios | android --sdk=S flutter | react-native | cordova | capacitor | dotnet | expo | unity | android android = native Android (OneSignal-Android-SDK/examples/demo); - only valid with --platform=android. + skips with exit 0 when --platform=ios. --device=NAME Device/simulator/AVD name (default: iPhone 17 / Samsung Galaxy S26) --appium-port=N Appium server port (default: 4723). Use unique values when running multiple sessions in parallel on the same host. @@ -181,7 +181,8 @@ case "$SDK_TYPE" in esac if [[ "$SDK_TYPE" == "android" && "$PLATFORM" != "android" ]]; then - error "--sdk=android only supports --platform=android" + warn "--sdk=android only runs on --platform=android; skipping --platform=$PLATFORM" + exit 0 fi # ── Real-device validation + signing setup ──────────────────────────────────── From c8191a5e58851116471dd52d8182bb3346d0ead2 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 19 May 2026 14:25:17 -0700 Subject: [PATCH 12/25] refactor(appium): simplify IAM trigger tap --- appium/tests/helpers/app.ts | 26 +++----------------------- 1 file changed, 3 insertions(+), 23 deletions(-) diff --git a/appium/tests/helpers/app.ts b/appium/tests/helpers/app.ts index b244cab..a9c751a 100644 --- a/appium/tests/helpers/app.ts +++ b/appium/tests/helpers/app.ts @@ -669,30 +669,10 @@ async function hasVisibleIamContent(expectedTitle?: string): Promise { return (await title.getText().catch(() => '')) === expectedTitle; } -/** Tap an IAM trigger and retry where overlay teardown can swallow taps. */ +/** Tap an IAM trigger. */ async function tapIamTrigger(buttonId: string) { - if (isWebViewSDK) { - await scrollToEl(buttonId); - await clickWebViewTestId(buttonId); - } else { - await (await scrollToEl(buttonId)).click(); - } - try { - await driver.waitUntil(() => isWebViewVisible(), { timeout: 2_500 }); - } catch { - if (isWebViewSDK) { - await clickWebViewTestId(buttonId); - } else { - await (await byTestId(buttonId)).click(); - } - } -} - -async function clickWebViewTestId(testId: string) { - await browser.execute((id: string) => { - const button = document.querySelector(`[data-testid="${id}"]`); - button?.click(); - }, testId); + const el = await scrollToEl(buttonId); + await el.click(); } export async function checkInAppMessage(opts: { From 6aef7a797977d0298ffa4905a058c4a963a0d3b8 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 19 May 2026 16:47:31 -0700 Subject: [PATCH 13/25] refactor: remove dismissKeyboard helper and its usages --- appium/tests/helpers/app.ts | 12 ------------ appium/tests/specs/08_outcome.spec.ts | 4 ---- 2 files changed, 16 deletions(-) diff --git a/appium/tests/helpers/app.ts b/appium/tests/helpers/app.ts index a9c751a..0a672c7 100644 --- a/appium/tests/helpers/app.ts +++ b/appium/tests/helpers/app.ts @@ -213,18 +213,6 @@ export async function allowNotifications() { ]); } -/** Dismiss Android IME before the next native click. */ -export async function dismissKeyboard() { - if (getPlatform() !== 'android') return; - if (isWebViewSDK) return; - if (!(await driver.isKeyboardShown())) return; - try { - await driver.execute('mobile: hideKeyboard'); - } catch (error) { - console.error('Error dismissing keyboard', error); - } -} - /** Tap the location permission allow button if present. */ export async function allowLocation() { if (driver.isIOS) return clickIosPermissionButton('Allow While Using App'); diff --git a/appium/tests/specs/08_outcome.spec.ts b/appium/tests/specs/08_outcome.spec.ts index a4d3821..f9d1cd7 100644 --- a/appium/tests/specs/08_outcome.spec.ts +++ b/appium/tests/specs/08_outcome.spec.ts @@ -1,6 +1,5 @@ import { checkTooltip, - dismissKeyboard, expectSnackbar, openModal, scrollToEl, @@ -21,7 +20,6 @@ describe('Outcomes', () => { it('can send a normal outcome', async () => { const nameInput = await openModal('send_outcome_button', 'outcome_name_input'); await nameInput.setValue('test_normal'); - await dismissKeyboard(); const normalRadio = await byTestId('outcome_type_normal_radio'); await normalRadio.click(); @@ -35,7 +33,6 @@ describe('Outcomes', () => { it('can send a unique outcome', async () => { const nameInput = await openModal('send_outcome_button', 'outcome_name_input'); await nameInput.setValue('test_unique'); - await dismissKeyboard(); const uniqueRadio = await byTestId('outcome_type_unique_radio'); await uniqueRadio.click(); @@ -57,7 +54,6 @@ describe('Outcomes', () => { const valueInput = await byTestId('outcome_value_input'); await valueInput.waitForDisplayed({ timeout: 5_000 }); await valueInput.setValue('3.14'); - await dismissKeyboard(); const sendBtn = await byTestId('outcome_send_button'); await sendBtn.click(); From dc6e9c6559731d1353db3e33e444b18a3d4d55f4 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 19 May 2026 16:47:31 -0700 Subject: [PATCH 14/25] fix: move Flutter post-swipe pause outside swipe loop and remove per-scroll pause --- appium/tests/helpers/app.ts | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/appium/tests/helpers/app.ts b/appium/tests/helpers/app.ts index 0a672c7..b931b6a 100644 --- a/appium/tests/helpers/app.ts +++ b/appium/tests/helpers/app.ts @@ -35,7 +35,7 @@ async function swipeMainContent(direction: 'up' | 'down', distance: 'small' | 'n const endY = Math.round(direction === 'down' ? startY - swipeDistance : startY + swipeDistance); // Slow Flutter drags to avoid fling momentum. - const moveDurationMs = isFlutterSDK ? 700 : 300; + const moveDurationMs = 300; // Bound pointer actions; stale WebView handles can otherwise hang. const SWIPE_TIMEOUT_MS = 5_000; @@ -64,6 +64,7 @@ async function swipeMainContent(direction: 'up' | 'down', distance: 'small' | 'n } finally { if (timer) clearTimeout(timer); } + if (isFlutterSDK) await driver.pause(1000); } /** Scroll to a test id using the fastest reliable SDK-specific path. */ @@ -93,24 +94,17 @@ export async function scrollToEl( return await scrollExtraIfNeeded(el, () => byTestId(identifier)); } await swipeMainContent(direction); - // Let Flutter realize newly visible widgets. - if (isFlutterSDK) await driver.pause(250); } throw new Error(`Element "${identifier}" not found after ${maxScrolls} scrolls`); } -/** Flutter can be visible in a11y even when `isDisplayed()` lies. */ -async function isVisibleInViewport( - el: { - isDisplayed(): Promise; - isExisting(): Promise; - getLocation(): Promise<{ x: number; y: number }>; - getSize(): Promise<{ width: number; height: number }>; - }, -): Promise { +async function isVisibleInViewport(el: { + isDisplayed(): Promise; + getLocation(): Promise<{ x: number; y: number }>; + getSize(): Promise<{ width: number; height: number }>; +}): Promise { if (await el.isDisplayed().catch(() => false)) return true; - if (!isFlutterSDK) return false; - if (!(await el.isExisting().catch(() => false))) return false; + // Fallback for SDKs whose `isDisplayed` lies (e.g. Flutter on iOS). try { const [loc, size] = await Promise.all([el.getLocation(), el.getSize()]); if (size.width <= 0 || size.height <= 0) return false; From 664cddba91b4c868d420192da965247d52f5a1a1 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 19 May 2026 16:47:31 -0700 Subject: [PATCH 15/25] fix: include bundleId in appPackageName lookup for iOS support --- appium/tests/helpers/app.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appium/tests/helpers/app.ts b/appium/tests/helpers/app.ts index b931b6a..7e11dfd 100644 --- a/appium/tests/helpers/app.ts +++ b/appium/tests/helpers/app.ts @@ -637,7 +637,7 @@ function isIamCandidateContext(context: string): boolean { } function appPackageName(): string { - for (const key of ['appPackage', 'appium:appPackage']) { + for (const key of ['bundleId', 'appium:bundleId', 'appPackage', 'appium:appPackage']) { const value = Reflect.get(driver.capabilities, key); if (typeof value === 'string' && value) return value; } From 71e1429af43a68a81ab660e32919723fe6e0f40a Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 19 May 2026 16:47:31 -0700 Subject: [PATCH 16/25] refactor: simplify isVisibleInViewport and checkInAppMessage helpers --- appium/tests/helpers/app.ts | 39 ++++++++++--------------------------- 1 file changed, 10 insertions(+), 29 deletions(-) diff --git a/appium/tests/helpers/app.ts b/appium/tests/helpers/app.ts index 7e11dfd..b7d5075 100644 --- a/appium/tests/helpers/app.ts +++ b/appium/tests/helpers/app.ts @@ -651,31 +651,27 @@ async function hasVisibleIamContent(expectedTitle?: string): Promise { return (await title.getText().catch(() => '')) === expectedTitle; } -/** Tap an IAM trigger. */ -async function tapIamTrigger(buttonId: string) { - const el = await scrollToEl(buttonId); - await el.click(); -} - export async function checkInAppMessage(opts: { buttonId: string; expectedTitle: string; - timeoutMs?: number; skipClick?: boolean; }) { - const { buttonId, expectedTitle, timeoutMs = 15_000 } = opts; - - if (!opts.skipClick) await tapIamTrigger(buttonId); + const timeout = 20_000; + const { buttonId, expectedTitle } = opts; + // Tap the IAM trigger + if (!opts.skipClick) { + const el = await scrollToEl(buttonId); + await el.click(); + } await driver.waitUntil(() => isWebViewVisible(), { - timeout: timeoutMs, + timeout, timeoutMsg: `IAM webview not shown after clicking "${buttonId}"`, }); - - await switchToIAMWebView(expectedTitle, timeoutMs); + await switchToIAMWebView(expectedTitle, timeout); const title = await $('h1'); - await title.waitForExist({ timeout: timeoutMs }); + await title.waitForExist({ timeout }); expect(await title.getText()).toBe(expectedTitle); const iamWindowHandle = await driver.getWindowHandle().catch(() => undefined); @@ -684,21 +680,6 @@ export async function checkInAppMessage(opts: { closedIamWindowHandles.add(iamWindowHandle); } await driver.switchContext('NATIVE_APP'); - - if (getPlatform() === 'ios') { - // iOS can keep dismissed IAM WebViews around briefly. - await driver.waitUntil(async () => !(await isWebViewVisible()), { - timeout: 15_000, - timeoutMsg: 'IAM webview still visible after closing', - }); - // Wait for the app UI to be hit-testable again. - if (!isWebViewSDK) { - const main = await byTestId('main_scroll_view'); - await main.waitForDisplayed({ timeout: timeoutMs }).catch(() => { - /* best-effort */ - }); - } - } await ensureMainWebViewContext(); } From 8b3f9fdc9dafb7466ee024b4599d58ec65c645f5 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 19 May 2026 17:31:03 -0700 Subject: [PATCH 17/25] refactor(appium): remove Unity SDK modal workarounds --- appium/tests/helpers/app.ts | 25 +------------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/appium/tests/helpers/app.ts b/appium/tests/helpers/app.ts index b7d5075..ed175ee 100644 --- a/appium/tests/helpers/app.ts +++ b/appium/tests/helpers/app.ts @@ -349,8 +349,6 @@ export async function waitForDisappear(testId: string, timeoutMs = 5_000) { export async function openModal(triggerTestId: string, expectedTestId: string, timeoutMs = 5_000) { const open = async () => { const trigger = await scrollToEl(triggerTestId); - if (isUnitySDK) await waitForStablePosition(trigger); - await trigger.click(); const expected = await byTestId(expectedTestId); @@ -358,8 +356,7 @@ export async function openModal(triggerTestId: string, expectedTestId: string, t return expected; }; - if (!isUnitySDK) return open(); - return retryOnce(open); + return open(); } async function retryOnce(fn: () => Promise, delayMs = 250): Promise { @@ -371,26 +368,6 @@ async function retryOnce(fn: () => Promise, delayMs = 250): Promise { } } -async function waitForStablePosition( - el: { getLocation(): Promise<{ x: number; y: number }> }, - timeoutMs = 1_000, - pollMs = 100, -) { - let prev: number | null = null; - let stableHits = 0; - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const loc = await el.getLocation().catch(() => null); - if (loc && prev !== null && Math.abs(loc.y - prev) < 1) { - if (++stableHits >= 2) return; - } else { - stableHits = 0; - } - prev = loc ? loc.y : null; - await driver.pause(pollMs); - } -} - /** Assert a unique key-value pair appears in a section. */ export async function expectPairInSection(sectionId: string, key: string, value: string) { await scrollToEl(`${sectionId}_section`, { direction: 'up' }); From 3a221e388af59a2911682acae09aaa2729fd4a38 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 19 May 2026 18:06:04 -0700 Subject: [PATCH 18/25] refactor(appium): unify snackbar check via native context --- appium/tests/helpers/app.ts | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/appium/tests/helpers/app.ts b/appium/tests/helpers/app.ts index ed175ee..46a7941 100644 --- a/appium/tests/helpers/app.ts +++ b/appium/tests/helpers/app.ts @@ -662,23 +662,13 @@ export async function checkInAppMessage(opts: { /** Assert a snackbar/toast appears with the expected text. */ export async function expectSnackbar(text: string, timeoutMs = 5_000) { - if (sdkType === 'cordova' || sdkType === 'capacitor') { - await browser.waitUntil( - async () => { - const toasts = await $$('ion-toast'); - for (const toast of toasts) { - const message = (await toast.getProperty('message')) as string | null; - if (message === text && (await toast.isDisplayed())) return true; - } - return false; - }, - { timeout: timeoutMs, timeoutMsg: `toast "${text}" not displayed within ${timeoutMs}ms` }, - ); - return; + await switchToNativeContext(); + try { + const el = await byText(text); + await el.waitForDisplayed({ timeout: timeoutMs }); + } finally { + await ensureMainWebViewContext(); } - - const el = await byText(text); - await el.waitForDisplayed({ timeout: timeoutMs }); } export async function checkTooltip(buttonId: string, key: string) { From c9fb987ffcdd88f7544b6357bb22f20e73cabaff Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 19 May 2026 18:14:15 -0700 Subject: [PATCH 19/25] refactor(appium): apply switchToNativeContext to all SDKs --- appium/tests/helpers/app.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/appium/tests/helpers/app.ts b/appium/tests/helpers/app.ts index 46a7941..21ab7d1 100644 --- a/appium/tests/helpers/app.ts +++ b/appium/tests/helpers/app.ts @@ -273,10 +273,8 @@ async function switchToMainWebViewWindow(): Promise { return false; } -/** Switch hybrid SDKs to native context for system UI. */ +/** Switch to NATIVE_APP for system UI. Usually a no-op outside WebView SDKs. */ export async function switchToNativeContext() { - if (!isWebViewSDK) return; - const current = contextName(await driver.getContext()); if (current !== 'NATIVE_APP') { await driver.switchContext('NATIVE_APP'); From 3319f5b67035d33e23a0ea833076fd2f958381de Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 19 May 2026 18:23:00 -0700 Subject: [PATCH 20/25] refactor(appium): hardcode package ID constant --- appium/tests/helpers/app.ts | 29 +++++++---------------------- 1 file changed, 7 insertions(+), 22 deletions(-) diff --git a/appium/tests/helpers/app.ts b/appium/tests/helpers/app.ts index 21ab7d1..2d4a6f5 100644 --- a/appium/tests/helpers/app.ts +++ b/appium/tests/helpers/app.ts @@ -6,6 +6,8 @@ import { getValue, setValue } from '@wdio/shared-store-service'; import { byTestId, byText, getPlatform, getSdkType, getTestExternalId } from './selectors.js'; +const PACKAGE_ID = 'com.onesignal.example'; + const __dirname = dirname(fileURLToPath(import.meta.url)); const tooltipContent = JSON.parse( readFileSync(resolve(__dirname, '../../../demo/tooltip_content.json'), 'utf-8'), @@ -392,19 +394,14 @@ export async function lockScreen() { /** Return to the app from system UI. */ export async function returnToApp() { - const caps = driver.capabilities as Record; const platform = getPlatform(); if (platform === 'android') { await driver.pressKeyCode(4); - const appId = (caps['appPackage'] ?? caps['appium:appPackage']) as string; - if (appId) { - await driver.execute('mobile: activateApp', { appId }); - } + await driver.execute('mobile: activateApp', { appId: PACKAGE_ID }); } else { - const bundleId = (caps['bundleId'] ?? caps['appium:bundleId']) as string; - await driver.updateSettings({ defaultActiveApplication: bundleId }); - await driver.execute('mobile: activateApp', { bundleId }); + await driver.updateSettings({ defaultActiveApplication: PACKAGE_ID }); + await driver.execute('mobile: activateApp', { bundleId: PACKAGE_ID }); } await ensureMainWebViewContext(); @@ -489,8 +486,6 @@ export async function waitForNotification(opts: { } // iOS banners live under SpringBoard. - const caps = driver.capabilities as Record; - const bundleId = (caps['bundleId'] ?? caps['appium:bundleId']) as string; await switchToNativeContext(); try { await driver.updateSettings({ defaultActiveApplication: 'com.apple.springboard' }); @@ -520,9 +515,7 @@ export async function waitForNotification(opts: { // Dismiss the banner. await banner.click(); } finally { - if (bundleId) { - await driver.updateSettings({ defaultActiveApplication: bundleId }); - } + await driver.updateSettings({ defaultActiveApplication: PACKAGE_ID }); await ensureMainWebViewContext(); } } @@ -608,15 +601,7 @@ const closedIamWindowHandles = new Set(); function isIamCandidateContext(context: string): boolean { if (context === 'NATIVE_APP') return false; if (getPlatform() !== 'android' || isWebViewSDK) return true; - return context.includes(appPackageName()); -} - -function appPackageName(): string { - for (const key of ['bundleId', 'appium:bundleId', 'appPackage', 'appium:appPackage']) { - const value = Reflect.get(driver.capabilities, key); - if (typeof value === 'string' && value) return value; - } - return process.env.BUNDLE_ID || 'com.onesignal.example'; + return context.includes(PACKAGE_ID); } async function hasVisibleIamContent(expectedTitle?: string): Promise { From d8ba5fe2e4e75fe98d01c336e9579acfee26696c Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 19 May 2026 18:47:50 -0700 Subject: [PATCH 21/25] refactor(appium): fix viewport visibility for SDK types --- appium/tests/helpers/app.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/appium/tests/helpers/app.ts b/appium/tests/helpers/app.ts index 2d4a6f5..7081a22 100644 --- a/appium/tests/helpers/app.ts +++ b/appium/tests/helpers/app.ts @@ -18,7 +18,6 @@ export const isWebViewSDK = sdkType === 'capacitor' || sdkType === 'cordova'; export const isBrowserStack = Boolean(process.env.BROWSERSTACK_USERNAME); export const isUnitySDK = sdkType === 'unity'; const isFlutterSDK = sdkType === 'flutter'; -const isNativeAndroidSDK = sdkType === 'android'; export function isBrowserStackIos(): boolean { return isBrowserStack && getPlatform() === 'ios'; @@ -105,8 +104,8 @@ async function isVisibleInViewport(el: { getLocation(): Promise<{ x: number; y: number }>; getSize(): Promise<{ width: number; height: number }>; }): Promise { - if (await el.isDisplayed().catch(() => false)) return true; - // Fallback for SDKs whose `isDisplayed` lies (e.g. Flutter on iOS). + if ((await el.isDisplayed().catch(() => false)) && !isFlutterSDK && !isUnitySDK) return true; + // Some SDKs report offscreen accessibility nodes as displayed. try { const [loc, size] = await Promise.all([el.getLocation(), el.getSize()]); if (size.width <= 0 || size.height <= 0) return false; @@ -405,12 +404,7 @@ export async function returnToApp() { } await ensureMainWebViewContext(); - - // Wait for Unity's focus bridge to refresh driver caches. - if (isUnitySDK) { - const root = await byTestId('main_scroll_view'); - await root.waitForDisplayed({ timeout: 3_000 }); - } + if (isUnitySDK) await driver.pause(1_000); } /** Expand an Android notification row when OEMs keep it collapsed. */ From 3fcbcbb6cba5664c5c80d6b8eff71d0160ec14be Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 19 May 2026 18:59:00 -0700 Subject: [PATCH 22/25] refactor(appium): extract IAM context helpers --- appium/tests/helpers/app.ts | 67 ++++++++++++++++++++++--------------- 1 file changed, 40 insertions(+), 27 deletions(-) diff --git a/appium/tests/helpers/app.ts b/appium/tests/helpers/app.ts index 7081a22..d0e7269 100644 --- a/appium/tests/helpers/app.ts +++ b/appium/tests/helpers/app.ts @@ -443,7 +443,7 @@ export async function waitForNotification(opts: { timeoutMs?: number; expectImage?: boolean; }) { - const { title, body, timeoutMs = 30_000, expectImage = false } = opts; + const { title, body, timeoutMs = 60_000, expectImage = false } = opts; const platform = getPlatform(); if (platform === 'android') { @@ -557,33 +557,19 @@ async function findIamWebView(expectedTitle?: string): Promise { } }; - try { - const contexts = (await driver.getContexts()).map(contextName).filter(isDefined); - const webviewContexts = contexts.filter(isIamCandidateContext); - - for (const context of webviewContexts) { - try { - await driver.switchContext(context); - const handles = await driver.getWindowHandles().catch(() => []); - const candidates = - handles.length > 0 - ? [...handles].reverse().filter((handle) => !closedIamWindowHandles.has(handle)) - : [undefined]; - - for (const handle of candidates) { - try { - if (handle) await driver.switchToWindow(handle); - if (await hasVisibleIamContent(expectedTitle)) return true; - } catch { - /* ignore closed/stale IAM windows */ - } - } - } catch { - /* try the next WebView context */ - } + const contexts = await getIamCandidateContexts(); + for (const context of contexts) { + if (!(await switchToContext(context))) continue; + + const handles = await driver.getWindowHandles().catch(() => []); + const candidates = handles.length + ? [...handles].reverse().filter((handle) => !closedIamWindowHandles.has(handle)) + : [undefined]; + + for (const handle of candidates) { + if (handle && !(await switchToWindow(handle))) continue; + if (await hasVisibleIamContent(expectedTitle).catch(() => false)) return true; } - } catch { - /* fall through to restore below */ } await restore(); @@ -598,6 +584,33 @@ function isIamCandidateContext(context: string): boolean { return context.includes(PACKAGE_ID); } +async function getIamCandidateContexts(): Promise { + try { + const contexts = await driver.getContexts(); + return contexts.map(contextName).filter(isDefined).filter(isIamCandidateContext); + } catch { + return []; + } +} + +async function switchToContext(context: string): Promise { + try { + await driver.switchContext(context); + return true; + } catch { + return false; + } +} + +async function switchToWindow(handle: string): Promise { + try { + await driver.switchToWindow(handle); + return true; + } catch { + return false; + } +} + async function hasVisibleIamContent(expectedTitle?: string): Promise { const title = $('h1'); if (!(await title.isExisting().catch(() => false))) return false; From 09b5bb55e148b69a4710d774fc3d943968fe9b68 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 19 May 2026 19:30:51 -0700 Subject: [PATCH 23/25] fix(appium): clear stale UiAutomator2 state on Android --- appium/scripts/run-local.sh | 11 +++++++++++ appium/tests/helpers/app.ts | 1 + 2 files changed, 12 insertions(+) diff --git a/appium/scripts/run-local.sh b/appium/scripts/run-local.sh index 678b1a5..fed1cb7 100755 --- a/appium/scripts/run-local.sh +++ b/appium/scripts/run-local.sh @@ -1664,6 +1664,16 @@ start_appium() { info "Appium ready (pid $pid)" } +# Clear stale UiAutomator2 state between Android combos without rebooting the emulator. +cleanup_android_automation() { + [[ "$PLATFORM" == "android" ]] || return 0 + adb shell cmd statusbar collapse >/dev/null 2>&1 || true + adb shell input keyevent KEYCODE_BACK >/dev/null 2>&1 || true + adb shell input keyevent KEYCODE_HOME >/dev/null 2>&1 || true + adb shell am force-stop io.appium.uiautomator2.server >/dev/null 2>&1 || true + adb shell am force-stop io.appium.uiautomator2.server.test >/dev/null 2>&1 || true +} + # ── 3. Reset app ───────────────────────────────────────────────────────────── reset_app() { if [[ "$SKIP_RESET" == true ]]; then @@ -1755,6 +1765,7 @@ main() { build_app start_device start_appium + cleanup_android_automation reset_app run_tests diff --git a/appium/tests/helpers/app.ts b/appium/tests/helpers/app.ts index d0e7269..a168186 100644 --- a/appium/tests/helpers/app.ts +++ b/appium/tests/helpers/app.ts @@ -685,6 +685,7 @@ export async function withRetryDelay(ctx: Mocha.Context, delayMs: number, fn: () const currentRetry: unknown = ctx.test ? Reflect.get(ctx.test, '_currentRetry') : 0; const retries: unknown = ctx.test ? Reflect.get(ctx.test, '_retries') : 0; if (typeof currentRetry === 'number' && typeof retries === 'number' && currentRetry < retries) { + await returnToApp().catch(() => {}); await browser.pause(delayMs); } throw err; From edeb383801226757c932bb3281394a207fbd47d3 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 19 May 2026 21:50:19 -0700 Subject: [PATCH 24/25] refactor(appium): extract waitForPushId helper --- appium/tests/helpers/app.ts | 17 ++++++++++++++--- appium/tests/specs/02_push.spec.ts | 11 +++++------ 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/appium/tests/helpers/app.ts b/appium/tests/helpers/app.ts index a168186..e799f85 100644 --- a/appium/tests/helpers/app.ts +++ b/appium/tests/helpers/app.ts @@ -443,7 +443,7 @@ export async function waitForNotification(opts: { timeoutMs?: number; expectImage?: boolean; }) { - const { title, body, timeoutMs = 60_000, expectImage = false } = opts; + const { title, body, timeoutMs = 30_000, expectImage = false } = opts; const platform = getPlatform(); if (platform === 'android') { @@ -514,16 +514,27 @@ export async function waitForNotification(opts: { } } +/** Wait for the push ID to be populated. */ +export async function waitForPushId(timeoutMs = 30_000): Promise { + const pushIdEl = await scrollToEl('push_id_value', { direction: 'up' }); + await driver.waitUntil(async () => { + const pushId = (await pushIdEl.getText().catch(() => '')).trim(); + return pushId !== '' && pushId !== '—'; + }, { timeout: timeoutMs, timeoutMsg: 'Push ID not populated' }); + return (await pushIdEl.getText()).trim(); +} + export async function checkNotification(opts: { buttonId: string; title: string; body?: string; expectImage?: boolean; }) { + await waitForPushId(); + const button = await scrollToEl(opts.buttonId); + await driver.pause(2_000); // small wait to hopefully get image notif early - // Let hybrid SDKs settle before the notification flow. - if (isWebViewSDK) await driver.pause(3_000); await button.click(); await waitForNotification({ title: opts.title, diff --git a/appium/tests/specs/02_push.spec.ts b/appium/tests/specs/02_push.spec.ts index fa5fe0f..5054f28 100644 --- a/appium/tests/specs/02_push.spec.ts +++ b/appium/tests/specs/02_push.spec.ts @@ -5,6 +5,7 @@ import { scrollToEl, isBrowserStackIos, withRetryDelay, + waitForPushId, } from '../helpers/app.js'; import { byTestId, expectToggleState } from '../helpers/selectors.js'; @@ -23,10 +24,8 @@ describe('Push Subscription', () => { it('should have push ID and be enabled initially', async function () { if (isBrowserStackIos()) this.skip(); - const pushIdEl = await scrollToEl('push_id_value'); - const pushId = await pushIdEl.getText(); - expect(pushId).not.toBe('N/A'); - expect(pushId.length).toBeGreaterThan(0); + const pushId = await waitForPushId(); + expect(pushId).not.toBe('—'); await scrollToEl('push_enabled_toggle'); const toggleEl = await byTestId('push_enabled_toggle'); @@ -35,8 +34,8 @@ describe('Push Subscription', () => { it('can send an image notification', async function () { if (isBrowserStackIos()) this.skip(); - this.retries(2); - await withRetryDelay(this, 5_000, () => + this.retries(1); + await withRetryDelay(this, 1_000, () => checkNotification({ buttonId: 'send_image_button', title: 'Image Notification', From 149e991385d94e8d3d7c965b16a6941268535e0f Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 20 May 2026 11:56:37 -0700 Subject: [PATCH 25/25] refactor(appium): remove withRetryDelay helper --- appium/tests/helpers/app.ts | 16 ---------------- appium/tests/specs/02_push.spec.ts | 16 ++++++---------- 2 files changed, 6 insertions(+), 26 deletions(-) diff --git a/appium/tests/helpers/app.ts b/appium/tests/helpers/app.ts index e799f85..fd8ff81 100644 --- a/appium/tests/helpers/app.ts +++ b/appium/tests/helpers/app.ts @@ -686,19 +686,3 @@ export async function checkTooltip(buttonId: string, key: string) { await okButton.click(); await waitForDisappear('tooltip_ok_button'); } - -export async function withRetryDelay(ctx: Mocha.Context, delayMs: number, fn: () => Promise) { - try { - await fn(); - } catch (err) { - const testTitle = ctx.test?.fullTitle() ?? 'unknown test'; - console.warn(`Retrying for "${testTitle}"...`); - const currentRetry: unknown = ctx.test ? Reflect.get(ctx.test, '_currentRetry') : 0; - const retries: unknown = ctx.test ? Reflect.get(ctx.test, '_retries') : 0; - if (typeof currentRetry === 'number' && typeof retries === 'number' && currentRetry < retries) { - await returnToApp().catch(() => {}); - await browser.pause(delayMs); - } - throw err; - } -} diff --git a/appium/tests/specs/02_push.spec.ts b/appium/tests/specs/02_push.spec.ts index 5054f28..9e0eada 100644 --- a/appium/tests/specs/02_push.spec.ts +++ b/appium/tests/specs/02_push.spec.ts @@ -4,7 +4,6 @@ import { checkTooltip, scrollToEl, isBrowserStackIos, - withRetryDelay, waitForPushId, } from '../helpers/app.js'; import { byTestId, expectToggleState } from '../helpers/selectors.js'; @@ -34,14 +33,11 @@ describe('Push Subscription', () => { it('can send an image notification', async function () { if (isBrowserStackIos()) this.skip(); - this.retries(1); - await withRetryDelay(this, 1_000, () => - checkNotification({ - buttonId: 'send_image_button', - title: 'Image Notification', - body: 'This notification includes an image', - expectImage: true, - }), - ); + await checkNotification({ + buttonId: 'send_image_button', + title: 'Image Notification', + body: 'This notification includes an image', + expectImage: true, + }); }); });