Skip to content

Commit a832a1b

Browse files
committed
fix(android): actionable error when the snapshot helper is unavailable (#1284)
#1284 kept the hard-fail from #1217 (a silent stock-UIAutomator fallback produced a materially different, app-window-only capture) but asked for actionable hints on both failure modes: - Artifact missing on disk: hint now names the exact `pnpm build:android` command, the full dist file set it produces, and notes packaged installs ship it via prepack. - Artifact present but the device rejects the install (adb/OEM policy): the underlying adb error already surfaces in the message; the hint now explicitly frames this as a device-side failure distinct from a missing build artifact, tagged via a new androidSnapshotHelperInstallFailure detail set at the adb install call site.
1 parent 1a1ef7c commit a832a1b

4 files changed

Lines changed: 91 additions & 3 deletions

File tree

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,38 @@ test('ensureAndroidSnapshotHelper installs when missing and skips a newer versio
204204
assert.equal(skipped.reason, 'current');
205205
});
206206

207+
test('ensureAndroidSnapshotHelper tags a device-side install rejection distinctly from artifact resolution', async () => {
208+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'snapshot-helper-install-reject-'));
209+
const apkPath = path.join(tmpDir, 'helper.apk');
210+
await fs.writeFile(apkPath, 'helper-apk');
211+
const adb: AndroidAdbExecutor = async (args) => {
212+
if (args.includes('--show-versioncode')) {
213+
return { exitCode: 1, stdout: '', stderr: 'not found' };
214+
}
215+
if (args[0] === 'install') {
216+
return { exitCode: 1, stdout: '', stderr: 'Failure [INSTALL_FAILED_TEST_ONLY]' };
217+
}
218+
throw new Error(`unexpected adb call: ${args.join(' ')}`);
219+
};
220+
221+
await assert.rejects(
222+
() =>
223+
ensureAndroidSnapshotHelper({
224+
adb,
225+
artifact: { apkPath, manifest: { ...manifest, sha256: sha256Text('helper-apk') } },
226+
}),
227+
(error) => {
228+
assert.match((error as Error).message, /Failed to install Android snapshot helper/);
229+
assert.equal(
230+
(error as { details?: Record<string, unknown> }).details
231+
?.androidSnapshotHelperInstallFailure,
232+
true,
233+
);
234+
return true;
235+
},
236+
);
237+
});
238+
207239
test('ensureAndroidSnapshotHelper replaces same-version helper when APK bytes differ', async () => {
208240
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'snapshot-helper-identity-'));
209241
const apkPath = path.join(tmpDir, 'helper.apk');

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

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -892,6 +892,56 @@ test('snapshotAndroid emits unavailable diagnostics when helper artifact is miss
892892
}
893893
});
894894

895+
test('snapshotAndroid gives an actionable hint when the helper artifact is missing on disk', async () => {
896+
const accessSpy = vi.spyOn(fs, 'access').mockRejectedValueOnce(new Error('helper missing'));
897+
898+
try {
899+
await assert.rejects(
900+
() => snapshotAndroid(device),
901+
(error) => {
902+
assert.match((error as Error).message, /the bundled helper artifact was not found/);
903+
const hint = String((error as { details?: Record<string, unknown> }).details?.hint);
904+
assert.match(hint, /pnpm build:android/);
905+
assert.match(hint, /prepack/);
906+
assert.match(hint, /\.apk\.idsig/);
907+
assert.match(hint, /\.apk\.sha256/);
908+
assert.match(hint, /\.manifest\.json/);
909+
return true;
910+
},
911+
);
912+
} finally {
913+
accessSpy.mockRestore();
914+
}
915+
});
916+
917+
test('snapshotAndroid distinguishes a device-side install rejection from a missing build artifact', async () => {
918+
const helperAdb: AndroidAdbExecutor = async (args) => {
919+
if (args.includes('--show-versioncode')) {
920+
// No installed package reported, so ensureAndroidSnapshotHelper treats the
921+
// helper as missing and attempts a real install.
922+
return { exitCode: 0, stdout: '', stderr: '' };
923+
}
924+
if (args[0] === 'install') {
925+
return { exitCode: 1, stdout: '', stderr: 'Failure [INSTALL_FAILED_TEST_ONLY]' };
926+
}
927+
throw new Error(`unexpected adb args: ${args.join(' ')}`);
928+
};
929+
930+
await assert.rejects(
931+
() => snapshotAndroidWithHelper(helperAdb),
932+
(error) => {
933+
const message = (error as Error).message;
934+
assert.match(message, /Android snapshot helper failed/);
935+
assert.match(message, /Failed to install Android snapshot helper/);
936+
assert.match(message, /INSTALL_FAILED_TEST_ONLY/);
937+
const hint = String((error as { details?: Record<string, unknown> }).details?.hint);
938+
assert.match(hint, /device-side install failure/);
939+
assert.doesNotMatch(hint, /pnpm build:android/);
940+
return true;
941+
},
942+
);
943+
});
944+
895945
test('snapshotAndroid emits timeout diagnostics when helper capture times out', async () => {
896946
const helperAdb = createHelperAdb({
897947
instrument: async () => {

src/platforms/android/snapshot-helper-install.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,9 @@ export async function ensureAndroidSnapshotHelper(options: {
151151
throw androidAdbResultError('Failed to install Android snapshot helper', result, {
152152
packageName,
153153
versionCode,
154+
// Distinguishes a device-side install rejection (adb/OEM policy) from an
155+
// artifact-resolution failure so the capture-layer error can hint accordingly.
156+
androidSnapshotHelperInstallFailure: true,
154157
});
155158
}
156159

src/platforms/android/snapshot.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -467,10 +467,13 @@ function androidSnapshotHelperCaptureError(error: unknown, reason: string): AppE
467467
const busy =
468468
isStructuredHelperTimeout(normalized.details?.helper, normalized.message) ||
469469
isKilledHelperInstrumentationFailure(normalized);
470+
const installFailure = normalized.details?.androidSnapshotHelperInstallFailure === true;
470471
const hint = busy
471472
? 'Android accessibility snapshots can be blocked by busy or continuously changing app UI. Use screenshot as visual truth after this timeout and report the busy UI if it persists.'
472-
: (normalized.hint ??
473-
'Retry once. If the helper still fails, run agent-device doctor and report the diagnostic log; agent-device does not substitute a second snapshot engine.');
473+
: installFailure
474+
? `${normalized.hint ? `${normalized.hint} ` : ''}This is a device-side install failure (adb rejection or OEM policy) — the helper artifact itself is present, this is not a missing/unbuilt build.`
475+
: (normalized.hint ??
476+
'Retry once. If the helper still fails, run agent-device doctor and report the diagnostic log; agent-device does not substitute a second snapshot engine.');
474477
return new AppError(
475478
toAppErrorCode(normalized.code),
476479
`Android snapshot helper failed: ${reason}`,
@@ -545,7 +548,7 @@ function androidSnapshotHelperUnavailableError(errorReason: string | undefined):
545548
const reason = errorReason ?? 'the bundled helper artifact was not found';
546549
return new AppError('COMMAND_FAILED', `Android snapshot helper is unavailable: ${reason}`, {
547550
androidSnapshotHelperFailureReason: reason,
548-
hint: 'For a source checkout, run pnpm build:android. For a packaged install, reinstall agent-device; the Android snapshot helper must ship with the package.',
551+
hint: 'Run `pnpm build:android` to build the full helper dist (android/snapshot-helper/dist: the .apk, .apk.idsig, .apk.sha256, and .manifest.json) for a source checkout. Packaged installs ship this dist automatically via the prepack script — if it is missing from a packaged install, reinstall agent-device.',
549552
});
550553
}
551554

0 commit comments

Comments
 (0)