@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.
- package/.claude-plugin/plugin.json +2 -2
- package/AGENTS.md +10 -1
- package/Harness/OCQAHarnessUITests/ExplorerTests.swift +54 -7
- package/README.md +27 -16
- package/bin/tapp.js +72 -1
- package/docs/scenarios.md +1 -1
- package/mcp-server/src/android-driver.js +13 -2
- package/mcp-server/src/focused-navigation.js +271 -0
- package/mcp-server/src/index.js +240 -25
- 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 +22 -2
- package/skills/tapp/references/commands.md +4 -1
|
@@ -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.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.
|
|
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"
|
|
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
|
-
|
|
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,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:
|
|
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,
|
|
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
|
|
91
|
-
npx -y @aarwitz/tapp@latest
|
|
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@
|
|
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@
|
|
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
|
-
|
|
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
|
@@ -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
|
|
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;
|