@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,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
|
+
}
|