Skip to content

chore: [SDK-4406] prepare Unity demo app for Appium E2E tests#869

Merged
fadi-george merged 59 commits into
mainfrom
fadi/sdk-4406-use-appium-tests-for-unity
May 18, 2026
Merged

chore: [SDK-4406] prepare Unity demo app for Appium E2E tests#869
fadi-george merged 59 commits into
mainfrom
fadi/sdk-4406-use-appium-tests-for-unity

Conversation

@fadi-george
Copy link
Copy Markdown
Collaborator

@fadi-george fadi-george commented May 15, 2026

Description

One Line Summary

Prepare the Unity demo app for Appium E2E test automation by adding accessibility bridges, stabilizing UI element naming, and improving iOS/Android test reliability.

Details

Motivation

SDK-4406: enable Appium-driven E2E tests against the Unity demo app. Unity's UI Toolkit does not expose native accessibility identifiers, so Appium cannot reliably locate elements on iOS or Android. This PR adds native accessibility bridges (iOS and Android), standardizes UI element names, and fixes a number of demo-side bugs uncovered while wiring up the test harness.

Scope

  • Only examples/demo/ is touched. No SDK source changes.
  • Adds a C# AccessibilityBridge plus iOS (OneSignalDemoKeyboard.mm) and Android (OneSignalUnityE2EAccessibility.java) native helpers used to expose UI elements to Appium.
  • Adds an iOS signing post-processor and arm64 simulator config so the demo builds cleanly on Apple Silicon CI.
  • Demo refactors: removes the in-app LogView/LogManager (replaced with Debug.Log), renames TrackEventSectionController to CustomEventsSectionController, consolidates upsert helpers, replaces the loading overlay with inline loading states, and tightens dialog/keyboard behavior.
  • Fixes stale Android UI Toolkit click targets after dialogs are dismissed, so repeated modal flows route clicks to the current visible element.
  • Gates Android WebView debugging to active IAM display windows in E2E mode, which prevents dismissed IAM WebView contexts from leaking into later Appium checks.
  • Bumps Unity editor version and demo packages.

Testing

Manual testing

  • Built and ran the demo on iOS Simulator (arm64, Apple Silicon) and Android emulator.
  • Verified Appium can locate and interact with home screen sections, dialogs, toggles, and toasts via the accessibility bridge.
  • Smoke-tested core demo flows: login/logout, aliases, tags, triggers, push send, IAM, custom events, location, live activities.
  • Ran Unity Android locally: ./run-local.sh --sdk=unity --platform=android --spec="{01_,02_,03_}".
  • Re-ran Unity Android locally with --skip-build for 01_ through 12_; all numbered specs passed.

Affected code checklist

  • None - demo app only, no SDK changes.

Checklist

Overview

  • I have filled out all REQUIRED sections above
  • PR does one thing (demo prep for Appium E2E)
  • No public API changes

Testing

  • Manually tested on iOS Simulator and Android emulator
  • No SDK behavior changes, so no new unit tests needed

Final pass

  • Code reviewed

Made with Cursor

Comment thread examples/demo/Assets/Scripts/UI/Dialogs/MultiPairInputDialog.cs Outdated
Comment thread examples/demo/Assets/Scripts/Services/AccessibilityBridge.cs Outdated
Comment thread examples/demo/Assets/Scripts/UI/Sections/SectionBuilder.cs
Comment thread examples/demo/Assets/Scripts/UI/Sections/SectionBuilder.cs Outdated
fadi-george and others added 8 commits May 14, 2026 23:54
Row TextField names were derived from `_rows.Count` at construction time,
but RemoveRow does not renumber survivors. After Add+Remove(middle)+Add,
the new row's name collided with an existing survivor's name, and
AccessibilityBridge.WalkAndUpsert dedupes by name (first-wins) — so the
new row was silently dropped from the a11y tree and Appium taps landed
on the stale row. Switch to a monotonic `_nextRowIndex++` so names are
permanently unique regardless of remove ordering.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@fadi-george
Copy link
Copy Markdown
Collaborator Author

@claude review

Comment thread examples/demo/Assets/Scripts/UI/SecondaryScreenController.cs
Comment thread examples/demo/Assets/App/Editor/iOS/BuildPostProcessor.cs
Comment thread examples/demo/Assets/Scripts/Services/AccessibilityBridge.cs
Comment thread examples/demo/Assets/App/Editor/iOS/SigningPostProcessor.cs Outdated
Comment thread examples/demo/Assets/Scripts/Services/AccessibilityBridge.cs Outdated
Comment thread examples/demo/Assets/Scripts/Services/AccessibilityBridge.cs
Comment on lines +56 to 64
case NotificationType.WithSound:
title = "Sound Notification";
body = "This notification plays a custom sound";
extra = new JObject
{
["ios_sound"] = "vine_boom.wav",
["android_channel_id"] = "b3b015d9-c050-4042-8548-dcc34aa44aa4",
};
break;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 The new WithSound notification payload sets ios_sound to vine_boom.wav, but no such file exists in the repo (only notification.wav under Android raw resources) and no iOS build post-processor copies a sound asset into the Xcode project. iOS requires the named UNNotificationSound resource to exist in the app bundle; when absent it silently falls back to the default system sound, so the new WITH SOUND button cannot deliver its advertised custom sound on iOS. Either bundle the .wav under Assets/Plugins/iOS/ and wire it through BuildPostProcessor (AddFile + AddFileToBuildSection on the main target), or remove the WithSound option until the asset pipeline is in place.

Extended reasoning...

What the bug is

OneSignalApiService.SendNotification (OneSignalApiService.cs:56-64) now handles a new NotificationType.WithSound case that builds a payload setting:

case NotificationType.WithSound:
    title = "Sound Notification";
    body = "This notification plays a custom sound";
    extra = new JObject
    {
        ["ios_sound"] = "vine_boom.wav",
        ["android_channel_id"] = "b3b015d9-c050-4042-8548-dcc34aa44aa4",
    };
    break;

OneSignal's ios_sound parameter names a sound file that must be packaged in the app bundle as a UNNotificationSound resource. If the named file is not in the bundle, iOS does not raise an error — it silently falls back to the default system sound.

Why it manifests

A repo-wide search for vine_boom finds the string literal only at OneSignalApiService.cs:61. The only audio assets present anywhere in the repository are notification.wav under com.onesignal.unity.android/Editor/OneSignalConfig.androidlib/src/main/res/raw/ (and its mirror under examples/demo/Assets/Plugins/Android/...). There is no vine_boom.wav (or any other .wav / .caf / .aiff) in examples/demo/Assets/Plugins/iOS/, StreamingAssets, or the iOS plugin folder.

The new SigningPostProcessor only touches entitlements / bundle IDs / signing, and BuildPostProcessor only handles the Live Activities widget extension and its Podfile target — neither adds a sound asset via PBXProject.AddFile + AddFileToBuildSection on the main target, and no CopyResourceBundle is wired up. So when xcodebuild produces the .app, no vine_boom.wav ends up under MyApp.app/.

Step-by-step proof

  1. User taps WITH SOUND (send_sound_button, SendPushSectionController.cs:50-52).
  2. AppViewModel.SendNotification(NotificationType.WithSound) calls _apiService.SendNotification(...).
  3. OneSignalApiService.SendNotification builds the payload with "ios_sound": "vine_boom.wav" and POSTs it to /api/v1/notifications.
  4. OneSignal delivers the push to APNs; the APS payload contains "sound": "vine_boom.wav".
  5. iOS UNNotificationSound(named: "vine_boom.wav") walks the app bundle, finds nothing, and silently falls back to the default notification sound.
  6. The user hears the default iOS chime, not a custom "vine boom" — the labeled behavior of the button ("plays a custom sound") does not happen on iOS.

Why existing code doesn't prevent it

There is no other dispatch path: Assets/Plugins/iOS/ contains only OneSignalDemoKeyboard.mm. The iOS plugin folder has no audio assets and no .bundle. BuildPostProcessor.cs adds the widget extension and rewrites the Podfile, but never references a sound file. Nothing in the build pipeline catches the missing asset.

Impact

Demo-only, and the push still delivers (with the default sound). E2E specs that only assert the API call happens still pass. The user-visible deficit is real but minor: a developer evaluating the demo, or a test that asserts sound semantics, would observe the wrong sound. Severity = nit.

Fix

Either:

  1. Ship vine_boom.wav (or any other custom sound) under examples/demo/Assets/Plugins/iOS/ with a .meta, then in BuildPostProcessor add it to the main target:
    var soundGuid = project.AddFile("vine_boom.wav", "vine_boom.wav");
    project.AddFileToBuild(project.GetUnityMainTargetGuid(), soundGuid);
  2. Or remove NotificationType.WithSound and the WITH SOUND button until the asset pipeline is in place.

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.

Addressed.

Comment on lines 440 to 448

public async void EndLiveActivity(string activityId)
{
if (string.IsNullOrEmpty(activityId) || _isLiveActivityUpdating)
if (string.IsNullOrEmpty(activityId))
return;

_isLiveActivityUpdating = true;
NotifyStateChanged();

try
{
var eventUpdates = new JObject
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Nit: EndLiveActivity dropped its _isLiveActivityUpdating early-return guard and set/clear bookends (AppViewModel.cs:440-474), and LiveActivitiesSectionController.RefreshButtonStates dropped && !_viewModel.IsLiveActivityUpdating from the End button (line 107), while UpdateLiveActivity still uses both guards. Rapid taps on End now fire N concurrent POSTs to /api/v1/notifications consuming rate-limit credits. The asymmetry with the still-guarded Update path reads as an oversight from the toast-removal refactor (the removed lines were adjacent to deleted ShowToast calls) — either restore the guard on End for consistency, or add a code comment documenting the deliberate asymmetry.

Extended reasoning...

What the asymmetry is

AppViewModel.UpdateLiveActivity at lines 400–439 still uses both arms of the in-flight guard:

if (string.IsNullOrEmpty(activityId) || _isLiveActivityUpdating)
    return;

_isLiveActivityUpdating = true;
NotifyStateChanged();
// ... await _apiService.UpdateLiveActivity(activityId, "update", ...) ...
_isLiveActivityUpdating = false;
NotifyStateChanged();

EndLiveActivity at lines 440–474, however, has only the activity-id null-check. The previous || _isLiveActivityUpdating clause and the matching _isLiveActivityUpdating = true/false; NotifyStateChanged(); bookends were removed in this PR. The companion UI change at LiveActivitiesSectionController.cs:107 reduced _endButton?.SetEnabled(hasActivityId && hasApiKey && !_viewModel.IsLiveActivityUpdating) to just _endButton?.SetEnabled(hasActivityId && hasApiKey), while the Update button at line 102 kept the full clause.

Step-by-step proof of the consequence

  1. User starts a Live Activity. _isLiveActivityUpdating = false, End button enabled.
  2. User rapid-taps End twice within ~50 ms (or a fast E2E spec does element.click(); element.click();).
  3. First tap: OnEndTap_viewModel.EndLiveActivity(activityId). The new entry guard checks only string.IsNullOrEmpty(activityId) → false → proceeds. It awaits _apiService.UpdateLiveActivity(activityId, "end", …), which builds the JSON payload with dismissal_date = DateTimeOffset.UtcNow.ToUnixTimeSeconds() and POSTs to https://api.onesignal.com/api/v1/notifications.
  4. Before that await resumes, the second tap fires. _isLiveActivityUpdating is never set, so there is no flag to short-circuit on. The End button is still enabled (the UI guard was also stripped). Same code path runs again: builds a new JSON payload with a fresh dismissal_date and POSTs a second concurrent request.
  5. The OneSignal end-event API is logically idempotent, so the final state converges, but both requests consume rate-limit credits against /api/v1/notifications and emit slightly-different dismissal_date timestamps in their bodies.

Why existing code doesn't prevent it

OnEndTap (LiveActivitiesSectionController.cs:129) dispatches straight to _viewModel.EndLiveActivity(activityId) with no debounce. The only flow-control on the End path used to be _isLiveActivityUpdating, and this PR removed both the model-side check and the UI-side disable in the same commit. Update kept both.

Addressing the refutation

A reviewer argued the change is intentional: End is a terminal operation and the OneSignal end event is idempotent, so duplicate calls converge to the same state; further, removing the guard lets users escape a stuck Update by hitting End, which is the better UX.

That is a plausible reading and I agree the functional outcome is benign — no data loss, no SDK behavior change, the UI eventually shows status_delivered. But (a) the PR description doesn't mention live-activity changes and lists only the Appium-prep scope, (b) the deleted lines sit immediately adjacent to deleted ShowToast calls in the diff, which is the cluster you'd expect from a mechanical toast-removal sweep, and (c) if the asymmetry is deliberate, a one-line comment (// End is idempotent; intentionally re-entrant so a stuck Update can be canceled) costs nothing and prevents a future reader from restoring symmetry by accident.

Impact

Demo-only, no SDK source changes. Worst case is N redundant POSTs to /api/v1/notifications consuming rate-limit credits on rapid double-tap. Filing at nit severity — either restore the guard for consistency, or annotate the intentional asymmetry so a future maintainer doesn't read it as a bug.

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.

Addressed.

@fadi-george fadi-george requested a review from nan-li May 18, 2026 17:59
fadi-george and others added 2 commits May 18, 2026 11:14
- Add vine_boom.wav for both Android (res/raw) and iOS (iOS/Sounds/)
  matching the convention used by the Flutter/RN/Cordova demos so the
  WITH SOUND notification actually resolves a UNNotificationSound resource.
- Wire the iOS sound through BuildPostProcessor by registering vine_boom.wav
  on the Unity main target's Resources phase (idempotent across appends).
- Restore the _isLiveActivityUpdating in-flight guard and matching End-button
  UI disable so rapid taps on End no longer fire concurrent POSTs.

Co-authored-by: Cursor <cursoragent@cursor.com>
Unused sample sound asset added in 2023 with no consumers in code or
docs across the repo or demo. Safe to delete; OneSignal channels reference
sound files by name from the app's own res/raw, not from this asset.

Co-authored-by: Cursor <cursoragent@cursor.com>
@fadi-george fadi-george merged commit db420a7 into main May 18, 2026
4 checks passed
@fadi-george fadi-george deleted the fadi/sdk-4406-use-appium-tests-for-unity branch May 18, 2026 18:28
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