Skip to content

Commit 0bc1b1e

Browse files
authored
fix: simplify iOS interactive snapshots (#578)
1 parent cd55a6a commit 0bc1b1e

26 files changed

Lines changed: 2667 additions & 125 deletions

src/commands/react-native/__tests__/overlay.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,46 @@ describe('React Native overlay helpers', () => {
2626
});
2727
});
2828

29+
test('targets visible close affordance when collapsed banner keeps outer bounds', () => {
30+
const nodes = [
31+
node({
32+
ref: 'e3',
33+
label: '!, Open debugger to view warnings.',
34+
rect: { x: 0, y: 0, width: 402, height: 874 },
35+
hittable: false,
36+
}),
37+
node({
38+
ref: 'e125',
39+
label: '!, Open debugger to view warnings.',
40+
rect: { x: 10, y: 786.666, width: 382, height: 67.333 },
41+
hittable: false,
42+
}),
43+
];
44+
45+
const target = resolveReactNativeOverlayDismissTarget(nodes);
46+
47+
expect(detectReactNativeOverlay(nodes).detected).toBe(true);
48+
expect(target).toMatchObject({
49+
action: 'close-collapsed-banner',
50+
ref: 'e125',
51+
point: { x: 369, y: 813 },
52+
});
53+
});
54+
55+
test('detects full-screen open-debugger wrappers but does not use them as targets', () => {
56+
const nodes = [
57+
node({
58+
ref: 'e3',
59+
label: '!, Open debugger to view warnings.',
60+
rect: { x: 0, y: 0, width: 402, height: 874 },
61+
hittable: false,
62+
}),
63+
];
64+
65+
expect(detectReactNativeOverlay(nodes).detected).toBe(true);
66+
expect(resolveReactNativeOverlayDismissTarget(nodes)).toBeNull();
67+
});
68+
2969
test('prefers Minimize for RedBox overlays', () => {
3070
const nodes = [
3171
node({ ref: 'e1', label: 'Runtime Error', rect: { x: 0, y: 0, width: 390, height: 100 } }),

src/commands/react-native/overlay.ts

Lines changed: 16 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
import { centerOfRect, type Point, type SnapshotNode } from '../../utils/snapshot.ts';
2+
import {
3+
hasKnownReactNativeOverlayText,
4+
isReactNativeCollapsedWarningLabel,
5+
isReactNativeOpenDebuggerWarningLabel,
6+
isReactNativeStackFrame,
7+
} from '../../utils/react-native-overlay-signals.ts';
28

39
export type ReactNativeOverlayState = {
410
detected: boolean;
@@ -42,9 +48,13 @@ export function detectReactNativeOverlay(nodes: SnapshotNode[]): ReactNativeOver
4248
const minimizeNodes = collectOverlayNodes(nodes, isMinimizeLabel);
4349
const collapsedNodes = collectOverlayNodes(
4450
nodes,
45-
isCollapsedReactNativeWarningLabel,
51+
isReactNativeCollapsedWarningLabel,
4652
isLikelyCollapsedWarningControl,
4753
);
54+
const openDebuggerWarningNodes = collectOverlayNodes(
55+
nodes,
56+
isReactNativeOpenDebuggerWarningLabel,
57+
);
4858
const dismissRefs = refsOf(dismissNodes);
4959
const minimizeRefs = refsOf(minimizeNodes);
5060
const collapsedRefs = refsOf(collapsedNodes);
@@ -56,6 +66,7 @@ export function detectReactNativeOverlay(nodes: SnapshotNode[]): ReactNativeOver
5666
(hasReactNativeStackFrame && hasOverlayControl);
5767
const detected =
5868
collapsedRefs.length > 0 ||
69+
openDebuggerWarningNodes.length > 0 ||
5970
(hasOverlayControl && (hasKnownReactNativeOverlayText(text) || hasReactNativeStackFrame));
6071
return {
6172
detected,
@@ -100,19 +111,6 @@ export function resolveReactNativeOverlayDismissTarget(
100111
};
101112
}
102113

103-
function hasKnownReactNativeOverlayText(text: string): boolean {
104-
return /\b(logbox|redbox|reload js|copy stack|component stack|call stack|runtime error|open debugger to view warnings)\b/.test(
105-
text,
106-
);
107-
}
108-
109-
function isReactNativeStackFrame(text: string): boolean {
110-
return (
111-
/\b[\w.$<>/-]+\.(?:tsx?|jsx?):\d+(?::\d+)?\b/.test(text) ||
112-
/\b[\w.$<>/-]+\.(?:tsx?|jsx?)\s+\(\d+:\d+\)/.test(text)
113-
);
114-
}
115-
116114
function isDismissControlLabel(label: string): boolean {
117115
return label === 'dismiss' || label === 'close' || isCloseIconLabel(label);
118116
}
@@ -125,20 +123,6 @@ function isMinimizeLabel(label: string): boolean {
125123
return /^minimi[sz]e$/.test(label);
126124
}
127125

128-
function isCollapsedReactNativeWarningLabel(label: string): boolean {
129-
return (
130-
label.includes('open debugger to view warnings') ||
131-
/^!,\s+/.test(label) ||
132-
/^(warn|warning|error):\s+/.test(label) ||
133-
/\b(?:possible\s+)?unhandled (?:promise )?rejection\b/.test(label) ||
134-
label.includes('getsnapshot should be cached to avoid an infinite loop') ||
135-
label.includes('unique "key" prop') ||
136-
label.includes("unique 'key' prop") ||
137-
label.includes('virtualizedlists should never be nested') ||
138-
label.includes('failed prop type')
139-
);
140-
}
141-
142126
function isLikelyCollapsedWarningControl(node: SnapshotNode): boolean {
143127
return !node.rect || node.rect.height <= 180;
144128
}
@@ -155,7 +139,7 @@ function collectOverlayNodes(
155139
const labels = [node.label, node.value, node.identifier]
156140
.map((value) => value?.trim().toLowerCase())
157141
.filter((value): value is string => Boolean(value));
158-
if (!labels.some(matches)) continue;
142+
if (!labels.some((label) => matches(label))) continue;
159143
matchedNodes.push(node);
160144
}
161145
return matchedNodes;
@@ -206,7 +190,8 @@ function chooseCollapsedWarningNode(nodes: SnapshotNode[]): SnapshotNode | null
206190

207191
function collapsedBannerClosePoint(node: SnapshotNode): Point {
208192
if (!node.rect) throw new Error('Collapsed React Native warning node must have rect');
209-
const inset = Math.min(36, Math.max(18, node.rect.height * 0.45));
193+
const closeTargetHeight = Math.min(node.rect.height, 52);
194+
const inset = Math.min(36, Math.max(18, closeTargetHeight * 0.45));
210195
return {
211196
x: Math.round(
212197
clamp(
@@ -215,7 +200,7 @@ function collapsedBannerClosePoint(node: SnapshotNode): Point {
215200
node.rect.x + node.rect.width - 1,
216201
),
217202
),
218-
y: Math.round(node.rect.y + node.rect.height / 2),
203+
y: Math.round(node.rect.y + closeTargetHeight / 2),
219204
};
220205
}
221206

src/daemon/__tests__/request-router-screenshot.test.ts

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import type { SessionState } from '../types.ts';
1515
import { LeaseRegistry } from '../lease-registry.ts';
1616
import { attachRefs } from '../../utils/snapshot.ts';
1717
import { PNG } from 'pngjs';
18-
import { ANDROID_EMULATOR } from '../../__tests__/test-utils/device-fixtures.ts';
18+
import { ANDROID_EMULATOR, IOS_SIMULATOR } from '../../__tests__/test-utils/device-fixtures.ts';
1919
import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts';
2020
import { makeSession as makeBaseSession } from '../../__tests__/test-utils/session-factories.ts';
2121

@@ -25,6 +25,10 @@ function makeSession(name: string): SessionState {
2525
return makeBaseSession(name, { device: ANDROID_EMULATOR });
2626
}
2727

28+
function makeIosSession(name: string): SessionState {
29+
return makeBaseSession(name, { device: IOS_SIMULATOR });
30+
}
31+
2832
function makeMacOsMenubarSession(name: string): SessionState {
2933
return {
3034
name,
@@ -415,6 +419,102 @@ test('screenshot --overlay-refs captures a fresh snapshot when the session has n
415419
expect(mockDispatch.mock.calls.map((call) => call[1])).toEqual(['screenshot', 'snapshot']);
416420
});
417421

422+
test('screenshot --overlay-refs uses interactive iOS presentation for row-like other nodes', async () => {
423+
const sessionStore = makeSessionStore('agent-device-router-screenshot-');
424+
sessionStore.set('default', makeIosSession('default'));
425+
const screenshotPath = path.join(os.tmpdir(), `agent-device-overlay-ios-${Date.now()}.png`);
426+
427+
mockDispatch.mockImplementation(async (_device, command) => {
428+
if (command === 'screenshot') {
429+
writeSolidPng(screenshotPath, 402, 874);
430+
return { path: screenshotPath };
431+
}
432+
if (command === 'snapshot') {
433+
return {
434+
backend: 'xctest',
435+
nodes: [
436+
{
437+
index: 0,
438+
depth: 0,
439+
type: 'Application',
440+
label: 'New Expensify Dev',
441+
rect: { x: 0, y: 0, width: 402, height: 874 },
442+
},
443+
{
444+
index: 1,
445+
depth: 1,
446+
parentIndex: 0,
447+
type: 'Other',
448+
label: '!, Open debugger to view warnings.',
449+
rect: { x: 0, y: 0, width: 402, height: 874 },
450+
},
451+
{
452+
index: 2,
453+
depth: 1,
454+
parentIndex: 0,
455+
type: 'ScrollView',
456+
label: 'Recent chats',
457+
rect: { x: 8, y: 212, width: 386, height: 600 },
458+
},
459+
{
460+
index: 3,
461+
depth: 2,
462+
parentIndex: 2,
463+
type: 'Other',
464+
label: 'Recent chats',
465+
rect: { x: 0, y: 220, width: 402, height: 16 },
466+
},
467+
{
468+
index: 4,
469+
depth: 2,
470+
parentIndex: 2,
471+
type: 'Other',
472+
label: 'Receipt missing details, Receipt scanning failed. Enter details manually.',
473+
rect: { x: 8, y: 367, width: 386, height: 64 },
474+
},
475+
],
476+
};
477+
}
478+
return {};
479+
});
480+
481+
const handler = createRequestHandler({
482+
logPath: path.join(os.tmpdir(), 'daemon.log'),
483+
token: 'test-token',
484+
sessionStore,
485+
leaseRegistry: new LeaseRegistry(),
486+
trackDownloadableArtifact: () => 'artifact-id',
487+
});
488+
489+
const response = await handler({
490+
token: 'test-token',
491+
session: 'default',
492+
command: 'screenshot',
493+
positionals: [screenshotPath],
494+
flags: { overlayRefs: true },
495+
meta: { requestId: 'req-overlay-ios-rows' },
496+
});
497+
498+
expect(response.ok).toBe(true);
499+
if (response.ok) {
500+
expect(response.data?.overlayRefs).toEqual([
501+
{
502+
ref: 'e5',
503+
label: 'Receipt missing details, Receipt scanning failed. Enter details manually.',
504+
rect: { x: 8, y: 367, width: 386, height: 64 },
505+
overlayRect: { x: 8, y: 367, width: 386, height: 64 },
506+
center: { x: 201, y: 399 },
507+
},
508+
]);
509+
}
510+
expect(mockDispatch.mock.calls.map((call) => call[1])).toEqual(['screenshot', 'snapshot']);
511+
expect(mockDispatch.mock.calls[1]?.[4]).toMatchObject({
512+
snapshotInteractiveOnly: true,
513+
snapshotCompact: true,
514+
});
515+
expect(sessionStore.get('default')?.snapshot?.nodes[4]?.type).toBe('Cell');
516+
});
517+
418518
test('screenshot --overlay-refs uses a fresh snapshot instead of stale session snapshot', async () => {
419519
const sessionStore = makeSessionStore('agent-device-router-screenshot-');
420520
const session = makeSession('default');

src/daemon/__tests__/screenshot-overlay.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,45 @@ test('buildScreenshotOverlayRefs promotes labeled children to actionable ancesto
8080
]);
8181
});
8282

83+
test('buildScreenshotOverlayRefs includes non-hittable iOS cell rows', () => {
84+
const snapshot = makeSnapshotState([
85+
{
86+
index: 0,
87+
type: 'XCUIElementTypeApplication',
88+
label: 'New Expensify Dev',
89+
hittable: true,
90+
rect: { x: 0, y: 0, width: 402, height: 874 },
91+
},
92+
{
93+
index: 1,
94+
parentIndex: 0,
95+
type: 'XCUIElementTypeScrollView',
96+
label: 'Recent chats',
97+
rect: { x: 8, y: 212, width: 386, height: 600 },
98+
},
99+
{
100+
index: 2,
101+
parentIndex: 1,
102+
type: 'Cell',
103+
label: 'Receipt missing details, Receipt scanning failed. Enter details manually.',
104+
hittable: false,
105+
rect: { x: 8, y: 367, width: 386, height: 64 },
106+
},
107+
]);
108+
109+
const overlayRefs = buildScreenshotOverlayRefs(snapshot, 804, 1748);
110+
111+
assert.deepEqual(overlayRefs, [
112+
{
113+
ref: 'e3',
114+
label: 'Receipt missing details, Receipt scanning failed. Enter details manually.',
115+
rect: { x: 8, y: 367, width: 386, height: 64 },
116+
overlayRect: { x: 16, y: 734, width: 772, height: 128 },
117+
center: { x: 402, y: 798 },
118+
},
119+
]);
120+
});
121+
83122
test('buildScreenshotOverlayRefs suppresses contained duplicates with the same label, keeping the smaller rect', () => {
84123
const snapshot = makeSnapshotState([
85124
{

src/daemon/handlers/__tests__/replay-heal.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,56 @@ test('replay heal rewrites longpress selector and preserves duration', async ()
183183
expect(healed?.positionals[1]).toBe('800');
184184
});
185185

186+
test('replay heal uses canonical iOS snapshot presentation', async () => {
187+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-heal-ios-row-'));
188+
const sessionsDir = path.join(tempRoot, 'sessions');
189+
const sessionStore = new SessionStore(sessionsDir);
190+
const sessionName = 'heal-ios-row-session';
191+
sessionStore.set(sessionName, makeSession(sessionName));
192+
const rowRect = { x: 16, y: 293, width: 370, height: 52 };
193+
194+
mockDispatchCommand.mockResolvedValue({
195+
nodes: [
196+
{ index: 0, depth: 0, type: 'Application', label: 'Settings' },
197+
{ index: 1, depth: 1, parentIndex: 0, type: 'CollectionView' },
198+
{ index: 2, depth: 2, parentIndex: 1, type: 'Cell', label: 'General', rect: rowRect },
199+
{ index: 3, depth: 3, parentIndex: 2, type: 'Other', label: 'General', rect: rowRect },
200+
{
201+
index: 4,
202+
depth: 4,
203+
parentIndex: 3,
204+
type: 'Button',
205+
label: 'General',
206+
identifier: 'com.apple.settings.general',
207+
rect: rowRect,
208+
},
209+
{ index: 5, depth: 5, parentIndex: 4, type: 'StaticText', label: 'General', rect: rowRect },
210+
],
211+
truncated: false,
212+
backend: 'xctest',
213+
});
214+
215+
const healed = await healReplayAction({
216+
action: {
217+
ts: Date.now(),
218+
command: 'click',
219+
positionals: ['label="General"'],
220+
flags: {},
221+
result: { selectorChain: ['label="General"'] },
222+
},
223+
sessionName,
224+
logPath: '/tmp/replay.log',
225+
sessionStore,
226+
});
227+
228+
expect(healed?.positionals[0]).toContain('com.apple.settings.general');
229+
expect(sessionStore.get(sessionName)?.snapshot?.nodes.map((node) => node.type)).toEqual([
230+
'Application',
231+
'CollectionView',
232+
'Cell',
233+
]);
234+
});
235+
186236
test('replay --update heals selector and rewrites replay file', async () => {
187237
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-heal-'));
188238
const sessionsDir = path.join(tempRoot, 'sessions');

0 commit comments

Comments
 (0)