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/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 e77c4a6..092d4d3 100644 --- a/appium/scripts/run-local/runtime.sh +++ b/appium/scripts/run-local/runtime.sh @@ -290,6 +290,22 @@ 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 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() { if [[ "$SKIP_RESET" == true ]]; then info "Skipping app reset (--skip-reset)" @@ -350,5 +366,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 f997a5f..f546917 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 }); @@ -322,13 +331,113 @@ 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()); +} + +/** + * 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 }); +} + +/** 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}"]`); + try { + await titleEl.waitForDisplayed({ timeout: timeoutMs }); + } catch (error) { + await driver.pressKeyCode(4); // KEYCODE_BACK, closes the shade + throw error; + } + 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() .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(() => @@ -480,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 () => { @@ -488,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: { diff --git a/appium/tests/helpers/selectors.ts b/appium/tests/helpers/selectors.ts index 375da65..89ae432 100644 --- a/appium/tests/helpers/selectors.ts +++ b/appium/tests/helpers/selectors.ts @@ -155,6 +155,53 @@ 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 and return its notification id. 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)}`, + ); + } + return result.id; +} + 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..f76124c --- /dev/null +++ b/appium/tests/specs/13_notification_click.spec.ts @@ -0,0 +1,104 @@ +import { + waitForAppReady, + waitForInitId, + isBrowserStackIos, + tapNotification, + switchToNativeContext, + returnToApp, + killApp, + backgroundApp, + hasLogView, + expectOneNewLog, +} from '../helpers/app.js'; +import { + getPlatform, + getTestExternalId, + isPushSubscriptionReady, + sendPushNotification, +} from '../helpers/selectors.js'; + +const itSkipBsIos = isBrowserStackIos() ? it.skip : it; + +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({ + subscriptionId, + title, + body: 'Tap to open URL', + url: LAUNCH_URL, + }); + await tapNotification(title); + + await driver.waitUntil(async () => (await getBrowserAddress()).includes(LAUNCH_URL_HOST), { + timeout: 20_000, + timeoutMsg: 'Launch URL never opened', + }); + + await expectSingleClickEvent(notificationId); +} + +/** 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. */ +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', () => { + 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. + itSkipBsIos('opens the launch URL after a cold-start tap', async () => { + await switchToNativeContext(); + await killApp(); + + 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()}`); + }); +});