@aarwitz/tapp 0.17.0-rc.9 → 0.17.1
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/marketplace.json +34 -0
- package/.claude-plugin/plugin.json +33 -0
- package/AGENTS.md +44 -15
- package/Harness/OCQAHarnessUITests/ExplorerTests.swift +65 -7
- package/README.md +141 -81
- package/bin/tapp.js +253 -64
- package/docs/BROWSER-PRODUCT.md +1 -1
- package/docs/application-model.md +12 -3
- package/docs/scenarios.md +1 -1
- package/mcp-server/src/android-driver.js +13 -2
- package/mcp-server/src/android-explorer.js +3 -1
- package/mcp-server/src/android-flow.js +18 -1
- package/mcp-server/src/application-model.js +83 -13
- package/mcp-server/src/ci-report.js +2 -2
- package/mcp-server/src/ci-setup.js +4 -4
- package/mcp-server/src/environment-preflight.js +43 -0
- package/mcp-server/src/focused-navigation.js +271 -0
- package/mcp-server/src/html-report.js +3 -2
- package/mcp-server/src/index.js +565 -134
- package/mcp-server/src/pr-selection.js +2 -2
- package/mcp-server/src/product-operations.js +111 -6
- package/mcp-server/src/report.js +17 -4
- package/mcp-server/src/ui-map.js +2 -2
- package/mcp-server/src/web-explorer.js +123 -10
- package/mcp-server/src/web-flow.js +17 -1
- package/package.json +6 -4
- package/scripts/ci-gate.sh +42 -0
- package/scripts/flow_lib.py +1 -1
- package/scripts/quick-capture.sh +44 -13
- package/scripts/run-flow.sh +12 -1
- package/skills/tapp/SKILL.md +95 -0
- package/skills/tapp/agents/openai.yaml +4 -0
- package/skills/tapp/references/commands.md +105 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tapp",
|
|
3
|
+
"description": "The official Tapp plugin: the agent skill and MCP tools for testing real iOS, Android, and web app surfaces.",
|
|
4
|
+
"owner": {
|
|
5
|
+
"name": "Aaron Horowitz",
|
|
6
|
+
"url": "https://github.com/aarwitz"
|
|
7
|
+
},
|
|
8
|
+
"plugins": [
|
|
9
|
+
{
|
|
10
|
+
"name": "tapp",
|
|
11
|
+
"source": {
|
|
12
|
+
"source": "npm",
|
|
13
|
+
"package": "@aarwitz/tapp"
|
|
14
|
+
},
|
|
15
|
+
"description": "Give Claude hands and eyes on iOS, Android, and web apps, with exploration, replayable flows, evidence, and deterministic CI gates.",
|
|
16
|
+
"author": {
|
|
17
|
+
"name": "Aaron Horowitz",
|
|
18
|
+
"url": "https://github.com/aarwitz"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://runtapp.com/",
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"keywords": [
|
|
23
|
+
"ios",
|
|
24
|
+
"simulator",
|
|
25
|
+
"testing",
|
|
26
|
+
"qa",
|
|
27
|
+
"xcuitest",
|
|
28
|
+
"mcp",
|
|
29
|
+
"agent-skill",
|
|
30
|
+
"copilot"
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tapp",
|
|
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.1",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Aaron Horowitz",
|
|
7
|
+
"url": "https://github.com/aarwitz"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://runtapp.com/",
|
|
10
|
+
"repository": "https://github.com/aarwitz/tapp",
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"keywords": [
|
|
13
|
+
"ios",
|
|
14
|
+
"simulator",
|
|
15
|
+
"testing",
|
|
16
|
+
"qa",
|
|
17
|
+
"xcuitest",
|
|
18
|
+
"mcp",
|
|
19
|
+
"agent-skill",
|
|
20
|
+
"copilot"
|
|
21
|
+
],
|
|
22
|
+
"mcpServers": {
|
|
23
|
+
"tapp": {
|
|
24
|
+
"command": "npx",
|
|
25
|
+
"args": [
|
|
26
|
+
"-y",
|
|
27
|
+
"@aarwitz/tapp@0.17.1",
|
|
28
|
+
"mcp"
|
|
29
|
+
],
|
|
30
|
+
"cwd": "${CLAUDE_PROJECT_DIR}"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
package/AGENTS.md
CHANGED
|
@@ -1,26 +1,41 @@
|
|
|
1
1
|
# Tapp agent playbook
|
|
2
2
|
|
|
3
|
-
You (the agent) have Tapp: hands
|
|
4
|
-
|
|
3
|
+
You (the agent) have Tapp: hands and eyes on real app surfaces — iOS simulators, Android
|
|
4
|
+
emulators/devices, plus (beta) web apps in a real browser. Release judgment belongs only to Tapp's
|
|
5
|
+
repository-connected deterministic gate.
|
|
5
6
|
|
|
6
7
|
## No MCP connected? Just run the CLI
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
The core inspect, explore, replay, and gate capabilities work as plain commands — no Tapp account,
|
|
10
|
+
server, or global install. `[target]` is
|
|
9
11
|
optional: with nothing, tapp finds + builds the Xcode project in the cwd (or falls back to
|
|
10
12
|
the app already on the simulator); it also accepts a repo dir, a `path/to/App.app`, a
|
|
11
13
|
bundle id, or (web) an http(s) URL. You never need to know a bundle id up front.
|
|
12
14
|
|
|
13
15
|
```bash
|
|
14
|
-
npx -y @aarwitz/tapp explore [target] # autonomous exploration → findings + evidence (observation, not a gate; ≈ tapp_explore)
|
|
15
|
-
npx -y @aarwitz/tapp
|
|
16
|
-
npx -y @aarwitz/tapp
|
|
17
|
-
npx -y @aarwitz/tapp
|
|
18
|
-
npx -y @aarwitz/tapp
|
|
19
|
-
npx -y @aarwitz/tapp
|
|
20
|
-
npx -y @aarwitz/tapp
|
|
21
|
-
npx -y @aarwitz/tapp
|
|
16
|
+
npx -y @aarwitz/tapp@latest explore [target] # autonomous exploration → findings + evidence (observation, not a gate; ≈ tapp_explore)
|
|
17
|
+
npx -y @aarwitz/tapp@latest focus "SCREEN OR CONTROL" [target] # source-locate + shortest observed route + screenshot
|
|
18
|
+
npx -y @aarwitz/tapp@latest explore https://your-app.example --watch # web: visibly follow the same exploration
|
|
19
|
+
npx -y @aarwitz/tapp@latest open [target] # launch + screen summary + screenshot saved to a file (≈ tapp_open_app)
|
|
20
|
+
npx -y @aarwitz/tapp@latest tree [target] # accessibility tree, --json for every element (≈ tapp_ui_tree)
|
|
21
|
+
npx -y @aarwitz/tapp@latest shot # screenshot the booted sim → file path (≈ tapp_screenshot)
|
|
22
|
+
npx -y @aarwitz/tapp@latest apps # what's installed on the simulator, with bundle ids
|
|
23
|
+
npx -y @aarwitz/tapp@latest build [dir] # build the app in an Xcode repo + install it (≈ tapp_build)
|
|
24
|
+
npx -y @aarwitz/tapp@latest explore app.apk --platform android --app-id com.acme.app
|
|
25
|
+
npx -y @aarwitz/tapp@latest flow run .tapp/flows/smoke.yml # committed, keyless E2E replay
|
|
22
26
|
```
|
|
23
27
|
|
|
28
|
+
If repository onboarding detects multiple application targets, target detection is deterministic but
|
|
29
|
+
the choice is the user's. Explicit `init --explore` asks even when the model has a saved default; a
|
|
30
|
+
later bare `explore` may consume that default. In a human TTY, Tapp displays a numbered selector and
|
|
31
|
+
continues in the same command. A non-interactive CLI prints the exact choices and exits before
|
|
32
|
+
building. MCP returns
|
|
33
|
+
`reason: "target-selection-required"` with structured `choices[]` (`platform`, `name`, `sourcePath`,
|
|
34
|
+
`selector`, and exact `command`). **Do not pick one yourself.** Present those choices to the user
|
|
35
|
+
with the client's native multiple-choice question UI when available, then rerun using the selected
|
|
36
|
+
`--platform` and `--target`. Plain chat can list the same choices when the client has no question
|
|
37
|
+
widget.
|
|
38
|
+
|
|
24
39
|
For focused web evidence, `open` and `tree` accept one semantic interaction plus an async content
|
|
25
40
|
wait: `tapp open https://example.com --tap "Not now" --wait-for "Dashboard"`. Tapp waits for the
|
|
26
41
|
page to stabilize before capturing it and warns honestly if the bounded wait ends while it is still
|
|
@@ -32,7 +47,9 @@ that IS the screen. If you cannot (Cursor, VS Code Copilot), connect the MCP ser
|
|
|
32
47
|
instead: its tool results carry the screenshot inline. Screen *recordings* are for the
|
|
33
48
|
human: on **iOS**, `tapp explore` records the full exploration and embeds it in the report.html
|
|
34
49
|
evidence page (Android does not currently record video) — tell the user the report path so they can
|
|
35
|
-
watch it.
|
|
50
|
+
watch it. On **web**, explicit `--watch` opens the isolated Playwright Chromium window and overlays
|
|
51
|
+
Tapp's current action and pointer; the overlay is omitted from evidence screenshots. It does not
|
|
52
|
+
drive the person's existing/default browser profile.
|
|
36
53
|
|
|
37
54
|
The interactive session/record loop is MCP-only (it needs a long-lived process). Flow replay is
|
|
38
55
|
also available in the CLI. The rest of this playbook assumes the `tapp_*` MCP tools are connected. With
|
|
@@ -43,7 +60,8 @@ installs, returns the bundle id) → `tapp_explore {appBundleId}`.
|
|
|
43
60
|
|
|
44
61
|
| The user wants… | Use | NOT |
|
|
45
62
|
|---|---|---|
|
|
46
|
-
| "Show me / screenshot a screen" | `tapp_open_app` (launch + screenshot + tree, ~15s) | `tapp_explore` (a full multi-minute
|
|
63
|
+
| "Show me / screenshot a screen" | `tapp_open_app` (launch + screenshot + tree, ~15s) | `tapp_explore` (a full multi-minute exploration) |
|
|
64
|
+
| "Find/reach this named screen or control" | `tapp_session_start` with `focus`, or `tapp_focus`; plain CLI: `tapp focus` | screenshot-by-screenshot wandering |
|
|
47
65
|
| "Tap through / drive / fill a form / log in" | `tapp_session_start` → `session_act` loop | repeated `open_app` calls (cold relaunch each time) |
|
|
48
66
|
| "Is my app broken? Find bugs" | `tapp_explore` — `appBundleId` for iOS, `androidAppId` for Android, `url` for owned web apps; returns an observation (findings + evidence), not a ship verdict — gate a merge with the CI gate (`tapp ci` CLI / the GitHub Action) + a contract | a manual session (exploration is autonomous) |
|
|
49
67
|
| "Make this flow a repeatable test" | drive it in a session, then `tapp_flow_save`; replay with `tapp_flow_run` | re-driving it by hand every time |
|
|
@@ -51,8 +69,16 @@ installs, returns the bundle id) → `tapp_explore {appBundleId}`.
|
|
|
51
69
|
|
|
52
70
|
## Session driving (the Playwright loop)
|
|
53
71
|
|
|
72
|
+
After an initial grounding exploration, use the source-connected fast path for any named
|
|
73
|
+
destination. Tapp searches the repository, matches the requested surface to `.tapp/ui-map.json`, and executes only the shortest
|
|
74
|
+
runtime-observed route. Source tells Tapp where intent lives; observed UI evidence authorizes taps.
|
|
75
|
+
If it returns source evidence without a route, inspect the cited file/navigation source—do not
|
|
76
|
+
wander blindly or invent a route. URL-only targets correctly have no source advantage.
|
|
77
|
+
|
|
54
78
|
```
|
|
55
|
-
tapp_session_start { appBundleId: "com.acme.app"
|
|
79
|
+
tapp_session_start { appBundleId: "com.acme.app", focus: "Save storefront settings visible above keyboard", projectDir: "." }
|
|
80
|
+
tapp_focus { query: "Save storefront settings visible above keyboard" } → one-call shortest observed route
|
|
81
|
+
tapp_session_act { action: "login", email: "qa@x.com", password: "…" } → atomic fill + submit + verify
|
|
56
82
|
tapp_session_act { action: "tap", id: "Email" } → tap by a11y id OR visible label
|
|
57
83
|
tapp_session_act { action: "type", text: "qa@x.com" } → types into the focused field
|
|
58
84
|
tapp_session_act { action: "tap", id: "Sign In" }
|
|
@@ -76,7 +102,8 @@ Rules that prevent 90% of failures:
|
|
|
76
102
|
3. **`wait` after anything async** (navigation, network loads): `{action: "wait", id|text, timeoutMs}`.
|
|
77
103
|
Never assume the next screen is instantly there.
|
|
78
104
|
4. **Tap the field before typing** — `type` goes to the focused field. Tap email → type email →
|
|
79
|
-
tap password → type password.
|
|
105
|
+
tap password → type password. For sign-in, prefer the atomic `login` action: it records a
|
|
106
|
+
secret-templated replay step and avoids native secure-field refocus behavior.
|
|
80
107
|
5. Tap results: `ok` (landed), `not_hittable` (exists but disabled/covered — the harness
|
|
81
108
|
auto-dismisses keyboards and retries), `not_found` (nothing matches — re-read the tree).
|
|
82
109
|
6. One session at a time. `session_start` always begins from a fresh app launch.
|
|
@@ -120,6 +147,8 @@ without a coding agent, model, subscription, or API key. AI generation and `asse
|
|
|
120
147
|
deterministic assertions, same result every time. A failed assertion is a finding.
|
|
121
148
|
- **Generate:** `tapp_flow_generate { goal: "log in and add the first item to cart" }` —
|
|
122
149
|
grounded in the app's actually-explored screens, so it can't invent steps.
|
|
150
|
+
- **Discover the file format without MCP:** `npx -y @aarwitz/tapp@latest flow example` prints a
|
|
151
|
+
complete starter Flow; `tapp flow validate <file>` checks it without launching a target.
|
|
123
152
|
|
|
124
153
|
## Setup facts (tell the user when relevant)
|
|
125
154
|
|
|
@@ -82,6 +82,31 @@ class ExplorerTests: XCTestCase {
|
|
|
82
82
|
return fallback
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/// Opens the visual-evidence boundary only after the target app is foregrounded and its first
|
|
86
|
+
/// UI has settled. The host starts simulator recording (and clients may reveal a live preview)
|
|
87
|
+
/// at this point, then acknowledges it. The wait is deliberately bounded: video/preview
|
|
88
|
+
/// failure must never prevent the actual exploration from running.
|
|
89
|
+
private func signalSettledVisualReady() {
|
|
90
|
+
let readyPath = resolve("OCQA_VISUAL_READY_PATH")
|
|
91
|
+
guard !readyPath.isEmpty else { return }
|
|
92
|
+
let readyURL = URL(fileURLWithPath: readyPath)
|
|
93
|
+
try? FileManager.default.createDirectory(at: readyURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
|
94
|
+
try? Data("ready\n".utf8).write(to: readyURL, options: .atomic)
|
|
95
|
+
print("OCQA_STATE:visual_ready")
|
|
96
|
+
|
|
97
|
+
let startedPath = resolve("OCQA_RECORDING_STARTED_PATH")
|
|
98
|
+
guard !startedPath.isEmpty else { return }
|
|
99
|
+
let deadline = Date().addingTimeInterval(5.0)
|
|
100
|
+
while Date() < deadline && !FileManager.default.fileExists(atPath: startedPath) {
|
|
101
|
+
Thread.sleep(forTimeInterval: 0.05)
|
|
102
|
+
}
|
|
103
|
+
if FileManager.default.fileExists(atPath: startedPath) {
|
|
104
|
+
print("OCQA_STATE:visual_capture_started")
|
|
105
|
+
} else {
|
|
106
|
+
print("OCQA_STATE:visual_capture_ack_timeout")
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
85
110
|
private func loadConfig() {
|
|
86
111
|
// OCQA_CONFIG_PATH (forwarded from the host via TEST_RUNNER_OCQA_CONFIG_PATH) is
|
|
87
112
|
// authoritative and per-run — checked FIRST so each device reads its own config and
|
|
@@ -585,14 +610,34 @@ class ExplorerTests: XCTestCase {
|
|
|
585
610
|
/// the password it just typed.
|
|
586
611
|
private func sessionLogin(email: String, password: String) -> (status: String, detail: String) {
|
|
587
612
|
waitForUIStability(timeout: 2.0)
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
613
|
+
// A recorded Flow invokes login immediately after a cold launch, while a human-driven
|
|
614
|
+
// session naturally invokes it after inspecting the first tree. Poll the SAME finder for
|
|
615
|
+
// a bounded interval so those two entry paths behave identically when login fields appear
|
|
616
|
+
// after an asynchronous launch transition.
|
|
617
|
+
var textFields: [XCUIElement] = []
|
|
618
|
+
var secureFields: [XCUIElement] = []
|
|
619
|
+
var emailField: XCUIElement?
|
|
620
|
+
var passwordField: XCUIElement?
|
|
621
|
+
let fieldsDeadline = Date().addingTimeInterval(8.0)
|
|
622
|
+
repeat {
|
|
623
|
+
textFields = app.textFields.allElementsBoundByIndex.filter { $0.exists && $0.frame.width > 0 }
|
|
624
|
+
secureFields = app.secureTextFields.allElementsBoundByIndex.filter { $0.exists && $0.frame.width > 0 }
|
|
625
|
+
let plainPasswordIndex = textFields.firstIndex { f in
|
|
626
|
+
let hint = (f.identifier + " " + (f.placeholderValue ?? "") + " " + f.label).lowercased()
|
|
627
|
+
return hint.contains("password") || hint.contains("passcode")
|
|
628
|
+
}
|
|
629
|
+
passwordField = secureFields.first ?? plainPasswordIndex.map { textFields[$0] }
|
|
630
|
+
emailField = textFields.first { f in
|
|
631
|
+
let hint = (f.identifier + " " + (f.placeholderValue ?? "") + " " + f.label).lowercased()
|
|
632
|
+
return hint.contains("email") || hint.contains("e-mail") || hint.contains("user")
|
|
633
|
+
} ?? textFields.enumerated().first(where: { index, _ in
|
|
634
|
+
passwordField != nil && (plainPasswordIndex.map { index != $0 } ?? true)
|
|
635
|
+
})?.element
|
|
636
|
+
if emailField != nil && passwordField != nil { break }
|
|
637
|
+
Thread.sleep(forTimeInterval: 0.25)
|
|
638
|
+
} while Date() < fieldsDeadline
|
|
594
639
|
guard let emailF = emailField else { return ("no_login_form", "no email/username field visible") }
|
|
595
|
-
guard let passF =
|
|
640
|
+
guard let passF = passwordField else { return ("no_login_form", "no password field visible") }
|
|
596
641
|
|
|
597
642
|
replaceText(on: emailF, with: email)
|
|
598
643
|
replaceText(on: passF, with: password)
|
|
@@ -765,6 +810,12 @@ class ExplorerTests: XCTestCase {
|
|
|
765
810
|
case "type":
|
|
766
811
|
status = sessionType(value, id: target.isEmpty ? nil : target) ? "pass" : "fail"
|
|
767
812
|
if status == "fail" { detail = "no field ‘\(target)’ to type into" }
|
|
813
|
+
case "login":
|
|
814
|
+
let email = subst((step["email"] as? String) ?? "$TEST_EMAIL")
|
|
815
|
+
let password = subst((step["password"] as? String) ?? "$TEST_PASSWORD")
|
|
816
|
+
let result = sessionLogin(email: email, password: password)
|
|
817
|
+
status = result.status == "ok" ? "pass" : "fail"
|
|
818
|
+
if status == "fail" { detail = result.detail.isEmpty ? result.status : result.detail }
|
|
768
819
|
case "swipe":
|
|
769
820
|
switch target.lowercased() { case "down": app.swipeDown(); case "left": app.swipeLeft(); case "right": app.swipeRight(); default: app.swipeUp() }
|
|
770
821
|
case "back":
|
|
@@ -1025,10 +1076,17 @@ class ExplorerTests: XCTestCase {
|
|
|
1025
1076
|
_ = app.descendants(matching: .any).firstMatch.waitForExistence(timeout: 3)
|
|
1026
1077
|
_ = waitForUIStability(timeout: 2.0)
|
|
1027
1078
|
|
|
1079
|
+
signalSettledVisualReady()
|
|
1080
|
+
|
|
1028
1081
|
print("OCQA_STATE:exploration_started max_actions=\(maxActions)")
|
|
1029
1082
|
|
|
1030
1083
|
let testEmail = resolve("OCQA_TEST_EMAIL")
|
|
1031
1084
|
let testPassword = resolve("OCQA_TEST_PASSWORD")
|
|
1085
|
+
if resolve("OCQA_CREDENTIALS_EXPLICIT") == "1" {
|
|
1086
|
+
// Presence only: never print, persist, or expose credential values. Report rebuilding
|
|
1087
|
+
// needs this durable marker to distinguish "not supplied" from "supplied but unused".
|
|
1088
|
+
print("OCQA_STATE:credentials_supplied")
|
|
1089
|
+
}
|
|
1032
1090
|
|
|
1033
1091
|
// --- Explicit login replay (config-driven): a recorded type/tap/wait sequence for custom
|
|
1034
1092
|
// login UIs the heuristic preamble below can't parse. When configured it takes precedence. ---
|