@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,121 @@
1
+ #!/usr/bin/env bash
2
+ # Corpus sweep — run a list of real open-source iOS apps through the tapp pipeline and
3
+ # produce a friction scoreboard. Failures ARE the data: every app gets a row recording
4
+ # how far it got (clone → detect → build → install → explore) and why it stopped.
5
+ #
6
+ # Usage: scripts/corpus-sweep.sh <urls-file> [--actions N] [--out <dir>]
7
+ # urls-file: one git URL per line (# comments ok)
8
+ #
9
+ # Needs a booted simulator. Rows land in <out>/scoreboard.tsv; per-app logs in <out>/<app>/.
10
+ set -uo pipefail
11
+ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
12
+
13
+ URLS_FILE="${1:?usage: corpus-sweep.sh <urls-file> [--actions N] [--out dir]}"; shift
14
+ ACTIONS=25
15
+ OUT="/tmp/tapp-corpus"
16
+ while [[ $# -gt 0 ]]; do
17
+ case "$1" in
18
+ --actions) ACTIONS="$2"; shift 2 ;;
19
+ --out) OUT="$2"; shift 2 ;;
20
+ *) echo "unknown arg $1" >&2; exit 2 ;;
21
+ esac
22
+ done
23
+ mkdir -p "$OUT"
24
+ BOARD="$OUT/scoreboard.tsv"
25
+ [[ -f "$BOARD" ]] || echo -e "app\tstage\tbackend_signals\tbundle\tscreens\tactions\tfindings\tverdict\tnote" > "$BOARD"
26
+
27
+ UDID="$(xcrun simctl list devices booted -j | 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"), ""))')"
28
+ [[ -n "$UDID" ]] || { echo "❌ boot a simulator first" >&2; exit 1; }
29
+
30
+ row() { echo -e "$1\t$2\t$3\t$4\t$5\t$6\t$7\t$8\t$9" >> "$BOARD"; }
31
+
32
+ # Cheap backend-class signals from the repo tree (feeds the classifier's rules).
33
+ backend_signals() {
34
+ local dir="$1" sig=()
35
+ [[ -n "$(find "$dir" -maxdepth 4 -name 'GoogleService-Info.plist' -print -quit 2>/dev/null)" ]] && sig+=("firebase-plist")
36
+ grep -rqli "firebase" "$dir"/Podfile "$dir"/*/Package.resolved "$dir"/Package.resolved 2>/dev/null && sig+=("firebase-dep")
37
+ grep -rqli "supabase" "$dir"/Podfile "$dir"/*/Package.resolved "$dir"/Package.resolved 2>/dev/null && sig+=("supabase-dep")
38
+ [[ -n "$(find "$dir" -maxdepth 2 -name 'docker-compose.y*ml' -print -quit 2>/dev/null)" ]] && sig+=("in-repo-backend")
39
+ grep -rqli "mastodon" "$dir"/Package.resolved 2>/dev/null && sig+=("mastodon-client")
40
+ grep -rql "YOUR_API_KEY\|INSERT_API_KEY\|<API_KEY>" --include="*.xcconfig" --include="*.plist" --include="*.swift" "$dir" 2>/dev/null | head -1 | grep -q . && sig+=("key-placeholder")
41
+ local IFS=","; echo "${sig[*]:-none}"
42
+ }
43
+
44
+ # fd 3 for the URL list — xcodebuild/git inside the loop consume stdin and would
45
+ # silently swallow the remaining lines (this truncated the first sweep run).
46
+ while IFS= read -r url <&3; do
47
+ [[ -z "$url" || "$url" == \#* ]] && continue
48
+ name="$(basename "$url" .git)"
49
+ dir="$OUT/$name"
50
+ log="$OUT/$name.log"
51
+ echo "━━━ $name"
52
+
53
+ # 1. clone
54
+ if [[ ! -d "$dir" ]]; then
55
+ git clone --depth 1 --recurse-submodules --shallow-submodules "$url" "$dir" >"$log" 2>&1 \
56
+ || { row "$name" clone none - - - - - "git clone failed"; continue; }
57
+ fi
58
+ signals="$(backend_signals "$dir")"
59
+
60
+ # 2. detect workspace/project + scheme (workspace wins; ignore Pods)
61
+ target="$(find "$dir" -maxdepth 3 -name '*.xcworkspace' -not -path '*/Pods/*' -not -path '*/.*' | head -1)"
62
+ flag="-workspace"
63
+ if [[ -z "$target" ]]; then
64
+ target="$(find "$dir" -maxdepth 3 -name '*.xcodeproj' -not -path '*/Pods/*' | head -1)"
65
+ flag="-project"
66
+ fi
67
+ [[ -n "$target" ]] || { row "$name" detect "$signals" - - - - - "no xcworkspace/xcodeproj found"; continue; }
68
+ schemes="$(xcodebuild -list -json $flag "$target" 2>>"$log" | python3 -c 'import sys,json
69
+ try:
70
+ d=json.load(sys.stdin); o=d.get("workspace") or d.get("project") or {}
71
+ print("\n".join(o.get("schemes") or []))
72
+ except Exception: pass')"
73
+ # Prefer an explicitly-iOS scheme (multi-platform repos like NetNewsWire name-match the
74
+ # macOS scheme first, which can't build for an iOS Simulator destination).
75
+ scheme="$(echo "$schemes" | grep -i "ios" | grep -iv "test\|widget\|extension\|notification\|clip" | head -1)"
76
+ [[ -z "$scheme" ]] && scheme="$(echo "$schemes" | grep -ix "$name" | head -1)"
77
+ [[ -z "$scheme" ]] && scheme="$(echo "$schemes" | grep -iv "test\|uitests\|widget\|extension\|notification\|clip\|watch\|mac" | head -1)"
78
+ [[ -z "$scheme" ]] && scheme="$(echo "$schemes" | head -1)"
79
+ [[ -n "$scheme" ]] || { row "$name" detect "$signals" - - - - - "no schemes listed"; continue; }
80
+ echo " scheme: $scheme"
81
+
82
+ # 3. build for simulator (20 min cap; perl alarm — stock macOS has no `timeout`)
83
+ dd="$OUT/dd-$name"
84
+ if ! perl -e 'alarm shift; exec @ARGV' 1200 xcodebuild $flag "$target" -scheme "$scheme" \
85
+ -destination 'generic/platform=iOS Simulator' -derivedDataPath "$dd" \
86
+ CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO \
87
+ build >>"$log" 2>&1; then
88
+ reason="$(grep -m1 -E "error: |xcodebuild: error" "$log" | tail -c 120 || true)"
89
+ row "$name" build "$signals" - - - - - "build failed: ${reason:-see log}"
90
+ continue
91
+ fi
92
+ app="$(find "$dd/Build/Products" -maxdepth 2 -name '*.app' -path '*iphonesimulator*' | head -1)"
93
+ [[ -n "$app" ]] || { row "$name" build "$signals" - - - - - "built but no simulator .app (macOS-only scheme?)"; continue; }
94
+ bundle="$(/usr/libexec/PlistBuddy -c 'Print CFBundleIdentifier' "$app/Info.plist" 2>/dev/null || echo "?")"
95
+
96
+ # 4. install
97
+ xcrun simctl install "$UDID" "$app" >>"$log" 2>&1 \
98
+ || { row "$name" install "$signals" "$bundle" - - - - "simctl install failed"; continue; }
99
+
100
+ # 5. explore + report
101
+ "$ROOT/scripts/quick-capture.sh" explore "$bundle" --actions "$ACTIONS" --timeout 420 >>"$log" 2>&1
102
+ cap="$(ls -td "$ROOT"/captures/*/ 2>/dev/null | head -1)"
103
+ if [[ ! -f "$cap/ocqa-markers.txt" ]]; then
104
+ row "$name" explore "$signals" "$bundle" - - - - "exploration produced no markers"
105
+ continue
106
+ fi
107
+ json="$OUT/$name-report.json"
108
+ node "$ROOT/mcp-server/src/ci-report.js" --markers "$cap/ocqa-markers.txt" --json-out "$json" >>"$log" 2>&1 || true
109
+ read -r screens acted findings verdict <<< "$(python3 -c "
110
+ import json,sys
111
+ try:
112
+ r=json.load(open('$json'))
113
+ print(r.get('screensExplored','?'), r.get('actionsPerformed','?'), r.get('findingCounts',{}).get('total','?'), r.get('verdict','?'))
114
+ except Exception: print('? ? ? ?')")"
115
+ row "$name" explored "$signals" "$bundle" "$screens" "$acted" "$findings" "$verdict" "capture: $(basename "$cap")"
116
+ echo " ✅ $verdict — $screens screens"
117
+ done 3< "$URLS_FILE"
118
+
119
+ echo ""
120
+ echo "═══ scoreboard: $BOARD ═══"
121
+ column -t -s $'\t' "$BOARD"
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env bash
2
+ # AutoTap penetration / coverage eval — the "real-app scoreboard".
3
+ #
4
+ # Unlike validation-matrix.sh (which asserts *expected findings* against DemoApp, i.e. accuracy on a
5
+ # known fixture), this measures how far AutoTap actually PENETRATES an arbitrary app: did it get past
6
+ # launch, did it hit a login wall, how many distinct screens it reached, how much of its budget went
7
+ # to productive exploration vs. stuck/recovery flailing, and what it found. There is no ground truth —
8
+ # this is a diagnostic you point at real apps to see where they stall, so coverage work is measurable.
9
+ #
10
+ # Usage:
11
+ # scripts/coverage-eval.sh [bundleId ...] # defaults to the local corpus apps
12
+ # scripts/coverage-eval.sh com.acme.app # a real app (must be installed on the booted sim)
13
+ # ACTIONS=120 scripts/coverage-eval.sh com.acme.app
14
+ # VISION_ESCALATION=1 ANTHROPIC_API_KEY=... scripts/coverage-eval.sh com.acme.app
15
+ # # opt-in: when the a11y tree goes blank or exploration gets stuck, a sidecar
16
+ # # (vision_escalation_responder.py) answers the harness's OCQA_VISION_QUERY with a
17
+ # # model-chosen next move — same channel the desktop app serves.
18
+ #
19
+ # Prereqs: a booted simulator, the harness built (scripts/deploy-and-build.sh --harness), and each
20
+ # app installed on the sim. Writes a JSON report to captures/coverage-eval-<timestamp>.json.
21
+ #
22
+ # NOTE: auth sessions stored in the keychain (Firebase et al) SURVIVE app reinstall — a "fresh"
23
+ # run may silently resume the previous account. For a genuinely fresh login:
24
+ # xcrun simctl keychain <udid> reset
25
+ set -uo pipefail
26
+
27
+ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
28
+ ACTIONS="${ACTIONS:-70}"
29
+ TIMEOUT="${TIMEOUT:-600}"
30
+ DEFAULT_APPS=(com.autotap.demoapp com.autotap.logindemo com.autotap.wizarddemo com.autotap.shopdemo com.autotap.restaurantdemo)
31
+ APPS=("$@"); [ ${#APPS[@]} -eq 0 ] && APPS=("${DEFAULT_APPS[@]}")
32
+
33
+ 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"), ""))')"
34
+ [ -z "$UDID" ] && { echo "❌ No booted simulator. Boot one first."; exit 2; }
35
+
36
+ XCTR="$(find "$HOME/Library/Developer/Xcode/DerivedData/OCQAHarness-"*/Build/Products -name '*.xctestrun' 2>/dev/null | head -1)"
37
+ [ -z "$XCTR" ] && XCTR="$(find /tmp/autotap-harness-derived/Build/Products -name '*.xctestrun' 2>/dev/null | head -1)"
38
+ [ -z "$XCTR" ] && XCTR="$(find /tmp/harness-build/Build/Products -name '*.xctestrun' 2>/dev/null | head -1)"
39
+ [ -z "$XCTR" ] && { echo "❌ Harness not built. Run: scripts/deploy-and-build.sh --harness"; exit 2; }
40
+
41
+ TS="$(date +%Y%m%d-%H%M%S)"
42
+ REPORT="$ROOT/captures/coverage-eval-$TS.json"
43
+ mkdir -p "$ROOT/captures"
44
+ echo "AutoTap coverage eval — $ACTIONS actions/app, sim $UDID"
45
+ echo "Apps: ${APPS[*]}"
46
+ echo ""
47
+
48
+ RESULTS_JSON="["
49
+ FIRST=1
50
+ for APP in "${APPS[@]}"; do
51
+ if ! xcrun simctl get_app_container "$UDID" "$APP" >/dev/null 2>&1; then
52
+ printf " %-32s ⏭ not installed — skipping\n" "$APP"
53
+ continue
54
+ fi
55
+ CFG="/tmp/ocqa-coverage-$APP.json"
56
+ LOG="/tmp/ocqa-coverage-$APP.log"
57
+ RESPONDER_PID=""
58
+ # Real test credentials (OCQA_TEST_EMAIL / OCQA_TEST_PASSWORD env) let the heuristic login
59
+ # preamble get PAST a real auth wall — the single biggest coverage unlock on gated apps.
60
+ CREDS_LINE=""
61
+ [ -n "${OCQA_TEST_EMAIL:-}" ] && CREDS_LINE=",
62
+ \"OCQA_TEST_EMAIL\": \"$OCQA_TEST_EMAIL\", \"OCQA_TEST_PASSWORD\": \"${OCQA_TEST_PASSWORD:-}\""
63
+ if [ "${VISION_ESCALATION:-0}" = "1" ] && [ -n "${ANTHROPIC_API_KEY:-}" ]; then
64
+ VRESP="/tmp/ocqa-vision-response-eval-$APP.json"
65
+ VDIR="/tmp/ocqa-vision-eval-$APP"; mkdir -p "$VDIR"
66
+ cat > "$CFG" <<JSON
67
+ { "OCQA_BUNDLE_ID": "$APP", "OCQA_MAX_ACTIONS": "$ACTIONS", "OCQA_TIMEOUT_SECONDS": "$TIMEOUT",
68
+ "OCQA_VISION_ESCALATION": "1", "OCQA_VISION_RESPONSE_PATH": "$VRESP",
69
+ "OCQA_VISION_IMAGE_DIR": "$VDIR", "OCQA_VISION_BUDGET": "4", "OCQA_VISION_WAIT_TIMEOUT": "60"$CREDS_LINE }
70
+ JSON
71
+ : > "$LOG"
72
+ python3 "$ROOT/scripts/vision_escalation_responder.py" "$LOG" "$VRESP" > "/tmp/ocqa-vision-responder-$APP.log" 2>&1 &
73
+ RESPONDER_PID=$!
74
+ else
75
+ cat > "$CFG" <<JSON
76
+ { "OCQA_BUNDLE_ID": "$APP", "OCQA_MAX_ACTIONS": "$ACTIONS", "OCQA_TIMEOUT_SECONDS": "$TIMEOUT"$CREDS_LINE }
77
+ JSON
78
+ fi
79
+ TEST_RUNNER_OCQA_CONFIG_PATH="$CFG" xcodebuild test-without-building \
80
+ -xctestrun "$XCTR" -destination "platform=iOS Simulator,id=$UDID" \
81
+ -only-testing:"OCQAHarnessUITests/ExplorerTests/testAutonomousExploration" > "$LOG" 2>&1
82
+ if [ -n "$RESPONDER_PID" ]; then kill "$RESPONDER_PID" 2>/dev/null; wait "$RESPONDER_PID" 2>/dev/null; fi
83
+
84
+ ROW="$(python3 "$ROOT/scripts/coverage_eval_parse.py" "$LOG" "$APP")"
85
+ echo "$ROW" | python3 -c 'import sys,json; d=json.load(sys.stdin); ca=d.get("crash_action") or {}; print(" %-32s screens=%-3s launch=%-3s login_wall=%-3s stuck=%-4s findings=%s%s" % (d["app"], d["screens"], "ok" if d["launch_ok"] else "NO", "YES" if d["login_wall"] else "no", d["stuck_ratio"], d["findings_total"], (" ⚠️ CRASHED on %s %s" % (ca.get("type",""), ca.get("screen","")) if d.get("crashed") else "")))'
86
+ [ $FIRST -eq 0 ] && RESULTS_JSON+=","
87
+ RESULTS_JSON+="$ROW"; FIRST=0
88
+ done
89
+ RESULTS_JSON+="]"
90
+ echo "$RESULTS_JSON" | python3 -m json.tool > "$REPORT" 2>/dev/null || echo "$RESULTS_JSON" > "$REPORT"
91
+ echo ""
92
+ echo "Report: $REPORT"
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env python3
2
+ """Parse a harness exploration log into penetration/coverage diagnostics for the coverage eval.
3
+ Emits one JSON object describing how far AutoTap got into the app. No ground truth — diagnostics only."""
4
+ import sys, re, json
5
+
6
+ log = open(sys.argv[1], encoding="utf-8", errors="replace").read()
7
+ app = sys.argv[2] if len(sys.argv) > 2 else "?"
8
+
9
+ # Reasons / action types that mean "flailing to escape / not making forward progress".
10
+ RECOVERY_REASONS = ("escape", "exhausted", "blind", "stuck", "discover", "drawer",
11
+ "carousel", "probe", "dismiss", "recovery", "back_failed", "trap", "rotation")
12
+ RECOVERY_TYPES = {"back", "swipe_dismiss", "tab_rotation", "forced_escape", "swipe_back", "swipe"}
13
+
14
+ screens, findings, actions = set(), {}, 0
15
+ recovery_actions = 0
16
+ saw_secure_field = False
17
+ completed = False
18
+ test_failed = False
19
+ last_action = None # (type, screen, step) of the most recent action
20
+
21
+ for line in log.splitlines():
22
+ m = re.search(r"OCQA_STATE:(\{.*\})", line)
23
+ if m:
24
+ try:
25
+ d = json.loads(m.group(1))
26
+ s = (d.get("screen") or "").strip()
27
+ if s and s not in ("Unknown", "pending"):
28
+ screens.add(s)
29
+ for inp in d.get("inputs", []) or []:
30
+ if inp.get("secure"):
31
+ saw_secure_field = True
32
+ except Exception:
33
+ pass
34
+ continue
35
+ m = re.search(r"OCQA_ACTION:(\{.*\})", line)
36
+ if m:
37
+ actions += 1
38
+ try:
39
+ d = json.loads(m.group(1))
40
+ reason = str(d.get("reason", "")).lower()
41
+ atype = str(d.get("type", "")).lower()
42
+ last_action = (atype, (d.get("screen") or "").strip(), d.get("step"))
43
+ if atype in RECOVERY_TYPES or any(k in reason for k in RECOVERY_REASONS):
44
+ recovery_actions += 1
45
+ except Exception:
46
+ pass
47
+ continue
48
+ m = re.search(r"OCQA_ISSUE:(\{.*\})", line)
49
+ if m:
50
+ try:
51
+ t = json.loads(m.group(1)).get("type", "unknown")
52
+ findings[t] = findings.get(t, 0) + 1
53
+ except Exception:
54
+ pass
55
+ continue
56
+ if line.startswith("OCQA_COMPLETE"):
57
+ completed = True
58
+ elif "TEST EXECUTE FAILED" in line or re.search(r"Executed 1 test, with [1-9]\d* failure", line):
59
+ test_failed = True
60
+
61
+ # ---- Host-side crash detection ----
62
+ # A crash DURING an action kills the test's connection to the app and aborts the harness before
63
+ # any in-harness detector can run (found on Wikipedia: WMFCaptchaViewController.refreshImage
64
+ # assertionFailure crashes the app on login). The signal is unambiguous: the test FAILED and the
65
+ # harness never emitted OCQA_COMPLETE, with actions performed — i.e. it died mid-run. Attribute the
66
+ # crash to the last action and surface it as a critical finding (the whole point of the tool).
67
+ crashed = test_failed and not completed and actions > 0
68
+ if crashed and "crash" not in findings:
69
+ findings["crash"] = findings.get("crash", 0) + 1
70
+
71
+ n_screens = len(screens)
72
+ launch_ok = n_screens >= 2
73
+ # Login wall: a password field was present but the explorer never got past a handful of screens.
74
+ login_present = saw_secure_field
75
+ login_wall = login_present and n_screens <= 3
76
+ stuck_ratio = round(recovery_actions / actions, 2) if actions else 0.0
77
+ findings_total = sum(findings.values())
78
+
79
+ print(json.dumps({
80
+ "app": app,
81
+ "screens": n_screens,
82
+ "screen_names": sorted(screens),
83
+ "launch_ok": launch_ok,
84
+ "login_present": login_present,
85
+ "login_wall": login_wall,
86
+ "actions": actions,
87
+ "recovery_actions": recovery_actions,
88
+ "stuck_ratio": stuck_ratio, # 0=all productive, 1=all flailing
89
+ "findings_total": findings_total,
90
+ "findings_by_type": findings,
91
+ "completed": completed,
92
+ "crashed": crashed,
93
+ "crash_action": ({"type": last_action[0], "screen": last_action[1], "step": last_action[2]}
94
+ if crashed and last_action else None),
95
+ }))
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Build AutoTap locally on this Mac
5
+ # Usage: ./scripts/deploy-and-build.sh [--harness] [--clean]
6
+ # --harness Also build the OCQAHarness XCUITest bundle
7
+ # --clean Clean derived data before building
8
+
9
+ PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
10
+ BUILD_HARNESS=0
11
+ DO_CLEAN=0
12
+
13
+ for arg in "$@"; do
14
+ case "$arg" in
15
+ --harness) BUILD_HARNESS=1 ;;
16
+ --clean) DO_CLEAN=1 ;;
17
+ esac
18
+ done
19
+
20
+ echo "=== AutoTap Local Build ==="
21
+ echo "Project: $PROJECT_ROOT"
22
+ echo ""
23
+
24
+ if [[ $DO_CLEAN -eq 1 ]]; then
25
+ echo "Cleaning derived data..."
26
+ rm -rf "$PROJECT_ROOT/build/DerivedData"
27
+ rm -rf /tmp/autotap-harness-derived
28
+ echo ""
29
+ fi
30
+
31
+ # Step 1: Generate Xcode project if needed
32
+ if [[ ! -f "$PROJECT_ROOT/AutoTap.xcodeproj/project.pbxproj" ]] || \
33
+ [[ "$PROJECT_ROOT/generate-xcodeproj.rb" -nt "$PROJECT_ROOT/AutoTap.xcodeproj/project.pbxproj" ]]; then
34
+ echo "Generating Xcode project..."
35
+ cd "$PROJECT_ROOT" && ruby generate-xcodeproj.rb
36
+ echo ""
37
+ fi
38
+
39
+ # Step 2: Build main app
40
+ echo "Building AutoTap..."
41
+ xcodebuild \
42
+ -project "$PROJECT_ROOT/AutoTap.xcodeproj" \
43
+ -scheme AutoTap \
44
+ -configuration Debug \
45
+ -derivedDataPath "$PROJECT_ROOT/build/DerivedData" \
46
+ CODE_SIGN_IDENTITY=- \
47
+ CODE_SIGNING_REQUIRED=NO \
48
+ CODE_SIGNING_ALLOWED=NO \
49
+ 2>&1 | tail -20
50
+
51
+ echo ""
52
+
53
+ # Step 3: Build harness if requested
54
+ if [[ $BUILD_HARNESS -eq 1 ]]; then
55
+ echo "Building OCQAHarness..."
56
+
57
+ # Generate harness xcodeproj if needed
58
+ if [[ ! -f "$PROJECT_ROOT/Harness/OCQAHarness.xcodeproj/project.pbxproj" ]] || \
59
+ [[ "$PROJECT_ROOT/Harness/generate-harness-xcodeproj.rb" -nt "$PROJECT_ROOT/Harness/OCQAHarness.xcodeproj/project.pbxproj" ]]; then
60
+ cd "$PROJECT_ROOT/Harness" && ruby generate-harness-xcodeproj.rb
61
+ fi
62
+
63
+ # Prefer a booted simulator by UDID to avoid ambiguity when the same device
64
+ # name exists across multiple OS runtimes (e.g. "iPhone 16 Pro" on iOS 18 + 26).
65
+ SIM_UDID=$(xcrun simctl list devices booted -j 2>/dev/null | \
66
+ 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"), ""))' 2>/dev/null || true)
67
+
68
+ if [[ -n "$SIM_UDID" ]]; then
69
+ SIM_DEST="platform=iOS Simulator,id=$SIM_UDID"
70
+ echo " Target simulator: $SIM_UDID (booted)"
71
+ else
72
+ SIM_NAME=$(xcrun simctl list devices available | grep -E 'iPhone.*(Booted|Shutdown)' | head -1 | sed 's/ (.*//' | xargs)
73
+ SIM_NAME=${SIM_NAME:-"iPhone 16 Pro"}
74
+ SIM_DEST="platform=iOS Simulator,name=$SIM_NAME"
75
+ echo " Target simulator: $SIM_NAME"
76
+ fi
77
+
78
+ xcodebuild build-for-testing \
79
+ -project "$PROJECT_ROOT/Harness/OCQAHarness.xcodeproj" \
80
+ -scheme OCQAHarnessUITests \
81
+ -destination "$SIM_DEST" \
82
+ -derivedDataPath "/tmp/autotap-harness-derived" \
83
+ 2>&1 | tail -5
84
+
85
+ echo ""
86
+ echo "Harness built. Quick capture:"
87
+ echo " ./scripts/quick-capture.sh explore com.autotap.demoapp --actions 25"
88
+ echo " ./scripts/quick-capture.sh screenshot"
89
+ echo " ./scripts/quick-capture.sh tree com.autotap.demoapp"
90
+ fi
91
+
92
+ echo ""
93
+ APP_PATH="$PROJECT_ROOT/build/DerivedData/Build/Products/Debug/AutoTap.app"
94
+ if [[ -d "$APP_PATH" ]]; then
95
+ echo "Build artifact: $APP_PATH"
96
+ echo "Run: open $APP_PATH"
97
+ else
98
+ echo "(check build output above for errors)"
99
+ fi
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ // Resolve a Flow's target without executing it. Legacy target-less Flows are
3
+ // iOS because that was Tapp's only driver when their format was introduced.
4
+ import path from "node:path";
5
+ import { inferFlowPlatform, loadFlowFile } from "../mcp-server/src/flow-runtime.js";
6
+
7
+ const input = process.argv[2] ? path.resolve(process.argv[2]) : "";
8
+ if (!input) {
9
+ console.error("usage: flow-platform.js <flow.yml|flow.json>");
10
+ process.exit(2);
11
+ }
12
+
13
+ try {
14
+ process.stdout.write(inferFlowPlatform(loadFlowFile(input)));
15
+ } catch (error) {
16
+ console.error(error.message || String(error));
17
+ process.exit(2);
18
+ }
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env python3
2
+ """Host-side judge for a Flow's `assert_ai` steps (see docs/flows-architecture.md).
3
+
4
+ Tails the harness log for OCQA_FLOW_AI_QUERY markers, shows the screenshot + the claim to a vision
5
+ model, and writes {index, pass, reason} to the response file the harness polls. The model call stays
6
+ HOST-side (the harness only screenshots + asks). On any error it writes pass=false with the error so
7
+ a broken judge can't silently green a flow.
8
+
9
+ Usage: ANTHROPIC_API_KEY=... python3 flow_ai_judge.py <harness.log> <response_path>
10
+ """
11
+ import base64
12
+ import json
13
+ import os
14
+ import ssl
15
+ import sys
16
+ import time
17
+ import urllib.request
18
+
19
+
20
+ def _ctx():
21
+ try:
22
+ import certifi
23
+ return ssl.create_default_context(cafile=certifi.where())
24
+ except Exception:
25
+ return ssl.create_default_context()
26
+
27
+
28
+ SSL_CTX = _ctx()
29
+ MODEL = os.environ.get("AUTOTAP_VISION_MODEL", "claude-haiku-4-5-20251001")
30
+ SYSTEM = (
31
+ "You are a QA test oracle. You are shown one iOS app screenshot and a CLAIM the test asserts about "
32
+ "it. Decide if the claim is TRUE of what is actually visible. Be strict and literal — only pass if "
33
+ "the screenshot clearly supports the claim. Respond with ONLY JSON: "
34
+ '{"pass": true|false, "reason": "<one concise sentence>"}.'
35
+ )
36
+
37
+
38
+ def judge(image_path, claim, key):
39
+ with open(image_path, "rb") as f:
40
+ b64 = base64.b64encode(f.read()).decode()
41
+ body = {
42
+ "model": MODEL, "max_tokens": 200, "system": SYSTEM,
43
+ "messages": [{"role": "user", "content": [
44
+ {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": b64}},
45
+ {"type": "text", "text": f"CLAIM: {claim}\nIs this claim true of the screenshot? Respond as the JSON object."},
46
+ ]}],
47
+ }
48
+ req = urllib.request.Request("https://api.anthropic.com/v1/messages", data=json.dumps(body).encode(),
49
+ method="POST", headers={"x-api-key": key, "anthropic-version": "2023-06-01",
50
+ "content-type": "application/json"})
51
+ with urllib.request.urlopen(req, timeout=45, context=SSL_CTX) as resp:
52
+ data = json.load(resp)
53
+ text = "\n".join(b.get("text", "") for b in data.get("content", []) if b.get("type") == "text")
54
+ s, e = text.find("{"), text.rfind("}")
55
+ obj = json.loads(text[s:e + 1]) if s >= 0 and e > s else {}
56
+ return bool(obj.get("pass", False)), str(obj.get("reason", ""))[:200]
57
+
58
+
59
+ def main():
60
+ if len(sys.argv) < 3:
61
+ print("usage: flow_ai_judge.py <harness.log> <response_path>", file=sys.stderr)
62
+ return 2
63
+ log_path, response_path = sys.argv[1], sys.argv[2]
64
+ key = os.environ.get("ANTHROPIC_API_KEY", "")
65
+ if not key:
66
+ return 2
67
+ handled = set()
68
+ pos = 0
69
+ while True:
70
+ time.sleep(0.4)
71
+ try:
72
+ with open(log_path, errors="ignore") as f:
73
+ f.seek(pos)
74
+ chunk = f.read()
75
+ pos = f.tell()
76
+ except FileNotFoundError:
77
+ continue
78
+ for line in chunk.splitlines():
79
+ i = line.find("OCQA_FLOW_AI_QUERY:{")
80
+ if i < 0:
81
+ continue
82
+ try:
83
+ q = json.loads(line[i + len("OCQA_FLOW_AI_QUERY:"):])
84
+ except Exception:
85
+ continue
86
+ idx = q.get("index")
87
+ if idx in handled:
88
+ continue
89
+ handled.add(idx)
90
+ try:
91
+ ok, reason = judge(q.get("image", ""), q.get("claim", ""), key)
92
+ except Exception as ex:
93
+ ok, reason = False, f"judge error: {ex}"
94
+ tmp = response_path + ".tmp"
95
+ with open(tmp, "w") as f:
96
+ json.dump({"index": idx, "pass": ok, "reason": reason}, f)
97
+ os.replace(tmp, response_path)
98
+ print(f"judge: #{idx} → {'PASS' if ok else 'FAIL'} — {reason}", flush=True)
99
+
100
+
101
+ if __name__ == "__main__":
102
+ sys.exit(main())