Skip to content

Commit 6fcb23b

Browse files
committed
Refine reinstall flow and add agent-focused coverage
1 parent d0525cd commit 6fcb23b

9 files changed

Lines changed: 403 additions & 8 deletions

File tree

README.md

Lines changed: 4 additions & 2 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 (simulator + limited device support) and Android (emulator + device).
17-
- Core commands: `open`, `back`, `home`, `app-switcher`, `press`, `long-press`, `focus`, `type`, `fill`, `scroll`, `scrollintoview`, `wait`, `alert`, `screenshot`, `close`.
17+
- Core commands: `open`, `back`, `home`, `app-switcher`, `press`, `long-press`, `focus`, `type`, `fill`, `scroll`, `scrollintoview`, `wait`, `alert`, `screenshot`, `close`, `reinstall`.
1818
- Inspection commands: `snapshot` (accessibility tree).
1919
- Device tooling: `adb` (Android), `simctl`/`devicectl` (iOS via Xcode).
2020
- Minimal dependencies; TypeScript executed directly on Node 22+ (no build step).
@@ -75,7 +75,7 @@ Coordinates:
7575
- X increases to the right, Y increases downward.
7676

7777
## Command Index
78-
- `open`, `close`, `home`, `back`, `app-switcher`
78+
- `open`, `close`, `reinstall`, `home`, `back`, `app-switcher`
7979
- `snapshot`, `find`, `get`
8080
- `click`, `focus`, `type`, `fill`, `press`, `long-press`, `scroll`, `scrollintoview`, `is`
8181
- `alert`, `wait`, `screenshot`
@@ -119,6 +119,8 @@ Sessions:
119119
- All interaction commands require an open session.
120120
- If a session is already open, `open <app>` switches the active app and updates the session app bundle.
121121
- `close` stops the session and releases device resources. Pass an app to close it explicitly, or omit to just close the session.
122+
- `reinstall <app> <path>` uninstalls and installs the app binary in one command (Android + iOS simulator in v1).
123+
- `reinstall` accepts package/bundle id style app names and supports `~` in paths.
122124
- Use `--session <name>` to manage multiple sessions.
123125
- Session scripts are written to `~/.agent-device/sessions/<session>-<timestamp>.ad` when recording is enabled with `--save-script`.
124126
- Deterministic replay is `.ad`-based; use `replay --update` (`-u`) to update selector drift and rewrite the replay file in place.

src/core/__tests__/capabilities.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ test('iOS simulator + Android commands reject iOS devices', () => {
4646
'home',
4747
'long-press',
4848
'open',
49+
'reinstall',
4950
'press',
5051
'record',
5152
'screenshot',

src/core/capabilities.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
2929
home: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
3030
'long-press': { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
3131
open: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
32+
reinstall: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
3233
press: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
3334
record: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
3435
screenshot: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import fs from 'node:fs';
4+
import os from 'node:os';
5+
import path from 'node:path';
6+
import { handleSessionCommands } from '../session.ts';
7+
import { SessionStore } from '../../session-store.ts';
8+
import type { DaemonRequest, DaemonResponse, SessionState } from '../../types.ts';
9+
10+
function makeStore(): SessionStore {
11+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-session-reinstall-'));
12+
return new SessionStore(path.join(tempRoot, 'sessions'));
13+
}
14+
15+
function makeSession(name: string, device: SessionState['device']): SessionState {
16+
return {
17+
name,
18+
device,
19+
createdAt: Date.now(),
20+
actions: [],
21+
};
22+
}
23+
24+
const invoke = async (_req: DaemonRequest): Promise<DaemonResponse> => {
25+
return { ok: false, error: { code: 'INVALID_ARGS', message: 'invoke should not be called in reinstall tests' } };
26+
};
27+
28+
test('reinstall requires active session or explicit device selector', async () => {
29+
const sessionStore = makeStore();
30+
const response = await handleSessionCommands({
31+
req: {
32+
token: 't',
33+
session: 'default',
34+
command: 'reinstall',
35+
positionals: ['com.example.app', '/tmp/app.apk'],
36+
flags: {},
37+
},
38+
sessionName: 'default',
39+
logPath: '/tmp/daemon.log',
40+
sessionStore,
41+
invoke,
42+
});
43+
assert.ok(response);
44+
assert.equal(response.ok, false);
45+
if (!response.ok) {
46+
assert.equal(response.error.code, 'INVALID_ARGS');
47+
assert.match(response.error.message, /active session or an explicit device selector/i);
48+
}
49+
});
50+
51+
test('reinstall validates required args before device operations', async () => {
52+
const sessionStore = makeStore();
53+
sessionStore.set(
54+
'default',
55+
makeSession('default', {
56+
platform: 'ios',
57+
id: 'sim-1',
58+
name: 'iPhone',
59+
kind: 'simulator',
60+
booted: true,
61+
}),
62+
);
63+
const response = await handleSessionCommands({
64+
req: {
65+
token: 't',
66+
session: 'default',
67+
command: 'reinstall',
68+
positionals: ['com.example.app'],
69+
flags: {},
70+
},
71+
sessionName: 'default',
72+
logPath: '/tmp/daemon.log',
73+
sessionStore,
74+
invoke,
75+
});
76+
assert.ok(response);
77+
assert.equal(response.ok, false);
78+
if (!response.ok) {
79+
assert.equal(response.error.code, 'INVALID_ARGS');
80+
assert.match(response.error.message, /reinstall <app> <path-to-app-binary>/i);
81+
}
82+
});
83+
84+
test('reinstall reports unsupported operation on iOS physical devices', async () => {
85+
const sessionStore = makeStore();
86+
sessionStore.set(
87+
'default',
88+
makeSession('default', {
89+
platform: 'ios',
90+
id: 'device-1',
91+
name: 'iPhone Device',
92+
kind: 'device',
93+
booted: true,
94+
}),
95+
);
96+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-reinstall-binary-'));
97+
const appPath = path.join(tempRoot, 'Sample.app');
98+
fs.writeFileSync(appPath, 'placeholder');
99+
100+
const response = await handleSessionCommands({
101+
req: {
102+
token: 't',
103+
session: 'default',
104+
command: 'reinstall',
105+
positionals: ['com.example.app', appPath],
106+
flags: {},
107+
},
108+
sessionName: 'default',
109+
logPath: '/tmp/daemon.log',
110+
sessionStore,
111+
invoke,
112+
});
113+
assert.ok(response);
114+
assert.equal(response.ok, false);
115+
if (!response.ok) {
116+
assert.equal(response.error.code, 'UNSUPPORTED_OPERATION');
117+
assert.match(response.error.message, /reinstall is not supported/i);
118+
}
119+
});
120+
121+
test('reinstall succeeds on active iOS simulator session and records action', async () => {
122+
const sessionStore = makeStore();
123+
const session = makeSession('default', {
124+
platform: 'ios',
125+
id: 'sim-1',
126+
name: 'iPhone',
127+
kind: 'simulator',
128+
booted: true,
129+
});
130+
sessionStore.set('default', session);
131+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-reinstall-success-ios-'));
132+
const appPath = path.join(tempRoot, 'Sample.app');
133+
fs.writeFileSync(appPath, 'placeholder');
134+
135+
const response = await handleSessionCommands({
136+
req: {
137+
token: 't',
138+
session: 'default',
139+
command: 'reinstall',
140+
positionals: ['com.example.app', appPath],
141+
flags: {},
142+
},
143+
sessionName: 'default',
144+
logPath: '/tmp/daemon.log',
145+
sessionStore,
146+
invoke,
147+
reinstallOps: {
148+
ios: async (_device, app, pathToBinary) => {
149+
assert.equal(app, 'com.example.app');
150+
assert.equal(pathToBinary, appPath);
151+
return { bundleId: 'com.example.app' };
152+
},
153+
android: async () => {
154+
throw new Error('unexpected android reinstall');
155+
},
156+
},
157+
});
158+
159+
assert.ok(response);
160+
assert.equal(response.ok, true);
161+
if (response.ok) {
162+
assert.equal(response.data?.platform, 'ios');
163+
assert.equal(response.data?.appId, 'com.example.app');
164+
assert.equal(response.data?.bundleId, 'com.example.app');
165+
assert.equal(response.data?.appPath, appPath);
166+
}
167+
assert.equal(session.actions.length, 1);
168+
assert.equal(session.actions[0]?.command, 'reinstall');
169+
});
170+
171+
test('reinstall succeeds on active Android session with normalized appId', async () => {
172+
const sessionStore = makeStore();
173+
sessionStore.set(
174+
'default',
175+
makeSession('default', {
176+
platform: 'android',
177+
id: 'emulator-5554',
178+
name: 'Pixel',
179+
kind: 'emulator',
180+
booted: true,
181+
}),
182+
);
183+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-reinstall-success-android-'));
184+
const appPath = path.join(tempRoot, 'Sample.apk');
185+
fs.writeFileSync(appPath, 'placeholder');
186+
187+
const response = await handleSessionCommands({
188+
req: {
189+
token: 't',
190+
session: 'default',
191+
command: 'reinstall',
192+
positionals: ['com.example.app', appPath],
193+
flags: {},
194+
},
195+
sessionName: 'default',
196+
logPath: '/tmp/daemon.log',
197+
sessionStore,
198+
invoke,
199+
reinstallOps: {
200+
ios: async () => {
201+
throw new Error('unexpected ios reinstall');
202+
},
203+
android: async (_device, app, pathToBinary) => {
204+
assert.equal(app, 'com.example.app');
205+
assert.equal(pathToBinary, appPath);
206+
return { package: 'com.example.app' };
207+
},
208+
},
209+
});
210+
211+
assert.ok(response);
212+
assert.equal(response.ok, true);
213+
if (response.ok) {
214+
assert.equal(response.data?.platform, 'android');
215+
assert.equal(response.data?.appId, 'com.example.app');
216+
assert.equal(response.data?.package, 'com.example.app');
217+
assert.equal(response.data?.appPath, appPath);
218+
}
219+
});

0 commit comments

Comments
 (0)