Skip to content

Commit 6ec465a

Browse files
authored
fix: preserve android snapshot visibility hints (#343)
1 parent 7df452b commit 6ec465a

14 files changed

Lines changed: 516 additions & 15 deletions

File tree

src/__tests__/client.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,3 +346,30 @@ test('client throws AppError for daemon failures', async () => {
346346
},
347347
);
348348
});
349+
350+
test('client capture.snapshot preserves visibility metadata from daemon responses', async () => {
351+
const setup = createTransport(async () => ({
352+
ok: true,
353+
data: {
354+
nodes: [],
355+
truncated: false,
356+
appBundleId: 'com.expensify.chat.dev',
357+
visibility: {
358+
partial: true,
359+
visibleNodeCount: 64,
360+
totalNodeCount: 67,
361+
reasons: ['offscreen-nodes'],
362+
},
363+
},
364+
}));
365+
const client = createAgentDeviceClient(setup.config, { transport: setup.transport });
366+
367+
const result = await client.capture.snapshot();
368+
369+
assert.deepEqual(result.visibility, {
370+
partial: true,
371+
visibleNodeCount: 64,
372+
totalNodeCount: 67,
373+
reasons: ['offscreen-nodes'],
374+
});
375+
});

src/client-shared.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ export function serializeSnapshotResult(result: CaptureSnapshotResult): Record<s
185185
truncated: result.truncated,
186186
...(result.appName ? { appName: result.appName } : {}),
187187
...(result.appBundleId ? { appBundleId: result.appBundleId } : {}),
188+
...(result.visibility ? { visibility: result.visibility } : {}),
188189
...(result.warnings && result.warnings.length > 0 ? { warnings: result.warnings } : {}),
189190
};
190191
}

src/client-types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type {
66
SessionRuntimeHints,
77
} from './daemon/types.ts';
88
import type { DeviceKind, DeviceTarget, Platform, PlatformSelector } from './utils/device.ts';
9-
import type { ScreenshotOverlayRef, SnapshotNode } from './utils/snapshot.ts';
9+
import type { ScreenshotOverlayRef, SnapshotNode, SnapshotVisibility } from './utils/snapshot.ts';
1010
import type { MetroPrepareKind, PrepareMetroRuntimeResult } from './client-metro.ts';
1111

1212
type DaemonTransportMode = 'auto' | 'socket' | 'http';
@@ -254,6 +254,7 @@ export type CaptureSnapshotResult = {
254254
truncated: boolean;
255255
appName?: string;
256256
appBundleId?: string;
257+
visibility?: SnapshotVisibility;
257258
warnings?: string[];
258259
identifiers: AgentDeviceIdentifiers;
259260
};

src/client.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import type {
2929
AppOpenOptions,
3030
CaptureScreenshotOptions,
3131
CaptureSnapshotOptions,
32+
CaptureSnapshotResult,
3233
EnsureSimulatorOptions,
3334
InternalRequestOptions,
3435
MaterializationReleaseOptions,
@@ -215,11 +216,16 @@ export function createAgentDeviceClient(
215216
const session = resolveSessionName(config.session, options.session);
216217
const data = await execute('snapshot', [], options);
217218
const appBundleId = readOptionalString(data, 'appBundleId');
219+
const visibility =
220+
typeof data.visibility === 'object' && data.visibility !== null
221+
? (data.visibility as CaptureSnapshotResult['visibility'])
222+
: undefined;
218223
return {
219224
nodes: readSnapshotNodes(data.nodes),
220225
truncated: data.truncated === true,
221226
appName: readOptionalString(data, 'appName'),
222227
appBundleId,
228+
...(visibility ? { visibility } : {}),
223229
warnings: Array.isArray(data.warnings)
224230
? data.warnings.filter((entry): entry is string => typeof entry === 'string')
225231
: undefined,

src/daemon/handlers/__tests__/snapshot-handler.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,60 @@ test('snapshot warns when Android freshness retries still return the previous ro
332332
expect(mockDispatch).toHaveBeenCalledTimes(3);
333333
});
334334

335+
test('snapshot response includes normalized visibility metadata', async () => {
336+
const sessionStore = makeSessionStore();
337+
const sessionName = 'android-visibility';
338+
sessionStore.set(sessionName, makeSession(sessionName, androidDevice));
339+
340+
mockDispatch.mockResolvedValue({
341+
nodes: [
342+
{
343+
index: 0,
344+
depth: 0,
345+
type: 'android.widget.ScrollView',
346+
label: 'Messages',
347+
rect: { x: 0, y: 100, width: 390, height: 500 },
348+
hiddenContentBelow: true,
349+
},
350+
{
351+
index: 1,
352+
depth: 1,
353+
parentIndex: 0,
354+
type: 'android.widget.Button',
355+
label: 'Visible message',
356+
rect: { x: 0, y: 140, width: 390, height: 48 },
357+
hittable: true,
358+
},
359+
],
360+
truncated: false,
361+
backend: 'android',
362+
analysis: { rawNodeCount: 2, maxDepth: 1 },
363+
});
364+
365+
const response = await handleSnapshotCommands({
366+
req: {
367+
token: 't',
368+
session: sessionName,
369+
command: 'snapshot',
370+
positionals: [],
371+
flags: { snapshotInteractiveOnly: true },
372+
},
373+
sessionName,
374+
logPath: '/tmp/daemon.log',
375+
sessionStore,
376+
});
377+
378+
expect(response?.ok).toBe(true);
379+
if (response?.ok) {
380+
expect(response.data?.visibility).toEqual({
381+
partial: true,
382+
visibleNodeCount: 2,
383+
totalNodeCount: 2,
384+
reasons: ['scroll-hidden-below'],
385+
});
386+
}
387+
});
388+
335389
test('diff snapshot carries stale-tree warnings for recent Android presses', async () => {
336390
const sessionStore = makeSessionStore();
337391
const sessionName = 'android-diff-stale-after-press';

src/daemon/handlers/snapshot-capture.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ import {
77
normalizeRef,
88
type RawSnapshotNode,
99
type SnapshotState,
10+
type SnapshotVisibility,
1011
} from '../../utils/snapshot.ts';
1112
import { normalizeSnapshotTree } from '../../utils/snapshot-tree.ts';
13+
import { buildMobileSnapshotPresentation } from '../../utils/mobile-snapshot-semantics.ts';
1214
import type { DaemonResponse, SessionState } from '../types.ts';
1315
import {
1416
ANDROID_FRESHNESS_RETRY_DELAYS_MS,
@@ -202,6 +204,41 @@ export function buildSnapshotState(
202204
};
203205
}
204206

207+
export function buildSnapshotVisibility(params: {
208+
nodes: SnapshotState['nodes'];
209+
backend?: SnapshotState['backend'];
210+
snapshotRaw?: boolean;
211+
}): SnapshotVisibility {
212+
const { nodes, backend, snapshotRaw } = params;
213+
if (snapshotRaw || backend === 'macos-helper') {
214+
return {
215+
partial: false,
216+
visibleNodeCount: nodes.length,
217+
totalNodeCount: nodes.length,
218+
reasons: [],
219+
};
220+
}
221+
222+
const presentation = buildMobileSnapshotPresentation(nodes);
223+
const reasons = new Set<SnapshotVisibility['reasons'][number]>();
224+
if (presentation.hiddenCount > 0) {
225+
reasons.add('offscreen-nodes');
226+
}
227+
if (presentation.nodes.some((node) => node.hiddenContentAbove)) {
228+
reasons.add('scroll-hidden-above');
229+
}
230+
if (presentation.nodes.some((node) => node.hiddenContentBelow)) {
231+
reasons.add('scroll-hidden-below');
232+
}
233+
234+
return {
235+
partial: reasons.size > 0,
236+
visibleNodeCount: presentation.nodes.length,
237+
totalNodeCount: nodes.length,
238+
reasons: [...reasons],
239+
};
240+
}
241+
205242
function shapeMacOsSurfaceSnapshot(
206243
data: SnapshotData,
207244
options: {

src/daemon/handlers/snapshot.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import { isCommandSupportedOnDevice } from '../../core/capabilities.ts';
22
import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts';
33
import { SessionStore } from '../session-store.ts';
44
import { buildSnapshotDiff, countSnapshotComparableLines } from '../snapshot-diff.ts';
5-
import { captureSnapshot, resolveSnapshotScope } from './snapshot-capture.ts';
5+
import {
6+
buildSnapshotVisibility,
7+
captureSnapshot,
8+
resolveSnapshotScope,
9+
} from './snapshot-capture.ts';
610
import {
711
buildSnapshotSession,
812
recordIfSession,
@@ -60,6 +64,11 @@ export async function handleSnapshotCommands(params: {
6064
flags: req.flags,
6165
session,
6266
});
67+
const visibility = buildSnapshotVisibility({
68+
nodes: capture.snapshot.nodes,
69+
backend: capture.snapshot.backend,
70+
snapshotRaw: req.flags?.snapshotRaw,
71+
});
6372
const nextSession = buildSnapshotSession({
6473
session,
6574
sessionName,
@@ -77,6 +86,7 @@ export async function handleSnapshotCommands(params: {
7786
data: {
7887
nodes: capture.snapshot.nodes,
7988
truncated: capture.snapshot.truncated ?? false,
89+
visibility,
8090
...(warnings.length > 0 ? { warnings } : {}),
8191
appName: nextSession.appBundleId
8292
? (nextSession.appName ?? nextSession.appBundleId)

src/platforms/android/__tests__/scroll-hints.test.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,3 +108,150 @@ test('annotateAndroidScrollableContentHints marks bottomed-out scroll areas with
108108
assert.equal(nodes[0].hiddenContentAbove, true);
109109
assert.equal(nodes[0].hiddenContentBelow, undefined);
110110
});
111+
112+
test('annotateAndroidScrollableContentHints infers bottomed-out scroll areas from a single aligned block', () => {
113+
const nodes: RawSnapshotNode[] = [
114+
{
115+
index: 0,
116+
type: 'android.widget.ScrollView',
117+
label: 'Messages',
118+
rect: { x: 0, y: 100, width: 390, height: 500 },
119+
depth: 0,
120+
},
121+
{
122+
index: 1,
123+
type: 'android.view.ViewGroup',
124+
rect: { x: 0, y: 100, width: 390, height: 500 },
125+
depth: 1,
126+
parentIndex: 0,
127+
},
128+
{
129+
index: 2,
130+
type: 'android.view.ViewGroup',
131+
rect: { x: 0, y: 432, width: 390, height: 168 },
132+
depth: 2,
133+
parentIndex: 1,
134+
},
135+
];
136+
137+
const dump = [
138+
' com.facebook.react.views.scroll.ReactScrollView{d32a800 VFED.V... ........ 0,0-390,500 #4b2}',
139+
' com.facebook.react.views.view.ReactViewGroup{77d31ae V.E...... ........ 0,0-390,804 #4b0}',
140+
' com.facebook.react.views.view.ReactViewGroup{c V.E...... ........ 0,636-390,804 #3}',
141+
].join('\n');
142+
143+
annotateAndroidScrollableContentHints(nodes, dump);
144+
145+
assert.equal(nodes[0].hiddenContentAbove, true);
146+
assert.equal(nodes[0].hiddenContentBelow, undefined);
147+
});
148+
149+
test('annotateAndroidScrollableContentHints infers virtualized scroll coverage without a unique block offset', () => {
150+
const nodes: RawSnapshotNode[] = [
151+
{
152+
index: 0,
153+
type: 'android.widget.ScrollView',
154+
label: 'Messages',
155+
rect: { x: 0, y: 100, width: 390, height: 500 },
156+
depth: 0,
157+
},
158+
{
159+
index: 1,
160+
type: 'android.view.ViewGroup',
161+
rect: { x: 0, y: 100, width: 390, height: 500 },
162+
depth: 1,
163+
parentIndex: 0,
164+
},
165+
{
166+
index: 2,
167+
type: 'android.view.ViewGroup',
168+
rect: { x: 0, y: 100, width: 390, height: 143 },
169+
depth: 2,
170+
parentIndex: 1,
171+
},
172+
...Array.from({ length: 11 }, (_value, index) => ({
173+
index: index + 3,
174+
type: 'android.view.ViewGroup',
175+
rect: { x: 0, y: 243 + index * 192, width: 390, height: 192 },
176+
depth: 2,
177+
parentIndex: 1,
178+
})),
179+
];
180+
181+
const dump = [
182+
' com.facebook.react.views.scroll.ReactScrollView{d32a800 VFED.V... ........ 0,0-390,500 #4b2}',
183+
' com.facebook.react.views.view.ReactViewGroup{77d31ae V.E...... ........ 0,0-390,853 #4b0}',
184+
' com.facebook.react.views.view.ReactViewGroup{a V.E...... ........ 0,285-390,477 #1}',
185+
' com.facebook.react.views.view.ReactViewGroup{b V.E...... ........ 0,477-390,669 #2}',
186+
' com.facebook.react.views.view.ReactViewGroup{c V.E...... ........ 0,669-390,861 #3}',
187+
' com.facebook.react.views.view.ReactViewGroup{d V.E...... ........ 0,861-390,1053 #4}',
188+
' com.facebook.react.views.view.ReactViewGroup{e V.E...... ........ 0,1053-390,1245 #5}',
189+
' com.facebook.react.views.view.ReactViewGroup{f V.E...... ........ 0,1245-390,1437 #6}',
190+
].join('\n');
191+
192+
annotateAndroidScrollableContentHints(nodes, dump);
193+
194+
assert.equal(nodes[0].hiddenContentAbove, true);
195+
assert.equal(nodes[0].hiddenContentBelow, true);
196+
});
197+
198+
test('annotateAndroidScrollableContentHints keeps shallow offset matching for fully mounted content', () => {
199+
const nodes: RawSnapshotNode[] = [
200+
{
201+
index: 0,
202+
type: 'android.widget.ScrollView',
203+
label: 'Messages',
204+
rect: { x: 0, y: 100, width: 390, height: 500 },
205+
depth: 0,
206+
},
207+
{
208+
index: 1,
209+
type: 'android.view.ViewGroup',
210+
rect: { x: 0, y: 100, width: 390, height: 500 },
211+
depth: 1,
212+
parentIndex: 0,
213+
},
214+
{
215+
index: 2,
216+
type: 'android.view.ViewGroup',
217+
rect: { x: 0, y: 100, width: 390, height: 100 },
218+
depth: 2,
219+
parentIndex: 1,
220+
},
221+
{
222+
index: 3,
223+
type: 'android.view.ViewGroup',
224+
rect: { x: 0, y: 200, width: 390, height: 180 },
225+
depth: 2,
226+
parentIndex: 1,
227+
},
228+
{
229+
index: 4,
230+
type: 'android.view.ViewGroup',
231+
rect: { x: 0, y: 380, width: 390, height: 120 },
232+
depth: 2,
233+
parentIndex: 1,
234+
},
235+
{
236+
index: 5,
237+
type: 'android.view.ViewGroup',
238+
rect: { x: 0, y: 500, width: 390, height: 100 },
239+
depth: 2,
240+
parentIndex: 1,
241+
},
242+
];
243+
244+
const dump = [
245+
' com.facebook.react.views.scroll.ReactScrollView{d32a800 VFED.V... ........ 0,0-390,500 #4b2}',
246+
' com.facebook.react.views.view.ReactViewGroup{77d31ae V.E...... ........ 0,0-390,520 #4b0}',
247+
' com.facebook.react.views.view.ReactViewGroup{a V.E...... ........ 0,20-390,120 #1}',
248+
' com.facebook.react.views.view.ReactViewGroup{b V.E...... ........ 0,120-390,300 #2}',
249+
' com.facebook.react.views.view.ReactViewGroup{c V.E...... ........ 0,300-390,420 #3}',
250+
' com.facebook.react.views.view.ReactViewGroup{d V.E...... ........ 0,420-390,520 #4}',
251+
].join('\n');
252+
253+
annotateAndroidScrollableContentHints(nodes, dump);
254+
255+
assert.equal(nodes[0].hiddenContentAbove, true);
256+
assert.equal(nodes[0].hiddenContentBelow, undefined);
257+
});

0 commit comments

Comments
 (0)