fix(app): unstick iOS image picker and land reusable Maestro primitives

On iOS, tapping "+" → "Add image" in the Composer left an invisible
backdrop trapping touches after the native photo picker dismissed. Root
cause: the dropdown menu's onSelect fired while the RN Modal was still
completing UIKit dismissal, so PHPicker presented over a dismissing
Modal that never cleanly released.

DropdownMenu now defers the selected item's onSelect until Modal
onDismiss (UIKit-level completion) plus a short buffer on iOS, then
invokes it. Same DropdownMenu instance — no per-call opt-in.

Along the way, the Maestro repro surfaced a controlled-TextInput
character-drop race: fast inputText on a controlled input drops
characters mid-type. Direct-host and pair-link inputs are now
ref-backed uncontrolled inputs with stable testIDs.

New reusable E2E primitives under packages/app/maestro/:
- flows/land-in-chat.yaml — clearState + direct connect to
  127.0.0.1:6767, lands in the composer. Compose on this for any
  composer-level fixture.
- image-picker-repro.yaml — regression for the zombie-backdrop bug.

docs/MOBILE_TESTING.md gains the patterns: reach-the-composer via
land-in-chat, uncontrolled inputs for Maestro-typed fields, and the
dropdown → native-presenter dismissal timing on iOS.

Existing relay-pairing and relay-then-direct fixtures updated to use
stable testIDs instead of ambiguous text / point-based taps.
This commit is contained in:
Mohamed Boudra
2026-04-17 14:07:15 +07:00
parent adf2f5921f
commit 9a8b01a738
11 changed files with 224 additions and 49 deletions

View File

@@ -95,6 +95,37 @@ 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
### Reach the composer
`flows/land-in-chat.yaml` is the canonical "get into a chat" primitive. It `clearState`s, runs `launch.yaml`, taps the welcome screen's direct-connection option, types `127.0.0.1:6767`, submits, and waits for `message-input-root`. Compose any composer-level fixture on top of it:
```yaml
appId: sh.paseo
---
- runFlow: flows/land-in-chat.yaml
# ...your scenario here, starting from a ready composer
```
See `image-picker-repro.yaml` for an example.
**Prefer direct connection over relay pairing for local E2E.** Relay needs a 400+ character pairing URL typed into an input; direct needs `127.0.0.1:6767`. The daemon listens on 6767 and the simulator can reach it directly.
### Inputs that Maestro types into
Maestro `inputText` fires one character at a time. React Native's **controlled** `TextInput` re-renders per keystroke; if a controlled input's state update lags or re-mounts mid-type, characters are dropped silently — the final value on screen is a truncated/scrambled version of what was "typed."
For inputs that E2E flows type into (host endpoint, pairing URL, etc.), use an **uncontrolled ref-backed input**: `defaultValue` + `onChangeText` writes into a `useRef`, reads via the ref on submit. No per-keystroke re-render, no dropped characters.
See `add-host-modal.tsx` and `pair-link-modal.tsx` for the pattern. Always pair the source change with a Maestro `assertVisible` on the input's `id + text` after `inputText`, so regressions are caught immediately.
### Dropdowns that launch native presenters (iOS)
On iOS, when a dropdown menu (`DropdownMenu` / RN `Modal`) item needs to launch a native presenter like `PHPickerViewController` (image picker) or a `UIDocumentPicker`, the callback **must not fire while the `Modal` is still dismissing**. UIKit dismissal completion spans multiple frames beyond React unmount; launching a native presenter mid-dismissal leaves an invisible backdrop mounted that traps every subsequent touch.
`DropdownMenu` handles this by deferring the selected item's `onSelect` until `Modal.onDismiss` fires (UIKit-level dismissal complete), then adds a small extra buffer before invoking it. See `components/ui/dropdown-menu.tsx`'s `selectItem` / `flushPendingSelect`.
When building a new component that composes a dropdown with a native presenter, reuse this dropdown — do not invent a new timing shim.
## 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.

View File

@@ -0,0 +1,40 @@
# NOTE: Maestro requires a config section for every flow file, even when used
# via `runFlow`. Keep this aligned with the dev build application id.
appId: sh.paseo
---
- launchApp:
clearState: true
- runFlow: launch.yaml
- extendedWaitUntil:
visible:
id: "welcome-screen"
timeout: 30000
- tapOn:
id: "welcome-direct-connection"
- extendedWaitUntil:
visible:
id: "add-host-modal"
timeout: 10000
- tapOn:
id: "direct-host-input"
- eraseText
- inputText: "127.0.0.1:6767"
- assertVisible:
id: "direct-host-input"
text: "127.0.0.1:6767"
- tapOn:
id: "direct-host-submit"
- extendedWaitUntil:
visible:
id: "message-input-root"
timeout: 60000
- assertVisible:
id: "message-input-attach-button"

View File

@@ -0,0 +1,50 @@
appId: sh.paseo
---
- runFlow: flows/land-in-chat.yaml
- tapOn:
id: "message-input-attach-button"
- extendedWaitUntil:
visible:
id: "message-input-attachment-menu"
timeout: 10000
- tapOn:
id: "message-input-attachment-menu-item-image"
- runFlow:
when:
visible: "Allow Full Access"
commands:
- tapOn: "Allow Full Access"
- runFlow:
when:
visible: "Allow Access to All Photos"
commands:
- tapOn: "Allow Access to All Photos"
- extendedWaitUntil:
visible: "Cancel"
timeout: 30000
- tapOn: "Cancel"
- extendedWaitUntil:
visible:
id: "message-input-root"
timeout: 10000
# If the dropdown Modal left an invisible backdrop mounted behind the picker,
# this tap is swallowed and the attachment menu never reopens.
- tapOn:
id: "message-input-attach-button"
- extendedWaitUntil:
visible:
id: "message-input-attachment-menu"
timeout: 10000
- tapOn:
id: "message-input-attachment-menu-backdrop"

View File

@@ -8,7 +8,15 @@ appId: sh.paseo
- eraseText
- inputText: ${PAIRING_OFFER_URL}
- tapOn: "Pair"
# Prove the input received the URL intact. If this fails, iOS keyboard input
# dropped characters and the controlled input path needs a source fix.
- assertVisible:
id: "pair-link-input"
text: ${PAIRING_OFFER_URL}
- tapOn:
id: "pair-link-submit"
- extendedWaitUntil:
visible: "New agent"

View File

@@ -8,7 +8,7 @@ appId: sh.paseo
- eraseText
- inputText: ${PAIRING_OFFER_URL}
- tapOn:
point: "75%,36%"
id: "pair-link-submit"
- extendedWaitUntil:
visible: "New agent"

View File

@@ -247,8 +247,9 @@ export function AdaptiveModalSheet({
handleIndicatorStyle={styles.bottomSheetHandle}
keyboardBehavior="extend"
keyboardBlurBehavior="restore"
accessible={false}
>
<View style={styles.bottomSheetHeader}>
<View style={styles.bottomSheetHeader} testID={testID}>
<View style={styles.headerTitleGroup}>
<Text style={styles.title} numberOfLines={1}>
{title}

View File

@@ -71,6 +71,7 @@ export function AddHostMethodModal({
style={styles.option}
onPress={handleDirect}
accessibilityLabel="Direct connection"
testID="add-host-method-direct"
>
<Link2 size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}>
@@ -93,6 +94,7 @@ export function AddHostMethodModal({
style={styles.option}
onPress={handlePaste}
accessibilityLabel="Paste pairing link"
testID="add-host-method-pair-link"
>
<ClipboardPaste size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}>

View File

@@ -155,29 +155,34 @@ export function AddHostModal({
const isMobile = useIsCompactFormFactor();
const hostInputRef = useRef<TextInput>(null);
const endpointRawRef = useRef("");
const [endpointRaw, setEndpointRaw] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const clearInput = useCallback(() => {
endpointRawRef.current = "";
hostInputRef.current?.clear();
}, []);
const handleClose = useCallback(() => {
if (isSaving) return;
setEndpointRaw("");
clearInput();
setErrorMessage("");
onClose();
}, [isSaving, onClose]);
}, [isSaving, clearInput, onClose]);
const handleCancel = useCallback(() => {
if (isSaving) return;
setEndpointRaw("");
clearInput();
setErrorMessage("");
(onCancel ?? onClose)();
}, [isSaving, onCancel, onClose]);
}, [isSaving, clearInput, onCancel, onClose]);
const handleSave = useCallback(async () => {
if (isSaving) return;
const raw = endpointRaw.trim();
const raw = endpointRawRef.current.trim();
if (!raw) {
setErrorMessage("Host is required");
return;
@@ -240,16 +245,7 @@ export function AddHostModal({
} finally {
setIsSaving(false);
}
}, [
daemons,
endpointRaw,
handleClose,
isMobile,
isSaving,
onSaved,
targetServerId,
upsertDirectConnection,
]);
}, [daemons, handleClose, isMobile, isSaving, onSaved, targetServerId, upsertDirectConnection]);
return (
<AdaptiveModalSheet
@@ -264,8 +260,12 @@ export function AddHostModal({
<Text style={styles.label}>Host</Text>
<AdaptiveTextInput
ref={hostInputRef}
value={endpointRaw}
onChangeText={setEndpointRaw}
testID="direct-host-input"
nativeID="direct-host-input"
accessibilityLabel="direct-host-input"
onChangeText={(next) => {
endpointRawRef.current = next;
}}
placeholder="hostname:port"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
@@ -289,6 +289,7 @@ export function AddHostModal({
onPress={() => void handleSave()}
disabled={isSaving}
leftIcon={<Link2 size={16} color={theme.colors.palette.white} />}
testID="direct-host-submit"
>
{isSaving ? "Connecting..." : "Connect"}
</Button>

View File

@@ -1,5 +1,5 @@
import { useCallback, useState } from "react";
import { Alert, Text, View } from "react-native";
import { useCallback, useRef, useState } from "react";
import { Alert, Text, TextInput, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Link } from "lucide-react-native";
@@ -69,27 +69,33 @@ export function PairLinkModal({
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
const isMobile = useIsCompactFormFactor();
const [offerUrl, setOfferUrl] = useState("");
const offerUrlRef = useRef("");
const inputRef = useRef<TextInput>(null);
const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const clearInput = useCallback(() => {
offerUrlRef.current = "";
inputRef.current?.clear();
}, []);
const handleClose = useCallback(() => {
if (isSaving) return;
setOfferUrl("");
clearInput();
setErrorMessage("");
onClose();
}, [isSaving, onClose]);
}, [isSaving, clearInput, onClose]);
const handleCancel = useCallback(() => {
if (isSaving) return;
setOfferUrl("");
clearInput();
setErrorMessage("");
(onCancel ?? onClose)();
}, [isSaving, onCancel, onClose]);
}, [isSaving, clearInput, onCancel, onClose]);
const handleSave = useCallback(async () => {
if (isSaving) return;
const raw = offerUrl.trim();
const raw = offerUrlRef.current.trim();
if (!raw) {
setErrorMessage("Paste a pairing link (…/#offer=...)");
return;
@@ -159,16 +165,7 @@ export function PairLinkModal({
} finally {
setIsSaving(false);
}
}, [
daemons,
handleClose,
isMobile,
isSaving,
offerUrl,
onSaved,
targetServerId,
upsertDaemonFromOfferUrl,
]);
}, [daemons, handleClose, isMobile, isSaving, onSaved, targetServerId, upsertDaemonFromOfferUrl]);
return (
<AdaptiveModalSheet
@@ -182,11 +179,13 @@ export function PairLinkModal({
<View style={styles.field}>
<Text style={styles.label}>Pairing link</Text>
<AdaptiveTextInput
ref={inputRef}
testID="pair-link-input"
nativeID="pair-link-input"
accessibilityLabel="pair-link-input"
value={offerUrl}
onChangeText={setOfferUrl}
onChangeText={(next) => {
offerUrlRef.current = next;
}}
placeholder="https://app.paseo.sh/#offer=..."
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}

View File

@@ -46,6 +46,8 @@ interface Rect {
type DropdownMenuContextValue = {
open: boolean;
setOpen: (open: boolean) => void;
selectItem: (onSelect: (() => void) | undefined, closeOnSelect: boolean) => void;
flushPendingSelect: () => void;
triggerRef: React.RefObject<View | null>;
};
@@ -167,19 +169,56 @@ export function DropdownMenu({
onOpenChange?: (open: boolean) => void;
}>): ReactElement {
const triggerRef = useRef<View>(null);
const pendingSelectRef = useRef<(() => void) | null>(null);
const [isOpen, setIsOpen] = useControllableOpenState({
open,
defaultOpen,
onOpenChange,
});
const flushPendingSelect = useCallback(() => {
const pendingSelect = pendingSelectRef.current;
pendingSelectRef.current = null;
if (!pendingSelect) return;
if (Platform.OS === "ios") {
// Native presenters such as PHPicker can hang if launched while an RN
// Modal is still completing dismissal on UIKit's side.
setTimeout(pendingSelect, 250);
return;
}
pendingSelect();
}, []);
const selectItem = useCallback(
(onSelect: (() => void) | undefined, closeOnSelect: boolean) => {
if (!closeOnSelect) {
onSelect?.();
return;
}
if (Platform.OS === "ios") {
pendingSelectRef.current = onSelect ?? null;
setIsOpen(false);
return;
}
setIsOpen(false);
onSelect?.();
},
[setIsOpen],
);
const value = useMemo<DropdownMenuContextValue>(
() => ({
open: isOpen,
setOpen: setIsOpen,
selectItem,
flushPendingSelect,
triggerRef,
}),
[isOpen, setIsOpen],
[flushPendingSelect, isOpen, selectItem, setIsOpen],
);
return <DropdownMenuContext.Provider value={value}>{children}</DropdownMenuContext.Provider>;
@@ -266,7 +305,8 @@ export function DropdownMenuContent({
horizontalPadding?: number;
testID?: string;
}>): ReactElement | null {
const { open, setOpen, triggerRef } = useDropdownMenuContext("DropdownMenuContent");
const { open, setOpen, triggerRef, flushPendingSelect } =
useDropdownMenuContext("DropdownMenuContent");
const [modalVisible, setModalVisible] = useState(false);
const webScrollbarStyle = useWebScrollbarStyle();
const [closing, setClosing] = useState(false);
@@ -288,6 +328,12 @@ export function DropdownMenuContent({
}
}, [open, modalVisible]);
useEffect(() => {
if (!open && !modalVisible) {
flushPendingSelect();
}
}, [flushPendingSelect, modalVisible, open]);
const handleClose = useCallback(() => {
setOpen(false);
}, [setOpen]);
@@ -377,6 +423,7 @@ export function DropdownMenuContent({
transparent
animationType="none"
statusBarTranslucent={Platform.OS === "android"}
onDismiss={flushPendingSelect}
onRequestClose={handleClose}
>
<View style={styles.overlay}>
@@ -501,7 +548,7 @@ export function DropdownMenuItem({
tooltip?: string;
}>): ReactElement {
const { theme } = useUnistyles();
const { setOpen } = useDropdownMenuContext("DropdownMenuItem");
const { selectItem } = useDropdownMenuContext("DropdownMenuItem");
// Derive state from status prop (preferred) or legacy loading prop
const isPending = status === "pending" || loading;
@@ -539,10 +586,7 @@ export function DropdownMenuItem({
disabled={isDisabled}
onPress={() => {
if (isDisabled) return;
if (closeOnSelect) {
setOpen(false);
}
onSelect?.();
selectItem(onSelect, closeOnSelect);
}}
style={({ pressed, hovered }) => [
styles.item,

View File

@@ -1,6 +1,5 @@
import { useCallback, useRef } from "react";
import { Alert } from "react-native";
import { Platform } from "react-native";
import * as ImagePicker from "expo-image-picker";
import { isElectronRuntime } from "@/desktop/host";
import {