Skip to content

Commit 688847f

Browse files
committed
feat: add install command for in-place app deployment
1 parent 83b757d commit 688847f

10 files changed

Lines changed: 275 additions & 61 deletions

File tree

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ The project is in early development and considered experimental. Pull requests a
1414

1515
## Features
1616
- Platforms: iOS/tvOS (simulator + physical device core automation) and Android/AndroidTV (emulator + device).
17-
- Core commands: `open`, `back`, `home`, `app-switcher`, `press`, `long-press`, `focus`, `type`, `fill`, `scroll`, `scrollintoview`, `wait`, `alert`, `screenshot`, `close`, `reinstall`, `push`, `trigger-app-event`.
17+
- 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`.
1818
- Inspection commands: `snapshot` (accessibility tree), `diff snapshot` (structural baseline diff), `appstate`, `apps`, `devices`.
1919
- Clipboard commands: `clipboard read`, `clipboard write <text>`.
2020
- Keyboard commands: `keyboard status|get|dismiss` (Android).
@@ -143,7 +143,7 @@ agent-device scrollintoview @e42
143143
```
144144

145145
## Command Index
146-
- `boot`, `open`, `close`, `reinstall`, `home`, `back`, `app-switcher`
146+
- `boot`, `open`, `close`, `install`, `reinstall`, `home`, `back`, `app-switcher`
147147
- `push`
148148
- `batch`
149149
- `snapshot`, `diff snapshot`, `find`, `get`
@@ -308,8 +308,9 @@ Navigation helpers:
308308
- `boot --platform ios|android|apple` ensures the target is ready without launching an app.
309309
- Use `boot` mainly when starting a new session and `open` fails because no booted simulator/emulator is available.
310310
- `open [app|url] [url]` already boots/activates the selected target when needed.
311+
- `install <app> <path>` installs app binary without uninstalling first (Android + iOS simulator/device).
311312
- `reinstall <app> <path>` uninstalls and installs the app binary in one command (Android + iOS simulator/device).
312-
- `reinstall` accepts package/bundle id style app names and supports `~` in paths.
313+
- `install`/`reinstall` accept package/bundle id style app names and support `~` in paths.
313314

314315
Deep links:
315316
- `open <url>` supports deep links with `scheme://...`.

src/core/__tests__/capabilities.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@ test('reinstall supports iOS simulator, iOS device, and Android', () => {
7474
assert.equal(isCommandSupportedOnDevice('reinstall', androidDevice), true, 'reinstall on Android');
7575
});
7676

77+
test('install supports iOS simulator, iOS device, and Android', () => {
78+
assert.equal(isCommandSupportedOnDevice('install', iosSimulator), true, 'install on iOS sim');
79+
assert.equal(isCommandSupportedOnDevice('install', iosDevice), true, 'install on iOS device');
80+
assert.equal(isCommandSupportedOnDevice('install', androidDevice), true, 'install on Android');
81+
});
82+
7783
test('core commands support iOS simulator, iOS device, and Android', () => {
7884
for (const cmd of [
7985
'app-switcher',
@@ -88,6 +94,7 @@ test('core commands support iOS simulator, iOS device, and Android', () => {
8894
'focus',
8995
'get',
9096
'home',
97+
'install',
9198
'longpress',
9299
'logs',
93100
'open',

src/core/capabilities.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
3636
longpress: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
3737
open: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
3838
perf: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
39+
install: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
3940
reinstall: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
4041
press: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
4142
push: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },

src/daemon/handlers/__tests__/session-reinstall.test.ts

Lines changed: 121 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { SessionStore } from '../../session-store.ts';
88
import type { DaemonRequest, DaemonResponse, SessionState } from '../../types.ts';
99

1010
function makeStore(): SessionStore {
11-
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-session-reinstall-'));
11+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-session-app-deploy-'));
1212
return new SessionStore(path.join(tempRoot, 'sessions'));
1313
}
1414

@@ -22,7 +22,7 @@ function makeSession(name: string, device: SessionState['device']): SessionState
2222
}
2323

2424
const invoke = async (_req: DaemonRequest): Promise<DaemonResponse> => {
25-
return { ok: false, error: { code: 'INVALID_ARGS', message: 'invoke should not be called in reinstall tests' } };
25+
return { ok: false, error: { code: 'INVALID_ARGS', message: 'invoke should not be called in app deploy tests' } };
2626
};
2727

2828
test('reinstall requires active session or explicit device selector', async () => {
@@ -229,3 +229,122 @@ test('reinstall succeeds on active Android session with normalized appId', async
229229
assert.equal(response.data?.appPath, appPath);
230230
}
231231
});
232+
233+
test('install requires active session or explicit device selector', async () => {
234+
const sessionStore = makeStore();
235+
const response = await handleSessionCommands({
236+
req: {
237+
token: 't',
238+
session: 'default',
239+
command: 'install',
240+
positionals: ['com.example.app', '/tmp/app.apk'],
241+
flags: {},
242+
},
243+
sessionName: 'default',
244+
logPath: '/tmp/daemon.log',
245+
sessionStore,
246+
invoke,
247+
});
248+
assert.ok(response);
249+
assert.equal(response.ok, false);
250+
if (!response.ok) {
251+
assert.equal(response.error.code, 'INVALID_ARGS');
252+
assert.match(response.error.message, /active session or an explicit device selector/i);
253+
}
254+
});
255+
256+
test('install succeeds on active iOS simulator session and records action', async () => {
257+
const sessionStore = makeStore();
258+
const session = makeSession('default', {
259+
platform: 'ios',
260+
id: 'sim-1',
261+
name: 'iPhone',
262+
kind: 'simulator',
263+
booted: true,
264+
});
265+
sessionStore.set('default', session);
266+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-install-success-ios-'));
267+
const appPath = path.join(tempRoot, 'Sample.app');
268+
fs.writeFileSync(appPath, 'placeholder');
269+
270+
const response = await handleSessionCommands({
271+
req: {
272+
token: 't',
273+
session: 'default',
274+
command: 'install',
275+
positionals: ['com.example.app', appPath],
276+
flags: {},
277+
},
278+
sessionName: 'default',
279+
logPath: '/tmp/daemon.log',
280+
sessionStore,
281+
invoke,
282+
installOps: {
283+
ios: async (_device, app, pathToBinary) => {
284+
assert.equal(app, 'com.example.app');
285+
assert.equal(pathToBinary, appPath);
286+
return { bundleId: 'com.example.app' };
287+
},
288+
android: async () => {
289+
throw new Error('unexpected android install');
290+
},
291+
},
292+
});
293+
294+
assert.ok(response);
295+
assert.equal(response.ok, true);
296+
if (response.ok) {
297+
assert.equal(response.data?.platform, 'ios');
298+
assert.equal(response.data?.appId, 'com.example.app');
299+
assert.equal(response.data?.bundleId, 'com.example.app');
300+
assert.equal(response.data?.appPath, appPath);
301+
}
302+
assert.equal(session.actions.length, 1);
303+
assert.equal(session.actions[0]?.command, 'install');
304+
});
305+
306+
test('install omits app id fields when platform op cannot resolve them', async () => {
307+
const sessionStore = makeStore();
308+
sessionStore.set(
309+
'default',
310+
makeSession('default', {
311+
platform: 'android',
312+
id: 'emulator-5554',
313+
name: 'Pixel',
314+
kind: 'emulator',
315+
booted: true,
316+
}),
317+
);
318+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-install-fallback-appid-'));
319+
const appPath = path.join(tempRoot, 'Sample.apk');
320+
fs.writeFileSync(appPath, 'placeholder');
321+
322+
const response = await handleSessionCommands({
323+
req: {
324+
token: 't',
325+
session: 'default',
326+
command: 'install',
327+
positionals: ['Demo', appPath],
328+
flags: {},
329+
},
330+
sessionName: 'default',
331+
logPath: '/tmp/daemon.log',
332+
sessionStore,
333+
invoke,
334+
installOps: {
335+
ios: async () => {
336+
throw new Error('unexpected ios install');
337+
},
338+
android: async () => ({}),
339+
},
340+
});
341+
342+
assert.ok(response);
343+
assert.equal(response.ok, true);
344+
if (response.ok) {
345+
assert.equal(response.data?.platform, 'android');
346+
assert.equal(response.data?.appId, undefined);
347+
assert.equal(response.data?.package, undefined);
348+
assert.equal(response.data?.appPath, appPath);
349+
}
350+
});

0 commit comments

Comments
 (0)