@aarwitz/tapp 0.17.5 → 0.17.7

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.7",
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.7",
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.7 # 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.7 # or pin the reviewed release commit SHA
402
402
  with:
403
403
  platform: android
404
404
  android-app-id: com.acme.app
package/bin/tapp.js CHANGED
@@ -270,10 +270,10 @@ function safeCommandUsage(verb) {
270
270
  shot: "tapp shot [--out FILE]",
271
271
  apps: "tapp apps",
272
272
  build: "tapp build [repo] [--scheme NAME] [--configuration NAME]",
273
- flow: "tapp flow example\ntapp flow validate FILE [--platform PLATFORM] [--map FILE]\ntapp flow run FILE [--actor NAME] [--email VALUE] [--password VALUE] [--device \"iPhone 13\"] [--viewport 390x844]",
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]\n Exit codes: 0 replay passed · 1 replay failed · 2 infrastructure/usage error",
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
- 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
- scenario: "tapp scenario validate FILE [--project-dir DIR]\ntapp scenario run FILE --platform web --url URL [--project-dir DIR]",
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]\n Exit codes: 0 contract held · 1 contract failed · 2 infrastructure/usage error",
276
+ scenario: "tapp scenario validate FILE [--project-dir DIR]\ntapp scenario run FILE --platform web --url URL [--project-dir DIR]\n Exit codes: 0 scenario passed · 1 scenario failed · 2 infrastructure/usage error",
277
277
  map: "tapp map build MARKERS [--platform PLATFORM] [--out FILE] [--replace]\ntapp map inspect [FILE]\ntapp map diff BEFORE AFTER [--comparable]",
278
278
  pr: "tapp pr plan [--base REF|--changed-files FILE] [--head REF] [--platform PLATFORM] [--out FILE]\ntapp pr gate PLAN [gate target/options]\ntapp pr adopt PLAN --item ID [--project-dir DIR]",
279
279
  plan: "tapp plan show [FILE]\ntapp plan review [FILE] --approve NAME[,NAME] --reject NAME[,NAME] --defer NAME[,NAME]\ntapp plan generate|validate|promote [FILE] [options]",
@@ -282,7 +282,7 @@ function safeCommandUsage(verb) {
282
282
  actor: "tapp actor set NAME --email-env ENV --password-env ENV [--replace] [--project-dir DIR]\ntapp actor list [repo]",
283
283
  app: "tapp app [repo] [--no-open] [--port PORT]",
284
284
  report: "tapp report [captureId|latest]",
285
- doctor: "tapp doctor",
285
+ doctor: "tapp doctor [--json]\n Exit codes: 0 environment healthy · 1 blocked (fix ❌ items)",
286
286
  install: "tapp install",
287
287
  mcp: "tapp mcp",
288
288
  };
@@ -1425,37 +1425,51 @@ switch (command) {
1425
1425
  }
1426
1426
 
1427
1427
  case "doctor": {
1428
- console.log(`tapp v${pkg.version} doctor\n`);
1428
+ const { flags: doctorFlags } = parseVerbArgs(rest);
1429
+ const jsonMode = doctorFlags.json === true;
1430
+ const report = { version: pkg.version, healthy: true, node: {}, python3: {}, disk: {},
1431
+ platforms: { ios: {}, android: {}, web: {} }, home: tappHome };
1432
+ const say = (line) => { if (!jsonMode) console.log(line); };
1433
+ const sayOk = (label, detail = "") => { if (!jsonMode) ok(label, detail); };
1434
+ const sayBad = (label, detail = "") => { if (!jsonMode) bad(label, detail); };
1435
+ say(`tapp v${pkg.version} — doctor\n`);
1429
1436
  let healthy = true;
1430
1437
 
1431
1438
  const major = Number(process.versions.node.split(".")[0]);
1432
- major >= 18 ? ok("Node", `v${process.versions.node}`) : (bad("Node", `v${process.versions.node} (need >= 18)`), (healthy = false));
1439
+ report.node = { ok: major >= 18, version: process.versions.node };
1440
+ major >= 18 ? sayOk("Node", `v${process.versions.node}`) : (sayBad("Node", `v${process.versions.node} (need >= 18)`), (healthy = false));
1433
1441
 
1434
1442
  const python = run("python3", ["--version"]);
1435
- python.code === 0 ? ok("python3", `${python.stdout} (used by Flows)`) : bad("python3", "not found Flow replay needs python3 + pyyaml (everything else works)");
1443
+ report.python3 = { ok: python.code === 0, version: python.code === 0 ? python.stdout : null };
1444
+ python.code === 0 ? sayOk("python3", `${python.stdout} (used by Flows)`) : sayBad("python3", "not found — Flow replay needs python3 + pyyaml (everything else works)");
1436
1445
 
1437
1446
  const { storagePreflight } = await import(path.join(packageRoot, "mcp-server", "src", "environment-preflight.js"));
1438
1447
  const storage = storagePreflight(tappHome);
1439
- if (storage.level === "blocked") { bad("Disk space", storage.message); healthy = false; }
1440
- else if (storage.level === "warning") console.log(` ⚠️ Disk space — ${storage.message}`);
1441
- else if (storage.level === "ok") ok("Disk space", storage.message);
1442
- else console.log(` ⬜ Disk space — ${storage.message || "could not be checked"}`);
1448
+ report.disk = { level: storage.level, message: storage.message || null };
1449
+ if (storage.level === "blocked") { sayBad("Disk space", storage.message); healthy = false; }
1450
+ else if (storage.level === "warning") say(` ⚠️ Disk space — ${storage.message}`);
1451
+ else if (storage.level === "ok") sayOk("Disk space", storage.message);
1452
+ else say(` ⬜ Disk space — ${storage.message || "could not be checked"}`);
1443
1453
 
1444
- console.log("\n Platforms:");
1454
+ say("\n Platforms:");
1445
1455
  if (process.platform === "darwin") {
1446
1456
  const xcode = run("xcode-select", ["-p"]);
1447
1457
  const simctl = run("xcrun", ["simctl", "help"]);
1448
1458
  if (xcode.code === 0 && simctl.code === 0) {
1449
1459
  const ver = run("xcodebuild", ["-version"]).stdout.split("\n")[0];
1450
1460
  const booted = bootedSims();
1451
- ok("iOS", `${ver || "Xcode"}; ${booted.length ? `${booted[0].name} booted` : "no simulator booted yet"}`);
1452
1461
  const xctestrun = harnessXctestrun();
1453
- xctestrun ? ok("iOS harness cache", xctestrun) : console.log(" ⬜ iOS harness cache — builds on first use (or: npx -y @aarwitz/tapp@latest install)");
1462
+ report.platforms.ios = { available: true, xcode: ver || "Xcode",
1463
+ bootedSimulator: booted.length ? booted[0].name : null, harnessCache: Boolean(xctestrun) };
1464
+ sayOk("iOS", `${ver || "Xcode"}; ${booted.length ? `${booted[0].name} booted` : "no simulator booted yet"}`);
1465
+ xctestrun ? sayOk("iOS harness cache", xctestrun) : say(" ⬜ iOS harness cache — builds on first use (or: npx -y @aarwitz/tapp@latest install)");
1454
1466
  } else {
1455
- console.log(" ⬜ iOS unavailable (install Xcode + simulator runtime)");
1467
+ report.platforms.ios = { available: false, reason: "install Xcode + simulator runtime" };
1468
+ say(" ⬜ iOS — unavailable (install Xcode + simulator runtime)");
1456
1469
  }
1457
1470
  } else {
1458
- console.log(` ⬜ iOS requires macOS (this host: ${process.platform})`);
1471
+ report.platforms.ios = { available: false, reason: `requires macOS (this host: ${process.platform})` };
1472
+ say(` ⬜ iOS — requires macOS (this host: ${process.platform})`);
1459
1473
  }
1460
1474
 
1461
1475
  const { resolveAdbPath, resolveAndroidSdkRoot } = await import(path.join(packageRoot, "mcp-server", "src", "android-driver.js"));
@@ -1463,17 +1477,20 @@ switch (command) {
1463
1477
  const adb = adbPath ? run(adbPath, ["devices"]) : { code: 1, stdout: "" };
1464
1478
  if (adb.code === 0) {
1465
1479
  const devices = adb.stdout.split(/\r?\n/).slice(1).filter((line) => /\sdevice(?:\s|$)/.test(line));
1466
- ok("Android", devices.length ? `${devices.length} connected emulator/device` : "adb available; no device connected");
1480
+ report.platforms.android = { adb: true, devicesConnected: devices.length };
1481
+ sayOk("Android", devices.length ? `${devices.length} connected emulator/device` : "adb available; no device connected");
1467
1482
  } else {
1468
- console.log(" ⬜ Android adb not found (install Android SDK platform-tools)");
1483
+ report.platforms.android = { adb: false, devicesConnected: 0 };
1484
+ say(" ⬜ Android — adb not found (install Android SDK platform-tools)");
1469
1485
  }
1470
1486
  const { resolveJavaRuntime } = await import(path.join(packageRoot, "mcp-server", "src", "environment-preflight.js"));
1471
1487
  const java = resolveJavaRuntime();
1472
1488
  const androidSdkRoot = resolveAndroidSdkRoot();
1473
- if (java && androidSdkRoot) ok("Android source builds", `${java.version || java.javaHome}; SDK ${androidSdkRoot}`);
1489
+ report.platforms.android.sourceBuilds = Boolean(java && androidSdkRoot);
1490
+ if (java && androidSdkRoot) sayOk("Android source builds", `${java.version || java.javaHome}; SDK ${androidSdkRoot}`);
1474
1491
  else {
1475
1492
  const missing = [!java ? "JDK 17" : "", !androidSdkRoot ? "Android SDK root" : ""].filter(Boolean).join(" and ");
1476
- console.log(` ⬜ Android source builds — install/configure ${missing} (prebuilt APK testing still works)`);
1493
+ say(` ⬜ Android source builds — install/configure ${missing} (prebuilt APK testing still works)`);
1477
1494
  }
1478
1495
 
1479
1496
  try {
@@ -1481,18 +1498,26 @@ switch (command) {
1481
1498
  let executable = "";
1482
1499
  try { executable = chromium.executablePath(); } catch { /* report the missing browser below */ }
1483
1500
  if (executable && fs.existsSync(executable)) {
1484
- ok("Web", `Playwright + Chromium (${executable})`);
1501
+ report.platforms.web = { available: true, chromium: executable };
1502
+ sayOk("Web", `Playwright + Chromium (${executable})`);
1485
1503
  } else {
1486
- console.log(" ⬜ Web Playwright installed; Chromium browser missing (run: npx playwright install chromium)");
1504
+ report.platforms.web = { available: false, reason: "Chromium browser missing (run: npx playwright install chromium)" };
1505
+ say(" ⬜ Web — Playwright installed; Chromium browser missing (run: npx playwright install chromium)");
1487
1506
  }
1488
1507
  } catch {
1489
- console.log(" ⬜ Web install Playwright in the app workspace: npm install -D playwright && npx playwright install chromium");
1508
+ report.platforms.web = { available: false, reason: "Playwright not installed" };
1509
+ say(" ⬜ Web — install Playwright in the app workspace: npm install -D playwright && npx playwright install chromium");
1490
1510
  }
1491
1511
 
1492
- console.log(`\n Home: ${tappHome}`);
1493
- console.log(healthy
1494
- ? "\nReady. Start with:\n npx -y @aarwitz/tapp@latest open [target]\n npx -y @aarwitz/tapp@latest explore [target]"
1495
- : "\nFix the ❌ items above, then re-run: npx -y @aarwitz/tapp@latest doctor");
1512
+ report.healthy = healthy;
1513
+ if (jsonMode) {
1514
+ console.log(JSON.stringify(report, null, 2));
1515
+ } else {
1516
+ console.log(`\n Home: ${tappHome}`);
1517
+ console.log(healthy
1518
+ ? "\nReady. Start with:\n npx -y @aarwitz/tapp@latest open [target]\n npx -y @aarwitz/tapp@latest explore [target]"
1519
+ : "\nFix the ❌ items above, then re-run: npx -y @aarwitz/tapp@latest doctor");
1520
+ }
1496
1521
  process.exit(healthy ? 0 : 1);
1497
1522
  }
1498
1523
 
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.7 # 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;
@@ -175,6 +178,7 @@ function loadBaseline(baselinePath) {
175
178
  actionsPerformed: parsed.actionsPerformed || 0,
176
179
  platform: parsed.baselineIdentity?.platform || parsed.platform || null,
177
180
  targetKey: parsed.baselineIdentity?.targetId || parsed.targetKey || null,
181
+ capture: parsed.capture || null,
178
182
  };
179
183
  }
180
184
 
@@ -396,6 +400,10 @@ function renderMarkdown(report, regression, flows, scenarios, contracts, prPlan,
396
400
  lines.push(`### Since baseline — ${regFailed ? "🔴 regression gate FAILED" : "🟢 regression gate passed"}`);
397
401
  lines.push(`+${regression.counts.new} new · ${regression.counts.persisting} persisting · ${regression.counts.resolved} resolved` +
398
402
  (regFailed ? ` — **${newCritical} new critical, ${newHigh} new high**` : ""));
403
+ if (regression.captureMismatch) {
404
+ const describe = (c) => [c?.device, c?.viewport ? `${c.viewport.width}x${c.viewport.height}` : null, c?.deviceScaleFactor ? `@${c.deviceScaleFactor}x` : null].filter(Boolean).join(" ") || "unknown";
405
+ lines.push(`⚠️ **Capture mismatch**: baseline was captured at ${describe(regression.captureMismatch.baseline)}, this run at ${describe(regression.captureMismatch.current)}. "Resolved" findings may reflect layout differences at the new size, not fixes — re-baseline at the same device/viewport to compare honestly.`);
406
+ }
399
407
  for (const f of regression.newFindings) {
400
408
  lines.push(`- NEW ${SEV_ICON[f.severity] || ""} ${f.severity}: ${f.title} (${f.screen ?? "—"})`);
401
409
  }
@@ -477,10 +485,22 @@ function renderMarkdown(report, regression, flows, scenarios, contracts, prPlan,
477
485
  }
478
486
  lines.push("");
479
487
  const badge = GATE_BADGE[gate.outcome] || (gate.failed ? "🔴 FAIL" : "🟢 PASS");
480
- lines.push(`**Gate (${gate.policy}): ${badge}**${gate.reasons.length ? "" + gate.reasons.join("; ") : ""}`);
488
+ // A PASS must state what it chose to ignore a green banner over known findings without
489
+ // saying so is exactly the dishonest verdict this product refuses to render.
490
+ const ignored = gate.outcome === "pass" && report.deterministicFindingCounts
491
+ ? ["high", "medium", "low"].map((sev) => [sev, report.deterministicFindingCounts[sev] || 0]).filter(([, n]) => n > 0)
492
+ : [];
493
+ const ignoredNote = ignored.length
494
+ ? ` — ${ignored.reduce((n, [, c]) => n + c, 0)} deterministic finding(s) below the fail threshold (${ignored.map(([sev, n]) => `${n} ${sev}`).join(", ")})`
495
+ : "";
496
+ lines.push(`**Gate (${gate.policy}): ${badge}**${gate.reasons.length ? " — " + gate.reasons.join("; ") : ""}${ignoredNote}`);
481
497
  // The gate is only authoritative about what it actually ran — record the scope explicitly.
482
498
  const rev = gate.revision?.sha ? `${String(gate.revision.sha).slice(0, 12)}${gate.revision.dirty ? "-dirty" : ""}` : "unknown";
483
- lines.push(`_target: ${gate.target || "—"} · revision: ${rev} · policy: ${gate.policy} v${gate.policyVersion || "?"}_`);
499
+ const captured = report.capture
500
+ ? ` · capture: ${[report.capture.device, report.capture.viewport ? `${report.capture.viewport.width}x${report.capture.viewport.height}` : null, report.capture.deviceScaleFactor ? `@${report.capture.deviceScaleFactor}x` : null].filter(Boolean).join(" ")}`
501
+ : "";
502
+ const stableId = report.targetKey && report.targetKey !== gate.target ? ` (${report.targetKey})` : "";
503
+ lines.push(`_target: ${gate.target || "—"}${stableId} · revision: ${rev} · policy: ${gate.policy} v${gate.policyVersion || "?"}${captured}_`);
484
504
  if (Array.isArray(gate.checked) && gate.checked.length) lines.push(`_Checked: ${gate.checked.join(" · ")}_`);
485
505
  if (Array.isArray(gate.notChecked) && gate.notChecked.length) lines.push(`_Not checked: ${gate.notChecked.join(" · ")}_`);
486
506
  return lines.join("\n");
@@ -539,6 +559,12 @@ if (collapsed.length) {
539
559
  report.headline = `${collapsed.length} screen(s) regressed vs. baseline (content collapsed or became unreachable).`;
540
560
  }
541
561
  const regression = computeRegression(report.findings, baseline?.findings ?? null);
562
+ // A baseline captured at a different device/viewport is a layout comparison, not a regression
563
+ // signal: a phone run legitimately hides desktop nav links, so its "resolved" list lies. Keep
564
+ // the diff (new findings still gate) but stamp the mismatch so every consumer can see it.
565
+ if (regression && baseline?.capture && report.capture && JSON.stringify(baseline.capture) !== JSON.stringify(report.capture)) {
566
+ regression.captureMismatch = { baseline: baseline.capture, current: report.capture };
567
+ }
542
568
  const runs = args.flowLogs.map(parseFlowLog);
543
569
  const contracts = runs.filter((run) => run.kind === "release-contract");
544
570
  const flows = runs.filter((run) => !["scenario", "release-contract"].includes(run.kind));
@@ -561,7 +587,9 @@ try {
561
587
  const decision = evaluateGate({ report, regression, flows, scenarios, contracts, prPlan, baseline, failOn: args.failOn });
562
588
  const gate = {
563
589
  ...decision,
564
- target: args.targetKey || report.target || null,
590
+ // Display identity is the URL/bundle the run exercised; the stable application-model id
591
+ // stays on report.targetKey for baseline matching (a hash is not a target name).
592
+ target: report.target || args.targetKey || null,
565
593
  revision: gitRevision(args.projectDir),
566
594
  checked: report.checkedFor,
567
595
  notChecked: report.notChecked,
@@ -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; }
@@ -137,6 +140,7 @@ ${r.conditionsNotReached?.length ? `<div><h2>Conditions not reached</h2><ul>${sc
137
140
  <h1>${esc(observationBadge(r))} <span class="dim">· ${esc(observationSummary(r))}</span></h1>
138
141
  <div class="meta">${esc(label)} · evidence page (observation, not a release decision)</div>
139
142
  ${r.platform === "web" ? `<div class="meta">Deterministic basis: ${r.deterministicFindingCounts?.total || 0} deterministic finding(s); ${r.sampledFindingCounts?.total || 0} sampled probe finding(s) are advisory.</div>` : ""}
143
+ ${r.capture ? `<div class="meta">Captured at ${esc([r.capture.device, r.capture.viewport ? `${r.capture.viewport.width}×${r.capture.viewport.height}` : null, r.capture.deviceScaleFactor ? `@${r.capture.deviceScaleFactor}x` : null].filter(Boolean).join(" "))} — every screenshot on this page shares these conditions.</div>` : ""}
140
144
  <div class="headline">${esc(r.headline)}</div>
141
145
  ${r.credentialWarning ? `<div class="warning">${esc(r.credentialWarning)}</div>` : ""}
142
146
  ${scopeHtml}
@@ -144,6 +148,10 @@ ${scopeHtml}
144
148
  <ul class="findings">
145
149
  ${findingsHtml}
146
150
  </ul>
151
+ ${Array.isArray(r.trace) && r.trace.length ? `<h2>Action trace — what was done, in order</h2>
152
+ <table class="trace"><thead><tr><th>t</th><th>action</th><th>target</th><th>screen</th></tr></thead><tbody>
153
+ ${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")}
154
+ </tbody></table>` : ""}
147
155
  <h2>Evidence — every screen explored</h2>
148
156
  <div class="grid">
149
157
  ${shotsHtml || "<p class='dim'>No screenshots captured.</p>"}
@@ -1721,9 +1721,10 @@ async function writeRunUiMap({ markersPath, platform, target, runId, outDir }) {
1721
1721
  /** One-line "3 buttons · 2 fields · 8 text" breakdown of an accessibility element list. */
1722
1722
  function elementBreakdown(elements) {
1723
1723
  const has = (e, ...pats) => pats.some((p) => String(e.type || "").includes(p));
1724
- let buttons = 0, fields = 0, texts = 0, cells = 0, other = 0;
1724
+ let buttons = 0, links = 0, fields = 0, texts = 0, cells = 0, other = 0;
1725
1725
  for (const e of elements || []) {
1726
- if (has(e, "Button", "rawValue: 9", "Link", "rawValue: 39")) buttons++;
1726
+ if (has(e, "Link", "rawValue: 39")) links++;
1727
+ else if (has(e, "Button", "rawValue: 9")) buttons++;
1727
1728
  else if (has(e, "TextField", "rawValue: 49", "rawValue: 50", "SecureTextField")) fields++;
1728
1729
  else if (has(e, "StaticText", "rawValue: 48")) texts++;
1729
1730
  else if (has(e, "Cell", "rawValue: 75")) cells++;
@@ -1731,6 +1732,7 @@ function elementBreakdown(elements) {
1731
1732
  }
1732
1733
  return [
1733
1734
  buttons && `${buttons} button${buttons > 1 ? "s" : ""}`,
1735
+ links && `${links} link${links > 1 ? "s" : ""}`,
1734
1736
  fields && `${fields} field${fields > 1 ? "s" : ""}`,
1735
1737
  cells && `${cells} cell${cells > 1 ? "s" : ""}`,
1736
1738
  texts && `${texts} text`,
@@ -1740,13 +1742,15 @@ function elementBreakdown(elements) {
1740
1742
  /** Scannable "Read screen X — N elements (...)" readout, plus the tappable/typeable controls. */
1741
1743
  export function formatScreen(screenTitle, elements) {
1742
1744
  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
1745
+ 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")));
1746
+ const allLabels = interactable
1745
1747
  .map((e) => (e.label || e.identifier || "").trim())
1746
- .filter((s) => s && s.length <= 40 && !s.includes("."))
1747
- .slice(0, 8);
1748
+ .filter((s) => s && s.length <= 40 && !s.includes("."));
1749
+ const labels = allLabels.slice(0, 8);
1748
1750
  const L = [`🌳 Read screen **${screenTitle || "Unknown"}** — ${els.length} elements (${elementBreakdown(els)})`];
1749
- if (labels.length) L.push("", "**Controls:** " + labels.map((l) => `\`${l}\``).join(" · "));
1751
+ // A silently cut list reads as complete; say when it isn't.
1752
+ if (labels.length) L.push("", "**Controls:** " + labels.map((l) => `\`${l}\``).join(" · ")
1753
+ + (allLabels.length > labels.length ? ` · … and ${allLabels.length - labels.length} more` : ""));
1750
1754
  return L.join("\n");
1751
1755
  }
1752
1756
 
@@ -30,6 +30,7 @@ export function parseOcqaMarkers(markersFilePath) {
30
30
  const transitions = [];
31
31
  const issues = [];
32
32
  let complete = null;
33
+ let context = null;
33
34
 
34
35
  for (const line of lines) {
35
36
  if (!line.startsWith("OCQA_")) continue;
@@ -57,6 +58,7 @@ export function parseOcqaMarkers(markersFilePath) {
57
58
  if (category === "TRANSITION") transitions.push(parsed);
58
59
  if (category === "ISSUE") issues.push(parsed);
59
60
  if (category === "COMPLETE") complete = parsed;
61
+ if (category === "CONTEXT") context = parsed;
60
62
  }
61
63
 
62
64
  return {
@@ -72,6 +74,7 @@ export function parseOcqaMarkers(markersFilePath) {
72
74
  )
73
75
  ),
74
76
  complete,
77
+ context,
75
78
  actions,
76
79
  recentActions: actions.slice(-5),
77
80
  recentTransitions: transitions.slice(-5),
@@ -101,7 +104,11 @@ export const ISSUE_CATEGORY = {
101
104
  explore_timeout: "performance_timeout",
102
105
  };
103
106
  export const CRITICAL_ISSUE_TYPES = new Set(["crash"]);
104
- export const WEB_SAMPLED_ISSUE_TYPES = new Set(["unresponsive_element", "outbound_unavailable"]);
107
+ // outbound_unavailable graduated to the deterministic tier in policy v4: since 0.17.6 the
108
+ // outbound audit renders each link in the live browser, so the unavailable-shell phrase match
109
+ // is a stable observation of the page, not a sampled probe — and a dead social link is exactly
110
+ // the broken-link class the web fail-on default exists to block.
111
+ export const WEB_SAMPLED_ISSUE_TYPES = new Set(["unresponsive_element"]);
105
112
 
106
113
  export function severityRank(s) {
107
114
  return { critical: 0, high: 1, medium: 2, low: 3 }[s] ?? 4;
@@ -295,6 +302,7 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
295
302
  : timeBudgetExhausted ? "time-budget-exhausted"
296
303
  : !coverageFloorMet ? "coverage-floor-not-met"
297
304
  : driverStop === "frontier-drained" ? "no-unexplored-in-scope-controls"
305
+ : driverStop === "probe-cap" ? "probe-cap-reached"
298
306
  : "completed";
299
307
 
300
308
  const headline = timeBudgetExhausted
@@ -320,12 +328,12 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
320
328
  "page errors (uncaught exceptions)", "failed/5xx requests", "broken links (404, same-origin crawl)",
321
329
  "placeholder links and anchors with no destination", "sampled dead-button probes (advisory)", "error text on pages", "load timeouts",
322
330
  ];
323
- if (outbound && !outbound.skipped && (outbound.total || outbound.mailtos)) {
331
+ if (outbound && outbound.total) {
324
332
  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)");
333
+ ? `outbound link reachability — browser-rendered · DNS · HTTP · unavailable-shell heuristic (first ${outbound.checked} of ${outbound.total})`
334
+ : "outbound link reachability (browser-rendered · DNS · HTTP · unavailable-shell heuristic)");
328
335
  }
336
+ if (outbound?.mailtos && !outbound.mailtoSkipped) checkedFor.push("mailto address domains (MX/A records)");
329
337
  notChecked = [
330
338
  "app-specific business logic (cover with Flows: record or generate, then assert)",
331
339
  "content and claim accuracy (including copy versus API data)",
@@ -335,8 +343,8 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
335
343
  "only the first few visible buttons per page are probed (web beta)",
336
344
  "content & reachability regressions require a baseline",
337
345
  ];
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)");
346
+ if (outbound?.mailtoSkipped === "egress-policy") notChecked.push("mailto address domains (MX lookups are skipped by the public-egress policy)");
347
+ if (!outbound || (!outbound.total && !outbound.mailtos)) conditionsNotReached.push("outbound links (none encountered this run)");
340
348
  if (inputFieldsEncountered.length && !loginAttempted) {
341
349
  notChecked.push(`form submission (${inputFieldsEncountered.reduce((n, s) => n + s.fields.length, 0)} field(s) catalogued, none submitted)`);
342
350
  }
@@ -400,6 +408,12 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
400
408
  ...(action.reason ? { reason: action.reason } : {}),
401
409
  })),
402
410
  evidence: { markers: base.relativeMarkersFilePath },
411
+ // Capture conditions shared by every screenshot/marker in this run (web: from the driver's
412
+ // CONTEXT marker). A baseline diff across different captures is a layout comparison, not a
413
+ // regression signal — consumers must be able to see that.
414
+ capture: base.context && typeof base.context === "object"
415
+ ? { device: base.context.device || null, viewport: base.context.viewport || null, deviceScaleFactor: base.context.deviceScaleFactor ?? null }
416
+ : null,
403
417
  uiMap: null,
404
418
  comparison: null,
405
419
  checkedFor,
@@ -543,7 +557,11 @@ export function computeRegression(current, baseline) {
543
557
  // the evidence." Exit codes are the CI contract; precedence is fail > inconclusive > pass.
544
558
  export const GATE_EXIT = { pass: 0, fail: 1, error: 2, inconclusive: 3 };
545
559
  // Bump when the gate's decision semantics change (NOT the npm version). Recorded on every GateRun.
546
- export const GATE_POLICY_VERSION = "2";
560
+ // v3: `failOn` gained severity thresholds ("high" | "medium") that block on any deterministic-tier
561
+ // finding at or above that severity, and the CLI defaults web targets to `medium` — a 404 in the
562
+ // nav is the release blocker on a website, and a field-tested green PASS over six deterministic
563
+ // findings was exactly the dishonest verdict this product refuses to render.
564
+ export const GATE_POLICY_VERSION = "4";
547
565
 
548
566
  // Pure gate evaluator: frozen evidence + policy → a GateRun decision. Extracted verbatim from the
549
567
  // former inline logic in ci-report.js so the `[char]` characterization tests keep passing — the
@@ -592,6 +610,13 @@ export function evaluateGate({ report, regression = null, flows = [], scenarios
592
610
  if (report.findingCounts.total > 0) fail(`${report.findingCounts.total} finding(s) (fail-on: any)`);
593
611
  // "any" is the strictest policy — an inconclusive run (evidence not obtained) must never pass it.
594
612
  if (report.inconclusive) inconclusive("run was inconclusive (coverage floor not met)");
613
+ } else if (failOn === "medium" || failOn === "high") {
614
+ // Severity thresholds are absolute over the deterministic tier: anything at or above the
615
+ // requested severity blocks, baseline or not. Sampled/advisory findings never participate.
616
+ const counts = report.deterministicFindingCounts || {};
617
+ const blocking = (counts.critical || 0) + (counts.high || 0) + (failOn === "medium" ? counts.medium || 0 : 0);
618
+ if (blocking > 0) fail(`${blocking} deterministic finding(s) at or above ${failOn} severity (fail-on: ${failOn})`);
619
+ if (report.inconclusive) inconclusive("run was inconclusive (coverage floor not met)");
595
620
  } else if (failOn === "absolute" || (failOn === "gate" && !regression)) {
596
621
  if (findingsBlock(report.deterministicFindingCounts, { inconclusive: report.inconclusive })) fail("blocking deterministic finding(s)");
597
622
  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);
@@ -573,7 +576,12 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
573
576
 
574
577
  const { chromium, devices } = await loadPlaywright();
575
578
  const browser = await chromium.launch(webBrowserLaunchOptions(process.env, { watch }));
576
- const context = await browser.newContext(webContextOptions({ device, viewport, devices }));
579
+ const captureProfile = webContextOptions({ device, viewport, devices });
580
+ // Evidence is only comparable when the capture conditions are on record: a desktop run and a
581
+ // phone run legitimately disagree about which nav links exist, and a baseline diff across
582
+ // them must be able to say so instead of reporting "resolved".
583
+ emit("CONTEXT", { ...(String(device || "").trim() ? { device: String(device).trim() } : {}), viewport: captureProfile.viewport, deviceScaleFactor: captureProfile.deviceScaleFactor ?? 1 });
584
+ const context = await browser.newContext(captureProfile);
577
585
  await installWebListenerTracking(context);
578
586
  if (watch) await installWebWatchUi(context);
579
587
  const page = await context.newPage();
@@ -581,9 +589,12 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
581
589
 
582
590
  const deadline = Date.now() + timeoutSec * 1000;
583
591
  const issues = []; // emitted immediately; kept for counting only
584
- const issue = (type, severity, title, screen, target) => {
592
+ const issue = (type, severity, title, screen, target, sourceUrl) => {
585
593
  issues.push(type);
586
- const pageUrl = page.url();
594
+ // Findings belong to the page that CARRIED the defect. Async detectors default to the
595
+ // current page; the post-crawl outbound audit passes the link's source page explicitly so
596
+ // a bad footer link is never attributed to whatever page happened to be visited last.
597
+ const pageUrl = sourceUrl || page.url();
587
598
  emit("ISSUE", { type, severity, title, screen, ...(target ? { target } : {}), ...(pageUrl && pageUrl !== "about:blank" ? { url: pageUrl } : {}) });
588
599
  };
589
600
 
@@ -628,6 +639,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
628
639
  const outboundLinks = new Map(); // href → { label, screen }
629
640
  const mailtoLinks = new Map(); // address → { screen }
630
641
  const outbound = { total: 0, checked: 0, mailtos: 0 };
642
+ let probeCapHit = false;
631
643
  let actions = 0;
632
644
  let screenCount = 0;
633
645
  let lastScreen = null;
@@ -930,14 +942,14 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
930
942
  try {
931
943
  if (/^mailto:/i.test(link.raw)) {
932
944
  const address = link.raw.replace(/^mailto:/i, "").split("?")[0].trim();
933
- if (address.includes("@") && !mailtoLinks.has(address)) mailtoLinks.set(address, { screen: ob.screen });
945
+ if (address.includes("@") && !mailtoLinks.has(address)) mailtoLinks.set(address, { screen: ob.screen, sourceUrl: start.origin + ob.key });
934
946
  continue;
935
947
  }
936
948
  const u = new URL(link.href);
937
949
  if (!/^https?:$/.test(u.protocol)) continue;
938
950
  if (u.origin !== start.origin) {
939
951
  const clean = u.origin + u.pathname;
940
- if (!outboundLinks.has(clean)) outboundLinks.set(clean, { label: link.label, screen: ob.screen });
952
+ if (!outboundLinks.has(clean)) outboundLinks.set(clean, { label: link.label, screen: ob.screen, sourceUrl: start.origin + ob.key });
941
953
  continue;
942
954
  }
943
955
  const key = u.pathname.replace(/\/+$/, "") + u.search || "/";
@@ -950,7 +962,9 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
950
962
  // Bounded button pass: click, watch for effect, flag dead controls (the web analog
951
963
  // of the iOS dead-button detector). Navigations are undone so BFS order holds.
952
964
  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);
965
+ const buttonTotal = await buttons.count().catch(() => 0);
966
+ const n = Math.min(buttonTotal, BUTTONS_PER_PAGE);
967
+ if (buttonTotal > BUTTONS_PER_PAGE) probeCapHit = true;
954
968
  for (let i = 0; i < n && actions < maxActions && Date.now() < deadline; i++) {
955
969
  const b = buttons.nth(i);
956
970
  const label = webControlLabel({
@@ -1000,56 +1014,65 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
1000
1014
  progress();
1001
1015
  }
1002
1016
 
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.
1017
+ // Post-crawl outbound audit. Link checks run through the LIVE browser context: JS-rendered
1018
+ // "unavailable" shells (Facebook et al.) are only visible to a real renderer, and browser
1019
+ // navigation honors the egress proxy policy, so these checks never bypass it. Only the
1020
+ // mailto MX lookups use node:dns and are therefore skipped under the enforced-egress policy.
1006
1021
  outbound.total = outboundLinks.size;
1007
1022
  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
- };
1023
+ if (outboundLinks.size) {
1024
+ const auditPage = await context.newPage();
1025
+ auditPage.setDefaultTimeout(8000);
1019
1026
  const targets = [...outboundLinks.entries()].slice(0, OUTBOUND_LINK_LIMIT);
1020
1027
  outbound.checked = targets.length;
1021
1028
  for (const [href, meta] of targets) {
1022
1029
  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);
1030
+ const nav = await auditPage.goto(href, { waitUntil: "domcontentloaded", timeout: 8000 })
1031
+ .catch((error) => ({ navError: String(error && error.message || error) }));
1032
+ if (nav && nav.navError) {
1033
+ const dnsFailure = /ERR_NAME_NOT_RESOLVED/i.test(nav.navError);
1034
+ issue("unresolvable_host", "medium",
1035
+ dnsFailure
1036
+ ? `Outbound link host does not resolve: ${new URL(href).hostname}`
1037
+ : `Outbound link unreachable: ${href.slice(0, 100)} (${nav.navError.slice(0, 60)})`,
1038
+ meta.screen, href, meta.sourceUrl);
1026
1039
  continue;
1027
1040
  }
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);
1041
+ if (nav && typeof nav.status === "function" && nav.status() >= 400) {
1042
+ issue("broken_link", "medium", `Outbound link returns HTTP ${nav.status()}: ${href.slice(0, 100)}`, meta.screen, href, meta.sourceUrl);
1043
+ continue;
1038
1044
  }
1045
+ await auditPage.waitForTimeout(400); // let client-rendered shells paint their copy
1046
+ const text = await auditPage.evaluate(() => (document.body && document.body.innerText || "").slice(0, 120000)).catch(() => "");
1047
+ const phrase = webUnavailableShellPhrase(text);
1048
+ if (phrase) issue("outbound_unavailable", "medium", `Outbound link returns 200 but shows "${phrase}": ${href.slice(0, 100)}`, meta.screen, href, meta.sourceUrl);
1039
1049
  }
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);
1050
+ await auditPage.close().catch(() => {});
1051
+ }
1052
+ if (mailtoLinks.size) {
1053
+ if (process.env.TAPP_ENFORCE_PUBLIC_EGRESS === "1") {
1054
+ outbound.mailtoSkipped = "egress-policy"; // node:dns would bypass the enforced proxy
1055
+ } else {
1056
+ const dns = await import("node:dns/promises");
1057
+ for (const [address, meta] of mailtoLinks) {
1058
+ if (Date.now() >= deadline) break;
1059
+ const domain = (address.split("@")[1] || "").toLowerCase();
1060
+ if (!domain) continue;
1061
+ // RFC 5321 implicit-MX: a domain with no MX but an A/AAAA record can still receive.
1062
+ const deliverable = await dns.resolveMx(domain).then((records) => records.length > 0).catch(() => false)
1063
+ || await dns.lookup(domain).then(() => true).catch(() => false);
1064
+ if (!deliverable) issue("mailto_no_mx", "medium", `mailto: domain cannot receive email (no MX or address record): ${address}`, meta.screen, address, meta.sourceUrl);
1065
+ }
1048
1066
  }
1049
1067
  }
1050
1068
  } finally {
1051
1069
  const timedOut = Date.now() >= deadline;
1052
- const stop = timedOut ? "time-budget" : actions >= maxActions ? "action-budget" : "frontier-drained";
1070
+ // A drained frontier with a truncating probe cap is NOT "nothing left" twin controls may
1071
+ // sit untapped past the per-page cap, and the stop reason must not claim otherwise.
1072
+ const stop = timedOut ? "time-budget"
1073
+ : actions >= maxActions ? "action-budget"
1074
+ : probeCapHit ? "probe-cap"
1075
+ : "frontier-drained";
1053
1076
  emit("COMPLETE", { actions, screens: screenCount, credentialsProvided: !!(testEmail || testPassword), credentialsUsed: loginTried, timedOut, stop, outbound });
1054
1077
  fs.closeSync(markersFd);
1055
1078
  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.7",
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