@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,419 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # quick-capture.sh — Fast local screenshot/video capture from iOS simulator
5
+ #
6
+ # Runs locally on the Mac against the booted simulator.
7
+ #
8
+ # Usage:
9
+ # quick-capture.sh screenshot [--app <bundleId>] # Screenshot current sim state
10
+ # quick-capture.sh record [--duration <secs>] # Record sim video
11
+ # quick-capture.sh explore <bundleId> [--actions N] # Run autonomous exploration + capture
12
+ # [--timeout <secs>] # Hard timeout for explore command
13
+ # quick-capture.sh tree <bundleId> # Dump accessibility tree
14
+ #
15
+ # All output goes to ~/repos/AutoTap/captures/<timestamp>/
16
+
17
+ PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
18
+ # TAPP_CAPTURE_DIR selects one exact run directory (used by CI so a failed run can never
19
+ # accidentally reuse stale markers). AUTOTAP_HOME redirects all writable output otherwise —
20
+ # set by the `autotap` CLI when running as an installed npm package, where the package dir
21
+ # must stay read-only. Unset (repo dev flow), everything lands in the repo as before.
22
+ if [[ -n "${TAPP_CAPTURE_DIR:-}" ]]; then
23
+ CAPTURE_DIR="$TAPP_CAPTURE_DIR"
24
+ elif [[ -n "${AUTOTAP_HOME:-}" ]]; then
25
+ CAPTURE_DIR="$AUTOTAP_HOME/captures/$(date +%Y%m%d-%H%M%S)"
26
+ else
27
+ CAPTURE_DIR="$PROJECT_ROOT/captures/$(date +%Y%m%d-%H%M%S)"
28
+ fi
29
+ if [[ -n "${AUTOTAP_HOME:-}" ]]; then
30
+ HARNESS_DERIVED="$AUTOTAP_HOME/harness-derived"
31
+ else
32
+ HARNESS_DERIVED="/tmp/autotap-harness-derived"
33
+ fi
34
+ HARNESS_PROJECT="$PROJECT_ROOT/Harness/OCQAHarness.xcodeproj"
35
+ DEFAULT_BUNDLE="com.autotap.demoapp"
36
+
37
+ get_booted_sim() {
38
+ xcrun simctl list devices booted -j 2>/dev/null \
39
+ | python3 -c "
40
+ import sys, json
41
+ d = json.load(sys.stdin)
42
+ for runtime, devices in d.get('devices', {}).items():
43
+ for dev in devices:
44
+ if dev.get('state') == 'Booted':
45
+ print(dev['udid'])
46
+ sys.exit(0)
47
+ " 2>/dev/null
48
+ }
49
+
50
+ get_sim_name() {
51
+ xcrun simctl list devices booted -j 2>/dev/null \
52
+ | python3 -c "
53
+ import sys, json
54
+ d = json.load(sys.stdin)
55
+ for runtime, devices in d.get('devices', {}).items():
56
+ for dev in devices:
57
+ if dev.get('state') == 'Booted':
58
+ print(dev['name'])
59
+ sys.exit(0)
60
+ " 2>/dev/null
61
+ }
62
+
63
+ cleanup_stale_recorders() {
64
+ local udid="$1"
65
+ pkill -f "simctl io $udid recordVideo" 2>/dev/null || true
66
+ sleep 1
67
+ }
68
+
69
+ ensure_harness_built() {
70
+ local sim_name="${1:-iPhone 16 Pro}"
71
+ local udid="${UDID:-}"
72
+ local marker="$HARNESS_DERIVED/.last-sim-udid"
73
+ local last_udid="" last_fp=""
74
+ if [[ -f "$marker" ]]; then
75
+ last_udid=$(sed -n 1p "$marker" 2>/dev/null || true)
76
+ last_fp=$(sed -n 2p "$marker" 2>/dev/null || true)
77
+ fi
78
+ # Fingerprint the harness source so a package update actually reaches users — without this,
79
+ # a warm cache serves the OLD harness forever (npm normalizes mtimes, so hash the content).
80
+ local src_file="$(dirname "$HARNESS_PROJECT")/OCQAHarnessUITests/ExplorerTests.swift"
81
+ local src_fp=$(shasum "$src_file" 2>/dev/null | cut -c1-12)
82
+ local xctestrun=$(find "$HARNESS_DERIVED/Build/Products" -name "*.xctestrun" 2>/dev/null | head -1)
83
+
84
+ # Reuse the cached harness ONLY if it was built for the currently-booted simulator AND from
85
+ # the same harness sources. A harness built for a different sim can fail to launch the
86
+ # interactive session; a stale-source harness silently lacks shipped fixes.
87
+ if [[ -n "$xctestrun" && ( -z "$udid" || "$udid" == "$last_udid" ) && "$src_fp" == "$last_fp" ]]; then
88
+ echo "Harness already built for this sim: $xctestrun" >&2
89
+ return 0
90
+ fi
91
+ if [[ -n "$xctestrun" && "$src_fp" != "$last_fp" ]]; then
92
+ echo "Harness sources changed — rebuilding for $sim_name..." >&2
93
+ elif [[ -n "$xctestrun" ]]; then
94
+ echo "Booted simulator changed ($last_udid -> $udid) — rebuilding harness for $sim_name..." >&2
95
+ else
96
+ echo "Building harness for $sim_name..." >&2
97
+ fi
98
+ xcodebuild build-for-testing \
99
+ -project "$HARNESS_PROJECT" \
100
+ -scheme OCQAHarnessUITests \
101
+ -destination "platform=iOS Simulator,id=$UDID" \
102
+ -derivedDataPath "$HARNESS_DERIVED" \
103
+ 2>&1 | tail -5 >&2
104
+ # Record which sim + harness sources this build came from so the next run detects both
105
+ # a simulator switch and a package update.
106
+ printf '%s\n%s\n' "$udid" "$src_fp" > "$marker"
107
+ }
108
+
109
+ run_harness_test() {
110
+ local test_method="$1"
111
+ local sim_name="${2:-iPhone 16 Pro}"
112
+ local bundle_id="${3:-$DEFAULT_BUNDLE}"
113
+ local max_actions="${4:-25}"
114
+ local timeout_secs="${5:-300}"
115
+
116
+ # Optional deterministic input overrides — a JSON object string passed via
117
+ # OCQA_INPUT_OVERRIDES_JSON, e.g. '{"id:email_field":"user@example.com"}'.
118
+ # Keys are 'id:<identifier>' / 'label:<label>' or 'screen:<title>|id:<identifier>'.
119
+ local overrides_line=""
120
+ # Interactive mid-run input: when the host can prompt a human (desktop app, VS Code
121
+ # extension), the harness pauses at input screens and polls the response path.
122
+ local interactive_line=""
123
+ if [[ "${OCQA_INTERACTIVE_INPUT:-}" == "1" && -n "${OCQA_INPUT_RESPONSE_PATH:-}" ]]; then
124
+ interactive_line=",
125
+ \"OCQA_INTERACTIVE_INPUT\": \"1\",
126
+ \"OCQA_INPUT_RESPONSE_PATH\": \"${OCQA_INPUT_RESPONSE_PATH}\""
127
+ fi
128
+ if [[ -n "${OCQA_INPUT_OVERRIDES_JSON:-}" ]]; then
129
+ overrides_line=",
130
+ \"OCQA_INPUT_OVERRIDES\": ${OCQA_INPUT_OVERRIDES_JSON}"
131
+ fi
132
+
133
+ # Optional app launch arguments / environment (e.g. backend override, login bypass) so the
134
+ # harness can drive real apps that need them. JSON array / object strings respectively.
135
+ local launch_args_line=""
136
+ if [[ -n "${OCQA_APP_LAUNCH_ARGS_JSON:-}" ]]; then
137
+ launch_args_line=",
138
+ \"OCQA_APP_LAUNCH_ARGS\": ${OCQA_APP_LAUNCH_ARGS_JSON}"
139
+ fi
140
+ local launch_env_line=""
141
+ if [[ -n "${OCQA_APP_LAUNCH_ENV_JSON:-}" ]]; then
142
+ launch_env_line=",
143
+ \"OCQA_APP_LAUNCH_ENV\": ${OCQA_APP_LAUNCH_ENV_JSON}"
144
+ fi
145
+
146
+ # Optional explicit login replay — a JSON array of {action,target,value?,timeoutMs?} steps run
147
+ # before exploration, for custom login UIs the heuristic preamble can't parse.
148
+ local login_steps_line=""
149
+ if [[ -n "${OCQA_LOGIN_STEPS_JSON:-}" ]]; then
150
+ login_steps_line=",
151
+ \"OCQA_LOGIN_STEPS\": ${OCQA_LOGIN_STEPS_JSON}"
152
+ fi
153
+
154
+ # One bounded, sanitized PR target compiled from the persistent UI Map. It is
155
+ # inserted as JSON data (never evaluated as shell source) and consumed by the
156
+ # native harness before ordinary breadth exploration.
157
+ local pr_target_line=""
158
+ if [[ -n "${OCQA_PR_TARGET_JSON:-}" ]]; then
159
+ TAPP_PR_TARGET_JSON="$OCQA_PR_TARGET_JSON" python3 -c 'import json,os,sys; sys.exit(0 if isinstance(json.loads(os.environ["TAPP_PR_TARGET_JSON"]), dict) else 1)' \
160
+ || { echo "ERROR: OCQA_PR_TARGET_JSON must be a JSON object" >&2; return 2; }
161
+ pr_target_line=",
162
+ \"OCQA_PR_TARGET\": ${OCQA_PR_TARGET_JSON}"
163
+ fi
164
+
165
+ cat > /tmp/ocqa-run-config.json << CONF
166
+ {
167
+ "OCQA_BUNDLE_ID": "$bundle_id",
168
+ "OCQA_MAX_ACTIONS": "$max_actions",
169
+ "OCQA_TIMEOUT_SECONDS": "$timeout_secs",
170
+ "OCQA_TEST_EMAIL": "${OCQA_TEST_EMAIL:-qa@example.com}",
171
+ "OCQA_TEST_PASSWORD": "${OCQA_TEST_PASSWORD:-Autotap123!}"$interactive_line$overrides_line$launch_args_line$launch_env_line$login_steps_line$pr_target_line
172
+ }
173
+ CONF
174
+
175
+ local xctestrun=$(find "$HARNESS_DERIVED/Build/Products" -name "*.xctestrun" 2>/dev/null | head -1)
176
+ if [[ -z "$xctestrun" ]]; then
177
+ echo "ERROR: No xctestrun found. Run: ./scripts/deploy-and-build.sh --harness" >&2
178
+ return 1
179
+ fi
180
+
181
+ # Forward the config path explicitly so the harness reads exactly this file
182
+ # (authoritative over any stale /tmp file), matching the app's delivery path.
183
+ export TEST_RUNNER_OCQA_CONFIG_PATH=/tmp/ocqa-run-config.json
184
+
185
+ xcodebuild test-without-building \
186
+ -xctestrun "$xctestrun" \
187
+ -destination "platform=iOS Simulator,id=$UDID" \
188
+ -only-testing:"OCQAHarnessUITests/ExplorerTests/$test_method" \
189
+ -resultBundlePath "$CAPTURE_DIR/result.xcresult" \
190
+ 2>&1
191
+ }
192
+
193
+ # --- Main ---
194
+ MODE="${1:-screenshot}"
195
+ shift || true
196
+
197
+ APP_BUNDLE="$DEFAULT_BUNDLE"
198
+ DURATION=30
199
+ MAX_ACTIONS=25
200
+ EXPLORE_TIMEOUT=600
201
+
202
+ while [[ $# -gt 0 ]]; do
203
+ case "$1" in
204
+ --app) APP_BUNDLE="$2"; shift 2 ;;
205
+ --duration) DURATION="$2"; shift 2 ;;
206
+ --actions) MAX_ACTIONS="$2"; shift 2 ;;
207
+ --timeout) EXPLORE_TIMEOUT="$2"; shift 2 ;;
208
+ *) APP_BUNDLE="$1"; shift ;;
209
+ esac
210
+ done
211
+
212
+ mkdir -p "$CAPTURE_DIR"
213
+ UDID=$(get_booted_sim)
214
+ SIM_NAME=$(get_sim_name)
215
+
216
+ if [[ -z "$UDID" ]]; then
217
+ echo "ERROR: No booted simulator found. Boot one first:"
218
+ echo " xcrun simctl boot 'iPhone 16 Pro'"
219
+ exit 1
220
+ fi
221
+ echo "Simulator: $SIM_NAME ($UDID)"
222
+ echo "Output: $CAPTURE_DIR"
223
+ echo ""
224
+
225
+ case "$MODE" in
226
+ screenshot)
227
+ echo "Taking screenshot..."
228
+ xcrun simctl io "$UDID" screenshot "$CAPTURE_DIR/screenshot.png"
229
+ echo "Saved: $CAPTURE_DIR/screenshot.png"
230
+ ls -lh "$CAPTURE_DIR/screenshot.png"
231
+ ;;
232
+
233
+ record)
234
+ echo "Recording for ${DURATION}s... (Ctrl+C to stop early)"
235
+ cleanup_stale_recorders "$UDID"
236
+ RECORD_PID=""
237
+ if xcrun simctl io "$UDID" recordVideo --codec=h264 "$CAPTURE_DIR/recording.mov" & then
238
+ RECORD_PID=$!
239
+ fi
240
+ if ! kill -0 "$RECORD_PID" 2>/dev/null; then
241
+ echo "ERROR: Unable to start simulator recording (resource busy)."
242
+ exit 1
243
+ fi
244
+ sleep "$DURATION"
245
+ kill -INT $RECORD_PID 2>/dev/null || true
246
+ wait $RECORD_PID 2>/dev/null || true
247
+ sleep 1
248
+
249
+ if [[ -f "$CAPTURE_DIR/recording.mov" ]]; then
250
+ echo "Converting to WebM..."
251
+ ffmpeg -hide_banner -loglevel error -y \
252
+ -i "$CAPTURE_DIR/recording.mov" \
253
+ -c:v libvpx-vp9 -crf 36 -b:v 0 -row-mt 1 -an \
254
+ "$CAPTURE_DIR/recording.webm"
255
+ echo "Saved: $CAPTURE_DIR/recording.webm ($(du -h "$CAPTURE_DIR/recording.webm" | cut -f1))"
256
+ echo "Raw: $CAPTURE_DIR/recording.mov ($(du -h "$CAPTURE_DIR/recording.mov" | cut -f1))"
257
+ fi
258
+ ;;
259
+
260
+ explore)
261
+ echo "Running autonomous exploration ($MAX_ACTIONS actions, timeout: ${EXPLORE_TIMEOUT}s, app: $APP_BUNDLE)..."
262
+ ensure_harness_built "$SIM_NAME"
263
+
264
+ # Start video recording in background
265
+ cleanup_stale_recorders "$UDID"
266
+ RECORD_PID=""
267
+ if xcrun simctl io "$UDID" recordVideo --codec=h264 "$CAPTURE_DIR/exploration.mov" & then
268
+ RECORD_PID=$!
269
+ fi
270
+ sleep 0.5
271
+ if ! kill -0 "$RECORD_PID" 2>/dev/null; then
272
+ echo "WARNING: Could not start simulator video recording. Continuing without video." >&2
273
+ RECORD_PID=""
274
+ fi
275
+
276
+ # Run exploration with watchdog timeout to avoid silent hangs.
277
+ local_output_file="$CAPTURE_DIR/harness-output.txt"
278
+ run_harness_test "testAutonomousExploration" "$SIM_NAME" "$APP_BUNDLE" "$MAX_ACTIONS" "$EXPLORE_TIMEOUT" > "$local_output_file" 2>&1 &
279
+ HARNESS_PID=$!
280
+
281
+ START_TS=$(date +%s)
282
+ TIMED_OUT=0
283
+ while kill -0 "$HARNESS_PID" 2>/dev/null; do
284
+ NOW_TS=$(date +%s)
285
+ ELAPSED=$((NOW_TS - START_TS))
286
+ if [[ "$ELAPSED" -ge "$EXPLORE_TIMEOUT" ]]; then
287
+ TIMED_OUT=1
288
+ kill -TERM "$HARNESS_PID" 2>/dev/null || true
289
+ sleep 2
290
+ kill -KILL "$HARNESS_PID" 2>/dev/null || true
291
+ break
292
+ fi
293
+ sleep 2
294
+ done
295
+
296
+ wait "$HARNESS_PID" 2>/dev/null || true
297
+ OUTPUT="$(cat "$local_output_file" 2>/dev/null || true)"
298
+
299
+ # Stop recording
300
+ if [[ -n "$RECORD_PID" ]]; then
301
+ kill -INT "$RECORD_PID" 2>/dev/null || true
302
+ wait "$RECORD_PID" 2>/dev/null || true
303
+ fi
304
+ sleep 1
305
+
306
+ # Parse OCQA_ markers
307
+ echo "$OUTPUT" | grep "^OCQA_" > "$CAPTURE_DIR/ocqa-markers.txt" || true
308
+ if [[ "$TIMED_OUT" -eq 1 ]]; then
309
+ echo "OCQA_ISSUE:{\"type\":\"explore_timeout\",\"severity\":\"high\",\"title\":\"Exploration timed out\",\"timeoutSeconds\":$EXPLORE_TIMEOUT}" >> "$CAPTURE_DIR/ocqa-markers.txt"
310
+ echo "OCQA_COMPLETE:{\"actions\":0,\"states\":0,\"issues\":1,\"screens\":\"\",\"timedOut\":true,\"timeoutSeconds\":$EXPLORE_TIMEOUT}" >> "$CAPTURE_DIR/ocqa-markers.txt"
311
+ echo "WARNING: Exploration hit timeout after ${EXPLORE_TIMEOUT}s" >&2
312
+ fi
313
+ echo "$OUTPUT" > "$CAPTURE_DIR/full-output.txt"
314
+
315
+ COMPLETE_LINE=$(echo "$OUTPUT" | grep "OCQA_COMPLETE" | tail -1)
316
+ if [[ -n "$COMPLETE_LINE" ]]; then
317
+ echo ""
318
+ echo "Exploration complete: $COMPLETE_LINE"
319
+ fi
320
+
321
+ # Extract screenshots from xcresult
322
+ if [[ -d "$CAPTURE_DIR/result.xcresult" ]]; then
323
+ mkdir -p "$CAPTURE_DIR/screenshots"
324
+ xcrun xcresulttool export attachments \
325
+ --path "$CAPTURE_DIR/result.xcresult" \
326
+ --output-path "$CAPTURE_DIR/screenshots" 2>/dev/null || true
327
+ SC_COUNT=$(find "$CAPTURE_DIR/screenshots" -name "*.png" 2>/dev/null | wc -l | xargs)
328
+ echo "Extracted $SC_COUNT screenshots from xcresult"
329
+ fi
330
+
331
+ # Convert video
332
+ if [[ -f "$CAPTURE_DIR/exploration.mov" && -x "$(command -v ffmpeg 2>/dev/null || true)" ]]; then
333
+ echo "Converting exploration video to WebM..."
334
+ ffmpeg -hide_banner -loglevel error -y \
335
+ -i "$CAPTURE_DIR/exploration.mov" \
336
+ -c:v libvpx-vp9 -crf 36 -b:v 0 -row-mt 1 -an \
337
+ "$CAPTURE_DIR/exploration.webm"
338
+ echo "Video: $CAPTURE_DIR/exploration.webm ($(du -h "$CAPTURE_DIR/exploration.webm" | cut -f1))"
339
+ elif [[ -f "$CAPTURE_DIR/exploration.mov" ]]; then
340
+ echo "Video: $CAPTURE_DIR/exploration.mov (ffmpeg unavailable; keeping the original recording)"
341
+ fi
342
+
343
+ echo ""
344
+ echo "All artifacts in: $CAPTURE_DIR/"
345
+ ls -lh "$CAPTURE_DIR/"
346
+ ;;
347
+
348
+ tree)
349
+ echo "Dumping accessibility tree for $APP_BUNDLE..."
350
+ ensure_harness_built "$SIM_NAME"
351
+ OUTPUT=$(run_harness_test "testDumpUITree" "$SIM_NAME" "$APP_BUNDLE" "1" "60")
352
+
353
+ echo "$OUTPUT" | sed -n '/OCQA_UITREE_START/,/OCQA_UITREE_END/p' | grep -v "OCQA_UITREE" > "$CAPTURE_DIR/uitree.json" || true
354
+ echo "$OUTPUT" > "$CAPTURE_DIR/full-output.txt"
355
+
356
+ if [[ -s "$CAPTURE_DIR/uitree.json" ]]; then
357
+ ELEMENTS=$(python3 -c "import json; d=json.load(open('$CAPTURE_DIR/uitree.json')); print(len(d.get('elements',[])))" 2>/dev/null || echo "?")
358
+ echo "Tree saved: $CAPTURE_DIR/uitree.json ($ELEMENTS elements)"
359
+ else
360
+ echo "WARNING: No tree JSON extracted. Check $CAPTURE_DIR/full-output.txt"
361
+ fi
362
+ ;;
363
+
364
+ session)
365
+ # Persistent interactive session: launch the app once, then service single commands written to
366
+ # OCQA_SESSION_CMD_PATH (the MCP drives this), emitting the fresh accessibility tree after each.
367
+ # Streams the harness stdout LIVE via exec so the client sees trees in real time.
368
+ ensure_harness_built "$SIM_NAME" >&2
369
+ SESS_CMD="${OCQA_SESSION_CMD_PATH:-/tmp/ocqa-session-cmd.json}"
370
+ SESS_RES="${OCQA_SESSION_RESULT_PATH:-/tmp/ocqa-session-result.json}"
371
+ SESS_TIMEOUT="${OCQA_SESSION_TIMEOUT:-1800}"
372
+ sess_args_line=""
373
+ [[ -n "${OCQA_APP_LAUNCH_ARGS_JSON:-}" ]] && sess_args_line=",
374
+ \"OCQA_APP_LAUNCH_ARGS\": ${OCQA_APP_LAUNCH_ARGS_JSON}"
375
+ sess_env_line=""
376
+ [[ -n "${OCQA_APP_LAUNCH_ENV_JSON:-}" ]] && sess_env_line=",
377
+ \"OCQA_APP_LAUNCH_ENV\": ${OCQA_APP_LAUNCH_ENV_JSON}"
378
+ cat > /tmp/ocqa-run-config.json << CONF
379
+ {
380
+ "OCQA_BUNDLE_ID": "$APP_BUNDLE",
381
+ "OCQA_SESSION_CMD_PATH": "$SESS_CMD",
382
+ "OCQA_SESSION_RESULT_PATH": "$SESS_RES",
383
+ "OCQA_SESSION_TIMEOUT": "$SESS_TIMEOUT",
384
+ "OCQA_TEST_EMAIL": "${OCQA_TEST_EMAIL:-qa@example.com}",
385
+ "OCQA_TEST_PASSWORD": "${OCQA_TEST_PASSWORD:-Autotap123!}"$sess_args_line$sess_env_line
386
+ }
387
+ CONF
388
+ xctestrun=$(find "$HARNESS_DERIVED/Build/Products" -name "*.xctestrun" 2>/dev/null | head -1)
389
+ if [[ -z "$xctestrun" ]]; then
390
+ echo "ERROR: No xctestrun. Run: ./scripts/deploy-and-build.sh --harness" >&2
391
+ exit 1
392
+ fi
393
+ export TEST_RUNNER_OCQA_CONFIG_PATH=/tmp/ocqa-run-config.json
394
+ exec xcodebuild test-without-building \
395
+ -xctestrun "$xctestrun" \
396
+ -destination "platform=iOS Simulator,id=$UDID" \
397
+ -only-testing:"OCQAHarnessUITests/ExplorerTests/testInteractiveSession" \
398
+ -resultBundlePath "$CAPTURE_DIR/session.xcresult" 2>&1
399
+ ;;
400
+
401
+ build-harness)
402
+ # Just (re)build the harness cache for the booted sim — used by `autotap install`
403
+ # for a fast first tool call later. No capture output.
404
+ ensure_harness_built "$SIM_NAME"
405
+ rmdir "$CAPTURE_DIR" 2>/dev/null || true
406
+ echo "Harness ready: $(find "$HARNESS_DERIVED/Build/Products" -name '*.xctestrun' 2>/dev/null | head -1)"
407
+ ;;
408
+
409
+ *)
410
+ echo "Usage: quick-capture.sh <screenshot|record|explore|tree|session|build-harness> [options]"
411
+ echo ""
412
+ echo " screenshot [--app <id>] Screenshot current simulator state"
413
+ echo " record [--duration <secs>] Record simulator video (default 30s)"
414
+ echo " explore <bundleId> [--actions N] [--timeout S] Autonomous exploration + capture"
415
+ echo " tree <bundleId> Dump accessibility tree JSON"
416
+ echo " build-harness Prebuild the exploration harness for the booted sim"
417
+ exit 1
418
+ ;;
419
+ esac
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
6
+ import { loadFlowFile } from "../mcp-server/src/flow-runtime.js";
7
+ import { runAndroidFlow } from "../mcp-server/src/android-flow.js";
8
+
9
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
10
+ const flowPath = process.argv[2];
11
+ if (!flowPath) {
12
+ console.error("usage: run-android-flow.js <flow.yml|flow.json> [appId] [apkPath] [serial]");
13
+ process.exit(2);
14
+ }
15
+ const flow = loadFlowFile(flowPath);
16
+ const token = `${process.pid}-${Date.now()}`;
17
+ const logPath = process.env.FLOW_LOG || path.join(os.tmpdir(), `tapp-android-flow-${token}.log`);
18
+ const screenshotDir = process.env.TAPP_FLOW_EVIDENCE_DIR || path.join(os.tmpdir(), `tapp-android-flow-${token}`);
19
+ try {
20
+ const result = await runAndroidFlow({ flow, appId: process.argv[3], apkPath: process.argv[4] || undefined, serial: process.argv[5] || undefined, logPath, screenshotDir });
21
+ const report = spawnSync("python3", [path.join(root, "scripts", "flow_lib.py"), "report", logPath], { encoding: "utf8" });
22
+ process.stdout.write((report.stdout || "").trim() + "\n");
23
+ process.exit(result.passed ? 0 : 1);
24
+ } catch (error) {
25
+ console.error(`❌ ${error.message || error}`);
26
+ process.exit(2);
27
+ }
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env bash
2
+ # Run an AutoTap Flow — a deterministic, authored/recorded E2E test (see docs/flows-architecture.md).
3
+ #
4
+ # Replays the flow's steps against an app on the BOOTED simulator with wait-for-condition timing and
5
+ # poll-with-timeout assertions (no sleeps), then prints a scannable pass/fail report. Deterministic by
6
+ # default; `assert_ai` steps are judged host-side only when ANTHROPIC_API_KEY is set (else skipped).
7
+ #
8
+ # Usage:
9
+ # scripts/run-flow.sh <flow.yml|flow.json> [bundleId]
10
+ # OCQA_TEST_EMAIL=… OCQA_TEST_PASSWORD=… scripts/run-flow.sh flow.yml
11
+ #
12
+ # bundleId defaults to the flow's `app:` field. The app must be installed on the booted sim.
13
+ set -uo pipefail
14
+ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
15
+
16
+ FLOW="${1:?usage: run-flow.sh <flow.yml|flow.json> [bundleId]}"
17
+ [ -f "$FLOW" ] || { echo "❌ Flow not found: $FLOW"; exit 2; }
18
+ FLOW_JSON="$(node "$ROOT/scripts/compile-flow.js" "$FLOW" ios)" || { echo "❌ Could not compile flow/tasks"; exit 2; }
19
+
20
+ # App bundle id: explicit arg, else the flow's `app:` field.
21
+ APP="${2:-}"
22
+ [ -z "$APP" ] && APP="$(python3 -c "import sys,json; print(json.loads(sys.argv[1]).get('app','') or '')" "$FLOW_JSON" 2>/dev/null)"
23
+ [ -z "$APP" ] && { echo "❌ No bundleId (pass one or set 'app:' in the flow)"; exit 2; }
24
+
25
+ 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"), ""))')"
26
+ [ -z "$UDID" ] && { echo "❌ No booted simulator."; exit 2; }
27
+ # When run through the `autotap` CLI (installed npm package), the harness cache lives under
28
+ # AUTOTAP_HOME — check there first, then the repo-dev locations.
29
+ XCTR=""
30
+ [ -n "${AUTOTAP_HOME:-}" ] && XCTR="$(find "$AUTOTAP_HOME/harness-derived/Build/Products" -name '*.xctestrun' 2>/dev/null | head -1)"
31
+ [ -z "$XCTR" ] && XCTR="$(find "$HOME/Library/Developer/Xcode/DerivedData/OCQAHarness-"*/Build/Products -name '*.xctestrun' 2>/dev/null | head -1)"
32
+ [ -z "$XCTR" ] && XCTR="$(find /tmp/autotap-harness-derived/Build/Products -name '*.xctestrun' 2>/dev/null | head -1)"
33
+ [ -z "$XCTR" ] && XCTR="$(find /tmp/harness-build/Build/Products -name '*.xctestrun' 2>/dev/null | head -1)"
34
+ [ -z "$XCTR" ] && { echo "❌ Harness not built. Run: scripts/deploy-and-build.sh --harness"; exit 2; }
35
+
36
+ NAME="$(python3 -c "import sys,json;print(json.loads(sys.argv[1]).get('name','flow'))" "$FLOW_JSON")"
37
+ echo "▶️ Running flow \"$NAME\" against $APP …"
38
+
39
+ TOKEN="$(date +%s)"
40
+ CFG="/tmp/ocqa-flow-$TOKEN.json"
41
+ AI_RESP="/tmp/ocqa-flow-ai-$TOKEN.json"
42
+ AI_DIR="/tmp/ocqa-flow-ai-$TOKEN"
43
+ python3 - "$CFG" "$APP" "$FLOW_JSON" "$AI_RESP" "$AI_DIR" <<'PY'
44
+ import json, os, sys
45
+ cfg, app, flow_json, ai_resp, ai_dir = sys.argv[1:6]
46
+ d = {
47
+ "OCQA_BUNDLE_ID": app,
48
+ "OCQA_FLOW_JSON": flow_json,
49
+ "OCQA_TEST_EMAIL": os.environ.get("OCQA_TEST_EMAIL", "test@example.com"),
50
+ "OCQA_TEST_PASSWORD": os.environ.get("OCQA_TEST_PASSWORD", "TestPass123!"),
51
+ }
52
+ launch_args = os.environ.get("OCQA_APP_LAUNCH_ARGS_JSON", "")
53
+ if launch_args:
54
+ value = json.loads(launch_args)
55
+ if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
56
+ raise SystemExit("OCQA_APP_LAUNCH_ARGS_JSON must be a JSON array of strings")
57
+ d["OCQA_APP_LAUNCH_ARGS"] = value
58
+ launch_env = os.environ.get("OCQA_APP_LAUNCH_ENV_JSON", "")
59
+ if launch_env:
60
+ value = json.loads(launch_env)
61
+ if not isinstance(value, dict) or not all(isinstance(key, str) and isinstance(item, str) for key, item in value.items()):
62
+ raise SystemExit("OCQA_APP_LAUNCH_ENV_JSON must be a JSON object with string values")
63
+ d["OCQA_APP_LAUNCH_ENV"] = value
64
+ if os.environ.get("ANTHROPIC_API_KEY"):
65
+ d["OCQA_FLOW_AI_RESPONSE_PATH"] = ai_resp
66
+ d["OCQA_FLOW_AI_IMAGE_DIR"] = ai_dir
67
+ open(cfg, "w").write(json.dumps(d))
68
+ PY
69
+ if [ "$?" -ne 0 ]; then
70
+ echo "❌ Invalid iOS Flow launch configuration"
71
+ exit 2
72
+ fi
73
+
74
+ LOG="${FLOW_LOG:-/tmp/ocqa-flow-$TOKEN.log}"
75
+ # assert_ai judge sidecar (only when a key is present) — same file-channel as vision escalation.
76
+ RESPONDER_PID=""
77
+ if [ -n "${ANTHROPIC_API_KEY:-}" ]; then
78
+ mkdir -p "$AI_DIR"; : > "$LOG"
79
+ python3 "$ROOT/scripts/flow_ai_judge.py" "$LOG" "$AI_RESP" > "/tmp/ocqa-flow-judge-$TOKEN.log" 2>&1 &
80
+ RESPONDER_PID=$!
81
+ fi
82
+
83
+ TEST_RUNNER_OCQA_CONFIG_PATH="$CFG" xcodebuild test-without-building \
84
+ -xctestrun "$XCTR" -destination "platform=iOS Simulator,id=$UDID" \
85
+ -only-testing:"OCQAHarnessUITests/ExplorerTests/testReplayFlow" > "$LOG" 2>&1
86
+ [ -n "$RESPONDER_PID" ] && { kill "$RESPONDER_PID" 2>/dev/null; wait "$RESPONDER_PID" 2>/dev/null; }
87
+
88
+ echo ""
89
+ python3 "$ROOT/scripts/flow_lib.py" report "$LOG"
90
+ exit $?
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
6
+ import { loadFlowFile } from "../mcp-server/src/flow-runtime.js";
7
+ import { runWebFlow } from "../mcp-server/src/web-flow.js";
8
+
9
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
10
+ const flowPath = process.argv[2];
11
+ if (!flowPath) {
12
+ console.error("usage: run-web-flow.js <flow.yml|flow.json> [url]");
13
+ process.exit(2);
14
+ }
15
+ const flow = loadFlowFile(flowPath);
16
+ const token = `${process.pid}-${Date.now()}`;
17
+ const logPath = process.env.FLOW_LOG || path.join(os.tmpdir(), `tapp-web-flow-${token}.log`);
18
+ const screenshotDir = process.env.TAPP_FLOW_EVIDENCE_DIR || path.join(os.tmpdir(), `tapp-web-flow-${token}`);
19
+
20
+ try {
21
+ const result = await runWebFlow({ flow, url: process.argv[3], logPath, screenshotDir });
22
+ const report = spawnSync("python3", [path.join(root, "scripts", "flow_lib.py"), "report", logPath], { encoding: "utf8" });
23
+ process.stdout.write((report.stdout || "").trim() + "\n");
24
+ process.exit(result.passed ? 0 : 1);
25
+ } catch (error) {
26
+ console.error(`❌ ${error.message || error}`);
27
+ process.exit(2);
28
+ }
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+ import path from "node:path";
3
+ import { loadScenarioFile, runWebScenario } from "../mcp-server/src/scenario-runtime.js";
4
+
5
+ const [, , scenarioPath, url] = process.argv;
6
+ if (!scenarioPath) {
7
+ console.error("usage: run-web-scenario.js <scenario.yml> [url]");
8
+ process.exit(2);
9
+ }
10
+
11
+ try {
12
+ const scenario = loadScenarioFile(path.resolve(scenarioPath));
13
+ const result = await runWebScenario({
14
+ scenario,
15
+ url,
16
+ logPath: process.env.FLOW_LOG,
17
+ screenshotDir: process.env.TAPP_FLOW_EVIDENCE_DIR,
18
+ });
19
+ process.exit(result.passed ? 0 : 1);
20
+ } catch (error) {
21
+ console.error(`Scenario could not run: ${error.message || error}`);
22
+ process.exit(1);
23
+ }