Skip to content

Commit 13bddfa

Browse files
committed
perf: tighten Maestro iOS stabilization
1 parent 66268a5 commit 13bddfa

40 files changed

Lines changed: 1764 additions & 329 deletions

.github/workflows/ios.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,8 @@ jobs:
7171
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testSystemModalProbeSliceSharesAndClampsToPlanDeadline \
7272
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testDispatchRecoverySkipsBookkeepingWhileXCTestChannelOccupied \
7373
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testBoundedSystemModalProbeTimeoutRecoversThenReleasesOnDrain \
74-
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testBoundedSystemModalProbeTimeoutRecoversThenReleasesOnDrainForSnapshotRaw
74+
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testBoundedSystemModalProbeTimeoutRecoversThenReleasesOnDrainForSnapshotRaw \
75+
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testSnapshotTraversalIdentityPreservesSameOriginNodesWithDifferentBounds
7576
7677
- name: Prepare iOS runner
7778
run: |

apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,12 @@ extension RunnerTests {
196196
regularSnapshot: true
197197
)
198198

199-
let key = "\(snapshot.elementType)-\(evaluation.label)-\(evaluation.identifier)-\(snapshot.frame.origin.x)-\(snapshot.frame.origin.y)"
199+
let key = Self.snapshotTraversalIdentity(
200+
elementType: snapshot.elementType,
201+
label: evaluation.label,
202+
identifier: evaluation.identifier,
203+
frame: snapshot.frame
204+
)
200205
let isDuplicate = seen.contains(key)
201206
if !isDuplicate {
202207
seen.insert(key)
@@ -1118,6 +1123,15 @@ extension RunnerTests {
11181123
return .infinite
11191124
}
11201125

1126+
static func snapshotTraversalIdentity(
1127+
elementType: XCUIElement.ElementType,
1128+
label: String,
1129+
identifier: String,
1130+
frame: CGRect
1131+
) -> String {
1132+
"\(elementType)-\(label)-\(identifier)-\(frame.origin.x)-\(frame.origin.y)-\(frame.width)-\(frame.height)"
1133+
}
1134+
11211135
private func aggregatedLabel(for snapshot: XCUIElementSnapshot, depth: Int = 0) -> String? {
11221136
if depth > 4 { return nil }
11231137
let text = snapshot.label.trimmingCharacters(in: .whitespacesAndNewlines)
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import XCTest
2+
3+
extension RunnerTests {
4+
func testSnapshotTraversalIdentityPreservesSameOriginNodesWithDifferentBounds() {
5+
let wrapper = Self.snapshotTraversalIdentity(
6+
elementType: .other,
7+
label: "Article",
8+
identifier: "",
9+
frame: CGRect(x: 0, y: 97, width: 393, height: 48)
10+
)
11+
let leaf = Self.snapshotTraversalIdentity(
12+
elementType: .other,
13+
label: "Article",
14+
identifier: "",
15+
frame: CGRect(x: 0, y: 97, width: 131, height: 48)
16+
)
17+
18+
XCTAssertNotEqual(wrapper, leaf)
19+
}
20+
}

docs/adr/0015-direct-maestro-engine.md

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,12 @@ are failure/debug artifacts, not happy-path requirements.
6666

6767
The daemon adapter may retain the provider snapshot behind a successful observation without exposing it
6868
through the engine contract. A following target resolution may use that snapshot only as semantic
69-
evidence. Maestro selector semantics continue to use the raw provider nodes, except for equivalent iOS
70-
accessibility wrapper chains described below. On iOS, the adapter derives the provider's canonical
71-
interactive presentation from that same snapshot solely to determine atomic selector uniqueness; it
72-
does not perform another capture. A unique exact, canonically hittable match may be dispatched with its
69+
evidence. Maestro selector semantics use the shared structurally normalized provider snapshot, before
70+
iOS canonical interactive presentation. They deliberately do not request the public `snapshot --raw`
71+
shape, whose output contract bypasses shared pruning and overlay annotations. On iOS, the adapter derives the provider's canonical
72+
interactive presentation from that same snapshot without another capture. Raw matching still selects
73+
the node and authored index; when that same source node has canonical interactive bounds, tap-like
74+
actions use those bounds. A unique exact, canonically hittable match may be dispatched with its
7375
resolved point so XCTest binds the live selector identity and coordinate delivery atomically; a
7476
structured live-selector miss, ambiguity, point mismatch, or off-screen result
7577
falls back to fresh Maestro resolution. All other targets capture fresh geometry before coordinate
@@ -87,22 +89,33 @@ matches. It does not adopt the public agent-device command surface's unique-matc
8789
suppressed by Maestro `optional`; atomic iOS dispatch handles that result by performing fresh Maestro
8890
resolution, as described above. Cancellation and infrastructure failures are likewise non-optional.
8991

90-
Every `tapOn` waits for hierarchy stability before the next command, matching Maestro's tap boundary.
91-
`retryTapIfNoChange` defaults to false; when explicitly enabled, the runtime compares the stable hierarchy
92-
with the target-resolution hierarchy and retries once when nothing changed. The stable result primes the
93-
next command, so the tap boundary does not add another hierarchy read there.
94-
95-
Successful gestures and scrolls require stabilization before the next command executes. The runtime
96-
port records that requirement without capturing a hierarchy in the gesture command itself. At the next
97-
command boundary, it samples at the compatibility polling cadence until the observation is stable, then
98-
retains that final observation for the immediately following read. This keeps mutation ordering explicit,
99-
prevents a later gesture or assertion from overtaking an in-flight transition, and avoids paying for the
100-
same stable observation twice.
92+
Mutations that can leave an in-flight transition record a pending stabilization boundary. Before another
93+
mutating command, the runtime samples at the compatibility polling cadence until the hierarchy is stable
94+
and retains that final observation for the next read. Commands with specialized completion semantics settle
95+
inline instead. Hierarchy signatures project the available semantic attributes, tree topology, and
96+
fixed-order integer edges while excluding provider-only metadata, object-key order, and subpixel noise.
97+
98+
An authored observation after a mutation skips the pending barrier and polls its own condition immediately.
99+
Its successful snapshot becomes the baseline for the boundary but does not discharge mutation ordering. If
100+
another mutation follows, the runtime waits one polling interval and compares a fresh hierarchy with that
101+
baseline, continuing until stable when the UI is still changing. If no later mutation follows, it performs no
102+
extra capture. This intentionally differs from upstream Maestro, which settles before evaluating an assertion,
103+
and retains the previous agent-device compatibility engine's faster read behavior without allowing a later
104+
tap, gesture, scroll, or input to overtake an in-flight transition.
105+
106+
`retryTapIfNoChange` defaults to false. When explicitly enabled, the runtime compares the stable hierarchy
107+
with the target-resolution hierarchy. On iOS, an unchanged hierarchy triggers the same screenshot comparison
108+
used by Maestro; the runtime retries once only when both surfaces remain unchanged. Screenshot evidence is
109+
best-effort because a failed capture cannot prove that repeating a mutation is safe. The stable hierarchy
110+
primes the next command, so the retry policy does not add another hierarchy read there.
101111

102112
`waitForAnimationToEnd` uses its own screenshot-stability operation, matching upstream's two immediate
103113
captures per attempt and 0.005% normalized absolute RGB-difference threshold. These captures explicitly
104114
bypass ordinary screenshot stabilization so the command observes the application rather than
105-
recursively waiting on another settling policy.
115+
recursively waiting on another settling policy. Screenshot stability does not discharge a pending
116+
mutation boundary; a later mutating command still verifies hierarchy stability before dispatch. On iOS,
117+
comparison captures use the persistent runner's screenshot surface so both frames come from one warmed
118+
transport and avoid simulator screenshot setup between polls.
106119

107120
Upstream Maestro is a version-pinned development reference, not a production dependency. The current
108121
opt-in conformance script compares a small checked-in parser corpus against manually captured Maestro
@@ -118,14 +131,15 @@ The migration cannot switch production routing until Android and iOS satisfy all
118131
and react-navigation corpora:
119132

120133
- total wall time is no slower than the pre-migration compatibility engine;
121-
- target interactions perform one outcome query after the compatibility polling interval and prime that
122-
result for the next command;
134+
- an observation immediately after a mutation polls its authored condition directly; a later mutation uses
135+
that result as its stabilization baseline;
123136
- no command captures a second hierarchy merely to re-verify evidence produced within that command;
124137
- absolute coordinate swipes perform one direct viewport query and no accessibility capture;
125-
- gesture stabilization is deferred to the next command boundary and primes the following observation;
138+
- mutation-to-mutation stabilization is deferred to the next command boundary and primes the following read;
126139
- percentage swipe conversion preserves authored endpoints exactly;
127140
- helper/runner startup remains amortized across a suite;
128-
- p50/p95 command latency, captures, retries, and transferred hierarchy bytes are reported separately;
141+
- p50/p95 command latency, hierarchy and screenshot captures, tap retries, and transferred hierarchy
142+
bytes are reported separately;
129143
- failure-only diagnostics are excluded from happy-path latency comparisons.
130144

131145
Android verification must prove the bundled helper backend and version. iOS verification must separate
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
import { expect, test } from 'vitest';
2+
import type { DaemonRequest } from '../../../daemon/types.ts';
3+
import { createDaemonMaestroRuntimePort } from '../daemon-runtime-port.ts';
4+
import { MAESTRO_OBSERVATION_POLL_MS } from '../daemon-runtime-port-observation.ts';
5+
import type { MaestroRuntimeCommand } from '../engine-types.ts';
6+
import { makeBaseRequest, makeDependencies } from './daemon-runtime-port-fixtures.ts';
7+
8+
test.each([
9+
{
10+
name: 'double tap',
11+
command: {
12+
kind: 'doubleTapOn',
13+
source: { line: 2 },
14+
target: { space: 'absolute', x: 100, y: 200 },
15+
},
16+
},
17+
{
18+
name: 'long press',
19+
command: {
20+
kind: 'longPressOn',
21+
source: { line: 2 },
22+
target: { space: 'absolute', x: 100, y: 200 },
23+
},
24+
},
25+
] satisfies { name: string; command: MaestroRuntimeCommand }[])(
26+
'settles $name before a following gesture',
27+
async ({ command }) => {
28+
const requests: DaemonRequest[] = [];
29+
const clock = { value: 0 };
30+
const port = createDaemonMaestroRuntimePort({
31+
baseReq: makeBaseRequest({ flags: { platform: 'ios', replayBackend: 'maestro' } }),
32+
invoke: async (request) => {
33+
requests.push(request);
34+
return request.command === 'snapshot'
35+
? {
36+
ok: true,
37+
data: {
38+
nodes: [
39+
{
40+
index: 0,
41+
type: 'Application',
42+
rect: { x: 0, y: 0, width: 393, height: 852 },
43+
},
44+
],
45+
},
46+
}
47+
: { ok: true, data: {} };
48+
},
49+
dependencies: makeDependencies(clock),
50+
platform: 'ios',
51+
});
52+
53+
await port.execute({
54+
command,
55+
generation: 0,
56+
env: {},
57+
invalidateObservation() {},
58+
});
59+
await port.execute({
60+
command: {
61+
kind: 'swipe',
62+
source: { line: 3 },
63+
gesture: {
64+
kind: 'coordinates',
65+
start: { space: 'absolute', x: 350, y: 400 },
66+
end: { space: 'absolute', x: 40, y: 400 },
67+
duration: 100,
68+
},
69+
},
70+
generation: 1,
71+
env: {},
72+
invalidateObservation() {},
73+
});
74+
75+
expect(requests.map(({ command: requestCommand }) => requestCommand)).toEqual([
76+
'click',
77+
'snapshot',
78+
'snapshot',
79+
'swipe',
80+
]);
81+
expect(clock.value).toBe(MAESTRO_OBSERVATION_POLL_MS);
82+
},
83+
);
84+
85+
test('preserves mutation ordering after a failed dispatch is continued', async () => {
86+
const requests: DaemonRequest[] = [];
87+
const clock = { value: 0 };
88+
let swipeAttempts = 0;
89+
const port = createDaemonMaestroRuntimePort({
90+
baseReq: makeBaseRequest({ flags: { platform: 'ios', replayBackend: 'maestro' } }),
91+
invoke: async (request) => {
92+
requests.push(request);
93+
if (request.command === 'swipe' && swipeAttempts++ === 0) {
94+
throw new Error('dispatch failed after delivery became possible');
95+
}
96+
return request.command === 'snapshot'
97+
? {
98+
ok: true,
99+
data: {
100+
nodes: [
101+
{
102+
index: 0,
103+
type: 'Application',
104+
rect: { x: 0, y: 0, width: 393, height: 852 },
105+
},
106+
],
107+
},
108+
}
109+
: { ok: true, data: {} };
110+
},
111+
dependencies: makeDependencies(clock),
112+
platform: 'ios',
113+
});
114+
const swipe = (generation: number) =>
115+
port.execute({
116+
command: {
117+
kind: 'swipe',
118+
source: { line: generation + 2 },
119+
gesture: {
120+
kind: 'coordinates',
121+
start: { space: 'absolute', x: 350, y: 400 },
122+
end: { space: 'absolute', x: 40, y: 400 },
123+
duration: 100,
124+
},
125+
},
126+
generation,
127+
env: {},
128+
invalidateObservation() {},
129+
});
130+
131+
await expect(swipe(0)).rejects.toThrow('dispatch failed');
132+
await swipe(1);
133+
134+
expect(requests.map(({ command }) => command)).toEqual([
135+
'swipe',
136+
'snapshot',
137+
'snapshot',
138+
'swipe',
139+
]);
140+
expect(clock.value).toBe(MAESTRO_OBSERVATION_POLL_MS);
141+
});
142+
143+
test('defers a pending mutation boundary across non-mutating runtime commands', async () => {
144+
const requests: DaemonRequest[] = [];
145+
const clock = { value: 0 };
146+
const port = createDaemonMaestroRuntimePort({
147+
baseReq: makeBaseRequest({ flags: { platform: 'ios', replayBackend: 'maestro' } }),
148+
invoke: async (request) => {
149+
requests.push(request);
150+
return request.command === 'snapshot'
151+
? {
152+
ok: true,
153+
data: {
154+
nodes: [
155+
{
156+
index: 0,
157+
type: 'Application',
158+
rect: { x: 0, y: 0, width: 393, height: 852 },
159+
},
160+
],
161+
},
162+
}
163+
: { ok: true, data: {} };
164+
},
165+
dependencies: makeDependencies(clock),
166+
platform: 'ios',
167+
});
168+
const swipe = (generation: number) =>
169+
port.execute({
170+
command: {
171+
kind: 'swipe',
172+
source: { line: generation + 2 },
173+
gesture: {
174+
kind: 'coordinates',
175+
start: { space: 'absolute', x: 350, y: 400 },
176+
end: { space: 'absolute', x: 40, y: 400 },
177+
duration: 100,
178+
},
179+
},
180+
generation,
181+
env: {},
182+
invalidateObservation() {},
183+
});
184+
185+
await swipe(0);
186+
await port.execute({
187+
command: { kind: 'takeScreenshot', source: { line: 3 }, path: 'state.png' },
188+
generation: 1,
189+
env: {},
190+
invalidateObservation() {},
191+
});
192+
expect(requests.map(({ command }) => command)).toEqual(['swipe', 'screenshot']);
193+
194+
await swipe(1);
195+
expect(requests.map(({ command }) => command)).toEqual([
196+
'swipe',
197+
'screenshot',
198+
'snapshot',
199+
'snapshot',
200+
'swipe',
201+
]);
202+
expect(clock.value).toBe(MAESTRO_OBSERVATION_POLL_MS);
203+
});

0 commit comments

Comments
 (0)