#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────────────────────
# yukti — a portable local iOS-Simulator QA rig for React Native / Expo apps.
#
#   build → sign → boot → install → launch → drive.  No cloud device farm.
#
# Two ways to run tests, and AI is OPTIONAL:
#   • Deterministic flows   →  `yukti flow flows/login.json`   (zero AI, CI-ready)
#   • AI-driven exploration →  any agent (Claude Code / OpenAI computer-use)
#                              drives the primitives below (shot/ui/find/tap/type)
#
# The whole rig is Apple tools + idb. Expo/EAS is an optional adapter, not a dep.
#
# MIT licensed. https://github.com/<you>/yukti
# ─────────────────────────────────────────────────────────────────────────────
set -euo pipefail

YUKTI_VERSION="0.1.0"
CONFIG="${YUKTI_CONFIG:-yukti.config.json}"
PY="/usr/bin/python3"                       # system python: reliable expat + json
NODE22="/opt/homebrew/opt/node@22/bin"      # RN/Metro pins node 22; sys node may be broken
IDB_BIN="$HOME/Library/Python/3.9/bin"      # fb-idb CLI install location

# ── pretty ───────────────────────────────────────────────────────────────────
c(){ printf '\033[%sm' "$1"; }; R=$(c 0); B=$(c '1;36'); G=$(c '1;32'); Y=$(c '1;33'); E=$(c '1;31')
say(){ printf '%s▸%s %s\n' "$B" "$R" "$*"; }
ok(){  printf '%s✓%s %s\n' "$G" "$R" "$*"; }
warn(){ printf '%s!%s %s\n' "$Y" "$R" "$*"; }
# A step a condition switched off. Not a warning (nothing went wrong) and not a
# pass (nothing was done), so it gets its own mark in the run.
skipped(){ printf '%s~%s %s\n' "$Y" "$R" "$*"; }
# The last words of a failing step are also the message its result entry needs,
# so keep a copy when a flow is running. The console output is unchanged.
#
# Where the step was written is part of those words. The verbs that fail most -
# tapText, waitFor, assertText, assertId, tapId, screenshot - die on their own
# and never come back with a status, so a suffix added where the status is read
# would be missing from exactly the steps a shared block is made of: the report
# would say `03 tapText Sign In` for forty-five flows and never name the one
# file to fix. The flow loop sets YUKTI_STEP_SRC for the step it is running -
# and this process owns that variable, whatever it inherited: a value left in
# the environment by a caller used to be printed after a message that belongs to
# no step at all, `no flow file 'x.json' (stale from parent)`.
YUKTI_STEP_SRC=""
die(){ local m="$*"; if [ -n "${YUKTI_STEP_SRC:-}" ]; then m="$m ($YUKTI_STEP_SRC)"; fi
  printf '%s✗%s %s\n' "$E" "$R" "$m" >&2; [ -n "${YUKTI_STEP_ERR:-}" ] && printf '%s' "$m" > "$YUKTI_STEP_ERR"; exit 1; }

need_config(){ [ -f "$CONFIG" ] || die "no config at '$CONFIG' — run 'yukti init' or set YUKTI_CONFIG"; }

# ── config reader (dotted path; prints scalars raw, lists/objects as JSON) ─────
cfg(){ need_config; "$PY" - "$CONFIG" "$1" "${2:-}" <<'PY'
import json,sys
d=json.load(open(sys.argv[1])); default=sys.argv[3] if len(sys.argv)>3 else ""
cur=d
try:
    for k in sys.argv[2].split('.'):
        cur=cur[k]
except (KeyError,TypeError):
    print(default); sys.exit(0)
print(cur if not isinstance(cur,(list,dict)) else json.dumps(cur))
PY
}

app_dir(){ ROOT="$(cfg projectRoot .)"; cd "$ROOT" 2>/dev/null || die "projectRoot '$ROOT' not found"; }
paths(){ export PATH="$NODE22:$IDB_BIN:/opt/homebrew/bin:$PATH"; }
udid_file(){ echo "${TMPDIR:-/tmp}/yukti-udid"; }
booted(){ xcrun simctl list devices 2>/dev/null | grep -m1 Booted | grep -oE '[0-9A-F-]{36}' || true; }
# The platform of this run, resolved once before the command runs (bottom of the
# file). Every primitive asks this and nothing else, so one run drives one
# platform: `yukti up android-qa` and `yukti flow` on the same config could
# disagree, because the first read the variant and the second a global that
# defaulted to iOS.
is_android(){ [ "${YUKTI_PLATFORM:?the platform of this run was never resolved}" = android ]; }

# Sources, in order: the environment, the platform of the variant this
# invocation is about, the top-level "platform". The variant is the one named on
# the command line, else $YUKTI_VARIANT, which is what Studio sets when a variant
# is picked in the panel. Nothing left to ask is not an iOS run - it is a run
# that cannot know what it is driving, so it says so and stops. The answer names
# its source: which platform, and which of the three said so.
resolve_platform(){
  local v="${1:-}" p='' src='' vp='' why=''
  [ -n "$v" ] || v="${YUKTI_VARIANT:-}"
  [ -n "$v" ] && [ -f "$CONFIG" ] && vp="$(cfg "variants.$v.platform")"
  if [ -n "${YUKTI_PLATFORM:-}" ]; then
    p="$YUKTI_PLATFORM"; src='$YUKTI_PLATFORM'
    # A variant that names its own platform states a fact about that variant,
    # not a preference. When the environment pins a different one, one of the
    # two is wrong, and picking either is the silent answer this whole change
    # exists to end.
    [ -z "$vp" ] || [ "$vp" = "$p" ] || die "\$YUKTI_PLATFORM says $p and variant '$v' says $vp - they cannot both be the platform of this run"
  elif [ -f "$CONFIG" ]; then
    if [ -n "$vp" ]; then p="$vp"; src="variant '$v'"
    else p="$(cfg platform)"; [ -n "$p" ] && src="\"platform\" in $CONFIG"; fi
  fi
  if [ -z "$p" ]; then
    # Say which of the three was asked and what it answered. Assembled here and
    # not inside the message: a ${v:-...} there reads as the variant's own name.
    if [ ! -f "$CONFIG" ]; then
      why="and there is no config at '$CONFIG' - run 'yukti init' or set YUKTI_CONFIG"
    else
      if [ -z "$v" ]; then why="no variant was named (either a command that takes one, or \$YUKTI_VARIANT)"
      elif [ -z "$(cfg "variants.$v")" ]; then why="there is no variant '$v' in $CONFIG, and it"
      else why="variant '$v' does not name a platform, and $CONFIG"; fi
      [ -n "$v" ] || why="$why, and $CONFIG"
      why="$why has no top-level \"platform\""
    fi
    die "cannot tell which platform this run is for: \$YUKTI_PLATFORM is not set, $why"
  fi
  case "$p" in ios|android) ;; *) die "'$p' is not a platform - the only ones are ios and android (from $src)";; esac
  export YUKTI_PLATFORM="$p"
  # To stderr: `yukti find`, `yukti shot` and `yukti ui` put an answer on stdout
  # that a caller reads.
  say "platform: $p (from $src)" >&2; }

# ── Android backend (adb + uiautomator + gradle) ─────────────────────────────
# 1:1 mirror of the iOS path. Implemented; validate on a Mac with the Android SDK.
# Android debug APKs are auto-signed with the debug key — none of the iOS signing/
# keychain/arch pain applies. Config per variant: platform:"android", package,
# gradleTask (default assembleDebug); global android.avd for the emulator.
ANDROID_SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Library/Android/sdk}}"
apaths(){ export PATH="$NODE22:$ANDROID_SDK/platform-tools:$ANDROID_SDK/emulator:$ANDROID_SDK/cmdline-tools/latest/bin:/opt/homebrew/bin:$PATH"; }

build_android(){ local V="$1"; app_dir; apaths
  local task apk env; task="$(cfg variants.$V.gradleTask assembleDebug)"; env="$(cfg variants.$V.envFile)"
  [ -n "$env" ] && [ -f "$env" ] && cp "$env" .env.local && ok "env: $env → .env.local"
  say "gradle $task"; ( cd android && ./gradlew "$task" ) || die "gradle build failed"
  apk="$(find android/app/build/outputs/apk -name '*.apk' 2>/dev/null | head -1)"
  [ -n "$apk" ] || die "no APK produced"; echo "$apk" > "${TMPDIR:-/tmp}/yukti-apk-$V"; ok "built: $apk"; }
boot_android(){ apaths; local avd; avd="$(cfg android.avd Pixel_7_API_34)"
  if ! adb devices 2>/dev/null | grep -q emulator; then say "starting emulator $avd"; ( "$ANDROID_SDK/emulator/emulator" -avd "$avd" -no-boot-anim >/dev/null 2>&1 & ); fi
  adb wait-for-device; until [ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" = 1 ]; do sleep 2; done; ok "emulator booted"; }
# Where the APK comes from, in order: the path named on the command line, the
# variant's "apkPath", and last the file the build step wrote. An artifact an
# earlier CI job built never had a build step here to write that file, and there
# was no other way in - the path had to be forged into a temporary file named by
# an internal convention, so installing fell out of the runner and the job ran
# `adb install` itself.
apk_of(){ local V="$1" want="${2:-}" p='' src
  if [ -n "$want" ]; then p="$want"; src="--apk"
  else
    if [ -f "$CONFIG" ]; then p="$(cfg "variants.$V.apkPath")"; fi; src="\"apkPath\" of variant '$V'"
    # A relative path there is relative to the config that names it. Taken from
    # the current directory instead, the same config would install a different
    # file depending on where the command was typed - and CI types it from
    # somewhere else than a laptop does.
    case "$p" in ''|/*) ;; *) p="$(cd "$(dirname "$CONFIG")" && pwd)/$p";; esac
    if [ -z "$p" ]; then
      p="$(cat "${TMPDIR:-/tmp}/yukti-apk-$V" 2>/dev/null || true)"; src="the build step"
      [ -n "$p" ] || die "no APK to install for '$V': pass --apk <path>, set variants.$V.apkPath, or run 'yukti build $V' first"
    fi
  fi
  [ -f "$p" ] || die "the APK named by $src is not a file: '$p'"
  printf '%s' "$p"; }

install_android(){ apaths; local V="$1" apk; apk="$(apk_of "$V" "${2:-}")"
  adb install -r "$apk" && ok "installed $(basename "$apk")"; }
launch_android(){ apaths; local pkg; pkg="$(cfg variants.$1.package)"; adb shell monkey -p "$pkg" -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1 && ok "launched $pkg"; }
# A screenshot value is a NAME, not a path. RUNBOOK says the files land in
# $TMPDIR and that the flows only name them; they landed in whatever directory
# the run was started from, so a suite scattered pngs across the caller's tree
# while $TMPDIR stayed empty. Join a relative name to $TMPDIR. An absolute path
# is still taken as given, and a name with a directory in it gets that directory
# created rather than failing the step.
shot_path(){ local n="${1:-yukti-shot.png}"
  # A name is a name. ".." in it would write outside $TMPDIR while reading like
  # a file inside it, so refuse rather than guess what was meant.
  case "$n" in *..*) die "screenshot name must not contain '..': '$n'";; esac
  case "$n" in /*) printf '%s' "$n";; *) printf '%s/%s' "${TMPDIR:-/tmp}" "$n";; esac; }
ashot(){ apaths; local o; o="$(shot_path "${1:-}")"; mkdir -p "$(dirname "$o")"; adb exec-out screencap -p > "$o" && echo "$o"; }
# Delete the dump before writing a new one. uiautomator refuses to dump while the
# screen is animating ("could not get idle state!"), the error was swallowed, and
# `cat` then served the tree of the PREVIOUS call: the next assert checked the
# previous screen and passed. A refusal is a normal state right after a tap, so
# retry a few times the way the iOS ui() already does, and only then give up.
# Remove, dump and read run in ONE adb round trip: a lookup used to cost two, a
# scroll_to up to eighteen.
#
# What counts as an answer is the WHOLE tree, not "some bytes". Non-empty is not
# enough:
# `cat` on a file uiautomator never wrote answers "No such file or directory" ON
# STDOUT through some adb shell protocol versions, and the daemon prints "* daemon
# started successfully *" on stdout too. Either one used to pass the emptiness
# check and reach the parser, which found no nodes and reported an absent element -
# the very confusion this retry exists to prevent. A dump cut off mid-tree does
# the same thing while looking even more like a tree, so the closing tag has to
# be there too: uiautomator writes the document in one go, and the end of it is
# the only evidence that nothing was lost on the way out.
#
# The file name carries the pid, so a dump that did not happen cannot be answered
# with the file some earlier run left on the device: `rm -f` failing silently was
# the one path back to serving a stale tree, and a per-run name closes it. The
# file is removed again after reading, in the same round trip.
# A tree held for the length of one step. A step that looks for several things
# on the SAME screen - dismiss looks for nine labels - used to read the screen
# once per thing, and every read after the first was of a screen that nothing
# had touched. A step fills this, reads as often as it likes, and empties it
# before anything taps: a held tree outliving a tap would be a step deciding
# against a screen that is no longer there.
HELD_TREE=""
aui(){ [ -n "$HELD_TREE" ] && { printf '%s' "$HELD_TREE"; return 0; }
  apaths; local xml i f="/sdcard/yukti-dump-$$.xml"
  for i in 1 2 3; do
    xml="$(adb shell "rm -f $f; uiautomator dump $f >/dev/null 2>&1; cat $f 2>/dev/null; rm -f $f" 2>/dev/null)"
    case "$xml" in *"<hierarchy"*"</hierarchy>"*) printf '%s' "$xml"; return 0;; esac
    [ "$i" = 3 ] || sleep 0.6
  done
  die "uiautomator produced no usable dump after 3 tries - the screen may still be animating"; }
afind(){ apaths; aui | "$PY" -c '
import sys,re,html
# Rank candidates instead of taking the first substring hit. Honors resource-id
# and prefers the interactive field over a nearby static caption — "Email" used
# to hit the "or use email" caption ~100px above the input, and "Password" hit
# the "Forgot Password?" link. A node now must be relevant (query appears in its
# text / content-desc / resource-id) AND scores highest to win; an editable /
# clickable / focusable node outranks a plain caption.
# mode="id": explicit selector. The resource-id segment must match EXACTLY and
# nothing else is considered — a flow that asks for an id gets that element or fails.
# Whitespace is normalised on both sides. unescape turns &#10; into a real line
# break, and a two-line caption would otherwise be assertable only by writing the
# line break into the flow file.
norm=lambda s: " ".join(s.split())
# The oracle compares the query as written; the locator modes keep folding case,
# because a flow author naming a button does not mean its capitalisation.
q_raw=norm(sys.argv[1]); q=q_raw.lower()
mode=(sys.argv[2] if len(sys.argv)>2 else "text")
match=(sys.argv[3] if len(sys.argv)>3 else "exact")
# What to print for the winner. The centre is the tap point and the default;
# "box" asks for the corners the filters below already read, plus the screen
# rect they are judged against, so that a caller can ask for a share of the
# screen without reading the screen a second time.
out=(sys.argv[4] if len(sys.argv)>4 else "centre")
xml=sys.stdin.read()
# Nothing arrived: the dump failed and said so on its own line. Status 1 is "the
# screen could not be read"; status 3 below is the different, quieter case where
# a tree did arrive and cannot be judged.
if not xml.strip(): sys.exit(1)
# Screen rect = the bounds of the ROOT node, i.e. the first bounds= in the dump.
# Anything whose centre falls outside it is scrolled away or parked off-screen:
# present in the tree, invisible to the user. Taking the maximum over every node
# instead would let a node larger than the viewport widen the rect - and the rows
# scrolled out of a list are exactly the ones that report such bounds, so the
# filter would loosen on the screens it was written for.
SX1=SY1=SX2=SY2=0
_m=re.search(r"bounds=\"\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]\"", xml)
if _m: SX1,SY1,SX2,SY2=map(int,_m.groups())
# No usable screen rect means the off-screen filter cannot run. A tap may still be
# worth attempting, but a check that cannot check must not report success - and
# assertId is as much an oracle as assertText. tapId loses nothing by it: it does
# not scroll, and a tap outside the window is useless anyway.
#
# Status 3, not silence: an empty answer means "the element is not there", and a
# negative assertion or a `when` condition PASSES on that answer. The screen that
# cannot be judged has to say so in a way the caller can read.
if mode in ("assert","id") and not (SX2>SX1 and SY2>SY1): sys.exit(3)
best=None; best_box=None; best_score=0
for m in re.finditer(r"<node\b[^>]*>", xml):
    tag=m.group(0)
    def a(n):
        # uiautomator escapes &, <, > and the quote characters. Unescaped,
        # assertText "Terms & Conditions" fails on the very screen that shows it,
        # and assertText "amp" passes on any screen with an ampersand - markup
        # matching through the back door.
        mm=re.search(n+r"=\"([^\"]*)\"",tag); return norm(html.unescape(mm.group(1))) if mm else ""
    text_raw=a("text"); desc_raw=a("content-desc")
    text=text_raw.lower(); desc=desc_raw.lower()
    rid=a("resource-id"); rid_seg=rid.split("/")[-1].lower()  # com.app:id/email -> email
    cls=a("class"); editable=cls.endswith("EditText")
    clickable=a("clickable")=="true"; focusable=a("focusable")=="true"
    if mode=="id":
        # An empty query must match nothing. The flow runner passes value="" for a
        # missing or misspelled key, and "" is the resource-id of most nodes: the
        # step would then report success and tapId would tap an arbitrary point.
        if not q or q!=rid_seg: continue
        score=10
    elif mode=="assert":
        # An assertion is the test oracle: it may only match what a person can
        # perceive - visible text or the accessibility label. Matching a
        # resource-id, a class name or an attribute lets a step pass on markup.
        #
        # The whole label, and the case as written. A substring match made a
        # false green cheap: assertText "net" passed on a screen whose only
        # relevant row said "Network & internet", and a check that passes on
        # three letters of another sentence has not checked anything. A flow
        # that does want the old rule says so: {"match":"contains"}.
        if match=="contains":
            if not (q and (q in text or q in desc)): continue
        else:
            if not (q_raw and q_raw in (text_raw, desc_raw)): continue
        score=10
    else:
        if not (q and (q in rid_seg or q in text or q in desc)): continue
        score=0
        if q==rid_seg: score+=6
        elif q in rid_seg: score+=5
        if q==text or q==desc: score+=4
        elif q in text or q in desc: score+=2
        if editable: score+=3
        if clickable: score+=1
        if focusable: score+=1
    if score<=best_score: continue
    b=re.search(r"bounds=\"\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]\"",tag)
    if not b: continue
    x1,y1,x2,y2=map(int,b.groups())
    if not (x2>x1 and y2>y1): continue   # zero-area node: in the tree, not on the screen
    cx,cy=(x1+x2)//2,(y1+y2)//2
    if SX2>SX1 and SY2>SY1 and not (SX1<=cx<SX2 and SY1<=cy<SY2): continue  # off-screen
    best=(cx,cy); best_box=(x1,y1,x2,y2); best_score=score
if best:
    if out=="box": print(best_box[0],best_box[1],best_box[2],best_box[3],SX1,SY1,SX2,SY2)
    else: print(best[0],best[1])
' "$1" "${2:-text}" "${3:-exact}" "${4:-centre}"; }
# Where a horizontal swipe should land, from the dump. A swipe across the middle
# of the screen lands on whatever is there, and an element that does not claim
# the gesture may be pressed instead of scrolled: measured on the app under test,
# a horizontal swipe across a clickable row opened that row's share sheet, and a
# search of eight swipes did it eight times. The dump says which nodes scroll, and
# one that scrolls and is wider than it is tall is what a horizontal swipe is for.
# Prints "cy x1 x2" of the widest such node, or nothing when the screen has none.
ahscroller(){ aui | "$PY" -c '
import re,sys
xml=sys.stdin.read()
best=None
for m in re.finditer(r"<node\b[^>]*>", xml):
    t=m.group(0)
    # No single quote anywhere in this snippet: it lives inside a single-quoted
    # shell string. With one, the string ended here, the rest of the code became
    # shell words, and the lookup answered nothing at all - silently, because a
    # screen with no sideways scroller is a legitimate answer.
    if re.search(r"scrollable=\"true\"", t) is None: continue
    b=re.search(r"bounds=\"\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]\"", t)
    if not b: continue
    x1,y1,x2,y2=map(int,b.groups())
    w,h=x2-x1,y2-y1
    # Taller than wide is a vertical list, whatever else it is.
    if w<=h or w<80 or h<20: continue
    # The WIDEST, which is what the guide and the runbook promise. By area a
    # 900x400 carousel would win over a 1080x100 tab strip, and then the code and
    # the text would mean different things - the area is only the tie-break.
    if best is None or (w,w*h)>(best[0],best[1]): best=(w,w*h,(y1+y2)//2,x1,x2)
if best: print(best[2],best[3],best[4])
'; }
atap(){ apaths; adb shell input tap "$1" "$2"; }
atype(){ apaths; local s="$1" q
  # Quote for the ON-DEVICE shell so metacharacters can't expand. Unquoted, a *
  # in a password was glob-expanded by /system/bin/sh (an 8-char password
  # arrived as 11 chars). Single-quote the arg (escaping embedded ' as '\''),
  # then map spaces to %s which `input text` turns back into spaces.
  q=$(printf '%s' "$s" | sed "s/'/'\\\\''/g")
  q=${q// /%s}
  adb shell "input text '$q'"; }
# Keys are named in the flow file, never numbered: a step that says `66` reads as
# noise and stops being true the moment the platform changes. These six names are
# the whole vocabulary - anything else stops the run instead of pressing nothing.
KEY_NAMES="back enter home delete tab escape"
akey(){ apaths; local c
  case "$1" in back) c=4;; enter) c=66;; home) c=3;; delete) c=67;; tab) c=61;; escape) c=111;;
    *) die "pressKey: no Android key code for '$1'";; esac
  adb shell input keyevent "$c"; }
# The IME's own answer about itself. A soft keyboard draws in its own window and
# never appears in the view dump the flows read, so "is it up" cannot be asked of
# the screen. Three outcomes, not two: up (0), down (1), and the device did not
# answer (2) - answering "down" to the third would let a step report success on a
# device that said nothing.
akb_shown(){ apaths; local s
  s="$(adb shell dumpsys input_method 2>/dev/null | grep -o "mInputShown=[a-z]*" | head -1 | cut -d= -f2 | tr -d "\r")"
  [ -n "$s" ] || return 2
  [ "$s" = true ]; }
# What sits under a point: its class, then its text. The rig has to be able to
# say what a field holds AFTER acting on it - a clear that is not checked is the
# same silent success this tool keeps finding elsewhere - and the class comes
# with it because "the text did not change" proves an empty field only when the
# thing under the point is a field. Aimed at a caption, it proves nothing at all.
# Two questions, one dump. "at" answers what sits under a point - the flow's own
# coordinates, before anything is touched. "focused" answers which text field
# holds focus now, and that is the field the keys will reach: after the keyboard
# opens the layout can move the field out from under the coordinates that opened
# it, and a check that kept hit-testing the old point would be reading a
# different node than the one being cleared.
afield(){ apaths; aui | "$PY" -c '
import re,sys
mode=sys.argv[1]
x0,y0=(int(sys.argv[2]),int(sys.argv[3])) if mode=="at" else (0,0)
best=None; best_area=0; best_edit=False
xml=sys.stdin.read()
# Nothing arrived: the dump failed and aui has already said so. Status 1 is "the
# screen could not be read", and it has to leave here as 1 - status 3 below is
# "the screen was read and there is no such field", which the caller reports in
# its own words. Without this line an unreadable screen left as 3 and clearText
# blamed the point it was aimed at.
if not xml.strip(): sys.exit(1)
for m in re.finditer(r"<node[^>]*?>", xml):
    tag=m.group(0)
    b=re.search(r"bounds=\"\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]\"", tag)
    if not b: continue
    x1,y1,x2,y2=map(int,b.groups())
    if not (x2>x1 and y2>y1): continue
    cls=re.search(r"class=\"([^\"]*)\"", tag)
    txt=re.search(r"text=\"([^\"]*)\"", tag)
    enb=re.search(r"enabled=\"([^\"]*)\"", tag)
    fcb=re.search(r"focusable=\"([^\"]*)\"", tag)
    res=re.search(r"resource-id=\"([^\"]*)\"", tag)
    foc=re.search(r"focused=\"([^\"]*)\"", tag)
    cls=cls.group(1) if cls else ""; edit="EditText" in cls
    if mode=="focused":
        if not (edit and foc and foc.group(1)=="true"): continue
    else:
        if not (x1<=x0<x2 and y1<=y0<y2): continue
    area=(x2-x1)*(y2-y1)
    # The field wins over the row that contains it; among equals, the smaller.
    if best is None or (edit and not best_edit) or (edit==best_edit and area<best_area):
        best=(cls, (enb.group(1) if enb else ""), (fcb.group(1) if fcb else ""),
              (res.group(1) if res else ""), "%d %d %d %d"%(x1,y1,x2,y2),
              txt.group(1) if txt else "")
        best_area=area; best_edit=edit
if best is None: sys.exit(3)
sys.stdout.write("%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s" % best)
' "$@"; }
# Select all, then one delete. Forty blind deletes were a guess about how much
# was in the field and about where the caret sat; two calls are neither. The
# result is then read back, because the only fact that matters here is the state
# of the field, not that the keys were accepted.
aclear(){ apaths; local x="$1" y="$2" vb="${3:-clearText}" sdk before after probe
  sdk="$(adb shell getprop ro.build.version.sdk 2>/dev/null | tr -d "\r")"
  case "$sdk" in ""|*[!0-9]*) die "$vb: the device did not report its Android version";; esac
  # The version is the first line and it does stop the run here. What it cannot
  # cover is the line below it: on API 30 the shell answers an unknown command
  # with its usage text and exit code 0, so `|| die` after the key combination
  # is blind there. The read-back at the end is what holds if this threshold
  # ever turns out to be wrong again. No quiet fallback to deleting character by
  # character: a clear that only looks like one is what this verb exists to end.
  [ "$sdk" -ge 31 ] || die "$vb needs Android 12 (API 31) or newer - 'input keycombination' does not exist on API $sdk"
  local cls enb fcb res box out res2 box2 txt2
  # What the flow aimed at, read BEFORE the tap: a disabled or read-only field
  # takes no focus and no keys, and its text would sit there unchanged through
  # select-all, delete and the probe below - three readings agreeing on a lie.
  out="$(afield at "$x" "$y")" || lookup_die $? "$vb: no element at $x,$y"
  IFS=$'\x1f' read -r cls enb fcb res box before <<<"$out"
  case "$cls" in *EditText*) ;; *) die "$vb: the element at $x,$y is a ${cls:-node with no class}, not a text field";; esac
  [ "$enb" = true ] || die "$vb: the field at $x,$y is disabled - it takes no keys"
  [ "$fcb" = true ] || die "$vb: the field at $x,$y cannot take focus - the keys would go elsewhere"
  adb shell input tap "$x" "$y" || die "$vb: the device refused the tap on the field"
  # From here on the focused field is the subject: it is the one the keys reach.
  # Which is only safe once it is the SAME field - a tap that opened a picker,
  # or landed on a keyboard the dump does not show, leaves focus where it was,
  # and clearing that field would be reported as clearing this one.
  out="$(afield focused)" || die "$vb: the tap at $x,$y did not put focus in a text field"
  IFS=$'\x1f' read -r cls enb fcb res2 box2 txt2 <<<"$out"
  # Two ids that differ settle it. Two that MATCH settle nothing: a resource id
  # is not unique - the rows of a list and the repeated rows of a form share
  # one. So the second question is asked every time: the same rectangle, or the
  # same text it had before the tap. A field that is neither is another field.
  if [ -n "$res" ] && [ -n "$res2" ] && [ "$res" != "$res2" ]; then
    die "$vb: focus is in a different field ($res2), not the one at $x,$y ($res)"
  fi
  if [ "$box" != "$box2" ] && [ "$before" != "$txt2" ]; then
    die "$vb: focus is in a different field than the one at $x,$y"
  fi
  if [ "$box" != "$box2" ]; then
    warn "$vb: the field moved to $box2 when the keyboard opened"
  fi
  adb shell input keycombination 113 29 || die "$vb: the device refused select-all (Ctrl+A)"
  adb shell input keyevent 67 || die "$vb: the device refused the delete key"
  out="$(afield focused)" || lookup_die $? "$vb: nothing on the screen has focus"
  IFS=$'\x1f' read -r cls enb fcb res box after <<<"$out"
  [ -n "$after" ] || { ok "$vb: field empty at $x,$y"; return 0; }
  # Any text left is unresolved, whatever it is and whether or not it changed:
  # an EditText with nothing in it reports its HINT in the same attribute that
  # carries its text, and a select-all that did not take leaves the delete to
  # remove ONE character - which also changes the text and would read as
  # success. One more delete separates the two: on an empty field it changes
  # nothing, on a field still holding text it takes another character.
  adb shell input keyevent 67 || die "$vb: the device refused the delete key"
  out="$(afield focused)" || lookup_die $? "$vb: nothing on the screen has focus"
  IFS=$'\x1f' read -r cls enb fcb res box probe <<<"$out"
  [ "$probe" = "$after" ] || die "$vb: the field still holds text after select-all and delete"
  ok "$vb: field empty at $x,$y"; }

# ── doctor: verify/report the toolchain ──────────────────────────────────────
doctor(){
  paths; local bad=0
  say "yukti v$YUKTI_VERSION — environment check"
  if xcrun simctl help >/dev/null 2>&1; then ok "Xcode + simctl"; else die "Full Xcode required (not just CLT). Install Xcode, then: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer"; fi
  xcrun simctl list runtimes 2>/dev/null | grep -qi ios && ok "iOS simulator runtime" || { warn "no iOS runtime — installing"; xcodebuild -downloadPlatform iOS; }
  command -v node >/dev/null && ok "node $(node -v)" || { warn "node@22 missing"; bad=1; echo "   brew install node@22"; }
  command -v pod  >/dev/null && ok "cocoapods $(pod --version)" || { warn "cocoapods missing"; bad=1; echo "   brew install cocoapods"; }
  command -v idb  >/dev/null && ok "idb (UI driver)" || { warn "idb missing"; bad=1; echo "   brew trust facebook/fb && brew install idb-companion && $PY -m pip install --user fb-idb"; }
  [ "$(cfg easEnabled false)" = "True" -o "$(cfg easEnabled false)" = "true" ] && { command -v npx >/dev/null && ok "npx (for EAS adapter)"; }
  [ $bad -eq 0 ] && ok "ready" || die "install the tools above, then re-run 'yukti doctor'"
}

# ── device-only-lib scan: static frameworks lacking an arm64-simulator slice ──
# (These break arm64 sim linking; exclude them from iOS autolinking for the rig.)
scan(){
  app_dir; say "scanning ios/Pods for device-only frameworks (no arm64-sim slice)…"
  find ios/Pods -type f -path "*.framework/*" ! -path "*.xcframework/*" 2>/dev/null | while read -r f; do
    file "$f" 2>/dev/null | grep -q "Mach-O" || continue
    lipo -info "$f" 2>/dev/null | grep -q arm64 || continue
    vtool -show-build "$f" 2>/dev/null | grep -qi SIMULATOR || echo "  device-only: $(echo "$f" | sed -E 's#.*/Pods/([^/]+)/.*#\1#')"
  done | sort -u
  warn "add the npm packages that vendor these to config .excludeIosAutolink[]"
}

# ── write react-native.config.js to drop device-only libs from iOS autolinking ─
write_rn_excludes(){
  app_dir; local ex; ex="$(cfg excludeIosAutolink '[]')"
  [ "$ex" = "[]" ] && return 0
  [ -f react-native.config.js ] && [ ! -f .yukti.rnconfig.bak ] && cp react-native.config.js .yukti.rnconfig.bak
  "$PY" - "$ex" > react-native.config.js <<'PY'
import json,sys
pkgs=json.loads(sys.argv[1])
deps=",\n".join(f"    {json.dumps(p)}: {{ platforms: {{ ios: null }} }}" for p in pkgs)
print("// AUTO-GENERATED by yukti — device-only libs w/o arm64-sim slice, excluded for sim builds.\n"
      "module.exports = {\n  dependencies: {\n"+deps+"\n  },\n};")
PY
  ok "react-native.config.js excludes: $(echo "$ex" | "$PY" -c 'import json,sys;print(", ".join(json.load(sys.stdin)))')"
}

# ── build a signed arm64 simulator .app for a variant ────────────────────────
build(){
  local V="${1:?usage: yukti build <variant>}"
  is_android && { build_android "$V"; return; }
  app_dir; paths
  local scheme cfgn env dd
  scheme="$(cfg variants.$V.scheme)";        [ -n "$scheme" ] || die "unknown variant '$V'"
  cfgn="$(cfg variants.$V.configuration)"
  env="$(cfg variants.$V.envFile)"
  dd="ios/.yukti-build/$V"
  say "building '$V'  (scheme='$scheme' config='$cfgn')"
  [ -n "$env" ] && [ -f "$env" ] && cp "$env" .env.local && ok "env: $env → .env.local" || warn "no envFile for '$V' (config .variants.$V.envFile)"
  write_rn_excludes
  ( cd ios && pod install >/dev/null 2>&1 ) && ok "pods installed"
  local appvar; appvar="$(cfg variants.$V.appVariant "$V")"
  NODE_ENV="$(cfg variants.$V.nodeEnv development)" APP_VARIANT="$appvar" EXPO_VARIANT_EXPLICIT=1 \
  xcodebuild -workspace "$(cfg workspace)" -scheme "$scheme" -configuration "$cfgn" \
    -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' -derivedDataPath "$dd" \
    ARCHS=arm64 ONLY_ACTIVE_ARCH=NO EXCLUDED_ARCHS=x86_64 \
    CODE_SIGN_IDENTITY="-" CODE_SIGN_STYLE=Manual DEVELOPMENT_TEAM="" PROVISIONING_PROFILE_SPECIFIER="" \
    build >/dev/null || die "xcodebuild failed — run without >/dev/null to see errors, or 'yukti scan' for device-only libs"
  # NB: ad-hoc CODE_SIGN_IDENTITY="-" keeps entitlements (keychain) so Firebase/auth works.
  #     CODE_SIGNING_ALLOWED=NO would strip them → -34018 keychain → login 401s.
  local app; app="$(find "$dd/Build/Products" -maxdepth 2 -name '*.app' | head -1)"
  [ -n "$app" ] || die "build produced no .app"
  echo "$app" > "${TMPDIR:-/tmp}/yukti-app-$V"
  ok "built: $app"
}

# ── pull an OFFICIAL simulator build from EAS (optional adapter) ──────────────
pull_eas(){
  local V="${1:?usage: yukti pull <variant>}"; app_dir; paths
  [ -f /tmp/expotoken ] || [ -n "${EXPO_TOKEN:-}" ] || die "set EXPO_TOKEN (or /tmp/expotoken) for the EAS adapter"
  local tok profile; tok="${EXPO_TOKEN:-$(cat /tmp/expotoken)}"; profile="$(cfg variants.$V.easProfile simulator)"
  say "triggering EAS simulator build (profile='$profile') — cloud, ~15 min"
  EXPO_TOKEN="$tok" npx eas-cli build --platform ios --profile "$profile" --non-interactive --wait || die "eas build failed (does eas.json have a '$profile' profile with ios.simulator=true?)"
  local url; url="$(EXPO_TOKEN="$tok" npx eas-cli build:list --platform ios --limit 1 --json --non-interactive 2>/dev/null | "$PY" -c 'import json,sys;print(json.load(sys.stdin)[0]["artifacts"]["applicationArchiveUrl"])')"
  say "downloading $url"; curl -sL "$url" -o /tmp/yukti-eas.tar.gz
  local out="ios/.yukti-build/eas-$V"; rm -rf "$out"; mkdir -p "$out"; tar -xzf /tmp/yukti-eas.tar.gz -C "$out"
  local app; app="$(find "$out" -maxdepth 2 -name '*.app' | head -1)"
  [ -n "$app" ] || die "EAS archive had no .app (was it a device .ipa? need a simulator profile)"
  echo "$app" > "${TMPDIR:-/tmp}/yukti-app-$V"; ok "pulled: $app"
}

# ── simulator lifecycle ──────────────────────────────────────────────────────
boot(){
  is_android && { boot_android; return; }
  paths; local dev rt u
  dev="$(cfg simulator.device 'iPhone 16 Pro')"
  u="$(xcrun simctl list devices | grep -m1 "$dev (" | grep -oE '[0-9A-F-]{36}' || true)"
  if [ -z "$u" ]; then
    rt="$(xcrun simctl list runtimes | grep -i ios | grep -oE 'com.apple.CoreSimulator.SimRuntime.iOS[^ ]*' | tail -1)"
    local dt; dt="$(xcrun simctl list devicetypes | grep -oE "com.apple.CoreSimulator.SimDeviceType.$(echo "$dev" | tr ' ' '-')" | head -1)"
    u="$(xcrun simctl create "yukti-$dev" "$dt" "$rt")"; ok "created $dev"
  fi
  xcrun simctl boot "$u" 2>/dev/null || true
  xcrun simctl bootstatus "$u" -b >/dev/null 2>&1 || true
  open -a Simulator
  echo "$u" > "$(udid_file)"; ok "booted $dev ($u)"
}
# The flag is read wherever it sits on the line, like `flow --check`: an option
# silently taken for the variant name is the kind of miss that ends as a run
# against the wrong build.
install(){ local V='' apk='' a
  while [ $# -gt 0 ]; do a="$1"; shift
    case "$a" in
      --apk) [ -z "$apk" ] || die "one --apk at a time - usage: yukti install [--apk <path>] <variant>"
             [ $# -gt 0 ] || die "--apk needs a path - usage: yukti install [--apk <path>] <variant>"
             apk="$1"; [ -n "$apk" ] || die "--apk needs a path - usage: yukti install [--apk <path>] <variant>"; shift;;
      --apk=*) [ -z "$apk" ] || die "one --apk at a time - usage: yukti install [--apk <path>] <variant>"
               apk="${a#--apk=}"; [ -n "$apk" ] || die "--apk needs a path - usage: yukti install [--apk <path>] <variant>";;
      -*) die "unknown option '$a' - usage: yukti install [--apk <path>] <variant>";;
      *) [ -z "$V" ] || die "one variant at a time - usage: yukti install [--apk <path>] <variant>"; V="$a";;
    esac
  done
  [ -n "$V" ] || die "usage: yukti install [--apk <path>] <variant>"
  is_android && { install_android "$V" "$apk"; return; }
  [ -z "$apk" ] || die "--apk installs an Android APK and this run is iOS - 'install' takes the .app that 'yukti build $V' or 'yukti pull $V' left"
  paths; local u app; u="$(cat "$(udid_file)")"; app="$(cat "${TMPDIR:-/tmp}/yukti-app-$V")"
  xcrun simctl install "$u" "$app" && ok "installed $(basename "$app")"; }
launch(){ local V="${1:?variant}"; is_android && { launch_android "$V"; return; }; paths; local u b; u="$(cat "$(udid_file)")"; b="$(cfg variants.$V.bundleId)"
  xcrun simctl launch "$u" "$b" && ok "launched $b"; }
# A flow inherits whatever the flow before it left behind - the session, the
# onboarding, cached content, a half-filled form. The same flow is then green
# alone and red in a suite, and nothing in the report says why. Nothing in the
# vocabulary dropped that state: `launch` on Android goes through `monkey`,
# which brings a RUNNING app to the front, and `install` uses `adb install -r`,
# which keeps the data of the previous install on purpose.
#
# Both steps take the variant name, the way `launch` does, so the package and
# the bundle id come from the config and never from the flow file.
# The platform of a step is the platform of the RUN. Asking the config for the
# variant alone is not that: a variant that names no platform of its own does
# not make the step iOS, and a config that names the platform once at the top
# level is legal - so a step reading the variant on its own would take the iOS
# path in the middle of an Android run. The run's platform is resolved once
# before the command starts, and that is what every drive primitive reads.
# Honour a variant that names its own platform, and otherwise follow the run.
step_platform(){ local V="$1" p; p="$(cfg "variants.$V.platform")"; printf '%s' "${p:-$YUKTI_PLATFORM}"; }
stop_app(){ local V="${1:-}" plat; [ -n "$V" ] || die "stopApp needs a variant name - the one used in $CONFIG"
  plat="$(step_platform "$V")"
  if [ "$plat" = android ]; then apaths; local pkg installed; pkg="$(cfg variants.$V.package)"
    [ -n "$pkg" ] || die "stopApp: variant '$V' has no 'package' in $CONFIG"
    # `am force-stop` prints nothing and exits zero for a package that is not
    # installed, exactly as for one that is. Without this probe the step reports
    # a stop that never happened every time the config carries a typo.
    installed="$(adb shell pm list packages 2>/dev/null | tr -d '\r')" || true
    # An empty list means the device did not answer, not that the app is gone:
    # without this the step blames the package name for an unreachable device
    # and sends the reader to fix the config.
    [ -n "$installed" ] || die "stopApp: could not read the package list from the device"
    case "
$installed
" in *"
package:$pkg
"*) ;; *) die "stopApp: '$pkg' is not installed on the device";; esac
    adb shell am force-stop "$pkg" || die "stopApp: force-stop failed for '$pkg'"
    ok "stopped $pkg"; return; fi
  paths; local u b; u="$(cat "$(udid_file)" 2>/dev/null)" || true
  [ -n "$u" ] || die "stopApp: no simulator recorded in $(udid_file) - run 'yukti up <variant>' first"
  b="$(cfg variants.$V.bundleId)"
  [ -n "$b" ] || die "stopApp: variant '$V' has no 'bundleId' in $CONFIG"
  # simctl refuses when the app is not running, and "not running" is the state
  # this step exists to reach: that refusal is a warning, not a failure.
  # Any refusal from simctl reads as "it was not running" here, including a
  # simulator that is not booted. The narrower form needs a Mac to write and to
  # check; the runbook names the gap rather than implying it is closed.
  xcrun simctl terminate "$u" "$b" 2>/dev/null && ok "stopped $b" || warn "stopApp: '$b' was not running (or the simulator refused)"; }
clear_state(){ local V="${1:-}" plat; [ -n "$V" ] || die "clearState needs a variant name - the one used in $CONFIG"
  plat="$(step_platform "$V")"
  [ "$plat" = android ] || die "clearState is Android-only for now - see RUNBOOK; on iOS reinstall the app between flows"
  apaths; local pkg out; pkg="$(cfg variants.$V.package)"
  [ -n "$pkg" ] || die "clearState: variant '$V' has no 'package' in $CONFIG"
  # `pm clear` says what happened in words, and its exit status does not agree
  # across platform versions: "Failed" comes back with status 1 on API 35 and
  # with status 0 elsewhere. The word is the evidence, so the step reads the
  # word, anchored at the start of the answer - stderr lands in the same string
  # and a warning that merely contains "Success" must not pass for one. `|| true`
  # is there for one reason: under `set -e` the step would otherwise die before
  # reading the answer, with no message at all.
  out="$(adb shell pm clear "$pkg" 2>&1 | tr -d '\r')" || true
  case "$out" in
    Success*) ok "cleared $pkg - the app is stopped now, launch it before the next step";;
    *) die "clearState: pm clear refused '$pkg': ${out:-no output}";;
  esac; }
# A flow could reach a screen only by walking to it: open the app, find the tab,
# find the card. Walking is the test when the test is about navigation, and pure
# cost in every other flow - and it cannot be walked back, which is why a suite
# ends up force-stopping the app between flows to get a known screen. Measured on
# one app on one Android 15 emulator, two runs each: stopApp + launch + dismiss +
# waitForId takes 34.3 and 33.7 seconds, the same screen through an address 3.7
# and 5.2 - and the address resets the scroll position of every tab, which is the
# part a tab tap does not do.
#
# What this step promises is delivery, not arrival, and the words it reports say
# so. `am start` answers "ok" for an address the app does not route - measured:
# myapp://nope-not-a-route comes back Status: ok and the app stays where it was -
# so a step that said it opened a screen would be lying for the app. Arrival is
# also later than the step: -W returns once the launch has settled, and for an
# address delivered to an app already running it returns at once, before the app
# has navigated. The step after this one therefore waits for the screen
# (waitFor / waitForId); an assert placed there races the navigation.
#
# The app is the one this run is driving, not one named in the step: the address
# is the object here, the way the variant is the object of launch and stopApp.
# Naming the package matters, and is not decoration: a device with the dev and
# the prod build side by side has two apps claiming the same scheme, and an
# unaddressed intent then opens a chooser - a flow would sit in front of it
# until it timed out, with nothing in the report about why.
#
# And the flow says WHERE, never in which environment. A scheme written into a
# step is the environment written into the test: the day the qa build and the
# prod build stop sharing one, every flow that carries an address has to be
# edited, and a flow edited for an environment is a flow that can only be run in
# that one. So the step carries a key, the config carries the base ("urlBase" on
# the variant, or one at the top level for a project whose builds share it), and
# a project that wants the paths in one place puts them there too ("urls"), so
# that a route the app renames is one line to change instead of forty.
#
# The table is all or nothing on purpose. Once a "urls" table exists, a key that
# is not in it is a typo or a route nobody wrote down - falling back to using the
# key as the path would open the address the author did not ask for and report
# success. Without a table, the key IS the path, which is the shape a project
# starts in.
url_of(){ local V="$1" key="$2" base table path
  base="$(cfg "variants.$V.urlBase")"; [ -n "$base" ] || base="$(cfg urlBase)"
  [ -n "$base" ] || die "openUrl: nothing says which address space this run is in - put \"urlBase\" on variant '$V' in $CONFIG, or one at the top level"
  table="$(cfg urls)"
  if [ -n "$table" ] && [ "$table" != "{}" ]; then
    # The key is looked up whole. The config reader walks a dotted path, and a
    # key with a dot in it would read as missing while sitting in the table in
    # plain sight - the author would then be told to add what is already there.
    path="$(printf '%s' "$table" | "$PY" -c 'import json,sys
d=json.load(sys.stdin); v=d.get(sys.argv[1], "")
print(v if isinstance(v, str) else "")' "$key")"
    [ -n "$path" ] || die "openUrl: '$key' is not in the \"urls\" table of $CONFIG - add it there, or remove the table and let the step carry the path"
  else
    path="$key"
  fi
  printf '%s%s' "$base" "$path"; }
open_url(){ local K="${1:-}" U plat V pkg out act
  [ -n "$K" ] || die "openUrl needs a destination - the key of a screen, for instance \"home\""
  V="${YUKTI_VARIANT:-}"
  [ -n "$V" ] || die "openUrl: this run does not know which app to hand '$K' to - set \$YUKTI_VARIANT to the variant in $CONFIG"
  [ -n "$(cfg "variants.$V")" ] || die "openUrl: there is no variant '$V' in $CONFIG"
  U="$(url_of "$V" "$K")" || exit 1
  plat="$(step_platform "$V")"
  if [ "$plat" = android ]; then apaths
    # The address travels through a shell on the device, where &, ; and a space
    # are punctuation. Single quotes hold all of those; a single quote inside the
    # address is the one thing they cannot hold, and guessing at it would hand the
    # device half an address. iOS takes the address as an argument and never
    # through a shell, so the refusal belongs on this side of the branch only.
    case "$U" in *"'"*) die "openUrl: the address contains a single quote, and it cannot be passed to the device shell intact: $U";; esac
    pkg="$(cfg "variants.$V.package")"
    [ -n "$pkg" ] || die "openUrl: variant '$V' has no 'package' in $CONFIG"
    # -W waits for the launch to settle and makes `am` say what happened in
    # words. The words are the evidence: `am start` exits zero on an intent it
    # could not resolve, the same as on one it delivered, so the status alone
    # would pass a typo'd scheme. `|| true` keeps `set -e` from killing the step
    # before the answer is read.
    out="$(adb shell am start -W -a android.intent.action.VIEW -d "'$U'" "$pkg" 2>&1 | tr -d '\r')" || true
    case "$out" in
      *"Error:"*) die "openUrl: the device refused '$U': $(printf '%s' "$out" | grep -m1 'Error:')";;
    esac
    # "Warning: Activity not started, intent has been delivered to currently
    # running top-most instance" is the ordinary answer for an app already in
    # front, and it is a delivery, not a refusal. "Status: ok" is what separates
    # the two, and it is printed in both cases.
    case "$out" in
      *"Status: ok"*) ;;
      *) die "openUrl: '$U' was not delivered - the device said: $(printf '%s' "${out:-nothing}" | tr '\n' ' ')";;
    esac
    # The intent named the package, so it cannot have gone elsewhere; this reads
    # back what actually took it, so that a report says which app answered.
    act="$(printf '%s' "$out" | sed -n 's/^Activity: //p' | head -1)"
    case "${act:-$pkg/}" in
      "$pkg"/*) ;;
      *) die "openUrl: '$U' was taken by $act, not by $pkg";;
    esac
    ok "delivered $U to $pkg"; return; fi
  paths; local u; u="$(cat "$(udid_file)" 2>/dev/null)" || true
  [ -n "$u" ] || die "openUrl: no simulator recorded in $(udid_file) - run 'yukti up <variant>' first"
  # simctl resolves the scheme itself and takes no bundle id, so on iOS the
  # address goes wherever the system sends it. Two builds claiming one scheme is
  # the same hazard as on Android and there is nothing here to aim with; the
  # RUNBOOK names the gap rather than implying it is closed.
  xcrun simctl openurl "$u" "$U" >/dev/null 2>&1 || die "openUrl: the simulator refused '$U'"
  ok "delivered $U"; }
up(){ local V="${1:?usage: yukti up <variant>}"; build "$V"; boot; install "$V"; launch "$V"; ok "up: '$V' running — drive with 'yukti shot' / 'yukti flow'"; }

# ── drive primitives (used by flows AND by AI agents) ────────────────────────
U(){ cat "$(udid_file)" 2>/dev/null || booted; }
idb_ready(){ paths; idb connect "$(U)" >/dev/null 2>&1 || true; }
shot(){ is_android && { ashot "$@"; return; }; paths; local o; o="$(shot_path "${1:-}")"; mkdir -p "$(dirname "$o")"; xcrun simctl io "$(U)" screenshot "$o" >/dev/null 2>&1 && echo "$o"; }
ui(){ is_android && { aui; return; }; [ -n "$HELD_TREE" ] && { printf '%s' "$HELD_TREE"; return 0; }; idb_ready; local u out i; u="$(U)"; for i in 1 2 3 4; do out="$(idb ui describe-all --udid "$u" 2>/dev/null)"; [ -n "$out" ] && { printf '%s' "$out"; return 0; }; sleep 0.6; done
  # An empty tree after four tries is idb failing, not a screen with nothing on
  # it: a booted simulator always reports at least its window. Returning the
  # empty string made every caller read it as "the element is not there". The
  # Android path already fails here; this is the same rule for iOS.
  die "idb returned no accessibility tree after 4 tries - is the simulator booted?"; }
find_el(){ is_android && { afind "$1" "${2:-text}" "${3:-exact}" "${4:-centre}"; return; }; paths; ui | "$PY" -c '
import json,sys,os,traceback
# A lookup must never leave with status 1: that status now means the screen
# could not be read AND the reason is already printed. Anything unforeseen in
# here is the other kind - the device answered with something this code cannot
# use - so it leaves as 2 and the caller says what that means rather than dying
# without any words. The traceback still reaches stderr: a lookup that crashes
# is a bug in this tool, not a state of the app.
def _unforeseen(t,v,tb):
    traceback.print_exception(t,v,tb); sys.stderr.flush(); os._exit(2)
sys.excepthook=_unforeseen
# Rank candidates instead of the first AXLabel/AXValue substring hit: honor the
# accessibility identifier (AXUniqueId) and prefer the actual field/control over
# a nearby static caption — same bug the Android path had ("Email" hit the "or
# use email" caption; "Password" hit the "Forgot Password?" link).
norm=lambda s: " ".join(s.split())   # a two-line caption is one query with spaces
# Same rule as the Android path: the oracle compares as written, locators fold case.
q_raw=norm(sys.argv[1]); q=q_raw.lower()
mode=(sys.argv[2] if len(sys.argv)>2 else "text")
match=(sys.argv[3] if len(sys.argv)>3 else "exact")
# The centre is the tap point and the default; "box" asks for the frame of the
# winner and the window it is judged against. Same contract as the Android path.
out=(sys.argv[4] if len(sys.argv)>4 else "centre")
raw=sys.stdin.read().strip()
if not raw: sys.exit(1)   # 1 = the tree never arrived; ui() has already said why
# Something arrived and it is not a tree: a traceback here would leave the step
# with no words of its own, so this is status 2 - answered, unreadable - the way
# ifield_probe already reports it.
try: els=json.loads(raw)
except ValueError: sys.exit(2)
def frame(e):
    # idb builds disagree on the case of the frame keys, so read both spellings.
    b=e.get("frame") or {}
    def n(*ks):
        for k in ks:
            v=b.get(k)
            if v is None: continue
            try: return float(v)
            except (TypeError,ValueError): return 0.0
        return 0.0
    return n("X","x"),n("Y","y"),n("Width","width"),n("Height","height")
# Screen rect = the window: the element idb names as the application or a window
# if there is one, else the largest frame on screen. Application is larger than
# any of its children by construction, and an iOS scroll view reports the visible
# part rather than its content, so the maximum is the window on lists too. Taking
# the FIRST framed element instead would rest on the order of a flat list, which
# idb does not promise - an alert, an overlay or the keyboard window can come
# first and shrink the rect to its own size. The Android path can rely on order
# because the dump format puts the root node first; here that guarantee is absent.
SX=SY=SW=SH=0.0; _best=(0,-1.0)
for _e in els:
    _x,_y,_w,_h=frame(_e)
    if not (_w>0 and _h>0): continue
    _t=(str(_e.get("type") or "")+" "+str(_e.get("role_description") or "")).lower()
    _rank=(1 if ("application" in _t or "window" in _t) else 0, _w*_h)
    if _rank>_best: _best=_rank; SX,SY,SW,SH=_x,_y,_w,_h
def centre(e):
    # The tap point, or None when the element is not really on screen: a11y reports
    # elements with no size and elements parked outside the window, and a check that
    # accepts them is not a check.
    x,y,w,h=frame(e)
    if not (w>0 and h>0): return None
    cx=int(x+w/2); cy=int(y+h/2)
    if SW>0 and SH>0 and not (SX<=cx<SX+SW and SY<=cy<SY+SH): return None
    return cx,cy
# Same reason as the Android path, assertId included. Note the limit of the rect
# itself: when no element is named application or window, the largest frame wins
# and it always contains itself, so on such an idb build the off-screen filter
# stops discriminating. Requiring a named window here would fail closed on every
# build that names none, which cannot be verified without a Mac.
if mode in ("assert","id") and not (SW>0 and SH>0): sys.exit(3)   # 3 = read, not judgeable
def emit(e,xy):
    # The frame in the same coordinates as the centre above it: whatever the
    # scale of this idb build, a share of the screen is the same share, and a
    # size in points is compared with a minimum written in points.
    if out=="box":
        x,y,w,h=frame(e); print(int(x),int(y),int(x+w),int(y+h),int(SX),int(SY),int(SX+SW),int(SY+SH))
    else: print(xy[0],xy[1])
best=None; best_el=None; best_score=-1
for e in els:
    label_raw=norm(str(e.get("AXLabel") or "")); label=label_raw.lower()
    value_raw=norm(str(e.get("AXValue") or "")); value=value_raw.lower()
    title_raw=norm(str(e.get("title") or "")); title=title_raw.lower()
    ident=str(e.get("AXUniqueId") or e.get("identifier") or "").lower()
    t=(str(e.get("type") or "")+" "+str(e.get("role_description") or "")).lower()
    editable=("textfield" in t) or ("securetext" in t) or ("text field" in t) or ("textview" in t)
    control=("button" in t) or ("link" in t)
    if mode=="id":
        # Explicit selector: the accessibility identifier must match exactly, and an
        # empty query matches nothing - the flow runner passes value="" for a missing
        # or misspelled key, and that must not resolve to the first element around.
        if not q or q!=ident: continue
        xy=centre(e)
        if xy is None: continue   # keep looking: a sized namesake may follow
        emit(e,xy); sys.exit(0)
    if mode=="assert":
        # See the Android path: an assertion matches perceivable text only, on an
        # element that is really on screen - real size, centre inside the window,
        # and the whole label as written unless the flow asks for "contains".
        if match=="contains":
            if not (q and (q in label or q in value or q in title)): continue
        elif not (q_raw and q_raw in (label_raw, value_raw, title_raw)): continue
        xy=centre(e)
        if xy is None: continue
        emit(e,xy); sys.exit(0)
    if not (q and (q in ident or q in label or q in value or q in title)): continue
    score=0
    if q==ident: score+=6
    elif q in ident: score+=5
    if q in (label,value,title): score+=4
    elif q in label or q in value or q in title: score+=2
    if editable: score+=3
    if control: score+=1
    x,y,w,h=frame(e)
    # A zero-size element cannot be tapped: a hidden namesake used to win the
    # ranking and send the tap to (0,0). Off-screen elements stay eligible here -
    # scroll_to needs their coordinates to know which way to scroll - but a
    # candidate that is actually on screen outranks one that is not, so a namesake
    # further down a list can no longer take the tap away from the visible one.
    if not (w>0 and h>0): continue
    if centre(e) is not None: score+=2
    if score<=best_score: continue
    best=(int(x+w/2),int(y+h/2)); best_el=e; best_score=score
if best: emit(best_el,best)
' "$1" "${2:-text}" "${3:-exact}" "${4:-centre}"; }
tap(){ is_android && { atap "$1" "$2"; return; }; paths; idb ui tap --udid "$(U)" "${1:?x}" "${2:?y}"; }
# A lookup has three outcomes, not two: coordinates, "no such element", and
# "the screen could not be read at all". The third one used to arrive as the
# second, because `die` inside aui/ui exits a command-substitution subshell and
# the status was dropped. A flow then skipped an optionalTapText, swiped eight
# times looking for a label, or blamed a missing element for a broken dump.
# Every lookup below now checks the status: an unreadable screen stops the run.
# Status 5 hands back the last sighting, which is stale by definition, so this
# verb must not tap it: an element that left the screen is an element that is not
# there, which is the answer it has always given.
tap_text(){ local xy st=0; xy="$(scroll_to "$1")" || st=$?
  case "$st" in 0) ;; 5) die "no element matching '$1'";; *) lookup_die "$st";; esac
  [ -n "$xy" ] || die "no element matching '$1'"; tap $xy; }  # auto-scrolls off-screen elements into view
# The exception these two verbs enjoy is about an ABSENT label, and nothing else.
# A device that refuses the tap is not an absent label: the status of `tap` is
# checked here, or the step keeps its exception and reports a tap that never
# happened - the very class of lie the status check above exists to end.
tap_text_optional(){ local xy st=0; xy="$(find_el "$1")" || st=$?; [ "$st" -eq 0 ] || lookup_die "$st"; [ -n "$xy" ] && { tap $xy || die "optional tap '$1': the device refused the tap"; ok "optional tap '$1'"; } || warn "optional '$1' not present — skipped"; }
# Auto-dismiss the interstitials that derail flows (Save Password, notifications, promos…)
# only SAFE interstitial labels — never Cancel/Close/OK (those navigate/confirm)
DISMISS_LABELS=("Not Now" "Not now" "Maybe Later" "Later" "Skip for now" "Skip" "No thanks" "No Thanks" "Got it")
# One read per round, not one per label: the nine labels are looked for in the
# same tree, the first one found is tapped, and the next round reads the screen
# again because the tap changed it. A clean screen costs one read instead of
# nine; two stacked cost three, and the second is found whichever label it
# carries - the single pass this replaces could walk past it. The rounds are
# capped - a screen that keeps answering with another interstitial is news, and
# the step says so instead of looping. It stops after the sixth tap without
# reading the screen again, so it never claims to know what is on it.
dismiss(){ local L xy st hit round=0
  while :; do
    HELD_TREE=""
    # ui() says why itself and leaves non-zero from the substitution; the step
    # stops here rather than deciding against a screen it could not read.
    HELD_TREE="$(ui)" || exit 1
    [ -n "$HELD_TREE" ] || die "dismiss: the screen came back empty"
    hit=""; xy=""
    for L in "${DISMISS_LABELS[@]}"; do
      st=0; xy="$(find_el "$L")" || st=$?
      [ "$st" -eq 0 ] || { HELD_TREE=""; lookup_die "$st"; }
      [ -n "$xy" ] && { hit="$L"; break; }
    done
    HELD_TREE=""
    [ -n "$hit" ] || return 0
    tap $xy || die "dismiss: the device refused the tap on '$hit'"
    sleep 0.6; ok "dismissed '$hit'"
    round=$((round + 1))
    [ "$round" -lt 6 ] || { warn "dismiss: six interstitials tapped in a row - stopping here without reading the screen again, and leaving whatever is on it to the step that follows"; return 0; }
  done; }
type_str(){ is_android && { atype "$1"; return; }; paths; local s="${1:?text}" i; for ((i=0;i<${#s};i++)); do idb ui text --udid "$(U)" "${s:$i:1}" >/dev/null 2>&1; sleep 0.12; done; }  # char-by-char = reliable
# The right edge of the field under a point, and what it holds. Both come out of
# one dump: on iOS the caret has to be put at the end of THIS field before
# deleting, and the old code tapped a fixed x=360 - right for one screen width
# and silently wrong for every other.
ifield_probe(){ paths; ui | "$PY" -c '
import json,sys,os,traceback
# A lookup must never leave with status 1: that status now means the screen
# could not be read AND the reason is already printed. Anything unforeseen in
# here is the other kind - the device answered with something this code cannot
# use - so it leaves as 2 and the caller says what that means rather than dying
# without any words. The traceback still reaches stderr: a lookup that crashes
# is a bug in this tool, not a state of the app.
def _unforeseen(t,v,tb):
    traceback.print_exception(t,v,tb); sys.stderr.flush(); os._exit(2)
sys.excepthook=_unforeseen
x0,y0=float(sys.argv[1]),float(sys.argv[2])
def frame(e):
    b=e.get("frame") or {}
    g=lambda *k: float(next((b[n] for n in k if n in b), 0) or 0)
    return g("x","X"), g("y","Y"), g("width","Width"), g("height","Height")
best=None; best_area=0.0
try: els=json.loads(sys.stdin.read())
except ValueError: sys.exit(2)
for e in els:
    x,y,w,h=frame(e)
    if w<=0 or h<=0: continue
    if not (x<=x0<x+w and y<=y0<y+h): continue
    if best is None or w*h<best_area: best, best_area = e, w*h
if best is None: sys.exit(3)
x,y,w,h=frame(best)
v=str(best.get("AXValue") or "")
t=(str(best.get("type") or "")+" "+str(best.get("role_description") or "")).lower()
sys.stdout.write("%d\x1f%s\x1f%s" % (int(x+w-4), "field" if ("field" in t or "textview" in t) else t or "element", v.replace("\n"," ")))
' "$1" "$2"; }
# iOS has no select-all through idb, so the deletes are counted from what the
# field actually holds - measured in the dump, not a constant that was right for
# one form. The read-back after them is the same rule as on Android.
iclear(){ paths; local x="${1:?x}" y="${2:?y}" vb="${3:-clearText}" u out edge val after i kind
  # iOS is written from the idb docs and has not been run - see AF-USER-GUIDE.
  u="$(U)"
  idb ui tap --udid "$u" "$x" "$y" || die "$vb: the simulator refused the tap on the field"
  sleep .4
  out="$(ifield_probe "$x" "$y")" || lookup_die $? "$vb: no element at $x,$y"
  IFS=$'\x1f' read -r edge kind val <<<"$out"
  [ "$kind" = field ] || die "$vb: the element at $x,$y is a $kind, not a text field"
  [ -n "$val" ] || { ok "$vb: the field was already empty"; return 0; }
  idb ui tap --udid "$u" "$edge" "$y" || die "$vb: the simulator refused the tap at the end of the field"
  sleep .3
  for i in $(seq 1 ${#val}); do idb ui key --udid "$u" 42 >/dev/null 2>&1 || die "$vb: the simulator refused the delete key"; done
  out="$(ifield_probe "$x" "$y")" || lookup_die $? "$vb: no element at $x,$y any more"
  IFS=$'\x1f' read -r edge kind after <<<"$out"
  [ -z "$after" ] || die "$vb: the field still holds text after ${#val} deletes"
  ok "$vb: field empty at $x,$y"; }
clear_field(){ is_android && { aclear "$1" "$2" "${3:-}"; return; }; iclear "$1" "$2" "${3:-}"; }
# One key, by name, on either platform. iOS has no Back key at all - saying so is
# the whole point: a flow that asks for one on iOS is wrong about the platform,
# and pressing nothing would let it believe otherwise.
press_key(){ local name="${1:-}" u
  [ -n "$name" ] || die "pressKey needs a key name - one of: $KEY_NAMES"
  case " $KEY_NAMES " in *" $name "*) ;; *) die "pressKey does not know the key '$name' - use one of: $KEY_NAMES";; esac
  if is_android; then
    akey "$name" || die "pressKey '$name': the device refused the key"
  else
    paths; u="$(U)"
    case "$name" in
      back) die "pressKey 'back' is Android only - iOS has no Back key; tap the screen's own back control with tapText or tapId";;
      home)   idb ui button HOME --udid "$u" || die "pressKey 'home': the simulator refused the button";;
      enter)  idb ui key --udid "$u" 40 || die "pressKey 'enter': the simulator refused the key";;
      escape) idb ui key --udid "$u" 41 || die "pressKey 'escape': the simulator refused the key";;
      delete) idb ui key --udid "$u" 42 || die "pressKey 'delete': the simulator refused the key";;
      tab)    idb ui key --udid "$u" 43 || die "pressKey 'tab': the simulator refused the key";;
    esac
  fi
  ok "pressKey '$name'"; }
# Whether a keyboard is up, asked of iOS the only way idb allows: the tree names
# the keyboard as an element. Same three outcomes as the Android side.
ikb_shown(){ paths; ui | "$PY" -c '
import json,sys
try: els=json.loads(sys.stdin.read())
except ValueError: sys.exit(2)   # an unreadable tree is not "no keyboard"
for e in els:
    t=(str(e.get("type") or "")+" "+str(e.get("role_description") or "")).lower()
    if "keyboard" in t: sys.exit(0)
sys.exit(1)
'; }
# hideKeyboard is pressKey with a postcondition, which is why it is built on it
# rather than beside it. The postcondition is the verb: an Android form longer
# than one field cannot be filled while the keyboard is up, so "the keys were
# accepted" is not an answer - "the keyboard is down" is.
kb_shown(){ if is_android; then akb_shown; else ikb_shown; fi; }
hide_keyboard(){ local st=0
  kb_shown || st=$?
  if [ "$st" -eq 2 ]; then die "hideKeyboard: the device did not report its input method state"; fi
  if [ "$st" -eq 1 ]; then ok "hideKeyboard: no keyboard was up"; return 0; fi
  press_key escape >/dev/null || die "hideKeyboard: the device refused the escape key"
  sleep 0.6
  st=0; kb_shown || st=$?
  if [ "$st" -eq 2 ]; then die "hideKeyboard: the device did not report its input method state"; fi
  if [ "$st" -eq 1 ]; then ok "hideKeyboard: keyboard closed"; return 0; fi
  if ! is_android; then die "hideKeyboard: the keyboard is still up after escape"; fi
  # Back is the fallback and never the mechanism, and only while the keyboard is
  # still up: the IME consumes it then. Measured on API 35 - with the keyboard
  # already down the same key walked the rig out of the app and, one screen
  # later, out to the launcher.
  warn "hideKeyboard: escape did not close the keyboard - trying Back"
  akey back || die "hideKeyboard: the device refused the back key"
  sleep 0.6
  st=0; akb_shown || st=$?
  if [ "$st" -eq 2 ]; then die "hideKeyboard: the device did not report its input method state"; fi
  if [ "$st" -eq 1 ]; then ok "hideKeyboard: keyboard closed (Back)"; return 0; fi
  die "hideKeyboard: the keyboard is still up after escape and Back"; }
# An assert must prove the element is ON SCREEN, not that the string appears
# somewhere in the dump. `ui | grep` matched invisible nodes, off-screen rows and
# attribute values, so an assertText passed on a screen that never opened. Reuse
# the same lookup taps use: relevance, ranking and real non-zero bounds.
# The match rule is part of the assertion, so the flow states it and the log
# repeats it: a green line that does not say how it matched cannot be read back.
assert_text(){ local q="${1:-}" m="${2:-exact}" xy st=0
  [ -n "$q" ] || die "assertText needs a non-empty value"
  case "$m" in exact|contains) ;; *) die "assertText: unknown match '$m' - use exact or contains";; esac
  xy="$(find_el "$q" assert "$m")" || st=$?
  [ "$st" -eq 0 ] || die "assert FAILED: $(lookup_err "$st")"
  [ -n "$xy" ] && ok "assert ($m): '$q' on screen at $xy" || die "assert FAILED ($m match): '$q' not on screen"; }
# Explicit-selector variants. tapText/assertText match on visible text and can
# land on a neighbouring caption; these two take an accessibility id / resource-id
# segment and match it exactly, or fail. An empty id is a failure, never a match:
# a flow step with a missing or misspelled `value` must stop the run, not tap a
# random point. Neither scrolls — use scrollToText first for a target below the fold.
tap_id(){ local q="${1:-}"; [ -n "$q" ] || die "tapId needs a non-empty id"; local xy st=0; xy="$(find_el "$q" id)" || st=$?; [ "$st" -eq 0 ] || die "$(lookup_err "$st")"; [ -n "$xy" ] || die "no element with id '$q'"; tap $xy; }
assert_id(){ local q="${1:-}"; [ -n "$q" ] || die "assertId needs a non-empty id"; local xy st=0; xy="$(find_el "$q" id)" || st=$?; [ "$st" -eq 0 ] || die "assert FAILED: $(lookup_err "$st")"; [ -n "$xy" ] && ok "assert id: '$q' on screen at $xy" || die "assert FAILED: no element with id '$q'"; }
# The density of the screen, for a constraint written in dp. Read once per run
# and kept: it cannot change under a run, and every box assertion would
# otherwise pay for another adb round trip. An override density wins when one is
# set - that is the density the app is actually laid out at.
ADPI=""
adpi(){
  [ -n "$ADPI" ] && { printf '%s' "$ADPI"; return 0; }
  # The lookup above ran in a command substitution, so the paths it set up died
  # with that subshell: without this, a machine whose adb is not on PATH answers
  # every dp constraint with "the device did not report its density" while every
  # other step works.
  apaths
  local out; out="$(adb shell wm density 2>/dev/null | tr -d '\r')"
  ADPI="$(printf '%s\n' "$out" | sed -n 's/^Override density: *\([0-9][0-9]*\).*/\1/p' | tail -1)"
  [ -n "$ADPI" ] || ADPI="$(printf '%s\n' "$out" | sed -n 's/^Physical density: *\([0-9][0-9]*\).*/\1/p' | tail -1)"
  [ -n "$ADPI" ] || return 1
  printf '%s' "$ADPI"; }
# ── assertBox: the geometry the lookup already read ──────────────────────────
# Every lookup reads the box of its candidates to decide whether they are on the
# screen at all, and then keeps the centre. What a layout check asks about lives
# in the numbers it drops: is this control still big enough to hit, is this
# header below the notch, did this banner go edge to edge. assertBox asks for the
# box instead and compares it with what the flow says.
#
# A constraint is written in pixels (48), in dp ("48dp") or as a share of the
# screen ("5%") - the share is of the width for the horizontal ones and of the
# height for the vertical ones, so the same flow file reads the same on a phone
# and on a tablet. dp is the unit the rules are written in: a touch target is 48
# of them on Android and 44 points on iOS, and on a 320 dpi screen 48 pixels is
# 24 dp - half the minimum, asserted as if it passed. The measured numbers go
# into the message whatever the unit: "48x50, expected height at least 96" is a
# bug report, "assert failed" is not.
#
# The spec arrives as one field of name/value pairs, packed with the two
# separators the conditions use - same reason: several of them belong to one step.
assert_box(){
  local q="${1:-}" kind="${2:-assert}" spec="${3:-}" what
  [ "$kind" = id ] && what="id" || what="text"
  [ -n "$q" ] || die "assertBox needs a non-empty $what"
  # A box assertion with no constraint passes on anything that is on the screen,
  # which is what assertText and assertId already say. The builder refuses it
  # before the run; this is the same door for the command line.
  [ -n "$spec" ] || die "assertBox needs at least one constraint - minWidth, minHeight, maxWidth, maxHeight, minX, minY, maxX, maxY"
  local dpi=""
  local out st=0
  out="$(find_el "$q" "$kind" exact box)" || st=$?
  [ "$st" -eq 0 ] || die "assert FAILED: $(lookup_err "$st")"
  [ -n "$out" ] || die "assert FAILED: no element with $what '$q'"
  local x1 y1 x2 y2 sx1 sy1 sx2 sy2
  read -r x1 y1 x2 y2 sx1 sy1 sx2 sy2 <<<"$out"
  local w=$((x2-x1)) h=$((y2-y1)) sw=$((sx2-sx1)) sh=$((sy2-sy1))
  local where="${w}x${h} at [$x1,$y1][$x2,$y2]" bad="" pair name val px n want
  while [ -n "$spec" ]; do
    case "$spec" in *"$COND_SEP"*) pair="${spec%%"$COND_SEP"*}"; spec="${spec#*"$COND_SEP"}";; *) pair="$spec"; spec="";; esac
    name="${pair%%"$COND_KV"*}"; val="${pair#*"$COND_KV"}"
    case "$val" in
      *%) n="${val%\%}"
          # A share of the screen needs a screen. A dump that does not report its
          # own rect cannot answer "5% of the height", and a question that cannot
          # be answered must not come back as a pass.
          case "$name" in
            minWidth|maxWidth)
              [ "$sw" -gt 0 ] || die "assert FAILED: '$name' is a share of the screen width, and this screen does not report its own rect"
              px=$(( sw * n / 100 ));;
            minHeight|maxHeight)
              [ "$sh" -gt 0 ] || die "assert FAILED: '$name' is a share of the screen height, and this screen does not report its own rect"
              px=$(( sh * n / 100 ));;
            # An edge is a place, not a length: "5% down the screen" is measured
            # from where the screen starts. On Android the root rect starts at
            # 0,0 and the two readings agree; an iOS window need not, and a
            # percentage counted from zero would then point outside it.
            minX|maxX)
              [ "$sw" -gt 0 ] || die "assert FAILED: '$name' is a share of the screen width, and this screen does not report its own rect"
              px=$(( sx1 + sw * n / 100 ));;
            *)
              [ "$sh" -gt 0 ] || die "assert FAILED: '$name' is a share of the screen height, and this screen does not report its own rect"
              px=$(( sy1 + sh * n / 100 ));;
          esac;;
      *dp) n="${val%dp}"
          if is_android; then
            # A dp that cannot be converted must not pass as a pixel: 48 unread
            # would compare against 48 pixels and call half a touch target fine.
            if [ -z "$dpi" ]; then
              # Called for the variable, not for what it prints: in a command
              # substitution the cache it fills would die with the subshell, and
              # every dp constraint would go back to the device for a number
              # that cannot change under a run.
              adpi >/dev/null || die "assert FAILED: '$name' is written in dp and the device did not report its density"
              dpi="$ADPI"
            fi
            px=$(( n * dpi / 160 ))
          else
            # An iOS frame is already in points, which is the density-independent
            # unit: 44pt is 44 here, and the conversion is the identity.
            px="$n"
          fi;;
      *) px="$val";;
    esac
    # Both numbers, because either alone hides half the answer: "at least 48dp"
    # does not say what that is on this screen, and "at least 96" does not say
    # which line of the flow file to change.
    if [ "$px" = "$val" ]; then want="$val"; else want="$val = $px px"; fi
    case "$name" in
      minWidth)  [ "$w"  -ge "$px" ] || bad="$bad, width at least $want (is $w)";;
      maxWidth)  [ "$w"  -le "$px" ] || bad="$bad, width at most $want (is $w)";;
      minHeight) [ "$h"  -ge "$px" ] || bad="$bad, height at least $want (is $h)";;
      maxHeight) [ "$h"  -le "$px" ] || bad="$bad, height at most $want (is $h)";;
      minX)      [ "$x1" -ge "$px" ] || bad="$bad, left edge at $want or more (is $x1)";;
      maxX)      [ "$x2" -le "$px" ] || bad="$bad, right edge at $want or less (is $x2)";;
      minY)      [ "$y1" -ge "$px" ] || bad="$bad, top edge at $want or more (is $y1)";;
      maxY)      [ "$y2" -le "$px" ] || bad="$bad, bottom edge at $want or less (is $y2)";;
      *) die "assertBox does not know the constraint '$name'";;
    esac
  done
  [ -z "$bad" ] || die "assert FAILED: $what '$q' is $where - expected ${bad#, }"
  ok "assert box: $what '$q' is $where"
}
# The same emptying, addressed by id. Coordinates were the only way to name the
# field, so a flow that addresses everything else by id had no way to clear one
# at all - and `type` appends: typed into a field that already holds text it
# makes a value neither the flow nor the app ever names, and the run fails on a
# later step, far from the line that caused it. The clearing itself, and every
# check around it, is the one `clearText` does.
clear_id(){ local q="${1:-}"; [ -n "$q" ] || die "clearTextId needs a non-empty id"; local xy st=0; xy="$(find_el "$q" id)" || st=$?; [ "$st" -eq 0 ] || die "$(lookup_err "$st")"; [ -n "$xy" ] || die "no element with id '$q'"; clear_field $xy "clearTextId '$q'"; }
# A lookup answers with coordinates, with "no such element", or with no answer at
# all - and "no answer" comes in two flavours the caller has to tell apart: the
# screen could not be read (status 1: the dump failed) and the screen was read but
# cannot be judged (status 3: it does not report its own bounds, so nothing on it
# can be called visible). Neither of them means the element is absent.
# Who says it, once. A lookup that could not read the screen at all has already
# printed the reason and recorded it for the step result - aui and ui end in
# `die`, and that `die` only kills the command substitution they run in. The
# caller adding "see the error above" printed that same line a second time, and
# on the quiet status 3 - a tree that arrived and cannot be judged, which nobody
# has spoken for - it pointed at an error that was never printed. So: stop the
# run without a word when the reason is already out, and supply the words when
# it is not.
# The caller's own words belong to status 3 alone: there the screen was read and
# the caller is the one who knows what it was looking for. Status 2 is a device
# that answered with something unreadable, which is no more the caller's fault
# than a failed dump - saying "no element at x,y" for it blames the point the
# step was aimed at.
lookup_die(){ local st="${1:-1}" msg="${2:-}"
  case "$st" in
    1) exit 1;;
    3) die "${msg:-$(lookup_err 3)}";;
    *) die "$(lookup_err "$st")";;
  esac; }
lookup_err(){ case "${1:-1}" in
    2) printf '%s' "the device answered, but not with a tree that can be read";;
    3) printf '%s' "the screen was read but does not report its own bounds, so nothing on it can be called visible";;
    *) printf '%s' "cannot read the screen - see the error above";; esac; }
# The negative assertions. Absence is the one verdict where an EMPTY answer is the
# pass, which makes it the one verdict that a missing answer passes by accident:
# an unreadable screen has no element on it either. Both statuses are checked
# before the verdict.
#
# Neither verb scrolls, by contract: eight swipes cannot prove that something is
# absent, and they change the state the next step is about to check. What these
# two state is "not on the screen as it is now", and the guide says so in those
# words - a flow that means "not anywhere in this list" scrolls for itself.
# By hand the constraints are written name=value: the packed form the runner
# uses is two control characters, and nobody types those.
#   yukti assert-box tab-today id minWidth=48 minHeight=48
assert_box_args(){ local q="${1:-}" kind="${2:-}" spec="" pair
  # Without this, `assert-box email-input minWidth=48` reads the constraint as
  # the name of a lookup and then complains that no constraint was given.
  case "$kind" in
    assert|id) ;;
    *) die "usage: yukti assert-box \"<text or id>\" <assert|id> name=value [name=value ...]";;
  esac
  shift 2
  for pair in "$@"; do spec="${spec:+$spec$COND_SEP}${pair%%=*}$COND_KV${pair#*=}"; done
  assert_box "$q" "$kind" "$spec"; }
assert_not_text(){ local q="${1:-}" m="${2:-exact}" xy st=0
  [ -n "$q" ] || die "assertNotText needs a non-empty value"
  case "$m" in exact|contains) ;; *) die "assertNotText: unknown match '$m' - use exact or contains";; esac
  xy="$(find_el "$q" assert "$m")" || st=$?
  [ "$st" -eq 0 ] || die "assertNot FAILED: $(lookup_err "$st")"
  [ -z "$xy" ] || die "assertNot FAILED ($m match): '$q' IS on the screen, at $xy"
  ok "assertNot ($m): '$q' not on the current screen"; }
assert_not_id(){ local q="${1:-}" xy st=0
  [ -n "$q" ] || die "assertNotId needs a non-empty id"
  xy="$(find_el "$q" id)" || st=$?
  [ "$st" -eq 0 ] || die "assertNot FAILED: $(lookup_err "$st")"
  [ -z "$xy" ] || die "assertNot FAILED: an element with id '$q' IS on the screen, at $xy"
  ok "assertNot id: '$q' not on the current screen"; }
wait_s(){ sleep "${1:-2}"; }
# `wait` is a sleep, and a sleep is the wrong tool for a screen that loads its
# content: too short and the flow goes red at random, too long and every run
# pays for it, on every flow. scrollToText retries, but it SCROLLS between the
# tries - on a screen that is still loading it swipes the content away instead
# of waiting for it.
#
# waitFor polls the same lookup the taps and the oracle use, and it never
# scrolls. The timeout is the failure: a screen that never arrives is a red
# step, not a green one and not an eight-swipe detour.
#
# An unreadable screen is not "not there yet". The poll keeps going while the
# screen cannot be read, and the timeout then says which of the two happened -
# the distinction the lookup verbs already make.
wait_for(){ local q="${1:-}" t="${2:-10}" m="${3:-exact}" mode="${4:-assert}" xy readable=1 lst=1 t0 el label
  [ -n "$q" ] || die "waitFor needs a non-empty value"
  case "$t" in ''|*[!0-9]*|0) die "waitFor: timeout must be a whole number of seconds greater than zero, got '$t'";; esac
  case "$m" in exact|contains) ;; *) die "waitFor: unknown match '$m' - use exact or contains";; esac
  [ "$mode" = id ] && label="waitFor id" || label="waitFor ($m match)"
  say "$label: up to ${t}s for '$q'"
  t0=$SECONDS
  while :; do
    if xy="$(find_el "$q" "$mode" "$m")"; then readable=1
      [ -n "$xy" ] && { el=$((SECONDS - t0)); ok "$label: '$q' on screen at $xy after ${el}s"; return 0; }
    else lst=$?; readable=0; fi
    [ $((SECONDS - t0)) -lt "$t" ] || break
    sleep 0.5
  done
  [ "$readable" = 1 ] || die "$label FAILED after ${t}s waiting for '$q': $(lookup_err "$lst")"
  die "$label FAILED: '$q' not on screen after ${t}s"; }
# A direction reveals what lies that way: `down` shows what is further down the
# content, which means the finger travels up. Same for `left` and `right`.
#
# A name the tool does not know used to scroll DOWN - the `else` of a two-way
# test - so `{"do":"scroll","value":"sideways"}` swiped down and said nothing.
# The list is checked first now, and a typo stops the flow.
#
# The vertical swipes keep the coordinates they were measured with. They are
# device pixels and they are not proportional to anything, which is worth its own
# issue, but changing them here would change the scroll distance of every flow
# that exists - and this change is about direction. The horizontal pair has no
# measured numbers to keep, so it is computed from the screen: across the widest
# element that says it scrolls, from a fifth of its width to four fifths of it -
# or, when the screen has no such element, across the middle of the screen, with
# a line in the run to say that is what happened.
scroll(){ local d="${1:-down}" w h
  case "$d" in up|down|left|right) ;; *) die "scroll: '$d' is not a direction - use up, down, left or right";; esac
  if is_android; then apaths
    case "$d" in
      up)    adb shell input swipe 200 300 200 700 300;;
      down)  adb shell input swipe 200 700 200 300 300;;
      left|right)
        local cy x1 x2 span aim ast=0
        # A dump that failed is not the same answer as a screen with nothing to
        # aim at: it used to arrive as the second, so a broken dump printed the
        # warning and swiped blind. A bare `scroll` step has no lookup in front
        # of it to catch that later, so it dies here, like every other reader of
        # the screen.
        aim="$(ahscroller)" || ast=$?
        [ "$ast" -eq 0 ] || lookup_die "$ast"
        set -- $aim; cy="${1:-}"; x1="${2:-}"; x2="${3:-}"
        if [ -z "$cy" ]; then
          # Nothing on this screen says it scrolls sideways. The swipe still goes
          # out, because a dump does not always report what an app implements -
          # but it says so once per run, since this is the case where the gesture
          # is most likely to be taken for a press.
          # To stderr, like the other line that can be printed from inside a
          # command substitution: scroll_to runs in one, so a warning on stdout
          # would arrive inside the coordinates it returns.
          [ -n "${H_WARNED:-}" ] || warn "no horizontally scrollable element in the dump - swiping across the middle of the screen, which the app may read as a press on what is there" >&2
          H_WARNED=1
          set -- $(screen_wh); w="${1:-$BASIS_W}"; h="${2:-$BASIS_H}"
          cy=$(( h / 2 )); x1=0; x2="$w"
        fi
        span=$(( x2 - x1 ))
        if [ "$d" = left ]; then adb shell input swipe $(( x1 + span / 5 )) "$cy" $(( x2 - span / 5 )) "$cy" 300
        else adb shell input swipe $(( x2 - span / 5 )) "$cy" $(( x1 + span / 5 )) "$cy" 300; fi;;
    esac
    return
  fi
  paths
  case "$d" in
    up)    idb ui swipe --udid "$(U)" 200 320 200 720;;
    down)  idb ui swipe --udid "$(U)" 200 720 200 320;;
    left|right)
      set -- $(screen_wh); w="${1:-$BASIS_W}"; h="${2:-$BASIS_H}"
      if [ "$d" = left ]; then idb ui swipe --udid "$(U)" $(( w / 5 )) $(( h / 2 )) $(( w * 4 / 5 )) $(( h / 2 ))
      else idb ui swipe --udid "$(U)" $(( w * 4 / 5 )) $(( h / 2 )) $(( w / 5 )) $(( h / 2 )); fi;;
  esac; }
# a11y reports FULL-CONTENT coords; taps need ON-SCREEN coords. Scroll a label into the viewport.
# Stop on an unreadable screen instead of swiping eight times into the dark:
# a swipe changes state, and the state is what the next step is checking.
# The accepted band used to be written out as 60..840. Those are one device's
# numbers - an iPhone 16 Pro, 874 points tall - not a universal viewport. A dump
# reports coordinates in the screen's own space, so on a 720x1600 Android phone
# the bottom navigation sits at y=1497 and every tab read as off-screen: each tap
# on one paid the full eight swipes before it acted, and those swipes unload the
# rows the NEXT step asserts on, which then reads as a defect in the app.
# Keep the proportions and scale them to the screen the run is actually on. On
# the original 874-point basis the bounds come out as 60 and 840, unchanged.
# The basis those numbers were written against: the screen Studio records on, an
# iPhone 16 Pro at 402x874 points. Same env names Studio already uses for it
# (studio/server.py DEVICE_PT_W/H), so one override covers both halves.
BASIS_W="${YUKTI_DEVICE_PT_W:-402}"; BASIS_H="${YUKTI_DEVICE_PT_H:-874}"; BAND_TOP=60; BAND_BOTTOM=840
# Where a per-run cache lives. Set once per invocation just before the command
# runs (see the bottom of this file) and inherited by everything the run shells
# out to. Empty when there is no such directory, and then nothing is cached at
# all: the device is asked every time and the answer is still correct, only
# slower. A placeholder path would be worse than nothing - a flow is free to
# name any directory in a screenshot step and would create it, and a cache
# under a fixed name outlives the run, which is the bug this replaced.
run_dir(){ printf '%s' "${YUKTI_RUN_DIR:-}"; }
# Screen size in the same space the dump reports. Cached in a file, not in a
# variable: every caller reads this through $( ), and an export from a command
# substitution never reaches the caller, so a variable would re-ask the device on
# every step. Asking must never be fatal and must never silently change geometry:
# on any failure the answer is the basis, which is exactly the old behaviour.
#
# The cache belongs to the RUN, not to the device: RUN_DIR is made once per
# invocation and removed when it ends. A device-named key cannot do this job -
# an emulator's serial is its port, not its AVD, so two AVDs started one after
# the other are both emulator-5554 and the second would read the first one's
# geometry with nothing in the log to say so; a resize on a live device does not
# change the serial at all. Scoped to the run, a stale answer cannot exist:
# the run that measured it is the only run that can read it.
screen_wh(){ local d f m out w h
  d="$(run_dir)"; f=''; m=''
  # No run directory, no cache: both halves stay empty and every lookup asks.
  [ -n "$d" ] && { f="$d/screen-$YUKTI_PLATFORM"; m="$f.fallback"; }
  [ -n "$f" ] && [ -s "$f" ] && { cat "$f"; return 0; }
  out=''
  if is_android; then
    apaths   # platform-tools go into PATH here; without it adb is simply absent
    out="$(adb shell wm size 2>/dev/null | tr -d '\r' | sed -n 's/.*: *\([0-9][0-9]*\)x\([0-9][0-9]*\).*/\1 \2/p' | tail -1 || true)"
  else
    paths
    # points only: a pixel field here would silently triple every number on a 3x
    # device, so a missing key must fall through to the basis, never to pixels.
    out="$(idb describe --udid "$(U)" --json 2>/dev/null | "$PY" -c 'import json,sys
try:
    d=(json.load(sys.stdin).get("screen_dimensions") or {})
    print(int(d["width_points"]), int(d["height_points"]))
except Exception:
    pass' 2>/dev/null || true)"
  fi
  set -- $out; w="${1:-}"; h="${2:-}"
  case "$w" in ''|*[!0-9]*|0) w='';; esac
  case "$h" in ''|*[!0-9]*|0) h='';; esac
  if [ -n "$w" ] && [ -n "$h" ]; then
    if [ -n "$f" ]; then
      ( printf '%s %s' "$w" "$h" > "$f" ) 2>/dev/null || true   # only a real answer is worth remembering
      rm -f "$m" 2>/dev/null || true   # it answered: the next failure is news again
    fi
    printf '%s %s' "$w" "$h"; return 0
  fi
  # NEVER cache the fallback. A device merely slow to appear would otherwise
  # switch the band off for every later step with nothing in the log to say so.
  # The notice goes to stderr: this function is read through a substitution, and
  # a line on stdout would be parsed as the screen size.
  if [ -z "$m" ] || [ ! -f "$m" ]; then
    warn "screen size unavailable, using the recording basis ${BASIS_W}x${BASIS_H}" >&2
    [ -n "$m" ] && { ( : > "$m" ) 2>/dev/null || true; }
  fi
  printf '%s %s' "$BASIS_W" "$BASIS_H"; }
screen_w(){ set -- $(screen_wh); printf '%s' "${1:-$BASIS_W}"; }
screen_h(){ set -- $(screen_wh); printf '%s' "${2:-$BASIS_H}"; }
# A recorded step carries a point on the screen it was recorded on. Studio records
# at BASIS_W x BASIS_H points; replayed on a 720x1600 phone the same pair lands
# somewhere else. Scale it where a recorded number ENTERS the run - not inside
# tap(), which is also fed coordinates taken straight from the device's own dump
# (tapText, tapId, dismiss) and must stay a raw primitive in the device's space.
# Set YUKTI_TAP_SCALE=0 to replay recorded pairs as-is.
scale_xy(){ local x="${1:?x}" y="${2:?y}" w h nx ny
  [ "${YUKTI_TAP_SCALE:-1}" = 0 ] && { printf '%s %s' "$x" "$y"; return 0; }
  set -- $(screen_wh); w="${1:-$BASIS_W}"; h="${2:-$BASIS_H}"
  [ "$w" = "$BASIS_W" ] && [ "$h" = "$BASIS_H" ] && { printf '%s %s' "$x" "$y"; return 0; }
  nx=$(( x * w / BASIS_W )); ny=$(( y * h / BASIS_H ))
  say "scaled $x,$y -> $nx,$ny (recorded ${BASIS_W}x${BASIS_H}, screen ${w}x${h})" >&2
  printf '%s %s' "$nx" "$ny"; }
# How many times a miss is worth swiping for. Read twice: by the loop, and by the
# message that reports the miss.
SCROLL_TRIES=8
# How much of the band counts as "the edge" when a step asks to be clear of it.
# A fifth from each side: enough to clear a sticky header or a bottom bar, which
# is what the request is for, and not so much that a short list can never satisfy
# it.
CLEAR_PCT=20
# The inset of the band on x, as a percentage of the width. The vertical band is
# measured - BAND_TOP and BAND_BOTTOM against BASIS_H - and on x there is nothing
# measured to keep: no status bar, no navigation bar. So the proportion the top
# inset comes to, 60 of 874, is applied to both sides. Written out as its own
# number because `w * BAND_TOP / BASIS_H` reads as a dimension mistake.
BAND_SIDE_PCT=7
# The direction is for FINDING the element. Once it is on the screen, which way
# to swipe is decided by where it IS: an element at the top edge has to travel
# down the screen, and a search that walked downwards to find it would push it
# out of view instead and then report it missing - a row under a sticky header is
# exactly the case `"edges":"clear"` exists for.
#
# Three answers, and the last two are new:
#   0 + coordinates - it is there, and where it has to be
#   0 + nothing     - it was never on the screen
#   4 + coordinates - in the band, never clear of its edges; the position is live
#   5 + coordinates - it came into view and the swipes took it out again; the
#                     position is the LAST SIGHTING and is stale. Saying "not on
#                     screen" of an element that was on it is a false reason, and
#                     a false reason is worse than a vague one.
scroll_to(){ local lbl="$1" dir="${2:-down}" clear="${3:-}" i xy c lo hi blo bhi m w h seen="" nudge st
  case "$dir" in
    # The band on y is the measured one, scaled to this screen.
    up|down)    h="$(screen_h)"; blo=$(( h * BAND_TOP / BASIS_H )); bhi=$(( h * BAND_BOTTOM / BASIS_H ));;
    left|right) w="$(screen_w)"; blo=$(( w * BAND_SIDE_PCT / 100 )); bhi=$(( w - blo ));;
    *) die "scrollToText: '$dir' is not a direction - use up, down, left or right";;
  esac
  lo="$blo"; hi="$bhi"
  if [ "$clear" = clear ]; then
    m=$(( (bhi - blo) * CLEAR_PCT / 100 )); lo=$(( blo + m )); hi=$(( bhi - m ))
  fi
  for i in $(seq 1 $SCROLL_TRIES); do st=0; xy="$(find_el "$lbl")" || st=$?; [ "$st" -eq 0 ] || lookup_die "$st"
    nudge="$dir"
    if [ -n "$xy" ]; then
      # The coordinate that decides is the one the swipe moves.
      case "$dir" in up|down) c="${xy##* }";; *) c="${xy%% *}";; esac
      seen="$xy"
      if [ "$c" -lt "$hi" ] && [ "$c" -gt "$lo" ]; then printf '%s' "$xy"; return 0; fi
      # Too near one end: reveal what lies on that side, which moves the element
      # towards the other. `up` and `left` reveal what is above and to the left,
      # so the element travels down and right.
      if [ "$c" -le "$lo" ]; then case "$dir" in up|down) nudge=up;; *) nudge=left;; esac
      else case "$dir" in up|down) nudge=down;; *) nudge=right;; esac; fi
    fi
    scroll "$nudge"; sleep 0.6; done
  # Out of swipes. As a locator this still answers with wherever the element is,
  # which is what tapText has always done with it - and the position is judged
  # here as it is inside the loop, because the last swipe of the loop can be the
  # one that placed it: without this test such an element would be reported as
  # "never landed clear" while sitting exactly where it was asked to be.
  st=0; xy="$(find_el "$lbl")" || st=$?; [ "$st" -eq 0 ] || lookup_die "$st"
  if [ -n "$xy" ]; then
    printf '%s' "$xy"
    case "$dir" in up|down) c="${xy##* }";; *) c="${xy%% *}";; esac
    [ "$c" -lt "$hi" ] && [ "$c" -gt "$lo" ] && return 0
    [ "$clear" = clear ] && return 4
    return 0
  fi
  if [ -n "$seen" ]; then printf '%s' "$seen"; return 5; fi
  return 0; }
# scroll_to is a locator and stays one: it is also the first half of tapText,
# which has its own words for a miss. As a STEP, though, scrollToText is a
# statement that the label is there - the flow swipes until it finds it - and a
# statement that turns out false has to fail. It did not: the swipes ended, the
# last lookup came back empty, and the step passed. The runbook recommends this
# step in front of an assertion, so the documented way to reach something below
# the fold was also the way to miss it in silence, at eight swipes a time.
scroll_to_step(){ local lbl="${1:-}" dir="${2:-down}" clear="${3:-}" xy st=0
  [ -n "$lbl" ] || die "scrollToText needs a non-empty value"
  case "$dir" in up|down|left|right) ;; *) die "scrollToText: '$dir' is not a direction - use up, down, left or right";; esac
  case "$clear" in ''|clear) ;; *) die "scrollToText: '$clear' is not a value for \"edges\" - the only one is clear";; esac
  xy="$(scroll_to "$lbl" "$dir" "$clear")" || st=$?
  # Neither of these two says the direction: once the element is on the screen
  # the swipes follow its position, not the direction the search came from.
  case "$st" in
    0) ;;
    4) die "scrollToText: '$lbl' came into view but never landed clear of the band edges after $SCROLL_TRIES swipes - it is at $xy";;
    5) die "scrollToText: '$lbl' came into view at $xy and the swipes took it out of view again - after $SCROLL_TRIES swipes it is not on the screen";;
    *) lookup_die "$st";;
  esac
  [ -n "$xy" ] || die "scrollToText: '$lbl' not on screen after $SCROLL_TRIES swipes $dir"
  printf '%s\n' "$xy"; }

# ── per-flow results ─────────────────────────────────────────────────────────
# A run left console text and screenshots and nothing a tool could read: which
# flow, which step, how long, where the artifacts are. That is enough at ten
# flows and useless at a hundred - no report, no history, no way to tell a
# permanent failure from a flaky one. Every flow now also leaves one JUnit XML
# file, the format report viewers and CI already understand.
#
# Written from an EXIT trap rather than after the loop: a failing step exits the
# whole run, so a result produced only on success would be missing exactly when
# it is needed. Steps are appended as they finish; the step that started and
# never finished is the one that failed.
RESULTS_DIR="${YUKTI_RESULTS_DIR:-yukti-results}"
FLOW_IDX=0
now(){ if [ -n "${EPOCHREALTIME:-}" ]; then printf '%s' "${EPOCHREALTIME/,/.}"; else date +%s; fi; }

flow_results(){
  local st="${1:-0}" out
  [ -n "${FLOW_REC:-}" ] || return 0
  trap - EXIT
  out="$("$PY" - "$FLOW_FILE" "$FLOW_REC" "$FLOW_CUR" "$FLOW_ERR" "$FLOW_T0" "$(now)" "$st" "$RESULTS_DIR" "$YUKTI_PLATFORM" <<'PY'
import os, sys, time
from xml.sax.saxutils import escape, quoteattr

flow, rec, cur, errf, t0, t1, st, outdir, plat = sys.argv[1:10]
SEP = "\x1f"

def num(v, d=0.0):
    try: return float(v)
    except ValueError: return d

def read(path):
    try:
        with open(path, encoding="utf-8", errors="replace") as fh: return fh.read()
    except OSError: return ""

steps = []
# split("\n"), not splitlines(): the latter also cuts on \r and on half a dozen
# other characters, so a value carrying one of them split its own record and the
# step vanished from the report - green run, no steps in the file.
for line in read(rec).split("\n"):
    if not line: continue
    parts = line.split(SEP)
    if len(parts) < 6: continue
    i, verb, value, s0, s1, art = parts[:6]
    # The seventh field is the condition that switched the step off, and it is
    # only there for a step that was skipped. Older records have six fields.
    skip = parts[6] if len(parts) > 6 else ""
    steps.append({"i": int(num(i)), "verb": verb, "value": value,
                  "t": max(0.0, num(s1) - num(s0)), "art": art, "skip": skip})

failing = None
if int(num(st)) != 0:
    parts = read(cur).strip("\n").split(SEP)
    if len(parts) >= 4 and parts[0] and not any(x["i"] == int(num(parts[0])) for x in steps):
        failing = {"i": int(num(parts[0])), "verb": parts[1], "value": parts[2],
                   "t": max(0.0, num(t1) - num(parts[3])), "art": "", "skip": ""}

msg = read(errf).strip() or "the run stopped with exit code %d" % int(num(st))

# A typed string is a credential as often as not - the flow files take
# ${TEST_PASSWORD} from the environment. The result file records how much was
# typed, never what: an artifact that leaks the password is worse than no
# artifact, and the console already prints the same count.
def shown(step):
    verb, value = step["verb"], step["value"]
    if verb in ("type", "clearText"):
        value = "%d chars" % len(value) if value else ""
    if len(value) > 60: value = value[:57] + "..."
    return ("%02d %s %s" % (step["i"], verb, value)).strip()

stem = os.path.splitext(os.path.basename(flow))[0] or "flow"
cases = []
for step in steps:
    if step["skip"]:
        # A step that did not run is not a step that passed. Reported as skipped,
        # with the condition in the message: a reader of the file can see which
        # part of the flow was switched off and by what.
        cases.append('    <testcase classname=%s name=%s time="%.3f">\n'
                     '      <skipped message=%s/>\n'
                     '    </testcase>'
                     % (quoteattr(stem), quoteattr(shown(step)), step["t"],
                        quoteattr("condition not met: " + step["skip"])))
    else:
        cases.append('    <testcase classname=%s name=%s time="%.3f"/>'
                     % (quoteattr(stem), quoteattr(shown(step)), step["t"]))
if failing is not None:
    cases.append('    <testcase classname=%s name=%s time="%.3f">\n'
                 '      <failure message=%s type="StepFailed"/>\n'
                 '    </testcase>'
                 % (quoteattr(stem), quoteattr(shown(failing)), failing["t"], quoteattr(msg)))
elif int(num(st)) != 0:
    # The run died before or between steps: still a failure, and a result that
    # said "all green, exit 1" would be worse than none.
    cases.append('    <testcase classname=%s name="00 flow" time="0.000">\n'
                 '      <failure message=%s type="FlowFailed"/>\n'
                 '    </testcase>' % (quoteattr(stem), quoteattr(msg)))

skips = sum(1 for s in steps if s["skip"])
arts = [s["art"] for s in steps if s["art"]]
out = [
    '<?xml version="1.0" encoding="UTF-8"?>',
    '<testsuites>',
    '  <testsuite name=%s tests="%d" failures="%d" errors="0" skipped="%d" time="%.3f" timestamp=%s>'
    % (quoteattr(stem), len(cases), 0 if failing is None and int(num(st)) == 0 else 1,
       skips, max(0.0, num(t1) - num(t0)),
       quoteattr(time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(num(t0))))),
    '    <properties>',
    '      <property name="platform" value=%s/>' % quoteattr(plat),
    '      <property name="flow" value=%s/>' % quoteattr(flow),
    '    </properties>',
]
out.extend(cases)
if arts:
    out.append('    <system-out>' + escape("\n".join(arts)) + '</system-out>')
out.append('  </testsuite>')
out.append('</testsuites>')

os.makedirs(outdir, exist_ok=True)
path = os.path.join(outdir, stem + ".xml")
with open(path, "w", encoding="utf-8") as fh:
    fh.write("\n".join(out) + "\n")
print(path)
PY
)" && ok "results → $out" || warn "could not write the results file to '$RESULTS_DIR'"
  rm -f "$FLOW_REC" "$FLOW_CUR" "$FLOW_ERR"; FLOW_REC=""
  return 0
}

# ── deterministic flow runner: a JSON list of steps ──────────────────────────
# step keys: include[with]|launch|stopApp|clearState|wait|waitFor[timeout,match]|waitForId[timeout]|screenshot|
#            openUrl|tap[x,y]|tapText|optionalTapText|tapId|type|clearText[x,y]|clearTextId|pressKey|hideKeyboard|
#            scroll|scrollToText[direction,edges]|dismiss|assertText[match]|assertId|assertNotText[match]|assertNotId|
#            assertBox[box]|assertBoxId[box]
#            any step, and an include, may carry "when": {visible|notVisible|
#            visibleId|notVisibleId|platform} - all of them have to hold
#
# The same names the dispatcher inside flow() knows, as a list a check can read.
# The dispatcher cannot answer "do you know this name?" without running the
# step, and one check below has to ask before the step runs. A verb added to the
# `case` has to be added here too: nothing but this comment ties the two, and
# the `*)` arm of the dispatcher stays as the backstop.
#
# `include` is deliberately in neither: the builder runs it while it reads the
# file and emits the block's steps in its place, so no record ever carries it
# and the dispatcher never sees it. `__when` is not in either list for the same
# reason from the other side: it is a record the loop answers itself, never a
# name a flow file may use - a step whose name starts with "__" is refused as
# unknown by the check above the dispatcher.
# ── step conditions (`when`) ─────────────────────────────────────────────────
# A condition has three outcomes, and only one of them is a skip:
#   it holds          - the guarded steps run, and the run says nothing about it
#   it does not hold  - they are skipped, out loud: a line each and `skipped` in
#                       the report, because a condition that quietly removes
#                       steps from a suite is a worse defect than the missing
#                       `when` it replaces
#   it cannot be answered - the run stops
#
# The third one is why conditions and the negative assertions are one change: an
# unreadable screen, or a screen that does not report its own bounds, answers
# every "is this visible?" with "no". Taken as an answer, that switches parts of
# a suite off and keeps the run green.
#
# `visible`/`notVisible` ask the ASSERTION oracle, not the tap locator. The
# locator folds case, matches substrings and looks into resource-ids, so a
# condition answered by it can be satisfied by markup - and this condition
# decides whether a step runs at all. `visibleId`/`notVisibleId` are the exact
# id selector, the rule assertId already follows. The cost is named in the
# guide: a condition is stricter than tapText, so it can skip a step tapText
# would have found - and the skip is never silent.
COND_KV=$'\x1d'; COND_SEP=$'\x1e'
cond_holds(){ local spec="$1" pair kind val xy st
  while [ -n "$spec" ]; do
    case "$spec" in *"$COND_SEP"*) pair="${spec%%"$COND_SEP"*}"; spec="${spec#*"$COND_SEP"}";; *) pair="$spec"; spec="";; esac
    kind="${pair%%"$COND_KV"*}"; val="${pair#*"$COND_KV"}"
    case "$kind" in
      # The platform of this run, as the engine resolved it at start-up: the
      # variant's platform, else the config's, else ios. That last fallback is
      # issue #36 and a condition inherits it, so the skip line below names the
      # platform it compared against - an author who expected android sees why
      # the step did not run instead of wondering.
      platform) [ "$val" = "${YUKTI_PLATFORM:-}" ] || return 1;;
      visible|notVisible|visibleId|notVisibleId)
        st=0
        case "$kind" in
          visible|notVisible)     xy="$(find_el "$val" assert exact)" || st=$?;;
          visibleId|notVisibleId) xy="$(find_el "$val" id)" || st=$?;;
        esac
        [ "$st" -eq 0 ] || die "when ($kind '$val'): $(lookup_err "$st")"
        case "$kind" in
          visible|visibleId)       [ -n "$xy" ] || return 1;;
          notVisible|notVisibleId) [ -z "$xy" ] || return 1;;
        esac;;
      # The builder refuses an unknown condition before the run starts. This arm
      # is the backstop for a record that reached the loop some other way.
      *) die "when: '$kind' is not a condition this version knows";;
    esac
  done
  return 0; }
# The condition in the author's words, for the line that says what was skipped.
cond_words(){ local spec="$1" pair kind val out=""
  while [ -n "$spec" ]; do
    case "$spec" in *"$COND_SEP"*) pair="${spec%%"$COND_SEP"*}"; spec="${spec#*"$COND_SEP"}";; *) pair="$spec"; spec="";; esac
    kind="${pair%%"$COND_KV"*}"; val="${pair#*"$COND_KV"}"
    case "$kind" in
      platform) out="$out${out:+ and }platform '$val' (this run: ${YUKTI_PLATFORM:-unset})";;
      *)        out="$out${out:+ and }$kind '$val'";;
    esac
  done
  printf '%s' "$out"; }
FLOW_VERBS="launch stopApp clearState openUrl wait waitFor waitForId screenshot tap tapText optionalTapText tapId type clearText clearTextId pressKey hideKeyboard scroll scrollToText dismiss assertText assertId assertNotText assertNotId assertBox assertBoxId"
# The steps a condition switched off. They keep their numbers - the numbering is
# end to end, and a skipped step that gave its number away would move every step
# after it - they say so on the console, and they land in the report as `skipped`
# with the condition that switched them off. A nested guard inside the group is
# not a step and takes no number: it is skipped together with its group.
flow_skip(){ local n="${1:-1}" why="$2" i=0 d v xx yy mm tt dd ee ss si wc ws bx t
  why="${why//[$'\n\r']/ }"
  while [ "$i" -lt "$n" ] && IFS=$'\x1f' read -r -d '' d v xx yy mm tt dd ee ss si wc ws bx <&3; do
    i=$((i+1))
    if [ "$d" = "__when" ]; then continue; fi
    FLOW_IDX=$((FLOW_IDX+1)); t="$(now)"
    skipped "step $FLOW_IDX '$d' skipped: $why"
    printf '%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\n' "$FLOW_IDX" "$d" "${v//[$'\n\r']/ }" "$t" "$t" "" "$why" >> "$FLOW_REC"
  done; }
flow(){
  # --check builds the flow and stops there: every step is read, resolved and
  # validated, and not one of them is performed. It needs no simulator and no
  # emulator, which is what makes it the only part of a flow a CI runner can
  # look at. A step whose condition decides at run time whether it runs is
  # checked all the same - the screen is never asked, so the condition is not
  # answered here, only its spelling is.
  local chk=0 a args=""
  # The flag is read wherever it stands. Written after the file name it used to
  # be one more argument nobody looked at, so `yukti flow x.json --check` ran the
  # flow: a check that performs the steps it was asked to only inspect fails in
  # the one direction a check must not. Anything else left over is a typo, and a
  # typo that is ignored is an instruction that did not happen.
  for a in "$@"; do
    case "$a" in
      --check) chk=1;;
      -*) die "unknown option '$a' - usage: yukti flow [--check] <file.json>";;
      *) [ -z "$args" ] || die "one flow file at a time - usage: yukti flow [--check] <file.json>"
         args="$a";;
    esac
  done
  [ -n "$args" ] || die "usage: yukti flow [--check] <file.json>"
  local f="$args" st; [ -f "$f" ] || die "no flow file '$f'"; paths
  FLOW_FILE="$f"; FLOW_IDX=0; FLOW_ART=""
  if [ "$chk" = 1 ]; then
    say "checking flow: $f"
  else
    say "running flow: $f"
    FLOW_REC="$(mktemp)"; FLOW_CUR="$(mktemp)"; FLOW_ERR="$(mktemp)"; FLOW_T0="$(now)"
    export YUKTI_STEP_ERR="$FLOW_ERR"
    # Nothing ran, so nothing is reported: a JUnit file written by --check would
    # say a flow passed on a machine that never had a device.
    trap 'flow_results $?; run_dir_cleanup' EXIT
  fi
  # The steps are built before the first one runs, and a flow file the builder
  # refuses never starts. It used to be generated straight into the loop, where
  # its status was lost with the process substitution: a flow file that is not
  # valid JSON, or one carrying a variable that is not set, produced no steps
  # and the run still ended with "flow complete".
  local steps gerr gmsg
  steps="$(mktemp)"; gerr="$(mktemp)"
  if ! "$PY" - "$f" > "$steps" 2> "$gerr" <<'PY'
import json, os, re, sys

def fail(msg):
    sys.stderr.write(msg + "\n"); sys.exit(2)

# The same forms os.path.expandvars accepted, minus its silence: it returns the
# text unchanged when the variable is not set, so ${TEST_PASSWORD} was typed
# into whatever held focus as a literal and the log said `type 16 chars`.
VAR = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)")
NAME = re.compile(r"\A[A-Za-z_][A-Za-z0-9_]*\Z")
# A record ends with NUL, the fields inside it are separated by US. A newline is
# then just a character in a value: a caption that wraps on screen is what an
# author copies out of it, and the lookup already folds whitespace on both sides
# of the comparison. Line-separated records cut such a value in half, ran the
# first half as the step and read the second as a step of its own.
SEP, END = "\x1f", "\0"
KEYS = ("do", "value", "x", "y", "match", "timeout", "direction", "edges")
# A step may also carry a condition. It is not one of the fields above: it is
# answered while the run goes on, and its answer decides whether the step runs.
WHEN_KINDS = ("notVisible", "notVisibleId", "platform", "visible", "visibleId")
# The box of an element, asserted by assertBox and assertBoxId. Like "when",
# this is a structured key: several constraints belong to one step, and they
# travel in one field rather than eight.
BOX_KINDS = ("maxHeight", "maxWidth", "maxX", "maxY",
             "minHeight", "minWidth", "minX", "minY")
STEP_KEYS = set(KEYS) | {"when", "box"}
INCLUDE_KEYS = {"do", "value", "with", "when"}
# One condition is a kind and a value; several in one `when` all have to hold.
# These two characters separate them inside the single field that carries them.
KV, CSEP = "\x1d", "\x1e"
# `include` runs the steps of another flow file here. It is resolved while the
# steps are built, not while they run: the block's steps enter the stream as
# ordinary records, so the runner, the console and the JUnit file treat them
# exactly like steps written in the file itself - a failure inside the login
# block is a failed step in the report, not a silence.
#
# A flow may include a block, and that block may include one more. Deeper than
# that has no use we can name, and an author who hits the limit reads a message
# instead of watching the run expand.
MAX_DEPTH = 2

def load(path, what, pre=""):
    try:
        with open(path, encoding="utf-8") as fh: doc = json.load(fh)
    except ValueError as e:
        fail("%s%s is not valid JSON: %s" % (pre, what, e))
    except OSError as e:
        fail("%s%s cannot be read: %s" % (pre, what, e))
    if not isinstance(doc, dict) or not isinstance(doc.get("steps"), list):
        fail('%s%s has no "steps" list' % (pre, what))
    if not doc["steps"]:
        fail("%s%s has no steps - it would pass without doing anything" % (pre, what))
    return doc["steps"]

def when_spec(step, at, scope, src):
    if "when" not in step: return ""
    w = step["when"]
    if not isinstance(w, dict):
        fail('%s: "when" is an object of conditions, for instance '
             '{"visible": "Continue"} or {"platform": "android"}' % at)
    if not w:
        fail('%s: "when" is empty - name a condition (%s) or drop the key'
             % (at, ", ".join(WHEN_KINDS)))
    pairs = []
    for key in sorted(w):
        if key not in WHEN_KINDS:
            fail("%s: '%s' is not a condition - this version knows %s"
                 % (at, key, ", ".join(WHEN_KINDS)))
        v = w[key]
        if v is None or isinstance(v, (dict, list, bool)):
            fail("%s: condition '%s' must be text or a number" % (at, key))
        v = fill(v if isinstance(v, str) else str(v), scope, at, bool(src)).strip()
        if not v:
            fail("%s: condition '%s' has no value" % (at, key))
        if any(ch in v for ch in (SEP, END, KV, CSEP)):
            fail("%s: condition '%s' contains a character the runner separates "
                 "fields with" % (at, key))
        # A platform nobody runs on is not a condition, it is an off switch: the
        # step would be skipped on every run and the run would stay green.
        if key == "platform" and v not in ("android", "ios"):
            fail("%s: '%s' is not a platform - use android or ios" % (at, v))
        pairs.append(key + KV + v)
    return CSEP.join(pairs)

def box_spec(step, at, scope, src):
    verb = step.get("do", "")
    if "box" not in step:
        # A box assertion with nothing to assert is a step that passes on any
        # element that is on the screen - which assertText and assertId already
        # say, more cheaply and more honestly.
        if verb in ("assertBox", "assertBoxId"):
            fail("%s: %s needs a \"box\" - the constraints are %s"
                 % (at, verb, ", ".join(BOX_KINDS)))
        return ""
    if verb not in ("assertBox", "assertBoxId"):
        fail('%s: step \'%s\' does not take a "box" - only assertBox and '
             "assertBoxId do" % (at, verb))
    b = step["box"]
    if not isinstance(b, dict):
        fail('%s: "box" is an object of constraints, for instance '
             '{"minWidth": 48, "minHeight": 48}' % at)
    if not b:
        fail('%s: "box" is empty - name a constraint (%s) or drop the step'
             % (at, ", ".join(BOX_KINDS)))
    pairs = []
    for key in sorted(b):
        if key not in BOX_KINDS:
            fail("%s: '%s' is not a constraint - this version knows %s"
                 % (at, key, ", ".join(BOX_KINDS)))
        v = b[key]
        if v is None or isinstance(v, (dict, list, bool)):
            fail("%s: constraint '%s' must be a number, for instance 48, "
                 '"48dp" or "5%%"' % (at, key))
        v = fill(v if isinstance(v, str) else str(v), scope, at, bool(src)).strip()
        # Pixels, dp or whole percent, and nothing else: the comparison runs in
        # the shell, where "48px", "4.5" or "" are not numbers and would either
        # abort the run with an arithmetic error or, worse, compare as zero.
        num = v[:-1] if v.endswith("%") else (v[:-2] if v.endswith("dp") else v)
        if not num.isdigit():
            fail("%s: constraint '%s' is '%s' - it has to be a whole number of "
                 'pixels (48), of dp ("48dp") or a whole share of the screen '
                 '("5%%")' % (at, key, v))
        # "08" is a number here and octal in the shell, where it dies in the
        # arithmetic with no step message and no JUnit entry. Normalised while
        # it is still text and the author can still be told what was written.
        v = str(int(num)) + v[len(num):]
        num = str(int(num))
        if v.endswith("%") and int(num) > 100:
            fail("%s: constraint '%s' is '%s' - a share of the screen cannot be "
                 "more than 100%%" % (at, key, v))
        pairs.append(key + KV + v)
    return CSEP.join(pairs)

def guard(cond, span, src, idx):
    # A condition is a record of its own, not another field on the step it
    # guards: a block included under a condition may carry a condition on its
    # own first step, and two conditions do not fit in one set of fields.
    #
    # The guard says how many of the records that follow belong to it, counted
    # here, where the group is still a group - by the time the run reads them
    # they are a flat stream. One answer then switches the whole group off, and
    # the screen cannot change halfway through it, which is exactly what
    # happens if the condition is re-asked at every step of the block: the
    # first step dismisses the dialog, and the rest of the block is skipped.
    return SEP.join(["__when"] + [""] * (len(KEYS) - 1) + [src, str(idx), cond, str(span), ""])

def fill(v, scope, at, inside):
    missing = []
    def one(m, missing=missing):
        name = m.group(1) or m.group(2)
        if name not in scope: missing.append(name); return ""
        return scope[name]
    v = VAR.sub(one, v)
    if missing:
        fail("%s: $%s is not set %s - the step would have used the text as "
             "written" % (at, missing[0],
                          'here: pass it in "with" on the include, or set it in '
                          "the environment" if inside else "in the environment"))
    return v

recs = []

def build(path, shown, src, scope, chain, depth, pre=""):
    # A block is named relative to the file that includes it: a suite is moved,
    # copied and run from anywhere as one directory, and the working directory
    # of the run is not something the flow file can know. Through the real path,
    # not the link: one shared directory of blocks symlinked into two suites
    # resolved the blocks' own includes next to the LINK, which is where a
    # same-named file of the borrowing suite sits.
    base = os.path.dirname(os.path.realpath(path))
    for idx, step in enumerate(load(path, shown, pre), 1):
        of = " of %s" % shown if src else ""
        if not isinstance(step, dict): fail("step %d%s is not an object" % (idx, of))
        verb = step.get("do") or ""
        at = "step %d '%s'%s" % (idx, verb, of)
        # The runner's own records are named with two leading underscores. A flow
        # file that used such a name would hand the loop a record it answers
        # itself - a forged guard, i.e. a step that quietly does nothing.
        if isinstance(verb, str) and verb.startswith("__"):
            fail("%s: a step name cannot start with __ - those belong to the "
                 "runner's own records" % at)
        if "with" in step and verb != "include":
            fail('%s: "with" passes values to an include - only include takes it'
                 % at)
        # A key nobody reads used to be mentioned by nobody. Tolerable while every
        # key did something visible; not tolerable next to a condition, where
        # `wehn` means a step that runs every time while the file says it is
        # conditional. The 45 flows this was measured against carry do, value, x
        # and y, so nothing in the suite depends on the old silence.
        if verb != "include":
            extra = sorted(k for k in step if k not in STEP_KEYS)
            if extra:
                fail("%s: '%s' is not a key a step takes - those are %s"
                     % (at, extra[0], ", ".join(sorted(STEP_KEYS))))
        cond = when_spec(step, at, scope, src)
        if verb == "include":
            # The guard is inserted after the block is built, because only then is
            # it known how many records it covers.
            pos = len(recs)
            include(step, at, base, src, scope, chain, depth)
            if cond: recs.insert(pos, guard(cond, len(recs) - pos, src, idx))
            continue
        fields = []
        for key in KEYS:
            v = step.get(key, "")
            if v is None:
                fail('%s: "%s" is null - remove the key or give it a value'
                     % (at, key))
            v = v if isinstance(v, str) else str(v)
            if key == "value":
                v = fill(v, scope, at, bool(src))
            if SEP in v or END in v:
                fail('%s: "%s" contains a character the runner separates fields '
                     "with" % (at, key))
            fields.append(v)
        # Which file the step was written in, carried so that a failure names it.
        # Empty for the flow file itself: the run already says which flow it is.
        fields.append(src)
        # And where it is in that file: the run numbers steps end to end, so the
        # number a failure prints is not the one to look for in the file.
        fields.append(str(idx))
        # The two fields a guard record uses. A step carries them empty: one
        # record shape for the loop to read, whatever the record is.
        fields.append("")
        fields.append("")
        fields.append(box_spec(step, at, scope, src))
        if cond: recs.append(guard(cond, 1, src, idx))
        recs.append(SEP.join(fields))

def include(step, at, base, src, scope, chain, depth):
    for key in ("x", "y", "match", "timeout", "box"):
        if key in step:
            fail('%s: "%s" is not a key an include takes - it runs the steps '
                 "of a file, and those steps carry their own keys" % (at, key))
    extra = sorted(k for k in step if k not in INCLUDE_KEYS)
    if extra:
        fail("%s: '%s' is not a key an include takes - those are %s"
             % (at, extra[0], ", ".join(sorted(INCLUDE_KEYS))))
    name = step.get("value")
    if not isinstance(name, str) or not name.strip():
        fail('%s: "value" is the flow file to run here, for instance '
             '"blocks/login.json"' % at)
    name = fill(name.strip(), scope, at, bool(src))
    if SEP in name or END in name:
        fail('%s: "value" contains a character the runner separates fields '
             "with" % at)
    path = name if os.path.isabs(name) else os.path.join(base, name)
    real = os.path.realpath(path)
    trail = [c[1] for c in chain] + [name]
    if any(c[0] == real for c in chain):
        fail("%s: '%s' is already running - a block cannot include itself, "
             "directly or in a circle: %s" % (at, name, " -> ".join(trail)))
    if depth + 1 > MAX_DEPTH:
        fail("%s: a flow may include a block, and a block one more - '%s' is "
             "deeper than that: %s" % (at, name, " -> ".join(trail)))
    with_ = step.get("with", {})
    if not isinstance(with_, dict):
        fail('%s: "with" is an object of name/value pairs, for instance '
             '{"TEST_EMAIL": "beth@example.com"}' % at)
    # The values a block is called with are read in the scope of the caller, so
    # an include can pass an environment variable on, or one of its own
    # parameters. Inside the block they shadow the environment, and only there.
    inner = dict(scope)
    for key in sorted(with_):
        v = with_[key]
        if not NAME.match(key):
            fail("%s: '%s' cannot be the name of a parameter - letters, digits "
                 "and _, not starting with a digit" % (at, key))
        if v is None or isinstance(v, (dict, list, bool)):
            fail("%s: parameter '%s' must be text or a number" % (at, key))
        v = v if isinstance(v, str) else str(v)
        inner[key] = fill(v, scope, at, bool(src))
    build(path, "block '%s'" % name, name, inner, chain + [(real, name)],
          depth + 1, "%s: " % at)

flow = sys.argv[1]
build(flow, "flow file", "", dict(os.environ),
      [(os.path.realpath(flow), os.path.basename(flow))], 0)

sys.stdout.write(END.join(recs) + (END if recs else ""))
PY
  then gmsg="$(cat "$gerr")"; rm -f "$steps" "$gerr"; die "${gmsg:-the flow file could not be read}"; fi
  rm -f "$gerr"
  # Opened and then unlinked: the loop keeps reading it, and no path is left
  # behind whichever way the flow ends.
  exec 3< "$steps"; rm -f "$steps"
  # The steps are fed on FD 3, NOT stdin: steps shell out to adb/idb,
  # and `adb shell` / subprocesses read stdin — if the loop read from the same
  # pipe they'd swallow the remaining steps, so only step 1 ran (and the flow
  # still reported PASS). Reading `<&3` also keeps the loop in THIS shell (not a
  # pipe subshell), so a step's `die` (exit 1) aborts the whole flow with a
  # non-zero status instead of being lost.
  while IFS=$'\x1f' read -r -d '' do_ val x y mtch tmo dir edg src sidx wcond wspan bspec <&3; do
      # A guard is not a step: it takes no number of its own, and when it holds
      # the run says nothing about it - the steps that follow simply run. When it
      # does not hold, its whole group is read off the stream and every step in it
      # is recorded as skipped.
      if [ "$do_" = "__when" ]; then
        if [ -n "$src" ]; then export YUKTI_STEP_SRC="block '$src', step $sidx"
        else export YUKTI_STEP_SRC="step $sidx of this flow file"; fi
        # Answering a condition means reading the screen. --check has none, and
        # skipping the group would leave the steps inside it unchecked - the
        # steps most likely to be stale, because a condition is what keeps them
        # off most runs.
        if [ "$chk" = 1 ]; then continue; fi
        if cond_holds "$wcond"; then continue; fi
        flow_skip "${wspan:-1}" "$(cond_words "$wcond")"
        continue
      fi
      # Record the step BEFORE running it and again once it survived: the one
      # step that has a start and no end is the step that failed, and that is
      # the single fact a report cannot reconstruct from console text.
      FLOW_IDX=$((FLOW_IDX+1)); FLOW_S0="$(now)"; FLOW_ART=""
      # The number the run counts and the number the author has to look for are
      # the same file only while a flow has no includes. Once it has one, step 4
      # of the run can be step 2 of the block, or step 2 of the flow file after
      # a block of three - so a failure says which file and which step in it,
      # and says nothing extra when the two numbers agree.
      if [ -n "$src" ]; then export YUKTI_STEP_SRC="block '$src', step $sidx"
      elif [ "$sidx" != "$FLOW_IDX" ]; then export YUKTI_STEP_SRC="step $sidx of this flow file"
      else export YUKTI_STEP_SRC=""; fi
      if [ "$chk" = 0 ]; then
        printf '%s\x1f%s\x1f%s\x1f%s\n' "$FLOW_IDX" "$do_" "${val//[$'\n\r']/ }" "$FLOW_S0" > "$FLOW_CUR"
        : > "$FLOW_ERR"
      fi
      # The name first, the keys after it, because the order decides what a typo
      # hears. A step named `tpText` carrying a `match` would otherwise be told
      # that `tpText` does not take the key - a message that asserts the verb
      # exists and costs the author a second run to find the real mistake.
      case " $FLOW_VERBS " in *" $do_ "*) ;; *) die "unknown step '$do_' - check the flow file";; esac
      # `match` belongs to the assertion contract, so only the verb that reads
      # it may be given it. A locator that ignored the key told the flow author
      # it was honoured: tapText folds case and matches a substring whatever the
      # step says, and a suite written on that belief is weaker than it reads.
      [ -z "$mtch" ] || case "$do_" in assertText|assertNotText|waitFor) ;; *) die "step $FLOW_IDX '$do_' does not take a \"match\" - only assertText, assertNotText and waitFor do";; esac
      # Same rule for the two keys scrollToText reads: a key a step ignores is a
      # line of the flow file that does not do what it says. `scroll` carries its
      # direction in "value", the way it always has.
      [ -z "$dir" ] || case "$do_" in scrollToText) ;; *) die "step $FLOW_IDX '$do_' does not take a \"direction\" - only scrollToText does; scroll takes its direction as its \"value\"";; esac
      [ -z "$edg" ] || case "$do_" in scrollToText) ;; *) die "step $FLOW_IDX '$do_' does not take an \"edges\" - only scrollToText does";; esac
      # Same rule, the other way round: hideKeyboard closes whatever keyboard is
      # up and has nothing to read a value with. Carrying one means the author
      # meant pressKey, and a key silently ignored is a flow that believes it
      # pressed something.
      case "$do_" in hideKeyboard) [ -z "$val" ] || die "step $FLOW_IDX 'hideKeyboard' does not take a \"value\" - it closes whatever keyboard is up; to press a named key use pressKey";; esac
      # Everything above this line is answerable without a device. Everything
      # below it touches one.
      if [ "$chk" = 1 ]; then continue; fi
      set +e   # a step must not abort the loop by itself: the status is read
               # right after the dispatcher, so the failure is attributed to
               # THIS step and the flow stops with a message.
      case "$do_" in
        wait)        wait_s "${val:-2}";;
        waitFor)     wait_for "$val" "${tmo:-10}" "${mtch:-exact}" assert;;
        waitForId)   wait_for "$val" "${tmo:-10}" exact id;;
        # shot runs in a substitution, so its own die kills only that subshell:
        # the step used to report PASS with an empty path, whether the name was
        # refused or screencap itself failed. The status has to be checked here,
        # in the shell that can stop the flow.
        screenshot)  FLOW_ART="$(shot "$val")" || die "screenshot failed"
                     [ -n "$FLOW_ART" ] || die "screenshot produced no file"
                     ok "shot → $FLOW_ART";;
        tap)         tap $(scale_xy "$x" "$y");;
        tapText)     say "tap '$val'"; tap_text "$val";;
        optionalTapText) tap_text_optional "$val";;
        scroll)      scroll "${val:-down}";;
        scrollToText) scroll_to_step "$val" "${dir:-down}" "$edg" >/dev/null;;
        dismiss)     dismiss;;
        clearText)   clear_field $(scale_xy "$x" "$y");;
        clearTextId) say "clear id '$val'"; clear_id "$val";;
        pressKey)    press_key "$val";;
        hideKeyboard) hide_keyboard;;
        type)        say "type ${#val} chars"; type_str "$val";;
        assertText)  assert_text "$val" "${mtch:-exact}";;
        assertNotText) assert_not_text "$val" "${mtch:-exact}";;
        assertNotId) assert_not_id "$val";;
        tapId)       say "tap id '$val'"; tap_id "$val";;
        assertId)    assert_id "$val";;
        assertBox)   assert_box "$val" assert "$bspec";;
        assertBoxId) assert_box "$val" id "$bspec";;
        launch)      launch "$val";;
        stopApp)     stop_app "$val";;
        clearState)  clear_state "$val";;
        openUrl)     open_url "$val";;
        # A name the dispatcher does not know is a typo in the flow file, and a
        # typo must not pass: `warn` returns zero, so this case has to die on its
        # own - reading the status below would not catch it.
        *) die "unknown step '$do_' - check the flow file";;
      esac
      st=$?
      # A step that failed without dying used to be recorded as passed: the
      # status of the dispatcher was never read. `adb` refusing a tap, a launch
      # of a package that is not installed, a clearText on a dead device - all
      # reported PASS, and the JUnit file said failures="0". Only an explicit
      # `die` could stop a flow.
      #
      # The two best-effort verbs stay the exception, by contract: they return
      # non-zero when a label is simply absent, which is the state they exist
      # to tolerate.
      case "$do_" in dismiss|optionalTapText) st=0;; esac
      [ "$st" -eq 0 ] || die "step $FLOW_IDX '$do_' failed (exit $st)"
      printf '%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\n' "$FLOW_IDX" "$do_" "${val//[$'\n\r']/ }" "$FLOW_S0" "$(now)" "$FLOW_ART" >> "$FLOW_REC"
  done
  exec 3<&-
  export YUKTI_STEP_SRC=""
  if [ "$chk" = 1 ]; then ok "flow checks out: $f - $FLOW_IDX steps"; else ok "flow complete: $f"; fi
}

init(){ [ -f "$CONFIG" ] && die "$CONFIG exists"
  if [ "${1:-}" = android ] || [ "${YUKTI_PLATFORM:-}" = android ]; then cat > "$CONFIG" <<'JSON'
{
  "platform": "android",
  "projectRoot": ".",
  "android": { "avd": "Pixel_7_API_34" },
  "variants": {
    "qa":   { "platform": "android", "package": "com.you.app",     "gradleTask": "assembleDebug",   "envFile": ".env.qa" },
    "prod": { "platform": "android", "package": "com.you.app",     "gradleTask": "assembleRelease", "envFile": ".env.production" }
  }
}
JSON
  ok "wrote $CONFIG (android) — set your package, then 'yukti doctor' and 'yukti up qa'"; return; fi
  cat > "$CONFIG" <<'JSON'
{
  "platform": "ios",
  "projectRoot": ".",
  "workspace": "ios/YourApp.xcworkspace",
  "simulator": { "device": "iPhone 16 Pro" },
  "excludeIosAutolink": [],
  "easEnabled": false,
  "variants": {
    "qa":   { "platform": "ios", "scheme": "YourApp", "configuration": "Release", "bundleId": "com.you.app", "envFile": ".env.qa",         "appVariant": "development", "nodeEnv": "development", "easProfile": "simulator" },
    "prod": { "platform": "ios", "scheme": "YourApp", "configuration": "Release", "bundleId": "com.you.app", "envFile": ".env.production", "appVariant": "production",  "nodeEnv": "production",  "easProfile": "simulator" }
  }
}
JSON
  ok "wrote $CONFIG — edit it, then 'yukti doctor' and 'yukti up qa'"; }

usage(){ cat <<EOF
${B}yukti v$YUKTI_VERSION${R} — YUKTI — smart mobile-app QA automation for React Native / Expo

  yukti doctor              check/guide the toolchain (Xcode, node@22, cocoapods, idb)
  yukti init                write a starter yukti.config.json
  yukti scan                list device-only frameworks (no arm64-sim slice) to exclude
  yukti build <variant>     build a signed arm64 simulator .app (local)
  yukti pull  <variant>     pull an OFFICIAL simulator build from EAS (needs EXPO_TOKEN)
  yukti boot                create/boot the simulator
  yukti up    <variant>     build + boot + install + launch  (one shot)
  yukti install <variant> [--apk <path>]
                            install a build; on Android --apk (or the variant's
                            "apkPath") installs an APK built elsewhere, e.g. by CI
  yukti flow  <file.json>   run a deterministic flow (Talos test-case engine — no AI, CI-ready)
                            leaves \$YUKTI_RESULTS_DIR/<flow>.xml (JUnit) — per-step result
  yukti flow --check <file.json>
                            build the flow and stop: verbs, keys, conditions,
                            includes and variables, no device, nothing performed

  drive primitives (for flows or an AI agent — Claude Code / OpenAI):
  yukti shot [file]         screenshot the sim (an agent reads the PNG)
  yukti ui                  accessibility tree (JSON, coords in points)
  yukti find "<label>"      -> "x y" center of the first element matching label
  yukti tap <x> <y>         tap
  yukti type "<text>"       type (char-by-char = reliable; idb bulk-type drops chars)
  yukti clear <x> <y>       clear a text field
  yukti scroll <up|down|left|right>
                            scroll the screen · yukti scroll-to "<label>" [dir] [clear] scroll a label into view, fail on a miss
  yukti dismiss             auto-dismiss common modals (Not Now / Skip / OK / Close…)
  yukti assert "<text>"     assert the text is on screen (perceivable text only, no scroll)
  yukti stop-app <variant>  force-stop the app of that variant
  yukti clear-state <variant>
                            wipe the app's data (Android only) - the app stops, launch it again
  yukti open-url "<key>"    open a screen of the app under test by its key ("home", "cart")
                            the app of \$YUKTI_VARIANT; the address is that variant's "urlBase"
                            plus the key, or plus its path from the "urls" table when there is
                            one, so no flow carries an environment. It delivers the address,
                            and arrival is asynchronous - the step after it is a waitFor
  yukti wait-for "<text>" [s] [exact|contains]
                            poll until the text is on screen, fail on timeout (default 10s)
  yukti tap-id "<id>"       tap the element with this a11y id / resource-id segment
  yukti clear-id "<id>"     empty the text field with this a11y id / resource-id segment
  yukti assert-id "<id>"    assert an element with this id is on screen
  yukti assert-box "<q>" <assert|id> minWidth=48dp minHeight=48dp
                            assert the box of that element: minWidth, minHeight,
                            maxWidth, maxHeight, minX, minY, maxX, maxY, written
                            in pixels (48), dp ("48dp") or a share ("5%")
  yukti assert-not "<text>" assert the text is NOT on the current screen (no scroll)
  yukti assert-not-id "<id>"
                            assert no element with this id is on the current screen

  flow steps: wait · waitFor · waitForId · screenshot · tap · tapText · optionalTapText · tapId · scroll · scrollToText · dismiss · type · clearText · clearTextId · pressKey · hideKeyboard · assertText · assertId · assertNotText · assertNotId · assertBox · assertBoxId · launch · stopApp · clearState · openUrl · include
              (clearText · clearTextId · pressKey · hideKeyboard · assertBox · assertBoxId: measured on Android, iOS not yet run)
              stopApp force-stops the app, clearState wipes its data (Android only); both take the variant name
              openUrl takes the key of a screen, not an address: the address is the variant's "urlBase" plus the key
              (or plus its path from a "urls" table), so a flow says where to go and never in which environment.
              Cheaper than a restart and it resets every tab, but it promises delivery only - an address the app does
              not route still answers ok, and the app navigates after the step returns, so the step after it is
              waitFor / waitForId, not an assert
              assertText matches the whole label as written; "match":"contains" asks for a substring
              waitFor polls for an element and fails on timeout ("timeout": seconds, default 10); it never scrolls
              scroll takes up, down, left or right as its "value"; scrollToText takes the same four as "direction"
              (default down) and "edges":"clear" to land the element away from the band edges, failing if it cannot
              assertNotText / assertNotId state that something is NOT on the screen as it is now; they never scroll
              any step, and an include, may carry "when": {"visible"|"notVisible"|"visibleId"|"notVisibleId"|"platform"} -
              all the conditions in one "when" have to hold; the step is skipped when they do not, and the run stops when
              they cannot be answered (unreadable screen, unknown platform). A skipped step is in the report as skipped
              include runs another flow file here as a block, path relative to the including file; "with" passes values into it,
              its steps are ordinary steps in the report, and nesting stops at a block inside a block;
              "when" on an include is answered once and covers the whole block
  config: \$YUKTI_CONFIG or ./yukti.config.json  ·  cross-platform: set variant "platform":"android"
EOF
}

# One directory per run for anything measured from the device, made here and
# removed on the way out. Nested calls - the agent shelling out to `yukti tap`,
# Studio driving a flow - inherit it and share the measurement; only the shell
# that made it removes it, so a child cannot pull the floor out from under its
# parent. The alternative, naming the cache after the device, cannot tell two
# emulators apart: their serial is the port they took, not the AVD behind it.
if [ -z "${YUKTI_RUN_DIR:-}" ] || [ ! -d "${YUKTI_RUN_DIR:-}" ]; then
  YUKTI_RUN_DIR="$(mktemp -d "${TMPDIR:-/tmp}/yukti-run.XXXXXX" 2>/dev/null || true)"
  export YUKTI_RUN_DIR
  [ -n "$YUKTI_RUN_DIR" ] && RUN_DIR_OWNED=1 || true
fi
# Not exported on purpose: a child PROCESS inherits the directory but never the
# right to delete it. A SUBSHELL is held off by something else - it does inherit
# this flag, and what saves it is that bash does not run an EXIT trap inside a
# subshell. Two different mechanisms: move this cleanup out of the trap into a
# plain call and the second one disappears without a sound.
run_dir_cleanup(){
  [ "${RUN_DIR_OWNED:-}" = 1 ] || return 0
  case "${YUKTI_RUN_DIR:-}" in */yukti-run.??????) rm -rf "$YUKTI_RUN_DIR";; esac
  return 0; }
trap run_dir_cleanup EXIT

cmd="${1:-}"; shift || true

# `install` is the one command that takes an option, and it may sit before the
# variant - `yukti install --apk build/app.apk android-qa`. Neither the option
# nor its value is a variant name, so the platform must not be resolved from
# one. Every other command still resolves from its first argument: a command
# that does not parse options must not be handed a variant it will never use,
# or `yukti up --apk x android-qa` would resolve a platform, build a variant
# called "--apk" with the default gradle task, boot a device, and only then
# stop. Refusing an unknown argument at the first step is the cheap failure.
install_variant(){ local a; while [ $# -gt 0 ]; do a="$1"; shift
    case "$a" in
      --apk) shift || true;;
      -*) ;;
      *) printf '%s' "$a"; return;;
    esac
  done; }

# Which platform this run drives is answered once, here, and only for the
# commands that drive something. init, doctor, scan and help touch no device,
# and `flow --check` reads a flow without performing any of it - all of them
# still run where there is no config at all, which is the case in CI.
case "$cmd" in
  flow) case " $* " in *" --check "*) ;; *) resolve_platform;; esac;;
  install) resolve_platform "$(install_variant "$@")";;
  build|pull|launch|up|"stop-app"|stopApp|"clear-state"|clearState) resolve_platform "${1:-}";;
  boot|shot|ui|find|tap|"tap-text"|tapText|scroll|"scroll-to"|scrollToText|dismiss|type|clear|assert|"open-url"|openUrl|\
  "tap-id"|tapId|"clear-id"|clearTextId|"assert-id"|assertId|"assert-box"|assertBox|"assert-not"|assertNotText|"assert-not-id"|assertNotId|"wait-for"|waitFor)
    resolve_platform;;
  # init, doctor, scan and help drive nothing, and a command this tool does not
  # know is answered by the dispatcher below - neither is a reason to ask which
  # platform a run is for.
  *) ;;
esac

case "$cmd" in
  doctor) doctor;;
  init) init "$@";;
  scan) scan;;
  build) build "$@";;
  pull) pull_eas "$@";;
  boot) boot;;
  install) install "$@";;
  launch) launch "$@";;
  "stop-app"|stopApp) stop_app "$@";;
  "clear-state"|clearState) clear_state "$@";;
  "open-url"|openUrl) open_url "$@";;
  up) up "$@";;
  flow) flow "$@";;
  shot) shot "$@";;
  ui) ui;;
  find) find_el "$@";;
  tap) tap "$@";;
  "tap-text"|tapText) tap_text "$@";;
  scroll) scroll "$@";;
  "scroll-to"|scrollToText) scroll_to_step "$@";;
  dismiss) dismiss;;
  type) type_str "$@";;
  clear) clear_field "$@";;
  assert) assert_text "$@";;
  "tap-id"|tapId) tap_id "$@";;
  "clear-id"|clearTextId) clear_id "$@";;
  "assert-id"|assertId) assert_id "$@";;
  "assert-box"|assertBox) assert_box_args "$@";;
  "assert-not"|assertNotText) assert_not_text "$@";;
  "assert-not-id"|assertNotId) assert_not_id "$@";;
  "wait-for"|waitFor) wait_for "$@";;
  ""|-h|--help|help) usage;;
  *) die "unknown command '$cmd' — see 'yukti help'";;
esac
