Skip to content

Commit 8fe03de

Browse files
authored
test(apple-runner): pin exchange session view and accounting (#3006)
* test(apple-runner): pin exchange accounting through real HTTP * fix(apple): preserve runner preflight state and await invalidation * fix(apple-runner): preserve exempt startup command order
1 parent 7a5a55e commit 8fe03de

3 files changed

Lines changed: 227 additions & 15 deletions

File tree

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import assert from 'node:assert/strict';
2+
import type { ExecResult } from '@agent-device/host-kit/command';
3+
import { afterEach, test } from 'vitest';
4+
import { executeRunnerExchange } from '../runner-exchange.ts';
5+
import { RunnerCommandAccounting, type RunnerSession } from '../runner-session-types.ts';
6+
import { IOS_SIMULATOR } from './device-fixtures.ts';
7+
import { startFakeRunnerServer, type FakeRunnerServer } from './fake-runner-server.ts';
8+
9+
let server: FakeRunnerServer | undefined;
10+
11+
afterEach(async () => {
12+
await server?.close();
13+
server = undefined;
14+
});
15+
16+
function sessionFor(port: number): RunnerSession {
17+
return {
18+
sessionId: `exchange:${port}`,
19+
device: IOS_SIMULATOR,
20+
deviceId: IOS_SIMULATOR.id,
21+
port,
22+
xctestrunPath: '/tmp/fake.xctestrun',
23+
jsonPath: '/tmp/fake.json',
24+
testPromise: new Promise<ExecResult>(() => {}),
25+
child: { pid: process.pid, exitCode: null },
26+
state: 'ready',
27+
commandCharges: new RunnerCommandAccounting(),
28+
};
29+
}
30+
31+
test('the exchange preflights a mutation and settles only its answer', async () => {
32+
server = await startFakeRunnerServer({
33+
uptime: [{ kind: 'ok', data: { uptimeMs: 5 } }],
34+
tap: [{ kind: 'ok', data: { tapped: true } }],
35+
});
36+
const session = sessionFor(server.port);
37+
const invalidations: string[] = [];
38+
39+
const result = await executeRunnerExchange(
40+
IOS_SIMULATOR,
41+
session,
42+
{ command: 'tap', x: 10, y: 10, appBundleId: 'com.example.app' },
43+
undefined,
44+
10_000,
45+
async (reason) => {
46+
invalidations.push(reason);
47+
},
48+
);
49+
50+
assert.deepEqual(result, { tapped: true });
51+
assert.deepEqual(
52+
server.requests.map(({ command }) => command),
53+
['uptime', 'tap'],
54+
);
55+
assert.equal(session.commandCharges.hasOutstandingCharges, false);
56+
assert.equal(session.lastHealthyMutation?.appBundleId, 'com.example.app');
57+
assert.deepEqual(invalidations, []);
58+
});
59+
60+
test('the readiness probe preserves its main-thread busy report when the command omits it', async () => {
61+
server = await startFakeRunnerServer({
62+
uptime: [{ kind: 'ok', data: { runnerMainThreadBusy: true } }],
63+
tap: [{ kind: 'ok', data: { tapped: true } }],
64+
});
65+
const session = sessionFor(server.port);
66+
67+
await executeRunnerExchange(
68+
IOS_SIMULATOR,
69+
session,
70+
{ command: 'tap', x: 10, y: 10, appBundleId: 'com.example.app' },
71+
undefined,
72+
10_000,
73+
async () => {},
74+
);
75+
76+
assert.equal(session.runnerMainThreadBusy, true);
77+
});
78+
79+
test.each(['activate', 'terminate', 'targetReset'] as const)(
80+
'a starting runner sends exempt %s without a readiness preflight',
81+
async (command) => {
82+
server = await startFakeRunnerServer([{ kind: 'ok', data: {} }]);
83+
const session = sessionFor(server.port);
84+
session.state = 'starting';
85+
86+
await executeRunnerExchange(
87+
IOS_SIMULATOR,
88+
session,
89+
{ command, appBundleId: 'com.example.app' },
90+
undefined,
91+
10_000,
92+
async () => {},
93+
);
94+
95+
assert.deepEqual(
96+
server.requests.map((request) => request.command),
97+
[command],
98+
);
99+
},
100+
);
101+
102+
test('the exchange awaits owner invalidation before returning a fatal answer', async () => {
103+
server = await startFakeRunnerServer({
104+
snapshot: [{ kind: 'ok', data: { runnerFatal: true, runnerFatalReason: 'ax_failed' } }],
105+
});
106+
const session = sessionFor(server.port);
107+
session.lastHealthyMutation = { atMs: Date.now(), appBundleId: 'com.example.app' };
108+
let signalInvalidationStarted!: () => void;
109+
const invalidationStarted = new Promise<void>((resolve) => {
110+
signalInvalidationStarted = resolve;
111+
});
112+
let releaseInvalidation!: () => void;
113+
const invalidationGate = new Promise<void>((resolve) => {
114+
releaseInvalidation = resolve;
115+
});
116+
let settled = false;
117+
118+
const exchange = executeRunnerExchange(
119+
IOS_SIMULATOR,
120+
session,
121+
{ command: 'snapshot', appBundleId: 'com.example.app' },
122+
undefined,
123+
10_000,
124+
async (reason) => {
125+
assert.equal(reason, 'ax_failed');
126+
signalInvalidationStarted();
127+
await invalidationGate;
128+
},
129+
).finally(() => {
130+
settled = true;
131+
});
132+
133+
try {
134+
await invalidationStarted;
135+
await new Promise<void>((resolve) => setImmediate(resolve));
136+
assert.equal(settled, false);
137+
} finally {
138+
releaseInvalidation();
139+
}
140+
const result = await exchange;
141+
142+
assert.equal(result.runnerFatal, true);
143+
assert.equal(session.lastHealthyMutation, undefined);
144+
assert.equal(session.commandCharges.hasOutstandingCharges, false);
145+
});
146+
147+
test('the exchange awaits owner invalidation before throwing a fatal runner error', async () => {
148+
server = await startFakeRunnerServer({
149+
snapshot: [{ kind: 'runnerError', code: 'RUNNER_WEDGED', message: 'runner wedged' }],
150+
});
151+
const session = sessionFor(server.port);
152+
let signalInvalidationStarted!: () => void;
153+
const invalidationStarted = new Promise<void>((resolve) => {
154+
signalInvalidationStarted = resolve;
155+
});
156+
let releaseInvalidation!: () => void;
157+
const invalidationGate = new Promise<void>((resolve) => {
158+
releaseInvalidation = resolve;
159+
});
160+
let settled = false;
161+
162+
const exchange = executeRunnerExchange(
163+
IOS_SIMULATOR,
164+
session,
165+
{ command: 'snapshot', appBundleId: 'com.example.app' },
166+
undefined,
167+
10_000,
168+
async (reason) => {
169+
assert.equal(reason, 'runner_main_thread_wedged');
170+
signalInvalidationStarted();
171+
await invalidationGate;
172+
},
173+
).finally(() => {
174+
settled = true;
175+
});
176+
void exchange.catch(() => {});
177+
178+
try {
179+
await invalidationStarted;
180+
await new Promise<void>((resolve) => setImmediate(resolve));
181+
assert.equal(settled, false);
182+
} finally {
183+
releaseInvalidation();
184+
}
185+
await assert.rejects(exchange);
186+
});

‎packages/platform-apple/src/runner/runner-exchange.ts‎

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { AppError } from '@agent-device/kernel/errors';
22
import type { DeviceInfo } from '@agent-device/kernel/device';
33
import { Deadline, emitDiagnostic, withDiagnosticTimer } from './host.ts';
4-
import { waitForRunner, RUNNER_STARTUP_TIMEOUT_MS } from './runner-startup-transport.ts';
4+
import {
5+
waitForRunner,
6+
RUNNER_STARTUP_TIMEOUT_MS,
7+
type RunnerConnectionSession,
8+
} from './runner-startup-transport.ts';
59
import { sendRunnerCommandOnce } from './runner-transport.ts';
610
import {
711
buildRunnerResponseError,
@@ -29,6 +33,12 @@ import {
2933
} from './runner-failure-diagnostics.ts';
3034
import { advanceRunnerSessionState, type RunnerSession } from './runner-session-types.ts';
3135

36+
type RunnerExchangeSession = RunnerConnectionSession &
37+
Pick<
38+
RunnerSession,
39+
'port' | 'commandCharges' | 'lastHealthyMutation' | 'runnerMainThreadBusy' | 'launchDeadline'
40+
>;
41+
3242
const RUNNER_READY_PREFLIGHT_TIMEOUT_MS = 1_000;
3343
const RUNNER_PREFLIGHT_SKIP_FRESHNESS_MS = 5_000;
3444

@@ -63,7 +73,7 @@ type RunnerReadinessPreflightDecision =
6373
*/
6474
export async function executeRunnerExchange(
6575
device: DeviceInfo,
66-
session: RunnerSession,
76+
session: RunnerExchangeSession,
6777
command: RunnerCommand,
6878
logPath: string | undefined,
6979
timeoutMs: number,
@@ -137,7 +147,7 @@ export async function executeRunnerExchange(
137147
* runner's occupancy report, and applies what the payload says about the session.
138148
*/
139149
async function settleRunnerAnsweredExchange(
140-
session: RunnerSession,
150+
session: RunnerExchangeSession,
141151
runnerCommand: RunnerCommand,
142152
data: Record<string, unknown>,
143153
invalidateFatalSession: (reason: string) => Promise<void>,
@@ -169,7 +179,7 @@ async function settleRunnerAnsweredExchange(
169179
* runner answered it.
170180
*/
171181
function recordUnansweredRunnerExchange(
172-
session: RunnerSession,
182+
session: RunnerExchangeSession,
173183
runnerCommand: RunnerCommand,
174184
error: unknown,
175185
): boolean {
@@ -199,7 +209,7 @@ function readRunnerMainThreadBusy(data: Record<string, unknown>): boolean | unde
199209

200210
function markSkippedPreflightTransportError(
201211
error: unknown,
202-
session: RunnerSession,
212+
session: RunnerExchangeSession,
203213
preflightDecision: RunnerReadinessPreflightDecision,
204214
): unknown {
205215
if (
@@ -218,7 +228,7 @@ function markSkippedPreflightTransportError(
218228

219229
async function sendRunnerCommandAfterPreflight(params: {
220230
device: DeviceInfo;
221-
session: RunnerSession;
231+
session: RunnerExchangeSession;
222232
runnerCommand: RunnerCommand;
223233
logPath: string | undefined;
224234
deadline: Deadline;
@@ -282,7 +292,7 @@ async function sendRunnerCommandAfterPreflight(params: {
282292

283293
async function runRunnerReadinessPreflight(params: {
284294
device: DeviceInfo;
285-
session: RunnerSession;
295+
session: RunnerExchangeSession;
286296
runnerCommand: RunnerCommand;
287297
logAttempt: RunnerLogAttempt | undefined;
288298
deadline: Deadline;
@@ -317,15 +327,19 @@ async function runRunnerReadinessPreflight(params: {
317327
timeoutMs: readinessTimeoutMs,
318328
},
319329
);
320-
await parseRunnerResponse(readinessResponse, session, logAttempt);
330+
const readinessData = await parseRunnerResponse(readinessResponse, session, logAttempt);
331+
const stampedMainThreadBusy = readRunnerMainThreadBusy(readinessData);
332+
if (stampedMainThreadBusy !== undefined) {
333+
session.runnerMainThreadBusy = stampedMainThreadBusy;
334+
}
321335
} catch (error) {
322336
throw markRunnerReadinessPreflightError(error);
323337
}
324338
}
325339

326340
function emitRunnerReadinessPreflightSkipped(
327341
runnerCommand: RunnerCommand,
328-
session: RunnerSession,
342+
session: RunnerExchangeSession,
329343
decision: Extract<RunnerReadinessPreflightDecision, { action: 'skip' }>,
330344
): void {
331345
emitDiagnostic({
@@ -392,7 +406,7 @@ function resolveRunnerFatalReason(data: Record<string, unknown>): string | undef
392406
}
393407

394408
function resolveRunnerReadinessPreflightDecision(
395-
session: RunnerSession,
409+
session: RunnerExchangeSession,
396410
command: RunnerCommand,
397411
): RunnerReadinessPreflightDecision {
398412
const readOnlyCommand = isReadOnlyRunnerCommand(command);

‎packages/platform-apple/src/runner/runner-startup-transport.ts‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,18 @@ import {
3030
} from './runner-error-classification.ts';
3131
import type { RunnerCommand } from './runner-contract.ts';
3232
import type { RunnerSession } from './runner-session-types.ts';
33+
34+
export type RunnerConnectionSession = Pick<
35+
RunnerSession,
36+
| 'startupRetryWake'
37+
| 'child'
38+
| 'startupDeviceStates'
39+
| 'state'
40+
| 'testPromise'
41+
| 'readLogTail'
42+
| 'device'
43+
| 'runnerLogPath'
44+
>;
3345
import {
3446
runnerSimulatorSetFailureDetails,
3547
simulatorSetDestinationNotFoundMessage,
@@ -53,7 +65,7 @@ export async function waitForRunner(
5365
command: RunnerCommand,
5466
logPath?: string,
5567
timeoutMs: number = RUNNER_STARTUP_TIMEOUT_MS,
56-
session?: RunnerSession,
68+
session?: RunnerConnectionSession,
5769
signal?: AbortSignal,
5870
): Promise<Response> {
5971
const deadline = Deadline.fromTimeoutMs(timeoutMs);
@@ -148,7 +160,7 @@ async function attemptRunnerConnection(params: {
148160
command: RunnerCommand;
149161
timeoutMs: number;
150162
logPath?: string;
151-
session?: RunnerSession;
163+
session?: RunnerConnectionSession;
152164
route: RunnerCommandRoute;
153165
resolveRoute: RunnerRouteResolver;
154166
markUsbmuxUnattached: () => void;
@@ -172,7 +184,7 @@ async function ensureRunnerAttemptCanStart(params: {
172184
port: number;
173185
timeoutMs: number;
174186
logPath?: string;
175-
session?: RunnerSession;
187+
session?: RunnerConnectionSession;
176188
attemptDeadline?: Deadline;
177189
}): Promise<void> {
178190
if (params.attemptDeadline?.isExpired()) {
@@ -246,7 +258,7 @@ async function tryReadySimulatorEndpoint(params: {
246258
device: DeviceInfo;
247259
port: number;
248260
command: RunnerCommand;
249-
session?: RunnerSession;
261+
session?: RunnerConnectionSession;
250262
signal?: AbortSignal;
251263
attemptDeadline?: Deadline;
252264
setLastError: (error: unknown) => void;
@@ -516,7 +528,7 @@ function buildRunnerConnectError(params: {
516528
}
517529

518530
export async function buildRunnerEarlyExitError(params: {
519-
session: RunnerSession;
531+
session: RunnerConnectionSession;
520532
port: number;
521533
logPath?: string;
522534
}): Promise<AppError> {

0 commit comments

Comments
 (0)