diff --git a/src/__tests__/cli-client-commands.test.ts b/src/__tests__/cli-client-commands.test.ts index a663a3405..a833586d4 100644 --- a/src/__tests__/cli-client-commands.test.ts +++ b/src/__tests__/cli-client-commands.test.ts @@ -537,6 +537,7 @@ 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, }); @@ -544,6 +545,7 @@ test('open forwards launch console option to the client apps API', async () => { 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 () => { diff --git a/src/__tests__/runtime-apps.test.ts b/src/__tests__/runtime-apps.test.ts index 477ea7d8a..ecf372e82 100644 --- a/src/__tests__/runtime-apps.test.ts +++ b/src/__tests__/runtime-apps.test.ts @@ -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, { @@ -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' }, diff --git a/src/backend.ts b/src/backend.ts index 9cd46589d..0518ee6c7 100644 --- a/src/backend.ts +++ b/src/backend.ts @@ -195,6 +195,7 @@ export type BackendOpenTarget = { }; export type BackendOpenOptions = { + launchArgs?: string[]; relaunch?: boolean; }; diff --git a/src/client-normalizers.ts b/src/client-normalizers.ts index 0af354a84..4c6eeebdd 100644 --- a/src/client-normalizers.ts +++ b/src/client-normalizers.ts @@ -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, diff --git a/src/client-types.ts b/src/client-types.ts index 4bc7ec6cc..6a61f9a67 100644 --- a/src/client-types.ts +++ b/src/client-types.ts @@ -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; @@ -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; diff --git a/src/commands/apps.ts b/src/commands/apps.ts index c03db17ea..218c9a884 100644 --- a/src/commands/apps.ts +++ b/src/commands/apps.ts @@ -22,6 +22,7 @@ const MAX_APP_PUSH_PAYLOAD_BYTES = 8 * 1024; export type OpenAppCommandOptions = CommandContext & BackendOpenTarget & { + launchArgs?: string[]; relaunch?: boolean; }; @@ -109,6 +110,7 @@ export const openAppCommand: RuntimeCommand({ oneOf: [booleanSchema(), stringSchema()] }), noRecord: booleanField('Do not record this action.'), diff --git a/src/daemon-client.ts b/src/daemon-client.ts index f1cd206d5..0af33e5f0 100644 --- a/src/daemon-client.ts +++ b/src/daemon-client.ts @@ -55,6 +55,7 @@ export type OpenAppOptions = { serial?: NonNullable['serial']; activity?: NonNullable['activity']; launchConsole?: NonNullable['launchConsole']; + launchArgs?: NonNullable['launchArgs']; out?: NonNullable['out']; saveScript?: NonNullable['saveScript']; relaunch?: boolean; @@ -213,6 +214,7 @@ export async function openApp(options: OpenAppOptions = {}): Promise { +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', { @@ -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 {}; }); @@ -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'), @@ -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, + }, ]); }); diff --git a/src/daemon/handlers/session-open.ts b/src/daemon/handlers/session-open.ts index be5b084d8..bb56a8470 100644 --- a/src/daemon/handlers/session-open.ts +++ b/src/daemon/handlers/session-open.ts @@ -91,6 +91,7 @@ function contextForRuntimeLaunchUrl( ): ReturnType { const context = contextFromFlags(logPath, flags, appBundleId, traceLogPath); delete context.launchConsole; + delete context.launchArgs; return context; } diff --git a/src/platforms/ios/__tests__/index.test.ts b/src/platforms/ios/__tests__/index.test.ts index 71337d4b7..78af6fdf0 100644 --- a/src/platforms/ios/__tests__/index.test.ts +++ b/src/platforms/ios/__tests__/index.test.ts @@ -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 }); + } +}); + +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), diff --git a/src/platforms/ios/apps.ts b/src/platforms/ios/apps.ts index cbd95dc22..dc7daae4a 100644 --- a/src/platforms/ios/apps.ts +++ b/src/platforms/ios/apps.ts @@ -68,6 +68,8 @@ const ALIASES: Record = { }; const IOS_SIMULATOR_CONSOLE_CAPTURE_MS = 25_000; const AGENT_DEVICE_RUNNER_BUNDLE_PREFIX = 'com.callstack.agentdevice.runner'; +const IOS_SIMULATOR_LAUNCH_ARGS_WITH_URL_MESSAGE = + '--launch-args is not supported with iOS simulator URL opens (simctl openurl ignores launch args). Launch the app first with --launch-args, then issue the URL open in a separate call.'; const iosAppResolutionCache = createAppResolutionCache(); let cachedSimctlPrivacyServices: Set | null = null; @@ -171,10 +173,17 @@ export async function openIosApp( options?: { appBundleId?: string; launchConsole?: string; launchArgs?: string[]; url?: string }, ): Promise { const launchConsole = options?.launchConsole?.trim(); + const launchArgs = options?.launchArgs; if (launchConsole && (device.platform !== 'ios' || device.kind !== 'simulator')) { throw new AppError('UNSUPPORTED_OPERATION', LAUNCH_CONSOLE_IOS_SIMULATOR_ONLY_MESSAGE); } if (device.platform === 'macos') { + if (launchArgs && launchArgs.length > 0) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + '--launch-args is not supported on macOS; launch arguments are currently iOS-only.', + ); + } await openMacOsApp(device, app, options); return; } @@ -187,6 +196,9 @@ export async function openIosApp( throw new AppError('INVALID_ARGS', 'open requires a valid URL target'); } if (device.kind === 'simulator') { + if (launchArgs && launchArgs.length > 0) { + throw new AppError('INVALID_ARGS', IOS_SIMULATOR_LAUNCH_ARGS_WITH_URL_MESSAGE); + } await ensureBootedSimulator(device); await runSimctl(device, ['openurl', device.id, explicitUrl]); return; @@ -199,7 +211,7 @@ export async function openIosApp( 'Deep link open on iOS devices requires an active app bundle ID. Open the app first, then open the URL.', ); } - await launchIosDeviceProcess(device, bundleId, { payloadUrl: explicitUrl }); + await launchIosDeviceProcess(device, bundleId, { payloadUrl: explicitUrl, launchArgs }); return; } @@ -209,6 +221,9 @@ export async function openIosApp( throw new AppError('INVALID_ARGS', LAUNCH_CONSOLE_DIRECT_APP_ONLY_MESSAGE); } if (device.kind === 'simulator') { + if (launchArgs && launchArgs.length > 0) { + throw new AppError('INVALID_ARGS', IOS_SIMULATOR_LAUNCH_ARGS_WITH_URL_MESSAGE); + } await ensureBootedSimulator(device); await runSimctl(device, ['openurl', device.id, deepLinkTarget]); return; @@ -220,7 +235,7 @@ export async function openIosApp( 'Deep link open on iOS devices requires an active app bundle ID. Open the app first, then open the URL.', ); } - await launchIosDeviceProcess(device, bundleId, { payloadUrl: deepLinkTarget }); + await launchIosDeviceProcess(device, bundleId, { payloadUrl: deepLinkTarget, launchArgs }); return; } @@ -228,12 +243,12 @@ export async function openIosApp( if (device.kind === 'simulator') { await launchIosSimulatorApp(device, bundleId, { ...(launchConsole ? { launchConsole } : {}), - ...(options?.launchArgs ? { launchArgs: options.launchArgs } : {}), + ...(launchArgs ? { launchArgs } : {}), }); return; } - await launchIosDeviceProcess(device, bundleId); + await launchIosDeviceProcess(device, bundleId, { launchArgs }); } export async function openIosDevice(device: DeviceInfo): Promise { @@ -1116,7 +1131,9 @@ function buildIosSimulatorLaunchArgs( const args = ['launch']; if (options?.launchConsole) args.push('--console-pty'); args.push(deviceId, bundleId); - if (options?.launchArgs) args.push(...options.launchArgs); + if (options?.launchArgs && options.launchArgs.length > 0) { + args.push(...options.launchArgs); + } return args; } @@ -1173,11 +1190,16 @@ function joinProcessOutput(stdout: string, stderr: string): string { async function launchIosDeviceProcess( device: DeviceInfo, bundleId: string, - options?: { payloadUrl?: string }, + options?: { payloadUrl?: string; launchArgs?: string[] }, ): Promise { const args = ['device', 'process', 'launch', '--device', device.id, bundleId]; if (options?.payloadUrl) { args.push('--payload-url', options.payloadUrl); } + if (options?.launchArgs && options.launchArgs.length > 0) { + // `devicectl` uses Swift ArgumentParser; without `--` an arg starting with + // `-` / `--` could be re-interpreted as one of devicectl's own options. + args.push('--', ...options.launchArgs); + } await runIosDevicectl(args, { action: 'launch iOS app', deviceId: device.id }); } diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index 58f9e2dbc..83fe4b91c 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -273,6 +273,66 @@ test('parseArgs accepts install-from-source url and repeated headers', () => { assert.equal(parsed.flags.retentionMs, 60000); }); +test('parseArgs accepts open --launch-args with plain values', () => { + const parsed = parseArgs( + ['open', 'com.example.app', '--launch-args', 'fixtureMode', '--launch-args', 'verbose'], + { strictFlags: true }, + ); + assert.equal(parsed.command, 'open'); + assert.deepEqual(parsed.positionals, ['com.example.app']); + assert.deepEqual(parsed.flags.launchArgs, ['fixtureMode', 'verbose']); +}); + +test('parseArgs accepts open --launch-args with dash-prefixed values', () => { + const parsed = parseArgs( + [ + 'open', + 'com.example.app', + '--platform', + 'ios', + '--launch-args', + '-FeatureFlag', + '--launch-args', + 'YES', + ], + { strictFlags: true }, + ); + assert.equal(parsed.command, 'open'); + assert.deepEqual(parsed.flags.launchArgs, ['-FeatureFlag', 'YES']); +}); + +test('parseArgs accepts open --launch-args with double-dash-prefixed values', () => { + const parsed = parseArgs( + [ + 'open', + 'com.example.app', + '--launch-args', + '--es', + '--launch-args', + 'EXTRA_CONFIG', + '--launch-args', + '{"mode":"debug"}', + ], + { strictFlags: true }, + ); + assert.equal(parsed.command, 'open'); + assert.deepEqual(parsed.flags.launchArgs, ['--es', 'EXTRA_CONFIG', '{"mode":"debug"}']); +}); + +test('parseArgs rejects --launch-args on commands that do not allow it', () => { + assert.throws( + () => parseArgs(['tap', '100', '200', '--launch-args', 'foo'], { strictFlags: true }), + (error) => error instanceof AppError && error.code === 'INVALID_ARGS', + ); +}); + +test('usageForCommand documents open --launch-args', () => { + const help = usageForCommand('open'); + if (help === null) throw new Error('Expected open help text'); + assert.match(help, /--launch-args /); + assert.match(help, /forwarded verbatim/); +}); + test('parseArgs accepts install-from-source GitHub Actions artifact flag', () => { const parsed = parseArgs( [ diff --git a/src/utils/__tests__/daemon-client.test.ts b/src/utils/__tests__/daemon-client.test.ts index f5569c344..2f0474cb7 100644 --- a/src/utils/__tests__/daemon-client.test.ts +++ b/src/utils/__tests__/daemon-client.test.ts @@ -945,6 +945,7 @@ test('openApp forwards typed runtime hints on open requests', async () => { session: 'qa-session', app: 'Demo', platform: 'android', + launchArgs: ['-FeatureFlag', 'YES'], relaunch: true, runtime, meta: { requestId: 'req-open-app' }, @@ -958,6 +959,7 @@ test('openApp forwards typed runtime hints on open requests', async () => { assert.deepEqual((rpcRequest as any)?.params?.positionals, ['Demo']); assert.deepEqual((rpcRequest as any)?.params?.flags, { platform: 'android', + launchArgs: ['-FeatureFlag', 'YES'], relaunch: true, }); assert.deepEqual((rpcRequest as any)?.params?.runtime, runtime); diff --git a/src/utils/cli-command-overrides.ts b/src/utils/cli-command-overrides.ts index 78b26b25b..75b627738 100644 --- a/src/utils/cli-command-overrides.ts +++ b/src/utils/cli-command-overrides.ts @@ -70,7 +70,7 @@ const CLI_COMMAND_OVERRIDES = { 'Boot device/simulator; optionally launch app or deep link URL (macOS also supports --surface app|frontmost-app|desktop|menubar)', summary: 'Open an app, deep link or URL, save replays', positionalArgs: ['appOrUrl?', 'url?'], - allowedFlags: ['activity', 'launchConsole', 'saveScript', 'relaunch', 'surface'], + allowedFlags: ['activity', 'launchConsole', 'launchArgs', 'saveScript', 'relaunch', 'surface'], }, close: { positionalArgs: ['app?'], diff --git a/src/utils/cli-flags.ts b/src/utils/cli-flags.ts index 8722515e4..f5c1d925e 100644 --- a/src/utils/cli-flags.ts +++ b/src/utils/cli-flags.ts @@ -70,6 +70,7 @@ export type CliFlags = RemoteConfigMetroOptions & pattern?: 'one-way' | 'ping-pong'; activity?: string; launchConsole?: string; + launchArgs?: string[]; header?: string[]; githubActionsArtifact?: string; installSource?: DaemonInstallSource; @@ -498,6 +499,15 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ usageLabel: '--launch-console ', usageDescription: 'open: capture the initial iOS simulator launch console window to a file', }, + { + key: 'launchArgs', + names: ['--launch-args'], + type: 'string', + multiple: true, + usageLabel: '--launch-args ', + usageDescription: + 'open: repeatable launch argument forwarded verbatim to the iOS launch command (simctl launch positional args for simulators; devicectl process launch positional args for devices, after `--`). Currently supported only on iOS; Android and macOS reject the flag.', + }, { key: 'header', names: ['--header'],