@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.16",
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.16",
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
- private func sessionBack() {
754
- let backButton = app.navigationBars.buttons.firstMatch
755
- if backButton.exists && backButton.isHittable {
756
- backButton.tap()
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
- app.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0.5))
759
- .press(forDuration: 0.05, thenDragTo: app.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5)))
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.16",
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",
@@ -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
  }