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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ The project is in early development and considered experimental. Pull requests a

## Features
- Platforms: iOS/tvOS (simulator + physical device core automation) and Android/AndroidTV (emulator + device).
- Core commands: `open`, `back`, `home`, `app-switcher`, `press`, `long-press`, `focus`, `type`, `fill`, `scroll`, `scrollintoview`, `wait`, `alert`, `screenshot`, `close`, `install`, `reinstall`, `push`, `trigger-app-event`.
- Core commands: `open`, `back`, `home`, `app-switcher`, `press`, `long-press`, `focus`, `type`, `fill`, `scroll`, `scrollintoview`, `wait`, `alert`, `screenshot`, `close`, `install`, `install-from-source`, `reinstall`, `push`, `trigger-app-event`.
- Inspection commands: `snapshot` (accessibility tree), `diff snapshot` (structural baseline diff), `appstate`, `apps`, `devices`.
- Clipboard commands: `clipboard read`, `clipboard write <text>`.
- Keyboard commands: `keyboard status|get|dismiss` (Android).
Expand Down Expand Up @@ -344,8 +344,10 @@ Navigation helpers:
- Use `boot` mainly when starting a new session and `open` fails because no booted simulator/emulator is available.
- `open [app|url] [url]` already boots/activates the selected target when needed.
- `install <app> <path>` installs app binary without uninstalling first (Android + iOS simulator/device).
- `install-from-source <url>` installs from a URL source through the normal daemon artifact flow; repeat `--header name:value` for authenticated downloads.
- `reinstall <app> <path>` uninstalls and installs the app binary in one command (Android + iOS simulator/device).
- `install`/`reinstall` accept package/bundle id style app names and support `~` in paths.
- `install-from-source` supports `--retain-paths` and `--retention-ms <ms>` when callers need retained materialized artifact paths after the install.
- When `AGENT_DEVICE_DAEMON_BASE_URL` targets a remote daemon, local `.apk`/`.aab`/`.ipa` files and `.app` bundles are uploaded automatically before `install`/`reinstall`.
- Remote daemon clients can persist session-scoped runtime hints with `runtime set` before `open`; Android launches write React Native dev prefs, and iOS simulator launches write React Native bundle defaults before app start. Example: `agent-device runtime set --session my-session --platform android --metro-host 10.0.0.10 --metro-port 8081 --launch-url "myapp://dev"`.
- Remote daemon screenshots and recordings are materialized back to the caller path instead of returning host-local daemon paths.
Expand Down
1 change: 1 addition & 0 deletions skills/agent-device/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ agent-device open [app|url] [url]
agent-device open [app] --relaunch
agent-device close [app]
agent-device install <app> <path-to-binary>
agent-device install-from-source <url> [--header "name:value"]
agent-device reinstall <app> <path-to-binary>
agent-device session list
```
Expand Down
155 changes: 155 additions & 0 deletions src/__tests__/cli-client-commands.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { tryRunClientBackedCommand } from '../cli-client-commands.ts';
import type { AgentDeviceClient, AppInstallFromSourceOptions } from '../client.ts';
import { AppError } from '../utils/errors.ts';

test('install-from-source forwards URL and repeated headers to client.apps.installFromSource', async () => {
let observed: AppInstallFromSourceOptions | undefined;
const client = createStubClient({
installFromSource: async (options) => {
observed = options;
return {
launchTarget: 'com.example.demo',
packageName: 'com.example.demo',
identifiers: { appId: 'com.example.demo', package: 'com.example.demo' },
};
},
});

const handled = await tryRunClientBackedCommand({
command: 'install-from-source',
positionals: ['https://example.com/app.apk'],
flags: {
json: false,
help: false,
version: false,
platform: 'android',
header: ['authorization: Bearer token', 'x-build-id: 42'],
retainPaths: true,
retentionMs: 60_000,
},
client,
});

assert.equal(handled, true);
assert.equal(observed?.platform, 'android');
assert.equal(observed?.retainPaths, true);
assert.equal(observed?.retentionMs, 60_000);
assert.deepEqual(observed?.source, {
kind: 'url',
url: 'https://example.com/app.apk',
headers: {
authorization: 'Bearer token',
'x-build-id': '42',
},
});
});

test('install-from-source rejects malformed header syntax', async () => {
const client = createStubClient({
installFromSource: async () => {
throw new Error('unexpected call');
},
});

await assert.rejects(
() =>
tryRunClientBackedCommand({
command: 'install-from-source',
positionals: ['https://example.com/app.apk'],
flags: {
json: false,
help: false,
version: false,
header: ['authorization'],
},
client,
}),
(error) =>
error instanceof AppError &&
error.code === 'INVALID_ARGS' &&
error.message.includes('Expected "name:value"'),
);
});

function createStubClient(params: {
installFromSource: AgentDeviceClient['apps']['installFromSource'];
}): AgentDeviceClient {
return {
devices: {
list: async () => [],
},
sessions: {
list: async () => [],
close: async () => ({ session: 'default', identifiers: { session: 'default' } }),
},
simulators: {
ensure: async () => ({
udid: 'sim-1',
device: 'iPhone 16',
runtime: 'iOS-18-0',
created: false,
booted: true,
identifiers: {
deviceId: 'sim-1',
deviceName: 'iPhone 16',
udid: 'sim-1',
},
}),
},
apps: {
install: async () => ({
app: 'Demo',
appPath: '/tmp/Demo.app',
platform: 'ios',
identifiers: { appId: 'com.example.demo' },
}),
reinstall: async () => ({
app: 'Demo',
appPath: '/tmp/Demo.app',
platform: 'ios',
identifiers: { appId: 'com.example.demo' },
}),
installFromSource: params.installFromSource,
open: async () => ({
session: 'default',
identifiers: { session: 'default' },
}),
close: async () => ({
session: 'default',
identifiers: { session: 'default' },
}),
},
materializations: {
release: async (options) => ({
released: true,
materializationId: options.materializationId,
identifiers: { session: options.session ?? 'default' },
}),
},
runtime: {
set: async () => ({
session: 'default',
configured: true,
identifiers: { session: 'default' },
}),
show: async () => ({
session: 'default',
configured: false,
identifiers: { session: 'default' },
}),
},
capture: {
snapshot: async () => ({
nodes: [],
truncated: false,
identifiers: { session: 'default' },
}),
screenshot: async () => ({
path: '/tmp/screenshot.png',
identifiers: { session: 'default' },
}),
},
};
}
26 changes: 25 additions & 1 deletion src/__tests__/client-shared.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { serializeOpenResult, serializeSessionListEntry } from '../client-shared.ts';
import {
serializeInstallFromSourceResult,
serializeOpenResult,
serializeSessionListEntry,
} from '../client-shared.ts';

test('serializeSessionListEntry preserves legacy android session payload shape', () => {
const data = serializeSessionListEntry({
Expand Down Expand Up @@ -74,3 +78,23 @@ test('serializeOpenResult includes android serial for open payloads', () => {
serial: 'emulator-5554',
});
});

test('serializeInstallFromSourceResult uses install-family package naming', () => {
const data = serializeInstallFromSourceResult({
launchTarget: 'com.example.demo',
appName: 'Demo',
appId: 'com.example.demo',
packageName: 'com.example.demo',
identifiers: {
appId: 'com.example.demo',
package: 'com.example.demo',
},
});

assert.deepEqual(data, {
launchTarget: 'com.example.demo',
appName: 'Demo',
appId: 'com.example.demo',
package: 'com.example.demo',
});
});
59 changes: 59 additions & 0 deletions src/cli-client-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
serializeDeployResult,
serializeDevice,
serializeEnsureSimulatorResult,
serializeInstallFromSourceResult,
serializeOpenResult,
serializeRuntimeResult,
serializeSessionListEntry,
Expand Down Expand Up @@ -113,6 +114,11 @@ const clientCommandHandlers: Partial<Record<string, ClientCommandHandler>> = {
if (flags.json) printJson({ success: true, data: serializeDeployResult(result) });
return true;
},
'install-from-source': async ({ positionals, flags, client }) => {
const result = await runInstallFromSourceCommand(positionals, flags, client);
if (flags.json) printJson({ success: true, data: serializeInstallFromSourceResult(result) });
return true;
},
open: async ({ positionals, flags, client }) => {
if (!positionals[0]) {
return false;
Expand Down Expand Up @@ -239,6 +245,59 @@ async function runDeployCommand(
: await client.apps.reinstall(options);
}

async function runInstallFromSourceCommand(
positionals: string[],
flags: CliFlags,
client: AgentDeviceClient,
) {
const url = positionals[0]?.trim();
if (!url) {
throw new AppError('INVALID_ARGS', 'install-from-source requires: install-from-source <url>');
}
if (positionals.length > 1) {
throw new AppError(
'INVALID_ARGS',
'install-from-source accepts exactly one positional argument: <url>',
);
}
return await client.apps.installFromSource({
...buildSelectionOptions(flags),
retainPaths: flags.retainPaths,
retentionMs: flags.retentionMs,
source: {
kind: 'url',
url,
headers: parseInstallSourceHeaders(flags.header),
},
});
}

function parseInstallSourceHeaders(
headerFlags: CliFlags['header'],
): Record<string, string> | undefined {
if (!headerFlags || headerFlags.length === 0) return undefined;
const headers: Record<string, string> = {};
for (const rawHeader of headerFlags) {
const separator = rawHeader.indexOf(':');
if (separator <= 0) {
throw new AppError(
'INVALID_ARGS',
`Invalid --header value "${rawHeader}". Expected "name:value".`,
);
}
const name = rawHeader.slice(0, separator).trim();
const value = rawHeader.slice(separator + 1).trim();
if (!name) {
throw new AppError(
'INVALID_ARGS',
`Invalid --header value "${rawHeader}". Header name cannot be empty.`,
);
}
headers[name] = value;
}
return headers;
}

function writeRuntimeResult(result: RuntimeResult, flags: CliFlags): void {
const data = serializeRuntimeResult(result);
if (flags.json) {
Expand Down
19 changes: 19 additions & 0 deletions src/client-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
AgentDeviceSessionDevice,
AppCloseResult,
AppDeployResult,
AppInstallFromSourceResult,
AppOpenResult,
CaptureSnapshotResult,
EnsureSimulatorResult,
Expand Down Expand Up @@ -116,6 +117,24 @@ export function serializeDeployResult(result: AppDeployResult): Record<string, u
};
}

export function serializeInstallFromSourceResult(
result: AppInstallFromSourceResult,
): Record<string, unknown> {
return {
launchTarget: result.launchTarget,
...(result.appName ? { appName: result.appName } : {}),
...(result.appId ? { appId: result.appId } : {}),
...(result.bundleId ? { bundleId: result.bundleId } : {}),
...(result.packageName ? { package: result.packageName } : {}),
...(result.installablePath ? { installablePath: result.installablePath } : {}),
...(result.archivePath ? { archivePath: result.archivePath } : {}),
...(result.materializationId ? { materializationId: result.materializationId } : {}),
...(result.materializationExpiresAt
? { materializationExpiresAt: result.materializationExpiresAt }
: {}),
};
}

export function serializeOpenResult(result: AppOpenResult): Record<string, unknown> {
return {
session: result.session,
Expand Down
4 changes: 4 additions & 0 deletions src/core/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
ios: { simulator: true, device: true },
android: { emulator: true, device: true, unknown: true },
},
'install-from-source': {
ios: { simulator: true, device: true },
android: { emulator: true, device: true, unknown: true },
},
reinstall: {
ios: { simulator: true, device: true },
android: { emulator: true, device: true, unknown: true },
Expand Down
24 changes: 24 additions & 0 deletions src/utils/__tests__/args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,28 @@ test('parseArgs accepts install command args', () => {
assert.deepEqual(parsed.positionals, ['com.example.app', './build/app.apk']);
});

test('parseArgs accepts install-from-source url and repeated headers', () => {
const parsed = parseArgs(
[
'install-from-source',
'https://example.com/builds/app.apk',
'--header',
'authorization: Bearer token',
'--header',
'x-build-id: 42',
'--retain-paths',
'--retention-ms',
'60000',
],
{ strictFlags: true },
);
assert.equal(parsed.command, 'install-from-source');
assert.deepEqual(parsed.positionals, ['https://example.com/builds/app.apk']);
assert.deepEqual(parsed.flags.header, ['authorization: Bearer token', 'x-build-id: 42']);
assert.equal(parsed.flags.retainPaths, true);
assert.equal(parsed.flags.retentionMs, 60000);
});

test('parseArgs accepts clipboard subcommands', () => {
const read = parseArgs(['clipboard', 'read'], { strictFlags: true });
assert.equal(read.command, 'clipboard');
Expand Down Expand Up @@ -398,6 +420,8 @@ test('parseArgs rejects invalid swipe pattern', () => {

test('usage includes --relaunch flag', () => {
assert.match(usage(), /--relaunch/);
assert.match(usage(), /install-from-source <url>/);
assert.match(usage(), /--header <name:value>/);
assert.match(usage(), /--restart/);
assert.match(usage(), /--target mobile\|tv/);
assert.match(usage(), /--ios-simulator-device-set <path>/);
Expand Down
Loading
Loading