@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.
- package/AGENTS.md +123 -0
- package/Harness/OCQAHarness/AppDelegate.swift +21 -0
- package/Harness/OCQAHarness/Info.plist +26 -0
- package/Harness/OCQAHarness.xcodeproj/project.pbxproj +199 -0
- package/Harness/OCQAHarness.xcodeproj/xcshareddata/xcschemes/OCQAHarnessUITests.xcscheme +22 -0
- package/Harness/OCQAHarnessUITests/ExplorerTests.swift +4526 -0
- package/Harness/OCQAHarnessUITests/Info.plist +22 -0
- package/Harness/generate-harness-xcodeproj.rb +254 -0
- package/LICENSE +21 -0
- package/README.md +374 -0
- package/bin/tapp.js +1382 -0
- package/browser/app.css +227 -0
- package/browser/app.js +675 -0
- package/browser/index.html +195 -0
- package/browser/product-contract.js +25 -0
- package/browser/view-model.js +16 -0
- package/docs/BROWSER-PRODUCT.md +72 -0
- package/docs/PRODUCT-ENGINE.md +102 -0
- package/docs/application-model.md +276 -0
- package/docs/scenarios.md +95 -0
- package/mcp-server/src/android-driver.js +287 -0
- package/mcp-server/src/android-explorer.js +197 -0
- package/mcp-server/src/android-flow.js +89 -0
- package/mcp-server/src/application-model.js +1597 -0
- package/mcp-server/src/browser-product.js +659 -0
- package/mcp-server/src/browser-workspaces.js +234 -0
- package/mcp-server/src/ci-report.js +557 -0
- package/mcp-server/src/ci-setup.js +359 -0
- package/mcp-server/src/contract-authoring.js +10 -0
- package/mcp-server/src/enrich.js +57 -0
- package/mcp-server/src/flow-runtime.js +127 -0
- package/mcp-server/src/html-report.js +124 -0
- package/mcp-server/src/index.js +3775 -0
- package/mcp-server/src/maintenance-proposal.js +178 -0
- package/mcp-server/src/managed-operation.js +61 -0
- package/mcp-server/src/pr-selection.js +841 -0
- package/mcp-server/src/product-execution.js +155 -0
- package/mcp-server/src/product-operations.js +526 -0
- package/mcp-server/src/project-config.js +101 -0
- package/mcp-server/src/release-contract.d.ts +81 -0
- package/mcp-server/src/release-contract.js +226 -0
- package/mcp-server/src/report.js +363 -0
- package/mcp-server/src/scenario-runtime.js +139 -0
- package/mcp-server/src/static-server.js +44 -0
- package/mcp-server/src/task-runtime.js +266 -0
- package/mcp-server/src/ui-map.js +661 -0
- package/mcp-server/src/web-explorer.js +493 -0
- package/mcp-server/src/web-flow.js +238 -0
- package/package.json +82 -0
- package/scripts/android-corpus-e2e.sh +30 -0
- package/scripts/ci-gate.sh +323 -0
- package/scripts/cleanup-xcode.sh +157 -0
- package/scripts/compile-contract.js +27 -0
- package/scripts/compile-flow.js +18 -0
- package/scripts/corpus-apps.txt +9 -0
- package/scripts/corpus-sweep.sh +121 -0
- package/scripts/coverage-eval.sh +92 -0
- package/scripts/coverage_eval_parse.py +95 -0
- package/scripts/deploy-and-build.sh +99 -0
- package/scripts/flow-platform.js +18 -0
- package/scripts/flow_ai_judge.py +102 -0
- package/scripts/flow_lib.py +154 -0
- package/scripts/mutation-recall-desktop.sh +186 -0
- package/scripts/mutation-recall.sh +121 -0
- package/scripts/mutation_lib.py +128 -0
- package/scripts/mutation_operators.py +144 -0
- package/scripts/platform-gate.js +186 -0
- package/scripts/pr-plan.js +68 -0
- package/scripts/quick-capture.sh +419 -0
- package/scripts/run-android-flow.js +27 -0
- package/scripts/run-flow.sh +90 -0
- package/scripts/run-web-flow.js +28 -0
- package/scripts/run-web-scenario.js +23 -0
- package/scripts/validation-matrix.sh +146 -0
- package/scripts/vision-fp-eval.sh +206 -0
- package/scripts/vision_escalation_responder.py +147 -0
- 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())
|