Skip to content

feat: [SDK-4334] add Appium-based E2E test workflow for React Native#1942

Merged
fadi-george merged 20 commits intomainfrom
fadi/sdk-4334-use-appium-action-for-react-native
Apr 21, 2026
Merged

feat: [SDK-4334] add Appium-based E2E test workflow for React Native#1942
fadi-george merged 20 commits intomainfrom
fadi/sdk-4334-use-appium-action-for-react-native

Conversation

@fadi-george
Copy link
Copy Markdown
Collaborator

@fadi-george fadi-george commented Apr 20, 2026

Description

One Line Summary

Adds an Appium-driven E2E test workflow for the React Native demo app, with signed Android and iOS builds wired into the shared OneSignal/sdk-shared Appium runner.

rn_android.mov
rn_ios_720.mov

Details

Motivation

We need automated end-to-end coverage for the React Native SDK to catch regressions across native + JS layers before release. This brings the React Native repo in line with the Flutter SDK, which already runs on the shared Appium workflow, and gives us a single place to keep the demo app and its E2E setup honest on every rel/** push.

Scope

  • New GitHub Actions workflow e2e.yml that builds the demo for Android and iOS and hands artifacts off to OneSignal/sdk-shared/.github/workflows/appium-e2e.yml@main.
  • New composite action setup-demo that installs Bun + Vite+, sets up the demo, optionally updates pods, and writes the demo .env.
  • iOS build now does real codesigning via OneSignal/sdk-shared/.github/actions/setup-ios-demo-codesigning@main and exports a signed IPA using a new examples/demo/ios/ExportOptions.plist. Adds an aps-environment entitlement check so we fail fast if push subscription would be broken.
  • Demo app refactor to support E2E mode: centralized snackbar/toast notifications (replaces the in-app LogView/LogManager), E2E_MODE flag plumbed through .env, and minor build tweaks (arm64-v8a only on Android, ccache enabled, iOS DerivedData + Pods caching).
  • Demo UX polish: custom AppHeader component for a consistent JS-rendered header with a real drop shadow on both platforms (the native-stack header was ignoring shadow styling), clearer action-button labels, an icon on the Add Row button, and standardized testIDs for multi-pair rows.
  • Bumps the bundled demo to react-native-onesignal@5.4.3.

Not changed: the SDK source, native module APIs, or any consumer-facing behavior.

Testing

Manual testing

  • Ran the demo locally on iOS (signed build via the same provisioning profiles) and Android (release APK on a Pixel emulator) to confirm the snackbar refactor and section changes still work end to end.
  • Triggered the workflow via workflow_dispatch on a feature branch to validate APK upload, signed IPA export, and the aps-environment verification step.

Unit testing

No unit tests added; this PR is CI plumbing and demo-only changes. Existing tests pass.

Affected code checklist

  • Notifications
    • Display
    • Open
    • Push Processing
    • Confirm Deliveries
  • Outcomes
  • Sessions
  • In-App Messaging
  • REST API requests
  • Public API changes

Checklist

Overview

  • I have filled out all REQUIRED sections above
  • PR does one thing
  • Any Public API changes are explained in the PR details and conform to existing APIs

Testing

  • I have included test coverage for these changes, or explained why they are not needed
  • All automated tests pass, or I explained why that is not possible
  • I have personally tested this on my device, or explained why that is not possible

Final pass

  • Code is as readable as possible.
  • I have reviewed this PR myself, ensuring it meets each checklist item

Follow-up needed before this can run green

The new iOS codesigning step expects two provisioning profiles to exist in the OneSignal Apple Developer team that match the RN demo's extension bundle IDs:

Bundle ID Expected profile name
com.onesignal.example Appium OneSignal Main (already exists, shared with Flutter)
com.onesignal.example.OneSignalNotificationServiceExtensionRN Appium OneSignal NSE RN
com.onesignal.example.OneSignalWidgetExtension Appium OneSignal Widget RN

Repo also needs the same Appium / App Store Connect secrets the Flutter workflow uses: APPIUM_IOS_DEV_CERT_P12_BASE64, APPIUM_IOS_DEV_CERT_PASSWORD, APPIUM_APP_STORE_CONNECT_KEY_ID, APPIUM_APP_STORE_CONNECT_ISSUER_ID, APPIUM_APP_STORE_CONNECT_PRIVATE_KEY, plus APPIUM_ONESIGNAL_APP_ID (var) and APPIUM_ONESIGNAL_API_KEY (secret).

Made with Cursor

@fadi-george fadi-george requested a review from a team as a code owner April 20, 2026 20:39
Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a large PR (46 files) adding meaningful CI infrastructure — new E2E workflow, iOS codesigning, demo app refactor, and build config changes — that warrants a human look before merging.

Extended reasoning...

Overview

The PR adds an Appium-driven E2E workflow wired into a shared OneSignal/sdk-shared reusable workflow. It touches: new GHA workflow and composite action, iOS ExportOptions.plist and project settings (ccache, manual codesigning), Android arch restriction to arm64-v8a only, and a broad demo app refactor removing LogManager/LogView in favor of Toast snackbars with testID attributes throughout.

Security Risks

The workflow uses secrets: inherit when calling the external OneSignal/sdk-shared reusable workflow. This is standard practice within an org's shared-repo model but means any compromise of that repo could receive all secrets. The API key and app ID are correctly injected from GitHub secrets, not hardcoded. The ExportOptions.plist contains a public Team ID which is benign.

Level of Scrutiny

This PR warrants human review despite being demo/CI-only because: (1) it restricts Android builds to arm64-v8a only, which affects developer experience on non-arm machines; (2) the ccache configuration in the Xcode project settings is subtle and could interact with CI unexpectedly; (3) the PR author explicitly notes provisioning profiles and secrets must be set up as follow-up before the workflow runs green, so a human should confirm the infrastructure plan is sound.

Other Factors

No bugs were found by the automated system. The demo app changes are largely mechanical (testID additions, log replacement) and the intent is clear. However, 46 files across CI infra, native build config, and significant app-layer changes is a meaningful scope that a human reviewer should sign off on.

@fadi-george fadi-george force-pushed the fadi/sdk-4334-use-appium-action-for-react-native branch from d27f13a to 40339b1 Compare April 20, 2026 22:24
Comment thread examples/demo/src/hooks/useOneSignal.ts Outdated
Comment thread examples/demo/src/hooks/useOneSignal.ts Outdated
Comment thread examples/demo/src/hooks/useOneSignal.ts
Comment thread examples/demo/src/hooks/useOneSignal.ts
Comment thread examples/setup.sh Outdated
Comment thread examples/demo/src/components/modals/LoginModal.tsx
Comment thread examples/demo/src/hooks/useOneSignal.ts
Comment thread examples/demo/src/hooks/useOneSignal.ts
Comment thread examples/demo/src/hooks/useOneSignal.ts Outdated
@fadi-george fadi-george force-pushed the fadi/sdk-4334-use-appium-action-for-react-native branch from 8b2fd2d to a2f5843 Compare April 21, 2026 01:18
Comment thread .github/workflows/e2e.yml
Comment thread examples/demo/src/hooks/useOneSignal.ts
@fadi-george fadi-george force-pushed the fadi/sdk-4334-use-appium-action-for-react-native branch from 08b8d20 to 9717a54 Compare April 21, 2026 02:08
Comment thread examples/demo/src/hooks/useOneSignal.ts
Comment thread examples/demo/src/hooks/useOneSignal.ts
Comment thread examples/demo/src/hooks/useOneSignal.ts Outdated
@fadi-george fadi-george force-pushed the fadi/sdk-4334-use-appium-action-for-react-native branch from 3c38886 to 797af4d Compare April 21, 2026 02:53
Comment thread examples/demo/src/hooks/useOneSignal.ts
Comment thread examples/demo/src/components/sections/UserSection.tsx
Comment thread .github/workflows/e2e.yml
Comment thread examples/setup.sh Outdated
Comment thread .github/actions/setup-demo/action.yml
Comment thread examples/demo/ios/demo.xcodeproj/project.pbxproj Outdated
Comment thread examples/demo/src/hooks/useOneSignal.ts
# Use this property to specify which architecture you want to build.
# You can also override it from the CLI using
# ./gradlew <task> -PreactNativeArchitectures=x86_64
reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For what reason(s) are we dropping support for these? Is this tied to the new architecture work in #1920?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For demo we only need arm64-v8a to build fast

Comment on lines +241 to +249
// Prefer the Activity captured by ActivityLifecycleTracker (registered via androidx.startup
// before MainActivity.onResume), then fall back to ReactApplicationContext's accessor and
// finally the ApplicationContext. Passing the real Activity lets the OneSignal SDK populate
// ApplicationService.current immediately, so requestPermission() can launch the OS dialog
// on the first cold-start instead of waiting for the next foreground event.
Context context = ActivityLifecycleTracker.getInstance().getCurrentActivity();
if (context == null) {
context = reactContext.getCurrentActivity();
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any context for this change? Like an issue or is it to get certain tests to pass with Appium?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There was an existing issue where if you do a fresh-start, then nothing is initialized unless you minimize/close the app and reopen. This fixes it so the app re-uses existing activity when app finally launches

* real Activity. That in turn leaves {@code ApplicationService.current == null} and queues
* {@code requestPermission()} until the next foreground.
*/
public class ActivityLifecycleTracker implements Application.ActivityLifecycleCallbacks {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this tied to LiveActivities or something like RN rendering lifecycles?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See other comment

@fadi-george fadi-george force-pushed the fadi/sdk-4334-use-appium-action-for-react-native branch from a0c2410 to cc00bf6 Compare April 21, 2026 22:15
@fadi-george fadi-george requested a review from sherwinski April 21, 2026 22:30
Comment thread examples/demo/ios/demo.xcodeproj/project.pbxproj
Comment on lines +300 to +325
OneSignal.User.removeEventListener('change', userChangeHandler);
};
}, [fetchUserDataFromApi]);

const loginUser = async (nextExternalUserId: string) => {
setAliasesList([]);
setEmailsList([]);
setSmsNumbersList([]);
setTagsList([]);
setTriggersList([]);
setExternalUserId(nextExternalUserId);
setIsLoading(true);

try {
OneSignal.login(nextExternalUserId);
await preferences.setExternalUserId(nextExternalUserId);
console.log(`Logged in as: ${nextExternalUserId}`);
// The user 'change' listener runs fetchUserDataFromApi once the new
// onesignalId is assigned; that call clears isLoading in its finally.
} catch (err) {
console.error(`Login error: ${String(err)}`);
setIsLoading(false);
}
};

const logoutUser = async () => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 loginUser() calls setIsLoading(true) without incrementing requestSequenceRef.current, so any in-flight fetchUserDataFromApi() call will see its sequence guard pass (requestSequenceRef.current === requestId) and incorrectly call setIsLoading(false), prematurely clearing the loading indicator mid-login. This also allows the in-flight fetch's stale pre-login user data to be written to state. Fix: add requestSequenceRef.current += 1 at the top of loginUser() before setIsLoading(true).

Extended reasoning...

What the bug is and how it manifests

fetchUserDataFromApi() (lines 127–153 of useOneSignal.ts) uses a requestSequenceRef counter to ensure stale responses are discarded. At entry it captures requestId = requestSequenceRef.current + 1 and increments the ref. In the finally block it only calls setIsLoading(false) when requestSequenceRef.current === requestId — this guard is meant to prevent a superseded fetch from clearing the loading state set by a newer request.

loginUser() (lines 300–323) calls setIsLoading(true) but never increments requestSequenceRef.current. This breaks the contract that the ref is always advanced whenever a new "owner" of isLoading is established.

The specific code path that triggers it

  1. The initial load calls fetchUserDataFromApi(); requestId=1, requestSequenceRef.current=1, isLoading=true.
  2. While that network request is in flight, the user taps LOGIN USER.
  3. loginUser() fires: clears state arrays, calls setIsLoading(true), but requestSequenceRef.current stays at 1.
  4. The in-flight fetch completes; its finally block evaluates requestSequenceRef.current (1) === requestId (1) → true → setIsLoading(false) is called, prematurely clearing the loading indicator.
  5. Additionally, the try-block stale guard if (requestSequenceRef.current !== requestId) return; also evaluates to false (1 === 1), so stale pre-login aliases, tags, emails, and SMS data is written to state.
  6. Moments later the SDK fires a user 'change' event; userChangeHandler calls fetchUserDataFromApi() → requestId=2, isLoading=true again.
  7. Net visible result: isLoading flickers true→false→true and pre-login user data briefly appears in the UI.

Why existing code doesn't prevent it

The requestSequenceRef pattern is already used correctly inside fetchUserDataFromApi — the bug is that loginUser() completely bypasses it. Any caller that sets isLoading=true without advancing the ref implicitly authorizes the last in-flight fetch to cancel the new loading state.

What the impact would be

Loading spinners in the Aliases, Tags, Emails, and SMS sections flicker off and back on during login while a prior fetch is in flight. Worse, pre-login user data is briefly displayed in those sections after the login action is taken, before the post-login fetch completes. For the Appium E2E test suite this PR introduces, any test that asserts loading state or data absence immediately after login could observe this inconsistent transient state.

How to fix it

Add requestSequenceRef.current += 1 as the first line of loginUser(), before setIsLoading(true):

Step-by-step proof

  1. App cold-starts; load() calls fetchUserDataFromApi(); requestId=1, ref=1, network request begins.
  2. While network is in flight, user taps LOGIN. loginUser('alice') executes: ref stays at 1, isLoading=true.
  3. Network response arrives for the anonymous user fetch. finally block: ref(1) === requestId(1) → setIsLoading(false). Loading indicator disappears.
  4. The try-block guard also passes, so anonymous user's aliases/tags/emails/sms are written to state — briefly displayed under alice's login session.
  5. SDK fires user 'change'. userChangeHandler → fetchUserDataFromApi(); requestId=2, isLoading=true. Loading reappears.
  6. With the fix, step 2 increments ref to 2. Step 3's finally sees ref(2) !== requestId(1) → does NOT call setIsLoading(false). The try-block guard also fires correctly. No flicker, no stale data.

Comment on lines +28 to +31
};

return (
<SectionCard title="Location" onInfoTap={onInfoTap}>
<SectionCard title="Location" onInfoTap={onInfoTap} sectionKey="location">
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 handleCheckLocation in LocationSection.tsx (lines 28–31) is an async function with no try/catch, passed directly to ActionButton.onPress which is typed as () => void. If OneSignal.Location.isShared() throws (SDK not initialized, native error, permissions issue), the returned Promise is silently dropped at the call site, showSnackbar is never called, and the user gets no error feedback. Fix by wrapping the body of handleCheckLocation in try/catch and calling showSnackbar with an error message on failure.

Extended reasoning...

What the bug is and how it manifests

LocationSection.tsx introduces a new handleCheckLocation function (lines 28–31) that is async and has no try/catch block:

const handleCheckLocation = async () => {
  const shared = await onCheckLocationShared();
  showSnackbar(`Location shared: ${shared}`);
};

This function is then passed directly to ActionButton.onPress, which is typed as () => void. TypeScript intentionally allows () => Promise to satisfy () => void structurally (a deliberate TypeScript design decision), so no compile-time error surfaces. The returned Promise is silently dropped when ActionButton calls onPress — and with it, any rejection that might occur.

The specific code path that triggers it

The call chain is: handleCheckLocation → onCheckLocationShared (passed in from HomeScreen as os.checkLocationShared) → checkLocationShared() in useOneSignal.ts (lines 516–520) → OneSignal.Location.isShared(). The last step is a native module call that can throw for several reasons: the SDK was not yet fully initialized, a native bridge exception occurs, or the location subsystem encounters a permissions error. None of handleCheckLocation, onCheckLocationShared, or checkLocationShared have a try/catch.

Why existing code does not prevent it

ActionButton internally calls its onPress prop via TouchableOpacity, which fires the callback synchronously and discards the return value. The returned Promise (and any subsequent rejection) has no handler at the call site. React Native's global unhandled-rejection handler may emit a warning or crash in strict mode, but no user-visible feedback is provided: showSnackbar is only called on the happy path after the await resolves, never in the error path.

What the impact would be

If the native call throws, the user sees no toast, no error message, no loading spinner dismissal — nothing. In E2E testing (which this PR introduces), any Appium test that relies on the snackbar feedback from this button to verify behavior will time out. The failure is entirely silent.

How to fix it

Wrap the body of handleCheckLocation in try/catch, mirroring the pattern used elsewhere in the demo (e.g., handleLogin in UserSection):

const handleCheckLocation = async () => {
  try {
    const shared = await onCheckLocationShared();
    showSnackbar(`Location shared: ${shared}`);
  } catch (err) {
    showSnackbar('Failed to check location shared status');
    console.error('checkLocationShared error:', err);
  }
};

Step-by-step proof

  1. User taps CHECK LOCATION SHARED.
  2. handleCheckLocation() is called fire-and-forget by TouchableOpacity (onPress typed as () => void).
  3. handleCheckLocation calls await onCheckLocationShared(), which calls OneSignal.Location.isShared().
  4. OneSignal.Location.isShared() throws (e.g., native bridge not initialized yet).
  5. The rejection propagates through onCheckLocationShared → handleCheckLocation and out of the async function.
  6. The Promise returned by handleCheckLocation is discarded by ActionButton's onPress handler — no handler exists.
  7. showSnackbar is never reached. The user sees nothing. React Native may emit an unhandled-rejection warning in development, but the app produces no user-facing error feedback.

This is the same async-without-try/catch onPress pattern identified in UserSection.handleLogout and LoginModal.onConfirm elsewhere in this PR.

@fadi-george fadi-george force-pushed the fadi/sdk-4334-use-appium-action-for-react-native branch 2 times, most recently from 09a9804 to 056581e Compare April 21, 2026 22:54
@fadi-george fadi-george force-pushed the fadi/sdk-4334-use-appium-action-for-react-native branch from 056581e to ba9e1d4 Compare April 21, 2026 23:04
@fadi-george fadi-george force-pushed the fadi/sdk-4334-use-appium-action-for-react-native branch from abd273a to 3060b69 Compare April 21, 2026 23:14
Comment on lines +515 to +520

const checkLocationShared = async () => {
const shared = await OneSignal.Location.isShared();
console.log(`Location shared: ${shared}`);
return shared;
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 checkLocationShared() in useOneSignal.ts reads the actual SDK value via OneSignal.Location.isShared() and returns it, but never calls setLocationSharedState(shared), leaving the LocationSection ToggleRow bound to stale React state. If the OS or SDK changes location-sharing status externally (e.g., user revokes permission in system settings), pressing CHECK LOCATION SHARED will show the correct SDK value in the snackbar while the toggle still displays the old optimistic state — contradictory UI. Fix: add setLocationSharedState(shared) inside checkLocationShared() after the await.

Extended reasoning...

What the bug is and how it manifests

In useOneSignal.ts (lines 515–520), checkLocationShared() is defined as:

const checkLocationShared = async () => {
    const shared = await OneSignal.Location.isShared();
    console.log(`Location shared: ${shared}`);
    return shared;
};

It reads the actual SDK value but never calls setLocationSharedState(shared). By contrast, setLocationShared() at lines ~509–513 correctly calls setLocationSharedState(shared) before the SDK call. This means pressing CHECK LOCATION SHARED returns the real SDK value (which drives the snackbar in LocationSection.tsx), while the locationShared React state driving the ToggleRow is never updated to reflect it.

The specific code path that triggers it

In LocationSection.tsx, handleCheckLocation calls onCheckLocationShared() and passes the result directly to showSnackbar(Location shared: ${shared}). The ToggleRow at line ~36 uses the locationShared prop which originates from React state. If the actual SDK isShared() value diverges from React state — for example because system settings revoke location permission, or the SDK internally resets the value during initialization — the CHECK LOCATION SHARED button will expose the discrepancy rather than fix it: the snackbar says "false", but the toggle continues to show "on".

Why existing code doesn't prevent it

setLocationShared() is correctly implemented: it calls setLocationSharedState(shared) optimistically before the SDK call, so the toggle and SDK stay in sync for all user-initiated changes. The omission in checkLocationShared() means the "check" operation is purely diagnostic — it reports truth from the SDK without reconciling the React state. There is no guard elsewhere that detects the divergence; the ToggleRow will persist showing stale state for the remainder of the session unless the user manually toggles the switch.

Impact

The user sees contradictory information: the snackbar accurately reports Location shared: false while the toggle continues to display the "on" position. For a demo app whose purpose is to verify SDK behavior, this is a meaningful regression. Any Appium E2E test that asserts toggle state after invoking CHECK LOCATION SHARED would observe the stale React state and could fail.

How to fix it

Add setLocationSharedState(shared) after the await in checkLocationShared():

const checkLocationShared = async () => {
    const shared = await OneSignal.Location.isShared();
    setLocationSharedState(shared);  // ← add this line
    console.log(`Location shared: ${shared}`);
    return shared;
};

This ensures the toggle always reflects the actual SDK value after the user presses CHECK LOCATION SHARED, eliminating the snackbar/toggle mismatch.

Step-by-step proof

  1. User enables location sharing via the toggle → setLocationShared(true)setLocationSharedState(true) + SDK setShared(true). Toggle shows "on".
  2. System settings (or another mechanism) disables location sharing at the SDK level without going through the React hook.
  3. User presses CHECK LOCATION SHARED.
  4. checkLocationShared() awaits OneSignal.Location.isShared() → returns false.
  5. console.log('Location shared: false') runs; return false is passed back.
  6. handleCheckLocation in LocationSection.tsx receives false and calls showSnackbar('Location shared: false'). Snackbar shows "false".
  7. locationShared React state is still true. The ToggleRow continues to show "on".
  8. The UI shows contradictory information for the rest of the session; no further event will correct it.

Comment on lines +355 to +368
OneSignal.User.pushSubscription.optIn();
} else {
OneSignal.User.pushSubscription.optOut();
}
setIsPushEnabled(enabled);
console.log(enabled ? 'Push enabled' : 'Push disabled');
};

const sendNotification = async (type: NotificationType) => {
const success = await postNotification(type);
console.log(success ? `Notification sent: ${type}` : 'Failed to send notification');
};

const sendCustomNotification = async (title: string, body: string) => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 In logoutUser(), OneSignal.logout() is called synchronously before await preferences.setExternalUserId(null), creating a split-brain state if AsyncStorage throws: the SDK enters anonymous mode but the stored external user ID is never cleared. On the next cold start, load() reads the stale ID from preferences and calls OneSignal.login(storedExternalUserId), silently re-logging the user in after an explicit logout. Fix by swapping the order — persist the cleared preference before calling OneSignal.logout() — so any storage failure leaves both the SDK and preferences in a consistent logged-in state.

Extended reasoning...

What the bug is and how it manifests

In useOneSignal.ts, logoutUser() executes operations in the wrong order for error safety. OneSignal.logout() is called synchronously as the very first statement, before the async preference write await preferences.setExternalUserId(null). If AsyncStorage throws at that point, the SDK has already entered anonymous mode but the stored external user ID remains intact in persistent storage.

The specific code path that triggers it

  1. logoutUser() is invoked. OneSignal.logout() fires immediately — the SDK is now in anonymous mode.
  2. await preferences.setExternalUserId(null) calls AsyncStorage.removeItem('onesignal_external_user_id').
  3. If AsyncStorage throws (storage corruption, quota exceeded, OS-level I/O error), execution halts. The subsequent setExternalUserId(undefined), setAliasesList([]), etc. never run.
  4. Result: SDK is anonymous, but AsyncStorage still holds the old external user ID.

Why existing code doesn't prevent it

logoutUser() has no try/catch block. Even if it did, the SDK state has already been mutated at step 1. There is no rollback mechanism — if the preference write fails, there is no code path that calls OneSignal.login(previousId) to restore consistency.

What the impact would be

On the next cold start, load() reads storedExternalUserId = await preferences.getExternalUserId(). Finding the stale ID, it calls OneSignal.login(storedExternalUserId), re-associating the device with the user who just explicitly logged out. No error is surfaced — the user has no indication this happened. All subsequent tags, aliases, emails, and SMS operations silently target the old user identity.

How to fix it

Swap the order — call await preferences.setExternalUserId(null) first, then OneSignal.logout(). This ensures that if the storage write fails, both the SDK and preferences remain in the logged-in state (consistent). If AsyncStorage succeeds but the SDK logout somehow fails, the user can retry. Alternatively, add a try/catch that calls OneSignal.login(previousExternalUserId) in the catch block to roll back the SDK state if the preference write fails.

Step-by-step proof

  1. User logs in as 'alice'. On cold start, load() reads 'alice' from preferences, calls OneSignal.login('alice'). SDK and preferences are both associated with 'alice'.
  2. User taps LOGOUT USER. handleLogout()logoutUser() fires.
  3. OneSignal.logout() executes — SDK enters anonymous mode immediately.
  4. await preferences.setExternalUserId(null) calls AsyncStorage.removeItem. AsyncStorage encounters an I/O error and throws.
  5. Execution exits logoutUser() without clearing any state. AsyncStorage still contains 'alice'.
  6. User restarts the app. load() reads storedExternalUserId = 'alice' from preferences, calls OneSignal.login('alice').
  7. The user is now silently re-logged in as 'alice', defeating the explicit logout action with no feedback to the user.

@fadi-george fadi-george merged commit 838a3cb into main Apr 21, 2026
7 checks passed
@fadi-george fadi-george deleted the fadi/sdk-4334-use-appium-action-for-react-native branch April 21, 2026 23:23
Comment on lines +187 to +198
OneSignal.User.pushSubscription.getIdAsync(),
OneSignal.User.pushSubscription.getOptedInAsync(),
]);
setPushSubscriptionId(id ?? undefined);
setIsPushEnabled(optedIn);
};

const permissionHandler = async () => {
setHasNotificationPermission(await OneSignal.Notifications.getPermissionAsync());
};

const userChangeHandler = (event: UserChangedState) => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Both pushSubHandler (lines 187–194) and permissionHandler (lines 196–198) are declared as async functions and registered as SDK event listeners, but the SDK discards the returned Promise without attaching .catch() — so if getIdAsync(), getOptedInAsync(), or getPermissionAsync() rejects, the state setters (setPushSubscriptionId, setIsPushEnabled, setHasNotificationPermission) are never called, leaving push and permission UI stuck at stale values. Add try/catch inside each handler body, or prefix each call with void handler().catch(console.error).

Extended reasoning...

What the bug is and how it manifests

In useOneSignal.ts (lines 187–198), pushSubHandler and permissionHandler are both declared as async arrow functions and registered as SDK event listeners:

const pushSubHandler = async () => {
  const [id, optedIn] = await Promise.all([
    OneSignal.User.pushSubscription.getIdAsync(),
    OneSignal.User.pushSubscription.getOptedInAsync(),
  ]);
  setPushSubscriptionId(id ?? undefined);
  setIsPushEnabled(optedIn);
};

const permissionHandler = async () => {
  setHasNotificationPermission(await OneSignal.Notifications.getPermissionAsync());
};

Both are then wired up via addEventListener. When the SDK fires these events, it calls the handlers synchronously as callbacks and discards the returned Promise — there is no .catch() attached and no void operator used.

The specific code path that triggers it

EventManager.ts (line 131) dispatches events via dispatchHandlers(), which calls handler(payload) with no await and no Promise handling. So any rejection from Promise.all([getIdAsync(), getOptedInAsync()]) inside pushSubHandler, or from getPermissionAsync() inside permissionHandler, propagates into an unhandled Promise rejection. The state setters (setPushSubscriptionId, setIsPushEnabled, setHasNotificationPermission) are on the other side of the awaits, so they are never called in the rejection path. One verifier confirmed that getPermissionAsync() can explicitly return Promise.reject(new Error('OneSignal native module not loaded')) — making this a realistic failure mode, not just a theoretical one.

Why existing code does not prevent it

The file already demonstrates the correct pattern: void load().catch((err) => { ... }) in the useEffect body. That convention is followed for the outer async initialization but was not applied to these two inner handlers. There is no try/catch inside either handler body and no .catch() at the call site.

What the impact would be

When a rejection occurs, the Push section's displayed subscription ID, enabled state, and permission indicator are stuck at their last-known values with no error indication to the user or developer. In React Native, unhandled promise rejections produce LogBox warnings in development and can cause crashes in strict-mode configurations. For the Appium E2E test suite this PR introduces, any test that waits on push subscription or permission state after an OS permission dialog is dismissed could time out if getPermissionAsync() rejects on the first call.

How to fix it

Wrap each handler body in try/catch:

const pushSubHandler = async () => {
  try {
    const [id, optedIn] = await Promise.all([...]);
    setPushSubscriptionId(id ?? undefined);
    setIsPushEnabled(optedIn);
  } catch (err) {
    console.error('pushSubHandler error:', err);
  }
};

const permissionHandler = async () => {
  try {
    setHasNotificationPermission(await OneSignal.Notifications.getPermissionAsync());
  } catch (err) {
    console.error('permissionHandler error:', err);
  }
};

Alternatively, keep the async body and prefix the registration call with void: void pushSubHandler().catch(console.error) — but wrapping inside is cleaner for locally-defined handlers.

Step-by-step proof

  1. OneSignalProvider mounts; load() completes and registers pushSubHandler and permissionHandler as SDK listeners.
  2. The user dismisses the OS push-permission dialog. The SDK fires a 'permissionChange' event.
  3. permissionHandler() is called by the SDK. The returned Promise is immediately discarded (no await, no .catch).
  4. getPermissionAsync() rejects (e.g., native module not loaded).
  5. The rejection propagates out of permissionHandler and becomes an unhandled promise rejection.
  6. setHasNotificationPermission is never called. The permission toggle in PushSection continues to show the pre-dialog value.
  7. React Native emits an unhandled rejection warning; in strict mode this may terminate the JS thread.

Comment on lines +325 to +335
const logoutUser = async () => {
OneSignal.logout();
await preferences.setExternalUserId(null);
setExternalUserId(undefined);
setAliasesList([]);
setEmailsList([]);
setSmsNumbersList([]);
setTagsList([]);
setTriggersList([]);
console.log('Logged out');
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 In logoutUser() (lines 325–335), the request-sequence counter (requestSequenceRef) is never incremented before clearing UI state, so any fetchUserDataFromApi() call that was in-flight at logout time will pass the guard at line 141 and overwrite the just-cleared anonymous state with the previous user's stale data. Fix: add requestSequenceRef.current += 1 as the first line of logoutUser().

Extended reasoning...

Bug description

fetchUserDataFromApi() (lines 127–153) uses a sequence-counter pattern to cancel superseded requests: it increments requestSequenceRef.current, captures the new value as requestId, and then, before committing any state writes, checks if (requestSequenceRef.current \!== requestId) return; (line 141). This guard only works if something increments the ref to invalidate the outstanding requestId. logoutUser() clears every UI-state array (lines 328–333) but never touches requestSequenceRef.current, leaving the guard permanently passable for any in-flight fetch.

Concrete race scenario

  1. The SDK fires a userChange event (e.g. an alias or tag was added), causing userChangeHandler to call fetchUserDataFromApi(). It captures requestId = N and begins three sequential awaits: getOnesignalId(), apiService.fetchUser(), getExternalId().
  2. While those network calls are in progress, the user taps LOGOUT. logoutUser() calls OneSignal.logout() and immediately zeros out all state arrays (setAliasesList([]), setTagsList([]), etc.).
  3. The SDK fires another userChange event for the logout transition; userChangeHandler sees nextOnesignalId === null at line 204 and returns early — no new fetchUserDataFromApi() call is made, so requestSequenceRef.current remains at N.
  4. The original in-flight fetch completes. It reaches the guard at line 141: requestSequenceRef.current (N) === requestId (N)the check passes. Lines 143–147 then write setAliasesList, setTagsList, setEmailsList, setSmsNumbersList, and setExternalUserId with the prior user's data, replacing the clean anonymous state logoutUser() just established.

Why existing code does not prevent this

The sequence guard at line 141 is the sole cancellation mechanism. Because logoutUser() skips the increment, it cannot invalidate any outstanding fetch. The userChangeHandler early-return for null onesignalId (line 204) means the logout event path never triggers a counter-bumping call either; there is simply no other code path that increments the ref during logout.

Impact

After logout the UI shows the previous user's aliases, tags, email addresses, and SMS numbers instead of an empty anonymous profile. In an Appium E2E test (the context this PR specifically adds), a logout assertion will intermittently fail depending on network latency, making the test flaky. In a real app, this is a user-data leak between accounts.

Fix

Add one line at the top of logoutUser():

const logoutUser = async () => {
  requestSequenceRef.current += 1;  // invalidate any in-flight fetchUserDataFromApi
  OneSignal.logout();
  ...
};

This mirrors how fetchUserDataFromApi() itself invalidates earlier calls and is the minimal, sufficient correction.

Comment on lines +9 to +16
const MASK_CHAR = '•';

function maskValue(value: string): string {
if (E2E_MODE === 'true') {
return MASK_CHAR.repeat(value.length);
}
return value;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MASK_CHAR and maskValue are defined identically in both AppSection.tsx and PushSection.tsx, creating a DRY violation in test-critical masking logic. If the two copies drift (e.g., one gets a value.slice(-4) tweak for debugging), E2E assertions against app_id_value and push_id_value could silently diverge. Extract the shared logic to src/utils/maskValue.ts, following the showSnackbar.ts pattern already established in this PR.

Extended reasoning...

The PR introduces Appium-based E2E testing and, to prevent sensitive IDs from leaking into test logs/screenshots, adds a maskValue() helper that replaces characters with when E2E_MODE === 'true'. This helper — along with its MASK_CHAR constant — is copy-pasted verbatim into two sibling components: AppSection.tsx (lines 9-16) and PushSection.tsx (lines 10-17). The implementations are byte-for-byte identical today, but they are maintained independently.

The specific code path is: AppSection renders app_id_value testID with maskValue(appId), and PushSection renders push_id_value testID with maskValue(pushId). The Appium test suite asserts against these testIDs. Both rely on the same masking contract, yet neither copy is authoritative — a change to one does not automatically propagate to the other.

The existing code does not prevent future drift. There is no shared import, no type alias, and no lint rule enforcing the two to stay in sync. A developer adding a showLast4 option, changing the bullet character, or toggling masking only in one file would silently break the other section's E2E assertions while leaving that section's unit tests green (assuming they mock E2E_MODE).

The impact is E2E flakiness that is hard to diagnose: one masked value passes the Appium assertion and another fails, the difference tracing back to a seemingly unrelated one-line change in a sibling file. Because this code exists solely to support E2E infrastructure — not product logic — reliability is especially important.

The fix is straightforward: create examples/demo/src/utils/maskValue.ts exporting the maskValue function (and optionally MASK_CHAR), then replace both local definitions with a single import. This is exactly the pattern the PR already uses for showSnackbar.ts, so it is consistent with the PR's own architecture and requires minimal effort.

Step-by-step proof of divergence risk: (1) A developer later wants to show the last 4 characters of the App ID for debugging — they edit AppSection.tsx so maskValue returns '•'.repeat(value.length - 4) + value.slice(-4). (2) They don't touch PushSection.tsx. (3) The Appium test for app_id_value now expects a partially-unmasked value, but the test for push_id_value still expects fully-masked output. (4) Both tests were written against the original spec, so one of them fails — or worse, the test is updated for App ID but the discrepancy in Push ID goes unnoticed until a later CI run on a real device.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants