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
3 changes: 3 additions & 0 deletions src/__tests__/android-adb-public.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ test('public android-adb entrypoint exposes helpers but not resolver internals',
assert.equal('resolveAndroidAdbProvider' in androidAdb, false);
assert.equal('resolveAndroidAdbExecutor' in androidAdb, false);
assert.equal('createDeviceAdbExecutor' in androidAdb, false);
assert.equal('installAndroidAdbPackage' in androidAdb, false);
assert.equal('pullAndroidAdbFile' in androidAdb, false);
assert.equal('pushAndroidAdbFile' in androidAdb, false);
assert.equal('withAndroidAdbProvider' in androidAdb, false);
assert.equal('spawnAndroidAdbBySerial' in androidAdb, false);
});
4 changes: 4 additions & 0 deletions src/android-adb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@ export {
type AndroidAdbExecutorOptions,
type AndroidAdbProcess,
type AndroidAdbExecutorResult,
type AndroidAdbInstallOptions,
type AndroidAdbInstaller,
type AndroidAdbProvider,
type AndroidAdbPuller,
type AndroidAdbSpawner,
type AndroidAdbTransferOptions,
type AndroidPortReverseEndpoint,
type AndroidPortReverseMapping,
type AndroidPortReverseOptions,
Expand Down
86 changes: 86 additions & 0 deletions src/daemon/handlers/__tests__/record-trace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import {
overlayRecordingTouches,
} from '../../../recording/overlay.ts';
import { runCmd, runCmdBackground } from '../../../utils/exec.ts';
import { withAndroidAdbProvider } from '../../../platforms/android/adb-executor.ts';

type RunnerCall = {
command: string;
Expand Down Expand Up @@ -1034,6 +1035,91 @@ test('record start/stop overlays Android gestures by default on devices', async
}
});

test('record stop copies Android recording through provider pull capability', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'android-provider-pull';
sessionStore.set(
sessionName,
makeSession(sessionName, {
platform: 'android',
id: 'emulator-5554',
name: 'Android',
kind: 'device',
booted: true,
}),
);

mockRunCmd.mockImplementation(async (_cmd, args) => {
const command = args.join(' ');
if (
/^-s emulator-5554 shell screenrecord \/sdcard\/agent-device-recording-\d+\.mp4 >\/dev\/null 2>&1 & echo \$!$/.test(
command,
)
) {
return { stdout: '4321\n', stderr: '', exitCode: 0 };
}
if (
/^-s emulator-5554 shell stat -c %s \/sdcard\/agent-device-recording-\d+\.mp4$/.test(command)
) {
return { stdout: '1024\n', stderr: '', exitCode: 0 };
}
return { stdout: '', stderr: '', exitCode: 0 };
});

await runRecordCommand({
sessionStore,
sessionName,
positionals: ['start', './android-provider.mp4'],
});

const pullCalls: Array<{ remotePath: string; localPath: string }> = [];
const execCalls: string[][] = [];
mockRunCmd.mockImplementation(async (_cmd, args) => {
const command = args.join(' ');
if (command === '-s emulator-5554 shell ps -o pid= -p 4321') {
return { stdout: '', stderr: '', exitCode: 1 };
}
if (
/^-s emulator-5554 shell stat -c %s \/sdcard\/agent-device-recording-\d+\.mp4$/.test(command)
) {
return { stdout: '2048\n', stderr: '', exitCode: 0 };
}
return { stdout: '', stderr: '', exitCode: 0 };
});

const responseStop = await withAndroidAdbProvider(
{
exec: async (args) => {
execCalls.push(args);
if (args.join(' ') === 'shell ps -o pid= -p 4321') {
return { stdout: '', stderr: '', exitCode: 1 };
}
if (/^shell stat -c %s \/sdcard\/agent-device-recording-\d+\.mp4$/.test(args.join(' '))) {
return { stdout: '2048\n', stderr: '', exitCode: 0 };
}
return { stdout: '', stderr: '', exitCode: 0 };
},
pull: async (remotePath, localPath) => {
pullCalls.push({ remotePath, localPath });
return { stdout: '', stderr: '', exitCode: 0 };
},
},
{ serial: 'emulator-5554' },
async () =>
await runRecordCommand({
sessionStore,
sessionName,
positionals: ['stop'],
}),
);

expect(responseStop?.ok).toBe(true);
expect(pullCalls).toHaveLength(1);
expect(pullCalls[0]?.remotePath).toMatch(/^\/sdcard\/agent-device-recording-\d+\.mp4$/);
expect(pullCalls[0]?.localPath).toBe(path.resolve('./android-provider.mp4'));
expect(execCalls.some((args) => args[0] === 'pull')).toBe(false);
});

test('record start passes scaled Android screenrecord size when quality is explicit', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'android-quality';
Expand Down
5 changes: 4 additions & 1 deletion src/daemon/handlers/record-trace-android.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
AndroidAdbExecutorOptions,
AndroidAdbExecutorResult,
} from '../../platforms/android/adb-executor.ts';
import { pullAndroidAdbFile } from '../../platforms/android/adb-executor.ts';

const ANDROID_REMOTE_FILE_POLL_MS = 250;
const ANDROID_REMOTE_FILE_ATTEMPTS = 20;
Expand Down Expand Up @@ -150,8 +151,10 @@ async function copyAndroidRecordingWithValidation(params: {
// Ignore stale local file cleanup issues and let adb pull report the real failure.
}

const pullResult = await runAndroidRecordingAdb(deviceId, ['pull', remotePath, outPath], {
const device = androidDeviceForSerial(deviceId);
const pullResult = await pullAndroidAdbFile(remotePath, outPath, {
allowFailure: true,
device,
});
if (pullResult.exitCode !== 0) {
lastCopyError = formatRecordTraceExecFailure(pullResult, 'adb pull');
Expand Down
84 changes: 84 additions & 0 deletions src/platforms/android/__tests__/adb-executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
createAndroidPortReverseManager,
createDeviceAdbExecutor,
createLocalAndroidAdbProvider,
installAndroidAdbPackage,
pullAndroidAdbFile,
resolveAndroidAdbExecutor,
resolveAndroidAdbProvider,
withAndroidAdbProvider,
Expand Down Expand Up @@ -125,6 +127,39 @@ test('createLocalAndroidAdbProvider exposes exec, spawn, and reverse over local
);
});

test('createLocalAndroidAdbProvider exposes local pull and install capabilities', async () => {
mockRunCmd.mockClear();
const provider = createLocalAndroidAdbProvider({
platform: 'android',
id: 'emulator-5554',
name: 'Pixel Emulator',
kind: 'emulator',
booted: true,
});

await provider.pull?.('/sdcard/video.mp4', '/tmp/video.mp4', { allowFailure: true });
await provider.install?.('/tmp/app.apk', {
allowDowngrade: true,
allowTestPackages: true,
grantPermissions: true,
replace: true,
timeoutMs: 2000,
});

assert.deepEqual(mockRunCmd.mock.calls, [
[
'adb',
['-s', 'emulator-5554', 'pull', '/sdcard/video.mp4', '/tmp/video.mp4'],
{ allowFailure: true },
],
[
'adb',
['-s', 'emulator-5554', 'install', '-r', '-t', '-d', '-g', '/tmp/app.apk'],
{ timeoutMs: 2000 },
],
]);
});

test('createAndroidPortReverseManager makes duplicate setup idempotent and cleans owner mappings', async () => {
const calls: string[][] = [];
const manager = createAndroidPortReverseManager(async (args) => {
Expand Down Expand Up @@ -198,3 +233,52 @@ test('resolveAndroidAdbProvider does not infer reverse support for plain executo

assert.equal(provider.reverse, undefined);
});

test('explicit transfer helpers prefer provider capabilities over exec-shaped fallback', async () => {
const calls: string[] = [];

await withAndroidAdbProvider(
{
exec: async (args) => {
calls.push(`exec:${args.join(' ')}`);
return { stdout: 'exec', stderr: '', exitCode: 0 };
},
pull: async (remotePath, localPath) => {
calls.push(`pull:${remotePath}:${localPath}`);
return { stdout: 'pull', stderr: '', exitCode: 0 };
},
install: async (source, options) => {
calls.push(`install:${String(source)}:${options?.replace === true}`);
return { stdout: 'install', stderr: '', exitCode: 0 };
},
},
{ serial: 'emulator-5554' },
async () => {
await pullAndroidAdbFile('/remote.mp4', '/local.mp4');
await installAndroidAdbPackage('/app.apk', { replace: true });
},
);

assert.deepEqual(calls, ['pull:/remote.mp4:/local.mp4', 'install:/app.apk:true']);
});

test('explicit transfer helpers keep exec-shaped fallback for older providers', async () => {
const calls: string[][] = [];

await withAndroidAdbProvider(
async (args) => {
calls.push(args);
return { stdout: 'ok', stderr: '', exitCode: 0 };
},
{ serial: 'emulator-5554' },
async () => {
await pullAndroidAdbFile('/remote.mp4', '/local.mp4');
await installAndroidAdbPackage('/app.apk', { replace: true });
},
);

assert.deepEqual(calls, [
['pull', '/remote.mp4', '/local.mp4'],
['install', '-r', '/app.apk'],
]);
});
34 changes: 34 additions & 0 deletions src/platforms/android/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
typeAndroid,
writeAndroidClipboardText,
} from '../index.ts';
import { withAndroidAdbProvider } from '../adb-executor.ts';
import type { DeviceInfo } from '../../../utils/device.ts';
import { AppError } from '../../../utils/errors.ts';
import { findBounds, parseUiHierarchy } from '../ui-hierarchy.ts';
Expand Down Expand Up @@ -341,6 +342,39 @@ test('installAndroidApp installs .apk via adb install -r', async () => {
await fs.rm(apkPath, { force: true });
});

test('installAndroidInstallablePath uses provider install capability when available', async () => {
const apkPath = path.join(os.tmpdir(), `agent-device-provider-install-${Date.now()}.apk`);
await fs.writeFile(apkPath, 'placeholder', 'utf8');
const installCalls: Array<{ source: string; replace: boolean | undefined }> = [];
const device: DeviceInfo = {
platform: 'android',
id: 'emulator-5554',
name: 'Pixel',
kind: 'emulator',
booted: true,
};

try {
await withAndroidAdbProvider(
{
exec: async (args) => {
throw new Error(`unexpected adb exec: ${args.join(' ')}`);
},
install: async (source, options) => {
installCalls.push({ source: String(source), replace: options?.replace });
return { stdout: 'Success', stderr: '', exitCode: 0 };
},
},
{ serial: 'emulator-5554' },
async () => await installAndroidInstallablePath(device, apkPath),
);
} finally {
await fs.rm(apkPath, { force: true });
}

assert.deepEqual(installCalls, [{ source: apkPath, replace: true }]);
});

test('installAndroidApp resolves packageName and launchTarget from nested archive artifacts', async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-android-install-archive-'));
const adbPath = path.join(tmpDir, 'adb');
Expand Down
Loading
Loading