@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.
Files changed (32) hide show
  1. package/LICENSE +191 -21
  2. package/README.md +186 -12
  3. package/bin/browse-android-app.apk +0 -0
  4. package/bin/browse-android.apk +0 -0
  5. package/bin/browse-ax +0 -0
  6. package/bin/browse-ios-runner/BrowseRunnerApp/BrowseRunnerApp.swift +382 -0
  7. package/bin/browse-ios-runner/BrowseRunnerApp/RunnerStatusStore.swift +135 -0
  8. package/bin/browse-ios-runner/BrowseRunnerUITests/ActionHandler.swift +277 -0
  9. package/bin/browse-ios-runner/BrowseRunnerUITests/BrowseRunnerUITests.swift +60 -0
  10. package/bin/browse-ios-runner/BrowseRunnerUITests/Models.swift +78 -0
  11. package/bin/browse-ios-runner/BrowseRunnerUITests/RunnerServer.swift +246 -0
  12. package/bin/browse-ios-runner/BrowseRunnerUITests/ScreenshotHandler.swift +35 -0
  13. package/bin/browse-ios-runner/BrowseRunnerUITests/StateHandler.swift +82 -0
  14. package/bin/browse-ios-runner/BrowseRunnerUITests/TreeBuilder.swift +323 -0
  15. package/bin/browse-ios-runner/build.sh +81 -0
  16. package/bin/browse-ios-runner/project.yml +47 -0
  17. package/browse-ios-runner/BrowseRunnerApp/BrowseRunnerApp.swift +382 -0
  18. package/browse-ios-runner/BrowseRunnerApp/RunnerStatusStore.swift +135 -0
  19. package/browse-ios-runner/BrowseRunnerUITests/ActionHandler.swift +277 -0
  20. package/browse-ios-runner/BrowseRunnerUITests/BrowseRunnerUITests.swift +60 -0
  21. package/browse-ios-runner/BrowseRunnerUITests/Models.swift +78 -0
  22. package/browse-ios-runner/BrowseRunnerUITests/RunnerServer.swift +246 -0
  23. package/browse-ios-runner/BrowseRunnerUITests/ScreenshotHandler.swift +35 -0
  24. package/browse-ios-runner/BrowseRunnerUITests/StateHandler.swift +82 -0
  25. package/browse-ios-runner/BrowseRunnerUITests/TreeBuilder.swift +323 -0
  26. package/browse-ios-runner/README.md +194 -0
  27. package/browse-ios-runner/build.sh +81 -0
  28. package/browse-ios-runner/project.yml +47 -0
  29. package/dist/browse.cjs +26595 -13648
  30. package/package.json +15 -5
  31. package/skill/SKILL.md +33 -0
  32. 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
+ }