diff --git a/docs/MOBILE_TESTING.md b/docs/MOBILE_TESTING.md index 12f543ad7..2120a1743 100644 --- a/docs/MOBILE_TESTING.md +++ b/docs/MOBILE_TESTING.md @@ -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. diff --git a/packages/app/maestro/flows/land-in-chat.yaml b/packages/app/maestro/flows/land-in-chat.yaml new file mode 100644 index 000000000..8fe798478 --- /dev/null +++ b/packages/app/maestro/flows/land-in-chat.yaml @@ -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" diff --git a/packages/app/maestro/image-picker-repro.yaml b/packages/app/maestro/image-picker-repro.yaml new file mode 100644 index 000000000..95a550fc0 --- /dev/null +++ b/packages/app/maestro/image-picker-repro.yaml @@ -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" diff --git a/packages/app/maestro/relay-pairing.yaml b/packages/app/maestro/relay-pairing.yaml index a64d1afcc..02eb7579f 100644 --- a/packages/app/maestro/relay-pairing.yaml +++ b/packages/app/maestro/relay-pairing.yaml @@ -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" diff --git a/packages/app/maestro/relay-then-direct.yaml b/packages/app/maestro/relay-then-direct.yaml index 17ac17d10..0a9372fca 100644 --- a/packages/app/maestro/relay-then-direct.yaml +++ b/packages/app/maestro/relay-then-direct.yaml @@ -8,7 +8,7 @@ appId: sh.paseo - eraseText - inputText: ${PAIRING_OFFER_URL} - tapOn: - point: "75%,36%" + id: "pair-link-submit" - extendedWaitUntil: visible: "New agent" diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index 0a29e2827..6b179124c 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -247,8 +247,9 @@ export function AdaptiveModalSheet({ handleIndicatorStyle={styles.bottomSheetHandle} keyboardBehavior="extend" keyboardBlurBehavior="restore" + accessible={false} > - + {title} diff --git a/packages/app/src/components/add-host-method-modal.tsx b/packages/app/src/components/add-host-method-modal.tsx index e5364e06d..3f343fd00 100644 --- a/packages/app/src/components/add-host-method-modal.tsx +++ b/packages/app/src/components/add-host-method-modal.tsx @@ -71,6 +71,7 @@ export function AddHostMethodModal({ style={styles.option} onPress={handleDirect} accessibilityLabel="Direct connection" + testID="add-host-method-direct" > @@ -93,6 +94,7 @@ export function AddHostMethodModal({ style={styles.option} onPress={handlePaste} accessibilityLabel="Paste pairing link" + testID="add-host-method-pair-link" > diff --git a/packages/app/src/components/add-host-modal.tsx b/packages/app/src/components/add-host-modal.tsx index 7e3bd9d29..ec2f7cf77 100644 --- a/packages/app/src/components/add-host-modal.tsx +++ b/packages/app/src/components/add-host-modal.tsx @@ -155,29 +155,34 @@ export function AddHostModal({ const isMobile = useIsCompactFormFactor(); const hostInputRef = useRef(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 ( Host { + 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={} + testID="direct-host-submit" > {isSaving ? "Connecting..." : "Connect"} diff --git a/packages/app/src/components/pair-link-modal.tsx b/packages/app/src/components/pair-link-modal.tsx index 178a36b23..e287e1596 100644 --- a/packages/app/src/components/pair-link-modal.tsx +++ b/packages/app/src/components/pair-link-modal.tsx @@ -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(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 ( Pairing link { + offerUrlRef.current = next; + }} placeholder="https://app.paseo.sh/#offer=..." placeholderTextColor={theme.colors.foregroundMuted} style={styles.input} diff --git a/packages/app/src/components/ui/dropdown-menu.tsx b/packages/app/src/components/ui/dropdown-menu.tsx index c11dbce7d..42e6cfd58 100644 --- a/packages/app/src/components/ui/dropdown-menu.tsx +++ b/packages/app/src/components/ui/dropdown-menu.tsx @@ -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; }; @@ -167,19 +169,56 @@ export function DropdownMenu({ onOpenChange?: (open: boolean) => void; }>): ReactElement { const triggerRef = useRef(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( () => ({ open: isOpen, setOpen: setIsOpen, + selectItem, + flushPendingSelect, triggerRef, }), - [isOpen, setIsOpen], + [flushPendingSelect, isOpen, selectItem, setIsOpen], ); return {children}; @@ -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} > @@ -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, diff --git a/packages/app/src/hooks/use-image-attachment-picker.ts b/packages/app/src/hooks/use-image-attachment-picker.ts index 014ffa281..6808acae6 100644 --- a/packages/app/src/hooks/use-image-attachment-picker.ts +++ b/packages/app/src/hooks/use-image-attachment-picker.ts @@ -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 {