@aarwitz/tapp 0.17.0 → 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.
@@ -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.1",
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.1",
28
28
  "mcp"
29
29
  ],
30
30
  "cwd": "${CLAUDE_PROJECT_DIR}"
package/AGENTS.md CHANGED
@@ -14,6 +14,7 @@ bundle id, or (web) an http(s) URL. You never need to know a bundle id up front.
14
14
 
15
15
  ```bash
16
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
17
18
  npx -y @aarwitz/tapp@latest explore https://your-app.example --watch # web: visibly follow the same exploration
18
19
  npx -y @aarwitz/tapp@latest open [target] # launch + screen summary + screenshot saved to a file (≈ tapp_open_app)
19
20
  npx -y @aarwitz/tapp@latest tree [target] # accessibility tree, --json for every element (≈ tapp_ui_tree)
@@ -60,6 +61,7 @@ installs, returns the bundle id) → `tapp_explore {appBundleId}`.
60
61
  | The user wants… | Use | NOT |
61
62
  |---|---|---|
62
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 |
63
65
  | "Tap through / drive / fill a form / log in" | `tapp_session_start` → `session_act` loop | repeated `open_app` calls (cold relaunch each time) |
64
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) |
65
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 |
@@ -67,8 +69,15 @@ installs, returns the bundle id) → `tapp_explore {appBundleId}`.
67
69
 
68
70
  ## Session driving (the Playwright loop)
69
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
+
70
78
  ```
71
- 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
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
@@ -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,7 +77,7 @@ 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
 
@@ -84,13 +86,19 @@ agent: "Done — and here it is working on the simulator: [screenshot]"
84
86
  Requirements: **Node ≥ 18**. iOS needs **macOS + Xcode**; Android needs `adb` plus a connected
85
87
  emulator/device; web needs Playwright + Chromium.
86
88
 
87
- From the app repository, let the agent see the current screen and then explore it:
89
+ From the app repository, ground Tapp once, then use the smallest operation for later checks:
88
90
 
89
91
  ```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)
92
+ npx -y @aarwitz/tapp@latest init . --explore # first run: detect/build, explore, and ground .tapp/ui-map.json
93
+ npx -y @aarwitz/tapp@latest open # one current screen + screenshot
94
+ npx -y @aarwitz/tapp@latest focus "Save storefront settings visible above keyboard" # source + observed-route fast path
95
+ npx -y @aarwitz/tapp@latest explore # later broad exploration (observation, not a gate)
92
96
  ```
93
97
 
98
+ Source tells `focus` where the requested UI likely lives; only a route already observed in
99
+ `.tapp/ui-map.json` authorizes navigation. If a fresh repository has no such route yet, Tapp returns
100
+ the source evidence instead of guessing through the app.
101
+
94
102
  Claude Code can read the saved image with its file-reading tool; Codex can open it with
95
103
  `view_image`. The agent should report what the screenshot proves, relay the exploration findings
96
104
  as-is (an observation, not a merge decision — `tapp ci` gates that), and link the HTML evidence
@@ -123,6 +131,9 @@ npx -y @aarwitz/tapp@latest baseline create . --platform web
123
131
  npx -y @aarwitz/tapp@latest ci install .
124
132
  ```
125
133
 
134
+ Actor setup refuses to overwrite an existing actor. Repeat `actor set` with `--replace` only when
135
+ you intend to replace that actor's reviewed role, session, provisioning, or credential bindings.
136
+
126
137
  In a repository containing multiple apps (for example, iOS plus web),
127
138
  `tapp init . --explore` without an explicit target does not guess from detection order—even when a
128
139
  prior choice is recorded. A human terminal gets a numbered
@@ -326,7 +337,7 @@ jobs:
326
337
  timeout-minutes: 45
327
338
  steps:
328
339
  - uses: actions/checkout@v4
329
- - uses: aarwitz/tapp@main # pin to the newest release tag for production
340
+ - uses: aarwitz/tapp@v0.17.1 # or pin the reviewed release commit SHA
330
341
  with:
331
342
  project: MyApp.xcodeproj # or MyApp.xcworkspace
332
343
  scheme: MyApp
@@ -376,7 +387,7 @@ Android CI runs on Linux with an emulator/device already connected. The Action c
376
387
  or accept a prebuilt one:
377
388
 
378
389
  ```yaml
379
- - uses: aarwitz/tapp@main
390
+ - uses: aarwitz/tapp@v0.17.1 # or pin the reviewed release commit SHA
380
391
  with:
381
392
  platform: android
382
393
  android-app-id: com.acme.app
package/bin/tapp.js CHANGED
@@ -254,6 +254,7 @@ async function resolveTargetOrExit(engine, input) {
254
254
  function safeCommandUsage(verb) {
255
255
  const usage = {
256
256
  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]",
257
+ focus: "tapp focus \"SCREEN OR CONTROL\" [target] [--platform ios|android|web] [--project-dir REPO] [--map FILE] [--out FILE]",
257
258
  init: "tapp init [repo] [--explore] [--refresh] [--platform PLATFORM] [--target NAME] [--url URL] [--watch] [--dry-run]",
258
259
  open: "tapp open [target] [--platform ios|android|web] [--out FILE] [--tap TEXT] [--wait-for TEXT]",
259
260
  tree: "tapp tree [target] [--platform ios|android|web] [--json] [--tap TEXT] [--wait-for TEXT]",
@@ -288,6 +289,16 @@ if (["--help", "-h"].includes(command)) {
288
289
  command = "help";
289
290
  rest = [];
290
291
  }
292
+ const knownCommands = new Set([
293
+ "help", "version", "--version", "-v", "mcp", "init", "focus", "explore", "qa", "open",
294
+ "tree", "shot", "screenshot", "apps", "build", "flow", "task", "contract", "scenario", "map",
295
+ "pr", "plan", "baseline", "ci", "actor", "app", "studio", "report", "doctor", "install",
296
+ ]);
297
+ if (!knownCommands.has(command)) {
298
+ console.error(`❌ Unknown command: ${command}`);
299
+ console.error("Run `npx -y @aarwitz/tapp@latest --help` for the command reference.");
300
+ process.exit(2);
301
+ }
291
302
  const safeHelpRequested = (rest.includes("--help") || rest.includes("-h"))
292
303
  && !["help", "version", "--version", "-v"].includes(command)
293
304
  && (command !== "ci" || rest[0] === "install");
@@ -529,6 +540,63 @@ switch (command) {
529
540
  // ---- Zero-config verbs: the same engine the MCP tools use (exported by index.js),
530
541
  // invokable by any agent or human with no server setup at all.
531
542
 
543
+ case "focus": {
544
+ const { flags, positionals } = parseVerbArgs(rest);
545
+ const query = positionals[0] || "";
546
+ if (!query) {
547
+ console.error('usage: tapp focus "SCREEN OR CONTROL" [target] [--platform ios|android|web] [--project-dir REPO] [--map FILE] [--out FILE]');
548
+ process.exit(2);
549
+ }
550
+ let projectDir;
551
+ try { projectDir = fs.realpathSync(path.resolve(typeof flags["project-dir"] === "string" ? flags["project-dir"] : process.cwd())); }
552
+ catch { console.error("❌ --project-dir must be an existing repository directory"); process.exit(2); }
553
+ const target = positionals[1] || (typeof flags.url === "string" ? flags.url : "");
554
+ const platform = requestedPlatform(flags, target);
555
+ if (!["ios", "android", "web"].includes(platform)) { console.error("❌ --platform must be ios|android|web"); process.exit(2); }
556
+ const engine = await engineImport();
557
+ let started = null;
558
+ try {
559
+ if (platform === "ios") {
560
+ requireMacFor("iOS focused navigation");
561
+ const sim = await engine.ensureBootedSim({ autoBoot:true });
562
+ if (sim.error) throw new Error(sim.error);
563
+ const bundleId = await resolveTargetOrExit(engine, target || projectDir);
564
+ const launch = iosLaunchOptions(flags, rest);
565
+ started = await engine.startIosInteractiveSession(bundleId, engine.explorationEnvFromArgs({ testEmail:flags.email, testPassword:flags.password, ...launch }));
566
+ } else if (platform === "android") {
567
+ const android = androidTarget(flags, target);
568
+ started = await engine.startAndroidInteractiveSession(android.appId, { serial:android.serial, apkPath:android.apkPath, clearData:flags["keep-data"] !== true });
569
+ } else {
570
+ if (!/^https?:\/\//i.test(target)) { console.error("❌ Web focus needs an http(s) target URL"); process.exit(2); }
571
+ started = await engine.startWebInteractiveSession(target);
572
+ }
573
+ if (started.error) throw new Error(started.error);
574
+ const focused = await engine.focusInteractiveSession({ projectDir, query, platform, mapPath:typeof flags.map === "string" ? flags.map : "" });
575
+ const { focusedTargetSummary } = await import(path.join(packageRoot, "mcp-server", "src", "focused-navigation.js"));
576
+ console.log(focusedTargetSummary(focused));
577
+ if (focused.execution?.status === "reached") {
578
+ console.log(`\n⚡ Reached in ${(focused.execution.steps || []).length} route action(s).\n`);
579
+ console.log(engine.formatScreen(focused.screenTitle, focused.elements));
580
+ const frame = await engine.captureInteractiveSessionFrame(Number(flags.width) || 900);
581
+ if (!frame.error) {
582
+ const out = saveShot(frame, typeof flags.out === "string" ? path.resolve(flags.out) : null, `focus-${Date.now()}.${frame.mimeType === "image/png" ? "png" : "jpg"}`);
583
+ console.log(`\n📸 Screenshot: ${out}`);
584
+ }
585
+ } else if (focused.execution?.status === "failed") {
586
+ console.error(`\n❌ Observed route stopped: ${focused.execution.reason}`);
587
+ process.exitCode = 1;
588
+ } else {
589
+ 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`.");
590
+ }
591
+ } catch (error) {
592
+ console.error(`❌ ${error.message || String(error)}`);
593
+ process.exitCode = 1;
594
+ } finally {
595
+ if (started) await engine.endInteractiveSession();
596
+ }
597
+ break;
598
+ }
599
+
532
600
  case "explore":
533
601
  case "qa": {
534
602
  // `explore` is the canonical verb (ADR-0005: exploration observes; the gate judges). `qa` is a
@@ -1003,7 +1071,9 @@ switch (command) {
1003
1071
  invocation = ["bash", [path.join(packageRoot, "scripts", "run-flow.sh"), absolute, typeof flags["bundle-id"] === "string" ? flags["bundle-id"] : flow.app || ""]];
1004
1072
  }
1005
1073
  const result = spawnSync(invocation[0], invocation[1], { stdio: "inherit", env });
1006
- console.log(`\nEvidence: ${evidenceDir}`);
1074
+ const evidenceWritten = fs.existsSync(evidenceDir) && fs.readdirSync(evidenceDir).length > 0;
1075
+ if (evidenceWritten) console.log(`\nEvidence: ${evidenceDir}`);
1076
+ else console.error("\n⚠️ Evidence unavailable — the platform runner did not write any artifacts for this Flow run.");
1007
1077
  process.exit(result.status ?? 1);
1008
1078
  }
1009
1079
 
@@ -1620,6 +1690,7 @@ Core — inspect, explore, gate (no Tapp account or server required):
1620
1690
  tapp explore [target] Autonomous exploration → findings + evidence (an observation, NOT a
1621
1691
  release decision — run 'npx -y @aarwitz/tapp@latest ci' to gate a merge)
1622
1692
  (web: --watch · all: --platform ios|android|web · --actions N)
1693
+ tapp focus "goal" [target] Source-locate a named screen/control and take the shortest observed route
1623
1694
  tapp contract run FILE Replay a business-level release contract — the guarantees that must hold
1624
1695
  tapp ci ... Merge-blocking release gate — explore + suites + baseline → pass/fail/inconclusive
1625
1696
  (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.1 # or pin the reviewed release commit SHA
78
78
  with:
79
79
  platform: web
80
80
  url: http://127.0.0.1:4180
@@ -305,14 +305,25 @@ export class AndroidDriver {
305
305
  return r.stdout;
306
306
  }
307
307
 
308
- async settle(timeoutMs = 2200) {
308
+ async settle(timeoutMs = 2200, previousSnapshot = null) {
309
309
  const deadline = Date.now() + timeoutMs;
310
+ const fingerprintOf = (snapshot) => (snapshot?.elements || [])
311
+ .map((e) => `${androidElementKey(e)}:${e.text}:${e.x},${e.y}`).join("|");
312
+ const previousScreen = String(previousSnapshot?.screenTitle || "");
313
+ const previousFingerprint = fingerprintOf(previousSnapshot);
310
314
  let previous = "";
311
315
  let stable = 0;
312
316
  let latest;
313
317
  while (Date.now() < deadline) {
314
318
  latest = await this.snapshot();
315
- const fingerprint = latest.elements.map((e) => `${androidElementKey(e)}:${e.text}:${e.x},${e.y}`).join("|");
319
+ const fingerprint = fingerprintOf(latest);
320
+ // UIAutomator's dump command itself waits for the UI to become idle. If its first complete
321
+ // snapshot proves that the requested interaction changed the screen, a second identical
322
+ // dump adds roughly two seconds without adding evidence. Preserve the two-snapshot stability
323
+ // requirement when nothing changed (including delayed navigation and no-op controls).
324
+ if (previousSnapshot && fingerprint && (
325
+ String(latest.screenTitle || "") !== previousScreen || fingerprint !== previousFingerprint
326
+ )) return latest;
316
327
  if (fingerprint === previous) stable += 1; else stable = 0;
317
328
  if (stable >= 1) return latest;
318
329
  previous = fingerprint;