@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.
@@ -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.3",
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.3",
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 app launch.
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.3 # or pin the reviewed release commit SHA
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.3 # or pin the reviewed release commit SHA
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. Multiple web targets, an unlocked dependency
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.3 # or pin the reviewed release commit SHA
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" || posix(target.sourcePath) !== container) return target;
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
- const previous = exact || lineage;
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
- for (const previous of prior.items || []) if (!currentIds.has(previous.id) && !consumedPriorIds.has(previous.id) && previous.decision && previous.decision !== "pending") carried.push({ ...previous, stale: true, status: "not-derived-on-refresh" });
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,
@@ -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:{ width:1280, height:900 } });
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
- if (/^(?: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}(?:,\s+\d{4})?$/i.test(text)) return false;
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
- return (elements || [])
856
+ const containing = (elements || [])
854
857
  .filter((element) => {
855
858
  const frame = element.frame || {};
856
- return element.hittable !== false && Number.isFinite(frame.x) && Number.isFinite(frame.y) && Number.isFinite(frame.width) && Number.isFinite(frame.height)
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
- .map((element) => String(element.id || element.identifier || element.label || "").trim())[0] || "";
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
- const port = declaredPort || (frameworkPort && await localPortAvailable(frameworkPort) ? frameworkPort : await openLocalPort());
2262
- const portBasis = declaredPort ? "repository-declared" : port === frameworkPort ? "framework-default" : "available-ephemeral";
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. Use appLaunchArgs/appLaunchEnv for apps that need a backend override or login bypass. " +
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
- "'screenshot'. Returns {status, screenTitle, elements[]}; status 'not_found'/'timeout'/'still_on_login' " +
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");
@@ -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 (issue.type === "placeholder_link" && issue.target) return { ...issue, screen: null };
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 ? "completed" : "coverage-floor-not-met";
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: { width: 1280, height: 900 } });
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: { width: 1280, height: 900 } });
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
- emit("ACTION", { type: "login", target: "Sign in", screen, narrative: "Filled and submitted the sign-in form with the provided test credentials" });
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
- emit("ACTION", { type: "open", target, via: lastActionTarget, narrative: `Opened ${target}` });
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
- emit("ACTION", { type: action.type, target: action.target, screen: beforeScreen, reason: "pr_ui_map_path", narrative: `Following observed UI Map path: ${action.type} ${action.target}` });
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 (u.origin !== start.origin || !/^https?:$/.test(u.protocol)) continue;
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
- emit("ACTION", { type: "tap", target: label, screen: webActionScreen(ob), narrative: `Tapped "${label}"` });
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
- emit("COMPLETE", { actions, screens: screenCount, credentialsProvided: !!(testEmail || testPassword), credentialsUsed: loginTried });
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: { width: 1280, height: 900 } });
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",
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
  }
@@ -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({ flow, url: process.argv[3], logPath, screenshotDir });
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.