Skip to content

Commit 03207d9

Browse files
authored
fix: improve Android helper snapshot stability (#529)
1 parent 80895ae commit 03207d9

9 files changed

Lines changed: 634 additions & 59 deletions

File tree

src/__tests__/runtime-snapshot.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,24 @@ test('runtime snapshot emits filtered Android guidance from backend analysis', a
108108
]);
109109
});
110110

111+
test('runtime snapshot warns when Android helper falls back to stock UIAutomator', async () => {
112+
const device = createSnapshotOnlyDevice({
113+
nodes: [{ ref: 'e1', index: 0, depth: 0, type: 'Window', label: 'Home' }],
114+
truncated: false,
115+
backend: 'android',
116+
androidSnapshot: {
117+
backend: 'uiautomator-dump',
118+
fallbackReason: 'helper artifact missing',
119+
},
120+
});
121+
122+
const result = await device.capture.snapshot({ session: 'default', interactiveOnly: true });
123+
124+
assert.deepEqual(result.warnings, [
125+
'Android snapshot helper unavailable; using stock UIAutomator dump, which can time out on busy React Native UIs. Reason: helper artifact missing',
126+
]);
127+
});
128+
111129
test('runtime snapshot warns when Android hierarchy looks like a React Native overlay', async () => {
112130
const device = createSnapshotOnlyDevice({
113131
nodes: [

src/commands/capture-snapshot.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ function buildSnapshotWarnings(params: {
194194
const warnings = [...(params.result.warnings ?? [])];
195195
const interactiveOnly = params.options.interactiveOnly === true;
196196
const analysis = params.result.analysis;
197+
const androidSnapshot = params.result.androidSnapshot;
197198

198199
if (
199200
params.snapshot.backend === 'android' &&
@@ -216,6 +217,15 @@ function buildSnapshotWarnings(params: {
216217
}
217218
}
218219

220+
if (androidSnapshot?.backend === 'uiautomator-dump') {
221+
const reason = androidSnapshot.fallbackReason
222+
? ` Reason: ${androidSnapshot.fallbackReason}`
223+
: '';
224+
warnings.push(
225+
`Android snapshot helper unavailable; using stock UIAutomator dump, which can time out on busy React Native UIs.${reason}`,
226+
);
227+
}
228+
219229
if (hasReactNativeOverlay(params.snapshot.nodes)) {
220230
warnings.push(
221231
'Possible React Native warning/error overlay detected. Capture screenshot --overlay-refs, check react-devtools errors if connected, dismiss Dismiss/Close only if unrelated, re-snapshot, and report it.',

src/platforms/android/__tests__/snapshot.test.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ test('snapshotAndroid uses injected helper artifact before stock uiautomator', a
260260
assert.equal(result.androidSnapshot.installReason, 'current');
261261
assert.equal(result.androidSnapshot.captureMode, 'interactive-windows');
262262
assert.equal(result.androidSnapshot.windowCount, 1);
263-
assert.deepEqual(timeouts, [30000, 13000]);
263+
assert.deepEqual(timeouts, [30000, 30000]);
264264
assert.equal(mockRunCmd.mock.calls.length, 0);
265265
});
266266

@@ -347,6 +347,45 @@ test('snapshotAndroid falls back to stock uiautomator when helper fails', async
347347
assert.equal(mockRunCmd.mock.calls.length, 0);
348348
});
349349

350+
test('snapshotAndroid preserves helper failure reason when stock fallback fails', async () => {
351+
const helperAdb: AndroidAdbExecutor = async (args) => {
352+
if (args.includes('--show-versioncode')) {
353+
return {
354+
exitCode: 0,
355+
stdout: 'package:com.callstack.agentdevice.snapshothelper versionCode:13003',
356+
stderr: '',
357+
};
358+
}
359+
if (args.includes('exec-out')) {
360+
throw new AppError('COMMAND_FAILED', 'stock dump timed out', { hint: 'stock hint' });
361+
}
362+
return { exitCode: 1, stdout: '', stderr: 'instrumentation failed' };
363+
};
364+
365+
await assert.rejects(
366+
() =>
367+
snapshotAndroid(device, {
368+
helperAdb,
369+
helperArtifact: {
370+
apkPath: '/tmp/helper.apk',
371+
manifest: helperManifest,
372+
},
373+
}),
374+
(error) => {
375+
assert.ok(error instanceof AppError);
376+
assert.match(error.message, /stock dump timed out/);
377+
assert.match(error.message, /Android snapshot helper failed before stock fallback/);
378+
assert.match(error.message, /failed before returning parseable output/);
379+
assert.equal(
380+
error.details?.androidSnapshotHelperFallbackReason,
381+
'Android snapshot helper failed before returning parseable output',
382+
);
383+
assert.equal(error.details?.hint, 'stock hint');
384+
return true;
385+
},
386+
);
387+
});
388+
350389
test('snapshotAndroid re-probes helper install after helper capture failure', async () => {
351390
let versionProbeCount = 0;
352391
let instrumentAttempts = 0;

src/platforms/android/snapshot.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import fs from 'node:fs/promises';
22
import path from 'node:path';
33
import { withRetry } from '../../utils/retry.ts';
4-
import { AppError, normalizeError } from '../../utils/errors.ts';
4+
import { AppError, normalizeError, toAppErrorCode } from '../../utils/errors.ts';
55
import type { DeviceInfo } from '../../utils/device.ts';
66
import { findProjectRoot, readVersion } from '../../utils/version.ts';
77
import {
@@ -39,7 +39,9 @@ import {
3939

4040
const UI_HIERARCHY_DUMP_TIMEOUT_MS = 8_000;
4141
const HELPER_INSTALL_TIMEOUT_MS = 30_000;
42-
const HELPER_COMMAND_TIMEOUT_MS = UI_HIERARCHY_DUMP_TIMEOUT_MS + 5_000;
42+
// Large React Native trees can legitimately exceed the stock dump timeout while
43+
// the helper is still making progress; keep this below the full fallback path.
44+
const HELPER_COMMAND_TIMEOUT_MS = 30_000;
4345

4446
type AndroidSnapshotOptions = SnapshotOptions & {
4547
helperArtifact?: AndroidSnapshotHelperArtifact;
@@ -182,15 +184,41 @@ async function captureStockUiHierarchy(
182184
fallbackReason?: string,
183185
adb?: AndroidAdbExecutor,
184186
): Promise<{ xml: string; metadata: AndroidSnapshotBackendMetadata }> {
187+
let xml: string;
188+
try {
189+
xml = await dumpUiHierarchy(device, adb);
190+
} catch (error) {
191+
if (fallbackReason) {
192+
throw enrichStockSnapshotFailureWithHelperReason(error, fallbackReason);
193+
}
194+
throw error;
195+
}
185196
return {
186-
xml: await dumpUiHierarchy(device, adb),
197+
xml,
187198
metadata: {
188199
backend: 'uiautomator-dump',
189200
...(fallbackReason ? { fallbackReason } : {}),
190201
},
191202
};
192203
}
193204

205+
function enrichStockSnapshotFailureWithHelperReason(
206+
error: unknown,
207+
fallbackReason: string,
208+
): AppError {
209+
const normalized = normalizeError(error);
210+
return new AppError(
211+
toAppErrorCode(normalized.code),
212+
`${normalized.message} Android snapshot helper failed before stock fallback: ${fallbackReason}`,
213+
{
214+
...normalized.details,
215+
androidSnapshotHelperFallbackReason: fallbackReason,
216+
...(normalized.hint ? { hint: normalized.hint } : {}),
217+
},
218+
error,
219+
);
220+
}
221+
194222
function getAndroidSnapshotHelperDeviceKey(device: DeviceInfo): string {
195223
// Emulator serials are port-based and can be reused after restart; capture failure invalidates
196224
// this key before falling back so stale process-local entries self-heal on the next snapshot.

src/utils/__tests__/args.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -970,6 +970,10 @@ test('usageForCommand resolves react-devtools help topic', () => {
970970
/agent-device react-devtools profile diff before\.json after\.json --limit 10/,
971971
);
972972
assert.match(help, /render causes and changed props\/state\/hooks/);
973+
assert.match(help, /logs clear --restart before the first logs mark/);
974+
assert.match(help, /agent-device logs mark "before catalog search"/);
975+
assert.match(help, /Do not write agent-devtools/);
976+
assert.match(help, /agent-device network dump --include headers/);
973977
assert.match(help, /@c refs reset after reload\/remount/);
974978
assert.match(help, /isolated --state-dir/);
975979
assert.match(help, /local service tunnel/);

src/utils/__tests__/output.test.ts

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,185 @@ test('formatSnapshotText keeps zero-height visible nodes out of off-screen summa
301301
assert.match(text, /\[off-screen below\] 1 interactive item: "Later"/);
302302
});
303303

304+
test('formatSnapshotText collapses inactive Android helper nodes in human output', () => {
305+
const nodes = [
306+
{
307+
ref: 'e1',
308+
index: 0,
309+
depth: 0,
310+
type: 'Window',
311+
rect: { x: 0, y: 0, width: 390, height: 844 },
312+
},
313+
{
314+
ref: 'e2',
315+
index: 1,
316+
depth: 1,
317+
parentIndex: 0,
318+
type: 'android.widget.Button',
319+
label: 'Alice, Today, filed the expense',
320+
rect: { x: 0, y: 420, width: 390, height: 96 },
321+
hittable: true,
322+
},
323+
{
324+
ref: 'e3',
325+
index: 2,
326+
depth: 2,
327+
parentIndex: 1,
328+
type: 'android.widget.Button',
329+
label: 'alice@example.com',
330+
rect: { x: 16, y: 432, width: 48, height: 48 },
331+
hittable: true,
332+
},
333+
{
334+
ref: 'e4',
335+
index: 3,
336+
depth: 2,
337+
parentIndex: 1,
338+
type: 'android.widget.Button',
339+
label: 'alice@example.com',
340+
rect: { x: 80, y: 432, width: 120, height: 48 },
341+
hittable: true,
342+
},
343+
{
344+
ref: 'e5',
345+
index: 4,
346+
depth: 3,
347+
parentIndex: 3,
348+
type: 'android.widget.TextView',
349+
label: 'Alice',
350+
rect: { x: 80, y: 432, width: 120, height: 48 },
351+
},
352+
{
353+
ref: 'e6',
354+
index: 5,
355+
depth: 1,
356+
parentIndex: 0,
357+
type: 'android.widget.Button',
358+
label: 'Invisible stale action',
359+
rect: { x: 0, y: 160, width: 390, height: 0 },
360+
hittable: true,
361+
},
362+
{
363+
ref: 'e7',
364+
index: 6,
365+
depth: 1,
366+
parentIndex: 0,
367+
type: 'android.widget.EditText',
368+
label: 'Write something...',
369+
identifier: 'composer',
370+
rect: { x: 72, y: 760, width: 240, height: 44 },
371+
hittable: true,
372+
},
373+
{
374+
ref: 'e8',
375+
index: 7,
376+
depth: 1,
377+
parentIndex: 0,
378+
type: 'android.view.View',
379+
label: 'Dashboard',
380+
rect: { x: 0, y: 720, width: 78, height: 96 },
381+
hittable: true,
382+
},
383+
{
384+
ref: 'e9',
385+
index: 8,
386+
depth: 2,
387+
parentIndex: 7,
388+
type: 'android.widget.TextView',
389+
label: 'Dashboard',
390+
rect: { x: 20, y: 780, width: 40, height: 24 },
391+
},
392+
{
393+
ref: 'e10',
394+
index: 9,
395+
depth: 1,
396+
parentIndex: 0,
397+
type: 'android.view.View',
398+
label: 'Messages. Your review is required',
399+
rect: { x: 78, y: 720, width: 78, height: 96 },
400+
hittable: true,
401+
},
402+
{
403+
ref: 'e11',
404+
index: 10,
405+
depth: 2,
406+
parentIndex: 9,
407+
type: 'android.widget.TextView',
408+
label: 'Messages',
409+
rect: { x: 98, y: 780, width: 40, height: 24 },
410+
},
411+
{
412+
ref: 'e12',
413+
index: 11,
414+
depth: 1,
415+
parentIndex: 0,
416+
type: 'android.view.View',
417+
label: 'Billing',
418+
rect: { x: 156, y: 720, width: 78, height: 96 },
419+
hittable: true,
420+
},
421+
{
422+
ref: 'e13',
423+
index: 12,
424+
depth: 2,
425+
parentIndex: 11,
426+
type: 'android.widget.TextView',
427+
label: 'Billing',
428+
rect: { x: 176, y: 780, width: 40, height: 24 },
429+
},
430+
{
431+
ref: 'e14',
432+
index: 13,
433+
depth: 1,
434+
parentIndex: 0,
435+
type: 'android.view.View',
436+
label: 'Profile, My settings.',
437+
rect: { x: 312, y: 720, width: 78, height: 96 },
438+
hittable: true,
439+
},
440+
{
441+
ref: 'e15',
442+
index: 14,
443+
depth: 2,
444+
parentIndex: 13,
445+
type: 'android.widget.TextView',
446+
label: 'Profile',
447+
rect: { x: 332, y: 780, width: 40, height: 24 },
448+
},
449+
];
450+
const text = withNoColor(() =>
451+
formatSnapshotText({
452+
nodes,
453+
truncated: false,
454+
androidSnapshot: { backend: 'android-helper' },
455+
}),
456+
);
457+
458+
assert.match(text, /Snapshot: 4 visible nodes \(15 total\)/);
459+
assert.match(text, /Collapsed 11 inactive Android helper nodes from text output/);
460+
assert.match(text, /@e3 \[button\] "alice@example\.com"/);
461+
assert.doesNotMatch(text, /@e4 \[button\] "alice@example\.com"/);
462+
assert.doesNotMatch(text, /Invisible stale action/);
463+
assert.doesNotMatch(text, /\[group\] "Dashboard"/);
464+
assert.doesNotMatch(text, /\[group\] "Messages/);
465+
assert.doesNotMatch(text, /\[group\] "Billing"/);
466+
assert.doesNotMatch(text, /\[group\] "Profile/);
467+
assert.doesNotMatch(text, /possible repeated nav subtree/);
468+
469+
const raw = withNoColor(() =>
470+
formatSnapshotText(
471+
{
472+
nodes,
473+
truncated: false,
474+
androidSnapshot: { backend: 'android-helper' },
475+
},
476+
{ raw: true },
477+
),
478+
);
479+
assert.match(raw, /"Invisible stale action"/);
480+
assert.match(raw, /"Messages\. Your review is required"/);
481+
});
482+
304483
test('formatSnapshotText renders explicit hidden scroll-area content hints', () => {
305484
const text = withNoColor(() =>
306485
formatSnapshotText({

0 commit comments

Comments
 (0)