mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
e58cbcdb5f
Move the scattered root-level native projects into per-platform folders and drop
the now-redundant platform prefix:
- android-ime-helper/ -> android/ime-helper/
- android-multitouch-helper/ -> android/multitouch-helper/
- android-snapshot-helper/ -> android/snapshot-helper/
- apple-runner/ -> apple/runner/
- macos-helper/ -> apple/macos-helper/
- src/platforms/linux/atspi-dump.py -> linux/atspi-dump.py
Only repo source paths move. Identity surfaces stay frozen so no user's runner
cache is invalidated on upgrade: the derived-cache key hashes source paths
relative to AgentDeviceRunner and excludes packageVersion, and the
~/.agent-device/{apple-runner,macos-helper} namespaces, the
agent-device-android-*-helper artifact/manifest/protocol names, the
AgentDeviceRunner Xcode project, and the `prepare ios-runner` CLI command are
unchanged. Updates build/package scripts, CI, package.json files+scripts,
ignore/attr/fallow configs, runtime path resolvers, and test fixtures.
Also: re-base repo-root-relative refs inside the moved apple/runner for the
added nesting level (gated XCUITest fixture walk + two doc links), and clean the
legacy dist/apple-runner packaged output so the relocated runner can't
double-ship into the wholesale-included dist (with a regression test).
318 lines
10 KiB
Swift
318 lines
10 KiB
Swift
import XCTest
|
|
import Network
|
|
|
|
extension RunnerTests {
|
|
// MARK: - Connection Lifecycle
|
|
|
|
func handle(connection: NWConnection) {
|
|
receiveRequest(connection: connection, buffer: Data())
|
|
}
|
|
|
|
// MARK: - Request Parsing
|
|
|
|
private func receiveRequest(connection: NWConnection, buffer: Data) {
|
|
connection.receive(minimumIncompleteLength: 1, maximumLength: 1024 * 1024) { [weak self] data, _, _, _ in
|
|
guard let self = self, let data = data else {
|
|
connection.cancel()
|
|
return
|
|
}
|
|
if buffer.count + data.count > self.maxRequestBytes {
|
|
let response = self.jsonResponse(
|
|
status: 413,
|
|
response: self.errorResponse(
|
|
code: "INVALID_ARGS",
|
|
message: "runner request body exceeds \(self.maxRequestBytes) bytes",
|
|
hint: "Send one runner command per request and keep the payload below the runner request limit."
|
|
)
|
|
)
|
|
self.sendResponse(response, over: connection) { [weak self] in
|
|
self?.finish()
|
|
}
|
|
return
|
|
}
|
|
let combined = buffer + data
|
|
if let body = self.parseRequest(data: combined) {
|
|
self.handleRequestBody(body) { [weak self] result in
|
|
self?.sendResponse(result.data, over: connection) { [weak self] in
|
|
if result.shouldFinish {
|
|
self?.finish()
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
self.receiveRequest(connection: connection, buffer: combined)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func sendResponse(
|
|
_ response: Data,
|
|
over connection: NWConnection,
|
|
afterSend: @escaping () -> Void = {}
|
|
) {
|
|
connection.send(content: response, isComplete: true, completion: .contentProcessed { error in
|
|
if let error {
|
|
NSLog("AGENT_DEVICE_RUNNER_SEND_FAILED=%@", String(describing: error))
|
|
}
|
|
connection.cancel()
|
|
afterSend()
|
|
})
|
|
}
|
|
|
|
private func parseRequest(data: Data) -> Data? {
|
|
guard let headerEnd = data.range(of: Data("\r\n\r\n".utf8)) else {
|
|
return nil
|
|
}
|
|
let headerData = data.subdata(in: 0..<headerEnd.lowerBound)
|
|
let bodyStart = headerEnd.upperBound
|
|
let headers = String(decoding: headerData, as: UTF8.self)
|
|
let contentLength = extractContentLength(headers: headers)
|
|
guard let contentLength = contentLength else {
|
|
return nil
|
|
}
|
|
if data.count < bodyStart + contentLength {
|
|
return nil
|
|
}
|
|
let body = data.subdata(in: bodyStart..<(bodyStart + contentLength))
|
|
return body
|
|
}
|
|
|
|
private func extractContentLength(headers: String) -> Int? {
|
|
for line in headers.split(separator: "\r\n") {
|
|
let parts = line.split(separator: ":", maxSplits: 1).map { $0.trimmingCharacters(in: .whitespaces) }
|
|
if parts.count == 2 && parts[0].lowercased() == "content-length" {
|
|
return Int(parts[1])
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func handleRequestBody(
|
|
_ body: Data,
|
|
completion: @escaping ((data: Data, shouldFinish: Bool)) -> Void
|
|
) {
|
|
guard String(data: body, encoding: .utf8) != nil else {
|
|
completion((
|
|
jsonResponse(
|
|
status: 400,
|
|
response: errorResponse(
|
|
code: "INVALID_ARGS",
|
|
message: "runner request body must be UTF-8 JSON",
|
|
hint: "Send a JSON object matching the runner command protocol."
|
|
)
|
|
),
|
|
false
|
|
))
|
|
return
|
|
}
|
|
|
|
do {
|
|
let command = try JSONDecoder().decode(Command.self, from: body)
|
|
if command.command == .status {
|
|
completion((jsonResponse(status: 200, response: executeStatus(command: command)), false))
|
|
return
|
|
}
|
|
if command.command == .uptime {
|
|
completion((jsonResponse(status: 200, response: executeUptime()), false))
|
|
return
|
|
}
|
|
// Re-sends of a still-executing commandId (the daemon's transport retry loop) attach to
|
|
// the in-flight execution and receive its response instead of piling a second execution
|
|
// onto the main queue behind it (#1105 capture pileup).
|
|
if attachToInFlightCommandIfNeeded(command: command, completion: completion) {
|
|
return
|
|
}
|
|
NSLog(
|
|
"AGENT_DEVICE_RUNNER_COMMAND_ACCEPTED command=%@ commandId=%@",
|
|
command.command.rawValue,
|
|
command.commandId ?? ""
|
|
)
|
|
commandJournal.accept(command: command)
|
|
commandExecutionQueue.async {
|
|
do {
|
|
let response = try self.executeAccepted(command: command)
|
|
NSLog(
|
|
"AGENT_DEVICE_RUNNER_COMMAND_COMPLETED command=%@ commandId=%@ ok=%d",
|
|
command.command.rawValue,
|
|
command.commandId ?? "",
|
|
response.ok ? 1 : 0
|
|
)
|
|
self.deliverCommandResult(
|
|
command: command,
|
|
result: (self.jsonResponse(status: 200, response: response), command.command == .shutdown),
|
|
completion: completion
|
|
)
|
|
} catch {
|
|
NSLog(
|
|
"AGENT_DEVICE_RUNNER_COMMAND_FAILED command=%@ commandId=%@ error=%@",
|
|
command.command.rawValue,
|
|
command.commandId ?? "",
|
|
String(describing: error)
|
|
)
|
|
self.deliverCommandResult(
|
|
command: command,
|
|
result: (
|
|
self.jsonResponse(
|
|
status: 500,
|
|
response: self.errorResponse(
|
|
code: "COMMAND_FAILED",
|
|
message: error.localizedDescription,
|
|
hint: "Check the runner log for XCTest details, then retry after the app is foregrounded if this was a timeout or activation failure."
|
|
)
|
|
),
|
|
false
|
|
),
|
|
completion: completion
|
|
)
|
|
}
|
|
}
|
|
} catch {
|
|
completion((
|
|
jsonResponse(
|
|
status: 400,
|
|
response: errorResponse(
|
|
code: "INVALID_ARGS",
|
|
message: "runner command payload is invalid: \(String(describing: error))",
|
|
hint: "Check the command name and fields against the runner protocol."
|
|
)
|
|
),
|
|
false
|
|
))
|
|
}
|
|
}
|
|
|
|
// MARK: - In-Flight Command Coalescing
|
|
|
|
/// Returns true when this send duplicated a still-executing commandId and was attached as a
|
|
/// waiter of the in-flight execution. Otherwise marks the commandId in flight and returns
|
|
/// false so the caller enqueues the (single) execution.
|
|
private func attachToInFlightCommandIfNeeded(
|
|
command: Command,
|
|
completion: @escaping ((data: Data, shouldFinish: Bool)) -> Void
|
|
) -> Bool {
|
|
guard let commandId = normalizedInFlightCommandId(command.commandId) else { return false }
|
|
inFlightCommandLock.lock()
|
|
if inFlightCommandIds.contains(commandId) {
|
|
inFlightCommandWaiters[commandId, default: []].append(completion)
|
|
inFlightCommandLock.unlock()
|
|
NSLog(
|
|
"AGENT_DEVICE_RUNNER_COMMAND_COALESCED command=%@ commandId=%@",
|
|
command.command.rawValue,
|
|
commandId
|
|
)
|
|
return true
|
|
}
|
|
inFlightCommandIds.insert(commandId)
|
|
inFlightCommandLock.unlock()
|
|
return false
|
|
}
|
|
|
|
private func deliverCommandResult(
|
|
command: Command,
|
|
result: (data: Data, shouldFinish: Bool),
|
|
completion: ((data: Data, shouldFinish: Bool)) -> Void
|
|
) {
|
|
var waiters: [((data: Data, shouldFinish: Bool)) -> Void] = []
|
|
if let commandId = normalizedInFlightCommandId(command.commandId) {
|
|
inFlightCommandLock.lock()
|
|
inFlightCommandIds.remove(commandId)
|
|
waiters = inFlightCommandWaiters.removeValue(forKey: commandId) ?? []
|
|
inFlightCommandLock.unlock()
|
|
}
|
|
completion(result)
|
|
for waiter in waiters {
|
|
waiter(result)
|
|
}
|
|
}
|
|
|
|
private func normalizedInFlightCommandId(_ commandId: String?) -> String? {
|
|
guard let trimmed = commandId?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!trimmed.isEmpty
|
|
else {
|
|
return nil
|
|
}
|
|
return trimmed
|
|
}
|
|
|
|
#if AGENT_DEVICE_RUNNER_UNIT_TESTS
|
|
func testDuplicateCommandIdCoalescesOntoInFlightExecution() throws {
|
|
let command = try JSONDecoder().decode(
|
|
Command.self,
|
|
from: Data(#"{"command":"snapshot","commandId":"snapshot-coalesce"}"#.utf8)
|
|
)
|
|
var primaryData: Data?
|
|
var waiterData: Data?
|
|
defer {
|
|
inFlightCommandIds.removeAll()
|
|
inFlightCommandWaiters.removeAll()
|
|
}
|
|
|
|
XCTAssertFalse(
|
|
attachToInFlightCommandIfNeeded(command: command) { result in
|
|
primaryData = result.data
|
|
}
|
|
)
|
|
XCTAssertTrue(
|
|
attachToInFlightCommandIfNeeded(command: command) { result in
|
|
waiterData = result.data
|
|
}
|
|
)
|
|
|
|
let delivered = Data("single-result".utf8)
|
|
deliverCommandResult(
|
|
command: command,
|
|
result: (delivered, false)
|
|
) { result in
|
|
primaryData = result.data
|
|
}
|
|
|
|
XCTAssertEqual(primaryData, delivered)
|
|
XCTAssertEqual(waiterData, delivered)
|
|
XCTAssertFalse(inFlightCommandIds.contains("snapshot-coalesce"))
|
|
XCTAssertNil(inFlightCommandWaiters["snapshot-coalesce"])
|
|
}
|
|
#endif
|
|
|
|
// MARK: - Response Encoding
|
|
|
|
private func jsonResponse(status: Int, response: Response) -> Data {
|
|
// Stamp the gesture-clock uptime at the END of command handling, just before the HTTP
|
|
// write, so the warm snapshot and recordStart responses carry the anchor for free. This
|
|
// runs AFTER commandJournal.finish, so journal-stored lifecycleResponseJson stays
|
|
// unstamped — recovered/status-replayed results carry no anchor and the daemon falls back
|
|
// rather than pairing a stale uptime with a much-later receipt time.
|
|
let stamped =
|
|
response.ok
|
|
? response.stampingCurrentUptimeMs(ProcessInfo.processInfo.systemUptime * 1000)
|
|
: response
|
|
let encoder = JSONEncoder()
|
|
let body = (try? encoder.encode(stamped)).flatMap { String(data: $0, encoding: .utf8) } ?? "{}"
|
|
return httpResponse(status: status, body: body)
|
|
}
|
|
|
|
private func errorResponse(code: String, message: String, hint: String? = nil) -> Response {
|
|
Response(ok: false, error: ErrorPayload(code: code, message: message, hint: hint))
|
|
}
|
|
|
|
private func httpResponse(status: Int, body: String) -> Data {
|
|
let headers = [
|
|
"HTTP/1.1 \(status) OK",
|
|
"Content-Type: application/json",
|
|
"Content-Length: \(body.utf8.count)",
|
|
"Connection: close",
|
|
"",
|
|
body
|
|
].joined(separator: "\r\n")
|
|
return Data(headers.utf8)
|
|
}
|
|
|
|
private func finish() {
|
|
listener?.cancel()
|
|
listener = nil
|
|
// Guard against double-fulfill: coalesced shutdown sends deliver one result to
|
|
// multiple waiters, each of which may ask to finish.
|
|
doneExpectation?.fulfill()
|
|
doneExpectation = nil
|
|
}
|
|
}
|