Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions src/daemon/__tests__/request-router-android-snapshot-helper.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { beforeEach, expect, test } from 'vitest';
import { createRequestHandler } from '../request-router.ts';
import { LeaseRegistry } from '../lease-registry.ts';
import { SessionStore } from '../session-store.ts';
import { AppError } from '../../kernel/errors.ts';
import { resetAndroidSnapshotHelperInstallCache } from '../../platforms/android/snapshot-helper-install.ts';
import { ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT } from '../../__tests__/test-utils/index.ts';
import type { AndroidAdbProvider } from '../../platforms/android/adb-executor.ts';

function makeAndroidSessionStore(name: string): SessionStore {
const sessionStore = new SessionStore(`/tmp/${name}`);
sessionStore.set('default', {
name: 'default',
createdAt: Date.now(),
device: {
platform: 'android',
id: 'remote-android-1',
name: 'Remote Android',
kind: 'device',
booted: true,
},
appBundleId: 'com.example.app',
actions: [],
});
return sessionStore;
}

function makeHandler(sessionStore: SessionStore, androidAdbProvider: () => AndroidAdbProvider) {
return createRequestHandler({
logPath: '/tmp/daemon.log',
token: 'token',
sessionStore,
leaseRegistry: new LeaseRegistry(),
androidAdbProvider,
trackDownloadableArtifact: () => 'artifact-id',
});
}

beforeEach(() => {
resetAndroidSnapshotHelperInstallCache();
});

// Regression for #1284/#1285 P1: a provider whose `install` REJECTS with an
// enriched INSTALL_FAILED_* error (instead of resolving with a nonzero result)
// must still surface the device-side install classification on the public
// daemon snapshot error, not the generic retry/doctor hint.
test('snapshot reports a device-side install failure when the provider install rejects', async () => {
const sessionStore = makeAndroidSessionStore(
'agent-device-request-router-snapshot-helper-install-reject-test',
);
const provider: AndroidAdbProvider = {
// Version probe reports no installed helper; every other device query is inert.
exec: async () => ({ exitCode: 0, stdout: '', stderr: '' }),
install: async () => {
throw new AppError('COMMAND_FAILED', 'Failed to install Android snapshot helper', {
stderr: 'adb: failed to install helper.apk: Failure [INSTALL_FAILED_TEST_ONLY]',
stdout: '',
exitCode: 1,
processExitError: true,
});
},
snapshotHelperArtifact: ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT,
};
const handler = makeHandler(sessionStore, () => provider);

const response = await handler({
token: 'token',
session: 'default',
command: 'snapshot',
positionals: [],
flags: {},
});

expect(response.ok).toBe(false);
if (response.ok) throw new Error('Expected snapshot to fail');
expect(response.error.message).toMatch(/Android snapshot helper failed/);
expect(response.error.message).toMatch(/INSTALL_FAILED_TEST_ONLY/);
// The provider funnel's classified adb hint is preserved, with the
// device-side framing appended rather than replacing it.
expect(response.error.hint).toMatch(/package installer rejected the APK/);
expect(response.error.hint).toMatch(/device-side install failure/);
expect(response.error.hint).not.toMatch(/pnpm build:android/);
expect(response.error.details?.androidSnapshotHelperInstallFailure).toBe(true);
});

// ADR 0010 wire contract: a transient-classified install rejection (here the
// funnel's `connection_dropped` family) must keep its structured `retriable`
// signal through the capture rewrap onto the public daemon error.
test('snapshot keeps the transient retry signal when the provider install rejection is retriable', async () => {
const sessionStore = makeAndroidSessionStore(
'agent-device-request-router-snapshot-helper-install-transient-test',
);
const provider: AndroidAdbProvider = {
exec: async () => ({ exitCode: 0, stdout: '', stderr: '' }),
install: async () => {
throw new AppError('COMMAND_FAILED', 'Failed to install Android snapshot helper', {
stderr: 'adb: transport error while pushing helper.apk',
stdout: '',
exitCode: 1,
processExitError: true,
});
},
snapshotHelperArtifact: ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT,
};
const handler = makeHandler(sessionStore, () => provider);

const response = await handler({
token: 'token',
session: 'default',
command: 'snapshot',
positionals: [],
flags: {},
});

expect(response.ok).toBe(false);
if (response.ok) throw new Error('Expected snapshot to fail');
expect(response.error.message).toMatch(/Android snapshot helper failed/);
expect(response.error.hint).toMatch(/connection dropped/);
expect(response.error.hint).toMatch(/device-side install failure/);
expect(response.error.retriable).toBe(true);
expect(response.error.details?.androidSnapshotHelperInstallFailure).toBe(true);
});
75 changes: 75 additions & 0 deletions src/platforms/android/__tests__/snapshot-helper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
forgetAndroidSnapshotHelperInstall,
resetAndroidSnapshotHelperInstallCache,
} from '../snapshot-helper-install.ts';
import { AppError } from '../../../kernel/errors.ts';
import { parseAndroidSnapshotHelperManifest } from '../snapshot-helper-artifact.ts';
import { verifyAndroidHelperApkChecksum } from '../helper-package-install.ts';
import type {
Expand Down Expand Up @@ -204,6 +205,80 @@ test('ensureAndroidSnapshotHelper installs when missing and skips a newer versio
assert.equal(skipped.reason, 'current');
});

test('ensureAndroidSnapshotHelper tags a device-side install rejection distinctly from artifact resolution', async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'snapshot-helper-install-reject-'));
const apkPath = path.join(tmpDir, 'helper.apk');
await fs.writeFile(apkPath, 'helper-apk');
const adb: AndroidAdbExecutor = async (args) => {
if (args.includes('--show-versioncode')) {
return { exitCode: 1, stdout: '', stderr: 'not found' };
}
if (args[0] === 'install') {
return { exitCode: 1, stdout: '', stderr: 'Failure [INSTALL_FAILED_TEST_ONLY]' };
}
throw new Error(`unexpected adb call: ${args.join(' ')}`);
};

await assert.rejects(
() =>
ensureAndroidSnapshotHelper({
adb,
artifact: { apkPath, manifest: { ...manifest, sha256: sha256Text('helper-apk') } },
}),
(error) => {
assert.match((error as Error).message, /Failed to install Android snapshot helper/);
assert.equal(
(error as { details?: Record<string, unknown> }).details
?.androidSnapshotHelperInstallFailure,
true,
);
return true;
},
);
});

test('ensureAndroidSnapshotHelper tags a rejected provider install without losing the enriched error', async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'snapshot-helper-install-throw-'));
const apkPath = path.join(tmpDir, 'helper.apk');
await fs.writeFile(apkPath, 'helper-apk');
const installError = new AppError('COMMAND_FAILED', 'Failed to install Android snapshot helper', {
stderr: 'adb: failed to install helper.apk: Failure [INSTALL_FAILED_TEST_ONLY]',
stdout: '',
exitCode: 1,
processExitError: true,
hint: 'The Android package installer rejected the APK — see the INSTALL_FAILED code in the error output for the exact cause.',
});
const adb: AndroidAdbExecutor = async (args) => {
if (args.includes('--show-versioncode')) {
return { exitCode: 1, stdout: '', stderr: 'not found' };
}
throw new Error(`unexpected adb call: ${args.join(' ')}`);
};
const adbProvider: AndroidAdbProvider = {
exec: adb,
install: async () => {
throw installError;
},
};

await assert.rejects(
() =>
ensureAndroidSnapshotHelper({
adb,
adbProvider,
artifact: { apkPath, manifest: { ...manifest, sha256: sha256Text('helper-apk') } },
}),
(error) => {
assert.equal(error, installError);
const details = (error as AppError).details;
assert.equal(details?.androidSnapshotHelperInstallFailure, true);
assert.match(String(details?.stderr), /INSTALL_FAILED_TEST_ONLY/);
assert.match(String(details?.hint), /package installer rejected the APK/);
return true;
},
);
});

test('ensureAndroidSnapshotHelper replaces same-version helper when APK bytes differ', async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'snapshot-helper-identity-'));
const apkPath = path.join(tmpDir, 'helper.apk');
Expand Down
73 changes: 73 additions & 0 deletions src/platforms/android/__tests__/snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -892,6 +892,79 @@ test('snapshotAndroid emits unavailable diagnostics when helper artifact is miss
}
});

test('snapshotAndroid gives an actionable hint when the helper artifact is missing on disk', async () => {
const accessSpy = vi.spyOn(fs, 'access').mockRejectedValueOnce(new Error('helper missing'));

try {
await assert.rejects(
() => snapshotAndroid(device),
(error) => {
assert.match((error as Error).message, /the bundled helper artifact was not found/);
const hint = String((error as { details?: Record<string, unknown> }).details?.hint);
assert.match(hint, /pnpm build:android/);
assert.match(hint, /prepack/);
assert.match(hint, /\.manifest\.json/);
assert.match(hint, /\.apk/);
// The npm package excludes *.idsig by design — the hint must never claim it is required.
assert.doesNotMatch(hint, /idsig/);
return true;
},
);
} finally {
accessSpy.mockRestore();
}
});

test('snapshotAndroid distinguishes a device-side install rejection from a missing build artifact', async () => {
const helperAdb: AndroidAdbExecutor = async (args) => {
if (args.includes('--show-versioncode')) {
// No installed package reported, so ensureAndroidSnapshotHelper treats the
// helper as missing and attempts a real install.
return { exitCode: 0, stdout: '', stderr: '' };
}
if (args[0] === 'install') {
return { exitCode: 1, stdout: '', stderr: 'Failure [INSTALL_FAILED_TEST_ONLY]' };
}
throw new Error(`unexpected adb args: ${args.join(' ')}`);
};

await assert.rejects(
() => snapshotAndroidWithHelper(helperAdb),
(error) => {
const message = (error as Error).message;
assert.match(message, /Android snapshot helper failed/);
assert.match(message, /Failed to install Android snapshot helper/);
assert.match(message, /INSTALL_FAILED_TEST_ONLY/);
const hint = String((error as { details?: Record<string, unknown> }).details?.hint);
assert.match(hint, /device-side install failure/);
assert.doesNotMatch(hint, /pnpm build:android/);
return true;
},
);
});

test('snapshotAndroid preserves upstream diagnosticId and logPath through the capture rewrap', async () => {
const helperAdb = createHelperAdb({
instrument: async () => {
throw new AppError('COMMAND_FAILED', 'helper capture exploded', {
diagnosticId: 'diag-upstream',
logPath: '/tmp/upstream.ndjson',
});
},
});

await assert.rejects(
() => snapshotAndroidWithHelper(helperAdb),
(error) => {
assert.match((error as Error).message, /Android snapshot helper failed/);
const details = (error as { details?: Record<string, unknown> }).details;
assert.equal(details?.diagnosticId, 'diag-upstream');
assert.equal(details?.logPath, '/tmp/upstream.ndjson');
return true;
},
);
});

test('snapshotAndroid emits timeout diagnostics when helper capture times out', async () => {
const helperAdb = createHelperAdb({
instrument: async () => {
Expand Down
54 changes: 40 additions & 14 deletions src/platforms/android/snapshot-helper-install.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { asAppError, type AppError } from '../../kernel/errors.ts';
import { readAndroidSnapshotHelperInstallOptions } from './snapshot-helper-artifact.ts';
import {
inspectInstalledAndroidHelper,
Expand Down Expand Up @@ -136,22 +137,30 @@ export async function ensureAndroidSnapshotHelper(options: {
};
}

const result = await installAndroidSnapshotHelper(
adb,
options.adbProvider ?? adb,
artifact.apkPath,
readAndroidSnapshotHelperInstallOptions(artifact.manifest),
{
packageName,
timeoutMs: options.timeoutMs,
},
);
let result: Awaited<ReturnType<AndroidAdbExecutor>>;
try {
result = await installAndroidSnapshotHelper(
adb,
options.adbProvider ?? adb,
artifact.apkPath,
readAndroidSnapshotHelperInstallOptions(artifact.manifest),
{
packageName,
timeoutMs: options.timeoutMs,
},
);
} catch (error) {
forgetInstalledSnapshotHelper(installCacheKey);
throw markAndroidSnapshotHelperInstallFailure(error, { packageName, versionCode });
}
if (result.exitCode !== 0) {
forgetInstalledSnapshotHelper(installCacheKey);
throw androidAdbResultError('Failed to install Android snapshot helper', result, {
packageName,
versionCode,
});
throw markAndroidSnapshotHelperInstallFailure(
androidAdbResultError('Failed to install Android snapshot helper', result, {
packageName,
versionCode,
}),
);
}

rememberInstalledSnapshotHelper(installCacheKey, versionCode);
Expand All @@ -165,6 +174,23 @@ export async function ensureAndroidSnapshotHelper(options: {
};
}

// Tags every failure of the helper install phase — nonzero results and provider
// rejections alike — so the capture layer can distinguish a device-side install
// rejection (adb/OEM policy) from a missing build artifact. Mutates details in
// place, preserving the original code, message, hint, and cause.
function markAndroidSnapshotHelperInstallFailure(
error: unknown,
context?: { packageName: string; versionCode: number },
): AppError {
const appError = asAppError(error, 'COMMAND_FAILED');
appError.details = {
...context,
...appError.details,
androidSnapshotHelperInstallFailure: true,
};
return appError;
}

function normalizeAdbProvider(
provider: AndroidAdbProvider | AndroidAdbExecutor | undefined,
adb: AndroidAdbExecutor,
Expand Down
Loading
Loading