@aarwitz/tapp 0.17.8 → 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 +12 -2
- package/bin/tapp.js +89 -4
- package/docs/scenarios.md +1 -1
- package/mcp-server/src/feedback.js +104 -0
- package/mcp-server/src/flow-runtime.js +54 -0
- package/mcp-server/src/index.js +84 -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 +5 -4
- package/skills/tapp/SKILL.md +2 -2
- package/skills/tapp/references/commands.md +3 -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
|
```
|
|
@@ -349,7 +350,7 @@ jobs:
|
|
|
349
350
|
timeout-minutes: 45
|
|
350
351
|
steps:
|
|
351
352
|
- uses: actions/checkout@v4
|
|
352
|
-
- uses: aarwitz/tapp@v0.17.
|
|
353
|
+
- uses: aarwitz/tapp@v0.17.9 # or pin the reviewed release commit SHA
|
|
353
354
|
with:
|
|
354
355
|
project: MyApp.xcodeproj # or MyApp.xcworkspace
|
|
355
356
|
scheme: MyApp
|
|
@@ -399,7 +400,7 @@ Android CI runs on Linux with an emulator/device already connected. The Action c
|
|
|
399
400
|
or accept a prebuilt one:
|
|
400
401
|
|
|
401
402
|
```yaml
|
|
402
|
-
- uses: aarwitz/tapp@v0.17.
|
|
403
|
+
- uses: aarwitz/tapp@v0.17.9 # or pin the reviewed release commit SHA
|
|
403
404
|
with:
|
|
404
405
|
platform: android
|
|
405
406
|
android-app-id: com.acme.app
|
|
@@ -458,6 +459,15 @@ only an optional authoring/enrichment layer (`tapp_flow_generate`, `assert_ai`,
|
|
|
458
459
|
The first tool call builds the harness once (~2 min, cached in `~/.tapp`; rebuilt automatically
|
|
459
460
|
if you switch simulators). All captures land in `~/.tapp/captures/`.
|
|
460
461
|
|
|
462
|
+
## Feedback
|
|
463
|
+
|
|
464
|
+
Tapp accepts feedback the way agents already work: as GitHub issues on
|
|
465
|
+
[aarwitz/tapp](https://github.com/aarwitz/tapp/issues). `tapp feedback "short title" --body "…"`
|
|
466
|
+
(or the `tapp_feedback` MCP tool) drafts an issue with the version, platform availability, and latest
|
|
467
|
+
capture id filled in and home paths and tokens redacted; `--submit` files it with your authenticated
|
|
468
|
+
`gh`. Drafts are the default because issues are public — an agent should show you the draft first.
|
|
469
|
+
Nothing is uploaded automatically; captures stay on your machine.
|
|
470
|
+
|
|
461
471
|
## License
|
|
462
472
|
|
|
463
473
|
[MIT](./LICENSE)
|
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",
|
|
@@ -282,6 +282,7 @@ function safeCommandUsage(verb) {
|
|
|
282
282
|
actor: "tapp actor set NAME --email-env ENV --password-env ENV [--replace] [--project-dir DIR]\ntapp actor list [repo]",
|
|
283
283
|
app: "tapp app [repo] [--no-open] [--port PORT]",
|
|
284
284
|
report: "tapp report [captureId|latest]",
|
|
285
|
+
feedback: "tapp feedback \"short title\" [--body TEXT|--body-file FILE] [--type bug|idea|question] [--capture ID|latest|none] [--as human] [--submit] [--json]\n Drafts a public GitHub issue on aarwitz/tapp about tapp itself; --submit files it with the authenticated gh CLI.\n Exit codes: 0 drafted or filed · 1 gh submission failed · 2 usage error",
|
|
285
286
|
doctor: "tapp doctor [--json]\n Exit codes: 0 environment healthy · 1 blocked (fix ❌ items)",
|
|
286
287
|
install: "tapp install",
|
|
287
288
|
mcp: "tapp mcp",
|
|
@@ -301,7 +302,7 @@ if (["--help", "-h"].includes(command)) {
|
|
|
301
302
|
const knownCommands = new Set([
|
|
302
303
|
"help", "version", "--version", "-v", "mcp", "init", "focus", "explore", "qa", "open",
|
|
303
304
|
"tree", "shot", "screenshot", "apps", "build", "flow", "task", "contract", "scenario", "map",
|
|
304
|
-
"pr", "plan", "baseline", "ci", "actor", "app", "studio", "report", "doctor", "install",
|
|
305
|
+
"pr", "plan", "baseline", "ci", "actor", "app", "studio", "report", "doctor", "install", "feedback",
|
|
305
306
|
]);
|
|
306
307
|
if (!knownCommands.has(command)) {
|
|
307
308
|
console.error(`❌ Unknown command: ${command}`);
|
|
@@ -695,15 +696,28 @@ switch (command) {
|
|
|
695
696
|
const launchOptions = iosLaunchOptions(flags, rest);
|
|
696
697
|
let target = positionals[0] || "";
|
|
697
698
|
let baselineFindings;
|
|
699
|
+
let baselineCaptureContext = null;
|
|
698
700
|
if (flags.baseline) {
|
|
699
701
|
try {
|
|
700
702
|
const parsed = JSON.parse(fs.readFileSync(flags.baseline, "utf8"));
|
|
701
703
|
baselineFindings = Array.isArray(parsed) ? parsed : parsed.findings;
|
|
704
|
+
// Accept the 0.17.7 gate JSON's stamp-shaped `capture` too (explore JSON never
|
|
705
|
+
// stamped that key — there it is the evidence-folder record).
|
|
706
|
+
if (!Array.isArray(parsed)) baselineCaptureContext = parsed.captureContext || (parsed.capture?.viewport ? parsed.capture : null);
|
|
702
707
|
} catch (e) {
|
|
703
708
|
console.error(`❌ Could not read baseline ${flags.baseline}: ${e.message}`);
|
|
704
709
|
process.exit(2);
|
|
705
710
|
}
|
|
706
711
|
}
|
|
712
|
+
// The same honesty rule the CI gate applies: a baseline captured at another device/viewport
|
|
713
|
+
// makes "resolved" a layout diff, not a fix — say so on the command the JSON hint points at.
|
|
714
|
+
const applyCaptureMismatch = (structured) => {
|
|
715
|
+
if (!structured?.regression || !baselineCaptureContext || !structured.captureContext) return;
|
|
716
|
+
if (JSON.stringify(baselineCaptureContext) === JSON.stringify(structured.captureContext)) return;
|
|
717
|
+
structured.regression.captureMismatch = { baseline: baselineCaptureContext, current: structured.captureContext };
|
|
718
|
+
const describe = (c) => [c?.device, c?.viewport ? `${c.viewport.width}x${c.viewport.height}` : null, c?.deviceScaleFactor ? `@${c.deviceScaleFactor}x` : null].filter(Boolean).join(" ") || "unknown";
|
|
719
|
+
console.error(`⚠️ Capture mismatch: the baseline was captured at ${describe(baselineCaptureContext)}, this run at ${describe(structured.captureContext)}. "Resolved" findings may reflect layout differences, not fixes — re-baseline at the same device/viewport to compare honestly.`);
|
|
720
|
+
};
|
|
707
721
|
const engine = await engineImport();
|
|
708
722
|
// Source-preparing bare explore (ADR-0005 §5): no explicit target + a repo application model →
|
|
709
723
|
// drive the model's default target end to end. Managed web is built/started/waited-for and
|
|
@@ -734,6 +748,7 @@ switch (command) {
|
|
|
734
748
|
});
|
|
735
749
|
finishProgress();
|
|
736
750
|
if (r.error) { printEngineError(r); process.exit(1); }
|
|
751
|
+
applyCaptureMismatch(r.structured);
|
|
737
752
|
console.log(r.text);
|
|
738
753
|
if (flags.json && typeof flags.json === "string") {
|
|
739
754
|
fs.writeFileSync(flags.json, JSON.stringify(r.structured, null, 2));
|
|
@@ -804,6 +819,7 @@ switch (command) {
|
|
|
804
819
|
printEngineError(r);
|
|
805
820
|
process.exit(1);
|
|
806
821
|
}
|
|
822
|
+
applyCaptureMismatch(r.structured);
|
|
807
823
|
console.log(r.text);
|
|
808
824
|
if (flags.json && typeof flags.json === "string") {
|
|
809
825
|
fs.writeFileSync(flags.json, JSON.stringify(r.structured, null, 2));
|
|
@@ -1117,8 +1133,16 @@ switch (command) {
|
|
|
1117
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`);
|
|
1118
1134
|
break;
|
|
1119
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
|
+
}
|
|
1120
1144
|
if (!["run", "validate"].includes(verb) || !flowPath) {
|
|
1121
|
-
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>");
|
|
1122
1146
|
process.exit(2);
|
|
1123
1147
|
}
|
|
1124
1148
|
const absolute = path.resolve(flowPath);
|
|
@@ -1126,7 +1150,7 @@ switch (command) {
|
|
|
1126
1150
|
console.error(`❌ Flow not found: ${absolute}`);
|
|
1127
1151
|
process.exit(2);
|
|
1128
1152
|
}
|
|
1129
|
-
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"));
|
|
1130
1154
|
let flow;
|
|
1131
1155
|
try { flow = loadFlowFile(absolute); } catch (error) {
|
|
1132
1156
|
console.error(`❌ Invalid Flow: ${error.message}`);
|
|
@@ -1141,6 +1165,13 @@ switch (command) {
|
|
|
1141
1165
|
console.error(`❌ Unsupported Flow platform: ${platform}`);
|
|
1142
1166
|
process.exit(2);
|
|
1143
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
|
+
}
|
|
1144
1175
|
if (verb === "validate") {
|
|
1145
1176
|
const taskCount = Array.isArray(flow.taskPlan) ? flow.taskPlan.length : 0;
|
|
1146
1177
|
console.log(`✅ Valid ${platform} Flow — ${flow.name} (${flow.steps.length} deterministic steps${taskCount ? ` compiled from ${taskCount} Task call${taskCount === 1 ? "" : "s"}` : ""})`);
|
|
@@ -1829,6 +1860,58 @@ switch (command) {
|
|
|
1829
1860
|
break;
|
|
1830
1861
|
}
|
|
1831
1862
|
|
|
1863
|
+
case "feedback": {
|
|
1864
|
+
const { flags, positionals } = parseVerbArgs(rest);
|
|
1865
|
+
const usage = 'Usage: tapp feedback "short title" [--body TEXT|--body-file FILE] [--type bug|idea|question] [--capture ID|latest|none] [--as human] [--submit] [--json]';
|
|
1866
|
+
const title = positionals.join(" ").trim();
|
|
1867
|
+
if (!title) { console.error(`❌ ${usage}`); process.exit(2); }
|
|
1868
|
+
const fb = await import(path.join(packageRoot, "mcp-server", "src", "feedback.js"));
|
|
1869
|
+
const type = typeof flags.type === "string" ? flags.type : "bug";
|
|
1870
|
+
if (!fb.FEEDBACK_TYPES.includes(type)) { console.error(`❌ --type must be one of ${fb.FEEDBACK_TYPES.join(", ")}\n${usage}`); process.exit(2); }
|
|
1871
|
+
let body = typeof flags.body === "string" ? flags.body : "";
|
|
1872
|
+
if (typeof flags["body-file"] === "string") {
|
|
1873
|
+
try { body = fs.readFileSync(flags["body-file"], "utf8"); } catch (error) { console.error(`❌ Could not read --body-file: ${error.message}`); process.exit(2); }
|
|
1874
|
+
}
|
|
1875
|
+
const captureFlag = typeof flags.capture === "string" ? flags.capture : "latest";
|
|
1876
|
+
const captureId = captureFlag === "none" ? null : captureFlag === "latest" ? fb.latestCaptureId(tappHome) : captureFlag;
|
|
1877
|
+
// Platform availability comes from doctor --json so the issue carries facts, not guesses.
|
|
1878
|
+
let doctor = null;
|
|
1879
|
+
try {
|
|
1880
|
+
const d = run(process.execPath, [path.join(packageRoot, "bin", "tapp.js"), "doctor", "--json"], { env: { ...process.env, TAPP_HOME: tappHome } });
|
|
1881
|
+
doctor = JSON.parse(d.stdout);
|
|
1882
|
+
} catch { /* feedback still works without the environment summary */ }
|
|
1883
|
+
let issue;
|
|
1884
|
+
try {
|
|
1885
|
+
issue = fb.composeFeedback({ title, body, type, version: pkg.version, doctor, captureId, filedBy: flags.as === "human" ? "human" : "agent" });
|
|
1886
|
+
} catch (error) { console.error(`❌ ${error.message}\n${usage}`); process.exit(2); }
|
|
1887
|
+
const url = fb.feedbackIssueUrl(issue);
|
|
1888
|
+
let submitted = false, issueUrl = null;
|
|
1889
|
+
if (flags.submit === true) {
|
|
1890
|
+
const status = fb.ghStatus();
|
|
1891
|
+
if (!status.available) {
|
|
1892
|
+
if (flags.json === true) console.log(JSON.stringify({ ...issue, url, submitted: false, error: "gh not authenticated" }, null, 2));
|
|
1893
|
+
else console.error(`❌ The GitHub CLI is not authenticated on this machine, so nothing was filed.\n Open this prefilled issue instead (it is public):\n ${url}`);
|
|
1894
|
+
process.exit(1);
|
|
1895
|
+
}
|
|
1896
|
+
const result = fb.submitFeedbackViaGh(issue);
|
|
1897
|
+
if (!result.ok) {
|
|
1898
|
+
if (flags.json === true) console.log(JSON.stringify({ ...issue, url, submitted: false, error: result.detail }, null, 2));
|
|
1899
|
+
else console.error(`❌ gh issue create failed: ${result.detail}\n Prefilled issue link: ${url}`);
|
|
1900
|
+
process.exit(1);
|
|
1901
|
+
}
|
|
1902
|
+
submitted = true; issueUrl = result.url;
|
|
1903
|
+
}
|
|
1904
|
+
if (flags.json === true) {
|
|
1905
|
+
console.log(JSON.stringify({ ...issue, url, submitted, issueUrl }, null, 2));
|
|
1906
|
+
break;
|
|
1907
|
+
}
|
|
1908
|
+
if (submitted) { ok("Filed feedback", issueUrl); break; }
|
|
1909
|
+
console.log(`📝 Feedback draft (not filed — issues on ${fb.FEEDBACK_REPO} are public; confirm with the user first):\n`);
|
|
1910
|
+
console.log(`Title: ${issue.title}\nLabels: ${issue.labels.join(", ")}\n\n${issue.body}`);
|
|
1911
|
+
console.log(`File it: tapp feedback ${JSON.stringify(issue.title)} … --submit (uses your authenticated gh)\nOr open: ${url}`);
|
|
1912
|
+
break;
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1832
1915
|
case "version":
|
|
1833
1916
|
case "--version":
|
|
1834
1917
|
case "-v": {
|
|
@@ -1859,6 +1942,8 @@ Repository & release:
|
|
|
1859
1942
|
(--explore grounds the UI Map · --url URL · --platform · --dry-run · --refresh)
|
|
1860
1943
|
tapp baseline create [repo] Run/import a conclusive full gate and save a target-scoped baseline
|
|
1861
1944
|
tapp report [captureId] Open the HTML evidence page for a capture (default: latest)
|
|
1945
|
+
tapp feedback "title" Send a bug, idea, or question to the tapp maintainers as a GitHub issue
|
|
1946
|
+
(drafts by default · --submit files it · issues are public)
|
|
1862
1947
|
tapp ci install [repo] Generate a reviewable target-aware GitHub workflow + CI manifest
|
|
1863
1948
|
tapp actor set NAME Configure an actor using environment-variable names only (never values)
|
|
1864
1949
|
tapp actor list [repo] Inspect named actors, sessions, provisioning, and secret env bindings
|
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
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Feedback to the tapp maintainers, the agent-native way: a GitHub issue on aarwitz/tapp.
|
|
2
|
+
//
|
|
3
|
+
// Shared by `tapp feedback` (CLI) and `tapp_feedback` (MCP). Both default to a DRAFT — the
|
|
4
|
+
// composed issue plus a prefilled github.com/.../issues/new URL — and only file the issue when
|
|
5
|
+
// explicitly asked (`--submit` / `submit: true`), because issues are public and an agent must not
|
|
6
|
+
// publish on a user's behalf without consent. Filing uses the machine's authenticated `gh` CLI;
|
|
7
|
+
// nothing is uploaded: captures stay local and only the capture id is referenced.
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
|
|
13
|
+
export const FEEDBACK_REPO = "aarwitz/tapp";
|
|
14
|
+
export const FEEDBACK_TYPES = ["bug", "idea", "question"];
|
|
15
|
+
const TYPE_LABEL = { bug: "bug", idea: "idea", question: "question" };
|
|
16
|
+
|
|
17
|
+
/** Strip home directories and token-shaped secrets before anything leaves the machine. */
|
|
18
|
+
export function redactText(text, home = os.homedir()) {
|
|
19
|
+
let s = String(text ?? "");
|
|
20
|
+
if (home && home.length > 1) s = s.split(home).join("~");
|
|
21
|
+
s = s
|
|
22
|
+
.replace(/\/Users\/[^/\s"']+/g, "~")
|
|
23
|
+
.replace(/\/home\/[^/\s"']+/g, "~")
|
|
24
|
+
.replace(/[A-Za-z]:\\Users\\[^\\\s"']+/g, "~")
|
|
25
|
+
.replace(/\b(sk-ant-[A-Za-z0-9_-]{8,}|ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AKIA[A-Z0-9]{16})\b/g, "[redacted]");
|
|
26
|
+
return s;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Newest capture directory name under TAPP_HOME, or null. Only the id is ever shared. */
|
|
30
|
+
export function latestCaptureId(tappHome) {
|
|
31
|
+
const dir = path.join(tappHome, "captures");
|
|
32
|
+
if (!fs.existsSync(dir)) return null;
|
|
33
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
34
|
+
.filter((e) => e.isDirectory())
|
|
35
|
+
.map((e) => ({ name: e.name, mtime: fs.statSync(path.join(dir, e.name)).mtimeMs }))
|
|
36
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
37
|
+
return entries[0]?.name ?? null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** One line of platform availability from `tapp doctor --json` output. */
|
|
41
|
+
export function summarizeDoctor(doctor) {
|
|
42
|
+
if (!doctor || typeof doctor !== "object") return null;
|
|
43
|
+
const p = doctor.platforms || {};
|
|
44
|
+
const parts = [];
|
|
45
|
+
if (p.ios) {
|
|
46
|
+
const detail = [p.ios.xcode, p.ios.bootedSimulator ? `${p.ios.bootedSimulator} booted` : ""].filter(Boolean).join(", ");
|
|
47
|
+
parts.push(`iOS ${p.ios.available ? "✅" : "⬜"}${detail ? ` (${detail})` : ""}`);
|
|
48
|
+
}
|
|
49
|
+
if (p.android) parts.push(`Android ${p.android.adb ? "✅" : "⬜"}${p.android.devicesConnected ? ` (${p.android.devicesConnected} device)` : ""}`);
|
|
50
|
+
if (p.web) parts.push(`web ${p.web.available ? "✅" : "⬜"}`);
|
|
51
|
+
return parts.join(" · ") || null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Build the issue: redacted title/body plus an automatic, path-free context footer. */
|
|
55
|
+
export function composeFeedback({
|
|
56
|
+
title, body = "", type = "bug", version = "unknown", node = process.version,
|
|
57
|
+
platform = `${process.platform} ${process.arch}`, doctor = null, captureId = null, filedBy = "agent",
|
|
58
|
+
}) {
|
|
59
|
+
const cleanTitle = redactText(title).trim();
|
|
60
|
+
if (!cleanTitle) throw new Error("feedback needs a short title");
|
|
61
|
+
if (!FEEDBACK_TYPES.includes(type)) throw new Error(`type must be one of: ${FEEDBACK_TYPES.join(", ")}`);
|
|
62
|
+
const context = [`- tapp ${version} · node ${node} · ${platform}`];
|
|
63
|
+
const platforms = summarizeDoctor(doctor);
|
|
64
|
+
if (platforms) context.push(`- platforms: ${platforms}`);
|
|
65
|
+
if (captureId) context.push(`- capture: \`${redactText(captureId)}\` (kept locally — nothing is uploaded; evidence can be shared privately on request)`);
|
|
66
|
+
const description = redactText(body).trim() || "_(no description provided)_";
|
|
67
|
+
const byline = filedBy === "agent" ? ", filed by a coding agent on the user's behalf" : "";
|
|
68
|
+
const full = `${description}\n\n---\n_Filed with \`tapp feedback\` (${type}${byline}). This issue is public._\n${context.join("\n")}\n`;
|
|
69
|
+
const labels = ["feedback", TYPE_LABEL[type]];
|
|
70
|
+
if (filedBy === "agent") labels.push("agent-filed");
|
|
71
|
+
return { title: cleanTitle.slice(0, 200), body: full, labels };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Prefilled "new issue" link — works for anyone with a GitHub account, no CLI needed. */
|
|
75
|
+
export function feedbackIssueUrl({ title, body, labels }) {
|
|
76
|
+
const url = new URL(`https://github.com/${FEEDBACK_REPO}/issues/new`);
|
|
77
|
+
url.searchParams.set("title", title);
|
|
78
|
+
url.searchParams.set("body", body);
|
|
79
|
+
url.searchParams.set("labels", labels.join(","));
|
|
80
|
+
return url.toString();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function ghStatus(ghBin = process.env.TAPP_GH_BIN || "gh") {
|
|
84
|
+
const r = spawnSync(ghBin, ["auth", "status"], { encoding: "utf8" });
|
|
85
|
+
return { available: r.status === 0, detail: (r.stderr || r.stdout || "").trim() };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function submitFeedbackViaGh(issue, ghBin = process.env.TAPP_GH_BIN || "gh") {
|
|
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, [
|
|
93
|
+
"issue", "create", "--repo", FEEDBACK_REPO,
|
|
94
|
+
"--title", issue.title, "--body-file", "-",
|
|
95
|
+
...(withLabels ? ["--label", issue.labels.join(",")] : []),
|
|
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; }
|
|
100
|
+
const out = (r.stdout || "").trim();
|
|
101
|
+
const err = (r.stderr || "").trim();
|
|
102
|
+
const url = (out.match(/https:\/\/github\.com\/\S+/) || [])[0] || null;
|
|
103
|
+
return { ok: r.status === 0 && Boolean(url), url, labelsApplied, detail: r.status === 0 ? out : (err || out) };
|
|
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);
|
|
@@ -2438,6 +2467,27 @@ server.setRequestHandler(GetPromptRequestSchema, async (request) => {
|
|
|
2438
2467
|
|
|
2439
2468
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
2440
2469
|
tools: [
|
|
2470
|
+
{
|
|
2471
|
+
name: "tapp_feedback",
|
|
2472
|
+
title: "Send feedback to the tapp maintainers",
|
|
2473
|
+
description:
|
|
2474
|
+
"Draft a public GitHub issue on aarwitz/tapp about tapp itself — a bug in tapp, an idea, or a " +
|
|
2475
|
+
"question (not a finding about the app under test). Adds the tapp version, platform availability, " +
|
|
2476
|
+
"and the latest capture id automatically and redacts home paths and tokens. Default is a DRAFT: " +
|
|
2477
|
+
"returns the composed issue plus a prefilled github.com URL the user can open. Pass submit:true only " +
|
|
2478
|
+
"after the user has agreed to file a public issue; filing uses this machine's authenticated GitHub CLI.",
|
|
2479
|
+
inputSchema: {
|
|
2480
|
+
type: "object",
|
|
2481
|
+
properties: {
|
|
2482
|
+
title: { type: "string", description: "Short, specific title" },
|
|
2483
|
+
body: { type: "string", description: "What happened, what you expected, the exact command or tool call. No secrets or private source." },
|
|
2484
|
+
type: { type: "string", enum: ["bug", "idea", "question"], description: "Default bug" },
|
|
2485
|
+
capture: { type: "string", description: "Capture id to reference, 'latest' (default) or 'none'" },
|
|
2486
|
+
submit: { type: "boolean", description: "File the issue now with gh (needs the user's consent). Default false = draft only." },
|
|
2487
|
+
},
|
|
2488
|
+
required: ["title"],
|
|
2489
|
+
},
|
|
2490
|
+
},
|
|
2441
2491
|
{
|
|
2442
2492
|
name: "tapp_health",
|
|
2443
2493
|
title: "Check Tapp readiness",
|
|
@@ -3170,6 +3220,40 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
3170
3220
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
3171
3221
|
const { name, arguments: args = {} } = request.params;
|
|
3172
3222
|
|
|
3223
|
+
if (name === "tapp_feedback") {
|
|
3224
|
+
const fb = await import("./feedback.js");
|
|
3225
|
+
const { fileURLToPath } = await import("node:url");
|
|
3226
|
+
const packageRootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
3227
|
+
const title = String(args.title || "").trim();
|
|
3228
|
+
if (!title) return errorResult("tapp_feedback needs a short title", {});
|
|
3229
|
+
const type = typeof args.type === "string" ? args.type : "bug";
|
|
3230
|
+
if (!fb.FEEDBACK_TYPES.includes(type)) return errorResult(`type must be one of ${fb.FEEDBACK_TYPES.join(", ")}`, {});
|
|
3231
|
+
const tappHomeDir = (process.env.TAPP_HOME || path.join(os.homedir(), ".tapp")).trim();
|
|
3232
|
+
const captureArg = typeof args.capture === "string" ? args.capture : "latest";
|
|
3233
|
+
const captureId = captureArg === "none" ? null : captureArg === "latest" ? fb.latestCaptureId(tappHomeDir) : captureArg;
|
|
3234
|
+
let doctor = null;
|
|
3235
|
+
try {
|
|
3236
|
+
const d = await runCommand(process.execPath, [path.join(packageRootDir, "bin", "tapp.js"), "doctor", "--json"], { timeoutMs: 60_000, env: { TAPP_HOME: tappHomeDir } });
|
|
3237
|
+
doctor = JSON.parse(d.stdout);
|
|
3238
|
+
} catch { /* optional context */ }
|
|
3239
|
+
const version = JSON.parse(fs.readFileSync(path.join(packageRootDir, "package.json"), "utf8")).version;
|
|
3240
|
+
let issue;
|
|
3241
|
+
try { issue = fb.composeFeedback({ title, body: String(args.body || ""), type, version, doctor, captureId, filedBy: "agent" }); }
|
|
3242
|
+
catch (error) { return errorResult(error.message, {}); }
|
|
3243
|
+
const url = fb.feedbackIssueUrl(issue);
|
|
3244
|
+
if (args.submit === true) {
|
|
3245
|
+
const status = fb.ghStatus();
|
|
3246
|
+
if (!status.available) return errorResult("The GitHub CLI is not authenticated on this machine, so nothing was filed. Give the user the prefilled link instead.", { url, ...issue, submitted: false });
|
|
3247
|
+
const result = fb.submitFeedbackViaGh(issue);
|
|
3248
|
+
if (!result.ok) return errorResult(`gh issue create failed: ${result.detail}`, { url, ...issue, submitted: false });
|
|
3249
|
+
return richResult(`✅ Filed feedback: ${result.url}`, { submitted: true, issueUrl: result.url, ...issue });
|
|
3250
|
+
}
|
|
3251
|
+
return richResult(
|
|
3252
|
+
`📝 Feedback drafted, not filed. Issues on ${fb.FEEDBACK_REPO} are public — confirm with the user, then call tapp_feedback again with submit: true, or give them this prefilled link:\n${url}\n\nTitle: ${issue.title}\nLabels: ${issue.labels.join(", ")}\n\n${issue.body}`,
|
|
3253
|
+
{ submitted: false, url, ...issue },
|
|
3254
|
+
);
|
|
3255
|
+
}
|
|
3256
|
+
|
|
3173
3257
|
if (name === "tapp_health") {
|
|
3174
3258
|
const coreChecks = [];
|
|
3175
3259
|
coreChecks.push({
|
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
|
@@ -183,8 +183,9 @@ run_harness_test() {
|
|
|
183
183
|
"OCQA_BUNDLE_ID": "$bundle_id",
|
|
184
184
|
"OCQA_MAX_ACTIONS": "$max_actions",
|
|
185
185
|
"OCQA_TIMEOUT_SECONDS": "$timeout_secs",
|
|
186
|
-
"OCQA_TEST_EMAIL": "${OCQA_TEST_EMAIL:-
|
|
187
|
-
"OCQA_TEST_PASSWORD": "${OCQA_TEST_PASSWORD:-
|
|
186
|
+
"OCQA_TEST_EMAIL": "${OCQA_TEST_EMAIL:-}",
|
|
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
|
|
|
@@ -447,8 +448,8 @@ OCQA_COMPLETE:{\"actions\":0,\"states\":0,\"issues\":1,\"screens\":\"\",\"outcom
|
|
|
447
448
|
"OCQA_SESSION_CMD_PATH": "$SESS_CMD",
|
|
448
449
|
"OCQA_SESSION_RESULT_PATH": "$SESS_RES",
|
|
449
450
|
"OCQA_SESSION_TIMEOUT": "$SESS_TIMEOUT",
|
|
450
|
-
"OCQA_TEST_EMAIL": "${OCQA_TEST_EMAIL:-
|
|
451
|
-
"OCQA_TEST_PASSWORD": "${OCQA_TEST_PASSWORD:-
|
|
451
|
+
"OCQA_TEST_EMAIL": "${OCQA_TEST_EMAIL:-}",
|
|
452
|
+
"OCQA_TEST_PASSWORD": "${OCQA_TEST_PASSWORD:-}"$sess_args_line$sess_env_line
|
|
452
453
|
}
|
|
453
454
|
CONF
|
|
454
455
|
xctestrun=$(find "$HARNESS_DERIVED/Build/Products" -name "*.xctestrun" 2>/dev/null | head -1)
|
package/skills/tapp/SKILL.md
CHANGED
|
@@ -92,7 +92,7 @@ After the focused fast path, read returned `elements[]` before any remaining act
|
|
|
92
92
|
ids or visible labels, check `hittable`, tap a field before typing, and wait for navigation or async
|
|
93
93
|
content. Use coordinates only as a last resort. End the session when finished.
|
|
94
94
|
|
|
95
|
-
Do not edit the app merely because testing found a defect unless the user also asked for a fix. State
|
|
96
|
-
|
|
95
|
+
Do not edit the app merely because testing found a defect unless the user also asked for a fix. State what the evidence proves and what remains untested.
|
|
96
|
+
If tapp itself misbehaves, draft feedback with `npx -y @aarwitz/tapp@latest feedback "title"` or `tapp_feedback`; it is public — draft first, `--submit` only with the user's OK.
|
|
97
97
|
|
|
98
98
|
Read [references/commands.md](references/commands.md) only for exact CLI/MCP syntax, Flow replay, credentials, or platform prerequisites.
|
|
@@ -14,6 +14,7 @@ npx -y @aarwitz/tapp@latest tree [target] --json
|
|
|
14
14
|
npx -y @aarwitz/tapp@latest shot
|
|
15
15
|
npx -y @aarwitz/tapp@latest report latest
|
|
16
16
|
npx -y @aarwitz/tapp@latest doctor
|
|
17
|
+
npx -y @aarwitz/tapp@latest feedback "short title" --body "what happened" --type bug|idea|question # draft; add --submit to file (public)
|
|
17
18
|
```
|
|
18
19
|
|
|
19
20
|
Platform examples:
|
|
@@ -100,6 +101,8 @@ ask the user rather than pretending the explored surface was complete.
|
|
|
100
101
|
Flow YAML belongs under `.tapp/flows/` and can replay without a model or API key:
|
|
101
102
|
|
|
102
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
|
|
103
106
|
npx -y @aarwitz/tapp@latest flow run .tapp/flows/smoke.yml
|
|
104
107
|
npx -y @aarwitz/tapp@latest ci
|
|
105
108
|
```
|