Skip to content

Commit 2a86f52

Browse files
authored
fix: harden press @ref bounds recovery (#141)
1 parent 74eee5a commit 2a86f52

2 files changed

Lines changed: 216 additions & 5 deletions

File tree

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

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,161 @@ test('press @ref resolves snapshot node and records press action', async () => {
155155
assert.ok(Array.isArray(result.selectorChain));
156156
});
157157

158+
test('press @ref refreshes snapshot when stored ref bounds are invalid', async () => {
159+
const sessionStore = makeSessionStore();
160+
const sessionName = 'default';
161+
const session = makeSession(sessionName);
162+
session.device = {
163+
platform: 'android',
164+
id: 'emulator-5554',
165+
name: 'Pixel 8 Pro',
166+
kind: 'emulator',
167+
booted: true,
168+
};
169+
session.snapshot = {
170+
nodes: attachRefs([
171+
{
172+
index: 0,
173+
type: 'android.widget.TextView',
174+
label: 'My App',
175+
// Simulate malformed persisted bounds from older/stale snapshot state.
176+
rect: { x: 20, y: 40, width: Number.NaN, height: 40 },
177+
enabled: true,
178+
hittable: true,
179+
},
180+
]),
181+
createdAt: Date.now(),
182+
backend: 'android',
183+
};
184+
sessionStore.set(sessionName, session);
185+
186+
const pressCalls: Array<{ command: string; positionals: string[] }> = [];
187+
let snapshotCalls = 0;
188+
const response = await handleInteractionCommands({
189+
req: {
190+
token: 't',
191+
session: sessionName,
192+
command: 'press',
193+
positionals: ['@e1'],
194+
flags: {},
195+
},
196+
sessionName,
197+
sessionStore,
198+
contextFromFlags,
199+
dispatch: async (_device, command, positionals) => {
200+
if (command === 'snapshot') {
201+
snapshotCalls += 1;
202+
return {
203+
nodes: [
204+
{
205+
index: 0,
206+
type: 'android.widget.TextView',
207+
label: 'My App',
208+
rect: { x: 20, y: 40, width: 100, height: 40 },
209+
enabled: true,
210+
hittable: true,
211+
},
212+
],
213+
backend: 'android',
214+
};
215+
}
216+
pressCalls.push({ command, positionals });
217+
return { pressed: true };
218+
},
219+
});
220+
221+
assert.ok(response);
222+
assert.equal(response.ok, true);
223+
assert.equal(snapshotCalls, 1);
224+
assert.equal(pressCalls.length, 1);
225+
assert.equal(pressCalls[0]?.command, 'press');
226+
assert.deepEqual(pressCalls[0]?.positionals, ['70', '60']);
227+
if (response.ok) {
228+
assert.equal(response.data?.x, 70);
229+
assert.equal(response.data?.y, 60);
230+
assert.equal(response.data?.ref, 'e1');
231+
}
232+
});
233+
234+
test('press @ref fallback label is used after refresh when ref bounds remain invalid', async () => {
235+
const sessionStore = makeSessionStore();
236+
const sessionName = 'default';
237+
const session = makeSession(sessionName);
238+
session.device = {
239+
platform: 'android',
240+
id: 'emulator-5554',
241+
name: 'Pixel 8 Pro',
242+
kind: 'emulator',
243+
booted: true,
244+
};
245+
session.snapshot = {
246+
nodes: attachRefs([
247+
{
248+
index: 0,
249+
type: 'android.widget.TextView',
250+
label: 'My App',
251+
rect: { x: 20, y: 40, width: Number.NaN, height: 40 },
252+
enabled: true,
253+
hittable: true,
254+
},
255+
]),
256+
createdAt: Date.now(),
257+
backend: 'android',
258+
};
259+
sessionStore.set(sessionName, session);
260+
261+
const pressCalls: Array<{ command: string; positionals: string[] }> = [];
262+
const response = await handleInteractionCommands({
263+
req: {
264+
token: 't',
265+
session: sessionName,
266+
command: 'press',
267+
positionals: ['@e1', 'My App'],
268+
flags: {},
269+
},
270+
sessionName,
271+
sessionStore,
272+
contextFromFlags,
273+
dispatch: async (_device, command, positionals) => {
274+
if (command === 'snapshot') {
275+
return {
276+
nodes: [
277+
{
278+
index: 0,
279+
type: 'android.widget.TextView',
280+
label: 'Different',
281+
rect: { x: 20, y: 40, width: Number.NaN, height: 40 },
282+
enabled: true,
283+
hittable: true,
284+
},
285+
{
286+
index: 1,
287+
type: 'android.widget.TextView',
288+
label: 'My App',
289+
rect: { x: 100, y: 200, width: 80, height: 40 },
290+
enabled: true,
291+
hittable: true,
292+
},
293+
],
294+
backend: 'android',
295+
};
296+
}
297+
pressCalls.push({ command, positionals });
298+
return { pressed: true };
299+
},
300+
});
301+
302+
assert.ok(response);
303+
assert.equal(response.ok, true);
304+
assert.equal(pressCalls.length, 1);
305+
assert.equal(pressCalls[0]?.command, 'press');
306+
assert.deepEqual(pressCalls[0]?.positionals, ['140', '220']);
307+
if (response.ok) {
308+
assert.equal(response.data?.x, 140);
309+
assert.equal(response.data?.y, 220);
310+
}
311+
});
312+
158313
test('press coordinates does not treat extra trailing args as selector', async () => {
159314
const sessionStore = makeSessionStore();
160315
const sessionName = 'default';

src/daemon/handlers/interaction.ts

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
findNodeByRef,
77
normalizeRef,
88
type RawSnapshotNode,
9+
type Rect,
910
type SnapshotNode,
1011
} from '../../utils/snapshot.ts';
1112
import type { DaemonCommandContext } from '../context.ts';
@@ -91,16 +92,40 @@ export async function handleInteractionCommands(params: {
9192
notFoundMessage: `Ref ${refInput} not found or has no bounds`,
9293
});
9394
if (!resolvedRefTarget.ok) return resolvedRefTarget.response;
94-
const { ref, node, snapshotNodes } = resolvedRefTarget.target;
95-
if (!node.rect) {
95+
const { ref } = resolvedRefTarget.target;
96+
let node = resolvedRefTarget.target.node;
97+
let snapshotNodes = resolvedRefTarget.target.snapshotNodes;
98+
let pressPoint = resolveRectCenter(node.rect);
99+
if (!pressPoint) {
100+
const refreshed = await captureSnapshotForSession(
101+
session,
102+
req.flags,
103+
sessionStore,
104+
contextFromFlags,
105+
{ interactiveOnly: true },
106+
dispatch,
107+
);
108+
const refNode = findNodeByRef(refreshed.nodes, ref);
109+
const fallbackNode = fallbackLabel.length > 0 ? findNodeByLabel(refreshed.nodes, fallbackLabel) : null;
110+
const fallbackNodePoint = resolveRectCenter(fallbackNode?.rect);
111+
const refNodePoint = resolveRectCenter(refNode?.rect);
112+
const refreshedNode = refNodePoint ? refNode : fallbackNodePoint ? fallbackNode : refNode ?? fallbackNode;
113+
const refreshedPoint = resolveRectCenter(refreshedNode?.rect);
114+
if (refreshedNode && refreshedPoint) {
115+
node = refreshedNode;
116+
snapshotNodes = refreshed.nodes;
117+
pressPoint = refreshedPoint;
118+
}
119+
}
120+
if (!pressPoint) {
96121
return {
97122
ok: false,
98-
error: { code: 'COMMAND_FAILED', message: `Ref ${refInput} not found or has no bounds` },
123+
error: { code: 'COMMAND_FAILED', message: `Ref ${refInput} not found or has invalid bounds` },
99124
};
100125
}
101126
const refLabel = resolveRefLabel(node, snapshotNodes);
102127
const selectorChain = buildSelectorChainForNode(node, session.device.platform, { action: selectorAction });
103-
const { x, y } = centerOfRect(node.rect);
128+
const { x, y } = pressPoint;
104129
const data = await dispatch(session.device, 'press', [String(x), String(y)], req.flags?.out, {
105130
...contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath),
106131
});
@@ -149,7 +174,17 @@ export async function handleInteractionCommands(params: {
149174
},
150175
};
151176
}
152-
const { x, y } = centerOfRect(resolved.node.rect);
177+
const pressPoint = resolveRectCenter(resolved.node.rect);
178+
if (!pressPoint) {
179+
return {
180+
ok: false,
181+
error: {
182+
code: 'COMMAND_FAILED',
183+
message: `Selector ${resolved.selector.raw} resolved to invalid bounds`,
184+
},
185+
};
186+
}
187+
const { x, y } = pressPoint;
153188
const data = await dispatch(session.device, 'press', [String(x), String(y)], req.flags?.out, {
154189
...contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath),
155190
});
@@ -772,3 +807,24 @@ function resolveRefTarget(params: {
772807
}
773808
return { ok: true, target: { ref, node, snapshotNodes: session.snapshot.nodes } };
774809
}
810+
811+
function resolveRectCenter(rect: Rect | undefined): { x: number; y: number } | null {
812+
const normalized = normalizeRect(rect);
813+
if (!normalized) return null;
814+
const center = centerOfRect(normalized);
815+
if (!Number.isFinite(center.x) || !Number.isFinite(center.y)) return null;
816+
return center;
817+
}
818+
819+
function normalizeRect(rect: Rect | undefined): Rect | null {
820+
if (!rect) return null;
821+
const x = Number(rect.x);
822+
const y = Number(rect.y);
823+
const width = Number(rect.width);
824+
const height = Number(rect.height);
825+
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(width) || !Number.isFinite(height)) {
826+
return null;
827+
}
828+
if (width < 0 || height < 0) return null;
829+
return { x, y, width, height };
830+
}

0 commit comments

Comments
 (0)