@aarwitz/tapp 0.17.16 → 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}"
|
|
@@ -391,7 +391,32 @@ class ExplorerTests: XCTestCase {
|
|
|
391
391
|
}
|
|
392
392
|
case "tap":
|
|
393
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"
|
|
394
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
|
+
}
|
|
395
420
|
} else if let x = cmd["x"] as? Double, let y = cmd["y"] as? Double {
|
|
396
421
|
app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: x, dy: y)).tap()
|
|
397
422
|
} else { status = "bad_args" }
|
|
@@ -403,7 +428,8 @@ class ExplorerTests: XCTestCase {
|
|
|
403
428
|
case "down": app.swipeDown(); case "left": app.swipeLeft(); case "right": app.swipeRight(); default: app.swipeUp()
|
|
404
429
|
}
|
|
405
430
|
case "back":
|
|
406
|
-
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\"})" }
|
|
407
433
|
case "wait":
|
|
408
434
|
let target = (cmd["id"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? (cmd["text"] as? String ?? "")
|
|
409
435
|
let waitMs = (cmd["timeoutMs"] as? Int) ?? 5000
|
|
@@ -750,14 +776,104 @@ class ExplorerTests: XCTestCase {
|
|
|
750
776
|
return ("still_on_login", errTexts.prefix(2).joined(separator: " | "))
|
|
751
777
|
}
|
|
752
778
|
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
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()
|
|
757
863
|
} else {
|
|
758
|
-
|
|
759
|
-
|
|
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
|
+
}
|
|
760
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"
|
|
761
877
|
}
|
|
762
878
|
|
|
763
879
|
/// Replays an explicit, config-driven login flow (OCQA_LOGIN_STEPS) before exploration — for the
|
|
@@ -933,6 +1049,20 @@ class ExplorerTests: XCTestCase {
|
|
|
933
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)}")
|
|
934
1050
|
if status == "fail" {
|
|
935
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
|
+
}
|
|
936
1066
|
// A failed step surfaces as a finding, with the same shape QA findings use.
|
|
937
1067
|
let screen = detectTitle(readUITree(app)) ?? "Unknown"
|
|
938
1068
|
print("OCQA_ISSUE:{\"type\":\"flow_assertion_failed\",\"severity\":\"high\",\"title\":\"\(escapeJSON("Step \(idx) (\(action)) failed: \(detail)"))\",\"screen\":\"\(escapeJSON(screen))\",\"step\":\(idx)}")
|
|
@@ -1433,6 +1563,7 @@ class ExplorerTests: XCTestCase {
|
|
|
1433
1563
|
// defect (field issue #9: "Personal Info ↔ back" filed as a finding). Remember when we
|
|
1434
1564
|
// last navigated backwards so the loop detector can tell the two apart.
|
|
1435
1565
|
var lastExplorerBackStep = -99
|
|
1566
|
+
var systemSurfaceEscapes = 0
|
|
1436
1567
|
while actionCount < maxActions {
|
|
1437
1568
|
// Subtract time spent paused for interactive input so human typing never eats the budget.
|
|
1438
1569
|
if Date().timeIntervalSince(startTime) - totalWaitSeconds > timeoutSeconds {
|
|
@@ -1780,6 +1911,31 @@ class ExplorerTests: XCTestCase {
|
|
|
1780
1911
|
)
|
|
1781
1912
|
}
|
|
1782
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
|
+
|
|
1783
1939
|
// Screenshot
|
|
1784
1940
|
let screenshot = app.screenshot()
|
|
1785
1941
|
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.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
|
}
|