agent-device 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,6 +14,39 @@ import { pruneGroupNodes } from '../snapshot-processing.ts';
14
14
  import { buildSelectorChainForNode, parseSelectorChain, resolveSelectorChain, splitSelectorFromArgs } from '../selectors.ts';
15
15
  import { inferFillText, uniqueStrings } from '../action-utils.ts';
16
16
 
17
+ type ReinstallOps = {
18
+ ios: (device: DeviceInfo, app: string, appPath: string) => Promise<{ bundleId: string }>;
19
+ android: (device: DeviceInfo, app: string, appPath: string) => Promise<{ package: string }>;
20
+ };
21
+
22
+ function hasExplicitDeviceSelector(flags: DaemonRequest['flags'] | undefined): boolean {
23
+ return Boolean(flags?.platform || flags?.device || flags?.udid || flags?.serial);
24
+ }
25
+
26
+ async function resolveCommandDevice(params: {
27
+ session: SessionState | undefined;
28
+ flags: DaemonRequest['flags'] | undefined;
29
+ ensureReadyFn: typeof ensureDeviceReady;
30
+ ensureReady?: boolean;
31
+ }): Promise<DeviceInfo> {
32
+ const device = params.session?.device ?? (await resolveTargetDevice(params.flags ?? {}));
33
+ if (params.ensureReady !== false) {
34
+ await params.ensureReadyFn(device);
35
+ }
36
+ return device;
37
+ }
38
+
39
+ const defaultReinstallOps: ReinstallOps = {
40
+ ios: async (device, app, appPath) => {
41
+ const { reinstallIosApp } = await import('../../platforms/ios/index.ts');
42
+ return await reinstallIosApp(device, app, appPath);
43
+ },
44
+ android: async (device, app, appPath) => {
45
+ const { reinstallAndroidApp } = await import('../../platforms/android/index.ts');
46
+ return await reinstallAndroidApp(device, app, appPath);
47
+ },
48
+ };
49
+
17
50
  export async function handleSessionCommands(params: {
18
51
  req: DaemonRequest;
19
52
  sessionName: string;
@@ -21,9 +54,21 @@ export async function handleSessionCommands(params: {
21
54
  sessionStore: SessionStore;
22
55
  invoke: (req: DaemonRequest) => Promise<DaemonResponse>;
23
56
  dispatch?: typeof dispatchCommand;
57
+ ensureReady?: typeof ensureDeviceReady;
58
+ reinstallOps?: ReinstallOps;
24
59
  }): Promise<DaemonResponse | null> {
25
- const { req, sessionName, logPath, sessionStore, invoke, dispatch: dispatchOverride } = params;
60
+ const {
61
+ req,
62
+ sessionName,
63
+ logPath,
64
+ sessionStore,
65
+ invoke,
66
+ dispatch: dispatchOverride,
67
+ ensureReady: ensureReadyOverride,
68
+ reinstallOps = defaultReinstallOps,
69
+ } = params;
26
70
  const dispatch = dispatchOverride ?? dispatchCommand;
71
+ const ensureReady = ensureReadyOverride ?? ensureDeviceReady;
27
72
  const command = req.command;
28
73
 
29
74
  if (command === 'session_list') {
@@ -72,7 +117,7 @@ export async function handleSessionCommands(params: {
72
117
  if (command === 'apps') {
73
118
  const session = sessionStore.get(sessionName);
74
119
  const flags = req.flags ?? {};
75
- if (!session && !flags.platform && !flags.device && !flags.udid && !flags.serial) {
120
+ if (!session && !hasExplicitDeviceSelector(flags)) {
76
121
  return {
77
122
  ok: false,
78
123
  error: {
@@ -81,8 +126,7 @@ export async function handleSessionCommands(params: {
81
126
  },
82
127
  };
83
128
  }
84
- const device = session?.device ?? (await resolveTargetDevice(flags));
85
- await ensureDeviceReady(device);
129
+ const device = await resolveCommandDevice({ session, flags, ensureReadyFn: ensureReady, ensureReady: true });
86
130
  if (!isCommandSupportedOnDevice('apps', device)) {
87
131
  return { ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'apps is not supported on this device' } };
88
132
  }
@@ -106,11 +150,39 @@ export async function handleSessionCommands(params: {
106
150
  return { ok: true, data: { apps } };
107
151
  }
108
152
 
109
- if (command === 'appstate') {
153
+ if (command === 'boot') {
110
154
  const session = sessionStore.get(sessionName);
111
155
  const flags = req.flags ?? {};
156
+ if (!session && !hasExplicitDeviceSelector(flags)) {
157
+ return {
158
+ ok: false,
159
+ error: {
160
+ code: 'INVALID_ARGS',
161
+ message: 'boot requires an active session or an explicit device selector (e.g. --platform ios).',
162
+ },
163
+ };
164
+ }
112
165
  const device = session?.device ?? (await resolveTargetDevice(flags));
113
- await ensureDeviceReady(device);
166
+ if (!isCommandSupportedOnDevice('boot', device)) {
167
+ return { ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'boot is not supported on this device' } };
168
+ }
169
+ await ensureReady(device);
170
+ return {
171
+ ok: true,
172
+ data: {
173
+ platform: device.platform,
174
+ device: device.name,
175
+ id: device.id,
176
+ kind: device.kind,
177
+ booted: true,
178
+ },
179
+ };
180
+ }
181
+
182
+ if (command === 'appstate') {
183
+ const session = sessionStore.get(sessionName);
184
+ const flags = req.flags ?? {};
185
+ const device = await resolveCommandDevice({ session, flags, ensureReadyFn: ensureReady, ensureReady: true });
114
186
  if (device.platform === 'ios') {
115
187
  if (session?.appBundleId) {
116
188
  return {
@@ -151,6 +223,62 @@ export async function handleSessionCommands(params: {
151
223
  };
152
224
  }
153
225
 
226
+ if (command === 'reinstall') {
227
+ const session = sessionStore.get(sessionName);
228
+ const flags = req.flags ?? {};
229
+ if (!session && !hasExplicitDeviceSelector(flags)) {
230
+ return {
231
+ ok: false,
232
+ error: {
233
+ code: 'INVALID_ARGS',
234
+ message: 'reinstall requires an active session or an explicit device selector (e.g. --platform ios).',
235
+ },
236
+ };
237
+ }
238
+ const app = req.positionals?.[0]?.trim();
239
+ const appPathInput = req.positionals?.[1]?.trim();
240
+ if (!app || !appPathInput) {
241
+ return {
242
+ ok: false,
243
+ error: { code: 'INVALID_ARGS', message: 'reinstall requires: reinstall <app> <path-to-app-binary>' },
244
+ };
245
+ }
246
+ const appPath = SessionStore.expandHome(appPathInput);
247
+ if (!fs.existsSync(appPath)) {
248
+ return {
249
+ ok: false,
250
+ error: { code: 'INVALID_ARGS', message: `App binary not found: ${appPath}` },
251
+ };
252
+ }
253
+ const device = await resolveCommandDevice({ session, flags, ensureReadyFn: ensureReady, ensureReady: false });
254
+ if (!isCommandSupportedOnDevice('reinstall', device)) {
255
+ return {
256
+ ok: false,
257
+ error: { code: 'UNSUPPORTED_OPERATION', message: 'reinstall is not supported on this device' },
258
+ };
259
+ }
260
+ let reinstallData:
261
+ | { platform: 'ios'; appId: string; bundleId: string }
262
+ | { platform: 'android'; appId: string; package: string };
263
+ if (device.platform === 'ios') {
264
+ const iosResult = await reinstallOps.ios(device, app, appPath);
265
+ reinstallData = { platform: 'ios', appId: iosResult.bundleId, bundleId: iosResult.bundleId };
266
+ } else {
267
+ const androidResult = await reinstallOps.android(device, app, appPath);
268
+ reinstallData = { platform: 'android', appId: androidResult.package, package: androidResult.package };
269
+ }
270
+ const result = { app, appPath, ...reinstallData };
271
+ if (session) {
272
+ sessionStore.recordAction(session, {
273
+ command,
274
+ positionals: req.positionals ?? [],
275
+ flags: req.flags ?? {},
276
+ result,
277
+ });
278
+ }
279
+ return { ok: true, data: result };
280
+ }
281
+
154
282
  if (command === 'open') {
155
283
  if (sessionStore.has(sessionName)) {
156
284
  const session = sessionStore.get(sessionName);
@@ -193,7 +321,6 @@ export async function handleSessionCommands(params: {
193
321
  return { ok: true, data: { session: sessionName, appName, appBundleId } };
194
322
  }
195
323
  const device = await resolveTargetDevice(req.flags ?? {});
196
- await ensureDeviceReady(device);
197
324
  const inUse = sessionStore.toArray().find((s) => s.device.id === device.id);
198
325
  if (inUse) {
199
326
  return {
@@ -269,7 +396,9 @@ export async function handleSessionCommands(params: {
269
396
  flags: action.flags ?? {},
270
397
  });
271
398
  if (response.ok) continue;
272
- if (!shouldUpdate) return response;
399
+ if (!shouldUpdate) {
400
+ return withReplayFailureContext(response, action, index, resolved);
401
+ }
273
402
  const nextAction = await healReplayAction({
274
403
  action,
275
404
  sessionName,
@@ -278,7 +407,7 @@ export async function handleSessionCommands(params: {
278
407
  dispatch,
279
408
  });
280
409
  if (!nextAction) {
281
- return response;
410
+ return withReplayFailureContext(response, action, index, resolved);
282
411
  }
283
412
  actions[index] = nextAction;
284
413
  response = await invoke({
@@ -289,7 +418,7 @@ export async function handleSessionCommands(params: {
289
418
  flags: nextAction.flags ?? {},
290
419
  });
291
420
  if (!response.ok) {
292
- return response;
421
+ return withReplayFailureContext(response, nextAction, index, resolved);
293
422
  }
294
423
  healed += 1;
295
424
  }
@@ -334,6 +463,42 @@ export async function handleSessionCommands(params: {
334
463
  return null;
335
464
  }
336
465
 
466
+ function withReplayFailureContext(
467
+ response: DaemonResponse,
468
+ action: SessionAction,
469
+ index: number,
470
+ replayPath: string,
471
+ ): DaemonResponse {
472
+ if (response.ok) return response;
473
+ const step = index + 1;
474
+ const summary = formatReplayActionSummary(action);
475
+ const details = {
476
+ ...(response.error.details ?? {}),
477
+ replayPath,
478
+ step,
479
+ action: action.command,
480
+ positionals: action.positionals ?? [],
481
+ };
482
+ return {
483
+ ok: false,
484
+ error: {
485
+ code: response.error.code,
486
+ message: `Replay failed at step ${step} (${summary}): ${response.error.message}`,
487
+ details,
488
+ },
489
+ };
490
+ }
491
+
492
+ function formatReplayActionSummary(action: SessionAction): string {
493
+ const values = (action.positionals ?? []).map((value) => {
494
+ const trimmed = value.trim();
495
+ if (/^-?\d+(\.\d+)?$/.test(trimmed)) return trimmed;
496
+ if (trimmed.startsWith('@')) return trimmed;
497
+ return JSON.stringify(trimmed);
498
+ });
499
+ return [action.command, ...values].join(' ');
500
+ }
501
+
337
502
  async function healReplayAction(params: {
338
503
  action: SessionAction;
339
504
  sessionName: string;
@@ -1,23 +1,38 @@
1
1
  import test from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { classifyBootFailure } from '../boot-diagnostics.ts';
3
+ import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts';
4
4
  import { AppError } from '../../utils/errors.ts';
5
5
 
6
6
  test('classifyBootFailure maps timeout errors', () => {
7
- const reason = classifyBootFailure({ message: 'bootstatus timed out after 120s' });
8
- assert.equal(reason, 'BOOT_TIMEOUT');
7
+ const reason = classifyBootFailure({
8
+ message: 'bootstatus timed out after 120s',
9
+ context: { platform: 'ios', phase: 'boot' },
10
+ });
11
+ assert.equal(reason, 'IOS_BOOT_TIMEOUT');
9
12
  });
10
13
 
11
14
  test('classifyBootFailure maps adb offline errors', () => {
12
- const reason = classifyBootFailure({ stderr: 'error: device offline' });
13
- assert.equal(reason, 'DEVICE_OFFLINE');
15
+ const reason = classifyBootFailure({
16
+ stderr: 'error: device offline',
17
+ context: { platform: 'android', phase: 'transport' },
18
+ });
19
+ assert.equal(reason, 'ADB_TRANSPORT_UNAVAILABLE');
14
20
  });
15
21
 
16
- test('classifyBootFailure maps tool missing from AppError code', () => {
22
+ test('classifyBootFailure maps tool missing from AppError code (android)', () => {
17
23
  const reason = classifyBootFailure({
18
24
  error: new AppError('TOOL_MISSING', 'adb not found in PATH'),
25
+ context: { platform: 'android', phase: 'transport' },
26
+ });
27
+ assert.equal(reason, 'ADB_TRANSPORT_UNAVAILABLE');
28
+ });
29
+
30
+ test('classifyBootFailure maps tool missing from AppError code (ios)', () => {
31
+ const reason = classifyBootFailure({
32
+ error: new AppError('TOOL_MISSING', 'xcrun not found in PATH'),
33
+ context: { platform: 'ios', phase: 'boot' },
19
34
  });
20
- assert.equal(reason, 'TOOL_MISSING');
35
+ assert.equal(reason, 'IOS_TOOL_MISSING');
21
36
  });
22
37
 
23
38
  test('classifyBootFailure reads stderr from AppError details', () => {
@@ -25,6 +40,20 @@ test('classifyBootFailure reads stderr from AppError details', () => {
25
40
  error: new AppError('COMMAND_FAILED', 'adb failed', {
26
41
  stderr: 'error: device unauthorized',
27
42
  }),
43
+ context: { platform: 'android', phase: 'transport' },
44
+ });
45
+ assert.equal(reason, 'ADB_TRANSPORT_UNAVAILABLE');
46
+ });
47
+
48
+ test('bootFailureHint returns actionable guidance', () => {
49
+ const hint = bootFailureHint('IOS_RUNNER_CONNECT_TIMEOUT');
50
+ assert.equal(hint.includes('xcodebuild logs'), true);
51
+ });
52
+
53
+ test('connect phase does not classify non-timeout errors as connect timeout', () => {
54
+ const reason = classifyBootFailure({
55
+ message: 'Runner returned malformed JSON payload',
56
+ context: { platform: 'ios', phase: 'connect' },
28
57
  });
29
- assert.equal(reason, 'PERMISSION_DENIED');
58
+ assert.equal(reason, 'BOOT_COMMAND_FAILED');
30
59
  });
@@ -1,5 +1,6 @@
1
1
  import test from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
+ import { parseAndroidLaunchComponent } from '../index.ts';
3
4
  import { findBounds, parseUiHierarchy } from '../ui-hierarchy.ts';
4
5
 
5
6
  test('parseUiHierarchy reads double-quoted Android node attributes', () => {
@@ -72,3 +73,19 @@ test('findBounds ignores bounds-like fragments inside other attribute values', (
72
73
 
73
74
  assert.deepEqual(findBounds(xml, 'target'), { x: 200, y: 350 });
74
75
  });
76
+
77
+ test('parseAndroidLaunchComponent extracts final resolved component', () => {
78
+ const stdout = [
79
+ 'priority=0 preferredOrder=0 match=0x108000 specificIndex=-1 isDefault=true',
80
+ 'com.boatsgroup.boattrader/com.boatsgroup.boattrader.MainActivity',
81
+ ].join('\n');
82
+ assert.equal(
83
+ parseAndroidLaunchComponent(stdout),
84
+ 'com.boatsgroup.boattrader/com.boatsgroup.boattrader.MainActivity',
85
+ );
86
+ });
87
+
88
+ test('parseAndroidLaunchComponent returns null when no component is present', () => {
89
+ const stdout = 'No activity found';
90
+ assert.equal(parseAndroidLaunchComponent(stdout), null);
91
+ });
@@ -2,11 +2,12 @@ import { runCmd, whichCmd } from '../../utils/exec.ts';
2
2
  import type { ExecResult } from '../../utils/exec.ts';
3
3
  import { AppError, asAppError } from '../../utils/errors.ts';
4
4
  import type { DeviceInfo } from '../../utils/device.ts';
5
- import { Deadline, retryWithPolicy } from '../../utils/retry.ts';
6
- import { classifyBootFailure } from '../boot-diagnostics.ts';
5
+ import { Deadline, isEnvTruthy, retryWithPolicy, TIMEOUT_PROFILES, type RetryTelemetryEvent } from '../../utils/retry.ts';
6
+ import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts';
7
7
 
8
8
  const EMULATOR_SERIAL_PREFIX = 'emulator-';
9
9
  const ANDROID_BOOT_POLL_MS = 1000;
10
+ const RETRY_LOGS_ENABLED = isEnvTruthy(process.env.AGENT_DEVICE_RETRY_LOGS);
10
11
 
11
12
  function adbArgs(serial: string, args: string[]): string[] {
12
13
  return ['-s', serial, ...args];
@@ -16,9 +17,13 @@ function isEmulatorSerial(serial: string): boolean {
16
17
  return serial.startsWith(EMULATOR_SERIAL_PREFIX);
17
18
  }
18
19
 
19
- async function readAndroidBootProp(serial: string): Promise<ExecResult> {
20
+ async function readAndroidBootProp(
21
+ serial: string,
22
+ timeoutMs = TIMEOUT_PROFILES.android_boot.operationMs,
23
+ ): Promise<ExecResult> {
20
24
  return runCmd('adb', adbArgs(serial, ['shell', 'getprop', 'sys.boot_completed']), {
21
25
  allowFailure: true,
26
+ timeoutMs,
22
27
  });
23
28
  }
24
29
 
@@ -27,6 +32,7 @@ async function resolveAndroidDeviceName(serial: string, rawModel: string): Promi
27
32
  if (!isEmulatorSerial(serial)) return modelName || serial;
28
33
  const avd = await runCmd('adb', adbArgs(serial, ['emu', 'avd', 'name']), {
29
34
  allowFailure: true,
35
+ timeoutMs: TIMEOUT_PROFILES.android_boot.operationMs,
30
36
  });
31
37
  const avdName = avd.stdout.trim();
32
38
  if (avd.exitCode === 0 && avdName) {
@@ -41,7 +47,9 @@ export async function listAndroidDevices(): Promise<DeviceInfo[]> {
41
47
  throw new AppError('TOOL_MISSING', 'adb not found in PATH');
42
48
  }
43
49
 
44
- const result = await runCmd('adb', ['devices', '-l']);
50
+ const result = await runCmd('adb', ['devices', '-l'], {
51
+ timeoutMs: TIMEOUT_PROFILES.android_boot.operationMs,
52
+ });
45
53
  const lines = result.stdout.split('\n').map((l: string) => l.trim());
46
54
  const entries = lines
47
55
  .filter((line) => line.length > 0 && !line.startsWith('List of devices'))
@@ -79,8 +87,9 @@ export async function isAndroidBooted(serial: string): Promise<boolean> {
79
87
  }
80
88
 
81
89
  export async function waitForAndroidBoot(serial: string, timeoutMs = 60000): Promise<void> {
82
- const deadline = Deadline.fromTimeoutMs(timeoutMs);
83
- const maxAttempts = Math.max(1, Math.ceil(timeoutMs / ANDROID_BOOT_POLL_MS));
90
+ const timeoutBudget = timeoutMs;
91
+ const deadline = Deadline.fromTimeoutMs(timeoutBudget);
92
+ const maxAttempts = Math.max(1, Math.ceil(timeoutBudget / ANDROID_BOOT_POLL_MS));
84
93
  let lastBootResult: ExecResult | undefined;
85
94
  let timedOut = false;
86
95
  try {
@@ -95,7 +104,11 @@ export async function waitForAndroidBoot(serial: string, timeoutMs = 60000): Pro
95
104
  message: 'timeout',
96
105
  });
97
106
  }
98
- const result = await readAndroidBootProp(serial);
107
+ const remainingMs = Math.max(1_000, attemptDeadline?.remainingMs() ?? timeoutBudget);
108
+ const result = await readAndroidBootProp(
109
+ serial,
110
+ Math.min(remainingMs, TIMEOUT_PROFILES.android_boot.operationMs),
111
+ );
99
112
  lastBootResult = result;
100
113
  if (result.stdout.trim() === '1') return;
101
114
  throw new AppError('COMMAND_FAILED', 'Android device is still booting', {
@@ -115,41 +128,61 @@ export async function waitForAndroidBoot(serial: string, timeoutMs = 60000): Pro
115
128
  error,
116
129
  stdout: lastBootResult?.stdout,
117
130
  stderr: lastBootResult?.stderr,
131
+ context: { platform: 'android', phase: 'boot' },
118
132
  });
119
- return reason !== 'PERMISSION_DENIED' && reason !== 'TOOL_MISSING' && reason !== 'BOOT_TIMEOUT';
133
+ return reason !== 'ADB_TRANSPORT_UNAVAILABLE' && reason !== 'ANDROID_BOOT_TIMEOUT';
134
+ },
135
+ },
136
+ {
137
+ deadline,
138
+ phase: 'boot',
139
+ classifyReason: (error) =>
140
+ classifyBootFailure({
141
+ error,
142
+ stdout: lastBootResult?.stdout,
143
+ stderr: lastBootResult?.stderr,
144
+ context: { platform: 'android', phase: 'boot' },
145
+ }),
146
+ onEvent: (event: RetryTelemetryEvent) => {
147
+ if (!RETRY_LOGS_ENABLED) return;
148
+ process.stderr.write(`[agent-device][retry] ${JSON.stringify(event)}\n`);
120
149
  },
121
150
  },
122
- { deadline },
123
151
  );
124
152
  } catch (error) {
125
153
  const appErr = asAppError(error);
126
154
  const stdout = lastBootResult?.stdout;
127
155
  const stderr = lastBootResult?.stderr;
128
156
  const exitCode = lastBootResult?.exitCode;
129
- const reason = classifyBootFailure({
157
+ let reason = classifyBootFailure({
130
158
  error,
131
159
  stdout,
132
160
  stderr,
161
+ context: { platform: 'android', phase: 'boot' },
133
162
  });
163
+ if (reason === 'BOOT_COMMAND_FAILED' && appErr.message === 'Android device is still booting') {
164
+ reason = 'ANDROID_BOOT_TIMEOUT';
165
+ }
134
166
  const baseDetails = {
135
167
  serial,
136
- timeoutMs,
168
+ timeoutMs: timeoutBudget,
137
169
  elapsedMs: deadline.elapsedMs(),
138
170
  reason,
171
+ hint: bootFailureHint(reason),
139
172
  stdout,
140
173
  stderr,
141
174
  exitCode,
142
175
  };
143
- if (timedOut || reason === 'BOOT_TIMEOUT') {
176
+ if (timedOut || reason === 'ANDROID_BOOT_TIMEOUT') {
144
177
  throw new AppError('COMMAND_FAILED', 'Android device did not finish booting in time', baseDetails);
145
178
  }
146
- if (appErr.code === 'TOOL_MISSING' || reason === 'TOOL_MISSING') {
179
+ if (appErr.code === 'TOOL_MISSING') {
147
180
  throw new AppError('TOOL_MISSING', appErr.message, {
148
181
  ...baseDetails,
149
182
  ...(appErr.details ?? {}),
150
183
  });
151
184
  }
152
- if (reason === 'PERMISSION_DENIED' || reason === 'DEVICE_UNAVAILABLE' || reason === 'DEVICE_OFFLINE') {
185
+ if (reason === 'ADB_TRANSPORT_UNAVAILABLE') {
153
186
  throw new AppError('COMMAND_FAILED', appErr.message, {
154
187
  ...baseDetails,
155
188
  ...(appErr.details ?? {}),
@@ -187,22 +187,70 @@ export async function openAndroidApp(
187
187
  );
188
188
  return;
189
189
  }
190
- await runCmd(
190
+ try {
191
+ await runCmd(
192
+ 'adb',
193
+ adbArgs(device, [
194
+ 'shell',
195
+ 'am',
196
+ 'start',
197
+ '-a',
198
+ 'android.intent.action.MAIN',
199
+ '-c',
200
+ 'android.intent.category.DEFAULT',
201
+ '-c',
202
+ 'android.intent.category.LAUNCHER',
203
+ '-p',
204
+ resolved.value,
205
+ ]),
206
+ );
207
+ return;
208
+ } catch (initialError) {
209
+ const component = await resolveAndroidLaunchComponent(device, resolved.value);
210
+ if (!component) throw initialError;
211
+ await runCmd(
212
+ 'adb',
213
+ adbArgs(device, [
214
+ 'shell',
215
+ 'am',
216
+ 'start',
217
+ '-a',
218
+ 'android.intent.action.MAIN',
219
+ '-c',
220
+ 'android.intent.category.DEFAULT',
221
+ '-c',
222
+ 'android.intent.category.LAUNCHER',
223
+ '-n',
224
+ component,
225
+ ]),
226
+ );
227
+ }
228
+ }
229
+
230
+ async function resolveAndroidLaunchComponent(
231
+ device: DeviceInfo,
232
+ packageName: string,
233
+ ): Promise<string | null> {
234
+ const result = await runCmd(
191
235
  'adb',
192
- adbArgs(device, [
193
- 'shell',
194
- 'am',
195
- 'start',
196
- '-a',
197
- 'android.intent.action.MAIN',
198
- '-c',
199
- 'android.intent.category.DEFAULT',
200
- '-c',
201
- 'android.intent.category.LAUNCHER',
202
- '-p',
203
- resolved.value,
204
- ]),
236
+ adbArgs(device, ['shell', 'cmd', 'package', 'resolve-activity', '--brief', packageName]),
237
+ { allowFailure: true },
205
238
  );
239
+ if (result.exitCode !== 0) return null;
240
+ return parseAndroidLaunchComponent(result.stdout);
241
+ }
242
+
243
+ export function parseAndroidLaunchComponent(stdout: string): string | null {
244
+ const lines = stdout
245
+ .split('\n')
246
+ .map((line: string) => line.trim())
247
+ .filter(Boolean);
248
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
249
+ const line = lines[index];
250
+ if (!line.includes('/')) continue;
251
+ return line.split(/\s+/)[0];
252
+ }
253
+ return null;
206
254
  }
207
255
 
208
256
  export async function openAndroidDevice(device: DeviceInfo): Promise<void> {
@@ -224,6 +272,45 @@ export async function closeAndroidApp(device: DeviceInfo, app: string): Promise<
224
272
  await runCmd('adb', adbArgs(device, ['shell', 'am', 'force-stop', resolved.value]));
225
273
  }
226
274
 
275
+ export async function uninstallAndroidApp(
276
+ device: DeviceInfo,
277
+ app: string,
278
+ ): Promise<{ package: string }> {
279
+ const resolved = await resolveAndroidApp(device, app);
280
+ if (resolved.type === 'intent') {
281
+ throw new AppError('INVALID_ARGS', 'reinstall requires a package name, not an intent');
282
+ }
283
+ const result = await runCmd('adb', adbArgs(device, ['uninstall', resolved.value]), { allowFailure: true });
284
+ if (result.exitCode !== 0) {
285
+ const output = `${result.stdout}\n${result.stderr}`.toLowerCase();
286
+ if (!output.includes('unknown package') && !output.includes('not installed')) {
287
+ throw new AppError('COMMAND_FAILED', `adb uninstall failed for ${resolved.value}`, {
288
+ stdout: result.stdout,
289
+ stderr: result.stderr,
290
+ exitCode: result.exitCode,
291
+ });
292
+ }
293
+ }
294
+ return { package: resolved.value };
295
+ }
296
+
297
+ export async function installAndroidApp(device: DeviceInfo, appPath: string): Promise<void> {
298
+ await runCmd('adb', adbArgs(device, ['install', appPath]));
299
+ }
300
+
301
+ export async function reinstallAndroidApp(
302
+ device: DeviceInfo,
303
+ app: string,
304
+ appPath: string,
305
+ ): Promise<{ package: string }> {
306
+ if (!device.booted) {
307
+ await waitForAndroidBoot(device.id);
308
+ }
309
+ const { package: pkg } = await uninstallAndroidApp(device, app);
310
+ await installAndroidApp(device, appPath);
311
+ return { package: pkg };
312
+ }
313
+
227
314
  export async function pressAndroid(device: DeviceInfo, x: number, y: number): Promise<void> {
228
315
  await runCmd('adb', adbArgs(device, ['shell', 'input', 'tap', String(x), String(y)]));
229
316
  }