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
503 changes: 293 additions & 210 deletions appium/bun.lock

Large diffs are not rendered by default.

27 changes: 13 additions & 14 deletions appium/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,20 @@
"test:ios": "wdio run wdio.ios.conf.ts"
},
"devDependencies": {
"@types/node": "^25.6.2",
"@wdio/browserstack-service": "^9.27.1",
"@wdio/cli": "^9.27.1",
"@wdio/junit-reporter": "^9.27.1",
"@wdio/local-runner": "^9.27.1",
"@wdio/mocha-framework": "^9.27.1",
"@wdio/shared-store-service": "^9.27.0",
"@wdio/spec-reporter": "^9.27.1",
"typescript": "^6.0.3",
"vite-plus": "0.1.22",
"webdriverio": "^9.27.1"
"@types/node": "^26.6.5",
"@wdio/browserstack-service": "^9.40.0",
"@wdio/cli": "^9.32.0",
"@wdio/junit-reporter": "^9.32.0",
"@wdio/local-runner": "^9.32.0",
"@wdio/mocha-framework": "^9.32.0",
"@wdio/shared-store-service": "^9.32.0",
"@wdio/spec-reporter": "^9.32.0",
"typescript": "^7.0.2",
"vite-plus": "1.3.0",
"webdriverio": "^9.32.0"
},
"overrides": {
"vite": "npm:@voidzero-dev/vite-plus-core@0.1.22",
"vitest": "npm:@voidzero-dev/vite-plus-test@0.1.22"
"vite": "npm:@voidzero-dev/vite-plus-core@1.3.0"
},
"packageManager": "bun@1.3.14"
"packageManager": "bun@1.4.3"
}
68 changes: 34 additions & 34 deletions appium/scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,19 +75,19 @@ If `--platform` or `--sdk` are not provided, the script prompts interactively.

### Options

| Flag | Description |
| --------------- | ----------------------------------------------------------- |
| `--platform=P` | `ios` or `android` |
| Flag | Description |
| --------------- | ---------------------------------------------------------------------------------------------- |
| `--platform=P` | `ios` or `android` |
| `--sdk=S` | `flutter`, `react-native`, `cordova`, `capacitor`, `expo`, `dotnet`, `unity`, `android`, `ios` |
| `--spec=GLOB` | Spec file glob (default: `tests/specs/**/*.spec.ts`) |
| `--skip` | Skip build, device launch, and app reset (rerun tests only) |
| `--skip-build` | Skip app build (reuse existing `.app`/`.apk`) |
| `--skip-device` | Skip simulator/emulator launch |
| `--skip-reset` | Keep existing app data between runs |
| `--pods` | Use `examples/demo-pods` for Flutter, Cordova, and Capacitor |
| `--release` | Check out the latest release point in each SDK repo first |
| `--main` | Check out the latest `main` in each SDK repo first |
| `-h, --help` | Show help |
| `--spec=GLOB` | Spec file glob (default: `tests/specs/**/*.spec.ts`) |
| `--skip` | Skip build, device launch, and app reset (rerun tests only) |
| `--skip-build` | Skip app build (reuse existing `.app`/`.apk`) |
| `--skip-device` | Skip simulator/emulator launch |
| `--skip-reset` | Keep existing app data between runs |
| `--pods` | Use `examples/demo-pods` for Flutter, Cordova, and Capacitor |
| `--release` | Check out the latest release point in each SDK repo first |
| `--main` | Check out the latest `main` in each SDK repo first |
| `-h, --help` | Show help |

### Examples

Expand Down Expand Up @@ -165,28 +165,28 @@ SDK repo paths (`*_DIR`) resolve in this order:

Credential files the runner writes into demo repos (`.env`, Unity's `StreamingAssets/.env`, iOS `Secrets.plist`) are backed up to `appium/.demo-backups/` and restored when the run exits, pass or fail. If a run is killed hard, the next run restores them first.

| Variable | Default | Description |
| ------------------- | ---------------------------------- | --------------------------------------------- |
| `ONESIGNAL_APP_ID` | -- | OneSignal app ID (written to demo app `.env`) |
| `ONESIGNAL_API_KEY` | -- | OneSignal REST API key |
| `FLUTTER_DIR` | `../../OneSignal-Flutter-SDK` | Path to the Flutter SDK repo |
| `RN_DIR` | `../../react-native-onesignal` | React Native SDK repo |
| `CORDOVA_DIR` | `../../OneSignal-Cordova-SDK` | Cordova SDK repo |
| `CAPACITOR_DIR` | `../../OneSignal-Capacitor-SDK` | Capacitor SDK repo |
| `EXPO_DIR` | `../../onesignal-expo-plugin` | Expo plugin repo |
| `DOTNET_DIR` | `../../DotNet/OneSignal-DotNet-SDK`| .NET MAUI SDK repo |
| `UNITY_DIR` | `../../OneSignal-Unity-SDK` | Unity SDK repo |
| `ANDROID_DIR` | `../../OneSignal-Android-SDK` | Native Android SDK repo |
| `IOS_DIR` | `../../OneSignal-iOS-SDK` | Native iOS SDK repo |
| `UNITY_PATH` | Unity Hub editor path | Unity Editor binary (unity builds) |
| `APP_PATH` | auto-detected from build | Path to `.app` or `.apk` |
| `BUNDLE_ID` | `com.onesignal.example` | App bundle/package ID |
| `DEVICE` | `iPhone 17` / `Android 17` | Device name for WebdriverIO |
| `OS_VERSION` | `26.5` / `17` | Platform version |
| `IOS_SIMULATOR` | same as `DEVICE` | Simulator name for `simctl` |
| `IOS_RUNTIME` | `iOS-26-5` | simctl runtime identifier |
| `AVD_NAME` | `Samsung_Galaxy_S26` | Android AVD name |
| `APPIUM_PORT` | `4723` | Appium server port |
| Variable | Default | Description |
| ------------------- | ----------------------------------- | --------------------------------------------- |
| `ONESIGNAL_APP_ID` | -- | OneSignal app ID (written to demo app `.env`) |
| `ONESIGNAL_API_KEY` | -- | OneSignal REST API key |
| `FLUTTER_DIR` | `../../OneSignal-Flutter-SDK` | Path to the Flutter SDK repo |
| `RN_DIR` | `../../react-native-onesignal` | React Native SDK repo |
| `CORDOVA_DIR` | `../../OneSignal-Cordova-SDK` | Cordova SDK repo |
| `CAPACITOR_DIR` | `../../OneSignal-Capacitor-SDK` | Capacitor SDK repo |
| `EXPO_DIR` | `../../onesignal-expo-plugin` | Expo plugin repo |
| `DOTNET_DIR` | `../../DotNet/OneSignal-DotNet-SDK` | .NET MAUI SDK repo |
| `UNITY_DIR` | `../../OneSignal-Unity-SDK` | Unity SDK repo |
| `ANDROID_DIR` | `../../OneSignal-Android-SDK` | Native Android SDK repo |
| `IOS_DIR` | `../../OneSignal-iOS-SDK` | Native iOS SDK repo |
| `UNITY_PATH` | Unity Hub editor path | Unity Editor binary (unity builds) |
| `APP_PATH` | auto-detected from build | Path to `.app` or `.apk` |
| `BUNDLE_ID` | `com.onesignal.example` | App bundle/package ID |
| `DEVICE` | `iPhone 17` / `Android 17` | Device name for WebdriverIO |
| `OS_VERSION` | `26.5` / `17` | Platform version |
| `IOS_SIMULATOR` | same as `DEVICE` | Simulator name for `simctl` |
| `IOS_RUNTIME` | `iOS-26-5` | simctl runtime identifier |
| `AVD_NAME` | `Samsung_Galaxy_S26` | Android AVD name |
| `APPIUM_PORT` | `4723` | Appium server port |

## Troubleshooting

Expand Down
54 changes: 53 additions & 1 deletion appium/tests/helpers/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
getPlatform,
getSdkType,
getTestExternalId,
waitForBackendUser,
waitForPushSubscriptionReady,
} from './selectors.js';

Expand Down Expand Up @@ -261,6 +262,19 @@ export async function logoutUser() {
await logoutButton.click();
}

/** Type into an input and confirm it stuck; typing right after a modal opens can be dropped. */
export async function fillInput(testId: string, value: string) {
const input = await byTestId(testId);
await input.waitForDisplayed({ timeout: 5_000 });
await driver.waitUntil(
async () => {
await input.setValue(value);
return (await input.getValue()) === value;
},
{ timeout: 10_000, interval: 500, timeoutMsg: `Input "${testId}" did not keep "${value}"` },
);
}

/** Tap a modal confirm button. */
export async function confirmModal(buttonTestId: string) {
const btn = await byTestId(buttonTestId);
Expand Down Expand Up @@ -469,6 +483,44 @@ export async function countLogMessages(...parts: string[]): Promise<number> {
return messages.filter((message) => parts.every((part) => message.includes(part))).length;
}

const USER_CHANGED_LOG = /User changed: onesignalId=([^,\s]+), externalId=(\S+)/;

/** onesignalId from the newest "User changed" log if it's for `externalId` (null = anonymous). */
async function latestUserChange(externalId: string | null) {
const messages = await getLogMessages().catch(() => []);
for (let i = messages.length - 1; i >= 0; i--) {
const match = USER_CHANGED_LOG.exec(messages[i]);
if (!match) continue;
return match[1] !== 'null' && match[2] === (externalId ?? 'null') ? match[1] : undefined;
}
return undefined;
}

/**
* Wait for a login/logout to settle: the demo logs a "User changed" for `externalId` (null = anonymous),
* then the backend has that user. Settling first avoids racing the SDK's user switch on the next call
* (e.g. a fast logout then login on iOS never fires the user observer). Demos without that log
* just get `timeoutMs` as a grace period.
*/
export async function waitForIdentityChange(
{ externalId }: { externalId: string | null },
timeoutMs = 10_000,
) {
let onesignalId: string | undefined;
await driver
.waitUntil(async () => Boolean((onesignalId = await latestUserChange(externalId))), {
timeout: timeoutMs,
interval: 500,
})
.catch(() => undefined);
if (!onesignalId) {
console.info(`No "User changed" log for external_id ${externalId ?? 'null'}`);
return undefined;
}
await waitForBackendUser(onesignalId, externalId);
return onesignalId;
}

/** Expect exactly one new log entry containing every one of `parts` beyond `baseline`. */
export async function expectOneNewLog(parts: string[], baseline = 0, timeoutMs = 15_000) {
await driver.waitUntil(async () => (await countLogMessages(...parts)) > baseline, {
Expand Down Expand Up @@ -707,7 +759,7 @@ async function hasVisibleIamContent(expectedTitle?: string): Promise<boolean> {
.execute((titleText?: string) => {
const title = document.querySelector('h1');
const isVisible = Boolean(title?.getClientRects().length);
return isVisible && (!titleText || title.textContent === titleText);
return isVisible && (!titleText || title?.textContent === titleText);
}, expectedTitle)
.catch(() => false);
}
Expand Down
35 changes: 35 additions & 0 deletions appium/tests/helpers/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,40 @@ export async function waitForPushSubscriptionReady(subscriptionId: string) {
});
}

export type BackendUser = {
identity: Record<string, string> & { onesignal_id: string; external_id?: string };
subscriptions?: { id: string; type: string; token?: string; enabled?: boolean }[];
};

/**
* The backend user with this onesignal_id, or undefined if it doesn't exist (yet).
* Uses View User rather than Fetch Aliases, which 404s for anonymous users since they have no aliases.
*/
export async function viewUser(onesignalId: string) {
const response = await fetch(
`https://api.onesignal.com/apps/${process.env.ONESIGNAL_APP_ID}/users/by/onesignal_id/${onesignalId}`,
{ headers: { Authorization: `Key ${process.env.ONESIGNAL_API_KEY}` } },
).catch(() => null);
if (!response?.ok) return undefined;
return (await response.json()) as BackendUser;
}

/** Wait until the backend user with this onesignal_id exists with `externalId` (null = anonymous). */
export async function waitForBackendUser(onesignalId: string, externalId: string | null) {
await driver.waitUntil(
async () => {
const user = await viewUser(onesignalId);
return user !== undefined && (user.identity.external_id ?? null) === externalId;
},
{
timeout: 30_000,
// View endpoints allow ~1 req/sec per app, shared with the demo's own user fetches.
interval: 3_000,
timeoutMsg: `Backend user ${onesignalId} never had external_id ${externalId ?? 'null'}`,
},
);
}

/** 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;
Expand Down Expand Up @@ -261,6 +295,7 @@ type ElementWithInteractionMethods = {
getAttribute(name: string): Promise<string | null>;
getText(): Promise<string>;
setValue(value: string): Promise<void>;
waitForDisplayed(options?: { timeout?: number }): Promise<unknown>;
};

// Centralized SDK-specific element shims.
Expand Down
10 changes: 9 additions & 1 deletion appium/tests/specs/01_user.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { waitForAppReady, loginUser, logoutUser, scrollToEl } from '../helpers/app.js';
import {
waitForAppReady,
loginUser,
logoutUser,
scrollToEl,
waitForIdentityChange,
} from '../helpers/app.js';
import { getTestExternalId } from '../helpers/selectors.js';

describe('User', () => {
Expand All @@ -20,6 +26,7 @@ describe('User', () => {
it('can login and logout', async () => {
const userId = getTestExternalId();
await loginUser(userId);
await waitForIdentityChange({ externalId: userId });

let statusEl = await scrollToEl('user_status_value');
const status = await statusEl.getText();
Expand All @@ -30,6 +37,7 @@ describe('User', () => {
expect(externalId).toBe(getTestExternalId());

await logoutUser();
await waitForIdentityChange({ externalId: null });

statusEl = await scrollToEl('user_status_value');
await statusEl.waitUntil(async () => (await statusEl.getText()) === 'Anonymous', {
Expand Down
28 changes: 9 additions & 19 deletions appium/tests/specs/04_alias.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import {
checkTooltip,
confirmModal,
expectPairInSection,
fillInput,
openModal,
scrollToEl,
waitForAppReady,
} from '../helpers/app';
import { byTestId, getTestData, TEST_DATA } from '../helpers/selectors.js';
import { getTestData, TEST_DATA } from '../helpers/selectors.js';

describe('Aliases', () => {
before(async () => {
Expand Down Expand Up @@ -34,12 +35,11 @@ describe('Aliases', () => {
it('can add an alias', async () => {
const { alias } = getTestData();
const labelValue = `${alias}1`;
const labelInput = await openModal('add_alias_button', 'alias_label_input');
await labelInput.setValue(labelValue);
await openModal('add_alias_button', 'alias_label_input');
await fillInput('alias_label_input', labelValue);

const idValue = 'id1';
const idInput = await byTestId('alias_id_input');
await idInput.setValue(idValue);
await fillInput('alias_id_input', idValue);

await confirmModal('singlepair_confirm_button');

Expand All @@ -51,20 +51,10 @@ describe('Aliases', () => {
const addRowButton = await openModal('add_multiple_aliases_button', 'multipair_add_row_button');
await addRowButton.click();

const label0 = await byTestId('multipair_key_0');
await label0.setValue(`${alias}2`);

const id0 = await byTestId('multipair_value_0');
await id0.waitForDisplayed({ timeout: 5_000 });
await id0.setValue('id2');

const label1 = await byTestId('multipair_key_1');
await label1.waitForDisplayed({ timeout: 5_000 });
await label1.setValue(`${alias}3`);

const id1 = await byTestId('multipair_value_1');
await id1.waitForDisplayed({ timeout: 5_000 });
await id1.setValue('id3');
await fillInput('multipair_key_0', `${alias}2`);
await fillInput('multipair_value_0', 'id2');
await fillInput('multipair_key_1', `${alias}3`);
await fillInput('multipair_value_1', 'id3');

await confirmModal('multipair_confirm_button');

Expand Down
Loading