@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.
@@ -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, eyes, and judgment on real app surfaces — iOS simulators,
4
- Android emulators/devices, plus (beta) web apps in a real browser.
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
- Every core capability works as a plain command — no server, no config. `[target]` is
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 open [target] # launch + screen summary + screenshot saved to a file (≈ tapp_open_app)
16
- npx -y @aarwitz/tapp tree [target] # accessibility tree, --json for every element (≈ tapp_ui_tree)
17
- npx -y @aarwitz/tapp shot # screenshot the booted sim file path (≈ tapp_screenshot)
18
- npx -y @aarwitz/tapp apps # what's installed on the simulator, with bundle ids
19
- npx -y @aarwitz/tapp build [dir] # build the app in an Xcode repo + install it (≈ tapp_build)
20
- npx -y @aarwitz/tapp explore app.apk --platform android --app-id com.acme.app
21
- npx -y @aarwitz/tapp flow run .tapp/flows/smoke.yml # committed, keyless E2E replay
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 QA exploration) |
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" } → fresh launch + initial tree
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
- let textFields = app.textFields.allElementsBoundByIndex.filter { $0.exists && $0.frame.width > 0 }
589
- let secureFields = app.secureTextFields.allElementsBoundByIndex.filter { $0.exists && $0.frame.width > 0 }
590
- let emailField = textFields.first { f in
591
- let hint = (f.identifier + " " + (f.placeholderValue ?? "") + " " + f.label).lowercased()
592
- return hint.contains("email") || hint.contains("e-mail") || hint.contains("user")
593
- } ?? (secureFields.isEmpty ? nil : textFields.first)
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 = secureFields.first else { return ("no_login_form", "no password (secure) field visible") }
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. ---