@aarwitz/tapp 0.17.18 → 0.17.20
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 +100 -36
- package/README.md +10 -2
- package/bin/tapp.js +33 -25
- package/mcp-server/src/index.js +97 -8
- package/mcp-server/src/project-config.js +18 -0
- package/package.json +1 -1
- package/scripts/flow_lib.py +26 -7
- package/scripts/quick-capture.sh +53 -31
- package/scripts/run-flow.sh +18 -11
|
@@ -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.20",
|
|
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.20",
|
|
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
|
|
@@ -715,8 +715,11 @@ class ExplorerTests: XCTestCase {
|
|
|
715
715
|
guard let emailF = emailField else { return ("no_login_form", "no email/username field visible") }
|
|
716
716
|
guard let passF = passwordField else { return ("no_login_form", "no password field visible") }
|
|
717
717
|
|
|
718
|
-
replaceText(on: emailF, with: email)
|
|
719
|
-
|
|
718
|
+
guard replaceText(on: emailF, with: email) else { return ("input_failed", lastTypeFailure) }
|
|
719
|
+
guard (emailF.value as? String) == email else {
|
|
720
|
+
return ("input_failed", "Email field did not retain the requested value; sign-in was not submitted")
|
|
721
|
+
}
|
|
722
|
+
guard replaceText(on: passF, with: password) else { return ("input_failed", lastTypeFailure) }
|
|
720
723
|
|
|
721
724
|
func formGone(within seconds: TimeInterval) -> Bool {
|
|
722
725
|
let deadline = Date().addingTimeInterval(seconds)
|
|
@@ -760,7 +763,7 @@ class ExplorerTests: XCTestCase {
|
|
|
760
763
|
// behavior). Detect and re-fill before submitting.
|
|
761
764
|
let pv = (passF.value as? String) ?? ""
|
|
762
765
|
if pv.isEmpty || pv == (passF.placeholderValue ?? "§none§") {
|
|
763
|
-
replaceText(on: passF, with: password)
|
|
766
|
+
guard replaceText(on: passF, with: password) else { return ("input_failed", lastTypeFailure) }
|
|
764
767
|
dismissKeyboardIfPresent()
|
|
765
768
|
}
|
|
766
769
|
submit = submitButton() ?? submit
|
|
@@ -807,7 +810,9 @@ class ExplorerTests: XCTestCase {
|
|
|
807
810
|
/// titles we have actually been on; a leading navigation-bar button named after one of them
|
|
808
811
|
/// is a genuine back control, while a custom leading action (Share, Delete) is not.
|
|
809
812
|
private func noteNavigationTitle() {
|
|
810
|
-
let
|
|
813
|
+
let bar = app.navigationBars.firstMatch
|
|
814
|
+
guard bar.exists else { return }
|
|
815
|
+
let title = bar.identifier
|
|
811
816
|
guard !title.isEmpty, backTitleHistory.last != title else { return }
|
|
812
817
|
backTitleHistory.append(title)
|
|
813
818
|
if backTitleHistory.count > 20 { backTitleHistory.removeFirst() }
|
|
@@ -994,6 +999,9 @@ class ExplorerTests: XCTestCase {
|
|
|
994
999
|
// Per-flow wait default (field issue #20): a splash that prefetches for ~8s makes the
|
|
995
1000
|
// fixed 6s wait_for fail on a healthy app. Steps may still override individually.
|
|
996
1001
|
let flowDefaultTimeoutMs = (flow["timeoutMs"] as? Int) ?? (flow["timeout"] as? Int) ?? 6000
|
|
1002
|
+
// XCTest runs teardown blocks even when a step aborts unexpectedly. Keep the final
|
|
1003
|
+
// visible frame inspectable for both successful and failing replays (public issue #26).
|
|
1004
|
+
addTeardownBlock { [self] in recordFlowScreenshot(name: "flow-final") }
|
|
997
1005
|
|
|
998
1006
|
// Variable substitution: $TEST_EMAIL/$TEST_PASSWORD from creds, plus any OCQA_FLOW_VARS.
|
|
999
1007
|
var vars: [String: String] = ["TEST_EMAIL": resolve("OCQA_TEST_EMAIL", fallback: "test@example.com"),
|
|
@@ -1121,17 +1129,7 @@ class ExplorerTests: XCTestCase {
|
|
|
1121
1129
|
// Web and Android flows write flow-failure-N.png next to the log; iOS buried the
|
|
1122
1130
|
// evidence in the .xcresult, so seeing the failing screen needed a second run
|
|
1123
1131
|
// (field issue #8). Write the same file, and keep the XCTest attachment too.
|
|
1124
|
-
|
|
1125
|
-
let failureAttachment = XCTAttachment(screenshot: failureShot)
|
|
1126
|
-
failureAttachment.name = "flow_failure_\(idx)"
|
|
1127
|
-
failureAttachment.lifetime = .keepAlways
|
|
1128
|
-
add(failureAttachment)
|
|
1129
|
-
let evidenceDir = resolve("OCQA_FLOW_EVIDENCE_DIR")
|
|
1130
|
-
if !evidenceDir.isEmpty {
|
|
1131
|
-
let destination = URL(fileURLWithPath: evidenceDir).appendingPathComponent("flow-failure-\(idx).png")
|
|
1132
|
-
try? FileManager.default.createDirectory(at: URL(fileURLWithPath: evidenceDir), withIntermediateDirectories: true)
|
|
1133
|
-
try? failureShot.pngRepresentation.write(to: destination)
|
|
1134
|
-
}
|
|
1132
|
+
recordFlowScreenshot(name: "flow-failure-\(idx)")
|
|
1135
1133
|
// A failed step surfaces as a finding, with the same shape QA findings use.
|
|
1136
1134
|
let screen = detectTitle(readUITree(app)) ?? "Unknown"
|
|
1137
1135
|
print("OCQA_ISSUE:{\"type\":\"flow_assertion_failed\",\"severity\":\"high\",\"title\":\"\(escapeJSON("Step \(idx) (\(action)) failed: \(detail)"))\",\"screen\":\"\(escapeJSON(screen))\",\"step\":\(idx)}")
|
|
@@ -1141,13 +1139,29 @@ class ExplorerTests: XCTestCase {
|
|
|
1141
1139
|
waitForAnimationsToSettle()
|
|
1142
1140
|
}
|
|
1143
1141
|
|
|
1144
|
-
let shot = app.screenshot()
|
|
1145
|
-
let att = XCTAttachment(screenshot: shot); att.name = "flow_final"; att.lifetime = .keepAlways; add(att)
|
|
1146
1142
|
let contractResult = contractName.isEmpty ? "" : ",\"contract\":\"\(escapeJSON(contractName))\",\"criticality\":\"\(escapeJSON(contractCriticality))\""
|
|
1147
1143
|
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)}")
|
|
1148
1144
|
if failed > 0 { XCTFail("Flow had \(failed) failed step(s)") }
|
|
1149
1145
|
}
|
|
1150
1146
|
|
|
1147
|
+
private func recordFlowScreenshot(name: String) {
|
|
1148
|
+
// Screen capture also works after an app crash and includes system sheets covering it.
|
|
1149
|
+
let shot = XCUIScreen.main.screenshot()
|
|
1150
|
+
let attachment = XCTAttachment(screenshot: shot)
|
|
1151
|
+
attachment.name = name.replacingOccurrences(of: "-", with: "_")
|
|
1152
|
+
attachment.lifetime = .keepAlways
|
|
1153
|
+
add(attachment)
|
|
1154
|
+
let evidenceDir = resolve("OCQA_FLOW_EVIDENCE_DIR")
|
|
1155
|
+
guard !evidenceDir.isEmpty else { return }
|
|
1156
|
+
do {
|
|
1157
|
+
let directory = URL(fileURLWithPath: evidenceDir)
|
|
1158
|
+
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
1159
|
+
try shot.pngRepresentation.write(to: directory.appendingPathComponent("\(name).png"), options: .atomic)
|
|
1160
|
+
} catch {
|
|
1161
|
+
print("OCQA_EVIDENCE_WARNING:Could not write \(name).png: \(error.localizedDescription); screenshot retained in result.xcresult")
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1151
1165
|
/// Accepts both sugar (`{ tap: "Sign In" }`) and explicit (`{ action: "tap", target: "Sign In" }`).
|
|
1152
1166
|
private func normalizeFlowStep(_ raw: [String: Any]) -> (String, [String: Any]) {
|
|
1153
1167
|
if let action = raw["action"] as? String { return (action.lowercased(), raw) }
|
|
@@ -1569,9 +1583,20 @@ class ExplorerTests: XCTestCase {
|
|
|
1569
1583
|
return
|
|
1570
1584
|
}
|
|
1571
1585
|
|
|
1572
|
-
//
|
|
1573
|
-
app
|
|
1574
|
-
|
|
1586
|
+
// Dismiss an observed system alert directly. A blind app.tap() here used to press
|
|
1587
|
+
// whichever app control occupied the center BEFORE the first screen was recorded,
|
|
1588
|
+
// so the explorer could then flag its own second tap as unresponsive (issue #27).
|
|
1589
|
+
let startupAlert = XCUIApplication(bundleIdentifier: "com.apple.springboard").alerts.firstMatch
|
|
1590
|
+
if startupAlert.exists {
|
|
1591
|
+
for label in ["Allow While Using App", "Allow Once", "OK", "Allow", "Don't Allow"] {
|
|
1592
|
+
let button = startupAlert.buttons[label]
|
|
1593
|
+
if button.exists && button.isHittable {
|
|
1594
|
+
button.tap()
|
|
1595
|
+
waitForAnimationsToSettle()
|
|
1596
|
+
break
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1575
1600
|
|
|
1576
1601
|
// Record the TRUE initial screen (e.g. a "Get Started" welcome sheet) before
|
|
1577
1602
|
// navigateToRootScreen() below auto-dismisses it. Without this, grounding (AI-generate,
|
|
@@ -2677,7 +2702,8 @@ class ExplorerTests: XCTestCase {
|
|
|
2677
2702
|
dismissKeyboardIfNeeded()
|
|
2678
2703
|
}
|
|
2679
2704
|
|
|
2680
|
-
|
|
2705
|
+
// Compare with the screen immediately before the action, after keyboard dismissal.
|
|
2706
|
+
let preContentSig = contentSignature(readUITree(app))
|
|
2681
2707
|
let actionDesc = performSmartAction(
|
|
2682
2708
|
on: target,
|
|
2683
2709
|
in: app,
|
|
@@ -2798,8 +2824,8 @@ class ExplorerTests: XCTestCase {
|
|
|
2798
2824
|
// value change) is likely a dead control — exactly the "clicking a button does
|
|
2799
2825
|
// nothing" case. We compare a content signature (labels + values) so controls that
|
|
2800
2826
|
// only change a value (counters, toggles) are never falsely flagged, exclude
|
|
2801
|
-
// selection/value controls, and
|
|
2802
|
-
//
|
|
2827
|
+
// selection/value controls, and observe unchanged controls for a bounded window
|
|
2828
|
+
// to allow asynchronous updates. NATIVE buttons only (rawValue:9): web buttons/links live
|
|
2803
2829
|
// inside a WKWebView, whose dynamic DOM changes aren't reliably reflected in the
|
|
2804
2830
|
// accessibility tree, so we can't measure their responsiveness this way.
|
|
2805
2831
|
let isButton = target.type.contains("rawValue: 9")
|
|
@@ -2816,12 +2842,11 @@ class ExplorerTests: XCTestCase {
|
|
|
2816
2842
|
&& !isInsideSelectionContainer(target)
|
|
2817
2843
|
&& !isSystemHandoffControl(humanLabel)
|
|
2818
2844
|
&& app.state == .runningForeground && !reportedIssueKeys.contains(noOpKey) {
|
|
2819
|
-
let
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
if app.state == .runningForeground, !post2.isEmpty, contentSignature(post2) == preContentSig {
|
|
2845
|
+
let remaining = timeoutSeconds - (Date().timeIntervalSince(startTime) - totalWaitSeconds)
|
|
2846
|
+
// A shortened observation is inconclusive, never evidence of a dead control.
|
|
2847
|
+
if remaining >= 8.0 {
|
|
2848
|
+
let response = observeControlResponse(previousContent: preContentSig)
|
|
2849
|
+
if response == .unchanged {
|
|
2825
2850
|
reportedIssueKeys.insert(noOpKey)
|
|
2826
2851
|
// A dead NAVIGATION control (Back/Close/Done/Cancel) is worse than a dead
|
|
2827
2852
|
// feature button: it strands the user on the screen (and strands this
|
|
@@ -2832,7 +2857,7 @@ class ExplorerTests: XCTestCase {
|
|
|
2832
2857
|
let issueTitle = isNavControl
|
|
2833
2858
|
? "Navigation control does nothing: '\(humanLabel)' — users may be stuck on this screen"
|
|
2834
2859
|
: "Control may be unresponsive: '\(humanLabel)'"
|
|
2835
|
-
let issueDesc = "Tapping '\(humanLabel)' on '\(titleStr)' produced no
|
|
2860
|
+
let issueDesc = "Tapping '\(humanLabel)' on '\(titleStr)' produced no observable navigation, content, selection, or enabled-state change within 8 seconds."
|
|
2836
2861
|
issues.append((type: "unresponsive_element", severity: sev, title: issueTitle, desc: issueDesc))
|
|
2837
2862
|
print("OCQA_ISSUE:{\"type\":\"unresponsive_element\",\"severity\":\"\(sev)\",\"title\":\"\(escapeJSON(issueTitle))\",\"screen\":\"\(escapedTitle)\",\"control\":\"\(escapeJSON(humanLabel))\",\"step\":\(actionCount)}")
|
|
2838
2863
|
}
|
|
@@ -3326,7 +3351,28 @@ class ExplorerTests: XCTestCase {
|
|
|
3326
3351
|
/// text). Used to tell a genuinely dead button ("nothing happened") from one that only changed
|
|
3327
3352
|
/// a value.
|
|
3328
3353
|
private func contentSignature(_ elements: [SimpleElement]) -> String {
|
|
3329
|
-
elements.map { "\($0.type)|\($0.identifier)|\($0.label)|\($0.value)" }.sorted().joined(separator: "~")
|
|
3354
|
+
elements.map { "\($0.type)|\($0.identifier)|\($0.label)|\($0.value)|\($0.isSelected)|\($0.isEnabled)" }.sorted().joined(separator: "~")
|
|
3355
|
+
}
|
|
3356
|
+
|
|
3357
|
+
private enum ControlResponse { case changed, unchanged, unobserved }
|
|
3358
|
+
|
|
3359
|
+
/// Stable element counts only establish that animations settled; they say nothing about a
|
|
3360
|
+
/// pending network response. Exit immediately on a semantic change, but require the full
|
|
3361
|
+
/// observation window before reporting a no-op. Missing trees and ongoing loading cannot
|
|
3362
|
+
/// establish that a control is inert.
|
|
3363
|
+
private func observeControlResponse(previousContent: String, timeout: TimeInterval = 8.0) -> ControlResponse {
|
|
3364
|
+
guard !previousContent.isEmpty else { return .unobserved }
|
|
3365
|
+
let deadline = Date().addingTimeInterval(timeout)
|
|
3366
|
+
while true {
|
|
3367
|
+
guard app.state == .runningForeground else { return .unobserved }
|
|
3368
|
+
let current = readUITree(app)
|
|
3369
|
+
guard !current.isEmpty else { return .unobserved }
|
|
3370
|
+
if contentSignature(current) != previousContent { return .changed }
|
|
3371
|
+
if Date() >= deadline {
|
|
3372
|
+
return hasIndeterminateLoadingIndicator() ? .unobserved : .unchanged
|
|
3373
|
+
}
|
|
3374
|
+
Thread.sleep(forTimeInterval: min(0.25, max(0, deadline.timeIntervalSinceNow)))
|
|
3375
|
+
}
|
|
3330
3376
|
}
|
|
3331
3377
|
|
|
3332
3378
|
/// Machine identifiers sometimes leak into a11y titles — mangled runtime type names
|
|
@@ -5049,6 +5095,7 @@ class ExplorerTests: XCTestCase {
|
|
|
5049
5095
|
/// a tap nudged to the top of the frame. Three attempts, ~4 s worst case.
|
|
5050
5096
|
private func focusForTyping(_ element: XCUIElement) -> Bool {
|
|
5051
5097
|
func focused() -> Bool { (element.value(forKey: "hasKeyboardFocus") as? Bool) ?? false }
|
|
5098
|
+
if focused() { return true }
|
|
5052
5099
|
func settleUntilFocused() -> Bool {
|
|
5053
5100
|
let deadline = Date().addingTimeInterval(1.2)
|
|
5054
5101
|
repeat {
|
|
@@ -5116,11 +5163,28 @@ class ExplorerTests: XCTestCase {
|
|
|
5116
5163
|
return false
|
|
5117
5164
|
}
|
|
5118
5165
|
|
|
5119
|
-
|
|
5120
|
-
|
|
5121
|
-
|
|
5122
|
-
|
|
5123
|
-
|
|
5166
|
+
func hasContent() -> Bool {
|
|
5167
|
+
guard let value = element.value as? String else { return false }
|
|
5168
|
+
return !value.isEmpty && value != element.placeholderValue
|
|
5169
|
+
}
|
|
5170
|
+
if hasContent() {
|
|
5171
|
+
// A tap may put the cursor in the middle. Select the complete field instead of
|
|
5172
|
+
// deleting only the prefix, then prove it is empty before inserting new text.
|
|
5173
|
+
element.typeKey("a", modifierFlags: .command)
|
|
5174
|
+
element.typeText(XCUIKeyboardKey.delete.rawValue)
|
|
5175
|
+
if hasContent(), let remaining = element.value as? String {
|
|
5176
|
+
// Some simulator keyboard configurations ignore Command-A. Clear on both
|
|
5177
|
+
// sides of the cursor in that case; backspace alone leaves the suffix intact.
|
|
5178
|
+
let count = remaining.utf16.count
|
|
5179
|
+
element.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: count)
|
|
5180
|
+
+ String(repeating: XCUIKeyboardKey.forwardDelete.rawValue, count: count))
|
|
5181
|
+
}
|
|
5182
|
+
let deadline = Date().addingTimeInterval(1.0)
|
|
5183
|
+
while hasContent() && Date() < deadline { Thread.sleep(forTimeInterval: 0.1) }
|
|
5184
|
+
guard !hasContent() else {
|
|
5185
|
+
lastTypeFailure = "Field contents could not be cleared; replacement was not entered"
|
|
5186
|
+
return false
|
|
5187
|
+
}
|
|
5124
5188
|
}
|
|
5125
5189
|
|
|
5126
5190
|
element.typeText(text)
|
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
|
@@ -102,13 +102,8 @@ function bootBestSimulator(preferredName = "iPhone 16 Pro") {
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
function harnessXctestrun() {
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
const found = fs.readdirSync(dir).find((f) => f.endsWith(".xctestrun"));
|
|
108
|
-
return found ? path.join(dir, found) : null;
|
|
109
|
-
} catch {
|
|
110
|
-
return null;
|
|
111
|
-
}
|
|
105
|
+
const result = run("bash", [path.join(packageRoot, "scripts/quick-capture.sh"), "harness-path"]);
|
|
106
|
+
return result.code === 0 && result.stdout ? result.stdout : null;
|
|
112
107
|
}
|
|
113
108
|
|
|
114
109
|
// Flags/positionals for the zero-config verbs (qa/open/tree/shot). `--key value` or bare `--key`.
|
|
@@ -172,17 +167,6 @@ function requireMacFor(what) {
|
|
|
172
167
|
process.exit(1);
|
|
173
168
|
}
|
|
174
169
|
|
|
175
|
-
function ensureIOSHarness() {
|
|
176
|
-
const result = spawnSync("bash", [path.join(packageRoot, "scripts", "quick-capture.sh"), "build-harness"], {
|
|
177
|
-
stdio: "inherit",
|
|
178
|
-
env: process.env,
|
|
179
|
-
});
|
|
180
|
-
if ((result.status ?? 1) !== 0) {
|
|
181
|
-
console.error("❌ Could not prepare the iOS test harness.");
|
|
182
|
-
process.exit(result.status ?? 1);
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
170
|
function requestedPlatform(flags, target = "") {
|
|
187
171
|
if (typeof flags.platform === "string") return flags.platform.toLowerCase();
|
|
188
172
|
if (/^https?:\/\//i.test(target)) return "web";
|
|
@@ -222,7 +206,7 @@ function printEngineError(r) {
|
|
|
222
206
|
console.error(" Available targets:");
|
|
223
207
|
for (const choice of choices) {
|
|
224
208
|
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)}` : ""}`);
|
|
209
|
+
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
210
|
}
|
|
227
211
|
}
|
|
228
212
|
if (r.details?.remediation) console.error(` Next: ${r.details.remediation}`);
|
|
@@ -250,8 +234,28 @@ async function promptForInitTarget(details) {
|
|
|
250
234
|
|
|
251
235
|
// Turn whatever the user gave us (nothing / repo dir / .app / bundle id) into an installed
|
|
252
236
|
// bundle id, narrating build/install progress on stderr.
|
|
253
|
-
async function resolveTargetOrExit(engine, input) {
|
|
254
|
-
|
|
237
|
+
async function resolveTargetOrExit(engine, input, { inspect = false } = {}) {
|
|
238
|
+
let resolved = inspect
|
|
239
|
+
? await engine.resolveInstalledAppTarget(input || "")
|
|
240
|
+
: await engine.resolveAppTarget(input || "", { onStatus: (s) => console.error(`⏳ ${s}`) });
|
|
241
|
+
if (inspect && resolved.details?.choices?.length && process.stdin.isTTY && process.stderr.isTTY && !process.env.CI) {
|
|
242
|
+
const { createInterface } = await import("node:readline/promises");
|
|
243
|
+
const terminal = createInterface({ input: process.stdin, output: process.stderr });
|
|
244
|
+
const choices = resolved.details.choices;
|
|
245
|
+
console.error(`\n${resolved.error}`);
|
|
246
|
+
choices.forEach((choice, index) => console.error(` ${index + 1}) ${choice.name} (${choice.bundleId})`));
|
|
247
|
+
try {
|
|
248
|
+
while (true) {
|
|
249
|
+
const answer = String(await terminal.question(`Select 1-${choices.length} (or q to cancel): `)).trim();
|
|
250
|
+
if (/^(q|quit|cancel)$/i.test(answer)) break;
|
|
251
|
+
const selected = Number(answer);
|
|
252
|
+
if (Number.isInteger(selected) && selected >= 1 && selected <= choices.length) {
|
|
253
|
+
resolved = await engine.resolveInstalledAppTarget(choices[selected - 1].bundleId);
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
} finally { terminal.close(); }
|
|
258
|
+
}
|
|
255
259
|
if (resolved.error) {
|
|
256
260
|
printEngineError(resolved);
|
|
257
261
|
process.exit(1);
|
|
@@ -266,11 +270,11 @@ function safeCommandUsage(verb) {
|
|
|
266
270
|
focus: "tapp focus \"SCREEN OR CONTROL\" [target] [--platform ios|android|web] [--project-dir REPO] [--target NAME|PATH] [--map FILE] [--out FILE]",
|
|
267
271
|
init: "tapp init [repo] [--explore] [--refresh] [--platform PLATFORM] [--target NAME] [--url URL] [--watch] [--dry-run]",
|
|
268
272
|
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]",
|
|
273
|
+
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
274
|
shot: "tapp shot [--out FILE]",
|
|
271
275
|
apps: "tapp apps",
|
|
272
276
|
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",
|
|
277
|
+
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
278
|
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
279
|
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
280
|
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 +966,7 @@ switch (command) {
|
|
|
962
966
|
console.error(`❌ ${sim.error}`);
|
|
963
967
|
process.exit(1);
|
|
964
968
|
}
|
|
965
|
-
const bundleId = await resolveTargetOrExit(engine, positionals[0]);
|
|
969
|
+
const bundleId = await resolveTargetOrExit(engine, positionals[0], { inspect: true });
|
|
966
970
|
const r = await engine.captureUiTree(bundleId);
|
|
967
971
|
if (r.error) {
|
|
968
972
|
console.error(`❌ ${r.error}`);
|
|
@@ -1230,6 +1234,11 @@ switch (command) {
|
|
|
1230
1234
|
const evidenceDir = path.join(tappHome, "captures", `flow-${platform}-${token}`);
|
|
1231
1235
|
fs.mkdirSync(path.dirname(flowLog), { recursive: true });
|
|
1232
1236
|
const env = { ...process.env, FLOW_LOG: flowLog, TAPP_FLOW_EVIDENCE_DIR: evidenceDir };
|
|
1237
|
+
if (platform === "ios") {
|
|
1238
|
+
const launch = iosLaunchOptions(flags, rest);
|
|
1239
|
+
if (launch.appLaunchArgs) env.OCQA_APP_LAUNCH_ARGS_JSON = JSON.stringify(launch.appLaunchArgs);
|
|
1240
|
+
if (launch.appLaunchEnv) env.OCQA_APP_LAUNCH_ENV_JSON = JSON.stringify(launch.appLaunchEnv);
|
|
1241
|
+
}
|
|
1233
1242
|
if (typeof flags.email === "string") env.OCQA_TEST_EMAIL = flags.email;
|
|
1234
1243
|
if (typeof flags.password === "string") env.OCQA_TEST_PASSWORD = flags.password;
|
|
1235
1244
|
if (typeof flags.device === "string") env.TAPP_WEB_DEVICE = flags.device;
|
|
@@ -1266,7 +1275,6 @@ switch (command) {
|
|
|
1266
1275
|
invocation = [process.execPath, [path.join(packageRoot, "scripts", "run-android-flow.js"), absolute, target.appId, target.apkPath || "", target.serial || ""]];
|
|
1267
1276
|
} else {
|
|
1268
1277
|
requireMacFor("iOS Flow replay");
|
|
1269
|
-
ensureIOSHarness();
|
|
1270
1278
|
invocation = ["bash", [path.join(packageRoot, "scripts", "run-flow.sh"), absolute, typeof flags["bundle-id"] === "string" ? flags["bundle-id"] : flow.app || ""]];
|
|
1271
1279
|
}
|
|
1272
1280
|
const result = spawnSync(invocation[0], invocation[1], { stdio: "inherit", env });
|
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
|
|
|
@@ -588,6 +649,18 @@ export async function resolveAppTarget(input, { cwd = process.cwd(), onStatus =
|
|
|
588
649
|
let activeSession = null;
|
|
589
650
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
590
651
|
|
|
652
|
+
export function sessionFailureReason(log = "", credentials = {}) {
|
|
653
|
+
for (const pattern of [/Failed to get matching snapshot:[^\n]*/, /Failed to synthesize event:[^\n]*/, /error:\s*[^\n]+/, /Error Domain=[^\n]+/, /Test Case '[^']*' failed[^\n]*/]) {
|
|
654
|
+
const match = String(log).match(pattern);
|
|
655
|
+
if (match) {
|
|
656
|
+
let message = match[0].trim();
|
|
657
|
+
for (const secret of Object.values(credentials).filter((value) => typeof value === "string" && value)) message = message.split(secret).join("[redacted]");
|
|
658
|
+
return message.slice(0, 1200);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
return "The XCTest session process exited; reopen the app to start a new session.";
|
|
662
|
+
}
|
|
663
|
+
|
|
591
664
|
function consumeSessionStdout(chunk) {
|
|
592
665
|
if (!activeSession) return;
|
|
593
666
|
activeSession.buffer += chunk;
|
|
@@ -704,9 +777,9 @@ async function startSession(bundleId, extraEnv = {}) {
|
|
|
704
777
|
while (!activeSession.ready && Date.now() < deadline && !activeSession.ended) await sleep(300);
|
|
705
778
|
if (activeSession.ended) {
|
|
706
779
|
// Surface the real failure from the harness output instead of a shrug.
|
|
707
|
-
const errLine = (
|
|
780
|
+
const errLine = sessionFailureReason(activeSession.buffer, activeSession.creds);
|
|
708
781
|
activeSession = null;
|
|
709
|
-
return { error: `Session process exited before it became ready
|
|
782
|
+
return { error: `Session process exited before it became ready — ${errLine}` };
|
|
710
783
|
}
|
|
711
784
|
if (!activeSession.ready) { return { error: "Session did not become ready within the time limit." }; }
|
|
712
785
|
|
|
@@ -953,9 +1026,14 @@ export function sessionActUsageError(cmd = {}) {
|
|
|
953
1026
|
async function sessionAct(cmd) {
|
|
954
1027
|
const startedAt = Date.now();
|
|
955
1028
|
const done = (result) => ({ ...result, durationMs: Date.now() - startedAt });
|
|
956
|
-
if (!activeSession
|
|
1029
|
+
if (!activeSession) return done({ error: "No active session. Call tapp_session_start first." });
|
|
1030
|
+
if (activeSession.ended) return done({ error: sessionFailureReason(activeSession.buffer, activeSession.creds) });
|
|
957
1031
|
const usage = sessionActUsageError(cmd);
|
|
958
1032
|
if (usage) return done({ status: "usage", detail: usage, ...treeSnapshot(), recordedSteps: activeSession.recording.length });
|
|
1033
|
+
if (cmd.action === "login") {
|
|
1034
|
+
if (cmd.email) activeSession.creds.email = cmd.email;
|
|
1035
|
+
if (cmd.password) activeSession.creds.password = cmd.password;
|
|
1036
|
+
}
|
|
959
1037
|
let coordinateResolvedTarget = "";
|
|
960
1038
|
if (cmd.action === "tap" && !cmd.id && Number.isFinite(cmd.x) && Number.isFinite(cmd.y)) {
|
|
961
1039
|
coordinateResolvedTarget = semanticTargetAtPoint(activeSession.latestTree?.elements, cmd.x, cmd.y);
|
|
@@ -1115,6 +1193,10 @@ async function sessionAct(cmd) {
|
|
|
1115
1193
|
const td = Date.now() + 5_000;
|
|
1116
1194
|
while (activeSession.treeVersion === beforeVer && Date.now() < td && !activeSession.ended) await sleep(150);
|
|
1117
1195
|
const snap = treeSnapshot();
|
|
1196
|
+
if (status === "timeout" && activeSession.ended) {
|
|
1197
|
+
status = "harness_failed";
|
|
1198
|
+
detail = sessionFailureReason(activeSession.buffer, activeSession.creds);
|
|
1199
|
+
}
|
|
1118
1200
|
if (status === "ok") recordStep(cmd, snap); // record only successful acts
|
|
1119
1201
|
return done({ status, typedInto, detail, ...snap, recordedSteps: activeSession ? activeSession.recording.length : 0, ...(coordinateResolvedTarget ? { coordinateResolvedTarget } : {}) });
|
|
1120
1202
|
}
|
|
@@ -3211,6 +3293,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
3211
3293
|
action: { type: "string", enum: ["login", "tap", "type", "swipe", "back", "wait", "tree", "screenshot"] },
|
|
3212
3294
|
email: { type: "string", description: "login: email/username to sign in with" },
|
|
3213
3295
|
password: { type: "string", description: "login: password to sign in with" },
|
|
3296
|
+
actor: { type: "string", description: "login: configured actor; explicit email/password override its environment bindings" },
|
|
3214
3297
|
id: { type: "string", description: "Element accessibility id or visible/partial label (for tap/type/wait)" },
|
|
3215
3298
|
x: { type: "number", description: "Tap X coordinate (points), if not using id" },
|
|
3216
3299
|
y: { type: "number", description: "Tap Y coordinate (points), if not using id" },
|
|
@@ -4524,6 +4607,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4524
4607
|
if (action === "login") {
|
|
4525
4608
|
if (isNonEmptyString(args.email)) cmd.email = args.email.trim();
|
|
4526
4609
|
if (isNonEmptyString(args.password)) cmd.password = args.password;
|
|
4610
|
+
if (isNonEmptyString(args.actor)) {
|
|
4611
|
+
try {
|
|
4612
|
+
const { resolveActorCredentials } = await import("./project-config.js");
|
|
4613
|
+
Object.assign(cmd, resolveActorCredentials(workspaceRoot, { actor: args.actor.trim(), email: cmd.email, password: cmd.password }));
|
|
4614
|
+
} catch (error) { return errorResult(error.message); }
|
|
4615
|
+
}
|
|
4527
4616
|
}
|
|
4528
4617
|
if (action === "wait") cmd.timeoutMs = Math.max(500, Math.min(60_000, asInteger(args.timeoutMs, 5000)));
|
|
4529
4618
|
const r = await sessionAct(cmd);
|
|
@@ -4,6 +4,24 @@ import { TAPP_DIRECTORY, projectArtifactDirectory } from "./project-paths.js";
|
|
|
4
4
|
|
|
5
5
|
export const PROJECT_CONFIG_RELATIVE_PATH = `${TAPP_DIRECTORY}/project.json`;
|
|
6
6
|
|
|
7
|
+
// Resolve a named actor entirely inside the engine. Never return these values as tool
|
|
8
|
+
// output or fall back to another identity when a binding is absent.
|
|
9
|
+
export function resolveActorCredentials(projectDir, { actor, email, password, env = process.env } = {}) {
|
|
10
|
+
const loaded = readProjectConfig(projectDir);
|
|
11
|
+
if (loaded.errors.length) throw new Error(`Invalid ${loaded.relativePath}: ${loaded.errors.join("; ")}`);
|
|
12
|
+
const configured = loaded.config.actors?.[actor];
|
|
13
|
+
if (!configured) throw new Error(`Actor '${actor}' is not configured in ${loaded.relativePath}`);
|
|
14
|
+
const credentials = { email, password };
|
|
15
|
+
for (const key of ["email", "password"]) {
|
|
16
|
+
if (typeof credentials[key] === "string" && credentials[key]) continue;
|
|
17
|
+
const binding = configured.credentials?.[key]?.env;
|
|
18
|
+
if (!binding) throw new Error(`Actor '${actor}' has no ${key} environment binding`);
|
|
19
|
+
if (!env[binding]) throw new Error(`Actor '${actor}' requires $${binding} in the Tapp process environment`);
|
|
20
|
+
credentials[key] = env[binding];
|
|
21
|
+
}
|
|
22
|
+
return credentials;
|
|
23
|
+
}
|
|
24
|
+
|
|
7
25
|
const ACTOR_NAME = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
|
|
8
26
|
const ENV_NAME = /^[A-Z_][A-Z0-9_]{0,127}$/;
|
|
9
27
|
const CREDENTIAL_NAME = /^[a-z][A-Za-z0-9_-]{0,63}$/;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aarwitz/tapp",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.20",
|
|
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/flow_lib.py
CHANGED
|
@@ -10,6 +10,7 @@ Usage:
|
|
|
10
10
|
flow_lib.py report --json <harness.log> # prints machine JSON {passed,total,failed,steps}
|
|
11
11
|
"""
|
|
12
12
|
import json
|
|
13
|
+
import os
|
|
13
14
|
import re
|
|
14
15
|
import sys
|
|
15
16
|
|
|
@@ -73,23 +74,34 @@ def raw_json(path):
|
|
|
73
74
|
ABORT_PATTERNS = [
|
|
74
75
|
r"Failed to synthesize event: [^\n]*",
|
|
75
76
|
r"Neither element nor any descendant has keyboard focus[^\n]*",
|
|
76
|
-
r"Test Case '[^']*' failed \([^)]*\)",
|
|
77
77
|
r"Error Domain=[^\n]*",
|
|
78
78
|
r"Unable to (?:find|launch|boot)[^\n]*",
|
|
79
79
|
r"App state is [^\n]*",
|
|
80
80
|
r"Timed out [^\n]*",
|
|
81
81
|
r"Testing failed:[^\n]*",
|
|
82
82
|
r"error: [^\n]*",
|
|
83
|
+
r"Test Case '[^']*' failed \([^)]*\)",
|
|
83
84
|
r"\*\* TEST (?:EXECUTE )?FAILED \*\*",
|
|
84
85
|
]
|
|
85
86
|
|
|
86
87
|
|
|
87
|
-
def harness_abort_reason(log):
|
|
88
|
+
def harness_abort_reason(log, path=None):
|
|
88
89
|
"""First XCTest/xcodebuild line that explains why a run ended before step 1."""
|
|
90
|
+
if path:
|
|
91
|
+
for summary_path in [path + ".xctest-summary.json", os.path.join(os.path.dirname(path), "xctest-summary.json")]:
|
|
92
|
+
try:
|
|
93
|
+
with open(summary_path, encoding="utf-8") as summary_file:
|
|
94
|
+
summary = json.load(summary_file)
|
|
95
|
+
messages = [failure["failureText"] for failure in summary.get("testFailures", [])
|
|
96
|
+
if isinstance(failure.get("failureText"), str) and failure["failureText"].strip()]
|
|
97
|
+
if messages:
|
|
98
|
+
return " | ".join(dict.fromkeys(messages))[:1200]
|
|
99
|
+
except (OSError, ValueError, TypeError, AttributeError):
|
|
100
|
+
pass
|
|
89
101
|
for pat in ABORT_PATTERNS:
|
|
90
102
|
m = re.search(pat, log)
|
|
91
103
|
if m:
|
|
92
|
-
return m.group(0).strip()[:
|
|
104
|
+
return m.group(0).strip()[:1200]
|
|
93
105
|
return None
|
|
94
106
|
|
|
95
107
|
|
|
@@ -102,7 +114,14 @@ def report(path, as_json=False):
|
|
|
102
114
|
declared_total = None
|
|
103
115
|
for line in log.splitlines():
|
|
104
116
|
line = line.strip()
|
|
105
|
-
if line.startswith("
|
|
117
|
+
if line.startswith("OCQA_FLOW_PLAN:"):
|
|
118
|
+
try:
|
|
119
|
+
plan = json.loads(line[len("OCQA_FLOW_PLAN:"):])
|
|
120
|
+
name = plan.get("name", name)
|
|
121
|
+
declared_total = plan.get("total", declared_total)
|
|
122
|
+
except (ValueError, TypeError, AttributeError):
|
|
123
|
+
pass
|
|
124
|
+
elif line.startswith("OCQA_FLOW_STEP:"):
|
|
106
125
|
try:
|
|
107
126
|
steps.append(json.loads(line[len("OCQA_FLOW_STEP:"):]))
|
|
108
127
|
except Exception:
|
|
@@ -124,7 +143,7 @@ def report(path, as_json=False):
|
|
|
124
143
|
except Exception:
|
|
125
144
|
pass
|
|
126
145
|
|
|
127
|
-
total = (result or {}).get("total", len(steps))
|
|
146
|
+
total = (result or {}).get("total", declared_total if declared_total is not None else len(steps))
|
|
128
147
|
failed = (result or {}).get("failed", sum(1 for s in steps if s.get("status") == "fail"))
|
|
129
148
|
passed = (result or {}).get("passed", failed == 0 and bool(steps))
|
|
130
149
|
|
|
@@ -136,7 +155,7 @@ def report(path, as_json=False):
|
|
|
136
155
|
# failure of the first step so the scoreboard says why instead of looking like a crash.
|
|
137
156
|
abort_reason = None
|
|
138
157
|
if not steps and not passed:
|
|
139
|
-
abort_reason = harness_abort_reason(log)
|
|
158
|
+
abort_reason = harness_abort_reason(log, path)
|
|
140
159
|
steps.append({"index": 1, "action": "harness", "target": "", "status": "fail",
|
|
141
160
|
"detail": abort_reason or "the harness exited before the first step; see flow.log"})
|
|
142
161
|
failed = max(failed, 1)
|
|
@@ -144,7 +163,7 @@ def report(path, as_json=False):
|
|
|
144
163
|
# The harness died MID-run (an XCTest assertion, the test time budget, a crash): there is
|
|
145
164
|
# no final OCQA_FLOW_RESULT line. This used to be reported as "PASSED 16/16" because
|
|
146
165
|
# `total` silently became the number of steps that happened to run before the death.
|
|
147
|
-
abort_reason = harness_abort_reason(log)
|
|
166
|
+
abort_reason = harness_abort_reason(log, path)
|
|
148
167
|
total = declared_total or total
|
|
149
168
|
steps.append({"index": len(steps) + 1, "action": "harness", "target": "", "status": "fail",
|
|
150
169
|
"detail": (abort_reason or "the harness exited") + f" — run ended after step {executed} of {total}"})
|
package/scripts/quick-capture.sh
CHANGED
|
@@ -67,44 +67,58 @@ cleanup_stale_recorders() {
|
|
|
67
67
|
sleep 1
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
harness_fingerprint() {
|
|
71
|
+
# SDK names in .xctestrun filenames describe the BUILD SDK, not the simulator OS.
|
|
72
|
+
# Record the source/project and selected toolchain, then reuse the exact build product.
|
|
73
|
+
{
|
|
74
|
+
find "$(dirname "$HARNESS_PROJECT")" -type f \( -name '*.swift' -o -name project.pbxproj -o -name '*.xcscheme' -o -name '*.plist' \) -print0 \
|
|
75
|
+
| sort -z | xargs -0 shasum
|
|
76
|
+
xcode-select -p
|
|
77
|
+
xcodebuild -version
|
|
78
|
+
xcrun --sdk iphonesimulator --show-sdk-version
|
|
79
|
+
} | shasum | cut -c1-40
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
cached_harness_path() {
|
|
83
|
+
local marker="$HARNESS_DERIVED/.last-sim-udid"
|
|
84
|
+
[[ -f "$marker" ]] || return 1
|
|
85
|
+
[[ "$(sed -n 1p "$marker")" == "$UDID" ]] || return 1
|
|
86
|
+
[[ "$(sed -n 2p "$marker")" == "$(harness_fingerprint)" ]] || return 1
|
|
87
|
+
local selected="$(sed -n 3p "$marker")"
|
|
88
|
+
[[ -n "$selected" && "$selected" != */* && "$selected" == *.xctestrun ]] || return 1
|
|
89
|
+
[[ -f "$HARNESS_DERIVED/Build/Products/$selected" ]] || return 1
|
|
90
|
+
printf '%s\n' "$HARNESS_DERIVED/Build/Products/$selected"
|
|
91
|
+
}
|
|
92
|
+
|
|
70
93
|
ensure_harness_built() {
|
|
71
94
|
local sim_name="${1:-iPhone 16 Pro}"
|
|
72
95
|
local udid="${UDID:-}"
|
|
73
96
|
local marker="$HARNESS_DERIVED/.last-sim-udid"
|
|
74
|
-
local
|
|
75
|
-
if
|
|
76
|
-
last_udid=$(sed -n 1p "$marker" 2>/dev/null || true)
|
|
77
|
-
last_fp=$(sed -n 2p "$marker" 2>/dev/null || true)
|
|
78
|
-
fi
|
|
79
|
-
# Fingerprint the harness source so a package update actually reaches users — without this,
|
|
80
|
-
# a warm cache serves the OLD harness forever (npm normalizes mtimes, so hash the content).
|
|
81
|
-
local src_file="$(dirname "$HARNESS_PROJECT")/OCQAHarnessUITests/ExplorerTests.swift"
|
|
82
|
-
local src_fp=$(shasum "$src_file" 2>/dev/null | cut -c1-12)
|
|
83
|
-
local xctestrun=$(find "$HARNESS_DERIVED/Build/Products" -name "*.xctestrun" 2>/dev/null | head -1)
|
|
84
|
-
|
|
85
|
-
# Reuse the cached harness ONLY if it was built for the currently-booted simulator AND from
|
|
86
|
-
# the same harness sources. A harness built for a different sim can fail to launch the
|
|
87
|
-
# interactive session; a stale-source harness silently lacks shipped fixes.
|
|
88
|
-
if [[ -n "$xctestrun" && ( -z "$udid" || "$udid" == "$last_udid" ) && "$src_fp" == "$last_fp" ]]; then
|
|
97
|
+
local xctestrun
|
|
98
|
+
if xctestrun=$(cached_harness_path); then
|
|
89
99
|
echo "Harness already built for this sim: $xctestrun" >&2
|
|
90
100
|
return 0
|
|
91
101
|
fi
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
102
|
+
echo "Preparing harness for $sim_name (source, simulator, or Xcode cache changed)..." >&2
|
|
103
|
+
local src_fp=$(harness_fingerprint)
|
|
104
|
+
mkdir -p "$HARNESS_DERIVED/Build/Products"
|
|
105
|
+
rm -f "$marker"
|
|
106
|
+
# Xcode leaves old SDK descriptors beside the new one. Only the descriptor emitted by
|
|
107
|
+
# this successful build may become current; never select an arbitrary directory entry.
|
|
108
|
+
find "$HARNESS_DERIVED/Build/Products" -maxdepth 1 -name '*.xctestrun' -type f -delete
|
|
99
109
|
xcodebuild build-for-testing \
|
|
100
110
|
-project "$HARNESS_PROJECT" \
|
|
101
111
|
-scheme OCQAHarnessUITests \
|
|
102
112
|
-destination "platform=iOS Simulator,id=$UDID" \
|
|
103
113
|
-derivedDataPath "$HARNESS_DERIVED" \
|
|
104
114
|
2>&1 | tail -5 >&2
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
115
|
+
local products=()
|
|
116
|
+
while IFS= read -r product; do products+=("$product"); done < <(find "$HARNESS_DERIVED/Build/Products" -maxdepth 1 -name '*.xctestrun' -type f)
|
|
117
|
+
if [[ ${#products[@]} -ne 1 ]]; then
|
|
118
|
+
echo "ERROR: Harness build produced ${#products[@]} test descriptors; no cache was selected." >&2
|
|
119
|
+
return 1
|
|
120
|
+
fi
|
|
121
|
+
printf '%s\n%s\n%s\n' "$udid" "$src_fp" "$(basename "${products[0]}")" > "$marker"
|
|
108
122
|
}
|
|
109
123
|
|
|
110
124
|
run_harness_test() {
|
|
@@ -189,7 +203,7 @@ run_harness_test() {
|
|
|
189
203
|
}
|
|
190
204
|
CONF
|
|
191
205
|
|
|
192
|
-
local xctestrun=$(
|
|
206
|
+
local xctestrun=$(cached_harness_path)
|
|
193
207
|
if [[ -z "$xctestrun" ]]; then
|
|
194
208
|
echo "ERROR: No xctestrun found. Run: tapp install" >&2
|
|
195
209
|
return 1
|
|
@@ -226,15 +240,23 @@ while [[ $# -gt 0 ]]; do
|
|
|
226
240
|
esac
|
|
227
241
|
done
|
|
228
242
|
|
|
229
|
-
mkdir -p "$CAPTURE_DIR"
|
|
230
243
|
UDID=$(get_booted_sim)
|
|
231
244
|
SIM_NAME=$(get_sim_name)
|
|
232
245
|
|
|
233
246
|
if [[ -z "$UDID" ]]; then
|
|
234
|
-
echo "ERROR: No booted simulator found. Boot one first:"
|
|
235
|
-
echo " xcrun simctl boot 'iPhone 16 Pro'"
|
|
247
|
+
echo "ERROR: No booted simulator found. Boot one first:" >&2
|
|
248
|
+
echo " xcrun simctl boot 'iPhone 16 Pro'" >&2
|
|
236
249
|
exit 1
|
|
237
250
|
fi
|
|
251
|
+
if [[ "$MODE" == "harness-path" ]]; then
|
|
252
|
+
cached_harness_path
|
|
253
|
+
exit $?
|
|
254
|
+
elif [[ "$MODE" == "prepare-harness" ]]; then
|
|
255
|
+
ensure_harness_built "$SIM_NAME"
|
|
256
|
+
cached_harness_path
|
|
257
|
+
exit $?
|
|
258
|
+
fi
|
|
259
|
+
mkdir -p "$CAPTURE_DIR"
|
|
238
260
|
echo "Simulator: $SIM_NAME ($UDID)"
|
|
239
261
|
echo "Output: $CAPTURE_DIR"
|
|
240
262
|
echo ""
|
|
@@ -452,7 +474,7 @@ OCQA_COMPLETE:{\"actions\":0,\"states\":0,\"issues\":1,\"screens\":\"\",\"outcom
|
|
|
452
474
|
"OCQA_TEST_PASSWORD": "${OCQA_TEST_PASSWORD:-}"$sess_args_line$sess_env_line
|
|
453
475
|
}
|
|
454
476
|
CONF
|
|
455
|
-
xctestrun=$(
|
|
477
|
+
xctestrun=$(cached_harness_path)
|
|
456
478
|
if [[ -z "$xctestrun" ]]; then
|
|
457
479
|
echo "ERROR: No xctestrun. Run: tapp install" >&2
|
|
458
480
|
exit 1
|
|
@@ -470,7 +492,7 @@ CONF
|
|
|
470
492
|
# for a fast first tool call later. No capture output.
|
|
471
493
|
ensure_harness_built "$SIM_NAME"
|
|
472
494
|
rmdir "$CAPTURE_DIR" 2>/dev/null || true
|
|
473
|
-
echo "Harness ready: $(
|
|
495
|
+
echo "Harness ready: $(cached_harness_path)"
|
|
474
496
|
;;
|
|
475
497
|
|
|
476
498
|
*)
|
package/scripts/run-flow.sh
CHANGED
|
@@ -24,19 +24,13 @@ APP="${2:-}"
|
|
|
24
24
|
|
|
25
25
|
UDID="$(xcrun simctl list devices booted -j 2>/dev/null | python3 -c 'import sys,json; d=json.load(sys.stdin); print(next((x["udid"] for v in d["devices"].values() for x in v if x.get("state")=="Booted"), ""))')"
|
|
26
26
|
[ -z "$UDID" ] && { echo "❌ No booted simulator."; exit 2; }
|
|
27
|
-
#
|
|
28
|
-
XCTR=""
|
|
29
|
-
TAPP_RUNTIME_HOME="${TAPP_HOME:-}"
|
|
30
|
-
[ -n "$TAPP_RUNTIME_HOME" ] && XCTR="$(find "$TAPP_RUNTIME_HOME/harness-derived/Build/Products" -name '*.xctestrun' 2>/dev/null | head -1)"
|
|
31
|
-
[ -z "$XCTR" ] && XCTR="$(find "$HOME/Library/Developer/Xcode/DerivedData/OCQAHarness-"*/Build/Products -name '*.xctestrun' 2>/dev/null | head -1)"
|
|
32
|
-
[ -z "$XCTR" ] && XCTR="$(find /tmp/tapp-harness-derived/Build/Products -name '*.xctestrun' 2>/dev/null | head -1)"
|
|
33
|
-
[ -z "$XCTR" ] && XCTR="$(find /tmp/harness-build/Build/Products -name '*.xctestrun' 2>/dev/null | head -1)"
|
|
34
|
-
[ -z "$XCTR" ] && { echo "❌ Harness not built. Run: tapp install"; exit 2; }
|
|
27
|
+
# CLI, MCP, sessions, and doctor share the same source/toolchain-validated descriptor.
|
|
28
|
+
XCTR="$(bash "$ROOT/scripts/quick-capture.sh" prepare-harness)" || exit 2
|
|
35
29
|
|
|
36
30
|
NAME="$(python3 -c "import sys,json;print(json.loads(sys.argv[1]).get('name','flow'))" "$FLOW_JSON")"
|
|
37
31
|
echo "▶️ Running flow \"$NAME\" against $APP …"
|
|
38
32
|
|
|
39
|
-
TOKEN="
|
|
33
|
+
TOKEN="$$-$(date +%s)"
|
|
40
34
|
CFG="/tmp/ocqa-flow-$TOKEN.json"
|
|
41
35
|
AI_RESP="/tmp/ocqa-flow-ai-$TOKEN.json"
|
|
42
36
|
AI_DIR="/tmp/ocqa-flow-ai-$TOKEN"
|
|
@@ -83,10 +77,15 @@ if [ "$?" -ne 0 ]; then
|
|
|
83
77
|
fi
|
|
84
78
|
|
|
85
79
|
LOG="${FLOW_LOG:-/tmp/ocqa-flow-$TOKEN.log}"
|
|
80
|
+
python3 - "$FLOW_JSON" > "$LOG" <<'PY'
|
|
81
|
+
import json, sys
|
|
82
|
+
flow = json.loads(sys.argv[1])
|
|
83
|
+
print("OCQA_FLOW_PLAN:" + json.dumps({"name": flow.get("name", "flow"), "total": len(flow.get("steps", []))}))
|
|
84
|
+
PY
|
|
86
85
|
# assert_ai judge sidecar (only when a key is present) — same file-channel as vision escalation.
|
|
87
86
|
RESPONDER_PID=""
|
|
88
87
|
if [ -n "${ANTHROPIC_API_KEY:-}" ]; then
|
|
89
|
-
mkdir -p "$AI_DIR"
|
|
88
|
+
mkdir -p "$AI_DIR"
|
|
90
89
|
python3 "$ROOT/scripts/flow_ai_judge.py" "$LOG" "$AI_RESP" > "/tmp/ocqa-flow-judge-$TOKEN.log" 2>&1 &
|
|
91
90
|
RESPONDER_PID=$!
|
|
92
91
|
fi
|
|
@@ -94,10 +93,18 @@ fi
|
|
|
94
93
|
TEST_RUNNER_OCQA_CONFIG_PATH="$CFG" xcodebuild test-without-building \
|
|
95
94
|
-xctestrun "$XCTR" -destination "platform=iOS Simulator,id=$UDID" \
|
|
96
95
|
-only-testing:"OCQAHarnessUITests/ExplorerTests/testReplayFlow" \
|
|
97
|
-
-resultBundlePath "$RESULT_BUNDLE"
|
|
96
|
+
-resultBundlePath "$RESULT_BUNDLE" >> "$LOG" 2>&1
|
|
97
|
+
XCODE_STATUS=$?
|
|
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
|
+
# XCTest can put the actionable failure only in the result bundle. Keep that summary
|
|
102
|
+
# beside the log so both the CLI and MCP report readers surface it before generic status.
|
|
103
|
+
if [ "$XCODE_STATUS" -ne 0 ] && [ -d "$RESULT_BUNDLE" ]; then
|
|
104
|
+
xcrun xcresulttool get test-results summary --path "$RESULT_BUNDLE" > "$EVIDENCE_DIR/xctest-summary.json" 2> "$EVIDENCE_DIR/xcresulttool.log" || true
|
|
105
|
+
cp "$EVIDENCE_DIR/xctest-summary.json" "$LOG.xctest-summary.json"
|
|
106
|
+
fi
|
|
107
|
+
grep '^OCQA_EVIDENCE_WARNING:' "$LOG" >&2 || true
|
|
101
108
|
python3 "$ROOT/scripts/flow_lib.py" report --json "$LOG" > "$EVIDENCE_DIR/flow-report.json"
|
|
102
109
|
echo ""
|
|
103
110
|
python3 "$ROOT/scripts/flow_lib.py" report "$LOG"
|