Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .kilo_workflow/learnings/maestro-tap-swallowed-by-ios-keyboard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# mobile: Maestro reports a tap COMPLETED that the iOS keyboard swallowed

Symptom: a flow taps a button, Maestro logs `Tap on "<label>"... COMPLETED`, and nothing happens — the next `assertVisible` times out with Maestro's generic "this could be a real regression" advice, sending the reader after a product bug that does not exist. Typing into the field above the button worked, so the screen looks healthy in the failure screenshot.

Cause: Maestro taps an element's **centre**, and iOS delivers any touch inside `UIRemoteKeyboardWindow` to the keyboard, not to the app. A control that is only partly covered by the keyboard therefore looks tappable and is not: the keyboard window's frame starts a few points above the visible keys, so a centre that clears the keys by a couple of points still lands in it. Maestro cannot see this — it gets no hit-test result back.

Verify it in two commands, both cheap:

```bash
maestro --device <udid> hierarchy # control centre vs the keyboard window's top bound
grep -E "Tapping [0-9]" ~/.maestro/tests/<run>/<flow>/logs/device-xctest.log
grep -A2 "Sending UIEvent" ~/.maestro/tests/<run>/<flow>/logs/device-simulator.log
```

The simulator log names the receiving window verbatim — `to window: <UIRemoteKeyboardWindow: 0x…>` is proof the app never saw the touch, `<UIWindow…>` (the app's) means look elsewhere.

Because the margin is a handful of points on a centred layout, this is device-model dependent: the same flow passes on a taller simulator and fails on a shorter one, which reads as "it used to work" when the pool hands out a different iPhone.

Fix: keep the control clear of the keyboard in the product (a root `KeyboardAvoidingView` on iOS, not `automaticallyAdjustKeyboardInsets` — that only makes the ScrollView scrollable, it never scrolls, and iOS auto-reveals only the focused field). Do not work around it in the flow with pasteboard tricks or coordinate taps; assert the reachable state instead, and never read a swallowed tap as a product regression.
4 changes: 3 additions & 1 deletion apps/mobile/e2e/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ apps/mobile/e2e/logout.sh <udid>

The default email is `e2e-mobile-<worktree-basename>@example.com`, derived deterministically from the worktree directory name. Hyphens are preserved by `normalizeEmail`, so each worktree signs into a distinct backend user. Pass an explicit email only when a test needs a specific account.

Login requests an email OTP, waits up to 30 seconds for the worktree-local outbox, verifies the code, accepts first-account consent, and asserts Home. It retries the known dev-client launch boundary once. `flows/settle-app.yaml` handles late tracking and Expo developer-menu prompts without restarting the app; `flows/open-app.yaml` is the standalone cold-launch flow.
Login requests an email OTP, waits up to 30 seconds for the worktree-local outbox, verifies the code, accepts first-account consent, and asserts Home. If the request half fails it cold-relaunches through `flows/open-app.yaml` and retries once — that clears both a half-started dev client and an email field left dirty by an earlier run — and if the retry fails too it says which half broke: no outbox email means the app never reached `POST /api/auth/native/otp`, a new outbox email means the request worked and only the code screen was never reached. `flows/settle-app.yaml` handles late tracking and Expo developer-menu prompts without restarting the app; `flows/open-app.yaml` is the standalone cold-launch flow.

Native prompts are states in the flow, not errors to tap through blindly:

Expand All @@ -149,6 +149,8 @@ When editing the flows, preserve these device-tested constraints:
- Tap the Kilo home-screen icon; Maestro `launchApp` can bounce the Expo dev client to SpringBoard.
- Pass `EMAIL` and `OTP` with `-e`; flow-level defaults override `-e` values in the installed Maestro version.
- Target the email field by its placeholder `you@example.com`, and tap `Verify code` without trying to dismiss the number pad.
- The email field is uncontrolled, so a login page left on screen by an earlier run still holds its address, and `inputText` inserts at the caret the tap just dropped mid-string. Erase the field first and assert the typed address before submitting; without that, two attempts interleave into one malformed address and the flow dies 15s later on a missing `Verify code`.
- Keep every control a flow taps clear of the keyboard. Maestro taps an element's centre, and iOS hands a touch inside `UIRemoteKeyboardWindow` to the keyboard while Maestro still logs the tap `COMPLETED` — a silent no-op. Verify with `maestro --device <udid> hierarchy`: the control's centre must be above the keyboard window's top bound. See `.kilo_workflow/learnings/maestro-tap-swallowed-by-ios-keyboard.md`.
- The native sign-out confirmation is the first case-insensitive `Sign Out` match (`index: 0`).

Seed only when needed. `pnpm dev:seed` with no arguments lists every topic and its usage:
Expand Down
7 changes: 7 additions & 0 deletions apps/mobile/e2e/flows/login-request-code.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,14 @@ appId: com.kilocode.kiloapp
# Tap the input via its placeholder — the "Email address" label shares the
# field's accessibility text and would match the (non-focusable) label.
- tapOn: 'you@example.com'
# The field is uncontrolled, so a login page left on screen by an earlier run
# still holds its address, and `inputText` inserts at the caret the tap just
# dropped mid-string — two attempts interleave into a malformed address.
- eraseText: 100
- inputText: ${EMAIL}
# Fail here, immediately and legibly, instead of 15s later on a missing
# "Verify code": a mismatch means typing landed in a field that was not empty.
- assertVisible: ${EMAIL}
- tapOn: 'Send sign-in code'
# Wait for the code screen — sending is a network round-trip.
- extendedWaitUntil:
Expand Down
28 changes: 24 additions & 4 deletions apps/mobile/e2e/login.sh
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,30 @@ latest_email() {

before="$(latest_email)"

echo "==> signing out and requesting sign-in code for $EMAIL"
if ! maestro --device "$DEVICE" test -e "EMAIL=$EMAIL" "$SCRIPT_DIR/flows/login-request-code.yaml"; then
echo "==> retrying launch and sign-in request once after a dev-client startup failure"
request_code() {
maestro --device "$DEVICE" test -e "EMAIL=$EMAIL" "$SCRIPT_DIR/flows/login-request-code.yaml"
}

# Say which half broke, so nobody reads Maestro's generic "could be a real
# regression" advice as a product-bug lead.
diagnose_request() {
local now
now="$(latest_email)"
if [ -n "$now" ] && [ "$now" != "$before" ]; then
echo "==> the backend DID email a code: the request worked, the app never reached the code screen" >&2
else
echo "==> no code email for $EMAIL in $OUTBOX: the app never reached POST /api/auth/native/otp," >&2
echo " so the submit press did not fire (preflight already proved the backend is up)" >&2
fi
}

echo "==> signing out and requesting sign-in code for $EMAIL"
if ! request_code; then
# One cold relaunch clears both known first-attempt failures: a half-started
# dev client, and an email field left dirty by an earlier run.
echo "==> retrying launch and sign-in request once after a cold relaunch"
maestro --device "$DEVICE" test "$SCRIPT_DIR/flows/open-app.yaml" || true
request_code || { diagnose_request; exit 1; }
fi

# Wait for a newer outbox email than we had before (the send is async).
Expand All @@ -65,7 +85,7 @@ for _ in $(seq 1 120); do
done

if [ -z "$code" ]; then
echo "==> no new sign-in code received" >&2
echo "==> reached the code screen, but no new code email for $EMAIL landed in $OUTBOX within 30s" >&2
exit 1
fi

Expand Down
18 changes: 14 additions & 4 deletions apps/mobile/src/components/login-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,23 @@ export function LoginScreen() {
// does not push the form up. Placing the KeyboardAvoidingView at the root
// gives it a window-relative frame (y ~ 0, height ~ screen height), so
// behavior="height" computes a non-zero shrink and resizes the ScrollView so
// the form stays above the IME. iOS is disabled because the ScrollView's
// automaticallyAdjustKeyboardInsets already handles the offset.
<KeyboardAvoidingView behavior="height" enabled={Platform.OS === 'android'} className="flex-1">
// the form stays above the IME.
//
// iOS needs it too: automaticallyAdjustKeyboardInsets only made the
// ScrollView scrollable, it never scrolls, and iOS only auto-reveals the
// focused field — so the centered form kept "Send code" under the keyboard
// on shorter devices (verified: iPhone 17 Pro, button centre 568pt vs
// keyboard window top 566pt, taps swallowed by UIRemoteKeyboardWindow).
// "padding" shrinks the ScrollView instead, so the whole form re-centres in
// the space above the keyboard; that replaces the inset, stacking both
// pushes the form off the top of the screen.
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
className="flex-1"
>
<ScrollView
className="flex-1 bg-background"
contentContainerClassName="flex-grow items-center justify-center gap-6 px-6 py-8"
automaticallyAdjustKeyboardInsets
keyboardShouldPersistTaps="handled"
>
<View className="w-full max-w-sm items-center gap-2">
Expand Down
Loading