@aarwitz/tapp 0.17.6 → 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.6",
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.6",
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.6 # 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.6 # 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.6 # 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
@@ -178,6 +178,7 @@ function loadBaseline(baselinePath) {
178
178
  actionsPerformed: parsed.actionsPerformed || 0,
179
179
  platform: parsed.baselineIdentity?.platform || parsed.platform || null,
180
180
  targetKey: parsed.baselineIdentity?.targetId || parsed.targetKey || null,
181
+ capture: parsed.capture || null,
181
182
  };
182
183
  }
183
184
 
@@ -399,6 +400,10 @@ function renderMarkdown(report, regression, flows, scenarios, contracts, prPlan,
399
400
  lines.push(`### Since baseline — ${regFailed ? "🔴 regression gate FAILED" : "🟢 regression gate passed"}`);
400
401
  lines.push(`+${regression.counts.new} new · ${regression.counts.persisting} persisting · ${regression.counts.resolved} resolved` +
401
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
+ }
402
407
  for (const f of regression.newFindings) {
403
408
  lines.push(`- NEW ${SEV_ICON[f.severity] || ""} ${f.severity}: ${f.title} (${f.screen ?? "—"})`);
404
409
  }
@@ -491,7 +496,11 @@ function renderMarkdown(report, regression, flows, scenarios, contracts, prPlan,
491
496
  lines.push(`**Gate (${gate.policy}): ${badge}**${gate.reasons.length ? " — " + gate.reasons.join("; ") : ""}${ignoredNote}`);
492
497
  // The gate is only authoritative about what it actually ran — record the scope explicitly.
493
498
  const rev = gate.revision?.sha ? `${String(gate.revision.sha).slice(0, 12)}${gate.revision.dirty ? "-dirty" : ""}` : "unknown";
494
- 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}_`);
495
504
  if (Array.isArray(gate.checked) && gate.checked.length) lines.push(`_Checked: ${gate.checked.join(" · ")}_`);
496
505
  if (Array.isArray(gate.notChecked) && gate.notChecked.length) lines.push(`_Not checked: ${gate.notChecked.join(" · ")}_`);
497
506
  return lines.join("\n");
@@ -550,6 +559,12 @@ if (collapsed.length) {
550
559
  report.headline = `${collapsed.length} screen(s) regressed vs. baseline (content collapsed or became unreachable).`;
551
560
  }
552
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
+ }
553
568
  const runs = args.flowLogs.map(parseFlowLog);
554
569
  const contracts = runs.filter((run) => run.kind === "release-contract");
555
570
  const flows = runs.filter((run) => !["scenario", "release-contract"].includes(run.kind));
@@ -572,7 +587,9 @@ try {
572
587
  const decision = evaluateGate({ report, regression, flows, scenarios, contracts, prPlan, baseline, failOn: args.failOn });
573
588
  const gate = {
574
589
  ...decision,
575
- 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,
576
593
  revision: gitRevision(args.projectDir),
577
594
  checked: report.checkedFor,
578
595
  notChecked: report.notChecked,
@@ -140,6 +140,7 @@ ${r.conditionsNotReached?.length ? `<div><h2>Conditions not reached</h2><ul>${sc
140
140
  <h1>${esc(observationBadge(r))} <span class="dim">· ${esc(observationSummary(r))}</span></h1>
141
141
  <div class="meta">${esc(label)} · evidence page (observation, not a release decision)</div>
142
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>` : ""}
143
144
  <div class="headline">${esc(r.headline)}</div>
144
145
  ${r.credentialWarning ? `<div class="warning">${esc(r.credentialWarning)}</div>` : ""}
145
146
  ${scopeHtml}
@@ -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`,
@@ -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;
@@ -401,6 +408,12 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
401
408
  ...(action.reason ? { reason: action.reason } : {}),
402
409
  })),
403
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,
404
417
  uiMap: null,
405
418
  comparison: null,
406
419
  checkedFor,
@@ -548,7 +561,7 @@ export const GATE_EXIT = { pass: 0, fail: 1, error: 2, inconclusive: 3 };
548
561
  // finding at or above that severity, and the CLI defaults web targets to `medium` — a 404 in the
549
562
  // nav is the release blocker on a website, and a field-tested green PASS over six deterministic
550
563
  // findings was exactly the dishonest verdict this product refuses to render.
551
- export const GATE_POLICY_VERSION = "3";
564
+ export const GATE_POLICY_VERSION = "4";
552
565
 
553
566
  // Pure gate evaluator: frozen evidence + policy → a GateRun decision. Extracted verbatim from the
554
567
  // former inline logic in ci-report.js so the `[char]` characterization tests keep passing — the
@@ -576,7 +576,12 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
576
576
 
577
577
  const { chromium, devices } = await loadPlaywright();
578
578
  const browser = await chromium.launch(webBrowserLaunchOptions(process.env, { watch }));
579
- 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);
580
585
  await installWebListenerTracking(context);
581
586
  if (watch) await installWebWatchUi(context);
582
587
  const page = await context.newPage();
@@ -1040,7 +1045,7 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
1040
1045
  await auditPage.waitForTimeout(400); // let client-rendered shells paint their copy
1041
1046
  const text = await auditPage.evaluate(() => (document.body && document.body.innerText || "").slice(0, 120000)).catch(() => "");
1042
1047
  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);
1048
+ if (phrase) issue("outbound_unavailable", "medium", `Outbound link returns 200 but shows "${phrase}": ${href.slice(0, 100)}`, meta.screen, href, meta.sourceUrl);
1044
1049
  }
1045
1050
  await auditPage.close().catch(() => {});
1046
1051
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aarwitz/tapp",
3
- "version": "0.17.6",
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",