@aarwitz/tapp 0.17.3 → 0.17.5
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 +37 -6
- 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 +79 -20
- package/mcp-server/src/project-config.js +8 -1
- package/mcp-server/src/report.js +43 -5
- package/mcp-server/src/web-explorer.js +141 -17
- package/mcp-server/src/web-flow.js +3 -3
- package/package.json +2 -2
- package/scripts/run-web-flow.js +8 -1
- package/skills/tapp/references/commands.md +16 -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.5",
|
|
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.5",
|
|
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.5 # 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.5 # 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
|
@@ -262,15 +262,15 @@ async function resolveTargetOrExit(engine, input) {
|
|
|
262
262
|
|
|
263
263
|
function safeCommandUsage(verb) {
|
|
264
264
|
const usage = {
|
|
265
|
-
explore: "tapp explore [target] [--platform ios|android|web] [--actions N] [--timeout SEC] [--email VALUE] [--password VALUE] [--baseline FILE] [--json FILE]\n Web: [--watch] opens Tapp's controlled browser and shows its actions\n iOS launch configuration: [--launch-arg VALUE ...] [--launch-env '{\"KEY\":\"VALUE\"}']\n Android: [--app-id ID] [--apk FILE] [--serial ID] [--keep-data]",
|
|
265
|
+
explore: "tapp explore [target] [--platform ios|android|web] [--actions N] [--timeout SEC] [--email VALUE] [--password VALUE] [--baseline FILE] [--json FILE]\n Web: [--watch] opens Tapp's controlled browser and shows its actions; [--device \"iPhone 13\"] [--viewport 390x844] render at a device profile or explicit size\n iOS launch configuration: [--launch-arg VALUE ...] [--launch-env '{\"KEY\":\"VALUE\"}']\n Android: [--app-id ID] [--apk FILE] [--serial ID] [--keep-data]",
|
|
266
266
|
focus: "tapp focus \"SCREEN OR CONTROL\" [target] [--platform ios|android|web] [--project-dir REPO] [--target NAME|PATH] [--map FILE] [--out FILE]",
|
|
267
267
|
init: "tapp init [repo] [--explore] [--refresh] [--platform PLATFORM] [--target NAME] [--url URL] [--watch] [--dry-run]",
|
|
268
|
-
open: "tapp open [target] [--platform ios|android|web] [--out FILE] [--tap TEXT] [--wait-for TEXT]",
|
|
269
|
-
tree: "tapp tree [target] [--platform ios|android|web] [--json] [--tap TEXT] [--wait-for TEXT]",
|
|
268
|
+
open: "tapp open [target] [--platform ios|android|web] [--out FILE] [--tap TEXT] [--wait-for TEXT]\n Web: [--device \"iPhone 13\"] [--viewport 390x844] [--full-page]",
|
|
269
|
+
tree: "tapp tree [target] [--platform ios|android|web] [--json] [--tap TEXT] [--wait-for TEXT]\n Web: [--device \"iPhone 13\"] [--viewport 390x844]",
|
|
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] [--device \"iPhone 13\"] [--viewport 390x844]",
|
|
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",
|
|
@@ -774,6 +774,8 @@ switch (command) {
|
|
|
774
774
|
testPassword: flags.password,
|
|
775
775
|
baselineFindings,
|
|
776
776
|
watch: flags.watch === true,
|
|
777
|
+
device: typeof flags.device === "string" ? flags.device : "",
|
|
778
|
+
viewport: typeof flags.viewport === "string" ? flags.viewport : "",
|
|
777
779
|
surface: "cli",
|
|
778
780
|
onProgress,
|
|
779
781
|
})
|
|
@@ -827,6 +829,9 @@ switch (command) {
|
|
|
827
829
|
timeoutMs: Number(flags.timeout) * 1000 || 15_000,
|
|
828
830
|
tapText: typeof flags.tap === "string" ? flags.tap : "",
|
|
829
831
|
waitForText: typeof flags["wait-for"] === "string" ? flags["wait-for"] : "",
|
|
832
|
+
device: typeof flags.device === "string" ? flags.device : "",
|
|
833
|
+
viewport: typeof flags.viewport === "string" ? flags.viewport : "",
|
|
834
|
+
fullPage: flags["full-page"] === true,
|
|
830
835
|
});
|
|
831
836
|
const out = typeof flags.out === "string" ? path.resolve(flags.out) : path.join(tappHome, "shots", `web-${Date.now()}.png`);
|
|
832
837
|
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
@@ -899,6 +904,8 @@ switch (command) {
|
|
|
899
904
|
screenshot: false,
|
|
900
905
|
tapText: typeof flags.tap === "string" ? flags.tap : "",
|
|
901
906
|
waitForText: typeof flags["wait-for"] === "string" ? flags["wait-for"] : "",
|
|
907
|
+
device: typeof flags.device === "string" ? flags.device : "",
|
|
908
|
+
viewport: typeof flags.viewport === "string" ? flags.viewport : "",
|
|
902
909
|
});
|
|
903
910
|
if (flags.json) console.log(JSON.stringify({ platform: "web", url: snap.url, screenTitle: snap.screenTitle, settled: snap.settled, elements: snap.elements }, null, 2));
|
|
904
911
|
else console.log(engine.formatScreen(snap.screenTitle, snap.elements));
|
|
@@ -1111,7 +1118,7 @@ switch (command) {
|
|
|
1111
1118
|
break;
|
|
1112
1119
|
}
|
|
1113
1120
|
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>");
|
|
1121
|
+
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
1122
|
process.exit(2);
|
|
1116
1123
|
}
|
|
1117
1124
|
const absolute = path.resolve(flowPath);
|
|
@@ -1146,6 +1153,30 @@ switch (command) {
|
|
|
1146
1153
|
const env = { ...process.env, FLOW_LOG: flowLog, TAPP_FLOW_EVIDENCE_DIR: evidenceDir };
|
|
1147
1154
|
if (typeof flags.email === "string") env.OCQA_TEST_EMAIL = flags.email;
|
|
1148
1155
|
if (typeof flags.password === "string") env.OCQA_TEST_PASSWORD = flags.password;
|
|
1156
|
+
if (typeof flags.device === "string") env.TAPP_WEB_DEVICE = flags.device;
|
|
1157
|
+
if (typeof flags.viewport === "string") env.TAPP_WEB_VIEWPORT = flags.viewport;
|
|
1158
|
+
if (typeof flags.actor === "string" && flags.actor) {
|
|
1159
|
+
const { readProjectConfig } = await import(path.join(packageRoot, "mcp-server", "src", "project-config.js"));
|
|
1160
|
+
const loaded = readProjectConfig(process.cwd());
|
|
1161
|
+
if (loaded.errors.length) { console.error(`❌ Invalid ${loaded.relativePath}: ${loaded.errors.join("; ")}`); process.exit(2); }
|
|
1162
|
+
const actor = loaded.config.actors?.[flags.actor];
|
|
1163
|
+
if (!actor) {
|
|
1164
|
+
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`);
|
|
1165
|
+
process.exit(2);
|
|
1166
|
+
}
|
|
1167
|
+
// Actors store env-var NAMES only; resolve the values here. Explicit --email/--password win.
|
|
1168
|
+
for (const [credential, flagName, envKey] of [["email", "email", "OCQA_TEST_EMAIL"], ["password", "password", "OCQA_TEST_PASSWORD"]]) {
|
|
1169
|
+
if (typeof flags[flagName] === "string") continue;
|
|
1170
|
+
const binding = actor.credentials?.[credential];
|
|
1171
|
+
if (!binding) continue;
|
|
1172
|
+
const value = process.env[binding.env];
|
|
1173
|
+
if (!value) {
|
|
1174
|
+
console.error(`❌ Actor '${flags.actor}' binds ${credential} to $${binding.env}, but that environment variable is not set.`);
|
|
1175
|
+
process.exit(2);
|
|
1176
|
+
}
|
|
1177
|
+
env[envKey] = value;
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1149
1180
|
let invocation;
|
|
1150
1181
|
if (platform === "web") {
|
|
1151
1182
|
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.5 # 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,
|
|
@@ -778,16 +778,16 @@ async function firstVisibleWebLocator(page, target, { input = false } = {}) {
|
|
|
778
778
|
return await fallback.isVisible().catch(() => false) ? fallback : null;
|
|
779
779
|
}
|
|
780
780
|
|
|
781
|
-
async function startWebSession(url, { testEmail = "", testPassword = "" } = {}) {
|
|
781
|
+
async function startWebSession(url, { testEmail = "", testPassword = "", device = "", viewport = "" } = {}) {
|
|
782
782
|
if (activeSession && !activeSession.ended) return { error: "A session is already active; call tapp_session_end first.", screen:treeSnapshot() };
|
|
783
783
|
let browser;
|
|
784
784
|
try {
|
|
785
785
|
const parsed = new URL(String(url || ""));
|
|
786
786
|
if (!/^https?:$/.test(parsed.protocol)) return { error:"Web session URL must be http(s)" };
|
|
787
|
-
const { loadPlaywright } = await import("./web-explorer.js");
|
|
788
|
-
const { chromium } = await loadPlaywright();
|
|
787
|
+
const { loadPlaywright, webContextOptions } = await import("./web-explorer.js");
|
|
788
|
+
const { chromium, devices } = await loadPlaywright();
|
|
789
789
|
browser = await chromium.launch({ headless:true });
|
|
790
|
-
const context = await browser.newContext({ viewport
|
|
790
|
+
const context = await browser.newContext(webContextOptions({ device, viewport, devices }));
|
|
791
791
|
const page = await context.newPage();
|
|
792
792
|
await page.goto(parsed.href, { waitUntil:"domcontentloaded", timeout:30_000 });
|
|
793
793
|
await page.waitForLoadState("networkidle", { timeout:5_000 }).catch(() => {});
|
|
@@ -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
|
|
@@ -1744,7 +1754,7 @@ export function formatScreen(screenTitle, elements) {
|
|
|
1744
1754
|
// `tapp` CLI verbs in bin/tapp.js — same pattern as report.js. Keep orchestration HERE so
|
|
1745
1755
|
// the surfaces can't drift.)
|
|
1746
1756
|
|
|
1747
|
-
export async function runQaWeb({ url, maxActions, timeout, testEmail, testPassword, baselineFindings, seedRoutes = [], seedTargets = [], watch = false, surface = "mcp", onProgress = () => {} }) {
|
|
1757
|
+
export async function runQaWeb({ url, maxActions, timeout, testEmail, testPassword, baselineFindings, seedRoutes = [], seedTargets = [], watch = false, surface = "mcp", onProgress = () => {}, device = "", viewport = "" }) {
|
|
1748
1758
|
const { storagePreflight } = await import("./environment-preflight.js");
|
|
1749
1759
|
const storage = storagePreflight(capturesDir);
|
|
1750
1760
|
if (!storage.ok) return { error: storage.message, details: { environment: "storage", storage } };
|
|
@@ -1766,6 +1776,8 @@ export async function runQaWeb({ url, maxActions, timeout, testEmail, testPasswo
|
|
|
1766
1776
|
seedTargets,
|
|
1767
1777
|
watch: watch === true,
|
|
1768
1778
|
onProgress,
|
|
1779
|
+
device: isNonEmptyString(device) ? device.trim() : "",
|
|
1780
|
+
viewport: isNonEmptyString(viewport) ? viewport.trim() : "",
|
|
1769
1781
|
});
|
|
1770
1782
|
} catch (err) {
|
|
1771
1783
|
return { error: String(err.message || err) };
|
|
@@ -2258,8 +2270,21 @@ export async function startManagedWebTarget({ root, requestedTarget = "", timeou
|
|
|
2258
2270
|
const dependencies = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
2259
2271
|
const declaredPort = startMatch ? declaredPortFromStartScript(pkg.scripts?.[startMatch[1]]) : 0;
|
|
2260
2272
|
const frameworkPort = declaredPort ? 0 : managedWebDefaultPort(dependencies);
|
|
2261
|
-
|
|
2262
|
-
|
|
2273
|
+
// An explicit `.tapp/project.json` web.port pin outranks everything: backends with a CORS
|
|
2274
|
+
// allowlist need the managed origin to be exact, so a busy pinned port is a hard error, never
|
|
2275
|
+
// a silent ephemeral fallback that would resurface as fetch/CORS findings.
|
|
2276
|
+
const { readProjectConfig } = await import("./project-config.js");
|
|
2277
|
+
const projectConfig = readProjectConfig(root);
|
|
2278
|
+
if (projectConfig.exists && projectConfig.errors.length) return { error: `Invalid ${projectConfig.relativePath}: ${projectConfig.errors.join("; ")}` };
|
|
2279
|
+
const pinnedPort = Number(projectConfig.config?.web?.port) || 0;
|
|
2280
|
+
if (pinnedPort && declaredPort && pinnedPort !== declaredPort) {
|
|
2281
|
+
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." } };
|
|
2282
|
+
}
|
|
2283
|
+
if (pinnedPort && !(await localPortAvailable(pinnedPort))) {
|
|
2284
|
+
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." } };
|
|
2285
|
+
}
|
|
2286
|
+
const port = pinnedPort || declaredPort || (frameworkPort && await localPortAvailable(frameworkPort) ? frameworkPort : await openLocalPort());
|
|
2287
|
+
const portBasis = pinnedPort ? "project-pinned" : declaredPort ? "repository-declared" : port === frameworkPort ? "framework-default" : "available-ephemeral";
|
|
2263
2288
|
let command = "npm";
|
|
2264
2289
|
let startArgs;
|
|
2265
2290
|
let startDir = projectDir;
|
|
@@ -2579,6 +2604,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2579
2604
|
clearData: { type: "boolean", default: true, description: "Android: clear app data before launch for a repeatable starting state." },
|
|
2580
2605
|
url: { type: "string", description: "Web (beta): URL of the app to explore in a real browser (same-origin only; your own app/staging). Provide exactly one of appBundleId | url." },
|
|
2581
2606
|
watch: { type: "boolean", default: false, description: "Web only: open Tapp's controlled Chromium window and show a cursor/HUD for each exploration action. Evidence screenshots exclude the overlay." },
|
|
2607
|
+
device: { type: "string", description: "Web only: render as a Playwright device profile (e.g. \"iPhone 13\") — viewport, user agent, touch" },
|
|
2608
|
+
viewport: { type: "string", description: "Web only: explicit viewport WIDTHxHEIGHT (e.g. \"390x844\"); overrides the device profile's viewport" },
|
|
2582
2609
|
maxActions: { type: "integer", minimum: 1, maximum: 1000, default: 60, description: "Exploration action budget" },
|
|
2583
2610
|
timeout: { type: "integer", minimum: 30, maximum: 3600, default: 600, description: "Max wall-clock seconds" },
|
|
2584
2611
|
testEmail: { type: "string", description: "Email for the login preamble, if the app has a sign-in" },
|
|
@@ -2846,7 +2873,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2846
2873
|
"Opt-in AI assertion: {assert_ai: '<claim about the current screen>'} (judged host-side; needs a key; " +
|
|
2847
2874
|
"skipped otherwise). Pass a flow inline via `flow`, or a repo-relative `flowPath` to a .yml/.json. " +
|
|
2848
2875
|
"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
|
|
2876
|
+
"flow `vars` are substituted; pass testEmail/testPassword for real credential values, or `actor` to " +
|
|
2877
|
+
"resolve them from a configured actor's environment-variable bindings.",
|
|
2850
2878
|
inputSchema: {
|
|
2851
2879
|
type: "object",
|
|
2852
2880
|
properties: {
|
|
@@ -2865,6 +2893,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2865
2893
|
androidSerial: { type: "string", description: "Android adb device serial." },
|
|
2866
2894
|
testEmail: { type: "string", description: "Value for $TEST_EMAIL" },
|
|
2867
2895
|
testPassword: { type: "string", description: "Value for $TEST_PASSWORD" },
|
|
2896
|
+
actor: { type: "string", description: "Configured actor name; resolves $TEST_EMAIL/$TEST_PASSWORD from the actor's env-var bindings (explicit testEmail/testPassword win)" },
|
|
2897
|
+
device: { type: "string", description: "Web flows: replay in a Playwright device profile (e.g. \"iPhone 13\")" },
|
|
2898
|
+
viewport: { type: "string", description: "Web flows: explicit viewport WIDTHxHEIGHT (e.g. \"390x844\")" },
|
|
2868
2899
|
},
|
|
2869
2900
|
},
|
|
2870
2901
|
},
|
|
@@ -3040,7 +3071,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
3040
3071
|
"launch per action. Returns the initial screen {screenTitle, elements[]}. Drive it with " +
|
|
3041
3072
|
"tapp_focus for any named destination (source + shortest observed route), then tapp_session_act only for remaining actions; finish with tapp_session_end. " +
|
|
3042
3073
|
"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
|
|
3074
|
+
"fresh COLD launch (terminate + relaunch, for a deterministic starting screen): persisted data such as Keychain credentials survives, but the app opens on its " +
|
|
3075
|
+
"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 " +
|
|
3076
|
+
"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. " +
|
|
3077
|
+
"Use appLaunchArgs/appLaunchEnv for apps that need a backend override or login bypass. " +
|
|
3044
3078
|
"When you reach a screen with input fields and don't have values for them, ASK THE USER what to type " +
|
|
3045
3079
|
"(offer defaults/skip) before typing — the session does not prompt on its own.",
|
|
3046
3080
|
inputSchema: {
|
|
@@ -3050,6 +3084,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
3050
3084
|
appBundleId: { type: "string", description: "Bundle id of the installed app to drive" },
|
|
3051
3085
|
androidAppId: { type: "string", description: "Android application id to drive (alternative to appBundleId)" },
|
|
3052
3086
|
url: { type: "string", description: "Owned http(s) web app URL to drive (alternative to appBundleId/androidAppId); omit all three target identifiers to build/start an unambiguous owned web target from projectDir" },
|
|
3087
|
+
device: { type: "string", description: "Web sessions: render as a Playwright device profile (e.g. \"iPhone 13\")" },
|
|
3088
|
+
viewport: { type: "string", description: "Web sessions: explicit viewport WIDTHxHEIGHT (e.g. \"390x844\")" },
|
|
3053
3089
|
target: { type: "string", description: "Optional managed-web target name/path when projectDir contains multiple browser applications" },
|
|
3054
3090
|
apkPath: { type: "string", description: "Android APK to install before starting" },
|
|
3055
3091
|
androidSerial: { type: "string", description: "Android adb device serial" },
|
|
@@ -3091,8 +3127,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
3091
3127
|
"on refocus, so step-by-step login flows lose the password), 'tap' (by `id` = accessibility identifier " +
|
|
3092
3128
|
"or visible/partial label or placeholder, or by `x`/`y` coordinates), 'type' (`text`, optional `id` to " +
|
|
3093
3129
|
"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
|
-
"
|
|
3130
|
+
"until an element with `id`/`text` appears, up to `timeoutMs`), 'tree' (re-inspect without acting; " +
|
|
3131
|
+
"pass `full: true` for every raw element with frames — the default projection dedupes and caps at " +
|
|
3132
|
+
"160 elements, so grep-style checks against it can miss text that IS on screen), " +
|
|
3133
|
+
"'screenshot'. Returns {status, screenTitle, elements[], durationMs}; status 'not_found'/'timeout'/'still_on_login' " +
|
|
3096
3134
|
"etc. with a `detail` explaining login failures.",
|
|
3097
3135
|
inputSchema: {
|
|
3098
3136
|
type: "object",
|
|
@@ -3107,6 +3145,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
3107
3145
|
text: { type: "string", description: "Text to type, or the label/text to wait for" },
|
|
3108
3146
|
direction: { type: "string", enum: ["up", "down", "left", "right"], description: "Swipe direction" },
|
|
3109
3147
|
timeoutMs: { type: "integer", minimum: 500, maximum: 60000, default: 5000, description: "For 'wait': how long to poll for the element" },
|
|
3148
|
+
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
3149
|
label: { type: "string", description: "Optional screenshot label" },
|
|
3111
3150
|
},
|
|
3112
3151
|
required: ["action"],
|
|
@@ -3413,6 +3452,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3413
3452
|
testPassword: args.testPassword,
|
|
3414
3453
|
baselineFindings: args.baselineFindings,
|
|
3415
3454
|
watch: args.watch === true,
|
|
3455
|
+
device: isNonEmptyString(args.device) ? args.device.trim() : "",
|
|
3456
|
+
viewport: isNonEmptyString(args.viewport) ? args.viewport.trim() : "",
|
|
3416
3457
|
onProgress: notifyProgress("pages reached"),
|
|
3417
3458
|
});
|
|
3418
3459
|
if (r.error) return errorResult(r.error, r.details || {});
|
|
@@ -3927,6 +3968,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3927
3968
|
const runEnv = { ...process.env, FLOW_LOG: flowLog, TAPP_FLOW_EVIDENCE_DIR: evidenceDir };
|
|
3928
3969
|
if (isNonEmptyString(args.testEmail)) runEnv.OCQA_TEST_EMAIL = args.testEmail.trim();
|
|
3929
3970
|
if (isNonEmptyString(args.testPassword)) runEnv.OCQA_TEST_PASSWORD = args.testPassword.trim();
|
|
3971
|
+
if (isNonEmptyString(args.actor)) {
|
|
3972
|
+
const { readProjectConfig } = await import("./project-config.js");
|
|
3973
|
+
const loaded = readProjectConfig(workspaceRoot);
|
|
3974
|
+
if (loaded.errors.length) return errorResult(`Invalid ${loaded.relativePath}: ${loaded.errors.join("; ")}`);
|
|
3975
|
+
const actor = loaded.config.actors?.[args.actor.trim()];
|
|
3976
|
+
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" });
|
|
3977
|
+
// Actors store env-var NAMES only; resolve values here. Explicit testEmail/testPassword win.
|
|
3978
|
+
for (const [credential, argName, envKey] of [["email", "testEmail", "OCQA_TEST_EMAIL"], ["password", "testPassword", "OCQA_TEST_PASSWORD"]]) {
|
|
3979
|
+
if (isNonEmptyString(args[argName])) continue;
|
|
3980
|
+
const binding = actor.credentials?.[credential];
|
|
3981
|
+
if (!binding) continue;
|
|
3982
|
+
const value = process.env[binding.env];
|
|
3983
|
+
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.`);
|
|
3984
|
+
runEnv[envKey] = value;
|
|
3985
|
+
}
|
|
3986
|
+
}
|
|
3930
3987
|
let run = { stdout: "", stderr: "", code: 0 };
|
|
3931
3988
|
if (platform === "web") {
|
|
3932
3989
|
try {
|
|
@@ -3936,6 +3993,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3936
3993
|
url: isNonEmptyString(args.url) ? args.url.trim() : undefined,
|
|
3937
3994
|
logPath: flowLog,
|
|
3938
3995
|
screenshotDir: evidenceDir,
|
|
3996
|
+
device: isNonEmptyString(args.device) ? args.device.trim() : "",
|
|
3997
|
+
viewport: isNonEmptyString(args.viewport) ? args.viewport.trim() : "",
|
|
3939
3998
|
});
|
|
3940
3999
|
run = { ...run, code: result.passed ? 0 : 1, evidenceDir };
|
|
3941
4000
|
} catch (error) {
|
|
@@ -4287,7 +4346,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4287
4346
|
: android
|
|
4288
4347
|
? await startAndroidSession(target, { serial: args.androidSerial, apkPath: args.apkPath, clearData: args.clearData !== false, testEmail: args.testEmail, testPassword: args.testPassword })
|
|
4289
4348
|
: web
|
|
4290
|
-
? await startWebSession(target, { testEmail:args.testEmail, testPassword:args.testPassword })
|
|
4349
|
+
? await startWebSession(target, { testEmail:args.testEmail, testPassword:args.testPassword, device:isNonEmptyString(args.device) ? args.device.trim() : "", viewport:isNonEmptyString(args.viewport) ? args.viewport.trim() : "" })
|
|
4291
4350
|
: await startManagedWebInteractiveSession({
|
|
4292
4351
|
projectDir:focusProjectDir,
|
|
4293
4352
|
requestedTarget:isNonEmptyString(args.target) ? args.target.trim() : "",
|
|
@@ -4376,7 +4435,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4376
4435
|
const detailNote = !ok && r.detail ? ` — ${r.detail}` : "";
|
|
4377
4436
|
const head = `${did} — ${ok ? "ok" : `⚠️ ${r.status}${detailNote}`} → now on **${r.screenTitle || "Unknown"}**`;
|
|
4378
4437
|
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);
|
|
4438
|
+
const screen = agentScreenProjection(r, { full: action === "tree" && args.full === true });
|
|
4380
4439
|
const result = richResult(head + "\n\n" + formatScreen(screen.screenTitle, screen.elements) + rec, { ...r, ...screen });
|
|
4381
4440
|
if (!ok) result.isError = true;
|
|
4382
4441
|
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/mcp-server/src/report.js
CHANGED
|
@@ -72,6 +72,7 @@ export function parseOcqaMarkers(markersFilePath) {
|
|
|
72
72
|
)
|
|
73
73
|
),
|
|
74
74
|
complete,
|
|
75
|
+
actions,
|
|
75
76
|
recentActions: actions.slice(-5),
|
|
76
77
|
recentTransitions: transitions.slice(-5),
|
|
77
78
|
recentIssues: issues.slice(-5),
|
|
@@ -87,6 +88,10 @@ export const ISSUE_CATEGORY = {
|
|
|
87
88
|
error_surface: "network_error_surface",
|
|
88
89
|
unresponsive_element: "unresponsive_element",
|
|
89
90
|
placeholder_link: "broken_link",
|
|
91
|
+
anchor_missing: "broken_link",
|
|
92
|
+
unresolvable_host: "broken_link",
|
|
93
|
+
mailto_no_mx: "broken_link",
|
|
94
|
+
outbound_unavailable: "broken_link",
|
|
90
95
|
dead_end: "navigation_dead_end",
|
|
91
96
|
navigation_loop: "repeated_loop",
|
|
92
97
|
navigation_trap: "navigation_dead_end",
|
|
@@ -96,7 +101,7 @@ export const ISSUE_CATEGORY = {
|
|
|
96
101
|
explore_timeout: "performance_timeout",
|
|
97
102
|
};
|
|
98
103
|
export const CRITICAL_ISSUE_TYPES = new Set(["crash"]);
|
|
99
|
-
export const WEB_SAMPLED_ISSUE_TYPES = new Set(["unresponsive_element"]);
|
|
104
|
+
export const WEB_SAMPLED_ISSUE_TYPES = new Set(["unresponsive_element", "outbound_unavailable"]);
|
|
100
105
|
|
|
101
106
|
export function severityRank(s) {
|
|
102
107
|
return { critical: 0, high: 1, medium: 2, low: 3 }[s] ?? 4;
|
|
@@ -206,7 +211,9 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
|
|
|
206
211
|
// keep the concrete missing-asset finding and discard that transport-level duplicate.
|
|
207
212
|
const normalizedIssues = rawIssues.map((issue) => {
|
|
208
213
|
if (platform !== "web") return issue;
|
|
209
|
-
if (
|
|
214
|
+
if (["placeholder_link", "anchor_missing", "unresolvable_host", "mailto_no_mx", "outbound_unavailable"].includes(issue.type) && issue.target) {
|
|
215
|
+
return { ...issue, screen: null };
|
|
216
|
+
}
|
|
210
217
|
if (!["missing_asset", "network_error"].includes(issue.type)) return issue;
|
|
211
218
|
const title = String(issue.title || "");
|
|
212
219
|
const match = issue.type === "missing_asset"
|
|
@@ -279,9 +286,16 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
|
|
|
279
286
|
: screensExplored >= 2 && actionsPerformed >= 3;
|
|
280
287
|
const unexercisedLoginWall = anySecure && !loginAttempted && screensExplored <= 1;
|
|
281
288
|
const inconclusive = !coverageFloorMet || unexercisedLoginWall || timeBudgetExhausted;
|
|
289
|
+
// "completed" is reserved for a run that exhausted its action budget; a drained frontier is
|
|
290
|
+
// reported as its own honest reason so a 2-action sweep of a small surface never reads like a
|
|
291
|
+
// 40-action campaign. Drivers signal the real cause in COMPLETE.stop; captures from drivers
|
|
292
|
+
// that predate the field keep the old inference.
|
|
293
|
+
const driverStop = base.complete && typeof base.complete === "object" ? base.complete.stop : null;
|
|
282
294
|
const stopReason = unexercisedLoginWall ? (credentialsProvided ? "login-wall-credentials-unused" : "login-wall-no-credentials")
|
|
283
295
|
: timeBudgetExhausted ? "time-budget-exhausted"
|
|
284
|
-
: coverageFloorMet ? "
|
|
296
|
+
: !coverageFloorMet ? "coverage-floor-not-met"
|
|
297
|
+
: driverStop === "frontier-drained" ? "no-unexplored-in-scope-controls"
|
|
298
|
+
: "completed";
|
|
285
299
|
|
|
286
300
|
const headline = timeBudgetExhausted
|
|
287
301
|
? `Inconclusive — exploration reached its ${base.complete?.timeoutSeconds || "configured"}s time budget after ${actionsPerformed} action(s) across ${screensExplored} screen(s). Findings are partial; this is not an app performance finding. Increase --timeout or request fewer actions.`
|
|
@@ -301,10 +315,17 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
|
|
|
301
315
|
let checkedFor;
|
|
302
316
|
let notChecked;
|
|
303
317
|
if (platform === "web") {
|
|
318
|
+
const outbound = base.complete && typeof base.complete === "object" ? base.complete.outbound : null;
|
|
304
319
|
checkedFor = [
|
|
305
|
-
"page errors (uncaught exceptions)", "failed/5xx requests", "broken links (404)",
|
|
306
|
-
"placeholder links with no destination", "sampled dead-button probes (advisory)", "error text on pages", "load timeouts",
|
|
320
|
+
"page errors (uncaught exceptions)", "failed/5xx requests", "broken links (404, same-origin crawl)",
|
|
321
|
+
"placeholder links and anchors with no destination", "sampled dead-button probes (advisory)", "error text on pages", "load timeouts",
|
|
307
322
|
];
|
|
323
|
+
if (outbound && !outbound.skipped && (outbound.total || outbound.mailtos)) {
|
|
324
|
+
checkedFor.push(outbound.total > outbound.checked
|
|
325
|
+
? `outbound link reachability — DNS · HTTP · unavailable-shell heuristic (first ${outbound.checked} of ${outbound.total})`
|
|
326
|
+
: "outbound link reachability (DNS · HTTP · unavailable-shell heuristic)");
|
|
327
|
+
if (outbound.mailtos) checkedFor.push("mailto address domains (MX/A records)");
|
|
328
|
+
}
|
|
308
329
|
notChecked = [
|
|
309
330
|
"app-specific business logic (cover with Flows: record or generate, then assert)",
|
|
310
331
|
"content and claim accuracy (including copy versus API data)",
|
|
@@ -314,6 +335,11 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
|
|
|
314
335
|
"only the first few visible buttons per page are probed (web beta)",
|
|
315
336
|
"content & reachability regressions require a baseline",
|
|
316
337
|
];
|
|
338
|
+
if (outbound?.skipped === "egress-policy") notChecked.push("outbound links and mailto domains (skipped by the public-egress policy)");
|
|
339
|
+
else if (!outbound || (!outbound.total && !outbound.mailtos)) conditionsNotReached.push("outbound links (none encountered this run)");
|
|
340
|
+
if (inputFieldsEncountered.length && !loginAttempted) {
|
|
341
|
+
notChecked.push(`form submission (${inputFieldsEncountered.reduce((n, s) => n + s.fields.length, 0)} field(s) catalogued, none submitted)`);
|
|
342
|
+
}
|
|
317
343
|
} else if (platform === "android") {
|
|
318
344
|
checkedFor = [
|
|
319
345
|
"crashes / process exits", "dead controls", "error surfaces", "blank screens",
|
|
@@ -361,6 +387,18 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
|
|
|
361
387
|
headline,
|
|
362
388
|
inconclusive,
|
|
363
389
|
coverage: { screensExplored, actionsPerformed, screens: Array.from(screens) },
|
|
390
|
+
// Per-action evidence: what was done, to what, on which screen, at what offset — so an
|
|
391
|
+
// agent can distinguish "clicked Services, clicked FAQ" from "clicked the same button twice".
|
|
392
|
+
trace: (base.actions || [])
|
|
393
|
+
.filter((action) => action && typeof action === "object")
|
|
394
|
+
.map((action) => ({
|
|
395
|
+
...(typeof action.t === "number" ? { t: action.t } : {}),
|
|
396
|
+
type: action.type || "action",
|
|
397
|
+
target: action.target || "",
|
|
398
|
+
...(action.screen ? { screen: action.screen } : {}),
|
|
399
|
+
...(action.via ? { via: action.via } : {}),
|
|
400
|
+
...(action.reason ? { reason: action.reason } : {}),
|
|
401
|
+
})),
|
|
364
402
|
evidence: { markers: base.relativeMarkersFilePath },
|
|
365
403
|
uiMap: null,
|
|
366
404
|
comparison: null,
|
|
@@ -23,6 +23,7 @@ import { execFileSync } from "child_process";
|
|
|
23
23
|
const CLICK_SETTLE_MS = 700;
|
|
24
24
|
const NAV_TIMEOUT_MS = 15_000;
|
|
25
25
|
const BUTTONS_PER_PAGE = 4;
|
|
26
|
+
const OUTBOUND_LINK_LIMIT = 10;
|
|
26
27
|
const WATCH_ACTION_DELAY_MS = 350;
|
|
27
28
|
const ERROR_TEXT_RE = /\b(something went wrong|internal server error|an error occurred|failed to load|unhandled exception)\b/i;
|
|
28
29
|
const STANDALONE_ERROR_TEXT_RE = /^(something went wrong|internal server error|an error occurred|failed to load|unhandled exception)(?:[.!:]|\s|$)/i;
|
|
@@ -299,6 +300,42 @@ export function webBrowserLaunchOptions(environment = process.env, { watch = fal
|
|
|
299
300
|
};
|
|
300
301
|
}
|
|
301
302
|
|
|
303
|
+
export function parseWebViewport(value) {
|
|
304
|
+
if (!value) return null;
|
|
305
|
+
const match = /^(\d{2,5})[xX](\d{2,5})$/.exec(String(value).trim());
|
|
306
|
+
if (!match) throw new Error(`Invalid viewport '${value}'; expected WIDTHxHEIGHT, e.g. 390x844`);
|
|
307
|
+
return { width: Number(match[1]), height: Number(match[2]) };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// The one seam for every web browser context: an optional Playwright device profile (viewport,
|
|
311
|
+
// user agent, touch, scale factor) with an explicit WIDTHxHEIGHT override on top; the historical
|
|
312
|
+
// 1280×900 desktop default otherwise. `defaultBrowserType` is stripped because newContext
|
|
313
|
+
// rejects it — we always drive the profile through chromium.
|
|
314
|
+
export function webContextOptions({ device = "", viewport = "", devices = null } = {}) {
|
|
315
|
+
const name = String(device || "").trim();
|
|
316
|
+
let base = {};
|
|
317
|
+
if (name) {
|
|
318
|
+
const profile = devices ? devices[name] : null;
|
|
319
|
+
if (!profile) {
|
|
320
|
+
const head = name.toLowerCase().split(" ")[0];
|
|
321
|
+
const close = devices ? Object.keys(devices).filter((d) => d.toLowerCase().includes(head)).slice(0, 5) : [];
|
|
322
|
+
throw new Error(`Unknown Playwright device '${name}'${close.length ? `; close matches: ${close.join(", ")}` : ""}`);
|
|
323
|
+
}
|
|
324
|
+
const { defaultBrowserType: _ignored, ...rest } = profile;
|
|
325
|
+
base = rest;
|
|
326
|
+
}
|
|
327
|
+
const parsed = parseWebViewport(viewport);
|
|
328
|
+
return { ...base, viewport: parsed || base.viewport || { width: 1280, height: 900 } };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Conservative "the page answered 200 but is an unavailable shell" phrases (outbound links to
|
|
332
|
+
// social platforms that never 404). Precision over recall: only unmistakable copy matches.
|
|
333
|
+
export function webUnavailableShellPhrase(html) {
|
|
334
|
+
const text = String(html || "").slice(0, 120_000);
|
|
335
|
+
const match = /(this content isn'?t available|content (?:is )?(?:currently )?unavailable|this page isn'?t available|page (?:can'?t|cannot) be found|page not found|isn'?t available right now)/i.exec(text);
|
|
336
|
+
return match ? match[1] : null;
|
|
337
|
+
}
|
|
338
|
+
|
|
302
339
|
// A headed Playwright browser does not move the host OS pointer when locator.click() runs. In
|
|
303
340
|
// explicit watch mode, draw a pointer inside the controlled page so a human can follow Tapp's
|
|
304
341
|
// real actions. The UI lives in a closed shadow root, ignores pointer events, and is hidden from
|
|
@@ -398,16 +435,16 @@ async function screenshotWithoutWebWatchUi(page, options, watch) {
|
|
|
398
435
|
// Focused one-screen inspection for the agent-facing `tapp open <url>` and `tapp tree <url>`
|
|
399
436
|
// commands. This deliberately does no exploration or judgment; it opens exactly one page,
|
|
400
437
|
// captures the visible semantic controls, and optionally takes one screenshot.
|
|
401
|
-
export async function inspectWebPage({ url, timeoutMs = NAV_TIMEOUT_MS, screenshot = true, tapText = "", waitForText = "" }) {
|
|
438
|
+
export async function inspectWebPage({ url, timeoutMs = NAV_TIMEOUT_MS, screenshot = true, tapText = "", waitForText = "", device = "", viewport = "", fullPage = false }) {
|
|
402
439
|
let target;
|
|
403
440
|
try { target = new URL(url); }
|
|
404
441
|
catch { throw new Error("Web inspection needs a valid http(s) URL"); }
|
|
405
442
|
if (!/^https?:$/.test(target.protocol)) throw new Error("Web inspection needs a valid http(s) URL");
|
|
406
443
|
|
|
407
|
-
const { chromium } = await loadPlaywright();
|
|
444
|
+
const { chromium, devices } = await loadPlaywright();
|
|
408
445
|
const browser = await chromium.launch(webBrowserLaunchOptions());
|
|
409
446
|
try {
|
|
410
|
-
const context = await browser.newContext({ viewport
|
|
447
|
+
const context = await browser.newContext(webContextOptions({ device, viewport, devices }));
|
|
411
448
|
const page = await context.newPage();
|
|
412
449
|
const boundedTimeout = Math.max(1000, Math.min(60_000, Number(timeoutMs) || NAV_TIMEOUT_MS));
|
|
413
450
|
page.setDefaultTimeout(boundedTimeout);
|
|
@@ -455,7 +492,7 @@ export async function inspectWebPage({ url, timeoutMs = NAV_TIMEOUT_MS, screensh
|
|
|
455
492
|
controls,
|
|
456
493
|
};
|
|
457
494
|
});
|
|
458
|
-
const image = screenshot ? await page.screenshot({ type: "png" }) : null;
|
|
495
|
+
const image = screenshot ? await page.screenshot({ type: "png", fullPage: !!fullPage }) : null;
|
|
459
496
|
return {
|
|
460
497
|
url: page.url(),
|
|
461
498
|
screenTitle: webScreenTitle(observed, target.pathname || target.href),
|
|
@@ -524,17 +561,19 @@ export function normalizeWebSeedTargets(seedTargets = [], limit = 5) {
|
|
|
524
561
|
return result;
|
|
525
562
|
}
|
|
526
563
|
|
|
527
|
-
export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDir, testEmail = "", testPassword = "", seedRoutes = [], seedTargets = [], watch = false, onProgress }) {
|
|
564
|
+
export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDir, testEmail = "", testPassword = "", seedRoutes = [], seedTargets = [], watch = false, onProgress, device = "", viewport = "" }) {
|
|
528
565
|
const start = new URL(url);
|
|
529
566
|
if (!/^https?:$/.test(start.protocol)) throw new Error("url must be http(s)");
|
|
530
567
|
fs.mkdirSync(outDir, { recursive: true });
|
|
531
568
|
const markersPath = path.join(outDir, "ocqa-markers.txt");
|
|
532
569
|
const markersFd = fs.openSync(markersPath, "w");
|
|
533
570
|
const emit = (kind, payload) => fs.writeSync(markersFd, `OCQA_${kind}:${JSON.stringify(payload)}\n`);
|
|
571
|
+
const startedAtMs = Date.now();
|
|
572
|
+
const emitAction = (payload) => emit("ACTION", { t: Date.now() - startedAtMs, ...payload });
|
|
534
573
|
|
|
535
|
-
const { chromium } = await loadPlaywright();
|
|
574
|
+
const { chromium, devices } = await loadPlaywright();
|
|
536
575
|
const browser = await chromium.launch(webBrowserLaunchOptions(process.env, { watch }));
|
|
537
|
-
const context = await browser.newContext({ viewport
|
|
576
|
+
const context = await browser.newContext(webContextOptions({ device, viewport, devices }));
|
|
538
577
|
await installWebListenerTracking(context);
|
|
539
578
|
if (watch) await installWebWatchUi(context);
|
|
540
579
|
const page = await context.newPage();
|
|
@@ -585,6 +624,10 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
585
624
|
];
|
|
586
625
|
const screenshotFor = new Map();
|
|
587
626
|
const placeholderLinksSeen = new Set();
|
|
627
|
+
const missingAnchorsSeen = new Set();
|
|
628
|
+
const outboundLinks = new Map(); // href → { label, screen }
|
|
629
|
+
const mailtoLinks = new Map(); // address → { screen }
|
|
630
|
+
const outbound = { total: 0, checked: 0, mailtos: 0 };
|
|
588
631
|
let actions = 0;
|
|
589
632
|
let screenCount = 0;
|
|
590
633
|
let lastScreen = null;
|
|
@@ -607,12 +650,12 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
607
650
|
secure: el.type === "password",
|
|
608
651
|
}))
|
|
609
652
|
.filter((f) => f.label);
|
|
610
|
-
const controls = [...document.querySelectorAll("button, a[href], input, textarea, select, [role=button], [role=tab], [role=checkbox], [role=switch]")]
|
|
653
|
+
const controls = [...document.querySelectorAll("button, a[href], input, textarea, select, summary, [role=button], [role=tab], [role=checkbox], [role=switch]")]
|
|
611
654
|
.filter((el) => el.type !== "hidden" && el.offsetParent !== null)
|
|
612
655
|
.slice(0, 60)
|
|
613
656
|
.map((el) => {
|
|
614
657
|
const tag = el.tagName.toLowerCase();
|
|
615
|
-
const role = el.getAttribute("role") || (tag === "a" ? "link" : tag === "button" ? "button" : "");
|
|
658
|
+
const role = el.getAttribute("role") || (tag === "a" ? "link" : tag === "button" || tag === "summary" ? "button" : "");
|
|
616
659
|
const field = ["input", "textarea", "select"].includes(tag);
|
|
617
660
|
const secure = el.type === "password";
|
|
618
661
|
const label = (el.labels?.[0]?.textContent || el.getAttribute("aria-label") || el.textContent || el.placeholder || el.name || el.id || "").trim().slice(0, 120);
|
|
@@ -674,6 +717,18 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
674
717
|
fingerprint: el.id || el.getAttribute("data-testid") || svgPath.slice(0, 80) || `link-${index + 1}`,
|
|
675
718
|
};
|
|
676
719
|
}),
|
|
720
|
+
missingAnchors: [...document.querySelectorAll('a[href^="#"]')]
|
|
721
|
+
.filter((el) => el.offsetParent !== null)
|
|
722
|
+
.map((el) => el.getAttribute("href") || "")
|
|
723
|
+
.filter((href) => href.length > 1)
|
|
724
|
+
.filter((href) => {
|
|
725
|
+
const id = decodeURIComponent(href.slice(1));
|
|
726
|
+
try {
|
|
727
|
+
const esc = window.CSS && CSS.escape ? CSS.escape(id) : id;
|
|
728
|
+
return !document.getElementById(id) && !document.querySelector(`a[name="${esc}"]`);
|
|
729
|
+
} catch { return !document.getElementById(id); }
|
|
730
|
+
})
|
|
731
|
+
.slice(0, 20),
|
|
677
732
|
inputs,
|
|
678
733
|
controls,
|
|
679
734
|
};
|
|
@@ -717,6 +772,13 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
717
772
|
placeholderLinksSeen.add(finding.target);
|
|
718
773
|
issue(finding.type, finding.severity, finding.title, screen, finding.target);
|
|
719
774
|
}
|
|
775
|
+
// Anchor links pointing at ids that do not exist are deterministic dead navigation.
|
|
776
|
+
for (const anchor of info.missingAnchors || []) {
|
|
777
|
+
const anchorTarget = `${key}${anchor}`;
|
|
778
|
+
if (missingAnchorsSeen.has(anchorTarget)) continue;
|
|
779
|
+
missingAnchorsSeen.add(anchorTarget);
|
|
780
|
+
issue("anchor_missing", "medium", `Anchor link "${anchor}" has no matching element on the page`, screen, anchorTarget);
|
|
781
|
+
}
|
|
720
782
|
}
|
|
721
783
|
return { key, screen, info };
|
|
722
784
|
}
|
|
@@ -737,7 +799,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
737
799
|
if (watch) await showWebWatchAction(page, { locator: pw, action: "Type", target: "Password" });
|
|
738
800
|
await pw.fill(testPassword).catch(() => {});
|
|
739
801
|
lastActionTarget = "Sign in";
|
|
740
|
-
|
|
802
|
+
emitAction({ type: "login", target: "Sign in", screen, narrative: "Filled and submitted the sign-in form with the provided test credentials" });
|
|
741
803
|
actions += 1;
|
|
742
804
|
await submitWebLogin(page, watch
|
|
743
805
|
? (locator) => showWebWatchAction(page, { locator, action: "Click", target: "Sign in" })
|
|
@@ -784,7 +846,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
784
846
|
actions += 1;
|
|
785
847
|
lastActionTarget = webNavigationAction(entry.action, target);
|
|
786
848
|
pendingNavigation = entry.pathTarget ? { ...entry, prTarget: false } : entry;
|
|
787
|
-
|
|
849
|
+
emitAction({ type: "open", target, via: lastActionTarget, narrative: `Opened ${target}` });
|
|
788
850
|
// Attribute load-time events (pageerror, 404s) to the page being loaded, not the one
|
|
789
851
|
// we just left; observe() refines this to the page title once it settles.
|
|
790
852
|
currentScreen = target;
|
|
@@ -814,7 +876,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
814
876
|
const beforeScreen = ob.screen;
|
|
815
877
|
actions += 1;
|
|
816
878
|
lastActionTarget = action.target;
|
|
817
|
-
|
|
879
|
+
emitAction({ type: action.type, target: action.target, screen: beforeScreen, reason: "pr_ui_map_path", narrative: `Following observed UI Map path: ${action.type} ${action.target}` });
|
|
818
880
|
let acted = false;
|
|
819
881
|
if (action.type === "back") {
|
|
820
882
|
if (watch) await showWebWatchAction(page, { action: "Back", target: beforeScreen });
|
|
@@ -857,15 +919,27 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
857
919
|
emit("PR_TARGET", { targetId: entry.targetId, status: "observed", screen: ob.screen });
|
|
858
920
|
}
|
|
859
921
|
|
|
860
|
-
// Enqueue unvisited same-origin links (BFS keeps exploration order deterministic)
|
|
922
|
+
// Enqueue unvisited same-origin links (BFS keeps exploration order deterministic);
|
|
923
|
+
// collect outbound http(s) links and mailto addresses for the post-crawl audit.
|
|
861
924
|
const links = await page.$$eval("a[href]", (as) => as.map((a) => ({
|
|
862
925
|
href: a.href,
|
|
926
|
+
raw: a.getAttribute("href") || "",
|
|
863
927
|
label: (a.getAttribute("aria-label") || a.textContent || "").trim(),
|
|
864
928
|
}))).catch(() => []);
|
|
865
929
|
for (const link of links) {
|
|
866
930
|
try {
|
|
931
|
+
if (/^mailto:/i.test(link.raw)) {
|
|
932
|
+
const address = link.raw.replace(/^mailto:/i, "").split("?")[0].trim();
|
|
933
|
+
if (address.includes("@") && !mailtoLinks.has(address)) mailtoLinks.set(address, { screen: ob.screen });
|
|
934
|
+
continue;
|
|
935
|
+
}
|
|
867
936
|
const u = new URL(link.href);
|
|
868
|
-
if (
|
|
937
|
+
if (!/^https?:$/.test(u.protocol)) continue;
|
|
938
|
+
if (u.origin !== start.origin) {
|
|
939
|
+
const clean = u.origin + u.pathname;
|
|
940
|
+
if (!outboundLinks.has(clean)) outboundLinks.set(clean, { label: link.label, screen: ob.screen });
|
|
941
|
+
continue;
|
|
942
|
+
}
|
|
869
943
|
const key = u.pathname.replace(/\/+$/, "") + u.search || "/";
|
|
870
944
|
if (!visited.has(key) && !frontier.some((item) => item.target === key)) {
|
|
871
945
|
frontier.push({ target: key, action: webNavigationAction(link.label, key), fromScreen: ob.screen });
|
|
@@ -875,7 +949,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
875
949
|
|
|
876
950
|
// Bounded button pass: click, watch for effect, flag dead controls (the web analog
|
|
877
951
|
// of the iOS dead-button detector). Navigations are undone so BFS order holds.
|
|
878
|
-
const buttons = page.locator("button:visible, [role=button]:visible, input[type=submit]:visible");
|
|
952
|
+
const buttons = page.locator("button:visible, [role=button]:visible, input[type=submit]:visible, summary:visible");
|
|
879
953
|
const n = Math.min(await buttons.count().catch(() => 0), BUTTONS_PER_PAGE);
|
|
880
954
|
for (let i = 0; i < n && actions < maxActions && Date.now() < deadline; i++) {
|
|
881
955
|
const b = buttons.nth(i);
|
|
@@ -893,7 +967,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
893
967
|
const beforeState = await captureWebControlState(page, b);
|
|
894
968
|
actions += 1;
|
|
895
969
|
lastActionTarget = label;
|
|
896
|
-
|
|
970
|
+
emitAction({ type: "tap", target: label, screen: webActionScreen(ob), narrative: `Tapped "${label}"` });
|
|
897
971
|
let clickSucceeded = false;
|
|
898
972
|
try {
|
|
899
973
|
if (watch) await showWebWatchAction(page, { locator: b, action: "Click", target: label });
|
|
@@ -925,8 +999,58 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
|
|
|
925
999
|
}
|
|
926
1000
|
progress();
|
|
927
1001
|
}
|
|
1002
|
+
|
|
1003
|
+
// Post-crawl outbound audit: DNS + bounded HTTP for external links, MX for mailto.
|
|
1004
|
+
// These calls leave the proxied browser, so the whole pass is skipped (and reported as
|
|
1005
|
+
// not-checked) when the public-egress policy is enforced.
|
|
1006
|
+
outbound.total = outboundLinks.size;
|
|
1007
|
+
outbound.mailtos = mailtoLinks.size;
|
|
1008
|
+
if (process.env.TAPP_ENFORCE_PUBLIC_EGRESS === "1") {
|
|
1009
|
+
outbound.skipped = "egress-policy";
|
|
1010
|
+
} else if (outboundLinks.size || mailtoLinks.size) {
|
|
1011
|
+
const dns = await import("node:dns/promises");
|
|
1012
|
+
const hostResolvable = new Map();
|
|
1013
|
+
const resolves = async (host) => {
|
|
1014
|
+
if (!hostResolvable.has(host)) {
|
|
1015
|
+
hostResolvable.set(host, await dns.lookup(host).then(() => true).catch(() => false));
|
|
1016
|
+
}
|
|
1017
|
+
return hostResolvable.get(host);
|
|
1018
|
+
};
|
|
1019
|
+
const targets = [...outboundLinks.entries()].slice(0, OUTBOUND_LINK_LIMIT);
|
|
1020
|
+
outbound.checked = targets.length;
|
|
1021
|
+
for (const [href, meta] of targets) {
|
|
1022
|
+
if (Date.now() >= deadline) break;
|
|
1023
|
+
const u = new URL(href);
|
|
1024
|
+
if (!(await resolves(u.hostname))) {
|
|
1025
|
+
issue("unresolvable_host", "medium", `Outbound link host does not resolve: ${u.hostname}`, meta.screen, href);
|
|
1026
|
+
continue;
|
|
1027
|
+
}
|
|
1028
|
+
try {
|
|
1029
|
+
const res = await fetch(href, { redirect: "follow", signal: AbortSignal.timeout(6000), headers: { "user-agent": "Mozilla/5.0 (compatible; tapp-link-audit)" } });
|
|
1030
|
+
if (res.status >= 400) {
|
|
1031
|
+
issue("broken_link", "medium", `Outbound link returns HTTP ${res.status}: ${href.slice(0, 100)}`, meta.screen, href);
|
|
1032
|
+
} else {
|
|
1033
|
+
const phrase = webUnavailableShellPhrase(await res.text().catch(() => ""));
|
|
1034
|
+
if (phrase) issue("outbound_unavailable", "low", `Outbound link returns 200 but shows "${phrase}": ${href.slice(0, 100)}`, meta.screen, href);
|
|
1035
|
+
}
|
|
1036
|
+
} catch (error) {
|
|
1037
|
+
issue("unresolvable_host", "medium", `Outbound link unreachable: ${href.slice(0, 100)}`, meta.screen, href);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
for (const [address, meta] of mailtoLinks) {
|
|
1041
|
+
if (Date.now() >= deadline) break;
|
|
1042
|
+
const domain = (address.split("@")[1] || "").toLowerCase();
|
|
1043
|
+
if (!domain) continue;
|
|
1044
|
+
// RFC 5321 implicit-MX: a domain with no MX but an A/AAAA record can still receive.
|
|
1045
|
+
const deliverable = await dns.resolveMx(domain).then((records) => records.length > 0).catch(() => false)
|
|
1046
|
+
|| await dns.lookup(domain).then(() => true).catch(() => false);
|
|
1047
|
+
if (!deliverable) issue("mailto_no_mx", "medium", `mailto: domain cannot receive email (no MX or address record): ${address}`, meta.screen, address);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
928
1050
|
} finally {
|
|
929
|
-
|
|
1051
|
+
const timedOut = Date.now() >= deadline;
|
|
1052
|
+
const stop = timedOut ? "time-budget" : actions >= maxActions ? "action-budget" : "frontier-drained";
|
|
1053
|
+
emit("COMPLETE", { actions, screens: screenCount, credentialsProvided: !!(testEmail || testPassword), credentialsUsed: loginTried, timedOut, stop, outbound });
|
|
930
1054
|
fs.closeSync(markersFd);
|
|
931
1055
|
await browser.close().catch(() => {});
|
|
932
1056
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { FlowLog, flowVariables, normalizeFlowStep, substituteFlowValue } from "./flow-runtime.js";
|
|
6
|
-
import { loadPlaywright } from "./web-explorer.js";
|
|
6
|
+
import { loadPlaywright, webContextOptions } from "./web-explorer.js";
|
|
7
7
|
|
|
8
8
|
const DEFAULT_TIMEOUT = 6000;
|
|
9
9
|
|
|
@@ -192,7 +192,7 @@ export async function executeWebFlowStep({ page, step, vars = {}, defaultTimeout
|
|
|
192
192
|
return { action, target: action === "login" ? "sign-in form" : target || value, status, detail, task: raw.task };
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
-
export async function runWebFlow({ flow, url, logPath, screenshotDir, playwright }) {
|
|
195
|
+
export async function runWebFlow({ flow, url, logPath, screenshotDir, playwright, device = "", viewport = "" }) {
|
|
196
196
|
const startUrl = url || flow.url || (/^https?:\/\//i.test(flow.app || "") ? flow.app : "");
|
|
197
197
|
if (!startUrl) throw new Error("Web Flow needs `url:` (or an http(s) `app:` value)");
|
|
198
198
|
if (logPath) fs.rmSync(logPath, { force: true });
|
|
@@ -202,7 +202,7 @@ export async function runWebFlow({ flow, url, logPath, screenshotDir, playwright
|
|
|
202
202
|
const log = new FlowLog({ logPath, flow: loggedFlow });
|
|
203
203
|
const pw = playwright || await loadPlaywright();
|
|
204
204
|
const browser = await pw.chromium.launch({ headless: true });
|
|
205
|
-
const context = await browser.newContext({ viewport
|
|
205
|
+
const context = await browser.newContext(webContextOptions({ device, viewport, devices: pw.devices }));
|
|
206
206
|
const page = await context.newPage();
|
|
207
207
|
const timeout = Number(flow.timeoutMs) || DEFAULT_TIMEOUT;
|
|
208
208
|
page.setDefaultTimeout(timeout);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aarwitz/tapp",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.5",
|
|
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",
|
|
@@ -89,7 +89,7 @@
|
|
|
89
89
|
"mobile"
|
|
90
90
|
],
|
|
91
91
|
"scripts": {
|
|
92
|
-
"test": "node --test tests/report.test.js tests/regression.test.js tests/engine.test.js tests/project-config.test.js tests/application-model.test.js tests/ui-map.test.js tests/focused-navigation.test.js tests/task-runtime.test.js tests/release-contract.test.js tests/pr-selection.test.js tests/flow-runtime.test.js tests/web-explorer.test.js tests/web-session.test.js tests/web-flow.test.js tests/scenario-runtime.test.js tests/android-driver.test.js tests/android-explorer.test.js tests/android-flow.test.js tests/android-primitives-protocol.test.js tests/managed-web.test.js tests/product-operations.test.js tests/browser-product.test.js tests/browser-onboarding.test.js tests/managed-operation.test.js tests/cloud-runner.test.js tests/ci-setup.test.js tests/ci-install.test.js tests/cli.test.js tests/mcp-workspace.test.js tests/action.test.js tests/package-surface.test.js tests/agent-surface.test.js tests/presentation-contract.test.js tests/landing-brand.test.js tests/ci-gate.test.js tests/ci-report.test.js tests/desktop-protocol.test.js tests/ios-flow-protocol.test.js vscode-extension/test/bridge.test.js",
|
|
92
|
+
"test": "node --test tests/report.test.js tests/regression.test.js tests/engine.test.js tests/project-config.test.js tests/application-model.test.js tests/ui-map.test.js tests/focused-navigation.test.js tests/task-runtime.test.js tests/release-contract.test.js tests/pr-selection.test.js tests/flow-runtime.test.js tests/web-explorer.test.js tests/web-session.test.js tests/web-link-audit.test.js tests/web-flow.test.js tests/scenario-runtime.test.js tests/android-driver.test.js tests/android-explorer.test.js tests/android-flow.test.js tests/android-primitives-protocol.test.js tests/managed-web.test.js tests/product-operations.test.js tests/browser-product.test.js tests/browser-onboarding.test.js tests/managed-operation.test.js tests/cloud-runner.test.js tests/ci-setup.test.js tests/ci-install.test.js tests/cli.test.js tests/mcp-workspace.test.js tests/action.test.js tests/package-surface.test.js tests/agent-surface.test.js tests/presentation-contract.test.js tests/landing-brand.test.js tests/ci-gate.test.js tests/ci-report.test.js tests/desktop-protocol.test.js tests/ios-flow-protocol.test.js vscode-extension/test/bridge.test.js",
|
|
93
93
|
"test:browser-journey": "node --test tests/browser-journey.test.js",
|
|
94
94
|
"test:browser-native": "TAPP_RUN_NATIVE_BROWSER=1 node --test tests/browser-native-journey.test.js"
|
|
95
95
|
}
|
package/scripts/run-web-flow.js
CHANGED
|
@@ -18,7 +18,14 @@ const logPath = process.env.FLOW_LOG || path.join(os.tmpdir(), `tapp-web-flow-${
|
|
|
18
18
|
const screenshotDir = process.env.TAPP_FLOW_EVIDENCE_DIR || path.join(os.tmpdir(), `tapp-web-flow-${token}`);
|
|
19
19
|
|
|
20
20
|
try {
|
|
21
|
-
const result = await runWebFlow({
|
|
21
|
+
const result = await runWebFlow({
|
|
22
|
+
flow,
|
|
23
|
+
url: process.argv[3],
|
|
24
|
+
logPath,
|
|
25
|
+
screenshotDir,
|
|
26
|
+
device: process.env.TAPP_WEB_DEVICE || "",
|
|
27
|
+
viewport: process.env.TAPP_WEB_VIEWPORT || "",
|
|
28
|
+
});
|
|
22
29
|
const report = spawnSync("python3", [path.join(root, "scripts", "flow_lib.py"), "report", logPath], { encoding: "utf8" });
|
|
23
30
|
process.stdout.write((report.stdout || "").trim() + "\n");
|
|
24
31
|
process.exit(result.passed ? 0 : 1);
|
|
@@ -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.
|
|
@@ -95,6 +104,13 @@ npx -y @aarwitz/tapp@latest flow run .tapp/flows/smoke.yml
|
|
|
95
104
|
npx -y @aarwitz/tapp@latest ci
|
|
96
105
|
```
|
|
97
106
|
|
|
107
|
+
Web commands (`open`, `tree`, `explore`, `flow run`) accept `--device "iPhone 13"` (any
|
|
108
|
+
Playwright device profile) or `--viewport 390x844` to render at mobile sizes, and `open`
|
|
109
|
+
accepts `--full-page` for a full-height screenshot — use these for "does it look right on a
|
|
110
|
+
phone" checks. Web explores also audit outbound links (DNS, HTTP status, unavailable-shell
|
|
111
|
+
heuristic) and `mailto:` domains, report an honest `stopReason` (`no-unexplored-in-scope-controls`
|
|
112
|
+
when a small surface is swept before the budget), and return a per-action `trace` in `--json`.
|
|
113
|
+
|
|
98
114
|
`tapp explore` observes. `tapp ci` applies versioned deterministic policy to evidence, selected
|
|
99
115
|
Flows/Scenarios/contracts, coverage, and any target-scoped baseline. Its outcomes are `pass`, `fail`,
|
|
100
116
|
or `inconclusive`; both `fail` and `inconclusive` block a merge.
|