From b4ba01a9c45db8970ea488322d9e74cf346eafb9 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Mon, 12 Oct 2026 16:59:14 -0700 Subject: [PATCH 01/10] test(appium): [SDK-5501] add cold-start and background notification tap tests Co-Authored-By: Claude Opus 5.5 --- appium/README.md | 31 +++---- appium/scripts/README.md | 2 +- appium/tests/helpers/app.ts | 53 ++++++++++++ appium/tests/helpers/selectors.ts | 46 ++++++++++ .../tests/specs/13_notification_click.spec.ts | 85 +++++++++++++++++++ 5 files changed, 201 insertions(+), 16 deletions(-) create mode 100644 appium/tests/specs/13_notification_click.spec.ts diff --git a/appium/README.md b/appium/README.md index 53d7eee..f6c1220 100644 --- a/appium/README.md +++ b/appium/README.md @@ -18,7 +18,7 @@ End-to-end tests for OneSignal mobile SDKs using [Appium](https://appium.io/) an CI (`.github/workflows/appium-e2e.yml`) runs these tests on BrowserStack devices with Node 24 and does **not** use `scripts/run-local.sh` — it calls `vpx wdio run "wdio..conf.ts"` directly. -Notification-dependent tests currently only run locally: `02_push.spec.ts` and `12_activity.spec.ts` mark them with `itSkipBsIos` (`isBrowserStackIos()` from `tests/helpers/app.ts`, true when `BROWSERSTACK_USERNAME` is set and the platform is iOS). BrowserStack requires the app to be built with an Enterprise Signing Certificate for these notification flows, which we don't have yet — a temporary signing limitation, not an inherent device capability limit. The skip helper exists so these tests can be re-enabled once signing support is available. Until then, run `scripts/run-local.sh` on iOS to cover them. +Notification-dependent tests currently only run locally: `02_push.spec.ts`, `12_activity.spec.ts`, and `13_notification_click.spec.ts` mark them with `itSkipBsIos` (`isBrowserStackIos()` from `tests/helpers/app.ts`, true when `BROWSERSTACK_USERNAME` is set and the platform is iOS). BrowserStack requires the app to be built with an Enterprise Signing Certificate for these notification flows, which we don't have yet — a temporary signing limitation, not an inherent device capability limit. The skip helper exists so these tests can be re-enabled once signing support is available. Until then, run `scripts/run-local.sh` on iOS to cover them. ## Directory Structure @@ -54,17 +54,18 @@ cp .env.example .env # add the OneSignal app dedicated to Appium tests Tests are numbered to run in dependency order (user login happens before push/email/SMS tests that require a logged-in user): -| File | Description | -| --------------------- | ------------------------------ | -| `01_user.spec.ts` | Login, logout, anonymous state | -| `02_push.spec.ts` | Push subscription | -| `03_iam.spec.ts` | In-app messaging | -| `04_alias.spec.ts` | Alias operations | -| `05_email.spec.ts` | Email subscription | -| `06_sms.spec.ts` | SMS subscription | -| `07_tag.spec.ts` | Tag operations | -| `08_outcome.spec.ts` | Outcome sending | -| `09_trigger.spec.ts` | Trigger add/remove/clear | -| `10_event.spec.ts` | Custom event tracking | -| `11_location.spec.ts` | Location prompting and sharing | -| `12_activity.spec.ts` | iOS Live Activity lifecycle | +| File | Description | +| ------------------------------- | ----------------------------------------------------------------- | +| `01_user.spec.ts` | Login, logout, anonymous state | +| `02_push.spec.ts` | Push subscription | +| `03_iam.spec.ts` | In-app messaging | +| `04_alias.spec.ts` | Alias operations | +| `05_email.spec.ts` | Email subscription | +| `06_sms.spec.ts` | SMS subscription | +| `07_tag.spec.ts` | Tag operations | +| `08_outcome.spec.ts` | Outcome sending | +| `09_trigger.spec.ts` | Trigger add/remove/clear | +| `10_event.spec.ts` | Custom event tracking | +| `11_location.spec.ts` | Location prompting and sharing | +| `12_activity.spec.ts` | iOS Live Activity lifecycle | +| `13_notification_click.spec.ts` | Notification tap opens the launch URL (cold start and background) | diff --git a/appium/scripts/README.md b/appium/scripts/README.md index 887e676..0cddd25 100644 --- a/appium/scripts/README.md +++ b/appium/scripts/README.md @@ -54,7 +54,7 @@ is on `PATH`. The manual steps below are the same thing, broken out. The script checks all of these up front and prints the exact install command for anything missing; `node_modules` in `appium/` is installed automatically on first run. -> **CI vs local:** CI runs on BrowserStack (Node 24) without this script. Notification-dependent tests (in `02_push.spec.ts` and `12_activity.spec.ts`) are skipped on BrowserStack iOS via `isBrowserStackIos()` because BrowserStack requires an Enterprise Signing Certificate for those notification flows, which we don't have yet (temporary — they'll be re-enabled once signing support is available), so for now they only run locally. If your local Node is 26+, the script sets `WDIO_USE_NATIVE_FETCH=1` automatically. +> **CI vs local:** CI runs on BrowserStack (Node 24) without this script. Notification-dependent tests (in `02_push.spec.ts`, `12_activity.spec.ts`, and `13_notification_click.spec.ts`) are skipped on BrowserStack iOS via `isBrowserStackIos()` because BrowserStack requires an Enterprise Signing Certificate for those notification flows, which we don't have yet (temporary — they'll be re-enabled once signing support is available), so for now they only run locally. If your local Node is 26+, the script sets `WDIO_USE_NATIVE_FETCH=1` automatically. ### Per-SDK prerequisites diff --git a/appium/tests/helpers/app.ts b/appium/tests/helpers/app.ts index f997a5f..84afb3b 100644 --- a/appium/tests/helpers/app.ts +++ b/appium/tests/helpers/app.ts @@ -322,6 +322,59 @@ export async function returnToApp() { await driver.pause(1_000); } +/** Values returned by Appium's `mobile: queryAppState` on both platforms. */ +export const AppState = { + NotRunning: 1, + RunningInBackgroundSuspended: 2, + RunningInBackground: 3, + RunningInForeground: 4, +} as const; + +const appIdArgs = () => + getPlatform() === 'ios' ? { bundleId: PACKAGE_ID } : { appId: PACKAGE_ID }; + +export async function getAppState(): Promise { + return Number(await driver.execute('mobile: queryAppState', appIdArgs())); +} + +export async function terminateApp() { + await driver.execute('mobile: terminateApp', appIdArgs()); +} + +/** Send the app to the background until something else activates it. */ +export async function backgroundApp() { + await driver.execute('mobile: backgroundApp', { seconds: -1 }); +} + +/** Bundle id (iOS) or package (Android) of the app currently in the foreground. */ +export async function getForegroundAppId(): Promise { + if (getPlatform() === 'android') return driver.getCurrentPackage(); + const info = (await driver.execute('mobile: activeAppInfo')) as { bundleId?: string }; + return info.bundleId ?? ''; +} + +/** Tap a notification from the iOS banner or the Android shade, whatever app is in front. */ +export async function tapNotification(title: string, timeoutMs = 30_000) { + await switchToNativeContext(); + + if (getPlatform() === 'android') { + await driver.openNotifications(); + const titleEl = $(`//*[@text="${title}"]`); + await titleEl.waitForDisplayed({ timeout: timeoutMs }); + await titleEl.click(); + return; + } + + await driver.updateSettings({ defaultActiveApplication: 'com.apple.springboard' }); + try { + const banner = $(`-ios predicate string:label CONTAINS "${title}"`); + await banner.waitForDisplayed({ timeout: timeoutMs }); + await banner.click(); + } finally { + await driver.updateSettings({ defaultActiveApplication: PACKAGE_ID }); + } +} + export async function hasLogView(): Promise { return $(byTestIdSelector('log_view')) .isExisting() diff --git a/appium/tests/helpers/selectors.ts b/appium/tests/helpers/selectors.ts index 375da65..642b9f8 100644 --- a/appium/tests/helpers/selectors.ts +++ b/appium/tests/helpers/selectors.ts @@ -155,6 +155,52 @@ export async function deleteUser(externalId: string) { } } +/** Whether the backend has a push token for this subscription and considers it enabled. */ +export async function isPushSubscriptionReady(externalId: string, subscriptionId: string) { + const response = await fetch( + `https://api.onesignal.com/apps/${process.env.ONESIGNAL_APP_ID}/users/by/external_id/${externalId}`, + { headers: { Authorization: `Key ${process.env.ONESIGNAL_API_KEY}` } }, + ).catch(() => null); + if (!response?.ok) return false; + + const user = (await response.json()) as { + subscriptions?: { id: string; enabled?: boolean; token?: string }[]; + }; + const subscription = user.subscriptions?.find((s) => s.id === subscriptionId); + return Boolean(subscription?.enabled && subscription.token); +} + +/** Send a push to one subscription through the REST API. Throws if OneSignal rejects it. */ +export async function sendPushNotification(opts: { + subscriptionId: string; + title: string; + body: string; + url?: string; +}) { + const response = await fetch('https://api.onesignal.com/notifications?c=push', { + method: 'POST', + headers: { + Authorization: `Key ${process.env.ONESIGNAL_API_KEY}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + app_id: process.env.ONESIGNAL_APP_ID, + include_subscription_ids: [opts.subscriptionId], + headings: { en: opts.title }, + contents: { en: opts.body }, + ...(opts.url ? { url: opts.url } : {}), + }), + }); + + // The API can return 200 with an `errors` field and no id (e.g. unsubscribed target). + const result = (await response.json().catch(() => ({}))) as { id?: string; errors?: unknown }; + if (!response.ok || !result.id) { + throw new Error( + `Failed to send push: ${response.status} ${JSON.stringify(result.errors ?? result)}`, + ); + } +} + export async function getToggleState(el: { getAttribute(name: string): Promise; }): Promise { diff --git a/appium/tests/specs/13_notification_click.spec.ts b/appium/tests/specs/13_notification_click.spec.ts new file mode 100644 index 0000000..77d1ab2 --- /dev/null +++ b/appium/tests/specs/13_notification_click.spec.ts @@ -0,0 +1,85 @@ +import { + waitForAppReady, + waitForInitId, + isBrowserStackIos, + tapNotification, + switchToNativeContext, + returnToApp, + getAppState, + terminateApp, + backgroundApp, + getForegroundAppId, + AppState, +} from '../helpers/app.js'; +import { + getPlatform, + getTestExternalId, + isPushSubscriptionReady, + sendPushNotification, +} from '../helpers/selectors.js'; + +const itSkipBsIos = isBrowserStackIos() ? it.skip : it; + +const LAUNCH_URL = 'https://onesignal.com'; +const BROWSERS: Record<'ios' | 'android', RegExp> = { + ios: /^com\.apple\.mobilesafari$/, + android: /chrome|browser/i, +}; + +async function tapPushAndExpectLaunchUrl(subscriptionId: string, title: string) { + await sendPushNotification({ subscriptionId, title, body: 'Tap to open URL', url: LAUNCH_URL }); + await tapNotification(title); + + const browser = BROWSERS[getPlatform()]; + await driver.waitUntil(async () => browser.test(await getForegroundAppId()), { + timeout: 20_000, + timeoutMsg: `Launch URL never opened (foreground app: ${await getForegroundAppId()})`, + }); + // The tap launched (or resumed) the app before the URL took over the foreground. + expect([AppState.RunningInBackgroundSuspended, AppState.RunningInBackground]).toContain( + await getAppState(), + ); +} + +describe('Notification click', () => { + let subscriptionId: string; + + before(async () => { + await waitForAppReady(); + subscriptionId = await waitForInitId(); + + // The app is killed below, so the SDK must have already synced the push + // token; otherwise the backend rejects the send as an invalid target. + const externalId = getTestExternalId(); + await driver.waitUntil(() => isPushSubscriptionReady(externalId, subscriptionId), { + timeout: 60_000, + interval: 2_000, + timeoutMsg: `Push subscription ${subscriptionId} never became sendable`, + }); + }); + + afterEach(async () => { + await returnToApp(); + }); + + // The click reaches the SDK before the wrapper has initialized it. Wrappers + // that initialize late must hold that click and replay it, or the launch + // URL is never opened. iOS only: Appium can only force-stop on Android, + // which drops both incoming FCM messages and posted notifications. + itSkipBsIos('opens the launch URL after a cold-start tap', async function () { + if (getPlatform() !== 'ios') return this.skip(); + + await switchToNativeContext(); + await terminateApp(); + expect(await getAppState()).toBe(AppState.NotRunning); + + await tapPushAndExpectLaunchUrl(subscriptionId, `Cold start ${Date.now()}`); + }); + + itSkipBsIos('opens the launch URL after a background tap', async () => { + await switchToNativeContext(); + await backgroundApp(); + + await tapPushAndExpectLaunchUrl(subscriptionId, `Background ${Date.now()}`); + }); +}); From 703e92adb3cf974543a08b13fac06e1416426ca9 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Mon, 12 Oct 2026 18:35:06 -0700 Subject: [PATCH 02/10] test(appium): [SDK-5501] assert JS click event fires once after tap The launch URL opening only proves the native SDK handled the tap. Also check the demo's log view for exactly one click entry with the sent notification id, which catches missing or duplicate replays in the wrapper. Co-Authored-By: Claude Opus 5.5 --- appium/tests/helpers/selectors.ts | 3 ++- .../tests/specs/13_notification_click.spec.ts | 19 ++++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/appium/tests/helpers/selectors.ts b/appium/tests/helpers/selectors.ts index 642b9f8..89ae432 100644 --- a/appium/tests/helpers/selectors.ts +++ b/appium/tests/helpers/selectors.ts @@ -170,7 +170,7 @@ export async function isPushSubscriptionReady(externalId: string, subscriptionId return Boolean(subscription?.enabled && subscription.token); } -/** Send a push to one subscription through the REST API. Throws if OneSignal rejects it. */ +/** Send a push to one subscription through the REST API and return its notification id. Throws if OneSignal rejects it. */ export async function sendPushNotification(opts: { subscriptionId: string; title: string; @@ -199,6 +199,7 @@ export async function sendPushNotification(opts: { `Failed to send push: ${response.status} ${JSON.stringify(result.errors ?? result)}`, ); } + return result.id; } export async function getToggleState(el: { diff --git a/appium/tests/specs/13_notification_click.spec.ts b/appium/tests/specs/13_notification_click.spec.ts index 77d1ab2..abd23d2 100644 --- a/appium/tests/specs/13_notification_click.spec.ts +++ b/appium/tests/specs/13_notification_click.spec.ts @@ -9,6 +9,8 @@ import { terminateApp, backgroundApp, getForegroundAppId, + hasLogView, + expectOneNewLog, AppState, } from '../helpers/app.js'; import { @@ -27,7 +29,12 @@ const BROWSERS: Record<'ios' | 'android', RegExp> = { }; async function tapPushAndExpectLaunchUrl(subscriptionId: string, title: string) { - await sendPushNotification({ subscriptionId, title, body: 'Tap to open URL', url: LAUNCH_URL }); + const notificationId = await sendPushNotification({ + subscriptionId, + title, + body: 'Tap to open URL', + url: LAUNCH_URL, + }); await tapNotification(title); const browser = BROWSERS[getPlatform()]; @@ -39,6 +46,16 @@ async function tapPushAndExpectLaunchUrl(subscriptionId: string, title: string) expect([AppState.RunningInBackgroundSuspended, AppState.RunningInBackground]).toContain( await getAppState(), ); + + await expectSingleClickEvent(notificationId); +} + +/** The JS click listener must fire exactly once for the tapped notification. */ +async function expectSingleClickEvent(notificationId: string) { + await returnToApp(); + // Demos without the log view can't report JS events yet. + if (!(await hasLogView())) return; + await expectOneNewLog([`Notification click: ${notificationId}`]); } describe('Notification click', () => { From 101c2901c081651b3c04c2215390bf30e792cf56 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 13 Oct 2026 11:06:17 -0700 Subject: [PATCH 03/10] test(appium): [SDK-5501] skip Chrome first run and reject it as a launch URL Co-Authored-By: Claude Opus 5.5 --- appium/scripts/run-local/runtime.sh | 11 +++++++++++ appium/tests/helpers/app.ts | 6 ++++++ .../tests/specs/13_notification_click.spec.ts | 17 +++++++++++++---- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/appium/scripts/run-local/runtime.sh b/appium/scripts/run-local/runtime.sh index e77c4a6..6708132 100644 --- a/appium/scripts/run-local/runtime.sh +++ b/appium/scripts/run-local/runtime.sh @@ -290,6 +290,16 @@ cleanup_android_automation() { adb -s "$ANDROID_UDID" shell am force-stop io.appium.uiautomator2.server.test >/dev/null 2>&1 || true } +# Chrome's first-run screen would otherwise replace any URL the tests open. +skip_chrome_first_run() { + [[ "$PLATFORM" == "android" ]] || return 0 + adb -s "$ANDROID_UDID" shell \ + 'echo "_ --disable-fre --no-default-browser-check --no-first-run" > /data/local/tmp/chrome-command-line' \ + >/dev/null 2>&1 || true + # Chrome only reads that file on non-rooted builds when it is the debug app. + adb -s "$ANDROID_UDID" shell am set-debug-app --persistent com.android.chrome >/dev/null 2>&1 || true +} + reset_app() { if [[ "$SKIP_RESET" == true ]]; then info "Skipping app reset (--skip-reset)" @@ -350,5 +360,6 @@ prepare_runtime() { install_chromedriver_if_needed start_appium cleanup_android_automation + skip_chrome_first_run reset_app } diff --git a/appium/tests/helpers/app.ts b/appium/tests/helpers/app.ts index 84afb3b..2995249 100644 --- a/appium/tests/helpers/app.ts +++ b/appium/tests/helpers/app.ts @@ -353,6 +353,12 @@ export async function getForegroundAppId(): Promise { return info.bundleId ?? ''; } +/** Android activity currently in the foreground; empty on iOS. */ +export async function getForegroundActivity(): Promise { + if (getPlatform() !== 'android') return ''; + return driver.getCurrentActivity(); +} + /** Tap a notification from the iOS banner or the Android shade, whatever app is in front. */ export async function tapNotification(title: string, timeoutMs = 30_000) { await switchToNativeContext(); diff --git a/appium/tests/specs/13_notification_click.spec.ts b/appium/tests/specs/13_notification_click.spec.ts index abd23d2..25cf1fb 100644 --- a/appium/tests/specs/13_notification_click.spec.ts +++ b/appium/tests/specs/13_notification_click.spec.ts @@ -9,6 +9,7 @@ import { terminateApp, backgroundApp, getForegroundAppId, + getForegroundActivity, hasLogView, expectOneNewLog, AppState, @@ -38,10 +39,13 @@ async function tapPushAndExpectLaunchUrl(subscriptionId: string, title: string) await tapNotification(title); const browser = BROWSERS[getPlatform()]; - await driver.waitUntil(async () => browser.test(await getForegroundAppId()), { - timeout: 20_000, - timeoutMsg: `Launch URL never opened (foreground app: ${await getForegroundAppId()})`, - }); + await driver.waitUntil( + async () => browser.test(await getForegroundAppId()) && !(await isBrowserFirstRun()), + { + timeout: 20_000, + timeoutMsg: `Launch URL never opened (foreground: ${await getForegroundAppId()} ${await getForegroundActivity()})`, + }, + ); // The tap launched (or resumed) the app before the URL took over the foreground. expect([AppState.RunningInBackgroundSuspended, AppState.RunningInBackground]).toContain( await getAppState(), @@ -50,6 +54,11 @@ async function tapPushAndExpectLaunchUrl(subscriptionId: string, title: string) await expectSingleClickEvent(notificationId); } +/** A browser setup screen (e.g. Chrome's FirstRunActivity) is showing instead of the URL. */ +async function isBrowserFirstRun() { + return getPlatform() === 'android' && /firstrun/i.test(await getForegroundActivity()); +} + /** The JS click listener must fire exactly once for the tapped notification. */ async function expectSingleClickEvent(notificationId: string) { await returnToApp(); From e35134eeec06612163672569e25e571fc62842ef Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 13 Oct 2026 13:32:18 -0700 Subject: [PATCH 04/10] test(appium): add killApp for recents-swipe termination --- appium/tests/helpers/app.ts | 61 ++++++++++++++++++- .../tests/specs/13_notification_click.spec.ts | 12 ++-- 2 files changed, 62 insertions(+), 11 deletions(-) diff --git a/appium/tests/helpers/app.ts b/appium/tests/helpers/app.ts index 2995249..9635a26 100644 --- a/appium/tests/helpers/app.ts +++ b/appium/tests/helpers/app.ts @@ -154,7 +154,7 @@ export async function ensureMainWebViewContext() { await driver.waitUntil( async () => { const contexts = (await driver.getContexts()).map(contextName).filter(isDefined); - const webview = contexts.find((c) => c !== 'NATIVE_APP'); + const webview = contexts.find(isAppWebViewContext); if (!webview) return false; const current = contextName(await driver.getContext()); if (current !== webview) { @@ -166,6 +166,12 @@ export async function ensureMainWebViewContext() { ); } +/** Android also lists other apps' WebViews, e.g. Chrome after a launch URL opened it. */ +function isAppWebViewContext(context: string): boolean { + if (context === 'NATIVE_APP') return false; + return getPlatform() !== 'android' || context.includes(PACKAGE_ID); +} + function contextName(context: unknown): string | undefined { if (typeof context === 'string') return context; if (typeof context === 'object' && context !== null) { @@ -311,8 +317,11 @@ export async function returnToApp() { const platform = getPlatform(); if (platform === 'android') { - await driver.pressKeyCode(4); - await driver.execute('mobile: activateApp', { appId: PACKAGE_ID }); + // Back would background the app itself if nothing is covering it. + if ((await getAppState()) !== AppState.RunningInForeground) { + await driver.pressKeyCode(4); + await driver.execute('mobile: activateApp', { appId: PACKAGE_ID }); + } } else { await driver.updateSettings({ defaultActiveApplication: PACKAGE_ID }); await driver.execute('mobile: activateApp', { bundleId: PACKAGE_ID }); @@ -341,6 +350,52 @@ export async function terminateApp() { await driver.execute('mobile: terminateApp', appIdArgs()); } +/** + * End the app's process the way a user swiping it away from recents does. + * Android's terminateApp is a force-stop, which blocks FCM delivery and clears + * posted notifications until the next manual launch, and BrowserStack rejects + * the shell commands that could kill it otherwise. + */ +export async function killApp() { + if (getPlatform() === 'ios') { + await terminateApp(); + } else { + await swipeAwayFromRecents(); + } + await driver.waitUntil(async () => (await getAppState()) === AppState.NotRunning, { + timeout: 10_000, + interval: 200, + timeoutMsg: 'App process is still running after being killed', + }); + if (getPlatform() === 'android') await driver.pressKeyCode(3); // KEYCODE_HOME +} + +async function swipeAwayFromRecents() { + // UiAutomator2 otherwise blocks each action until the launcher's recents + // animations go idle, which takes seconds. + const { waitForIdleTimeout } = (await driver.getSettings()) as { waitForIdleTimeout?: number }; + await driver.updateSettings({ waitForIdleTimeout: 0 }); + try { + await driver.pressKeyCode(187); // KEYCODE_APP_SWITCH, with this app as the focused card + await driver.waitUntil(async () => (await driver.getCurrentPackage()) !== PACKAGE_ID, { + timeout: 5_000, + interval: 100, + timeoutMsg: 'Recents never opened', + }); + // The launcher takes focus before the card finishes animating into place. + await driver.pause(500); + const { width, height } = await driver.getWindowSize(); + const x = Math.round(width / 2); + await driver.swipe({ + from: { x, y: Math.round(height * 0.55) }, + to: { x, y: Math.round(height * 0.05) }, + duration: 200, + }); + } finally { + await driver.updateSettings({ waitForIdleTimeout: waitForIdleTimeout ?? 10_000 }); + } +} + /** Send the app to the background until something else activates it. */ export async function backgroundApp() { await driver.execute('mobile: backgroundApp', { seconds: -1 }); diff --git a/appium/tests/specs/13_notification_click.spec.ts b/appium/tests/specs/13_notification_click.spec.ts index 25cf1fb..2c976f9 100644 --- a/appium/tests/specs/13_notification_click.spec.ts +++ b/appium/tests/specs/13_notification_click.spec.ts @@ -6,7 +6,7 @@ import { switchToNativeContext, returnToApp, getAppState, - terminateApp, + killApp, backgroundApp, getForegroundAppId, getForegroundActivity, @@ -90,14 +90,10 @@ describe('Notification click', () => { // The click reaches the SDK before the wrapper has initialized it. Wrappers // that initialize late must hold that click and replay it, or the launch - // URL is never opened. iOS only: Appium can only force-stop on Android, - // which drops both incoming FCM messages and posted notifications. - itSkipBsIos('opens the launch URL after a cold-start tap', async function () { - if (getPlatform() !== 'ios') return this.skip(); - + // URL is never opened. + itSkipBsIos('opens the launch URL after a cold-start tap', async () => { await switchToNativeContext(); - await terminateApp(); - expect(await getAppState()).toBe(AppState.NotRunning); + await killApp(); await tapPushAndExpectLaunchUrl(subscriptionId, `Cold start ${Date.now()}`); }); From 2796eb290a3de17d417a7cddd65f98d48f5eed9f Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 13 Oct 2026 13:40:22 -0700 Subject: [PATCH 05/10] test(appium): remove app-state check --- appium/tests/specs/13_notification_click.spec.ts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/appium/tests/specs/13_notification_click.spec.ts b/appium/tests/specs/13_notification_click.spec.ts index 2c976f9..4de1f1f 100644 --- a/appium/tests/specs/13_notification_click.spec.ts +++ b/appium/tests/specs/13_notification_click.spec.ts @@ -5,14 +5,12 @@ import { tapNotification, switchToNativeContext, returnToApp, - getAppState, killApp, backgroundApp, getForegroundAppId, getForegroundActivity, hasLogView, expectOneNewLog, - AppState, } from '../helpers/app.js'; import { getPlatform, @@ -41,14 +39,7 @@ async function tapPushAndExpectLaunchUrl(subscriptionId: string, title: string) const browser = BROWSERS[getPlatform()]; await driver.waitUntil( async () => browser.test(await getForegroundAppId()) && !(await isBrowserFirstRun()), - { - timeout: 20_000, - timeoutMsg: `Launch URL never opened (foreground: ${await getForegroundAppId()} ${await getForegroundActivity()})`, - }, - ); - // The tap launched (or resumed) the app before the URL took over the foreground. - expect([AppState.RunningInBackgroundSuspended, AppState.RunningInBackground]).toContain( - await getAppState(), + { timeout: 20_000, timeoutMsg: 'Launch URL never opened' }, ); await expectSingleClickEvent(notificationId); From 1c7eaf00d2fea16eb74e83c3e491424608517d77 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 13 Oct 2026 13:48:56 -0700 Subject: [PATCH 06/10] test(appium): check address bar instead of app state --- appium/tests/helpers/app.ts | 13 ------- .../tests/specs/13_notification_click.spec.ts | 34 +++++++++++-------- 2 files changed, 20 insertions(+), 27 deletions(-) diff --git a/appium/tests/helpers/app.ts b/appium/tests/helpers/app.ts index 9635a26..c57cc9d 100644 --- a/appium/tests/helpers/app.ts +++ b/appium/tests/helpers/app.ts @@ -401,19 +401,6 @@ export async function backgroundApp() { await driver.execute('mobile: backgroundApp', { seconds: -1 }); } -/** Bundle id (iOS) or package (Android) of the app currently in the foreground. */ -export async function getForegroundAppId(): Promise { - if (getPlatform() === 'android') return driver.getCurrentPackage(); - const info = (await driver.execute('mobile: activeAppInfo')) as { bundleId?: string }; - return info.bundleId ?? ''; -} - -/** Android activity currently in the foreground; empty on iOS. */ -export async function getForegroundActivity(): Promise { - if (getPlatform() !== 'android') return ''; - return driver.getCurrentActivity(); -} - /** Tap a notification from the iOS banner or the Android shade, whatever app is in front. */ export async function tapNotification(title: string, timeoutMs = 30_000) { await switchToNativeContext(); diff --git a/appium/tests/specs/13_notification_click.spec.ts b/appium/tests/specs/13_notification_click.spec.ts index 4de1f1f..f76124c 100644 --- a/appium/tests/specs/13_notification_click.spec.ts +++ b/appium/tests/specs/13_notification_click.spec.ts @@ -7,8 +7,6 @@ import { returnToApp, killApp, backgroundApp, - getForegroundAppId, - getForegroundActivity, hasLogView, expectOneNewLog, } from '../helpers/app.js'; @@ -21,11 +19,15 @@ import { const itSkipBsIos = isBrowserStackIos() ? it.skip : it; -const LAUNCH_URL = 'https://onesignal.com'; -const BROWSERS: Record<'ios' | 'android', RegExp> = { - ios: /^com\.apple\.mobilesafari$/, - android: /chrome|browser/i, +const LAUNCH_URL_HOST = 'onesignal.com'; +const LAUNCH_URL = `https://${LAUNCH_URL_HOST}`; +const ADDRESS_BARS: Record<'ios' | 'android', string> = { + ios: '-ios predicate string:name == "TabBarItemTitle"', + android: 'android=new UiSelector().resourceId("com.android.chrome:id/url_bar")', }; +// Chrome asks for notification permission on its first launch, covering the address bar. +const CHROME_DIALOG_DISMISS = + 'android=new UiSelector().resourceId("com.android.chrome:id/negative_button")'; async function tapPushAndExpectLaunchUrl(subscriptionId: string, title: string) { const notificationId = await sendPushNotification({ @@ -36,18 +38,22 @@ async function tapPushAndExpectLaunchUrl(subscriptionId: string, title: string) }); await tapNotification(title); - const browser = BROWSERS[getPlatform()]; - await driver.waitUntil( - async () => browser.test(await getForegroundAppId()) && !(await isBrowserFirstRun()), - { timeout: 20_000, timeoutMsg: 'Launch URL never opened' }, - ); + await driver.waitUntil(async () => (await getBrowserAddress()).includes(LAUNCH_URL_HOST), { + timeout: 20_000, + timeoutMsg: 'Launch URL never opened', + }); await expectSingleClickEvent(notificationId); } -/** A browser setup screen (e.g. Chrome's FirstRunActivity) is showing instead of the URL. */ -async function isBrowserFirstRun() { - return getPlatform() === 'android' && /firstrun/i.test(await getForegroundActivity()); +/** Address shown by the foreground browser, or empty if no browser address bar is showing. */ +async function getBrowserAddress(): Promise { + if (getPlatform() === 'android') { + const dismiss = $(CHROME_DIALOG_DISMISS); + if (await dismiss.isExisting()) await dismiss.click(); + } + const addressBar = $(ADDRESS_BARS[getPlatform()]); + return (await addressBar.isExisting()) ? addressBar.getText() : ''; } /** The JS click listener must fire exactly once for the tapped notification. */ From 8dadd9f5a7200c91a69433208053bef0503c6712 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 13 Oct 2026 13:54:30 -0700 Subject: [PATCH 07/10] test(appium): restore Chrome debug-app state on exit --- appium/scripts/run-local.sh | 2 +- appium/scripts/run-local/runtime.sh | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/appium/scripts/run-local.sh b/appium/scripts/run-local.sh index 38059f6..8000782 100755 --- a/appium/scripts/run-local.sh +++ b/appium/scripts/run-local.sh @@ -20,7 +20,7 @@ source "$RUN_LOCAL_LIB_DIR/build.sh" source "$RUN_LOCAL_LIB_DIR/runtime.sh" restore_demo_files -trap restore_demo_files EXIT +trap 'restore_demo_files; restore_chrome_first_run' EXIT trap 'exit 130' INT trap 'exit 143' TERM diff --git a/appium/scripts/run-local/runtime.sh b/appium/scripts/run-local/runtime.sh index 6708132..092d4d3 100644 --- a/appium/scripts/run-local/runtime.sh +++ b/appium/scripts/run-local/runtime.sh @@ -297,7 +297,13 @@ skip_chrome_first_run() { 'echo "_ --disable-fre --no-default-browser-check --no-first-run" > /data/local/tmp/chrome-command-line' \ >/dev/null 2>&1 || true # Chrome only reads that file on non-rooted builds when it is the debug app. - adb -s "$ANDROID_UDID" shell am set-debug-app --persistent com.android.chrome >/dev/null 2>&1 || true + adb -s "$ANDROID_UDID" shell am set-debug-app com.android.chrome >/dev/null 2>&1 || true +} + +restore_chrome_first_run() { + [[ "${PLATFORM:-}" == "android" && -n "${ANDROID_UDID:-}" ]] || return 0 + adb -s "$ANDROID_UDID" shell am clear-debug-app >/dev/null 2>&1 || true + adb -s "$ANDROID_UDID" shell rm -f /data/local/tmp/chrome-command-line >/dev/null 2>&1 || true } reset_app() { From 6e33c1a339f9000508d9cef9eb3534043d34e70f Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 13 Oct 2026 13:59:25 -0700 Subject: [PATCH 08/10] docs(appium): note log counts only see on-screen Android entries Co-Authored-By: Claude Opus 5.5 --- appium/tests/helpers/app.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/appium/tests/helpers/app.ts b/appium/tests/helpers/app.ts index c57cc9d..de96311 100644 --- a/appium/tests/helpers/app.ts +++ b/appium/tests/helpers/app.ts @@ -429,7 +429,10 @@ export async function hasLogView(): Promise { .catch(() => false); } -/** Text of every entry in the demo's on-screen log view, oldest first. */ +/** + * Text of the demo's log entries, oldest first. Native Android only sees + * entries on screen, so counts can miss older matches that scrolled away. + */ export async function getLogMessages(): Promise { if (isWebViewSDK) { return driver.execute(() => From f1bcae73d309feeef1ddfb2756b84c3cb317781a Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 13 Oct 2026 13:59:25 -0700 Subject: [PATCH 09/10] test(appium): close notification shade when tap target never appears Co-Authored-By: Claude Opus 5.5 --- appium/tests/helpers/app.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/appium/tests/helpers/app.ts b/appium/tests/helpers/app.ts index de96311..342d874 100644 --- a/appium/tests/helpers/app.ts +++ b/appium/tests/helpers/app.ts @@ -408,7 +408,12 @@ export async function tapNotification(title: string, timeoutMs = 30_000) { if (getPlatform() === 'android') { await driver.openNotifications(); const titleEl = $(`//*[@text="${title}"]`); - await titleEl.waitForDisplayed({ timeout: timeoutMs }); + try { + await titleEl.waitForDisplayed({ timeout: timeoutMs }); + } catch (error) { + await driver.pressKeyCode(4); // KEYCODE_BACK, closes the shade + throw error; + } await titleEl.click(); return; } From 3f89102be64c77540d947b992360add4b99ba157 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 13 Oct 2026 15:51:12 -0700 Subject: [PATCH 10/10] perf(appium): cache push ID to skip re-scroll --- appium/tests/helpers/app.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/appium/tests/helpers/app.ts b/appium/tests/helpers/app.ts index 342d874..f546917 100644 --- a/appium/tests/helpers/app.ts +++ b/appium/tests/helpers/app.ts @@ -589,6 +589,10 @@ export async function waitForNotification(opts: { /** Wait for the push ID to be populated. */ export async function waitForInitId(timeoutMs = 30_000): Promise { + // Later specs start scrolled far down, and scrolling back up to read it is slow. + const cached = (await getValue('pushId')) as string | undefined; + if (cached) return cached; + const pushIdEl = await scrollToEl('push_id_value', { direction: 'up' }); await driver.waitUntil( async () => { @@ -597,7 +601,9 @@ export async function waitForInitId(timeoutMs = 30_000): Promise { }, { timeout: timeoutMs, timeoutMsg: 'Notifications not ready.' }, ); - return (await pushIdEl.getText()).trim(); + const pushId = (await pushIdEl.getText()).trim(); + await setValue('pushId', pushId); + return pushId; } export async function checkNotification(opts: {