Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/commands/client-command-contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,9 @@ export const clientCommandDefinitions = [
surface: enumField(SURFACE_VALUES),
activity: stringField('Android activity name.'),
launchConsole: stringField('Launch console mode.'),
launchArgs: stringArrayField('iOS launch arguments forwarded verbatim to the app process.'),
launchArgs: stringArrayField(
'Launch arguments forwarded verbatim to the platform launch command.',
),
relaunch: booleanField('Force relaunch.'),
saveScript: jsonSchemaField<boolean | string>({ oneOf: [booleanSchema(), stringSchema()] }),
noRecord: booleanField('Do not record this action.'),
Expand Down
50 changes: 47 additions & 3 deletions src/core/__tests__/dispatch-open.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { dispatchCommand } from '../dispatch.ts';
import { AppError } from '../../utils/errors.ts';
import type { DeviceInfo } from '../../utils/device.ts';
import { clearIosSimulatorAppState, openIosApp } from '../../platforms/ios/apps.ts';
import { openAndroidApp } from '../../platforms/android/app-lifecycle.ts';
import { IOS_SIMULATOR } from '../../__tests__/test-utils/device-fixtures.ts';

vi.mock('../../platforms/ios/apps.ts', async (importOriginal) => {
Expand All @@ -18,12 +19,22 @@ vi.mock('../../platforms/ios/apps.ts', async (importOriginal) => {
};
});

vi.mock('../../platforms/android/app-lifecycle.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../platforms/android/app-lifecycle.ts')>();
return {
...actual,
openAndroidApp: vi.fn(async () => {}),
};
});

const mockClearIosSimulatorAppState = vi.mocked(clearIosSimulatorAppState);
const mockOpenIosApp = vi.mocked(openIosApp);
const mockOpenAndroidApp = vi.mocked(openAndroidApp);

beforeEach(() => {
mockClearIosSimulatorAppState.mockClear();
mockOpenIosApp.mockClear();
mockOpenAndroidApp.mockClear();
});

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

test('dispatch open rejects Android launch arguments instead of dropping them', async () => {
test('dispatch open rejects launch arguments without an app target', async () => {
await assert.rejects(
() => dispatchCommand(IOS_SIMULATOR, 'open', [], undefined, { launchArgs: ['-Flag'] }),
(error: unknown) => {
assert.equal(error instanceof AppError, true);
assert.equal((error as AppError).code, 'INVALID_ARGS');
assert.match((error as AppError).message, /requires an app target/i);
return true;
},
);
});

test('dispatch open forwards Android launch arguments to openAndroidApp', async () => {
const device: DeviceInfo = {
platform: 'android',
id: 'emulator-5554',
Expand All @@ -55,15 +78,36 @@ test('dispatch open rejects Android launch arguments instead of dropping them',
booted: true,
};

await dispatchCommand(device, 'open', ['com.example.app'], undefined, {
launchArgs: ['--es', 'KEY', 'value'],
});

assert.equal(mockOpenAndroidApp.mock.calls.length, 1);
assert.equal(mockOpenAndroidApp.mock.calls[0]?.[0], device);
assert.equal(mockOpenAndroidApp.mock.calls[0]?.[1], 'com.example.app');
const optionsArg = mockOpenAndroidApp.mock.calls[0]?.[2];
assert.ok(optionsArg && typeof optionsArg === 'object', 'expected options object');
assert.deepEqual(optionsArg.launchArgs, ['--es', 'KEY', 'value']);
});

test('dispatch open rejects launch arguments on Linux', async () => {
const device: DeviceInfo = {
platform: 'linux',
id: 'linux-local',
name: 'Linux',
kind: 'device',
booted: true,
};

await assert.rejects(
() =>
dispatchCommand(device, 'open', ['com.example.app'], undefined, {
dispatchCommand(device, 'open', ['org.example.App'], undefined, {
launchArgs: ['--fixture', 'demo'],
}),
(error: unknown) => {
assert.equal(error instanceof AppError, true);
assert.equal((error as AppError).code, 'UNSUPPORTED_OPERATION');
assert.match((error as AppError).message, /Apple platforms/i);
assert.match((error as AppError).message, /Linux/i);
return true;
},
);
Expand Down
17 changes: 9 additions & 8 deletions src/core/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,19 +181,26 @@ async function handleOpenCommand(
const app = positionals[0];
const url = positionals[1];
const launchConsole = context?.launchConsole;
const launchArgs = context?.launchArgs;
if (positionals.length > 2) {
throw new AppError('INVALID_ARGS', 'open accepts at most two arguments: <app|url> [url]');
}
if (!app) {
if (launchConsole) {
throw new AppError('INVALID_ARGS', '--launch-console requires an app target');
}
if (launchArgs && launchArgs.length > 0) {
throw new AppError('INVALID_ARGS', '--launch-args requires an app target');
}
await interactor.openDevice();
return { app: null, ...successText('Opened device') };
}
if (launchConsole && (device.platform !== 'ios' || device.kind !== 'simulator')) {
throw new AppError('UNSUPPORTED_OPERATION', LAUNCH_CONSOLE_IOS_SIMULATOR_ONLY_MESSAGE);
}
if (device.platform === 'linux' && launchArgs && launchArgs.length > 0) {
throw new AppError('UNSUPPORTED_OPERATION', '--launch-args is not supported on Linux.');
}
if (url !== undefined) {
if (isDeepLinkTarget(app)) {
throw new AppError(
Expand All @@ -210,20 +217,14 @@ async function handleOpenCommand(
await interactor.open(app, {
activity: context?.activity,
appBundleId: context?.appBundleId,
launchArgs: context?.launchArgs,
launchArgs,
url,
});
return { app, url, ...successText(`Opened: ${app}`) };
}
if (launchConsole && isDeepLinkTarget(app)) {
throw new AppError('INVALID_ARGS', LAUNCH_CONSOLE_DIRECT_APP_ONLY_MESSAGE);
}
if (device.platform === 'android' && context?.launchArgs && context.launchArgs.length > 0) {
throw new AppError(
'UNSUPPORTED_OPERATION',
'Launch arguments are currently supported only on Apple platforms.',
);
}
if (context?.clearAppState) {
if (isDeepLinkTarget(app)) {
throw new AppError(
Expand All @@ -243,7 +244,7 @@ async function handleOpenCommand(
activity: context?.activity,
appBundleId: context?.appBundleId,
launchConsole,
launchArgs: context?.launchArgs,
launchArgs,
});
return { app, ...(launchConsole ? { launchConsole } : {}), ...successText(`Opened: ${app}`) };
}
Expand Down
1 change: 1 addition & 0 deletions src/core/interactors/android.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export function createAndroidInteractor(device: DeviceInfo): Interactor {
openAndroidApp(device, app, {
activity: options?.activity,
appBundleId: options?.appBundleId,
launchArgs: options?.launchArgs,
url: options?.url,
}),
openDevice: () => openAndroidDevice(device),
Expand Down
119 changes: 100 additions & 19 deletions src/platforms/android/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,24 @@ async function withMockedAdb(
}
}

function androidOpenAdbScript(): string {
return [
'#!/bin/sh',
'printf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
'printf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
'if [ "$1" = "-s" ]; then',
' shift',
' shift',
'fi',
'if [ "$1" = "shell" ] && [ "$2" = "am" ] && [ "$3" = "start" ]; then',
' echo "Status: ok"',
' exit 0',
'fi',
'exit 0',
'',
].join('\n');
}

test('parseUiHierarchy reads double-quoted Android node attributes', () => {
const xml =
'<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>';
Expand Down Expand Up @@ -1121,25 +1139,7 @@ test('installAndroidInstallablePath invalidates cached display-name package matc
test('openAndroidApp default launch uses -p package flag', async () => {
await withMockedAdb(
'agent-device-android-open-default-',
[
'#!/bin/sh',
'printf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
'printf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
'if [ "$1" = "-s" ]; then',
' shift',
' shift',
'fi',
'if [ "$1" = "shell" ] && [ "$2" = "pm" ] && [ "$3" = "list" ]; then',
' echo "package:com.example.app"',
' exit 0',
'fi',
'if [ "$1" = "shell" ] && [ "$2" = "am" ] && [ "$3" = "start" ]; then',
' echo "Status: ok"',
' exit 0',
'fi',
'exit 0',
'',
].join('\n'),
androidOpenAdbScript(),
async ({ argsLogPath, device }) => {
await openAndroidApp(device, 'com.example.app');
const logged = await fs.readFile(argsLogPath, 'utf8');
Expand All @@ -1149,6 +1149,87 @@ test('openAndroidApp default launch uses -p package flag', async () => {
);
});

test('openAndroidApp appends launchArgs to am start when launching by package', async () => {
await withMockedAdb(
'agent-device-android-open-launch-args-',
androidOpenAdbScript(),
async ({ argsLogPath, device }) => {
await openAndroidApp(device, 'com.example.app', {
launchArgs: ['--es', 'screen', 'home', '--ez', 'fresh', 'true'],
});
const logged = await fs.readFile(argsLogPath, 'utf8');
assert.match(logged, /-p\ncom\.example\.app\n--es\nscreen\nhome\n--ez\nfresh\ntrue/);
},
);
});

test('openAndroidApp appends launchArgs to am start when activity override is set', async () => {
await withMockedAdb(
'agent-device-android-open-launch-args-activity-',
androidOpenAdbScript(),
async ({ argsLogPath, device }) => {
await openAndroidApp(device, 'com.example.app', {
activity: '.MainActivity',
launchArgs: ['--es', 'mode', 'debug'],
});
const logged = await fs.readFile(argsLogPath, 'utf8');
assert.match(logged, /-n\ncom\.example\.app\/\.MainActivity\n--es\nmode\ndebug/);
},
);
});

test('openAndroidApp appends launchArgs to am start for deep link URL opens', async () => {
await withMockedAdb(
'agent-device-android-open-launch-args-url-',
androidOpenAdbScript(),
async ({ argsLogPath, device }) => {
await openAndroidApp(device, 'myapp://item/42', {
launchArgs: ['--es', 'ref', 'campaign'],
});
const logged = await fs.readFile(argsLogPath, 'utf8');
assert.match(logged, /-d\nmyapp:\/\/item\/42\n--es\nref\ncampaign/);
},
);
});

test('openAndroidApp appends launchArgs to am start for app-bound URL opens', async () => {
await withMockedAdb(
'agent-device-android-open-launch-args-app-bound-url-',
androidOpenAdbScript(),
async ({ argsLogPath, device }) => {
await openAndroidApp(device, 'com.example.app', {
url: 'https://example.com/promo',
launchArgs: ['--es', 'ref', 'campaign'],
});
const logged = await fs.readFile(argsLogPath, 'utf8');
assert.match(
logged,
/-d\nhttps:\/\/example\.com\/promo\n-p\ncom\.example\.app\n--es\nref\ncampaign/,
);
},
);
});

test('openAndroidApp shell-quotes launchArgs containing JSON or shell metacharacters', async () => {
await withMockedAdb(
'agent-device-android-open-launch-args-quoting-',
androidOpenAdbScript(),
async ({ argsLogPath, device }) => {
// Value contains characters the device shell would otherwise re-interpret:
// `#` (comment), `;` (statement separator), `&` (background), `*` (glob),
// ` ` (word separator), `\` (escape).
const jsonPayload = '{"a":"x #y;z&w","b":"path/*"}';
await openAndroidApp(device, 'com.example.app', {
launchArgs: ['--es', 'EXTRA_CONFIG', jsonPayload],
});
const logged = await fs.readFile(argsLogPath, 'utf8');
// `--es` and the safe extra key pass through unquoted; the JSON value
// is single-quoted so `adb shell` re-tokenisation preserves it.
assert.match(logged, /--es\nEXTRA_CONFIG\n'\{"a":"x #y;z&w","b":"path\/\*"\}'/);
},
);
});

test('openAndroidApp normalizes missing package launch failures into APP_NOT_INSTALLED', async () => {
await withMockedAdb(
'agent-device-android-open-missing-package-',
Expand Down
Loading
Loading