agent-device 0.7.4 → 0.7.5

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.
@@ -0,0 +1,359 @@
1
+ import XCTest
2
+
3
+ extension RunnerTests {
4
+ // MARK: - Snapshot Entry
5
+
6
+ func elementTypeName(_ type: XCUIElement.ElementType) -> String {
7
+ switch type {
8
+ case .application: return "Application"
9
+ case .window: return "Window"
10
+ case .button: return "Button"
11
+ case .cell: return "Cell"
12
+ case .staticText: return "StaticText"
13
+ case .textField: return "TextField"
14
+ case .textView: return "TextView"
15
+ case .secureTextField: return "SecureTextField"
16
+ case .switch: return "Switch"
17
+ case .slider: return "Slider"
18
+ case .link: return "Link"
19
+ case .image: return "Image"
20
+ case .navigationBar: return "NavigationBar"
21
+ case .tabBar: return "TabBar"
22
+ case .collectionView: return "CollectionView"
23
+ case .table: return "Table"
24
+ case .scrollView: return "ScrollView"
25
+ case .searchField: return "SearchField"
26
+ case .segmentedControl: return "SegmentedControl"
27
+ case .stepper: return "Stepper"
28
+ case .picker: return "Picker"
29
+ case .checkBox: return "CheckBox"
30
+ case .menuItem: return "MenuItem"
31
+ case .other: return "Other"
32
+ default:
33
+ switch type.rawValue {
34
+ case 19:
35
+ return "Keyboard"
36
+ case 20:
37
+ return "Key"
38
+ case 24:
39
+ return "SearchField"
40
+ default:
41
+ return "Element(\(type.rawValue))"
42
+ }
43
+ }
44
+ }
45
+
46
+ func snapshotFast(app: XCUIApplication, options: SnapshotOptions) -> DataPayload {
47
+ if let blocking = blockingSystemAlertSnapshot() {
48
+ return blocking
49
+ }
50
+
51
+ var nodes: [SnapshotNode] = []
52
+ var truncated = false
53
+ let maxDepth = options.depth ?? Int.max
54
+ let viewport = app.frame
55
+ let queryRoot = options.scope.flatMap { findScopeElement(app: app, scope: $0) } ?? app
56
+
57
+ let rootSnapshot: XCUIElementSnapshot
58
+ do {
59
+ rootSnapshot = try queryRoot.snapshot()
60
+ } catch {
61
+ return DataPayload(nodes: nodes, truncated: truncated)
62
+ }
63
+
64
+ let (flatSnapshots, snapshotRanges) = flattenedSnapshots(rootSnapshot)
65
+ let rootLaterNodes = laterSnapshots(
66
+ for: rootSnapshot,
67
+ in: flatSnapshots,
68
+ ranges: snapshotRanges
69
+ )
70
+ let rootLabel = aggregatedLabel(for: rootSnapshot) ?? rootSnapshot.label.trimmingCharacters(in: .whitespacesAndNewlines)
71
+ let rootIdentifier = rootSnapshot.identifier.trimmingCharacters(in: .whitespacesAndNewlines)
72
+ let rootValue = snapshotValueText(rootSnapshot)
73
+ let rootHittable = computedSnapshotHittable(rootSnapshot, viewport: viewport, laterNodes: rootLaterNodes)
74
+ nodes.append(
75
+ SnapshotNode(
76
+ index: 0,
77
+ type: elementTypeName(rootSnapshot.elementType),
78
+ label: rootLabel.isEmpty ? nil : rootLabel,
79
+ identifier: rootIdentifier.isEmpty ? nil : rootIdentifier,
80
+ value: rootValue,
81
+ rect: SnapshotRect(
82
+ x: Double(rootSnapshot.frame.origin.x),
83
+ y: Double(rootSnapshot.frame.origin.y),
84
+ width: Double(rootSnapshot.frame.size.width),
85
+ height: Double(rootSnapshot.frame.size.height),
86
+ ),
87
+ enabled: rootSnapshot.isEnabled,
88
+ hittable: rootHittable,
89
+ depth: 0,
90
+ )
91
+ )
92
+
93
+ var seen = Set<String>()
94
+ var stack: [(XCUIElementSnapshot, Int, Int)] = rootSnapshot.children.map { ($0, 1, 1) }
95
+
96
+ while let (snapshot, depth, visibleDepth) = stack.popLast() {
97
+ if nodes.count >= fastSnapshotLimit {
98
+ truncated = true
99
+ break
100
+ }
101
+ if let limit = options.depth, depth > limit { continue }
102
+
103
+ let label = aggregatedLabel(for: snapshot) ?? snapshot.label.trimmingCharacters(in: .whitespacesAndNewlines)
104
+ let identifier = snapshot.identifier.trimmingCharacters(in: .whitespacesAndNewlines)
105
+ let valueText = snapshotValueText(snapshot)
106
+ let laterNodes = laterSnapshots(
107
+ for: snapshot,
108
+ in: flatSnapshots,
109
+ ranges: snapshotRanges
110
+ )
111
+ let hittable = computedSnapshotHittable(snapshot, viewport: viewport, laterNodes: laterNodes)
112
+ let hasContent = !label.isEmpty || !identifier.isEmpty || (valueText != nil)
113
+ if !isVisibleInViewport(snapshot.frame, viewport) && !hasContent {
114
+ continue
115
+ }
116
+
117
+ let include = shouldInclude(
118
+ snapshot: snapshot,
119
+ label: label,
120
+ identifier: identifier,
121
+ valueText: valueText,
122
+ options: options,
123
+ hittable: hittable
124
+ )
125
+
126
+ let key = "\(snapshot.elementType)-\(label)-\(identifier)-\(snapshot.frame.origin.x)-\(snapshot.frame.origin.y)"
127
+ let isDuplicate = seen.contains(key)
128
+ if !isDuplicate {
129
+ seen.insert(key)
130
+ }
131
+
132
+ if depth < maxDepth {
133
+ let nextVisibleDepth = include && !isDuplicate ? visibleDepth + 1 : visibleDepth
134
+ for child in snapshot.children.reversed() {
135
+ stack.append((child, depth + 1, nextVisibleDepth))
136
+ }
137
+ }
138
+
139
+ if !include || isDuplicate { continue }
140
+
141
+ nodes.append(
142
+ SnapshotNode(
143
+ index: nodes.count,
144
+ type: elementTypeName(snapshot.elementType),
145
+ label: label.isEmpty ? nil : label,
146
+ identifier: identifier.isEmpty ? nil : identifier,
147
+ value: valueText,
148
+ rect: SnapshotRect(
149
+ x: Double(snapshot.frame.origin.x),
150
+ y: Double(snapshot.frame.origin.y),
151
+ width: Double(snapshot.frame.size.width),
152
+ height: Double(snapshot.frame.size.height),
153
+ ),
154
+ enabled: snapshot.isEnabled,
155
+ hittable: hittable,
156
+ depth: min(maxDepth, visibleDepth),
157
+ )
158
+ )
159
+
160
+ }
161
+
162
+ return DataPayload(nodes: nodes, truncated: truncated)
163
+ }
164
+
165
+ func snapshotRaw(app: XCUIApplication, options: SnapshotOptions) -> DataPayload {
166
+ if let blocking = blockingSystemAlertSnapshot() {
167
+ return blocking
168
+ }
169
+
170
+ let queryRoot = options.scope.flatMap { findScopeElement(app: app, scope: $0) } ?? app
171
+ var nodes: [SnapshotNode] = []
172
+ var truncated = false
173
+ let viewport = app.frame
174
+
175
+ let rootSnapshot: XCUIElementSnapshot
176
+ do {
177
+ rootSnapshot = try queryRoot.snapshot()
178
+ } catch {
179
+ return DataPayload(nodes: nodes, truncated: truncated)
180
+ }
181
+
182
+ let (flatSnapshots, snapshotRanges) = flattenedSnapshots(rootSnapshot)
183
+
184
+ func walk(_ snapshot: XCUIElementSnapshot, depth: Int) {
185
+ if nodes.count >= maxSnapshotElements {
186
+ truncated = true
187
+ return
188
+ }
189
+ if let limit = options.depth, depth > limit { return }
190
+ if !isVisibleInViewport(snapshot.frame, viewport) { return }
191
+
192
+ let label = aggregatedLabel(for: snapshot) ?? snapshot.label.trimmingCharacters(in: .whitespacesAndNewlines)
193
+ let identifier = snapshot.identifier.trimmingCharacters(in: .whitespacesAndNewlines)
194
+ let valueText = snapshotValueText(snapshot)
195
+ let laterNodes = laterSnapshots(
196
+ for: snapshot,
197
+ in: flatSnapshots,
198
+ ranges: snapshotRanges
199
+ )
200
+ let hittable = computedSnapshotHittable(snapshot, viewport: viewport, laterNodes: laterNodes)
201
+ if shouldInclude(
202
+ snapshot: snapshot,
203
+ label: label,
204
+ identifier: identifier,
205
+ valueText: valueText,
206
+ options: options,
207
+ hittable: hittable
208
+ ) {
209
+ nodes.append(
210
+ SnapshotNode(
211
+ index: nodes.count,
212
+ type: elementTypeName(snapshot.elementType),
213
+ label: label.isEmpty ? nil : label,
214
+ identifier: identifier.isEmpty ? nil : identifier,
215
+ value: valueText,
216
+ rect: snapshotRect(from: snapshot.frame),
217
+ enabled: snapshot.isEnabled,
218
+ hittable: hittable,
219
+ depth: depth,
220
+ )
221
+ )
222
+ }
223
+
224
+ let children = snapshot.children
225
+ for child in children {
226
+ walk(child, depth: depth + 1)
227
+ if truncated { return }
228
+ }
229
+ }
230
+
231
+ walk(rootSnapshot, depth: 0)
232
+ return DataPayload(nodes: nodes, truncated: truncated)
233
+ }
234
+
235
+ func snapshotRect(from frame: CGRect) -> SnapshotRect {
236
+ return SnapshotRect(
237
+ x: Double(frame.origin.x),
238
+ y: Double(frame.origin.y),
239
+ width: Double(frame.size.width),
240
+ height: Double(frame.size.height)
241
+ )
242
+ }
243
+
244
+ // MARK: - Snapshot Filtering
245
+
246
+ private func shouldInclude(
247
+ snapshot: XCUIElementSnapshot,
248
+ label: String,
249
+ identifier: String,
250
+ valueText: String?,
251
+ options: SnapshotOptions,
252
+ hittable: Bool
253
+ ) -> Bool {
254
+ let type = snapshot.elementType
255
+ let hasContent = !label.isEmpty || !identifier.isEmpty || (valueText != nil)
256
+ if options.compact && type == .other && !hasContent && !hittable {
257
+ if snapshot.children.count <= 1 { return false }
258
+ }
259
+ if options.interactiveOnly {
260
+ if interactiveTypes.contains(type) { return true }
261
+ if hittable && type != .other { return true }
262
+ if hasContent { return true }
263
+ return false
264
+ }
265
+ if options.compact {
266
+ return hasContent || hittable
267
+ }
268
+ return true
269
+ }
270
+
271
+ private func computedSnapshotHittable(
272
+ _ snapshot: XCUIElementSnapshot,
273
+ viewport: CGRect,
274
+ laterNodes: ArraySlice<XCUIElementSnapshot>
275
+ ) -> Bool {
276
+ guard snapshot.isEnabled else { return false }
277
+ let frame = snapshot.frame
278
+ if frame.isNull || frame.isEmpty { return false }
279
+ let center = CGPoint(x: frame.midX, y: frame.midY)
280
+ if !viewport.contains(center) { return false }
281
+ for node in laterNodes {
282
+ if !isOccludingType(node.elementType) { continue }
283
+ let nodeFrame = node.frame
284
+ if nodeFrame.isNull || nodeFrame.isEmpty { continue }
285
+ if nodeFrame.contains(center) { return false }
286
+ }
287
+ return true
288
+ }
289
+
290
+ private func isOccludingType(_ type: XCUIElement.ElementType) -> Bool {
291
+ switch type {
292
+ case .application, .window:
293
+ return false
294
+ default:
295
+ return true
296
+ }
297
+ }
298
+
299
+ private func flattenedSnapshots(
300
+ _ root: XCUIElementSnapshot
301
+ ) -> ([XCUIElementSnapshot], [ObjectIdentifier: (Int, Int)]) {
302
+ var ordered: [XCUIElementSnapshot] = []
303
+ var ranges: [ObjectIdentifier: (Int, Int)] = [:]
304
+
305
+ @discardableResult
306
+ func visit(_ snapshot: XCUIElementSnapshot) -> Int {
307
+ let start = ordered.count
308
+ ordered.append(snapshot)
309
+ var end = start
310
+ for child in snapshot.children {
311
+ end = max(end, visit(child))
312
+ }
313
+ ranges[ObjectIdentifier(snapshot)] = (start, end)
314
+ return end
315
+ }
316
+
317
+ _ = visit(root)
318
+ return (ordered, ranges)
319
+ }
320
+
321
+ private func laterSnapshots(
322
+ for snapshot: XCUIElementSnapshot,
323
+ in ordered: [XCUIElementSnapshot],
324
+ ranges: [ObjectIdentifier: (Int, Int)]
325
+ ) -> ArraySlice<XCUIElementSnapshot> {
326
+ guard let (_, subtreeEnd) = ranges[ObjectIdentifier(snapshot)] else {
327
+ return ordered.suffix(from: ordered.count)
328
+ }
329
+ let nextIndex = subtreeEnd + 1
330
+ if nextIndex >= ordered.count {
331
+ return ordered.suffix(from: ordered.count)
332
+ }
333
+ return ordered.suffix(from: nextIndex)
334
+ }
335
+
336
+ private func snapshotValueText(_ snapshot: XCUIElementSnapshot) -> String? {
337
+ guard let value = snapshot.value else { return nil }
338
+ let text = String(describing: value).trimmingCharacters(in: .whitespacesAndNewlines)
339
+ return text.isEmpty ? nil : text
340
+ }
341
+
342
+ private func aggregatedLabel(for snapshot: XCUIElementSnapshot, depth: Int = 0) -> String? {
343
+ if depth > 4 { return nil }
344
+ let text = snapshot.label.trimmingCharacters(in: .whitespacesAndNewlines)
345
+ if !text.isEmpty { return text }
346
+ if let valueText = snapshotValueText(snapshot) { return valueText }
347
+ for child in snapshot.children {
348
+ if let childLabel = aggregatedLabel(for: child, depth: depth + 1) {
349
+ return childLabel
350
+ }
351
+ }
352
+ return nil
353
+ }
354
+
355
+ private func isVisibleInViewport(_ rect: CGRect, _ viewport: CGRect) -> Bool {
356
+ if rect.isNull || rect.isEmpty { return false }
357
+ return rect.intersects(viewport)
358
+ }
359
+ }
@@ -0,0 +1,220 @@
1
+ import XCTest
2
+
3
+ extension RunnerTests {
4
+ // MARK: - Blocking System Modal Snapshot
5
+
6
+ func blockingSystemAlertSnapshot() -> DataPayload? {
7
+ guard let modal = firstBlockingSystemModal(in: springboard) else {
8
+ return nil
9
+ }
10
+ let actions = actionableElements(in: modal)
11
+ guard !actions.isEmpty else {
12
+ return nil
13
+ }
14
+
15
+ let title = preferredSystemModalTitle(modal)
16
+ guard let modalNode = safeMakeSnapshotNode(
17
+ element: modal,
18
+ index: 0,
19
+ type: "Alert",
20
+ labelOverride: title,
21
+ identifierOverride: modal.identifier,
22
+ depth: 0,
23
+ hittableOverride: true
24
+ ) else {
25
+ return nil
26
+ }
27
+ var nodes: [SnapshotNode] = [modalNode]
28
+
29
+ for action in actions {
30
+ guard let actionNode = safeMakeSnapshotNode(
31
+ element: action,
32
+ index: nodes.count,
33
+ type: elementTypeName(action.elementType),
34
+ depth: 1,
35
+ hittableOverride: true
36
+ ) else {
37
+ continue
38
+ }
39
+ nodes.append(actionNode)
40
+ }
41
+
42
+ return DataPayload(nodes: nodes, truncated: false)
43
+ }
44
+
45
+ private func firstBlockingSystemModal(in springboard: XCUIApplication) -> XCUIElement? {
46
+ let disableSafeProbe = RunnerEnv.isTruthy("AGENT_DEVICE_RUNNER_DISABLE_SAFE_MODAL_PROBE")
47
+ let queryElements: (() -> [XCUIElement]) -> [XCUIElement] = { fetch in
48
+ if disableSafeProbe {
49
+ return fetch()
50
+ }
51
+ return self.safeElementsQuery(fetch)
52
+ }
53
+
54
+ let alerts = queryElements {
55
+ springboard.alerts.allElementsBoundByIndex
56
+ }
57
+ for alert in alerts {
58
+ if safeIsBlockingSystemModal(alert, in: springboard) {
59
+ return alert
60
+ }
61
+ }
62
+
63
+ let sheets = queryElements {
64
+ springboard.sheets.allElementsBoundByIndex
65
+ }
66
+ for sheet in sheets {
67
+ if safeIsBlockingSystemModal(sheet, in: springboard) {
68
+ return sheet
69
+ }
70
+ }
71
+
72
+ return nil
73
+ }
74
+
75
+ private func safeElementsQuery(_ fetch: () -> [XCUIElement]) -> [XCUIElement] {
76
+ var elements: [XCUIElement] = []
77
+ let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
78
+ elements = fetch()
79
+ })
80
+ if let exceptionMessage {
81
+ NSLog(
82
+ "AGENT_DEVICE_RUNNER_MODAL_QUERY_IGNORED_EXCEPTION=%@",
83
+ exceptionMessage
84
+ )
85
+ return []
86
+ }
87
+ return elements
88
+ }
89
+
90
+ private func safeIsBlockingSystemModal(_ element: XCUIElement, in springboard: XCUIApplication) -> Bool {
91
+ var isBlocking = false
92
+ let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
93
+ isBlocking = isBlockingSystemModal(element, in: springboard)
94
+ })
95
+ if let exceptionMessage {
96
+ NSLog(
97
+ "AGENT_DEVICE_RUNNER_MODAL_CHECK_IGNORED_EXCEPTION=%@",
98
+ exceptionMessage
99
+ )
100
+ return false
101
+ }
102
+ return isBlocking
103
+ }
104
+
105
+ private func isBlockingSystemModal(_ element: XCUIElement, in springboard: XCUIApplication) -> Bool {
106
+ guard element.exists else { return false }
107
+ let frame = element.frame
108
+ if frame.isNull || frame.isEmpty { return false }
109
+
110
+ let viewport = springboard.frame
111
+ if viewport.isNull || viewport.isEmpty { return false }
112
+
113
+ let center = CGPoint(x: frame.midX, y: frame.midY)
114
+ if !viewport.contains(center) { return false }
115
+
116
+ return true
117
+ }
118
+
119
+ private func actionableElements(in element: XCUIElement) -> [XCUIElement] {
120
+ var seen = Set<String>()
121
+ var actions: [XCUIElement] = []
122
+ let descendants = safeElementsQuery {
123
+ element.descendants(matching: .any).allElementsBoundByIndex
124
+ }
125
+ for candidate in descendants {
126
+ if !safeIsActionableCandidate(candidate, seen: &seen) { continue }
127
+ actions.append(candidate)
128
+ }
129
+ return actions
130
+ }
131
+
132
+ private func safeIsActionableCandidate(_ candidate: XCUIElement, seen: inout Set<String>) -> Bool {
133
+ var include = false
134
+ let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
135
+ if !candidate.exists || !candidate.isHittable { return }
136
+ if !actionableTypes.contains(candidate.elementType) { return }
137
+ let frame = candidate.frame
138
+ if frame.isNull || frame.isEmpty { return }
139
+ let key = "\(candidate.elementType.rawValue)-\(frame.origin.x)-\(frame.origin.y)-\(frame.size.width)-\(frame.size.height)-\(candidate.label)"
140
+ if seen.contains(key) { return }
141
+ seen.insert(key)
142
+ include = true
143
+ })
144
+ if let exceptionMessage {
145
+ NSLog(
146
+ "AGENT_DEVICE_RUNNER_MODAL_ACTION_IGNORED_EXCEPTION=%@",
147
+ exceptionMessage
148
+ )
149
+ return false
150
+ }
151
+ return include
152
+ }
153
+
154
+ private func preferredSystemModalTitle(_ element: XCUIElement) -> String {
155
+ let label = element.label
156
+ if !label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
157
+ return label
158
+ }
159
+ let identifier = element.identifier
160
+ if !identifier.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
161
+ return identifier
162
+ }
163
+ return "System Alert"
164
+ }
165
+
166
+ private func makeSnapshotNode(
167
+ element: XCUIElement,
168
+ index: Int,
169
+ type: String,
170
+ labelOverride: String? = nil,
171
+ identifierOverride: String? = nil,
172
+ depth: Int,
173
+ hittableOverride: Bool? = nil
174
+ ) -> SnapshotNode {
175
+ let label = (labelOverride ?? element.label).trimmingCharacters(in: .whitespacesAndNewlines)
176
+ let identifier = (identifierOverride ?? element.identifier).trimmingCharacters(in: .whitespacesAndNewlines)
177
+ return SnapshotNode(
178
+ index: index,
179
+ type: type,
180
+ label: label.isEmpty ? nil : label,
181
+ identifier: identifier.isEmpty ? nil : identifier,
182
+ value: nil,
183
+ rect: snapshotRect(from: element.frame),
184
+ enabled: element.isEnabled,
185
+ hittable: hittableOverride ?? element.isHittable,
186
+ depth: depth
187
+ )
188
+ }
189
+
190
+ private func safeMakeSnapshotNode(
191
+ element: XCUIElement,
192
+ index: Int,
193
+ type: String,
194
+ labelOverride: String? = nil,
195
+ identifierOverride: String? = nil,
196
+ depth: Int,
197
+ hittableOverride: Bool? = nil
198
+ ) -> SnapshotNode? {
199
+ var node: SnapshotNode?
200
+ let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
201
+ node = makeSnapshotNode(
202
+ element: element,
203
+ index: index,
204
+ type: type,
205
+ labelOverride: labelOverride,
206
+ identifierOverride: identifierOverride,
207
+ depth: depth,
208
+ hittableOverride: hittableOverride
209
+ )
210
+ })
211
+ if let exceptionMessage {
212
+ NSLog(
213
+ "AGENT_DEVICE_RUNNER_MODAL_NODE_IGNORED_EXCEPTION=%@",
214
+ exceptionMessage
215
+ )
216
+ return nil
217
+ }
218
+ return node
219
+ }
220
+ }
@@ -0,0 +1,124 @@
1
+ import XCTest
2
+ import Network
3
+
4
+ extension RunnerTests {
5
+ // MARK: - Connection Lifecycle
6
+
7
+ func handle(connection: NWConnection) {
8
+ receiveRequest(connection: connection, buffer: Data())
9
+ }
10
+
11
+ // MARK: - Request Parsing
12
+
13
+ private func receiveRequest(connection: NWConnection, buffer: Data) {
14
+ connection.receive(minimumIncompleteLength: 1, maximumLength: 1024 * 1024) { [weak self] data, _, _, _ in
15
+ guard let self = self, let data = data else {
16
+ connection.cancel()
17
+ return
18
+ }
19
+ if buffer.count + data.count > self.maxRequestBytes {
20
+ let response = self.jsonResponse(
21
+ status: 413,
22
+ response: Response(ok: false, error: ErrorPayload(message: "request too large")),
23
+ )
24
+ connection.send(content: response, completion: .contentProcessed { [weak self] _ in
25
+ connection.cancel()
26
+ self?.finish()
27
+ })
28
+ return
29
+ }
30
+ let combined = buffer + data
31
+ if let body = self.parseRequest(data: combined) {
32
+ let result = self.handleRequestBody(body)
33
+ connection.send(content: result.data, completion: .contentProcessed { _ in
34
+ connection.cancel()
35
+ if result.shouldFinish {
36
+ self.finish()
37
+ }
38
+ })
39
+ } else {
40
+ self.receiveRequest(connection: connection, buffer: combined)
41
+ }
42
+ }
43
+ }
44
+
45
+ private func parseRequest(data: Data) -> Data? {
46
+ guard let headerEnd = data.range(of: Data("\r\n\r\n".utf8)) else {
47
+ return nil
48
+ }
49
+ let headerData = data.subdata(in: 0..<headerEnd.lowerBound)
50
+ let bodyStart = headerEnd.upperBound
51
+ let headers = String(decoding: headerData, as: UTF8.self)
52
+ let contentLength = extractContentLength(headers: headers)
53
+ guard let contentLength = contentLength else {
54
+ return nil
55
+ }
56
+ if data.count < bodyStart + contentLength {
57
+ return nil
58
+ }
59
+ let body = data.subdata(in: bodyStart..<(bodyStart + contentLength))
60
+ return body
61
+ }
62
+
63
+ private func extractContentLength(headers: String) -> Int? {
64
+ for line in headers.split(separator: "\r\n") {
65
+ let parts = line.split(separator: ":", maxSplits: 1).map { $0.trimmingCharacters(in: .whitespaces) }
66
+ if parts.count == 2 && parts[0].lowercased() == "content-length" {
67
+ return Int(parts[1])
68
+ }
69
+ }
70
+ return nil
71
+ }
72
+
73
+ private func handleRequestBody(_ body: Data) -> (data: Data, shouldFinish: Bool) {
74
+ guard let json = String(data: body, encoding: .utf8) else {
75
+ return (
76
+ jsonResponse(status: 400, response: Response(ok: false, error: ErrorPayload(message: "invalid json"))),
77
+ false
78
+ )
79
+ }
80
+ guard let data = json.data(using: .utf8) else {
81
+ return (
82
+ jsonResponse(status: 400, response: Response(ok: false, error: ErrorPayload(message: "invalid json"))),
83
+ false
84
+ )
85
+ }
86
+
87
+ do {
88
+ let command = try JSONDecoder().decode(Command.self, from: data)
89
+ let response = try execute(command: command)
90
+ return (jsonResponse(status: 200, response: response), command.command == .shutdown)
91
+ } catch {
92
+ return (
93
+ jsonResponse(status: 500, response: Response(ok: false, error: ErrorPayload(message: "\(error)"))),
94
+ false
95
+ )
96
+ }
97
+ }
98
+
99
+ // MARK: - Response Encoding
100
+
101
+ private func jsonResponse(status: Int, response: Response) -> Data {
102
+ let encoder = JSONEncoder()
103
+ let body = (try? encoder.encode(response)).flatMap { String(data: $0, encoding: .utf8) } ?? "{}"
104
+ return httpResponse(status: status, body: body)
105
+ }
106
+
107
+ private func httpResponse(status: Int, body: String) -> Data {
108
+ let headers = [
109
+ "HTTP/1.1 \(status) OK",
110
+ "Content-Type: application/json",
111
+ "Content-Length: \(body.utf8.count)",
112
+ "Connection: close",
113
+ "",
114
+ body,
115
+ ].joined(separator: "\r\n")
116
+ return Data(headers.utf8)
117
+ }
118
+
119
+ private func finish() {
120
+ listener?.cancel()
121
+ listener = nil
122
+ doneExpectation?.fulfill()
123
+ }
124
+ }