@aarwitz/tapp 0.17.10 → 0.17.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tapp",
|
|
3
3
|
"description": "Give Claude hands and eyes on iOS, Android, and web apps, with exploration, replayable flows, evidence, and deterministic CI gates.",
|
|
4
|
-
"version": "0.17.
|
|
4
|
+
"version": "0.17.11",
|
|
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.11",
|
|
28
28
|
"mcp"
|
|
29
29
|
],
|
|
30
30
|
"cwd": "${CLAUDE_PROJECT_DIR}"
|
|
@@ -387,7 +387,8 @@ class ExplorerTests: XCTestCase {
|
|
|
387
387
|
app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: x, dy: y)).tap()
|
|
388
388
|
} else { status = "bad_args" }
|
|
389
389
|
case "type":
|
|
390
|
-
status = sessionType(cmd["text"] as? String ?? "", id: cmd["id"] as? String) ? "ok" : "not_found"
|
|
390
|
+
status = sessionType(cmd["text"] as? String ?? "", id: cmd["id"] as? String) ? "ok" : (lastTypeFailure.isEmpty ? "not_found" : "focus_failed")
|
|
391
|
+
if status == "focus_failed" { loginDetail = lastTypeFailure }
|
|
391
392
|
case "swipe":
|
|
392
393
|
switch (cmd["direction"] as? String ?? "up") {
|
|
393
394
|
case "down": app.swipeDown(); case "left": app.swipeLeft(); case "right": app.swipeRight(); default: app.swipeUp()
|
|
@@ -578,10 +579,12 @@ class ExplorerTests: XCTestCase {
|
|
|
578
579
|
// credential the server rejects with no visible cause.
|
|
579
580
|
if let id = id, !id.isEmpty {
|
|
580
581
|
for field in [app.textFields[id], app.secureTextFields[id], app.textViews[id]] where field.exists {
|
|
581
|
-
replaceText(on: field, with: text)
|
|
582
|
+
guard replaceText(on: field, with: text) else { return false }
|
|
583
|
+
lastTypedInto = fieldDesc(field); return true
|
|
582
584
|
}
|
|
583
585
|
if let field = resolveFieldByHint(id) {
|
|
584
|
-
replaceText(on: field, with: text)
|
|
586
|
+
guard replaceText(on: field, with: text) else { return false }
|
|
587
|
+
lastTypedInto = fieldDesc(field); return true
|
|
585
588
|
}
|
|
586
589
|
return false
|
|
587
590
|
}
|
|
@@ -590,8 +593,7 @@ class ExplorerTests: XCTestCase {
|
|
|
590
593
|
if app.keyboards.firstMatch.exists {
|
|
591
594
|
if let focused = focusedField() {
|
|
592
595
|
lastTypedInto = fieldDesc(focused)
|
|
593
|
-
replaceText(on: focused, with: text)
|
|
594
|
-
return true
|
|
596
|
+
return replaceText(on: focused, with: text)
|
|
595
597
|
}
|
|
596
598
|
app.typeText(text)
|
|
597
599
|
lastTypedInto = "focused field"
|
|
@@ -599,7 +601,8 @@ class ExplorerTests: XCTestCase {
|
|
|
599
601
|
}
|
|
600
602
|
// Nothing focused and no usable id — last resort: the first text field or text view.
|
|
601
603
|
for first in [app.textFields.firstMatch, app.textViews.firstMatch] where first.exists {
|
|
602
|
-
replaceText(on: first, with: text)
|
|
604
|
+
guard replaceText(on: first, with: text) else { return false }
|
|
605
|
+
lastTypedInto = fieldDesc(first); return true
|
|
603
606
|
}
|
|
604
607
|
return false
|
|
605
608
|
}
|
|
@@ -809,7 +812,7 @@ class ExplorerTests: XCTestCase {
|
|
|
809
812
|
if status == "fail" { detail = "could not tap ‘\(target)’" }
|
|
810
813
|
case "type":
|
|
811
814
|
status = sessionType(value, id: target.isEmpty ? nil : target) ? "pass" : "fail"
|
|
812
|
-
if status == "fail" { detail = "no field ‘\(target)’ to type into" }
|
|
815
|
+
if status == "fail" { detail = lastTypeFailure.isEmpty ? "no field ‘\(target)’ to type into" : lastTypeFailure }
|
|
813
816
|
case "login":
|
|
814
817
|
let email = subst((step["email"] as? String) ?? "$TEST_EMAIL")
|
|
815
818
|
let password = subst((step["password"] as? String) ?? "$TEST_PASSWORD")
|
|
@@ -4718,9 +4721,62 @@ class ExplorerTests: XCTestCase {
|
|
|
4718
4721
|
print("OCQA_PROGRESS:{\"action\":\(action),\"max\":\(maxActions),\"states\":\(states)}")
|
|
4719
4722
|
}
|
|
4720
4723
|
|
|
4721
|
-
|
|
4722
|
-
|
|
4723
|
-
|
|
4724
|
+
/// Why the last replaceText could not type — surfaced as the step's detail so a flow says
|
|
4725
|
+
/// "the field never took keyboard focus" instead of dying on an XCTest assertion.
|
|
4726
|
+
private var lastTypeFailure = ""
|
|
4727
|
+
|
|
4728
|
+
/// Give a field keyboard focus and PROVE it before typing. `element.tap()` returning is not
|
|
4729
|
+
/// proof: SwiftUI fields inside a ScrollView regularly report an accessibility frame that
|
|
4730
|
+
/// lags the visible layout (content above them resolved and shifted), so the first tap lands
|
|
4731
|
+
/// on empty space, `typeText` then fails XCTest's "Neither element nor any descendant has
|
|
4732
|
+
/// keyboard focus" assertion and the whole run dies. Strategy: tap, poll for focus, and on a
|
|
4733
|
+
/// miss re-resolve the element (fresh snapshot) and try a coordinate tap at its centre, then
|
|
4734
|
+
/// a tap nudged to the top of the frame. Three attempts, ~4 s worst case.
|
|
4735
|
+
private func focusForTyping(_ element: XCUIElement) -> Bool {
|
|
4736
|
+
func focused() -> Bool { (element.value(forKey: "hasKeyboardFocus") as? Bool) ?? false }
|
|
4737
|
+
func settleUntilFocused() -> Bool {
|
|
4738
|
+
let deadline = Date().addingTimeInterval(1.2)
|
|
4739
|
+
repeat {
|
|
4740
|
+
if focused() { return true }
|
|
4741
|
+
Thread.sleep(forTimeInterval: 0.15)
|
|
4742
|
+
} while Date() < deadline
|
|
4743
|
+
return focused()
|
|
4744
|
+
}
|
|
4745
|
+
for attempt in 0..<3 {
|
|
4746
|
+
switch attempt {
|
|
4747
|
+
case 0:
|
|
4748
|
+
if element.isHittable { element.tap() } else { element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() }
|
|
4749
|
+
case 1:
|
|
4750
|
+
element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
|
4751
|
+
default:
|
|
4752
|
+
// Frame may be stale by a few rows: tap just inside its top edge, which stays
|
|
4753
|
+
// within the visible control when content shifted up.
|
|
4754
|
+
element.coordinate(withNormalizedOffset: CGVector(dx: 0.35, dy: 0.2)).tap()
|
|
4755
|
+
}
|
|
4756
|
+
if settleUntilFocused() {
|
|
4757
|
+
if attempt > 0 { print("OCQA_STATE:type_focus_recovered attempt=\(attempt + 1)") }
|
|
4758
|
+
return true
|
|
4759
|
+
}
|
|
4760
|
+
let f = element.frame
|
|
4761
|
+
print("OCQA_STATE:type_focus_miss attempt=\(attempt + 1) frame=\(Int(f.minX)),\(Int(f.minY)),\(Int(f.width))x\(Int(f.height)) keyboard=\(app.keyboards.count) focusedElsewhere=\(focusedField().map { fieldDesc($0) } ?? "none")")
|
|
4762
|
+
}
|
|
4763
|
+
return false
|
|
4764
|
+
}
|
|
4765
|
+
|
|
4766
|
+
// Replace existing field contents to avoid repeatedly appending test text. Returns false
|
|
4767
|
+
// (and records `lastTypeFailure`) instead of letting XCTest abort the run when the field
|
|
4768
|
+
// cannot be focused.
|
|
4769
|
+
@discardableResult
|
|
4770
|
+
private func replaceText(on element: XCUIElement, with text: String) -> Bool {
|
|
4771
|
+
lastTypeFailure = ""
|
|
4772
|
+
guard focusForTyping(element) else {
|
|
4773
|
+
let f = element.frame
|
|
4774
|
+
lastTypeFailure = "‘\(fieldDesc(element))’ was found (frame \(Int(f.minX)),\(Int(f.minY)) \(Int(f.width))×\(Int(f.height))) but never took keyboard focus after 3 taps"
|
|
4775
|
+
+ (app.keyboards.count == 0 ? " — no keyboard appeared" : "")
|
|
4776
|
+
+ (focusedField().map { " — focus is on ‘\(fieldDesc($0))’" } ?? "")
|
|
4777
|
+
print("OCQA_STATE:type_focus_failed detail=\(escapeJSON(lastTypeFailure))")
|
|
4778
|
+
return false
|
|
4779
|
+
}
|
|
4724
4780
|
|
|
4725
4781
|
if let existing = element.value as? String,
|
|
4726
4782
|
!existing.isEmpty,
|
|
@@ -4730,5 +4786,6 @@ class ExplorerTests: XCTestCase {
|
|
|
4730
4786
|
}
|
|
4731
4787
|
|
|
4732
4788
|
element.typeText(text)
|
|
4789
|
+
return true
|
|
4733
4790
|
}
|
|
4734
4791
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aarwitz/tapp",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.11",
|
|
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",
|
|
@@ -89,7 +89,7 @@
|
|
|
89
89
|
"mobile"
|
|
90
90
|
],
|
|
91
91
|
"scripts": {
|
|
92
|
-
"test": "node --test tests/report.test.js tests/regression.test.js tests/engine.test.js tests/project-config.test.js tests/application-model.test.js tests/ui-map.test.js tests/focused-navigation.test.js tests/task-runtime.test.js tests/release-contract.test.js tests/pr-selection.test.js tests/flow-runtime.test.js tests/web-explorer.test.js tests/web-session.test.js tests/web-link-audit.test.js tests/web-flow.test.js tests/scenario-runtime.test.js tests/android-driver.test.js tests/android-explorer.test.js tests/android-flow.test.js tests/android-primitives-protocol.test.js tests/managed-web.test.js tests/product-operations.test.js tests/browser-product.test.js tests/browser-onboarding.test.js tests/managed-operation.test.js tests/cloud-runner.test.js tests/ci-setup.test.js tests/ci-install.test.js tests/cli.test.js tests/mcp-workspace.test.js tests/action.test.js tests/package-surface.test.js tests/agent-surface.test.js tests/presentation-contract.test.js tests/landing-brand.test.js tests/ci-gate.test.js tests/ci-report.test.js tests/desktop-protocol.test.js tests/ios-flow-protocol.test.js vscode-extension/test/bridge.test.js",
|
|
92
|
+
"test": "node --test tests/report.test.js tests/regression.test.js tests/engine.test.js tests/project-config.test.js tests/application-model.test.js tests/ui-map.test.js tests/focused-navigation.test.js tests/task-runtime.test.js tests/release-contract.test.js tests/pr-selection.test.js tests/flow-runtime.test.js tests/web-explorer.test.js tests/web-session.test.js tests/web-link-audit.test.js tests/web-flow.test.js tests/scenario-runtime.test.js tests/android-driver.test.js tests/android-explorer.test.js tests/android-flow.test.js tests/android-primitives-protocol.test.js tests/managed-web.test.js tests/product-operations.test.js tests/browser-product.test.js tests/browser-onboarding.test.js tests/managed-operation.test.js tests/cloud-runner.test.js tests/ci-setup.test.js tests/ci-install.test.js tests/cli.test.js tests/mcp-workspace.test.js tests/action.test.js tests/package-surface.test.js tests/agent-surface.test.js tests/presentation-contract.test.js tests/landing-brand.test.js tests/ci-gate.test.js tests/ci-report.test.js tests/desktop-protocol.test.js tests/ios-flow-protocol.test.js tests/feedback-triage-0919.test.js vscode-extension/test/bridge.test.js",
|
|
93
93
|
"test:browser-journey": "node --test tests/browser-journey.test.js",
|
|
94
94
|
"test:browser-native": "TAPP_RUN_NATIVE_BROWSER=1 node --test tests/browser-native-journey.test.js"
|
|
95
95
|
}
|
package/scripts/flow_lib.py
CHANGED
|
@@ -92,6 +92,7 @@ def report(path, as_json=False):
|
|
|
92
92
|
result = None
|
|
93
93
|
name = "flow"
|
|
94
94
|
kind = "flow"
|
|
95
|
+
declared_total = None
|
|
95
96
|
for line in log.splitlines():
|
|
96
97
|
line = line.strip()
|
|
97
98
|
if line.startswith("OCQA_FLOW_STEP:"):
|
|
@@ -106,6 +107,9 @@ def report(path, as_json=False):
|
|
|
106
107
|
km = re.search(r"\bkind=([^ ]+)", line)
|
|
107
108
|
if km:
|
|
108
109
|
kind = km.group(1)
|
|
110
|
+
tm = re.search(r"\btotal=(\d+)", line)
|
|
111
|
+
if tm:
|
|
112
|
+
declared_total = int(tm.group(1))
|
|
109
113
|
elif line.startswith("OCQA_FLOW_RESULT:{"):
|
|
110
114
|
try:
|
|
111
115
|
result = json.loads(line[len("OCQA_FLOW_RESULT:"):])
|
|
@@ -129,6 +133,16 @@ def report(path, as_json=False):
|
|
|
129
133
|
steps.append({"index": 1, "action": "harness", "target": "", "status": "fail",
|
|
130
134
|
"detail": abort_reason or "the harness exited before the first step; see flow.log"})
|
|
131
135
|
failed = max(failed, 1)
|
|
136
|
+
elif result is None and steps:
|
|
137
|
+
# The harness died MID-run (an XCTest assertion, the test time budget, a crash): there is
|
|
138
|
+
# no final OCQA_FLOW_RESULT line. This used to be reported as "PASSED 16/16" because
|
|
139
|
+
# `total` silently became the number of steps that happened to run before the death.
|
|
140
|
+
abort_reason = harness_abort_reason(log)
|
|
141
|
+
total = declared_total or total
|
|
142
|
+
steps.append({"index": len(steps) + 1, "action": "harness", "target": "", "status": "fail",
|
|
143
|
+
"detail": (abort_reason or "the harness exited") + f" — run ended after step {executed} of {total}"})
|
|
144
|
+
failed += 1
|
|
145
|
+
passed = False
|
|
132
146
|
|
|
133
147
|
if as_json:
|
|
134
148
|
print(json.dumps({"name": name, "kind": kind, "passed": passed, "total": total, "executed": executed, "failed": failed,
|