-
Notifications
You must be signed in to change notification settings - Fork 379
chore: [SDK-4334] add Appium-based E2E test workflow for React Native #1942
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c3313e5
00d9a0d
54a92dc
cf49f5e
cdda732
db97d94
fab3776
4aac4ad
437c4ab
44c7895
40339b1
f0b7833
8743251
a2f5843
45c0920
2b06ee0
9717a54
797af4d
ba9e1d4
3060b69
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| name: 'Setup Demo' | ||
| description: 'Installs toolchains, builds the SDK, sets up the demo app, and creates the .env file' | ||
| inputs: | ||
| onesignal-app-id: | ||
| description: 'OneSignal App ID for the demo .env' | ||
| required: true | ||
| onesignal-api-key: | ||
| description: 'OneSignal API Key for the demo .env' | ||
| required: true | ||
| install-pods: | ||
| description: 'Whether to run pod update for iOS' | ||
| required: false | ||
| default: 'false' | ||
| runs: | ||
| using: 'composite' | ||
| steps: | ||
| - name: Set up Vite+ | ||
| uses: voidzero-dev/setup-vp@v1 | ||
| with: | ||
| cache: true | ||
| run-install: true | ||
|
|
||
| - name: Set up Bun | ||
| uses: oven-sh/setup-bun@v2 | ||
|
|
||
| - name: Cache bun dependencies | ||
| uses: actions/cache@v5 | ||
| with: | ||
| path: examples/demo/node_modules | ||
| key: bun-${{ runner.os }}-${{ hashFiles('examples/demo/bun.lock') }} | ||
| restore-keys: bun-${{ runner.os }}- | ||
|
|
||
| - name: Install and set up demo | ||
| shell: bash | ||
| working-directory: examples/demo | ||
| run: | | ||
| bun run setup | ||
| bun install | ||
|
|
||
| - name: Cache CocoaPods | ||
| if: inputs.install-pods == 'true' | ||
| uses: actions/cache@v5 | ||
| with: | ||
| path: examples/demo/ios/Pods | ||
| key: pods-${{ runner.os }}-${{ hashFiles('examples/demo/ios/Podfile.lock') }} | ||
| restore-keys: pods-${{ runner.os }}- | ||
|
|
||
| - name: Update CocoaPods | ||
| if: inputs.install-pods == 'true' | ||
| shell: bash | ||
| working-directory: examples/demo | ||
| run: bun run update:pods | ||
|
|
||
| - name: Create demo .env | ||
| shell: bash | ||
| working-directory: examples/demo | ||
| run: | | ||
| echo "ONESIGNAL_APP_ID=${{ inputs.onesignal-app-id }}" > .env | ||
| echo "ONESIGNAL_API_KEY=${{ inputs.onesignal-api-key }}" >> .env | ||
| echo "E2E_MODE=true" >> .env | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,152 @@ | ||
| name: E2E Tests | ||
|
|
||
| on: | ||
| push: | ||
| branches: | ||
| - rel/** | ||
| workflow_dispatch: | ||
| inputs: | ||
| platform: | ||
| description: 'Platform to test' | ||
| required: true | ||
| default: 'both' | ||
| type: choice | ||
| options: | ||
| - android | ||
| - ios | ||
| - both | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| concurrency: | ||
| group: ${{ github.workflow }}-${{ github.ref }} | ||
| cancel-in-progress: true | ||
|
|
||
| jobs: | ||
| build-android: | ||
| if: >- | ||
| github.event_name == 'push' || | ||
| github.event.inputs.platform == 'android' || | ||
| github.event.inputs.platform == 'both' | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - name: Checkout | ||
| uses: actions/checkout@v6 | ||
|
|
||
| - name: Set up Java | ||
| uses: actions/setup-java@v5 | ||
| with: | ||
| distribution: temurin | ||
| java-version: '17' | ||
|
|
||
| - name: Set up demo | ||
| uses: ./.github/actions/setup-demo | ||
| with: | ||
| onesignal-app-id: ${{ vars.APPIUM_ONESIGNAL_APP_ID }} | ||
| onesignal-api-key: ${{ secrets.APPIUM_ONESIGNAL_API_KEY }} | ||
|
|
||
| - name: Build release APK | ||
| working-directory: examples/demo/android | ||
| run: ./gradlew assembleRelease | ||
|
|
||
| - name: Upload APK | ||
| uses: actions/upload-artifact@v7 | ||
|
fadi-george marked this conversation as resolved.
|
||
| with: | ||
| name: demo-apk | ||
| path: examples/demo/android/app/build/outputs/apk/release/app-release.apk | ||
| retention-days: 1 | ||
| compression-level: 0 | ||
|
|
||
| build-ios: | ||
| if: >- | ||
| github.event_name == 'push' || | ||
| github.event.inputs.platform == 'ios' || | ||
| github.event.inputs.platform == 'both' | ||
| runs-on: macos-latest | ||
| steps: | ||
| - name: Checkout | ||
| uses: actions/checkout@v6 | ||
|
|
||
| - name: Set up demo | ||
| uses: ./.github/actions/setup-demo | ||
| with: | ||
| onesignal-app-id: ${{ vars.APPIUM_ONESIGNAL_APP_ID }} | ||
| onesignal-api-key: ${{ secrets.APPIUM_ONESIGNAL_API_KEY }} | ||
| install-pods: 'true' | ||
|
fadi-george marked this conversation as resolved.
|
||
|
|
||
| - name: Cache Xcode DerivedData | ||
| uses: actions/cache@v5 | ||
| with: | ||
| path: examples/demo/ios/build | ||
| key: deriveddata-${{ runner.os }}-${{ hashFiles('examples/demo/ios/Podfile.lock') }} | ||
| restore-keys: deriveddata-${{ runner.os }}- | ||
|
|
||
| - name: Set up iOS codesigning | ||
| uses: OneSignal/sdk-shared/.github/actions/setup-ios-demo-codesigning@main | ||
| with: | ||
| p12-base64: ${{ secrets.APPIUM_IOS_DEV_CERT_P12_BASE64 }} | ||
| p12-password: ${{ secrets.APPIUM_IOS_DEV_CERT_PASSWORD }} | ||
| asc-key-id: ${{ secrets.APPIUM_APP_STORE_CONNECT_KEY_ID }} | ||
| asc-issuer-id: ${{ secrets.APPIUM_APP_STORE_CONNECT_ISSUER_ID }} | ||
| asc-private-key: ${{ secrets.APPIUM_APP_STORE_CONNECT_PRIVATE_KEY }} | ||
|
|
||
| - name: Build signed IPA | ||
| working-directory: examples/demo/ios | ||
| run: | | ||
| xcodebuild archive \ | ||
| -workspace demo.xcworkspace \ | ||
| -scheme demo \ | ||
| -configuration Release \ | ||
| -sdk iphoneos \ | ||
| -destination 'generic/platform=iOS' \ | ||
| -archivePath build/demo.xcarchive \ | ||
| -derivedDataPath build \ | ||
| CODE_SIGN_STYLE=Manual \ | ||
| COMPILER_INDEX_STORE_ENABLE=NO | ||
| xcodebuild -exportArchive \ | ||
| -archivePath build/demo.xcarchive \ | ||
| -exportOptionsPlist ExportOptions.plist \ | ||
| -exportPath build/ipa | ||
|
|
||
| - name: Verify aps-environment in IPA | ||
| working-directory: examples/demo/ios | ||
| run: | | ||
| IPA=$(ls build/ipa/*.ipa | head -n1) | ||
| unzip -oq "$IPA" -d /tmp/ipa | ||
| APP=$(ls -d /tmp/ipa/Payload/*.app | head -n1) | ||
| codesign -d --entitlements - "$APP" 2>&1 | tee /tmp/entitlements.txt | ||
| if ! grep -q 'aps-environment' /tmp/entitlements.txt; then | ||
| echo "::error::Built IPA is missing aps-environment entitlement; push subscription will not work" | ||
| exit 1 | ||
| fi | ||
|
|
||
| - name: Upload IPA | ||
| uses: actions/upload-artifact@v7 | ||
| with: | ||
| name: demo-ipa | ||
| path: examples/demo/ios/build/ipa/demo.ipa | ||
| retention-days: 1 | ||
| compression-level: 0 | ||
|
|
||
| e2e-android: | ||
| needs: build-android | ||
| uses: OneSignal/sdk-shared/.github/workflows/appium-e2e.yml@main | ||
| secrets: inherit | ||
| with: | ||
| platform: android | ||
| app-artifact: demo-apk | ||
| app-filename: app-release.apk | ||
| sdk-type: react-native | ||
| build-name: react-native-android-${{ github.ref_name }}-${{ github.run_number }} | ||
|
|
||
| e2e-ios: | ||
| needs: build-ios | ||
| uses: OneSignal/sdk-shared/.github/workflows/appium-e2e.yml@main | ||
| secrets: inherit | ||
| with: | ||
| platform: ios | ||
| app-artifact: demo-ipa | ||
| app-filename: demo.ipa | ||
| sdk-type: react-native | ||
| build-name: react-native-ios-${{ github.ref_name }}-${{ github.run_number }} | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,6 +15,7 @@ build | |
| dist | ||
| android/build | ||
| *.tgz | ||
| .rn-sdk-source.stamp | ||
|
|
||
| # OSX | ||
| # | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,2 +1,14 @@ | ||
| <manifest xmlns:android="http://schemas.android.com/apk/res/android"> | ||
| <manifest xmlns:android="http://schemas.android.com/apk/res/android" | ||
| xmlns:tools="http://schemas.android.com/tools"> | ||
| <application> | ||
| <provider | ||
| android:name="androidx.startup.InitializationProvider" | ||
| android:authorities="${applicationId}.androidx-startup" | ||
| android:exported="false" | ||
| tools:node="merge"> | ||
| <meta-data | ||
| android:name="com.onesignal.rnonesignalandroid.OneSignalInitializer" | ||
| android:value="androidx.startup" /> | ||
| </provider> | ||
| </application> | ||
| </manifest> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| package com.onesignal.rnonesignalandroid; | ||
|
|
||
| import android.app.Activity; | ||
| import android.app.Application; | ||
| import android.os.Bundle; | ||
| import androidx.annotation.Nullable; | ||
| import java.lang.ref.WeakReference; | ||
|
|
||
| /** | ||
| * Tracks the host app's current Activity from Application.onCreate onward. | ||
| * | ||
| * <p>Registered very early via {@link OneSignalInitializer} (androidx.startup) so it captures the | ||
| * first {@code MainActivity.onResume} that fires before the React Native bridge has loaded the JS | ||
| * bundle. Without this, {@link com.facebook.react.bridge.ReactApplicationContext#getCurrentActivity()} | ||
| * frequently returns {@code null} during cold start in bridgeless mode, causing | ||
| * {@code RNOneSignal.initialize} to hand the OneSignal SDK an ApplicationContext instead of the | ||
| * real Activity. That in turn leaves {@code ApplicationService.current == null} and queues | ||
| * {@code requestPermission()} until the next foreground. | ||
| */ | ||
| public class ActivityLifecycleTracker implements Application.ActivityLifecycleCallbacks { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this tied to LiveActivities or something like RN rendering lifecycles?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. See other comment |
||
| private static final ActivityLifecycleTracker INSTANCE = new ActivityLifecycleTracker(); | ||
|
|
||
| private volatile WeakReference<Activity> currentActivity = new WeakReference<>(null); | ||
|
|
||
| private ActivityLifecycleTracker() {} | ||
|
|
||
| public static ActivityLifecycleTracker getInstance() { | ||
| return INSTANCE; | ||
| } | ||
|
|
||
| @Nullable | ||
| public Activity getCurrentActivity() { | ||
| return currentActivity.get(); | ||
| } | ||
|
|
||
| @Override | ||
| public void onActivityCreated(Activity activity, @Nullable Bundle savedInstanceState) { | ||
| currentActivity = new WeakReference<>(activity); | ||
| } | ||
|
|
||
| @Override | ||
| public void onActivityStarted(Activity activity) { | ||
| currentActivity = new WeakReference<>(activity); | ||
| } | ||
|
|
||
| @Override | ||
| public void onActivityResumed(Activity activity) { | ||
| currentActivity = new WeakReference<>(activity); | ||
| } | ||
|
|
||
| @Override | ||
| public void onActivityPaused(Activity activity) { | ||
| // Intentionally no-op: keep the reference so a transient overlay (e.g. permission dialog, | ||
| // PermissionsActivity) doesn't blank out the current Activity for callers that race with it. | ||
| } | ||
|
|
||
| @Override | ||
| public void onActivityStopped(Activity activity) { | ||
| // Intentionally no-op for the same reason as onActivityPaused. | ||
| } | ||
|
|
||
| @Override | ||
| public void onActivitySaveInstanceState(Activity activity, Bundle outState) {} | ||
|
|
||
| @Override | ||
| public void onActivityDestroyed(Activity activity) { | ||
| Activity current = currentActivity.get(); | ||
| if (current == activity) { | ||
| currentActivity = new WeakReference<>(null); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| package com.onesignal.rnonesignalandroid; | ||
|
|
||
| import android.app.Application; | ||
| import android.content.Context; | ||
| import androidx.annotation.NonNull; | ||
| import androidx.startup.Initializer; | ||
| import java.util.Collections; | ||
| import java.util.List; | ||
|
|
||
| /** | ||
| * androidx.startup entry point that registers {@link ActivityLifecycleTracker} against the host | ||
| * {@link Application} during {@code Application.onCreate}, before any Activity is created. | ||
| * | ||
| * <p>This does NOT initialize the OneSignal SDK itself: the App ID is supplied at runtime by JS | ||
| * via {@code OneSignal.initialize(appId)}. The job here is purely to capture the current Activity | ||
| * early so that when JS later calls initialize, {@code RNOneSignal} can hand a real Activity to | ||
| * {@code OneSignal.initWithContext}. | ||
| */ | ||
| public class OneSignalInitializer implements Initializer<ActivityLifecycleTracker> { | ||
|
|
||
| @NonNull | ||
| @Override | ||
| public ActivityLifecycleTracker create(@NonNull Context context) { | ||
| ActivityLifecycleTracker tracker = ActivityLifecycleTracker.getInstance(); | ||
| Context appContext = context.getApplicationContext(); | ||
| if (appContext instanceof Application) { | ||
| ((Application) appContext).registerActivityLifecycleCallbacks(tracker); | ||
| } | ||
| return tracker; | ||
| } | ||
|
|
||
| @NonNull | ||
| @Override | ||
| public List<Class<? extends Initializer<?>>> dependencies() { | ||
| return Collections.emptyList(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -238,11 +238,21 @@ public void initialize(String appId) { | |
| } | ||
|
|
||
| ReactApplicationContext reactContext = getReactApplicationContext(); | ||
| Context context = reactContext.getCurrentActivity(); | ||
| // 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(); | ||
| } | ||
|
Comment on lines
+241
to
+249
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| if (context == null) { | ||
| context = reactContext.getApplicationContext(); | ||
| } | ||
|
|
||
| Logging.debug( | ||
| "OneSignal initialize using context: " + context.getClass().getSimpleName(), null); | ||
| OneSignal.initWithContext(context, appId); | ||
| oneSignalInitDone = true; | ||
| } | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.