@aarwitz/tapp 0.17.17 → 0.17.19
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.
- package/.claude-plugin/plugin.json +2 -2
- package/AGENTS.md +5 -0
- package/Harness/OCQAHarnessUITests/ExplorerTests.swift +154 -44
- package/README.md +10 -2
- package/bin/tapp.js +31 -6
- package/mcp-server/src/index.js +66 -5
- package/package.json +1 -1
- package/scripts/run-flow.sh +1 -0
|
@@ -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.19",
|
|
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.19",
|
|
28
28
|
"mcp"
|
|
29
29
|
],
|
|
30
30
|
"cwd": "${CLAUDE_PROJECT_DIR}"
|
package/AGENTS.md
CHANGED
|
@@ -24,6 +24,11 @@ npx -y @aarwitz/tapp@latest explore app.apk --platform android --app-id com.acme
|
|
|
24
24
|
npx -y @aarwitz/tapp@latest flow run .tapp/flows/smoke.yml # committed, keyless E2E replay
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
+
On iOS, `tree` only uses an installed app: it never builds, uninstalls, or replaces it. With
|
|
28
|
+
no target it uses the repository's recorded iOS target or the sole installed app; ambiguity
|
|
29
|
+
requires a choice. Pass a bundle id to inspect a particular sandbox build. To replace a build,
|
|
30
|
+
run `tapp build` explicitly first.
|
|
31
|
+
|
|
27
32
|
If repository onboarding detects multiple application targets, target detection is deterministic but
|
|
28
33
|
the choice is the user's. Explicit `init --explore` asks even when the model has a saved default; a
|
|
29
34
|
later bare `explore` may consume that default. In a human TTY, Tapp displays a numbered selector and
|
|
@@ -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
|
|
|
@@ -930,6 +994,9 @@ class ExplorerTests: XCTestCase {
|
|
|
930
994
|
// Per-flow wait default (field issue #20): a splash that prefetches for ~8s makes the
|
|
931
995
|
// fixed 6s wait_for fail on a healthy app. Steps may still override individually.
|
|
932
996
|
let flowDefaultTimeoutMs = (flow["timeoutMs"] as? Int) ?? (flow["timeout"] as? Int) ?? 6000
|
|
997
|
+
// XCTest runs teardown blocks even when a step aborts unexpectedly. Keep the final
|
|
998
|
+
// visible frame inspectable for both successful and failing replays (public issue #26).
|
|
999
|
+
addTeardownBlock { [self] in recordFlowScreenshot(name: "flow-final") }
|
|
933
1000
|
|
|
934
1001
|
// Variable substitution: $TEST_EMAIL/$TEST_PASSWORD from creds, plus any OCQA_FLOW_VARS.
|
|
935
1002
|
var vars: [String: String] = ["TEST_EMAIL": resolve("OCQA_TEST_EMAIL", fallback: "test@example.com"),
|
|
@@ -964,6 +1031,7 @@ class ExplorerTests: XCTestCase {
|
|
|
964
1031
|
let taskName = (raw["__tappTask"] as? [String: Any])?["name"] as? String ?? ""
|
|
965
1032
|
// A step is either {action: value} sugar or {action:..., target/value/...}. Normalize.
|
|
966
1033
|
let (action, step) = normalizeFlowStep(raw)
|
|
1034
|
+
noteNavigationTitle() // navigation history, so `back` can recognise its control
|
|
967
1035
|
let target = subst((step["target"] as? String) ?? "")
|
|
968
1036
|
let value = subst((step["value"] as? String) ?? "")
|
|
969
1037
|
let timeoutMs = (step["timeoutMs"] as? Int) ?? (step["timeout"] as? Int) ?? flowDefaultTimeoutMs
|
|
@@ -1004,7 +1072,11 @@ class ExplorerTests: XCTestCase {
|
|
|
1004
1072
|
case "swipe":
|
|
1005
1073
|
switch target.lowercased() { case "down": app.swipeDown(); case "left": app.swipeLeft(); case "right": app.swipeRight(); default: app.swipeUp() }
|
|
1006
1074
|
case "back":
|
|
1007
|
-
|
|
1075
|
+
// Same rule as the session (#10): a back that did not navigate is a failed step,
|
|
1076
|
+
// not a silent pass — the rest of the Flow would otherwise run against the wrong
|
|
1077
|
+
// screen and fail somewhere unrelated.
|
|
1078
|
+
status = sessionBack() == "ok" ? "pass" : "fail"
|
|
1079
|
+
if status == "fail" { detail = "back did not navigate: no back control on this screen, or tapping it did not change the screen\(visibleLabelsHint())" }
|
|
1008
1080
|
case "wait":
|
|
1009
1081
|
Thread.sleep(forTimeInterval: Double(timeoutMs) / 1000.0)
|
|
1010
1082
|
case "wait_for":
|
|
@@ -1052,17 +1124,7 @@ class ExplorerTests: XCTestCase {
|
|
|
1052
1124
|
// Web and Android flows write flow-failure-N.png next to the log; iOS buried the
|
|
1053
1125
|
// evidence in the .xcresult, so seeing the failing screen needed a second run
|
|
1054
1126
|
// (field issue #8). Write the same file, and keep the XCTest attachment too.
|
|
1055
|
-
|
|
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
|
-
}
|
|
1127
|
+
recordFlowScreenshot(name: "flow-failure-\(idx)")
|
|
1066
1128
|
// A failed step surfaces as a finding, with the same shape QA findings use.
|
|
1067
1129
|
let screen = detectTitle(readUITree(app)) ?? "Unknown"
|
|
1068
1130
|
print("OCQA_ISSUE:{\"type\":\"flow_assertion_failed\",\"severity\":\"high\",\"title\":\"\(escapeJSON("Step \(idx) (\(action)) failed: \(detail)"))\",\"screen\":\"\(escapeJSON(screen))\",\"step\":\(idx)}")
|
|
@@ -1072,13 +1134,29 @@ class ExplorerTests: XCTestCase {
|
|
|
1072
1134
|
waitForAnimationsToSettle()
|
|
1073
1135
|
}
|
|
1074
1136
|
|
|
1075
|
-
let shot = app.screenshot()
|
|
1076
|
-
let att = XCTAttachment(screenshot: shot); att.name = "flow_final"; att.lifetime = .keepAlways; add(att)
|
|
1077
1137
|
let contractResult = contractName.isEmpty ? "" : ",\"contract\":\"\(escapeJSON(contractName))\",\"criticality\":\"\(escapeJSON(contractCriticality))\""
|
|
1078
1138
|
print("OCQA_FLOW_RESULT:{\"passed\":\(failed == 0),\"name\":\"\(escapeJSON(flow["name"] as? String ?? "flow"))\",\"kind\":\"\(escapeJSON(evidenceKind))\"\(contractResult),\"total\":\(steps.count),\"executed\":\(executed),\"failed\":\(failed)}")
|
|
1079
1139
|
if failed > 0 { XCTFail("Flow had \(failed) failed step(s)") }
|
|
1080
1140
|
}
|
|
1081
1141
|
|
|
1142
|
+
private func recordFlowScreenshot(name: String) {
|
|
1143
|
+
// Screen capture also works after an app crash and includes system sheets covering it.
|
|
1144
|
+
let shot = XCUIScreen.main.screenshot()
|
|
1145
|
+
let attachment = XCTAttachment(screenshot: shot)
|
|
1146
|
+
attachment.name = name.replacingOccurrences(of: "-", with: "_")
|
|
1147
|
+
attachment.lifetime = .keepAlways
|
|
1148
|
+
add(attachment)
|
|
1149
|
+
let evidenceDir = resolve("OCQA_FLOW_EVIDENCE_DIR")
|
|
1150
|
+
guard !evidenceDir.isEmpty else { return }
|
|
1151
|
+
do {
|
|
1152
|
+
let directory = URL(fileURLWithPath: evidenceDir)
|
|
1153
|
+
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
1154
|
+
try shot.pngRepresentation.write(to: directory.appendingPathComponent("\(name).png"), options: .atomic)
|
|
1155
|
+
} catch {
|
|
1156
|
+
print("OCQA_EVIDENCE_WARNING:Could not write \(name).png: \(error.localizedDescription); screenshot retained in result.xcresult")
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1082
1160
|
/// Accepts both sugar (`{ tap: "Sign In" }`) and explicit (`{ action: "tap", target: "Sign In" }`).
|
|
1083
1161
|
private func normalizeFlowStep(_ raw: [String: Any]) -> (String, [String: Any]) {
|
|
1084
1162
|
if let action = raw["action"] as? String { return (action.lowercased(), raw) }
|
|
@@ -1500,9 +1578,20 @@ class ExplorerTests: XCTestCase {
|
|
|
1500
1578
|
return
|
|
1501
1579
|
}
|
|
1502
1580
|
|
|
1503
|
-
//
|
|
1504
|
-
app
|
|
1505
|
-
|
|
1581
|
+
// Dismiss an observed system alert directly. A blind app.tap() here used to press
|
|
1582
|
+
// whichever app control occupied the center BEFORE the first screen was recorded,
|
|
1583
|
+
// so the explorer could then flag its own second tap as unresponsive (issue #27).
|
|
1584
|
+
let startupAlert = XCUIApplication(bundleIdentifier: "com.apple.springboard").alerts.firstMatch
|
|
1585
|
+
if startupAlert.exists {
|
|
1586
|
+
for label in ["Allow While Using App", "Allow Once", "OK", "Allow", "Don't Allow"] {
|
|
1587
|
+
let button = startupAlert.buttons[label]
|
|
1588
|
+
if button.exists && button.isHittable {
|
|
1589
|
+
button.tap()
|
|
1590
|
+
waitForAnimationsToSettle()
|
|
1591
|
+
break
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1506
1595
|
|
|
1507
1596
|
// Record the TRUE initial screen (e.g. a "Get Started" welcome sheet) before
|
|
1508
1597
|
// navigateToRootScreen() below auto-dismisses it. Without this, grounding (AI-generate,
|
|
@@ -2608,7 +2697,8 @@ class ExplorerTests: XCTestCase {
|
|
|
2608
2697
|
dismissKeyboardIfNeeded()
|
|
2609
2698
|
}
|
|
2610
2699
|
|
|
2611
|
-
|
|
2700
|
+
// Compare with the screen immediately before the action, after keyboard dismissal.
|
|
2701
|
+
let preContentSig = contentSignature(readUITree(app))
|
|
2612
2702
|
let actionDesc = performSmartAction(
|
|
2613
2703
|
on: target,
|
|
2614
2704
|
in: app,
|
|
@@ -2729,8 +2819,8 @@ class ExplorerTests: XCTestCase {
|
|
|
2729
2819
|
// value change) is likely a dead control — exactly the "clicking a button does
|
|
2730
2820
|
// nothing" case. We compare a content signature (labels + values) so controls that
|
|
2731
2821
|
// only change a value (counters, toggles) are never falsely flagged, exclude
|
|
2732
|
-
// selection/value controls, and
|
|
2733
|
-
//
|
|
2822
|
+
// selection/value controls, and observe unchanged controls for a bounded window
|
|
2823
|
+
// to allow asynchronous updates. NATIVE buttons only (rawValue:9): web buttons/links live
|
|
2734
2824
|
// inside a WKWebView, whose dynamic DOM changes aren't reliably reflected in the
|
|
2735
2825
|
// accessibility tree, so we can't measure their responsiveness this way.
|
|
2736
2826
|
let isButton = target.type.contains("rawValue: 9")
|
|
@@ -2747,12 +2837,11 @@ class ExplorerTests: XCTestCase {
|
|
|
2747
2837
|
&& !isInsideSelectionContainer(target)
|
|
2748
2838
|
&& !isSystemHandoffControl(humanLabel)
|
|
2749
2839
|
&& app.state == .runningForeground && !reportedIssueKeys.contains(noOpKey) {
|
|
2750
|
-
let
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
if app.state == .runningForeground, !post2.isEmpty, contentSignature(post2) == preContentSig {
|
|
2840
|
+
let remaining = timeoutSeconds - (Date().timeIntervalSince(startTime) - totalWaitSeconds)
|
|
2841
|
+
// A shortened observation is inconclusive, never evidence of a dead control.
|
|
2842
|
+
if remaining >= 8.0 {
|
|
2843
|
+
let response = observeControlResponse(previousContent: preContentSig)
|
|
2844
|
+
if response == .unchanged {
|
|
2756
2845
|
reportedIssueKeys.insert(noOpKey)
|
|
2757
2846
|
// A dead NAVIGATION control (Back/Close/Done/Cancel) is worse than a dead
|
|
2758
2847
|
// feature button: it strands the user on the screen (and strands this
|
|
@@ -2763,7 +2852,7 @@ class ExplorerTests: XCTestCase {
|
|
|
2763
2852
|
let issueTitle = isNavControl
|
|
2764
2853
|
? "Navigation control does nothing: '\(humanLabel)' — users may be stuck on this screen"
|
|
2765
2854
|
: "Control may be unresponsive: '\(humanLabel)'"
|
|
2766
|
-
let issueDesc = "Tapping '\(humanLabel)' on '\(titleStr)' produced no
|
|
2855
|
+
let issueDesc = "Tapping '\(humanLabel)' on '\(titleStr)' produced no observable navigation, content, selection, or enabled-state change within 8 seconds."
|
|
2767
2856
|
issues.append((type: "unresponsive_element", severity: sev, title: issueTitle, desc: issueDesc))
|
|
2768
2857
|
print("OCQA_ISSUE:{\"type\":\"unresponsive_element\",\"severity\":\"\(sev)\",\"title\":\"\(escapeJSON(issueTitle))\",\"screen\":\"\(escapedTitle)\",\"control\":\"\(escapeJSON(humanLabel))\",\"step\":\(actionCount)}")
|
|
2769
2858
|
}
|
|
@@ -3257,7 +3346,28 @@ class ExplorerTests: XCTestCase {
|
|
|
3257
3346
|
/// text). Used to tell a genuinely dead button ("nothing happened") from one that only changed
|
|
3258
3347
|
/// a value.
|
|
3259
3348
|
private func contentSignature(_ elements: [SimpleElement]) -> String {
|
|
3260
|
-
elements.map { "\($0.type)|\($0.identifier)|\($0.label)|\($0.value)" }.sorted().joined(separator: "~")
|
|
3349
|
+
elements.map { "\($0.type)|\($0.identifier)|\($0.label)|\($0.value)|\($0.isSelected)|\($0.isEnabled)" }.sorted().joined(separator: "~")
|
|
3350
|
+
}
|
|
3351
|
+
|
|
3352
|
+
private enum ControlResponse { case changed, unchanged, unobserved }
|
|
3353
|
+
|
|
3354
|
+
/// Stable element counts only establish that animations settled; they say nothing about a
|
|
3355
|
+
/// pending network response. Exit immediately on a semantic change, but require the full
|
|
3356
|
+
/// observation window before reporting a no-op. Missing trees and ongoing loading cannot
|
|
3357
|
+
/// establish that a control is inert.
|
|
3358
|
+
private func observeControlResponse(previousContent: String, timeout: TimeInterval = 8.0) -> ControlResponse {
|
|
3359
|
+
guard !previousContent.isEmpty else { return .unobserved }
|
|
3360
|
+
let deadline = Date().addingTimeInterval(timeout)
|
|
3361
|
+
while true {
|
|
3362
|
+
guard app.state == .runningForeground else { return .unobserved }
|
|
3363
|
+
let current = readUITree(app)
|
|
3364
|
+
guard !current.isEmpty else { return .unobserved }
|
|
3365
|
+
if contentSignature(current) != previousContent { return .changed }
|
|
3366
|
+
if Date() >= deadline {
|
|
3367
|
+
return hasIndeterminateLoadingIndicator() ? .unobserved : .unchanged
|
|
3368
|
+
}
|
|
3369
|
+
Thread.sleep(forTimeInterval: min(0.25, max(0, deadline.timeIntervalSinceNow)))
|
|
3370
|
+
}
|
|
3261
3371
|
}
|
|
3262
3372
|
|
|
3263
3373
|
/// Machine identifiers sometimes leak into a11y titles — mangled runtime type names
|
package/README.md
CHANGED
|
@@ -163,8 +163,8 @@ configuration and existing-file collisions, and never commits, pushes, enables b
|
|
|
163
163
|
or creates GitHub resources. Review and pin the generated Tapp release reference to its immutable
|
|
164
164
|
commit SHA before production.
|
|
165
165
|
|
|
166
|
-
|
|
167
|
-
|
|
166
|
+
`open` and `explore` accept a repo directory, a `path/to/App.app`, a bundle id, or no target
|
|
167
|
+
(auto-detecting the repository or installed app):
|
|
168
168
|
|
|
169
169
|
```bash
|
|
170
170
|
npx -y @aarwitz/tapp@latest open [target] # launch the app → screen summary + screenshot file
|
|
@@ -174,6 +174,14 @@ npx -y @aarwitz/tapp@latest apps # what's installed on the simulator
|
|
|
174
174
|
npx -y @aarwitz/tapp@latest build [dir] # just build + install (scheme auto-detected)
|
|
175
175
|
```
|
|
176
176
|
|
|
177
|
+
On iOS, `tree` inspects an installed app and never builds, uninstalls, or replaces it. With no
|
|
178
|
+
target, it uses the repository's recorded iOS target or the sole installed app. If the choice
|
|
179
|
+
is ambiguous, it lists the installed apps for you to select. Pass a bundle id to inspect a
|
|
180
|
+
specific sandbox build; run `tapp build` explicitly when you intend to replace it.
|
|
181
|
+
|
|
182
|
+
iOS Flow evidence includes `flow-final.png` on passing and failing replays, plus
|
|
183
|
+
`flow-failure-N.png` at a failed step, alongside the log, JSON report, and XCTest result bundle.
|
|
184
|
+
|
|
177
185
|
Web: `npx -y @aarwitz/tapp@latest explore http://localhost:3000` *(one-time setup:
|
|
178
186
|
`npm i -g playwright && npx playwright install chromium`)*. Add `--watch` to open Tapp's controlled,
|
|
179
187
|
isolated Chromium window and follow its clicks with an on-page pointer/action label. Tapp hides that
|
package/bin/tapp.js
CHANGED
|
@@ -222,7 +222,7 @@ function printEngineError(r) {
|
|
|
222
222
|
console.error(" Available targets:");
|
|
223
223
|
for (const choice of choices) {
|
|
224
224
|
const selector = choice.name || choice.id || choice.sourcePath;
|
|
225
|
-
console.error(` ${choice.platform ? `${choice.platform} · ` : ""}${choice.name || choice.id}${choice.sourcePath ? ` (${choice.sourcePath})` : ""}${selector ? ` — use --target ${JSON.stringify(selector)}` : ""}`);
|
|
225
|
+
console.error(` ${choice.platform ? `${choice.platform} · ` : ""}${choice.name || choice.id}${choice.sourcePath ? ` (${choice.sourcePath})` : ""}${choice.command ? ` — ${choice.command}` : selector ? ` — use --target ${JSON.stringify(selector)}` : ""}`);
|
|
226
226
|
}
|
|
227
227
|
}
|
|
228
228
|
if (r.details?.remediation) console.error(` Next: ${r.details.remediation}`);
|
|
@@ -250,8 +250,28 @@ async function promptForInitTarget(details) {
|
|
|
250
250
|
|
|
251
251
|
// Turn whatever the user gave us (nothing / repo dir / .app / bundle id) into an installed
|
|
252
252
|
// bundle id, narrating build/install progress on stderr.
|
|
253
|
-
async function resolveTargetOrExit(engine, input) {
|
|
254
|
-
|
|
253
|
+
async function resolveTargetOrExit(engine, input, { inspect = false } = {}) {
|
|
254
|
+
let resolved = inspect
|
|
255
|
+
? await engine.resolveInstalledAppTarget(input || "")
|
|
256
|
+
: await engine.resolveAppTarget(input || "", { onStatus: (s) => console.error(`⏳ ${s}`) });
|
|
257
|
+
if (inspect && resolved.details?.choices?.length && process.stdin.isTTY && process.stderr.isTTY && !process.env.CI) {
|
|
258
|
+
const { createInterface } = await import("node:readline/promises");
|
|
259
|
+
const terminal = createInterface({ input: process.stdin, output: process.stderr });
|
|
260
|
+
const choices = resolved.details.choices;
|
|
261
|
+
console.error(`\n${resolved.error}`);
|
|
262
|
+
choices.forEach((choice, index) => console.error(` ${index + 1}) ${choice.name} (${choice.bundleId})`));
|
|
263
|
+
try {
|
|
264
|
+
while (true) {
|
|
265
|
+
const answer = String(await terminal.question(`Select 1-${choices.length} (or q to cancel): `)).trim();
|
|
266
|
+
if (/^(q|quit|cancel)$/i.test(answer)) break;
|
|
267
|
+
const selected = Number(answer);
|
|
268
|
+
if (Number.isInteger(selected) && selected >= 1 && selected <= choices.length) {
|
|
269
|
+
resolved = await engine.resolveInstalledAppTarget(choices[selected - 1].bundleId);
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
} finally { terminal.close(); }
|
|
274
|
+
}
|
|
255
275
|
if (resolved.error) {
|
|
256
276
|
printEngineError(resolved);
|
|
257
277
|
process.exit(1);
|
|
@@ -266,11 +286,11 @@ function safeCommandUsage(verb) {
|
|
|
266
286
|
focus: "tapp focus \"SCREEN OR CONTROL\" [target] [--platform ios|android|web] [--project-dir REPO] [--target NAME|PATH] [--map FILE] [--out FILE]",
|
|
267
287
|
init: "tapp init [repo] [--explore] [--refresh] [--platform PLATFORM] [--target NAME] [--url URL] [--watch] [--dry-run]",
|
|
268
288
|
open: "tapp open [target] [--platform ios|android|web] [--out FILE] [--tap TEXT] [--wait-for TEXT]\n Web: [--device \"iPhone 13\"] [--viewport 390x844] [--full-page]",
|
|
269
|
-
tree: "tapp tree [target] [--platform ios|android|web] [--json] [--tap TEXT] [--wait-for TEXT]\n Web: [--device \"iPhone 13\"] [--viewport 390x844]",
|
|
289
|
+
tree: "tapp tree [target] [--platform ios|android|web] [--json] [--tap TEXT] [--wait-for TEXT]\n iOS: inspects an installed app; never builds or installs. Use tapp build explicitly first if needed.\n Web: [--device \"iPhone 13\"] [--viewport 390x844]",
|
|
270
290
|
shot: "tapp shot [--out FILE]",
|
|
271
291
|
apps: "tapp apps",
|
|
272
292
|
build: "tapp build [repo] [--scheme NAME] [--configuration NAME]",
|
|
273
|
-
flow: "tapp flow example\ntapp flow steps [--json]\ntapp flow validate FILE [--platform PLATFORM] [--map FILE]\ntapp flow run FILE [--actor NAME] [--email VALUE] [--password VALUE] [--device \"iPhone 13\"] [--viewport 390x844]\n Exit codes: 0 replay passed · 1 replay failed · 2 infrastructure/usage error",
|
|
293
|
+
flow: "tapp flow example\ntapp flow steps [--json]\ntapp flow validate FILE [--platform PLATFORM] [--map FILE]\ntapp flow run FILE [--actor NAME] [--email VALUE] [--password VALUE] [--device \"iPhone 13\"] [--viewport 390x844]\n iOS: [--launch-arg ARG] [--launch-env JSON]\n Exit codes: 0 replay passed · 1 replay failed · 2 infrastructure/usage error",
|
|
274
294
|
task: "tapp task validate FILE [--platform PLATFORM] [--map FILE]\ntapp task compile FILE --platform PLATFORM [--inputs JSON] [--out FILE]\ntapp task run FILE --platform PLATFORM [--url URL|--bundle-id ID|--app-id ID] [--inputs JSON]",
|
|
275
295
|
contract: "tapp contract validate FILE [--platform PLATFORM] [--map FILE]\ntapp contract compile FILE --platform PLATFORM [--out FILE]\ntapp contract run FILE --platform PLATFORM [--url URL|--bundle-id ID|--app-id ID]\n Exit codes: 0 contract held · 1 contract failed · 2 infrastructure/usage error",
|
|
276
296
|
scenario: "tapp scenario validate FILE [--project-dir DIR]\ntapp scenario run FILE --platform web --url URL [--project-dir DIR]\n Exit codes: 0 scenario passed · 1 scenario failed · 2 infrastructure/usage error",
|
|
@@ -962,7 +982,7 @@ switch (command) {
|
|
|
962
982
|
console.error(`❌ ${sim.error}`);
|
|
963
983
|
process.exit(1);
|
|
964
984
|
}
|
|
965
|
-
const bundleId = await resolveTargetOrExit(engine, positionals[0]);
|
|
985
|
+
const bundleId = await resolveTargetOrExit(engine, positionals[0], { inspect: true });
|
|
966
986
|
const r = await engine.captureUiTree(bundleId);
|
|
967
987
|
if (r.error) {
|
|
968
988
|
console.error(`❌ ${r.error}`);
|
|
@@ -1230,6 +1250,11 @@ switch (command) {
|
|
|
1230
1250
|
const evidenceDir = path.join(tappHome, "captures", `flow-${platform}-${token}`);
|
|
1231
1251
|
fs.mkdirSync(path.dirname(flowLog), { recursive: true });
|
|
1232
1252
|
const env = { ...process.env, FLOW_LOG: flowLog, TAPP_FLOW_EVIDENCE_DIR: evidenceDir };
|
|
1253
|
+
if (platform === "ios") {
|
|
1254
|
+
const launch = iosLaunchOptions(flags, rest);
|
|
1255
|
+
if (launch.appLaunchArgs) env.OCQA_APP_LAUNCH_ARGS_JSON = JSON.stringify(launch.appLaunchArgs);
|
|
1256
|
+
if (launch.appLaunchEnv) env.OCQA_APP_LAUNCH_ENV_JSON = JSON.stringify(launch.appLaunchEnv);
|
|
1257
|
+
}
|
|
1233
1258
|
if (typeof flags.email === "string") env.OCQA_TEST_EMAIL = flags.email;
|
|
1234
1259
|
if (typeof flags.password === "string") env.OCQA_TEST_PASSWORD = flags.password;
|
|
1235
1260
|
if (typeof flags.device === "string") env.TAPP_WEB_DEVICE = flags.device;
|
package/mcp-server/src/index.js
CHANGED
|
@@ -137,7 +137,9 @@ function runCommand(command, args = [], options = {}) {
|
|
|
137
137
|
const timeoutMessage = timedOut ? `\nProcess timed out after ${timeoutMs}ms` : "";
|
|
138
138
|
resolve({
|
|
139
139
|
code: timedOut ? 124 : (code ?? 1),
|
|
140
|
-
|
|
140
|
+
// Structured inventories are parsed internally; diagnostic truncation would corrupt
|
|
141
|
+
// their JSON/plist before the caller sees it (large hosted simulator inventories).
|
|
142
|
+
stdout: options.rawStdout ? stdout : clampOutput(stdout),
|
|
141
143
|
stderr: clampOutput(`${stderr}${timeoutMessage}`.trim()),
|
|
142
144
|
timedOut,
|
|
143
145
|
});
|
|
@@ -147,7 +149,7 @@ function runCommand(command, args = [], options = {}) {
|
|
|
147
149
|
clearTimeout(timer);
|
|
148
150
|
resolve({
|
|
149
151
|
code: 1,
|
|
150
|
-
stdout: clampOutput(stdout),
|
|
152
|
+
stdout: options.rawStdout ? stdout : clampOutput(stdout),
|
|
151
153
|
stderr: clampOutput(`${stderr}\n${error.message}`.trim()),
|
|
152
154
|
timedOut: false,
|
|
153
155
|
});
|
|
@@ -205,7 +207,7 @@ function summarizeCapture(runPath) {
|
|
|
205
207
|
|
|
206
208
|
|
|
207
209
|
async function listSimulators() {
|
|
208
|
-
const res = await runCommand("xcrun", ["simctl", "list", "devices", "-j"]);
|
|
210
|
+
const res = await runCommand("xcrun", ["simctl", "list", "devices", "-j"], { rawStdout: true });
|
|
209
211
|
if (res.code !== 0) return { error: res.stderr || "simctl failed", simulators: [] };
|
|
210
212
|
let data;
|
|
211
213
|
try {
|
|
@@ -280,12 +282,12 @@ function notInstalledError(bundleId, booted) {
|
|
|
280
282
|
// and the tapp_build MCP tool.
|
|
281
283
|
|
|
282
284
|
export async function listInstalledUserApps() {
|
|
283
|
-
const r = await runCommand("xcrun", ["simctl", "listapps", "booted"], { timeoutMs: 30_000 });
|
|
285
|
+
const r = await runCommand("xcrun", ["simctl", "listapps", "booted"], { timeoutMs: 30_000, rawStdout: true });
|
|
284
286
|
if (r.code !== 0) return { error: "Could not list installed apps", details: { stderr: r.stderr } };
|
|
285
287
|
// simctl emits an old-style plist; plutil converts it.
|
|
286
288
|
const tmp = path.join(os.tmpdir(), `tapp-apps-${Date.now().toString(36)}.plist`);
|
|
287
289
|
fs.writeFileSync(tmp, r.stdout);
|
|
288
|
-
const conv = await runCommand("plutil", ["-convert", "json", "-o", "-", tmp], { timeoutMs: 30_000 });
|
|
290
|
+
const conv = await runCommand("plutil", ["-convert", "json", "-o", "-", tmp], { timeoutMs: 30_000, rawStdout: true });
|
|
289
291
|
try { fs.rmSync(tmp, { force: true }); } catch {}
|
|
290
292
|
let data;
|
|
291
293
|
try {
|
|
@@ -502,6 +504,65 @@ export async function installAppOnBootedSim(appPath, { cleanInstall = true, onSt
|
|
|
502
504
|
return { bundleId };
|
|
503
505
|
}
|
|
504
506
|
|
|
507
|
+
// Inspection must never enter the build/install resolver. Even a clean install of the same
|
|
508
|
+
// bundle id can replace a staging binary and destroy its data (public issue #25).
|
|
509
|
+
export async function resolveInstalledAppTarget(input, { cwd = process.cwd(), listApps = listInstalledUserApps, isInstalled = appInstalledOnBootedSim } = {}) {
|
|
510
|
+
const target = String(input || "").trim();
|
|
511
|
+
let bundleId = "";
|
|
512
|
+
let dir = cwd;
|
|
513
|
+
if (target) {
|
|
514
|
+
const candidate = path.resolve(cwd, target);
|
|
515
|
+
if (!target.includes("/") && target.includes(".") && !fs.existsSync(candidate)) {
|
|
516
|
+
// Explicit ids may name system apps too; the user-app picker intentionally omits them.
|
|
517
|
+
if (!await isInstalled(target)) return { error: `${target} is not installed on the booted simulator. Install the intended build first; tree will not replace it.` };
|
|
518
|
+
return { bundleId: target, via: "using the installed app", targetResolution: { kind: "installed-application", bundleId: target } };
|
|
519
|
+
}
|
|
520
|
+
else if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory() && !/\.(app|xcodeproj|xcworkspace)$/.test(candidate)) dir = candidate;
|
|
521
|
+
else return { error: "tree inspects an installed app. Pass its bundle id, or run npx -y @aarwitz/tapp@latest build explicitly before inspecting it." };
|
|
522
|
+
}
|
|
523
|
+
const listed = await listApps();
|
|
524
|
+
if (listed.error) return listed;
|
|
525
|
+
const apps = listed.apps;
|
|
526
|
+
if (!bundleId) {
|
|
527
|
+
const modelPath = existingProjectArtifactPath(dir, "application-model.json");
|
|
528
|
+
if (fs.existsSync(modelPath)) {
|
|
529
|
+
let model;
|
|
530
|
+
try {
|
|
531
|
+
model = JSON.parse(fs.readFileSync(modelPath, "utf8"));
|
|
532
|
+
if (model.kind !== "tapp-application-model" || !Array.isArray(model.targets)) throw new Error("invalid application model");
|
|
533
|
+
} catch (error) {
|
|
534
|
+
return { error: `Cannot read ${modelPath}: ${error.message}. Pass an installed bundle id explicitly.` };
|
|
535
|
+
}
|
|
536
|
+
const targets = model.targets.filter((item) => item.platform === "ios");
|
|
537
|
+
const selected = targets.find((item) => item.id === model.application?.defaultTargetId)
|
|
538
|
+
|| (targets.length === 1 ? targets[0] : null);
|
|
539
|
+
bundleId = String(selected?.runtime?.bundleId || "").trim();
|
|
540
|
+
// A repository with multiple/unconfirmed targets must not silently fall back to an
|
|
541
|
+
// unrelated app just because it is the only one installed on this simulator.
|
|
542
|
+
if (!bundleId) return installedAppChoices(apps, "The repository has no unambiguous installed iOS target. Pass the app's bundle id.");
|
|
543
|
+
} else if (target) {
|
|
544
|
+
return installedAppChoices(apps, "This repository has no recorded iOS bundle id. Pass the installed app's bundle id; tree does not build it.");
|
|
545
|
+
} else if (apps.length === 1) {
|
|
546
|
+
bundleId = apps[0].bundleId;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
if (bundleId) {
|
|
550
|
+
if (!apps.some((item) => item.bundleId === bundleId)) return { error: `${bundleId} is not installed on the booted simulator. Install the intended build first; tree will not replace it.` };
|
|
551
|
+
return { bundleId, via: "using the installed app", targetResolution: { kind: "installed-application", bundleId } };
|
|
552
|
+
}
|
|
553
|
+
return installedAppChoices(apps, apps.length ? "Choose the installed app to inspect." : "No user app is installed. Install the intended build first; tree does not build or install apps.");
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function installedAppChoices(apps, error) {
|
|
557
|
+
return {
|
|
558
|
+
error,
|
|
559
|
+
details: {
|
|
560
|
+
reason: apps.length ? "target-selection-required" : "app-not-installed",
|
|
561
|
+
choices: apps.map((app) => ({ platform: "ios", name: app.name, bundleId: app.bundleId, selector: app.bundleId, command: `npx -y @aarwitz/tapp@latest tree ${app.bundleId}` })),
|
|
562
|
+
},
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
505
566
|
export async function resolveAppTarget(input, { cwd = process.cwd(), onStatus = () => {}, scheme = "", configuration = "Debug" } = {}) {
|
|
506
567
|
const t = (input || "").trim();
|
|
507
568
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aarwitz/tapp",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.19",
|
|
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
|
@@ -98,6 +98,7 @@ TEST_RUNNER_OCQA_CONFIG_PATH="$CFG" xcodebuild test-without-building \
|
|
|
98
98
|
[ -n "$RESPONDER_PID" ] && { kill "$RESPONDER_PID" 2>/dev/null; wait "$RESPONDER_PID" 2>/dev/null; }
|
|
99
99
|
|
|
100
100
|
cp "$LOG" "$EVIDENCE_DIR/flow.log"
|
|
101
|
+
grep '^OCQA_EVIDENCE_WARNING:' "$LOG" >&2 || true
|
|
101
102
|
python3 "$ROOT/scripts/flow_lib.py" report --json "$LOG" > "$EVIDENCE_DIR/flow-report.json"
|
|
102
103
|
echo ""
|
|
103
104
|
python3 "$ROOT/scripts/flow_lib.py" report "$LOG"
|