@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.
|
|
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.
|
|
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 = "
|
|
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
|
-
//
|
|
859
|
-
//
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|