@aarwitz/tapp 0.17.14 → 0.17.17
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.17",
|
|
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.17",
|
|
28
28
|
"mcp"
|
|
29
29
|
],
|
|
30
30
|
"cwd": "${CLAUDE_PROJECT_DIR}"
|
|
@@ -231,6 +231,11 @@ class ExplorerTests: XCTestCase {
|
|
|
231
231
|
for query in queries {
|
|
232
232
|
let element = query[identifier]
|
|
233
233
|
if element.exists && element.isHittable {
|
|
234
|
+
if !waitForEnabled(element) {
|
|
235
|
+
print("OCQA_ACTION:{\"type\":\"tap\",\"identifier\":\"\(identifier)\",\"status\":\"disabled\"}")
|
|
236
|
+
Thread.sleep(forTimeInterval: 0.5)
|
|
237
|
+
return
|
|
238
|
+
}
|
|
234
239
|
element.tap()
|
|
235
240
|
print("OCQA_ACTION:{\"type\":\"tap\",\"identifier\":\"\(identifier)\"}")
|
|
236
241
|
Thread.sleep(forTimeInterval: 0.5)
|
|
@@ -240,8 +245,12 @@ class ExplorerTests: XCTestCase {
|
|
|
240
245
|
let predicate = NSPredicate(format: "label == %@", identifier)
|
|
241
246
|
let match = app.descendants(matching: .any).matching(predicate).firstMatch
|
|
242
247
|
if match.exists && match.isHittable {
|
|
243
|
-
match
|
|
244
|
-
|
|
248
|
+
if !waitForEnabled(match) {
|
|
249
|
+
print("OCQA_ACTION:{\"type\":\"tap\",\"label\":\"\(identifier)\",\"status\":\"disabled\"}")
|
|
250
|
+
} else {
|
|
251
|
+
match.tap()
|
|
252
|
+
print("OCQA_ACTION:{\"type\":\"tap\",\"label\":\"\(identifier)\"}")
|
|
253
|
+
}
|
|
245
254
|
} else {
|
|
246
255
|
print("OCQA_ACTION:{\"type\":\"tap\",\"identifier\":\"\(identifier)\",\"status\":\"not_found\"}")
|
|
247
256
|
}
|
|
@@ -382,7 +391,32 @@ class ExplorerTests: XCTestCase {
|
|
|
382
391
|
}
|
|
383
392
|
case "tap":
|
|
384
393
|
if let id = cmd["id"] as? String, !id.isEmpty {
|
|
394
|
+
let beforeElements = readUITree(app)
|
|
395
|
+
let beforeHash = computeHash(beforeElements)
|
|
396
|
+
let beforeTitle = detectTitle(beforeElements) ?? "Unknown"
|
|
385
397
|
status = sessionTapById(id)
|
|
398
|
+
if status == "ok" {
|
|
399
|
+
waitForUIStability(timeout: 1.5)
|
|
400
|
+
let afterElements = readUITree(app)
|
|
401
|
+
// Nothing moved and the target is still sitting there: the tap landed on
|
|
402
|
+
// something that is not the control the caller meant — classically a system
|
|
403
|
+
// sheet ("Save Password?") whose buttons belong to SpringBoard, not the app
|
|
404
|
+
// (field issue #6: three "ok" taps while the sheet stayed up).
|
|
405
|
+
let unchanged = computeHash(afterElements) == beforeHash
|
|
406
|
+
&& (detectTitle(afterElements) ?? "Unknown") == beforeTitle
|
|
407
|
+
if unchanged && elementPresent(id) {
|
|
408
|
+
if tapSystemSheetButton(id) {
|
|
409
|
+
waitForUIStability(timeout: 1.5)
|
|
410
|
+
let recovered = readUITree(app)
|
|
411
|
+
status = computeHash(recovered) == beforeHash ? "no_effect" : "ok"
|
|
412
|
+
} else {
|
|
413
|
+
status = "no_effect"
|
|
414
|
+
}
|
|
415
|
+
if status == "no_effect" {
|
|
416
|
+
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}"
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
386
420
|
} else if let x = cmd["x"] as? Double, let y = cmd["y"] as? Double {
|
|
387
421
|
app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: x, dy: y)).tap()
|
|
388
422
|
} else { status = "bad_args" }
|
|
@@ -394,7 +428,8 @@ class ExplorerTests: XCTestCase {
|
|
|
394
428
|
case "down": app.swipeDown(); case "left": app.swipeLeft(); case "right": app.swipeRight(); default: app.swipeUp()
|
|
395
429
|
}
|
|
396
430
|
case "back":
|
|
397
|
-
sessionBack()
|
|
431
|
+
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\"})" }
|
|
398
433
|
case "wait":
|
|
399
434
|
let target = (cmd["id"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? (cmd["text"] as? String ?? "")
|
|
400
435
|
let waitMs = (cmd["timeoutMs"] as? Int) ?? 5000
|
|
@@ -434,10 +469,27 @@ class ExplorerTests: XCTestCase {
|
|
|
434
469
|
/// Returns "ok" if tapped, "not_hittable" if the element exists but couldn't be tapped (disabled,
|
|
435
470
|
/// or covered by the keyboard even after dismissing it), or "not_found" if nothing matched. The
|
|
436
471
|
/// distinction tells the caller whether to enter valid input first vs. that the target is absent.
|
|
472
|
+
/// A disabled control is still `exists` and still `isHittable`, so tapping one
|
|
473
|
+
/// succeeds and reports a normal action while nothing happens: the step passes and
|
|
474
|
+
/// the flow carries on against a screen that never advanced. Wait before calling it
|
|
475
|
+
/// disabled — a button guarded by form validation becomes enabled a moment after the
|
|
476
|
+
/// last field is filled, and failing on that would be its own kind of wrong answer.
|
|
477
|
+
private func waitForEnabled(_ element: XCUIElement, timeout: TimeInterval = 3) -> Bool {
|
|
478
|
+
if element.isEnabled { return true }
|
|
479
|
+
let deadline = Date().addingTimeInterval(timeout)
|
|
480
|
+
while Date() < deadline {
|
|
481
|
+
if element.isEnabled { return true }
|
|
482
|
+
Thread.sleep(forTimeInterval: 0.15)
|
|
483
|
+
}
|
|
484
|
+
return false
|
|
485
|
+
}
|
|
486
|
+
|
|
437
487
|
private func sessionTapById(_ identifier: String) -> String {
|
|
438
488
|
var existedButNotHittable = false
|
|
489
|
+
var existedButDisabled = false
|
|
439
490
|
func tryTap(_ el: XCUIElement) -> Bool {
|
|
440
491
|
guard el.exists else { return false }
|
|
492
|
+
if !waitForEnabled(el) { existedButDisabled = true; return false }
|
|
441
493
|
if el.isHittable { el.tap(); return true }
|
|
442
494
|
// Often covered by the keyboard (a submit button below filled fields) — dismiss + retry,
|
|
443
495
|
// like Playwright auto-scrolls a target into view.
|
|
@@ -472,6 +524,9 @@ class ExplorerTests: XCTestCase {
|
|
|
472
524
|
// label) — "Password" must reach the secureTextField or a later `type` lands in whatever
|
|
473
525
|
// field still has keyboard focus.
|
|
474
526
|
if let field = resolveFieldByHint(identifier), tryTap(field) { return "ok" }
|
|
527
|
+
// "disabled" outranks the others: the control was found and refused to act, which
|
|
528
|
+
// is a different answer from "it is not here" and needs a different fix.
|
|
529
|
+
if existedButDisabled { return "disabled" }
|
|
475
530
|
return existedButNotHittable ? "not_hittable" : "not_found"
|
|
476
531
|
}
|
|
477
532
|
|
|
@@ -721,14 +776,104 @@ class ExplorerTests: XCTestCase {
|
|
|
721
776
|
return ("still_on_login", errTexts.prefix(2).joined(separator: " | "))
|
|
722
777
|
}
|
|
723
778
|
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
779
|
+
/// Go back and PROVE it: a caller that believes a no-op navigated keeps driving the wrong
|
|
780
|
+
/// screen (field issue #10 — four "ok" backs in a row while the title never changed, then
|
|
781
|
+
/// every following tap failed `not_hittable` because the pushed screen was still on top).
|
|
782
|
+
/// Returns "ok" only when the screen actually changed; "no_effect" otherwise.
|
|
783
|
+
/// A surface the SYSTEM owns even though it renders inside the app process: the share sheet
|
|
784
|
+
/// (`UIActivityContentView`) and the app extensions it hosts, the photo picker's unavailable
|
|
785
|
+
/// view, and other remote-view hosts. Their content belongs to Apple or a third-party
|
|
786
|
+
/// extension, so an empty or unreadable accessibility tree there says nothing about the app
|
|
787
|
+
/// under test — scoring it produced this app's only "high" finding (field issue #4).
|
|
788
|
+
private func systemSurfaceName(_ elements: [SimpleElement], title: String) -> String? {
|
|
789
|
+
let markers = ["UIActivityContentView", "ActivityListView", "PUPickerUnavailableView",
|
|
790
|
+
"RemoteViewBridge", "_UIRemoteView", "UIDocumentPickerViewController",
|
|
791
|
+
"SFSafariView", "UIActivityGroupView"]
|
|
792
|
+
for element in elements {
|
|
793
|
+
let signature = element.type + " " + element.identifier + " " + element.label
|
|
794
|
+
if let hit = markers.first(where: { signature.contains($0) }) { return hit }
|
|
795
|
+
}
|
|
796
|
+
// The share sheet hands off to an extension whose whole UI is out of process; the title
|
|
797
|
+
// is then the extension's name over a tree the app cannot see.
|
|
798
|
+
if title == "UIActivityContentView" { return "UIActivityContentView" }
|
|
799
|
+
return nil
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/// Leave a system-owned surface. A share sheet is a presented sheet, not a pushed screen:
|
|
803
|
+
/// `back` does nothing and a mid-screen swipe scrolls its content instead of dismissing it.
|
|
804
|
+
/// Try its real affordances in order, and say honestly whether we actually got out.
|
|
805
|
+
private func dismissSystemSurface(currentTitle: String) -> Bool {
|
|
806
|
+
let stillThere = { () -> Bool in
|
|
807
|
+
let elements = self.readUITree(self.app)
|
|
808
|
+
return self.systemSurfaceName(elements, title: self.detectTitle(elements) ?? "Unknown") != nil
|
|
809
|
+
}
|
|
810
|
+
// 1. An explicit dismissal control, in the app and in SpringBoard (extensions live there).
|
|
811
|
+
for label in ["Close", "Cancel", "Done", "Dismiss"] {
|
|
812
|
+
for candidate in [app.buttons[label], XCUIApplication(bundleIdentifier: "com.apple.springboard").buttons[label]] {
|
|
813
|
+
if candidate.exists && candidate.isHittable {
|
|
814
|
+
candidate.tap()
|
|
815
|
+
waitForUIStability(timeout: 1.5)
|
|
816
|
+
if !stillThere() { return true }
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
// 2. Drag the sheet down by its top edge (the grabber), not from the middle.
|
|
821
|
+
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.35))
|
|
822
|
+
.press(forDuration: 0.1, thenDragTo: app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.98)))
|
|
823
|
+
waitForUIStability(timeout: 1.5)
|
|
824
|
+
if !stillThere() { return true }
|
|
825
|
+
// 3. Tap the dimmed backdrop above the sheet.
|
|
826
|
+
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.06)).tap()
|
|
827
|
+
waitForUIStability(timeout: 1.5)
|
|
828
|
+
if !stillThere() { return true }
|
|
829
|
+
// 4. Last resort: relaunch. The surface belongs to iOS; the app is still healthy.
|
|
830
|
+
app.terminate()
|
|
831
|
+
Thread.sleep(forTimeInterval: 0.8)
|
|
832
|
+
app.launch()
|
|
833
|
+
_ = app.wait(for: .runningForeground, timeout: 10)
|
|
834
|
+
waitForUIStability(timeout: 2.0)
|
|
835
|
+
return !stillThere()
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/// iOS renders "Save Password?", location prompts and similar sheets in SpringBoard, not in
|
|
839
|
+
/// the app under test, so an app-scoped query silently matches something else. Tap the real
|
|
840
|
+
/// button when a system sheet is up (field issue #6).
|
|
841
|
+
private func tapSystemSheetButton(_ identifier: String) -> Bool {
|
|
842
|
+
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
|
|
843
|
+
for query in [springboard.buttons, springboard.staticTexts] {
|
|
844
|
+
let element = query[identifier]
|
|
845
|
+
if element.exists && element.isHittable { element.tap(); return true }
|
|
846
|
+
}
|
|
847
|
+
let matches = springboard.descendants(matching: .any)
|
|
848
|
+
.matching(NSPredicate(format: "label ==[c] %@", identifier)).allElementsBoundByIndex
|
|
849
|
+
if let match = matches.first(where: { $0.exists && $0.isHittable }) { match.tap(); return true }
|
|
850
|
+
return false
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
private func sessionBack() -> String {
|
|
854
|
+
let beforeElements = readUITree(app)
|
|
855
|
+
let beforeTitle = detectTitle(beforeElements) ?? "Unknown"
|
|
856
|
+
let beforeHash = computeHash(beforeElements)
|
|
857
|
+
|
|
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()
|
|
728
863
|
} else {
|
|
729
|
-
|
|
730
|
-
|
|
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
|
+
}
|
|
731
871
|
}
|
|
872
|
+
waitForUIStability(timeout: 1.5)
|
|
873
|
+
let afterElements = readUITree(app)
|
|
874
|
+
let afterTitle = detectTitle(afterElements) ?? "Unknown"
|
|
875
|
+
if afterTitle != beforeTitle || computeHash(afterElements) != beforeHash { return "ok" }
|
|
876
|
+
return "no_effect"
|
|
732
877
|
}
|
|
733
878
|
|
|
734
879
|
/// Replays an explicit, config-driven login flow (OCQA_LOGIN_STEPS) before exploration — for the
|
|
@@ -827,8 +972,19 @@ class ExplorerTests: XCTestCase {
|
|
|
827
972
|
|
|
828
973
|
switch action {
|
|
829
974
|
case "tap":
|
|
830
|
-
|
|
831
|
-
|
|
975
|
+
let tapStatus = sessionTapById(target)
|
|
976
|
+
status = tapStatus == "ok" ? "pass" : "fail"
|
|
977
|
+
if status == "fail" {
|
|
978
|
+
// Name which of the three it was. "Disabled" in particular sends the
|
|
979
|
+
// author somewhere completely different from "not found": the control
|
|
980
|
+
// is right there, and something upstream in the flow has not satisfied
|
|
981
|
+
// whatever guards it.
|
|
982
|
+
switch tapStatus {
|
|
983
|
+
case "disabled": detail = "‘\(target)’ is on screen but disabled — an earlier step has not met whatever enables it"
|
|
984
|
+
case "not_hittable": detail = "‘\(target)’ exists but could not be reached, even after dismissing the keyboard and scrolling"
|
|
985
|
+
default: detail = "could not find ‘\(target)’ on this screen"
|
|
986
|
+
}
|
|
987
|
+
}
|
|
832
988
|
case "type":
|
|
833
989
|
status = sessionType(value, id: target.isEmpty ? nil : target) ? "pass" : "fail"
|
|
834
990
|
if status == "fail" { detail = lastTypeFailure.isEmpty ? "no field ‘\(target)’ to type into" : lastTypeFailure }
|
|
@@ -893,6 +1049,20 @@ class ExplorerTests: XCTestCase {
|
|
|
893
1049
|
print("OCQA_FLOW_STEP:{\"index\":\(idx),\"action\":\"\(escapeJSON(action))\",\"target\":\"\(escapeJSON(target.isEmpty ? value : target))\",\"assert\":\(isAssert),\"status\":\"\(status)\",\"detail\":\"\(escapeJSON(detail))\"\(taskEvidence)\(contractEvidence)}")
|
|
894
1050
|
if status == "fail" {
|
|
895
1051
|
failed += 1
|
|
1052
|
+
// Web and Android flows write flow-failure-N.png next to the log; iOS buried the
|
|
1053
|
+
// evidence in the .xcresult, so seeing the failing screen needed a second run
|
|
1054
|
+
// (field issue #8). Write the same file, and keep the XCTest attachment too.
|
|
1055
|
+
let failureShot = app.screenshot()
|
|
1056
|
+
let failureAttachment = XCTAttachment(screenshot: failureShot)
|
|
1057
|
+
failureAttachment.name = "flow_failure_\(idx)"
|
|
1058
|
+
failureAttachment.lifetime = .keepAlways
|
|
1059
|
+
add(failureAttachment)
|
|
1060
|
+
let evidenceDir = resolve("OCQA_FLOW_EVIDENCE_DIR")
|
|
1061
|
+
if !evidenceDir.isEmpty {
|
|
1062
|
+
let destination = URL(fileURLWithPath: evidenceDir).appendingPathComponent("flow-failure-\(idx).png")
|
|
1063
|
+
try? FileManager.default.createDirectory(at: URL(fileURLWithPath: evidenceDir), withIntermediateDirectories: true)
|
|
1064
|
+
try? failureShot.pngRepresentation.write(to: destination)
|
|
1065
|
+
}
|
|
896
1066
|
// A failed step surfaces as a finding, with the same shape QA findings use.
|
|
897
1067
|
let screen = detectTitle(readUITree(app)) ?? "Unknown"
|
|
898
1068
|
print("OCQA_ISSUE:{\"type\":\"flow_assertion_failed\",\"severity\":\"high\",\"title\":\"\(escapeJSON("Step \(idx) (\(action)) failed: \(detail)"))\",\"screen\":\"\(escapeJSON(screen))\",\"step\":\(idx)}")
|
|
@@ -1393,6 +1563,7 @@ class ExplorerTests: XCTestCase {
|
|
|
1393
1563
|
// defect (field issue #9: "Personal Info ↔ back" filed as a finding). Remember when we
|
|
1394
1564
|
// last navigated backwards so the loop detector can tell the two apart.
|
|
1395
1565
|
var lastExplorerBackStep = -99
|
|
1566
|
+
var systemSurfaceEscapes = 0
|
|
1396
1567
|
while actionCount < maxActions {
|
|
1397
1568
|
// Subtract time spent paused for interactive input so human typing never eats the budget.
|
|
1398
1569
|
if Date().timeIntervalSince(startTime) - totalWaitSeconds > timeoutSeconds {
|
|
@@ -1740,6 +1911,31 @@ class ExplorerTests: XCTestCase {
|
|
|
1740
1911
|
)
|
|
1741
1912
|
}
|
|
1742
1913
|
|
|
1914
|
+
// ---- System-owned surface: catalogue, back out, never score ----
|
|
1915
|
+
// The share sheet and the extensions it hosts run inside the app process, so the
|
|
1916
|
+
// left-app detector (which watches the frontmost process) cannot see them. Their
|
|
1917
|
+
// tree is Apple's or a third party's, not the app's: report the handoff as an
|
|
1918
|
+
// observation and leave, instead of filing blank/limited findings against it.
|
|
1919
|
+
if let systemSurface = systemSurfaceName(elements, title: titleStr) {
|
|
1920
|
+
let surfaceKey = "system_surface:\(systemSurface)"
|
|
1921
|
+
if !reportedIssueKeys.contains(surfaceKey) {
|
|
1922
|
+
reportedIssueKeys.insert(surfaceKey)
|
|
1923
|
+
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."))\"}")
|
|
1924
|
+
}
|
|
1925
|
+
actionCount += 1
|
|
1926
|
+
systemSurfaceEscapes += 1
|
|
1927
|
+
let escaped = dismissSystemSurface(currentTitle: titleStr)
|
|
1928
|
+
// Never let a surface we refuse to explore eat the whole budget: a share sheet
|
|
1929
|
+
// that will not dismiss is a dead end for the run, not a reason to keep trying.
|
|
1930
|
+
if !escaped && systemSurfaceEscapes >= 3 {
|
|
1931
|
+
print("OCQA_STATE:system_surface_unrecoverable screen=\(escapedTitle) step=\(actionCount)")
|
|
1932
|
+
explorationStopCause = "system-surface-unrecoverable"
|
|
1933
|
+
break
|
|
1934
|
+
}
|
|
1935
|
+
emitProgress(action: actionCount, maxActions: maxActions, states: visitedStates.count)
|
|
1936
|
+
continue
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1743
1939
|
// Screenshot
|
|
1744
1940
|
let screenshot = app.screenshot()
|
|
1745
1941
|
let attachment = XCTAttachment(screenshot: screenshot)
|
|
@@ -4792,23 +4988,46 @@ class ExplorerTests: XCTestCase {
|
|
|
4792
4988
|
} while Date() < deadline
|
|
4793
4989
|
return focused()
|
|
4794
4990
|
}
|
|
4795
|
-
|
|
4991
|
+
// A field further down a form is usually UNDER the keyboard that the previous
|
|
4992
|
+
// field opened, so every tap below lands on a key instead and three attempts
|
|
4993
|
+
// report "never took keyboard focus" for a field that was simply unreachable.
|
|
4994
|
+
// sessionTapById already dismisses and scrolls for taps; typing needs the same
|
|
4995
|
+
// or no form taller than the keyboard can be filled at all.
|
|
4996
|
+
func keyboardCovers(_ el: XCUIElement) -> Bool {
|
|
4997
|
+
let keyboard = app.keyboards.firstMatch
|
|
4998
|
+
guard keyboard.exists else { return false }
|
|
4999
|
+
let f = el.frame, k = keyboard.frame
|
|
5000
|
+
return f.intersects(k) || f.minY >= k.minY
|
|
5001
|
+
}
|
|
5002
|
+
|
|
5003
|
+
for attempt in 0..<5 {
|
|
4796
5004
|
switch attempt {
|
|
4797
5005
|
case 0:
|
|
4798
5006
|
if element.isHittable { element.tap() } else { element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() }
|
|
4799
5007
|
case 1:
|
|
4800
5008
|
element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
|
4801
|
-
|
|
5009
|
+
case 2:
|
|
4802
5010
|
// Frame may be stale by a few rows: tap just inside its top edge, which stays
|
|
4803
5011
|
// within the visible control when content shifted up.
|
|
4804
5012
|
element.coordinate(withNormalizedOffset: CGVector(dx: 0.35, dy: 0.2)).tap()
|
|
5013
|
+
case 3:
|
|
5014
|
+
// Get the keyboard out of the way and try where the field now sits.
|
|
5015
|
+
dismissKeyboardIfPresent()
|
|
5016
|
+
waitForAnimationsToSettle()
|
|
5017
|
+
if element.isHittable { element.tap() } else { element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() }
|
|
5018
|
+
default:
|
|
5019
|
+
// Still out of reach: bring it up the screen, then tap.
|
|
5020
|
+
dismissKeyboardIfPresent()
|
|
5021
|
+
app.swipeUp()
|
|
5022
|
+
Thread.sleep(forTimeInterval: 0.25)
|
|
5023
|
+
if element.isHittable { element.tap() } else { element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() }
|
|
4805
5024
|
}
|
|
4806
5025
|
if settleUntilFocused() {
|
|
4807
5026
|
if attempt > 0 { print("OCQA_STATE:type_focus_recovered attempt=\(attempt + 1)") }
|
|
4808
5027
|
return true
|
|
4809
5028
|
}
|
|
4810
5029
|
let f = element.frame
|
|
4811
|
-
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")")
|
|
5030
|
+
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) covered=\(keyboardCovers(element)) focusedElsewhere=\(focusedField().map { fieldDesc($0) } ?? "none")")
|
|
4812
5031
|
}
|
|
4813
5032
|
return false
|
|
4814
5033
|
}
|
|
@@ -4821,7 +5040,7 @@ class ExplorerTests: XCTestCase {
|
|
|
4821
5040
|
lastTypeFailure = ""
|
|
4822
5041
|
guard focusForTyping(element) else {
|
|
4823
5042
|
let f = element.frame
|
|
4824
|
-
lastTypeFailure = "‘\(fieldDesc(element))’ was found (frame \(Int(f.minX)),\(Int(f.minY)) \(Int(f.width))×\(Int(f.height))) but never took keyboard focus after
|
|
5043
|
+
lastTypeFailure = "‘\(fieldDesc(element))’ was found (frame \(Int(f.minX)),\(Int(f.minY)) \(Int(f.width))×\(Int(f.height))) but never took keyboard focus, including after dismissing the keyboard and scrolling it into view"
|
|
4825
5044
|
+ (app.keyboards.count == 0 ? " — no keyboard appeared" : "")
|
|
4826
5045
|
+ (focusedField().map { " — focus is on ‘\(fieldDesc($0))’" } ?? "")
|
|
4827
5046
|
print("OCQA_STATE:type_focus_failed detail=\(escapeJSON(lastTypeFailure))")
|
package/mcp-server/src/index.js
CHANGED
|
@@ -471,10 +471,26 @@ export async function prepareAndroidInteractiveTarget({ projectDir = process.cwd
|
|
|
471
471
|
return { ...built, appId, selectedTarget:selected };
|
|
472
472
|
}
|
|
473
473
|
|
|
474
|
-
|
|
474
|
+
// Said before a build overwrites whatever is on the simulator. The clean install
|
|
475
|
+
// below wipes the old app's data with it, so a build someone deliberately
|
|
476
|
+
// configured — pointed at a local server, a staging key, a test account —
|
|
477
|
+
// disappears without being mentioned. Naming it is the difference between "my run
|
|
478
|
+
// is slow" and "my run is now testing something else entirely". Passing a bundle
|
|
479
|
+
// id instead of a project path uses what is installed and never builds.
|
|
480
|
+
export function replacingInstalledBuildNotice(bundleId) {
|
|
481
|
+
const id = String(bundleId || "").trim();
|
|
482
|
+
if (!id) return "";
|
|
483
|
+
return `Replacing the installed build of ${id} (its data is wiped too). To use what is already installed instead of building, pass the bundle id: ${id}`;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
export async function installAppOnBootedSim(appPath, { cleanInstall = true, onStatus = () => {} } = {}) {
|
|
475
487
|
const bid = await runCommand("/usr/libexec/PlistBuddy", ["-c", "Print CFBundleIdentifier", path.join(appPath, "Info.plist")], { timeoutMs: 30_000 });
|
|
476
488
|
const bundleId = (bid.stdout || "").trim();
|
|
477
489
|
if (!bundleId) return { error: `Could not read CFBundleIdentifier from ${appPath}/Info.plist — is this a simulator .app build?` };
|
|
490
|
+
if (cleanInstall) {
|
|
491
|
+
const existing = await runCommand("xcrun", ["simctl", "get_app_container", "booted", bundleId, "app"], { timeoutMs: 15_000 });
|
|
492
|
+
if (existing.code === 0 && (existing.stdout || "").trim()) onStatus(replacingInstalledBuildNotice(bundleId));
|
|
493
|
+
}
|
|
478
494
|
if (cleanInstall) {
|
|
479
495
|
// Clean install: stale keychain items from a previous install leave apps half-signed-in
|
|
480
496
|
// (Firebase Auth's "error accessing the keychain") — uninstall first for a fresh state.
|
|
@@ -496,7 +512,7 @@ export async function resolveAppTarget(input, { cwd = process.cwd(), onStatus =
|
|
|
496
512
|
const sim = await ensureBootedSim({ autoBoot: true });
|
|
497
513
|
if (sim.error) return { error: sim.error };
|
|
498
514
|
onStatus(`Installing ${path.basename(appPath)}…`);
|
|
499
|
-
const inst = await installAppOnBootedSim(appPath);
|
|
515
|
+
const inst = await installAppOnBootedSim(appPath, { onStatus });
|
|
500
516
|
if (inst.error) return inst;
|
|
501
517
|
return {
|
|
502
518
|
bundleId: inst.bundleId,
|
|
@@ -522,7 +538,7 @@ export async function resolveAppTarget(input, { cwd = process.cwd(), onStatus =
|
|
|
522
538
|
const built = await buildAppForSim({ container, scheme, configuration });
|
|
523
539
|
if (built.error) return built;
|
|
524
540
|
onStatus(`Built ${path.basename(built.appPath)} (scheme ${built.scheme}) — installing…`);
|
|
525
|
-
const inst = await installAppOnBootedSim(built.appPath);
|
|
541
|
+
const inst = await installAppOnBootedSim(built.appPath, { onStatus });
|
|
526
542
|
if (inst.error) return inst;
|
|
527
543
|
return {
|
|
528
544
|
bundleId: inst.bundleId,
|
|
@@ -139,6 +139,11 @@ function cssAttrEscape(value) {
|
|
|
139
139
|
async function installWebListenerTracking(context) {
|
|
140
140
|
await context.addInitScript(() => {
|
|
141
141
|
const key = Symbol.for("tapp.clickListeners");
|
|
142
|
+
// Delegated handlers live on document/window, not on the control, so an
|
|
143
|
+
// element-only record says "nothing is listening" for a page where every
|
|
144
|
+
// button works. Keep their source text: it is the only way to tell which
|
|
145
|
+
// controls such a handler actually claims.
|
|
146
|
+
const delegatedKey = Symbol.for("tapp.delegatedClickHandlers");
|
|
142
147
|
const add = EventTarget.prototype.addEventListener;
|
|
143
148
|
const remove = EventTarget.prototype.removeEventListener;
|
|
144
149
|
EventTarget.prototype.addEventListener = function tappTrackedAdd(type, listener, options) {
|
|
@@ -146,6 +151,11 @@ async function installWebListenerTracking(context) {
|
|
|
146
151
|
if (!this[key]) Object.defineProperty(this, key, { value: new Set(), configurable: true });
|
|
147
152
|
this[key].add(listener);
|
|
148
153
|
}
|
|
154
|
+
if (type === "click" && listener && (this === document || this === window)) {
|
|
155
|
+
const target = this === window ? window : document;
|
|
156
|
+
if (!target[delegatedKey]) Object.defineProperty(target, delegatedKey, { value: new Set(), configurable: true });
|
|
157
|
+
try { target[delegatedKey].add(String(listener).slice(0, 4000)); } catch { /* exotic handler */ }
|
|
158
|
+
}
|
|
149
159
|
return add.call(this, type, listener, options);
|
|
150
160
|
};
|
|
151
161
|
EventTarget.prototype.removeEventListener = function tappTrackedRemove(type, listener, options) {
|
|
@@ -1227,6 +1237,56 @@ export async function auditWebPage({ url, timeoutMs = NAV_TIMEOUT_MS, device = "
|
|
|
1227
1237
|
};
|
|
1228
1238
|
const name = (el) => (el.getAttribute("aria-label") || el.textContent || el.getAttribute("value") || "").replace(/\s+/g, " ").trim().slice(0, 80);
|
|
1229
1239
|
const selectorFor = (el) => el.id ? `#${el.id}` : el.getAttribute("data-testid") ? `[data-testid="${el.getAttribute("data-testid")}"]` : el.tagName.toLowerCase();
|
|
1240
|
+
// Some controls are wired in CSS, not JavaScript: a menu that opens while
|
|
1241
|
+
// its trigger is hovered or focused has no listener to find, and calling
|
|
1242
|
+
// it dead is wrong. Collect the selectors that some rule reacts to — the
|
|
1243
|
+
// trigger side of any `X:hover Y` / `X:focus-within Y` rule whose
|
|
1244
|
+
// declarations change whether Y can be seen. A rule that only restyles
|
|
1245
|
+
// the trigger itself (`button:hover { background: … }`) is not behaviour
|
|
1246
|
+
// and is ignored, which is why the descendant part must be present.
|
|
1247
|
+
const REVEALS = /(^|;)\s*(display|visibility|opacity|height|max-height|transform|pointer-events|clip-path)\s*:/i;
|
|
1248
|
+
const revealTriggers = [];
|
|
1249
|
+
const collectTriggers = (rules) => {
|
|
1250
|
+
for (const rule of rules || []) {
|
|
1251
|
+
// A plain style rule also exposes .cssRules now that CSS nesting is
|
|
1252
|
+
// supported — an empty list, which is truthy. Recursing on that and
|
|
1253
|
+
// skipping the rule would walk straight past every selector there is.
|
|
1254
|
+
if (rule.cssRules && rule.cssRules.length) collectTriggers(rule.cssRules);
|
|
1255
|
+
const selector = rule.selectorText;
|
|
1256
|
+
if (!selector || !REVEALS.test(rule.style?.cssText || "")) continue;
|
|
1257
|
+
for (const part of selector.split(",")) {
|
|
1258
|
+
const match = part.match(/^(.*?):(?:hover|focus-within|focus-visible|focus)\b(.+)$/);
|
|
1259
|
+
if (!match) continue;
|
|
1260
|
+
const trigger = match[1].trim();
|
|
1261
|
+
if (trigger && match[2].trim()) revealTriggers.push(trigger);
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
};
|
|
1265
|
+
for (const sheet of document.styleSheets) {
|
|
1266
|
+
try { collectTriggers(sheet.cssRules); } catch { /* cross-origin sheet */ }
|
|
1267
|
+
}
|
|
1268
|
+
const opensSomethingOnHoverOrFocus = (el) => revealTriggers.some((trigger) => {
|
|
1269
|
+
try { return el.matches(trigger) || Boolean(el.closest(trigger)); } catch { return false; }
|
|
1270
|
+
});
|
|
1271
|
+
|
|
1272
|
+
// What a delegated handler on document/window claims. The dominant idiom is
|
|
1273
|
+
// `e.target.closest(SELECTOR)` / `.matches(SELECTOR)`, so pull those
|
|
1274
|
+
// selectors out and test controls against them. A handler we cannot read
|
|
1275
|
+
// this way (an outside-click closer, say) claims nothing and is ignored,
|
|
1276
|
+
// rather than excusing every unwired control on the page.
|
|
1277
|
+
const delegatedSelectors = [];
|
|
1278
|
+
for (const source of [
|
|
1279
|
+
...(document[Symbol.for("tapp.delegatedClickHandlers")] || []),
|
|
1280
|
+
...(window[Symbol.for("tapp.delegatedClickHandlers")] || []),
|
|
1281
|
+
]) {
|
|
1282
|
+
for (const m of String(source).matchAll(/\.(?:closest|matches)\(\s*["'`]([^"'`]+)["'`]/g)) {
|
|
1283
|
+
delegatedSelectors.push(m[1]);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
const claimedByDelegate = (el) => delegatedSelectors.some((selector) => {
|
|
1287
|
+
try { return el.matches(selector) || Boolean(el.closest(selector)); } catch { return false; }
|
|
1288
|
+
});
|
|
1289
|
+
|
|
1230
1290
|
const dead = [];
|
|
1231
1291
|
for (const el of document.querySelectorAll("a[href], button, [role=button], [aria-controls]")) {
|
|
1232
1292
|
if (!visible(el)) continue;
|
|
@@ -1255,6 +1315,8 @@ export async function auditWebPage({ url, timeoutMs = NAV_TIMEOUT_MS, device = "
|
|
|
1255
1315
|
}
|
|
1256
1316
|
if (!wired && el.matches("button[type=submit], input[type=submit]") && el.closest("form")) wired = true;
|
|
1257
1317
|
if (!wired && el.closest("label")) wired = true; // a label drives its own control
|
|
1318
|
+
if (!wired && opensSomethingOnHoverOrFocus(el)) wired = true;
|
|
1319
|
+
if (!wired && claimedByDelegate(el)) wired = true;
|
|
1258
1320
|
if (!wired) dead.push({ kind: "unwired_control", label, selector: selectorFor(el) });
|
|
1259
1321
|
}
|
|
1260
1322
|
return { dead, controlCount: document.querySelectorAll("a[href], button, [role=button]").length, title: document.title };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aarwitz/tapp",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.17",
|
|
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
|
}
|