@aarwitz/tapp 0.17.14 → 0.17.16

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.14",
4
+ "version": "0.17.16",
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.14",
27
+ "@aarwitz/tapp@0.17.16",
28
28
  "mcp"
29
29
  ],
30
30
  "cwd": "${CLAUDE_PROJECT_DIR}"
@@ -231,6 +231,11 @@ class ExplorerTests: XCTestCase {
231
231
  for query in queries {
232
232
  let element = query[identifier]
233
233
  if element.exists && element.isHittable {
234
+ if !waitForEnabled(element) {
235
+ print("OCQA_ACTION:{\"type\":\"tap\",\"identifier\":\"\(identifier)\",\"status\":\"disabled\"}")
236
+ Thread.sleep(forTimeInterval: 0.5)
237
+ return
238
+ }
234
239
  element.tap()
235
240
  print("OCQA_ACTION:{\"type\":\"tap\",\"identifier\":\"\(identifier)\"}")
236
241
  Thread.sleep(forTimeInterval: 0.5)
@@ -240,8 +245,12 @@ class ExplorerTests: XCTestCase {
240
245
  let predicate = NSPredicate(format: "label == %@", identifier)
241
246
  let match = app.descendants(matching: .any).matching(predicate).firstMatch
242
247
  if match.exists && match.isHittable {
243
- match.tap()
244
- print("OCQA_ACTION:{\"type\":\"tap\",\"label\":\"\(identifier)\"}")
248
+ if !waitForEnabled(match) {
249
+ print("OCQA_ACTION:{\"type\":\"tap\",\"label\":\"\(identifier)\",\"status\":\"disabled\"}")
250
+ } else {
251
+ match.tap()
252
+ print("OCQA_ACTION:{\"type\":\"tap\",\"label\":\"\(identifier)\"}")
253
+ }
245
254
  } else {
246
255
  print("OCQA_ACTION:{\"type\":\"tap\",\"identifier\":\"\(identifier)\",\"status\":\"not_found\"}")
247
256
  }
@@ -434,10 +443,27 @@ class ExplorerTests: XCTestCase {
434
443
  /// Returns "ok" if tapped, "not_hittable" if the element exists but couldn't be tapped (disabled,
435
444
  /// or covered by the keyboard even after dismissing it), or "not_found" if nothing matched. The
436
445
  /// distinction tells the caller whether to enter valid input first vs. that the target is absent.
446
+ /// A disabled control is still `exists` and still `isHittable`, so tapping one
447
+ /// succeeds and reports a normal action while nothing happens: the step passes and
448
+ /// the flow carries on against a screen that never advanced. Wait before calling it
449
+ /// disabled — a button guarded by form validation becomes enabled a moment after the
450
+ /// last field is filled, and failing on that would be its own kind of wrong answer.
451
+ private func waitForEnabled(_ element: XCUIElement, timeout: TimeInterval = 3) -> Bool {
452
+ if element.isEnabled { return true }
453
+ let deadline = Date().addingTimeInterval(timeout)
454
+ while Date() < deadline {
455
+ if element.isEnabled { return true }
456
+ Thread.sleep(forTimeInterval: 0.15)
457
+ }
458
+ return false
459
+ }
460
+
437
461
  private func sessionTapById(_ identifier: String) -> String {
438
462
  var existedButNotHittable = false
463
+ var existedButDisabled = false
439
464
  func tryTap(_ el: XCUIElement) -> Bool {
440
465
  guard el.exists else { return false }
466
+ if !waitForEnabled(el) { existedButDisabled = true; return false }
441
467
  if el.isHittable { el.tap(); return true }
442
468
  // Often covered by the keyboard (a submit button below filled fields) — dismiss + retry,
443
469
  // like Playwright auto-scrolls a target into view.
@@ -472,6 +498,9 @@ class ExplorerTests: XCTestCase {
472
498
  // label) — "Password" must reach the secureTextField or a later `type` lands in whatever
473
499
  // field still has keyboard focus.
474
500
  if let field = resolveFieldByHint(identifier), tryTap(field) { return "ok" }
501
+ // "disabled" outranks the others: the control was found and refused to act, which
502
+ // is a different answer from "it is not here" and needs a different fix.
503
+ if existedButDisabled { return "disabled" }
475
504
  return existedButNotHittable ? "not_hittable" : "not_found"
476
505
  }
477
506
 
@@ -827,8 +856,19 @@ class ExplorerTests: XCTestCase {
827
856
 
828
857
  switch action {
829
858
  case "tap":
830
- status = sessionTapById(target) == "ok" ? "pass" : "fail"
831
- if status == "fail" { detail = "could not tap ‘\(target)’" }
859
+ let tapStatus = sessionTapById(target)
860
+ status = tapStatus == "ok" ? "pass" : "fail"
861
+ if status == "fail" {
862
+ // Name which of the three it was. "Disabled" in particular sends the
863
+ // author somewhere completely different from "not found": the control
864
+ // is right there, and something upstream in the flow has not satisfied
865
+ // whatever guards it.
866
+ switch tapStatus {
867
+ case "disabled": detail = "‘\(target)’ is on screen but disabled — an earlier step has not met whatever enables it"
868
+ case "not_hittable": detail = "‘\(target)’ exists but could not be reached, even after dismissing the keyboard and scrolling"
869
+ default: detail = "could not find ‘\(target)’ on this screen"
870
+ }
871
+ }
832
872
  case "type":
833
873
  status = sessionType(value, id: target.isEmpty ? nil : target) ? "pass" : "fail"
834
874
  if status == "fail" { detail = lastTypeFailure.isEmpty ? "no field ‘\(target)’ to type into" : lastTypeFailure }
@@ -4792,23 +4832,46 @@ class ExplorerTests: XCTestCase {
4792
4832
  } while Date() < deadline
4793
4833
  return focused()
4794
4834
  }
4795
- for attempt in 0..<3 {
4835
+ // A field further down a form is usually UNDER the keyboard that the previous
4836
+ // field opened, so every tap below lands on a key instead and three attempts
4837
+ // report "never took keyboard focus" for a field that was simply unreachable.
4838
+ // sessionTapById already dismisses and scrolls for taps; typing needs the same
4839
+ // or no form taller than the keyboard can be filled at all.
4840
+ func keyboardCovers(_ el: XCUIElement) -> Bool {
4841
+ let keyboard = app.keyboards.firstMatch
4842
+ guard keyboard.exists else { return false }
4843
+ let f = el.frame, k = keyboard.frame
4844
+ return f.intersects(k) || f.minY >= k.minY
4845
+ }
4846
+
4847
+ for attempt in 0..<5 {
4796
4848
  switch attempt {
4797
4849
  case 0:
4798
4850
  if element.isHittable { element.tap() } else { element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() }
4799
4851
  case 1:
4800
4852
  element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
4801
- default:
4853
+ case 2:
4802
4854
  // Frame may be stale by a few rows: tap just inside its top edge, which stays
4803
4855
  // within the visible control when content shifted up.
4804
4856
  element.coordinate(withNormalizedOffset: CGVector(dx: 0.35, dy: 0.2)).tap()
4857
+ case 3:
4858
+ // Get the keyboard out of the way and try where the field now sits.
4859
+ dismissKeyboardIfPresent()
4860
+ waitForAnimationsToSettle()
4861
+ if element.isHittable { element.tap() } else { element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() }
4862
+ default:
4863
+ // Still out of reach: bring it up the screen, then tap.
4864
+ dismissKeyboardIfPresent()
4865
+ app.swipeUp()
4866
+ Thread.sleep(forTimeInterval: 0.25)
4867
+ if element.isHittable { element.tap() } else { element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() }
4805
4868
  }
4806
4869
  if settleUntilFocused() {
4807
4870
  if attempt > 0 { print("OCQA_STATE:type_focus_recovered attempt=\(attempt + 1)") }
4808
4871
  return true
4809
4872
  }
4810
4873
  let f = element.frame
4811
- print("OCQA_STATE:type_focus_miss attempt=\(attempt + 1) frame=\(Int(f.minX)),\(Int(f.minY)),\(Int(f.width))x\(Int(f.height)) keyboard=\(app.keyboards.count) focusedElsewhere=\(focusedField().map { fieldDesc($0) } ?? "none")")
4874
+ print("OCQA_STATE:type_focus_miss attempt=\(attempt + 1) frame=\(Int(f.minX)),\(Int(f.minY)),\(Int(f.width))x\(Int(f.height)) keyboard=\(app.keyboards.count) covered=\(keyboardCovers(element)) focusedElsewhere=\(focusedField().map { fieldDesc($0) } ?? "none")")
4812
4875
  }
4813
4876
  return false
4814
4877
  }
@@ -4821,7 +4884,7 @@ class ExplorerTests: XCTestCase {
4821
4884
  lastTypeFailure = ""
4822
4885
  guard focusForTyping(element) else {
4823
4886
  let f = element.frame
4824
- lastTypeFailure = "‘\(fieldDesc(element))’ was found (frame \(Int(f.minX)),\(Int(f.minY)) \(Int(f.width))×\(Int(f.height))) but never took keyboard focus after 3 taps"
4887
+ lastTypeFailure = "‘\(fieldDesc(element))’ was found (frame \(Int(f.minX)),\(Int(f.minY)) \(Int(f.width))×\(Int(f.height))) but never took keyboard focus, including after dismissing the keyboard and scrolling it into view"
4825
4888
  + (app.keyboards.count == 0 ? " — no keyboard appeared" : "")
4826
4889
  + (focusedField().map { " — focus is on ‘\(fieldDesc($0))’" } ?? "")
4827
4890
  print("OCQA_STATE:type_focus_failed detail=\(escapeJSON(lastTypeFailure))")
@@ -471,10 +471,26 @@ export async function prepareAndroidInteractiveTarget({ projectDir = process.cwd
471
471
  return { ...built, appId, selectedTarget:selected };
472
472
  }
473
473
 
474
- export async function installAppOnBootedSim(appPath, { cleanInstall = true } = {}) {
474
+ // Said before a build overwrites whatever is on the simulator. The clean install
475
+ // below wipes the old app's data with it, so a build someone deliberately
476
+ // configured — pointed at a local server, a staging key, a test account —
477
+ // disappears without being mentioned. Naming it is the difference between "my run
478
+ // is slow" and "my run is now testing something else entirely". Passing a bundle
479
+ // id instead of a project path uses what is installed and never builds.
480
+ export function replacingInstalledBuildNotice(bundleId) {
481
+ const id = String(bundleId || "").trim();
482
+ if (!id) return "";
483
+ return `Replacing the installed build of ${id} (its data is wiped too). To use what is already installed instead of building, pass the bundle id: ${id}`;
484
+ }
485
+
486
+ export async function installAppOnBootedSim(appPath, { cleanInstall = true, onStatus = () => {} } = {}) {
475
487
  const bid = await runCommand("/usr/libexec/PlistBuddy", ["-c", "Print CFBundleIdentifier", path.join(appPath, "Info.plist")], { timeoutMs: 30_000 });
476
488
  const bundleId = (bid.stdout || "").trim();
477
489
  if (!bundleId) return { error: `Could not read CFBundleIdentifier from ${appPath}/Info.plist — is this a simulator .app build?` };
490
+ if (cleanInstall) {
491
+ const existing = await runCommand("xcrun", ["simctl", "get_app_container", "booted", bundleId, "app"], { timeoutMs: 15_000 });
492
+ if (existing.code === 0 && (existing.stdout || "").trim()) onStatus(replacingInstalledBuildNotice(bundleId));
493
+ }
478
494
  if (cleanInstall) {
479
495
  // Clean install: stale keychain items from a previous install leave apps half-signed-in
480
496
  // (Firebase Auth's "error accessing the keychain") — uninstall first for a fresh state.
@@ -496,7 +512,7 @@ export async function resolveAppTarget(input, { cwd = process.cwd(), onStatus =
496
512
  const sim = await ensureBootedSim({ autoBoot: true });
497
513
  if (sim.error) return { error: sim.error };
498
514
  onStatus(`Installing ${path.basename(appPath)}…`);
499
- const inst = await installAppOnBootedSim(appPath);
515
+ const inst = await installAppOnBootedSim(appPath, { onStatus });
500
516
  if (inst.error) return inst;
501
517
  return {
502
518
  bundleId: inst.bundleId,
@@ -522,7 +538,7 @@ export async function resolveAppTarget(input, { cwd = process.cwd(), onStatus =
522
538
  const built = await buildAppForSim({ container, scheme, configuration });
523
539
  if (built.error) return built;
524
540
  onStatus(`Built ${path.basename(built.appPath)} (scheme ${built.scheme}) — installing…`);
525
- const inst = await installAppOnBootedSim(built.appPath);
541
+ const inst = await installAppOnBootedSim(built.appPath, { onStatus });
526
542
  if (inst.error) return inst;
527
543
  return {
528
544
  bundleId: inst.bundleId,
@@ -139,6 +139,11 @@ function cssAttrEscape(value) {
139
139
  async function installWebListenerTracking(context) {
140
140
  await context.addInitScript(() => {
141
141
  const key = Symbol.for("tapp.clickListeners");
142
+ // Delegated handlers live on document/window, not on the control, so an
143
+ // element-only record says "nothing is listening" for a page where every
144
+ // button works. Keep their source text: it is the only way to tell which
145
+ // controls such a handler actually claims.
146
+ const delegatedKey = Symbol.for("tapp.delegatedClickHandlers");
142
147
  const add = EventTarget.prototype.addEventListener;
143
148
  const remove = EventTarget.prototype.removeEventListener;
144
149
  EventTarget.prototype.addEventListener = function tappTrackedAdd(type, listener, options) {
@@ -146,6 +151,11 @@ async function installWebListenerTracking(context) {
146
151
  if (!this[key]) Object.defineProperty(this, key, { value: new Set(), configurable: true });
147
152
  this[key].add(listener);
148
153
  }
154
+ if (type === "click" && listener && (this === document || this === window)) {
155
+ const target = this === window ? window : document;
156
+ if (!target[delegatedKey]) Object.defineProperty(target, delegatedKey, { value: new Set(), configurable: true });
157
+ try { target[delegatedKey].add(String(listener).slice(0, 4000)); } catch { /* exotic handler */ }
158
+ }
149
159
  return add.call(this, type, listener, options);
150
160
  };
151
161
  EventTarget.prototype.removeEventListener = function tappTrackedRemove(type, listener, options) {
@@ -1227,6 +1237,56 @@ export async function auditWebPage({ url, timeoutMs = NAV_TIMEOUT_MS, device = "
1227
1237
  };
1228
1238
  const name = (el) => (el.getAttribute("aria-label") || el.textContent || el.getAttribute("value") || "").replace(/\s+/g, " ").trim().slice(0, 80);
1229
1239
  const selectorFor = (el) => el.id ? `#${el.id}` : el.getAttribute("data-testid") ? `[data-testid="${el.getAttribute("data-testid")}"]` : el.tagName.toLowerCase();
1240
+ // Some controls are wired in CSS, not JavaScript: a menu that opens while
1241
+ // its trigger is hovered or focused has no listener to find, and calling
1242
+ // it dead is wrong. Collect the selectors that some rule reacts to — the
1243
+ // trigger side of any `X:hover Y` / `X:focus-within Y` rule whose
1244
+ // declarations change whether Y can be seen. A rule that only restyles
1245
+ // the trigger itself (`button:hover { background: … }`) is not behaviour
1246
+ // and is ignored, which is why the descendant part must be present.
1247
+ const REVEALS = /(^|;)\s*(display|visibility|opacity|height|max-height|transform|pointer-events|clip-path)\s*:/i;
1248
+ const revealTriggers = [];
1249
+ const collectTriggers = (rules) => {
1250
+ for (const rule of rules || []) {
1251
+ // A plain style rule also exposes .cssRules now that CSS nesting is
1252
+ // supported — an empty list, which is truthy. Recursing on that and
1253
+ // skipping the rule would walk straight past every selector there is.
1254
+ if (rule.cssRules && rule.cssRules.length) collectTriggers(rule.cssRules);
1255
+ const selector = rule.selectorText;
1256
+ if (!selector || !REVEALS.test(rule.style?.cssText || "")) continue;
1257
+ for (const part of selector.split(",")) {
1258
+ const match = part.match(/^(.*?):(?:hover|focus-within|focus-visible|focus)\b(.+)$/);
1259
+ if (!match) continue;
1260
+ const trigger = match[1].trim();
1261
+ if (trigger && match[2].trim()) revealTriggers.push(trigger);
1262
+ }
1263
+ }
1264
+ };
1265
+ for (const sheet of document.styleSheets) {
1266
+ try { collectTriggers(sheet.cssRules); } catch { /* cross-origin sheet */ }
1267
+ }
1268
+ const opensSomethingOnHoverOrFocus = (el) => revealTriggers.some((trigger) => {
1269
+ try { return el.matches(trigger) || Boolean(el.closest(trigger)); } catch { return false; }
1270
+ });
1271
+
1272
+ // What a delegated handler on document/window claims. The dominant idiom is
1273
+ // `e.target.closest(SELECTOR)` / `.matches(SELECTOR)`, so pull those
1274
+ // selectors out and test controls against them. A handler we cannot read
1275
+ // this way (an outside-click closer, say) claims nothing and is ignored,
1276
+ // rather than excusing every unwired control on the page.
1277
+ const delegatedSelectors = [];
1278
+ for (const source of [
1279
+ ...(document[Symbol.for("tapp.delegatedClickHandlers")] || []),
1280
+ ...(window[Symbol.for("tapp.delegatedClickHandlers")] || []),
1281
+ ]) {
1282
+ for (const m of String(source).matchAll(/\.(?:closest|matches)\(\s*["'`]([^"'`]+)["'`]/g)) {
1283
+ delegatedSelectors.push(m[1]);
1284
+ }
1285
+ }
1286
+ const claimedByDelegate = (el) => delegatedSelectors.some((selector) => {
1287
+ try { return el.matches(selector) || Boolean(el.closest(selector)); } catch { return false; }
1288
+ });
1289
+
1230
1290
  const dead = [];
1231
1291
  for (const el of document.querySelectorAll("a[href], button, [role=button], [aria-controls]")) {
1232
1292
  if (!visible(el)) continue;
@@ -1255,6 +1315,8 @@ export async function auditWebPage({ url, timeoutMs = NAV_TIMEOUT_MS, device = "
1255
1315
  }
1256
1316
  if (!wired && el.matches("button[type=submit], input[type=submit]") && el.closest("form")) wired = true;
1257
1317
  if (!wired && el.closest("label")) wired = true; // a label drives its own control
1318
+ if (!wired && opensSomethingOnHoverOrFocus(el)) wired = true;
1319
+ if (!wired && claimedByDelegate(el)) wired = true;
1258
1320
  if (!wired) dead.push({ kind: "unwired_control", label, selector: selectorFor(el) });
1259
1321
  }
1260
1322
  return { dead, controlCount: document.querySelectorAll("a[href], button, [role=button]").length, title: document.title };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aarwitz/tapp",
3
- "version": "0.17.14",
3
+ "version": "0.17.16",
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",