Skip to content

Commit 53d9f01

Browse files
committed
fix(ios): avoid replaying alert mutations
1 parent 80997b6 commit 53d9f01

12 files changed

Lines changed: 202 additions & 41 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import Foundation
2+
import XCTest
3+
4+
#if AGENT_DEVICE_RUNNER_UNIT_TESTS
5+
private struct AlertCommandTraitsFixture: Decodable {
6+
let name: String
7+
let command: Command
8+
let readOnly: Bool
9+
}
10+
11+
extension RunnerTests {
12+
func testAlertReadOnlyClassificationMatchesGoldenTable() throws {
13+
let fixtureURL = URL(fileURLWithPath: #filePath)
14+
.deletingLastPathComponent()
15+
.deletingLastPathComponent()
16+
.deletingLastPathComponent()
17+
.deletingLastPathComponent()
18+
.deletingLastPathComponent()
19+
.deletingLastPathComponent()
20+
.appendingPathComponent("contracts/fixtures/alert-command-traits.json")
21+
let cases = try JSONDecoder().decode(
22+
[AlertCommandTraitsFixture].self,
23+
from: Data(contentsOf: fixtureURL)
24+
)
25+
XCTAssertEqual(cases.map { $0.command.action }, [nil, "get", "accept", "dismiss"])
26+
for fixture in cases {
27+
XCTAssertEqual(isReadOnlyCommand(fixture.command), fixture.readOnly, fixture.name)
28+
}
29+
}
30+
}
31+
#endif
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
[
2+
{ "name": "default query", "command": { "command": "alert" }, "readOnly": true },
3+
{
4+
"name": "explicit query",
5+
"command": { "command": "alert", "action": "get" },
6+
"readOnly": true
7+
},
8+
{
9+
"name": "accept mutates",
10+
"command": { "command": "alert", "action": "accept" },
11+
"readOnly": false
12+
},
13+
{
14+
"name": "dismiss mutates",
15+
"command": { "command": "alert", "action": "dismiss" },
16+
"readOnly": false
17+
}
18+
]

packages/platform-apple/src/runner/__tests__/runner-client.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -375,14 +375,14 @@ test('withRunnerCommandId preserves existing command ids', () => {
375375
test('scroll is a mutating, command-id-tracked runner command', () => {
376376
// Runner command traits classify fused scroll as mutating, routing it through single-send
377377
// (no transport retry), command-id tracking, and status recovery.
378-
assert.equal(isReadOnlyRunnerCommand('scroll'), false);
378+
assert.equal(isReadOnlyRunnerCommand({ command: 'scroll' }), false);
379379

380380
const command = withRunnerCommandId({ command: 'scroll', direction: 'down', pixels: 120 });
381381
assert.match(command.commandId ?? '', /^runner-/);
382382
});
383383

384384
test('desktopScroll is a mutating, command-id-tracked runner command', () => {
385-
assert.equal(isReadOnlyRunnerCommand('desktopScroll'), false);
385+
assert.equal(isReadOnlyRunnerCommand({ command: 'desktopScroll' }), false);
386386

387387
const command = withRunnerCommandId({
388388
command: 'desktopScroll',

packages/platform-apple/src/runner/__tests__/runner-command-traits.test.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import assert from 'node:assert/strict';
2+
import fs from 'node:fs';
23
import { test } from 'vitest';
34
import type { RunnerCommand } from '../runner-contract.ts';
45
import {
@@ -22,7 +23,7 @@ test('runner command traits are derived from the runner command manifest', () =>
2223
for (const [command, expectedTraits] of Object.entries(EXPECTED_RUNNER_COMMAND_TRAITS) as Array<
2324
[RunnerCommand['command'], RunnerCommandTraits]
2425
>) {
25-
assert.deepEqual(readRunnerCommandTraits(command), expectedTraits, command);
26+
assert.deepEqual(readRunnerCommandTraits({ command }), expectedTraits, command);
2627
}
2728
});
2829

@@ -38,14 +39,14 @@ test('runner command manifest pins lifecycle-sensitive command groups', () => {
3839
'tap',
3940
]);
4041
assert.deepEqual(commandsForClass('readOnly'), [
41-
'alert',
4242
'findText',
4343
'gestureViewport',
4444
'querySelector',
4545
'readText',
4646
'screenshot',
4747
'snapshot',
4848
]);
49+
assert.deepEqual(commandsForClass('alertAction'), ['alert']);
4950
assert.deepEqual(commandsForClass('readOnlyReadinessProbe'), ['status', 'uptime']);
5051
assert.deepEqual(commandsForClass('readinessPreflightExemptMutation'), [
5152
'activate',
@@ -59,21 +60,38 @@ test('runner command trait helpers read from the shared trait table', () => {
5960
RunnerCommand['command']
6061
>) {
6162
const traits = EXPECTED_RUNNER_COMMAND_TRAITS[command];
62-
assert.equal(isReadOnlyRunnerCommand(command), traits.readOnly, command);
63-
assert.equal(isRunnerReadinessProbeCommand(command), traits.readinessProbe, command);
63+
assert.equal(isReadOnlyRunnerCommand({ command }), traits.readOnly, command);
64+
assert.equal(isRunnerReadinessProbeCommand({ command }), traits.readinessProbe, command);
6465
assert.equal(
65-
isRunnerReadinessPreflightExempt(command),
66+
isRunnerReadinessPreflightExempt({ command }),
6667
traits.readinessPreflightExempt,
6768
command,
6869
);
6970
assert.equal(
70-
canSkipRunnerReadinessPreflightAfterHealthyMutation(command),
71+
canSkipRunnerReadinessPreflightAfterHealthyMutation({ command }),
7172
traits.readinessPreflightSkipEligibleAfterHealthyMutation,
7273
command,
7374
);
7475
}
7576
});
7677

78+
test('alert actions match the native read-only golden table', () => {
79+
const cases = JSON.parse(
80+
fs.readFileSync(
81+
new URL('../../../../../contracts/fixtures/alert-command-traits.json', import.meta.url),
82+
'utf8',
83+
),
84+
) as Array<{ name: string; command: RunnerCommand; readOnly: boolean }>;
85+
assert.deepEqual(
86+
cases.map(({ command }) => command.action),
87+
[undefined, 'get', 'accept', 'dismiss'],
88+
);
89+
for (const { name, command, readOnly } of cases) {
90+
assert.deepEqual(readRunnerCommandTraits(command), { ...defaults(), readOnly }, name);
91+
assert.equal(isReadOnlyRunnerCommand(command), readOnly, name);
92+
}
93+
});
94+
7795
function commandsForClass(
7896
traitClass: (typeof RUNNER_COMMAND_TRAIT_MANIFEST)[RunnerCommand['command']],
7997
): RunnerCommand['command'][] {
@@ -92,6 +110,7 @@ function expectedTraitsForClass(
92110
case 'readinessPreflightExemptMutation':
93111
return preflightExemptMutation();
94112
case 'readOnly':
113+
case 'alertAction':
95114
return readOnly();
96115
case 'readOnlyReadinessProbe':
97116
return readOnlyReadinessProbe();

packages/platform-apple/src/runner/__tests__/runner-recovery-wiring.test.ts

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import assert from 'node:assert/strict';
2-
import { afterEach, expect, test, vi } from 'vitest';
2+
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
33
import { AppError } from '@agent-device/kernel/errors';
44
import { IOS_SIMULATOR } from './device-fixtures.ts';
55
import type { ExecResult } from '../host.ts';
66
import type { RunnerSession } from '../runner-session.ts';
7+
import { appleRunnerTestHost } from '../test-host.ts';
8+
import { withAppleRunnerProvider } from '../runner-provider.ts';
9+
import type { RunnerCommand } from '../runner-contract.ts';
710
import { startFakeRunnerServer, type FakeRunnerServer } from './fake-runner-server.ts';
811

912
/**
@@ -45,6 +48,14 @@ vi.mock('../runner-session.ts', async (importOriginal) => {
4548

4649
const { runAppleRunnerCommand } = await import('../runner-client.ts');
4750

51+
beforeEach(() => {
52+
const { retryWithPolicy } = appleRunnerTestHost.defaults();
53+
appleRunnerTestHost.update({
54+
retryWithPolicy: (task, policy, options) =>
55+
retryWithPolicy(task, { ...policy, baseDelayMs: 1, maxDelayMs: 1, jitter: 0 }, options),
56+
});
57+
});
58+
4859
type LostResponseAcceptanceCommand = 'press' | 'fill';
4960

5061
const LOST_RESPONSE_MUTATION_ROWS = {
@@ -214,3 +225,95 @@ test('an exact-session command never dispatches to a replacement runner', async
214225
).rejects.toThrow('runner session ownership changed');
215226
assert.deepEqual(server.requests, []);
216227
});
228+
229+
test.each(
230+
(['accept', 'dismiss'] as const).flatMap((action) =>
231+
(['accepted', 'started', 'completed'] as const).map((lifecycleState) => ({
232+
action,
233+
lifecycleState,
234+
recovery:
235+
lifecycleState === 'completed'
236+
? 'completed_without_retained_response'
237+
: 'command_still_in_flight',
238+
})),
239+
),
240+
)(
241+
'alert $action with lost response and $lifecycleState status is not replayed',
242+
async ({ action, lifecycleState, recovery }) => {
243+
server = await startFakeRunnerServer({
244+
alert: [{ kind: 'hangUp' }, { kind: 'ok', data: { replayed: true } }],
245+
status: [{ kind: 'ok', data: { lifecycleState } }],
246+
});
247+
seedSession(server.port);
248+
249+
await expect(
250+
runAppleRunnerCommand(IOS_SIMULATOR, { command: 'alert', action }),
251+
).rejects.toMatchObject({ details: { lifecycleState, recovery } });
252+
253+
const actions = server.requests.filter((request) => request.command === 'alert');
254+
const probes = server.requests.filter((request) => request.command === 'status');
255+
assert.equal(actions.length, 1);
256+
assert.equal(actions[0]?.body.action, action);
257+
assert.equal(probes.length, 1);
258+
assert.equal(probes[0]?.body.statusCommandId, actions[0]?.body.commandId);
259+
assert.equal(invalidateRunnerSessionMock.mock.calls.length, 0);
260+
},
261+
);
262+
263+
test.each([undefined, 'get'] as const)(
264+
'alert query action %s remains retryable after a transport failure',
265+
async (action) => {
266+
server = await startFakeRunnerServer({
267+
alert: [{ kind: 'hangUp' }, { kind: 'ok', data: { present: true } }],
268+
});
269+
seedSession(server.port);
270+
271+
const result = await runAppleRunnerCommand(IOS_SIMULATOR, { command: 'alert', action });
272+
273+
assert.deepEqual(result, { present: true });
274+
const queries = server.requests.filter((request) => request.command === 'alert');
275+
assert.equal(queries.length, 2);
276+
assert.equal(queries[0]?.body.commandId, queries[1]?.body.commandId);
277+
},
278+
);
279+
280+
test.each([undefined, 'get', 'accept', 'dismiss'] as const)(
281+
'alert action %s selects startup readiness by mutation semantics',
282+
async (action) => {
283+
server = await startFakeRunnerServer({ alert: [{ kind: 'ok', data: {} }] });
284+
seedSession(server.port).ready = false;
285+
286+
await runAppleRunnerCommand(IOS_SIMULATOR, { command: 'alert', action });
287+
288+
assert.deepEqual(
289+
server.requests.map((request) => request.command),
290+
action === 'accept' || action === 'dismiss' ? ['uptime', 'alert'] : ['alert'],
291+
);
292+
},
293+
);
294+
295+
test.each([undefined, 'get', 'accept', 'dismiss'] as const)(
296+
'alert action %s selects provider retries by mutation semantics',
297+
async (action) => {
298+
const commands: RunnerCommand[] = [];
299+
const failure = new AppError('COMMAND_FAILED', 'response unavailable', { retriable: true });
300+
const result = withAppleRunnerProvider(
301+
async (_device, command) => {
302+
commands.push(command);
303+
if (commands.length === 1) throw failure;
304+
return { present: true };
305+
},
306+
{ deviceId: IOS_SIMULATOR.id },
307+
() => runAppleRunnerCommand(IOS_SIMULATOR, { command: 'alert', action }),
308+
);
309+
310+
if (action === 'accept' || action === 'dismiss') {
311+
await assert.rejects(result, (error: unknown) => error === failure);
312+
assert.equal(commands.length, 1);
313+
} else {
314+
assert.deepEqual(await result, { present: true });
315+
assert.equal(commands.length, 2);
316+
assert.equal(commands[0]?.commandId, commands[1]?.commandId);
317+
}
318+
},
319+
);

packages/platform-apple/src/runner/runner-client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export async function runAppleRunnerCommand(
3939
assertRunnerRequestActive(options.requestId);
4040
const runnerCommand = withRunnerCommandId(command);
4141
const provider = resolveAppleRunnerRuntime(device, options);
42-
if (isReadOnlyRunnerCommand(runnerCommand.command)) {
42+
if (isReadOnlyRunnerCommand(runnerCommand)) {
4343
return retryWithPolicy(
4444
() => {
4545
assertRunnerRequestActive(options.requestId);

packages/platform-apple/src/runner/runner-command-manifest.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export type RunnerCommandTraitClass =
44
| 'default'
55
| 'readinessPreflightExemptMutation'
66
| 'readOnly'
7+
| 'alertAction'
78
| 'readOnlyReadinessProbe'
89
| 'preflightSkippableTouchMutation';
910

@@ -32,7 +33,7 @@ export const RUNNER_COMMAND_TRAIT_MANIFEST = {
3233
appSwitcher: 'default',
3334
keyboardDismiss: 'default',
3435
keyboardReturn: 'default',
35-
alert: 'readOnly',
36+
alert: 'alertAction',
3637
sequence: 'preflightSkippableTouchMutation',
3738
recordStart: 'default',
3839
recordStop: 'default',

packages/platform-apple/src/runner/runner-command-recovery.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ function handleCompletedRunnerStatus(
242242
lifecycleState: 'completed',
243243
};
244244
}
245-
if (isReadOnlyRunnerCommand(command.command)) {
245+
if (isReadOnlyRunnerCommand(command)) {
246246
return {
247247
type: 'skipInvalidation',
248248
error: transportError,
@@ -311,7 +311,7 @@ function runnerStatusInFlightError(
311311
transportError: AppError,
312312
options: AppleRunnerCommandOptions,
313313
): AppError {
314-
if (isReadOnlyRunnerCommand(command.command)) {
314+
if (isReadOnlyRunnerCommand(command)) {
315315
return transportError;
316316
}
317317
const readinessPreflight = readReadinessPreflightRecoveryDetails(transportError);

packages/platform-apple/src/runner/runner-command-traits.ts

Lines changed: 9 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
import type { RunnerCommand } from './runner-contract.ts';
2-
import {
3-
RUNNER_COMMAND_TRAIT_MANIFEST,
4-
type RunnerCommandTraitClass,
5-
} from './runner-command-manifest.ts';
2+
import { RUNNER_COMMAND_TRAIT_MANIFEST } from './runner-command-manifest.ts';
63

74
export type RunnerCommandTraits = Readonly<{
85
readOnly: boolean;
@@ -44,43 +41,34 @@ const PREFLIGHT_SKIPPABLE_TOUCH_MUTATION_TRAITS: RunnerCommandTraits = {
4441
readinessPreflightSkipEligibleAfterHealthyMutation: true,
4542
};
4643

47-
const RUNNER_COMMAND_TRAITS = Object.fromEntries(
48-
Object.entries(RUNNER_COMMAND_TRAIT_MANIFEST).map(([command, traitClass]) => [
49-
command,
50-
traitsForClass(traitClass),
51-
]),
52-
) as Record<RunnerCommand['command'], RunnerCommandTraits>;
53-
54-
export function readRunnerCommandTraits(command: RunnerCommand['command']): RunnerCommandTraits {
55-
return RUNNER_COMMAND_TRAITS[command];
56-
}
57-
58-
export function isReadOnlyRunnerCommand(command: RunnerCommand['command']): boolean {
44+
export function isReadOnlyRunnerCommand(command: RunnerCommand): boolean {
5945
return readRunnerCommandTraits(command).readOnly;
6046
}
6147

62-
export function isRunnerReadinessProbeCommand(command: RunnerCommand['command']): boolean {
48+
export function isRunnerReadinessProbeCommand(command: RunnerCommand): boolean {
6349
return readRunnerCommandTraits(command).readinessProbe;
6450
}
6551

66-
export function isRunnerReadinessPreflightExempt(command: RunnerCommand['command']): boolean {
52+
export function isRunnerReadinessPreflightExempt(command: RunnerCommand): boolean {
6753
return readRunnerCommandTraits(command).readinessPreflightExempt;
6854
}
6955

7056
export function canSkipRunnerReadinessPreflightAfterHealthyMutation(
71-
command: RunnerCommand['command'],
57+
command: RunnerCommand,
7258
): boolean {
7359
return readRunnerCommandTraits(command).readinessPreflightSkipEligibleAfterHealthyMutation;
7460
}
7561

76-
function traitsForClass(traitClass: RunnerCommandTraitClass): RunnerCommandTraits {
77-
switch (traitClass) {
62+
export function readRunnerCommandTraits(command: RunnerCommand): RunnerCommandTraits {
63+
switch (RUNNER_COMMAND_TRAIT_MANIFEST[command.command]) {
7864
case 'default':
7965
return DEFAULT_TRAITS;
8066
case 'readinessPreflightExemptMutation':
8167
return READINESS_PREFLIGHT_EXEMPT_MUTATION_TRAITS;
8268
case 'readOnly':
8369
return READ_ONLY_TRAITS;
70+
case 'alertAction':
71+
return (command.action ?? 'get').toLowerCase() === 'get' ? READ_ONLY_TRAITS : DEFAULT_TRAITS;
8472
case 'readOnlyReadinessProbe':
8573
return READ_ONLY_READINESS_PROBE_TRAITS;
8674
case 'preflightSkippableTouchMutation':

0 commit comments

Comments
 (0)