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
2 changes: 2 additions & 0 deletions src/__tests__/cli-client-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -537,13 +537,15 @@ test('open forwards launch console option to the client apps API', async () => {
version: false,
platform: 'ios',
launchConsole: '/tmp/launch-console.log',
launchArgs: ['-FeatureFlag', 'YES'],
},
client,
});

assert.equal(handled, true);
assert.equal(observed?.platform, 'ios');
assert.equal(observed?.launchConsole, '/tmp/launch-console.log');
assert.deepEqual(observed?.launchArgs, ['-FeatureFlag', 'YES']);
});

test('apps command defaults to user-installed and prints discovery hint', async () => {
Expand Down
3 changes: 2 additions & 1 deletion src/__tests__/runtime-apps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ test('runtime app commands call typed backend lifecycle primitives', async () =>
const opened = await device.apps.open({
session: 'default',
app: ' com.example.app ',
launchArgs: ['-FeatureFlag', 'YES'],
relaunch: true,
});
assert.deepEqual(opened, {
Expand Down Expand Up @@ -61,7 +62,7 @@ test('runtime app commands call typed backend lifecycle primitives', async () =>
{
command: 'openApp',
target: { app: 'com.example.app' },
options: { relaunch: true },
options: { launchArgs: ['-FeatureFlag', 'YES'], relaunch: true },
session: 'default',
},
{ command: 'closeApp', app: 'com.example.app' },
Expand Down
1 change: 1 addition & 0 deletions src/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ export type BackendOpenTarget = {
};

export type BackendOpenOptions = {
launchArgs?: string[];
relaunch?: boolean;
};

Expand Down
1 change: 1 addition & 0 deletions src/client-normalizers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@ export function buildFlags(options: InternalRequestOptions): CommandFlags {
surface: options.surface,
activity: options.activity,
launchConsole: options.launchConsole,
launchArgs: options.launchArgs,
relaunch: options.relaunch,
shutdown: options.shutdown,
saveScript: options.saveScript,
Expand Down
2 changes: 2 additions & 0 deletions src/client-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ export type AppOpenOptions = AgentDeviceRequestOverrides &
surface?: 'app' | 'frontmost-app' | 'desktop' | 'menubar';
activity?: string;
launchConsole?: string;
launchArgs?: string[];
relaunch?: boolean;
saveScript?: boolean | string;
noRecord?: boolean;
Expand Down Expand Up @@ -844,6 +845,7 @@ export type InternalRequestOptions = AgentDeviceClientConfig &
surface?: 'app' | 'frontmost-app' | 'desktop' | 'menubar';
activity?: string;
launchConsole?: string;
launchArgs?: string[];
relaunch?: boolean;
shutdown?: boolean;
saveScript?: boolean | string;
Expand Down
2 changes: 2 additions & 0 deletions src/commands/apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const MAX_APP_PUSH_PAYLOAD_BYTES = 8 * 1024;

export type OpenAppCommandOptions = CommandContext &
BackendOpenTarget & {
launchArgs?: string[];
relaunch?: boolean;
};

Expand Down Expand Up @@ -109,6 +110,7 @@ export const openAppCommand: RuntimeCommand<OpenAppCommandOptions, OpenAppComman
toAppBackendContext(runtime, options),
target,
{
launchArgs: options.launchArgs,
relaunch: options.relaunch,
},
);
Expand Down
1 change: 1 addition & 0 deletions src/commands/cli-grammar/apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export const appCliReaders = {
surface: flags.surface,
activity: flags.activity,
launchConsole: flags.launchConsole,
launchArgs: flags.launchArgs,
relaunch: flags.relaunch,
saveScript: flags.saveScript,
noRecord: flags.noRecord,
Expand Down
1 change: 1 addition & 0 deletions src/commands/client-command-contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ 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.'),
relaunch: booleanField('Force relaunch.'),
saveScript: jsonSchemaField<boolean | string>({ oneOf: [booleanSchema(), stringSchema()] }),
noRecord: booleanField('Do not record this action.'),
Expand Down
3 changes: 3 additions & 0 deletions src/daemon-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export type OpenAppOptions = {
serial?: NonNullable<DaemonRequest['flags']>['serial'];
activity?: NonNullable<DaemonRequest['flags']>['activity'];
launchConsole?: NonNullable<DaemonRequest['flags']>['launchConsole'];
launchArgs?: NonNullable<DaemonRequest['flags']>['launchArgs'];
out?: NonNullable<DaemonRequest['flags']>['out'];
saveScript?: NonNullable<DaemonRequest['flags']>['saveScript'];
relaunch?: boolean;
Expand Down Expand Up @@ -213,6 +214,7 @@ export async function openApp(options: OpenAppOptions = {}): Promise<DaemonRespo
serial,
activity,
launchConsole,
launchArgs,
out,
saveScript,
relaunch,
Expand All @@ -234,6 +236,7 @@ export async function openApp(options: OpenAppOptions = {}): Promise<DaemonRespo
...(serial !== undefined ? { serial } : {}),
...(activity !== undefined ? { activity } : {}),
...(launchConsole !== undefined ? { launchConsole } : {}),
...(launchArgs !== undefined ? { launchArgs } : {}),
...(out !== undefined ? { out } : {}),
...(saveScript !== undefined ? { saveScript } : {}),
...(relaunch ? { relaunch: true } : {}),
Expand Down
26 changes: 21 additions & 5 deletions src/daemon/handlers/__tests__/session-open-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,13 +146,14 @@ test('open applies stored runtime launchUrl and reports runtime hints', async ()
}
});

test('open applies launchConsole only to the direct app launch before runtime launchUrl', async () => {
test('open applies launch-only flags only to the direct app launch before runtime launchUrl', async () => {
const sessionStore = makeSessionStore();
const launchConsolePath = path.join(os.tmpdir(), 'launch-console.log');
const dispatchCalls: Array<{
command: string;
positionals: string[];
launchConsole?: string;
launchArgs?: string[];
}> = [];

sessionStore.setRuntimeHints('launch-console-runtime', {
Expand All @@ -167,7 +168,12 @@ test('open applies launchConsole only to the direct app launch before runtime la
booted: true,
});
mockDispatch.mockImplementation(async (_device, command, positionals, _outPath, context) => {
dispatchCalls.push({ command, positionals, launchConsole: context?.launchConsole });
dispatchCalls.push({
command,
positionals,
launchConsole: context?.launchConsole,
launchArgs: context?.launchArgs,
});
return {};
});

Expand All @@ -177,7 +183,7 @@ test('open applies launchConsole only to the direct app launch before runtime la
session: 'launch-console-runtime',
command: 'open',
positionals: ['Demo'],
flags: { platform: 'ios', launchConsole: launchConsolePath },
flags: { platform: 'ios', launchConsole: launchConsolePath, launchArgs: ['-Flag', 'YES'] },
},
sessionName: 'launch-console-runtime',
logPath: path.join(os.tmpdir(), 'daemon.log'),
Expand All @@ -187,8 +193,18 @@ test('open applies launchConsole only to the direct app launch before runtime la

expect(response?.ok).toBe(true);
expect(dispatchCalls).toEqual([
{ command: 'open', positionals: ['Demo'], launchConsole: launchConsolePath },
{ command: 'open', positionals: ['myapp://dev-client'], launchConsole: undefined },
{
command: 'open',
positionals: ['Demo'],
launchConsole: launchConsolePath,
launchArgs: ['-Flag', 'YES'],
},
{
command: 'open',
positionals: ['myapp://dev-client'],
launchConsole: undefined,
launchArgs: undefined,
},
]);
});

Expand Down
1 change: 1 addition & 0 deletions src/daemon/handlers/session-open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ function contextForRuntimeLaunchUrl(
): ReturnType<typeof contextFromFlags> {
const context = contextFromFlags(logPath, flags, appBundleId, traceLogPath);
delete context.launchConsole;
delete context.launchArgs;
return context;
}

Expand Down
171 changes: 171 additions & 0 deletions src/platforms/ios/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -926,6 +926,177 @@ test('openIosApp captures iOS simulator launch console output when requested', a
}
});

test('openIosApp emits a clean simctl launch when launchArgs is an empty array', async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-ios-launch-args-empty-'));
const xcrunPath = path.join(tmpDir, 'xcrun');
const argsLogPath = path.join(tmpDir, 'args.log');
await fs.writeFile(
xcrunPath,
'#!/bin/sh\nprintf "%s\\n" "$@" > "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n',
'utf8',
);
await fs.chmod(xcrunPath, 0o755);

const previousPath = process.env.PATH;
const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE;
process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`;
process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath;

try {
mockEnsureBootedSimulator.mockResolvedValue();
await openIosApp(IOS_TEST_SIMULATOR, 'MyApp', {
appBundleId: 'com.example.app',
launchArgs: [],
});
const args = (await fs.readFile(argsLogPath, 'utf8')).trim().split('\n').filter(Boolean);
assert.deepEqual(args, ['simctl', 'launch', 'sim-1', 'com.example.app']);
} finally {
process.env.PATH = previousPath;
if (previousArgsFile === undefined) {
delete process.env.AGENT_DEVICE_TEST_ARGS_FILE;
} else {
process.env.AGENT_DEVICE_TEST_ARGS_FILE = previousArgsFile;
}
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
Comment thread
mikegarfinkle marked this conversation as resolved.

test('openIosApp appends launchArgs after the bundle id on iOS device', async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-ios-launch-args-dev-'));
const xcrunPath = path.join(tmpDir, 'xcrun');
const argsLogPath = path.join(tmpDir, 'args.log');
await fs.writeFile(
xcrunPath,
'#!/bin/sh\nprintf "%s\\n" "$@" > "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n',
'utf8',
);
await fs.chmod(xcrunPath, 0o755);

const previousPath = process.env.PATH;
const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE;
process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`;
process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath;

try {
await openIosApp(IOS_TEST_DEVICE, 'MyApp', {
appBundleId: 'com.example.app',
launchArgs: ['-FeatureFlag', 'YES'],
});
const args = (await fs.readFile(argsLogPath, 'utf8')).trim().split('\n').filter(Boolean);
assert.deepEqual(args, [
'devicectl',
'device',
'process',
'launch',
'--device',
'ios-device-1',
'com.example.app',
'--',
'-FeatureFlag',
'YES',
]);
} finally {
process.env.PATH = previousPath;
if (previousArgsFile === undefined) {
delete process.env.AGENT_DEVICE_TEST_ARGS_FILE;
} else {
process.env.AGENT_DEVICE_TEST_ARGS_FILE = previousArgsFile;
}
await fs.rm(tmpDir, { recursive: true, force: true });
}
});

test('openIosApp appends launchArgs alongside --payload-url for iOS device deep links', async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-ios-launch-args-deep-'));
const xcrunPath = path.join(tmpDir, 'xcrun');
const argsLogPath = path.join(tmpDir, 'args.log');
await fs.writeFile(
xcrunPath,
'#!/bin/sh\nprintf "%s\\n" "$@" > "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n',
'utf8',
);
await fs.chmod(xcrunPath, 0o755);

const previousPath = process.env.PATH;
const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE;
process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`;
process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath;

try {
await openIosApp(IOS_TEST_DEVICE, 'myapp://item/42', {
appBundleId: 'com.example.app',
launchArgs: ['-Tracking', 'NO'],
});
const args = (await fs.readFile(argsLogPath, 'utf8')).trim().split('\n').filter(Boolean);
assert.deepEqual(args, [
'devicectl',
'device',
'process',
'launch',
'--device',
'ios-device-1',
'com.example.app',
'--payload-url',
'myapp://item/42',
'--',
'-Tracking',
'NO',
]);
} finally {
process.env.PATH = previousPath;
if (previousArgsFile === undefined) {
delete process.env.AGENT_DEVICE_TEST_ARGS_FILE;
} else {
process.env.AGENT_DEVICE_TEST_ARGS_FILE = previousArgsFile;
}
await fs.rm(tmpDir, { recursive: true, force: true });
}
});

test('openIosApp rejects launchArgs combined with URL deep link on iOS simulator', async () => {
mockEnsureBootedSimulator.mockResolvedValue();
await assert.rejects(
() =>
openIosApp(IOS_TEST_SIMULATOR, 'myapp://item/42', {
launchArgs: ['-FeatureFlag', 'YES'],
}),
(error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(error.code, 'INVALID_ARGS');
assert.match(String(error.message), /simctl openurl/);
return true;
},
);
await assert.rejects(
() =>
openIosApp(IOS_TEST_SIMULATOR, 'MyApp', {
appBundleId: 'com.example.app',
url: 'https://example.com/path',
launchArgs: ['-FeatureFlag', 'YES'],
}),
(error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(error.code, 'INVALID_ARGS');
return true;
},
);
});

test('openIosApp rejects launchArgs on macOS', async () => {
await assert.rejects(
() =>
openIosApp(MACOS_TEST_DEVICE, 'TextEdit', {
launchArgs: ['-FeatureFlag', 'YES'],
}),
(error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(error.code, 'UNSUPPORTED_OPERATION');
assert.match(String(error.message), /macOS/);
return true;
},
);
});

test('readIosClipboardText rejects physical devices', async () => {
await assert.rejects(
() => readIosClipboardText(IOS_TEST_DEVICE),
Expand Down
Loading
Loading