@aarwitz/tapp 0.17.3 → 0.17.4
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 +21 -1
- package/README.md +2 -2
- package/bin/tapp.js +25 -3
- package/docs/application-model.md +5 -1
- package/docs/scenarios.md +1 -1
- package/mcp-server/src/application-model.js +34 -4
- package/mcp-server/src/index.js +61 -14
- package/mcp-server/src/project-config.js +8 -1
- package/package.json +1 -1
- package/skills/tapp/references/commands.md +9 -0
|
@@ -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.4",
|
|
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.4",
|
|
28
28
|
"mcp"
|
|
29
29
|
],
|
|
30
30
|
"cwd": "${CLAUDE_PROJECT_DIR}"
|
package/AGENTS.md
CHANGED
|
@@ -106,7 +106,13 @@ Rules that prevent 90% of failures:
|
|
|
106
106
|
secret-templated replay step and avoids native secure-field refocus behavior.
|
|
107
107
|
5. Tap results: `ok` (landed), `not_hittable` (exists but disabled/covered — the harness
|
|
108
108
|
auto-dismisses keyboards and retries), `not_found` (nothing matches — re-read the tree).
|
|
109
|
-
6. One session at a time. `session_start` always begins from a fresh
|
|
109
|
+
6. One session at a time. `session_start` always begins from a fresh **cold** launch
|
|
110
|
+
(terminate + relaunch, for a deterministic starting screen). Persisted app data such as
|
|
111
|
+
Keychain credentials survives, but the app opens on its launch screen, not a resumed
|
|
112
|
+
foreground state — an app that gates each cold start behind sign-in WILL show its login
|
|
113
|
+
wall, so plan `login` (or a bypass launch argument) as the first act. Plain `tapp tree` /
|
|
114
|
+
`tapp screenshot` warm-resume the currently foregrounded app instead, which is why they can
|
|
115
|
+
look signed-in when a fresh session does not.
|
|
110
116
|
|
|
111
117
|
## Autonomous exploration (`tapp_explore`)
|
|
112
118
|
|
|
@@ -145,6 +151,11 @@ without a coding agent, model, subscription, or API key. AI generation and `asse
|
|
|
145
151
|
a final screen assertion auto-inserted; typed credentials are templated to `$TEST_EMAIL`/`$TEST_PASSWORD`.
|
|
146
152
|
- **Replay:** `tapp_flow_run { flowPath: ".tapp/flows/checkout.yml" }` — exact steps,
|
|
147
153
|
deterministic assertions, same result every time. A failed assertion is a finding.
|
|
154
|
+
- **Credentials at replay:** pass real values (`testEmail`/`testPassword`, CLI `--email`/
|
|
155
|
+
`--password`), or name a configured actor (`actor: "coach"`, CLI `--actor coach`) and Tapp
|
|
156
|
+
resolves `$TEST_EMAIL`/`$TEST_PASSWORD` from the env vars that actor binds. Actors store
|
|
157
|
+
env-var **names** only — never values. `tapp actor set` refuses to overwrite an existing
|
|
158
|
+
actor unless you pass `--replace`, so idempotent setup scripts must include it.
|
|
148
159
|
- **Generate:** `tapp_flow_generate { goal: "log in and add the first item to cart" }` —
|
|
149
160
|
grounded in the app's actually-explored screens, so it can't invent steps.
|
|
150
161
|
- **Discover the file format without MCP:** `npx -y @aarwitz/tapp@latest flow example` prints a
|
|
@@ -161,6 +172,15 @@ without a coding agent, model, subscription, or API key. AI generation and `asse
|
|
|
161
172
|
and installs from an Xcode project/workspace; or the user's normal build).
|
|
162
173
|
- A simulator must be booted (`tapp_list_simulators` → `tapp_boot_simulator`).
|
|
163
174
|
- Screenshots/captures land in `~/.tapp/captures/`.
|
|
175
|
+
- Driving `tapp mcp` from a raw stdio client: **consume or discard stderr** — the server logs
|
|
176
|
+
progress there, and an unread stderr pipe can deadlock a naive client. The first
|
|
177
|
+
`tapp_session_start` on a cold machine includes the one-time harness build, so it can take
|
|
178
|
+
minutes before the first result arrives; that is startup cost, not a hang.
|
|
179
|
+
- Managed web targets always bind `127.0.0.1` and prefer the repository's declared or framework
|
|
180
|
+
default port (vite → 5173, next → 3000). If the app's backend uses a CORS allowlist, pin the
|
|
181
|
+
origin with `"web": { "port": 5173 }` in `.tapp/project.json` — an unexpected port surfaces as
|
|
182
|
+
misleading fetch/CORS findings, and a busy pinned port is a hard error, never a silent
|
|
183
|
+
ephemeral fallback.
|
|
164
184
|
|
|
165
185
|
## Honesty rules
|
|
166
186
|
|
package/README.md
CHANGED
|
@@ -348,7 +348,7 @@ jobs:
|
|
|
348
348
|
timeout-minutes: 45
|
|
349
349
|
steps:
|
|
350
350
|
- uses: actions/checkout@v4
|
|
351
|
-
- uses: aarwitz/tapp@v0.17.
|
|
351
|
+
- uses: aarwitz/tapp@v0.17.4 # or pin the reviewed release commit SHA
|
|
352
352
|
with:
|
|
353
353
|
project: MyApp.xcodeproj # or MyApp.xcworkspace
|
|
354
354
|
scheme: MyApp
|
|
@@ -398,7 +398,7 @@ Android CI runs on Linux with an emulator/device already connected. The Action c
|
|
|
398
398
|
or accept a prebuilt one:
|
|
399
399
|
|
|
400
400
|
```yaml
|
|
401
|
-
- uses: aarwitz/tapp@v0.17.
|
|
401
|
+
- uses: aarwitz/tapp@v0.17.4 # or pin the reviewed release commit SHA
|
|
402
402
|
with:
|
|
403
403
|
platform: android
|
|
404
404
|
android-app-id: com.acme.app
|
package/bin/tapp.js
CHANGED
|
@@ -270,7 +270,7 @@ function safeCommandUsage(verb) {
|
|
|
270
270
|
shot: "tapp shot [--out FILE]",
|
|
271
271
|
apps: "tapp apps",
|
|
272
272
|
build: "tapp build [repo] [--scheme NAME] [--configuration NAME]",
|
|
273
|
-
flow: "tapp flow example\ntapp flow validate FILE [--platform PLATFORM] [--map FILE]\ntapp flow run FILE [--email VALUE] [--password VALUE]",
|
|
273
|
+
flow: "tapp flow example\ntapp flow validate FILE [--platform PLATFORM] [--map FILE]\ntapp flow run FILE [--actor NAME] [--email VALUE] [--password VALUE]",
|
|
274
274
|
task: "tapp task validate FILE [--platform PLATFORM] [--map FILE]\ntapp task compile FILE --platform PLATFORM [--inputs JSON] [--out FILE]\ntapp task run FILE --platform PLATFORM [--url URL|--bundle-id ID|--app-id ID] [--inputs JSON]",
|
|
275
275
|
contract: "tapp contract validate FILE [--platform PLATFORM] [--map FILE]\ntapp contract compile FILE --platform PLATFORM [--out FILE]\ntapp contract run FILE --platform PLATFORM [--url URL|--bundle-id ID|--app-id ID]",
|
|
276
276
|
scenario: "tapp scenario validate FILE [--project-dir DIR]\ntapp scenario run FILE --platform web --url URL [--project-dir DIR]",
|
|
@@ -279,7 +279,7 @@ function safeCommandUsage(verb) {
|
|
|
279
279
|
plan: "tapp plan show [FILE]\ntapp plan review [FILE] --approve NAME[,NAME] --reject NAME[,NAME] --defer NAME[,NAME]\ntapp plan generate|validate|promote [FILE] [options]",
|
|
280
280
|
baseline: "tapp baseline create [repo] [--platform PLATFORM] [--target NAME] [--from GATE.json] [--replace]",
|
|
281
281
|
ci: "tapp ci ...\ntapp ci install [repo] [--out FILE] [--manifest FILE] [--dry-run] [--replace]",
|
|
282
|
-
actor: "tapp actor set NAME --email-env ENV --password-env ENV [--project-dir DIR]\ntapp actor list [repo]",
|
|
282
|
+
actor: "tapp actor set NAME --email-env ENV --password-env ENV [--replace] [--project-dir DIR]\ntapp actor list [repo]",
|
|
283
283
|
app: "tapp app [repo] [--no-open] [--port PORT]",
|
|
284
284
|
report: "tapp report [captureId|latest]",
|
|
285
285
|
doctor: "tapp doctor",
|
|
@@ -1111,7 +1111,7 @@ switch (command) {
|
|
|
1111
1111
|
break;
|
|
1112
1112
|
}
|
|
1113
1113
|
if (!["run", "validate"].includes(verb) || !flowPath) {
|
|
1114
|
-
console.error("usage: tapp flow example\n tapp flow run <flow.yml> [--platform ios|android|web] [--url URL] [--app-id ID] [--apk FILE] [--serial ID]\n tapp flow validate <flow.yml>");
|
|
1114
|
+
console.error("usage: tapp flow example\n tapp flow run <flow.yml> [--platform ios|android|web] [--actor NAME] [--email VALUE] [--password VALUE] [--url URL] [--app-id ID] [--apk FILE] [--serial ID]\n tapp flow validate <flow.yml>");
|
|
1115
1115
|
process.exit(2);
|
|
1116
1116
|
}
|
|
1117
1117
|
const absolute = path.resolve(flowPath);
|
|
@@ -1146,6 +1146,28 @@ switch (command) {
|
|
|
1146
1146
|
const env = { ...process.env, FLOW_LOG: flowLog, TAPP_FLOW_EVIDENCE_DIR: evidenceDir };
|
|
1147
1147
|
if (typeof flags.email === "string") env.OCQA_TEST_EMAIL = flags.email;
|
|
1148
1148
|
if (typeof flags.password === "string") env.OCQA_TEST_PASSWORD = flags.password;
|
|
1149
|
+
if (typeof flags.actor === "string" && flags.actor) {
|
|
1150
|
+
const { readProjectConfig } = await import(path.join(packageRoot, "mcp-server", "src", "project-config.js"));
|
|
1151
|
+
const loaded = readProjectConfig(process.cwd());
|
|
1152
|
+
if (loaded.errors.length) { console.error(`❌ Invalid ${loaded.relativePath}: ${loaded.errors.join("; ")}`); process.exit(2); }
|
|
1153
|
+
const actor = loaded.config.actors?.[flags.actor];
|
|
1154
|
+
if (!actor) {
|
|
1155
|
+
console.error(`❌ Actor '${flags.actor}' is not configured in ${loaded.relativePath}. Run from the repository root, or configure it: tapp actor set ${flags.actor} --email-env ENV --password-env ENV`);
|
|
1156
|
+
process.exit(2);
|
|
1157
|
+
}
|
|
1158
|
+
// Actors store env-var NAMES only; resolve the values here. Explicit --email/--password win.
|
|
1159
|
+
for (const [credential, flagName, envKey] of [["email", "email", "OCQA_TEST_EMAIL"], ["password", "password", "OCQA_TEST_PASSWORD"]]) {
|
|
1160
|
+
if (typeof flags[flagName] === "string") continue;
|
|
1161
|
+
const binding = actor.credentials?.[credential];
|
|
1162
|
+
if (!binding) continue;
|
|
1163
|
+
const value = process.env[binding.env];
|
|
1164
|
+
if (!value) {
|
|
1165
|
+
console.error(`❌ Actor '${flags.actor}' binds ${credential} to $${binding.env}, but that environment variable is not set.`);
|
|
1166
|
+
process.exit(2);
|
|
1167
|
+
}
|
|
1168
|
+
env[envKey] = value;
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1149
1171
|
let invocation;
|
|
1150
1172
|
if (platform === "web") {
|
|
1151
1173
|
const url = typeof flags.url === "string" ? flags.url : flow.url || flow.app;
|
|
@@ -95,7 +95,11 @@ derived lockfile-backed install command, runs its declared build script when pre
|
|
|
95
95
|
`start`, `dev`, `serve`, or `preview` package script with argument-array process execution (never
|
|
96
96
|
generated shell source). A static site with no script uses Tapp's local read-only static server. The
|
|
97
97
|
runtime binds to an available loopback port, writes its log under the Tapp runtime directory, and is
|
|
98
|
-
terminated after exploration even when QA fails.
|
|
98
|
+
terminated after exploration even when QA fails. Port precedence is: an explicit
|
|
99
|
+
`"web": { "port": N }` pin in `.tapp/project.json`, then a port declared by the start script, then
|
|
100
|
+
the framework default (vite 5173, next 3000), then an ephemeral port. Pin the port when a backend
|
|
101
|
+
CORS allowlist expects a fixed origin; a busy or contradicted pin is a hard startup error rather
|
|
102
|
+
than a silent fallback that would resurface as fetch/CORS findings. The host is always `127.0.0.1`. Multiple web targets, an unlocked dependency
|
|
99
103
|
graph, an unrecognized start path, or backend-specific configuration produce explicit remediation;
|
|
100
104
|
provide `--target` and/or an already-running owned `--url` in those cases. Running repository build
|
|
101
105
|
scripts executes repository code and should only be used for a checkout the customer trusts.
|
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@v0.17.
|
|
77
|
+
- uses: aarwitz/tapp@v0.17.4 # or pin the reviewed release commit SHA
|
|
78
78
|
with:
|
|
79
79
|
platform: web
|
|
80
80
|
url: http://127.0.0.1:4180
|
|
@@ -91,8 +91,15 @@ function applyRuntimeTargetValidation(root, targets, validation) {
|
|
|
91
91
|
if (!container || !scheme || !bundleId) return targets;
|
|
92
92
|
const captureId = String(validation.evidence?.captureId || "").trim();
|
|
93
93
|
const explored = !!captureId;
|
|
94
|
+
// The build records the container findXcodeContainer resolved (shallowest workspace-first),
|
|
95
|
+
// which can differ from the modeled sourcePath in a repo exposing both a workspace and a
|
|
96
|
+
// project. With exactly one iOS target there is no ambiguity — apply the validation rather
|
|
97
|
+
// than silently dropping it and re-blocking on scheme confirmation after every refresh.
|
|
98
|
+
const iosTargets = targets.filter((target) => target.platform === "ios");
|
|
99
|
+
const pathMatched = iosTargets.some((target) => posix(target.sourcePath) === container);
|
|
94
100
|
return targets.map((target) => {
|
|
95
|
-
if (target.platform !== "ios"
|
|
101
|
+
if (target.platform !== "ios") return target;
|
|
102
|
+
if (posix(target.sourcePath) !== container && (pathMatched || iosTargets.length !== 1)) return target;
|
|
96
103
|
return {
|
|
97
104
|
...target,
|
|
98
105
|
status: "configured",
|
|
@@ -917,12 +924,25 @@ function mergePlanDecisions(next, prior, { invalidateValidation = false } = {})
|
|
|
917
924
|
priorByNameScope.set(key, list);
|
|
918
925
|
}
|
|
919
926
|
const consumedPriorIds = new Set();
|
|
927
|
+
const derivedScopes = new Set(next.items.map((item) => item.scope || "."));
|
|
920
928
|
const carried = next.items.map((item) => {
|
|
921
929
|
const exact = priorItems.get(item.id);
|
|
922
930
|
const lineage = item.origin === "committed"
|
|
923
931
|
? (priorByNameScope.get(`${item.scope || "."}|${item.name}`) || []).find((candidate) => candidate.origin === "promoted-validated" || candidate.generation?.status === "promoted")
|
|
924
932
|
: null;
|
|
925
|
-
|
|
933
|
+
// Proposal ids hash the node's targetId, which can drift between refreshes when target
|
|
934
|
+
// detection or map attribution shifts. Without name+scope regrounding, that drift strands
|
|
935
|
+
// the reviewed item as stale AND re-adds the same proposal as pending — a duplicate the
|
|
936
|
+
// customer already decided.
|
|
937
|
+
const sameNameScope = exact || lineage ? [] : (priorByNameScope.get(`${item.scope || "."}|${item.name}`) || []).filter((candidate) => !consumedPriorIds.has(candidate.id));
|
|
938
|
+
const regrounded = sameNameScope.find((candidate) => candidate.decision && candidate.decision !== "pending") || sameNameScope[0] || null;
|
|
939
|
+
// When a scope stops being derived entirely (its target no longer grounds any proposals — the
|
|
940
|
+
// mis-attribution case), a decided same-name item from that vanished scope carries the
|
|
941
|
+
// customer's decision onto the surviving surface instead of lingering as a stale duplicate
|
|
942
|
+
// beside a re-added pending twin. The migration is recorded via regroundedFromScope.
|
|
943
|
+
const crossScope = exact || lineage || regrounded ? null
|
|
944
|
+
: (prior.items || []).find((candidate) => !consumedPriorIds.has(candidate.id) && candidate.name === item.name && !derivedScopes.has(candidate.scope || ".") && candidate.decision && candidate.decision !== "pending") || null;
|
|
945
|
+
const previous = exact || lineage || regrounded || crossScope;
|
|
926
946
|
if (!previous) return item;
|
|
927
947
|
consumedPriorIds.add(previous.id);
|
|
928
948
|
if (exact && lineage && exact.id !== lineage.id) consumedPriorIds.add(lineage.id);
|
|
@@ -936,10 +956,20 @@ function mergePlanDecisions(next, prior, { invalidateValidation = false } = {})
|
|
|
936
956
|
// A reviewed proposal becomes a committed contract without becoming a different
|
|
937
957
|
// customer decision. Preserve its original plan identity so browser links, CLI
|
|
938
958
|
// item selectors, and review history remain stable across promotion refreshes.
|
|
939
|
-
return { ...item, id: previous.id, ...human };
|
|
959
|
+
return { ...item, id: previous.id, ...human, ...(crossScope ? { regroundedFromScope: previous.scope || "." } : {}) };
|
|
940
960
|
});
|
|
941
961
|
const currentIds = new Set(carried.map((item) => item.id));
|
|
942
|
-
|
|
962
|
+
const survivorNames = new Set(carried.map((item) => item.name));
|
|
963
|
+
for (const previous of prior.items || []) {
|
|
964
|
+
if (currentIds.has(previous.id) || consumedPriorIds.has(previous.id) || !previous.decision || previous.decision === "pending") continue;
|
|
965
|
+
const committed = previous.origin === "committed" || previous.origin === "promoted-validated" || previous.generation?.status === "promoted";
|
|
966
|
+
// A decided proposal whose scope is no longer derived duplicates the surviving same-name
|
|
967
|
+
// decision — drop the duplicate rather than carrying it as stale forever. If that scope is
|
|
968
|
+
// re-derived later, its proposals return as pending review; the failure direction is more
|
|
969
|
+
// human review, never a silently granted decision. Committed/promoted items always carry.
|
|
970
|
+
if (!committed && !derivedScopes.has(previous.scope || ".") && survivorNames.has(previous.name)) continue;
|
|
971
|
+
carried.push({ ...previous, stale: true, status: "not-derived-on-refresh" });
|
|
972
|
+
}
|
|
943
973
|
let generation = prior.generation;
|
|
944
974
|
if (invalidateValidation && generation) generation = {
|
|
945
975
|
...generation,
|
package/mcp-server/src/index.js
CHANGED
|
@@ -634,9 +634,9 @@ export function agentFacingElements(elements, limit = 160) {
|
|
|
634
634
|
return result;
|
|
635
635
|
}
|
|
636
636
|
|
|
637
|
-
function agentScreenProjection(screen) {
|
|
637
|
+
function agentScreenProjection(screen, { full = false } = {}) {
|
|
638
638
|
const all = screen?.elements || [];
|
|
639
|
-
const elements = agentFacingElements(all);
|
|
639
|
+
const elements = full ? all : agentFacingElements(all);
|
|
640
640
|
return {
|
|
641
641
|
screenTitle:screen?.screenTitle ?? null,
|
|
642
642
|
elementCount:elements.length,
|
|
@@ -843,22 +843,32 @@ export function isStableFlowCheckpoint(value) {
|
|
|
843
843
|
const text = String(value || "").replace(/\s+/g, " ").trim();
|
|
844
844
|
if (!text || /^(loading|fetching|please wait|preparing|connecting|syncing|signing in)(?:[.…!]*|\s.*)$/i.test(text)) return false;
|
|
845
845
|
if (/^(?:mon|tues?|wed(?:nes)?|thu(?:rs)?|fri|sat(?:ur)?|sun)(?:day)?\b/i.test(text)) return false;
|
|
846
|
-
|
|
846
|
+
// Single dates ("Aug 16, 2026") and date ranges ("AUG 16 - AUG 22, 2026") both roll over on
|
|
847
|
+
// the calendar, so neither is a stable replay checkpoint.
|
|
848
|
+
const monthDay = "(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:tember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\\s+\\d{1,2}";
|
|
849
|
+
if (new RegExp(`^${monthDay}(?:,\\s+\\d{4})?(?:\\s*[-–—]\\s*(?:${monthDay}|\\d{1,2})(?:,\\s+\\d{4})?)?$`, "i").test(text)) return false;
|
|
847
850
|
if (/^\d{4}-\d{2}-\d{2}(?:[ T].*)?$/.test(text)) return false;
|
|
848
851
|
return true;
|
|
849
852
|
}
|
|
850
853
|
|
|
851
854
|
export function semanticTargetAtPoint(elements, x, y) {
|
|
852
855
|
if (!Number.isFinite(x) || !Number.isFinite(y)) return "";
|
|
853
|
-
|
|
856
|
+
const containing = (elements || [])
|
|
854
857
|
.filter((element) => {
|
|
855
858
|
const frame = element.frame || {};
|
|
856
|
-
return
|
|
859
|
+
return Number.isFinite(frame.x) && Number.isFinite(frame.y) && Number.isFinite(frame.width) && Number.isFinite(frame.height)
|
|
857
860
|
&& x >= frame.x && y >= frame.y && x <= frame.x + frame.width && y <= frame.y + frame.height
|
|
858
861
|
&& String(element.id || element.identifier || element.label || "").trim();
|
|
859
862
|
})
|
|
860
|
-
.sort((a, b) => (a.frame.width * a.frame.height) - (b.frame.width * b.frame.height))
|
|
861
|
-
|
|
863
|
+
.sort((a, b) => (a.frame.width * a.frame.height) - (b.frame.width * b.frame.height));
|
|
864
|
+
// Prefer a hittable match. A labelled control that reports hittable=false at its own
|
|
865
|
+
// tree-reported center (custom tab bars over safe-area insets do this) still responds to an
|
|
866
|
+
// element tap, which retries in the harness — so fall back to it rather than firing a raw
|
|
867
|
+
// coordinate tap that lands on nothing. Containers stay excluded: redirecting a coordinate
|
|
868
|
+
// tap to the center of a large labelled group would change where the tap lands.
|
|
869
|
+
const best = containing.find((element) => element.hittable !== false)
|
|
870
|
+
|| containing.find((element) => /button|link|cell|tab|switch|checkbox|toggle|textfield|securetext|textarea|edittext|segmented|menuitem/i.test(`${element.role || ""} ${element.type || ""}`));
|
|
871
|
+
return best ? String(best.id || best.identifier || best.label || "").trim() : "";
|
|
862
872
|
}
|
|
863
873
|
|
|
864
874
|
/** Append a Flow step for an act (record-by-doing). Inserts wait_for on screen change for
|
|
@@ -2258,8 +2268,21 @@ export async function startManagedWebTarget({ root, requestedTarget = "", timeou
|
|
|
2258
2268
|
const dependencies = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
2259
2269
|
const declaredPort = startMatch ? declaredPortFromStartScript(pkg.scripts?.[startMatch[1]]) : 0;
|
|
2260
2270
|
const frameworkPort = declaredPort ? 0 : managedWebDefaultPort(dependencies);
|
|
2261
|
-
|
|
2262
|
-
|
|
2271
|
+
// An explicit `.tapp/project.json` web.port pin outranks everything: backends with a CORS
|
|
2272
|
+
// allowlist need the managed origin to be exact, so a busy pinned port is a hard error, never
|
|
2273
|
+
// a silent ephemeral fallback that would resurface as fetch/CORS findings.
|
|
2274
|
+
const { readProjectConfig } = await import("./project-config.js");
|
|
2275
|
+
const projectConfig = readProjectConfig(root);
|
|
2276
|
+
if (projectConfig.exists && projectConfig.errors.length) return { error: `Invalid ${projectConfig.relativePath}: ${projectConfig.errors.join("; ")}` };
|
|
2277
|
+
const pinnedPort = Number(projectConfig.config?.web?.port) || 0;
|
|
2278
|
+
if (pinnedPort && declaredPort && pinnedPort !== declaredPort) {
|
|
2279
|
+
return { error: `${projectConfig.relativePath} pins the managed web port to ${pinnedPort}, but the repository start script declares port ${declaredPort}`, details: { remediation: "Align web.port with the start script (or remove one of them) so the managed origin is unambiguous." } };
|
|
2280
|
+
}
|
|
2281
|
+
if (pinnedPort && !(await localPortAvailable(pinnedPort))) {
|
|
2282
|
+
return { error: `${projectConfig.relativePath} pins the managed web port to ${pinnedPort}, but that port is already in use`, details: { remediation: "Stop the process holding the port, change web.port, or provide an already-running owned --url." } };
|
|
2283
|
+
}
|
|
2284
|
+
const port = pinnedPort || declaredPort || (frameworkPort && await localPortAvailable(frameworkPort) ? frameworkPort : await openLocalPort());
|
|
2285
|
+
const portBasis = pinnedPort ? "project-pinned" : declaredPort ? "repository-declared" : port === frameworkPort ? "framework-default" : "available-ephemeral";
|
|
2263
2286
|
let command = "npm";
|
|
2264
2287
|
let startArgs;
|
|
2265
2288
|
let startDir = projectDir;
|
|
@@ -2846,7 +2869,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2846
2869
|
"Opt-in AI assertion: {assert_ai: '<claim about the current screen>'} (judged host-side; needs a key; " +
|
|
2847
2870
|
"skipped otherwise). Pass a flow inline via `flow`, or a repo-relative `flowPath` to a .yml/.json. " +
|
|
2848
2871
|
"A failed assertion fails the flow and is reported like a QA finding. $TEST_EMAIL/$TEST_PASSWORD and any " +
|
|
2849
|
-
"flow `vars` are substituted; pass testEmail/testPassword for real credential values
|
|
2872
|
+
"flow `vars` are substituted; pass testEmail/testPassword for real credential values, or `actor` to " +
|
|
2873
|
+
"resolve them from a configured actor's environment-variable bindings.",
|
|
2850
2874
|
inputSchema: {
|
|
2851
2875
|
type: "object",
|
|
2852
2876
|
properties: {
|
|
@@ -2865,6 +2889,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2865
2889
|
androidSerial: { type: "string", description: "Android adb device serial." },
|
|
2866
2890
|
testEmail: { type: "string", description: "Value for $TEST_EMAIL" },
|
|
2867
2891
|
testPassword: { type: "string", description: "Value for $TEST_PASSWORD" },
|
|
2892
|
+
actor: { type: "string", description: "Configured actor name; resolves $TEST_EMAIL/$TEST_PASSWORD from the actor's env-var bindings (explicit testEmail/testPassword win)" },
|
|
2868
2893
|
},
|
|
2869
2894
|
},
|
|
2870
2895
|
},
|
|
@@ -3040,7 +3065,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
3040
3065
|
"launch per action. Returns the initial screen {screenTitle, elements[]}. Drive it with " +
|
|
3041
3066
|
"tapp_focus for any named destination (source + shortest observed route), then tapp_session_act only for remaining actions; finish with tapp_session_end. " +
|
|
3042
3067
|
"For a focused user request, ALWAYS pass it in `focus` so the session reaches that surface before returning. Only one session at a time. Starts from a " +
|
|
3043
|
-
"fresh launch
|
|
3068
|
+
"fresh COLD launch (terminate + relaunch, for a deterministic starting screen): persisted data such as Keychain credentials survives, but the app opens on its " +
|
|
3069
|
+
"launch screen, NOT a resumed foreground state — an app that gates each cold start behind sign-in will show its login wall, so plan a `login` act (or a login bypass " +
|
|
3070
|
+
"launch argument) as the first step. CLI `tapp tree`/`tapp screenshot` warm-resume the currently foregrounded app instead, which is why they can look signed-in when a session does not. " +
|
|
3071
|
+
"Use appLaunchArgs/appLaunchEnv for apps that need a backend override or login bypass. " +
|
|
3044
3072
|
"When you reach a screen with input fields and don't have values for them, ASK THE USER what to type " +
|
|
3045
3073
|
"(offer defaults/skip) before typing — the session does not prompt on its own.",
|
|
3046
3074
|
inputSchema: {
|
|
@@ -3091,8 +3119,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
3091
3119
|
"on refocus, so step-by-step login flows lose the password), 'tap' (by `id` = accessibility identifier " +
|
|
3092
3120
|
"or visible/partial label or placeholder, or by `x`/`y` coordinates), 'type' (`text`, optional `id` to " +
|
|
3093
3121
|
"target a field — always REPLACES the field's content), 'swipe' (`direction`), 'back', 'wait' (block " +
|
|
3094
|
-
"until an element with `id`/`text` appears, up to `timeoutMs`), 'tree' (re-inspect without acting
|
|
3095
|
-
"
|
|
3122
|
+
"until an element with `id`/`text` appears, up to `timeoutMs`), 'tree' (re-inspect without acting; " +
|
|
3123
|
+
"pass `full: true` for every raw element with frames — the default projection dedupes and caps at " +
|
|
3124
|
+
"160 elements, so grep-style checks against it can miss text that IS on screen), " +
|
|
3125
|
+
"'screenshot'. Returns {status, screenTitle, elements[], durationMs}; status 'not_found'/'timeout'/'still_on_login' " +
|
|
3096
3126
|
"etc. with a `detail` explaining login failures.",
|
|
3097
3127
|
inputSchema: {
|
|
3098
3128
|
type: "object",
|
|
@@ -3107,6 +3137,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
3107
3137
|
text: { type: "string", description: "Text to type, or the label/text to wait for" },
|
|
3108
3138
|
direction: { type: "string", enum: ["up", "down", "left", "right"], description: "Swipe direction" },
|
|
3109
3139
|
timeoutMs: { type: "integer", minimum: 500, maximum: 60000, default: 5000, description: "For 'wait': how long to poll for the element" },
|
|
3140
|
+
full: { type: "boolean", default: false, description: "For 'tree': return the complete raw element list (frames included) instead of the deduplicated agent-facing projection capped at 160 elements" },
|
|
3110
3141
|
label: { type: "string", description: "Optional screenshot label" },
|
|
3111
3142
|
},
|
|
3112
3143
|
required: ["action"],
|
|
@@ -3927,6 +3958,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3927
3958
|
const runEnv = { ...process.env, FLOW_LOG: flowLog, TAPP_FLOW_EVIDENCE_DIR: evidenceDir };
|
|
3928
3959
|
if (isNonEmptyString(args.testEmail)) runEnv.OCQA_TEST_EMAIL = args.testEmail.trim();
|
|
3929
3960
|
if (isNonEmptyString(args.testPassword)) runEnv.OCQA_TEST_PASSWORD = args.testPassword.trim();
|
|
3961
|
+
if (isNonEmptyString(args.actor)) {
|
|
3962
|
+
const { readProjectConfig } = await import("./project-config.js");
|
|
3963
|
+
const loaded = readProjectConfig(workspaceRoot);
|
|
3964
|
+
if (loaded.errors.length) return errorResult(`Invalid ${loaded.relativePath}: ${loaded.errors.join("; ")}`);
|
|
3965
|
+
const actor = loaded.config.actors?.[args.actor.trim()];
|
|
3966
|
+
if (!actor) return errorResult(`Actor '${args.actor.trim()}' is not configured in ${loaded.relativePath}`, { hint: "Configure it with tapp_actor_set / tapp actor set first" });
|
|
3967
|
+
// Actors store env-var NAMES only; resolve values here. Explicit testEmail/testPassword win.
|
|
3968
|
+
for (const [credential, argName, envKey] of [["email", "testEmail", "OCQA_TEST_EMAIL"], ["password", "testPassword", "OCQA_TEST_PASSWORD"]]) {
|
|
3969
|
+
if (isNonEmptyString(args[argName])) continue;
|
|
3970
|
+
const binding = actor.credentials?.[credential];
|
|
3971
|
+
if (!binding) continue;
|
|
3972
|
+
const value = process.env[binding.env];
|
|
3973
|
+
if (!value) return errorResult(`Actor '${args.actor.trim()}' binds ${credential} to $${binding.env}, but that environment variable is not set in the MCP server's environment.`);
|
|
3974
|
+
runEnv[envKey] = value;
|
|
3975
|
+
}
|
|
3976
|
+
}
|
|
3930
3977
|
let run = { stdout: "", stderr: "", code: 0 };
|
|
3931
3978
|
if (platform === "web") {
|
|
3932
3979
|
try {
|
|
@@ -4376,7 +4423,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4376
4423
|
const detailNote = !ok && r.detail ? ` — ${r.detail}` : "";
|
|
4377
4424
|
const head = `${did} — ${ok ? "ok" : `⚠️ ${r.status}${detailNote}`} → now on **${r.screenTitle || "Unknown"}**`;
|
|
4378
4425
|
const rec = typeof r.recordedSteps === "number" ? `\n\n🔴 Recording — ${r.recordedSteps} step(s). \`tapp_flow_save\` to keep it as a test.` : "";
|
|
4379
|
-
const screen = agentScreenProjection(r);
|
|
4426
|
+
const screen = agentScreenProjection(r, { full: action === "tree" && args.full === true });
|
|
4380
4427
|
const result = richResult(head + "\n\n" + formatScreen(screen.screenTitle, screen.elements) + rec, { ...r, ...screen });
|
|
4381
4428
|
if (!ok) result.isError = true;
|
|
4382
4429
|
return result;
|
|
@@ -22,7 +22,7 @@ function cleanActor(actor) {
|
|
|
22
22
|
export function validateProjectConfig(config) {
|
|
23
23
|
const errors = [];
|
|
24
24
|
if (!config || typeof config !== "object" || Array.isArray(config)) return ["Project configuration must be an object"];
|
|
25
|
-
for (const key of Object.keys(config)) if (!["kind", "schemaVersion", "actors", "lifecycle", "provenance"].includes(key)) errors.push(`unsupported project configuration field '${key}'`);
|
|
25
|
+
for (const key of Object.keys(config)) if (!["kind", "schemaVersion", "actors", "lifecycle", "provenance", "web"].includes(key)) errors.push(`unsupported project configuration field '${key}'`);
|
|
26
26
|
if (config.kind !== "tapp-project-config") errors.push("kind must be 'tapp-project-config'");
|
|
27
27
|
if (config.schemaVersion !== 1) errors.push("schemaVersion must be 1");
|
|
28
28
|
if (config.actors !== undefined && (!config.actors || typeof config.actors !== "object" || Array.isArray(config.actors))) errors.push("actors must be an object");
|
|
@@ -43,6 +43,13 @@ export function validateProjectConfig(config) {
|
|
|
43
43
|
for (const key of Object.keys(binding || {})) if (key !== "env") errors.push(`actor '${name}' credential '${credential}' may contain only env; credential values are forbidden`);
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
|
+
if (config.web !== undefined) {
|
|
47
|
+
if (!config.web || typeof config.web !== "object" || Array.isArray(config.web)) errors.push("web must be an object");
|
|
48
|
+
else {
|
|
49
|
+
for (const key of Object.keys(config.web)) if (key !== "port") errors.push(`web has unsupported field '${key}'; the managed web host is always 127.0.0.1`);
|
|
50
|
+
if (config.web.port !== undefined && (!Number.isInteger(config.web.port) || config.web.port < 1 || config.web.port > 65535)) errors.push("web.port must be an integer between 1 and 65535");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
46
53
|
if (config.lifecycle !== undefined && (!config.lifecycle || typeof config.lifecycle !== "object" || Array.isArray(config.lifecycle))) errors.push("lifecycle must be an object");
|
|
47
54
|
for (const key of Object.keys(config.lifecycle || {})) if (!["setup", "teardown"].includes(key)) errors.push(`lifecycle has unsupported phase '${key}'`);
|
|
48
55
|
if (config.provenance !== undefined && (!config.provenance || typeof config.provenance !== "object" || Array.isArray(config.provenance))) errors.push("provenance must be an object");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aarwitz/tapp",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.4",
|
|
4
4
|
"mcpName": "io.github.aarwitz/tapp",
|
|
5
5
|
"description": "Let coding agents verify UI changes on real iOS, Android, and web surfaces, then enforce reviewed proof in deterministic CI.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -48,6 +48,11 @@ npx -y @aarwitz/tapp@latest open https://example.com --tap "Not now" --wait-for
|
|
|
48
48
|
- `tapp_focus`: source-locate a named screen/control and execute the shortest observed route in the active session.
|
|
49
49
|
- `tapp_ui_tree` / `tapp_screenshot`: inspect the current real surface.
|
|
50
50
|
- `tapp_session_start` → `tapp_session_act` → `tapp_session_end`: drive one persistent journey.
|
|
51
|
+
Sessions begin from a **cold** launch: persisted data survives, but an app that gates each cold
|
|
52
|
+
start behind sign-in shows its login wall first — make `{action:"login"}` the first act (plain
|
|
53
|
+
`tapp_ui_tree`/`tapp_screenshot` warm-resume the foregrounded app, so they can look signed-in
|
|
54
|
+
when a fresh session does not). A `tree` act accepts `full:true` for the complete raw element
|
|
55
|
+
list when the default ≤160-element projection might omit the text you are checking for.
|
|
51
56
|
- `tapp_explore`: autonomous iOS, Android, or web exploration; observation only.
|
|
52
57
|
- `tapp_flow_save` / `tapp_flow_run`: save a driven journey and replay it deterministically.
|
|
53
58
|
- `tapp_release_contract`: validate, compile, or run a reviewed business guarantee.
|
|
@@ -82,6 +87,10 @@ For autonomous exploration, pass test-only values when authorized:
|
|
|
82
87
|
- CLI: `--email`, `--password`, repeated `--launch-arg`, and JSON `--launch-env`.
|
|
83
88
|
- MCP: `testEmail`, `testPassword`, `inputOverrides`, `appLaunchArgs`, `appLaunchEnv`, or explicit
|
|
84
89
|
`loginSteps`.
|
|
90
|
+
- Reusable: `tapp actor set NAME --email-env ENV --password-env ENV` stores env-var **names**
|
|
91
|
+
(never values); Flow replay resolves them via `tapp flow run FILE --actor NAME` or
|
|
92
|
+
`tapp_flow_run {actor:"NAME"}`. Re-running `actor set` on an existing actor requires
|
|
93
|
+
`--replace` — idempotent setup scripts must include it.
|
|
85
94
|
|
|
86
95
|
Do not persist secrets in `.tapp/`. If the result reports input fields and no values were supplied,
|
|
87
96
|
ask the user rather than pretending the explored surface was complete.
|