diff --git a/.agents/skills/mobile-demo-record/SKILL.md b/.agents/skills/mobile-demo-record/SKILL.md index 0dbaa423e26..5fdb4027101 100644 --- a/.agents/skills/mobile-demo-record/SKILL.md +++ b/.agents/skills/mobile-demo-record/SKILL.md @@ -44,7 +44,7 @@ xcrun simctl ui $U appearance light xcrun simctl status_bar $U override --time "9:41" --batteryState discharging \ --batteryLevel 100 --cellularBars 4 --wifiBars 3 --dataNetwork wifi xcrun simctl spawn $U defaults write sh.superset.mobile EXDevMenuIsOnboardingFinished -bool YES -xcrun simctl openurl $U "superset://expo-development-client/?url=http%3A%2F%2Flocalhost%3A8081" +xcrun simctl openurl $U "superset://expo-development-client/?url=http%3A%2F%2Flocalhost%3A8081&disableAutoLaunch=1&disableFab=1" ``` - `simctl boot` does not launch Simulator.app. Keep it that way. diff --git a/.agents/skills/mobile-eas-dev/SKILL.md b/.agents/skills/mobile-eas-dev/SKILL.md index cfb109a3875..75c1e99012c 100644 --- a/.agents/skills/mobile-eas-dev/SKILL.md +++ b/.agents/skills/mobile-eas-dev/SKILL.md @@ -16,8 +16,31 @@ apps/mobile/scripts/eas-dev.sh It reuses this checkout's running session when there is one. Otherwise it installs the finished simulator build whose native fingerprint matches this checkout and starts a session, about two minutes. It builds only when no such build exists (the first time, or after a native change), about fifteen -minutes. Pass `--new` to stop the running session and start a fresh one. `apps/mobile/AGENTS.md`'s -"Verifying in the real app" section records why the script does each step. +minutes. `apps/mobile/AGENTS.md`'s "Verifying in the real app" section records why the script does +each step. + +```bash +apps/mobile/scripts/eas-dev.sh --reload # load the app from Metro again; starts nothing +apps/mobile/scripts/eas-dev.sh --new # stop the running session and start a fresh one +``` + +- **Reload with `--reload`.** It opens the dev-client URL and waits for a new `iOS Bundled` line in the + Metro log, three tries. Never `agent-device open Superset`: that relaunches the app without the + Metro URL and it fails with "Could not connect to the server". +- **Switch sessions with `--new`,** never `eas simulator` by hand. The egress client is bound to the + session it started with, so the script stops the old client and starts one for the new session. + With no flag it keeps a connected client and restarts one that died. +- When the script started Metro, each run that (re)starts the tunnel ends only when Metro has served + the app a bundle; otherwise it exits non-zero and names the Metro and egress logs. With a Metro + started some other way it opens the app but cannot confirm the load. +- The dev-client URL carries `disableAutoLaunch=1&disableFab=1`, so no dev menu opens at launch and + no gear button covers the toolbar. Each session is a fresh install, so every load passes them. +- Logs and PID files are in `.tmp/eas-dev/` of the checkout, so two checkouts do not stop each + other's tunnel. +- The fingerprint lookup refuses to run when `bun --version` differs from `.bun-version`. The + dependencies that bun installs give a fingerprint no build has, and EAS would start a 15-minute + build that also fails. A cloud workspace made before the pin changed has the old bun: make a new + one. The session is tagged with the workspace id. The desktop Mobile pane lists the workspace's tagged sessions, so the person can watch the simulator there without a link. The script also prints an @@ -40,6 +63,10 @@ agent-device help # the rest - Through `eas simulator:exec npx agent-device` each command takes about 25 s. Install it globally (`npm install -g agent-device`) when it is missing. - The dev-menu sheet covers the first screen on a fresh install: press `Continue`, then `Close`. +- iOS's "slide to type" keyboard intro is already dismissed on a session the script started. +- SwiftUI menu items (`@expo/ui` `Menu`) are not in `agent-device snapshot`. Open the menu, take a + screenshot, and tap the item by coordinate: `agent-device press `. This is a limit of the + tool, not of the app. - Source edits reach the app through fast refresh in about two seconds. ## Limits diff --git a/apps/mobile/.maestro/flows/host-states/never-connected.yml b/apps/mobile/.maestro/flows/host-states/never-connected.yml index 3e3272f2a85..ad218342d80 100644 --- a/apps/mobile/.maestro/flows/host-states/never-connected.yml +++ b/apps/mobile/.maestro/flows/host-states/never-connected.yml @@ -7,7 +7,7 @@ env: HOST_NAME: "E2E Mac mini" --- - stopApp -- openLink: "superset://expo-development-client/?url=http%3A%2F%2Flocalhost%3A8081" +- openLink: "superset://expo-development-client/?url=http%3A%2F%2Flocalhost%3A8081&disableAutoLaunch=1&disableFab=1" - extendedWaitUntil: visible: "One step left" timeout: 60000 diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 1ee2d91c591..527c78d058c 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -91,8 +91,20 @@ export default function AuthenticatedLayout() { has the dev values in its shell. - **The simulator reaches this machine only through local egress,** and the egress client can only start once the session exists. The app's first load therefore always fails; the script loads it - again when the tunnel is up. A port the app calls must be passed as `--egress-allow`; the script + again through the dev-client URL when the tunnel is up, and retries until the Metro log shows a + new `iOS Bundled` line. A port the app calls must be passed as `--egress-allow`; the script reads `$API_PORT` from `.env` rather than assuming a fixed port. + - **An egress client serves only the session it started with.** It reads `.env.eas-simulator` once, + so after a new session a running client still tunnels to the old one and the app shows "Could + not connect to the server". `--new` replaces the client; never start a session by hand. + - **Reload with `eas-dev.sh --reload`, not `agent-device open Superset`.** A plain launch has no + Metro URL and fails; only + `superset://expo-development-client/?url=...&disableAutoLaunch=1&disableFab=1` loads the app + without the dev menu and its gear button. + - **The "slide to type" keyboard intro** covers the screen the first time a keyboard opens. + `simctl` is out of reach, so the script opens the keyboard once in Settings and presses Continue. + - **SwiftUI menu items (`@expo/ui` `Menu`) are not in `agent-device snapshot`.** Tap them by + coordinate from a screenshot. This is a limit of the tool. - **Native Sentry starts only when `EXPO_PUBLIC_SENTRY_DSN_MOBILE` is set.** Native init without a DSN crashes the app at launch with a bare SIGSEGV before any JS runs, and the EAS `development` environment has no DSN. diff --git a/apps/mobile/scripts/eas-dev.sh b/apps/mobile/scripts/eas-dev.sh index 8cbf5f4bc89..9c0ac32dc1d 100755 --- a/apps/mobile/scripts/eas-dev.sh +++ b/apps/mobile/scripts/eas-dev.sh @@ -10,6 +10,7 @@ # Usage: # apps/mobile/scripts/eas-dev.sh # reuse this checkout's running session, else start one # apps/mobile/scripts/eas-dev.sh --new # stop the running session and start a fresh one +# apps/mobile/scripts/eas-dev.sh --reload # load the app from Metro again; starts nothing # MOBILE_EAS_BUILD_ID= apps/mobile/scripts/eas-dev.sh # install this build, skip the lookup # # Requires: EXPO_TOKEN (or `eas login`), this repo's .env already written @@ -17,19 +18,25 @@ # account. set -euo pipefail -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd -P)" cd "$ROOT_DIR" NEW=0 +RELOAD=0 for arg in "$@"; do case "$arg" in --new) NEW=1 ;; + --reload) RELOAD=1 ;; *) echo "Unknown argument: $arg" >&2 exit 1 ;; esac done +if [ "$NEW" = "1" ] && [ "$RELOAD" = "1" ]; then + echo "--new and --reload cannot be used together." >&2 + exit 1 +fi if [ ! -f .env ]; then echo "No .env here — run .superset/setup.sh or .superset/setup.cloud.sh first." >&2 @@ -45,12 +52,17 @@ API_PORT="${API_PORT:?API_PORT is missing from .env}" BUILD_PROFILE=development-simulator IDLE_MINUTES="${MOBILE_EAS_IDLE_MINUTES:-15}" SESSION_FILE=apps/mobile/.env.eas-simulator -EGRESS_LOG=/tmp/superset-eas-egress.log -METRO_LOG=/tmp/superset-metro.log +STATE_DIR="$ROOT_DIR/.tmp/eas-dev" +EGRESS_LOG="$STATE_DIR/egress.log" +EGRESS_PID_FILE="$STATE_DIR/egress.pid" +METRO_LOG="$STATE_DIR/metro.log" +METRO_PID_FILE="$STATE_DIR/metro.pid" +LOAD_ATTEMPTS=3 +BUNDLE_WAIT_SECONDS=90 cd apps/mobile -DEV_CLIENT_URL="superset://expo-development-client/?url=$(node -e 'console.log(encodeURIComponent(process.argv[1]))' "http://localhost:$METRO_PORT")" +DEV_CLIENT_URL="superset://expo-development-client/?url=$(node -e 'console.log(encodeURIComponent(process.argv[1]))' "http://localhost:$METRO_PORT")&disableAutoLaunch=1&disableFab=1" # The runtime version is a fingerprint of the app config. EAS resolves it # without this machine's dev values, so the build and its lookup must too. @@ -76,13 +88,163 @@ session_running() { [ "$(eas simulator:get --id "$id" --json --non-interactive 2>/dev/null | json 'j.status')" = "IN_PROGRESS" ] } -if ! curl -s "http://127.0.0.1:$METRO_PORT/status" 2>/dev/null | grep -q "packager-status:running"; then +metro_running() { + curl -s "http://127.0.0.1:$METRO_PORT/status" 2>/dev/null | grep -q "packager-status:running" +} + +agent_device() { + if command -v agent-device >/dev/null 2>&1; then + ( + set -a + # shellcheck disable=SC1090 + source "$ROOT_DIR/$SESSION_FILE" + set +a + agent-device "$@" + ) + else + eas simulator:exec npx -y agent-device "$@" + fi +} + +process_cwd() { + if [ -d /proc ]; then + readlink "/proc/$1/cwd" 2>/dev/null || true + else + lsof -a -d cwd -p "$1" -Fn 2>/dev/null | sed -n 's/^n//p' + fi +} + +is_own_egress_client() { + [ "$(process_cwd "$1")" = "$PWD" ] && ps -o command= -p "$1" 2>/dev/null | grep -q "simulator:egress" +} + +egress_pids() { + local pid + for pid in $(pgrep -f "eas simulator:egress" || true); do + if is_own_egress_client "$pid"; then echo "$pid"; fi + done +} + +egress_connected() { + grep -E "Egress tunnel (connected|disconnected)" "$EGRESS_LOG" 2>/dev/null | tail -n 1 | grep -q "Egress tunnel connected" +} + +egress_healthy() { + local pid + pid="$(cat "$EGRESS_PID_FILE" 2>/dev/null || true)" + [ -n "$pid" ] && is_own_egress_client "$pid" && + grep -qF "simulator session $1." "$EGRESS_LOG" 2>/dev/null && + egress_connected +} + +stop_egress() { + local pid + for pid in $(egress_pids); do + pkill -P "$pid" 2>/dev/null || true + kill "$pid" 2>/dev/null || true + done + rm -f "$EGRESS_PID_FILE" +} + +# The egress client reads .env.eas-simulator once, at start, so it stays bound +# to the session that was current then. +start_egress() { + stop_egress + echo "==> Egress for session $1" + nohup eas simulator:egress >"$EGRESS_LOG" 2>&1 "$EGRESS_PID_FILE" + for _ in $(seq 1 60); do + egress_connected && return 0 + sleep 1 + done + echo "The egress tunnel did not connect in 60 s. See $EGRESS_LOG." >&2 + exit 1 +} + +bundle_count() { + local count + count="$(grep -c "iOS Bundled" "$METRO_LOG" 2>/dev/null || true)" + echo "${count:-0}" +} + +metro_started_here() { + local pid + pid="$(cat "$METRO_PID_FILE" 2>/dev/null || true)" + [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null && [ -f "$METRO_LOG" ] +} + +load_app() { + local attempt before + if ! metro_started_here; then + echo "==> Loading the app from Metro (not confirmed: Metro was not started by this script)" + agent_device open "$DEV_CLIENT_URL" >/dev/null 2>&1 || true + agent_device alert accept >/dev/null 2>&1 || true + return 0 + fi + for attempt in $(seq 1 "$LOAD_ATTEMPTS"); do + echo "==> Loading the app from Metro (attempt $attempt of $LOAD_ATTEMPTS)" + before="$(bundle_count)" + agent_device open "$DEV_CLIENT_URL" >/dev/null 2>&1 || true + agent_device alert accept >/dev/null 2>&1 || true + for _ in $(seq 1 "$BUNDLE_WAIT_SECONDS"); do + if [ "$(bundle_count)" -gt "$before" ]; then + echo "==> Metro served the bundle" + return 0 + fi + sleep 1 + done + done + echo "Metro served no bundle after $LOAD_ATTEMPTS tries. See $METRO_LOG and $EGRESS_LOG." >&2 + exit 1 +} + +# simctl is not reachable from here, so the intro cannot be turned off with +# `defaults write`. Showing the keyboard once in Settings and pressing Continue +# marks it as shown for the whole simulator. +dismiss_keyboard_intro() { + local output + agent_device open com.apple.Preferences --relaunch >/dev/null 2>&1 || return 0 + output="$(agent_device press 'role="search"' --settle 2>&1 || true)" + if [[ "$output" == *UIContinuousPathIntroductionView* ]]; then + echo "==> Dismissing the slide-to-type keyboard intro" + agent_device press 'label="Continue"' --settle >/dev/null 2>&1 || true + else + echo "==> No slide-to-type keyboard intro found" + fi + agent_device close com.apple.Preferences >/dev/null 2>&1 || true +} + +mkdir -p "$STATE_DIR" + +if [ "$RELOAD" = "1" ]; then + metro_running || { + echo "Metro is not running. Run apps/mobile/scripts/eas-dev.sh without --reload." >&2 + exit 1 + } + session_running || { + echo "No running simulator session. Run apps/mobile/scripts/eas-dev.sh without --reload." >&2 + exit 1 + } + egress_healthy "$(session_id)" || { + echo "The egress client is not connected to session $(session_id). Run apps/mobile/scripts/eas-dev.sh without --reload." >&2 + exit 1 + } + load_app + exit 0 +fi + +if ! metro_running; then echo "==> Starting Metro" nohup bunx expo start --dev-client --port "$METRO_PORT" >"$METRO_LOG" 2>&1 "$METRO_PID_FILE" for _ in $(seq 1 30); do - curl -s "http://127.0.0.1:$METRO_PORT/status" 2>/dev/null | grep -q "packager-status:running" && break + metro_running && break sleep 2 done + metro_running || { + echo "Metro did not start in 60 s. See $METRO_LOG." >&2 + exit 1 + } fi if [ "$NEW" = "1" ] && session_running; then @@ -90,11 +252,19 @@ if [ "$NEW" = "1" ] && session_running; then eas simulator:stop --id "$(session_id)" --non-interactive >/dev/null fi +STARTED=0 if session_running; then echo "==> Reusing session $(session_id)" else + STARTED=1 BUILD_ID="${MOBILE_EAS_BUILD_ID:-}" if [ -z "$BUILD_ID" ]; then + BUN_PINNED="$(cat "$ROOT_DIR/.bun-version")" + if [ "$(bun --version)" != "$BUN_PINNED" ]; then + echo "bun $(bun --version) is installed, but .bun-version pins $BUN_PINNED. No build matches the fingerprint of dependencies it installed." >&2 + echo "In a cloud workspace, create a new one from the current image. Or set MOBILE_EAS_BUILD_ID to install a known build." >&2 + exit 1 + fi HASH="$(without_dev_env eas fingerprint:generate -p ios --build-profile "$BUILD_PROFILE" --json --non-interactive | json 'j.hash')" BUILD_ID="$(eas build:list -p ios --build-profile "$BUILD_PROFILE" --status finished --fingerprint-hash "$HASH" --limit 1 --json --non-interactive | json 'j[0]?.id')" if [ -z "$BUILD_ID" ]; then @@ -120,33 +290,10 @@ else --out-config-type dotenv --non-interactive --force fi -agent_device() { - if command -v agent-device >/dev/null 2>&1; then - ( - set -a - # shellcheck disable=SC1090 - source "$ROOT_DIR/$SESSION_FILE" - set +a - agent-device "$@" - ) - else - eas simulator:exec npx -y agent-device "$@" - fi -} - -# The simulator reaches this machine only while the egress client runs, and -# the client needs the session, so the app's first load at session start -# always fails. Load it again once the tunnel is up. -if ! pgrep -f "eas simulator:egress" >/dev/null 2>&1; then - echo "==> Egress" - nohup eas simulator:egress >"$EGRESS_LOG" 2>&1 /dev/null && break - sleep 1 - done - echo "==> Loading the app from Metro" - agent_device open "$DEV_CLIENT_URL" >/dev/null 2>&1 || true - agent_device alert accept >/dev/null 2>&1 || true +if [ "$STARTED" = "1" ] || ! egress_healthy "$(session_id)"; then + start_egress "$(session_id)" + [ "$STARTED" = "1" ] && dismiss_keyboard_intro + load_app fi SESSION_ID="$(session_id)" @@ -159,6 +306,7 @@ Ready. Preview: $PREVIEW_URL Drive it: (cd apps/mobile && set -a && . ./.env.eas-simulator && set +a && agent-device snapshot -i) needs "npm install -g agent-device"; through npx each command takes about 25 s + Reload it: apps/mobile/scripts/eas-dev.sh --reload Stop it: (cd apps/mobile && eas simulator:stop --id $SESSION_ID) Metro log: $METRO_LOG Egress log: $EGRESS_LOG