Skip to content

Commit b7ca4fb

Browse files
feat: forward --launch-args to adb shell am start on Android (#599)
* 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. * fix: reject unsupported launch args openings --------- Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
1 parent dc37f86 commit b7ca4fb

8 files changed

Lines changed: 205 additions & 41 deletions

File tree

src/commands/client-command-contracts.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,9 @@ export const clientCommandDefinitions = [
8181
surface: enumField(SURFACE_VALUES),
8282
activity: stringField('Android activity name.'),
8383
launchConsole: stringField('Launch console mode.'),
84-
launchArgs: stringArrayField('iOS launch arguments forwarded verbatim to the app process.'),
84+
launchArgs: stringArrayField(
85+
'Launch arguments forwarded verbatim to the platform launch command.',
86+
),
8587
relaunch: booleanField('Force relaunch.'),
8688
saveScript: jsonSchemaField<boolean | string>({ oneOf: [booleanSchema(), stringSchema()] }),
8789
noRecord: booleanField('Do not record this action.'),

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

Lines changed: 47 additions & 3 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,22 @@ vi.mock('../../platforms/ios/apps.ts', async (importOriginal) => {
1819
};
1920
});
2021

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

2434
beforeEach(() => {
2535
mockClearIosSimulatorAppState.mockClear();
2636
mockOpenIosApp.mockClear();
37+
mockOpenAndroidApp.mockClear();
2738
});
2839

2940
test('dispatch open rejects URL as first argument when second URL is provided', async () => {
@@ -46,7 +57,19 @@ test('dispatch open rejects URL as first argument when second URL is provided',
4657
);
4758
});
4859

49-
test('dispatch open rejects Android launch arguments instead of dropping them', async () => {
60+
test('dispatch open rejects launch arguments without an app target', async () => {
61+
await assert.rejects(
62+
() => dispatchCommand(IOS_SIMULATOR, 'open', [], undefined, { launchArgs: ['-Flag'] }),
63+
(error: unknown) => {
64+
assert.equal(error instanceof AppError, true);
65+
assert.equal((error as AppError).code, 'INVALID_ARGS');
66+
assert.match((error as AppError).message, /requires an app target/i);
67+
return true;
68+
},
69+
);
70+
});
71+
72+
test('dispatch open forwards Android launch arguments to openAndroidApp', async () => {
5073
const device: DeviceInfo = {
5174
platform: 'android',
5275
id: 'emulator-5554',
@@ -55,15 +78,36 @@ test('dispatch open rejects Android launch arguments instead of dropping them',
5578
booted: true,
5679
};
5780

81+
await dispatchCommand(device, 'open', ['com.example.app'], undefined, {
82+
launchArgs: ['--es', 'KEY', 'value'],
83+
});
84+
85+
assert.equal(mockOpenAndroidApp.mock.calls.length, 1);
86+
assert.equal(mockOpenAndroidApp.mock.calls[0]?.[0], device);
87+
assert.equal(mockOpenAndroidApp.mock.calls[0]?.[1], 'com.example.app');
88+
const optionsArg = mockOpenAndroidApp.mock.calls[0]?.[2];
89+
assert.ok(optionsArg && typeof optionsArg === 'object', 'expected options object');
90+
assert.deepEqual(optionsArg.launchArgs, ['--es', 'KEY', 'value']);
91+
});
92+
93+
test('dispatch open rejects launch arguments on Linux', async () => {
94+
const device: DeviceInfo = {
95+
platform: 'linux',
96+
id: 'linux-local',
97+
name: 'Linux',
98+
kind: 'device',
99+
booted: true,
100+
};
101+
58102
await assert.rejects(
59103
() =>
60-
dispatchCommand(device, 'open', ['com.example.app'], undefined, {
104+
dispatchCommand(device, 'open', ['org.example.App'], undefined, {
61105
launchArgs: ['--fixture', 'demo'],
62106
}),
63107
(error: unknown) => {
64108
assert.equal(error instanceof AppError, true);
65109
assert.equal((error as AppError).code, 'UNSUPPORTED_OPERATION');
66-
assert.match((error as AppError).message, /Apple platforms/i);
110+
assert.match((error as AppError).message, /Linux/i);
67111
return true;
68112
},
69113
);

src/core/dispatch.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -181,19 +181,26 @@ async function handleOpenCommand(
181181
const app = positionals[0];
182182
const url = positionals[1];
183183
const launchConsole = context?.launchConsole;
184+
const launchArgs = context?.launchArgs;
184185
if (positionals.length > 2) {
185186
throw new AppError('INVALID_ARGS', 'open accepts at most two arguments: <app|url> [url]');
186187
}
187188
if (!app) {
188189
if (launchConsole) {
189190
throw new AppError('INVALID_ARGS', '--launch-console requires an app target');
190191
}
192+
if (launchArgs && launchArgs.length > 0) {
193+
throw new AppError('INVALID_ARGS', '--launch-args requires an app target');
194+
}
191195
await interactor.openDevice();
192196
return { app: null, ...successText('Opened device') };
193197
}
194198
if (launchConsole && (device.platform !== 'ios' || device.kind !== 'simulator')) {
195199
throw new AppError('UNSUPPORTED_OPERATION', LAUNCH_CONSOLE_IOS_SIMULATOR_ONLY_MESSAGE);
196200
}
201+
if (device.platform === 'linux' && launchArgs && launchArgs.length > 0) {
202+
throw new AppError('UNSUPPORTED_OPERATION', '--launch-args is not supported on Linux.');
203+
}
197204
if (url !== undefined) {
198205
if (isDeepLinkTarget(app)) {
199206
throw new AppError(
@@ -210,20 +217,14 @@ async function handleOpenCommand(
210217
await interactor.open(app, {
211218
activity: context?.activity,
212219
appBundleId: context?.appBundleId,
213-
launchArgs: context?.launchArgs,
220+
launchArgs,
214221
url,
215222
});
216223
return { app, url, ...successText(`Opened: ${app}`) };
217224
}
218225
if (launchConsole && isDeepLinkTarget(app)) {
219226
throw new AppError('INVALID_ARGS', LAUNCH_CONSOLE_DIRECT_APP_ONLY_MESSAGE);
220227
}
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-
}
227228
if (context?.clearAppState) {
228229
if (isDeepLinkTarget(app)) {
229230
throw new AppError(
@@ -243,7 +244,7 @@ async function handleOpenCommand(
243244
activity: context?.activity,
244245
appBundleId: context?.appBundleId,
245246
launchConsole,
246-
launchArgs: context?.launchArgs,
247+
launchArgs,
247248
});
248249
return { app, ...(launchConsole ? { launchConsole } : {}), ...successText(`Opened: ${app}`) };
249250
}

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: 100 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,24 @@ async function withMockedAdb(
6666
}
6767
}
6868

69+
function androidOpenAdbScript(): string {
70+
return [
71+
'#!/bin/sh',
72+
'printf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
73+
'printf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
74+
'if [ "$1" = "-s" ]; then',
75+
' shift',
76+
' shift',
77+
'fi',
78+
'if [ "$1" = "shell" ] && [ "$2" = "am" ] && [ "$3" = "start" ]; then',
79+
' echo "Status: ok"',
80+
' exit 0',
81+
'fi',
82+
'exit 0',
83+
'',
84+
].join('\n');
85+
}
86+
6987
test('parseUiHierarchy reads double-quoted Android node attributes', () => {
7088
const xml =
7189
'<hierarchy><node class="android.widget.TextView" text="Hello" content-desc="Greeting" resource-id="com.demo:id/title" bounds="[10,20][110,60]" clickable="true" enabled="true"/></hierarchy>';
@@ -1121,25 +1139,7 @@ test('installAndroidInstallablePath invalidates cached display-name package matc
11211139
test('openAndroidApp default launch uses -p package flag', async () => {
11221140
await withMockedAdb(
11231141
'agent-device-android-open-default-',
1124-
[
1125-
'#!/bin/sh',
1126-
'printf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
1127-
'printf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
1128-
'if [ "$1" = "-s" ]; then',
1129-
' shift',
1130-
' shift',
1131-
'fi',
1132-
'if [ "$1" = "shell" ] && [ "$2" = "pm" ] && [ "$3" = "list" ]; then',
1133-
' echo "package:com.example.app"',
1134-
' exit 0',
1135-
'fi',
1136-
'if [ "$1" = "shell" ] && [ "$2" = "am" ] && [ "$3" = "start" ]; then',
1137-
' echo "Status: ok"',
1138-
' exit 0',
1139-
'fi',
1140-
'exit 0',
1141-
'',
1142-
].join('\n'),
1142+
androidOpenAdbScript(),
11431143
async ({ argsLogPath, device }) => {
11441144
await openAndroidApp(device, 'com.example.app');
11451145
const logged = await fs.readFile(argsLogPath, 'utf8');
@@ -1149,6 +1149,87 @@ 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+
androidOpenAdbScript(),
1156+
async ({ argsLogPath, device }) => {
1157+
await openAndroidApp(device, 'com.example.app', {
1158+
launchArgs: ['--es', 'screen', 'home', '--ez', 'fresh', 'true'],
1159+
});
1160+
const logged = await fs.readFile(argsLogPath, 'utf8');
1161+
assert.match(logged, /-p\ncom\.example\.app\n--es\nscreen\nhome\n--ez\nfresh\ntrue/);
1162+
},
1163+
);
1164+
});
1165+
1166+
test('openAndroidApp appends launchArgs to am start when activity override is set', async () => {
1167+
await withMockedAdb(
1168+
'agent-device-android-open-launch-args-activity-',
1169+
androidOpenAdbScript(),
1170+
async ({ argsLogPath, device }) => {
1171+
await openAndroidApp(device, 'com.example.app', {
1172+
activity: '.MainActivity',
1173+
launchArgs: ['--es', 'mode', 'debug'],
1174+
});
1175+
const logged = await fs.readFile(argsLogPath, 'utf8');
1176+
assert.match(logged, /-n\ncom\.example\.app\/\.MainActivity\n--es\nmode\ndebug/);
1177+
},
1178+
);
1179+
});
1180+
1181+
test('openAndroidApp appends launchArgs to am start for deep link URL opens', async () => {
1182+
await withMockedAdb(
1183+
'agent-device-android-open-launch-args-url-',
1184+
androidOpenAdbScript(),
1185+
async ({ argsLogPath, device }) => {
1186+
await openAndroidApp(device, 'myapp://item/42', {
1187+
launchArgs: ['--es', 'ref', 'campaign'],
1188+
});
1189+
const logged = await fs.readFile(argsLogPath, 'utf8');
1190+
assert.match(logged, /-d\nmyapp:\/\/item\/42\n--es\nref\ncampaign/);
1191+
},
1192+
);
1193+
});
1194+
1195+
test('openAndroidApp appends launchArgs to am start for app-bound URL opens', async () => {
1196+
await withMockedAdb(
1197+
'agent-device-android-open-launch-args-app-bound-url-',
1198+
androidOpenAdbScript(),
1199+
async ({ argsLogPath, device }) => {
1200+
await openAndroidApp(device, 'com.example.app', {
1201+
url: 'https://example.com/promo',
1202+
launchArgs: ['--es', 'ref', 'campaign'],
1203+
});
1204+
const logged = await fs.readFile(argsLogPath, 'utf8');
1205+
assert.match(
1206+
logged,
1207+
/-d\nhttps:\/\/example\.com\/promo\n-p\ncom\.example\.app\n--es\nref\ncampaign/,
1208+
);
1209+
},
1210+
);
1211+
});
1212+
1213+
test('openAndroidApp shell-quotes launchArgs containing JSON or shell metacharacters', async () => {
1214+
await withMockedAdb(
1215+
'agent-device-android-open-launch-args-quoting-',
1216+
androidOpenAdbScript(),
1217+
async ({ argsLogPath, device }) => {
1218+
// Value contains characters the device shell would otherwise re-interpret:
1219+
// `#` (comment), `;` (statement separator), `&` (background), `*` (glob),
1220+
// ` ` (word separator), `\` (escape).
1221+
const jsonPayload = '{"a":"x #y;z&w","b":"path/*"}';
1222+
await openAndroidApp(device, 'com.example.app', {
1223+
launchArgs: ['--es', 'EXTRA_CONFIG', jsonPayload],
1224+
});
1225+
const logged = await fs.readFile(argsLogPath, 'utf8');
1226+
// `--es` and the safe extra key pass through unquoted; the JSON value
1227+
// is single-quoted so `adb shell` re-tokenisation preserves it.
1228+
assert.match(logged, /--es\nEXTRA_CONFIG\n'\{"a":"x #y;z&w","b":"path\/\*"\}'/);
1229+
},
1230+
);
1231+
});
1232+
11521233
test('openAndroidApp normalizes missing package launch failures into APP_NOT_INSTALLED', async () => {
11531234
await withMockedAdb(
11541235
'agent-device-android-open-missing-package-',

0 commit comments

Comments
 (0)