Skip to content

Commit 1e630d6

Browse files
committed
fix: keep iOS runner status transport visible
1 parent 3f65124 commit 1e630d6

4 files changed

Lines changed: 229 additions & 18 deletions

File tree

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,10 @@ extension RunnerTests {
9898
return executeStatus(command: command)
9999
}
100100
commandJournal.accept(command: command)
101+
return try executeAccepted(command: command)
102+
}
103+
104+
func executeAccepted(command: Command) throws -> Response {
101105
commandJournal.start(command: command)
102106
do {
103107
let response = try executeDispatched(command: command)
@@ -109,7 +113,7 @@ extension RunnerTests {
109113
}
110114
}
111115

112-
private func executeStatus(command: Command) -> Response {
116+
func executeStatus(command: Command) -> Response {
113117
guard
114118
let statusCommandId = command.statusCommandId?
115119
.trimmingCharacters(in: .whitespacesAndNewlines),

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

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,11 @@ extension RunnerTests {
2828
}
2929
let combined = buffer + data
3030
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()
31+
self.handleRequestBody(body) { [weak self] result in
32+
self?.sendResponse(result.data, over: connection) { [weak self] in
33+
if result.shouldFinish {
34+
self?.finish()
35+
}
3536
}
3637
}
3738
} else {
@@ -82,29 +83,49 @@ extension RunnerTests {
8283
return nil
8384
}
8485

85-
private func handleRequestBody(_ body: Data) -> (data: Data, shouldFinish: Bool) {
86+
private func handleRequestBody(
87+
_ body: Data,
88+
completion: @escaping ((data: Data, shouldFinish: Bool)) -> Void
89+
) {
8690
guard let json = String(data: body, encoding: .utf8) else {
87-
return (
91+
completion((
8892
jsonResponse(status: 400, response: Response(ok: false, error: ErrorPayload(message: "invalid json"))),
8993
false
90-
)
94+
))
95+
return
9196
}
9297
guard let data = json.data(using: .utf8) else {
93-
return (
98+
completion((
9499
jsonResponse(status: 400, response: Response(ok: false, error: ErrorPayload(message: "invalid json"))),
95100
false
96-
)
101+
))
102+
return
97103
}
98104

99105
do {
100106
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)
107+
if command.command == .status {
108+
completion((jsonResponse(status: 200, response: executeStatus(command: command)), false))
109+
return
110+
}
111+
commandJournal.accept(command: command)
112+
commandExecutionQueue.async { [weak self] in
113+
guard let self else { return }
114+
do {
115+
let response = try self.executeAccepted(command: command)
116+
completion((self.jsonResponse(status: 200, response: response), command.command == .shutdown))
117+
} catch {
118+
completion((
119+
self.jsonResponse(status: 500, response: Response(ok: false, error: ErrorPayload(message: "\(error)"))),
120+
false
121+
))
122+
}
123+
}
103124
} catch {
104-
return (
125+
completion((
105126
jsonResponse(status: 500, response: Response(ok: false, error: ErrorPayload(message: "\(error)"))),
106127
false
107-
)
128+
))
108129
}
109130
}
110131

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)