Skip to content

Commit 3b8959b

Browse files
mikegarfinklethymikee
authored andcommitted
feat: forward --launch-args to adb shell am start on Android
Stacked on top of the iOS-only --launch-args PR (#598). Removes the Android UNSUPPORTED_OPERATION guard added with the Maestro work and threads launchArgs through all five Android open paths. Per-path threading: - openAndroidPackage (-p package launch + activity-fallback) - openAndroidPackageActivity (-n component override) - openAndroidIntent (named intent action) - openAndroidDeepLink (-a VIEW -d <url>, with optional -p) - openAndroidAppBoundDeepLink (-a VIEW -d <url> -p <resolved>) `adb shell` joins its argv with spaces and feeds the result to a device shell, which re-tokenises. The other am-start arguments are well-known and never contain shell-significant characters, so they round-trip untouched. Launch arguments are user-supplied and may contain JSON, spaces, `#`, etc.; each is single-quoted unless it consists entirely of safe shell characters (the same approach long used in adb-driven tooling for the same reason). Help text on --launch-args is updated to describe the Android shape (`adb shell am start args, e.g. --es key value` for typed Intent extras) and macOS remains the only rejected platform. Tests: - src/platforms/android/__tests__/index.test.ts: five new tests cover package, activity-override, deep-link URL, app-bound URL, and JSON-with-shell-metacharacters quoting paths. - src/core/__tests__/dispatch-open.test.ts: the previous "rejects Android launch arguments" test is inverted into a forwarding test that asserts openAndroidApp receives the args. Validated end-to-end on a Pixel emulator running a debug build whose launcher activity reads an Intent extra to bootstrap test configuration: a JSON value containing `#`, `/`, `:` survived single-quoted transit through `adb shell` and arrived at the activity unchanged.
1 parent dc37f86 commit 3b8959b

5 files changed

Lines changed: 245 additions & 28 deletions

File tree

src/core/__tests__/dispatch-open.test.ts

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { dispatchCommand } from '../dispatch.ts';
44
import { AppError } from '../../utils/errors.ts';
55
import type { DeviceInfo } from '../../utils/device.ts';
66
import { clearIosSimulatorAppState, openIosApp } from '../../platforms/ios/apps.ts';
7+
import { openAndroidApp } from '../../platforms/android/app-lifecycle.ts';
78
import { IOS_SIMULATOR } from '../../__tests__/test-utils/device-fixtures.ts';
89

910
vi.mock('../../platforms/ios/apps.ts', async (importOriginal) => {
@@ -18,12 +19,23 @@ vi.mock('../../platforms/ios/apps.ts', async (importOriginal) => {
1819
};
1920
});
2021

22+
vi.mock('../../platforms/android/app-lifecycle.ts', async (importOriginal) => {
23+
const actual =
24+
await importOriginal<typeof import('../../platforms/android/app-lifecycle.ts')>();
25+
return {
26+
...actual,
27+
openAndroidApp: vi.fn(async () => {}),
28+
};
29+
});
30+
2131
const mockClearIosSimulatorAppState = vi.mocked(clearIosSimulatorAppState);
2232
const mockOpenIosApp = vi.mocked(openIosApp);
33+
const mockOpenAndroidApp = vi.mocked(openAndroidApp);
2334

2435
beforeEach(() => {
2536
mockClearIosSimulatorAppState.mockClear();
2637
mockOpenIosApp.mockClear();
38+
mockOpenAndroidApp.mockClear();
2739
});
2840

2941
test('dispatch open rejects URL as first argument when second URL is provided', async () => {
@@ -46,7 +58,7 @@ test('dispatch open rejects URL as first argument when second URL is provided',
4658
);
4759
});
4860

49-
test('dispatch open rejects Android launch arguments instead of dropping them', async () => {
61+
test('dispatch open forwards Android launch arguments to openAndroidApp', async () => {
5062
const device: DeviceInfo = {
5163
platform: 'android',
5264
id: 'emulator-5554',
@@ -55,18 +67,16 @@ test('dispatch open rejects Android launch arguments instead of dropping them',
5567
booted: true,
5668
};
5769

58-
await assert.rejects(
59-
() =>
60-
dispatchCommand(device, 'open', ['com.example.app'], undefined, {
61-
launchArgs: ['--fixture', 'demo'],
62-
}),
63-
(error: unknown) => {
64-
assert.equal(error instanceof AppError, true);
65-
assert.equal((error as AppError).code, 'UNSUPPORTED_OPERATION');
66-
assert.match((error as AppError).message, /Apple platforms/i);
67-
return true;
68-
},
69-
);
70+
await dispatchCommand(device, 'open', ['com.example.app'], undefined, {
71+
launchArgs: ['--es', 'KEY', 'value'],
72+
});
73+
74+
assert.equal(mockOpenAndroidApp.mock.calls.length, 1);
75+
assert.equal(mockOpenAndroidApp.mock.calls[0]?.[0], device);
76+
assert.equal(mockOpenAndroidApp.mock.calls[0]?.[1], 'com.example.app');
77+
const optionsArg = mockOpenAndroidApp.mock.calls[0]?.[2];
78+
assert.ok(optionsArg && typeof optionsArg === 'object', 'expected options object');
79+
assert.deepEqual(optionsArg.launchArgs, ['--es', 'KEY', 'value']);
7080
});
7181

7282
test('dispatch open clears Maestro iOS simulator state and launches once', async () => {

src/core/dispatch.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -218,12 +218,6 @@ async function handleOpenCommand(
218218
if (launchConsole && isDeepLinkTarget(app)) {
219219
throw new AppError('INVALID_ARGS', LAUNCH_CONSOLE_DIRECT_APP_ONLY_MESSAGE);
220220
}
221-
if (device.platform === 'android' && context?.launchArgs && context.launchArgs.length > 0) {
222-
throw new AppError(
223-
'UNSUPPORTED_OPERATION',
224-
'Launch arguments are currently supported only on Apple platforms.',
225-
);
226-
}
227221
if (context?.clearAppState) {
228222
if (isDeepLinkTarget(app)) {
229223
throw new AppError(

src/core/interactors/android.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export function createAndroidInteractor(device: DeviceInfo): Interactor {
3838
openAndroidApp(device, app, {
3939
activity: options?.activity,
4040
appBundleId: options?.appBundleId,
41+
launchArgs: options?.launchArgs,
4142
url: options?.url,
4243
}),
4344
openDevice: () => openAndroidDevice(device),

src/platforms/android/__tests__/index.test.ts

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1149,6 +1149,178 @@ test('openAndroidApp default launch uses -p package flag', async () => {
11491149
);
11501150
});
11511151

1152+
test('openAndroidApp appends launchArgs to am start when launching by package', async () => {
1153+
await withMockedAdb(
1154+
'agent-device-android-open-launch-args-',
1155+
[
1156+
'#!/bin/sh',
1157+
'printf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
1158+
'printf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
1159+
'if [ "$1" = "-s" ]; then',
1160+
' shift',
1161+
' shift',
1162+
'fi',
1163+
'if [ "$1" = "shell" ] && [ "$2" = "pm" ] && [ "$3" = "list" ]; then',
1164+
' echo "package:com.example.app"',
1165+
' exit 0',
1166+
'fi',
1167+
'if [ "$1" = "shell" ] && [ "$2" = "am" ] && [ "$3" = "start" ]; then',
1168+
' echo "Status: ok"',
1169+
' exit 0',
1170+
'fi',
1171+
'exit 0',
1172+
'',
1173+
].join('\n'),
1174+
async ({ argsLogPath, device }) => {
1175+
await openAndroidApp(device, 'com.example.app', {
1176+
launchArgs: ['--es', 'screen', 'home', '--ez', 'fresh', 'true'],
1177+
});
1178+
const logged = await fs.readFile(argsLogPath, 'utf8');
1179+
assert.match(logged, /-p\ncom\.example\.app\n--es\nscreen\nhome\n--ez\nfresh\ntrue/);
1180+
},
1181+
);
1182+
});
1183+
1184+
test('openAndroidApp appends launchArgs to am start when activity override is set', async () => {
1185+
await withMockedAdb(
1186+
'agent-device-android-open-launch-args-activity-',
1187+
[
1188+
'#!/bin/sh',
1189+
'printf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
1190+
'printf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
1191+
'if [ "$1" = "-s" ]; then',
1192+
' shift',
1193+
' shift',
1194+
'fi',
1195+
'if [ "$1" = "shell" ] && [ "$2" = "am" ] && [ "$3" = "start" ]; then',
1196+
' echo "Status: ok"',
1197+
' exit 0',
1198+
'fi',
1199+
'exit 0',
1200+
'',
1201+
].join('\n'),
1202+
async ({ argsLogPath, device }) => {
1203+
await openAndroidApp(device, 'com.example.app', {
1204+
activity: '.MainActivity',
1205+
launchArgs: ['--es', 'mode', 'debug'],
1206+
});
1207+
const logged = await fs.readFile(argsLogPath, 'utf8');
1208+
assert.match(
1209+
logged,
1210+
/-n\ncom\.example\.app\/\.MainActivity\n--es\nmode\ndebug/,
1211+
);
1212+
},
1213+
);
1214+
});
1215+
1216+
test('openAndroidApp appends launchArgs to am start for deep link URL opens', async () => {
1217+
await withMockedAdb(
1218+
'agent-device-android-open-launch-args-url-',
1219+
[
1220+
'#!/bin/sh',
1221+
'printf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
1222+
'printf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
1223+
'if [ "$1" = "-s" ]; then',
1224+
' shift',
1225+
' shift',
1226+
'fi',
1227+
'if [ "$1" = "shell" ] && [ "$2" = "am" ] && [ "$3" = "start" ]; then',
1228+
' echo "Status: ok"',
1229+
' exit 0',
1230+
'fi',
1231+
'exit 0',
1232+
'',
1233+
].join('\n'),
1234+
async ({ argsLogPath, device }) => {
1235+
await openAndroidApp(device, 'myapp://item/42', {
1236+
launchArgs: ['--es', 'ref', 'campaign'],
1237+
});
1238+
const logged = await fs.readFile(argsLogPath, 'utf8');
1239+
assert.match(
1240+
logged,
1241+
/-d\nmyapp:\/\/item\/42\n--es\nref\ncampaign/,
1242+
);
1243+
},
1244+
);
1245+
});
1246+
1247+
test('openAndroidApp appends launchArgs to am start for app-bound URL opens', async () => {
1248+
await withMockedAdb(
1249+
'agent-device-android-open-launch-args-app-bound-url-',
1250+
[
1251+
'#!/bin/sh',
1252+
'printf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
1253+
'printf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
1254+
'if [ "$1" = "-s" ]; then',
1255+
' shift',
1256+
' shift',
1257+
'fi',
1258+
'if [ "$1" = "shell" ] && [ "$2" = "pm" ] && [ "$3" = "list" ]; then',
1259+
' echo "package:com.example.app"',
1260+
' exit 0',
1261+
'fi',
1262+
'if [ "$1" = "shell" ] && [ "$2" = "am" ] && [ "$3" = "start" ]; then',
1263+
' echo "Status: ok"',
1264+
' exit 0',
1265+
'fi',
1266+
'exit 0',
1267+
'',
1268+
].join('\n'),
1269+
async ({ argsLogPath, device }) => {
1270+
await openAndroidApp(device, 'com.example.app', {
1271+
url: 'https://example.com/promo',
1272+
launchArgs: ['--es', 'ref', 'campaign'],
1273+
});
1274+
const logged = await fs.readFile(argsLogPath, 'utf8');
1275+
assert.match(
1276+
logged,
1277+
/-d\nhttps:\/\/example\.com\/promo\n-p\ncom\.example\.app\n--es\nref\ncampaign/,
1278+
);
1279+
},
1280+
);
1281+
});
1282+
1283+
test('openAndroidApp shell-quotes launchArgs containing JSON or shell metacharacters', async () => {
1284+
await withMockedAdb(
1285+
'agent-device-android-open-launch-args-quoting-',
1286+
[
1287+
'#!/bin/sh',
1288+
'printf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
1289+
'printf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
1290+
'if [ "$1" = "-s" ]; then',
1291+
' shift',
1292+
' shift',
1293+
'fi',
1294+
'if [ "$1" = "shell" ] && [ "$2" = "pm" ] && [ "$3" = "list" ]; then',
1295+
' echo "package:com.example.app"',
1296+
' exit 0',
1297+
'fi',
1298+
'if [ "$1" = "shell" ] && [ "$2" = "am" ] && [ "$3" = "start" ]; then',
1299+
' echo "Status: ok"',
1300+
' exit 0',
1301+
'fi',
1302+
'exit 0',
1303+
'',
1304+
].join('\n'),
1305+
async ({ argsLogPath, device }) => {
1306+
// Value contains characters the device shell would otherwise re-interpret:
1307+
// `#` (comment), `;` (statement separator), `&` (background), `*` (glob),
1308+
// ` ` (word separator), `\` (escape).
1309+
const jsonPayload = '{"a":"x #y;z&w","b":"path/*"}';
1310+
await openAndroidApp(device, 'com.example.app', {
1311+
launchArgs: ['--es', 'EXTRA_CONFIG', jsonPayload],
1312+
});
1313+
const logged = await fs.readFile(argsLogPath, 'utf8');
1314+
// `--es` and the safe extra key pass through unquoted; the JSON value
1315+
// is single-quoted so `adb shell` re-tokenisation preserves it.
1316+
assert.match(
1317+
logged,
1318+
/--es\nEXTRA_CONFIG\n'\{"a":"x #y;z&w","b":"path\/\*"\}'/,
1319+
);
1320+
},
1321+
);
1322+
});
1323+
11521324
test('openAndroidApp normalizes missing package launch failures into APP_NOT_INSTALLED', async () => {
11531325
await withMockedAdb(
11541326
'agent-device-android-open-missing-package-',

0 commit comments

Comments
 (0)