Skip to content

Commit eddeb43

Browse files
committed
refactor: add ios runner lifecycle protocol
1 parent be5081d commit eddeb43

10 files changed

Lines changed: 308 additions & 22 deletions

File tree

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,19 @@ extension RunnerTests {
2626
}
2727

2828
func execute(command: Command) throws -> Response {
29+
commandJournal.accept(command: command)
30+
commandJournal.start(command: command)
31+
do {
32+
let response = try executeDispatched(command: command)
33+
commandJournal.finish(command: command, response: response)
34+
return response
35+
} catch {
36+
commandJournal.fail(command: command, error: error)
37+
throw error
38+
}
39+
}
40+
41+
private func executeDispatched(command: Command) throws -> Response {
2942
if Thread.isMainThread {
3043
return try executeOnMainSafely(command: command)
3144
}
@@ -183,6 +196,15 @@ extension RunnerTests {
183196
}
184197

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

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: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ const runnerProtocolCommandFixtures: Record<RunnerCommand['command'], RunnerComm
159159
quality: 7,
160160
},
161161
recordStop: { command: 'recordStop' },
162+
status: { command: 'status', statusCommandId: 'runner-command-1' },
162163
uptime: { command: 'uptime' },
163164
shutdown: { command: 'shutdown' },
164165
};
@@ -359,6 +360,7 @@ test('runner protocol fixtures cover every runner command with JSON-safe samples
359360
'screenshot',
360361
'shutdown',
361362
'snapshot',
363+
'status',
362364
'swipe',
363365
'tap',
364366
'tapSeries',

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

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -128,13 +128,44 @@ test('runner session executes read-only commands without uptime preflight', asyn
128128
assert.deepEqual(result, { nodes: [], truncated: false });
129129
assert.equal(session.ready, true);
130130
assert.equal(mockWaitForRunner.mock.calls.length, 1);
131-
assert.deepEqual(mockWaitForRunner.mock.calls[0]?.[2], {
131+
assertRunnerCommand(mockWaitForRunner.mock.calls[0]?.[2], {
132132
command: 'snapshot',
133133
appBundleId: 'com.example.demo',
134134
});
135135
assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 0);
136136
});
137137

138+
test('runner session executes status command as read-only lifecycle command', async () => {
139+
const session = makeRunnerSession({ ready: true });
140+
mockWaitForRunner.mockResolvedValueOnce(
141+
runnerResponse({
142+
commandId: 'runner-command-1',
143+
lifecycleState: 'completed',
144+
lifecycleResponseOk: true,
145+
}),
146+
);
147+
148+
const result = await executeRunnerCommandWithSession(
149+
IOS_SIMULATOR,
150+
session,
151+
{ command: 'status', statusCommandId: 'runner-command-1' },
152+
'/tmp/runner.log',
153+
30_000,
154+
);
155+
156+
assert.deepEqual(result, {
157+
commandId: 'runner-command-1',
158+
lifecycleState: 'completed',
159+
lifecycleResponseOk: true,
160+
});
161+
assert.equal(mockWaitForRunner.mock.calls.length, 1);
162+
assertRunnerCommand(mockWaitForRunner.mock.calls[0]?.[2], {
163+
command: 'status',
164+
statusCommandId: 'runner-command-1',
165+
});
166+
assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 0);
167+
});
168+
138169
test('runner session probes readiness before mutating commands', async () => {
139170
const session = makeRunnerSession({ ready: false });
140171
mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ uptimeMs: 42 }));
@@ -151,9 +182,9 @@ test('runner session probes readiness before mutating commands', async () => {
151182
assert.deepEqual(result, { tapped: true });
152183
assert.equal(session.ready, true);
153184
assert.equal(mockWaitForRunner.mock.calls.length, 1);
154-
assert.deepEqual(mockWaitForRunner.mock.calls[0]?.[2], { command: 'uptime' });
185+
assertRunnerCommand(mockWaitForRunner.mock.calls[0]?.[2], { command: 'uptime' });
155186
assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 1);
156-
assert.deepEqual(mockSendRunnerCommandOnce.mock.calls[0]?.[2], {
187+
assertRunnerCommand(mockSendRunnerCommandOnce.mock.calls[0]?.[2], {
157188
command: 'tap',
158189
x: 120,
159190
y: 240,
@@ -239,7 +270,7 @@ test('runner session keeps readiness preflight for tap commands when ready but n
239270

240271
assert.deepEqual(result, { tapped: true });
241272
assert.equal(mockWaitForRunner.mock.calls.length, 1);
242-
assert.deepEqual(mockWaitForRunner.mock.calls[0]?.[2], { command: 'uptime' });
273+
assertRunnerCommand(mockWaitForRunner.mock.calls[0]?.[2], { command: 'uptime' });
243274
assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 1);
244275
});
245276

@@ -261,7 +292,7 @@ test('runner session keeps readiness preflight for tap commands when marked read
261292

262293
assert.deepEqual(result, { tapped: true });
263294
assert.equal(mockWaitForRunner.mock.calls.length, 1);
264-
assert.deepEqual(mockWaitForRunner.mock.calls[0]?.[2], { command: 'uptime' });
295+
assertRunnerCommand(mockWaitForRunner.mock.calls[0]?.[2], { command: 'uptime' });
265296
assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 1);
266297
});
267298

@@ -280,7 +311,7 @@ test('runner session keeps readiness preflight for non-tap mutating commands whe
280311

281312
assert.deepEqual(result, { pressed: true });
282313
assert.equal(mockWaitForRunner.mock.calls.length, 1);
283-
assert.deepEqual(mockWaitForRunner.mock.calls[0]?.[2], { command: 'uptime' });
314+
assertRunnerCommand(mockWaitForRunner.mock.calls[0]?.[2], { command: 'uptime' });
284315
assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 1);
285316
});
286317

@@ -371,7 +402,7 @@ test('runner session stop sends shutdown, cleans temporary runner files, and rel
371402
mockIsProcessAlive.mockReturnValue(false);
372403
await stopRunnerSession(session);
373404

374-
assert.deepEqual(mockWaitForRunner.mock.calls.at(-1)?.[2], { command: 'shutdown' });
405+
assertRunnerCommand(mockWaitForRunner.mock.calls.at(-1)?.[2], { command: 'shutdown' });
375406
assert.deepEqual(mockCleanupTempFile.mock.calls, [
376407
['/tmp/session-runner.xctestrun'],
377408
['/tmp/session-runner.json'],
@@ -453,3 +484,18 @@ function runnerResponse(data: Record<string, unknown>): Response {
453484
function runnerError(error: { code: string; message: string }): Response {
454485
return new Response(JSON.stringify({ ok: false, error }));
455486
}
487+
488+
function assertRunnerCommand(
489+
actual: unknown,
490+
expected: Record<string, unknown>,
491+
): asserts actual is Record<string, unknown> {
492+
assert.equal(typeof actual, 'object');
493+
assert.notEqual(actual, null);
494+
const command = actual as Record<string, unknown>;
495+
const commandId = command.commandId;
496+
if (typeof commandId !== 'string') {
497+
assert.fail('expected runner commandId');
498+
}
499+
assert.match(commandId, /^runner-/);
500+
assert.deepEqual({ ...command, commandId: undefined }, { ...expected, commandId: undefined });
501+
}

0 commit comments

Comments
 (0)