@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.
@@ -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.0",
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.0",
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
- optional: with nothing, tapp finds + builds the Xcode project in the cwd (or falls back to
12
- the app already on the simulator); it also accepts a repo dir, a `path/to/App.app`, a
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" } → fresh launch + initial tree
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. Web needs Playwright + Chromium. `tapp doctor` reports each capability.
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
- 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)
@@ -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
  [![Install in Cursor](https://img.shields.io/badge/Cursor-Install_MCP-000000)](cursor://anysphere.cursor-deeplink/mcp/install?name=tapp&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBhYXJ3aXR6L3RhcHBAbGF0ZXN0IiwibWNwIl19)
10
10
  [![VS Code MCP](https://img.shields.io/badge/VS_Code-Install_MCP-0098FF)](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 gives coding agents hands and eyes on real iOS, Android, and web apps.** It can inspect and
13
- drive screens, explore for technical failures, save journeys as deterministic tests, and gate
14
- reviewed behavior in CI.
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; no MCP server, plugin, account, API key, global Tapp install, or pasted prompt block is
49
- required. Add `-g` for a user-wide install, or `--agent claude-code`, `--agent codex`, and similar
50
- selectors to constrain the clients. Start or restart the agent from the application repository and
51
- use the short prompt above.
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. Use it when
61
- you want inline screenshot tool results and a persistent interactive tap/read/type session; it is
62
- not required for the core skill-to-CLI workflow.
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: builds, opens the app, navigates to Settings, screenshots it*
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; web needs Playwright + Chromium.
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, let the agent see the current screen and then explore it:
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 open # builds/launches as needed; prints a screenshot path + screen summary
91
- npx -y @aarwitz/tapp@latest explore # explores the real app; prints findings + evidence (an observation, not a gate)
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@main # pin to the newest release tag for production
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@main
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
- console.log(`\nEvidence: ${evidenceDir}`);
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
@@ -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@main
77
+ - uses: aarwitz/tapp@v0.17.2 # or pin the reviewed release commit SHA
78
78
  with:
79
79
  platform: web
80
80
  url: http://127.0.0.1:4180
@@ -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.elements.map((e) => `${androidElementKey(e)}:${e.text}:${e.x},${e.y}`).join("|");
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
+ }