diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index ea5e249d5..40034481e 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -80,6 +80,35 @@ test('apps.open resolves session device identifiers from open response', async ( assert.equal(result.device?.ios?.simulatorSetPath, '/tmp/sim-set'); }); +test('devices.boot forwards Android emulator camera options', async () => { + const setup = createTransport(async () => ({ + ok: true, + data: { + platform: 'android', + target: 'mobile', + device: 'Pixel_9_Pro_XL', + id: 'emulator-5554', + kind: 'emulator', + booted: true, + }, + })); + const client = createAgentDeviceClient(setup.config, { transport: setup.transport }); + + await client.devices.boot({ + platform: 'android', + device: 'Pixel_9_Pro_XL', + cameraFront: '/tmp/front.mp4', + cameraBack: 'virtualscene', + }); + + assert.equal(setup.calls.length, 1); + assert.equal(setup.calls[0]?.command, 'boot'); + assert.equal(setup.calls[0]?.flags?.platform, 'android'); + assert.equal(setup.calls[0]?.flags?.device, 'Pixel_9_Pro_XL'); + assert.equal(setup.calls[0]?.flags?.cameraFront, '/tmp/front.mp4'); + assert.equal(setup.calls[0]?.flags?.cameraBack, 'virtualscene'); +}); + test('apps.open forwards explicit runtime hints through the daemon request', async () => { const setup = createTransport(async () => ({ ok: true, diff --git a/src/client-normalizers.ts b/src/client-normalizers.ts index 25cd3d796..b306bca57 100644 --- a/src/client-normalizers.ts +++ b/src/client-normalizers.ts @@ -311,6 +311,8 @@ export function buildFlags(options: InternalRequestOptions): CommandFlags { pauseMs: options.pauseMs, pattern: options.pattern, headless: options.headless, + cameraFront: options.cameraFront, + cameraBack: options.cameraBack, restart: options.restart, replayUpdate: options.replayUpdate, replayBackend: options.replayBackend, diff --git a/src/platforms/android/devices.ts b/src/platforms/android/devices.ts index e130d5d48..36ebfea31 100644 --- a/src/platforms/android/devices.ts +++ b/src/platforms/android/devices.ts @@ -438,27 +438,9 @@ export async function ensureAndroidEmulatorBooted(params: { resolvedAvdName, params.serial, ); - if (existing && (params.cameraFront || params.cameraBack)) { - throw new AppError( - 'INVALID_STATE', - 'Android emulator camera inputs can only be applied when starting an emulator.', - { - avdName: resolvedAvdName, - serial: existing.id, - hint: 'Shut down the emulator first, then run boot again with --camera-front or --camera-back.', - }, - ); - } + assertCameraInputsCanApplyToEmulator(existing, resolvedAvdName, params); if (!existing) { - const launchArgs = ['-avd', resolvedAvdName]; - if (params.headless) { - launchArgs.push('-no-window', '-no-audio'); - } - const cameraFront = resolveAndroidEmulatorCameraMode(params.cameraFront, 'front'); - if (cameraFront) launchArgs.push('-camera-front', cameraFront); - const cameraBack = resolveAndroidEmulatorCameraMode(params.cameraBack, 'back'); - if (cameraBack) launchArgs.push('-camera-back', cameraBack); - runCmdDetached('emulator', launchArgs); + runCmdDetached('emulator', buildEmulatorLaunchArgs(resolvedAvdName, params)); } const discovered = @@ -487,6 +469,45 @@ export async function ensureAndroidEmulatorBooted(params: { }; } +function assertCameraInputsCanApplyToEmulator( + existing: DeviceInfo | undefined, + resolvedAvdName: string, + params: { + cameraFront?: string; + cameraBack?: string; + }, +): void { + if (!existing || (!params.cameraFront && !params.cameraBack)) return; + throw new AppError( + 'INVALID_STATE', + 'Android emulator camera inputs can only be applied when starting an emulator.', + { + avdName: resolvedAvdName, + serial: existing.id, + hint: 'Shut down the emulator first, then run boot again with --camera-front or --camera-back.', + }, + ); +} + +function buildEmulatorLaunchArgs( + resolvedAvdName: string, + params: { + headless?: boolean; + cameraFront?: string; + cameraBack?: string; + }, +): string[] { + const launchArgs = ['-avd', resolvedAvdName]; + if (params.headless) { + launchArgs.push('-no-window', '-no-audio'); + } + const cameraFront = resolveAndroidEmulatorCameraMode(params.cameraFront, 'front'); + if (cameraFront) launchArgs.push('-camera-front', cameraFront); + const cameraBack = resolveAndroidEmulatorCameraMode(params.cameraBack, 'back'); + if (cameraBack) launchArgs.push('-camera-back', cameraBack); + return launchArgs; +} + function resolveAndroidEmulatorCameraMode( value: string | undefined, camera: 'front' | 'back', diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index efa594947..36cf4a119 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -257,6 +257,19 @@ Additional CLI-backed methods are exposed on their domain groups with typed opti - `client.recording.record()` and `client.recording.trace()` - `client.settings.update()` +`client.devices.boot({ platform: 'android', device: 'Pixel_9_Pro_XL', headless: true })` starts an Android emulator without a GUI when it is not already running. To launch with emulator camera inputs, pass `cameraFront` and/or `cameraBack` with `emulated`, `none`, `webcam`, `virtualscene` for the back camera, or a video file path: + +```ts +await client.devices.boot({ + platform: 'android', + device: 'Pixel_9_Pro_XL', + cameraFront: './front.mp4', + cameraBack: 'virtualscene', +}); +``` + +Camera inputs are Android-emulator-only and apply only when starting the emulator; shut down a running emulator before changing them. + `client.observability.perf()` returns daemon-shaped JSON so local and remote transports expose the same metrics payload. Pass `{ area: 'metrics' }` for the broad startup/CPU/memory/frame first pass, or `{ area: 'frames' }` for a focused frame/jank-health payload. On Android and supported Apple targets, `data.metrics.fps.droppedFramePercent` is the primary frame-smoothness value. Android derives it from the current `adb shell dumpsys gfxinfo framestats` window; connected iOS devices derive it from `xcrun xctrace` Animation Hitches for the active app process. Frame samples include `windowStartedAt`, `windowEndedAt`, and `worstWindows` so agents can correlate dropped-frame clusters with logs, network entries, and their own session actions. A successful Android read resets Android frame stats; `open ` resets the Android frame window too, so agents can call `perf({ area: 'frames' })`, perform a transition or gesture, then call it again to inspect that focused window. iOS simulator and macOS app sessions report frame health as unavailable rather than inventing FPS or dropped-frame values. `client.recording.record({ action: 'start', path, quality: 5 })` starts a smaller 50% resolution video; omit `quality` to keep native/current resolution. diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 1e59dafa6..5d8dcb26d 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -38,6 +38,7 @@ agent-device boot agent-device boot --platform ios agent-device boot --platform android agent-device boot --platform android --device Pixel_9_Pro_XL --headless +agent-device boot --platform android --device Pixel_9_Pro_XL --camera-back ./back.mp4 --camera-front none agent-device shutdown --platform ios agent-device shutdown --platform android --device Pixel_9_Pro_XL agent-device open [app|url] [url] @@ -62,6 +63,8 @@ agent-device app-switcher - `boot` is mainly needed when starting a new session and `open` fails because no booted simulator/emulator is available. - Android: `boot --platform android --device ` launches that emulator in GUI mode when needed. - Android: add `--headless` to launch without opening a GUI window. +- Android: add `--camera-front ` and/or `--camera-back ` when launching an emulator with camera inputs. Supported modes are `emulated`, `none`, `webcam`, and `virtualscene` for the back camera only. File paths are converted to `videofile:` for the emulator. +- Android camera inputs apply only when starting an emulator. Shut down an already-running emulator first, then run `boot` again with the camera flags. - Android: `shutdown --platform android --device ` stops a running emulator. - `open [app|url] [url]` already boots/activates the selected target when needed. - `open ` deep links are supported on Android and iOS.