diff --git a/.changeset/simulator-control-literals.md b/.changeset/simulator-control-literals.md new file mode 100644 index 0000000000..681a81d242 --- /dev/null +++ b/.changeset/simulator-control-literals.md @@ -0,0 +1,5 @@ +--- +'eas-cli': patch +--- + +Preserve the input order of repeated simulator control arguments. Values after `--`, including `name=value`, are now positional literals; place named arguments before `--`. diff --git a/packages/eas-cli/README.md b/packages/eas-cli/README.md index bf56a22453..54ee8ebc08 100644 --- a/packages/eas-cli/README.md +++ b/packages/eas-cli/README.md @@ -2788,8 +2788,9 @@ USAGE $ eas sim:control [ACTION...] [-h] [--id ] [--list] [--output ] [--json] [--non-interactive] ARGUMENTS - [ACTION...] Action to run, followed by its values in order or as name=value. Omit to list the actions. Put -- before - values that start with --. + [ACTION...] Action to run, followed by its values in order or as name=value. Omit to list the actions. Values after + -- are positional literals, including text that starts with -- or name=. Shell quotes alone do not + disable name=value parsing. FLAGS -h, --help Show command help, or usage for the selected action from the session. @@ -2817,6 +2818,14 @@ EXAMPLES $ eas simulator:control clipboard.write -- --some-text + Write hello using the named text parameter (shell quotes do not change parsing): + + $ eas simulator:control clipboard.write 'text=hello' + + Write the literal text=hello using --: + + $ eas simulator:control clipboard.write -- 'text=hello' + $ eas simulator:control hardwareKeyboard.set off $ eas simulator:control permissions.set grant camera com.example.app @@ -3263,8 +3272,9 @@ USAGE $ eas simulator:control [ACTION...] [-h] [--id ] [--list] [--output ] [--json] [--non-interactive] ARGUMENTS - [ACTION...] Action to run, followed by its values in order or as name=value. Omit to list the actions. Put -- before - values that start with --. + [ACTION...] Action to run, followed by its values in order or as name=value. Omit to list the actions. Values after + -- are positional literals, including text that starts with -- or name=. Shell quotes alone do not + disable name=value parsing. FLAGS -h, --help Show command help, or usage for the selected action from the session. @@ -3292,6 +3302,14 @@ EXAMPLES $ eas simulator:control clipboard.write -- --some-text + Write hello using the named text parameter (shell quotes do not change parsing): + + $ eas simulator:control clipboard.write 'text=hello' + + Write the literal text=hello using --: + + $ eas simulator:control clipboard.write -- 'text=hello' + $ eas simulator:control hardwareKeyboard.set off $ eas simulator:control permissions.set grant camera com.example.app diff --git a/packages/eas-cli/src/commands/simulator/__tests__/control.test.ts b/packages/eas-cli/src/commands/simulator/__tests__/control.test.ts index 53af67182c..e28939fd28 100644 --- a/packages/eas-cli/src/commands/simulator/__tests__/control.test.ts +++ b/packages/eas-cli/src/commands/simulator/__tests__/control.test.ts @@ -179,6 +179,91 @@ describe(SimulatorControl, () => { } }); + it.each(['text=hello', '--', '--help', ''])( + 'preserves the literal value after the separator: %s', + async value => { + served = { + ...catalog, + 'clipboard.write': { + type: 'object', + properties: { udid: { type: 'string' }, text: { type: 'string' } }, + required: ['udid', 'text'], + }, + }; + await createCommand(['clipboard.write', '--id', 'session-123', '--', value]).runAsync(); + expect(actionRequests()).toEqual([['clipboard.write', { text: value }]]); + } + ); + + it.each([ + { + argv: ['permissions.set', '--id', 's', 'action=grant', '--', 's', 'camera'], + params: { action: 'grant', service: 's', bundleId: 'camera' }, + }, + { + argv: ['permissions.set', '--id', 's', '--', 'grant', '--', 'com.app'], + params: { action: 'grant', service: '--', bundleId: 'com.app' }, + }, + { + argv: ['permissions.set', '--id', 's', '--', 'grant', 'permissions.set', 'com.app'], + params: { action: 'grant', service: 'permissions.set', bundleId: 'com.app' }, + }, + { + argv: ['permissions.set', '--id', 'camera', 'grant', 'microphone', 'camera'], + params: { action: 'grant', service: 'microphone', bundleId: 'camera' }, + }, + ])( + 'preserves input order when positional tokens repeat earlier input: $argv', + async ({ argv, params }) => { + await createCommand(argv).runAsync(); + expect(actionRequests()).toEqual([['permissions.set', params]]); + } + ); + + it('treats values as literal when the separator precedes the action', async () => { + served = { + ...catalog, + 'clipboard.write': { + type: 'object', + properties: { udid: { type: 'string' }, text: { type: 'string' } }, + required: ['udid', 'text'], + }, + }; + await createCommand(['--id', 'session-123', '--', 'clipboard.write', 'text=hello']).runAsync(); + expect(actionRequests()).toEqual([['clipboard.write', { text: 'text=hello' }]]); + }); + + it('does not treat a flag value of -- as the literal separator', async () => { + served = { + ...catalog, + 'clipboard.write': { + type: 'object', + properties: { udid: { type: 'string' }, text: { type: 'string' } }, + required: ['udid', 'text'], + }, + }; + await createCommand(['clipboard.write', 'text=hello', '--id', '--']).runAsync(); + expect(actionRequests()).toEqual([['clipboard.write', { text: 'hello' }]]); + }); + + it('keeps named arguments before the separator and positional values after it', async () => { + await createCommand([ + 'permissions.set', + 'action=grant', + '--id', + 'session-123', + '--', + 'service=camera', + 'bundleId=literal', + ]).runAsync(); + expect(actionRequests()).toEqual([ + [ + 'permissions.set', + { action: 'grant', service: 'service=camera', bundleId: 'bundleId=literal' }, + ], + ]); + }); + it('runs an action from the catalog with its values and prints the output', async () => { await createCommand(['hardwareKeyboard.set', 'off', '--id', 'session-123']).runAsync(); @@ -212,6 +297,12 @@ describe(SimulatorControl, () => { expect(jest.mocked(Log.log)).toHaveBeenCalledWith( ' eas simulator:control permissions.set ' ); + const lines = jest.mocked(Log.log).mock.calls.map(([line]) => String(line)); + const usage = lines.indexOf( + ' eas simulator:control permissions.set ' + ); + expect(lines[usage + 1]).toBe(' [--id ] [--json]'); + expect(lines.indexOf(' Values after -- are positional literals.')).toBeGreaterThan(usage + 1); expect(actionRequests()).toEqual([]); expect(close).toHaveBeenCalledTimes(1); }); diff --git a/packages/eas-cli/src/commands/simulator/control.ts b/packages/eas-cli/src/commands/simulator/control.ts index 00eb4dd2df..31722c9eed 100644 --- a/packages/eas-cli/src/commands/simulator/control.ts +++ b/packages/eas-cli/src/commands/simulator/control.ts @@ -43,6 +43,15 @@ export default class SimulatorControl extends EasCommand { '$ eas simulator:control --id ', '$ eas simulator:control screenshot.capture --help', '$ eas simulator:control clipboard.write -- --some-text', + { + description: + 'Write hello using the named text parameter (shell quotes do not change parsing):', + command: "$ eas simulator:control clipboard.write 'text=hello'", + }, + { + description: 'Write the literal text=hello using --:', + command: "$ eas simulator:control clipboard.write -- 'text=hello'", + }, '$ eas simulator:control hardwareKeyboard.set off', '$ eas simulator:control permissions.set grant camera com.example.app', '$ eas simulator:control app.install ./MyApp.ipa', @@ -53,7 +62,7 @@ export default class SimulatorControl extends EasCommand { static override args = { action: Args.string({ description: - 'Action to run, followed by its values in order or as name=value. Omit to list the actions. Put -- before values that start with --.', + 'Action to run, followed by its values in order or as name=value. Omit to list the actions. Values after -- are positional literals, including text that starts with -- or name=. Shell quotes alone do not disable name=value parsing.', }), }; @@ -81,7 +90,28 @@ export default class SimulatorControl extends EasCommand { }; async runAsync(): Promise { - const { args, argv, flags } = await this.parse(SimulatorControl); + const { args, raw, flags } = await this.parse(SimulatorControl); + // oclif's argv sorts repeated values by their first occurrence in the input. + // Raw arg tokens preserve their actual order, including literals after --. + const values = raw + .filter(token => token.type === 'arg') + .map(token => token.input) + .slice(1); + let literalFrom = values.length; + for (let index = 0; index < this.argv.length; index++) { + const value = this.argv[index]; + if (value === '--') { + literalFrom = Math.max(0, values.length - (this.argv.length - index - 1)); + break; + } + // A standalone -- consumed as a flag value is not an argument separator. + const flag = value.startsWith('--') + ? SimulatorControl.flags[value.slice(2) as keyof typeof SimulatorControl.flags] + : undefined; + if (flag?.type === 'option') { + index++; + } + } const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags); if (jsonFlag) { @@ -137,7 +167,8 @@ export default class SimulatorControl extends EasCommand { await this.runActionAsync(connection, entry, { sessionId, action, - values: argv.slice(1).map(String), + values, + literalFrom, outputPath: flags.output, jsonFlag, }); @@ -181,6 +212,8 @@ export default class SimulatorControl extends EasCommand { const outputFlag = entry.output === 'base64' ? '--output ' : '[--output ]'; Log.log(` ${entry.output ? `${outputFlag} ` : ''}[--id ] [--json]`); Log.newLine(); + Log.log(' Values after -- are positional literals.'); + Log.newLine(); Log.log(` ${chalk.bold('OPTIONS')}`); Log.log(` --id Defaults to ${SIMULATOR_DOTENV_FILE_NAME}.`); Log.log(' --json Print the action schema as JSON. Implies --non-interactive.'); @@ -208,17 +241,19 @@ export default class SimulatorControl extends EasCommand { sessionId, action, values, + literalFrom, outputPath, jsonFlag, }: { sessionId: string; action: string; values: string[]; + literalFrom: number; outputPath: string | undefined; jsonFlag: boolean; } ): Promise { - const params = buildHostActionParams(action, entry, values); + const params = buildHostActionParams(action, entry, values, literalFrom); await assertHostActionOutputAsync(action, entry, outputPath); const localFile = await findLocalUploadAsync(entry, params); diff --git a/packages/eas-cli/src/simulator/hostActions.ts b/packages/eas-cli/src/simulator/hostActions.ts index f5bcc4d60c..660907637f 100644 --- a/packages/eas-cli/src/simulator/hostActions.ts +++ b/packages/eas-cli/src/simulator/hostActions.ts @@ -392,13 +392,14 @@ export function formatHostActionUsage(name: string, entry: HostActionCatalogEntr export function buildHostActionParams( name: string, entry: HostActionCatalogEntry, - values: string[] + values: string[], + literalFrom = values.length ): Record { const properties = hostActionProperties(entry); const params: Record = {}; const positional: string[] = []; - for (const value of values) { - const named = NAMED_VALUE.exec(value); + for (const [index, value] of values.entries()) { + const named = index < literalFrom ? NAMED_VALUE.exec(value) : null; if (!named || !properties.has(named[1])) { positional.push(value); } else if (Object.hasOwn(params, named[1])) {