@aarwitz/tapp 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/AGENTS.md +123 -0
  2. package/Harness/OCQAHarness/AppDelegate.swift +21 -0
  3. package/Harness/OCQAHarness/Info.plist +26 -0
  4. package/Harness/OCQAHarness.xcodeproj/project.pbxproj +199 -0
  5. package/Harness/OCQAHarness.xcodeproj/xcshareddata/xcschemes/OCQAHarnessUITests.xcscheme +22 -0
  6. package/Harness/OCQAHarnessUITests/ExplorerTests.swift +4526 -0
  7. package/Harness/OCQAHarnessUITests/Info.plist +22 -0
  8. package/Harness/generate-harness-xcodeproj.rb +254 -0
  9. package/LICENSE +21 -0
  10. package/README.md +374 -0
  11. package/bin/tapp.js +1382 -0
  12. package/browser/app.css +227 -0
  13. package/browser/app.js +675 -0
  14. package/browser/index.html +195 -0
  15. package/browser/product-contract.js +25 -0
  16. package/browser/view-model.js +16 -0
  17. package/docs/BROWSER-PRODUCT.md +72 -0
  18. package/docs/PRODUCT-ENGINE.md +102 -0
  19. package/docs/application-model.md +276 -0
  20. package/docs/scenarios.md +95 -0
  21. package/mcp-server/src/android-driver.js +287 -0
  22. package/mcp-server/src/android-explorer.js +197 -0
  23. package/mcp-server/src/android-flow.js +89 -0
  24. package/mcp-server/src/application-model.js +1597 -0
  25. package/mcp-server/src/browser-product.js +659 -0
  26. package/mcp-server/src/browser-workspaces.js +234 -0
  27. package/mcp-server/src/ci-report.js +557 -0
  28. package/mcp-server/src/ci-setup.js +359 -0
  29. package/mcp-server/src/contract-authoring.js +10 -0
  30. package/mcp-server/src/enrich.js +57 -0
  31. package/mcp-server/src/flow-runtime.js +127 -0
  32. package/mcp-server/src/html-report.js +124 -0
  33. package/mcp-server/src/index.js +3775 -0
  34. package/mcp-server/src/maintenance-proposal.js +178 -0
  35. package/mcp-server/src/managed-operation.js +61 -0
  36. package/mcp-server/src/pr-selection.js +841 -0
  37. package/mcp-server/src/product-execution.js +155 -0
  38. package/mcp-server/src/product-operations.js +526 -0
  39. package/mcp-server/src/project-config.js +101 -0
  40. package/mcp-server/src/release-contract.d.ts +81 -0
  41. package/mcp-server/src/release-contract.js +226 -0
  42. package/mcp-server/src/report.js +363 -0
  43. package/mcp-server/src/scenario-runtime.js +139 -0
  44. package/mcp-server/src/static-server.js +44 -0
  45. package/mcp-server/src/task-runtime.js +266 -0
  46. package/mcp-server/src/ui-map.js +661 -0
  47. package/mcp-server/src/web-explorer.js +493 -0
  48. package/mcp-server/src/web-flow.js +238 -0
  49. package/package.json +82 -0
  50. package/scripts/android-corpus-e2e.sh +30 -0
  51. package/scripts/ci-gate.sh +323 -0
  52. package/scripts/cleanup-xcode.sh +157 -0
  53. package/scripts/compile-contract.js +27 -0
  54. package/scripts/compile-flow.js +18 -0
  55. package/scripts/corpus-apps.txt +9 -0
  56. package/scripts/corpus-sweep.sh +121 -0
  57. package/scripts/coverage-eval.sh +92 -0
  58. package/scripts/coverage_eval_parse.py +95 -0
  59. package/scripts/deploy-and-build.sh +99 -0
  60. package/scripts/flow-platform.js +18 -0
  61. package/scripts/flow_ai_judge.py +102 -0
  62. package/scripts/flow_lib.py +154 -0
  63. package/scripts/mutation-recall-desktop.sh +186 -0
  64. package/scripts/mutation-recall.sh +121 -0
  65. package/scripts/mutation_lib.py +128 -0
  66. package/scripts/mutation_operators.py +144 -0
  67. package/scripts/platform-gate.js +186 -0
  68. package/scripts/pr-plan.js +68 -0
  69. package/scripts/quick-capture.sh +419 -0
  70. package/scripts/run-android-flow.js +27 -0
  71. package/scripts/run-flow.sh +90 -0
  72. package/scripts/run-web-flow.js +28 -0
  73. package/scripts/run-web-scenario.js +23 -0
  74. package/scripts/validation-matrix.sh +146 -0
  75. package/scripts/vision-fp-eval.sh +206 -0
  76. package/scripts/vision_escalation_responder.py +147 -0
  77. package/scripts/vision_fp_probe.py +221 -0
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env bash
2
+ # AutoTap harness accuracy benchmark.
3
+ #
4
+ # Runs the exploration harness against DemoApp (the fixture corpus) and asserts expected outcomes:
5
+ # - the run completes (no crash/abort),
6
+ # - a minimum number of distinct screens are reached (breadth),
7
+ # - each detection fixture, WHEN its screen is reached, produces its expected finding (accuracy),
8
+ # - DemoApp never produces a `crash` finding (no false alarms).
9
+ # Then, if RestaurantDemo is installed, a freeze regression: with interactive input ENABLED it must
10
+ # NOT pause for input on its (credential-free) item screens — guarding the multi-minute stall fix.
11
+ #
12
+ # Exploration is non-deterministic, so fixture assertions are conditional on the screen being
13
+ # reached. Use --actions to raise the budget for fuller coverage. Exits non-zero on any failure.
14
+ #
15
+ # Prereqs: a booted simulator, the harness built (scripts/deploy-and-build.sh --harness), and
16
+ # DemoApp installed (ruby generate-demoapp-xcodeproj.rb + xcodebuild + simctl install).
17
+ set -uo pipefail
18
+
19
+ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
20
+ ACTIONS="${1:-70}"
21
+ BUNDLE="com.autotap.demoapp"
22
+
23
+ UDID="$(xcrun simctl list devices booted -j 2>/dev/null | python3 -c 'import sys,json; d=json.load(sys.stdin); print(next((x["udid"] for v in d["devices"].values() for x in v if x.get("state")=="Booted"), ""))')"
24
+ [ -z "$UDID" ] && { echo "❌ No booted simulator. Boot one first."; exit 2; }
25
+
26
+ XCTR="$(find "$HOME/Library/Developer/Xcode/DerivedData/OCQAHarness-"*/Build/Products -name '*.xctestrun' 2>/dev/null | head -1)"
27
+ # Fallback: harness built via deploy-and-build.sh --harness lands here
28
+ [ -z "$XCTR" ] && XCTR="$(find /tmp/autotap-harness-derived/Build/Products -name '*.xctestrun' 2>/dev/null | head -1)"
29
+ [ -z "$XCTR" ] && { echo "❌ Harness not built. Run: scripts/deploy-and-build.sh --harness"; exit 2; }
30
+
31
+ xcrun simctl get_app_container "$UDID" "$BUNDLE" >/dev/null 2>&1 || { echo "❌ DemoApp not installed on $UDID."; exit 2; }
32
+
33
+ CFG="/tmp/ocqa-validation-matrix.json"
34
+ cat > "$CFG" <<JSON
35
+ { "OCQA_BUNDLE_ID": "$BUNDLE", "OCQA_MAX_ACTIONS": "$ACTIONS", "OCQA_TIMEOUT_SECONDS": "600" }
36
+ JSON
37
+
38
+ LOG="/tmp/ocqa-validation-matrix.log"
39
+ echo "Running harness vs DemoApp ($ACTIONS actions, sim $UDID)…"
40
+ TEST_RUNNER_OCQA_CONFIG_PATH="$CFG" xcodebuild test-without-building \
41
+ -xctestrun "$XCTR" -destination "platform=iOS Simulator,id=$UDID" \
42
+ -only-testing:"OCQAHarnessUITests/ExplorerTests/testAutonomousExploration" > "$LOG" 2>&1
43
+
44
+ python3 - "$LOG" <<'PY'
45
+ import sys, re, json
46
+ log = open(sys.argv[1]).read()
47
+ screens, issues, complete = set(), {}, None
48
+ for line in log.splitlines():
49
+ m = re.search(r'OCQA_STATE:(\{.*\})', line)
50
+ if m:
51
+ try: screens.add(json.loads(m.group(1)).get("screen",""))
52
+ except: pass
53
+ m = re.search(r'OCQA_ISSUE:(\{.*\})', line)
54
+ if m:
55
+ try:
56
+ d = json.loads(m.group(1)); issues.setdefault(d.get("screen",""), set()).add(d.get("type"))
57
+ except: pass
58
+ m = re.search(r'OCQA_COMPLETE:(\{.*\})', line)
59
+ if m:
60
+ try: complete = json.loads(m.group(1))
61
+ except: pass
62
+ screens.discard("")
63
+
64
+ # fixture screen -> expected finding type (asserted only if the screen was reached)
65
+ FIXTURES = [
66
+ ("System Status", "error_surface"), # always-on error text
67
+ ("System Status", "unresponsive_element"), # dead Retry button on error screen
68
+ ("Live Feed", "app_hang"), # perpetual spinner
69
+ ("Dashboard", "unresponsive_element"), # dead Sync Now button
70
+ ("Changelog", "error_surface"), # error message below the fold
71
+ ("Update Profile", "error_surface"), # inline validation error after form submit
72
+ ("Saved Reports", "unresponsive_element"), # dead Add Report button on empty state
73
+ ("Queued Tasks", "unresponsive_element"), # swipe-only rows invisible to tap-based exploration
74
+ ]
75
+ MIN_SCREENS = 12
76
+
77
+ rows, ok = [], True
78
+ def check(name, passed, detail=""):
79
+ global ok
80
+ ok = ok and passed
81
+ rows.append((("PASS ✅" if passed else "FAIL ❌"), name, detail))
82
+
83
+ check("run completed (no abort/crash)", complete is not None, "" if complete else "no OCQA_COMPLETE")
84
+ check(f">= {MIN_SCREENS} distinct screens reached", len(screens) >= MIN_SCREENS, f"reached {len(screens)}")
85
+ crashed = any("crash" in t for t in issues.values())
86
+ check("no spurious 'crash' finding", not crashed)
87
+ for screen, expected in FIXTURES:
88
+ if screen in screens:
89
+ check(f"[{screen}] → {expected}", expected in issues.get(screen, set()), f"found {sorted(issues.get(screen,set()))}")
90
+ else:
91
+ rows.append(("SKIP ⏭ ", f"[{screen}] → {expected}", "screen not reached this run"))
92
+
93
+ print("\n=== AutoTap Validation Matrix ===")
94
+ for status, name, detail in rows:
95
+ print(f" {status} {name}" + (f" ({detail})" if detail else ""))
96
+ print(f"\nScreens: {', '.join(sorted(screens))}")
97
+ sys.exit(0 if ok else 1)
98
+ PY
99
+ RESULT=$?
100
+
101
+ # ===== RestaurantDemo: interactive-input freeze regression =====
102
+ # RestaurantDemo has form fields (e.g. "Special Instructions") but NO credential screens, so with
103
+ # interactive input ENABLED it must NOT pause (OCQA_AWAIT_INPUT) — that pause once froze runs for
104
+ # ~180s on item detail screens. Guards the credential-only gate + short fallback.
105
+ RD_BUNDLE="com.autotap.restaurantdemo"
106
+ RD_RESULT=0
107
+ if xcrun simctl get_app_container "$UDID" "$RD_BUNDLE" >/dev/null 2>&1; then
108
+ RD_CFG="/tmp/ocqa-restaurant-regression.json"
109
+ rm -f /tmp/ocqa-rd-noresp.json
110
+ cat > "$RD_CFG" <<JSON
111
+ { "OCQA_BUNDLE_ID": "$RD_BUNDLE", "OCQA_MAX_ACTIONS": "20", "OCQA_TIMEOUT_SECONDS": "240", "OCQA_INTERACTIVE_INPUT": "1", "OCQA_INPUT_RESPONSE_PATH": "/tmp/ocqa-rd-noresp.json", "OCQA_INPUT_WAIT_TIMEOUT": "30" }
112
+ JSON
113
+ RD_LOG="/tmp/ocqa-restaurant-regression.log"
114
+ echo ""; echo "Running RestaurantDemo freeze-regression (interactive input ON, $UDID)…"
115
+ TEST_RUNNER_OCQA_CONFIG_PATH="$RD_CFG" xcodebuild test-without-building \
116
+ -xctestrun "$XCTR" -destination "platform=iOS Simulator,id=$UDID" \
117
+ -only-testing:"OCQAHarnessUITests/ExplorerTests/testAutonomousExploration" > "$RD_LOG" 2>&1
118
+ python3 - "$RD_LOG" <<'PY'
119
+ import sys, re
120
+ log = open(sys.argv[1]).read()
121
+ pauses = len(re.findall(r'OCQA_AWAIT_INPUT', log))
122
+ screens = set(re.findall(r'OCQA_STATE:\{"screen":"([^"]*)"', log))
123
+ completed = "OCQA_COMPLETE" in log
124
+ ok = True
125
+ def check(name, passed, detail=""):
126
+ global ok; ok = ok and passed
127
+ print(f" {'PASS ✅' if passed else 'FAIL ❌'} {name}" + (f" ({detail})" if detail else ""))
128
+ print("\n=== RestaurantDemo freeze regression ===")
129
+ check("no interactive-input pause (0 OCQA_AWAIT_INPUT)", pauses == 0, f"{pauses} pause(s)")
130
+ check("reached Menu", "Menu" in screens)
131
+ check("reached a menu-item detail", any(s in screens for s in ("Bruschetta","Tiramisu","Charcuterie Board")), f"screens={sorted(screens)}")
132
+ check("run completed", completed)
133
+ sys.exit(0 if ok else 1)
134
+ PY
135
+ RD_RESULT=$?
136
+ else
137
+ echo ""; echo "⏭ RestaurantDemo not installed — skipping freeze regression"
138
+ echo " (ruby generate-demo-named.rb RestaurantDemo + xcodebuild + simctl install to enable)"
139
+ fi
140
+
141
+ echo ""
142
+ if [ $RESULT -eq 0 ] && [ "$RD_RESULT" -eq 0 ]; then
143
+ echo "✅ Validation matrix PASSED"; exit 0
144
+ else
145
+ echo "❌ Validation matrix FAILED (DemoApp log: $LOG)"; exit 1
146
+ fi
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env bash
2
+ # Vision false-positive eval — measure how often AutoTap's vision pass invents a defect on a clean
3
+ # screen, BEFORE we default it on.
4
+ #
5
+ # The vision reviewer (VisionInspector.swift) is disabled by default. This runs the SAME prompt/model
6
+ # over real captured screens (via scripts/vision_fp_probe.py) and tallies the visual findings. Our
7
+ # corpus apps are standard SwiftUI (visually clean — their fixtures are logical/interaction bugs, not
8
+ # visual ones), so on that corpus every finding is a candidate FALSE POSITIVE. A low flag rate here
9
+ # is the evidence needed to default vision on.
10
+ #
11
+ # Usage:
12
+ # ANTHROPIC_API_KEY=... scripts/vision-fp-eval.sh # capture + review corpus apps
13
+ # ANTHROPIC_API_KEY=... scripts/vision-fp-eval.sh com.acme.app # specific installed app(s)
14
+ # ANTHROPIC_API_KEY=... scripts/vision-fp-eval.sh --dir path/to/pngs # review existing PNGs
15
+ # ACTIONS=40 scripts/vision-fp-eval.sh # exploration budget per app
16
+ #
17
+ # Prereqs (capture mode): a booted sim, the harness built (scripts/deploy-and-build.sh --harness),
18
+ # apps installed. Writes captures/vision-fp-<timestamp>.json.
19
+ set -uo pipefail
20
+
21
+ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
22
+ ACTIONS="${ACTIONS:-40}"
23
+ TIMEOUT="${TIMEOUT:-420}"
24
+ MAX_SCREENS="${MAX_SCREENS:-25}" # mirrors ExplorationService.visionScreenBudget
25
+ DEFAULT_APPS=(com.autotap.demoapp com.autotap.logindemo com.autotap.wizarddemo com.autotap.shopdemo com.autotap.restaurantdemo)
26
+
27
+ [ -z "${ANTHROPIC_API_KEY:-}" ] && { echo "❌ ANTHROPIC_API_KEY not set."; exit 2; }
28
+
29
+ TS="$(date +%Y%m%d-%H%M%S)"
30
+ REPORT="$ROOT/captures/vision-fp-$TS.json"
31
+ mkdir -p "$ROOT/captures"
32
+
33
+ # ---- Direct-directory mode: review a folder of PNGs, no capture ----
34
+ if [ "${1:-}" = "--dir" ]; then
35
+ DIR="${2:?--dir needs a path}"
36
+ PNGS=() # macOS ships bash 3.2 (no mapfile) — read into the array portably.
37
+ while IFS= read -r p; do [ -n "$p" ] && PNGS+=("$p"); done < <(find "$DIR" -iname '*.png' | sort)
38
+ [ ${#PNGS[@]} -eq 0 ] && { echo "❌ No PNGs under $DIR"; exit 2; }
39
+ echo "Reviewing ${#PNGS[@]} screenshot(s) from $DIR ..."
40
+ python3 "$ROOT/scripts/vision_fp_probe.py" "${PNGS[@]}" > "$REPORT"
41
+ python3 -c '
42
+ import sys, json
43
+ d = json.load(open(sys.argv[1]))
44
+ if d.get("errors"): print("⚠️ %d/%d calls FAILED (e.g. %s) — not clean screens." % (d["errors"], d["reviewed"], (d.get("first_error") or "")[:80]))
45
+ print("reviewed=%s succeeded=%s flagged=%s findings=%s by_sev=%s fp_rate=%s" % (d["reviewed"], d["succeeded"], d["flagged"], d["findings_total"], d["by_severity"], d["fp_rate_screens"]))
46
+ ' "$REPORT"
47
+ echo "Report: $REPORT"
48
+ exit 0
49
+ fi
50
+
51
+ # ---- Capture mode: drive corpus apps through the harness, extract distinct screens, review ----
52
+ APPS=("$@"); [ ${#APPS[@]} -eq 0 ] && APPS=("${DEFAULT_APPS[@]}")
53
+
54
+ UDID="$(xcrun simctl list devices booted -j 2>/dev/null | python3 -c 'import sys,json; d=json.load(sys.stdin); print(next((x["udid"] for v in d["devices"].values() for x in v if x.get("state")=="Booted"), ""))')"
55
+ [ -z "$UDID" ] && { echo "❌ No booted simulator. Boot one first."; exit 2; }
56
+ XCTR="$(find "$HOME/Library/Developer/Xcode/DerivedData/OCQAHarness-"*/Build/Products -name '*.xctestrun' 2>/dev/null | head -1)"
57
+ [ -z "$XCTR" ] && XCTR="$(find /tmp/autotap-harness-derived/Build/Products -name '*.xctestrun' 2>/dev/null | head -1)"
58
+ [ -z "$XCTR" ] && XCTR="$(find /tmp/harness-build/Build/Products -name '*.xctestrun' 2>/dev/null | head -1)"
59
+ [ -z "$XCTR" ] && { echo "❌ Harness not built. Run: scripts/deploy-and-build.sh --harness"; exit 2; }
60
+
61
+ echo "Vision FP eval — $ACTIONS actions/app, sim $UDID"
62
+ echo "Apps: ${APPS[*]}"
63
+ echo ""
64
+
65
+ ALL_PNGS=()
66
+ TITLE_MAP="/tmp/vision-fp-titlemap-$TS.json"
67
+ SUMMARY_MAP="/tmp/vision-fp-summarymap-$TS.json" # basename -> a11y summary (mirrors runVisionPass)
68
+ echo "{}" > "$TITLE_MAP"; echo "{}" > "$SUMMARY_MAP"
69
+
70
+ for APP in "${APPS[@]}"; do
71
+ if ! xcrun simctl get_app_container "$UDID" "$APP" >/dev/null 2>&1; then
72
+ printf " %-32s ⏭ not installed — skipping\n" "$APP"; continue
73
+ fi
74
+ CFG="/tmp/ocqa-visfp-$APP.json"
75
+ cat > "$CFG" <<JSON
76
+ { "OCQA_BUNDLE_ID": "$APP", "OCQA_MAX_ACTIONS": "$ACTIONS", "OCQA_TIMEOUT_SECONDS": "$TIMEOUT" }
77
+ JSON
78
+ XCRESULT="/tmp/ocqa-visfp-$APP.xcresult"; rm -rf "$XCRESULT"
79
+ OUTDIR="/tmp/ocqa-visfp-shots-$APP"; rm -rf "$OUTDIR"; mkdir -p "$OUTDIR"
80
+ LOG="/tmp/ocqa-visfp-$APP.log" # harness stdout; carries OCQA_STATE settled flags per action
81
+ printf " %-32s exploring...\n" "$APP"
82
+ TEST_RUNNER_OCQA_CONFIG_PATH="$CFG" xcodebuild test-without-building \
83
+ -xctestrun "$XCTR" -destination "platform=iOS Simulator,id=$UDID" \
84
+ -resultBundlePath "$XCRESULT" \
85
+ -only-testing:"OCQAHarnessUITests/ExplorerTests/testAutonomousExploration" > "$LOG" 2>&1
86
+
87
+ xcrun xcresulttool export attachments --path "$XCRESULT" --output-path "$OUTDIR" >/dev/null 2>&1 || true
88
+
89
+ # Dedup to distinct SCREENS and pick ONE representative per title, PREFERRING a settled capture —
90
+ # mirrors ExplorationService.runVisionPass. The harness names shots "state_<action>_<Title>_<n>_
91
+ # <uuid>.png"; the action count joins to the "settled" flag on that step from the harness log
92
+ # (OCQA_STATE). Bounded by MAX_SCREENS. Kept apostrophe-free (heredoc nested in $()).
93
+ SELECTED="$(APP="$APP" OUTDIR="$OUTDIR" MAX_SCREENS="$MAX_SCREENS" TITLE_MAP="$TITLE_MAP" SUMMARY_MAP="$SUMMARY_MAP" LOG="$LOG" SETTLED_MODE="${SETTLED_MODE:-prefer}" python3 - <<'PY'
94
+ import os, re, json, glob
95
+ outdir = os.environ["OUTDIR"]; app = os.environ["APP"]
96
+ maxs = int(os.environ["MAX_SCREENS"]); tmpath = os.environ["TITLE_MAP"]; smpath = os.environ["SUMMARY_MAP"]
97
+ logpath = os.environ.get("LOG", ""); mode = os.environ.get("SETTLED_MODE", "prefer")
98
+
99
+ # action -> settled(bool) and action -> a11y context (summary + full text inventory), parsed from
100
+ # the harness log OCQA_STATE lines. Context mirrors ExplorationService.visionContext.
101
+ settled_by_action = {}
102
+ summary_by_action = {}
103
+ if logpath and os.path.exists(logpath):
104
+ for line in open(logpath, errors="ignore"):
105
+ if line.startswith("OCQA_STATE:{"):
106
+ try:
107
+ o = json.loads(line[len("OCQA_STATE:"):])
108
+ if "action" not in o:
109
+ continue
110
+ act = int(o["action"])
111
+ if "settled" in o:
112
+ settled_by_action[act] = bool(o["settled"])
113
+ ctx = o.get("summary", "")
114
+ texts = [t for t in (o.get("atext") or []) if isinstance(t, str)]
115
+ if texts:
116
+ ctx += ("\n" if ctx else "") + "Full visible text (from accessibility): " + " | ".join(texts)
117
+ if ctx:
118
+ summary_by_action[act] = ctx
119
+ except Exception:
120
+ pass
121
+
122
+ def parse_name(nm):
123
+ m = re.match(r"(?:final_)?state_(\d+)_(.+?)_\d+_[0-9A-Fa-f-]{8,}\.png$", nm)
124
+ if m:
125
+ return int(m.group(1)), m.group(2).replace("_", " ").strip()
126
+ if nm.startswith("final_state"):
127
+ return 10**9, "final state"
128
+ return -1, os.path.splitext(nm)[0]
129
+
130
+ entries = [] # (path, title, action, settled)
131
+ manifest = os.path.join(outdir, "manifest.json")
132
+ if os.path.exists(manifest):
133
+ try:
134
+ data = json.load(open(manifest))
135
+ for test in data if isinstance(data, list) else []:
136
+ for att in test.get("attachments", []):
137
+ fn = att.get("exportedFileName"); nm = att.get("suggestedHumanReadableName") or fn or ""
138
+ if not (fn and fn.lower().endswith(".png")):
139
+ continue
140
+ action, title = parse_name(nm)
141
+ st = settled_by_action.get(action, True)
142
+ entries.append((os.path.join(outdir, fn), title, action, st))
143
+ except Exception:
144
+ pass
145
+ if not entries:
146
+ entries = [(p, "Screen %d" % (i + 1), -1, True)
147
+ for i, p in enumerate(sorted(glob.glob(os.path.join(outdir, "*.png"))))]
148
+
149
+ # One representative per title. mode=prefer upgrades an unsettled pick to a settled one.
150
+ best = {} # title -> [path, settled, order, action]
151
+ order = 0
152
+ for path, title, action, st in sorted(entries, key=lambda e: e[2] if e[2] >= 0 else 10**9):
153
+ if title.lower() in ("unknown", ""):
154
+ continue
155
+ order += 1
156
+ if title in best:
157
+ if mode == "prefer" and (not best[title][1]) and st:
158
+ best[title][0] = path; best[title][1] = True; best[title][3] = action
159
+ else:
160
+ best[title] = [path, st, order, action]
161
+
162
+ chosen = sorted(best.items(), key=lambda kv: kv[1][2])[:maxs]
163
+ tmap = json.load(open(tmpath)) if os.path.exists(tmpath) else {}
164
+ smap = json.load(open(smpath)) if os.path.exists(smpath) else {}
165
+ paths = []
166
+ for title, (path, st, _o, action) in chosen:
167
+ paths.append(path)
168
+ tmap[os.path.basename(path)] = ("%s: %s" % (app, title)) + ("" if st else " [unsettled]")
169
+ smap[os.path.basename(path)] = summary_by_action.get(action, "")
170
+ json.dump(tmap, open(tmpath, "w"))
171
+ json.dump(smap, open(smpath, "w"))
172
+ print("\n".join(paths))
173
+ PY
174
+ )"
175
+ CNT=0
176
+ while IFS= read -r line; do [ -n "$line" ] && { ALL_PNGS+=("$line"); CNT=$((CNT+1)); }; done <<< "$SELECTED"
177
+ printf " %-32s %s distinct screen(s) captured\n" "$APP" "$CNT"
178
+ done
179
+
180
+ [ ${#ALL_PNGS[@]} -eq 0 ] && { echo "❌ No screenshots captured — is the harness built and are apps installed?"; exit 2; }
181
+
182
+ echo ""
183
+ echo "Reviewing ${#ALL_PNGS[@]} distinct screen(s) with the vision model..."
184
+ python3 "$ROOT/scripts/vision_fp_probe.py" --title-map "$TITLE_MAP" --summary-map "$SUMMARY_MAP" "${ALL_PNGS[@]}" > "$REPORT"
185
+
186
+ python3 -c '
187
+ import sys, json
188
+ d = json.load(open(sys.argv[1]))
189
+ print("")
190
+ print(" reviewed screens : %d" % d["reviewed"])
191
+ print(" succeeded calls : %d" % d["succeeded"])
192
+ if d.get("errors"):
193
+ print(" ⚠️ FAILED calls : %d (e.g. %s)" % (d["errors"], (d.get("first_error") or "")[:70]))
194
+ print(" Failed calls are NOT clean screens — fix connectivity and re-run before trusting the rate.")
195
+ print(" flagged screens : %d" % d["flagged"])
196
+ print(" total findings : %d (by severity: %s)" % (d["findings_total"], d["by_severity"]))
197
+ rate = d["fp_rate_screens"]
198
+ print(" screen flag rate : %s (over succeeded calls; on a visually-clean corpus this ~= false-positive rate)" % ("n/a" if rate is None else "%.1f%%" % (100*rate)))
199
+ if d["flagged"]:
200
+ print("\n flagged (inspect each — real defect or false positive?):")
201
+ for img in d["images"]:
202
+ for f in img["findings"]:
203
+ print(" - [%s/%s] %s — %s :: %s" % (f["severity"], f["category"], img["title"], f["title"], f["detail"][:80]))
204
+ ' "$REPORT"
205
+ echo ""
206
+ echo "Report: $REPORT"
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env python3
2
+ """Host-side responder for in-loop vision escalation — lets script runs (coverage-eval.sh) exercise
3
+ OCQA_VISION_QUERY without the AutoTap desktop app.
4
+
5
+ Tails the harness log for OCQA_VISION_QUERY markers, sends the screenshot to the vision model with
6
+ the SAME next-move prompt AnthropicVisionInspector.decideNextAction uses (kept in sync with
7
+ VisionInspector.swift), and writes the {requestId, action, x, y} decision to the response file the
8
+ harness polls. On any failure it writes action="none" so the harness falls back to its bail path
9
+ promptly instead of blocking to its timeout.
10
+
11
+ Usage (run alongside xcodebuild, kill when the run ends):
12
+ ANTHROPIC_API_KEY=... python3 vision_escalation_responder.py <harness.log> <response_path>
13
+ """
14
+ import base64
15
+ import json
16
+ import os
17
+ import ssl
18
+ import sys
19
+ import time
20
+ import urllib.request
21
+
22
+
23
+ def _ssl_context():
24
+ try:
25
+ import certifi
26
+ return ssl.create_default_context(cafile=certifi.where())
27
+ except Exception:
28
+ return ssl.create_default_context()
29
+
30
+
31
+ _SSL_CTX = _ssl_context()
32
+ MODEL = os.environ.get("AUTOTAP_VISION_MODEL", "claude-haiku-4-5-20251001")
33
+
34
+ # Mirror of AnthropicVisionInspector.navPrompt (VisionInspector.swift).
35
+ NAV_PROMPT = (
36
+ "You are guiding an automated iOS UI explorer that is STUCK on the screen shown — either its "
37
+ "accessibility tree is empty (custom-drawn/canvas/WebView UI) or it has been looping without "
38
+ "reaching anything new. Look at the screenshot and choose the SINGLE next action a real user "
39
+ "would take to make forward progress (reveal new content or navigate onward). Respond with ONLY "
40
+ "JSON: {\"action\":\"tap|swipe_up|swipe_down|back|none\",\"x\":<0..1>,\"y\":<0..1>,\"reason\":"
41
+ "\"<brief>\"}. For \"tap\", x and y are NORMALIZED coordinates (0=left/top, 1=right/bottom) of "
42
+ "the point to tap — aim at the center of a primary button or an unexplored item. Use "
43
+ "\"swipe_up\" to scroll for more, \"back\" to leave a dead-end, and \"none\" only if truly "
44
+ "nothing is actionable."
45
+ )
46
+
47
+
48
+ def parse_action(text):
49
+ """Mirror of parseVisionAction — tolerant, clamps coords, tap-without-coords -> none."""
50
+ start, end = text.find("{"), text.rfind("}")
51
+ if start < 0 or end <= start:
52
+ return None
53
+ try:
54
+ obj = json.loads(text[start:end + 1])
55
+ except Exception:
56
+ return None
57
+ raw = str(obj.get("action", "none")).lower().strip()
58
+ aliases = {"swipeup": "swipe_up", "swipe up": "swipe_up", "scroll_up": "swipe_up", "scroll up": "swipe_up",
59
+ "swipedown": "swipe_down", "swipe down": "swipe_down", "scroll_down": "swipe_down", "scroll down": "swipe_down"}
60
+ kind = raw if raw in ("tap", "swipe_up", "swipe_down", "back", "none") else aliases.get(raw, "none")
61
+
62
+ def num(v):
63
+ try:
64
+ return float(v)
65
+ except Exception:
66
+ return None
67
+ x, y = num(obj.get("x")), num(obj.get("y"))
68
+ if kind == "tap" and (x is None or y is None):
69
+ kind = "none"
70
+ return {"action": kind,
71
+ "x": min(max(x if x is not None else 0.5, 0.0), 1.0),
72
+ "y": min(max(y if y is not None else 0.5, 0.0), 1.0),
73
+ "reason": str(obj.get("reason", ""))[:120]}
74
+
75
+
76
+ def decide(image_path, screen, reason, key):
77
+ with open(image_path, "rb") as f:
78
+ b64 = base64.b64encode(f.read()).decode()
79
+ body = {
80
+ "model": MODEL, "max_tokens": 300, "system": NAV_PROMPT,
81
+ "messages": [{"role": "user", "content": [
82
+ {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": b64}},
83
+ {"type": "text", "text": f"Screen title: {screen}. The explorer got stuck ({reason}). Choose the single best next action as the JSON object."},
84
+ ]}],
85
+ }
86
+ req = urllib.request.Request("https://api.anthropic.com/v1/messages", data=json.dumps(body).encode(),
87
+ method="POST", headers={"x-api-key": key, "anthropic-version": "2023-06-01",
88
+ "content-type": "application/json"})
89
+ with urllib.request.urlopen(req, timeout=45, context=_SSL_CTX) as resp:
90
+ data = json.load(resp)
91
+ text = "\n".join(b.get("text", "") for b in data.get("content", []) if b.get("type") == "text")
92
+ return parse_action(text)
93
+
94
+
95
+ def main():
96
+ if len(sys.argv) < 3:
97
+ print("usage: vision_escalation_responder.py <harness.log> <response_path>", file=sys.stderr)
98
+ return 2
99
+ log_path, response_path = sys.argv[1], sys.argv[2]
100
+ key = os.environ.get("ANTHROPIC_API_KEY", "")
101
+ if not key:
102
+ print("ANTHROPIC_API_KEY not set", file=sys.stderr)
103
+ return 2
104
+
105
+ handled = set()
106
+ pos = 0
107
+ while True:
108
+ time.sleep(0.4)
109
+ try:
110
+ with open(log_path, errors="ignore") as f:
111
+ f.seek(pos)
112
+ chunk = f.read()
113
+ pos = f.tell()
114
+ except FileNotFoundError:
115
+ continue
116
+ for line in chunk.splitlines():
117
+ idx = line.find("OCQA_VISION_QUERY:{")
118
+ if idx < 0:
119
+ continue
120
+ try:
121
+ q = json.loads(line[idx + len("OCQA_VISION_QUERY:"):])
122
+ except Exception:
123
+ continue
124
+ rid = q.get("requestId", "")
125
+ if not rid or rid in handled:
126
+ continue
127
+ handled.add(rid)
128
+ decision = None
129
+ try:
130
+ decision = decide(q.get("image", ""), q.get("screen", "?"), q.get("reason", "stuck"), key)
131
+ except Exception as e:
132
+ print(f"responder: decide failed for {rid}: {e}", file=sys.stderr, flush=True)
133
+ out = {"requestId": rid,
134
+ "action": (decision or {}).get("action", "none"),
135
+ "x": (decision or {}).get("x", 0.5),
136
+ "y": (decision or {}).get("y", 0.5)}
137
+ tmp = response_path + ".tmp"
138
+ with open(tmp, "w") as f:
139
+ json.dump(out, f)
140
+ os.replace(tmp, response_path) # atomic — harness never sees a partial file
141
+ print(f"responder: {rid[:8]} on '{q.get('screen','?')}' ({q.get('reason','')}) -> "
142
+ f"{out['action']} ({out['x']:.2f},{out['y']:.2f}) {(decision or {}).get('reason','')}",
143
+ flush=True)
144
+
145
+
146
+ if __name__ == "__main__":
147
+ sys.exit(main())