@aarwitz/tapp 0.17.12 → 0.17.13

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.12",
4
+ "version": "0.17.13",
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.12",
27
+ "@aarwitz/tapp@0.17.13",
28
28
  "mcp"
29
29
  ],
30
30
  "cwd": "${CLAUDE_PROJECT_DIR}"
@@ -1136,8 +1136,24 @@ class ExplorerTests: XCTestCase {
1136
1136
  // --- Login preamble: if credentials are provided and login fields are visible, log in first ---
1137
1137
  if !ranExplicitLogin, !testEmail.isEmpty, !testPassword.isEmpty {
1138
1138
  waitForUIStability(timeout: 2.0) // let app fully settle
1139
- let allTextFields = app.textFields.allElementsBoundByIndex.filter { $0.exists && $0.frame.width > 0 }
1140
- let allSecureFields = app.secureTextFields.allElementsBoundByIndex.filter { $0.exists && $0.frame.width > 0 }
1139
+ var allTextFields = app.textFields.allElementsBoundByIndex.filter { $0.exists && $0.frame.width > 0 }
1140
+ var allSecureFields = app.secureTextFields.allElementsBoundByIndex.filter { $0.exists && $0.frame.width > 0 }
1141
+
1142
+ if allSecureFields.count > 1 {
1143
+ let existingAccountLabels = ["Back to login", "Back to Login", "Already have an account?", "Sign in instead", "Log in instead"]
1144
+ for label in existingAccountLabels {
1145
+ let control = app.buttons[label].exists ? app.buttons[label] : app.staticTexts[label]
1146
+ if control.exists && control.isHittable {
1147
+ control.tap()
1148
+ print("OCQA_STATE:login_preamble_switched_from_signup")
1149
+ Thread.sleep(forTimeInterval: 0.8)
1150
+ waitForUIStability(timeout: 2.0)
1151
+ allTextFields = app.textFields.allElementsBoundByIndex.filter { $0.exists && $0.frame.width > 0 }
1152
+ allSecureFields = app.secureTextFields.allElementsBoundByIndex.filter { $0.exists && $0.frame.width > 0 }
1153
+ break
1154
+ }
1155
+ }
1156
+ }
1141
1157
  print("OCQA_STATE:login_preamble_fields textFields=\(allTextFields.count) secureFields=\(allSecureFields.count)")
1142
1158
 
1143
1159
  let emailField = allTextFields.first { f in
@@ -84,6 +84,9 @@ export function writeHtmlReport(captureDir, { report, label = "", recordingWarni
84
84
  ${esc(f.title)}${f.screen ? ` <span class="dim">— on ${esc(f.screen)}</span>` : ""}${f.url ? ` <span class="dim">— ${esc(f.url)}</span>` : ""}
85
85
  ${f.aiAnalysis ? `<div class="ai">why: ${esc(f.aiAnalysis)}</div>` : ""}
86
86
  ${f.suggestedFix ? `<div class="ai">fix: ${esc(f.suggestedFix)}</div>` : ""}
87
+ ${f.evidence && fs.existsSync(path.join(captureDir, f.evidence))
88
+ ? `<a class="evidence" href="${esc(f.evidence)}"><img loading="lazy" src="${esc(f.evidence)}" alt="The specific control named above, scrolled into view"></a>`
89
+ : ""}
87
90
  </li>`
88
91
  )
89
92
  .join("\n")
@@ -129,6 +132,8 @@ ${r.conditionsNotReached?.length ? `<div><h2>Conditions not reached</h2><ul>${sc
129
132
  table.trace th, table.trace td { text-align: left; padding: 0.25rem 0.7rem 0.25rem 0; border-bottom: 1px solid #eceef1; }
130
133
  table.trace th { color: #57606a; font-weight: 600; }
131
134
  .ai { color: #57606a; font-size: 0.88rem; margin: 0.15rem 0 0 0.2rem; }
135
+ a.evidence { display: block; margin: 0.4rem 0 0 0.2rem; max-width: 360px; }
136
+ a.evidence img { width: 100%; border: 1px solid #d0d7de; border-radius: 6px; }
132
137
  .dim { color: #57606a; }
133
138
  .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.8rem; }
134
139
  figure { margin: 0; } figure img { width: 100%; border: 1px solid #d0d7de; border-radius: 6px; }
@@ -175,7 +175,10 @@ export function buildQaReport(markersFilePath, { platform = "ios", target = null
175
175
  // same screen are two findings, and fixing one while breaking another is a regression.
176
176
  const target = (typeof o.control === "string" && o.control) || (typeof o.target === "string" && o.target) || null;
177
177
  const url = typeof o.url === "string" && o.url.trim() ? o.url.trim() : null;
178
- rawIssues.push({ type: o.type, severity: sev, title: o.title, screen: o.screen || null, target, url, step: o.step ?? null });
178
+ // Element-scoped screenshot proving the specific control the finding names is actually
179
+ // visible — set only for web findings that name one (anchor_missing, placeholder_link).
180
+ const evidence = typeof o.evidence === "string" && o.evidence.trim() ? o.evidence.trim() : null;
181
+ rawIssues.push({ type: o.type, severity: sev, title: o.title, screen: o.screen || null, target, url, step: o.step ?? null, evidence });
179
182
  } catch {
180
183
  /* ignore malformed */
181
184
  }
@@ -25,6 +25,13 @@ const NAV_TIMEOUT_MS = 15_000;
25
25
  const BUTTONS_PER_PAGE = 4;
26
26
  const OUTBOUND_LINK_LIMIT = 10;
27
27
  const WATCH_ACTION_DELAY_MS = 350;
28
+ // A page's visible signature (waitForWebStability's basis) can go quiet before an async-injected
29
+ // widget (a third-party tour scheduler, a chat launcher, ...) has actually finished mounting its
30
+ // target element — nothing about that wait shows a spinner. One snapshot cannot tell "genuinely
31
+ // dead" from "hasn't finished loading"; give a real anchor-missing candidate a second look before
32
+ // treating it as a confirmed defect.
33
+ const ANCHOR_RECHECK_MS = 4_000;
34
+ const ANCHOR_RECHECK_INTERVAL_MS = 400;
28
35
  const ERROR_TEXT_RE = /\b(something went wrong|internal server error|an error occurred|failed to load|unhandled exception)\b/i;
29
36
  const STANDALONE_ERROR_TEXT_RE = /^(something went wrong|internal server error|an error occurred|failed to load|unhandled exception)(?:[.!:]|\s|$)/i;
30
37
 
@@ -82,6 +89,53 @@ export function webPageAppearsBlank({ textLen = 0, controlCount = 0, visualConte
82
89
  return Number(textLen) === 0 && Number(controlCount) === 0 && Number(visualContentCount) === 0;
83
90
  }
84
91
 
92
+ function webAnchorIdMissing(rawId) {
93
+ try {
94
+ const esc = window.CSS && CSS.escape ? CSS.escape(rawId) : rawId;
95
+ return !document.getElementById(rawId) && !document.querySelector(`a[name="${esc}"]`);
96
+ } catch {
97
+ return !document.getElementById(rawId);
98
+ }
99
+ }
100
+
101
+ // A single DOM snapshot cannot distinguish "this anchor target will never exist" from "the
102
+ // script that creates it hasn't run yet". Poll for up to timeoutMs before accepting the miss —
103
+ // cheap when the target is genuinely absent (every poll agrees), and it only spends the extra
104
+ // time on pages that actually have a candidate anchor_missing finding.
105
+ export async function webAnchorStillMissing(page, anchor, { timeoutMs = ANCHOR_RECHECK_MS, intervalMs = ANCHOR_RECHECK_INTERVAL_MS } = {}) {
106
+ const id = decodeURIComponent(String(anchor || "").slice(1));
107
+ if (!id) return true;
108
+ const deadline = Date.now() + Math.max(0, Number(timeoutMs) || 0);
109
+ for (;;) {
110
+ const missing = await page.evaluate(webAnchorIdMissing, id).catch(() => true);
111
+ if (!missing) return false;
112
+ if (Date.now() >= deadline) return true;
113
+ await page.waitForTimeout(intervalMs);
114
+ }
115
+ }
116
+
117
+ // Evidence for a finding that names a specific control is worthless if the screenshot never
118
+ // actually shows that control — a generic per-screen shot proves nothing about where the element
119
+ // is on the page. Scroll it into view and shoot it directly; fall back to a viewport shot (still
120
+ // scrolled to the element) if the element itself can't be screenshotted (zero-size, clipped).
121
+ export async function captureElementEvidence(page, locator, outDir, name) {
122
+ try {
123
+ const target = locator.first();
124
+ if ((await target.count()) === 0) return null;
125
+ await target.scrollIntoViewIfNeeded({ timeout: 2_000 });
126
+ await page.waitForTimeout(120);
127
+ const evidencePath = path.join(outDir, name);
128
+ await target.screenshot({ path: evidencePath }).catch(() => page.screenshot({ path: evidencePath }));
129
+ return path.basename(evidencePath);
130
+ } catch {
131
+ return null;
132
+ }
133
+ }
134
+
135
+ function cssAttrEscape(value) {
136
+ return String(value).replace(/["\\]/g, (c) => "\\" + c);
137
+ }
138
+
85
139
  async function installWebListenerTracking(context) {
86
140
  await context.addInitScript(() => {
87
141
  const key = Symbol.for("tapp.clickListeners");
@@ -612,13 +666,13 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
612
666
 
613
667
  const deadline = Date.now() + timeoutSec * 1000;
614
668
  const issues = []; // emitted immediately; kept for counting only
615
- const issue = (type, severity, title, screen, target, sourceUrl) => {
669
+ const issue = (type, severity, title, screen, target, sourceUrl, evidence) => {
616
670
  issues.push(type);
617
671
  // Findings belong to the page that CARRIED the defect. Async detectors default to the
618
672
  // current page; the post-crawl outbound audit passes the link's source page explicitly so
619
673
  // a bad footer link is never attributed to whatever page happened to be visited last.
620
674
  const pageUrl = sourceUrl || page.url();
621
- emit("ISSUE", { type, severity, title, screen, ...(target ? { target } : {}), ...(pageUrl && pageUrl !== "about:blank" ? { url: pageUrl } : {}) });
675
+ emit("ISSUE", { type, severity, title, screen, ...(target ? { target } : {}), ...(pageUrl && pageUrl !== "about:blank" ? { url: pageUrl } : {}), ...(evidence ? { evidence } : {}) });
622
676
  };
623
677
 
624
678
  // Async defect listeners: attribute to whatever screen is current when they fire.
@@ -805,14 +859,33 @@ export async function exploreWeb({ url, maxActions = 40, timeoutSec = 300, outDi
805
859
  for (const finding of webPlaceholderLinkFindings(info.placeholderLinks)) {
806
860
  if (placeholderLinksSeen.has(finding.target)) continue;
807
861
  placeholderLinksSeen.add(finding.target);
808
- issue(finding.type, finding.severity, finding.title, screen, finding.target);
862
+ // Only a labeled link's evidence can be located with any confidence — an unlabeled
863
+ // link's `target` is a synthetic fingerprint, not text a locator can find on the page.
864
+ const evidence = finding.target.startsWith("unlabeled:")
865
+ ? null
866
+ : await captureElementEvidence(
867
+ page,
868
+ page.locator("a[href]").filter({ hasText: finding.target }),
869
+ outDir,
870
+ `evidence_${screenshotFor.size}_${slug(screen)}_${slug(finding.target)}.png`
871
+ );
872
+ issue(finding.type, finding.severity, finding.title, screen, finding.target, null, evidence);
809
873
  }
810
- // Anchor links pointing at ids that do not exist are deterministic dead navigation.
874
+ // Anchor links pointing at ids that do not exist are deterministic dead navigation — but
875
+ // one snapshot can't tell "genuinely dead" from "the script that creates the target hasn't
876
+ // finished running yet" (an async-mounted widget shows no spinner while it loads).
811
877
  for (const anchor of info.missingAnchors || []) {
812
878
  const anchorTarget = `${key}${anchor}`;
813
879
  if (missingAnchorsSeen.has(anchorTarget)) continue;
814
880
  missingAnchorsSeen.add(anchorTarget);
815
- issue("anchor_missing", "medium", `Anchor link "${anchor}" has no matching element on the page`, screen, anchorTarget);
881
+ if (!(await webAnchorStillMissing(page, anchor))) continue;
882
+ const evidence = await captureElementEvidence(
883
+ page,
884
+ page.locator(`a[href="${cssAttrEscape(anchor)}"]`),
885
+ outDir,
886
+ `evidence_${screenshotFor.size}_${slug(screen)}_${slug(anchor)}.png`
887
+ );
888
+ issue("anchor_missing", "medium", `Anchor link "${anchor}" has no matching element on the page`, screen, anchorTarget, null, evidence);
816
889
  }
817
890
  }
818
891
  return { key, screen, info };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aarwitz/tapp",
3
- "version": "0.17.12",
3
+ "version": "0.17.13",
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",