chore: [SDK-4406] prepare Unity demo app for Appium E2E tests#869
chore: [SDK-4406] prepare Unity demo app for Appium E2E tests#869fadi-george wants to merge 44 commits into
Conversation
| var keyField = new TextField(); | ||
| keyField.name = $"multi_key_{_rows.Count}"; | ||
| keyField.name = $"multipair_key_{_rows.Count}"; | ||
| keyField.AddToClassList("input-field"); | ||
| keyField.AddToClassList("dialog-field-group-left"); | ||
| keyField.textEdition.placeholder = _keyLabel; | ||
| keyField.RegisterValueChangedCallback(_ => ValidateAll()); | ||
|
|
||
| var valueField = new TextField(); | ||
| valueField.name = $"multi_value_{_rows.Count}"; | ||
| valueField.name = $"multipair_value_{_rows.Count}"; |
There was a problem hiding this comment.
🔴 MultiPairInputDialog names new TextFields using $"multipair_key_{_rows.Count}" / $"multipair_value_{_rows.Count}", but RemoveRow removes an entry from _rows without renumbering the survivors. After Add×3 → Remove(middle) → Add, the new row collides with the still-present original row 2, and AccessibilityBridge.WalkAndUpsert dedupes by name (first-wins), so the new row is silently dropped from the a11y tree and Appium taps targeting multipair_key_2 land on the stale original row. Fix: use a monotonic counter (_nextRowIndex++) instead of _rows.Count, or renumber survivors on remove.
Extended reasoning...
Bug
MultiPairInputDialog.AddRow at examples/demo/Assets/Scripts/UI/Dialogs/MultiPairInputDialog.cs:92-100 names the new row's TextFields:
keyField.name = $"multipair_key_{_rows.Count}";
// ...
valueField.name = $"multipair_value_{_rows.Count}";The suffix is _rows.Count captured before the new entry is appended (line 116 _rows.Add(entry)). RemoveRow at line 134 removes an entry from _rows and rebuilds _rowsContainer from the remaining rows, but it never renames the survivors — they keep their original suffixes.
Why it manifests
This only matters because the PR's new AccessibilityBridge.WalkAndUpsert (Assets/Scripts/Services/AccessibilityBridge.cs ~line 895-898) dedupes by name:
if (seenNames.Add(name))
{
// ... AddNode / re-bind ...
_map[el] = node;
}HashSet<string>.Add returns false for duplicates, so the second VisualElement with the same name is silently skipped — no accessibility node, no Android sync entry. _rowsContainer is walked in order, so the original row (added first, still present after RemoveRow re-adds in _rows order) wins, and the newly-added row is invisible to XCUITest / UiAutomator2.
Step-by-step proof
- Open Add Multiple Tags dialog.
AddRowruns once during BuildContent (line 65):_rows.Count == 0→ createsmultipair_key_0/multipair_value_0. - Tap "Add Row" (
multipair_add_row_button).InvokeAddRow→AddRow:_rows.Count == 1→ createsmultipair_key_1/multipair_value_1. - Tap "Add Row" again.
_rows.Count == 2→ createsmultipair_key_2/multipair_value_2._rowsis now[r0, r1, r2]. - Type "old_value" into row 2 (
multipair_value_2). - Tap the close button on row 1 (the middle row).
RemoveRow(r1)calls_rows.Remove(entry), then clears and re-adds_rowsContainerfrom_rows._rowsis now[r0, r2]. The surviving rows still carry namesmultipair_key_0/2andmultipair_value_0/2. - Tap "Add Row".
_rows.Count == 2→ creates a newmultipair_key_2/multipair_value_2— duplicate of the surviving r2. - AccessibilityBridge's next BuildHierarchy walks
_rowsContainerin tree order (the old r2 was added before the new row), soseenNames.Add("multipair_key_2")returns true for the old r2 and false for the new one. Only the old r2 is published to the a11y tree. - Test types into
multipair_key_2expecting the freshly-added empty row → input lands on the stale row carrying "old_value".
Why existing code doesn't prevent it
There is no rename step in RemoveRow, and AddRow reads _rows.Count rather than tracking a monotonically-increasing index. The dedup in WalkAndUpsert is intentional (the comment explicitly says "first one wins; only one accessibility node per name to keep ids unambiguous"), so the bridge cannot disambiguate post-hoc.
Impact
E2E tests that exercise the multi-pair dialogs (Add Multiple Aliases / Tags / Triggers) and perform a remove-then-add sequence will silently target the wrong row, producing a wrong-target failure instead of a clean error. Demo-only — no SDK impact. The PR description explicitly identifies preparing the demo for Appium E2E tests as the motivating goal, and remove-then-add is a plausible test scenario for these dialogs.
Fix
One-line fix: replace _rows.Count in the two $"multipair_..." interpolations with a monotonic counter:
private int _nextRowIndex;
// ...
keyField.name = $"multipair_key_{_nextRowIndex}";
valueField.name = $"multipair_value_{_nextRowIndex}";
_nextRowIndex++;Alternative: renumber surviving rows in RemoveRow after the _rowsContainer.Clear() + replay.
| // One-shot ScrollView taming: BuildHierarchy can run dozens of | ||
| // times per session (every dialog open/close, every section | ||
| // refresh), but the main ScrollView only needs these hooks once. | ||
| // The flag is reset only by OnDestroy — a fresh bridge instance | ||
| // re-applies them. | ||
| if (!_scrollViewHooksInstalled) | ||
| { | ||
| var mainSv = _root.Q<ScrollView>("main_scroll_view"); | ||
| if (mainSv != null) | ||
| { | ||
| _mainSv = mainSv; | ||
| _lastScrollOffset = mainSv.scrollOffset; | ||
| _lastScrollChangeMs = -1.0; | ||
| _scrollLayoutChangePending = false; | ||
| InstallScrollViewE2EHooks(mainSv); | ||
| _scrollViewHooksInstalled = true; | ||
| } | ||
| } |
There was a problem hiding this comment.
🔴 AccessibilityBridge is a DontDestroyOnLoad singleton and _scrollViewHooksInstalled / _mainSv are never reset across scenes. After the Main → Secondary → Main flow wired up by the new next_screen_button and back_button, Initialize re-runs BuildHierarchy on the new root but the if (!_scrollViewHooksInstalled) guard at line 466 skips re-finding main_scroll_view — so the new ScrollView never gets WheelEvent blocking, touchScrollBehavior = Clamped, decel/elasticity = 0, or the pointer-activity watchdog, and _mainSv keeps pointing at the now-orphaned ScrollView from the previous Main scene. Fix is to clear _scrollViewHooksInstalled and _mainSv at the top of Initialize (or detect a _root change), so the hooks are re-installed on the new ScrollView.
Extended reasoning...
What the bug is
AccessibilityBridge.EnableForE2E creates its singleton with DontDestroyOnLoad (line 161), so the bridge instance survives scene transitions. On every subsequent EnableForE2E(root) the if (_instance == null) branch is skipped and only Initialize(root) runs. Initialize sets _root = root and calls BuildHierarchy(), but it does not reset _scrollViewHooksInstalled (or clear _mainSv). The one-shot ScrollView taming block in BuildHierarchy (lines 461–478) is therefore skipped on the second Main load, and _mainSv still points at the orphaned ScrollView from the previous Main scene.
The inline comment at line 464 (The flag is reset only by OnDestroy — a fresh bridge instance re-applies them.) is incorrect for this codebase: OnDestroy on a DontDestroyOnLoad singleton only fires on application quit, never on scene unload.
Trigger path
The demo wires up a real multi-scene flow:
HomeScreenController.BuildSections()adds anext_screen_buttonthat callsSceneManager.LoadScene("Secondary")(HomeScreenController.cs:253).SecondaryScreenController.OnEnableadds aback_buttonthat callsSceneManager.LoadScene("Main")(SecondaryScreenController.cs:27).- Both controllers call
AccessibilityBridge.EnableForE2E(root)in theirOnEnable(HomeScreenController.cs:64, SecondaryScreenController.cs:58).
Step-by-step proof (Main → Secondary → Main)
- First Main load.
HomeScreenController.OnEnablerunsEnableForE2E(R1). The singleton is created,Initialize(R1)runs,BuildHierarchy()findsmain_scroll_viewS1, callsInstallScrollViewE2EHooks(S1), sets_mainSv = S1, and sets_scrollViewHooksInstalled = true. S1 now hasWheelEvent.StopImmediatePropagation,touchScrollBehavior = Clamped,scrollDecelerationRate = 0,elasticity = 0, and the PointerDown/Move/Up activity watchdog. - Navigate to Secondary.
next_screen_buttoncallsLoadScene("Secondary"). The Main scene's UIDocument is destroyed, so S1 is destroyed. The bridge GameObject persists (DontDestroyOnLoad).SecondaryScreenController.OnEnablerunsEnableForE2E(R2);Initialize(R2)sets_root = R2and runsBuildHierarchy(). Theif (!_scrollViewHooksInstalled)guard is false, so the block is skipped — but Secondary has nomain_scroll_viewanyway, so this is benign here. - Navigate back to Main.
back_buttoncallsLoadScene("Main"). A fresh Main scene loads with a new ScrollView S3.HomeScreenController.OnEnablerunsEnableForE2E(R3). The singleton already exists, so onlyInitialize(R3)runs.Initializesets_root = R3and runsBuildHierarchy(). The guardif (!_scrollViewHooksInstalled)is stilltruefrom step 1 → the block at lines 466–478 is skipped entirely. S3 receives none of the taming hooks, and_mainSvis still pointing at the destroyed S1.
Consequences on the second Main load
- iOS XCUITest implicit-scroll resurfaces.
InstallScrollViewE2EHookswas specifically designed to swallow the synthetic WheelEvent that XCUITest injects before tap dispatch (see the doc-comment onInstallScrollViewE2EHooks). S3 has no WheelEvent handler, so the tap-shift bug returns. - Bounce/inertia mid-tap. S3 keeps engine-default
touchScrollBehavior,scrollDecelerationRate, andelasticity, soPostPointerUpAnimationruns during the next queued tap. - Stale
_mainSvreferences.Update'sRefreshScrollAccessibilityStatereads_mainSv.scrollOffsetoff the orphaned S1 every frame.InvokeAndroidNativeScroll(Android) operates on S1, so UiAutomator2 swipes scroll the dead element instead of S3. TheResetScrollViewPanStatewatchdog reflects into S1'sm_PointerCaptureScheduled/m_Pressedfields, leaving S3's real stuck-capture state untouched._mainSvis aVisualElement(not aUnityEngine.Object), soif (_mainSv == null)guards never trip — the reference simply outlives the panel that owned it. - Touch-activity watchdog is neutered. The PointerDown/Move/Up callbacks are registered on S1; S3's events never feed
_lastTouchActivityMs, so the stuck-capture timer can't fire on the live ScrollView.
Severity
Demo-only, but the PR's stated motivation is "enable Appium-driven E2E tests against the Unity demo app" and this directly defeats that on any multi-scene test path. A test that exercises NEXT SCREEN → back loses every guarantee InstallScrollViewE2EHooks was written to deliver.
Fix
Reset _scrollViewHooksInstalled = false; _mainSv = null; at the top of Initialize (or, more precisely, only when root != _root), so BuildHierarchy re-finds main_scroll_view and re-installs the hooks against the new ScrollView instance.
| var infoBtn = new Label(MaterialIcons.Info); | ||
| infoBtn.name = $"{SectionKeyFromName(name)}_info_icon"; | ||
| infoBtn.AddToClassList("info-button"); | ||
| infoBtn.pickingMode = PickingMode.Position; | ||
| AccessibilityBridge.RegisterE2ETapTarget( | ||
| infoBtn, | ||
| () => infoBtn.panel?.visualTree.Q("tooltip_title") == null, | ||
| onInfoTap | ||
| ); | ||
| header.Add(infoBtn); |
There was a problem hiding this comment.
🔴 The info icon (*_info_icon) was changed from new Button(onInfoTap) to a plain new Label(MaterialIcons.Info) whose tap is dispatched exclusively through AccessibilityBridge.RegisterE2ETapTarget. That dispatch path is fully gated by E2E mode (and is a compile-time no-op under UNITY_EDITOR), so in any non-E2E run — Editor play, plus any device build with the default E2E_MODE=false — tapping any section's info icon does nothing and the tooltip dialog never opens. Fix by registering an unconditional ClickEvent/PointerDownEvent callback on the Label in addition to the E2E bridge registration (or restore the Button).
Extended reasoning...
What changed
In examples/demo/Assets/Scripts/UI/Sections/SectionBuilder.cs:38-47 the info icon was changed from a Button(onInfoTap) to a plain Label(MaterialIcons.Info):
var infoBtn = new Label(MaterialIcons.Info);
infoBtn.name = $"{SectionKeyFromName(name)}_info_icon";
infoBtn.AddToClassList("info-button");
infoBtn.pickingMode = PickingMode.Position;
AccessibilityBridge.RegisterE2ETapTarget(
infoBtn,
() => infoBtn.panel?.visualTree.Q("tooltip_title") == null,
onInfoTap
);The previous Button(Action) constructor wired onInfoTap through UI Toolkit's built-in Clickable manipulator, which fires on every platform/run-mode. A plain Label has no Clickable and no direct RegisterCallback<ClickEvent> / RegisterCallback<PointerDownEvent> — so the only wiring left is AccessibilityBridge.RegisterE2ETapTarget.
Why that dispatch path is empty outside E2E mode
AccessibilityBridge.RegisterE2ETapTarget (AccessibilityBridge.cs:179-202) has the whole body wrapped in #if UNITY_ANDROID && !UNITY_EDITOR / #elif UNITY_IOS && !UNITY_EDITOR, so in the Unity Editor (regardless of build target) the call compiles to a no-op.
On an iOS device build the function stores the action in _iosInfoTapByName, but the actual dispatch happens via the panel-root TrickleDown handler _onIosInfoIconPointerDown. That handler is only registered inside BuildHierarchy (visualTree.RegisterCallback(_onIosInfoIconPointerDown, ...)). BuildHierarchy runs only from Initialize (line 227), which runs only from EnableForE2E (line 165). And EnableForE2E early-returns at lines 150-156 when !DotEnv.IsE2EMode:
if (!DotEnv.IsE2EMode)
{
#if UNITY_ANDROID && !UNITY_EDITOR
Debug.LogWarning("[OneSignalDemo] E2E accessibility bridge disabled");
#endif
return;
}The Android route is gated identically: dispatch goes through the OneSignalUnityE2EAccessibility Java overlay populated by SyncAndroidNativeAccessibility, which runs only from the same E2E-gated Initialize path. .env.example ships E2E_MODE=false, and a missing key defaults to empty/false in DotEnv.IsE2EMode.
Step-by-step proof — Editor play of the demo
AppBootstrapper.StartcallsDotEnv.Load().E2E_MODEisfalse(default).HomeScreenController.OnEnablecallsAccessibilityBridge.EnableForE2E(_root). Hits the!DotEnv.IsE2EModebranch and returns._instanceis never created; the iOS panel-root handler is never registered.SectionBuilder.CreateSection("Push", "push_section", ...)constructspush_info_iconas a Label and callsRegisterE2ETapTarget. Both#ifbranches are excluded in the Editor — no-op. Even on a non-E2E device build, the entry that does get stored in_iosInfoTapByNameis unreachable because no panel-root handler exists to read it.- User taps
push_info_icon. UI Toolkit hit-tests to the Label (PickingMode.Positionis set), noClickablemanipulator runs, noClickEvent/PointerDownEventcallback is registered, no panel-root handler exists — the tap is silently absorbed. Tooltip dialog never opens. Same outcome foriam_info_icon,aliases_info_icon,tags_info_icon,triggers_info_icon, etc.
A grep for ClickEvent / PointerDownEvent / Clickable handlers on info icons or the info-button class returns nothing else — there is no other dispatch path.
Impact
Demo-only regression, but it silently breaks the tooltip feature for every developer running the demo outside the E2E pipeline — Editor play sessions and any public non-E2E device build. Those are exactly the discoverability paths an SDK user takes when learning the demo.
Fix
Register an unconditional event callback on the Label in addition to the E2E bridge, e.g.:
infoBtn.RegisterCallback<ClickEvent>(_ => onInfoTap());(or restore new Button(onInfoTap) and re-style the button). This preserves the E2E dispatch path while making the tooltip work in Editor play and in non-E2E device builds.
| deleteBtn.name = $"{sectionKey}_remove_{itemKey}"; | ||
| deleteBtn.text = MaterialIcons.Close; | ||
| deleteBtn.AddToClassList("delete-button"); | ||
| AccessibilityBridge.RegisterE2ETapTarget(deleteBtn, () => true, onDelete); |
There was a problem hiding this comment.
🟡 Indentation nit: AccessibilityBridge.RegisterE2ETapTarget(deleteBtn, () => true, onDelete); is indented at 12 spaces in SectionBuilder.cs:167 (inside CreateKeyValueItem's if (onDelete != null) block) and SectionBuilder.cs:223 (inside CreateSingleItem's if (onDelete != null) block), while the sibling statements use 16 spaces. Purely cosmetic — braces enclose it correctly so behavior is unaffected, but the line visually pops out of the if-block. A re-indent (or dotnet format pass) fixes both.
Extended reasoning...
What and where
In SectionBuilder.CreateKeyValueItem (line 167) and SectionBuilder.CreateSingleItem (line 223), the newly-added AccessibilityBridge.RegisterE2ETapTarget(deleteBtn, () => true, onDelete); call is indented at column 12 (matching the enclosing if keyword), while the sibling statements around it — deleteBtn.text = ..., deleteBtn.AddToClassList("delete-button"), and item.Add(deleteBtn) — are at column 16, the expected inner indent for statements inside if (onDelete != null) { ... }.
Step-by-step proof (CreateKeyValueItem, lines 160-169 of SectionBuilder.cs):
if (onDelete != null) // col 12 — if keyword
{ // col 12 — open brace
var deleteBtn = new Button(onDelete); // col 16 — sibling
if (sectionKey != null && itemKey != null) // col 16 — sibling
deleteBtn.name = $"{sectionKey}_remove_{itemKey}"; // col 20 — nested
deleteBtn.text = MaterialIcons.Close; // col 16 — sibling
deleteBtn.AddToClassList("delete-button"); // col 16 — sibling
AccessibilityBridge.RegisterE2ETapTarget(deleteBtn, () => true, onDelete); // col 12 — OFF
item.Add(deleteBtn); // col 16 — sibling
} // col 12 — close brace
The same pattern repeats at lines 218-224 in CreateSingleItem.
Why behavior is unaffected
C# treats whitespace as insignificant; the braces from the surrounding if (onDelete != null) { ... } unambiguously include this statement. deleteBtn is declared inside the same block, so the call would not even compile if it had really escaped the if. Both call sites compile and run correctly — this is purely visual.
Why it still matters
A reader scanning the method sees a statement at the if keyword's column and naturally reads it as belonging to the outer scope. That bias makes the next maintainer hesitate when answering "is this called only when onDelete is non-null?". In a file that is otherwise consistently formatted at 16-space inner indentation, the inconsistency reads as a bug even though it isn't.
Fix
Add four spaces to the start of each affected line (167 and 223), or run dotnet format over the file. Two-character edit per line, no functional change. Severity: nit.
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
examples/demo/is touched. No SDK source changes.AccessibilityBridgeplus iOS (OneSignalDemoKeyboard.mm) and Android (OneSignalUnityE2EAccessibility.java) native helpers used to expose UI elements to Appium.LogView/LogManager(replaced withDebug.Log), renamesTrackEventSectionControllertoCustomEventsSectionController, consolidates upsert helpers, replaces the loading overlay with inline loading states, and tightens dialog/keyboard behavior.Testing
Manual testing
Affected code checklist
Checklist
Overview
Testing
Final pass
Made with Cursor