diff --git a/packages/contracts/src/interaction-guarantees.ts b/packages/contracts/src/interaction-guarantees.ts index 731cd1c55c..ab8b8689b4 100644 --- a/packages/contracts/src/interaction-guarantees.ts +++ b/packages/contracts/src/interaction-guarantees.ts @@ -166,7 +166,7 @@ const SHARED_RESPONSE_CONSTRUCTION: GuaranteeEnforcement = { const TAP_OUTCOME_NOT_OBSERVED_GAP: GuaranteeEnforcement = { kind: 'waived', reason: - '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.', + '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.', trackingIssue: TAP_OUTCOME_NOT_OBSERVED_GAP_ISSUE, }; diff --git a/src/daemon/__tests__/deferred-interaction-outcome.test.ts b/src/daemon/__tests__/deferred-interaction-outcome.test.ts index 0526a37eef..ee6b02f0cb 100644 --- a/src/daemon/__tests__/deferred-interaction-outcome.test.ts +++ b/src/daemon/__tests__/deferred-interaction-outcome.test.ts @@ -109,18 +109,25 @@ test('an Apple open that could not observe its app marks stabilization without a assert.equal(session.postGestureStabilization?.baselineSignature, undefined); }); -test('only an open that saw the launch still in flight marks stabilization', () => { - const quiet = ( - ['observable', 'probe-failed', 'app-unidentified', 'not-eligible', undefined] as const +test('every open verdict other than observable marks stabilization', () => { + const pending = ( + [ + 'unobservable', + 'probe-failed', + 'app-unidentified', + 'not-eligible', + 'observable', + undefined, + ] as const ).map((observation) => { const session = makeSession('ios'); markPostOpenStabilization(session, observation); return isPostGestureStabilizationPending(session); }); const android = makeSession('android'); - markPostOpenStabilization(android, 'unobservable'); + markPostOpenStabilization(android, 'probe-failed'); - assert.deepEqual(quiet, [false, false, false, false, false]); + assert.deepEqual(pending, [true, true, true, true, false, false]); assert.equal(isPostGestureStabilizationPending(android), false); }); diff --git a/src/daemon/deferred-interaction-outcome.ts b/src/daemon/deferred-interaction-outcome.ts index f3799c8f11..d7084cee34 100644 --- a/src/daemon/deferred-interaction-outcome.ts +++ b/src/daemon/deferred-interaction-outcome.ts @@ -109,10 +109,12 @@ function markPostGestureStabilization( } /** - * Called once an app open has published its session. `unobservable` means the launch was still in - * flight or no app process was found when the open returned, so the next capture runs the - * quiet-window loop before a selector resolves against a layout that may still be moving (#3354). No baseline: the pre-open tree is a - * different surface, so the open is never reported as having no effect. + * Called once an app open has published its session. A verdict other than `observable` means the + * open never read the launched app: the probe failed or could not identify it, the device has no + * observation path, or the app stayed unreadable. Nothing proved its layout at rest, so the next + * capture runs the quiet-window loop (#3354, #3367). An open that reports no verdict does not mark. + * No baseline: the pre-open tree is a different surface, so the open is never reported as having no + * effect. */ export function markPostOpenStabilization( session: SessionState, @@ -120,7 +122,7 @@ export function markPostOpenStabilization( ): void { if (!isApplePlatform(session.device.platform)) return; if (!supportsPostGestureStabilization(session.device)) return; - if (observation !== 'unobservable') return; + if (observation === undefined || observation === 'observable') return; session.postGestureStabilization = { action: 'open', positionals: [], markedAt: Date.now() }; } diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts index da6ac1150f..df14d19968 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts @@ -500,6 +500,12 @@ test('a fresh iOS open that could not observe its app leaves the next capture to expect(session?.postGestureStabilization?.action).toBe('open'); }); +test('a fresh iOS open whose observation probe failed leaves the next capture to settle', async () => { + const session = await openReportingObservation('probe-failed-open', 'probe-failed'); + + expect(session?.postGestureStabilization?.action).toBe('open'); +}); + test('a fresh iOS open that observed its app leaves nothing pending', async () => { const session = await openReportingObservation('observed-open', 'observable'); diff --git a/test/integration/interaction-contract/daemon-harness.ts b/test/integration/interaction-contract/daemon-harness.ts index 5af28d5ff8..c1d6e2eeb3 100644 --- a/test/integration/interaction-contract/daemon-harness.ts +++ b/test/integration/interaction-contract/daemon-harness.ts @@ -19,6 +19,16 @@ import { export const CONTRACT_APP = 'com.example.app'; const CONTRACT_DEVICE_ID = PROVIDER_SCENARIO_IOS_SIMULATOR.id; +const SETTLING_CAPTURE_READS = 2; +const SETTLING_CAPTURE_NODES = [ + { + index: 0, + type: 'Application', + label: 'Example', + rect: { x: 0, y: 0, width: 400, height: 800 }, + }, +]; + /** * Provider-transcript harness for contract scenarios whose path involves the * iOS runner (maestro-non-hittable-fallback) or that @@ -32,7 +42,25 @@ export async function withIosContractDaemon( run: (daemon: ProviderScenarioHarness, transcript: ProviderScenarioTranscript) => Promise, options: { saveScript?: boolean | string } = {}, ): Promise { - const transcript = createProviderTranscript(entries); + // The scripted open is not read back, so the first capture after it settles over a quiet pair of + // reads and pauses the direct selector path. A snapshot consumes that pair here, keeping each + // scenario's own conversation with the runner exactly as scripted. + const transcript = createProviderTranscript([ + ...Array.from({ length: SETTLING_CAPTURE_READS }, () => + runnerSnapshotEntry(SETTLING_CAPTURE_NODES), + ), + ...entries, + ]); + const scenarioTranscript: ProviderScenarioTranscript = { + get calls() { + return transcript.calls.slice(SETTLING_CAPTURE_READS); + }, + get remaining() { + return transcript.remaining; + }, + assertComplete: () => transcript.assertComplete(), + next: (command, request, scope) => transcript.next(command, request, scope), + }; const appleRunnerProvider = createAppleRunnerProviderFromTranscript(transcript, 'ios.runner'); const appleTool = createRecordingAppleToolProvider({ simctl: simctlDeviceLifecycleHandler('com.apple.CoreSimulator.SimRuntime.iOS-18-0', [ @@ -54,7 +82,8 @@ export async function withIosContractDaemon( ...(options.saveScript !== undefined ? { saveScript: options.saveScript } : {}), }); assertRpcOk(open); - await run(daemon, transcript); + assertRpcOk(await daemon.callCommand('snapshot')); + await run(daemon, scenarioTranscript); transcript.assertComplete(); }, ); diff --git a/test/integration/provider-scenarios/interaction-direct-selector-fallback.test.ts b/test/integration/provider-scenarios/interaction-direct-selector-fallback.test.ts index a39f65e80d..7b158c2696 100644 --- a/test/integration/provider-scenarios/interaction-direct-selector-fallback.test.ts +++ b/test/integration/provider-scenarios/interaction-direct-selector-fallback.test.ts @@ -46,6 +46,17 @@ function snapshotEntry(nodes: unknown[]): ProviderScenarioProviderEntry { }; } +// The scripted open is not read back, so the first capture after it settles over a quiet pair of +// reads and the direct tap path stays paused until that capture has run. +const SETTLING_CAPTURE_ENTRIES = [ + snapshotEntry([APPLICATION_NODE]), + snapshotEntry([APPLICATION_NODE]), +]; + +async function settleAfterOpen(daemon: ProviderScenarioHarness): Promise { + assertRpcOk(await daemon.callCommand('snapshot')); +} + async function withDirectSelectorScenario( transcript: ProviderScenarioTranscript, run: (daemon: ProviderScenarioHarness) => Promise, @@ -77,19 +88,18 @@ async function withDirectSelectorScenario( } test('Provider-backed iOS selector wait captures directly and strips selectorChain', async () => { - const transcript = createProviderTranscript([ - snapshotEntry([ - APPLICATION_NODE, - { - index: 1, - parentIndex: 0, - type: 'Button', - label: 'Continue', - hittable: true, - rect: { x: 100, y: 300, width: 200, height: 44 }, - }, - ]), + const continueSnapshot = snapshotEntry([ + APPLICATION_NODE, + { + index: 1, + parentIndex: 0, + type: 'Button', + label: 'Continue', + hittable: true, + rect: { x: 100, y: 300, width: 200, height: 44 }, + }, ]); + const transcript = createProviderTranscript([continueSnapshot, continueSnapshot]); await withDirectSelectorScenario(transcript, async (daemon) => { const wait = await daemon.callCommand('wait', ['label="Continue"']); @@ -100,18 +110,20 @@ test('Provider-backed iOS selector wait captures directly and strips selectorCha }); test('Provider-backed iOS selector wait polls a fresh capture after a selector miss', async () => { + const loadingSnapshot = snapshotEntry([ + APPLICATION_NODE, + { + index: 1, + parentIndex: 0, + type: 'Button', + label: 'Loading', + hittable: true, + rect: { x: 100, y: 300, width: 200, height: 44 }, + }, + ]); const transcript = createProviderTranscript([ - snapshotEntry([ - APPLICATION_NODE, - { - index: 1, - parentIndex: 0, - type: 'Button', - label: 'Loading', - hittable: true, - rect: { x: 100, y: 300, width: 200, height: 44 }, - }, - ]), + loadingSnapshot, + loadingSnapshot, snapshotEntry([ APPLICATION_NODE, { @@ -135,6 +147,7 @@ test('Provider-backed iOS selector wait polls a fresh capture after a selector m test('Provider-backed integration maestro replay dispatch keeps runner AMBIGUOUS_MATCH without fallback', async () => { const transcript = createProviderTranscript([ + ...SETTLING_CAPTURE_ENTRIES, { command: 'ios.runner.tap', deviceId: DEVICE_ID, @@ -154,6 +167,7 @@ test('Provider-backed integration maestro replay dispatch keeps runner AMBIGUOUS ]); await withDirectSelectorScenario(transcript, async (daemon) => { + await settleAfterOpen(daemon); const click = await daemon.callCommand('click', ['label="Continue"'], { maestro: { allowNonHittableCoordinateFallback: true }, }); @@ -165,6 +179,7 @@ test( 'Provider-backed integration maestro replay dispatch keeps runner ELEMENT_OFFSCREEN without fallback', async () => { const transcript = createProviderTranscript([ + ...SETTLING_CAPTURE_ENTRIES, { command: 'ios.runner.tap', deviceId: DEVICE_ID, @@ -182,6 +197,7 @@ test( ]); await withDirectSelectorScenario(transcript, async (daemon) => { + await settleAfterOpen(daemon); const click = await daemon.callCommand('click', ['label="Continue"'], { maestro: { allowNonHittableCoordinateFallback: true }, }); diff --git a/test/integration/provider-scenarios/interaction-verify.test.ts b/test/integration/provider-scenarios/interaction-verify.test.ts index 788c7db782..733fdd74be 100644 --- a/test/integration/provider-scenarios/interaction-verify.test.ts +++ b/test/integration/provider-scenarios/interaction-verify.test.ts @@ -57,7 +57,8 @@ function snapshotEntry(nodes: unknown[]): ProviderScenarioProviderEntry { test('Provider-backed integration press --verify returns post-action evidence digest', async () => { const runnerTranscript = createProviderTranscript([ - // snapshot -i to obtain refs + // snapshot -i to obtain refs; the first capture after open settles: one extra read + snapshotEntry(BEFORE_NODES), snapshotEntry(BEFORE_NODES), { command: 'ios.runner.tap', diff --git a/test/integration/provider-scenarios/press-target-readiness.test.ts b/test/integration/provider-scenarios/press-target-readiness.test.ts index 4fb0d34aff..78fbde08ff 100644 --- a/test/integration/provider-scenarios/press-target-readiness.test.ts +++ b/test/integration/provider-scenarios/press-target-readiness.test.ts @@ -73,7 +73,13 @@ async function withPressReadinessDaemon( transcript: ReturnType, ) => Promise, ): Promise { - const runnerTranscript = createProviderTranscript(entries); + // The scripted open is not read back, so the first capture after it settles over a quiet pair of + // reads. A snapshot consumes that pair here, keeping the scenario's own captures one-for-one. + const runnerTranscript = createProviderTranscript([ + snapshotEntry(APPLICATION_ONLY_NODES), + snapshotEntry(APPLICATION_ONLY_NODES), + ...entries, + ]); const appleRunnerProvider = createAppleRunnerProviderFromTranscript( runnerTranscript, 'ios.runner', @@ -94,6 +100,7 @@ async function withPressReadinessDaemon( async (daemon) => { const open = await daemon.callCommand('open', [APP], { platform: 'ios', udid: DEVICE_ID }); assertRpcOk(open); + assertRpcOk(await daemon.callCommand('snapshot')); await run(daemon, runnerTranscript); }, ); diff --git a/test/integration/provider-scenarios/scroll-movement-observation.test.ts b/test/integration/provider-scenarios/scroll-movement-observation.test.ts index 1a71568b22..7e059935f1 100644 --- a/test/integration/provider-scenarios/scroll-movement-observation.test.ts +++ b/test/integration/provider-scenarios/scroll-movement-observation.test.ts @@ -149,6 +149,7 @@ function iosSimulatorTool(): { provider: AppleToolProvider } { test('Provider-backed integration scroll down answers moved on the first post-gesture capture', async () => { const runnerTranscript = createProviderTranscript([ snapshotEntry(screen(0, true)), // pre-scroll baseline + snapshotEntry(screen(0, true)), // the first capture after open settles: one extra read scrollEntry(), snapshotEntry(screen(-100, true)), // post-gesture: rows shifted into view on the first read ]); diff --git a/test/integration/provider-scenarios/settle-observation.test.ts b/test/integration/provider-scenarios/settle-observation.test.ts index 9024c041b5..555faa56b7 100644 --- a/test/integration/provider-scenarios/settle-observation.test.ts +++ b/test/integration/provider-scenarios/settle-observation.test.ts @@ -174,7 +174,8 @@ function tapEntry(x: number, y: number): ProviderScenarioProviderEntry { test('Provider-backed integration press --settle returns the settled diff and fresh refs', async () => { const runnerTranscript = createProviderTranscript([ - // snapshot -i: issues refs + // snapshot -i: issues refs; the first capture after open settles: one extra read + snapshotEntry(BEFORE_NODES), snapshotEntry(BEFORE_NODES), // press label=Continue --settle: resolution capture, tap, settle captures snapshotEntry(BEFORE_NODES), @@ -296,8 +297,9 @@ test('Provider-backed integration never-settled press --settle does not issue di }, ]; const runnerTranscript = createProviderTranscript([ - // press label=Continue --settle: resolution capture, tap, then a settle - // stream that never repeats itself, so the loop can only ever time out. + // press label=Continue --settle: resolution capture (the first capture after open settles: one extra read), + // tap, then a settle stream that never repeats itself, so the loop can only ever time out. + snapshotEntry(BEFORE_NODES), snapshotEntry(BEFORE_NODES), tapEntry(200, 322), changingSnapshotEntry(loadingNodes), @@ -356,7 +358,8 @@ test('Provider-backed integration never-settled press --settle does not issue di test('Provider-backed integration modal-dismiss press --settle attaches the unchanged interactive tail', async () => { const runnerTranscript = createProviderTranscript([ - // snapshot -i: issues refs + // snapshot -i: issues refs; the first capture after open settles: one extra read + snapshotEntry(MODAL_BEFORE_NODES), snapshotEntry(MODAL_BEFORE_NODES), // press label=Cancel --settle: resolution capture, tap, settle captures. // The dialog closes leaving Continue in place — a removals-only diff with @@ -691,7 +694,8 @@ const FILL_SETTLED_NODES = [ test('Provider-backed integration fill --settle summoning the keyboard still attaches the unchanged interactive tail', async () => { const runnerTranscript = createProviderTranscript([ - // snapshot -i: issues refs + // snapshot -i: issues refs; the first capture after open settles: one extra read + snapshotEntry(FILL_BEFORE_NODES), snapshotEntry(FILL_BEFORE_NODES), // fill @e6 'hello' --settle: the ref resolves on the stored tree (no // fresh capture), the runner types, then the settle loop captures. The diff --git a/test/integration/provider-scenarios/stale-ref-warning.test.ts b/test/integration/provider-scenarios/stale-ref-warning.test.ts index ae19cd8070..6354a00cb5 100644 --- a/test/integration/provider-scenarios/stale-ref-warning.test.ts +++ b/test/integration/provider-scenarios/stale-ref-warning.test.ts @@ -82,7 +82,8 @@ function tapEntry(x: number, y: number): ProviderScenarioProviderEntry { test('Provider-backed integration iOS @refs reject after a selector press replaces the tree', async () => { const runnerTranscript = createProviderTranscript([ - // snapshot -i: issues refs to the client + // snapshot -i: issues refs to the client; the first capture after open settles: one extra read + snapshotEntry(), snapshotEntry(), // press label=Continue: selector resolution capture replaces the stored tree snapshotEntry(), @@ -149,7 +150,9 @@ test('Provider-backed integration iOS @refs reject after a selector press replac test('Provider-backed iOS press rejects a stale ref after navigation', async () => { const runnerTranscript = createProviderTranscript([ - // snapshot -i: @e3 is Verify on the magic-code screen. + // snapshot -i: @e3 is Verify on the magic-code screen; the first capture after open settles: + // one extra read. + snapshotEntry(MAGIC_CODE_NODES), snapshotEntry(MAGIC_CODE_NODES), // press label=Back: the pre-action capture still sees that screen. snapshotEntry(MAGIC_CODE_NODES), diff --git a/test/integration/provider-scenarios/versioned-refs.test.ts b/test/integration/provider-scenarios/versioned-refs.test.ts index 27a9d9f9b6..6049f9e700 100644 --- a/test/integration/provider-scenarios/versioned-refs.test.ts +++ b/test/integration/provider-scenarios/versioned-refs.test.ts @@ -66,7 +66,9 @@ function tapEntry(x: number, y: number): ProviderScenarioProviderEntry { test('Provider-backed integration rejects stale pinned @refs and accepts current pins', async () => { const runnerTranscript = createProviderTranscript([ - // snapshot -i: issues the complete frame at the seeded generation g1 + // snapshot -i: issues the complete frame at the seeded generation g1; the first capture after + // open settles: one extra read + snapshotEntry(), snapshotEntry(), // press @e2~s{g1}: pinned to the CURRENT frame — admitted, taps Cancel, // and crosses the seam so the frame expires diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 7947dac14d..15bfd7f9e1 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -93,7 +93,7 @@ agent-device fold open - Android: `shutdown --platform android --device ` stops a running emulator. - `open [app|url] [url]` boots and activates the selected target when needed. - `open --wait ` waits up to that budget for a device another session is holding instead of failing at once, then fails with `DEVICE_IN_USE` if the budget runs out. Only session contention is waited for: a device claim held by another workspace's daemon is never retriable and returns its recovery command immediately. The wait extends the command's timeout envelope, so a long budget does not need a longer `--timeout`. See [Sessions](/docs/sessions#wait-for-a-slow-or-busy-device) for queuing behavior. -- On an iOS Simulator, `open` watches the launch for a bounded time. When the app was still unreadable when that watch ended (`timing.postOpenObservation: "unobservable"` in the open response), the next command that reads the screen keeps reading until two reads in a row agree, within a short bounded wait, before it acts. A `click` right after such an open then targets where the element comes to rest, not where it was mid-launch. If the screen is still moving after that wait, the command proceeds and its response warns that the surface was still changing after `open`. Any other `postOpenObservation` value, including `probe-failed`, leaves the next command reading the screen once. +- On an iOS Simulator, an `open` that launches an app tries to read it before returning, and reports the result as `timing.postOpenObservation`. When that value is present and is not `"observable"`, the next command that reads the screen keeps reading until two reads in a row agree, within a short bounded wait, before it acts. A `click` right after such an open then targets where the element comes to rest, not where it was mid-launch. If the screen is still moving after that wait, the command proceeds and its response warns that the surface was still changing after `open`. After an `observable` open, or an open that reports no `postOpenObservation`, the next command reads the screen once. - `open ` deep links are supported on Android and iOS. - On an iOS Simulator, handing the URL to the device is one bounded `simctl openurl`: when CoreSimulator never answers it, the open fails within 20 seconds with `details.reason: ios-simulator-openurl-timeout` and kills the child, instead of the request being cancelled around a still-running `openurl`. The Simulator may still have received the URL, so retry the open or answer the prompt with `alert accept`. - `open ` opens a deep link on iOS.