@aarwitz/tapp 0.15.0
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/AGENTS.md +123 -0
- package/Harness/OCQAHarness/AppDelegate.swift +21 -0
- package/Harness/OCQAHarness/Info.plist +26 -0
- package/Harness/OCQAHarness.xcodeproj/project.pbxproj +199 -0
- package/Harness/OCQAHarness.xcodeproj/xcshareddata/xcschemes/OCQAHarnessUITests.xcscheme +22 -0
- package/Harness/OCQAHarnessUITests/ExplorerTests.swift +4526 -0
- package/Harness/OCQAHarnessUITests/Info.plist +22 -0
- package/Harness/generate-harness-xcodeproj.rb +254 -0
- package/LICENSE +21 -0
- package/README.md +374 -0
- package/bin/tapp.js +1382 -0
- package/browser/app.css +227 -0
- package/browser/app.js +675 -0
- package/browser/index.html +195 -0
- package/browser/product-contract.js +25 -0
- package/browser/view-model.js +16 -0
- package/docs/BROWSER-PRODUCT.md +72 -0
- package/docs/PRODUCT-ENGINE.md +102 -0
- package/docs/application-model.md +276 -0
- package/docs/scenarios.md +95 -0
- package/mcp-server/src/android-driver.js +287 -0
- package/mcp-server/src/android-explorer.js +197 -0
- package/mcp-server/src/android-flow.js +89 -0
- package/mcp-server/src/application-model.js +1597 -0
- package/mcp-server/src/browser-product.js +659 -0
- package/mcp-server/src/browser-workspaces.js +234 -0
- package/mcp-server/src/ci-report.js +557 -0
- package/mcp-server/src/ci-setup.js +359 -0
- package/mcp-server/src/contract-authoring.js +10 -0
- package/mcp-server/src/enrich.js +57 -0
- package/mcp-server/src/flow-runtime.js +127 -0
- package/mcp-server/src/html-report.js +124 -0
- package/mcp-server/src/index.js +3775 -0
- package/mcp-server/src/maintenance-proposal.js +178 -0
- package/mcp-server/src/managed-operation.js +61 -0
- package/mcp-server/src/pr-selection.js +841 -0
- package/mcp-server/src/product-execution.js +155 -0
- package/mcp-server/src/product-operations.js +526 -0
- package/mcp-server/src/project-config.js +101 -0
- package/mcp-server/src/release-contract.d.ts +81 -0
- package/mcp-server/src/release-contract.js +226 -0
- package/mcp-server/src/report.js +363 -0
- package/mcp-server/src/scenario-runtime.js +139 -0
- package/mcp-server/src/static-server.js +44 -0
- package/mcp-server/src/task-runtime.js +266 -0
- package/mcp-server/src/ui-map.js +661 -0
- package/mcp-server/src/web-explorer.js +493 -0
- package/mcp-server/src/web-flow.js +238 -0
- package/package.json +82 -0
- package/scripts/android-corpus-e2e.sh +30 -0
- package/scripts/ci-gate.sh +323 -0
- package/scripts/cleanup-xcode.sh +157 -0
- package/scripts/compile-contract.js +27 -0
- package/scripts/compile-flow.js +18 -0
- package/scripts/corpus-apps.txt +9 -0
- package/scripts/corpus-sweep.sh +121 -0
- package/scripts/coverage-eval.sh +92 -0
- package/scripts/coverage_eval_parse.py +95 -0
- package/scripts/deploy-and-build.sh +99 -0
- package/scripts/flow-platform.js +18 -0
- package/scripts/flow_ai_judge.py +102 -0
- package/scripts/flow_lib.py +154 -0
- package/scripts/mutation-recall-desktop.sh +186 -0
- package/scripts/mutation-recall.sh +121 -0
- package/scripts/mutation_lib.py +128 -0
- package/scripts/mutation_operators.py +144 -0
- package/scripts/platform-gate.js +186 -0
- package/scripts/pr-plan.js +68 -0
- package/scripts/quick-capture.sh +419 -0
- package/scripts/run-android-flow.js +27 -0
- package/scripts/run-flow.sh +90 -0
- package/scripts/run-web-flow.js +28 -0
- package/scripts/run-web-scenario.js +23 -0
- package/scripts/validation-matrix.sh +146 -0
- package/scripts/vision-fp-eval.sh +206 -0
- package/scripts/vision_escalation_responder.py +147 -0
- package/scripts/vision_fp_probe.py +221 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
# Application model and `tapp init`
|
|
2
|
+
|
|
3
|
+
`tapp init` is the deterministic import, exploration, and planning entrypoint of Tapp's customer
|
|
4
|
+
journey. It turns a repository into three platform-neutral, repository-native artifacts:
|
|
5
|
+
|
|
6
|
+
- `.autotap/ui-map.json` — observed UI states, controls, and transitions from real exploration;
|
|
7
|
+
- `.autotap/application-model.json` — what Tapp can support with evidence;
|
|
8
|
+
- `.autotap/release-plan.json` — the compact set of committed and proposed business guarantees a
|
|
9
|
+
customer must review before generation.
|
|
10
|
+
|
|
11
|
+
Plain `tapp init` performs source/artifact inspection only. `tapp init --explore` additionally uses
|
|
12
|
+
the same keyless QA engine as `tapp qa` to build/install/launch or connect to one selected real
|
|
13
|
+
target, merge the observed map, and construct the model and plan from that runtime evidence. It
|
|
14
|
+
does not generate or approve tests, call AI, or claim contract validation. When iOS repository
|
|
15
|
+
resolution actually builds and installs the detected Xcode container, the model records the exact
|
|
16
|
+
scheme as runtime-observed validation and removes the corresponding confirmation blocker. Merely
|
|
17
|
+
supplying a bundle id or prebuilt `.app` does not prove repository build configuration.
|
|
18
|
+
|
|
19
|
+
## First inspection
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# Read-only preview. For web, provide the owned runtime URL if already known.
|
|
23
|
+
tapp init . --url http://127.0.0.1:3000 --dry-run \
|
|
24
|
+
--json-out /tmp/tapp-init-preview.json
|
|
25
|
+
|
|
26
|
+
# Create canonical artifacts. Existing files are never overwritten implicitly.
|
|
27
|
+
tapp init . --url http://127.0.0.1:3000
|
|
28
|
+
|
|
29
|
+
# Build/start the detected web target, explore it, persist its UI Map, then stop it.
|
|
30
|
+
tapp init . --explore --platform web --actions 40 --timeout 600
|
|
31
|
+
|
|
32
|
+
# Or connect to an already-running owned environment.
|
|
33
|
+
tapp init . --explore --platform web --url http://127.0.0.1:3000 \
|
|
34
|
+
--actions 40 --timeout 600
|
|
35
|
+
|
|
36
|
+
# iOS can resolve a repository/Xcode container/.app/bundle id and build when needed.
|
|
37
|
+
tapp init . --explore --platform ios --target .
|
|
38
|
+
|
|
39
|
+
# Android can install an APK, then launch the explicit application id.
|
|
40
|
+
tapp init . --explore --platform android \
|
|
41
|
+
--apk app/build/outputs/apk/debug/app-debug.apk --app-id com.acme.app
|
|
42
|
+
|
|
43
|
+
# Re-inspect after source/UI Map changes while preserving explicit review decisions.
|
|
44
|
+
tapp init . --url http://127.0.0.1:3000 --refresh
|
|
45
|
+
|
|
46
|
+
# Re-explore after review without losing approve/reject/defer choices.
|
|
47
|
+
tapp init . --refresh --explore --platform web --url http://127.0.0.1:3000
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
MCP clients use `tapp_init` with `operation: inspect|write|refresh|explore`. `inspect` is the safe
|
|
51
|
+
default. `explore` writes real evidence, so the CLI rejects `--explore --dry-run`; the CLI also
|
|
52
|
+
requires `--refresh --explore` once model/plan artifacts exist. Credentials are passed only to the
|
|
53
|
+
runtime and are never written into the model, map, or plan.
|
|
54
|
+
|
|
55
|
+
Successful repository-driven iOS build validation is portable and durable. The application model
|
|
56
|
+
stores the repository-relative container, scheme, configuration, bundle id, and a
|
|
57
|
+
`tapp-capture:<id>` evidence reference—never the local DerivedData or checkout path. A later
|
|
58
|
+
source-only `tapp init --refresh` retains that validation when it still names the same detected
|
|
59
|
+
container. Tapp does not infer equivalent proof from an installed application, an explicit bundle
|
|
60
|
+
id, or a prebuilt artifact; those paths can demonstrate runtime reachability but cannot silently
|
|
61
|
+
confirm the repository's Xcode scheme.
|
|
62
|
+
|
|
63
|
+
## Actors and credential bindings
|
|
64
|
+
|
|
65
|
+
Configure named actors once instead of repeating credentials or session policy across tests:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
tapp actor set alice . --role member --session isolated --provisioning seeded \
|
|
69
|
+
--credential email=ALICE_EMAIL --credential password=ALICE_PASSWORD
|
|
70
|
+
tapp actor set bob . --role member --session isolated --provisioning seeded \
|
|
71
|
+
--credential email=BOB_EMAIL --credential password=BOB_PASSWORD
|
|
72
|
+
tapp actor list .
|
|
73
|
+
tapp init . --refresh
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
This writes `.autotap/project.json`. The file contains roles, `default`/`isolated` session policy,
|
|
77
|
+
provisioning mode, same-origin lifecycle declarations, and environment-variable *names*. The CLI
|
|
78
|
+
and MCP `tapp_actor_config` reject credential values and refuse to replace an actor without an
|
|
79
|
+
explicit `--replace`/`replace: true`. Contracts refer to `$ALICE_EMAIL`-style placeholders. Tapp
|
|
80
|
+
merges those reviewed placeholders with the central configuration, blocks missing/conflicting
|
|
81
|
+
bindings, and never copies resolved values into the application model, release plan, UI Map, CI
|
|
82
|
+
manifest, or generated workflow.
|
|
83
|
+
|
|
84
|
+
When web `--url` is omitted, Tapp selects one detected browser target, runs only its internally
|
|
85
|
+
derived lockfile-backed install command, runs its declared build script when present, and starts its
|
|
86
|
+
`start`, `dev`, `serve`, or `preview` package script with argument-array process execution (never
|
|
87
|
+
generated shell source). A static site with no script uses Tapp's local read-only static server. The
|
|
88
|
+
runtime binds to an available loopback port, writes its log under the Tapp runtime directory, and is
|
|
89
|
+
terminated after exploration even when QA fails. Multiple web targets, an unlocked dependency
|
|
90
|
+
graph, an unrecognized start path, or backend-specific configuration produce explicit remediation;
|
|
91
|
+
provide `--target` and/or an already-running owned `--url` in those cases. Running repository build
|
|
92
|
+
scripts executes repository code and should only be used for a checkout the customer trusts.
|
|
93
|
+
The managed loop never persists its ephemeral loopback URL as customer configuration. The model
|
|
94
|
+
records `runtime.management: tapp-managed`, and the portable gate/Action reconstructs the same
|
|
95
|
+
start/wait/stop lifecycle later. An explicitly supplied owned URL remains `customer-managed`.
|
|
96
|
+
|
|
97
|
+
## What the model records
|
|
98
|
+
|
|
99
|
+
Application Model v1 records:
|
|
100
|
+
|
|
101
|
+
- detected iOS simulator, Android application, and browser targets;
|
|
102
|
+
- inspectable build commands, project/module/container paths, scheme candidates, application ids,
|
|
103
|
+
owned URLs, missing confirmations, and exact runtime-observed target validation where Tapp itself
|
|
104
|
+
completed the repository build/install path;
|
|
105
|
+
- actors, roles, provisioning modes, credential requirements/environment bindings, and
|
|
106
|
+
session-isolation boundaries without credential values;
|
|
107
|
+
- business entities and capabilities explicitly declared by reviewed contracts or conservatively
|
|
108
|
+
derived from reusable Task names;
|
|
109
|
+
- authored critical journeys, revenue paths, and cross-actor system invariants;
|
|
110
|
+
- the shared UI Map's observed state/transition/control counts and uncovered ids;
|
|
111
|
+
- the latest import exploration's verdict and explicit inconclusive status, when available;
|
|
112
|
+
- existing Tasks and contracts;
|
|
113
|
+
- blocking requirements and exact remediation.
|
|
114
|
+
|
|
115
|
+
Every fact identifies its evidence class. The current deterministic importer uses:
|
|
116
|
+
|
|
117
|
+
- `source-observed` for repository files and build metadata;
|
|
118
|
+
- `runtime-observed` for a successful exact target build/install/exploration, with portable evidence;
|
|
119
|
+
- `reviewed-artifact` for committed Tasks, contracts, and UI Map evidence;
|
|
120
|
+
- `task-derived` or another source-derived status when a fact still requires review;
|
|
121
|
+
- `authored-unvalidated` when a committed contract exists without current-revision replay proof.
|
|
122
|
+
|
|
123
|
+
Runtime observation, source inference, optional AI proposals, and human decisions must not be
|
|
124
|
+
collapsed into one confidence label. The artifact explicitly records that remote AI was not used.
|
|
125
|
+
|
|
126
|
+
## Release-plan quality
|
|
127
|
+
|
|
128
|
+
The deterministic planner starts with committed contracts, then proposes only evidence-grounded
|
|
129
|
+
gaps:
|
|
130
|
+
|
|
131
|
+
- a conservative cross-actor propagation guarantee when two explicitly configured isolated actors,
|
|
132
|
+
deterministic setup/teardown, compatible authentication/precondition screens, and an exact
|
|
133
|
+
content-producing Task output jointly prove that the proposal is grounded;
|
|
134
|
+
- reusable Tasks not composed by a reviewed contract;
|
|
135
|
+
- uncovered UI states carrying business signals such as authentication, pricing, checkout,
|
|
136
|
+
account, messaging, or settings behavior.
|
|
137
|
+
|
|
138
|
+
Error pages, blank pages, loading surfaces, changelogs, and generic feature-description pages remain
|
|
139
|
+
visible as UI Map coverage gaps but do not automatically become business contracts. The target is
|
|
140
|
+
approximately 5–15 contracts for a sufficiently rich product, not an artificial quota for a small
|
|
141
|
+
fixture. Every proposal includes business value, risk, criticality, actors, platforms, grounding,
|
|
142
|
+
and the real-surface validation required before it can be trusted.
|
|
143
|
+
|
|
144
|
+
## Explicit review
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
tapp plan show .autotap/release-plan.json
|
|
148
|
+
tapp plan review .autotap/release-plan.json \
|
|
149
|
+
--approve signInWorks,checkoutWorks \
|
|
150
|
+
--reject marketingPageReachable \
|
|
151
|
+
--defer adminAuditWorks
|
|
152
|
+
|
|
153
|
+
# Only after review: generate grounded Task + contract drafts under .autotap/proposals/.
|
|
154
|
+
tapp plan generate .autotap/release-plan.json --project-dir .
|
|
155
|
+
|
|
156
|
+
# Replay the draft on the real target and attach evidence to the plan.
|
|
157
|
+
# Omit --url to build/start/stop the detected managed browser target.
|
|
158
|
+
tapp plan validate .autotap/release-plan.json --project-dir . --platform web
|
|
159
|
+
# Or connect to an already-running owned environment.
|
|
160
|
+
tapp plan validate .autotap/release-plan.json --project-dir . \
|
|
161
|
+
--platform web --url http://127.0.0.1:3000
|
|
162
|
+
|
|
163
|
+
# Explicitly accept only fully validated drafts into canonical reviewed locations.
|
|
164
|
+
tapp plan promote .autotap/release-plan.json --project-dir . \
|
|
165
|
+
--item checkoutWorks
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
The MCP equivalent is `tapp_release_plan` with `read|review|generate|validate|promote`. Review
|
|
169
|
+
changes decision metadata only. It cannot silently
|
|
170
|
+
edit a Task, contract, selector, or assertion. On `tapp init --refresh`, decisions, constraints, and
|
|
171
|
+
review notes are carried forward by stable item id; reviewed items no longer derived from current
|
|
172
|
+
evidence are retained and marked stale instead of disappearing.
|
|
173
|
+
|
|
174
|
+
A source-only refresh preserves recorded replay evidence. `tapp init --refresh --explore` carries
|
|
175
|
+
the history forward but invalidates trust for affected generated Tasks and contracts: prior
|
|
176
|
+
platform results move to historical evidence, status becomes `requires-revalidation`, and replay is
|
|
177
|
+
required before the draft can be trusted against the newly observed revision. Exploration never
|
|
178
|
+
silently self-heals or accepts the prior selector path.
|
|
179
|
+
|
|
180
|
+
The macOS desktop Coverage experience reads these same files. Its **Application** tab explains
|
|
181
|
+
detected targets, actors, capabilities, journeys, Tasks, contracts, and exact remediation. Its
|
|
182
|
+
**Release Plan** tab writes explicit approve/reject/defer decisions atomically into the canonical
|
|
183
|
+
plan while preserving fields from newer engine versions; committed contract intent is not editable
|
|
184
|
+
through these proposal controls. Flow Map merges the repository `.autotap/ui-map.json` with current
|
|
185
|
+
run evidence instead of building a separate desktop-only graph.
|
|
186
|
+
|
|
187
|
+
Schema compatibility can be rehearsed headlessly after building the desktop app:
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
AutoTap.app/Contents/MacOS/AutoTap --verify-artifacts /path/to/repository \
|
|
191
|
+
--out /tmp/tapp-desktop-artifacts.json
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
`plan generate` handles only explicitly approved proposals. Grounded cross-actor proposals preserve
|
|
195
|
+
actor-attributed Task calls, captured output variables, bounded eventual assertions, and the
|
|
196
|
+
reviewed project lifecycle, then compile through the isolated Scenario executor. Existing
|
|
197
|
+
Task-backed proposals compose those reviewed Tasks. For UI-Map-only proposals, it finds an observed path from each platform's
|
|
198
|
+
recorded entry state, deduplicates shared semantic transitions into compositional Task drafts under
|
|
199
|
+
`.autotap/proposals/tasks/`, grounds every Task in exact node/edge ids, and writes the contract draft
|
|
200
|
+
under `.autotap/proposals/contracts/`. Proposal Tasks are visible only to proposal contracts; an
|
|
201
|
+
ordinary committed contract or CI glob cannot silently consume one.
|
|
202
|
+
|
|
203
|
+
Generation blocks when entry-state evidence is missing, the target is unreachable, an observed
|
|
204
|
+
action cannot be represented deterministically, or platform paths require incompatible semantic
|
|
205
|
+
composition. It never overwrites a draft, statically compiles each declared platform, and marks all
|
|
206
|
+
outputs untrusted. Missing non-secret Task inputs stay blocked until the plan has explicit bindings;
|
|
207
|
+
standard email/password secrets remain placeholders. Successful grounding and compilation are not
|
|
208
|
+
real-surface evidence and never promote drafts into `.autotap/tasks` or `.autotap/contracts`.
|
|
209
|
+
|
|
210
|
+
`plan validate` invokes the ordinary deterministic contract executor and records pass/fail evidence
|
|
211
|
+
per declared platform. A multi-platform draft remains only partially validated until every declared
|
|
212
|
+
platform passes. Failed replay remains visible and sets `trusted: false`; there is no selector
|
|
213
|
+
substitution or automatic assertion update.
|
|
214
|
+
|
|
215
|
+
`plan promote` is the explicit acceptance boundary. It refuses any contract or generated Task that
|
|
216
|
+
has not passed every declared platform, preflights every destination, never overwrites a reviewed
|
|
217
|
+
artifact, moves accepted files from `.autotap/proposals/{tasks,contracts}` into
|
|
218
|
+
`.autotap/{tasks,contracts}`, and applies their exact node/edge coverage to the canonical UI Map.
|
|
219
|
+
Shared Task paths in still-unpromoted proposals are rewritten to the canonical file. Promotion does
|
|
220
|
+
not commit, push, or install CI; the resulting repository patch remains reviewable by the customer.
|
|
221
|
+
|
|
222
|
+
## Baseline and CI handoff
|
|
223
|
+
|
|
224
|
+
After promotion, complete the local onboarding loop with:
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
# Runs the ordinary autonomous QA + committed suites gate. Builds native targets when possible;
|
|
228
|
+
# web targets can be detected, built, started, awaited, and stopped without a durable URL.
|
|
229
|
+
tapp baseline create . --platform web
|
|
230
|
+
|
|
231
|
+
# Or import an already-retained successful full-gate report after review.
|
|
232
|
+
tapp baseline create . --platform web --from /path/to/tapp-report.json
|
|
233
|
+
|
|
234
|
+
# Generate one target-aware job per model target plus a machine-readable manifest.
|
|
235
|
+
tapp ci install . --action-ref aarwitz/tapp@v0.13.1
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Baseline creation rejects non-gate JSON, missing or mismatched target identity, platform mismatch,
|
|
239
|
+
failed Flows/Scenarios/contracts, `blocked`, and `inconclusive`. It writes atomically to
|
|
240
|
+
`.autotap/baselines/<platform>/<target-id>.json` and requires `--replace` to supersede reviewed
|
|
241
|
+
evidence. Capture-local paths are replaced with portable `tapp-capture:` references before the
|
|
242
|
+
repository artifact is written. The gate also checks baseline platform and target identity before
|
|
243
|
+
comparing findings. On iOS, the same validated launch arguments and string-valued launch
|
|
244
|
+
environment are passed to autonomous exploration and deterministic Flow/contract replay; invalid
|
|
245
|
+
JSON or unsupported value types fail before execution rather than silently testing different app
|
|
246
|
+
configurations.
|
|
247
|
+
|
|
248
|
+
CI installation writes `.github/workflows/tapp.yml` and `.autotap/ci.json`, never overwrites by
|
|
249
|
+
default, and refuses unresolved iOS schemes, Android ids, browser lockfiles, or runtimes. The
|
|
250
|
+
workflow uses exact contract paths, maps each actor environment binding to a same-named GitHub
|
|
251
|
+
Secret, uses the first/default actor for autonomous-login inputs, preserves the remaining bindings
|
|
252
|
+
for deterministic multi-actor replay, and supports managed web startup, Android emulator
|
|
253
|
+
provisioning, and the target-specific baseline. It does not commit,
|
|
254
|
+
push, enable branch protection, or create remote resources. Use MCP `tapp_ci_setup` for the same
|
|
255
|
+
read-only render, baseline import, and guarded install engine.
|
|
256
|
+
|
|
257
|
+
## Current boundary
|
|
258
|
+
|
|
259
|
+
Repository detection, one-target real exploration, first-map merge, evidence classification,
|
|
260
|
+
runtime-observed iOS scheme confirmation, durable source-only refresh, deterministic planning, safe
|
|
261
|
+
persistence, approve/reject/defer review, and compile-checked Task-backed draft generation are
|
|
262
|
+
implemented. An empty map or a latest exploration marked inconclusive remains a blocking
|
|
263
|
+
requirement; observing a login wall is not treated as useful coverage.
|
|
264
|
+
|
|
265
|
+
`tapp init` does not yet orchestrate every detected target in one invocation, provision arbitrary
|
|
266
|
+
web backends/services, automatically replay every approved draft, or promote validated drafts without
|
|
267
|
+
explicit customer acceptance. Baseline creation and a reviewable per-target GitHub CI patch are now
|
|
268
|
+
implemented as explicit post-promotion commands, but the generated workflow has not yet passed on
|
|
269
|
+
current GitHub-hosted iOS, Android, and web runners. Task generation currently handles observed
|
|
270
|
+
reachable navigation. Deterministic business planning is deliberately limited to cross-actor
|
|
271
|
+
content propagation and one checkout-to-order-history persistence pattern supported by exact Task
|
|
272
|
+
input/output, screen, actor, UI Map, and lifecycle evidence. General forms, broader payment shapes,
|
|
273
|
+
dynamic value capture, messaging/reactions, role-asymmetric invariants, and incompatible platform
|
|
274
|
+
journeys still require reviewed authoring. Optional
|
|
275
|
+
AI business reasoning is also not wired into this path. Those missing stages remain completion
|
|
276
|
+
blockers.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Multi-actor Scenarios
|
|
2
|
+
|
|
3
|
+
A Scenario is Tapp's low-level deterministic multi-actor execution format in `.autotap/scenarios/*.yml`. It uses the same semantic actions, polling assertions, timeouts, evidence markers, and merge policy as a Flow, but adds isolated named actors, shared variables, and explicit lifecycle steps. The customer-facing business authoring layer is a TypeScript [release contract](release-contracts.md), which composes reusable Tasks and compiles to this runtime instead of duplicating UI steps.
|
|
4
|
+
|
|
5
|
+
Ordinary replay is keyless. AI may propose a Scenario during authoring, but no model, API key, or coding agent participates when CI executes it.
|
|
6
|
+
|
|
7
|
+
## Current support
|
|
8
|
+
|
|
9
|
+
Web replay is implemented through one isolated Playwright browser context per actor. Cookies, local storage, and in-browser session state cannot leak between actors; all contexts point at the same deployed application and backend. The Action, portable gate, CLI, and MCP surface all consume the same file.
|
|
10
|
+
|
|
11
|
+
iOS and Android still support sequential account switching inside ordinary Flows, but do not yet have first-class isolated multi-actor Scenario drivers. Tapp rejects those platform combinations instead of presenting sequential login/logout as equivalent proof.
|
|
12
|
+
|
|
13
|
+
## Contract
|
|
14
|
+
|
|
15
|
+
```yaml
|
|
16
|
+
name: Alice publishes and Bob sees it
|
|
17
|
+
kind: scenario
|
|
18
|
+
platform: web
|
|
19
|
+
url: http://127.0.0.1:4180
|
|
20
|
+
timeoutMs: 6000
|
|
21
|
+
vars: # shared deterministic data
|
|
22
|
+
POST: Scenario post 7319
|
|
23
|
+
actors:
|
|
24
|
+
alice:
|
|
25
|
+
vars: # actor-scoped credentials/session inputs
|
|
26
|
+
EMAIL: alice@example.test
|
|
27
|
+
PASSWORD: demo
|
|
28
|
+
bob:
|
|
29
|
+
vars:
|
|
30
|
+
EMAIL: bob@example.test
|
|
31
|
+
PASSWORD: demo
|
|
32
|
+
setup:
|
|
33
|
+
- request: # bounded, same-origin HTTP; never arbitrary shell
|
|
34
|
+
method: POST
|
|
35
|
+
path: /__tapp/reset
|
|
36
|
+
status: 200
|
|
37
|
+
steps:
|
|
38
|
+
- actor: alice
|
|
39
|
+
type: { field: Email, value: $EMAIL }
|
|
40
|
+
- actor: alice
|
|
41
|
+
type: { field: Password, value: $PASSWORD }
|
|
42
|
+
- actor: alice
|
|
43
|
+
tap: Sign in
|
|
44
|
+
- actor: alice
|
|
45
|
+
type: { field: Post text, value: $POST }
|
|
46
|
+
- actor: alice
|
|
47
|
+
tap: Publish
|
|
48
|
+
- actor: bob
|
|
49
|
+
assert_exists: { target: $POST, timeoutMs: 6000 }
|
|
50
|
+
teardown:
|
|
51
|
+
- request: { method: POST, path: /__tapp/reset, status: 200 }
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
- `actors` must contain at least two names. Every journey step names one of them.
|
|
55
|
+
- Actor variables override shared variables. A committed value such as `$ALICE_PASSWORD` resolves only that explicitly referenced environment variable at run time; Tapp does not serialize the surrounding environment.
|
|
56
|
+
- `setup` and `teardown` currently accept bounded HTTP request steps on the target origin. Teardown runs after a journey failure so state is still cleaned up.
|
|
57
|
+
- Flow assertions poll until their bounded timeout. This models eventual consistency without blind sleeps or unbounded retries. A condition that never becomes true fails visibly.
|
|
58
|
+
- Typed values are not written to step evidence. Results include actor, action, selector, status, and error; failures capture that actor's screen and final screenshots for all actors.
|
|
59
|
+
- A failed Scenario always blocks the release gate, independently of whether autonomous single-user exploration found a problem.
|
|
60
|
+
|
|
61
|
+
## Run it
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
tapp scenario validate .autotap/scenarios/social-system.yml
|
|
65
|
+
ALICE_EMAIL=alice@example.test ALICE_PASSWORD=demo \
|
|
66
|
+
BOB_EMAIL=bob@example.test BOB_PASSWORD=demo \
|
|
67
|
+
tapp scenario run .autotap/scenarios/social-system.yml
|
|
68
|
+
|
|
69
|
+
tapp ci --platform web --url http://127.0.0.1:4180 \
|
|
70
|
+
--scenarios '.autotap/scenarios/*.yml' \
|
|
71
|
+
--json-out tapp-report.json --md-out tapp-report.md
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
GitHub Action:
|
|
75
|
+
|
|
76
|
+
```yaml
|
|
77
|
+
- uses: aarwitz/tapp@main
|
|
78
|
+
with:
|
|
79
|
+
platform: web
|
|
80
|
+
url: http://127.0.0.1:4180
|
|
81
|
+
scenarios: .autotap/scenarios/*.yml
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
MCP clients call `tapp_scenario_run` with `scenarioPath`, or an inline reviewed Scenario. The structured result identifies `kind: scenario`, actual executed/total steps, and actor-tagged steps.
|
|
85
|
+
|
|
86
|
+
## Verified fixture and boundaries
|
|
87
|
+
|
|
88
|
+
`SocialDemo/.autotap/contracts/social-system.contract.ts` is the reference system guarantee; the Scenario remains its low-level execution proof and backwards-compatible escape hatch. On 2026-08-04 the contract passed 41/41 compiled steps using two isolated contexts against one delayed shared backend. With `SOCIAL_DEMO_FAULT=hide-cross-actor-posts`, the unchanged contract failed for Bob at 19/41 and the portable merge gate exited non-zero specifically because one release contract failed.
|
|
89
|
+
|
|
90
|
+
The fixture's `.autotap/project.json` is the central actor contract. It records Alice and Bob's
|
|
91
|
+
roles, isolated sessions, seeded provisioning, reset lifecycle, and four environment-variable
|
|
92
|
+
names. The release contract and Scenario consume those names; neither stores the public fixture
|
|
93
|
+
values. Customer values belong in the local environment or CI secret store.
|
|
94
|
+
|
|
95
|
+
This proves the contract and web implementation, not universal multi-user reliability. Real customers still need reset/provisioning hooks or dedicated test data, enough accessibility semantics to select controls, and a test backend whose eventual-consistency budget is known.
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// Native black-box Android driver: the UIAutomator/ADB counterpart to Tapp's
|
|
2
|
+
// generic XCUITest harness. It can attach to any debuggable or release APK
|
|
3
|
+
// without linking a Tapp SDK into the app.
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
|
+
|
|
9
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
10
|
+
|
|
11
|
+
function executable(name, env = process.env) {
|
|
12
|
+
const suffix = process.platform === "win32" ? ".exe" : "";
|
|
13
|
+
const homes = [
|
|
14
|
+
env.ANDROID_SDK_ROOT,
|
|
15
|
+
env.ANDROID_HOME,
|
|
16
|
+
env.HOME && path.join(env.HOME, "Library", "Android", "sdk"),
|
|
17
|
+
env.HOME && path.join(env.HOME, "Android", "Sdk"),
|
|
18
|
+
"/opt/homebrew/share/android-commandlinetools",
|
|
19
|
+
"/usr/local/share/android-commandlinetools",
|
|
20
|
+
].filter(Boolean);
|
|
21
|
+
for (const home of homes) {
|
|
22
|
+
const p = path.join(home, "platform-tools", name + suffix);
|
|
23
|
+
if (fs.existsSync(p)) return p;
|
|
24
|
+
}
|
|
25
|
+
for (const dir of String(env.PATH || "").split(path.delimiter)) {
|
|
26
|
+
const p = path.join(dir, name + suffix);
|
|
27
|
+
if (fs.existsSync(p)) return p;
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function resolveAdbPath(env = process.env) {
|
|
33
|
+
return executable("adb", env);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function runFile(command, args, { encoding = "utf8", timeout = 30_000, maxBuffer = 16 * 1024 * 1024 } = {}) {
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
execFile(command, args, { encoding, timeout, maxBuffer }, (error, stdout, stderr) => {
|
|
39
|
+
resolve({ code: error?.code && Number.isInteger(error.code) ? error.code : error ? 1 : 0, stdout, stderr, error });
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function entityDecode(value) {
|
|
45
|
+
return String(value || "")
|
|
46
|
+
.replaceAll(""", '"').replaceAll("'", "'")
|
|
47
|
+
.replaceAll("<", "<").replaceAll(">", ">").replaceAll("&", "&");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function bounds(value) {
|
|
51
|
+
const m = String(value || "").match(/^\[(\d+),(\d+)\]\[(\d+),(\d+)\]$/);
|
|
52
|
+
if (!m) return { x: 0, y: 0, w: 0, h: 0 };
|
|
53
|
+
const [, x1, y1, x2, y2] = m.map(Number);
|
|
54
|
+
return { x: x1, y: y1, w: Math.max(0, x2 - x1), h: Math.max(0, y2 - y1) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function parseUiAutomatorXml(xml) {
|
|
58
|
+
const nodes = [];
|
|
59
|
+
for (const match of String(xml || "").matchAll(/<node\s+([^>]*?)(?:\/?>)/g)) {
|
|
60
|
+
const attrs = {};
|
|
61
|
+
for (const a of match[1].matchAll(/([\w:-]+)="([^"]*)"/g)) attrs[a[1]] = entityDecode(a[2]);
|
|
62
|
+
const frame = bounds(attrs.bounds);
|
|
63
|
+
const text = attrs.text || "";
|
|
64
|
+
const description = attrs["content-desc"] || "";
|
|
65
|
+
const resourceId = attrs["resource-id"] || "";
|
|
66
|
+
nodes.push({
|
|
67
|
+
type: attrs.class || "android.view.View",
|
|
68
|
+
id: resourceId,
|
|
69
|
+
label: description || text,
|
|
70
|
+
text,
|
|
71
|
+
value: text,
|
|
72
|
+
description,
|
|
73
|
+
package: attrs.package || "",
|
|
74
|
+
enabled: attrs.enabled !== "false",
|
|
75
|
+
clickable: attrs.clickable === "true",
|
|
76
|
+
focusable: attrs.focusable === "true",
|
|
77
|
+
focused: attrs.focused === "true",
|
|
78
|
+
scrollable: attrs.scrollable === "true",
|
|
79
|
+
secure: attrs.password === "true",
|
|
80
|
+
selected: attrs.selected === "true",
|
|
81
|
+
hittable: attrs.enabled !== "false" && attrs["visible-to-user"] !== "false" && frame.w > 0 && frame.h > 0,
|
|
82
|
+
...frame,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return nodes;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function androidElementKey(element) {
|
|
89
|
+
return element.id || element.description || element.text || `${element.type}:${element.x},${element.y}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function findAndroidElement(elements, target, { hittable = false } = {}) {
|
|
93
|
+
const wanted = String(target || "").trim();
|
|
94
|
+
if (!wanted) return null;
|
|
95
|
+
const pool = hittable ? elements.filter((e) => e.hittable) : elements;
|
|
96
|
+
const exact = (value) => value && value.localeCompare(wanted, undefined, { sensitivity: "accent" }) === 0;
|
|
97
|
+
return pool.find((e) => exact(e.id))
|
|
98
|
+
|| pool.find((e) => e.id && e.id.endsWith(`/${wanted}`))
|
|
99
|
+
|| pool.find((e) => exact(e.description))
|
|
100
|
+
|| pool.find((e) => exact(e.text))
|
|
101
|
+
|| pool.find((e) => exact(e.label))
|
|
102
|
+
|| pool.find((e) => [e.description, e.text, e.label].some((v) => v && v.toLowerCase().includes(wanted.toLowerCase())))
|
|
103
|
+
|| null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function detectAndroidScreen(elements, activity = "") {
|
|
107
|
+
const explicit = elements.find((e) => /\/(screen_title|toolbar_title|title)$/.test(e.id) && e.text);
|
|
108
|
+
if (explicit) return explicit.text;
|
|
109
|
+
const described = elements.find((e) => /^screen:/i.test(e.description));
|
|
110
|
+
if (described) return described.description.replace(/^screen:\s*/i, "");
|
|
111
|
+
const topText = elements
|
|
112
|
+
.filter((e) => e.text && e.y < 260 && !/statusbar|navigationbar/i.test(e.type))
|
|
113
|
+
.sort((a, b) => a.y - b.y || a.x - b.x)[0];
|
|
114
|
+
if (topText) return topText.text;
|
|
115
|
+
const component = String(activity || "").split("/").at(-1)?.replace(/^\./, "").replace(/Activity$/, "");
|
|
116
|
+
return component || "Unknown";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function isAndroidAppSnapshot(snapshot, appId) {
|
|
120
|
+
if (!snapshot || !appId) return false;
|
|
121
|
+
if (String(snapshot.activity || "").startsWith(`${appId}/`)) return true;
|
|
122
|
+
return snapshot.elements.some((e) => e.package === appId);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export class AndroidDriver {
|
|
126
|
+
constructor({ adbPath = resolveAdbPath(), serial = "", appId = "" } = {}) {
|
|
127
|
+
if (!adbPath) throw new Error("Android testing needs adb. Install Android SDK platform-tools or set ANDROID_SDK_ROOT.");
|
|
128
|
+
this.adbPath = adbPath;
|
|
129
|
+
this.serial = serial;
|
|
130
|
+
this.appId = appId;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
args(args) { return this.serial ? ["-s", this.serial, ...args] : args; }
|
|
134
|
+
async adb(args, options) { return runFile(this.adbPath, this.args(args), options); }
|
|
135
|
+
|
|
136
|
+
async ensureDevice() {
|
|
137
|
+
const r = await runFile(this.adbPath, ["devices", "-l"]);
|
|
138
|
+
if (r.code !== 0) throw new Error((r.stderr || "adb devices failed").trim());
|
|
139
|
+
const devices = String(r.stdout).split(/\r?\n/).slice(1)
|
|
140
|
+
.map((line) => line.trim().split(/\s+/)).filter((p) => p[0] && p[1] === "device");
|
|
141
|
+
if (this.serial) {
|
|
142
|
+
if (!devices.some(([serial]) => serial === this.serial)) throw new Error(`Android device ${this.serial} is not connected and authorized`);
|
|
143
|
+
} else if (devices.length) {
|
|
144
|
+
this.serial = devices[0][0];
|
|
145
|
+
} else {
|
|
146
|
+
throw new Error("No connected Android emulator/device. Start an emulator or connect a device with USB debugging enabled.");
|
|
147
|
+
}
|
|
148
|
+
return { serial: this.serial };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async install(apkPath) {
|
|
152
|
+
const r = await this.adb(["install", "-r", "-t", apkPath], { timeout: 180_000 });
|
|
153
|
+
if (r.code !== 0 || !String(r.stdout).includes("Success")) throw new Error((r.stderr || r.stdout || "APK install failed").trim());
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async forceStop() {
|
|
158
|
+
if (this.appId) await this.adb(["shell", "am", "force-stop", this.appId]);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async clearData() {
|
|
162
|
+
if (!this.appId) throw new Error("appId is required to clear Android app data");
|
|
163
|
+
const r = await this.adb(["shell", "pm", "clear", this.appId]);
|
|
164
|
+
if (!String(r.stdout).includes("Success")) throw new Error((r.stderr || r.stdout || "Could not clear app data").trim());
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async launch({ clearData = false } = {}) {
|
|
168
|
+
await this.ensureDevice();
|
|
169
|
+
if (!this.appId) throw new Error("Android appId is required");
|
|
170
|
+
await this.forceStop();
|
|
171
|
+
if (clearData) await this.clearData();
|
|
172
|
+
const resolved = await this.adb(["shell", "cmd", "package", "resolve-activity", "--brief", "-c", "android.intent.category.LAUNCHER", this.appId]);
|
|
173
|
+
const component = String(resolved.stdout || "").split(/\r?\n/).map((s) => s.trim()).findLast((s) => s.includes("/"));
|
|
174
|
+
if (!component) throw new Error(`No launchable Activity found for ${this.appId}`);
|
|
175
|
+
const r = await this.adb(["shell", "am", "start", "-W", "-n", component], { timeout: 30_000 });
|
|
176
|
+
if (r.code !== 0 || !/Status:\s*ok/i.test(String(r.stdout))) throw new Error((r.stderr || r.stdout || `Could not launch ${this.appId}`).trim());
|
|
177
|
+
await sleep(600);
|
|
178
|
+
return this.snapshot();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async currentActivity() {
|
|
182
|
+
const activity = await this.adb(["shell", "dumpsys", "activity", "activities"]);
|
|
183
|
+
const atext = String(activity.stdout || "");
|
|
184
|
+
const resumed = (atext.match(/mResumedActivity:.*?\s([\w.$]+\/[\w.$]+)/) || atext.match(/topResumedActivity=.*?\s([\w.$]+\/[\w.$]+)/) || [])[1];
|
|
185
|
+
if (resumed) return resumed;
|
|
186
|
+
const window = await this.adb(["shell", "dumpsys", "window", "windows"]);
|
|
187
|
+
const wtext = String(window.stdout || "");
|
|
188
|
+
return (wtext.match(/mCurrentFocus=.*?\s([\w.$]+\/[\w.$]+)/) || wtext.match(/mFocusedApp=.*?\s([\w.$]+\/[\w.$]+)/) || [])[1] || "";
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async dumpXml() {
|
|
192
|
+
let r = await this.adb(["exec-out", "uiautomator", "dump", "/dev/tty"], { timeout: 20_000, maxBuffer: 24 * 1024 * 1024 });
|
|
193
|
+
let output = String(r.stdout || "");
|
|
194
|
+
let at = output.indexOf("<?xml");
|
|
195
|
+
if (r.code !== 0 || at < 0) {
|
|
196
|
+
const remote = `/sdcard/tapp-window-${process.pid}.xml`;
|
|
197
|
+
await this.adb(["shell", "uiautomator", "dump", remote], { timeout: 20_000 });
|
|
198
|
+
r = await this.adb(["exec-out", "cat", remote], { timeout: 20_000, maxBuffer: 24 * 1024 * 1024 });
|
|
199
|
+
await this.adb(["shell", "rm", "-f", remote]);
|
|
200
|
+
output = String(r.stdout || "");
|
|
201
|
+
at = output.indexOf("<?xml");
|
|
202
|
+
}
|
|
203
|
+
if (at < 0) throw new Error((r.stderr || "UIAutomator produced no XML hierarchy").trim());
|
|
204
|
+
return output.slice(at);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async snapshot() {
|
|
208
|
+
const [xml, activity] = await Promise.all([this.dumpXml(), this.currentActivity()]);
|
|
209
|
+
const elements = parseUiAutomatorXml(xml);
|
|
210
|
+
return { screenTitle: detectAndroidScreen(elements, activity), elements, activity, xml };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async screenshot(filePath) {
|
|
214
|
+
const r = await this.adb(["exec-out", "screencap", "-p"], { encoding: "buffer", timeout: 30_000, maxBuffer: 32 * 1024 * 1024 });
|
|
215
|
+
if (r.code !== 0 || !r.stdout?.length) throw new Error("Android screenshot failed");
|
|
216
|
+
if (filePath) {
|
|
217
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
218
|
+
fs.writeFileSync(filePath, r.stdout);
|
|
219
|
+
}
|
|
220
|
+
return r.stdout;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async settle(timeoutMs = 2200) {
|
|
224
|
+
const deadline = Date.now() + timeoutMs;
|
|
225
|
+
let previous = "";
|
|
226
|
+
let stable = 0;
|
|
227
|
+
let latest;
|
|
228
|
+
while (Date.now() < deadline) {
|
|
229
|
+
latest = await this.snapshot();
|
|
230
|
+
const fingerprint = latest.elements.map((e) => `${androidElementKey(e)}:${e.text}:${e.x},${e.y}`).join("|");
|
|
231
|
+
if (fingerprint === previous) stable += 1; else stable = 0;
|
|
232
|
+
if (stable >= 1) return latest;
|
|
233
|
+
previous = fingerprint;
|
|
234
|
+
await sleep(180);
|
|
235
|
+
}
|
|
236
|
+
return latest || this.snapshot();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async tap(target, snapshot) {
|
|
240
|
+
const snap = snapshot || await this.snapshot();
|
|
241
|
+
const element = findAndroidElement(snap.elements, target, { hittable: true });
|
|
242
|
+
if (!element) return { status: "not_found", detail: `could not find ‘${target}’` };
|
|
243
|
+
const x = Math.round(element.x + element.w / 2);
|
|
244
|
+
const y = Math.round(element.y + element.h / 2);
|
|
245
|
+
const r = await this.adb(["shell", "input", "tap", String(x), String(y)]);
|
|
246
|
+
return r.code === 0 ? { status: "ok", element } : { status: "not_hittable", detail: String(r.stderr || "tap failed") };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async type(target, value, snapshot) {
|
|
250
|
+
const snap = snapshot || await this.snapshot();
|
|
251
|
+
const element = target ? findAndroidElement(snap.elements, target, { hittable: true }) : snap.elements.find((e) => e.focused);
|
|
252
|
+
if (!element) return { status: "not_found", detail: `no field ‘${target}’ to type into` };
|
|
253
|
+
await this.tap(androidElementKey(element), snap);
|
|
254
|
+
await this.adb(["shell", "input", "keyevent", "KEYCODE_MOVE_END"]);
|
|
255
|
+
await this.adb(["shell", "input", "keyevent", "--longpress", "KEYCODE_DEL"]);
|
|
256
|
+
// ADB input uses %s for spaces. Keep it as an argv value so the shell never
|
|
257
|
+
// interprets credentials or punctuation.
|
|
258
|
+
const encoded = String(value).replaceAll("%", "%25").replaceAll(" ", "%s");
|
|
259
|
+
const r = await this.adb(["shell", "input", "text", encoded]);
|
|
260
|
+
return r.code === 0 ? { status: "ok", element } : { status: "not_hittable", detail: String(r.stderr || "type failed") };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async back() { await this.adb(["shell", "input", "keyevent", "KEYCODE_BACK"]); return { status: "ok" }; }
|
|
264
|
+
|
|
265
|
+
async swipe(direction = "up") {
|
|
266
|
+
const points = {
|
|
267
|
+
up: [540, 1500, 540, 500], down: [540, 500, 540, 1500],
|
|
268
|
+
left: [900, 1000, 180, 1000], right: [180, 1000, 900, 1000],
|
|
269
|
+
}[direction.toLowerCase()] || [540, 1500, 540, 500];
|
|
270
|
+
await this.adb(["shell", "input", "swipe", ...points.map(String), "250"]);
|
|
271
|
+
return { status: "ok" };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async waitFor(target, timeoutMs = 6000) {
|
|
275
|
+
const deadline = Date.now() + timeoutMs;
|
|
276
|
+
while (Date.now() < deadline) {
|
|
277
|
+
const snap = await this.snapshot();
|
|
278
|
+
if (snap.screenTitle.toLowerCase() === String(target).toLowerCase() || findAndroidElement(snap.elements, target)) return snap;
|
|
279
|
+
await sleep(250);
|
|
280
|
+
}
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function androidCaptureDir(prefix = "android") {
|
|
286
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), `tapp-${prefix}-`));
|
|
287
|
+
}
|