@aarwitz/tapp 0.17.16 → 0.17.18

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.16",
4
+ "version": "0.17.18",
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.16",
27
+ "@aarwitz/tapp@0.17.18",
28
28
  "mcp"
29
29
  ],
30
30
  "cwd": "${CLAUDE_PROJECT_DIR}"
@@ -377,6 +377,7 @@ class ExplorerTests: XCTestCase {
377
377
  let action = (cmd["action"] as? String ?? "").lowercased()
378
378
  var status = "ok"
379
379
  var loginDetail = ""
380
+ noteNavigationTitle() // navigation history, so `back` can recognise its control
380
381
 
381
382
  switch action {
382
383
  case "login":
@@ -391,7 +392,32 @@ class ExplorerTests: XCTestCase {
391
392
  }
392
393
  case "tap":
393
394
  if let id = cmd["id"] as? String, !id.isEmpty {
395
+ let beforeElements = readUITree(app)
396
+ let beforeHash = computeHash(beforeElements)
397
+ let beforeTitle = detectTitle(beforeElements) ?? "Unknown"
394
398
  status = sessionTapById(id)
399
+ if status == "ok" {
400
+ waitForUIStability(timeout: 1.5)
401
+ let afterElements = readUITree(app)
402
+ // Nothing moved and the target is still sitting there: the tap landed on
403
+ // something that is not the control the caller meant — classically a system
404
+ // sheet ("Save Password?") whose buttons belong to SpringBoard, not the app
405
+ // (field issue #6: three "ok" taps while the sheet stayed up).
406
+ let unchanged = computeHash(afterElements) == beforeHash
407
+ && (detectTitle(afterElements) ?? "Unknown") == beforeTitle
408
+ if unchanged && elementPresent(id) {
409
+ if tapSystemSheetButton(id) {
410
+ waitForUIStability(timeout: 1.5)
411
+ let recovered = readUITree(app)
412
+ status = computeHash(recovered) == beforeHash ? "no_effect" : "ok"
413
+ } else {
414
+ status = "no_effect"
415
+ }
416
+ if status == "no_effect" {
417
+ loginDetail = "‘\(id)’ is still on screen and nothing changed — the tap did not reach the control you meant; if this is a system sheet, tap by {x, y}"
418
+ }
419
+ }
420
+ }
395
421
  } else if let x = cmd["x"] as? Double, let y = cmd["y"] as? Double {
396
422
  app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: x, dy: y)).tap()
397
423
  } else { status = "bad_args" }
@@ -403,7 +429,8 @@ class ExplorerTests: XCTestCase {
403
429
  case "down": app.swipeDown(); case "left": app.swipeLeft(); case "right": app.swipeRight(); default: app.swipeUp()
404
430
  }
405
431
  case "back":
406
- sessionBack()
432
+ status = sessionBack()
433
+ if status == "no_effect" { loginDetail = "no back control on this screen, or tapping it did not change the screen — this is a root screen, or its Back belongs to a different navigation stack (try tap {id: \"Back\"}, or `swipe` for a gesture)" }
407
434
  case "wait":
408
435
  let target = (cmd["id"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? (cmd["text"] as? String ?? "")
409
436
  let waitMs = (cmd["timeoutMs"] as? Int) ?? 5000
@@ -458,6 +485,8 @@ class ExplorerTests: XCTestCase {
458
485
  return false
459
486
  }
460
487
 
488
+ private var backTitleHistory: [String] = []
489
+
461
490
  private func sessionTapById(_ identifier: String) -> String {
462
491
  var existedButNotHittable = false
463
492
  var existedButDisabled = false
@@ -750,14 +779,165 @@ class ExplorerTests: XCTestCase {
750
779
  return ("still_on_login", errTexts.prefix(2).joined(separator: " | "))
751
780
  }
752
781
 
753
- private func sessionBack() {
754
- let backButton = app.navigationBars.buttons.firstMatch
755
- if backButton.exists && backButton.isHittable {
756
- backButton.tap()
757
- } else {
758
- app.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0.5))
759
- .press(forDuration: 0.05, thenDragTo: app.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5)))
782
+ /// Go back and PROVE it: a caller that believes a no-op navigated keeps driving the wrong
783
+ /// screen (field issue #10 — four "ok" backs in a row while the title never changed, then
784
+ /// every following tap failed `not_hittable` because the pushed screen was still on top).
785
+ /// Returns "ok" only when the screen actually changed; "no_effect" otherwise.
786
+ /// A surface the SYSTEM owns even though it renders inside the app process: the share sheet
787
+ /// (`UIActivityContentView`) and the app extensions it hosts, the photo picker's unavailable
788
+ /// view, and other remote-view hosts. Their content belongs to Apple or a third-party
789
+ /// extension, so an empty or unreadable accessibility tree there says nothing about the app
790
+ /// under test — scoring it produced this app's only "high" finding (field issue #4).
791
+ private func systemSurfaceName(_ elements: [SimpleElement], title: String) -> String? {
792
+ let markers = ["UIActivityContentView", "ActivityListView", "PUPickerUnavailableView",
793
+ "RemoteViewBridge", "_UIRemoteView", "UIDocumentPickerViewController",
794
+ "SFSafariView", "UIActivityGroupView"]
795
+ for element in elements {
796
+ let signature = element.type + " " + element.identifier + " " + element.label
797
+ if let hit = markers.first(where: { signature.contains($0) }) { return hit }
798
+ }
799
+ // The share sheet hands off to an extension whose whole UI is out of process; the title
800
+ // is then the extension's name over a tree the app cannot see.
801
+ if title == "UIActivityContentView" { return "UIActivityContentView" }
802
+ return nil
803
+ }
804
+
805
+ /// iOS labels a pushed screen's back button with the PARENT screen's title ("Todo List"),
806
+ /// not "Back", so a back control cannot be recognised by its own label alone. Remember the
807
+ /// titles we have actually been on; a leading navigation-bar button named after one of them
808
+ /// is a genuine back control, while a custom leading action (Share, Delete) is not.
809
+ private func noteNavigationTitle() {
810
+ let title = app.navigationBars.firstMatch.identifier
811
+ guard !title.isEmpty, backTitleHistory.last != title else { return }
812
+ backTitleHistory.append(title)
813
+ if backTitleHistory.count > 20 { backTitleHistory.removeFirst() }
814
+ }
815
+
816
+ /// Leave a system-owned surface. A share sheet is a presented sheet, not a pushed screen:
817
+ /// `back` does nothing and a mid-screen swipe scrolls its content instead of dismissing it.
818
+ /// Try its real affordances in order, and say honestly whether we actually got out.
819
+ private func dismissSystemSurface(currentTitle: String) -> Bool {
820
+ let stillThere = { () -> Bool in
821
+ let elements = self.readUITree(self.app)
822
+ return self.systemSurfaceName(elements, title: self.detectTitle(elements) ?? "Unknown") != nil
823
+ }
824
+ // 1. An explicit dismissal control, in the app and in SpringBoard (extensions live there).
825
+ for label in ["Close", "Cancel", "Done", "Dismiss"] {
826
+ for candidate in [app.buttons[label], XCUIApplication(bundleIdentifier: "com.apple.springboard").buttons[label]] {
827
+ if candidate.exists && candidate.isHittable {
828
+ candidate.tap()
829
+ waitForUIStability(timeout: 1.5)
830
+ if !stillThere() { return true }
831
+ }
832
+ }
760
833
  }
834
+ // 2. Drag the sheet down by its top edge (the grabber), not from the middle.
835
+ app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.35))
836
+ .press(forDuration: 0.1, thenDragTo: app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.98)))
837
+ waitForUIStability(timeout: 1.5)
838
+ if !stillThere() { return true }
839
+ // 3. Tap the dimmed backdrop above the sheet.
840
+ app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.06)).tap()
841
+ waitForUIStability(timeout: 1.5)
842
+ if !stillThere() { return true }
843
+ // 4. Last resort: relaunch. The surface belongs to iOS; the app is still healthy.
844
+ app.terminate()
845
+ Thread.sleep(forTimeInterval: 0.8)
846
+ app.launch()
847
+ _ = app.wait(for: .runningForeground, timeout: 10)
848
+ waitForUIStability(timeout: 2.0)
849
+ return !stillThere()
850
+ }
851
+
852
+ /// iOS renders "Save Password?", location prompts and similar sheets in SpringBoard, not in
853
+ /// the app under test, so an app-scoped query silently matches something else. Tap the real
854
+ /// button when a system sheet is up (field issue #6).
855
+ private func tapSystemSheetButton(_ identifier: String) -> Bool {
856
+ let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
857
+ for query in [springboard.buttons, springboard.staticTexts] {
858
+ let element = query[identifier]
859
+ if element.exists && element.isHittable { element.tap(); return true }
860
+ }
861
+ let matches = springboard.descendants(matching: .any)
862
+ .matching(NSPredicate(format: "label ==[c] %@", identifier)).allElementsBoundByIndex
863
+ if let match = matches.first(where: { $0.exists && $0.isHittable }) { match.tap(); return true }
864
+ return false
865
+ }
866
+
867
+ private func sessionBack() -> String {
868
+ let beforeElements = readUITree(app)
869
+ let beforeTitle = detectTitle(beforeElements) ?? "Unknown"
870
+ let beforeHash = computeHash(beforeElements)
871
+
872
+ // ONLY a real back affordance counts. Tapping `navigationBars.buttons.firstMatch` meant
873
+ // that on a screen with no back button, `back` fired whatever toolbar action happened to
874
+ // come first — observed live: it tapped Share, opened the share sheet, saw the screen
875
+ // change and reported "ok". A back that performs an unrelated (possibly destructive)
876
+ // action is worse than one that does nothing.
877
+ let isBackAffordance = NSPredicate(format:
878
+ "label ==[c] 'Back' OR label BEGINSWITH[c] 'Back to' OR identifier ==[c] 'BackButton' OR identifier CONTAINS[c] 'back-button'")
879
+ var backControls = app.buttons.matching(isBackAffordance).allElementsBoundByIndex
880
+ if backControls.first(where: { $0.exists && $0.isHittable }) == nil {
881
+ // The standard iOS back button: the navigation bar's leading control, labelled with
882
+ // a screen we have already been on. A leading button named anything else (Share,
883
+ // Delete) is an app action, not a back, and must never be tapped by `back`.
884
+ // The navigation bar's leading control. Recognising it cannot depend on the nav-bar
885
+ // identifier being readable (that varies by iOS version), so accept three shapes of
886
+ // the standard back button — named after a screen we have been on, named after the
887
+ // current screen (a detail view that inherits its parent's title), or a bare chevron
888
+ // with no label — while never accepting a named app action.
889
+ let appActionLabels: Set<String> = ["share", "delete", "remove", "add", "edit", "save",
890
+ "done", "cancel", "close", "more", "sign out",
891
+ "log out", "settings", "search", "filter", "menu"]
892
+ let leading = app.navigationBars.buttons.allElementsBoundByIndex.first
893
+ if let leading, leading.exists, leading.isHittable,
894
+ !appActionLabels.contains(leading.label.lowercased()),
895
+ backTitleHistory.contains(leading.label) || leading.label == beforeTitle || leading.label.isEmpty {
896
+ backControls = [leading]
897
+ }
898
+ }
899
+ guard let back = backControls.first(where: { $0.exists && $0.isHittable }) else {
900
+ // No back affordance: say so, and do nothing. The edge-swipe fallback used to run
901
+ // here and it was not a back — on a screen whose content scrolls sideways the drag
902
+ // landed in the content and navigated FORWARD ("Dashboard" → "What's New"), which
903
+ // `back` then reported as a successful pop. A caller who wants the gesture can ask
904
+ // for it explicitly with `swipe`.
905
+ print("OCQA_STATE:back_check before=\(beforeTitle) control=none")
906
+ return "no_effect"
907
+ }
908
+ let backLabel = back.label
909
+ // The CONTROLS on screen are the evidence a pop happened. Re-reading the tapped element
910
+ // does not work: XCUIElement is a lazy query, so `back.exists` after the tap re-resolves
911
+ // "the navigation bar's first button" against the NEW screen and finds whatever leads it
912
+ // there — so a successful pop looked like the control was still present. Titles do not
913
+ // work either: a detail view that inherits its parent's title reads the same on both
914
+ // sides. A pop replaces the screen's controls, and that is what we compare.
915
+ let controlLabels = { (elements: [SimpleElement]) -> Set<String> in
916
+ Set(elements.filter { self.isInteractable($0.type) && $0.isEnabled }
917
+ .map { self.normalizeVisibleText($0.label) }
918
+ .filter { !$0.isEmpty })
919
+ }
920
+ let beforeControls = controlLabels(beforeElements)
921
+ back.tap()
922
+ waitForUIStability(timeout: 1.5)
923
+ let afterElements = readUITree(app)
924
+ let afterTitle = detectTitle(afterElements) ?? "Unknown"
925
+ let afterControls = controlLabels(afterElements)
926
+ // Two or more controls differing is a screen change; one is ordinary content churn.
927
+ let changedControls = beforeControls.symmetricDifference(afterControls).count
928
+ print("OCQA_STATE:back_check before=\(beforeTitle) after=\(afterTitle) control=\(backLabel) changedControls=\(changedControls)")
929
+ if changedControls >= 2 { return "ok" }
930
+ // Navigation is a change of SCREEN, and the screen's identity is its title. A raw tree
931
+ // hash is too sensitive to be evidence of it: a dashboard with delayed content or a
932
+ // relative timestamp changes hash on its own, which made a no-op back at a root screen
933
+ // look like a successful pop (observed on the corpus app). Fall back to the hash only
934
+ // when neither read produced a title to compare.
935
+ // Observability: a back that reports the wrong thing is invisible without this.
936
+ if afterTitle != beforeTitle { return "ok" }
937
+ if beforeTitle == "Unknown" && afterTitle == "Unknown" {
938
+ return computeHash(afterElements) != beforeHash ? "ok" : "no_effect"
939
+ }
940
+ return "no_effect"
761
941
  }
762
942
 
763
943
  /// Replays an explicit, config-driven login flow (OCQA_LOGIN_STEPS) before exploration — for the
@@ -848,6 +1028,7 @@ class ExplorerTests: XCTestCase {
848
1028
  let taskName = (raw["__tappTask"] as? [String: Any])?["name"] as? String ?? ""
849
1029
  // A step is either {action: value} sugar or {action:..., target/value/...}. Normalize.
850
1030
  let (action, step) = normalizeFlowStep(raw)
1031
+ noteNavigationTitle() // navigation history, so `back` can recognise its control
851
1032
  let target = subst((step["target"] as? String) ?? "")
852
1033
  let value = subst((step["value"] as? String) ?? "")
853
1034
  let timeoutMs = (step["timeoutMs"] as? Int) ?? (step["timeout"] as? Int) ?? flowDefaultTimeoutMs
@@ -888,7 +1069,11 @@ class ExplorerTests: XCTestCase {
888
1069
  case "swipe":
889
1070
  switch target.lowercased() { case "down": app.swipeDown(); case "left": app.swipeLeft(); case "right": app.swipeRight(); default: app.swipeUp() }
890
1071
  case "back":
891
- _ = tryGoBack()
1072
+ // Same rule as the session (#10): a back that did not navigate is a failed step,
1073
+ // not a silent pass — the rest of the Flow would otherwise run against the wrong
1074
+ // screen and fail somewhere unrelated.
1075
+ status = sessionBack() == "ok" ? "pass" : "fail"
1076
+ if status == "fail" { detail = "back did not navigate: no back control on this screen, or tapping it did not change the screen\(visibleLabelsHint())" }
892
1077
  case "wait":
893
1078
  Thread.sleep(forTimeInterval: Double(timeoutMs) / 1000.0)
894
1079
  case "wait_for":
@@ -933,6 +1118,20 @@ class ExplorerTests: XCTestCase {
933
1118
  print("OCQA_FLOW_STEP:{\"index\":\(idx),\"action\":\"\(escapeJSON(action))\",\"target\":\"\(escapeJSON(target.isEmpty ? value : target))\",\"assert\":\(isAssert),\"status\":\"\(status)\",\"detail\":\"\(escapeJSON(detail))\"\(taskEvidence)\(contractEvidence)}")
934
1119
  if status == "fail" {
935
1120
  failed += 1
1121
+ // Web and Android flows write flow-failure-N.png next to the log; iOS buried the
1122
+ // evidence in the .xcresult, so seeing the failing screen needed a second run
1123
+ // (field issue #8). Write the same file, and keep the XCTest attachment too.
1124
+ let failureShot = app.screenshot()
1125
+ let failureAttachment = XCTAttachment(screenshot: failureShot)
1126
+ failureAttachment.name = "flow_failure_\(idx)"
1127
+ failureAttachment.lifetime = .keepAlways
1128
+ add(failureAttachment)
1129
+ let evidenceDir = resolve("OCQA_FLOW_EVIDENCE_DIR")
1130
+ if !evidenceDir.isEmpty {
1131
+ let destination = URL(fileURLWithPath: evidenceDir).appendingPathComponent("flow-failure-\(idx).png")
1132
+ try? FileManager.default.createDirectory(at: URL(fileURLWithPath: evidenceDir), withIntermediateDirectories: true)
1133
+ try? failureShot.pngRepresentation.write(to: destination)
1134
+ }
936
1135
  // A failed step surfaces as a finding, with the same shape QA findings use.
937
1136
  let screen = detectTitle(readUITree(app)) ?? "Unknown"
938
1137
  print("OCQA_ISSUE:{\"type\":\"flow_assertion_failed\",\"severity\":\"high\",\"title\":\"\(escapeJSON("Step \(idx) (\(action)) failed: \(detail)"))\",\"screen\":\"\(escapeJSON(screen))\",\"step\":\(idx)}")
@@ -1433,6 +1632,7 @@ class ExplorerTests: XCTestCase {
1433
1632
  // defect (field issue #9: "Personal Info ↔ back" filed as a finding). Remember when we
1434
1633
  // last navigated backwards so the loop detector can tell the two apart.
1435
1634
  var lastExplorerBackStep = -99
1635
+ var systemSurfaceEscapes = 0
1436
1636
  while actionCount < maxActions {
1437
1637
  // Subtract time spent paused for interactive input so human typing never eats the budget.
1438
1638
  if Date().timeIntervalSince(startTime) - totalWaitSeconds > timeoutSeconds {
@@ -1780,6 +1980,31 @@ class ExplorerTests: XCTestCase {
1780
1980
  )
1781
1981
  }
1782
1982
 
1983
+ // ---- System-owned surface: catalogue, back out, never score ----
1984
+ // The share sheet and the extensions it hosts run inside the app process, so the
1985
+ // left-app detector (which watches the frontmost process) cannot see them. Their
1986
+ // tree is Apple's or a third party's, not the app's: report the handoff as an
1987
+ // observation and leave, instead of filing blank/limited findings against it.
1988
+ if let systemSurface = systemSurfaceName(elements, title: titleStr) {
1989
+ let surfaceKey = "system_surface:\(systemSurface)"
1990
+ if !reportedIssueKeys.contains(surfaceKey) {
1991
+ reportedIssueKeys.insert(surfaceKey)
1992
+ print("OCQA_ACTION:{\"type\":\"system_surface\",\"target\":\"\(escapeJSON(systemSurface))\",\"screen\":\"\(escapedTitle)\",\"step\":\(actionCount),\"narrative\":\"\(escapeJSON("Reached \(systemSurface), a system-owned surface — its content belongs to iOS, not the app under test. Backing out."))\"}")
1993
+ }
1994
+ actionCount += 1
1995
+ systemSurfaceEscapes += 1
1996
+ let escaped = dismissSystemSurface(currentTitle: titleStr)
1997
+ // Never let a surface we refuse to explore eat the whole budget: a share sheet
1998
+ // that will not dismiss is a dead end for the run, not a reason to keep trying.
1999
+ if !escaped && systemSurfaceEscapes >= 3 {
2000
+ print("OCQA_STATE:system_surface_unrecoverable screen=\(escapedTitle) step=\(actionCount)")
2001
+ explorationStopCause = "system-surface-unrecoverable"
2002
+ break
2003
+ }
2004
+ emitProgress(action: actionCount, maxActions: maxActions, states: visitedStates.count)
2005
+ continue
2006
+ }
2007
+
1783
2008
  // Screenshot
1784
2009
  let screenshot = app.screenshot()
1785
2010
  let attachment = XCTAttachment(screenshot: screenshot)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aarwitz/tapp",
3
- "version": "0.17.16",
3
+ "version": "0.17.18",
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",
@@ -48,12 +48,15 @@ case "$FLOW" in
48
48
  *) FLOW_EVIDENCE_SOURCE="$EVIDENCE_DIR/flow-source.yml" ;;
49
49
  esac
50
50
  cp "$FLOW" "$FLOW_EVIDENCE_SOURCE"
51
- python3 - "$CFG" "$APP" "$FLOW_JSON" "$AI_RESP" "$AI_DIR" <<'PY'
51
+ TAPP_FLOW_EVIDENCE_DIR_RESOLVED="$EVIDENCE_DIR" python3 - "$CFG" "$APP" "$FLOW_JSON" "$AI_RESP" "$AI_DIR" <<'PY'
52
52
  import json, os, sys
53
53
  cfg, app, flow_json, ai_resp, ai_dir = sys.argv[1:6]
54
54
  d = {
55
55
  "OCQA_BUNDLE_ID": app,
56
56
  "OCQA_FLOW_JSON": flow_json,
57
+ # Where the harness writes per-step failure screenshots, so iOS flow evidence matches web
58
+ # and android instead of living only inside the .xcresult (field issue #8).
59
+ "OCQA_FLOW_EVIDENCE_DIR": os.environ.get("TAPP_FLOW_EVIDENCE_DIR_RESOLVED", ""),
57
60
  "OCQA_TEST_EMAIL": os.environ.get("OCQA_TEST_EMAIL", "test@example.com"),
58
61
  "OCQA_TEST_PASSWORD": os.environ.get("OCQA_TEST_PASSWORD", "TestPass123!"),
59
62
  }