Skip to content

Commit c81a649

Browse files
committed
fix: harden modal probe and verify ref scrollintoview
1 parent a6f37c4 commit c81a649

5 files changed

Lines changed: 479 additions & 183 deletions

File tree

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift

Lines changed: 78 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1011,29 +1011,30 @@ final class RunnerTests: XCTestCase {
10111011
}
10121012

10131013
let title = preferredSystemModalTitle(modal)
1014-
1015-
var nodes: [SnapshotNode] = [
1016-
makeSnapshotNode(
1017-
element: modal,
1018-
index: 0,
1019-
type: "Alert",
1020-
labelOverride: title,
1021-
identifierOverride: modal.identifier,
1022-
depth: 0,
1023-
hittableOverride: true
1024-
)
1025-
]
1014+
guard let modalNode = safeMakeSnapshotNode(
1015+
element: modal,
1016+
index: 0,
1017+
type: "Alert",
1018+
labelOverride: title,
1019+
identifierOverride: modal.identifier,
1020+
depth: 0,
1021+
hittableOverride: true
1022+
) else {
1023+
return nil
1024+
}
1025+
var nodes: [SnapshotNode] = [modalNode]
10261026

10271027
for action in actions {
1028-
nodes.append(
1029-
makeSnapshotNode(
1030-
element: action,
1031-
index: nodes.count,
1032-
type: elementTypeName(action.elementType),
1033-
depth: 1,
1034-
hittableOverride: true
1035-
)
1036-
)
1028+
guard let actionNode = safeMakeSnapshotNode(
1029+
element: action,
1030+
index: nodes.count,
1031+
type: elementTypeName(action.elementType),
1032+
depth: 1,
1033+
hittableOverride: true
1034+
) else {
1035+
continue
1036+
}
1037+
nodes.append(actionNode)
10371038
}
10381039

10391040
return DataPayload(nodes: nodes, truncated: false)
@@ -1116,18 +1117,36 @@ final class RunnerTests: XCTestCase {
11161117
private func actionableElements(in element: XCUIElement) -> [XCUIElement] {
11171118
var seen = Set<String>()
11181119
var actions: [XCUIElement] = []
1119-
let descendants = element.descendants(matching: .any).allElementsBoundByIndex
1120+
let descendants = safeElementsQuery {
1121+
element.descendants(matching: .any).allElementsBoundByIndex
1122+
}
11201123
for candidate in descendants {
1121-
if !candidate.exists || !candidate.isHittable { continue }
1122-
if !actionableTypes.contains(candidate.elementType) { continue }
1124+
if !safeIsActionableCandidate(candidate, seen: &seen) { continue }
1125+
actions.append(candidate)
1126+
}
1127+
return actions
1128+
}
1129+
1130+
private func safeIsActionableCandidate(_ candidate: XCUIElement, seen: inout Set<String>) -> Bool {
1131+
var include = false
1132+
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
1133+
if !candidate.exists || !candidate.isHittable { return }
1134+
if !actionableTypes.contains(candidate.elementType) { return }
11231135
let frame = candidate.frame
1124-
if frame.isNull || frame.isEmpty { continue }
1136+
if frame.isNull || frame.isEmpty { return }
11251137
let key = "\(candidate.elementType.rawValue)-\(frame.origin.x)-\(frame.origin.y)-\(frame.size.width)-\(frame.size.height)-\(candidate.label)"
1126-
if seen.contains(key) { continue }
1138+
if seen.contains(key) { return }
11271139
seen.insert(key)
1128-
actions.append(candidate)
1140+
include = true
1141+
})
1142+
if let exceptionMessage {
1143+
NSLog(
1144+
"AGENT_DEVICE_RUNNER_MODAL_ACTION_IGNORED_EXCEPTION=%@",
1145+
exceptionMessage
1146+
)
1147+
return false
11291148
}
1130-
return actions
1149+
return include
11311150
}
11321151

11331152
private func preferredSystemModalTitle(_ element: XCUIElement) -> String {
@@ -1166,6 +1185,37 @@ final class RunnerTests: XCTestCase {
11661185
)
11671186
}
11681187

1188+
private func safeMakeSnapshotNode(
1189+
element: XCUIElement,
1190+
index: Int,
1191+
type: String,
1192+
labelOverride: String? = nil,
1193+
identifierOverride: String? = nil,
1194+
depth: Int,
1195+
hittableOverride: Bool? = nil
1196+
) -> SnapshotNode? {
1197+
var node: SnapshotNode?
1198+
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
1199+
node = makeSnapshotNode(
1200+
element: element,
1201+
index: index,
1202+
type: type,
1203+
labelOverride: labelOverride,
1204+
identifierOverride: identifierOverride,
1205+
depth: depth,
1206+
hittableOverride: hittableOverride
1207+
)
1208+
})
1209+
if let exceptionMessage {
1210+
NSLog(
1211+
"AGENT_DEVICE_RUNNER_MODAL_NODE_IGNORED_EXCEPTION=%@",
1212+
exceptionMessage
1213+
)
1214+
return nil
1215+
}
1216+
return node
1217+
}
1218+
11691219
private func snapshotRect(from frame: CGRect) -> SnapshotRect {
11701220
return SnapshotRect(
11711221
x: Double(frame.origin.x),
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { type RawSnapshotNode } from '../../utils/snapshot.ts';
4+
import {
5+
buildScrollIntoViewPlan,
6+
isRectWithinSafeViewportBand,
7+
resolveViewportRect,
8+
} from '../scroll-planner.ts';
9+
10+
function makeNode(index: number, type: string, rect?: RawSnapshotNode['rect']): RawSnapshotNode {
11+
return { index, type, rect };
12+
}
13+
14+
test('resolveViewportRect picks containing application/window viewport', () => {
15+
const targetRect = { x: 20, y: 1700, width: 120, height: 40 };
16+
const nodes: RawSnapshotNode[] = [
17+
makeNode(0, 'Application', { x: 0, y: 0, width: 390, height: 844 }),
18+
makeNode(1, 'Window', { x: 0, y: 0, width: 390, height: 844 }),
19+
makeNode(2, 'Cell', targetRect),
20+
];
21+
const viewport = resolveViewportRect(nodes, targetRect);
22+
assert.deepEqual(viewport, { x: 0, y: 0, width: 390, height: 844 });
23+
});
24+
25+
test('resolveViewportRect returns null when no valid viewport can be inferred', () => {
26+
const targetRect = { x: 20, y: 100, width: 120, height: 40 };
27+
const nodes: RawSnapshotNode[] = [makeNode(0, 'Cell', undefined)];
28+
const viewport = resolveViewportRect(nodes, targetRect);
29+
assert.equal(viewport, null);
30+
});
31+
32+
test('buildScrollIntoViewPlan computes downward content scroll when target is below safe band', () => {
33+
const targetRect = { x: 20, y: 2100, width: 120, height: 40 };
34+
const viewportRect = { x: 0, y: 0, width: 390, height: 844 };
35+
const plan = buildScrollIntoViewPlan(targetRect, viewportRect);
36+
assert.ok(plan);
37+
assert.equal(plan?.direction, 'down');
38+
assert.ok((plan?.count ?? 0) > 1);
39+
});
40+
41+
test('buildScrollIntoViewPlan returns null when already in safe viewport band', () => {
42+
const targetRect = { x: 20, y: 320, width: 120, height: 40 };
43+
const viewportRect = { x: 0, y: 0, width: 390, height: 844 };
44+
const plan = buildScrollIntoViewPlan(targetRect, viewportRect);
45+
assert.equal(plan, null);
46+
assert.equal(isRectWithinSafeViewportBand(targetRect, viewportRect), true);
47+
});

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

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@ test('scrollintoview @ref dispatches geometry-based swipe series', async () => {
214214
positionals: string[];
215215
context: Record<string, unknown> | undefined;
216216
}> = [];
217+
let snapshotCallCount = 0;
217218
const response = await handleInteractionCommands({
218219
req: {
219220
token: 't',
@@ -226,13 +227,24 @@ test('scrollintoview @ref dispatches geometry-based swipe series', async () => {
226227
sessionStore,
227228
contextFromFlags,
228229
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+
}
229240
dispatchCalls.push({ command, positionals, context: context as Record<string, unknown> | undefined });
230241
return { ok: true };
231242
},
232243
});
233244

234245
assert.ok(response);
235246
assert.equal(response.ok, true);
247+
assert.equal(snapshotCallCount, 1);
236248
assert.equal(dispatchCalls.length, 1);
237249
assert.equal(dispatchCalls[0]?.command, 'swipe');
238250
assert.equal(dispatchCalls[0]?.positionals.length, 5);
@@ -248,6 +260,7 @@ test('scrollintoview @ref dispatches geometry-based swipe series', async () => {
248260
const result = (stored?.actions[0]?.result ?? {}) as Record<string, unknown>;
249261
assert.equal(result.ref, 'e2');
250262
assert.equal(result.strategy, 'ref-geometry');
263+
assert.equal(result.verified, true);
251264
});
252265

253266
test('scrollintoview @ref returns immediately when target is already in viewport safe band', async () => {
@@ -299,3 +312,59 @@ test('scrollintoview @ref returns immediately when target is already in viewport
299312
assert.equal(response.data?.alreadyVisible, true);
300313
}
301314
});
315+
316+
test('scrollintoview @ref fails if target remains outside viewport after scroll', async () => {
317+
const sessionStore = makeSessionStore();
318+
const sessionName = 'default';
319+
const session = makeSession(sessionName);
320+
session.snapshot = {
321+
nodes: attachRefs([
322+
{
323+
index: 0,
324+
type: 'Application',
325+
rect: { x: 0, y: 0, width: 390, height: 844 },
326+
},
327+
{
328+
index: 1,
329+
type: 'XCUIElementTypeStaticText',
330+
label: 'Far item',
331+
rect: { x: 20, y: 2600, width: 120, height: 40 },
332+
},
333+
]),
334+
createdAt: Date.now(),
335+
backend: 'xctest',
336+
};
337+
sessionStore.set(sessionName, session);
338+
339+
const response = await handleInteractionCommands({
340+
req: {
341+
token: 't',
342+
session: sessionName,
343+
command: 'scrollintoview',
344+
positionals: ['@e2'],
345+
flags: {},
346+
},
347+
sessionName,
348+
sessionStore,
349+
contextFromFlags,
350+
dispatch: async (_device, command) => {
351+
if (command === 'snapshot') {
352+
return {
353+
nodes: [
354+
{ index: 0, type: 'Application', rect: { x: 0, y: 0, width: 390, height: 844 } },
355+
{ index: 1, type: 'XCUIElementTypeStaticText', label: 'Far item', rect: { x: 20, y: 2600, width: 120, height: 40 } },
356+
],
357+
backend: 'xctest',
358+
};
359+
}
360+
return { ok: true };
361+
},
362+
});
363+
364+
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+
}
370+
});

0 commit comments

Comments
 (0)