@aarwitz/tapp 0.17.0 → 0.17.2
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/AGENTS.md +16 -6
- package/Harness/OCQAHarnessUITests/ExplorerTests.swift +54 -7
- package/README.md +29 -17
- package/bin/tapp.js +170 -2
- package/docs/scenarios.md +1 -1
- package/mcp-server/src/android-driver.js +20 -2
- package/mcp-server/src/environment-preflight.js +35 -0
- package/mcp-server/src/focused-navigation.js +271 -0
- package/mcp-server/src/index.js +325 -29
- package/mcp-server/src/ui-map.js +2 -2
- package/package.json +3 -3
- package/scripts/quick-capture.sh +42 -11
- package/scripts/run-flow.sh +12 -1
- package/skills/tapp/SKILL.md +28 -5
- package/skills/tapp/references/commands.md +13 -5
|
@@ -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.2",
|
|
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.2",
|
|
28
28
|
"mcp"
|
|
29
29
|
],
|
|
30
30
|
"cwd": "${CLAUDE_PROJECT_DIR}"
|
package/AGENTS.md
CHANGED
|
@@ -7,13 +7,13 @@ repository-connected deterministic gate.
|
|
|
7
7
|
## No MCP connected? Just run the CLI
|
|
8
8
|
|
|
9
9
|
The core inspect, explore, replay, and gate capabilities work as plain commands — no Tapp account,
|
|
10
|
-
server, or global install. `[target]` is
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
bundle id, or (web) an http(s) URL. You never need to know a bundle id up front.
|
|
10
|
+
server, or global install. `[target]` is optional: with a repository/model, Tapp selects and prepares
|
|
11
|
+
one conclusive web, iOS, or Android target; it also accepts a repo dir, `path/to/App.app`, bundle id,
|
|
12
|
+
APK plus app id, or an owned HTTP(S) URL. You never need to know an iOS bundle id up front.
|
|
14
13
|
|
|
15
14
|
```bash
|
|
16
15
|
npx -y @aarwitz/tapp@latest explore [target] # autonomous exploration → findings + evidence (observation, not a gate; ≈ tapp_explore)
|
|
16
|
+
npx -y @aarwitz/tapp@latest focus "SCREEN OR CONTROL" [target] # source-locate + shortest observed route + screenshot
|
|
17
17
|
npx -y @aarwitz/tapp@latest explore https://your-app.example --watch # web: visibly follow the same exploration
|
|
18
18
|
npx -y @aarwitz/tapp@latest open [target] # launch + screen summary + screenshot saved to a file (≈ tapp_open_app)
|
|
19
19
|
npx -y @aarwitz/tapp@latest tree [target] # accessibility tree, --json for every element (≈ tapp_ui_tree)
|
|
@@ -60,6 +60,7 @@ installs, returns the bundle id) → `tapp_explore {appBundleId}`.
|
|
|
60
60
|
| The user wants… | Use | NOT |
|
|
61
61
|
|---|---|---|
|
|
62
62
|
| "Show me / screenshot a screen" | `tapp_open_app` (launch + screenshot + tree, ~15s) | `tapp_explore` (a full multi-minute exploration) |
|
|
63
|
+
| "Find/reach this named screen or control" | `tapp_session_start` with `focus`, or `tapp_focus`; plain CLI: `tapp focus` | screenshot-by-screenshot wandering |
|
|
63
64
|
| "Tap through / drive / fill a form / log in" | `tapp_session_start` → `session_act` loop | repeated `open_app` calls (cold relaunch each time) |
|
|
64
65
|
| "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) |
|
|
65
66
|
| "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 |
|
|
@@ -67,8 +68,16 @@ installs, returns the bundle id) → `tapp_explore {appBundleId}`.
|
|
|
67
68
|
|
|
68
69
|
## Session driving (the Playwright loop)
|
|
69
70
|
|
|
71
|
+
After an initial grounding exploration, use the source-connected fast path for any named
|
|
72
|
+
destination. Tapp searches the repository, matches the requested surface to `.tapp/ui-map.json`, and executes only the shortest
|
|
73
|
+
runtime-observed route. Source tells Tapp where intent lives; observed UI evidence authorizes taps.
|
|
74
|
+
If it returns source evidence without a route, inspect the cited file/navigation source—do not
|
|
75
|
+
wander blindly or invent a route. URL-only targets correctly have no source advantage.
|
|
76
|
+
|
|
70
77
|
```
|
|
71
|
-
tapp_session_start { appBundleId: "com.acme.app"
|
|
78
|
+
tapp_session_start { appBundleId: "com.acme.app", focus: "Save storefront settings visible above keyboard", projectDir: "." }
|
|
79
|
+
tapp_session_start { focus: "Storefront Settings", projectDir: "." } → managed web target from the workspace
|
|
80
|
+
tapp_focus { query: "Save storefront settings visible above keyboard" } → one-call shortest observed route
|
|
72
81
|
tapp_session_act { action: "login", email: "qa@x.com", password: "…" } → atomic fill + submit + verify
|
|
73
82
|
tapp_session_act { action: "tap", id: "Email" } → tap by a11y id OR visible label
|
|
74
83
|
tapp_session_act { action: "type", text: "qa@x.com" } → types into the focused field
|
|
@@ -144,7 +153,8 @@ without a coding agent, model, subscription, or API key. AI generation and `asse
|
|
|
144
153
|
## Setup facts (tell the user when relevant)
|
|
145
154
|
|
|
146
155
|
- iOS runs locally on a Mac with Xcode + a simulator. Android needs `adb` and a connected
|
|
147
|
-
emulator/device
|
|
156
|
+
emulator/device; source builds also need JDK 17, while prebuilt APK testing does not. Web needs
|
|
157
|
+
Playwright + Chromium. `tapp doctor` reports each capability separately.
|
|
148
158
|
- First tool call builds the test harness once (~2 min, cached in `~/.tapp`). `tapp install`
|
|
149
159
|
prebuilds it. Switching simulators triggers an automatic rebuild.
|
|
150
160
|
- The app under test must be **installed on the booted simulator** (`tapp_install_app` builds
|
|
@@ -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)
|
|
@@ -1031,6 +1076,8 @@ class ExplorerTests: XCTestCase {
|
|
|
1031
1076
|
_ = app.descendants(matching: .any).firstMatch.waitForExistence(timeout: 3)
|
|
1032
1077
|
_ = waitForUIStability(timeout: 2.0)
|
|
1033
1078
|
|
|
1079
|
+
signalSettledVisualReady()
|
|
1080
|
+
|
|
1034
1081
|
print("OCQA_STATE:exploration_started max_actions=\(maxActions)")
|
|
1035
1082
|
|
|
1036
1083
|
let testEmail = resolve("OCQA_TEST_EMAIL")
|
package/README.md
CHANGED
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
[](cursor://anysphere.cursor-deeplink/mcp/install?name=tapp&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBhYXJ3aXR6L3RhcHBAbGF0ZXN0IiwibWNwIl19)
|
|
10
10
|
[](https://insiders.vscode.dev/redirect/mcp/install?name=tapp&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40aarwitz%2Ftapp%40latest%22%2C%22mcp%22%5D%7D)
|
|
11
11
|
|
|
12
|
-
**Tapp
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
**Tapp lets coding agents verify UI changes on real iOS, Android, and web surfaces, then turns
|
|
13
|
+
reviewed proof into deterministic CI checks.** It can inspect and drive screens, explore for
|
|
14
|
+
technical failures, and save important journeys as replayable tests.
|
|
15
15
|
|
|
16
16
|
Exploration reports findings, coverage, evidence, and limits. Only the repository-connected gate
|
|
17
17
|
returns `pass`, `fail`, or `inconclusive`. Tapp does not turn an autonomous crawl into a subjective
|
|
@@ -45,10 +45,11 @@ npx -y skills add aarwitz/tapp --skill tapp
|
|
|
45
45
|
```
|
|
46
46
|
|
|
47
47
|
This installs the open Agent Skills workflow into the current project and lets the agent run the npm
|
|
48
|
-
CLI directly
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
use the short prompt
|
|
48
|
+
CLI directly. Inspecting, focused evidence, autonomous exploration, deterministic replay, and gating
|
|
49
|
+
need no MCP server, plugin, account, API key, global Tapp install, or pasted prompt block. Add `-g`
|
|
50
|
+
for a user-wide install, or `--agent claude-code`, `--agent codex`, and similar selectors to constrain
|
|
51
|
+
the clients. Start or restart the agent from the application repository and use the short prompt
|
|
52
|
+
above.
|
|
52
53
|
|
|
53
54
|
**Claude Code — optional enhanced skill and MCP tools:**
|
|
54
55
|
|
|
@@ -57,9 +58,10 @@ claude plugin marketplace add aarwitz/tapp
|
|
|
57
58
|
claude plugin install tapp@tapp
|
|
58
59
|
```
|
|
59
60
|
|
|
60
|
-
The plugin bundles the same `tapp` Agent Skill with the matching npm-backed MCP server.
|
|
61
|
-
you want inline screenshot
|
|
62
|
-
not required for the core
|
|
61
|
+
The plugin bundles the same `tapp` Agent Skill with the matching npm-backed MCP server. Add it when
|
|
62
|
+
you want inline screenshot results or when the agent must interactively tap, type, and record an
|
|
63
|
+
arbitrary multi-step journey in one persistent session. It is not required for the core
|
|
64
|
+
skill-to-CLI workflow.
|
|
63
65
|
|
|
64
66
|
**No agent integration:** run the npm package directly from an app repository in one line:
|
|
65
67
|
|
|
@@ -75,22 +77,29 @@ Android and web remain available through the skill's CLI/MCP workflow.
|
|
|
75
77
|
```
|
|
76
78
|
you: "Add a logout button to the settings screen"
|
|
77
79
|
agent: *writes the Swift*
|
|
78
|
-
agent: *tapp:
|
|
80
|
+
agent: *tapp: finds Settings in source, follows its previously observed route, screenshots it*
|
|
79
81
|
agent: "Done — and here it is working on the simulator: [screenshot]"
|
|
80
82
|
```
|
|
81
83
|
|
|
82
84
|
## npm CLI quickstart
|
|
83
85
|
|
|
84
86
|
Requirements: **Node ≥ 18**. iOS needs **macOS + Xcode**; Android needs `adb` plus a connected
|
|
85
|
-
emulator/device
|
|
87
|
+
emulator/device (and JDK 17 when Tapp builds source rather than installing an existing APK); web
|
|
88
|
+
needs Playwright + Chromium. `tapp doctor` reports these separately.
|
|
86
89
|
|
|
87
|
-
From the app repository,
|
|
90
|
+
From the app repository, ground Tapp once, then use the smallest operation for later checks:
|
|
88
91
|
|
|
89
92
|
```bash
|
|
90
|
-
npx -y @aarwitz/tapp@latest
|
|
91
|
-
npx -y @aarwitz/tapp@latest
|
|
93
|
+
npx -y @aarwitz/tapp@latest init . --explore # first run: detect/build, explore, and ground .tapp/ui-map.json
|
|
94
|
+
npx -y @aarwitz/tapp@latest open # one current screen + screenshot
|
|
95
|
+
npx -y @aarwitz/tapp@latest focus "Save storefront settings visible above keyboard" # source + observed-route fast path
|
|
96
|
+
npx -y @aarwitz/tapp@latest explore # later broad exploration (observation, not a gate)
|
|
92
97
|
```
|
|
93
98
|
|
|
99
|
+
Source tells `focus` where the requested UI likely lives; only a route already observed in
|
|
100
|
+
`.tapp/ui-map.json` authorizes navigation. If a fresh repository has no such route yet, Tapp returns
|
|
101
|
+
the source evidence instead of guessing through the app.
|
|
102
|
+
|
|
94
103
|
Claude Code can read the saved image with its file-reading tool; Codex can open it with
|
|
95
104
|
`view_image`. The agent should report what the screenshot proves, relay the exploration findings
|
|
96
105
|
as-is (an observation, not a merge decision — `tapp ci` gates that), and link the HTML evidence
|
|
@@ -123,6 +132,9 @@ npx -y @aarwitz/tapp@latest baseline create . --platform web
|
|
|
123
132
|
npx -y @aarwitz/tapp@latest ci install .
|
|
124
133
|
```
|
|
125
134
|
|
|
135
|
+
Actor setup refuses to overwrite an existing actor. Repeat `actor set` with `--replace` only when
|
|
136
|
+
you intend to replace that actor's reviewed role, session, provisioning, or credential bindings.
|
|
137
|
+
|
|
126
138
|
In a repository containing multiple apps (for example, iOS plus web),
|
|
127
139
|
`tapp init . --explore` without an explicit target does not guess from detection order—even when a
|
|
128
140
|
prior choice is recorded. A human terminal gets a numbered
|
|
@@ -326,7 +338,7 @@ jobs:
|
|
|
326
338
|
timeout-minutes: 45
|
|
327
339
|
steps:
|
|
328
340
|
- uses: actions/checkout@v4
|
|
329
|
-
- uses: aarwitz/tapp@
|
|
341
|
+
- uses: aarwitz/tapp@v0.17.2 # or pin the reviewed release commit SHA
|
|
330
342
|
with:
|
|
331
343
|
project: MyApp.xcodeproj # or MyApp.xcworkspace
|
|
332
344
|
scheme: MyApp
|
|
@@ -376,7 +388,7 @@ Android CI runs on Linux with an emulator/device already connected. The Action c
|
|
|
376
388
|
or accept a prebuilt one:
|
|
377
389
|
|
|
378
390
|
```yaml
|
|
379
|
-
- uses: aarwitz/tapp@
|
|
391
|
+
- uses: aarwitz/tapp@v0.17.2 # or pin the reviewed release commit SHA
|
|
380
392
|
with:
|
|
381
393
|
platform: android
|
|
382
394
|
android-app-id: com.acme.app
|
package/bin/tapp.js
CHANGED
|
@@ -217,6 +217,15 @@ function printEngineError(r) {
|
|
|
217
217
|
if (r.details && Array.isArray(r.details.errors) && r.details.errors.length) {
|
|
218
218
|
console.error(r.details.errors.map((e) => " " + e.trim()).join("\n"));
|
|
219
219
|
}
|
|
220
|
+
const choices = Array.isArray(r.details?.choices) ? r.details.choices : Array.isArray(r.details?.targets) ? r.details.targets : [];
|
|
221
|
+
if (choices.length) {
|
|
222
|
+
console.error(" Available targets:");
|
|
223
|
+
for (const choice of choices) {
|
|
224
|
+
const selector = choice.name || choice.id || choice.sourcePath;
|
|
225
|
+
console.error(` ${choice.platform ? `${choice.platform} · ` : ""}${choice.name || choice.id}${choice.sourcePath ? ` (${choice.sourcePath})` : ""}${selector ? ` — use --target ${JSON.stringify(selector)}` : ""}`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (r.details?.remediation) console.error(` Next: ${r.details.remediation}`);
|
|
220
229
|
}
|
|
221
230
|
|
|
222
231
|
async function promptForInitTarget(details) {
|
|
@@ -254,6 +263,7 @@ async function resolveTargetOrExit(engine, input) {
|
|
|
254
263
|
function safeCommandUsage(verb) {
|
|
255
264
|
const usage = {
|
|
256
265
|
explore: "tapp explore [target] [--platform ios|android|web] [--actions N] [--timeout SEC] [--email VALUE] [--password VALUE] [--baseline FILE] [--json FILE]\n Web: [--watch] opens Tapp's controlled browser and shows its actions\n iOS launch configuration: [--launch-arg VALUE ...] [--launch-env '{\"KEY\":\"VALUE\"}']\n Android: [--app-id ID] [--apk FILE] [--serial ID] [--keep-data]",
|
|
266
|
+
focus: "tapp focus \"SCREEN OR CONTROL\" [target] [--platform ios|android|web] [--project-dir REPO] [--target NAME|PATH] [--map FILE] [--out FILE]",
|
|
257
267
|
init: "tapp init [repo] [--explore] [--refresh] [--platform PLATFORM] [--target NAME] [--url URL] [--watch] [--dry-run]",
|
|
258
268
|
open: "tapp open [target] [--platform ios|android|web] [--out FILE] [--tap TEXT] [--wait-for TEXT]",
|
|
259
269
|
tree: "tapp tree [target] [--platform ios|android|web] [--json] [--tap TEXT] [--wait-for TEXT]",
|
|
@@ -288,6 +298,16 @@ if (["--help", "-h"].includes(command)) {
|
|
|
288
298
|
command = "help";
|
|
289
299
|
rest = [];
|
|
290
300
|
}
|
|
301
|
+
const knownCommands = new Set([
|
|
302
|
+
"help", "version", "--version", "-v", "mcp", "init", "focus", "explore", "qa", "open",
|
|
303
|
+
"tree", "shot", "screenshot", "apps", "build", "flow", "task", "contract", "scenario", "map",
|
|
304
|
+
"pr", "plan", "baseline", "ci", "actor", "app", "studio", "report", "doctor", "install",
|
|
305
|
+
]);
|
|
306
|
+
if (!knownCommands.has(command)) {
|
|
307
|
+
console.error(`❌ Unknown command: ${command}`);
|
|
308
|
+
console.error("Run `npx -y @aarwitz/tapp@latest --help` for the command reference.");
|
|
309
|
+
process.exit(2);
|
|
310
|
+
}
|
|
291
311
|
const safeHelpRequested = (rest.includes("--help") || rest.includes("-h"))
|
|
292
312
|
&& !["help", "version", "--version", "-v"].includes(command)
|
|
293
313
|
&& (command !== "ci" || rest[0] === "install");
|
|
@@ -529,6 +549,143 @@ switch (command) {
|
|
|
529
549
|
// ---- Zero-config verbs: the same engine the MCP tools use (exported by index.js),
|
|
530
550
|
// invokable by any agent or human with no server setup at all.
|
|
531
551
|
|
|
552
|
+
case "focus": {
|
|
553
|
+
const { flags, positionals } = parseVerbArgs(rest);
|
|
554
|
+
const query = positionals[0] || "";
|
|
555
|
+
if (!query) {
|
|
556
|
+
console.error('usage: tapp focus "SCREEN OR CONTROL" [target] [--platform ios|android|web] [--project-dir REPO] [--target NAME|PATH] [--map FILE] [--out FILE]');
|
|
557
|
+
process.exit(2);
|
|
558
|
+
}
|
|
559
|
+
let projectDir;
|
|
560
|
+
try { projectDir = fs.realpathSync(path.resolve(typeof flags["project-dir"] === "string" ? flags["project-dir"] : process.cwd())); }
|
|
561
|
+
catch { console.error("❌ --project-dir must be an existing repository directory"); process.exit(2); }
|
|
562
|
+
const target = positionals[1] || (typeof flags.url === "string" ? flags.url : "");
|
|
563
|
+
if (typeof flags["project-dir"] !== "string" && target && !/^https?:\/\//i.test(target)) {
|
|
564
|
+
try {
|
|
565
|
+
const targetPath = fs.realpathSync(path.resolve(target));
|
|
566
|
+
if (fs.statSync(targetPath).isDirectory()) projectDir = targetPath;
|
|
567
|
+
} catch { /* a bundle/application id is not a repository path */ }
|
|
568
|
+
}
|
|
569
|
+
let platform = requestedPlatform(flags, target);
|
|
570
|
+
let modeledTarget = null;
|
|
571
|
+
const directRuntimeTarget = /^https?:\/\//i.test(target) || /\.apk$/i.test(target)
|
|
572
|
+
|| (target && !fs.existsSync(path.resolve(target)));
|
|
573
|
+
const modelPath = existingProjectArtifactPath(projectDir, "application-model.json");
|
|
574
|
+
if (!directRuntimeTarget && fs.existsSync(modelPath)) {
|
|
575
|
+
try {
|
|
576
|
+
const model = JSON.parse(fs.readFileSync(modelPath, "utf8"));
|
|
577
|
+
const { selectApplicationTarget } = await import(path.join(packageRoot, "mcp-server", "src", "ci-setup.js"));
|
|
578
|
+
modeledTarget = selectApplicationTarget(model, {
|
|
579
|
+
platform:typeof flags.platform === "string" ? flags.platform.toLowerCase() : "",
|
|
580
|
+
target:typeof flags.target === "string" ? flags.target.trim() : "",
|
|
581
|
+
useDefault:true,
|
|
582
|
+
});
|
|
583
|
+
platform = modeledTarget.platform;
|
|
584
|
+
} catch (error) {
|
|
585
|
+
let choices = [];
|
|
586
|
+
try {
|
|
587
|
+
const model = JSON.parse(fs.readFileSync(modelPath, "utf8"));
|
|
588
|
+
choices = (model.targets || []).filter((item) => typeof flags.platform !== "string" || item.platform === flags.platform.toLowerCase());
|
|
589
|
+
} catch { /* the primary parse/select error is printed below */ }
|
|
590
|
+
printEngineError({ error:error.message || String(error), details:{ targets:choices } });
|
|
591
|
+
process.exit(2);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
if (!["ios", "android", "web"].includes(platform)) { console.error("❌ --platform must be ios|android|web"); process.exit(2); }
|
|
595
|
+
const engine = await engineImport();
|
|
596
|
+
let started = null;
|
|
597
|
+
try {
|
|
598
|
+
if (platform === "ios") {
|
|
599
|
+
requireMacFor("iOS focused navigation");
|
|
600
|
+
let bundleId;
|
|
601
|
+
if (modeledTarget) {
|
|
602
|
+
const resolved = await engine.resolveAppTarget(path.resolve(projectDir, modeledTarget.sourcePath || "."), {
|
|
603
|
+
cwd:projectDir,
|
|
604
|
+
scheme:modeledTarget.build?.scheme || modeledTarget.build?.proposedScheme || "",
|
|
605
|
+
configuration:modeledTarget.build?.configuration || "Debug",
|
|
606
|
+
onStatus:(status) => console.error(`⏳ ${status}`),
|
|
607
|
+
});
|
|
608
|
+
if (resolved.error) {
|
|
609
|
+
printEngineError(resolved);
|
|
610
|
+
process.exitCode = 1;
|
|
611
|
+
break;
|
|
612
|
+
}
|
|
613
|
+
bundleId = resolved.bundleId;
|
|
614
|
+
if (resolved.via) console.error(`🎯 Target: ${bundleId} — ${resolved.via}`);
|
|
615
|
+
} else {
|
|
616
|
+
const sim = await engine.ensureBootedSim({ autoBoot:true });
|
|
617
|
+
if (sim.error) throw new Error(sim.error);
|
|
618
|
+
bundleId = await resolveTargetOrExit(engine, target || projectDir);
|
|
619
|
+
}
|
|
620
|
+
const launch = iosLaunchOptions(flags, rest);
|
|
621
|
+
started = await engine.startIosInteractiveSession(bundleId, engine.explorationEnvFromArgs({ testEmail:flags.email, testPassword:flags.password, ...launch }));
|
|
622
|
+
} else if (platform === "android") {
|
|
623
|
+
const explicitAndroid = typeof flags["app-id"] === "string" || /\.apk$/i.test(target)
|
|
624
|
+
|| (target && !fs.existsSync(path.resolve(target)));
|
|
625
|
+
if (explicitAndroid) {
|
|
626
|
+
const android = androidTarget(flags, target);
|
|
627
|
+
started = await engine.startAndroidInteractiveSession(android.appId, { serial:android.serial, apkPath:android.apkPath, clearData:flags["keep-data"] !== true });
|
|
628
|
+
} else {
|
|
629
|
+
const prepared = await engine.prepareAndroidInteractiveTarget({
|
|
630
|
+
projectDir,
|
|
631
|
+
target:typeof flags.target === "string" ? flags.target : modeledTarget?.id || "",
|
|
632
|
+
onStatus:(status) => console.error(`⏳ ${status}`),
|
|
633
|
+
});
|
|
634
|
+
if (prepared.error) {
|
|
635
|
+
printEngineError(prepared);
|
|
636
|
+
process.exitCode = 1;
|
|
637
|
+
break;
|
|
638
|
+
}
|
|
639
|
+
console.error(`🎯 Target: ${prepared.appId} — built ${prepared.selectedTarget.name} → ${prepared.apkPath}`);
|
|
640
|
+
started = await engine.startAndroidInteractiveSession(prepared.appId, {
|
|
641
|
+
serial:typeof flags.serial === "string" ? flags.serial : undefined,
|
|
642
|
+
apkPath:prepared.apkPath,
|
|
643
|
+
clearData:flags["keep-data"] !== true,
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
} else {
|
|
647
|
+
started = /^https?:\/\//i.test(target)
|
|
648
|
+
? await engine.startWebInteractiveSession(target)
|
|
649
|
+
: await engine.startManagedWebInteractiveSession({
|
|
650
|
+
projectDir,
|
|
651
|
+
requestedTarget:typeof flags.target === "string" ? flags.target : modeledTarget?.sourcePath || modeledTarget?.name || target,
|
|
652
|
+
timeout:flags.timeout,
|
|
653
|
+
testEmail:typeof flags.email === "string" ? flags.email : "",
|
|
654
|
+
testPassword:typeof flags.password === "string" ? flags.password : "",
|
|
655
|
+
onStatus:(status) => console.error(`⏳ ${status}`),
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
if (started.error) {
|
|
659
|
+
printEngineError(started);
|
|
660
|
+
process.exitCode = 1;
|
|
661
|
+
break;
|
|
662
|
+
}
|
|
663
|
+
const focused = await engine.focusInteractiveSession({ projectDir, query, platform, mapPath:typeof flags.map === "string" ? flags.map : "" });
|
|
664
|
+
const { focusedTargetSummary } = await import(path.join(packageRoot, "mcp-server", "src", "focused-navigation.js"));
|
|
665
|
+
console.log(focusedTargetSummary(focused));
|
|
666
|
+
if (focused.execution?.status === "reached") {
|
|
667
|
+
console.log(`\n⚡ Reached in ${(focused.execution.steps || []).length} route action(s).\n`);
|
|
668
|
+
console.log(engine.formatScreen(focused.screenTitle, focused.elements));
|
|
669
|
+
const frame = await engine.captureInteractiveSessionFrame(Number(flags.width) || 900);
|
|
670
|
+
if (!frame.error) {
|
|
671
|
+
const out = saveShot(frame, typeof flags.out === "string" ? path.resolve(flags.out) : null, `focus-${Date.now()}.${frame.mimeType === "image/png" ? "png" : "jpg"}`);
|
|
672
|
+
console.log(`\n📸 Screenshot: ${out}`);
|
|
673
|
+
}
|
|
674
|
+
} else if (focused.execution?.status === "failed") {
|
|
675
|
+
console.error(`\n❌ Observed route stopped: ${focused.execution.reason}`);
|
|
676
|
+
process.exitCode = 1;
|
|
677
|
+
} else {
|
|
678
|
+
console.error("\nℹ️ Tapp located the source but did not drive an unobserved route. Ground the UI Map with `npx -y @aarwitz/tapp@latest init . --explore`.");
|
|
679
|
+
}
|
|
680
|
+
} catch (error) {
|
|
681
|
+
console.error(`❌ ${error.message || String(error)}`);
|
|
682
|
+
process.exitCode = 1;
|
|
683
|
+
} finally {
|
|
684
|
+
if (started) await engine.endInteractiveSession();
|
|
685
|
+
}
|
|
686
|
+
break;
|
|
687
|
+
}
|
|
688
|
+
|
|
532
689
|
case "explore":
|
|
533
690
|
case "qa": {
|
|
534
691
|
// `explore` is the canonical verb (ADR-0005: exploration observes; the gate judges). `qa` is a
|
|
@@ -1003,7 +1160,9 @@ switch (command) {
|
|
|
1003
1160
|
invocation = ["bash", [path.join(packageRoot, "scripts", "run-flow.sh"), absolute, typeof flags["bundle-id"] === "string" ? flags["bundle-id"] : flow.app || ""]];
|
|
1004
1161
|
}
|
|
1005
1162
|
const result = spawnSync(invocation[0], invocation[1], { stdio: "inherit", env });
|
|
1006
|
-
|
|
1163
|
+
const evidenceWritten = fs.existsSync(evidenceDir) && fs.readdirSync(evidenceDir).length > 0;
|
|
1164
|
+
if (evidenceWritten) console.log(`\nEvidence: ${evidenceDir}`);
|
|
1165
|
+
else console.error("\n⚠️ Evidence unavailable — the platform runner did not write any artifacts for this Flow run.");
|
|
1007
1166
|
process.exit(result.status ?? 1);
|
|
1008
1167
|
}
|
|
1009
1168
|
|
|
@@ -1268,7 +1427,7 @@ switch (command) {
|
|
|
1268
1427
|
console.log(` ⬜ iOS — requires macOS (this host: ${process.platform})`);
|
|
1269
1428
|
}
|
|
1270
1429
|
|
|
1271
|
-
const { resolveAdbPath } = await import(path.join(packageRoot, "mcp-server", "src", "android-driver.js"));
|
|
1430
|
+
const { resolveAdbPath, resolveAndroidSdkRoot } = await import(path.join(packageRoot, "mcp-server", "src", "android-driver.js"));
|
|
1272
1431
|
const adbPath = resolveAdbPath();
|
|
1273
1432
|
const adb = adbPath ? run(adbPath, ["devices"]) : { code: 1, stdout: "" };
|
|
1274
1433
|
if (adb.code === 0) {
|
|
@@ -1277,6 +1436,14 @@ switch (command) {
|
|
|
1277
1436
|
} else {
|
|
1278
1437
|
console.log(" ⬜ Android — adb not found (install Android SDK platform-tools)");
|
|
1279
1438
|
}
|
|
1439
|
+
const { resolveJavaRuntime } = await import(path.join(packageRoot, "mcp-server", "src", "environment-preflight.js"));
|
|
1440
|
+
const java = resolveJavaRuntime();
|
|
1441
|
+
const androidSdkRoot = resolveAndroidSdkRoot();
|
|
1442
|
+
if (java && androidSdkRoot) ok("Android source builds", `${java.version || java.javaHome}; SDK ${androidSdkRoot}`);
|
|
1443
|
+
else {
|
|
1444
|
+
const missing = [!java ? "JDK 17" : "", !androidSdkRoot ? "Android SDK root" : ""].filter(Boolean).join(" and ");
|
|
1445
|
+
console.log(` ⬜ Android source builds — install/configure ${missing} (prebuilt APK testing still works)`);
|
|
1446
|
+
}
|
|
1280
1447
|
|
|
1281
1448
|
try {
|
|
1282
1449
|
const { chromium } = await import("playwright");
|
|
@@ -1620,6 +1787,7 @@ Core — inspect, explore, gate (no Tapp account or server required):
|
|
|
1620
1787
|
tapp explore [target] Autonomous exploration → findings + evidence (an observation, NOT a
|
|
1621
1788
|
release decision — run 'npx -y @aarwitz/tapp@latest ci' to gate a merge)
|
|
1622
1789
|
(web: --watch · all: --platform ios|android|web · --actions N)
|
|
1790
|
+
tapp focus "goal" [target] Source-locate a named screen/control and take the shortest observed route
|
|
1623
1791
|
tapp contract run FILE Replay a business-level release contract — the guarantees that must hold
|
|
1624
1792
|
tapp ci ... Merge-blocking release gate — explore + suites + baseline → pass/fail/inconclusive
|
|
1625
1793
|
(see: tapp ci --help)
|
package/docs/scenarios.md
CHANGED
|
@@ -33,6 +33,13 @@ export function resolveAdbPath(env = process.env) {
|
|
|
33
33
|
return executable("adb", env);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/** Resolve the SDK root from the same evidence used to find adb, including common unlinked
|
|
37
|
+
* Homebrew installations. Gradle needs this even when device automation already found adb. */
|
|
38
|
+
export function resolveAndroidSdkRoot(env = process.env) {
|
|
39
|
+
const adbPath = resolveAdbPath(env);
|
|
40
|
+
return adbPath ? path.dirname(path.dirname(adbPath)) : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
36
43
|
function runFile(command, args, { encoding = "utf8", timeout = 30_000, maxBuffer = 16 * 1024 * 1024 } = {}) {
|
|
37
44
|
return new Promise((resolve) => {
|
|
38
45
|
execFile(command, args, { encoding, timeout, maxBuffer }, (error, stdout, stderr) => {
|
|
@@ -305,14 +312,25 @@ export class AndroidDriver {
|
|
|
305
312
|
return r.stdout;
|
|
306
313
|
}
|
|
307
314
|
|
|
308
|
-
async settle(timeoutMs = 2200) {
|
|
315
|
+
async settle(timeoutMs = 2200, previousSnapshot = null) {
|
|
309
316
|
const deadline = Date.now() + timeoutMs;
|
|
317
|
+
const fingerprintOf = (snapshot) => (snapshot?.elements || [])
|
|
318
|
+
.map((e) => `${androidElementKey(e)}:${e.text}:${e.x},${e.y}`).join("|");
|
|
319
|
+
const previousScreen = String(previousSnapshot?.screenTitle || "");
|
|
320
|
+
const previousFingerprint = fingerprintOf(previousSnapshot);
|
|
310
321
|
let previous = "";
|
|
311
322
|
let stable = 0;
|
|
312
323
|
let latest;
|
|
313
324
|
while (Date.now() < deadline) {
|
|
314
325
|
latest = await this.snapshot();
|
|
315
|
-
const fingerprint = latest
|
|
326
|
+
const fingerprint = fingerprintOf(latest);
|
|
327
|
+
// UIAutomator's dump command itself waits for the UI to become idle. If its first complete
|
|
328
|
+
// snapshot proves that the requested interaction changed the screen, a second identical
|
|
329
|
+
// dump adds roughly two seconds without adding evidence. Preserve the two-snapshot stability
|
|
330
|
+
// requirement when nothing changed (including delayed navigation and no-op controls).
|
|
331
|
+
if (previousSnapshot && fingerprint && (
|
|
332
|
+
String(latest.screenTitle || "") !== previousScreen || fingerprint !== previousFingerprint
|
|
333
|
+
)) return latest;
|
|
316
334
|
if (fingerprint === previous) stable += 1; else stable = 0;
|
|
317
335
|
if (stable >= 1) return latest;
|
|
318
336
|
previous = fingerprint;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
3
4
|
|
|
4
5
|
export const STORAGE_BLOCK_BYTES = 256 * 1024 * 1024;
|
|
5
6
|
export const STORAGE_WARN_BYTES = 5 * 1024 * 1024 * 1024;
|
|
@@ -41,3 +42,37 @@ export function storagePreflight(candidate, { statfs = fs.statfsSync, blockBytes
|
|
|
41
42
|
return { ok: true, level: "unknown", path: checkedPath, freeBytes: null, message: `Free disk space could not be checked: ${error.message || String(error)}` };
|
|
42
43
|
}
|
|
43
44
|
}
|
|
45
|
+
|
|
46
|
+
/** Find a working Java runtime for repository-owned Android builds, including common unlinked
|
|
47
|
+
* Homebrew and Android Studio installations on macOS. APK/device testing itself does not need it. */
|
|
48
|
+
export function resolveJavaRuntime(env = process.env, { probe = spawnSync, minimumMajor = 17 } = {}) {
|
|
49
|
+
const executable = process.platform === "win32" ? "java.exe" : "java";
|
|
50
|
+
const homes = [
|
|
51
|
+
env.JAVA_HOME,
|
|
52
|
+
process.platform === "darwin" ? "/Applications/Android Studio.app/Contents/jbr/Contents/Home" : "",
|
|
53
|
+
process.platform === "darwin" ? "/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home" : "",
|
|
54
|
+
process.platform === "darwin" ? "/opt/homebrew/opt/openjdk/libexec/openjdk.jdk/Contents/Home" : "",
|
|
55
|
+
process.platform === "darwin" ? "/usr/local/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home" : "",
|
|
56
|
+
process.platform === "darwin" ? "/usr/local/opt/openjdk/libexec/openjdk.jdk/Contents/Home" : "",
|
|
57
|
+
].filter(Boolean);
|
|
58
|
+
const candidates = [
|
|
59
|
+
...homes.map((home) => ({ javaHome:home, javaPath:path.join(home, "bin", executable) })),
|
|
60
|
+
...String(env.PATH || "").split(path.delimiter).filter(Boolean).map((dir) => ({ javaHome:"", javaPath:path.join(dir, executable) })),
|
|
61
|
+
];
|
|
62
|
+
const seen = new Set();
|
|
63
|
+
for (const candidate of candidates) {
|
|
64
|
+
if (seen.has(candidate.javaPath) || !fs.existsSync(candidate.javaPath)) continue;
|
|
65
|
+
seen.add(candidate.javaPath);
|
|
66
|
+
const checked = probe(candidate.javaPath, ["-version"], { encoding:"utf8" });
|
|
67
|
+
if ((checked.status ?? 1) !== 0) continue;
|
|
68
|
+
const version = String(checked.stderr || checked.stdout || "").split(/\r?\n/)[0].trim();
|
|
69
|
+
const matched = version.match(/version\s+"(\d+)(?:\.(\d+))?/i);
|
|
70
|
+
const major = matched ? Number(matched[1] === "1" ? matched[2] : matched[1]) : 0;
|
|
71
|
+
if (!major || major < minimumMajor) continue;
|
|
72
|
+
let javaPath = candidate.javaPath;
|
|
73
|
+
try { javaPath = fs.realpathSync(javaPath); } catch { /* preserve the discovered path */ }
|
|
74
|
+
const javaHome = candidate.javaHome || path.dirname(path.dirname(javaPath));
|
|
75
|
+
return { javaPath, javaHome, version, major };
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|