@aarwitz/tapp 0.17.10 → 0.17.11

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.10",
4
+ "version": "0.17.11",
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.10",
27
+ "@aarwitz/tapp@0.17.11",
28
28
  "mcp"
29
29
  ],
30
30
  "cwd": "${CLAUDE_PROJECT_DIR}"
@@ -387,7 +387,8 @@ class ExplorerTests: XCTestCase {
387
387
  app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: x, dy: y)).tap()
388
388
  } else { status = "bad_args" }
389
389
  case "type":
390
- status = sessionType(cmd["text"] as? String ?? "", id: cmd["id"] as? String) ? "ok" : "not_found"
390
+ status = sessionType(cmd["text"] as? String ?? "", id: cmd["id"] as? String) ? "ok" : (lastTypeFailure.isEmpty ? "not_found" : "focus_failed")
391
+ if status == "focus_failed" { loginDetail = lastTypeFailure }
391
392
  case "swipe":
392
393
  switch (cmd["direction"] as? String ?? "up") {
393
394
  case "down": app.swipeDown(); case "left": app.swipeLeft(); case "right": app.swipeRight(); default: app.swipeUp()
@@ -578,10 +579,12 @@ class ExplorerTests: XCTestCase {
578
579
  // credential the server rejects with no visible cause.
579
580
  if let id = id, !id.isEmpty {
580
581
  for field in [app.textFields[id], app.secureTextFields[id], app.textViews[id]] where field.exists {
581
- replaceText(on: field, with: text); lastTypedInto = fieldDesc(field); return true
582
+ guard replaceText(on: field, with: text) else { return false }
583
+ lastTypedInto = fieldDesc(field); return true
582
584
  }
583
585
  if let field = resolveFieldByHint(id) {
584
- replaceText(on: field, with: text); lastTypedInto = fieldDesc(field); return true
586
+ guard replaceText(on: field, with: text) else { return false }
587
+ lastTypedInto = fieldDesc(field); return true
585
588
  }
586
589
  return false
587
590
  }
@@ -590,8 +593,7 @@ class ExplorerTests: XCTestCase {
590
593
  if app.keyboards.firstMatch.exists {
591
594
  if let focused = focusedField() {
592
595
  lastTypedInto = fieldDesc(focused)
593
- replaceText(on: focused, with: text)
594
- return true
596
+ return replaceText(on: focused, with: text)
595
597
  }
596
598
  app.typeText(text)
597
599
  lastTypedInto = "focused field"
@@ -599,7 +601,8 @@ class ExplorerTests: XCTestCase {
599
601
  }
600
602
  // Nothing focused and no usable id — last resort: the first text field or text view.
601
603
  for first in [app.textFields.firstMatch, app.textViews.firstMatch] where first.exists {
602
- replaceText(on: first, with: text); lastTypedInto = fieldDesc(first); return true
604
+ guard replaceText(on: first, with: text) else { return false }
605
+ lastTypedInto = fieldDesc(first); return true
603
606
  }
604
607
  return false
605
608
  }
@@ -809,7 +812,7 @@ class ExplorerTests: XCTestCase {
809
812
  if status == "fail" { detail = "could not tap ‘\(target)’" }
810
813
  case "type":
811
814
  status = sessionType(value, id: target.isEmpty ? nil : target) ? "pass" : "fail"
812
- if status == "fail" { detail = "no field ‘\(target)’ to type into" }
815
+ if status == "fail" { detail = lastTypeFailure.isEmpty ? "no field ‘\(target)’ to type into" : lastTypeFailure }
813
816
  case "login":
814
817
  let email = subst((step["email"] as? String) ?? "$TEST_EMAIL")
815
818
  let password = subst((step["password"] as? String) ?? "$TEST_PASSWORD")
@@ -4718,9 +4721,62 @@ class ExplorerTests: XCTestCase {
4718
4721
  print("OCQA_PROGRESS:{\"action\":\(action),\"max\":\(maxActions),\"states\":\(states)}")
4719
4722
  }
4720
4723
 
4721
- // Replace existing field contents to avoid repeatedly appending test text.
4722
- private func replaceText(on element: XCUIElement, with text: String) {
4723
- element.tap()
4724
+ /// Why the last replaceText could not type — surfaced as the step's detail so a flow says
4725
+ /// "the field never took keyboard focus" instead of dying on an XCTest assertion.
4726
+ private var lastTypeFailure = ""
4727
+
4728
+ /// Give a field keyboard focus and PROVE it before typing. `element.tap()` returning is not
4729
+ /// proof: SwiftUI fields inside a ScrollView regularly report an accessibility frame that
4730
+ /// lags the visible layout (content above them resolved and shifted), so the first tap lands
4731
+ /// on empty space, `typeText` then fails XCTest's "Neither element nor any descendant has
4732
+ /// keyboard focus" assertion and the whole run dies. Strategy: tap, poll for focus, and on a
4733
+ /// miss re-resolve the element (fresh snapshot) and try a coordinate tap at its centre, then
4734
+ /// a tap nudged to the top of the frame. Three attempts, ~4 s worst case.
4735
+ private func focusForTyping(_ element: XCUIElement) -> Bool {
4736
+ func focused() -> Bool { (element.value(forKey: "hasKeyboardFocus") as? Bool) ?? false }
4737
+ func settleUntilFocused() -> Bool {
4738
+ let deadline = Date().addingTimeInterval(1.2)
4739
+ repeat {
4740
+ if focused() { return true }
4741
+ Thread.sleep(forTimeInterval: 0.15)
4742
+ } while Date() < deadline
4743
+ return focused()
4744
+ }
4745
+ for attempt in 0..<3 {
4746
+ switch attempt {
4747
+ case 0:
4748
+ if element.isHittable { element.tap() } else { element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() }
4749
+ case 1:
4750
+ element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
4751
+ default:
4752
+ // Frame may be stale by a few rows: tap just inside its top edge, which stays
4753
+ // within the visible control when content shifted up.
4754
+ element.coordinate(withNormalizedOffset: CGVector(dx: 0.35, dy: 0.2)).tap()
4755
+ }
4756
+ if settleUntilFocused() {
4757
+ if attempt > 0 { print("OCQA_STATE:type_focus_recovered attempt=\(attempt + 1)") }
4758
+ return true
4759
+ }
4760
+ let f = element.frame
4761
+ 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")")
4762
+ }
4763
+ return false
4764
+ }
4765
+
4766
+ // Replace existing field contents to avoid repeatedly appending test text. Returns false
4767
+ // (and records `lastTypeFailure`) instead of letting XCTest abort the run when the field
4768
+ // cannot be focused.
4769
+ @discardableResult
4770
+ private func replaceText(on element: XCUIElement, with text: String) -> Bool {
4771
+ lastTypeFailure = ""
4772
+ guard focusForTyping(element) else {
4773
+ let f = element.frame
4774
+ 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"
4775
+ + (app.keyboards.count == 0 ? " — no keyboard appeared" : "")
4776
+ + (focusedField().map { " — focus is on ‘\(fieldDesc($0))’" } ?? "")
4777
+ print("OCQA_STATE:type_focus_failed detail=\(escapeJSON(lastTypeFailure))")
4778
+ return false
4779
+ }
4724
4780
 
4725
4781
  if let existing = element.value as? String,
4726
4782
  !existing.isEmpty,
@@ -4730,5 +4786,6 @@ class ExplorerTests: XCTestCase {
4730
4786
  }
4731
4787
 
4732
4788
  element.typeText(text)
4789
+ return true
4733
4790
  }
4734
4791
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aarwitz/tapp",
3
- "version": "0.17.10",
3
+ "version": "0.17.11",
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",
@@ -89,7 +89,7 @@
89
89
  "mobile"
90
90
  ],
91
91
  "scripts": {
92
- "test": "node --test tests/report.test.js tests/regression.test.js tests/engine.test.js tests/project-config.test.js tests/application-model.test.js tests/ui-map.test.js tests/focused-navigation.test.js tests/task-runtime.test.js tests/release-contract.test.js tests/pr-selection.test.js tests/flow-runtime.test.js tests/web-explorer.test.js tests/web-session.test.js tests/web-link-audit.test.js tests/web-flow.test.js tests/scenario-runtime.test.js tests/android-driver.test.js tests/android-explorer.test.js tests/android-flow.test.js tests/android-primitives-protocol.test.js tests/managed-web.test.js tests/product-operations.test.js tests/browser-product.test.js tests/browser-onboarding.test.js tests/managed-operation.test.js tests/cloud-runner.test.js tests/ci-setup.test.js tests/ci-install.test.js tests/cli.test.js tests/mcp-workspace.test.js tests/action.test.js tests/package-surface.test.js tests/agent-surface.test.js tests/presentation-contract.test.js tests/landing-brand.test.js tests/ci-gate.test.js tests/ci-report.test.js tests/desktop-protocol.test.js tests/ios-flow-protocol.test.js vscode-extension/test/bridge.test.js",
92
+ "test": "node --test tests/report.test.js tests/regression.test.js tests/engine.test.js tests/project-config.test.js tests/application-model.test.js tests/ui-map.test.js tests/focused-navigation.test.js tests/task-runtime.test.js tests/release-contract.test.js tests/pr-selection.test.js tests/flow-runtime.test.js tests/web-explorer.test.js tests/web-session.test.js tests/web-link-audit.test.js tests/web-flow.test.js tests/scenario-runtime.test.js tests/android-driver.test.js tests/android-explorer.test.js tests/android-flow.test.js tests/android-primitives-protocol.test.js tests/managed-web.test.js tests/product-operations.test.js tests/browser-product.test.js tests/browser-onboarding.test.js tests/managed-operation.test.js tests/cloud-runner.test.js tests/ci-setup.test.js tests/ci-install.test.js tests/cli.test.js tests/mcp-workspace.test.js tests/action.test.js tests/package-surface.test.js tests/agent-surface.test.js tests/presentation-contract.test.js tests/landing-brand.test.js tests/ci-gate.test.js tests/ci-report.test.js tests/desktop-protocol.test.js tests/ios-flow-protocol.test.js tests/feedback-triage-0919.test.js vscode-extension/test/bridge.test.js",
93
93
  "test:browser-journey": "node --test tests/browser-journey.test.js",
94
94
  "test:browser-native": "TAPP_RUN_NATIVE_BROWSER=1 node --test tests/browser-native-journey.test.js"
95
95
  }
@@ -92,6 +92,7 @@ def report(path, as_json=False):
92
92
  result = None
93
93
  name = "flow"
94
94
  kind = "flow"
95
+ declared_total = None
95
96
  for line in log.splitlines():
96
97
  line = line.strip()
97
98
  if line.startswith("OCQA_FLOW_STEP:"):
@@ -106,6 +107,9 @@ def report(path, as_json=False):
106
107
  km = re.search(r"\bkind=([^ ]+)", line)
107
108
  if km:
108
109
  kind = km.group(1)
110
+ tm = re.search(r"\btotal=(\d+)", line)
111
+ if tm:
112
+ declared_total = int(tm.group(1))
109
113
  elif line.startswith("OCQA_FLOW_RESULT:{"):
110
114
  try:
111
115
  result = json.loads(line[len("OCQA_FLOW_RESULT:"):])
@@ -129,6 +133,16 @@ def report(path, as_json=False):
129
133
  steps.append({"index": 1, "action": "harness", "target": "", "status": "fail",
130
134
  "detail": abort_reason or "the harness exited before the first step; see flow.log"})
131
135
  failed = max(failed, 1)
136
+ elif result is None and steps:
137
+ # The harness died MID-run (an XCTest assertion, the test time budget, a crash): there is
138
+ # no final OCQA_FLOW_RESULT line. This used to be reported as "PASSED 16/16" because
139
+ # `total` silently became the number of steps that happened to run before the death.
140
+ abort_reason = harness_abort_reason(log)
141
+ total = declared_total or total
142
+ steps.append({"index": len(steps) + 1, "action": "harness", "target": "", "status": "fail",
143
+ "detail": (abort_reason or "the harness exited") + f" — run ended after step {executed} of {total}"})
144
+ failed += 1
145
+ passed = False
132
146
 
133
147
  if as_json:
134
148
  print(json.dumps({"name": name, "kind": kind, "passed": passed, "total": total, "executed": executed, "failed": failed,