@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.
|
|
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":
|
|
@@ -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
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|
package/scripts/run-flow.sh
CHANGED
|
@@ -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
|
}
|