@ulpi/browse 1.4.4 → 2.3.1
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/LICENSE +191 -21
- package/README.md +186 -12
- package/bin/browse-android-app.apk +0 -0
- package/bin/browse-android.apk +0 -0
- package/bin/browse-ax +0 -0
- package/bin/browse-ios-runner/BrowseRunnerApp/BrowseRunnerApp.swift +382 -0
- package/bin/browse-ios-runner/BrowseRunnerApp/RunnerStatusStore.swift +135 -0
- package/bin/browse-ios-runner/BrowseRunnerUITests/ActionHandler.swift +277 -0
- package/bin/browse-ios-runner/BrowseRunnerUITests/BrowseRunnerUITests.swift +60 -0
- package/bin/browse-ios-runner/BrowseRunnerUITests/Models.swift +78 -0
- package/bin/browse-ios-runner/BrowseRunnerUITests/RunnerServer.swift +246 -0
- package/bin/browse-ios-runner/BrowseRunnerUITests/ScreenshotHandler.swift +35 -0
- package/bin/browse-ios-runner/BrowseRunnerUITests/StateHandler.swift +82 -0
- package/bin/browse-ios-runner/BrowseRunnerUITests/TreeBuilder.swift +323 -0
- package/bin/browse-ios-runner/build.sh +81 -0
- package/bin/browse-ios-runner/project.yml +47 -0
- package/browse-ios-runner/BrowseRunnerApp/BrowseRunnerApp.swift +382 -0
- package/browse-ios-runner/BrowseRunnerApp/RunnerStatusStore.swift +135 -0
- package/browse-ios-runner/BrowseRunnerUITests/ActionHandler.swift +277 -0
- package/browse-ios-runner/BrowseRunnerUITests/BrowseRunnerUITests.swift +60 -0
- package/browse-ios-runner/BrowseRunnerUITests/Models.swift +78 -0
- package/browse-ios-runner/BrowseRunnerUITests/RunnerServer.swift +246 -0
- package/browse-ios-runner/BrowseRunnerUITests/ScreenshotHandler.swift +35 -0
- package/browse-ios-runner/BrowseRunnerUITests/StateHandler.swift +82 -0
- package/browse-ios-runner/BrowseRunnerUITests/TreeBuilder.swift +323 -0
- package/browse-ios-runner/README.md +194 -0
- package/browse-ios-runner/build.sh +81 -0
- package/browse-ios-runner/project.yml +47 -0
- package/dist/browse.cjs +26595 -13648
- package/package.json +15 -5
- package/skill/SKILL.md +33 -0
- package/skill/references/commands.md +18 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
|
|
3
|
+
// MARK: - Action Handler
|
|
4
|
+
|
|
5
|
+
/// Executes actions on XCUIElements resolved by tree path.
|
|
6
|
+
///
|
|
7
|
+
/// Supports tap, double-tap, long-press, swipe, and value setting.
|
|
8
|
+
/// All actions resolve the target element using `TreeBuilder.resolveElement`.
|
|
9
|
+
enum ActionHandler {
|
|
10
|
+
|
|
11
|
+
// MARK: - Perform Action
|
|
12
|
+
|
|
13
|
+
/// Perform a named action on the element at the given tree path.
|
|
14
|
+
///
|
|
15
|
+
/// Supported actions:
|
|
16
|
+
/// - `tap` / `press` — single tap
|
|
17
|
+
/// - `doubleTap` — double tap
|
|
18
|
+
/// - `longPress` — long press (1 second)
|
|
19
|
+
/// - `swipeUp`, `swipeDown`, `swipeLeft`, `swipeRight` — swipe gestures
|
|
20
|
+
/// - `twoFingerTap` — two-finger tap
|
|
21
|
+
///
|
|
22
|
+
/// - Parameters:
|
|
23
|
+
/// - app: The XCUIApplication to search in.
|
|
24
|
+
/// - path: Tree path (array of child indices) to the target element.
|
|
25
|
+
/// - actionName: Name of the action to perform.
|
|
26
|
+
/// - Returns: A dictionary with `success` and optional `error` keys.
|
|
27
|
+
static func performAction(
|
|
28
|
+
app: XCUIApplication,
|
|
29
|
+
path: [Int],
|
|
30
|
+
actionName: String
|
|
31
|
+
) -> [String: Any] {
|
|
32
|
+
guard let element = TreeBuilder.resolveElement(in: app, path: path) else {
|
|
33
|
+
return [
|
|
34
|
+
"success": false,
|
|
35
|
+
"error": "Element not found at path \(path). Run /tree to refresh the element hierarchy.",
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
guard element.exists else {
|
|
40
|
+
return [
|
|
41
|
+
"success": false,
|
|
42
|
+
"error": "Element at path \(path) no longer exists. The UI may have changed.",
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
switch actionName.lowercased() {
|
|
47
|
+
case "tap", "press", "axpress":
|
|
48
|
+
element.tap()
|
|
49
|
+
|
|
50
|
+
case "doubletap":
|
|
51
|
+
element.doubleTap()
|
|
52
|
+
|
|
53
|
+
case "longpress":
|
|
54
|
+
element.press(forDuration: 1.0)
|
|
55
|
+
|
|
56
|
+
case "swipeup":
|
|
57
|
+
let upTarget = swipeTarget(for: element, in: app)
|
|
58
|
+
coordinateSwipe(upTarget, direction: "up")
|
|
59
|
+
|
|
60
|
+
case "swipedown":
|
|
61
|
+
let downTarget = swipeTarget(for: element, in: app)
|
|
62
|
+
coordinateSwipe(downTarget, direction: "down")
|
|
63
|
+
|
|
64
|
+
case "swipeleft":
|
|
65
|
+
let leftTarget = swipeTarget(for: element, in: app)
|
|
66
|
+
coordinateSwipe(leftTarget, direction: "left")
|
|
67
|
+
|
|
68
|
+
case "swiperight":
|
|
69
|
+
let rightTarget = swipeTarget(for: element, in: app)
|
|
70
|
+
coordinateSwipe(rightTarget, direction: "right")
|
|
71
|
+
|
|
72
|
+
case "twofingertap":
|
|
73
|
+
element.twoFingerTap()
|
|
74
|
+
|
|
75
|
+
case "forcetap":
|
|
76
|
+
// Use coordinate-based tap for elements that may not be directly tappable
|
|
77
|
+
let coordinate = element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
|
|
78
|
+
coordinate.tap()
|
|
79
|
+
|
|
80
|
+
default:
|
|
81
|
+
return [
|
|
82
|
+
"success": false,
|
|
83
|
+
"error": "Unknown action '\(actionName)'. Supported: tap, doubleTap, longPress, swipeUp, swipeDown, swipeLeft, swipeRight, twoFingerTap, forceTap",
|
|
84
|
+
]
|
|
85
|
+
}
|
|
86
|
+
return ["success": true]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// MARK: - Swipe Target Resolution
|
|
90
|
+
|
|
91
|
+
/// When swiping on the app root (XCUIApplication), find the first scrollable
|
|
92
|
+
/// descendant so the swipe actually scrolls content (e.g. WebView, ScrollView, Table).
|
|
93
|
+
/// Falls back to the original element if no scrollable descendant is found.
|
|
94
|
+
private static func swipeTarget(for element: XCUIElement, in app: XCUIApplication) -> XCUIElement {
|
|
95
|
+
// Only apply smart resolution when the target is the root app element
|
|
96
|
+
guard element.elementType == .application else { return element }
|
|
97
|
+
|
|
98
|
+
// Prioritized list of scrollable element types
|
|
99
|
+
let scrollableTypes: [XCUIElement.ElementType] = [.webView, .scrollView, .table, .collectionView]
|
|
100
|
+
for type in scrollableTypes {
|
|
101
|
+
let match = app.descendants(matching: type).firstMatch
|
|
102
|
+
if match.exists {
|
|
103
|
+
return match
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return element
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/// Perform a coordinate-based swipe on the given element.
|
|
110
|
+
/// Uses XCUICoordinate press-and-drag which reliably scrolls WebViews
|
|
111
|
+
/// and other content that doesn't respond to element.swipeUp().
|
|
112
|
+
private static func coordinateSwipe(_ element: XCUIElement, direction: String) {
|
|
113
|
+
// Swipe across the middle 60% of the element for a reliable scroll
|
|
114
|
+
let start: CGVector
|
|
115
|
+
let end: CGVector
|
|
116
|
+
switch direction {
|
|
117
|
+
case "up": start = CGVector(dx: 0.5, dy: 0.75); end = CGVector(dx: 0.5, dy: 0.25)
|
|
118
|
+
case "down": start = CGVector(dx: 0.5, dy: 0.25); end = CGVector(dx: 0.5, dy: 0.75)
|
|
119
|
+
case "left": start = CGVector(dx: 0.75, dy: 0.5); end = CGVector(dx: 0.25, dy: 0.5)
|
|
120
|
+
case "right": start = CGVector(dx: 0.25, dy: 0.5); end = CGVector(dx: 0.75, dy: 0.5)
|
|
121
|
+
default: element.swipeUp(); return
|
|
122
|
+
}
|
|
123
|
+
let startCoord = element.coordinate(withNormalizedOffset: start)
|
|
124
|
+
let endCoord = element.coordinate(withNormalizedOffset: end)
|
|
125
|
+
startCoord.press(forDuration: 0.05, thenDragTo: endCoord)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// MARK: - Set Value
|
|
129
|
+
|
|
130
|
+
/// Set the text value of an editable element at the given tree path.
|
|
131
|
+
///
|
|
132
|
+
/// Clears the existing value first, then types the new value.
|
|
133
|
+
/// The element must be a text field, text view, or search field.
|
|
134
|
+
///
|
|
135
|
+
/// - Parameters:
|
|
136
|
+
/// - app: The XCUIApplication to search in.
|
|
137
|
+
/// - path: Tree path to the target element.
|
|
138
|
+
/// - value: The text to set.
|
|
139
|
+
/// - Returns: A dictionary with `success` and optional `error` keys.
|
|
140
|
+
static func setValue(
|
|
141
|
+
app: XCUIApplication,
|
|
142
|
+
path: [Int],
|
|
143
|
+
value: String
|
|
144
|
+
) -> [String: Any] {
|
|
145
|
+
guard let element = TreeBuilder.resolveElement(in: app, path: path) else {
|
|
146
|
+
return [
|
|
147
|
+
"success": false,
|
|
148
|
+
"error": "Element not found at path \(path). Run /tree to refresh the element hierarchy.",
|
|
149
|
+
]
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
guard element.exists else {
|
|
153
|
+
return [
|
|
154
|
+
"success": false,
|
|
155
|
+
"error": "Element at path \(path) no longer exists.",
|
|
156
|
+
]
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Tap to focus the element first
|
|
160
|
+
element.tap()
|
|
161
|
+
|
|
162
|
+
// Clear existing text: select all + delete
|
|
163
|
+
// Use keyboard shortcut on iOS: triple-tap to select all, then type replacement
|
|
164
|
+
let currentValue = element.value as? String ?? ""
|
|
165
|
+
if !currentValue.isEmpty {
|
|
166
|
+
// Select all text by tapping with enough taps to select all
|
|
167
|
+
element.press(forDuration: 1.0)
|
|
168
|
+
// Wait for the selection menu
|
|
169
|
+
let selectAll = app.menuItems["Select All"]
|
|
170
|
+
if selectAll.waitForExistence(timeout: 2.0) {
|
|
171
|
+
selectAll.tap()
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Type the new value
|
|
176
|
+
element.typeText(value)
|
|
177
|
+
|
|
178
|
+
return ["success": true]
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// MARK: - Type Text
|
|
182
|
+
|
|
183
|
+
/// Type text using the focused element or the application.
|
|
184
|
+
///
|
|
185
|
+
/// The text is typed character by character through the keyboard.
|
|
186
|
+
/// An element should already have focus before calling this.
|
|
187
|
+
///
|
|
188
|
+
/// - Parameters:
|
|
189
|
+
/// - app: The XCUIApplication.
|
|
190
|
+
/// - text: The text to type.
|
|
191
|
+
/// - Returns: A dictionary with `success` and optional `error` keys.
|
|
192
|
+
static func typeText(
|
|
193
|
+
app: XCUIApplication,
|
|
194
|
+
text: String
|
|
195
|
+
) -> [String: Any] {
|
|
196
|
+
app.typeText(text)
|
|
197
|
+
return ["success": true]
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// MARK: - Press Key
|
|
201
|
+
|
|
202
|
+
/// Press a named key.
|
|
203
|
+
///
|
|
204
|
+
/// Supported keys:
|
|
205
|
+
/// - `return` / `enter` — Return key
|
|
206
|
+
/// - `delete` / `backspace` — Delete backward
|
|
207
|
+
/// - `tab` — Tab key
|
|
208
|
+
/// - `escape` — Escape key
|
|
209
|
+
/// - `space` — Space bar
|
|
210
|
+
/// - `home` — Home button (via XCUIDevice)
|
|
211
|
+
/// - `volumeup`, `volumedown` — Volume buttons
|
|
212
|
+
///
|
|
213
|
+
/// - Parameters:
|
|
214
|
+
/// - app: The XCUIApplication.
|
|
215
|
+
/// - key: The key name to press.
|
|
216
|
+
/// - Returns: A dictionary with `success` and optional `error` keys.
|
|
217
|
+
static func pressKey(
|
|
218
|
+
app: XCUIApplication,
|
|
219
|
+
key: String
|
|
220
|
+
) -> [String: Any] {
|
|
221
|
+
switch key.lowercased() {
|
|
222
|
+
case "return", "enter":
|
|
223
|
+
app.typeText("\n")
|
|
224
|
+
|
|
225
|
+
case "delete", "backspace":
|
|
226
|
+
app.typeText(XCUIKeyboardKey.delete.rawValue)
|
|
227
|
+
|
|
228
|
+
case "tab":
|
|
229
|
+
app.typeText("\t")
|
|
230
|
+
|
|
231
|
+
case "escape":
|
|
232
|
+
// No direct escape key on iOS; try pressing the keyboard dismiss if visible
|
|
233
|
+
if app.keyboards.count > 0 {
|
|
234
|
+
app.typeText(XCUIKeyboardKey.escape.rawValue)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
case "space":
|
|
238
|
+
app.typeText(" ")
|
|
239
|
+
|
|
240
|
+
case "home":
|
|
241
|
+
XCUIDevice.shared.press(.home)
|
|
242
|
+
|
|
243
|
+
case "volumeup":
|
|
244
|
+
#if !targetEnvironment(simulator)
|
|
245
|
+
XCUIDevice.shared.press(.volumeUp)
|
|
246
|
+
#else
|
|
247
|
+
return [
|
|
248
|
+
"success": false,
|
|
249
|
+
"error": "volumeUp is not available in the iOS Simulator.",
|
|
250
|
+
]
|
|
251
|
+
#endif
|
|
252
|
+
|
|
253
|
+
case "volumedown":
|
|
254
|
+
#if !targetEnvironment(simulator)
|
|
255
|
+
XCUIDevice.shared.press(.volumeDown)
|
|
256
|
+
#else
|
|
257
|
+
return [
|
|
258
|
+
"success": false,
|
|
259
|
+
"error": "volumeDown is not available in the iOS Simulator.",
|
|
260
|
+
]
|
|
261
|
+
#endif
|
|
262
|
+
|
|
263
|
+
default:
|
|
264
|
+
// Try to type it as a single character
|
|
265
|
+
if key.count == 1 {
|
|
266
|
+
app.typeText(key)
|
|
267
|
+
} else {
|
|
268
|
+
return [
|
|
269
|
+
"success": false,
|
|
270
|
+
"error": "Unknown key '\(key)'. Supported: return, delete, tab, escape, space, home, volumeUp, volumeDown, or single characters.",
|
|
271
|
+
]
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return ["success": true]
|
|
276
|
+
}
|
|
277
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
|
|
3
|
+
final class BrowseRunnerUITests: XCTestCase {
|
|
4
|
+
|
|
5
|
+
override func setUpWithError() throws {
|
|
6
|
+
continueAfterFailure = true
|
|
7
|
+
executionTimeAllowance = 86400
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/// Synchronous test that keeps the main thread alive via RunLoop.
|
|
11
|
+
/// The FlyingFox server runs in a detached Task on a background thread.
|
|
12
|
+
/// XCUITest API calls hop to the main thread via DispatchQueue.main +
|
|
13
|
+
/// withCheckedContinuation (the `onMain` helper in RunnerServer.swift).
|
|
14
|
+
/// RunLoop.current.run(until:) drains the main dispatch queue each iteration.
|
|
15
|
+
func testRunServer() throws {
|
|
16
|
+
let portString = ProcessInfo.processInfo.environment["BROWSE_RUNNER_PORT"] ?? "9820"
|
|
17
|
+
let port = UInt16(portString) ?? 9820
|
|
18
|
+
|
|
19
|
+
let envBundleId = ProcessInfo.processInfo.environment["BROWSE_TARGET_BUNDLE_ID"]
|
|
20
|
+
let targetBundleId = envBundleId ?? "io.ulpi.browse-ios-runner"
|
|
21
|
+
|
|
22
|
+
// Setup on main thread (synchronous — no actor issues)
|
|
23
|
+
let hostApp = XCUIApplication()
|
|
24
|
+
hostApp.launch()
|
|
25
|
+
|
|
26
|
+
let targetApp: XCUIApplication
|
|
27
|
+
if targetBundleId != "io.ulpi.browse-ios-runner" {
|
|
28
|
+
targetApp = XCUIApplication(bundleIdentifier: targetBundleId)
|
|
29
|
+
targetApp.activate()
|
|
30
|
+
} else {
|
|
31
|
+
targetApp = hostApp
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let context = RunnerContext(targetApp: targetApp, targetBundleId: targetBundleId)
|
|
35
|
+
let server = RunnerServer(port: port)
|
|
36
|
+
|
|
37
|
+
server.route("/health", handler: HealthHandler())
|
|
38
|
+
server.route("/configure", handler: ConfigureHandler(context: context))
|
|
39
|
+
server.route("/tree", handler: TreeHandler(context: context))
|
|
40
|
+
server.route("/action", handler: ActionRouteHandler(context: context))
|
|
41
|
+
server.route("/set-value", handler: SetValueRouteHandler(context: context))
|
|
42
|
+
server.route("/type", handler: TypeRouteHandler(context: context))
|
|
43
|
+
server.route("/press", handler: PressRouteHandler(context: context))
|
|
44
|
+
server.route("/screenshot", handler: ScreenshotRouteHandler(context: context))
|
|
45
|
+
server.route("/state", handler: StateRouteHandler(context: context))
|
|
46
|
+
|
|
47
|
+
NSLog("[BrowseRunner] Starting server on port %u, target: %@", port, targetBundleId)
|
|
48
|
+
|
|
49
|
+
// Start FlyingFox on a background thread
|
|
50
|
+
Task.detached {
|
|
51
|
+
try await server.start()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Pump the main RunLoop forever — this is what allows
|
|
55
|
+
// DispatchQueue.main.async blocks (from onMain) to execute.
|
|
56
|
+
while true {
|
|
57
|
+
RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.05))
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
// MARK: - RawIOSNode
|
|
4
|
+
|
|
5
|
+
/// Matches the `RawIOSNode` type in `src/app/ios/protocol.ts`.
|
|
6
|
+
/// Represents a single node in the iOS accessibility tree.
|
|
7
|
+
struct RawIOSNode: Encodable {
|
|
8
|
+
let elementType: String
|
|
9
|
+
let identifier: String
|
|
10
|
+
let label: String
|
|
11
|
+
let value: String
|
|
12
|
+
let placeholderValue: String
|
|
13
|
+
let frame: NodeFrame
|
|
14
|
+
let isEnabled: Bool
|
|
15
|
+
let isSelected: Bool
|
|
16
|
+
let hasFocus: Bool
|
|
17
|
+
let traits: [String]
|
|
18
|
+
let children: [RawIOSNode]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/// Bounding frame in screen coordinates.
|
|
22
|
+
struct NodeFrame: Encodable {
|
|
23
|
+
let x: Double
|
|
24
|
+
let y: Double
|
|
25
|
+
let width: Double
|
|
26
|
+
let height: Double
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// MARK: - IOSState
|
|
30
|
+
|
|
31
|
+
/// Matches the `IOSState` type in `src/app/ios/protocol.ts`.
|
|
32
|
+
/// Lightweight state snapshot from the runner.
|
|
33
|
+
struct IOSState: Encodable {
|
|
34
|
+
let bundleId: String
|
|
35
|
+
let screenTitle: String
|
|
36
|
+
let elementCount: Int
|
|
37
|
+
let alertPresent: Bool
|
|
38
|
+
let keyboardVisible: Bool
|
|
39
|
+
let orientation: String
|
|
40
|
+
let statusBarTime: String
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// MARK: - Action Request
|
|
44
|
+
|
|
45
|
+
/// JSON body for `POST /action`.
|
|
46
|
+
struct ActionRequest: Decodable {
|
|
47
|
+
let path: [Int]
|
|
48
|
+
let actionName: String
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// MARK: - SetValue Request
|
|
52
|
+
|
|
53
|
+
/// JSON body for `POST /set-value`.
|
|
54
|
+
struct SetValueRequest: Decodable {
|
|
55
|
+
let path: [Int]
|
|
56
|
+
let value: String
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// MARK: - Type Request
|
|
60
|
+
|
|
61
|
+
/// JSON body for `POST /type`.
|
|
62
|
+
struct TypeRequest: Decodable {
|
|
63
|
+
let text: String
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// MARK: - Press Request
|
|
67
|
+
|
|
68
|
+
/// JSON body for `POST /press`.
|
|
69
|
+
struct PressRequest: Decodable {
|
|
70
|
+
let key: String
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// MARK: - Screenshot Request
|
|
74
|
+
|
|
75
|
+
/// JSON body for `POST /screenshot`.
|
|
76
|
+
struct ScreenshotRequest: Decodable {
|
|
77
|
+
let outputPath: String
|
|
78
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import FlyingFox
|
|
3
|
+
import XCTest
|
|
4
|
+
|
|
5
|
+
// MARK: - HTTP Server
|
|
6
|
+
|
|
7
|
+
/// Async HTTP server built on FlyingFox for the XCUITest runner.
|
|
8
|
+
///
|
|
9
|
+
/// Runs inside the XCUITest process, listening on a configurable port.
|
|
10
|
+
/// Uses `onMain {}` to dispatch XCUITest API calls to the main thread
|
|
11
|
+
/// via DispatchQueue.main + withCheckedContinuation, since @MainActor
|
|
12
|
+
/// doesn't execute reliably inside XCUITest async contexts.
|
|
13
|
+
final class RunnerServer {
|
|
14
|
+
|
|
15
|
+
private let port: UInt16
|
|
16
|
+
private var handlers: [String: any HTTPHandler] = [:]
|
|
17
|
+
|
|
18
|
+
init(port: UInt16 = 9820) {
|
|
19
|
+
self.port = port
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
func route(_ path: String, handler: some HTTPHandler) {
|
|
23
|
+
handlers[path] = handler
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func start() async throws {
|
|
27
|
+
let server = HTTPServer(
|
|
28
|
+
address: try .inet(ip4: "127.0.0.1", port: port),
|
|
29
|
+
timeout: 100
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
for (path, handler) in handlers {
|
|
33
|
+
await server.appendRoute(HTTPRoute(path), to: handler)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
NSLog("[BrowseRunner] HTTP server listening on port %d", port)
|
|
37
|
+
try await server.run()
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// MARK: - Main Thread Dispatch
|
|
42
|
+
|
|
43
|
+
/// Dispatch a block to the main thread and await its result.
|
|
44
|
+
/// XCUITest APIs must run on the main thread. We can't use @MainActor
|
|
45
|
+
/// because the Swift concurrency main actor executor doesn't drain
|
|
46
|
+
/// reliably inside XCUITest async test methods. Instead we use
|
|
47
|
+
/// DispatchQueue.main + withCheckedContinuation, which always works.
|
|
48
|
+
func onMain<T: Sendable>(_ block: @escaping @Sendable () -> T) async -> T {
|
|
49
|
+
await withCheckedContinuation { continuation in
|
|
50
|
+
DispatchQueue.main.async {
|
|
51
|
+
continuation.resume(returning: block())
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// MARK: - JSON Response Helpers
|
|
57
|
+
|
|
58
|
+
enum JSONResponse {
|
|
59
|
+
|
|
60
|
+
static func success(_ data: Any? = nil) -> FlyingFox.HTTPResponse {
|
|
61
|
+
var obj: [String: Any] = ["success": true]
|
|
62
|
+
if let data = data {
|
|
63
|
+
obj["data"] = data
|
|
64
|
+
}
|
|
65
|
+
return jsonResponse(obj, status: 200)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
static func error(_ message: String, status: Int = 400) -> FlyingFox.HTTPResponse {
|
|
69
|
+
return jsonResponse(["success": false, "error": message], status: status)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private static func jsonResponse(_ object: Any, status: Int) -> FlyingFox.HTTPResponse {
|
|
73
|
+
let data = (try? JSONSerialization.data(withJSONObject: object)) ?? Data()
|
|
74
|
+
return FlyingFox.HTTPResponse(
|
|
75
|
+
statusCode: HTTPStatusCode(status, phrase: ""),
|
|
76
|
+
headers: [HTTPHeader("Content-Type"): "application/json"],
|
|
77
|
+
body: data
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// MARK: - Route Handlers
|
|
83
|
+
|
|
84
|
+
struct HealthHandler: HTTPHandler {
|
|
85
|
+
func handleRequest(_ request: FlyingFox.HTTPRequest) async throws -> FlyingFox.HTTPResponse {
|
|
86
|
+
return JSONResponse.success(["status": "healthy"])
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
struct ConfigureHandler: HTTPHandler {
|
|
91
|
+
let context: RunnerContext
|
|
92
|
+
|
|
93
|
+
func handleRequest(_ request: FlyingFox.HTTPRequest) async throws -> FlyingFox.HTTPResponse {
|
|
94
|
+
let body = try await request.bodyData
|
|
95
|
+
guard let json = try? JSONSerialization.jsonObject(with: body) as? [String: Any],
|
|
96
|
+
let bundleId = json["targetBundleId"] as? String else {
|
|
97
|
+
return JSONResponse.error("Expected: {\"targetBundleId\": \"com.apple.Preferences\"}")
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
await onMain {
|
|
101
|
+
context.targetBundleId = bundleId
|
|
102
|
+
let app = XCUIApplication(bundleIdentifier: bundleId)
|
|
103
|
+
app.activate()
|
|
104
|
+
context.targetApp = app
|
|
105
|
+
}
|
|
106
|
+
NSLog("[BrowseRunner] Target app switched to: %@", bundleId)
|
|
107
|
+
return JSONResponse.success(["configured": bundleId])
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
struct TreeHandler: HTTPHandler {
|
|
112
|
+
let context: RunnerContext
|
|
113
|
+
|
|
114
|
+
func handleRequest(_ request: FlyingFox.HTTPRequest) async throws -> FlyingFox.HTTPResponse {
|
|
115
|
+
let tree = await onMain {
|
|
116
|
+
TreeBuilder.buildTree(from: context.targetApp)
|
|
117
|
+
}
|
|
118
|
+
guard let data = try? JSONEncoder().encode(tree),
|
|
119
|
+
let dict = try? JSONSerialization.jsonObject(with: data) else {
|
|
120
|
+
return JSONResponse.error("Failed to encode tree")
|
|
121
|
+
}
|
|
122
|
+
return JSONResponse.success(dict)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
struct ActionRouteHandler: HTTPHandler {
|
|
127
|
+
let context: RunnerContext
|
|
128
|
+
|
|
129
|
+
func handleRequest(_ request: FlyingFox.HTTPRequest) async throws -> FlyingFox.HTTPResponse {
|
|
130
|
+
let body = try await request.bodyData
|
|
131
|
+
guard let req = try? JSONDecoder().decode(ActionRequest.self, from: body) else {
|
|
132
|
+
return JSONResponse.error("Invalid request body. Expected: {\"path\":[0,1],\"actionName\":\"tap\"}")
|
|
133
|
+
}
|
|
134
|
+
let result = await onMain {
|
|
135
|
+
ActionHandler.performAction(app: context.targetApp, path: req.path, actionName: req.actionName)
|
|
136
|
+
}
|
|
137
|
+
if let ok = result["success"] as? Bool, ok {
|
|
138
|
+
return JSONResponse.success(result)
|
|
139
|
+
} else {
|
|
140
|
+
return JSONResponse.error(result["error"] as? String ?? "Action failed")
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
struct SetValueRouteHandler: HTTPHandler {
|
|
146
|
+
let context: RunnerContext
|
|
147
|
+
|
|
148
|
+
func handleRequest(_ request: FlyingFox.HTTPRequest) async throws -> FlyingFox.HTTPResponse {
|
|
149
|
+
let body = try await request.bodyData
|
|
150
|
+
guard let req = try? JSONDecoder().decode(SetValueRequest.self, from: body) else {
|
|
151
|
+
return JSONResponse.error("Invalid request body. Expected: {\"path\":[0,1],\"value\":\"text\"}")
|
|
152
|
+
}
|
|
153
|
+
let result = await onMain {
|
|
154
|
+
ActionHandler.setValue(app: context.targetApp, path: req.path, value: req.value)
|
|
155
|
+
}
|
|
156
|
+
if let ok = result["success"] as? Bool, ok {
|
|
157
|
+
return JSONResponse.success(result)
|
|
158
|
+
} else {
|
|
159
|
+
return JSONResponse.error(result["error"] as? String ?? "Set value failed")
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
struct TypeRouteHandler: HTTPHandler {
|
|
165
|
+
let context: RunnerContext
|
|
166
|
+
|
|
167
|
+
func handleRequest(_ request: FlyingFox.HTTPRequest) async throws -> FlyingFox.HTTPResponse {
|
|
168
|
+
let body = try await request.bodyData
|
|
169
|
+
guard let req = try? JSONDecoder().decode(TypeRequest.self, from: body) else {
|
|
170
|
+
return JSONResponse.error("Invalid request body. Expected: {\"text\":\"hello\"}")
|
|
171
|
+
}
|
|
172
|
+
let result = await onMain {
|
|
173
|
+
ActionHandler.typeText(app: context.targetApp, text: req.text)
|
|
174
|
+
}
|
|
175
|
+
if let ok = result["success"] as? Bool, ok {
|
|
176
|
+
return JSONResponse.success(result)
|
|
177
|
+
} else {
|
|
178
|
+
return JSONResponse.error(result["error"] as? String ?? "Type failed")
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
struct PressRouteHandler: HTTPHandler {
|
|
184
|
+
let context: RunnerContext
|
|
185
|
+
|
|
186
|
+
func handleRequest(_ request: FlyingFox.HTTPRequest) async throws -> FlyingFox.HTTPResponse {
|
|
187
|
+
let body = try await request.bodyData
|
|
188
|
+
guard let req = try? JSONDecoder().decode(PressRequest.self, from: body) else {
|
|
189
|
+
return JSONResponse.error("Invalid request body. Expected: {\"key\":\"return\"}")
|
|
190
|
+
}
|
|
191
|
+
let result = await onMain {
|
|
192
|
+
ActionHandler.pressKey(app: context.targetApp, key: req.key)
|
|
193
|
+
}
|
|
194
|
+
if let ok = result["success"] as? Bool, ok {
|
|
195
|
+
return JSONResponse.success(result)
|
|
196
|
+
} else {
|
|
197
|
+
return JSONResponse.error(result["error"] as? String ?? "Press key failed")
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
struct ScreenshotRouteHandler: HTTPHandler {
|
|
203
|
+
let context: RunnerContext
|
|
204
|
+
|
|
205
|
+
func handleRequest(_ request: FlyingFox.HTTPRequest) async throws -> FlyingFox.HTTPResponse {
|
|
206
|
+
let body = try await request.bodyData
|
|
207
|
+
guard let req = try? JSONDecoder().decode(ScreenshotRequest.self, from: body) else {
|
|
208
|
+
return JSONResponse.error("Invalid request body. Expected: {\"outputPath\":\"/tmp/shot.png\"}")
|
|
209
|
+
}
|
|
210
|
+
let result = await onMain {
|
|
211
|
+
ScreenshotHandler.captureScreenshot(app: context.targetApp, outputPath: req.outputPath)
|
|
212
|
+
}
|
|
213
|
+
if let ok = result["success"] as? Bool, ok {
|
|
214
|
+
return JSONResponse.success(result)
|
|
215
|
+
} else {
|
|
216
|
+
return JSONResponse.error(result["error"] as? String ?? "Screenshot failed")
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
struct StateRouteHandler: HTTPHandler {
|
|
222
|
+
let context: RunnerContext
|
|
223
|
+
|
|
224
|
+
func handleRequest(_ request: FlyingFox.HTTPRequest) async throws -> FlyingFox.HTTPResponse {
|
|
225
|
+
let state = await onMain {
|
|
226
|
+
StateHandler.captureState(app: context.targetApp, bundleId: context.targetBundleId)
|
|
227
|
+
}
|
|
228
|
+
guard let data = try? JSONEncoder().encode(state),
|
|
229
|
+
let dict = try? JSONSerialization.jsonObject(with: data) else {
|
|
230
|
+
return JSONResponse.error("Failed to encode state")
|
|
231
|
+
}
|
|
232
|
+
return JSONResponse.success(dict)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// MARK: - Runner Context
|
|
237
|
+
|
|
238
|
+
final class RunnerContext: @unchecked Sendable {
|
|
239
|
+
var targetApp: XCUIApplication
|
|
240
|
+
var targetBundleId: String
|
|
241
|
+
|
|
242
|
+
init(targetApp: XCUIApplication, targetBundleId: String) {
|
|
243
|
+
self.targetApp = targetApp
|
|
244
|
+
self.targetBundleId = targetBundleId
|
|
245
|
+
}
|
|
246
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
|
|
3
|
+
// MARK: - Screenshot Handler
|
|
4
|
+
|
|
5
|
+
/// Captures screenshots of the running application.
|
|
6
|
+
///
|
|
7
|
+
/// Uses XCUIScreen to capture the full simulator screen, then writes
|
|
8
|
+
/// the PNG data to the specified output path on the host filesystem.
|
|
9
|
+
/// (The simulator shares the host filesystem, so paths like `/tmp/` work.)
|
|
10
|
+
enum ScreenshotHandler {
|
|
11
|
+
|
|
12
|
+
/// Capture a screenshot and save it to the specified path.
|
|
13
|
+
///
|
|
14
|
+
/// - Parameters:
|
|
15
|
+
/// - app: The XCUIApplication (used for app-specific screenshots if needed).
|
|
16
|
+
/// - outputPath: Absolute path on the host filesystem to write the PNG file.
|
|
17
|
+
/// - Returns: A dictionary with `success` and optional `error` keys.
|
|
18
|
+
static func captureScreenshot(
|
|
19
|
+
app: XCUIApplication,
|
|
20
|
+
outputPath: String
|
|
21
|
+
) -> [String: Any] {
|
|
22
|
+
let screenshot = app.screenshot()
|
|
23
|
+
let pngData = screenshot.pngRepresentation
|
|
24
|
+
|
|
25
|
+
do {
|
|
26
|
+
try pngData.write(to: URL(fileURLWithPath: outputPath))
|
|
27
|
+
return ["success": true]
|
|
28
|
+
} catch {
|
|
29
|
+
return [
|
|
30
|
+
"success": false,
|
|
31
|
+
"error": "Failed to write screenshot to \(outputPath): \(error.localizedDescription)",
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|