diff --git a/appium/scripts/run-all.sh b/appium/scripts/run-all.sh index c3f6efe..6854599 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=(android cordova capacitor dotnet expo flutter react-native unity) EXTRA_ARGS=() PLATFORM_FILTER="" @@ -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") ;; @@ -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) skips --platform=ios. --bail Stop after the first failing combo Options forwarded to run-local.sh: @@ -56,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. @@ -91,9 +94,19 @@ 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. + 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}" echo "" echo -e "${BOLD}━━━ Running: ${label} ━━━${NC}" @@ -118,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 @@ -134,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 241264c..fed1cb7 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 < (or UDID env). Find via: xcrun devicectl list devices" @@ -293,6 +314,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 +1410,48 @@ build_unity_android() { info "App built: $APP_PATH" } +build_android_native() { + # 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}" + + # 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" "${gradle_args[@]}") + + [[ -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 +1503,8 @@ build_app() { else build_unity_android fi + elif [[ "$SDK_TYPE" == "android" ]]; then + build_android_native fi } @@ -1583,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 @@ -1674,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 72b6bee..fd8ff81 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'), @@ -21,39 +23,22 @@ 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. - const moveDurationMs = isFlutterSDK ? 700 : 300; + // Slow Flutter drags to avoid fling momentum. + const moveDurationMs = 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' } }) @@ -80,30 +65,10 @@ async function swipeMainContent(direction: 'up' | 'down', distance: 'small' | 'n } finally { if (timer) clearTimeout(timer); } + if (isFlutterSDK) await driver.pause(1000); } -/** - * 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,11 +76,8 @@ 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(); if (isWebViewSDK) { const el = await byTestId(identifier); @@ -127,129 +89,23 @@ 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) { - 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, sdkType)) { + if (await isVisibleInViewport(el)) { return await scrollExtraIfNeeded(el, () => byTestId(identifier)); } await swipeMainContent(direction); - // Let Flutter realize freshly scrolled-in widgets before the next poll. - 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. - */ -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}`); - return await result.isExisting(); - } catch { - return false; - } -} - -/** - * 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). - */ -async function tryNativeScrollIos(id: string): Promise { - try { - const main = await byTestId('main_scroll_view'); - if (!(await main.isExisting())) return false; - for (let i = 0; i < 30; i++) { - const el = await byTestId(id); - if (await el.isDisplayed().catch(() => false)) return true; - await driver.execute('mobile: scroll', { - elementId: main.elementId, - direction: 'down', - }); - } - return false; - } catch { - return false; - } -} - -/** - * 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. - */ -async function isVisibleInViewport( - el: { - isDisplayed(): Promise; - isExisting(): Promise; - 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; - if (!(await el.isExisting().catch(() => false))) return false; +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)) && !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; @@ -271,14 +127,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 +141,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 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'); } } -/** - * 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 +294,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,42 +312,21 @@ 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. - */ -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". - */ -export async function confirmModal(buttonTestId: string, timeoutMs = 5_000) { +/** Tap a modal confirm button. */ +export async function confirmModal(buttonTestId: string) { const btn = await byTestId(buttonTestId); await btn.click(); } @@ -582,18 +344,10 @@ 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); - if (isUnitySDK) await waitForStablePosition(trigger); - await trigger.click(); const expected = await byTestId(expectedTestId); @@ -601,8 +355,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 { @@ -614,49 +367,7 @@ 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); - } -} - -/** - * Add a single tag via 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 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 +381,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,43 +391,23 @@ 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(); 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(); - - // Bridge invalidates WDA / UiAutomator2 caches in OnApplicationFocus(true); - // this poll covers the gap before Unity's player loop dispatches it. - if (isUnitySDK) { - const root = await byTestId('main_scroll_view'); - await root.waitForDisplayed({ timeout: 3_000 }); - } + if (isUnitySDK) await driver.pause(1_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 +415,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 +436,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 +447,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 +461,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,13 +479,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. - const caps = driver.capabilities as Record; - const bundleId = (caps['bundleId'] ?? caps['appium:bundleId']) as string; + // iOS banners live under SpringBoard. await switchToNativeContext(); try { await driver.updateSettings({ defaultActiveApplication: 'com.apple.springboard' }); @@ -834,8 +491,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,26 +506,35 @@ export async function waitForNotification(opts: { ); } - // dismiss the banner + // Dismiss the banner. await banner.click(); } finally { - if (bundleId) { - await driver.updateSettings({ defaultActiveApplication: bundleId }); - } + await driver.updateSettings({ defaultActiveApplication: PACKAGE_ID }); await ensureMainWebViewContext(); } } +/** 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 - // webview goes through flows really quick so need to pause a bit - if (isWebViewSDK) await driver.pause(3_000); await button.click(); await waitForNotification({ title: opts.title, @@ -903,33 +568,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(); @@ -941,74 +592,64 @@ 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()); + return context.includes(PACKAGE_ID); } -function appPackageName(): string { - for (const key of ['appPackage', 'appium:appPackage']) { - const value = Reflect.get(driver.capabilities, key); - if (typeof value === 'string' && value) return value; +async function getIamCandidateContexts(): Promise { + try { + const contexts = await driver.getContexts(); + return contexts.map(contextName).filter(isDefined).filter(isIamCandidateContext); + } catch { + return []; } - return process.env.BUNDLE_ID || 'com.onesignal.example'; } -async function hasVisibleIamContent(expectedTitle?: string): Promise { - const title = $('h1'); - if (!(await title.isExisting().catch(() => false))) return false; - if (!expectedTitle) return true; - return (await title.getText().catch(() => '')) === expectedTitle; +async function switchToContext(context: string): Promise { + try { + await driver.switchContext(context); + return true; + } catch { + return false; + } } -/** - * 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. - */ -async function tapIamTrigger(buttonId: string) { - if (isWebViewSDK) { - await scrollToEl(buttonId); - await clickWebViewTestId(buttonId); - } else { - await (await scrollToEl(buttonId)).click(); - } - if (getPlatform() === 'android' && !isFlutterSDK && !isWebViewSDK) return; +async function switchToWindow(handle: string): Promise { try { - await driver.waitUntil(() => isWebViewVisible(), { timeout: 2_500 }); + await driver.switchToWindow(handle); + return true; } catch { - if (isWebViewSDK) { - await clickWebViewTestId(buttonId); - } else { - await (await byTestId(buttonId)).click(); - } + return false; } } -async function clickWebViewTestId(testId: string) { - await browser.execute((id: string) => { - const button = document.querySelector(`[data-testid="${id}"]`); - button?.click(); - }, testId); +async function hasVisibleIamContent(expectedTitle?: string): Promise { + const title = $('h1'); + if (!(await title.isExisting().catch(() => false))) return false; + if (!expectedTitle) return true; + return (await title.getText().catch(() => '')) === expectedTitle; } 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); @@ -1017,53 +658,18 @@ export async function checkInAppMessage(opts: { closedIamWindowHandles.add(iamWindowHandle); } 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`. - 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. - if (!isWebViewSDK) { - const main = await byTestId('main_scroll_view'); - await main.waitForDisplayed({ timeout: timeoutMs }).catch(() => { - /* best-effort; caller will surface real failure */ - }); - } - } 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( - 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) { @@ -1080,18 +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 browser.pause(delayMs); - } - throw err; - } -} diff --git a/appium/tests/helpers/selectors.ts b/appium/tests/helpers/selectors.ts index 6472869..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'; @@ -150,10 +142,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 +155,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, @@ -186,29 +170,29 @@ 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; }; -// 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'; - if (!isFlutterAndroid && !isUnity) { + if (!isFlutterAndroid) { return el; } @@ -250,28 +234,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 the mapping varies by SDK: - * - 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 (`~`) - * - .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); @@ -280,14 +249,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(); diff --git a/appium/tests/specs/02_push.spec.ts b/appium/tests/specs/02_push.spec.ts index fa5fe0f..9e0eada 100644 --- a/appium/tests/specs/02_push.spec.ts +++ b/appium/tests/specs/02_push.spec.ts @@ -4,7 +4,7 @@ import { checkTooltip, scrollToEl, isBrowserStackIos, - withRetryDelay, + waitForPushId, } from '../helpers/app.js'; import { byTestId, expectToggleState } from '../helpers/selectors.js'; @@ -23,10 +23,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,14 +33,11 @@ describe('Push Subscription', () => { it('can send an image notification', async function () { if (isBrowserStackIos()) this.skip(); - this.retries(2); - await withRetryDelay(this, 5_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, + }); }); }); 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(); diff --git a/demo/build.md b/demo/build.md index ba7259d..d857173 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(...)` with `testTagsAsResourceId = true` | +| iOS (native Swift) | `accessibilityIdentifier`| +| Flutter | `Semantics(identifier:)` | +| React Native | `testID` | +| Capacitor / Cordova | `data-testid` | +| .NET MAUI | `AutomationId` | **Scroll view**: `main_scroll_view` @@ -466,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 | @@ -496,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 | @@ -534,25 +545,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 +561,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 +571,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 +579,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 +606,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}`.