Skip to content

Commit 43ebddd

Browse files
committed
fix: speed up and simplify scrollintoview @ref
1 parent 8eea063 commit 43ebddd

8 files changed

Lines changed: 90 additions & 125 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,8 +197,8 @@ Pinch:
197197
Swipe timing:
198198
- `swipe` accepts optional `durationMs` (default `250`, range `16..10000`).
199199
- Android uses requested swipe duration directly.
200-
- iOS uses a safe normalized duration to avoid longpress side effects.
201-
- `scrollintoview` accepts either plain text or a snapshot ref (`@eN`); ref mode uses geometry-based scrolling.
200+
- iOS clamps swipe duration to a safe range (`16..60ms`) to avoid longpress side effects.
201+
- `scrollintoview` accepts either plain text or a snapshot ref (`@eN`); ref mode uses best-effort geometry-based scrolling without post-scroll verification. Run `snapshot` again before follow-up `@ref` commands.
202202

203203
## Skills
204204
Install the automation skills listed in [SKILL.md](skills/agent-device/SKILL.md).

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ final class RunnerTests: XCTestCase {
4040
private let retryCooldown: TimeInterval = 0.2
4141
private let postSnapshotInteractionDelay: TimeInterval = 0.2
4242
private let firstInteractionAfterActivateDelay: TimeInterval = 0.25
43+
private let scrollInteractionIdleTimeoutDefault: TimeInterval = 1.0
4344
private let minRecordingFps = 1
4445
private let maxRecordingFps = 120
4546
private var needsPostSnapshotInteractionDelay = false
@@ -712,7 +713,9 @@ final class RunnerTests: XCTestCase {
712713
return Response(ok: false, error: ErrorPayload(message: "drag requires x, y, x2, and y2"))
713714
}
714715
let holdDuration = min(max((command.durationMs ?? 60) / 1000.0, 0.016), 10.0)
715-
dragAt(app: activeApp, x: x, y: y, x2: x2, y2: y2, holdDuration: holdDuration)
716+
withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
717+
dragAt(app: activeApp, x: x, y: y, x2: x2, y2: y2, holdDuration: holdDuration)
718+
}
716719
return Response(ok: true, data: DataPayload(message: "dragged"))
717720
case .dragSeries:
718721
guard let x = command.x, let y = command.y, let x2 = command.x2, let y2 = command.y2 else {
@@ -725,12 +728,14 @@ final class RunnerTests: XCTestCase {
725728
return Response(ok: false, error: ErrorPayload(message: "dragSeries pattern must be one-way or ping-pong"))
726729
}
727730
let holdDuration = min(max((command.durationMs ?? 60) / 1000.0, 0.016), 10.0)
728-
runSeries(count: count, pauseMs: pauseMs) { idx in
729-
let reverse = pattern == "ping-pong" && (idx % 2 == 1)
730-
if reverse {
731-
dragAt(app: activeApp, x: x2, y: y2, x2: x, y2: y, holdDuration: holdDuration)
732-
} else {
733-
dragAt(app: activeApp, x: x, y: y, x2: x2, y2: y2, holdDuration: holdDuration)
731+
withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
732+
runSeries(count: count, pauseMs: pauseMs) { idx in
733+
let reverse = pattern == "ping-pong" && (idx % 2 == 1)
734+
if reverse {
735+
dragAt(app: activeApp, x: x2, y: y2, x2: x, y2: y, holdDuration: holdDuration)
736+
} else {
737+
dragAt(app: activeApp, x: x, y: y, x2: x2, y2: y2, holdDuration: holdDuration)
738+
}
734739
}
735740
}
736741
return Response(ok: true, data: DataPayload(message: "drag series"))
@@ -756,7 +761,9 @@ final class RunnerTests: XCTestCase {
756761
guard let direction = command.direction else {
757762
return Response(ok: false, error: ErrorPayload(message: "swipe requires direction"))
758763
}
759-
swipe(app: activeApp, direction: direction)
764+
withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
765+
swipe(app: activeApp, direction: direction)
766+
}
760767
return Response(ok: true, data: DataPayload(message: "swiped"))
761768
case .findText:
762769
guard let text = command.text else {
@@ -884,6 +891,38 @@ final class RunnerTests: XCTestCase {
884891
return target
885892
}
886893

894+
private func withTemporaryScrollIdleTimeoutIfSupported(
895+
_ target: XCUIApplication,
896+
operation: () -> Void
897+
) {
898+
let setter = NSSelectorFromString("setWaitForIdleTimeout:")
899+
guard target.responds(to: setter) else {
900+
operation()
901+
return
902+
}
903+
let previous = target.value(forKey: "waitForIdleTimeout") as? NSNumber
904+
target.setValue(resolveScrollInteractionIdleTimeout(), forKey: "waitForIdleTimeout")
905+
defer {
906+
if let previous {
907+
target.setValue(previous.doubleValue, forKey: "waitForIdleTimeout")
908+
}
909+
}
910+
operation()
911+
}
912+
913+
private func resolveScrollInteractionIdleTimeout() -> TimeInterval {
914+
guard
915+
let raw = ProcessInfo.processInfo.environment["AGENT_DEVICE_IOS_INTERACTION_IDLE_TIMEOUT"],
916+
!raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
917+
else {
918+
return scrollInteractionIdleTimeoutDefault
919+
}
920+
guard let parsed = Double(raw), parsed >= 0 else {
921+
return scrollInteractionIdleTimeoutDefault
922+
}
923+
return min(parsed, 30)
924+
}
925+
887926
private func shouldRetryCommand(_ command: Command) -> Bool {
888927
if isEnvTruthy("AGENT_DEVICE_RUNNER_DISABLE_READONLY_RETRY") {
889928
return false

src/core/dispatch.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ export async function dispatchCommand(
219219

220220
const requestedDurationMs = positionals[4] ? Number(positionals[4]) : 250;
221221
const durationMs = requireIntInRange(requestedDurationMs, 'durationMs', 16, 10_000);
222-
const effectiveDurationMs = device.platform === 'ios' ? 60 : durationMs;
222+
const effectiveDurationMs = device.platform === 'ios' ? clampIosSwipeDuration(durationMs) : durationMs;
223223
const count = requireIntInRange(context?.count ?? 1, 'count', 1, 200);
224224
const pauseMs = requireIntInRange(context?.pauseMs ?? 0, 'pause-ms', 0, 10_000);
225225
const pattern = context?.pattern ?? 'one-way';
@@ -511,6 +511,11 @@ function requireIntInRange(value: number, name: string, min: number, max: number
511511
return value;
512512
}
513513

514+
function clampIosSwipeDuration(durationMs: number): number {
515+
// Keep iOS swipes stable while allowing explicit fast durations for scroll-heavy flows.
516+
return Math.min(60, Math.max(16, Math.round(durationMs)));
517+
}
518+
514519
export function shouldUseIosTapSeries(
515520
device: DeviceInfo,
516521
count: number,

src/daemon/__tests__/scroll-planner.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ test('buildScrollIntoViewPlan computes downward content scroll when target is be
3636
assert.ok(plan);
3737
assert.equal(plan?.direction, 'down');
3838
assert.ok((plan?.count ?? 0) > 1);
39+
assert.equal(plan?.x, 80);
40+
assert.equal(plan?.startY, 726);
41+
assert.equal(plan?.endY, 118);
3942
});
4043

4144
test('buildScrollIntoViewPlan returns null when already in safe viewport band', () => {
@@ -45,3 +48,11 @@ test('buildScrollIntoViewPlan returns null when already in safe viewport band',
4548
assert.equal(plan, null);
4649
assert.equal(isRectWithinSafeViewportBand(targetRect, viewportRect), true);
4750
});
51+
52+
test('buildScrollIntoViewPlan keeps swipe lane inside viewport when target center is out of bounds', () => {
53+
const targetRect = { x: 1000, y: 2100, width: 120, height: 40 };
54+
const viewportRect = { x: 0, y: 0, width: 390, height: 844 };
55+
const plan = buildScrollIntoViewPlan(targetRect, viewportRect);
56+
assert.ok(plan);
57+
assert.equal(plan?.x, 351);
58+
});

src/daemon/handlers/__tests__/interaction.test.ts

Lines changed: 5 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,6 @@ test('scrollintoview @ref dispatches geometry-based swipe series', async () => {
214214
positionals: string[];
215215
context: Record<string, unknown> | undefined;
216216
}> = [];
217-
let snapshotCallCount = 0;
218217
const response = await handleInteractionCommands({
219218
req: {
220219
token: 't',
@@ -227,24 +226,13 @@ test('scrollintoview @ref dispatches geometry-based swipe series', async () => {
227226
sessionStore,
228227
contextFromFlags,
229228
dispatch: async (_device, command, positionals, _out, context) => {
230-
if (command === 'snapshot') {
231-
snapshotCallCount += 1;
232-
return {
233-
nodes: [
234-
{ index: 0, type: 'Application', rect: { x: 0, y: 0, width: 390, height: 844 } },
235-
{ index: 1, type: 'XCUIElementTypeStaticText', label: 'Far item', rect: { x: 20, y: 320, width: 120, height: 40 } },
236-
],
237-
backend: 'xctest',
238-
};
239-
}
240229
dispatchCalls.push({ command, positionals, context: context as Record<string, unknown> | undefined });
241230
return { ok: true };
242231
},
243232
});
244233

245234
assert.ok(response);
246235
assert.equal(response.ok, true);
247-
assert.equal(snapshotCallCount, 1);
248236
assert.equal(dispatchCalls.length, 1);
249237
assert.equal(dispatchCalls[0]?.command, 'swipe');
250238
assert.equal(dispatchCalls[0]?.positionals.length, 5);
@@ -259,8 +247,6 @@ test('scrollintoview @ref dispatches geometry-based swipe series', async () => {
259247
assert.equal(stored?.actions[0]?.command, 'scrollintoview');
260248
const result = (stored?.actions[0]?.result ?? {}) as Record<string, unknown>;
261249
assert.equal(result.ref, 'e2');
262-
assert.equal(result.strategy, 'ref-geometry');
263-
assert.equal(result.verified, true);
264250
});
265251

266252
test('scrollintoview @ref returns immediately when target is already in viewport safe band', async () => {
@@ -313,7 +299,7 @@ test('scrollintoview @ref returns immediately when target is already in viewport
313299
}
314300
});
315301

316-
test('scrollintoview @ref fails if target remains outside viewport after scroll', async () => {
302+
test('scrollintoview @ref does not run post-scroll verification snapshot', async () => {
317303
const sessionStore = makeSessionStore();
318304
const sessionName = 'default';
319305
const session = makeSession(sessionName);
@@ -335,6 +321,7 @@ test('scrollintoview @ref fails if target remains outside viewport after scroll'
335321
backend: 'xctest',
336322
};
337323
sessionStore.set(sessionName, session);
324+
let snapshotCallCount = 0;
338325

339326
const response = await handleInteractionCommands({
340327
req: {
@@ -349,6 +336,7 @@ test('scrollintoview @ref fails if target remains outside viewport after scroll'
349336
contextFromFlags,
350337
dispatch: async (_device, command) => {
351338
if (command === 'snapshot') {
339+
snapshotCallCount += 1;
352340
return {
353341
nodes: [
354342
{ index: 0, type: 'Application', rect: { x: 0, y: 0, width: 390, height: 844 } },
@@ -362,9 +350,6 @@ test('scrollintoview @ref fails if target remains outside viewport after scroll'
362350
});
363351

364352
assert.ok(response);
365-
assert.equal(response.ok, false);
366-
if (!response.ok) {
367-
assert.equal(response.error?.code, 'COMMAND_FAILED');
368-
assert.match(response.error?.message ?? '', /outside viewport/i);
369-
}
353+
assert.equal(response.ok, true);
354+
assert.equal(snapshotCallCount, 0);
370355
});

src/daemon/handlers/interaction.ts

Lines changed: 4 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {
2323
splitSelectorFromArgs,
2424
} from '../selectors.ts';
2525
import { withDiagnosticTimer } from '../../utils/diagnostics.ts';
26-
import { buildScrollIntoViewPlan, isRectWithinSafeViewportBand, resolveViewportRect } from '../scroll-planner.ts';
26+
import { buildScrollIntoViewPlan, resolveViewportRect } from '../scroll-planner.ts';
2727

2828
type ContextFromFlags = (
2929
flags: CommandFlags | undefined,
@@ -598,14 +598,14 @@ export async function handleInteractionCommands(params: {
598598
command,
599599
positionals: req.positionals ?? [],
600600
flags: req.flags ?? {},
601-
result: { ref, attempts: 0, alreadyVisible: true, strategy: 'ref-geometry', refLabel, selectorChain },
601+
result: { ref, attempts: 0, alreadyVisible: true, refLabel, selectorChain },
602602
});
603-
return { ok: true, data: { ref, attempts: 0, alreadyVisible: true, strategy: 'ref-geometry' } };
603+
return { ok: true, data: { ref, attempts: 0, alreadyVisible: true } };
604604
}
605605
const data = await dispatch(
606606
session.device,
607607
'swipe',
608-
[String(plan.x), String(plan.startY), String(plan.x), String(plan.endY), '60'],
608+
[String(plan.x), String(plan.startY), String(plan.x), String(plan.endY), '16'],
609609
req.flags?.out,
610610
{
611611
...contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath),
@@ -614,15 +614,6 @@ export async function handleInteractionCommands(params: {
614614
pattern: 'one-way',
615615
},
616616
);
617-
const verification = await verifyRefTargetInViewport({
618-
session,
619-
flags: req.flags,
620-
sessionStore,
621-
contextFromFlags,
622-
dispatch,
623-
selectorChain,
624-
});
625-
if (!verification.ok) return verification.response;
626617
sessionStore.recordAction(session, {
627618
command,
628619
positionals: req.positionals ?? [],
@@ -632,8 +623,6 @@ export async function handleInteractionCommands(params: {
632623
ref,
633624
attempts: plan.count,
634625
direction: plan.direction,
635-
strategy: 'ref-geometry',
636-
verified: true,
637626
refLabel,
638627
selectorChain,
639628
},
@@ -645,8 +634,6 @@ export async function handleInteractionCommands(params: {
645634
ref,
646635
attempts: plan.count,
647636
direction: plan.direction,
648-
strategy: 'ref-geometry',
649-
verified: true,
650637
},
651638
};
652639
}
@@ -761,74 +748,3 @@ function resolveRefTarget(params: {
761748
}
762749
return { ok: true, target: { ref, node, snapshotNodes: session.snapshot.nodes } };
763750
}
764-
765-
async function verifyRefTargetInViewport(params: {
766-
session: SessionState;
767-
flags: CommandFlags | undefined;
768-
sessionStore: SessionStore;
769-
contextFromFlags: ContextFromFlags;
770-
dispatch: typeof dispatchCommand;
771-
selectorChain: string[];
772-
}): Promise<{ ok: true } | { ok: false; response: DaemonResponse }> {
773-
const { session, flags, sessionStore, contextFromFlags, dispatch, selectorChain } = params;
774-
if (selectorChain.length === 0) {
775-
return {
776-
ok: false,
777-
response: { ok: false, error: { code: 'COMMAND_FAILED', message: 'scrollintoview verification selector is empty' } },
778-
};
779-
}
780-
let chainExpression = '';
781-
try {
782-
chainExpression = selectorChain.join(' || ');
783-
parseSelectorChain(chainExpression);
784-
} catch {
785-
return {
786-
ok: false,
787-
response: { ok: false, error: { code: 'COMMAND_FAILED', message: 'scrollintoview verification selector is invalid' } },
788-
};
789-
}
790-
const snapshot = await captureSnapshotForSession(
791-
session,
792-
flags,
793-
sessionStore,
794-
contextFromFlags,
795-
{ interactiveOnly: true },
796-
dispatch,
797-
);
798-
const chain = parseSelectorChain(chainExpression);
799-
const resolved = resolveSelectorChain(snapshot.nodes, chain, {
800-
platform: session.device.platform,
801-
requireRect: true,
802-
requireUnique: false,
803-
disambiguateAmbiguous: true,
804-
});
805-
if (!resolved?.node.rect) {
806-
return {
807-
ok: false,
808-
response: {
809-
ok: false,
810-
error: { code: 'COMMAND_FAILED', message: 'scrollintoview target could not be verified after scrolling' },
811-
},
812-
};
813-
}
814-
const viewportRect = resolveViewportRect(snapshot.nodes, resolved.node.rect);
815-
if (!viewportRect) {
816-
return {
817-
ok: false,
818-
response: {
819-
ok: false,
820-
error: { code: 'COMMAND_FAILED', message: 'scrollintoview could not infer viewport during verification' },
821-
},
822-
};
823-
}
824-
if (!isRectWithinSafeViewportBand(resolved.node.rect, viewportRect)) {
825-
return {
826-
ok: false,
827-
response: {
828-
ok: false,
829-
error: { code: 'COMMAND_FAILED', message: 'scrollintoview target is still outside viewport after scrolling' },
830-
},
831-
};
832-
}
833-
return { ok: true };
834-
}

src/daemon/scroll-planner.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,22 +38,27 @@ export function resolveViewportRect(nodes: RawSnapshotNode[], targetRect: Rect):
3838

3939
export function buildScrollIntoViewPlan(targetRect: Rect, viewportRect: Rect): ScrollIntoViewPlan | null {
4040
const viewportHeight = Math.max(1, viewportRect.height);
41+
const viewportWidth = Math.max(1, viewportRect.width);
4142
const viewportTop = viewportRect.y;
4243
const viewportBottom = viewportRect.y + viewportHeight;
44+
const viewportLeft = viewportRect.x;
45+
const viewportRight = viewportRect.x + viewportWidth;
4346
const safeTop = viewportTop + viewportHeight * 0.25;
4447
const safeBottom = viewportBottom - viewportHeight * 0.25;
48+
const lanePaddingPx = Math.max(8, viewportWidth * 0.1);
4549
const targetCenterY = targetRect.y + targetRect.height / 2;
50+
const targetCenterX = targetRect.x + targetRect.width / 2;
4651

4752
if (targetCenterY >= safeTop && targetCenterY <= safeBottom) {
4853
return null;
4954
}
5055

51-
const x = Math.round(viewportRect.x + viewportRect.width / 2);
52-
const dragUpStartY = Math.round(viewportTop + viewportHeight * 0.78);
53-
const dragUpEndY = Math.round(viewportTop + viewportHeight * 0.22);
56+
const x = Math.round(clamp(targetCenterX, viewportLeft + lanePaddingPx, viewportRight - lanePaddingPx));
57+
const dragUpStartY = Math.round(viewportTop + viewportHeight * 0.86);
58+
const dragUpEndY = Math.round(viewportTop + viewportHeight * 0.14);
5459
const dragDownStartY = dragUpEndY;
5560
const dragDownEndY = dragUpStartY;
56-
const swipeStepPx = Math.max(1, Math.abs(dragUpStartY - dragUpEndY) * 0.9);
61+
const swipeStepPx = Math.max(1, Math.abs(dragUpStartY - dragUpEndY));
5762

5863
if (targetCenterY > safeBottom) {
5964
const delta = targetCenterY - safeBottom;
@@ -111,3 +116,7 @@ function pickLargestRect(rects: Rect[]): Rect | null {
111116
function clampInt(value: number, min: number, max: number): number {
112117
return Math.min(max, Math.max(min, Math.round(value)));
113118
}
119+
120+
function clamp(value: number, min: number, max: number): number {
121+
return Math.min(max, Math.max(min, value));
122+
}

0 commit comments

Comments
 (0)