Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ extension RunnerTests {
return executeStatus(command: command)
}
commandJournal.accept(command: command)
return try executeAccepted(command: command)
}

func executeAccepted(command: Command) throws -> Response {
commandJournal.start(command: command)
do {
let response = try executeDispatched(command: command)
Expand All @@ -109,13 +113,20 @@ extension RunnerTests {
}
}

private func executeStatus(command: Command) -> Response {
func executeStatus(command: Command) -> Response {
guard
let statusCommandId = command.statusCommandId?
.trimmingCharacters(in: .whitespacesAndNewlines),
!statusCommandId.isEmpty
else {
return Response(ok: false, error: ErrorPayload(message: "status requires statusCommandId"))
return Response(
ok: false,
error: ErrorPayload(
code: "INVALID_ARGS",
message: "status requires statusCommandId",
hint: "Set statusCommandId to the commandId of the runner command to inspect."
)
)
}
return Response(ok: true, data: commandJournal.status(commandId: statusCommandId))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ extension RunnerTests {
if buffer.count + data.count > self.maxRequestBytes {
let response = self.jsonResponse(
status: 413,
response: Response(ok: false, error: ErrorPayload(message: "request too large"))
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()
Expand All @@ -28,10 +32,11 @@ extension RunnerTests {
}
let combined = buffer + data
if let body = self.parseRequest(data: combined) {
let result = self.handleRequestBody(body)
self.sendResponse(result.data, over: connection) { [weak self] in
if result.shouldFinish {
self?.finish()
self.handleRequestBody(body) { [weak self] result in
self?.sendResponse(result.data, over: connection) { [weak self] in
if result.shouldFinish {
self?.finish()
}
}
}
} else {
Expand Down Expand Up @@ -82,29 +87,62 @@ extension RunnerTests {
return nil
}

private func handleRequestBody(_ body: Data) -> (data: Data, shouldFinish: Bool) {
guard let json = String(data: body, encoding: .utf8) else {
return (
jsonResponse(status: 400, response: Response(ok: false, error: ErrorPayload(message: "invalid json"))),
false
)
}
guard let data = json.data(using: .utf8) else {
return (
jsonResponse(status: 400, response: Response(ok: false, error: ErrorPayload(message: "invalid json"))),
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: data)
let response = try execute(command: command)
return (jsonResponse(status: 200, response: response), command.command == .shutdown)
let command = try JSONDecoder().decode(Command.self, from: body)
if command.command == .status {
completion((jsonResponse(status: 200, response: executeStatus(command: command)), false))
return
}
commandJournal.accept(command: command)
commandExecutionQueue.async {
do {
let response = try self.executeAccepted(command: command)
completion((self.jsonResponse(status: 200, response: response), command.command == .shutdown))
} catch {
completion((
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
))
}
}
} catch {
return (
jsonResponse(status: 500, response: Response(ok: false, error: ErrorPayload(message: "\(error)"))),
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
)
))
}
}

Expand All @@ -116,6 +154,10 @@ extension RunnerTests {
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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ final class RunnerTests: XCTestCase {
static let defaultRecordingFps: Int32 = 15
var listener: NWListener?
var doneExpectation: XCTestExpectation?
let transportQueue = DispatchQueue(label: "agent-device.runner.transport")
let commandExecutionQueue = DispatchQueue(label: "agent-device.runner.commands")
let app = XCUIApplication()
lazy var springboard = XCUIApplication(bundleIdentifier: Self.springboardBundleId)
var currentApp: XCUIApplication?
Expand Down Expand Up @@ -92,7 +94,6 @@ final class RunnerTests: XCTestCase {
func testCommand() throws {
doneExpectation = expectation(description: "agent-device command handled")
NSLog("AGENT_DEVICE_RUNNER_HEADLESS_STARTUP=1")
let queue = DispatchQueue(label: "agent-device.runner")
let desiredPort = RunnerEnv.resolvePort()
NSLog("AGENT_DEVICE_RUNNER_DESIRED_PORT=%d", desiredPort)
listener = try makeRunnerListener(desiredPort: desiredPort)
Expand All @@ -113,10 +114,11 @@ final class RunnerTests: XCTestCase {
}
}
listener?.newConnectionHandler = { [weak self] conn in
conn.start(queue: queue)
self?.handle(connection: conn)
guard let self else { return }
conn.start(queue: self.transportQueue)
self.handle(connection: conn)
}
listener?.start(queue: queue)
listener?.start(queue: transportQueue)

guard let expectation = doneExpectation else {
XCTFail("runner expectation was not initialized")
Expand Down
184 changes: 184 additions & 0 deletions test/integration/smoke-ios-runner-status-visible-transport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import {
closeLoopbackServer,
listenOnLoopback,
skipWhenLoopbackUnavailable,
} from '../../src/__tests__/test-utils/loopback.ts';

type FixtureCommand = {
command: 'status' | 'tap' | 'type';
commandId?: string;
statusCommandId?: string;
delayMs?: number;
};

type FixtureJournalEntry = {
command: string;
state: 'accepted' | 'started' | 'completed' | 'failed';
};

type FixtureResponse = {
ok: boolean;
data?: {
commandId?: string;
lifecycleState?: string;
lifecycleCommand?: string;
message?: string;
};
};

test('iOS runner status transport stays visible while command execution remains serial', async (t) => {
if (await skipWhenLoopbackUnavailable(t, 'iOS runner status-visible transport fixture')) {
return;
}

const fixture = new StatusVisibleRunnerFixture();
const server = http.createServer((req, res) => {
void fixture.handle(req, res);
});
const port = await listenOnLoopback(server);
t.after(async () => {
await closeLoopbackServer(server);
});

let longCommandCompleted = false;
const longCommand = postCommand(port, { command: 'tap', commandId: 'long', delayMs: 300 }).then(
(response) => {
longCommandCompleted = true;
return response;
},
);

const visibleStatus = await pollStatus(port, 'long', (state) => state !== 'notAccepted');
assert.match(visibleStatus.data?.lifecycleState ?? '', /^(accepted|started)$/);
assert.equal(longCommandCompleted, false, 'status returned before long command completed');

const secondCommand = postCommand(port, { command: 'type', commandId: 'second' });
const secondStatus = await pollStatus(port, 'second', (state) => state === 'accepted');
assert.equal(secondStatus.data?.lifecycleState, 'accepted');

assert.deepEqual(await longCommand, { ok: true, data: { message: 'tap completed' } });
assert.deepEqual(await secondCommand, { ok: true, data: { message: 'type completed' } });
assert.deepEqual(fixture.events, [
'long:accepted',
'long:started',
'second:accepted',
'long:completed',
'second:started',
'second:completed',
]);
});

class StatusVisibleRunnerFixture {
public readonly events: string[] = [];
private readonly journal = new Map<string, FixtureJournalEntry>();
private commandQueue: Promise<void> = Promise.resolve();

async handle(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
if (req.method !== 'POST') {
writeJson(res, 404, { ok: false });
return;
}

const command = await readJsonBody(req);
if (command.command === 'status') {
writeJson(res, 200, this.status(command.statusCommandId));
return;
}

this.accept(command);
const response = this.enqueue(command);
writeJson(res, 200, await response);
}

private accept(command: FixtureCommand): void {
if (!command.commandId) return;
this.journal.set(command.commandId, { command: command.command, state: 'accepted' });
this.events.push(`${command.commandId}:accepted`);
}

private enqueue(command: FixtureCommand): Promise<FixtureResponse> {
const response = this.commandQueue.then(() => this.execute(command));
this.commandQueue = response.then(
() => {},
() => {},
);
return response;
}

private async execute(command: FixtureCommand): Promise<FixtureResponse> {
this.update(command, 'started');
if (command.delayMs) {
await delay(command.delayMs);
}
this.update(command, 'completed');
return { ok: true, data: { message: `${command.command} completed` } };
}

private status(commandId: string | undefined): FixtureResponse {
if (!commandId) return { ok: true, data: { lifecycleState: 'notAccepted' } };
const entry = this.journal.get(commandId);
return {
ok: true,
data: {
commandId,
lifecycleState: entry?.state ?? 'notAccepted',
lifecycleCommand: entry?.command,
},
};
}

private update(command: FixtureCommand, state: FixtureJournalEntry['state']): void {
if (!command.commandId) return;
this.journal.set(command.commandId, { command: command.command, state });
this.events.push(`${command.commandId}:${state}`);
}
}

async function pollStatus(
port: number,
statusCommandId: string,
predicate: (state: string) => boolean,
): Promise<FixtureResponse> {
const deadline = Date.now() + 1_000;
while (Date.now() < deadline) {
const response = await postCommand(port, { command: 'status', statusCommandId });
const state = response.data?.lifecycleState ?? 'notAccepted';
if (predicate(state)) return response;
await delay(10);
}
throw new Error(`status for ${statusCommandId} did not reach expected state`);
}

async function postCommand(port: number, command: FixtureCommand): Promise<FixtureResponse> {
const response = await fetch(`http://127.0.0.1:${port}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(command),
});
return (await response.json()) as FixtureResponse;
}

async function readJsonBody(req: http.IncomingMessage): Promise<FixtureCommand> {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return JSON.parse(Buffer.concat(chunks).toString('utf8')) as FixtureCommand;
}

function writeJson(res: http.ServerResponse, status: number, body: FixtureResponse): void {
res.writeHead(status, {
'content-type': 'application/json',
connection: 'close',
});
res.end(JSON.stringify(body));
}

function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
Loading