@aarwitz/tapp 0.17.5 โ†’ 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.5",
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.5",
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.5 # 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.5 # 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/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.5 # 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>"}
@@ -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
 
@@ -295,6 +295,7 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
295
295
  : timeBudgetExhausted ? "time-budget-exhausted"
296
296
  : !coverageFloorMet ? "coverage-floor-not-met"
297
297
  : driverStop === "frontier-drained" ? "no-unexplored-in-scope-controls"
298
+ : driverStop === "probe-cap" ? "probe-cap-reached"
298
299
  : "completed";
299
300
 
300
301
  const headline = timeBudgetExhausted
@@ -320,12 +321,12 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
320
321
  "page errors (uncaught exceptions)", "failed/5xx requests", "broken links (404, same-origin crawl)",
321
322
  "placeholder links and anchors with no destination", "sampled dead-button probes (advisory)", "error text on pages", "load timeouts",
322
323
  ];
323
- if (outbound && !outbound.skipped && (outbound.total || outbound.mailtos)) {
324
+ if (outbound && outbound.total) {
324
325
  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)");
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
328
  }
329
+ if (outbound?.mailtos && !outbound.mailtoSkipped) checkedFor.push("mailto address domains (MX/A records)");
329
330
  notChecked = [
330
331
  "app-specific business logic (cover with Flows: record or generate, then assert)",
331
332
  "content and claim accuracy (including copy versus API data)",
@@ -335,8 +336,8 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
335
336
  "only the first few visible buttons per page are probed (web beta)",
336
337
  "content & reachability regressions require a baseline",
337
338
  ];
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)");
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)");
340
341
  if (inputFieldsEncountered.length && !loginAttempted) {
341
342
  notChecked.push(`form submission (${inputFieldsEncountered.reduce((n, s) => n + s.fields.length, 0)} field(s) catalogued, none submitted)`);
342
343
  }
@@ -543,7 +544,11 @@ export function computeRegression(current, baseline) {
543
544
  // the evidence." Exit codes are the CI contract; precedence is fail > inconclusive > pass.
544
545
  export const GATE_EXIT = { pass: 0, fail: 1, error: 2, inconclusive: 3 };
545
546
  // Bump when the gate's decision semantics change (NOT the npm version). Recorded on every GateRun.
546
- 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";
547
552
 
548
553
  // Pure gate evaluator: frozen evidence + policy โ†’ a GateRun decision. Extracted verbatim from the
549
554
  // former inline logic in ci-report.js so the `[char]` characterization tests keep passing โ€” the
@@ -592,6 +597,13 @@ export function evaluateGate({ report, regression = null, flows = [], scenarios
592
597
  if (report.findingCounts.total > 0) fail(`${report.findingCounts.total} finding(s) (fail-on: any)`);
593
598
  // "any" is the strictest policy โ€” an inconclusive run (evidence not obtained) must never pass it.
594
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)");
595
607
  } else if (failOn === "absolute" || (failOn === "gate" && !regression)) {
596
608
  if (findingsBlock(report.deterministicFindingCounts, { inconclusive: report.inconclusive })) fail("blocking deterministic finding(s)");
597
609
  if (report.inconclusive) inconclusive("run was inconclusive (coverage floor not met)");
@@ -466,23 +466,26 @@ export async function inspectWebPage({ url, timeoutMs = NAV_TIMEOUT_MS, screensh
466
466
  }
467
467
  const observed = await page.evaluate(() => {
468
468
  const visible = (element) => element.offsetParent !== null;
469
- 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]")]
470
470
  .filter((element) => element.type !== "hidden" && visible(element))
471
471
  .slice(0, 80)
472
472
  .map((element) => {
473
473
  const tag = element.tagName.toLowerCase();
474
474
  const field = ["input", "textarea", "select"].includes(tag);
475
475
  const secure = element.type === "password";
476
- 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" : "");
477
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();
478
479
  return {
479
- 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",
480
482
  role,
481
483
  label,
482
484
  identifier: element.id || element.getAttribute("data-testid") || element.getAttribute("aria-label") || "",
483
485
  isEnabled: !element.disabled && element.getAttribute("aria-disabled") !== "true",
484
486
  hittable: true,
485
487
  secure,
488
+ rect: { x: Math.round(box.x), y: Math.round(box.y), width: Math.round(box.width), height: Math.round(box.height) },
486
489
  };
487
490
  })
488
491
  .filter((control) => control.label || control.identifier);
@@ -581,9 +584,12 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
581
584
 
582
585
  const deadline = Date.now() + timeoutSec * 1000;
583
586
  const issues = []; // emitted immediately; kept for counting only
584
- const issue = (type, severity, title, screen, target) => {
587
+ const issue = (type, severity, title, screen, target, sourceUrl) => {
585
588
  issues.push(type);
586
- 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();
587
593
  emit("ISSUE", { type, severity, title, screen, ...(target ? { target } : {}), ...(pageUrl && pageUrl !== "about:blank" ? { url: pageUrl } : {}) });
588
594
  };
589
595
 
@@ -628,6 +634,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
628
634
  const outboundLinks = new Map(); // href โ†’ { label, screen }
629
635
  const mailtoLinks = new Map(); // address โ†’ { screen }
630
636
  const outbound = { total: 0, checked: 0, mailtos: 0 };
637
+ let probeCapHit = false;
631
638
  let actions = 0;
632
639
  let screenCount = 0;
633
640
  let lastScreen = null;
@@ -930,14 +937,14 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
930
937
  try {
931
938
  if (/^mailto:/i.test(link.raw)) {
932
939
  const address = link.raw.replace(/^mailto:/i, "").split("?")[0].trim();
933
- if (address.includes("@") && !mailtoLinks.has(address)) mailtoLinks.set(address, { screen: ob.screen });
940
+ if (address.includes("@") && !mailtoLinks.has(address)) mailtoLinks.set(address, { screen: ob.screen, sourceUrl: start.origin + ob.key });
934
941
  continue;
935
942
  }
936
943
  const u = new URL(link.href);
937
944
  if (!/^https?:$/.test(u.protocol)) continue;
938
945
  if (u.origin !== start.origin) {
939
946
  const clean = u.origin + u.pathname;
940
- if (!outboundLinks.has(clean)) outboundLinks.set(clean, { label: link.label, screen: ob.screen });
947
+ if (!outboundLinks.has(clean)) outboundLinks.set(clean, { label: link.label, screen: ob.screen, sourceUrl: start.origin + ob.key });
941
948
  continue;
942
949
  }
943
950
  const key = u.pathname.replace(/\/+$/, "") + u.search || "/";
@@ -950,7 +957,9 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
950
957
  // Bounded button pass: click, watch for effect, flag dead controls (the web analog
951
958
  // of the iOS dead-button detector). Navigations are undone so BFS order holds.
952
959
  const buttons = page.locator("button:visible, [role=button]:visible, input[type=submit]:visible, summary:visible");
953
- const n = Math.min(await buttons.count().catch(() => 0), BUTTONS_PER_PAGE);
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;
954
963
  for (let i = 0; i < n && actions < maxActions && Date.now() < deadline; i++) {
955
964
  const b = buttons.nth(i);
956
965
  const label = webControlLabel({
@@ -1000,56 +1009,65 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
1000
1009
  progress();
1001
1010
  }
1002
1011
 
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.
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.
1006
1016
  outbound.total = outboundLinks.size;
1007
1017
  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
- };
1018
+ if (outboundLinks.size) {
1019
+ const auditPage = await context.newPage();
1020
+ auditPage.setDefaultTimeout(8000);
1019
1021
  const targets = [...outboundLinks.entries()].slice(0, OUTBOUND_LINK_LIMIT);
1020
1022
  outbound.checked = targets.length;
1021
1023
  for (const [href, meta] of targets) {
1022
1024
  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);
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);
1026
1034
  continue;
1027
1035
  }
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);
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;
1038
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);
1039
1044
  }
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);
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
+ }
1048
1061
  }
1049
1062
  }
1050
1063
  } finally {
1051
1064
  const timedOut = Date.now() >= deadline;
1052
- const stop = timedOut ? "time-budget" : actions >= maxActions ? "action-budget" : "frontier-drained";
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";
1053
1071
  emit("COMPLETE", { actions, screens: screenCount, credentialsProvided: !!(testEmail || testPassword), credentialsUsed: loginTried, timedOut, stop, outbound });
1054
1072
  fs.closeSync(markersFd);
1055
1073
  await browser.close().catch(() => {});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aarwitz/tapp",
3
- "version": "0.17.5",
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",
@@ -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