@aarwitz/tapp 0.17.9 → 0.17.10
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/.claude-plugin/plugin.json +2 -2
- package/Harness/OCQAHarnessUITests/ExplorerTests.swift +15 -3
- package/README.md +1 -0
- package/bin/tapp.js +18 -3
- package/docs/scenarios.md +1 -1
- package/mcp-server/src/feedback.js +10 -3
- package/mcp-server/src/flow-runtime.js +54 -0
- package/mcp-server/src/index.js +29 -0
- package/mcp-server/src/report.js +6 -0
- package/package.json +1 -1
- package/scripts/flow_lib.py +35 -1
- package/scripts/quick-capture.sh +2 -1
- package/skills/tapp/references/commands.md +2 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tapp",
|
|
3
3
|
"description": "Give Claude hands and eyes on iOS, Android, and web apps, with exploration, replayable flows, evidence, and deterministic CI gates.",
|
|
4
|
-
"version": "0.17.
|
|
4
|
+
"version": "0.17.10",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Aaron Horowitz",
|
|
7
7
|
"url": "https://github.com/aarwitz"
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"command": "npx",
|
|
25
25
|
"args": [
|
|
26
26
|
"-y",
|
|
27
|
-
"@aarwitz/tapp@0.17.
|
|
27
|
+
"@aarwitz/tapp@0.17.10",
|
|
28
28
|
"mcp"
|
|
29
29
|
],
|
|
30
30
|
"cwd": "${CLAUDE_PROJECT_DIR}"
|
|
@@ -1082,11 +1082,23 @@ class ExplorerTests: XCTestCase {
|
|
|
1082
1082
|
|
|
1083
1083
|
let testEmail = resolve("OCQA_TEST_EMAIL")
|
|
1084
1084
|
let testPassword = resolve("OCQA_TEST_PASSWORD")
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1085
|
+
// Presence only: never print, persist, or expose credential values. Report rebuilding
|
|
1086
|
+
// needs this durable marker to distinguish "not supplied" from "supplied but unused".
|
|
1087
|
+
// Decided by what actually reached the run config, not by a separate flag the run config
|
|
1088
|
+
// never carried (feedback #3: every iOS run reported credentialsProvided=false).
|
|
1089
|
+
if resolve("OCQA_CREDENTIALS_EXPLICIT") == "1" || !testEmail.isEmpty || !testPassword.isEmpty {
|
|
1088
1090
|
print("OCQA_STATE:credentials_supplied")
|
|
1089
1091
|
}
|
|
1092
|
+
// Capture conditions shared by every screenshot in this run, so a baseline diff across a
|
|
1093
|
+
// different device/scale is visibly a layout comparison (feedback #5: iOS reports carried
|
|
1094
|
+
// captureContext: null while the changelog promised it).
|
|
1095
|
+
do {
|
|
1096
|
+
let env = ProcessInfo.processInfo.environment
|
|
1097
|
+
let device = env["SIMULATOR_DEVICE_NAME"] ?? env["SIMULATOR_MODEL_IDENTIFIER"] ?? "iOS Simulator"
|
|
1098
|
+
let bounds = app.windows.firstMatch.exists ? app.windows.firstMatch.frame : app.frame
|
|
1099
|
+
let scale = UIScreen.main.scale
|
|
1100
|
+
print("OCQA_CONTEXT:{\"device\":\"\(escapeJSON(device))\",\"viewport\":{\"width\":\(Int(bounds.width.rounded())),\"height\":\(Int(bounds.height.rounded()))},\"deviceScaleFactor\":\(scale)}")
|
|
1101
|
+
}
|
|
1090
1102
|
|
|
1091
1103
|
// --- Explicit login replay (config-driven): a recorded type/tap/wait sequence for custom
|
|
1092
1104
|
// login UIs the heuristic preamble below can't parse. When configured it takes precedence. ---
|
package/README.md
CHANGED
|
@@ -199,6 +199,7 @@ one by hand:
|
|
|
199
199
|
|
|
200
200
|
```bash
|
|
201
201
|
npx -y @aarwitz/tapp@latest flow example
|
|
202
|
+
npx -y @aarwitz/tapp@latest flow steps # the step vocabulary: target semantics and pass condition per step
|
|
202
203
|
npx -y @aarwitz/tapp@latest flow validate .tapp/flows/smoke.yml
|
|
203
204
|
npx -y @aarwitz/tapp@latest flow run .tapp/flows/smoke.yml
|
|
204
205
|
```
|
package/bin/tapp.js
CHANGED
|
@@ -270,7 +270,7 @@ function safeCommandUsage(verb) {
|
|
|
270
270
|
shot: "tapp shot [--out FILE]",
|
|
271
271
|
apps: "tapp apps",
|
|
272
272
|
build: "tapp build [repo] [--scheme NAME] [--configuration NAME]",
|
|
273
|
-
flow: "tapp flow example\ntapp flow validate FILE [--platform PLATFORM] [--map FILE]\ntapp flow run FILE [--actor NAME] [--email VALUE] [--password VALUE] [--device \"iPhone 13\"] [--viewport 390x844]\n Exit codes: 0 replay passed · 1 replay failed · 2 infrastructure/usage error",
|
|
273
|
+
flow: "tapp flow example\ntapp flow steps [--json]\ntapp flow validate FILE [--platform PLATFORM] [--map FILE]\ntapp flow run FILE [--actor NAME] [--email VALUE] [--password VALUE] [--device \"iPhone 13\"] [--viewport 390x844]\n Exit codes: 0 replay passed · 1 replay failed · 2 infrastructure/usage error",
|
|
274
274
|
task: "tapp task validate FILE [--platform PLATFORM] [--map FILE]\ntapp task compile FILE --platform PLATFORM [--inputs JSON] [--out FILE]\ntapp task run FILE --platform PLATFORM [--url URL|--bundle-id ID|--app-id ID] [--inputs JSON]",
|
|
275
275
|
contract: "tapp contract validate FILE [--platform PLATFORM] [--map FILE]\ntapp contract compile FILE --platform PLATFORM [--out FILE]\ntapp contract run FILE --platform PLATFORM [--url URL|--bundle-id ID|--app-id ID]\n Exit codes: 0 contract held · 1 contract failed · 2 infrastructure/usage error",
|
|
276
276
|
scenario: "tapp scenario validate FILE [--project-dir DIR]\ntapp scenario run FILE --platform web --url URL [--project-dir DIR]\n Exit codes: 0 scenario passed · 1 scenario failed · 2 infrastructure/usage error",
|
|
@@ -1133,8 +1133,16 @@ switch (command) {
|
|
|
1133
1133
|
console.log(`# Tapp Flow — deterministic, keyless replay\nname: sign-in-smoke\nplatform: web\nurl: https://example.test/login\nsteps:\n - login:\n email: $TEST_EMAIL\n password: $TEST_PASSWORD\n - wait_for: Dashboard\n - assert_screen: Dashboard\n`);
|
|
1134
1134
|
break;
|
|
1135
1135
|
}
|
|
1136
|
+
if (verb === "steps") {
|
|
1137
|
+
const { FLOW_ACTIONS } = await import(path.join(packageRoot, "mcp-server", "src", "flow-runtime.js"));
|
|
1138
|
+
if (flags.json === true) { console.log(JSON.stringify(FLOW_ACTIONS, null, 2)); break; }
|
|
1139
|
+
console.log("Flow step vocabulary — identical on ios, android, and web. Anything else fails `tapp flow validate`.\n");
|
|
1140
|
+
for (const a of FLOW_ACTIONS) console.log(` ${a.action.padEnd(14)} target: ${a.target}\n ${"".padEnd(14)} passes: ${a.passes}\n`);
|
|
1141
|
+
console.log("Targets are labels / accessibility ids / visible text, never coordinates. `assert_screen` checks the detected screen TITLE; use `assert_exists` for \"this text is on screen\".");
|
|
1142
|
+
break;
|
|
1143
|
+
}
|
|
1136
1144
|
if (!["run", "validate"].includes(verb) || !flowPath) {
|
|
1137
|
-
console.error("usage: tapp flow example\n tapp flow run <flow.yml> [--platform ios|android|web] [--actor NAME] [--email VALUE] [--password VALUE] [--url URL] [--app-id ID] [--apk FILE] [--serial ID]\n tapp flow validate <flow.yml>");
|
|
1145
|
+
console.error("usage: tapp flow example\n tapp flow steps [--json]\n tapp flow run <flow.yml> [--platform ios|android|web] [--actor NAME] [--email VALUE] [--password VALUE] [--url URL] [--app-id ID] [--apk FILE] [--serial ID]\n tapp flow validate <flow.yml>");
|
|
1138
1146
|
process.exit(2);
|
|
1139
1147
|
}
|
|
1140
1148
|
const absolute = path.resolve(flowPath);
|
|
@@ -1142,7 +1150,7 @@ switch (command) {
|
|
|
1142
1150
|
console.error(`❌ Flow not found: ${absolute}`);
|
|
1143
1151
|
process.exit(2);
|
|
1144
1152
|
}
|
|
1145
|
-
const { loadFlowFile } = await import(path.join(packageRoot, "mcp-server", "src", "flow-runtime.js"));
|
|
1153
|
+
const { loadFlowFile, validateFlowSteps } = await import(path.join(packageRoot, "mcp-server", "src", "flow-runtime.js"));
|
|
1146
1154
|
let flow;
|
|
1147
1155
|
try { flow = loadFlowFile(absolute); } catch (error) {
|
|
1148
1156
|
console.error(`❌ Invalid Flow: ${error.message}`);
|
|
@@ -1157,6 +1165,13 @@ switch (command) {
|
|
|
1157
1165
|
console.error(`❌ Unsupported Flow platform: ${platform}`);
|
|
1158
1166
|
process.exit(2);
|
|
1159
1167
|
}
|
|
1168
|
+
// Static step checks run before validate AND run: a Flow outside the vocabulary (or a
|
|
1169
|
+
// coordinate tap) can never replay, so it must not reach a simulator (feedback #2).
|
|
1170
|
+
const stepErrors = validateFlowSteps(flow, platform);
|
|
1171
|
+
if (stepErrors.length) {
|
|
1172
|
+
console.error(`❌ Invalid ${platform} Flow — ${flow.name || path.basename(absolute)}:\n${stepErrors.map((e) => ` • ${e}`).join("\n")}\n Vocabulary: tapp flow steps`);
|
|
1173
|
+
process.exit(2);
|
|
1174
|
+
}
|
|
1160
1175
|
if (verb === "validate") {
|
|
1161
1176
|
const taskCount = Array.isArray(flow.taskPlan) ? flow.taskPlan.length : 0;
|
|
1162
1177
|
console.log(`✅ Valid ${platform} Flow — ${flow.name} (${flow.steps.length} deterministic steps${taskCount ? ` compiled from ${taskCount} Task call${taskCount === 1 ? "" : "s"}` : ""})`);
|
package/docs/scenarios.md
CHANGED
|
@@ -74,7 +74,7 @@ tapp ci --platform web --url http://127.0.0.1:4180 \
|
|
|
74
74
|
GitHub Action:
|
|
75
75
|
|
|
76
76
|
```yaml
|
|
77
|
-
- uses: aarwitz/tapp@v0.17.
|
|
77
|
+
- uses: aarwitz/tapp@v0.17.10 # or pin the reviewed release commit SHA
|
|
78
78
|
with:
|
|
79
79
|
platform: web
|
|
80
80
|
url: http://127.0.0.1:4180
|
|
@@ -86,12 +86,19 @@ export function ghStatus(ghBin = process.env.TAPP_GH_BIN || "gh") {
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
export function submitFeedbackViaGh(issue, ghBin = process.env.TAPP_GH_BIN || "gh") {
|
|
89
|
-
|
|
89
|
+
// Labels can only be set by accounts with triage rights on the repo. Anyone else (which is
|
|
90
|
+
// every real user) gets the issue filed without them; the footer already carries the kind
|
|
91
|
+
// and the maintainer applies labels on triage. Try with labels first, then without.
|
|
92
|
+
const attempt = (withLabels) => spawnSync(ghBin, [
|
|
90
93
|
"issue", "create", "--repo", FEEDBACK_REPO,
|
|
91
|
-
"--title", issue.title, "--body-file", "-",
|
|
94
|
+
"--title", issue.title, "--body-file", "-",
|
|
95
|
+
...(withLabels ? ["--label", issue.labels.join(",")] : []),
|
|
92
96
|
], { encoding: "utf8", input: issue.body });
|
|
97
|
+
let r = attempt(true);
|
|
98
|
+
let labelsApplied = r.status === 0;
|
|
99
|
+
if (r.status !== 0 && /label/i.test(`${r.stderr || ""}${r.stdout || ""}`)) { r = attempt(false); labelsApplied = false; }
|
|
93
100
|
const out = (r.stdout || "").trim();
|
|
94
101
|
const err = (r.stderr || "").trim();
|
|
95
102
|
const url = (out.match(/https:\/\/github\.com\/\S+/) || [])[0] || null;
|
|
96
|
-
return { ok: r.status === 0 && Boolean(url), url, detail: r.status === 0 ? out : (err || out) };
|
|
103
|
+
return { ok: r.status === 0 && Boolean(url), url, labelsApplied, detail: r.status === 0 ? out : (err || out) };
|
|
97
104
|
}
|
|
@@ -36,6 +36,60 @@ export function normalizeFlowStep(raw) {
|
|
|
36
36
|
return { action: key.toLowerCase(), target: body === true ? "" : String(body ?? ""), value: body === true ? "" : String(body ?? ""), params: {}, ...(raw.__tappTask?.name ? { task: raw.__tappTask.name } : {}) };
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
// The committed Flow step vocabulary. Every driver (XCUITest, Android, browser) implements exactly
|
|
40
|
+
// this table; `tapp flow steps` prints it and `tapp flow validate` rejects anything outside it, so
|
|
41
|
+
// a Flow that validates can actually replay (feedback #2: coordinate taps and `click:` used to
|
|
42
|
+
// validate and then fail at runtime).
|
|
43
|
+
export const FLOW_ACTIONS = Object.freeze([
|
|
44
|
+
{ action: "tap", target: "a visible label / accessibility id", passes: "the control was found and tapped; coordinates are not accepted — use the session's tap-by-point to learn the label" },
|
|
45
|
+
{ action: "type", target: "{field, value}", passes: "the field was found and now holds the value ($TEST_EMAIL/$TEST_PASSWORD substitute)" },
|
|
46
|
+
{ action: "login", target: "{email, password} (defaults to $TEST_EMAIL/$TEST_PASSWORD)", passes: "credentials were entered and submitted and the login form went away" },
|
|
47
|
+
{ action: "swipe", target: "up | down | left | right", passes: "the gesture was performed" },
|
|
48
|
+
{ action: "back", target: "(none)", passes: "the platform back navigation was performed" },
|
|
49
|
+
{ action: "wait", target: "milliseconds (fixed pause; prefer wait_for)", passes: "always" },
|
|
50
|
+
{ action: "wait_for", target: "label / text (+ timeoutMs)", passes: "the element appeared before the timeout" },
|
|
51
|
+
{ action: "assert_screen", target: "the detected SCREEN TITLE (navigation bar / heading), not arbitrary text", passes: "the current screen's title equals the target" },
|
|
52
|
+
{ action: "assert_exists", target: "label / text", passes: "an element with that text or id is present" },
|
|
53
|
+
{ action: "assert_absent", target: "label / text", passes: "no element with that text or id is present" },
|
|
54
|
+
{ action: "assert_text", target: "{of, contains}", passes: "the element's text contains the substring" },
|
|
55
|
+
{ action: "assert_ai", target: "a natural-language expectation", passes: "the vision judge agrees (needs ANTHROPIC_API_KEY; advisory)" },
|
|
56
|
+
]);
|
|
57
|
+
const FLOW_ACTION_NAMES = new Set(FLOW_ACTIONS.map((a) => a.action));
|
|
58
|
+
const ALIASES = { click: "tap", press: "tap", fill: "type", input: "type", sleep: "wait", wait_for_text: "wait_for", assert_visible: "assert_exists", expect: "assert_exists" };
|
|
59
|
+
|
|
60
|
+
// Static checks a Flow must pass before any runtime is launched. Returns human-readable errors;
|
|
61
|
+
// an empty array means every step is in the vocabulary and shaped so a driver can execute it.
|
|
62
|
+
export function validateFlowSteps(flow, platform = inferFlowPlatform(flow)) {
|
|
63
|
+
const errors = [];
|
|
64
|
+
const steps = Array.isArray(flow?.steps) ? flow.steps : [];
|
|
65
|
+
steps.forEach((raw, i) => {
|
|
66
|
+
const step = normalizeFlowStep(raw);
|
|
67
|
+
const n = i + 1;
|
|
68
|
+
if (step.action === "noop") { errors.push(`step ${n}: empty step`); return; }
|
|
69
|
+
if (!FLOW_ACTION_NAMES.has(step.action)) {
|
|
70
|
+
const alias = ALIASES[step.action];
|
|
71
|
+
errors.push(`step ${n}: unknown action '${step.action}'${alias ? ` — did you mean '${alias}'? (web flows use tap:, not click:)` : ""}; run \`tapp flow steps\` for the vocabulary`);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (step.action === "tap" && /^\s*-?\d+(\.\d+)?\s*,\s*-?\d+(\.\d+)?\s*$/.test(step.target)) {
|
|
75
|
+
errors.push(`step ${n}: tap target '${step.target.trim()}' is a coordinate; Flow taps are label-only on ${platform} (use tapp_session_act tap {x,y} to learn the label, then record it)`);
|
|
76
|
+
}
|
|
77
|
+
if (["tap", "wait_for", "assert_screen", "assert_exists", "assert_absent"].includes(step.action) && !step.target.trim()) {
|
|
78
|
+
errors.push(`step ${n}: ${step.action} needs a target`);
|
|
79
|
+
}
|
|
80
|
+
if (step.action === "type" && (!step.target.trim() || !("value" in (step.params || {})))) {
|
|
81
|
+
errors.push(`step ${n}: type needs {field, value}`);
|
|
82
|
+
}
|
|
83
|
+
if (step.action === "assert_text" && (!step.target.trim() || !step.value)) {
|
|
84
|
+
errors.push(`step ${n}: assert_text needs {of, contains}`);
|
|
85
|
+
}
|
|
86
|
+
if (step.action === "swipe" && step.target && !["up", "down", "left", "right"].includes(step.target.trim().toLowerCase())) {
|
|
87
|
+
errors.push(`step ${n}: swipe direction must be up|down|left|right`);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
return errors;
|
|
91
|
+
}
|
|
92
|
+
|
|
39
93
|
export function flowVariables(flow, overrides = {}) {
|
|
40
94
|
return {
|
|
41
95
|
TEST_EMAIL: process.env.OCQA_TEST_EMAIL || "test@example.com",
|
package/mcp-server/src/index.js
CHANGED
|
@@ -907,10 +907,39 @@ function recordStep(cmd, result) {
|
|
|
907
907
|
if (newScreen) activeSession.lastScreen = newScreen;
|
|
908
908
|
}
|
|
909
909
|
|
|
910
|
+
// Argument shapes tapp_session_act accepts, per action. A malformed call is answered here with
|
|
911
|
+
// the accepted shape and never reaches the driver, so it can neither time out nor disturb the
|
|
912
|
+
// session (feedback #7: `{action:"wait", seconds:3}` used to wait on an empty target).
|
|
913
|
+
const SESSION_ACT_ARGS = Object.freeze({
|
|
914
|
+
tap: "{id: <label|accessibility id>} or {x, y} (points)",
|
|
915
|
+
type: "{id|label: <field>, text: <value>}",
|
|
916
|
+
wait: "{text|id: <label to wait for>, timeoutMs?: <default 5000, max 60000>} — there is no fixed sleep; wait for something",
|
|
917
|
+
login: "{email?, password?} (defaults to the session's credentials)",
|
|
918
|
+
swipe: "{direction: up|down|left|right}",
|
|
919
|
+
back: "{}",
|
|
920
|
+
tree: "{verbose?: true}",
|
|
921
|
+
screenshot: "{label?}",
|
|
922
|
+
});
|
|
923
|
+
export function sessionActUsageError(cmd = {}) {
|
|
924
|
+
const action = String(cmd.action || "");
|
|
925
|
+
if (!SESSION_ACT_ARGS[action]) return `Unknown action '${action || "(none)"}'. Accepted: ${Object.keys(SESSION_ACT_ARGS).join(", ")}.`;
|
|
926
|
+
const has = (k) => cmd[k] !== undefined && cmd[k] !== null && String(cmd[k]).trim() !== "";
|
|
927
|
+
const bad = (why) => `${why}. ${action} takes ${SESSION_ACT_ARGS[action]}.`;
|
|
928
|
+
if (action === "wait" && !has("text") && !has("id")) return bad(`wait needs a target${cmd.seconds !== undefined || cmd.ms !== undefined ? " (seconds/ms are not arguments)" : ""}`);
|
|
929
|
+
if (action === "wait" && cmd.timeoutMs !== undefined && !(Number.isFinite(Number(cmd.timeoutMs)) && Number(cmd.timeoutMs) > 0)) return bad("timeoutMs must be a positive number of milliseconds");
|
|
930
|
+
if (action === "tap" && !has("id") && !has("label") && !(Number.isFinite(cmd.x) && Number.isFinite(cmd.y))) return bad("tap needs an id/label or both x and y");
|
|
931
|
+
if (action === "type" && !has("id") && !has("label")) return bad("type needs the field's id/label");
|
|
932
|
+
if (action === "type" && cmd.text === undefined) return bad("type needs text");
|
|
933
|
+
if (action === "swipe" && cmd.direction !== undefined && !["up", "down", "left", "right"].includes(String(cmd.direction))) return bad("direction must be up|down|left|right");
|
|
934
|
+
return null;
|
|
935
|
+
}
|
|
936
|
+
|
|
910
937
|
async function sessionAct(cmd) {
|
|
911
938
|
const startedAt = Date.now();
|
|
912
939
|
const done = (result) => ({ ...result, durationMs: Date.now() - startedAt });
|
|
913
940
|
if (!activeSession || activeSession.ended) return done({ error: "No active session. Call tapp_session_start first." });
|
|
941
|
+
const usage = sessionActUsageError(cmd);
|
|
942
|
+
if (usage) return done({ status: "usage", detail: usage, ...treeSnapshot(), recordedSteps: activeSession.recording.length });
|
|
914
943
|
let coordinateResolvedTarget = "";
|
|
915
944
|
if (cmd.action === "tap" && !cmd.id && Number.isFinite(cmd.x) && Number.isFinite(cmd.y)) {
|
|
916
945
|
coordinateResolvedTarget = semanticTargetAtPoint(activeSession.latestTree?.elements, cmd.x, cmd.y);
|
package/mcp-server/src/report.js
CHANGED
|
@@ -298,6 +298,7 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
|
|
|
298
298
|
// 40-action campaign. Drivers signal the real cause in COMPLETE.stop; captures from drivers
|
|
299
299
|
// that predate the field keep the old inference.
|
|
300
300
|
const driverStop = base.complete && typeof base.complete === "object" ? base.complete.stop : null;
|
|
301
|
+
const nativeOutcome = base.complete && typeof base.complete === "object" && typeof base.complete.outcome === "string" ? base.complete.outcome : null;
|
|
301
302
|
const stopReason = unexercisedLoginWall ? (credentialsProvided ? "login-wall-credentials-unused" : "login-wall-no-credentials")
|
|
302
303
|
: timeBudgetExhausted ? "time-budget-exhausted"
|
|
303
304
|
: !coverageFloorMet ? "coverage-floor-not-met"
|
|
@@ -306,6 +307,11 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
|
|
|
306
307
|
// Any other driver-signalled cause (navigation-trap, app-crashed, stuck-no-progress …)
|
|
307
308
|
// passes through verbatim: "completed" is ONLY the exhausted action budget.
|
|
308
309
|
: driverStop && driverStop !== "action-budget" && driverStop !== "time-budget" ? String(driverStop)
|
|
310
|
+
// XCUITest predates COMPLETE.stop and signals the cause through COMPLETE.outcome instead
|
|
311
|
+
// (feedback #5: a run whose marker said limited_surface must not read as "completed").
|
|
312
|
+
: nativeOutcome === "limited_surface" ? "limited-surface"
|
|
313
|
+
: nativeOutcome === "timeout" ? "time-budget-exhausted"
|
|
314
|
+
: nativeOutcome && nativeOutcome.startsWith("crash") ? "app-crashed"
|
|
309
315
|
: "completed";
|
|
310
316
|
|
|
311
317
|
const headline = timeBudgetExhausted
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aarwitz/tapp",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.10",
|
|
4
4
|
"mcpName": "io.github.aarwitz/tapp",
|
|
5
5
|
"description": "Let coding agents verify UI changes on real iOS, Android, and web surfaces, then enforce reviewed proof in deterministic CI.",
|
|
6
6
|
"license": "MIT",
|
package/scripts/flow_lib.py
CHANGED
|
@@ -63,6 +63,29 @@ def raw_json(path):
|
|
|
63
63
|
print(json.dumps(load_flow(path) or {}))
|
|
64
64
|
|
|
65
65
|
|
|
66
|
+
ABORT_PATTERNS = [
|
|
67
|
+
r"Failed to synthesize event: [^\n]*",
|
|
68
|
+
r"Neither element nor any descendant has keyboard focus[^\n]*",
|
|
69
|
+
r"Test Case '[^']*' failed \([^)]*\)",
|
|
70
|
+
r"Error Domain=[^\n]*",
|
|
71
|
+
r"Unable to (?:find|launch|boot)[^\n]*",
|
|
72
|
+
r"App state is [^\n]*",
|
|
73
|
+
r"Timed out [^\n]*",
|
|
74
|
+
r"Testing failed:[^\n]*",
|
|
75
|
+
r"error: [^\n]*",
|
|
76
|
+
r"\*\* TEST (?:EXECUTE )?FAILED \*\*",
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def harness_abort_reason(log):
|
|
81
|
+
"""First XCTest/xcodebuild line that explains why a run ended before step 1."""
|
|
82
|
+
for pat in ABORT_PATTERNS:
|
|
83
|
+
m = re.search(pat, log)
|
|
84
|
+
if m:
|
|
85
|
+
return m.group(0).strip()[:300]
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
66
89
|
def report(path, as_json=False):
|
|
67
90
|
log = open(path, encoding="utf-8", errors="replace").read()
|
|
68
91
|
steps = []
|
|
@@ -97,8 +120,19 @@ def report(path, as_json=False):
|
|
|
97
120
|
executed = (result or {}).get("executed", len(steps))
|
|
98
121
|
passed_steps = sum(1 for s in steps if s.get("status") == "pass")
|
|
99
122
|
|
|
123
|
+
# A run that died before step 1 used to print `0 passed · 0 failed · 0/0 executed` and
|
|
124
|
+
# nothing else; the XCTest reason lived only in flow.log (feedback #1). Surface it as the
|
|
125
|
+
# failure of the first step so the scoreboard says why instead of looking like a crash.
|
|
126
|
+
abort_reason = None
|
|
127
|
+
if not steps and not passed:
|
|
128
|
+
abort_reason = harness_abort_reason(log)
|
|
129
|
+
steps.append({"index": 1, "action": "harness", "target": "", "status": "fail",
|
|
130
|
+
"detail": abort_reason or "the harness exited before the first step; see flow.log"})
|
|
131
|
+
failed = max(failed, 1)
|
|
132
|
+
|
|
100
133
|
if as_json:
|
|
101
|
-
print(json.dumps({"name": name, "kind": kind, "passed": passed, "total": total, "executed": executed, "failed": failed,
|
|
134
|
+
print(json.dumps({"name": name, "kind": kind, "passed": passed, "total": total, "executed": executed, "failed": failed,
|
|
135
|
+
**({"abortReason": abort_reason} if abort_reason else {}), "steps": steps}))
|
|
102
136
|
return 0 if passed else 1
|
|
103
137
|
|
|
104
138
|
icon = {"pass": "✅", "fail": "❌", "skip": "⚪️"}
|
package/scripts/quick-capture.sh
CHANGED
|
@@ -184,7 +184,8 @@ run_harness_test() {
|
|
|
184
184
|
"OCQA_MAX_ACTIONS": "$max_actions",
|
|
185
185
|
"OCQA_TIMEOUT_SECONDS": "$timeout_secs",
|
|
186
186
|
"OCQA_TEST_EMAIL": "${OCQA_TEST_EMAIL:-}",
|
|
187
|
-
"OCQA_TEST_PASSWORD": "${OCQA_TEST_PASSWORD:-}"
|
|
187
|
+
"OCQA_TEST_PASSWORD": "${OCQA_TEST_PASSWORD:-}",
|
|
188
|
+
"OCQA_CREDENTIALS_EXPLICIT": "${OCQA_CREDENTIALS_EXPLICIT:-}"$interactive_line$overrides_line$launch_args_line$launch_env_line$login_steps_line$pr_target_line$visual_ready_line$recording_started_line
|
|
188
189
|
}
|
|
189
190
|
CONF
|
|
190
191
|
|
|
@@ -101,6 +101,8 @@ ask the user rather than pretending the explored surface was complete.
|
|
|
101
101
|
Flow YAML belongs under `.tapp/flows/` and can replay without a model or API key:
|
|
102
102
|
|
|
103
103
|
```bash
|
|
104
|
+
npx -y @aarwitz/tapp@latest flow steps # vocabulary: assert_screen = screen TITLE, assert_exists = text present; taps are label-only
|
|
105
|
+
npx -y @aarwitz/tapp@latest flow validate .tapp/flows/smoke.yml
|
|
104
106
|
npx -y @aarwitz/tapp@latest flow run .tapp/flows/smoke.yml
|
|
105
107
|
npx -y @aarwitz/tapp@latest ci
|
|
106
108
|
```
|