Skip to content

Commit 3f65124

Browse files
authored
refactor: add iOS runner lifecycle protocol (#658)
* refactor: add ios runner lifecycle protocol * fix: avoid journaling ios runner status probes * fix: cap ios runner journal responses * perf: avoid snapshot journal serialization * perf: skip ids for ios runner status probes * refactor: keep ios runner status off main thread
1 parent cbe725d commit 3f65124

10 files changed

Lines changed: 353 additions & 22 deletions

File tree

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,33 @@ extension RunnerTests {
9494
}
9595

9696
func execute(command: Command) throws -> Response {
97+
if command.command == .status {
98+
return executeStatus(command: command)
99+
}
100+
commandJournal.accept(command: command)
101+
commandJournal.start(command: command)
102+
do {
103+
let response = try executeDispatched(command: command)
104+
commandJournal.finish(command: command, response: response)
105+
return response
106+
} catch {
107+
commandJournal.fail(command: command, error: error)
108+
throw error
109+
}
110+
}
111+
112+
private func executeStatus(command: Command) -> Response {
113+
guard
114+
let statusCommandId = command.statusCommandId?
115+
.trimmingCharacters(in: .whitespacesAndNewlines),
116+
!statusCommandId.isEmpty
117+
else {
118+
return Response(ok: false, error: ErrorPayload(message: "status requires statusCommandId"))
119+
}
120+
return Response(ok: true, data: commandJournal.status(commandId: statusCommandId))
121+
}
122+
123+
private func executeDispatched(command: Command) throws -> Response {
97124
if Thread.isMainThread {
98125
return try executeOnMainSafely(command: command)
99126
}
@@ -251,6 +278,8 @@ extension RunnerTests {
251278
}
252279

253280
switch command.command {
281+
case .status:
282+
return executeStatus(command: command)
254283
case .shutdown:
255284
stopRecordingIfNeeded()
256285
return Response(ok: true, data: DataPayload(message: "shutdown"))
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import Foundation
2+
3+
enum RunnerCommandLifecycleState: String {
4+
case notAccepted
5+
case accepted
6+
case started
7+
case completed
8+
case failed
9+
}
10+
11+
struct RunnerCommandJournalEntry {
12+
let commandId: String
13+
let command: String
14+
var state: RunnerCommandLifecycleState
15+
var responseOk: Bool?
16+
var responseJson: String?
17+
var error: ErrorPayload?
18+
}
19+
20+
final class RunnerCommandJournal {
21+
private let lock = NSLock()
22+
private let maxEntries = 64
23+
private let maxResponseJsonBytes = 16 * 1024
24+
private var entries: [String: RunnerCommandJournalEntry] = [:]
25+
private var order: [String] = []
26+
27+
func accept(command: Command) {
28+
guard let commandId = normalizedCommandId(command.commandId) else { return }
29+
lock.lock()
30+
defer { lock.unlock() }
31+
entries[commandId] = RunnerCommandJournalEntry(
32+
commandId: commandId,
33+
command: command.command.rawValue,
34+
state: .accepted,
35+
responseOk: nil,
36+
responseJson: nil,
37+
error: nil
38+
)
39+
order.removeAll { $0 == commandId }
40+
order.append(commandId)
41+
pruneIfNeeded()
42+
}
43+
44+
func start(command: Command) {
45+
update(command: command, state: .started, responseOk: nil, responseJson: nil, error: nil)
46+
}
47+
48+
func finish(command: Command, response: Response) {
49+
update(
50+
command: command,
51+
state: response.ok ? .completed : .failed,
52+
responseOk: response.ok,
53+
responseJson: encodeResponseJson(response),
54+
error: response.error
55+
)
56+
}
57+
58+
func fail(command: Command, error: Error) {
59+
update(
60+
command: command,
61+
state: .failed,
62+
responseOk: nil,
63+
responseJson: nil,
64+
error: ErrorPayload(message: error.localizedDescription)
65+
)
66+
}
67+
68+
func status(commandId: String) -> DataPayload {
69+
guard let normalized = normalizedCommandId(commandId) else {
70+
return DataPayload(lifecycleState: RunnerCommandLifecycleState.notAccepted.rawValue)
71+
}
72+
lock.lock()
73+
let entry = entries[normalized]
74+
lock.unlock()
75+
guard let entry else {
76+
return DataPayload(
77+
commandId: normalized,
78+
lifecycleState: RunnerCommandLifecycleState.notAccepted.rawValue
79+
)
80+
}
81+
return DataPayload(
82+
commandId: entry.commandId,
83+
lifecycleState: entry.state.rawValue,
84+
lifecycleCommand: entry.command,
85+
lifecycleResponseOk: entry.responseOk,
86+
lifecycleResponseJson: entry.responseJson,
87+
lifecycleErrorCode: entry.error?.code,
88+
lifecycleErrorMessage: entry.error?.message,
89+
lifecycleErrorHint: entry.error?.hint
90+
)
91+
}
92+
93+
private func update(
94+
command: Command,
95+
state: RunnerCommandLifecycleState,
96+
responseOk: Bool?,
97+
responseJson: String?,
98+
error: ErrorPayload?
99+
) {
100+
guard let commandId = normalizedCommandId(command.commandId) else { return }
101+
lock.lock()
102+
defer { lock.unlock() }
103+
var entry = entries[commandId] ?? RunnerCommandJournalEntry(
104+
commandId: commandId,
105+
command: command.command.rawValue,
106+
state: .accepted,
107+
responseOk: nil,
108+
responseJson: nil,
109+
error: nil
110+
)
111+
entry.state = state
112+
entry.responseOk = responseOk
113+
entry.responseJson = responseJson
114+
entry.error = error
115+
entries[commandId] = entry
116+
order.removeAll { $0 == commandId }
117+
order.append(commandId)
118+
pruneIfNeeded()
119+
}
120+
121+
private func pruneIfNeeded() {
122+
while order.count > maxEntries {
123+
let removed = order.removeFirst()
124+
entries.removeValue(forKey: removed)
125+
}
126+
}
127+
128+
private func normalizedCommandId(_ value: String?) -> String? {
129+
guard let value else { return nil }
130+
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
131+
return trimmed.isEmpty ? nil : trimmed
132+
}
133+
134+
private func encodeResponseJson(_ response: Response) -> String? {
135+
guard response.data?.nodes == nil else { return nil }
136+
guard let data = try? JSONEncoder().encode(response) else { return nil }
137+
guard data.count <= maxResponseJsonBytes else { return nil }
138+
return String(data: data, encoding: .utf8)
139+
}
140+
}

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ enum CommandType: String, Codable {
3030
case transformGesture
3131
case recordStart
3232
case recordStop
33+
case status
3334
case uptime
3435
case shutdown
3536
}
@@ -91,6 +92,9 @@ extension CommandType {
9192
case .recordStop, .uptime, .shutdown:
9293
return CommandTraits(isInteraction: false, readOnly: .never, isLifecycle: true)
9394

95+
case .status:
96+
return CommandTraits(isInteraction: false, readOnly: .always, isLifecycle: true)
97+
9498
// Normal preflight, not retried.
9599
// NOTE: mouseClick stays non-interaction for now — it is macOS-only and the foreground
96100
// guard interacts with bespoke macOS activation, so classifying it needs a macOS smoke
@@ -104,6 +108,8 @@ extension CommandType {
104108

105109
struct Command: Codable {
106110
let command: CommandType
111+
let commandId: String?
112+
let statusCommandId: String?
107113
let appBundleId: String?
108114
let text: String?
109115
let selectorKey: String?
@@ -171,6 +177,14 @@ struct DataPayload: Codable {
171177
let referenceWidth: Double?
172178
let referenceHeight: Double?
173179
let currentUptimeMs: Double?
180+
let commandId: String?
181+
let lifecycleState: String?
182+
let lifecycleCommand: String?
183+
let lifecycleResponseOk: Bool?
184+
let lifecycleResponseJson: String?
185+
let lifecycleErrorCode: String?
186+
let lifecycleErrorMessage: String?
187+
let lifecycleErrorHint: String?
174188
let visible: Bool?
175189
let wasVisible: Bool?
176190
let dismissed: Bool?
@@ -192,6 +206,14 @@ struct DataPayload: Codable {
192206
referenceWidth: Double? = nil,
193207
referenceHeight: Double? = nil,
194208
currentUptimeMs: Double? = nil,
209+
commandId: String? = nil,
210+
lifecycleState: String? = nil,
211+
lifecycleCommand: String? = nil,
212+
lifecycleResponseOk: Bool? = nil,
213+
lifecycleResponseJson: String? = nil,
214+
lifecycleErrorCode: String? = nil,
215+
lifecycleErrorMessage: String? = nil,
216+
lifecycleErrorHint: String? = nil,
195217
visible: Bool? = nil,
196218
wasVisible: Bool? = nil,
197219
dismissed: Bool? = nil,
@@ -212,6 +234,14 @@ struct DataPayload: Codable {
212234
self.referenceWidth = referenceWidth
213235
self.referenceHeight = referenceHeight
214236
self.currentUptimeMs = currentUptimeMs
237+
self.commandId = commandId
238+
self.lifecycleState = lifecycleState
239+
self.lifecycleCommand = lifecycleCommand
240+
self.lifecycleResponseOk = lifecycleResponseOk
241+
self.lifecycleResponseJson = lifecycleResponseJson
242+
self.lifecycleErrorCode = lifecycleErrorCode
243+
self.lifecycleErrorMessage = lifecycleErrorMessage
244+
self.lifecycleErrorHint = lifecycleErrorHint
215245
self.visible = visible
216246
self.wasVisible = wasVisible
217247
self.dismissed = dismissed

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ final class RunnerTests: XCTestCase {
5353
var needsPostSnapshotInteractionDelay = false
5454
var needsFirstInteractionDelay = false
5555
var activeRecording: ScreenRecorder?
56+
let commandJournal = RunnerCommandJournal()
5657
let interactiveTypes: Set<XCUIElement.ElementType> = [
5758
.button,
5859
.cell,

src/platforms/ios/__tests__/runner-client.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ vi.mock('../runner-macos-products.ts', async () => {
3232
import type { DeviceInfo } from '../../../utils/device.ts';
3333
import { AppError } from '../../../utils/errors.ts';
3434
import type { RunnerCommand } from '../runner-contract.ts';
35+
import { withRunnerCommandId } from '../runner-contract.ts';
3536
import {
3637
assertSafeDerivedCleanup,
3738
isRetryableRunnerError,
@@ -159,6 +160,7 @@ const runnerProtocolCommandFixtures: Record<RunnerCommand['command'], RunnerComm
159160
quality: 7,
160161
},
161162
recordStop: { command: 'recordStop' },
163+
status: { command: 'status', statusCommandId: 'runner-command-1' },
162164
uptime: { command: 'uptime' },
163165
shutdown: { command: 'shutdown' },
164166
};
@@ -359,6 +361,7 @@ test('runner protocol fixtures cover every runner command with JSON-safe samples
359361
'screenshot',
360362
'shutdown',
361363
'snapshot',
364+
'status',
362365
'swipe',
363366
'tap',
364367
'tapSeries',
@@ -380,6 +383,27 @@ test('runner protocol fixtures cover every runner command with JSON-safe samples
380383
assert.equal(roundTrip.recordStart!.quality, 7);
381384
});
382385

386+
test('withRunnerCommandId replaces blank command ids', () => {
387+
const command = withRunnerCommandId({ command: 'uptime', commandId: ' ' });
388+
389+
assert.match(command.commandId ?? '', /^runner-/);
390+
});
391+
392+
test('withRunnerCommandId preserves existing command ids', () => {
393+
const command = withRunnerCommandId({ command: 'uptime', commandId: 'runner-existing' });
394+
395+
assert.deepEqual(command, { command: 'uptime', commandId: 'runner-existing' });
396+
});
397+
398+
test('withRunnerCommandId does not add command ids to status probes', () => {
399+
const command = withRunnerCommandId({
400+
command: 'status',
401+
statusCommandId: 'runner-command-1',
402+
});
403+
404+
assert.deepEqual(command, { command: 'status', statusCommandId: 'runner-command-1' });
405+
});
406+
383407
test('resolveRunnerDestination uses device destination for physical devices', () => {
384408
assert.equal(resolveRunnerDestination(iosDevice), 'platform=iOS,id=00008110-000E12341234002E');
385409
});

0 commit comments

Comments
 (0)