Files
paseo/docs/MOBILE_TESTING.md
Mohamed Boudra 4896cfe970 fix(app): fix sidebar crash when switching iOS theme
Unistyles' StyleSheet.create((theme) => ...) wraps styled components in
<UnistylesComponent> and patches native view properties via C++. When
these dynamic styles are applied to Reanimated's Animated.View, a theme
change causes both systems to fight over the same native node, crashing
with "Unable to find node on an unmounted component."

Fix: use plain React Native StyleSheet for static positioning on
Animated.View, and pass theme-dependent values (backgroundColor) as
inline styles from useUnistyles() instead of Unistyles dynamic sheets.

Also:
- Add gestureAnimatingRef to prevent double withTiming during gesture opens
- Remove all debug instrumentation added during investigation
- Add Maestro flow and bash script for automated theme-toggle verification
- Add docs/MOBILE_TESTING.md covering Maestro patterns, self-verification
  loops, the Unistyles+Reanimated pitfall, and simulator commands
2026-04-04 13:21:31 +07:00

6.0 KiB

Mobile Testing

Maestro

Maestro flows live in packages/app/maestro/. Reusable sub-flows live in packages/app/maestro/flows/.

Run a flow:

maestro test packages/app/maestro/my-flow.yaml

Screenshots

takeScreenshot writes to the current working directory — there's no way to configure the output path in the YAML. To keep screenshots out of the checkout, cd into a temp directory and use an absolute path for the flow:

FLOW="$(pwd)/packages/app/maestro/my-flow.yaml"
mkdir -p /tmp/maestro-out
cd /tmp/maestro-out && maestro test "$FLOW"

packages/app/maestro/.gitignore excludes *.png as a safety net.

Element targeting

Use testID or nativeID on components, then target with id: in flows. Prefer this over text matching — text breaks on copy changes.

// Component
<Pressable testID="sidebar-sessions" onPress={onPress}>
# Flow
- tapOn:
    id: "sidebar-sessions"
- assertVisible:
    id: "sidebar-sessions"

Conditional steps

Use runFlow:when:visible for steps that should only execute when a specific element is on screen:

- runFlow:
    when:
      visible:
        id: "sidebar-sessions"
    commands:
      - swipe:
          direction: LEFT
          duration: 300

This is how flows/dev-client.yaml handles Expo dev client screens that only appear in dev builds.

Don't use launchApp against a running dev app

launchApp kills and restarts the app, disrupting Expo dev client state and host connections. For flows that test against an already-running dev app, omit launchApp entirely — just interact with whatever is on screen.

Use launchApp only in flows that need a clean start (e.g., onboarding tests).

Swipe gestures

Use start/end with percentage coordinates for precise control:

# Edge swipe from left to open sidebar
- swipe:
    start: "5%,50%"
    end: "80%,50%"
    duration: 300

direction: RIGHT is simpler but less precise — use it for generic swipes, use coordinates when the start position matters (edge gestures, avoiding specific UI regions).

Assertions

assertVisible checks actual screen visibility, not just view tree presence. An element that exists in the tree but is off-screen (e.g., translateX: -400) will correctly fail assertVisible. This makes it reliable for catching animation bugs where state says "open" but the view is visually hidden.

For async elements, use extendedWaitUntil:

- extendedWaitUntil:
    visible: ".*Online.*"
    timeout: 90000

Dev client handling

Two reusable flows handle Expo dev client screens after launch:

  • flows/launch.yaml — handles dev launcher, dismisses dev menu, asserts "Welcome to Paseo"
  • flows/dev-client.yaml — same but without asserting a particular app route

Self-verification loops

Maestro can only interact with the app UI — it can't toggle iOS appearance, change locale, or simulate network conditions. For bugs that depend on system-level state, wrap Maestro in a bash script that handles the system changes between Maestro runs.

This pattern also lets agents self-verify fixes without manual user testing.

Pattern

  1. Run baseline Maestro flow (confirm feature works)
  2. Make system-level change via xcrun simctl (toggle appearance, etc.)
  3. Re-run Maestro flow (confirm feature still works)
  4. Repeat N iterations to catch intermittent failures

Scripts run maestro test from inside a temp directory so screenshots don't dirty the checkout.

See packages/app/maestro/test-sidebar-theme.sh for the canonical example:

bash packages/app/maestro/test-sidebar-theme.sh 6 1
# Args: iterations=6, wait_seconds=1 between toggle and test

Key elements of the script pattern:

set -euo pipefail
ITERATIONS="${1:-3}"

for i in $(seq 1 "$ITERATIONS"); do
  # Toggle system state
  xcrun simctl ui booted appearance light

  # Wait for change to propagate
  sleep 1

  # Run Maestro flow and capture result
  if maestro test "$FLOW" 2>&1 | tee "$ITER_DIR/test.log"; then
    echo "PASS"
  else
    echo "FAIL"
    xcrun simctl io booted screenshot "$ITER_DIR/failure-state.png"
  fi
done

Unistyles + Reanimated

The crash

Applying Unistyles theme-reactive styles (StyleSheet.create((theme) => ...)) directly to Animated.View causes "Unable to find node on an unmounted component" on theme change.

Unistyles wraps styled components in <UnistylesComponent> and patches native view properties via C++. Reanimated also manages the same native node for animated transforms. When the theme changes, both systems try to update the node simultaneously and the view crashes.

The fix

Use plain React Native StyleSheet.create for static positioning on Animated.View. Pass theme-dependent values as inline styles from useUnistyles():

// BAD: Unistyles dynamic style on Animated.View
const styles = StyleSheet.create((theme) => ({
  sidebar: {
    position: "absolute",
    top: 0,
    left: 0,
    bottom: 0,
    backgroundColor: theme.colors.surfaceSidebar, // theme-reactive
    overflow: "hidden",
  },
}));

<Animated.View style={[styles.sidebar, animatedStyle]} />
// GOOD: static stylesheet + inline theme values
import { StyleSheet as RNStyleSheet } from "react-native";

const staticStyles = RNStyleSheet.create({
  sidebar: {
    position: "absolute",
    top: 0,
    left: 0,
    bottom: 0,
    overflow: "hidden",
  },
});

const { theme } = useUnistyles();

<Animated.View
  style={[staticStyles.sidebar, animatedStyle, { backgroundColor: theme.colors.surfaceSidebar }]}
/>

Regular View components can safely use Unistyles dynamic styles — the conflict is specific to Animated.View.

iOS Simulator

# Screenshot
xcrun simctl io booted screenshot /tmp/screenshot.png

# Dark/light mode
xcrun simctl ui booted appearance          # check current
xcrun simctl ui booted appearance dark     # set dark
xcrun simctl ui booted appearance light    # set light

Expo dev server logs are in the tmux pane running npm run dev. Daemon logs are at $PASEO_HOME/daemon.log (see DEVELOPMENT.md).