@swmansion/argent 0.23.0 → 0.23.1-next.0
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/README.md +1 -1
- package/bin/argent-android-devtools-0.1.0.apk +0 -0
- package/bin/darwin/ax-service +0 -0
- package/bin/darwin/tvos-ax-service +0 -0
- package/bin/darwin/tvos-hid-daemon +0 -0
- package/bin/tcp/ax-service +0 -0
- package/dist/cli-cmds.mjs +17 -2
- package/dist/installer.mjs +17 -2
- package/dist/ios-device-runner/ArgentRunner/ArgentRunner/RunnerHostApp.swift +37 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunner.xcodeproj/project.pbxproj +389 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunner.xcodeproj/xcshareddata/xcschemes/ArgentRunner.xcscheme +88 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentExceptionGuard.h +16 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentExceptionGuard.m +16 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentRunnerSession+Commands.swift +280 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentRunnerSession+Gestures.swift +149 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentRunnerSession+Screenshot.swift +21 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentRunnerSession+Snapshot.swift +327 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentRunnerSession+TextEntry.swift +157 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentRunnerSession.swift +410 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentRunnerUITests-Bridging-Header.h +1 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/CommandJournal.swift +134 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/MainThreadGate.swift +111 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/RunnerHTTPServer.swift +267 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/RunnerProtocol.swift +331 -0
- package/dist/mcp-server.mjs +16 -1
- package/dist/tool-server.cjs +4224 -1388
- package/dylibs/libArgentInjectionBootstrap.dylib +0 -0
- package/dylibs/libKeyboardPatch.dylib +0 -0
- package/dylibs/libNativeDevtoolsIos.dylib +0 -0
- package/dylibs/tcp/libArgentInjectionBootstrap.dylib +0 -0
- package/dylibs/tcp/libKeyboardPatch.dylib +0 -0
- package/dylibs/tcp/libNativeDevtoolsIos.dylib +0 -0
- package/dylibs/tvos/libArgentInjectionBootstrap.dylib +0 -0
- package/dylibs/tvos/libKeyboardPatch.dylib +0 -0
- package/dylibs/tvos/libNativeDevtoolsIos.dylib +0 -0
- package/package.json +1 -1
- package/rules/argent.md +8 -3
- package/skills/argent-create-flow/SKILL.md +1 -0
- package/skills/argent-device-interact/SKILL.md +2 -0
- package/skills/argent-ios-device-interact/SKILL.md +18 -0
- package/skills/argent-ios-device-setup/SKILL.md +20 -0
- package/skills/argent-qa-flows/SKILL.md +2 -0
- package/skills/argent-screenshot-diff/SKILL.md +2 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Network
|
|
3
|
+
|
|
4
|
+
/// Minimal single-purpose HTTP/1.1 endpoint on NWListener: each connection
|
|
5
|
+
/// carries one POSTed JSON command and receives one JSON reply, then closes.
|
|
6
|
+
///
|
|
7
|
+
/// The listener is unauthenticated, so it binds loopback-only. The host
|
|
8
|
+
/// reaches the runner through a usbmux forwarded stream that terminates on
|
|
9
|
+
/// the device's own loopback. Bodies are opaque bytes handed to the
|
|
10
|
+
/// dispatch closure.
|
|
11
|
+
final class RunnerHTTPServer {
|
|
12
|
+
struct Reply {
|
|
13
|
+
let status: Int
|
|
14
|
+
let body: Data
|
|
15
|
+
/// When set, the server invokes `onFinish` after this reply has been
|
|
16
|
+
/// flushed. The shutdown acknowledgement must reach the client before
|
|
17
|
+
/// the session ends.
|
|
18
|
+
let finishAfterSend: Bool
|
|
19
|
+
|
|
20
|
+
init(status: Int, body: Data, finishAfterSend: Bool = false) {
|
|
21
|
+
self.status = status
|
|
22
|
+
self.body = body
|
|
23
|
+
self.finishAfterSend = finishAfterSend
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/// Upper bound on the size of one request. Each request carries a single
|
|
28
|
+
/// command, so a larger body indicates a client bug.
|
|
29
|
+
private static let maxRequestBytes = 2 * 1024 * 1024
|
|
30
|
+
private let queue = DispatchQueue(label: "argent.runner.transport")
|
|
31
|
+
private let dispatch: (Data, @escaping (Reply) -> Void) -> Void
|
|
32
|
+
/// The clean end of the session, after a shutdown reply has been flushed.
|
|
33
|
+
private let onFinish: () -> Void
|
|
34
|
+
/// The listener can no longer accept connections (the port is in use, the
|
|
35
|
+
/// interface is unavailable). Carries the failure description.
|
|
36
|
+
private let onListenerFailure: (String) -> Void
|
|
37
|
+
private var listener: NWListener?
|
|
38
|
+
|
|
39
|
+
init(
|
|
40
|
+
dispatch: @escaping (Data, @escaping (Reply) -> Void) -> Void,
|
|
41
|
+
onFinish: @escaping () -> Void,
|
|
42
|
+
onListenerFailure: @escaping (String) -> Void
|
|
43
|
+
) {
|
|
44
|
+
self.dispatch = dispatch
|
|
45
|
+
self.onFinish = onFinish
|
|
46
|
+
self.onListenerFailure = onListenerFailure
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/// Starts listening on loopback, on the given port or a system-assigned
|
|
50
|
+
/// one when the port is 0.
|
|
51
|
+
func start(port: UInt16) throws {
|
|
52
|
+
// Restrict to the loopback interface rather than pinning a 127.0.0.1
|
|
53
|
+
// endpoint. The interface restriction admits either address family and
|
|
54
|
+
// keeps port-0 auto-assignment and the .port readback working.
|
|
55
|
+
let parameters = NWParameters.tcp
|
|
56
|
+
parameters.requiredInterfaceType = .loopback
|
|
57
|
+
let listener: NWListener
|
|
58
|
+
|
|
59
|
+
if port > 0, let nwPort = NWEndpoint.Port(rawValue: port) {
|
|
60
|
+
listener = try NWListener(using: parameters, on: nwPort)
|
|
61
|
+
} else {
|
|
62
|
+
listener = try NWListener(using: parameters)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
listener.stateUpdateHandler = { [weak self] state in
|
|
66
|
+
switch state {
|
|
67
|
+
case .ready:
|
|
68
|
+
NSLog(
|
|
69
|
+
"ARGENT_RUNNER_LISTENING port=%d",
|
|
70
|
+
Int(self?.listener?.port?.rawValue ?? 0)
|
|
71
|
+
)
|
|
72
|
+
case .failed(let error):
|
|
73
|
+
// A failed listener can never receive another command. Hand the
|
|
74
|
+
// failure to the session so it ends as a failed test, and the
|
|
75
|
+
// host observes a failing exit instead of a silent hang or a
|
|
76
|
+
// green run that never served.
|
|
77
|
+
let description = String(describing: error)
|
|
78
|
+
NSLog("ARGENT_RUNNER_LISTENER_FAILED error=%@", description)
|
|
79
|
+
self?.onListenerFailure(description)
|
|
80
|
+
default:
|
|
81
|
+
break
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
listener.newConnectionHandler = { [weak self] connection in
|
|
86
|
+
guard let self else { return }
|
|
87
|
+
connection.start(queue: self.queue)
|
|
88
|
+
self.receive(on: connection, buffered: Data())
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
self.listener = listener
|
|
92
|
+
listener.start(queue: queue)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/// Stops accepting connections.
|
|
96
|
+
func stop() {
|
|
97
|
+
listener?.cancel()
|
|
98
|
+
listener = nil
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/// Accumulates bytes from one connection until the buffered request is
|
|
102
|
+
/// complete, then dispatches its body.
|
|
103
|
+
private func receive(on connection: NWConnection, buffered: Data) {
|
|
104
|
+
connection.receive(minimumIncompleteLength: 1, maximumLength: 1 << 20) {
|
|
105
|
+
[weak self] data, _, _, _ in
|
|
106
|
+
|
|
107
|
+
guard let self, let data, !data.isEmpty else {
|
|
108
|
+
connection.cancel()
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
var buffer = buffered
|
|
113
|
+
buffer.append(data)
|
|
114
|
+
|
|
115
|
+
if buffer.count > Self.maxRequestBytes {
|
|
116
|
+
self.send(
|
|
117
|
+
Reply(
|
|
118
|
+
status: 413,
|
|
119
|
+
body: Self.oversizedRequestBody(
|
|
120
|
+
limit: Self.maxRequestBytes
|
|
121
|
+
)
|
|
122
|
+
),
|
|
123
|
+
over: connection
|
|
124
|
+
)
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
switch Self.requestVerdict(in: buffer) {
|
|
129
|
+
case .complete(let body):
|
|
130
|
+
self.dispatch(body) { reply in
|
|
131
|
+
self.send(reply, over: connection)
|
|
132
|
+
}
|
|
133
|
+
case .incomplete:
|
|
134
|
+
self.receive(on: connection, buffered: buffer)
|
|
135
|
+
case .malformed:
|
|
136
|
+
self.send(
|
|
137
|
+
Reply(status: 400, body: Self.malformedRequestBody()),
|
|
138
|
+
over: connection
|
|
139
|
+
)
|
|
140
|
+
case .oversized:
|
|
141
|
+
self.send(
|
|
142
|
+
Reply(
|
|
143
|
+
status: 413,
|
|
144
|
+
body: Self.oversizedRequestBody(
|
|
145
|
+
limit: Self.maxRequestBytes
|
|
146
|
+
)
|
|
147
|
+
),
|
|
148
|
+
over: connection
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/// Writes the HTTP reply, closes the connection, and triggers `onFinish`
|
|
155
|
+
/// for a reply marked `finishAfterSend`.
|
|
156
|
+
private func send(_ reply: Reply, over connection: NWConnection) {
|
|
157
|
+
let head = [
|
|
158
|
+
"HTTP/1.1 \(reply.status) \(reply.status == 200 ? "OK" : "Error")",
|
|
159
|
+
"Content-Type: application/json",
|
|
160
|
+
"Content-Length: \(reply.body.count)",
|
|
161
|
+
"Connection: close",
|
|
162
|
+
"",
|
|
163
|
+
"",
|
|
164
|
+
].joined(separator: "\r\n")
|
|
165
|
+
|
|
166
|
+
var payload = Data(head.utf8)
|
|
167
|
+
payload.append(reply.body)
|
|
168
|
+
|
|
169
|
+
connection.send(
|
|
170
|
+
content: payload,
|
|
171
|
+
isComplete: true,
|
|
172
|
+
completion: .contentProcessed { [weak self] error in
|
|
173
|
+
if let error {
|
|
174
|
+
NSLog(
|
|
175
|
+
"ARGENT_RUNNER_SEND_FAILED error=%@",
|
|
176
|
+
String(describing: error)
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
connection.cancel()
|
|
181
|
+
|
|
182
|
+
if reply.finishAfterSend {
|
|
183
|
+
self?.onFinish()
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/// Verdict on the bytes buffered so far for one request.
|
|
190
|
+
enum RequestVerdict: Equatable {
|
|
191
|
+
/// The header block or declared body has not fully arrived. Keep
|
|
192
|
+
/// receiving.
|
|
193
|
+
case incomplete
|
|
194
|
+
/// A full request. The payload is the raw body bytes.
|
|
195
|
+
case complete(Data)
|
|
196
|
+
/// The header block ended without a usable Content-Length. Later bytes
|
|
197
|
+
/// cannot repair a finished header block, so waiting for more would
|
|
198
|
+
/// hang the connection.
|
|
199
|
+
case malformed
|
|
200
|
+
/// The declared Content-Length exceeds `maxRequestBytes`. The request
|
|
201
|
+
/// is rejected before its body arrives.
|
|
202
|
+
case oversized
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/// Classifies the buffered bytes as an incomplete, complete, malformed,
|
|
206
|
+
/// or oversized request.
|
|
207
|
+
static func requestVerdict(in buffer: Data) -> RequestVerdict {
|
|
208
|
+
guard let headEnd = buffer.range(of: Data("\r\n\r\n".utf8)) else {
|
|
209
|
+
return .incomplete
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
let head = String(
|
|
213
|
+
decoding: buffer.subdata(
|
|
214
|
+
in: buffer.startIndex..<headEnd.lowerBound
|
|
215
|
+
),
|
|
216
|
+
as: UTF8.self
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
var contentLength: Int?
|
|
220
|
+
|
|
221
|
+
for line in head.split(separator: "\r\n") {
|
|
222
|
+
let parts = line.split(separator: ":", maxSplits: 1)
|
|
223
|
+
guard parts.count == 2 else { continue }
|
|
224
|
+
|
|
225
|
+
if parts[0].trimmingCharacters(in: .whitespaces).lowercased()
|
|
226
|
+
== "content-length"
|
|
227
|
+
{
|
|
228
|
+
contentLength = Int(
|
|
229
|
+
parts[1].trimmingCharacters(in: .whitespaces)
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
guard let contentLength, contentLength >= 0 else { return .malformed }
|
|
235
|
+
guard contentLength <= maxRequestBytes else { return .oversized }
|
|
236
|
+
|
|
237
|
+
let bodyStart = headEnd.upperBound
|
|
238
|
+
|
|
239
|
+
guard
|
|
240
|
+
buffer.distance(from: bodyStart, to: buffer.endIndex)
|
|
241
|
+
>= contentLength
|
|
242
|
+
else { return .incomplete }
|
|
243
|
+
|
|
244
|
+
return .complete(
|
|
245
|
+
buffer.subdata(
|
|
246
|
+
in: bodyStart..<buffer.index(bodyStart, offsetBy: contentLength)
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/// The INVALID_REQUEST body for a request that exceeds the size limit.
|
|
252
|
+
private static func oversizedRequestBody(limit: Int) -> Data {
|
|
253
|
+
Data(
|
|
254
|
+
#"{"ok":false,"error":{"code":"INVALID_REQUEST","message":"request body exceeds \#(limit) bytes"}}"#
|
|
255
|
+
.utf8
|
|
256
|
+
)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/// The INVALID_REQUEST body for a header block without a usable
|
|
260
|
+
/// Content-Length.
|
|
261
|
+
private static func malformedRequestBody() -> Data {
|
|
262
|
+
Data(
|
|
263
|
+
#"{"ok":false,"error":{"code":"INVALID_REQUEST","message":"request headers lack a usable Content-Length"}}"#
|
|
264
|
+
.utf8
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
// MARK: - Argent iOS runner wire protocol (v1)
|
|
4
|
+
//
|
|
5
|
+
// One HTTP POST per command: the request body is a JSON `CommandRequest`, the
|
|
6
|
+
// reply a JSON `Envelope`. PROTOCOL.md at the package root documents the
|
|
7
|
+
// contract. The TypeScript client under
|
|
8
|
+
// packages/tool-server/src/utils/ios-device mirrors these shapes.
|
|
9
|
+
|
|
10
|
+
enum CommandKind: String, Codable {
|
|
11
|
+
case status
|
|
12
|
+
case viewport
|
|
13
|
+
case tap
|
|
14
|
+
case longPress
|
|
15
|
+
case drag
|
|
16
|
+
case type
|
|
17
|
+
case keyboardReturn
|
|
18
|
+
case keyboardDelete
|
|
19
|
+
case button
|
|
20
|
+
case snapshot
|
|
21
|
+
case screenshot
|
|
22
|
+
case shutdown
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
extension CommandKind {
|
|
26
|
+
/// Commands that read state without side effects. The client retries these
|
|
27
|
+
/// freely. Mutating commands are sent exactly once and recovered through
|
|
28
|
+
/// the journal via `status` with `statusCommandId`.
|
|
29
|
+
var isReadOnly: Bool {
|
|
30
|
+
switch self {
|
|
31
|
+
case .status, .viewport, .snapshot, .screenshot:
|
|
32
|
+
return true
|
|
33
|
+
case .tap, .longPress, .drag, .type, .keyboardReturn, .keyboardDelete,
|
|
34
|
+
.button, .shutdown:
|
|
35
|
+
return false
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// Commands that target an app and therefore require `appBundleId`. A
|
|
40
|
+
/// missing target is treated as a caller error, never redirected to the
|
|
41
|
+
/// runner's own host app.
|
|
42
|
+
var requiresAppBundleId: Bool {
|
|
43
|
+
switch self {
|
|
44
|
+
case .viewport, .tap, .longPress, .drag, .type, .keyboardReturn,
|
|
45
|
+
.keyboardDelete, .snapshot:
|
|
46
|
+
return true
|
|
47
|
+
case .status, .button, .screenshot, .shutdown:
|
|
48
|
+
return false
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/// Whether the journal keeps the full response JSON for `status` recovery.
|
|
53
|
+
var retainsResponseInJournal: Bool {
|
|
54
|
+
switch self {
|
|
55
|
+
// Snapshot and screenshot replies are large and read-only. The client
|
|
56
|
+
// reruns them instead of recovering them from the journal, which keeps
|
|
57
|
+
// them from evicting journaled mutation results.
|
|
58
|
+
case .snapshot, .screenshot:
|
|
59
|
+
return false
|
|
60
|
+
default:
|
|
61
|
+
return true
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// Main-thread watchdog budget for one command execution. PROTOCOL.md's
|
|
66
|
+
/// "Timeout budgets" table is the authoritative pairing with the client
|
|
67
|
+
/// windows.
|
|
68
|
+
var executionTimeout: TimeInterval {
|
|
69
|
+
switch self {
|
|
70
|
+
// XCTest types long strings in real time, so typing needs extra room.
|
|
71
|
+
case .type: return 55
|
|
72
|
+
// Gestures must outlast XCTest's ~60s pre-event idle wait. A screen that
|
|
73
|
+
// never reports quiescent stalls the wait until XCTest gives up and
|
|
74
|
+
// synthesizes the event anyway, and a shorter budget would abandon a
|
|
75
|
+
// command that still completes.
|
|
76
|
+
case .tap, .longPress, .drag: return 75
|
|
77
|
+
default: return 30
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/// Hardware buttons the `button` command accepts, mapped onto
|
|
83
|
+
/// `XCUIDevice.Button` in ArgentRunnerSession+Commands.swift so this file
|
|
84
|
+
/// needs no XCTest import. The power/lock button and the app switcher have no
|
|
85
|
+
/// public XCUIDevice API, and `camera` would pin the runner to a newer Xcode.
|
|
86
|
+
enum DeviceButton: String, Codable {
|
|
87
|
+
case home
|
|
88
|
+
case volumeUp
|
|
89
|
+
case volumeDown
|
|
90
|
+
case actionButton
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
struct CommandRequest: Codable {
|
|
94
|
+
let command: CommandKind
|
|
95
|
+
let commandId: String?
|
|
96
|
+
/// `status` only: the commandId whose journaled fate is requested.
|
|
97
|
+
let statusCommandId: String?
|
|
98
|
+
/// The app the command targets. Required when `command.requiresAppBundleId`.
|
|
99
|
+
let appBundleId: String?
|
|
100
|
+
/// `tap`/`longPress`: absolute point (in points) in the app's coordinate space.
|
|
101
|
+
let x: Double?
|
|
102
|
+
let y: Double?
|
|
103
|
+
/// `tap`: number of taps. Defaults to 1; 2 is the native double-tap.
|
|
104
|
+
/// Higher counts loop single taps on the device: there is no native N-tap
|
|
105
|
+
/// API, so they land as separate taps, not one multi-tap gesture.
|
|
106
|
+
let numberOfTaps: Int?
|
|
107
|
+
/// `drag`: absolute start and end points.
|
|
108
|
+
let fromX: Double?
|
|
109
|
+
let fromY: Double?
|
|
110
|
+
let toX: Double?
|
|
111
|
+
let toY: Double?
|
|
112
|
+
/// `longPress`: press duration. `drag`: duration of the movement.
|
|
113
|
+
let durationMs: Double?
|
|
114
|
+
/// `drag`: how long the touch rests at the start point before it moves
|
|
115
|
+
/// (default 50 ms). A long-press pickup, the way a list item is lifted for
|
|
116
|
+
/// reordering, needs about 500 ms or more.
|
|
117
|
+
let holdMs: Double?
|
|
118
|
+
/// `drag`: rest the touch at the destination before lifting so the release
|
|
119
|
+
/// velocity is near zero and the scroll view does not fling. Mirrors the
|
|
120
|
+
/// simulator's ease-out `settle` swipe.
|
|
121
|
+
let settle: Bool?
|
|
122
|
+
/// `type`: the text delivered to the focused input.
|
|
123
|
+
let text: String?
|
|
124
|
+
/// `button`: which hardware button to press.
|
|
125
|
+
let button: DeviceButton?
|
|
126
|
+
|
|
127
|
+
/// The trimmed commandId, nil when absent or blank.
|
|
128
|
+
var normalizedCommandId: String? {
|
|
129
|
+
guard
|
|
130
|
+
let trimmed = commandId?.trimmingCharacters(
|
|
131
|
+
in: .whitespacesAndNewlines
|
|
132
|
+
),
|
|
133
|
+
!trimmed.isEmpty
|
|
134
|
+
else { return nil }
|
|
135
|
+
return trimmed
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/// The trimmed appBundleId, nil when absent or blank.
|
|
139
|
+
var normalizedAppBundleId: String? {
|
|
140
|
+
guard
|
|
141
|
+
let trimmed = appBundleId?.trimmingCharacters(
|
|
142
|
+
in: .whitespacesAndNewlines
|
|
143
|
+
),
|
|
144
|
+
!trimmed.isEmpty
|
|
145
|
+
else { return nil }
|
|
146
|
+
return trimmed
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/// Stable error codes shared with the TypeScript client. Only RUNNER_BUSY is
|
|
151
|
+
/// client-retryable. RUNNER_WEDGED tells the host to recycle the session.
|
|
152
|
+
enum RunnerErrorCode: String {
|
|
153
|
+
case invalidRequest = "INVALID_REQUEST"
|
|
154
|
+
case appBundleIdRequired = "APP_BUNDLE_ID_REQUIRED"
|
|
155
|
+
case appNotAvailable = "APP_NOT_AVAILABLE"
|
|
156
|
+
case appBackgrounded = "APP_BACKGROUNDED"
|
|
157
|
+
case textInputNotFocused = "TEXT_INPUT_NOT_FOCUSED"
|
|
158
|
+
/// Hardware or an API this device lacks: an absent button, a tap count
|
|
159
|
+
/// XCUICoordinate cannot execute as one gesture. An XCTest call that
|
|
160
|
+
/// throws mid-command is COMMAND_FAILED, never this.
|
|
161
|
+
case unsupportedOperation = "UNSUPPORTED_OPERATION"
|
|
162
|
+
case runnerBusy = "RUNNER_BUSY"
|
|
163
|
+
case runnerWedged = "RUNNER_WEDGED"
|
|
164
|
+
case xctestRecordedFailure = "XCTEST_RECORDED_FAILURE"
|
|
165
|
+
case snapshotFailed = "SNAPSHOT_FAILED"
|
|
166
|
+
case commandTimedOut = "COMMAND_TIMED_OUT"
|
|
167
|
+
case commandFailed = "COMMAND_FAILED"
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
struct ErrorPayload: Encodable {
|
|
171
|
+
let code: String
|
|
172
|
+
let message: String
|
|
173
|
+
let hint: String?
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/// Type-erased Encodable wrapper that lets `Envelope` carry any per-command
|
|
177
|
+
/// payload.
|
|
178
|
+
struct AnyEncodable: Encodable {
|
|
179
|
+
private let encodeInto: (Encoder) throws -> Void
|
|
180
|
+
init<T: Encodable>(_ value: T) { self.encodeInto = value.encode(to:) }
|
|
181
|
+
func encode(to encoder: Encoder) throws { try encodeInto(encoder) }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
struct Envelope: Encodable {
|
|
185
|
+
let ok: Bool
|
|
186
|
+
let data: AnyEncodable?
|
|
187
|
+
let error: ErrorPayload?
|
|
188
|
+
/// True only when the runner re-fronted a backgrounded target app before
|
|
189
|
+
/// executing the command (see `foregroundTarget`), on ok and error
|
|
190
|
+
/// envelopes alike: the re-front precedes the command, so a command that
|
|
191
|
+
/// then failed changed the foreground screen too. nil is omitted from the
|
|
192
|
+
/// encoded JSON.
|
|
193
|
+
let reactivated: Bool?
|
|
194
|
+
/// Advisory on an otherwise-ok reply: set when a mutating command succeeded
|
|
195
|
+
/// while the suppressed-issue counter grew, meaning the gesture may have
|
|
196
|
+
/// missed. nil is omitted from the encoded JSON.
|
|
197
|
+
let warning: String?
|
|
198
|
+
|
|
199
|
+
/// An ok envelope carrying the payload.
|
|
200
|
+
static func success<T: Encodable>(_ payload: T) -> Envelope {
|
|
201
|
+
Envelope(
|
|
202
|
+
ok: true,
|
|
203
|
+
data: AnyEncodable(payload),
|
|
204
|
+
error: nil,
|
|
205
|
+
reactivated: nil,
|
|
206
|
+
warning: nil
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/// An error envelope with a stable code, a message, and an optional hint.
|
|
211
|
+
static func failure(
|
|
212
|
+
_ code: RunnerErrorCode,
|
|
213
|
+
_ message: String,
|
|
214
|
+
hint: String? = nil
|
|
215
|
+
) -> Envelope {
|
|
216
|
+
Envelope(
|
|
217
|
+
ok: false,
|
|
218
|
+
data: nil,
|
|
219
|
+
error: ErrorPayload(
|
|
220
|
+
code: code.rawValue,
|
|
221
|
+
message: message,
|
|
222
|
+
hint: hint
|
|
223
|
+
),
|
|
224
|
+
reactivated: nil,
|
|
225
|
+
warning: nil
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/// A copy of this reply with `reactivated: true`, marking that the target
|
|
230
|
+
/// app was backgrounded and had to be re-fronted before the command ran.
|
|
231
|
+
func withReactivated() -> Envelope {
|
|
232
|
+
Envelope(
|
|
233
|
+
ok: ok,
|
|
234
|
+
data: data,
|
|
235
|
+
error: error,
|
|
236
|
+
reactivated: true,
|
|
237
|
+
warning: warning
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/// A copy of this reply carrying an advisory `warning`. Composes with
|
|
242
|
+
/// `withReactivated()`, so a reply can carry both markers.
|
|
243
|
+
func withWarning(_ warning: String) -> Envelope {
|
|
244
|
+
Envelope(
|
|
245
|
+
ok: ok,
|
|
246
|
+
data: data,
|
|
247
|
+
error: error,
|
|
248
|
+
reactivated: reactivated,
|
|
249
|
+
warning: warning
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// MARK: - Per-command payloads
|
|
255
|
+
|
|
256
|
+
struct MessagePayload: Encodable {
|
|
257
|
+
let message: String
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
struct HealthPayload: Encodable {
|
|
261
|
+
let uptimeMs: Double
|
|
262
|
+
/// "idle" | "busy" | "wedged", the main-thread gate's view of the runner.
|
|
263
|
+
let state: String
|
|
264
|
+
/// XCTIssues muted as accessibility noise since launch. Suppression matches
|
|
265
|
+
/// Apple-owned wording in `ArgentRunnerSession.SuppressedIssueWording`, so
|
|
266
|
+
/// a count stuck at zero while `recordedFailures` climbs means an Xcode
|
|
267
|
+
/// release reworded the strings.
|
|
268
|
+
let suppressedIssues: Int
|
|
269
|
+
/// XCTest's cumulative recorded-failure count for the session, read from
|
|
270
|
+
/// `testRun.totalFailureCount`. A failure that gets past suppression turns
|
|
271
|
+
/// a successful mutation into XCTEST_RECORDED_FAILURE.
|
|
272
|
+
let recordedFailures: Int
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
struct CommandStatusPayload: Encodable {
|
|
276
|
+
let commandId: String
|
|
277
|
+
/// "notAccepted" | "accepted" | "started" | "completed" | "failed"
|
|
278
|
+
let state: String
|
|
279
|
+
let command: String?
|
|
280
|
+
let responseOk: Bool?
|
|
281
|
+
/// The completed command's full JSON envelope, when retained.
|
|
282
|
+
let responseJson: String?
|
|
283
|
+
let errorCode: String?
|
|
284
|
+
let errorMessage: String?
|
|
285
|
+
let errorHint: String?
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
struct ViewportPayload: Encodable {
|
|
289
|
+
let x: Double
|
|
290
|
+
let y: Double
|
|
291
|
+
let width: Double
|
|
292
|
+
let height: Double
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
struct ScreenshotPayload: Encodable {
|
|
296
|
+
let imageBase64: String
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
struct SnapshotRect: Encodable {
|
|
300
|
+
let x: Double
|
|
301
|
+
let y: Double
|
|
302
|
+
let width: Double
|
|
303
|
+
let height: Double
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
struct SnapshotNodePayload: Encodable {
|
|
307
|
+
let index: Int
|
|
308
|
+
let type: String
|
|
309
|
+
let label: String?
|
|
310
|
+
let identifier: String?
|
|
311
|
+
let value: String?
|
|
312
|
+
let rect: SnapshotRect
|
|
313
|
+
let enabled: Bool
|
|
314
|
+
let focused: Bool?
|
|
315
|
+
let selected: Bool?
|
|
316
|
+
let depth: Int
|
|
317
|
+
let parentIndex: Int?
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
struct SnapshotQualityPayload: Encodable {
|
|
321
|
+
/// "healthy" | "degraded"
|
|
322
|
+
let state: String
|
|
323
|
+
let backend: String
|
|
324
|
+
let reason: String?
|
|
325
|
+
let reasonCode: String?
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
struct SnapshotPayload: Encodable {
|
|
329
|
+
let nodes: [SnapshotNodePayload]
|
|
330
|
+
let quality: SnapshotQualityPayload
|
|
331
|
+
}
|
package/dist/mcp-server.mjs
CHANGED
|
@@ -16474,6 +16474,18 @@ var FAILURE_CODES = {
|
|
|
16474
16474
|
SIMULATOR_COMMAND_REJECTED: "SIMULATOR_COMMAND_REJECTED",
|
|
16475
16475
|
SIMULATOR_COMMAND_ACK_TIMEOUT: "SIMULATOR_COMMAND_ACK_TIMEOUT",
|
|
16476
16476
|
SIMULATOR_COMMAND_TRANSPORT_FAILED: "SIMULATOR_COMMAND_TRANSPORT_FAILED",
|
|
16477
|
+
// Physical iOS devices: the XCUITest-runner blueprint lifecycle, the runner
|
|
16478
|
+
// command client, and the `xcrun devicectl` subprocess wrapper (simulators
|
|
16479
|
+
// use the SIMULATOR_* set).
|
|
16480
|
+
IOS_DEVICE_RUNNER_FACTORY_OPTIONS_MISSING: "IOS_DEVICE_RUNNER_FACTORY_OPTIONS_MISSING",
|
|
16481
|
+
IOS_DEVICE_RUNNER_NOT_READY: "IOS_DEVICE_RUNNER_NOT_READY",
|
|
16482
|
+
IOS_DEVICE_RUNNER_TERMINATED: "IOS_DEVICE_RUNNER_TERMINATED",
|
|
16483
|
+
IOS_DEVICE_RUNNER_EXITED: "IOS_DEVICE_RUNNER_EXITED",
|
|
16484
|
+
// The runner answered a command with an ok:false envelope.
|
|
16485
|
+
IOS_DEVICE_RUNNER_COMMAND_FAILED: "IOS_DEVICE_RUNNER_COMMAND_FAILED",
|
|
16486
|
+
// The usbmux or HTTP exchange with a reachable runner failed.
|
|
16487
|
+
IOS_DEVICE_RUNNER_TRANSPORT_FAILED: "IOS_DEVICE_RUNNER_TRANSPORT_FAILED",
|
|
16488
|
+
IOS_DEVICECTL_COMMAND_FAILED: "IOS_DEVICECTL_COMMAND_FAILED",
|
|
16477
16489
|
AX_QUERY_TIMEOUT: "AX_QUERY_TIMEOUT",
|
|
16478
16490
|
AX_DAEMON_READY_TIMEOUT: "AX_DAEMON_READY_TIMEOUT",
|
|
16479
16491
|
AX_DAEMON_EXITED_BEFORE_READY: "AX_DAEMON_EXITED_BEFORE_READY",
|
|
@@ -16644,6 +16656,7 @@ var FAILURE_CODES = {
|
|
|
16644
16656
|
KEYBOARD_KEY_UNSUPPORTED: "KEYBOARD_KEY_UNSUPPORTED",
|
|
16645
16657
|
KEYBOARD_CHARACTER_UNSUPPORTED: "KEYBOARD_CHARACTER_UNSUPPORTED",
|
|
16646
16658
|
KEYBOARD_TEXT_AND_KEY_COMBINED: "KEYBOARD_TEXT_AND_KEY_COMBINED",
|
|
16659
|
+
KEYBOARD_INPUT_NOT_FOCUSED: "KEYBOARD_INPUT_NOT_FOCUSED",
|
|
16647
16660
|
SECRET_PLACEHOLDER_UNKNOWN: "SECRET_PLACEHOLDER_UNKNOWN",
|
|
16648
16661
|
SCREENSHOT_DIFF_INPUT_INVALID: "SCREENSHOT_DIFF_INPUT_INVALID",
|
|
16649
16662
|
BOOT_DEVICE_TARGET_SELECTION_INVALID: "BOOT_DEVICE_TARGET_SELECTION_INVALID",
|
|
@@ -16688,7 +16701,9 @@ var FAILURE_COMMANDS = [
|
|
|
16688
16701
|
"electron",
|
|
16689
16702
|
"npm",
|
|
16690
16703
|
"npx",
|
|
16691
|
-
"unknown"
|
|
16704
|
+
"unknown",
|
|
16705
|
+
"devicectl",
|
|
16706
|
+
"xcodebuild"
|
|
16692
16707
|
];
|
|
16693
16708
|
var FAILURE_SIGNAL_NAMES = [
|
|
16694
16709
|
"SIGABRT",
|