@aarwitz/tapp 0.17.17 → 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.17",
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.17",
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":
@@ -429,7 +430,7 @@ class ExplorerTests: XCTestCase {
429
430
  }
430
431
  case "back":
431
432
  status = sessionBack()
432
- if status == "no_effect" { loginDetail = "the screen did not change — there may be no back destination here, or the Back control belongs to a different navigation stack (try tap {id: \"Back\"})" }
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)" }
433
434
  case "wait":
434
435
  let target = (cmd["id"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? (cmd["text"] as? String ?? "")
435
436
  let waitMs = (cmd["timeoutMs"] as? Int) ?? 5000
@@ -484,6 +485,8 @@ class ExplorerTests: XCTestCase {
484
485
  return false
485
486
  }
486
487
 
488
+ private var backTitleHistory: [String] = []
489
+
487
490
  private func sessionTapById(_ identifier: String) -> String {
488
491
  var existedButNotHittable = false
489
492
  var existedButDisabled = false
@@ -799,6 +802,17 @@ class ExplorerTests: XCTestCase {
799
802
  return nil
800
803
  }
801
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
+
802
816
  /// Leave a system-owned surface. A share sheet is a presented sheet, not a pushed screen:
803
817
  /// `back` does nothing and a mid-screen swipe scrolls its content instead of dismissing it.
804
818
  /// Try its real affordances in order, and say honestly whether we actually got out.
@@ -855,24 +869,74 @@ class ExplorerTests: XCTestCase {
855
869
  let beforeTitle = detectTitle(beforeElements) ?? "Unknown"
856
870
  let beforeHash = computeHash(beforeElements)
857
871
 
858
- // A NavigationStack inside a sheet has its own navigation bar; `navigationBars.firstMatch`
859
- // can resolve the wrong one, so prefer an explicitly labelled, hittable Back control.
860
- let labelled = app.buttons.matching(NSPredicate(format: "label ==[c] %@", "Back")).allElementsBoundByIndex
861
- if let back = labelled.first(where: { $0.exists && $0.isHittable }) {
862
- back.tap()
863
- } else {
864
- let backButton = app.navigationBars.buttons.firstMatch
865
- if backButton.exists && backButton.isHittable {
866
- backButton.tap()
867
- } else {
868
- app.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0.5))
869
- .press(forDuration: 0.05, thenDragTo: app.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5)))
870
- }
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()
872
922
  waitForUIStability(timeout: 1.5)
873
923
  let afterElements = readUITree(app)
874
924
  let afterTitle = detectTitle(afterElements) ?? "Unknown"
875
- if afterTitle != beforeTitle || computeHash(afterElements) != beforeHash { return "ok" }
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
+ }
876
940
  return "no_effect"
877
941
  }
878
942
 
@@ -964,6 +1028,7 @@ class ExplorerTests: XCTestCase {
964
1028
  let taskName = (raw["__tappTask"] as? [String: Any])?["name"] as? String ?? ""
965
1029
  // A step is either {action: value} sugar or {action:..., target/value/...}. Normalize.
966
1030
  let (action, step) = normalizeFlowStep(raw)
1031
+ noteNavigationTitle() // navigation history, so `back` can recognise its control
967
1032
  let target = subst((step["target"] as? String) ?? "")
968
1033
  let value = subst((step["value"] as? String) ?? "")
969
1034
  let timeoutMs = (step["timeoutMs"] as? Int) ?? (step["timeout"] as? Int) ?? flowDefaultTimeoutMs
@@ -1004,7 +1069,11 @@ class ExplorerTests: XCTestCase {
1004
1069
  case "swipe":
1005
1070
  switch target.lowercased() { case "down": app.swipeDown(); case "left": app.swipeLeft(); case "right": app.swipeRight(); default: app.swipeUp() }
1006
1071
  case "back":
1007
- _ = 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())" }
1008
1077
  case "wait":
1009
1078
  Thread.sleep(forTimeInterval: Double(timeoutMs) / 1000.0)
1010
1079
  case "wait_for":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aarwitz/tapp",
3
- "version": "0.17.17",
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",