Skip to content

Commit 486e1d6

Browse files
thymikeeclaude
andauthored
fix(daemon): settle the first capture after any iOS open that did not read the app (#3376)
* fix(daemon): settle the first capture after any iOS open that did not read the app (#3367) CI evidence across 30 runs of 01-settings.ad: 61 iOS opens reported 54 probe-failed, 7 observable, 0 unobservable, so the unobservable-only mark from #3356 never fired. Every stale-tap failure followed a probe-failed open. Mark the next capture for any defined verdict other than observable, mirroring the open-policy fixed-delay condition; an open with no verdict (physical device) still does not mark. Co-Authored-By: Claude Sonnet 5.5 <[email protected]> * docs(open): scope the post-open settle to opens that report a verdict Co-Authored-By: Claude Opus 5.5 <[email protected]> * docs(daemon): describe the post-open mark by what the verdict proves, not its cause Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Claude Sonnet 5.5 <[email protected]>
1 parent 0692458 commit 486e1d6

13 files changed

Lines changed: 125 additions & 47 deletions

‎packages/contracts/src/interaction-guarantees.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ const SHARED_RESPONSE_CONSTRUCTION: GuaranteeEnforcement = {
166166
const TAP_OUTCOME_NOT_OBSERVED_GAP: GuaranteeEnforcement = {
167167
kind: 'waived',
168168
reason:
169-
'gap: the response reports the dispatch only; only opt-in --verify/--settle capture post-action evidence into it. The deferred marks set after dispatch (Android snapshot freshness after press/click, post-gesture stabilization when the request sets postGestureStabilization) are judged by the next capture, never in this response. An iOS Simulator open whose launch watch ended unobservable marks the next capture in that session to wait for a quiet surface (markPostOpenStabilization); that steadies where a target is resolved, not whether the tap landed. The iOS ambiguous-failure corroboration reconsiders only a thrown runner error.',
169+
'gap: the response reports the dispatch only; only opt-in --verify/--settle capture post-action evidence into it. The deferred marks set after dispatch (Android snapshot freshness after press/click, post-gesture stabilization when the request sets postGestureStabilization) are judged by the next capture, never in this response. An iOS Simulator open that did not read the launched app (any postOpenObservation other than observable) marks the next capture in that session to wait for a quiet surface (markPostOpenStabilization); that steadies where a target is resolved, not whether the tap landed. The iOS ambiguous-failure corroboration reconsiders only a thrown runner error.',
170170
trackingIssue: TAP_OUTCOME_NOT_OBSERVED_GAP_ISSUE,
171171
};
172172

‎src/daemon/__tests__/deferred-interaction-outcome.test.ts‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -109,18 +109,25 @@ test('an Apple open that could not observe its app marks stabilization without a
109109
assert.equal(session.postGestureStabilization?.baselineSignature, undefined);
110110
});
111111

112-
test('only an open that saw the launch still in flight marks stabilization', () => {
113-
const quiet = (
114-
['observable', 'probe-failed', 'app-unidentified', 'not-eligible', undefined] as const
112+
test('every open verdict other than observable marks stabilization', () => {
113+
const pending = (
114+
[
115+
'unobservable',
116+
'probe-failed',
117+
'app-unidentified',
118+
'not-eligible',
119+
'observable',
120+
undefined,
121+
] as const
115122
).map((observation) => {
116123
const session = makeSession('ios');
117124
markPostOpenStabilization(session, observation);
118125
return isPostGestureStabilizationPending(session);
119126
});
120127
const android = makeSession('android');
121-
markPostOpenStabilization(android, 'unobservable');
128+
markPostOpenStabilization(android, 'probe-failed');
122129

123-
assert.deepEqual(quiet, [false, false, false, false, false]);
130+
assert.deepEqual(pending, [true, true, true, true, false, false]);
124131
assert.equal(isPostGestureStabilizationPending(android), false);
125132
});
126133

‎src/daemon/deferred-interaction-outcome.ts‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -109,18 +109,20 @@ function markPostGestureStabilization(
109109
}
110110

111111
/**
112-
* Called once an app open has published its session. `unobservable` means the launch was still in
113-
* flight or no app process was found when the open returned, so the next capture runs the
114-
* quiet-window loop before a selector resolves against a layout that may still be moving (#3354). No baseline: the pre-open tree is a
115-
* different surface, so the open is never reported as having no effect.
112+
* Called once an app open has published its session. A verdict other than `observable` means the
113+
* open never read the launched app: the probe failed or could not identify it, the device has no
114+
* observation path, or the app stayed unreadable. Nothing proved its layout at rest, so the next
115+
* capture runs the quiet-window loop (#3354, #3367). An open that reports no verdict does not mark.
116+
* No baseline: the pre-open tree is a different surface, so the open is never reported as having no
117+
* effect.
116118
*/
117119
export function markPostOpenStabilization(
118120
session: SessionState,
119121
observation: PostOpenObservation | undefined,
120122
): void {
121123
if (!isApplePlatform(session.device.platform)) return;
122124
if (!supportsPostGestureStabilization(session.device)) return;
123-
if (observation !== 'unobservable') return;
125+
if (observation === undefined || observation === 'observable') return;
124126
session.postGestureStabilization = { action: 'open', positionals: [], markedAt: Date.now() };
125127
}
126128

‎src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,12 @@ test('a fresh iOS open that could not observe its app leaves the next capture to
500500
expect(session?.postGestureStabilization?.action).toBe('open');
501501
});
502502

503+
test('a fresh iOS open whose observation probe failed leaves the next capture to settle', async () => {
504+
const session = await openReportingObservation('probe-failed-open', 'probe-failed');
505+
506+
expect(session?.postGestureStabilization?.action).toBe('open');
507+
});
508+
503509
test('a fresh iOS open that observed its app leaves nothing pending', async () => {
504510
const session = await openReportingObservation('observed-open', 'observable');
505511

‎test/integration/interaction-contract/daemon-harness.ts‎

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,16 @@ import {
1919
export const CONTRACT_APP = 'com.example.app';
2020
const CONTRACT_DEVICE_ID = PROVIDER_SCENARIO_IOS_SIMULATOR.id;
2121

22+
const SETTLING_CAPTURE_READS = 2;
23+
const SETTLING_CAPTURE_NODES = [
24+
{
25+
index: 0,
26+
type: 'Application',
27+
label: 'Example',
28+
rect: { x: 0, y: 0, width: 400, height: 800 },
29+
},
30+
];
31+
2232
/**
2333
* Provider-transcript harness for contract scenarios whose path involves the
2434
* iOS runner (maestro-non-hittable-fallback) or that
@@ -32,7 +42,25 @@ export async function withIosContractDaemon(
3242
run: (daemon: ProviderScenarioHarness, transcript: ProviderScenarioTranscript) => Promise<void>,
3343
options: { saveScript?: boolean | string } = {},
3444
): Promise<void> {
35-
const transcript = createProviderTranscript(entries);
45+
// The scripted open is not read back, so the first capture after it settles over a quiet pair of
46+
// reads and pauses the direct selector path. A snapshot consumes that pair here, keeping each
47+
// scenario's own conversation with the runner exactly as scripted.
48+
const transcript = createProviderTranscript([
49+
...Array.from({ length: SETTLING_CAPTURE_READS }, () =>
50+
runnerSnapshotEntry(SETTLING_CAPTURE_NODES),
51+
),
52+
...entries,
53+
]);
54+
const scenarioTranscript: ProviderScenarioTranscript = {
55+
get calls() {
56+
return transcript.calls.slice(SETTLING_CAPTURE_READS);
57+
},
58+
get remaining() {
59+
return transcript.remaining;
60+
},
61+
assertComplete: () => transcript.assertComplete(),
62+
next: (command, request, scope) => transcript.next(command, request, scope),
63+
};
3664
const appleRunnerProvider = createAppleRunnerProviderFromTranscript(transcript, 'ios.runner');
3765
const appleTool = createRecordingAppleToolProvider({
3866
simctl: simctlDeviceLifecycleHandler('com.apple.CoreSimulator.SimRuntime.iOS-18-0', [
@@ -54,7 +82,8 @@ export async function withIosContractDaemon(
5482
...(options.saveScript !== undefined ? { saveScript: options.saveScript } : {}),
5583
});
5684
assertRpcOk(open);
57-
await run(daemon, transcript);
85+
assertRpcOk(await daemon.callCommand('snapshot'));
86+
await run(daemon, scenarioTranscript);
5887
transcript.assertComplete();
5988
},
6089
);

‎test/integration/provider-scenarios/interaction-direct-selector-fallback.test.ts‎

Lines changed: 39 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,17 @@ function snapshotEntry(nodes: unknown[]): ProviderScenarioProviderEntry {
4646
};
4747
}
4848

49+
// The scripted open is not read back, so the first capture after it settles over a quiet pair of
50+
// reads and the direct tap path stays paused until that capture has run.
51+
const SETTLING_CAPTURE_ENTRIES = [
52+
snapshotEntry([APPLICATION_NODE]),
53+
snapshotEntry([APPLICATION_NODE]),
54+
];
55+
56+
async function settleAfterOpen(daemon: ProviderScenarioHarness): Promise<void> {
57+
assertRpcOk(await daemon.callCommand('snapshot'));
58+
}
59+
4960
async function withDirectSelectorScenario(
5061
transcript: ProviderScenarioTranscript,
5162
run: (daemon: ProviderScenarioHarness) => Promise<void>,
@@ -77,19 +88,18 @@ async function withDirectSelectorScenario(
7788
}
7889

7990
test('Provider-backed iOS selector wait captures directly and strips selectorChain', async () => {
80-
const transcript = createProviderTranscript([
81-
snapshotEntry([
82-
APPLICATION_NODE,
83-
{
84-
index: 1,
85-
parentIndex: 0,
86-
type: 'Button',
87-
label: 'Continue',
88-
hittable: true,
89-
rect: { x: 100, y: 300, width: 200, height: 44 },
90-
},
91-
]),
91+
const continueSnapshot = snapshotEntry([
92+
APPLICATION_NODE,
93+
{
94+
index: 1,
95+
parentIndex: 0,
96+
type: 'Button',
97+
label: 'Continue',
98+
hittable: true,
99+
rect: { x: 100, y: 300, width: 200, height: 44 },
100+
},
92101
]);
102+
const transcript = createProviderTranscript([continueSnapshot, continueSnapshot]);
93103

94104
await withDirectSelectorScenario(transcript, async (daemon) => {
95105
const wait = await daemon.callCommand('wait', ['label="Continue"']);
@@ -100,18 +110,20 @@ test('Provider-backed iOS selector wait captures directly and strips selectorCha
100110
});
101111

102112
test('Provider-backed iOS selector wait polls a fresh capture after a selector miss', async () => {
113+
const loadingSnapshot = snapshotEntry([
114+
APPLICATION_NODE,
115+
{
116+
index: 1,
117+
parentIndex: 0,
118+
type: 'Button',
119+
label: 'Loading',
120+
hittable: true,
121+
rect: { x: 100, y: 300, width: 200, height: 44 },
122+
},
123+
]);
103124
const transcript = createProviderTranscript([
104-
snapshotEntry([
105-
APPLICATION_NODE,
106-
{
107-
index: 1,
108-
parentIndex: 0,
109-
type: 'Button',
110-
label: 'Loading',
111-
hittable: true,
112-
rect: { x: 100, y: 300, width: 200, height: 44 },
113-
},
114-
]),
125+
loadingSnapshot,
126+
loadingSnapshot,
115127
snapshotEntry([
116128
APPLICATION_NODE,
117129
{
@@ -135,6 +147,7 @@ test('Provider-backed iOS selector wait polls a fresh capture after a selector m
135147

136148
test('Provider-backed integration maestro replay dispatch keeps runner AMBIGUOUS_MATCH without fallback', async () => {
137149
const transcript = createProviderTranscript([
150+
...SETTLING_CAPTURE_ENTRIES,
138151
{
139152
command: 'ios.runner.tap',
140153
deviceId: DEVICE_ID,
@@ -154,6 +167,7 @@ test('Provider-backed integration maestro replay dispatch keeps runner AMBIGUOUS
154167
]);
155168

156169
await withDirectSelectorScenario(transcript, async (daemon) => {
170+
await settleAfterOpen(daemon);
157171
const click = await daemon.callCommand('click', ['label="Continue"'], {
158172
maestro: { allowNonHittableCoordinateFallback: true },
159173
});
@@ -165,6 +179,7 @@ test(
165179
'Provider-backed integration maestro replay dispatch keeps runner ELEMENT_OFFSCREEN without fallback',
166180
async () => {
167181
const transcript = createProviderTranscript([
182+
...SETTLING_CAPTURE_ENTRIES,
168183
{
169184
command: 'ios.runner.tap',
170185
deviceId: DEVICE_ID,
@@ -182,6 +197,7 @@ test(
182197
]);
183198

184199
await withDirectSelectorScenario(transcript, async (daemon) => {
200+
await settleAfterOpen(daemon);
185201
const click = await daemon.callCommand('click', ['label="Continue"'], {
186202
maestro: { allowNonHittableCoordinateFallback: true },
187203
});

‎test/integration/provider-scenarios/interaction-verify.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,8 @@ function snapshotEntry(nodes: unknown[]): ProviderScenarioProviderEntry {
5757

5858
test('Provider-backed integration press --verify returns post-action evidence digest', async () => {
5959
const runnerTranscript = createProviderTranscript([
60-
// snapshot -i to obtain refs
60+
// snapshot -i to obtain refs; the first capture after open settles: one extra read
61+
snapshotEntry(BEFORE_NODES),
6162
snapshotEntry(BEFORE_NODES),
6263
{
6364
command: 'ios.runner.tap',

‎test/integration/provider-scenarios/press-target-readiness.test.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,13 @@ async function withPressReadinessDaemon(
7373
transcript: ReturnType<typeof createProviderTranscript>,
7474
) => Promise<void>,
7575
): Promise<void> {
76-
const runnerTranscript = createProviderTranscript(entries);
76+
// The scripted open is not read back, so the first capture after it settles over a quiet pair of
77+
// reads. A snapshot consumes that pair here, keeping the scenario's own captures one-for-one.
78+
const runnerTranscript = createProviderTranscript([
79+
snapshotEntry(APPLICATION_ONLY_NODES),
80+
snapshotEntry(APPLICATION_ONLY_NODES),
81+
...entries,
82+
]);
7783
const appleRunnerProvider = createAppleRunnerProviderFromTranscript(
7884
runnerTranscript,
7985
'ios.runner',
@@ -94,6 +100,7 @@ async function withPressReadinessDaemon(
94100
async (daemon) => {
95101
const open = await daemon.callCommand('open', [APP], { platform: 'ios', udid: DEVICE_ID });
96102
assertRpcOk(open);
103+
assertRpcOk(await daemon.callCommand('snapshot'));
97104
await run(daemon, runnerTranscript);
98105
},
99106
);

‎test/integration/provider-scenarios/scroll-movement-observation.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ function iosSimulatorTool(): { provider: AppleToolProvider } {
149149
test('Provider-backed integration scroll down answers moved on the first post-gesture capture', async () => {
150150
const runnerTranscript = createProviderTranscript([
151151
snapshotEntry(screen(0, true)), // pre-scroll baseline
152+
snapshotEntry(screen(0, true)), // the first capture after open settles: one extra read
152153
scrollEntry(),
153154
snapshotEntry(screen(-100, true)), // post-gesture: rows shifted into view on the first read
154155
]);

‎test/integration/provider-scenarios/settle-observation.test.ts‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,8 @@ function tapEntry(x: number, y: number): ProviderScenarioProviderEntry {
174174

175175
test('Provider-backed integration press --settle returns the settled diff and fresh refs', async () => {
176176
const runnerTranscript = createProviderTranscript([
177-
// snapshot -i: issues refs
177+
// snapshot -i: issues refs; the first capture after open settles: one extra read
178+
snapshotEntry(BEFORE_NODES),
178179
snapshotEntry(BEFORE_NODES),
179180
// press label=Continue --settle: resolution capture, tap, settle captures
180181
snapshotEntry(BEFORE_NODES),
@@ -296,8 +297,9 @@ test('Provider-backed integration never-settled press --settle does not issue di
296297
},
297298
];
298299
const runnerTranscript = createProviderTranscript([
299-
// press label=Continue --settle: resolution capture, tap, then a settle
300-
// stream that never repeats itself, so the loop can only ever time out.
300+
// press label=Continue --settle: resolution capture (the first capture after open settles: one extra read),
301+
// tap, then a settle stream that never repeats itself, so the loop can only ever time out.
302+
snapshotEntry(BEFORE_NODES),
301303
snapshotEntry(BEFORE_NODES),
302304
tapEntry(200, 322),
303305
changingSnapshotEntry(loadingNodes),
@@ -356,7 +358,8 @@ test('Provider-backed integration never-settled press --settle does not issue di
356358

357359
test('Provider-backed integration modal-dismiss press --settle attaches the unchanged interactive tail', async () => {
358360
const runnerTranscript = createProviderTranscript([
359-
// snapshot -i: issues refs
361+
// snapshot -i: issues refs; the first capture after open settles: one extra read
362+
snapshotEntry(MODAL_BEFORE_NODES),
360363
snapshotEntry(MODAL_BEFORE_NODES),
361364
// press label=Cancel --settle: resolution capture, tap, settle captures.
362365
// The dialog closes leaving Continue in place — a removals-only diff with
@@ -691,7 +694,8 @@ const FILL_SETTLED_NODES = [
691694

692695
test('Provider-backed integration fill --settle summoning the keyboard still attaches the unchanged interactive tail', async () => {
693696
const runnerTranscript = createProviderTranscript([
694-
// snapshot -i: issues refs
697+
// snapshot -i: issues refs; the first capture after open settles: one extra read
698+
snapshotEntry(FILL_BEFORE_NODES),
695699
snapshotEntry(FILL_BEFORE_NODES),
696700
// fill @e6 'hello' --settle: the ref resolves on the stored tree (no
697701
// fresh capture), the runner types, then the settle loop captures. The

0 commit comments

Comments
 (0)