Skip to content

Commit 3ae2472

Browse files
authored
fix: keep iOS runner status transport visible (#663)
1 parent f2ef688 commit 3ae2472

4 files changed

Lines changed: 267 additions & 28 deletions

File tree

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

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@ extension RunnerTests {
101101
return executeStatus(command: command)
102102
}
103103
commandJournal.accept(command: command)
104+
return try executeAccepted(command: command)
105+
}
106+
107+
func executeAccepted(command: Command) throws -> Response {
104108
commandJournal.start(command: command)
105109
do {
106110
let response = try executeDispatched(command: command)
@@ -112,13 +116,20 @@ extension RunnerTests {
112116
}
113117
}
114118

115-
private func executeStatus(command: Command) -> Response {
119+
func executeStatus(command: Command) -> Response {
116120
guard
117121
let statusCommandId = command.statusCommandId?
118122
.trimmingCharacters(in: .whitespacesAndNewlines),
119123
!statusCommandId.isEmpty
120124
else {
121-
return Response(ok: false, error: ErrorPayload(message: "status requires statusCommandId"))
125+
return Response(
126+
ok: false,
127+
error: ErrorPayload(
128+
code: "INVALID_ARGS",
129+
message: "status requires statusCommandId",
130+
hint: "Set statusCommandId to the commandId of the runner command to inspect."
131+
)
132+
)
122133
}
123134
return Response(ok: true, data: commandJournal.status(commandId: statusCommandId))
124135
}

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

Lines changed: 64 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@ extension RunnerTests {
1919
if buffer.count + data.count > self.maxRequestBytes {
2020
let response = self.jsonResponse(
2121
status: 413,
22-
response: Response(ok: false, error: ErrorPayload(message: "request too large"))
22+
response: self.errorResponse(
23+
code: "INVALID_ARGS",
24+
message: "runner request body exceeds \(self.maxRequestBytes) bytes",
25+
hint: "Send one runner command per request and keep the payload below the runner request limit."
26+
)
2327
)
2428
self.sendResponse(response, over: connection) { [weak self] in
2529
self?.finish()
@@ -28,10 +32,11 @@ extension RunnerTests {
2832
}
2933
let combined = buffer + data
3034
if let body = self.parseRequest(data: combined) {
31-
let result = self.handleRequestBody(body)
32-
self.sendResponse(result.data, over: connection) { [weak self] in
33-
if result.shouldFinish {
34-
self?.finish()
35+
self.handleRequestBody(body) { [weak self] result in
36+
self?.sendResponse(result.data, over: connection) { [weak self] in
37+
if result.shouldFinish {
38+
self?.finish()
39+
}
3540
}
3641
}
3742
} else {
@@ -82,29 +87,62 @@ extension RunnerTests {
8287
return nil
8388
}
8489

85-
private func handleRequestBody(_ body: Data) -> (data: Data, shouldFinish: Bool) {
86-
guard let json = String(data: body, encoding: .utf8) else {
87-
return (
88-
jsonResponse(status: 400, response: Response(ok: false, error: ErrorPayload(message: "invalid json"))),
89-
false
90-
)
91-
}
92-
guard let data = json.data(using: .utf8) else {
93-
return (
94-
jsonResponse(status: 400, response: Response(ok: false, error: ErrorPayload(message: "invalid json"))),
90+
private func handleRequestBody(
91+
_ body: Data,
92+
completion: @escaping ((data: Data, shouldFinish: Bool)) -> Void
93+
) {
94+
guard String(data: body, encoding: .utf8) != nil else {
95+
completion((
96+
jsonResponse(
97+
status: 400,
98+
response: errorResponse(
99+
code: "INVALID_ARGS",
100+
message: "runner request body must be UTF-8 JSON",
101+
hint: "Send a JSON object matching the runner command protocol."
102+
)
103+
),
95104
false
96-
)
105+
))
106+
return
97107
}
98108

99109
do {
100-
let command = try JSONDecoder().decode(Command.self, from: data)
101-
let response = try execute(command: command)
102-
return (jsonResponse(status: 200, response: response), command.command == .shutdown)
110+
let command = try JSONDecoder().decode(Command.self, from: body)
111+
if command.command == .status {
112+
completion((jsonResponse(status: 200, response: executeStatus(command: command)), false))
113+
return
114+
}
115+
commandJournal.accept(command: command)
116+
commandExecutionQueue.async {
117+
do {
118+
let response = try self.executeAccepted(command: command)
119+
completion((self.jsonResponse(status: 200, response: response), command.command == .shutdown))
120+
} catch {
121+
completion((
122+
self.jsonResponse(
123+
status: 500,
124+
response: self.errorResponse(
125+
code: "COMMAND_FAILED",
126+
message: error.localizedDescription,
127+
hint: "Check the runner log for XCTest details, then retry after the app is foregrounded if this was a timeout or activation failure."
128+
)
129+
),
130+
false
131+
))
132+
}
133+
}
103134
} catch {
104-
return (
105-
jsonResponse(status: 500, response: Response(ok: false, error: ErrorPayload(message: "\(error)"))),
135+
completion((
136+
jsonResponse(
137+
status: 400,
138+
response: errorResponse(
139+
code: "INVALID_ARGS",
140+
message: "runner command payload is invalid: \(String(describing: error))",
141+
hint: "Check the command name and fields against the runner protocol."
142+
)
143+
),
106144
false
107-
)
145+
))
108146
}
109147
}
110148

@@ -116,6 +154,10 @@ extension RunnerTests {
116154
return httpResponse(status: status, body: body)
117155
}
118156

157+
private func errorResponse(code: String, message: String, hint: String? = nil) -> Response {
158+
Response(ok: false, error: ErrorPayload(code: code, message: message, hint: hint))
159+
}
160+
119161
private func httpResponse(status: Int, body: String) -> Data {
120162
let headers = [
121163
"HTTP/1.1 \(status) OK",

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ final class RunnerTests: XCTestCase {
3232
static let defaultRecordingFps: Int32 = 15
3333
var listener: NWListener?
3434
var doneExpectation: XCTestExpectation?
35+
let transportQueue = DispatchQueue(label: "agent-device.runner.transport")
36+
let commandExecutionQueue = DispatchQueue(label: "agent-device.runner.commands")
3537
let app = XCUIApplication()
3638
lazy var springboard = XCUIApplication(bundleIdentifier: Self.springboardBundleId)
3739
var currentApp: XCUIApplication?
@@ -92,7 +94,6 @@ final class RunnerTests: XCTestCase {
9294
func testCommand() throws {
9395
doneExpectation = expectation(description: "agent-device command handled")
9496
NSLog("AGENT_DEVICE_RUNNER_HEADLESS_STARTUP=1")
95-
let queue = DispatchQueue(label: "agent-device.runner")
9697
let desiredPort = RunnerEnv.resolvePort()
9798
NSLog("AGENT_DEVICE_RUNNER_DESIRED_PORT=%d", desiredPort)
9899
listener = try makeRunnerListener(desiredPort: desiredPort)
@@ -113,10 +114,11 @@ final class RunnerTests: XCTestCase {
113114
}
114115
}
115116
listener?.newConnectionHandler = { [weak self] conn in
116-
conn.start(queue: queue)
117-
self?.handle(connection: conn)
117+
guard let self else { return }
118+
conn.start(queue: self.transportQueue)
119+
self.handle(connection: conn)
118120
}
119-
listener?.start(queue: queue)
121+
listener?.start(queue: transportQueue)
120122

121123
guard let expectation = doneExpectation else {
122124
XCTFail("runner expectation was not initialized")
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import http from 'node:http';
4+
import {
5+
closeLoopbackServer,
6+
listenOnLoopback,
7+
skipWhenLoopbackUnavailable,
8+
} from '../../src/__tests__/test-utils/loopback.ts';
9+
10+
type FixtureCommand = {
11+
command: 'status' | 'tap' | 'type';
12+
commandId?: string;
13+
statusCommandId?: string;
14+
delayMs?: number;
15+
};
16+
17+
type FixtureJournalEntry = {
18+
command: string;
19+
state: 'accepted' | 'started' | 'completed' | 'failed';
20+
};
21+
22+
type FixtureResponse = {
23+
ok: boolean;
24+
data?: {
25+
commandId?: string;
26+
lifecycleState?: string;
27+
lifecycleCommand?: string;
28+
message?: string;
29+
};
30+
};
31+
32+
test('iOS runner status transport stays visible while command execution remains serial', async (t) => {
33+
if (await skipWhenLoopbackUnavailable(t, 'iOS runner status-visible transport fixture')) {
34+
return;
35+
}
36+
37+
const fixture = new StatusVisibleRunnerFixture();
38+
const server = http.createServer((req, res) => {
39+
void fixture.handle(req, res);
40+
});
41+
const port = await listenOnLoopback(server);
42+
t.after(async () => {
43+
await closeLoopbackServer(server);
44+
});
45+
46+
let longCommandCompleted = false;
47+
const longCommand = postCommand(port, { command: 'tap', commandId: 'long', delayMs: 300 }).then(
48+
(response) => {
49+
longCommandCompleted = true;
50+
return response;
51+
},
52+
);
53+
54+
const visibleStatus = await pollStatus(port, 'long', (state) => state !== 'notAccepted');
55+
assert.match(visibleStatus.data?.lifecycleState ?? '', /^(accepted|started)$/);
56+
assert.equal(longCommandCompleted, false, 'status returned before long command completed');
57+
58+
const secondCommand = postCommand(port, { command: 'type', commandId: 'second' });
59+
const secondStatus = await pollStatus(port, 'second', (state) => state === 'accepted');
60+
assert.equal(secondStatus.data?.lifecycleState, 'accepted');
61+
62+
assert.deepEqual(await longCommand, { ok: true, data: { message: 'tap completed' } });
63+
assert.deepEqual(await secondCommand, { ok: true, data: { message: 'type completed' } });
64+
assert.deepEqual(fixture.events, [
65+
'long:accepted',
66+
'long:started',
67+
'second:accepted',
68+
'long:completed',
69+
'second:started',
70+
'second:completed',
71+
]);
72+
});
73+
74+
class StatusVisibleRunnerFixture {
75+
public readonly events: string[] = [];
76+
private readonly journal = new Map<string, FixtureJournalEntry>();
77+
private commandQueue: Promise<void> = Promise.resolve();
78+
79+
async handle(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
80+
if (req.method !== 'POST') {
81+
writeJson(res, 404, { ok: false });
82+
return;
83+
}
84+
85+
const command = await readJsonBody(req);
86+
if (command.command === 'status') {
87+
writeJson(res, 200, this.status(command.statusCommandId));
88+
return;
89+
}
90+
91+
this.accept(command);
92+
const response = this.enqueue(command);
93+
writeJson(res, 200, await response);
94+
}
95+
96+
private accept(command: FixtureCommand): void {
97+
if (!command.commandId) return;
98+
this.journal.set(command.commandId, { command: command.command, state: 'accepted' });
99+
this.events.push(`${command.commandId}:accepted`);
100+
}
101+
102+
private enqueue(command: FixtureCommand): Promise<FixtureResponse> {
103+
const response = this.commandQueue.then(() => this.execute(command));
104+
this.commandQueue = response.then(
105+
() => {},
106+
() => {},
107+
);
108+
return response;
109+
}
110+
111+
private async execute(command: FixtureCommand): Promise<FixtureResponse> {
112+
this.update(command, 'started');
113+
if (command.delayMs) {
114+
await delay(command.delayMs);
115+
}
116+
this.update(command, 'completed');
117+
return { ok: true, data: { message: `${command.command} completed` } };
118+
}
119+
120+
private status(commandId: string | undefined): FixtureResponse {
121+
if (!commandId) return { ok: true, data: { lifecycleState: 'notAccepted' } };
122+
const entry = this.journal.get(commandId);
123+
return {
124+
ok: true,
125+
data: {
126+
commandId,
127+
lifecycleState: entry?.state ?? 'notAccepted',
128+
lifecycleCommand: entry?.command,
129+
},
130+
};
131+
}
132+
133+
private update(command: FixtureCommand, state: FixtureJournalEntry['state']): void {
134+
if (!command.commandId) return;
135+
this.journal.set(command.commandId, { command: command.command, state });
136+
this.events.push(`${command.commandId}:${state}`);
137+
}
138+
}
139+
140+
async function pollStatus(
141+
port: number,
142+
statusCommandId: string,
143+
predicate: (state: string) => boolean,
144+
): Promise<FixtureResponse> {
145+
const deadline = Date.now() + 1_000;
146+
while (Date.now() < deadline) {
147+
const response = await postCommand(port, { command: 'status', statusCommandId });
148+
const state = response.data?.lifecycleState ?? 'notAccepted';
149+
if (predicate(state)) return response;
150+
await delay(10);
151+
}
152+
throw new Error(`status for ${statusCommandId} did not reach expected state`);
153+
}
154+
155+
async function postCommand(port: number, command: FixtureCommand): Promise<FixtureResponse> {
156+
const response = await fetch(`http://127.0.0.1:${port}`, {
157+
method: 'POST',
158+
headers: { 'content-type': 'application/json' },
159+
body: JSON.stringify(command),
160+
});
161+
return (await response.json()) as FixtureResponse;
162+
}
163+
164+
async function readJsonBody(req: http.IncomingMessage): Promise<FixtureCommand> {
165+
const chunks: Buffer[] = [];
166+
for await (const chunk of req) {
167+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
168+
}
169+
return JSON.parse(Buffer.concat(chunks).toString('utf8')) as FixtureCommand;
170+
}
171+
172+
function writeJson(res: http.ServerResponse, status: number, body: FixtureResponse): void {
173+
res.writeHead(status, {
174+
'content-type': 'application/json',
175+
connection: 'close',
176+
});
177+
res.end(JSON.stringify(body));
178+
}
179+
180+
function delay(ms: number): Promise<void> {
181+
return new Promise((resolve) => {
182+
setTimeout(resolve, ms);
183+
});
184+
}

0 commit comments

Comments
 (0)