From 35d4071af70c868c5e36dc4f25a23bcaea5893fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 23 Sep 2026 12:17:04 +0200 Subject: [PATCH] fix(scroll): answer with the movement a directional scroll observed --- .../src/platform-runtime-operations.ts | 18 +- src/daemon/__tests__/scroll-runtime.test.ts | 289 +++++++++++++++++- src/daemon/generic-runtime-execution.ts | 5 + src/daemon/scroll-runtime.ts | 156 ++++++++-- 4 files changed, 427 insertions(+), 41 deletions(-) diff --git a/packages/contracts/src/platform-runtime-operations.ts b/packages/contracts/src/platform-runtime-operations.ts index 8ca18eabcc..3d6981294d 100644 --- a/packages/contracts/src/platform-runtime-operations.ts +++ b/packages/contracts/src/platform-runtime-operations.ts @@ -250,8 +250,22 @@ const gestureTargetAuthoredDragUse = defineUse({ */ export const gestureViewportRuntimeUse = defineUse({ required: ['gestureViewport'] }); -/** `scroll ` executes one pass and needs nothing else. */ -const scrollDirectionUse = defineUse({ required: ['scrollDirection'] }); +/** + * `scroll ` executes one pass, and the capture that lets it answer with the movement it + * observed (#2714) is CONDITIONAL — not `preferred`, not `required` (ADR 0019 §2). Not preferred: + * the observation is what makes the direction tier's answer honest, which is correctness rather + * than a faster path, and §2 reserves `preferred` for optimizations that never carry correctness. + * Not required: a runtime that cannot read a screen still scrolls, and `movement: 'unobserved'` — + * or no movement field at all — is the answer it owes. + * + * Both sides are complete. An owner that can capture answers with the movement it saw; an owner that + * cannot answers exactly the response it answered before, distance and all. `scroll-runtime.test.ts` + * pins one case per side of that parity. + */ +const scrollDirectionUse = defineUse({ + required: ['scrollDirection'], + conditional: ['captureSnapshot'], +}); /** * Every scroll that verifies between passes: `scroll top`/`scroll bottom` read hidden content at * the edge, and `scroll --until ` re-reads the tree to decide whether the target came diff --git a/src/daemon/__tests__/scroll-runtime.test.ts b/src/daemon/__tests__/scroll-runtime.test.ts index be7e1e63c2..121ebcd45e 100644 --- a/src/daemon/__tests__/scroll-runtime.test.ts +++ b/src/daemon/__tests__/scroll-runtime.test.ts @@ -1,6 +1,12 @@ import { expect, expectTypeOf, test } from 'vitest'; import assert from 'node:assert/strict'; +import { buildSnapshotState } from '@agent-device/capture-kit/snapshot-state'; import { AppError } from '@agent-device/kernel/errors'; +import type { CommandFlags } from '@agent-device/contracts/command'; +import type { DaemonRequest } from '../daemon-request.ts'; +import type { SessionState } from '../session-state.ts'; +import type { GenericPlatformExecutionParams } from '../request-generic-dispatch.ts'; +import type { SnapshotNode } from '@agent-device/kernel/snapshot'; import type { BoundDeviceRuntime, RuntimeFacts } from '@agent-device/contracts/platform-runtime'; import { type PlatformRuntimeOperations, @@ -12,6 +18,8 @@ import { resolveBoundScrollRuntime } from '../scroll-runtime.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; import { unavailableDeploymentSnapshotAndShutdownOperationFacts } from '../../__tests__/test-utils/runtime-operation-facts.ts'; import { IOS_SIMULATOR } from '../../__tests__/test-utils/device-fixtures.ts'; +import { makeIosSession, makeMacOsSession } from '../../__tests__/test-utils/session-factories.ts'; +import { activateCompleteRefFrame, expireRefFrame } from '../ref-frame.ts'; /** * The retired `handleScrollCommand` suite, re-pointed at the bound runtime (R43). Every @@ -25,7 +33,10 @@ type ScrollCall = { direction: string; options: unknown }; function bindings(options: { scroll: (direction: string, scrollOptions: unknown) => Promise | void>; - captureSnapshot?: (input: { options?: { scope?: string } }) => Promise; + captureSnapshot?: (input: { + options?: Record; + execution?: unknown; + }) => Promise; }): { inspectFacts: InspectDeviceRuntimeFacts; bindDevice: BindDeviceRuntime } { const available = { available: true } as const; const facts = { @@ -54,20 +65,62 @@ async function runScroll( positionals: string[], context: Partial, options: Parameters[0], + dispatch?: { + session?: SessionState; + flags?: CommandFlags; + }, ): Promise> { + const session = dispatch?.session ?? makeIosSession('scroll-runtime'); + const request: DaemonRequest = + dispatch?.flags === undefined + ? { command: 'scroll', session: session.name, token: 'test-token', positionals } + : { + command: 'scroll', + session: session.name, + token: 'test-token', + positionals, + flags: dispatch.flags, + }; const resolved = await resolveBoundScrollRuntime({ - device: IOS_SIMULATOR, + device: session.device, positionals, context: context as DaemonCommandContext, + session, + flags: dispatch?.flags, ...bindings(options), }); if (!resolved.ok) throw new AppError('UNSUPPORTED_OPERATION', 'admission refused the scroll'); - const data = await resolved.execute({ + // The dispatcher expires the ref frame at its side-effect seam between resolution and execution + // (ADR 0014). Running the same transition here keeps a test honest about WHICH of the two moments + // a scroll's own evidence has to be read at. + expireRefFrame(session); + const params: GenericPlatformExecutionParams = { + session, + sessionName: session.name, + logPath: '/tmp/agent-device-scroll-runtime-test.log', + command: 'scroll', + request, + positionals, + out: undefined, dispatchContext: context as DaemonCommandContext, - } as Parameters[0]); + }; + const data = await resolved.execute(params); return (data ?? {}) as Record; } +/** A session whose stored tree IS the newest observation — what a `snapshot` leaves behind. */ +function sessionWithStoredScreen( + nodes: SnapshotNode[], + overrides?: Partial, +): SessionState { + const session = makeIosSession('scroll-runtime', { + snapshot: buildSnapshotState({ nodes, backend: 'xctest', producer: 'apple-runner' }, undefined), + ...overrides, + }); + activateCompleteRefFrame(session); + return session; +} + test('bound scroll rejects mixing amount and --pixels', async () => { await assert.rejects( () => @@ -150,8 +203,10 @@ test('bound scroll bottom refuses at admission when the owner declares no captur const calls: ScrollCall[] = []; const resolved = await resolveBoundScrollRuntime({ device: IOS_SIMULATOR, + session: makeIosSession('scroll-runtime'), positionals: ['bottom'], context: {} as DaemonCommandContext, + flags: undefined, ...bindings({ scroll: async (direction, options) => { calls.push({ direction, options }); @@ -306,12 +361,16 @@ function makeScrollSnapshot(options: { hiddenBelow: boolean; message: string }) /** * R53 type-level regression. The two scroll plans must project DIFFERENT bindings: an edge scroll - * proves `captureSnapshot` statically, and an ordinary scroll must not be able to name it at all. + * proves `captureSnapshot` statically, and an ordinary scroll may hold one but can never require it. * * This is the property a runtime `if (!captureSnapshot) throw` guard silently gave up — the guard * type-checks against a widened binding, so the compiler stops enforcing what admission proved. + * #2714 made the direction plan's capture a declared PREFERENCE rather than a widening: the owner + * observes its own effect when the runtime can read the screen, and says `movement: 'unobserved'` + * when it cannot. `RequiredKeys` is what keeps that distinction honest — a direction use that ever + * grew a required capture stops type-checking here, and the owner would owe a refusal instead. */ -test('the edge plan proves its capture statically and the direction plan cannot expose one', () => { +test('the edge plan proves its capture statically and the direction plan cannot require one', () => { const direction = resolveScrollRuntimePlan({}); const edge = resolveScrollRuntimePlan({ edge: 'bottom' }); @@ -319,8 +378,12 @@ test('the edge plan proves its capture statically and the direction plan cannot expect(direction.kind).toBe('direction'); expect(edge).toMatchObject({ kind: 'edge', edge: 'bottom' }); - // Structural: the required sets differ, and only the edge use names the capture. + // Structural: the required sets differ, and only the edge use names the capture. The direction + // use names it CONDITIONALLY instead — the observation that makes its answer honest is + // correctness-bearing, which ADR 0019 §2 keeps out of `preferred`, while an owner with no capture + // still answers the way it answered before. Both sides of that parity are pinned below. expect([...direction.use.required]).toEqual(['scrollDirection']); + expect([...(direction.use.conditional ?? [])]).toEqual(['captureSnapshot']); expect([...edge.use.required]).toEqual(['scrollDirection', 'captureSnapshot']); type DirectionOperations = BoundDeviceRuntime< @@ -337,8 +400,9 @@ test('the edge plan proves its capture statically and the direction plan cannot expectTypeOf>().toEqualTypeOf< 'scrollDirection' | 'captureSnapshot' >(); - // The ordinary binding cannot even name a capture — absent, not merely optional. - expectTypeOf().toEqualTypeOf<'scrollDirection'>(); + // The ordinary binding can name a capture but never promises one: the key is present, and + // optional, which is exactly the disclosure the `movement` field reports instead of a refusal. + expectTypeOf().toEqualTypeOf<'scrollDirection' | 'captureSnapshot'>(); expectTypeOf>().toEqualTypeOf<'scrollDirection'>(); }); @@ -346,6 +410,7 @@ test('the edge plan proves its capture statically and the direction plan cannot function untilNodes(targetY: number, hiddenBelow: boolean) { return [ { + ref: 'e1', index: 1, depth: 0, type: 'ScrollView', @@ -411,9 +476,215 @@ test('bound scroll rejects --until on an edge direction before any device work', test('bound scroll --until is refused at admission when the owner declares no capture', async () => { const resolved = await resolveBoundScrollRuntime({ device: IOS_SIMULATOR, + session: makeIosSession('scroll-runtime'), positionals: ['down'], context: { until: 'label=Email' } as DaemonCommandContext, + flags: undefined, ...bindings({ scroll: async () => ({}) }), }); assert.equal(resolved.ok, false); }); + +/** + * #2714: a directional scroll answers with the movement it OBSERVED, and refuses to spend a + * distance it did not earn. The observation math lives in `scroll-movement.test.ts`; these cases + * prove the claim reaches the command, and that it stays out of every path that was never entitled + * to read the screen — a runtime without a capture, a Maestro replay, a `--settle` caller, the macOS + * desktop — or that the evidence for it was never there. + */ +const SCREEN_CONTAINER = { x: 18, y: 178, width: 366, height: 662 }; + +function automationScreen(rowOffset: number, hiddenBelow = true): SnapshotNode[] { + return [ + { + ref: 'e1', + index: 1, + depth: 0, + type: 'ScrollView', + identifier: 'lab-list', + rect: SCREEN_CONTAINER, + ...(hiddenBelow ? { hiddenContentBelow: true } : {}), + }, + { + ref: 'e2', + index: 2, + parentIndex: 1, + depth: 1, + type: 'StaticText', + label: 'Row one', + rect: { x: 24, y: 200 + rowOffset, width: 300, height: 20 }, + }, + { + ref: 'e3', + index: 3, + parentIndex: 1, + depth: 1, + type: 'StaticText', + label: 'Row two', + rect: { x: 24, y: 260 + rowOffset, width: 300, height: 20 }, + }, + ]; +} + +/** The swipe the platform reports back, whose midpoint sits inside the container above. */ +const OBSERVED_SWIPE = { x1: 201, y1: 600, x2: 201, y2: 250, pixels: 656, durationMs: 250 }; + +function frozenCaptures(frames: SnapshotNode[][]) { + const queue = [...frames]; + let calls = 0; + return { + calls: () => calls, + captureSnapshot: async () => { + const next = queue.shift(); + if (next === undefined) + throw new Error('scroll observed more captures than the case provided'); + calls += 1; + return { nodes: next, backend: 'xctest', producer: 'apple-runner' }; + }, + }; +} + +/** + * The scroll of a session that just captured this screen and has moved nothing since: the one + * situation in which the stored tree really is the pre-gesture surface. + */ +function scrollOverStoredScreen(options: { + frames: SnapshotNode[][]; + baseline?: SnapshotNode[]; + flags?: CommandFlags; + session?: SessionState; + positionals?: string[]; +}) { + const captures = frozenCaptures(options.frames); + const scroll = runScroll( + options.positionals ?? ['down', '0.75'], + {}, + { ...captures, scroll: async () => ({ ...OBSERVED_SWIPE }) }, + { + session: options.session ?? sessionWithStoredScreen(options.baseline ?? automationScreen(0)), + ...(options.flags === undefined ? {} : { flags: options.flags }), + }, + ); + return { scroll, captures }; +} + +test('bound scroll refuses the distance when the screen it read never moved', async () => { + const { scroll } = scrollOverStoredScreen({ frames: [automationScreen(0), automationScreen(0)] }); + + await assert.rejects( + () => scroll, + (error: unknown) => + error instanceof AppError && + error.details?.reason === 'scroll_no_progress' && + error.details.direction === 'down' && + error.details.hiddenContentAt === 'bottom', + ); +}); + +test('bound scroll keeps its distance and says the surface moved when it did', async () => { + const { scroll, captures } = scrollOverStoredScreen({ + frames: [automationScreen(-320)], + }); + const result = await scroll; + + assert.equal(result.movement, 'moved'); + assert.match(String(result.message), /Scrolled down by 0\.75 of the viewport \(656px\)/); + assert.equal(captures.calls(), 1); +}); + +test('bound scroll stops claiming a movement its runtime cannot read', async () => { + const captures = frozenCaptures([automationScreen(0), automationScreen(0)]); + const result = await runScroll( + ['down', '0.75'], + {}, + // The same case WITHOUT the capture: this runtime cannot read a screen, so the answer carries + // no movement claim at all rather than one it could not have made. + { scroll: async () => ({ ...OBSERVED_SWIPE }) }, + { session: sessionWithStoredScreen(automationScreen(0)) }, + ); + + assert.equal('movement' in result, false); + assert.equal(captures.calls(), 0); +}); + +test('bound scroll reads no screen for a Maestro replay that asked for no stabilization', async () => { + const { scroll, captures } = scrollOverStoredScreen({ + frames: [automationScreen(0), automationScreen(0)], + flags: { postGestureStabilization: false }, + }); + const result = await scroll; + + assert.equal('movement' in result, false); + assert.equal(captures.calls(), 0); +}); + +test('bound scroll spends no capture on a movement another observer already owns', async () => { + const { scroll, captures } = scrollOverStoredScreen({ + frames: [automationScreen(0), automationScreen(0)], + flags: { settle: true }, + }); + const result = await scroll; + + assert.equal('movement' in result, false); + assert.equal(captures.calls(), 0); +}); + +test('bound scroll on the macOS desktop reads no screen to confirm a scroll', async () => { + const captures = frozenCaptures([automationScreen(0), automationScreen(0)]); + const session = makeMacOsSession('scroll-desktop'); + session.snapshot = buildSnapshotState( + { nodes: automationScreen(0), backend: 'xctest', producer: 'apple-runner' }, + undefined, + ); + activateCompleteRefFrame(session); + + const result = await runScroll( + ['down', '0.75'], + {}, + { ...captures, scroll: async () => ({ ...OBSERVED_SWIPE }) }, + { session }, + ); + + assert.equal('movement' in result, false); + assert.equal(captures.calls(), 0); +}); + +/** + * The pair to the refusal above, and the reason the refusal is allowed to exist: a tree that + * predates a device side effect says nothing about what THIS gesture did. The session's ref frame + * is the existing owner of that answer (ADR 0014), so a scroll after a mutation says it observed + * nothing rather than crediting the swipe with whatever the earlier command changed. + */ +test('bound scroll will not read movement off a tree that predates the last mutation', async () => { + const captures = frozenCaptures([automationScreen(-320)]); + const session = sessionWithStoredScreen(automationScreen(0)); + // Whatever the previous command was, it crossed the side-effect seam before this scroll resolved. + expireRefFrame(session); + + const result = await runScroll( + ['down', '0.75'], + {}, + { ...captures, scroll: async () => ({ ...OBSERVED_SWIPE }) }, + { session }, + ); + + assert.equal(result.movement, 'unobserved'); + assert.equal(captures.calls(), 0); +}); + +test('bound scroll will not bill an unread earlier gesture to the scroll it is dispatching', async () => { + const captures = frozenCaptures([automationScreen(-320)]); + const session = sessionWithStoredScreen(automationScreen(0), { + postGestureStabilization: { action: 'press', positionals: [], markedAt: Date.now() }, + }); + + const result = await runScroll( + ['down', '0.75'], + {}, + { ...captures, scroll: async () => ({ ...OBSERVED_SWIPE }) }, + { session }, + ); + + assert.equal(result.movement, 'unobserved'); + assert.equal(captures.calls(), 0); +}); diff --git a/src/daemon/generic-runtime-execution.ts b/src/daemon/generic-runtime-execution.ts index c0b631b467..cdd440102f 100644 --- a/src/daemon/generic-runtime-execution.ts +++ b/src/daemon/generic-runtime-execution.ts @@ -55,6 +55,11 @@ export async function resolveGenericRuntimeExecution( device: params.session.device, positionals: params.req.positionals ?? [], context: params.context, + // The scroll's own observation is decided here rather than in its execution closure: this is + // the last moment the session's stored tree is still the newest observation, before the + // dispatcher's ADR 0014 side-effect seam. + session: params.session, + flags: params.req.flags, inspectFacts: params.inspectFacts, bindDevice: params.bindDevice, }); diff --git a/src/daemon/scroll-runtime.ts b/src/daemon/scroll-runtime.ts index 601df69497..9645097f73 100644 --- a/src/daemon/scroll-runtime.ts +++ b/src/daemon/scroll-runtime.ts @@ -2,11 +2,13 @@ import { assertExclusiveScrollDistanceInputs, assertScrollUntilCompatible, honoredScrollDurationMs, + honoredScrollSwipeMidpoint, honoredScrollPixels, normalizeScrollDurationMs, resolveScrollExecutionOptions, type ResolvedScrollExecutionOptions, type ScrollCommandOptions, + type ScrollMovementObservation, } from '@agent-device/contracts/scroll-command'; import { parseScrollDirection, type ScrollDirection } from '@agent-device/contracts/scroll-gesture'; import { @@ -28,11 +30,22 @@ import { } from '@agent-device/capture-kit/scroll-edge-state'; import { formatScrollUntilMessage, runScrollUntilVisible } from './scroll-until.ts'; import { publicPlatformString } from '@agent-device/kernel/device'; +import type { CommandFlags } from '@agent-device/contracts/command'; import { withSuccessText } from '@agent-device/kernel/success-text'; import type { DaemonCommandContext } from './context.ts'; +import type { SessionState } from './session-state.ts'; import type { ResolvedGenericExecution } from './request-generic-dispatch.ts'; import { resolveBoundGenericRuntime, type RuntimeAdmissionBindings } from './runtime-admission.ts'; +import { + observeScrollMovement, + planScrollMovement, + reportScrollMovementNotApplicable, + reportScrollMovementUnobserved, + type ScrollMovementPlan, + type ScrollSwipeEvidence, +} from './scroll-movement.ts'; import { runtimeExecutionFromContext } from './snapshot-runtime-capture-input.ts'; +import type { CaptureSnapshotInput } from '@agent-device/contracts/snapshot-runtime'; import { errorResponse } from '@agent-device/kernel/contracts'; type ScrollTarget = Readonly<{ @@ -83,6 +96,12 @@ export async function resolveBoundScrollRuntime( device: DeviceInfo; positionals: readonly string[]; context: DaemonCommandContext; + /** + * The live session and the caller's flags, both read before the dispatcher's side-effect seam can + * expire the stored tree this command compares its own gesture against. + */ + session: SessionState; + flags: CommandFlags | undefined; } & RuntimeAdmissionBindings, ): Promise { const directionInput = params.positionals[0]; @@ -108,12 +127,21 @@ export async function resolveBoundScrollRuntime( bindDevice: params.bindDevice, }; switch (plan.kind) { - case 'direction': + case 'direction': { + // Decided before the gesture, because this command's own scroll is what invalidates the tree it + // compares against: the dispatcher expires the session's ref frame at its ADR 0014 side-effect + // seam, and resolvers run before that seam. The same pre-effect freeze #1638's settle plan makes. + const movementPlan = planScrollMovement({ + device: params.device, + flags: params.flags, + session: params.session, + }); return await resolveBoundGenericRuntime( { ...admission, use: plan.use }, async (runtime, dispatchContext) => - await executeDirectionScroll(runtime, target, options, dispatchContext), + await executeDirectionScroll(runtime, target, options, dispatchContext, movementPlan), ); + } case 'edge': { const edge = plan.edge; return await resolveBoundGenericRuntime( @@ -171,19 +199,96 @@ function scrollCaptureUnsupported(subject: string, hint: string | undefined) { ); } -/** One pass. This binding carries no capture, so an edge-style read will not type-check here. */ +/** + * One pass, and the observation that decides whether this response may name a distance (#2714). + * + * The baseline is the tree the session already holds, so a scroll that works costs one capture and + * answers on it; only a surface that looks untouched keeps polling. Whether the read is owed at all was + * already answered at resolve time by `planScrollMovement`; the one fact left is whether this binding + * carries a capture. + */ async function executeDirectionScroll( runtime: BoundScrollDirection, target: ScrollTarget, options: ResolvedScrollExecutionOptions, context: DaemonCommandContext, + movementPlan: ScrollMovementPlan, ): Promise> { - return scrollResult( - target, - options, - 1, - (await scrollOnce(runtime, target, options, context)) ?? {}, + const interactionResult = (await scrollOnce(runtime, target, options, context)) ?? {}; + const movement = await directionalMovementClaim( + runtime, + target.direction, + context, + movementPlan, + interactionResult, ); + return scrollResult(target, options, 1, interactionResult, movement); +} + +/** + * The movement this response may claim, or `undefined` when this command owed no observation of its own + * effect at all. An absent `movement` field is a claim of its own, so every skip names a typed reason in + * the daemon log; a runtime bound without a capture answers that way rather than `unobserved`, which + * keeps its response exactly what it was before this observation existed. + */ +async function directionalMovementClaim( + runtime: BoundScrollDirection, + direction: ScrollDirection, + context: DaemonCommandContext, + movementPlan: ScrollMovementPlan, + interactionResult: Record, +): Promise { + if (movementPlan.kind === 'declined') { + reportScrollMovementNotApplicable(direction, movementPlan.reason); + return undefined; + } + const capture = runtime.operations.captureSnapshot; + if (!capture) { + reportScrollMovementNotApplicable(direction, 'owner-without-capture'); + return undefined; + } + if (movementPlan.kind === 'unobservable') { + // The read was owed and the evidence was not there. The answer says so rather than resting the + // distance on a tree that predates something this command cannot see. + return reportScrollMovementUnobserved(direction, movementPlan.reason, { + swipe: swipeEvidence(interactionResult), + }); + } + return await observeScrollMovement({ + direction, + baseline: movementPlan.baseline, + swipe: swipeEvidence(interactionResult), + capture: async () => await capture(scrollCaptureInput(context)), + }); +} + +/** + * The capture intent every scroll read shares: the session's app and the request's execution metadata, + * and deliberately none of the caller's snapshot flags — a stop condition or a movement claim has to be + * decided on the tree the platform answers by default, so `snapshot -i` on the same request cannot + * change what this command reads. + */ +function scrollCaptureInput( + context: DaemonCommandContext, + scoped?: { scope: string | undefined }, +): CaptureSnapshotInput { + return { + options: { + ...(context.appBundleId === undefined ? {} : { appBundleId: context.appBundleId }), + ...scoped, + }, + execution: runtimeExecutionFromContext(context), + }; +} + +/** What the leaf reported about the gesture it ran: where it ran, and how far it got. */ +function swipeEvidence(result: Record): ScrollSwipeEvidence { + const midpoint = honoredScrollSwipeMidpoint(result); + const pixels = honoredScrollPixels(result); + return { + ...(midpoint === undefined ? {} : { midpoint }), + ...(pixels === undefined ? {} : { pixels }), + }; } /** Repeats the pass while the verified state still moves; the capture needs no guard here. */ @@ -208,15 +313,8 @@ async function executeEdgeScroll( settleAfterPass: async () => { await pollForScrollRest( async () => - ( - await runtime.operations.captureSnapshot({ - options: { - ...(context.appBundleId === undefined ? {} : { appBundleId: context.appBundleId }), - scope, - }, - execution: runtimeExecutionFromContext(context), - }) - ).nodes ?? [], + (await runtime.operations.captureSnapshot(scrollCaptureInput(context, { scope }))) + .nodes ?? [], edge, ); }, @@ -237,11 +335,7 @@ async function executeUntilScroll( selector, direction: target.direction, platform: publicPlatformString(device), - capture: async () => - await runtime.operations.captureSnapshot({ - options: context.appBundleId === undefined ? {} : { appBundleId: context.appBundleId }, - execution: runtimeExecutionFromContext(context), - }), + capture: async () => await runtime.operations.captureSnapshot(scrollCaptureInput(context)), scroll: async () => await scrollOnce(runtime, target, options, context), }); return withSuccessText( @@ -268,13 +362,9 @@ async function captureEdgeState( scope, captureNodes: async (snapshotScope) => ( - await runtime.operations.captureSnapshot({ - options: { - ...(context.appBundleId === undefined ? {} : { appBundleId: context.appBundleId }), - scope: snapshotScope, - }, - execution: runtimeExecutionFromContext(context), - }) + await runtime.operations.captureSnapshot( + scrollCaptureInput(context, { scope: snapshotScope }), + ) ).nodes ?? [], }); } @@ -321,8 +411,10 @@ function scrollResult( options: ScrollCommandOptions, completedPasses: number, interactionResult: Record, + movement?: ScrollMovementObservation, ): Record { const durationMs = honoredScrollDurationMs(interactionResult); + const honoredPixels = honoredScrollPixels(interactionResult); return withSuccessText( { direction: target.direction, @@ -331,6 +423,9 @@ function scrollResult( ...(options.pixels !== undefined ? { pixels: options.pixels } : {}), ...(durationMs !== undefined ? { durationMs } : {}), ...interactionResult, + // The observation is this command's own claim about its effect, so it is the one field the + // owner answers with rather than echoes from the platform leaf. + ...(movement === undefined ? {} : { movement }), }, formatScrollEdgeMessage({ direction: target.direction, @@ -338,7 +433,8 @@ function scrollResult( passes: completedPasses, amount: options.amount, pixels: options.pixels, - honoredPixels: honoredScrollPixels(interactionResult), + honoredPixels, + ...(movement === undefined ? {} : { movement }), }), ); }