Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 16 additions & 15 deletions appium/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<platform>.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

Expand Down Expand Up @@ -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) |
2 changes: 1 addition & 1 deletion appium/scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion appium/scripts/run-local.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
17 changes: 17 additions & 0 deletions appium/scripts/run-local/runtime.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down Expand Up @@ -350,5 +366,6 @@ prepare_runtime() {
install_chromedriver_if_needed
start_appium
cleanup_android_automation
skip_chrome_first_run
reset_app
}
125 changes: 120 additions & 5 deletions appium/tests/helpers/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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 });
Expand All @@ -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<number> {
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;
Comment thread
fadi-george marked this conversation as resolved.
}

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<boolean> {
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<string[]> {
if (isWebViewSDK) {
return driver.execute(() =>
Expand Down Expand Up @@ -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<string> {
// 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 () => {
Expand All @@ -488,7 +601,9 @@ export async function waitForInitId(timeoutMs = 30_000): Promise<string> {
},
{ 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: {
Expand Down
47 changes: 47 additions & 0 deletions appium/tests/helpers/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>;
}): Promise<boolean> {
Expand Down
Loading
Loading