@aarwitz/tapp 0.17.4 → 0.17.6

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.4",
4
+ "version": "0.17.6",
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.4",
27
+ "@aarwitz/tapp@0.17.6",
28
28
  "mcp"
29
29
  ],
30
30
  "cwd": "${CLAUDE_PROJECT_DIR}"
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.4 # or pin the reviewed release commit SHA
351
+ - uses: aarwitz/tapp@v0.17.6 # 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.4 # or pin the reviewed release commit SHA
401
+ - uses: aarwitz/tapp@v0.17.6 # 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 [--actor NAME] [--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]",
@@ -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));
@@ -1146,6 +1153,8 @@ 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;
1149
1158
  if (typeof flags.actor === "string" && flags.actor) {
1150
1159
  const { readProjectConfig } = await import(path.join(packageRoot, "mcp-server", "src", "project-config.js"));
1151
1160
  const loaded = readProjectConfig(process.cwd());
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.4 # or pin the reviewed release commit SHA
77
+ - uses: aarwitz/tapp@v0.17.6 # or pin the reviewed release commit SHA
78
78
  with:
79
79
  platform: web
80
80
  url: http://127.0.0.1:4180
@@ -16,7 +16,7 @@
16
16
  // [--pr-plan <plan.json>] # selected PR contract execution manifest
17
17
  // [--project-dir <repo> --maintenance-url <url>]
18
18
  // # optional disposable web patch replay
19
- // [--fail-on <gate|absolute|any>] # default: gate
19
+ // [--fail-on <gate|absolute|any|high|medium>] # default: gate (web CLI defaults to medium)
20
20
  //
21
21
  // Gate policy (--fail-on):
22
22
  // gate fail when the run introduced NEW high/critical findings vs. the baseline
@@ -25,6 +25,9 @@
25
25
  // absolute fail on any current-run deterministic findings-block (critical / risk threshold) or an
26
26
  // inconclusive run, or any failed suite — no baseline needed.
27
27
  // any fail on any finding at all, or any suite failure. Strictest.
28
+ // high fail on any deterministic finding at high/critical severity (absolute; no baseline).
29
+ // medium fail on any deterministic finding at medium severity or above. The `tapp ci` CLI
30
+ // defaults web targets to this: on a website, a broken link IS the release blocker.
28
31
  import fs from "fs";
29
32
  import path from "node:path";
30
33
  import { execSync } from "node:child_process";
@@ -61,8 +64,8 @@ function parseArgs(argv) {
61
64
  console.error("Required: --markers <ocqa-markers.txt>");
62
65
  process.exit(2);
63
66
  }
64
- if (!["gate", "absolute", "any"].includes(args.failOn)) {
65
- console.error(`--fail-on must be gate|absolute|any, got: ${args.failOn}`);
67
+ if (!["gate", "absolute", "any", "high", "medium"].includes(args.failOn)) {
68
+ console.error(`--fail-on must be gate|absolute|any|high|medium, got: ${args.failOn}`);
66
69
  process.exit(2);
67
70
  }
68
71
  return args;
@@ -477,7 +480,15 @@ function renderMarkdown(report, regression, flows, scenarios, contracts, prPlan,
477
480
  }
478
481
  lines.push("");
479
482
  const badge = GATE_BADGE[gate.outcome] || (gate.failed ? "🔴 FAIL" : "🟢 PASS");
480
- lines.push(`**Gate (${gate.policy}): ${badge}**${gate.reasons.length ? "" + gate.reasons.join("; ") : ""}`);
483
+ // A PASS must state what it chose to ignore a green banner over known findings without
484
+ // saying so is exactly the dishonest verdict this product refuses to render.
485
+ const ignored = gate.outcome === "pass" && report.deterministicFindingCounts
486
+ ? ["high", "medium", "low"].map((sev) => [sev, report.deterministicFindingCounts[sev] || 0]).filter(([, n]) => n > 0)
487
+ : [];
488
+ const ignoredNote = ignored.length
489
+ ? ` — ${ignored.reduce((n, [, c]) => n + c, 0)} deterministic finding(s) below the fail threshold (${ignored.map(([sev, n]) => `${n} ${sev}`).join(", ")})`
490
+ : "";
491
+ lines.push(`**Gate (${gate.policy}): ${badge}**${gate.reasons.length ? " — " + gate.reasons.join("; ") : ""}${ignoredNote}`);
481
492
  // The gate is only authoritative about what it actually ran — record the scope explicitly.
482
493
  const rev = gate.revision?.sha ? `${String(gate.revision.sha).slice(0, 12)}${gate.revision.dirty ? "-dirty" : ""}` : "unknown";
483
494
  lines.push(`_target: ${gate.target || "—"} · revision: ${rev} · policy: ${gate.policy} v${gate.policyVersion || "?"}_`);
@@ -125,6 +125,9 @@ ${r.conditionsNotReached?.length ? `<div><h2>Conditions not reached</h2><ul>${sc
125
125
  .scope ul { margin-top: 0; padding-left: 1.2rem; }
126
126
  .sev { color: #fff; border-radius: 4px; padding: 0.05rem 0.45rem; font-size: 0.78rem; font-weight: 600; margin-right: 0.4rem; }
127
127
  ul.findings { padding-left: 1.1rem; } ul.findings li { margin-bottom: 0.6rem; }
128
+ table.trace { border-collapse: collapse; font-size: 0.88rem; width: 100%; }
129
+ table.trace th, table.trace td { text-align: left; padding: 0.25rem 0.7rem 0.25rem 0; border-bottom: 1px solid #eceef1; }
130
+ table.trace th { color: #57606a; font-weight: 600; }
128
131
  .ai { color: #57606a; font-size: 0.88rem; margin: 0.15rem 0 0 0.2rem; }
129
132
  .dim { color: #57606a; }
130
133
  .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.8rem; }
@@ -144,6 +147,10 @@ ${scopeHtml}
144
147
  <ul class="findings">
145
148
  ${findingsHtml}
146
149
  </ul>
150
+ ${Array.isArray(r.trace) && r.trace.length ? `<h2>Action trace — what was done, in order</h2>
151
+ <table class="trace"><thead><tr><th>t</th><th>action</th><th>target</th><th>screen</th></tr></thead><tbody>
152
+ ${r.trace.map((a) => `<tr><td>${typeof a.t === "number" ? (a.t / 1000).toFixed(1) + "s" : "—"}</td><td>${esc(a.type || "")}</td><td>${esc(a.target || "")}</td><td>${esc(a.screen || "")}</td></tr>`).join("\n")}
153
+ </tbody></table>` : ""}
147
154
  <h2>Evidence — every screen explored</h2>
148
155
  <div class="grid">
149
156
  ${shotsHtml || "<p class='dim'>No screenshots captured.</p>"}
@@ -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(() => {});
@@ -1740,13 +1740,15 @@ function elementBreakdown(elements) {
1740
1740
  /** Scannable "Read screen X — N elements (...)" readout, plus the tappable/typeable controls. */
1741
1741
  export function formatScreen(screenTitle, elements) {
1742
1742
  const els = elements || [];
1743
- const interactable = els.filter((e) => e.isEnabled !== false && (String(e.type).includes("Button") || String(e.type).includes("rawValue: 9") || String(e.type).includes("TextField") || String(e.type).includes("rawValue: 49") || String(e.type).includes("rawValue: 50") || String(e.type).includes("Cell") || String(e.type).includes("rawValue: 75")));
1744
- const labels = interactable
1743
+ const interactable = els.filter((e) => e.isEnabled !== false && (String(e.type).includes("Button") || String(e.type).includes("Link") || String(e.type).includes("rawValue: 9") || String(e.type).includes("TextField") || String(e.type).includes("rawValue: 49") || String(e.type).includes("rawValue: 50") || String(e.type).includes("Cell") || String(e.type).includes("rawValue: 75")));
1744
+ const allLabels = interactable
1745
1745
  .map((e) => (e.label || e.identifier || "").trim())
1746
- .filter((s) => s && s.length <= 40 && !s.includes("."))
1747
- .slice(0, 8);
1746
+ .filter((s) => s && s.length <= 40 && !s.includes("."));
1747
+ const labels = allLabels.slice(0, 8);
1748
1748
  const L = [`🌳 Read screen **${screenTitle || "Unknown"}** — ${els.length} elements (${elementBreakdown(els)})`];
1749
- if (labels.length) L.push("", "**Controls:** " + labels.map((l) => `\`${l}\``).join(" · "));
1749
+ // A silently cut list reads as complete; say when it isn't.
1750
+ if (labels.length) L.push("", "**Controls:** " + labels.map((l) => `\`${l}\``).join(" · ")
1751
+ + (allLabels.length > labels.length ? ` · … and ${allLabels.length - labels.length} more` : ""));
1750
1752
  return L.join("\n");
1751
1753
  }
1752
1754
 
@@ -1754,7 +1756,7 @@ export function formatScreen(screenTitle, elements) {
1754
1756
  // `tapp` CLI verbs in bin/tapp.js — same pattern as report.js. Keep orchestration HERE so
1755
1757
  // the surfaces can't drift.)
1756
1758
 
1757
- export async function runQaWeb({ url, maxActions, timeout, testEmail, testPassword, baselineFindings, seedRoutes = [], seedTargets = [], watch = false, surface = "mcp", onProgress = () => {} }) {
1759
+ export async function runQaWeb({ url, maxActions, timeout, testEmail, testPassword, baselineFindings, seedRoutes = [], seedTargets = [], watch = false, surface = "mcp", onProgress = () => {}, device = "", viewport = "" }) {
1758
1760
  const { storagePreflight } = await import("./environment-preflight.js");
1759
1761
  const storage = storagePreflight(capturesDir);
1760
1762
  if (!storage.ok) return { error: storage.message, details: { environment: "storage", storage } };
@@ -1776,6 +1778,8 @@ export async function runQaWeb({ url, maxActions, timeout, testEmail, testPasswo
1776
1778
  seedTargets,
1777
1779
  watch: watch === true,
1778
1780
  onProgress,
1781
+ device: isNonEmptyString(device) ? device.trim() : "",
1782
+ viewport: isNonEmptyString(viewport) ? viewport.trim() : "",
1779
1783
  });
1780
1784
  } catch (err) {
1781
1785
  return { error: String(err.message || err) };
@@ -2602,6 +2606,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2602
2606
  clearData: { type: "boolean", default: true, description: "Android: clear app data before launch for a repeatable starting state." },
2603
2607
  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." },
2604
2608
  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." },
2609
+ device: { type: "string", description: "Web only: render as a Playwright device profile (e.g. \"iPhone 13\") — viewport, user agent, touch" },
2610
+ viewport: { type: "string", description: "Web only: explicit viewport WIDTHxHEIGHT (e.g. \"390x844\"); overrides the device profile's viewport" },
2605
2611
  maxActions: { type: "integer", minimum: 1, maximum: 1000, default: 60, description: "Exploration action budget" },
2606
2612
  timeout: { type: "integer", minimum: 30, maximum: 3600, default: 600, description: "Max wall-clock seconds" },
2607
2613
  testEmail: { type: "string", description: "Email for the login preamble, if the app has a sign-in" },
@@ -2890,6 +2896,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2890
2896
  testEmail: { type: "string", description: "Value for $TEST_EMAIL" },
2891
2897
  testPassword: { type: "string", description: "Value for $TEST_PASSWORD" },
2892
2898
  actor: { type: "string", description: "Configured actor name; resolves $TEST_EMAIL/$TEST_PASSWORD from the actor's env-var bindings (explicit testEmail/testPassword win)" },
2899
+ device: { type: "string", description: "Web flows: replay in a Playwright device profile (e.g. \"iPhone 13\")" },
2900
+ viewport: { type: "string", description: "Web flows: explicit viewport WIDTHxHEIGHT (e.g. \"390x844\")" },
2893
2901
  },
2894
2902
  },
2895
2903
  },
@@ -3078,6 +3086,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
3078
3086
  appBundleId: { type: "string", description: "Bundle id of the installed app to drive" },
3079
3087
  androidAppId: { type: "string", description: "Android application id to drive (alternative to appBundleId)" },
3080
3088
  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" },
3089
+ device: { type: "string", description: "Web sessions: render as a Playwright device profile (e.g. \"iPhone 13\")" },
3090
+ viewport: { type: "string", description: "Web sessions: explicit viewport WIDTHxHEIGHT (e.g. \"390x844\")" },
3081
3091
  target: { type: "string", description: "Optional managed-web target name/path when projectDir contains multiple browser applications" },
3082
3092
  apkPath: { type: "string", description: "Android APK to install before starting" },
3083
3093
  androidSerial: { type: "string", description: "Android adb device serial" },
@@ -3444,6 +3454,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3444
3454
  testPassword: args.testPassword,
3445
3455
  baselineFindings: args.baselineFindings,
3446
3456
  watch: args.watch === true,
3457
+ device: isNonEmptyString(args.device) ? args.device.trim() : "",
3458
+ viewport: isNonEmptyString(args.viewport) ? args.viewport.trim() : "",
3447
3459
  onProgress: notifyProgress("pages reached"),
3448
3460
  });
3449
3461
  if (r.error) return errorResult(r.error, r.details || {});
@@ -3983,6 +3995,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3983
3995
  url: isNonEmptyString(args.url) ? args.url.trim() : undefined,
3984
3996
  logPath: flowLog,
3985
3997
  screenshotDir: evidenceDir,
3998
+ device: isNonEmptyString(args.device) ? args.device.trim() : "",
3999
+ viewport: isNonEmptyString(args.viewport) ? args.viewport.trim() : "",
3986
4000
  });
3987
4001
  run = { ...run, code: result.passed ? 0 : 1, evidenceDir };
3988
4002
  } catch (error) {
@@ -4334,7 +4348,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4334
4348
  : android
4335
4349
  ? await startAndroidSession(target, { serial: args.androidSerial, apkPath: args.apkPath, clearData: args.clearData !== false, testEmail: args.testEmail, testPassword: args.testPassword })
4336
4350
  : web
4337
- ? await startWebSession(target, { testEmail:args.testEmail, testPassword:args.testPassword })
4351
+ ? await startWebSession(target, { testEmail:args.testEmail, testPassword:args.testPassword, device:isNonEmptyString(args.device) ? args.device.trim() : "", viewport:isNonEmptyString(args.viewport) ? args.viewport.trim() : "" })
4338
4352
  : await startManagedWebInteractiveSession({
4339
4353
  projectDir:focusProjectDir,
4340
4354
  requestedTarget:isNonEmptyString(args.target) ? args.target.trim() : "",
@@ -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,17 @@ 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
+ : driverStop === "probe-cap" ? "probe-cap-reached"
299
+ : "completed";
285
300
 
286
301
  const headline = timeBudgetExhausted
287
302
  ? `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 +316,17 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
301
316
  let checkedFor;
302
317
  let notChecked;
303
318
  if (platform === "web") {
319
+ const outbound = base.complete && typeof base.complete === "object" ? base.complete.outbound : null;
304
320
  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",
321
+ "page errors (uncaught exceptions)", "failed/5xx requests", "broken links (404, same-origin crawl)",
322
+ "placeholder links and anchors with no destination", "sampled dead-button probes (advisory)", "error text on pages", "load timeouts",
307
323
  ];
324
+ if (outbound && outbound.total) {
325
+ checkedFor.push(outbound.total > outbound.checked
326
+ ? `outbound link reachability — browser-rendered · DNS · HTTP · unavailable-shell heuristic (first ${outbound.checked} of ${outbound.total})`
327
+ : "outbound link reachability (browser-rendered · DNS · HTTP · unavailable-shell heuristic)");
328
+ }
329
+ if (outbound?.mailtos && !outbound.mailtoSkipped) checkedFor.push("mailto address domains (MX/A records)");
308
330
  notChecked = [
309
331
  "app-specific business logic (cover with Flows: record or generate, then assert)",
310
332
  "content and claim accuracy (including copy versus API data)",
@@ -314,6 +336,11 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
314
336
  "only the first few visible buttons per page are probed (web beta)",
315
337
  "content & reachability regressions require a baseline",
316
338
  ];
339
+ if (outbound?.mailtoSkipped === "egress-policy") notChecked.push("mailto address domains (MX lookups are skipped by the public-egress policy)");
340
+ if (!outbound || (!outbound.total && !outbound.mailtos)) conditionsNotReached.push("outbound links (none encountered this run)");
341
+ if (inputFieldsEncountered.length && !loginAttempted) {
342
+ notChecked.push(`form submission (${inputFieldsEncountered.reduce((n, s) => n + s.fields.length, 0)} field(s) catalogued, none submitted)`);
343
+ }
317
344
  } else if (platform === "android") {
318
345
  checkedFor = [
319
346
  "crashes / process exits", "dead controls", "error surfaces", "blank screens",
@@ -361,6 +388,18 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
361
388
  headline,
362
389
  inconclusive,
363
390
  coverage: { screensExplored, actionsPerformed, screens: Array.from(screens) },
391
+ // Per-action evidence: what was done, to what, on which screen, at what offset — so an
392
+ // agent can distinguish "clicked Services, clicked FAQ" from "clicked the same button twice".
393
+ trace: (base.actions || [])
394
+ .filter((action) => action && typeof action === "object")
395
+ .map((action) => ({
396
+ ...(typeof action.t === "number" ? { t: action.t } : {}),
397
+ type: action.type || "action",
398
+ target: action.target || "",
399
+ ...(action.screen ? { screen: action.screen } : {}),
400
+ ...(action.via ? { via: action.via } : {}),
401
+ ...(action.reason ? { reason: action.reason } : {}),
402
+ })),
364
403
  evidence: { markers: base.relativeMarkersFilePath },
365
404
  uiMap: null,
366
405
  comparison: null,
@@ -505,7 +544,11 @@ export function computeRegression(current, baseline) {
505
544
  // the evidence." Exit codes are the CI contract; precedence is fail > inconclusive > pass.
506
545
  export const GATE_EXIT = { pass: 0, fail: 1, error: 2, inconclusive: 3 };
507
546
  // Bump when the gate's decision semantics change (NOT the npm version). Recorded on every GateRun.
508
- export const GATE_POLICY_VERSION = "2";
547
+ // v3: `failOn` gained severity thresholds ("high" | "medium") that block on any deterministic-tier
548
+ // finding at or above that severity, and the CLI defaults web targets to `medium` — a 404 in the
549
+ // nav is the release blocker on a website, and a field-tested green PASS over six deterministic
550
+ // findings was exactly the dishonest verdict this product refuses to render.
551
+ export const GATE_POLICY_VERSION = "3";
509
552
 
510
553
  // Pure gate evaluator: frozen evidence + policy → a GateRun decision. Extracted verbatim from the
511
554
  // former inline logic in ci-report.js so the `[char]` characterization tests keep passing — the
@@ -554,6 +597,13 @@ export function evaluateGate({ report, regression = null, flows = [], scenarios
554
597
  if (report.findingCounts.total > 0) fail(`${report.findingCounts.total} finding(s) (fail-on: any)`);
555
598
  // "any" is the strictest policy — an inconclusive run (evidence not obtained) must never pass it.
556
599
  if (report.inconclusive) inconclusive("run was inconclusive (coverage floor not met)");
600
+ } else if (failOn === "medium" || failOn === "high") {
601
+ // Severity thresholds are absolute over the deterministic tier: anything at or above the
602
+ // requested severity blocks, baseline or not. Sampled/advisory findings never participate.
603
+ const counts = report.deterministicFindingCounts || {};
604
+ const blocking = (counts.critical || 0) + (counts.high || 0) + (failOn === "medium" ? counts.medium || 0 : 0);
605
+ if (blocking > 0) fail(`${blocking} deterministic finding(s) at or above ${failOn} severity (fail-on: ${failOn})`);
606
+ if (report.inconclusive) inconclusive("run was inconclusive (coverage floor not met)");
557
607
  } else if (failOn === "absolute" || (failOn === "gate" && !regression)) {
558
608
  if (findingsBlock(report.deterministicFindingCounts, { inconclusive: report.inconclusive })) fail("blocking deterministic finding(s)");
559
609
  if (report.inconclusive) inconclusive("run was inconclusive (coverage floor not met)");
@@ -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);
@@ -429,23 +466,26 @@ export async function inspectWebPage({ url, timeoutMs = NAV_TIMEOUT_MS, screensh
429
466
  }
430
467
  const observed = await page.evaluate(() => {
431
468
  const visible = (element) => element.offsetParent !== null;
432
- const controls = [...document.querySelectorAll("button, a[href], input, textarea, select, [role=button], [role=tab], [role=checkbox], [role=switch]")]
469
+ const controls = [...document.querySelectorAll("button, a[href], input, textarea, select, summary, [role=button], [role=tab], [role=checkbox], [role=switch]")]
433
470
  .filter((element) => element.type !== "hidden" && visible(element))
434
471
  .slice(0, 80)
435
472
  .map((element) => {
436
473
  const tag = element.tagName.toLowerCase();
437
474
  const field = ["input", "textarea", "select"].includes(tag);
438
475
  const secure = element.type === "password";
439
- const role = element.getAttribute("role") || (tag === "a" ? "link" : tag === "button" ? "button" : "");
476
+ const role = element.getAttribute("role") || (tag === "a" ? "link" : tag === "button" || tag === "summary" ? "button" : "");
440
477
  const label = (element.labels?.[0]?.textContent || element.getAttribute("aria-label") || element.textContent || element.placeholder || element.name || element.id || "").trim().slice(0, 120);
478
+ const box = element.getBoundingClientRect();
441
479
  return {
442
- type: field ? (secure ? "SecureTextField" : "TextField") : "Button",
480
+ // `type` stays faithful so an agent follows links and presses buttons, not vice versa.
481
+ type: field ? (secure ? "SecureTextField" : "TextField") : tag === "a" ? "Link" : "Button",
443
482
  role,
444
483
  label,
445
484
  identifier: element.id || element.getAttribute("data-testid") || element.getAttribute("aria-label") || "",
446
485
  isEnabled: !element.disabled && element.getAttribute("aria-disabled") !== "true",
447
486
  hittable: true,
448
487
  secure,
488
+ rect: { x: Math.round(box.x), y: Math.round(box.y), width: Math.round(box.width), height: Math.round(box.height) },
449
489
  };
450
490
  })
451
491
  .filter((control) => control.label || control.identifier);
@@ -455,7 +495,7 @@ export async function inspectWebPage({ url, timeoutMs = NAV_TIMEOUT_MS, screensh
455
495
  controls,
456
496
  };
457
497
  });
458
- const image = screenshot ? await page.screenshot({ type: "png" }) : null;
498
+ const image = screenshot ? await page.screenshot({ type: "png", fullPage: !!fullPage }) : null;
459
499
  return {
460
500
  url: page.url(),
461
501
  screenTitle: webScreenTitle(observed, target.pathname || target.href),
@@ -524,17 +564,19 @@ export function normalizeWebSeedTargets(seedTargets = [], limit = 5) {
524
564
  return result;
525
565
  }
526
566
 
527
- export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDir, testEmail = "", testPassword = "", seedRoutes = [], seedTargets = [], watch = false, onProgress }) {
567
+ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDir, testEmail = "", testPassword = "", seedRoutes = [], seedTargets = [], watch = false, onProgress, device = "", viewport = "" }) {
528
568
  const start = new URL(url);
529
569
  if (!/^https?:$/.test(start.protocol)) throw new Error("url must be http(s)");
530
570
  fs.mkdirSync(outDir, { recursive: true });
531
571
  const markersPath = path.join(outDir, "ocqa-markers.txt");
532
572
  const markersFd = fs.openSync(markersPath, "w");
533
573
  const emit = (kind, payload) => fs.writeSync(markersFd, `OCQA_${kind}:${JSON.stringify(payload)}\n`);
574
+ const startedAtMs = Date.now();
575
+ const emitAction = (payload) => emit("ACTION", { t: Date.now() - startedAtMs, ...payload });
534
576
 
535
- const { chromium } = await loadPlaywright();
577
+ const { chromium, devices } = await loadPlaywright();
536
578
  const browser = await chromium.launch(webBrowserLaunchOptions(process.env, { watch }));
537
- const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
579
+ const context = await browser.newContext(webContextOptions({ device, viewport, devices }));
538
580
  await installWebListenerTracking(context);
539
581
  if (watch) await installWebWatchUi(context);
540
582
  const page = await context.newPage();
@@ -542,9 +584,12 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
542
584
 
543
585
  const deadline = Date.now() + timeoutSec * 1000;
544
586
  const issues = []; // emitted immediately; kept for counting only
545
- const issue = (type, severity, title, screen, target) => {
587
+ const issue = (type, severity, title, screen, target, sourceUrl) => {
546
588
  issues.push(type);
547
- const pageUrl = page.url();
589
+ // Findings belong to the page that CARRIED the defect. Async detectors default to the
590
+ // current page; the post-crawl outbound audit passes the link's source page explicitly so
591
+ // a bad footer link is never attributed to whatever page happened to be visited last.
592
+ const pageUrl = sourceUrl || page.url();
548
593
  emit("ISSUE", { type, severity, title, screen, ...(target ? { target } : {}), ...(pageUrl && pageUrl !== "about:blank" ? { url: pageUrl } : {}) });
549
594
  };
550
595
 
@@ -585,6 +630,11 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
585
630
  ];
586
631
  const screenshotFor = new Map();
587
632
  const placeholderLinksSeen = new Set();
633
+ const missingAnchorsSeen = new Set();
634
+ const outboundLinks = new Map(); // href → { label, screen }
635
+ const mailtoLinks = new Map(); // address → { screen }
636
+ const outbound = { total: 0, checked: 0, mailtos: 0 };
637
+ let probeCapHit = false;
588
638
  let actions = 0;
589
639
  let screenCount = 0;
590
640
  let lastScreen = null;
@@ -607,12 +657,12 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
607
657
  secure: el.type === "password",
608
658
  }))
609
659
  .filter((f) => f.label);
610
- const controls = [...document.querySelectorAll("button, a[href], input, textarea, select, [role=button], [role=tab], [role=checkbox], [role=switch]")]
660
+ const controls = [...document.querySelectorAll("button, a[href], input, textarea, select, summary, [role=button], [role=tab], [role=checkbox], [role=switch]")]
611
661
  .filter((el) => el.type !== "hidden" && el.offsetParent !== null)
612
662
  .slice(0, 60)
613
663
  .map((el) => {
614
664
  const tag = el.tagName.toLowerCase();
615
- const role = el.getAttribute("role") || (tag === "a" ? "link" : tag === "button" ? "button" : "");
665
+ const role = el.getAttribute("role") || (tag === "a" ? "link" : tag === "button" || tag === "summary" ? "button" : "");
616
666
  const field = ["input", "textarea", "select"].includes(tag);
617
667
  const secure = el.type === "password";
618
668
  const label = (el.labels?.[0]?.textContent || el.getAttribute("aria-label") || el.textContent || el.placeholder || el.name || el.id || "").trim().slice(0, 120);
@@ -674,6 +724,18 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
674
724
  fingerprint: el.id || el.getAttribute("data-testid") || svgPath.slice(0, 80) || `link-${index + 1}`,
675
725
  };
676
726
  }),
727
+ missingAnchors: [...document.querySelectorAll('a[href^="#"]')]
728
+ .filter((el) => el.offsetParent !== null)
729
+ .map((el) => el.getAttribute("href") || "")
730
+ .filter((href) => href.length > 1)
731
+ .filter((href) => {
732
+ const id = decodeURIComponent(href.slice(1));
733
+ try {
734
+ const esc = window.CSS && CSS.escape ? CSS.escape(id) : id;
735
+ return !document.getElementById(id) && !document.querySelector(`a[name="${esc}"]`);
736
+ } catch { return !document.getElementById(id); }
737
+ })
738
+ .slice(0, 20),
677
739
  inputs,
678
740
  controls,
679
741
  };
@@ -717,6 +779,13 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
717
779
  placeholderLinksSeen.add(finding.target);
718
780
  issue(finding.type, finding.severity, finding.title, screen, finding.target);
719
781
  }
782
+ // Anchor links pointing at ids that do not exist are deterministic dead navigation.
783
+ for (const anchor of info.missingAnchors || []) {
784
+ const anchorTarget = `${key}${anchor}`;
785
+ if (missingAnchorsSeen.has(anchorTarget)) continue;
786
+ missingAnchorsSeen.add(anchorTarget);
787
+ issue("anchor_missing", "medium", `Anchor link "${anchor}" has no matching element on the page`, screen, anchorTarget);
788
+ }
720
789
  }
721
790
  return { key, screen, info };
722
791
  }
@@ -737,7 +806,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
737
806
  if (watch) await showWebWatchAction(page, { locator: pw, action: "Type", target: "Password" });
738
807
  await pw.fill(testPassword).catch(() => {});
739
808
  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" });
809
+ emitAction({ type: "login", target: "Sign in", screen, narrative: "Filled and submitted the sign-in form with the provided test credentials" });
741
810
  actions += 1;
742
811
  await submitWebLogin(page, watch
743
812
  ? (locator) => showWebWatchAction(page, { locator, action: "Click", target: "Sign in" })
@@ -784,7 +853,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
784
853
  actions += 1;
785
854
  lastActionTarget = webNavigationAction(entry.action, target);
786
855
  pendingNavigation = entry.pathTarget ? { ...entry, prTarget: false } : entry;
787
- emit("ACTION", { type: "open", target, via: lastActionTarget, narrative: `Opened ${target}` });
856
+ emitAction({ type: "open", target, via: lastActionTarget, narrative: `Opened ${target}` });
788
857
  // Attribute load-time events (pageerror, 404s) to the page being loaded, not the one
789
858
  // we just left; observe() refines this to the page title once it settles.
790
859
  currentScreen = target;
@@ -814,7 +883,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
814
883
  const beforeScreen = ob.screen;
815
884
  actions += 1;
816
885
  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}` });
886
+ 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
887
  let acted = false;
819
888
  if (action.type === "back") {
820
889
  if (watch) await showWebWatchAction(page, { action: "Back", target: beforeScreen });
@@ -857,15 +926,27 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
857
926
  emit("PR_TARGET", { targetId: entry.targetId, status: "observed", screen: ob.screen });
858
927
  }
859
928
 
860
- // Enqueue unvisited same-origin links (BFS keeps exploration order deterministic).
929
+ // Enqueue unvisited same-origin links (BFS keeps exploration order deterministic);
930
+ // collect outbound http(s) links and mailto addresses for the post-crawl audit.
861
931
  const links = await page.$$eval("a[href]", (as) => as.map((a) => ({
862
932
  href: a.href,
933
+ raw: a.getAttribute("href") || "",
863
934
  label: (a.getAttribute("aria-label") || a.textContent || "").trim(),
864
935
  }))).catch(() => []);
865
936
  for (const link of links) {
866
937
  try {
938
+ if (/^mailto:/i.test(link.raw)) {
939
+ const address = link.raw.replace(/^mailto:/i, "").split("?")[0].trim();
940
+ if (address.includes("@") && !mailtoLinks.has(address)) mailtoLinks.set(address, { screen: ob.screen, sourceUrl: start.origin + ob.key });
941
+ continue;
942
+ }
867
943
  const u = new URL(link.href);
868
- if (u.origin !== start.origin || !/^https?:$/.test(u.protocol)) continue;
944
+ if (!/^https?:$/.test(u.protocol)) continue;
945
+ if (u.origin !== start.origin) {
946
+ const clean = u.origin + u.pathname;
947
+ if (!outboundLinks.has(clean)) outboundLinks.set(clean, { label: link.label, screen: ob.screen, sourceUrl: start.origin + ob.key });
948
+ continue;
949
+ }
869
950
  const key = u.pathname.replace(/\/+$/, "") + u.search || "/";
870
951
  if (!visited.has(key) && !frontier.some((item) => item.target === key)) {
871
952
  frontier.push({ target: key, action: webNavigationAction(link.label, key), fromScreen: ob.screen });
@@ -875,8 +956,10 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
875
956
 
876
957
  // Bounded button pass: click, watch for effect, flag dead controls (the web analog
877
958
  // 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");
879
- const n = Math.min(await buttons.count().catch(() => 0), BUTTONS_PER_PAGE);
959
+ const buttons = page.locator("button:visible, [role=button]:visible, input[type=submit]:visible, summary:visible");
960
+ const buttonTotal = await buttons.count().catch(() => 0);
961
+ const n = Math.min(buttonTotal, BUTTONS_PER_PAGE);
962
+ if (buttonTotal > BUTTONS_PER_PAGE) probeCapHit = true;
880
963
  for (let i = 0; i < n && actions < maxActions && Date.now() < deadline; i++) {
881
964
  const b = buttons.nth(i);
882
965
  const label = webControlLabel({
@@ -893,7 +976,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
893
976
  const beforeState = await captureWebControlState(page, b);
894
977
  actions += 1;
895
978
  lastActionTarget = label;
896
- emit("ACTION", { type: "tap", target: label, screen: webActionScreen(ob), narrative: `Tapped "${label}"` });
979
+ emitAction({ type: "tap", target: label, screen: webActionScreen(ob), narrative: `Tapped "${label}"` });
897
980
  let clickSucceeded = false;
898
981
  try {
899
982
  if (watch) await showWebWatchAction(page, { locator: b, action: "Click", target: label });
@@ -925,8 +1008,67 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
925
1008
  }
926
1009
  progress();
927
1010
  }
1011
+
1012
+ // Post-crawl outbound audit. Link checks run through the LIVE browser context: JS-rendered
1013
+ // "unavailable" shells (Facebook et al.) are only visible to a real renderer, and browser
1014
+ // navigation honors the egress proxy policy, so these checks never bypass it. Only the
1015
+ // mailto MX lookups use node:dns and are therefore skipped under the enforced-egress policy.
1016
+ outbound.total = outboundLinks.size;
1017
+ outbound.mailtos = mailtoLinks.size;
1018
+ if (outboundLinks.size) {
1019
+ const auditPage = await context.newPage();
1020
+ auditPage.setDefaultTimeout(8000);
1021
+ const targets = [...outboundLinks.entries()].slice(0, OUTBOUND_LINK_LIMIT);
1022
+ outbound.checked = targets.length;
1023
+ for (const [href, meta] of targets) {
1024
+ if (Date.now() >= deadline) break;
1025
+ const nav = await auditPage.goto(href, { waitUntil: "domcontentloaded", timeout: 8000 })
1026
+ .catch((error) => ({ navError: String(error && error.message || error) }));
1027
+ if (nav && nav.navError) {
1028
+ const dnsFailure = /ERR_NAME_NOT_RESOLVED/i.test(nav.navError);
1029
+ issue("unresolvable_host", "medium",
1030
+ dnsFailure
1031
+ ? `Outbound link host does not resolve: ${new URL(href).hostname}`
1032
+ : `Outbound link unreachable: ${href.slice(0, 100)} (${nav.navError.slice(0, 60)})`,
1033
+ meta.screen, href, meta.sourceUrl);
1034
+ continue;
1035
+ }
1036
+ if (nav && typeof nav.status === "function" && nav.status() >= 400) {
1037
+ issue("broken_link", "medium", `Outbound link returns HTTP ${nav.status()}: ${href.slice(0, 100)}`, meta.screen, href, meta.sourceUrl);
1038
+ continue;
1039
+ }
1040
+ await auditPage.waitForTimeout(400); // let client-rendered shells paint their copy
1041
+ const text = await auditPage.evaluate(() => (document.body && document.body.innerText || "").slice(0, 120000)).catch(() => "");
1042
+ const phrase = webUnavailableShellPhrase(text);
1043
+ if (phrase) issue("outbound_unavailable", "low", `Outbound link returns 200 but shows "${phrase}": ${href.slice(0, 100)}`, meta.screen, href, meta.sourceUrl);
1044
+ }
1045
+ await auditPage.close().catch(() => {});
1046
+ }
1047
+ if (mailtoLinks.size) {
1048
+ if (process.env.TAPP_ENFORCE_PUBLIC_EGRESS === "1") {
1049
+ outbound.mailtoSkipped = "egress-policy"; // node:dns would bypass the enforced proxy
1050
+ } else {
1051
+ const dns = await import("node:dns/promises");
1052
+ for (const [address, meta] of mailtoLinks) {
1053
+ if (Date.now() >= deadline) break;
1054
+ const domain = (address.split("@")[1] || "").toLowerCase();
1055
+ if (!domain) continue;
1056
+ // RFC 5321 implicit-MX: a domain with no MX but an A/AAAA record can still receive.
1057
+ const deliverable = await dns.resolveMx(domain).then((records) => records.length > 0).catch(() => false)
1058
+ || await dns.lookup(domain).then(() => true).catch(() => false);
1059
+ if (!deliverable) issue("mailto_no_mx", "medium", `mailto: domain cannot receive email (no MX or address record): ${address}`, meta.screen, address, meta.sourceUrl);
1060
+ }
1061
+ }
1062
+ }
928
1063
  } finally {
929
- emit("COMPLETE", { actions, screens: screenCount, credentialsProvided: !!(testEmail || testPassword), credentialsUsed: loginTried });
1064
+ const timedOut = Date.now() >= deadline;
1065
+ // A drained frontier with a truncating probe cap is NOT "nothing left" — twin controls may
1066
+ // sit untapped past the per-page cap, and the stop reason must not claim otherwise.
1067
+ const stop = timedOut ? "time-budget"
1068
+ : actions >= maxActions ? "action-budget"
1069
+ : probeCapHit ? "probe-cap"
1070
+ : "frontier-drained";
1071
+ emit("COMPLETE", { actions, screens: screenCount, credentialsProvided: !!(testEmail || testPassword), credentialsUsed: loginTried, timedOut, stop, outbound });
930
1072
  fs.closeSync(markersFd);
931
1073
  await browser.close().catch(() => {});
932
1074
  }
@@ -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.4",
3
+ "version": "0.17.6",
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
  }
@@ -10,9 +10,10 @@
10
10
  # for GitHub Actions; equally usable from any other CI or locally.
11
11
  #
12
12
  # Usage:
13
- # scripts/ci-gate.sh [--platform ios] --app <path/to/App.app> [--bundle-id <com.example.app>]
14
- # scripts/ci-gate.sh --platform android --apk <path/to/app.apk> --app-id <com.example.app> [--serial <adb-serial>]
15
- # scripts/ci-gate.sh --platform web [--url <http(s)://owned-app>]
13
+ # tapp ci [--platform ios] --app <path/to/App.app> [--bundle-id <com.example.app>]
14
+ # tapp ci --platform android --apk <path/to/app.apk> --app-id <com.example.app> [--serial <adb-serial>]
15
+ # tapp ci --platform web [--url <http(s)://owned-app>]
16
+ # tapp ci # in an initialized repo: reads .tapp/application-model.json for platform/target
16
17
  # # omit --url with --project-dir to detect/build/start/stop one owned web target
17
18
  # # bundle id is detected from the .app when omitted
18
19
  # [--actions N] # exploration budget (default 40)
@@ -27,7 +28,10 @@
27
28
  # [--pr-plan-out <file.json>] # persist the reviewable selection plan
28
29
  # [--baseline <file.json>] # prior report to diff against (skipped if absent)
29
30
  # [--target-key <stable-id>] # isolates target-specific baselines in monorepos
30
- # [--fail-on gate|absolute|any] # gate policy (default gate; see ci-report.js)
31
+ # [--fail-on gate|absolute|any|high|medium]
32
+ # # gate policy (default: gate; web targets default
33
+ # # to medium — any deterministic medium+ finding
34
+ # # blocks; see ci-report.js)
31
35
  # [--json-out <file.json>] # write the full report (use as the next baseline)
32
36
  # [--md-out <file.md>] # write the rendered markdown report (for a PR comment)
33
37
  # [--device <name>] # simulator device to boot if none is (default "iPhone 16 Pro")
@@ -43,10 +47,10 @@ usage() {
43
47
 
44
48
  PLATFORM="ios" APP_PATH="" BUNDLE_ID="" APK_PATH="" APP_ID="" URL="" WEB_TARGET="" TARGET_KEY="" SERIAL="" ACTIONS=40 TIMEOUT=600 FLOWS="" SCENARIOS="" CONTRACTS="" PROJECT_DIR="" BASELINE="" FAIL_ON="gate" JSON_OUT="" MD_OUT="" DEVICE="iPhone 16 Pro" PR_BASE="" PR_HEAD="HEAD" CHANGED_FILES_FILE="" PR_PLAN_OUT=""
45
49
  IOS_PR_TARGET_JSON=""
46
- FLOWS_EXPLICIT=false SCENARIOS_EXPLICIT=false CONTRACTS_EXPLICIT=false
50
+ FLOWS_EXPLICIT=false SCENARIOS_EXPLICIT=false CONTRACTS_EXPLICIT=false PLATFORM_EXPLICIT=false FAIL_ON_EXPLICIT=false
47
51
  while [[ $# -gt 0 ]]; do
48
52
  case "$1" in
49
- --platform) PLATFORM="$2"; shift 2 ;;
53
+ --platform) PLATFORM="$2"; PLATFORM_EXPLICIT=true; shift 2 ;;
50
54
  --app) APP_PATH="$2"; shift 2 ;;
51
55
  --bundle-id) BUNDLE_ID="$2"; shift 2 ;;
52
56
  --apk) APK_PATH="$2"; shift 2 ;;
@@ -66,7 +70,7 @@ while [[ $# -gt 0 ]]; do
66
70
  --changed-files-file) CHANGED_FILES_FILE="$2"; shift 2 ;;
67
71
  --pr-plan-out) PR_PLAN_OUT="$2"; shift 2 ;;
68
72
  --baseline) BASELINE="$2"; shift 2 ;;
69
- --fail-on) FAIL_ON="$2"; shift 2 ;;
73
+ --fail-on) FAIL_ON="$2"; FAIL_ON_EXPLICIT=true; shift 2 ;;
70
74
  --json-out) JSON_OUT="$2"; shift 2 ;;
71
75
  --md-out) MD_OUT="$2"; shift 2 ;;
72
76
  --device) DEVICE="$2"; shift 2 ;;
@@ -77,16 +81,48 @@ done
77
81
  [[ "$PLATFORM" == "ios" || "$PLATFORM" == "android" || "$PLATFORM" == "web" ]] || { echo "❌ --platform must be ios|android|web" >&2; exit 2; }
78
82
  [[ "$ACTIONS" =~ ^[1-9][0-9]*$ ]] || { echo "❌ --actions must be a positive integer" >&2; exit 2; }
79
83
  [[ "$TIMEOUT" =~ ^[1-9][0-9]*$ ]] || { echo "❌ --timeout must be a positive integer" >&2; exit 2; }
80
- [[ "$FAIL_ON" == "gate" || "$FAIL_ON" == "absolute" || "$FAIL_ON" == "any" ]] || { echo "❌ --fail-on must be gate|absolute|any" >&2; exit 2; }
84
+ [[ "$FAIL_ON" == "gate" || "$FAIL_ON" == "absolute" || "$FAIL_ON" == "any" || "$FAIL_ON" == "high" || "$FAIL_ON" == "medium" ]] || { echo "❌ --fail-on must be gate|absolute|any|high|medium" >&2; exit 2; }
81
85
  if [[ -n "$PROJECT_DIR" ]]; then
82
86
  [[ -d "$PROJECT_DIR" ]] || { echo "❌ Project directory not found: $PROJECT_DIR" >&2; exit 2; }
83
87
  PROJECT_DIR="$(cd "$PROJECT_DIR" && pwd)"
84
88
  fi
89
+ # A repository-connected gate should connect to the repository it is run from: when no
90
+ # --project-dir was given but the working directory is an initialized Tapp repo, use it.
91
+ if [[ -z "$PROJECT_DIR" && -f "$(pwd)/.tapp/application-model.json" ]]; then
92
+ PROJECT_DIR="$(pwd)"
93
+ echo "Using repository artifacts from $PROJECT_DIR/.tapp"
94
+ fi
85
95
  TAPP_PROJECT_ARTIFACTS=""
86
96
  if [[ -n "$PROJECT_DIR" ]]; then
87
97
  [[ -d "$PROJECT_DIR/.tapp" ]] && TAPP_PROJECT_ARTIFACTS="$PROJECT_DIR/.tapp"
88
98
  fi
89
99
 
100
+ # When --platform was not given, derive it from the application model instead of assuming iOS —
101
+ # but only when the model is unambiguous (exactly one platform across its targets).
102
+ if [[ "$PLATFORM_EXPLICIT" == "false" && -n "$TAPP_PROJECT_ARTIFACTS" && -f "$TAPP_PROJECT_ARTIFACTS/application-model.json" ]]; then
103
+ MODEL_PLATFORM="$(node - "$TAPP_PROJECT_ARTIFACTS/application-model.json" <<'NODE'
104
+ const fs = require("fs");
105
+ try {
106
+ const model = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
107
+ const platforms = [...new Set((model.targets || []).map((t) => t.platform).filter(Boolean))];
108
+ if (platforms.length === 1) process.stdout.write(platforms[0]);
109
+ } catch {}
110
+ NODE
111
+ )"
112
+ if [[ -n "$MODEL_PLATFORM" && "$MODEL_PLATFORM" != "$PLATFORM" ]]; then
113
+ PLATFORM="$MODEL_PLATFORM"
114
+ echo "Platform derived from the application model: $PLATFORM (pass --platform to override)"
115
+ fi
116
+ fi
117
+
118
+ # Web policy default (gate policy v3): on a website, a deterministic broken link IS the release
119
+ # blocker — default web targets to fail-on medium. Pass --fail-on gate to restore baseline-diff
120
+ # semantics for a web target.
121
+ if [[ "$FAIL_ON_EXPLICIT" == "false" && "$PLATFORM" == "web" ]]; then
122
+ FAIL_ON="medium"
123
+ echo "Gate policy: fail-on medium (web default; any deterministic medium+ finding blocks — override with --fail-on)"
124
+ fi
125
+
90
126
  # A repository-connected gate must retain the stable application-model target identity in its
91
127
  # report. Otherwise its first passing report cannot become a target-scoped baseline, even though
92
128
  # Tapp already knows exactly which application it built and exercised. Explicit --target-key still
@@ -42,12 +42,18 @@ for (let i = 2; i < process.argv.length; i += 1) {
42
42
  process.exit(2);
43
43
  }
44
44
  }
45
- if (!["web", "android"].includes(args.platform)) throw new Error("--platform must be web|android");
46
- if (args.platform === "web" && !args.url && !args.projectDir) throw new Error("Web gate requires --url or --project-dir for managed build/start");
47
- if (args.platform === "android" && !args.appId) throw new Error("Android gate requires --app-id");
45
+ // Usage problems are part of the public outcome contract (exit 2), never an uncaught stack trace.
46
+ const usageError = (message) => { console.error(`❌ ${message}`); process.exit(2); };
47
+ if (!["web", "android"].includes(args.platform)) usageError("--platform must be web|android");
48
+ if (args.platform === "web" && !args.url && !args.projectDir) usageError("The web gate needs a target: pass --url <http(s)://owned-app>, or --project-dir <repo> to build/start the repository's own web target.");
49
+ if (args.platform === "android" && !args.appId) usageError("The Android gate requires --app-id");
48
50
  if (args.projectDir) {
49
- args.projectDir = fs.realpathSync(path.resolve(args.projectDir));
50
- if (!fs.statSync(args.projectDir).isDirectory()) throw new Error(`Project directory not found: ${args.projectDir}`);
51
+ try {
52
+ args.projectDir = fs.realpathSync(path.resolve(args.projectDir));
53
+ if (!fs.statSync(args.projectDir).isDirectory()) usageError(`Project directory not found: ${args.projectDir}`);
54
+ } catch {
55
+ usageError(`Project directory not found: ${args.projectDir}`);
56
+ }
51
57
  }
52
58
 
53
59
  // Parse and platform-filter before launching a browser/device so a typo cannot
@@ -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);
@@ -104,6 +104,13 @@ npx -y @aarwitz/tapp@latest flow run .tapp/flows/smoke.yml
104
104
  npx -y @aarwitz/tapp@latest ci
105
105
  ```
106
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
+
107
114
  `tapp explore` observes. `tapp ci` applies versioned deterministic policy to evidence, selected
108
115
  Flows/Scenarios/contracts, coverage, and any target-scoped baseline. Its outcomes are `pass`, `fail`,
109
116
  or `inconclusive`; both `fail` and `inconclusive` block a merge.