From 3a142cdefcd3eecb1bbf7a5d0812e90605259936 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 30 Sep 2026 15:22:51 -0700 Subject: [PATCH] Add test-mode animation switch for UI tests Debug iOS builds read the EdgeTestAnimations user default (the -EdgeTestAnimations launch argument). off turns UIKit animations off and fast runs the window's Core Animation clock 100x. Either value also mounts Reanimated's ReducedMotionConfig with ReduceMotion.Always, so withRepeat loops (Shimmer, ShimmerCard, SwipeChart) stop after one cycle, and freezes UIActivityIndicatorView spinners in place, so screenshots settle between automated steps. RN core Animated is only used for finite fades here, so it needs no change. Release builds and Android ignore the switch. --- CHANGELOG.md | 1 + ios/edge/SceneDelegate.swift | 53 ++++++++++++++++++++++++++++++++++++ src/components/App.tsx | 9 ++++++ src/util/testAnimations.ts | 15 ++++++++++ 4 files changed, 78 insertions(+) create mode 100644 src/util/testAnimations.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 597e7c137fb..54a5ff01249 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased (develop) +- added: iOS debug-build `EdgeTestAnimations` switch to turn off or speed up animations and freeze spinners in UI tests - added: `YOLO_OTP_KEY` env setting, which lets auto-login reach a 2FA-protected account on a device that has no login stash for it yet. - added: Slow-sync explainer card on Bitcoin-family wallets with a long transaction history. - added: Logbox disable option to env.json diff --git a/ios/edge/SceneDelegate.swift b/ios/edge/SceneDelegate.swift index d3d4b45681e..8406f66b0b7 100644 --- a/ios/edge/SceneDelegate.swift +++ b/ios/edge/SceneDelegate.swift @@ -37,6 +37,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { let window = UIWindow(windowScene: windowScene) self.window = window appDelegate.window = window + Self.applyTestAnimations(to: window) // A reconnecting scene already has React Native running, and starting it // again would mount a second copy of the app, so reuse the root view @@ -149,6 +150,27 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } } + /** + * Test-mode animation switch for automated UI runs, read from the + * `EdgeTestAnimations` user default (the `-EdgeTestAnimations off|fast` + * launch argument). `off` makes UIKit animations complete immediately and + * `fast` runs the window's Core Animation clock 100x. Either value freezes + * activity spinners. Debug builds only. Edge addition. + */ + private static func applyTestAnimations(to window: UIWindow) { + #if DEBUG + switch UserDefaults.standard.string(forKey: "EdgeTestAnimations") { + case "off": + UIView.setAnimationsEnabled(false) + case "fast": + window.layer.speed = 100 + default: + return + } + UIActivityIndicatorView.freezeForTests() + #endif + } + /** * Rebuilds the launch options React Native expects from the scene's * connection options. @@ -181,3 +203,34 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { return [:] } } + +#if DEBUG +/** + * Test-mode spinners stay visible but stop turning, so a loading screen + * settles for screenshot comparisons. Edge addition. + */ +extension UIActivityIndicatorView { + private static let swapStartAnimating: Void = { + guard + let original = class_getInstanceMethod( + UIActivityIndicatorView.self, #selector(startAnimating)), + let frozen = class_getInstanceMethod( + UIActivityIndicatorView.self, #selector(frozenStartAnimating)) + else { + return + } + method_exchangeImplementations(original, frozen) + }() + + /** Swaps `startAnimating` once, however many scenes connect. */ + static func freezeForTests() { + _ = swapStartAnimating + } + + /** After the swap, the inner call runs UIKit's own `startAnimating`. */ + @objc private func frozenStartAnimating() { + frozenStartAnimating() + layer.speed = 0 + } +} +#endif diff --git a/src/components/App.tsx b/src/components/App.tsx index 5f73973ea2d..3e7f5e0ca11 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -5,9 +5,11 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import * as React from 'react' import { StyleSheet } from 'react-native' import { GestureHandlerRootView } from 'react-native-gesture-handler' +import { ReducedMotionConfig, ReduceMotion } from 'react-native-reanimated' import { SafeAreaProvider } from 'react-native-safe-area-context' import { useHandler } from '../hooks/useHandler' +import { getTestAnimationsMode } from '../util/testAnimations' import { CrashScene } from './scenes/CrashScene' import { EdgeCoreManager } from './services/EdgeCoreManager' import { QuickActionsManager } from './services/QuickActionsManager' @@ -23,6 +25,10 @@ const queryClient = new QueryClient({ } }) +// Automated UI runs finish Reanimated loops (shimmers, chart pulses) after +// one cycle so the screen can settle between steps: +const testAnimationsMode = getTestAnimationsMode() + const MainApp: React.FC = () => { const handleBeforeCapture = useHandler((scope: Scope) => { scope.setLevel('fatal') @@ -34,6 +40,9 @@ const MainApp: React.FC = () => { + {testAnimationsMode == null ? null : ( + + )} } diff --git a/src/util/testAnimations.ts b/src/util/testAnimations.ts new file mode 100644 index 00000000000..65c88c35212 --- /dev/null +++ b/src/util/testAnimations.ts @@ -0,0 +1,15 @@ +import { Platform, Settings } from 'react-native' + +export type TestAnimationsMode = 'off' | 'fast' + +/** + * Test-mode animation switch for automated UI runs, read from the + * `EdgeTestAnimations` user default (the `-EdgeTestAnimations off|fast` + * launch argument, or a value the test harness wrote to the simulator). + * Debug builds on iOS only; always null elsewhere. + */ +export const getTestAnimationsMode = (): TestAnimationsMode | null => { + if (!__DEV__ || Platform.OS !== 'ios') return null + const mode: unknown = Settings.get('EdgeTestAnimations') + return mode === 'off' || mode === 'fast' ? mode : null +}