From dcd01831381f4cd4cdccbdf1c8ab6f13a1f45f4b Mon Sep 17 00:00:00 2001 From: Fil Maj Date: Mon, 4 Nov 2024 12:08:31 -0500 Subject: [PATCH] feat!(cli-test): Use `child_process` `spawn` arguments properly, fixing JSON encoding on the command line on Windows (#2090) Co-authored-by: Michael Brooks --- packages/cli-test/package.json | 10 +- packages/cli-test/src/cli/cli-process.spec.ts | 138 +++++++++----- packages/cli-test/src/cli/cli-process.ts | 47 +++-- .../cli-test/src/cli/commands/app.spec.ts | 11 +- packages/cli-test/src/cli/commands/app.ts | 6 +- .../cli-test/src/cli/commands/auth.spec.ts | 26 +-- packages/cli-test/src/cli/commands/auth.ts | 6 +- .../src/cli/commands/collaborator.spec.ts | 14 +- .../cli-test/src/cli/commands/collaborator.ts | 6 +- .../cli-test/src/cli/commands/create.spec.ts | 24 ++- packages/cli-test/src/cli/commands/create.ts | 2 +- .../src/cli/commands/datastore.spec.ts | 24 +-- .../cli-test/src/cli/commands/datastore.ts | 36 ++-- .../cli-test/src/cli/commands/env.spec.ts | 10 +- packages/cli-test/src/cli/commands/env.ts | 6 +- .../src/cli/commands/external-auth.spec.ts | 61 ++++-- .../src/cli/commands/external-auth.ts | 8 +- .../src/cli/commands/function.spec.ts | 41 ++-- .../cli-test/src/cli/commands/function.ts | 2 +- .../src/cli/commands/manifest.spec.ts | 16 +- .../cli-test/src/cli/commands/manifest.ts | 4 +- .../src/cli/commands/platform.spec.ts | 39 ++-- .../cli-test/src/cli/commands/platform.ts | 8 +- .../cli-test/src/cli/commands/trigger.spec.ts | 178 ++++++++++++------ packages/cli-test/src/cli/commands/trigger.ts | 22 +-- packages/cli-test/src/cli/shell.spec.ts | 86 ++++++++- packages/cli-test/src/cli/shell.ts | 74 ++++++-- packages/cli-test/tsconfig.json | 8 +- 28 files changed, 602 insertions(+), 311 deletions(-) diff --git a/packages/cli-test/package.json b/packages/cli-test/package.json index a2b8641e8..ac1981a04 100644 --- a/packages/cli-test/package.json +++ b/packages/cli-test/package.json @@ -4,16 +4,10 @@ "description": "Node.js bindings for the Slack CLI for use in automated testing", "author": "Salesforce, Inc.", "license": "MIT", - "keywords": [ - "slack", - "cli", - "test" - ], + "keywords": ["slack", "cli", "test"], "main": "dist/index.js", "types": "dist/index.d.ts", - "files": [ - "dist/**/*" - ], + "files": ["dist/**/*"], "engines": { "node": ">=18.15.5" }, diff --git a/packages/cli-test/src/cli/cli-process.spec.ts b/packages/cli-test/src/cli/cli-process.spec.ts index 923ebb192..1ca719140 100644 --- a/packages/cli-test/src/cli/cli-process.spec.ts +++ b/packages/cli-test/src/cli/cli-process.spec.ts @@ -21,7 +21,7 @@ describe('SlackCLIProcess class', () => { const orig = process.env.SLACK_CLI_PATH; process.env.SLACK_CLI_PATH = ''; assert.throws(() => { - new SlackCLIProcess('help'); + new SlackCLIProcess(['help']); }); process.env.SLACK_CLI_PATH = orig; }); @@ -30,108 +30,156 @@ describe('SlackCLIProcess class', () => { describe('CLI flag handling', () => { describe('global options', () => { it('should map dev option to --slackdev', async () => { - let cmd = new SlackCLIProcess('help', { dev: true }); + let cmd = new SlackCLIProcess(['help'], { dev: true }); await cmd.execAsync(); - sandbox.assert.calledWithMatch(spawnProcessSpy, '--slackdev'); + sandbox.assert.calledWith(spawnProcessSpy, sinon.match.string, sinon.match.array.contains(['--slackdev'])); spawnProcessSpy.resetHistory(); - cmd = new SlackCLIProcess('help'); + cmd = new SlackCLIProcess(['help']); await cmd.execAsync(); - sandbox.assert.neverCalledWithMatch(spawnProcessSpy, '--slackdev'); + sandbox.assert.neverCalledWith(spawnProcessSpy, sinon.match.string, sinon.match.array.contains(['--slackdev'])); spawnProcessSpy.resetHistory(); }); it('should map qa option to QA host', async () => { - let cmd = new SlackCLIProcess('help', { qa: true }); + let cmd = new SlackCLIProcess(['help'], { qa: true }); await cmd.execAsync(); - sandbox.assert.calledWithMatch(spawnProcessSpy, '--apihost qa.slack.com'); + sandbox.assert.calledWith( + spawnProcessSpy, + sinon.match.string, + sinon.match.array.contains(['--apihost', 'qa.slack.com']), + ); spawnProcessSpy.resetHistory(); - cmd = new SlackCLIProcess('help'); + cmd = new SlackCLIProcess(['help']); await cmd.execAsync(); - sandbox.assert.neverCalledWithMatch(spawnProcessSpy, '--apihost qa.slack.com'); + sandbox.assert.neverCalledWith( + spawnProcessSpy, + sinon.match.string, + sinon.match.array.contains(['--apihost', 'qa.slack.com']), + ); spawnProcessSpy.resetHistory(); }); it('should map apihost option to provided host', async () => { - let cmd = new SlackCLIProcess('help', { apihost: 'dev123.slack.com' }); + let cmd = new SlackCLIProcess(['help'], { apihost: 'dev123.slack.com' }); await cmd.execAsync(); - sandbox.assert.calledWithMatch(spawnProcessSpy, '--apihost dev123.slack.com'); + sandbox.assert.calledWith( + spawnProcessSpy, + sinon.match.string, + sinon.match.array.contains(['--apihost', 'dev123.slack.com']), + ); spawnProcessSpy.resetHistory(); - cmd = new SlackCLIProcess('help'); + cmd = new SlackCLIProcess(['help']); await cmd.execAsync(); - sandbox.assert.neverCalledWithMatch(spawnProcessSpy, '--apihost dev123.slack.com'); + sandbox.assert.neverCalledWith( + spawnProcessSpy, + sinon.match.string, + sinon.match.array.contains(['--apihost', 'dev123.slack.com']), + ); spawnProcessSpy.resetHistory(); }); it('should default to passing --skip-update but allow overriding that', async () => { - let cmd = new SlackCLIProcess('help'); + let cmd = new SlackCLIProcess(['help']); await cmd.execAsync(); - sandbox.assert.calledWithMatch(spawnProcessSpy, '--skip-update'); + sandbox.assert.calledWith(spawnProcessSpy, sinon.match.string, sinon.match.array.contains(['--skip-update'])); spawnProcessSpy.resetHistory(); - cmd = new SlackCLIProcess('help', { skipUpdate: false }); + cmd = new SlackCLIProcess(['help'], { skipUpdate: false }); await cmd.execAsync(); - sandbox.assert.neverCalledWithMatch(spawnProcessSpy, '--skip-update'); + sandbox.assert.neverCalledWith( + spawnProcessSpy, + sinon.match.string, + sinon.match.array.contains(['--skip-update']), + ); spawnProcessSpy.resetHistory(); - cmd = new SlackCLIProcess('help', { skipUpdate: true }); + cmd = new SlackCLIProcess(['help'], { skipUpdate: true }); await cmd.execAsync(); - sandbox.assert.calledWithMatch(spawnProcessSpy, '--skip-update'); + sandbox.assert.calledWith(spawnProcessSpy, sinon.match.string, sinon.match.array.contains(['--skip-update'])); spawnProcessSpy.resetHistory(); - cmd = new SlackCLIProcess('help', {}); // empty global options; so undefined skipUpdate option + cmd = new SlackCLIProcess(['help'], {}); // empty global options; so undefined skipUpdate option await cmd.execAsync(); - sandbox.assert.calledWithMatch(spawnProcessSpy, '--skip-update'); + sandbox.assert.calledWith(spawnProcessSpy, sinon.match.string, sinon.match.array.contains(['--skip-update'])); }); it('should default to `--app deployed` but allow overriding that via the `app` parameter', async () => { - let cmd = new SlackCLIProcess('help'); + let cmd = new SlackCLIProcess(['help']); await cmd.execAsync(); - sandbox.assert.calledWithMatch(spawnProcessSpy, '--app deployed'); + sandbox.assert.calledWith( + spawnProcessSpy, + sinon.match.string, + sinon.match.array.contains(['--app', 'deployed']), + ); spawnProcessSpy.resetHistory(); - cmd = new SlackCLIProcess('help', { app: 'local' }); + cmd = new SlackCLIProcess(['help'], { app: 'local' }); await cmd.execAsync(); - sandbox.assert.calledWithMatch(spawnProcessSpy, '--app local'); + sandbox.assert.calledWith(spawnProcessSpy, sinon.match.string, sinon.match.array.contains(['--app', 'local'])); }); it('should default to `--force` but allow overriding that via the `force` parameter', async () => { - let cmd = new SlackCLIProcess('help'); + let cmd = new SlackCLIProcess(['help']); await cmd.execAsync(); - sandbox.assert.calledWithMatch(spawnProcessSpy, '--force'); + sandbox.assert.calledWith(spawnProcessSpy, sinon.match.string, sinon.match.array.contains(['--force'])); spawnProcessSpy.resetHistory(); - cmd = new SlackCLIProcess('help', { force: true }); + cmd = new SlackCLIProcess(['help'], { force: true }); await cmd.execAsync(); - sandbox.assert.calledWithMatch(spawnProcessSpy, '--force'); + sandbox.assert.calledWithMatch(spawnProcessSpy, sinon.match.string, sinon.match.array.contains(['--force'])); spawnProcessSpy.resetHistory(); - cmd = new SlackCLIProcess('help', { force: false }); + cmd = new SlackCLIProcess(['help'], { force: false }); await cmd.execAsync(); - sandbox.assert.neverCalledWithMatch(spawnProcessSpy, '--force'); + sandbox.assert.neverCalledWith(spawnProcessSpy, sinon.match.string, sinon.match.array.contains(['--force'])); }); it('should map token option to `--token`', async () => { - let cmd = new SlackCLIProcess('help', { token: 'xoxb-1234' }); + let cmd = new SlackCLIProcess(['help'], { token: 'xoxb-1234' }); await cmd.execAsync(); - sandbox.assert.calledWithMatch(spawnProcessSpy, '--token xoxb-1234'); + sandbox.assert.calledWith( + spawnProcessSpy, + sinon.match.string, + sinon.match.array.contains(['--token', 'xoxb-1234']), + ); spawnProcessSpy.resetHistory(); - cmd = new SlackCLIProcess('help'); + cmd = new SlackCLIProcess(['help']); await cmd.execAsync(); - sandbox.assert.neverCalledWithMatch(spawnProcessSpy, '--token xoxb-1234'); + sandbox.assert.neverCalledWith( + spawnProcessSpy, + sinon.match.string, + sinon.match.array.contains(['--token', 'xoxb-1234']), + ); spawnProcessSpy.resetHistory(); }); }); describe('command options', () => { it('should pass command-level key/value options to command in the form `-- value`', async () => { - const cmd = new SlackCLIProcess('help', {}, { '--awesome': 'yes' }); + const cmd = new SlackCLIProcess(['help'], {}, { '--awesome': 'yes' }); await cmd.execAsync(); - sandbox.assert.calledWithMatch(spawnProcessSpy, '--awesome yes'); + sandbox.assert.calledWith( + spawnProcessSpy, + sinon.match.string, + sinon.match.array.contains(['--awesome', 'yes']), + ); }); it('should only pass command-level key option if value is true in the form `--key`', async () => { - const cmd = new SlackCLIProcess('help', {}, { '--no-prompt': true }); + const cmd = new SlackCLIProcess(['help'], {}, { '--no-prompt': true }); await cmd.execAsync(); - sandbox.assert.calledWithMatch(spawnProcessSpy, '--no-prompt'); + sandbox.assert.calledWith(spawnProcessSpy, sinon.match.string, sinon.match.array.contains(['--no-prompt'])); }); it('should not pass command-level key option if value is falsy', async () => { - let cmd = new SlackCLIProcess('help', {}, { '--no-prompt': false }); + let cmd = new SlackCLIProcess(['help'], {}, { '--no-prompt': false }); await cmd.execAsync(); - sandbox.assert.neverCalledWithMatch(spawnProcessSpy, '--no-prompt'); + sandbox.assert.neverCalledWith( + spawnProcessSpy, + sinon.match.string, + sinon.match.array.contains(['--no-prompt']), + ); spawnProcessSpy.resetHistory(); - cmd = new SlackCLIProcess('help', {}, { '--no-prompt': '' }); + cmd = new SlackCLIProcess(['help'], {}, { '--no-prompt': '' }); await cmd.execAsync(); - sandbox.assert.neverCalledWithMatch(spawnProcessSpy, '--no-prompt'); + sandbox.assert.neverCalledWith( + spawnProcessSpy, + sinon.match.string, + sinon.match.array.contains(['--no-prompt']), + ); spawnProcessSpy.resetHistory(); - cmd = new SlackCLIProcess('help', {}, { '--no-prompt': undefined }); + cmd = new SlackCLIProcess(['help'], {}, { '--no-prompt': undefined }); await cmd.execAsync(); - sandbox.assert.neverCalledWithMatch(spawnProcessSpy, '--no-prompt'); + sandbox.assert.neverCalledWith( + spawnProcessSpy, + sinon.match.string, + sinon.match.array.contains(['--no-prompt']), + ); }); }); }); diff --git a/packages/cli-test/src/cli/cli-process.ts b/packages/cli-test/src/cli/cli-process.ts index a915c01c7..54aac020d 100644 --- a/packages/cli-test/src/cli/cli-process.ts +++ b/packages/cli-test/src/cli/cli-process.ts @@ -49,7 +49,7 @@ export class SlackCLIProcess { /** * @description The CLI command to invoke */ - public command: string; + public command: string[]; /** * @description The global CLI options to pass to the command @@ -61,7 +61,11 @@ export class SlackCLIProcess { */ public commandOptions: SlackCLICommandOptions | undefined; - public constructor(command: string, globalOptions?: SlackCLIGlobalOptions, commandOptions?: SlackCLICommandOptions) { + public constructor( + command: string[], + globalOptions?: SlackCLIGlobalOptions, + commandOptions?: SlackCLICommandOptions, + ) { if (!process.env.SLACK_CLI_PATH) { throw new Error('`SLACK_CLI_PATH` environment variable not found! Aborting!'); } @@ -75,7 +79,8 @@ export class SlackCLIProcess { */ public async execAsync(shellOpts?: Partial): Promise { const cmd = this.assembleShellInvocation(); - const proc = shell.spawnProcess(cmd, shellOpts); + // biome-ignore lint/style/noNonNullAssertion: the constructor checks for the truthiness of this environment variable + const proc = shell.spawnProcess(process.env.SLACK_CLI_PATH!, cmd, shellOpts); await shell.checkIfFinished(proc); return proc; } @@ -88,7 +93,8 @@ export class SlackCLIProcess { shellOpts?: Partial, ): Promise { const cmd = this.assembleShellInvocation(); - const proc = shell.spawnProcess(cmd, shellOpts); + // biome-ignore lint/style/noNonNullAssertion: the constructor checks for the truthiness of this environment variable + const proc = shell.spawnProcess(process.env.SLACK_CLI_PATH!, cmd, shellOpts); await shell.waitForOutput(output, proc, { timeout: shellOpts?.timeout, }); @@ -100,53 +106,54 @@ export class SlackCLIProcess { */ public execSync(shellOpts?: Partial): string { const cmd = this.assembleShellInvocation(); - return shell.runCommandSync(cmd, shellOpts); + // biome-ignore lint/style/noNonNullAssertion: the constructor checks for the truthiness of this environment variable + return shell.runCommandSync(process.env.SLACK_CLI_PATH!, cmd, shellOpts); } - private assembleShellInvocation(): string { - let cmd = `${process.env.SLACK_CLI_PATH}`; + private assembleShellInvocation(): string[] { + let cmd: string[] = []; if (this.globalOptions) { const opts = this.globalOptions; // Determine API host target if (opts.apihost) { - cmd += ` --apihost ${opts.apihost}`; + cmd = cmd.concat(['--apihost', opts.apihost]); } else if (opts.qa) { - cmd += ' --apihost qa.slack.com'; + cmd = cmd.concat(['--apihost', 'qa.slack.com']); } else if (opts.dev) { - cmd += ' --slackdev'; + cmd = cmd.concat(['--slackdev']); } // Always skip update unless explicitly set to something falsy if (opts.skipUpdate || opts.skipUpdate === undefined) { - cmd += ' --skip-update'; + cmd = cmd.concat(['--skip-update']); } // Target team if (opts.team) { - cmd += ` --team ${opts.team}`; + cmd = cmd.concat(['--team', opts.team]); } // App instance; defaults to `deployed` if (opts.app) { - cmd += ` --app ${opts.app}`; + cmd = cmd.concat(['--app', opts.app]); } else { - cmd += ' --app deployed'; + cmd = cmd.concat(['--app', 'deployed']); } // Ignore warnings via --force; defaults to true if (opts.force || typeof opts.force === 'undefined') { - cmd += ' --force'; + cmd = cmd.concat(['--force']); } // Specifying custom token if (opts.token) { - cmd += ` --token ${opts.token}`; + cmd = cmd.concat(['--token', opts.token]); } } else { - cmd += ' --skip-update --force --app deployed'; + cmd = cmd.concat(['--skip-update', '--force', '--app', 'deployed']); } - cmd += ` ${this.command}`; + cmd = cmd.concat(this.command); if (this.commandOptions) { for (const [key, value] of Object.entries(this.commandOptions)) { if (key && value) { - cmd += ` ${key}`; + cmd.push(key); if (value !== true) { - cmd += ` ${value}`; + cmd.push(String(value)); } } } diff --git a/packages/cli-test/src/cli/commands/app.spec.ts b/packages/cli-test/src/cli/commands/app.spec.ts index 385bb42c9..a21f4e826 100644 --- a/packages/cli-test/src/cli/commands/app.spec.ts +++ b/packages/cli-test/src/cli/commands/app.spec.ts @@ -25,28 +25,27 @@ describe('app commands', () => { describe('delete method', () => { it('should invoke `app delete` and default force=true', async () => { await app.delete({ appPath: '/some/path' }); - sandbox.assert.calledWith(spawnSpy, sinon.match('--force')); - sandbox.assert.calledWith(spawnSpy, sinon.match('app delete')); + sandbox.assert.calledWith(spawnSpy, sinon.match.string, sinon.match.array.contains(['--force', 'app', 'delete'])); }); it('should invoke with `--force` if force=true', async () => { await app.delete({ appPath: '/some/path', force: true }); - sandbox.assert.calledWith(spawnSpy, sinon.match('--force')); + sandbox.assert.calledWith(spawnSpy, sinon.match.string, sinon.match.array.contains(['--force'])); }); it('should invoke without `--force` if force=false', async () => { await app.delete({ appPath: '/some/path', force: false }); - sandbox.assert.neverCalledWith(spawnSpy, sinon.match('--force')); + sandbox.assert.neverCalledWith(spawnSpy, sinon.match.string, sinon.match.array.contains(['--force'])); }); }); describe('install method', () => { it('should invoke a CLI process with `app install`', async () => { await app.install({ appPath: '/some/path' }); - sandbox.assert.calledWith(spawnSpy, sinon.match('app install')); + sandbox.assert.calledWith(spawnSpy, sinon.match.string, sinon.match.array.contains(['app', 'install'])); }); }); describe('list method', () => { it('should invoke a CLI process with `app list`', async () => { await app.list({ appPath: '/some/path' }); - sandbox.assert.calledWith(spawnSpy, sinon.match('app list')); + sandbox.assert.calledWith(spawnSpy, sinon.match.string, sinon.match.array.contains(['app', 'list'])); }); }); }); diff --git a/packages/cli-test/src/cli/commands/app.ts b/packages/cli-test/src/cli/commands/app.ts index 07c799aaf..bc5eecb6a 100644 --- a/packages/cli-test/src/cli/commands/app.ts +++ b/packages/cli-test/src/cli/commands/app.ts @@ -7,7 +7,7 @@ import type { ProjectCommandArguments } from '../../types/commands/common_argume * @returns command output */ export const del = async function appDelete(args: ProjectCommandArguments): Promise { - const cmd = new SlackCLIProcess('app delete', args); + const cmd = new SlackCLIProcess(['app', 'delete'], args); const proc = await cmd.execAsync({ cwd: args.appPath, }); @@ -19,7 +19,7 @@ export const del = async function appDelete(args: ProjectCommandArguments): Prom * @returns command output */ export const install = async function workspaceInstall(args: ProjectCommandArguments): Promise { - const cmd = new SlackCLIProcess('app install', args); + const cmd = new SlackCLIProcess(['app', 'install'], args); const proc = await cmd.execAsync({ cwd: args.appPath, }); @@ -31,7 +31,7 @@ export const install = async function workspaceInstall(args: ProjectCommandArgum * @returns command output */ export const list = async function appList(args: ProjectCommandArguments): Promise { - const cmd = new SlackCLIProcess('app list', args); + const cmd = new SlackCLIProcess(['app', 'list'], args); const proc = await cmd.execAsync({ cwd: args.appPath, }); diff --git a/packages/cli-test/src/cli/commands/auth.spec.ts b/packages/cli-test/src/cli/commands/auth.spec.ts index cbb869076..74ec8fa03 100644 --- a/packages/cli-test/src/cli/commands/auth.spec.ts +++ b/packages/cli-test/src/cli/commands/auth.spec.ts @@ -31,8 +31,7 @@ describe('auth commands', () => { process: mockProcess(), }); const resp = await auth.loginNoPrompt(); - sandbox.assert.calledWith(spawnSpy, sinon.match('login')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--no-prompt')); + sandbox.assert.calledWith(spawnSpy, sinon.match.string, sinon.match.array.contains(['login', '--no-prompt'])); sandbox.assert.match(resp.authTicket, '123456'); sandbox.assert.match(resp.authTicketSlashCommand, '/slackauthticket 123456'); }); @@ -43,27 +42,30 @@ describe('auth commands', () => { authTicket: '123456', challenge: 'batman', }); - sandbox.assert.calledWith(spawnSpy, sinon.match('login')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--no-prompt')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--challenge batman')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--ticket 123456')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains(['login', '--no-prompt', '--challenge', 'batman', '--ticket', '123456']), + ); }); }); describe('logout method', () => { it('should invoke a CLI process with `logout`', async () => { await auth.logout(); - sandbox.assert.calledWith(spawnSpy, sinon.match('logout')); + sandbox.assert.calledWith(spawnSpy, sinon.match.string, sinon.match.array.contains(['logout'])); }); it('should invoke a CLI process with `logout --team` if both `team` and `all` are specified', async () => { await auth.logout({ team: 'T1234', all: true }); - sandbox.assert.calledWith(spawnSpy, sinon.match('logout')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--team T1234')); - sandbox.assert.neverCalledWith(spawnSpy, sinon.match('--all')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains(['logout', '--team', 'T1234']), + ); + sandbox.assert.neverCalledWith(spawnSpy, sinon.match.string, sinon.match.array.contains(['--all'])); }); it('should invoke a CLI process with `logout --all` if `all` specified', async () => { await auth.logout({ all: true }); - sandbox.assert.calledWith(spawnSpy, sinon.match('logout')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--all')); + sandbox.assert.calledWith(spawnSpy, sinon.match.string, sinon.match.array.contains(['logout', '--all'])); }); }); }); diff --git a/packages/cli-test/src/cli/commands/auth.ts b/packages/cli-test/src/cli/commands/auth.ts index ba9ec077a..ad9eb69dc 100644 --- a/packages/cli-test/src/cli/commands/auth.ts +++ b/packages/cli-test/src/cli/commands/auth.ts @@ -29,7 +29,7 @@ export default { */ authTicket: string; }> { - const cmd = new SlackCLIProcess('login', args, { + const cmd = new SlackCLIProcess(['login'], args, { '--no-prompt': true, }); const proc = await cmd.execAsync(); @@ -64,7 +64,7 @@ export default { authTicket: string; }, ): Promise { - const cmd = new SlackCLIProcess('login', args, { + const cmd = new SlackCLIProcess(['login'], args, { '--no-prompt': true, '--challenge': args.challenge, '--ticket': args.authTicket, @@ -95,7 +95,7 @@ export default { if (args && 'all' in args && !('team' in args) && args.all) { cmdOpts['--all'] = true; } - const cmd = new SlackCLIProcess('logout', args, cmdOpts); + const cmd = new SlackCLIProcess(['logout'], args, cmdOpts); const proc = await cmd.execAsync(); return proc.output; }, diff --git a/packages/cli-test/src/cli/commands/collaborator.spec.ts b/packages/cli-test/src/cli/commands/collaborator.spec.ts index 918a439db..e352fa1ad 100644 --- a/packages/cli-test/src/cli/commands/collaborator.spec.ts +++ b/packages/cli-test/src/cli/commands/collaborator.spec.ts @@ -25,19 +25,27 @@ describe('collaborator commands', () => { describe('add method', () => { it('should invoke `collaborators add `', async () => { await collaborator.add({ appPath: '/some/path', collaboratorEmail: 'you@me.com' }); - sandbox.assert.calledWith(spawnSpy, sinon.match('collaborators add you@me.com')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains(['collaborators', 'add', 'you@me.com']), + ); }); }); describe('list method', () => { it('should invoke `collaborators list`', async () => { await collaborator.list({ appPath: '/some/path' }); - sandbox.assert.calledWith(spawnSpy, sinon.match('collaborators list')); + sandbox.assert.calledWith(spawnSpy, sinon.match.string, sinon.match.array.contains(['collaborators', 'list'])); }); }); describe('remove method', () => { it('should invoke `collaborators remove `', async () => { await collaborator.remove({ appPath: '/some/path', collaboratorEmail: 'you@me.com' }); - sandbox.assert.calledWith(spawnSpy, sinon.match('collaborators remove you@me.com')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains(['collaborators', 'remove', 'you@me.com']), + ); }); }); }); diff --git a/packages/cli-test/src/cli/commands/collaborator.ts b/packages/cli-test/src/cli/commands/collaborator.ts index 46690acba..e5374d925 100644 --- a/packages/cli-test/src/cli/commands/collaborator.ts +++ b/packages/cli-test/src/cli/commands/collaborator.ts @@ -11,7 +11,7 @@ export interface CollaboratorEmail { * @returns command output */ export const add = async function collaboratorsAdd(args: ProjectCommandArguments & CollaboratorEmail): Promise { - const cmd = new SlackCLIProcess(`collaborators add ${args.collaboratorEmail}`, args); + const cmd = new SlackCLIProcess(['collaborators', 'add', args.collaboratorEmail], args); const proc = await cmd.execAsync({ cwd: args.appPath, }); @@ -23,7 +23,7 @@ export const add = async function collaboratorsAdd(args: ProjectCommandArguments * @returns command output */ export const list = async function collaboratorsList(args: ProjectCommandArguments): Promise { - const cmd = new SlackCLIProcess('collaborators list', args); + const cmd = new SlackCLIProcess(['collaborators', 'list'], args); const proc = await cmd.execAsync({ cwd: args.appPath, }); @@ -38,7 +38,7 @@ export const list = async function collaboratorsList(args: ProjectCommandArgumen export const remove = async function collaboratorsRemove( args: ProjectCommandArguments & CollaboratorEmail, ): Promise { - const cmd = new SlackCLIProcess(`collaborators remove ${args.collaboratorEmail}`, args); + const cmd = new SlackCLIProcess(['collaborators', 'remove', args.collaboratorEmail], args); const proc = await cmd.execAsync({ cwd: args.appPath, }); diff --git a/packages/cli-test/src/cli/commands/create.spec.ts b/packages/cli-test/src/cli/commands/create.spec.ts index 239b0937a..977ab7921 100644 --- a/packages/cli-test/src/cli/commands/create.spec.ts +++ b/packages/cli-test/src/cli/commands/create.spec.ts @@ -25,18 +25,30 @@ describe('create', () => { describe('method', () => { it('should invoke `create `', async () => { await create({ appPath: 'myApp' }); - sandbox.assert.calledWith(spawnSpy, sinon.match('create myApp')); + sandbox.assert.calledWith(spawnSpy, sinon.match.string, sinon.match.array.contains(['create', 'myApp'])); }); it('should invoke `create --template` if template specified', async () => { await create({ appPath: 'myApp', template: 'slack-samples/deno-hello-world' }); - sandbox.assert.calledWith(spawnSpy, sinon.match('create myApp')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--template slack-samples/deno-hello-world')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains(['create', 'myApp', '--template', 'slack-samples/deno-hello-world']), + ); }); it('should invoke `create --template --branch` if both template and branch specified', async () => { await create({ appPath: 'myApp', template: 'slack-samples/deno-hello-world', branch: 'feat-functions' }); - sandbox.assert.calledWith(spawnSpy, sinon.match('create myApp')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--template slack-samples/deno-hello-world')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--branch feat-functions')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains([ + 'create', + 'myApp', + '--template', + 'slack-samples/deno-hello-world', + '--branch', + 'feat-functions', + ]), + ); }); }); }); diff --git a/packages/cli-test/src/cli/commands/create.ts b/packages/cli-test/src/cli/commands/create.ts index a99d10152..d3195edcf 100644 --- a/packages/cli-test/src/cli/commands/create.ts +++ b/packages/cli-test/src/cli/commands/create.ts @@ -20,7 +20,7 @@ export const create = async function create( cmdOpts['--branch'] = args.branch; } } - const cmd = new SlackCLIProcess(`create ${args.appPath}`, args, cmdOpts); + const cmd = new SlackCLIProcess(['create', args.appPath], args, cmdOpts); const proc = await cmd.execAsync(); return proc.output; }; diff --git a/packages/cli-test/src/cli/commands/datastore.spec.ts b/packages/cli-test/src/cli/commands/datastore.spec.ts index 4694a1ea2..adb7d90f9 100644 --- a/packages/cli-test/src/cli/commands/datastore.spec.ts +++ b/packages/cli-test/src/cli/commands/datastore.spec.ts @@ -25,35 +25,37 @@ describe('datastore commands', () => { describe('get method', () => { it('should invoke `datastore get `', async () => { await datastore.datastoreGet({ appPath: '/some/path', datastoreName: 'datastore', primaryKeyValue: '1' }); - sandbox.assert.calledWith(spawnSpy, sinon.match('datastore get')); + sandbox.assert.calledWith(spawnSpy, sinon.match.string, sinon.match.array.contains(['datastore', 'get'])); }); }); describe('delete method', () => { it('should invoke `datastore delete `', async () => { await datastore.datastoreDelete({ appPath: '/some/path', datastoreName: 'datastore', primaryKeyValue: '1' }); - sandbox.assert.calledWith(spawnSpy, sinon.match('datastore delete')); + sandbox.assert.calledWith(spawnSpy, sinon.match.string, sinon.match.array.contains(['datastore', 'delete'])); }); }); describe('put method', () => { it('should invoke `datastore put [item details]`', async () => { const itemObj = { - id: "1", - content: "text" + id: '1', + content: 'text', }; await datastore.datastorePut({ appPath: '/some/path', datastoreName: 'datastore', putItem: itemObj }); - sandbox.assert.calledWith( - spawnSpy, - sinon.match('datastore put'), - ); + sandbox.assert.calledWith(spawnSpy, sinon.match.string, sinon.match.array.contains(['datastore', 'put'])); }); }); describe('query method', () => { it('should invoke `datastore query [expression]`', async () => { const expressObj = { - id: "1", + id: '1', }; - await datastore.datastoreQuery({ appPath: '/some/path', datastoreName: 'datastore', queryExpression: 'id = :id', queryExpressionValues: expressObj}); - sandbox.assert.calledWith(spawnSpy, sinon.match('datastore query')); + await datastore.datastoreQuery({ + appPath: '/some/path', + datastoreName: 'datastore', + queryExpression: 'id = :id', + queryExpressionValues: expressObj, + }); + sandbox.assert.calledWith(spawnSpy, sinon.match.string, sinon.match.array.contains(['datastore', 'query'])); }); }); }); diff --git a/packages/cli-test/src/cli/commands/datastore.ts b/packages/cli-test/src/cli/commands/datastore.ts index 9c8716fe1..e2def3bdd 100644 --- a/packages/cli-test/src/cli/commands/datastore.ts +++ b/packages/cli-test/src/cli/commands/datastore.ts @@ -14,6 +14,13 @@ export interface DatastoreCommandArguments { queryExpressionValues: object; } +/** + * Used to escape double quotes in JSON strings; this is needed when JSON is passed as a command line argument, which for the datastore commands, it is. + */ +function escapeJSON(obj: Record): string { + return `"${JSON.stringify(obj).replace(/"/g, '\\"')}"`; +} + /** * `slack datastore get` * @returns command output @@ -23,10 +30,9 @@ export const datastoreGet = async function datastoreGet( ): Promise { const getQueryObj = { datastore: args.datastoreName, - id: args.primaryKeyValue + id: args.primaryKeyValue, }; - const getQuery = JSON.stringify(getQueryObj); - const cmd = new SlackCLIProcess(`datastore get '${getQuery}'`, args); + const cmd = new SlackCLIProcess(['datastore', 'get', escapeJSON(getQueryObj)], args); const proc = await cmd.execAsync({ cwd: args.appPath, }); @@ -42,10 +48,9 @@ export const datastoreDelete = async function datastoreDelete( ): Promise { const deleteQueryObj = { datastore: args.datastoreName, - id: args.primaryKeyValue + id: args.primaryKeyValue, }; - const deleteQuery = JSON.stringify(deleteQueryObj); - const cmd = new SlackCLIProcess(`datastore delete '${deleteQuery}'`, args); + const cmd = new SlackCLIProcess(['datastore', 'delete', escapeJSON(deleteQueryObj)], args); const proc = await cmd.execAsync({ cwd: args.appPath, }); @@ -61,13 +66,9 @@ export const datastorePut = async function datastorePut( ): Promise { const putQueryObj = { datastore: args.datastoreName, - item: args.putItem + item: args.putItem, }; - const putQuery = JSON.stringify(putQueryObj); - const cmd = new SlackCLIProcess( - `datastore put '${putQuery}'`, - args, - ); + const cmd = new SlackCLIProcess(['datastore', 'put', escapeJSON(putQueryObj)], args); const proc = await cmd.execAsync({ cwd: args.appPath, }); @@ -79,18 +80,15 @@ export const datastorePut = async function datastorePut( * @returns command output */ export const datastoreQuery = async function datastoreQuery( - args: ProjectCommandArguments & Pick, + args: ProjectCommandArguments & + Pick, ): Promise { const queryObj = { datastore: args.datastoreName, expression: args.queryExpression, - expression_values: args.queryExpressionValues + expression_values: args.queryExpressionValues, }; - const query = JSON.stringify(queryObj); - const cmd = new SlackCLIProcess( - `datastore query '${query}'`, - args, - ); + const cmd = new SlackCLIProcess(['datastore', 'query', escapeJSON(queryObj)], args); const proc = await cmd.execAsync({ cwd: args.appPath, }); diff --git a/packages/cli-test/src/cli/commands/env.spec.ts b/packages/cli-test/src/cli/commands/env.spec.ts index ec55e85e0..ef223f7f4 100644 --- a/packages/cli-test/src/cli/commands/env.spec.ts +++ b/packages/cli-test/src/cli/commands/env.spec.ts @@ -25,19 +25,23 @@ describe('env commands', () => { describe('add method', () => { it('should invoke `env add `', async () => { await env.add({ appPath: '/some/path', secretKey: 'key', secretValue: 'value' }); - sandbox.assert.calledWith(spawnSpy, sinon.match('env add key value')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains(['env', 'add', 'key', 'value']), + ); }); }); describe('list method', () => { it('should invoke `env list`', async () => { await env.list({ appPath: '/some/path' }); - sandbox.assert.calledWith(spawnSpy, sinon.match('env list')); + sandbox.assert.calledWith(spawnSpy, sinon.match.string, sinon.match.array.contains(['env', 'list'])); }); }); describe('remove method', () => { it('should invoke `env remove `', async () => { await env.remove({ appPath: '/some/path', secretKey: 'key' }); - sandbox.assert.calledWith(spawnSpy, sinon.match('env remove key')); + sandbox.assert.calledWith(spawnSpy, sinon.match.string, sinon.match.array.contains(['env', 'remove', 'key'])); }); }); }); diff --git a/packages/cli-test/src/cli/commands/env.ts b/packages/cli-test/src/cli/commands/env.ts index 912f91138..c9894a08c 100644 --- a/packages/cli-test/src/cli/commands/env.ts +++ b/packages/cli-test/src/cli/commands/env.ts @@ -13,7 +13,7 @@ export interface EnvCommandArguments { * @returns command output */ export const add = async function envAdd(args: ProjectCommandArguments & EnvCommandArguments): Promise { - const cmd = new SlackCLIProcess(`env add ${args.secretKey} ${args.secretValue}`, args); + const cmd = new SlackCLIProcess(['env', 'add', args.secretKey, args.secretValue], args); const proc = await cmd.execAsync({ cwd: args.appPath, }); @@ -25,7 +25,7 @@ export const add = async function envAdd(args: ProjectCommandArguments & EnvComm * @returns command output */ export const list = async function envList(args: ProjectCommandArguments): Promise { - const cmd = new SlackCLIProcess('env list', args); + const cmd = new SlackCLIProcess(['env', 'list'], args); const proc = await cmd.execAsync({ cwd: args.appPath, }); @@ -39,7 +39,7 @@ export const list = async function envList(args: ProjectCommandArguments): Promi export const remove = async function envRemove( args: ProjectCommandArguments & Pick, ): Promise { - const cmd = new SlackCLIProcess(`env remove ${args.secretKey}`, args); + const cmd = new SlackCLIProcess(['env', 'remove', args.secretKey], args); const proc = await cmd.execAsync({ cwd: args.appPath, }); diff --git a/packages/cli-test/src/cli/commands/external-auth.spec.ts b/packages/cli-test/src/cli/commands/external-auth.spec.ts index b0a635bb6..28897fa8b 100644 --- a/packages/cli-test/src/cli/commands/external-auth.spec.ts +++ b/packages/cli-test/src/cli/commands/external-auth.spec.ts @@ -25,42 +25,79 @@ describe('external-auth commands', () => { describe('add method', () => { it('should invoke `external-auth add --provider`', async () => { await extAuth.add({ appPath: '/some/path', provider: 'bigcorp' }); - sandbox.assert.calledWith(spawnSpy, sinon.match('external-auth add --provider bigcorp')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains(['external-auth', 'add', '--provider', 'bigcorp']), + ); }); }); describe('addSecret method', () => { it('should invoke `external-auth add-secret --provider --secret`', async () => { await extAuth.addSecret({ appPath: '/some/path', provider: 'bigcorp', secret: 'shh' }); - sandbox.assert.calledWith(spawnSpy, sinon.match('external-auth add-secret')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--provider bigcorp')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--secret shh')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains(['external-auth', 'add-secret', '--provider', 'bigcorp', '--secret', 'shh']), + ); }); }); describe('remove method', () => { it('should invoke `external-auth remove --provider`', async () => { await extAuth.remove({ appPath: '/some/path', provider: 'bigcorp' }); - sandbox.assert.calledWith(spawnSpy, sinon.match('external-auth remove --provider bigcorp')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains(['external-auth', 'remove', '--provider', 'bigcorp']), + ); }); it('should invoke `external-auth remove --provider --all` if `all: true` specified', async () => { await extAuth.remove({ appPath: '/some/path', provider: 'bigcorp', all: true }); - sandbox.assert.calledWith(spawnSpy, sinon.match('external-auth remove --provider bigcorp')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--all')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains(['external-auth', 'remove', '--provider', 'bigcorp', '--all']), + ); }); }); describe('select-auth method', () => { it('should invoke `external-auth select-auth --provider`', async () => { await extAuth.selectAuth({ appPath: '/some/path', provider: 'bigcorp' }); - sandbox.assert.calledWith(spawnSpy, sinon.match('external-auth select-auth --provider bigcorp')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains(['external-auth', 'select-auth', '--provider', 'bigcorp']), + ); }); it('should invoke `external-auth select-auth --provider --external-account` if `externalAccount` specified', async () => { await extAuth.selectAuth({ appPath: '/some/path', provider: 'bigcorp', externalAccount: 'me@me.com' }); - sandbox.assert.calledWith(spawnSpy, sinon.match('external-auth select-auth --provider bigcorp')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--external-account me@me.com')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains([ + 'external-auth', + 'select-auth', + '--provider', + 'bigcorp', + '--external-account', + 'me@me.com', + ]), + ); }); it('should invoke `external-auth select-auth --provider --workflow` if `workflow` specified', async () => { await extAuth.selectAuth({ appPath: '/some/path', provider: 'bigcorp', workflow: '#/workflow/1234' }); - sandbox.assert.calledWith(spawnSpy, sinon.match('external-auth select-auth --provider bigcorp')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--workflow #/workflow/1234')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains([ + 'external-auth', + 'select-auth', + '--provider', + 'bigcorp', + '--workflow', + '#/workflow/1234', + ]), + ); }); }); }); diff --git a/packages/cli-test/src/cli/commands/external-auth.ts b/packages/cli-test/src/cli/commands/external-auth.ts index 23cf7e604..41a36f922 100644 --- a/packages/cli-test/src/cli/commands/external-auth.ts +++ b/packages/cli-test/src/cli/commands/external-auth.ts @@ -20,7 +20,7 @@ export interface ExternalAuthCommandArguments { export const add = async function externalAuthAdd( args: ProjectCommandArguments & Pick, ): Promise { - const cmd = new SlackCLIProcess('external-auth add', args, { + const cmd = new SlackCLIProcess(['external-auth', 'add'], args, { '--provider': args.provider, }); const proc = await cmd.execAsync({ @@ -36,7 +36,7 @@ export const add = async function externalAuthAdd( export const addSecret = async function extAuthAddSecret( args: ProjectCommandArguments & Omit, ): Promise { - const cmd = new SlackCLIProcess('external-auth add-secret', args, { + const cmd = new SlackCLIProcess(['external-auth', 'add-secret'], args, { '--provider': args.provider, '--secret': args.secret, }); @@ -59,7 +59,7 @@ export const remove = async function extAuthRemove( if (args.all) { cmdOpts['--all'] = true; } - const cmd = new SlackCLIProcess('external-auth remove', args, cmdOpts); + const cmd = new SlackCLIProcess(['external-auth', 'remove'], args, cmdOpts); const proc = await cmd.execAsync({ cwd: args.appPath, }); @@ -88,7 +88,7 @@ export const selectAuth = async function extAuthSelectAuth( if (args.workflow) { cmdOpts['--workflow'] = args.workflow; } - const cmd = new SlackCLIProcess('external-auth select-auth', args, cmdOpts); + const cmd = new SlackCLIProcess(['external-auth', 'select-auth'], args, cmdOpts); const proc = await cmd.execAsync({ cwd: args.appPath, }); diff --git a/packages/cli-test/src/cli/commands/function.spec.ts b/packages/cli-test/src/cli/commands/function.spec.ts index 2d19da30f..a815572d7 100644 --- a/packages/cli-test/src/cli/commands/function.spec.ts +++ b/packages/cli-test/src/cli/commands/function.spec.ts @@ -25,34 +25,43 @@ describe('function commands', () => { describe('access method', () => { it('should invoke `function access --info` if info=true', async () => { await func.access({ appPath: '/some/path', info: true }); - sandbox.assert.calledWith(spawnSpy, sinon.match('function access')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--info')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains(['function', 'access', '--info']), + ); }); it('should invoke `function access --name --app-collaborators` if `name` and `appCollaborators` specified', async () => { await func.access({ appPath: '/some/path', name: 'best', appCollaborators: true }); - sandbox.assert.calledWith(spawnSpy, sinon.match('function access')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--name best')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--app-collaborators')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains(['function', 'access', '--name', 'best', '--app-collaborators']), + ); }); it('should invoke `function access --name --everyone` if `name` and `everyone` specified', async () => { await func.access({ appPath: '/some/path', name: 'best', everyone: true }); - sandbox.assert.calledWith(spawnSpy, sinon.match('function access')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--name best')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--everyone')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains(['function', 'access', '--name', 'best', '--everyone']), + ); }); it('should invoke `function access --name --grant --users` if `name`, `grant` and `users` specified', async () => { await func.access({ appPath: '/some/path', name: 'best', grant: true, users: ['U1234'] }); - sandbox.assert.calledWith(spawnSpy, sinon.match('function access')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--name best')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--grant')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--users U1234')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains(['function', 'access', '--name', 'best', '--grant', '--users', 'U1234']), + ); }); it('should invoke `function access --name --revoke --users` if `name`, `revoke` and `users` specified', async () => { await func.access({ appPath: '/some/path', name: 'best', revoke: true, users: ['U1234'] }); - sandbox.assert.calledWith(spawnSpy, sinon.match('function access')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--name best')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--revoke')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--users U1234')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains(['function', 'access', '--name', 'best', '--revoke', '--users', 'U1234']), + ); }); }); }); diff --git a/packages/cli-test/src/cli/commands/function.ts b/packages/cli-test/src/cli/commands/function.ts index 8c5c05cc7..c97401045 100644 --- a/packages/cli-test/src/cli/commands/function.ts +++ b/packages/cli-test/src/cli/commands/function.ts @@ -45,7 +45,7 @@ export const access = async function functionAccess( throw new Error('When setting function access, you must specify a target for whom to give access to.'); } } - const cmd = new SlackCLIProcess('function access', args, cmdOpts); + const cmd = new SlackCLIProcess(['function', 'access'], args, cmdOpts); const proc = await cmd.execAsync({ cwd: args.appPath, }); diff --git a/packages/cli-test/src/cli/commands/manifest.spec.ts b/packages/cli-test/src/cli/commands/manifest.spec.ts index 68a77c3c8..2fda96373 100644 --- a/packages/cli-test/src/cli/commands/manifest.spec.ts +++ b/packages/cli-test/src/cli/commands/manifest.spec.ts @@ -25,19 +25,25 @@ describe('manifest commands', () => { describe('info method', () => { it('should invoke `manifest info` and default `--source project`', async () => { await manifest.info({ appPath: '/some/path' }); - sandbox.assert.calledWith(spawnSpy, sinon.match('manifest info')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--source project')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains(['manifest', 'info', '--source', 'project']), + ); }); it('should invoke `manifest info --source remote` source=remote specified', async () => { await manifest.info({ appPath: '/some/path', source: 'remote' }); - sandbox.assert.calledWith(spawnSpy, sinon.match('manifest info')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--source remote')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains(['manifest', 'info', '--source', 'remote']), + ); }); }); describe('validate method', () => { it('should invoke `manifest validate`', async () => { await manifest.validate({ appPath: '/some/path' }); - sandbox.assert.calledWith(spawnSpy, sinon.match('manifest validate')); + sandbox.assert.calledWith(spawnSpy, sinon.match.string, sinon.match.array.contains(['manifest', 'validate'])); }); }); }); diff --git a/packages/cli-test/src/cli/commands/manifest.ts b/packages/cli-test/src/cli/commands/manifest.ts index 43172b232..bb8f216b0 100644 --- a/packages/cli-test/src/cli/commands/manifest.ts +++ b/packages/cli-test/src/cli/commands/manifest.ts @@ -16,7 +16,7 @@ export const info = async function manifestInfo( const cmdOpts: SlackCLICommandOptions = { '--source': args.source || 'project', }; - const cmd = new SlackCLIProcess('manifest info', args, cmdOpts); + const cmd = new SlackCLIProcess(['manifest', 'info'], args, cmdOpts); const proc = await cmd.execAsync({ cwd: args.appPath, }); @@ -28,7 +28,7 @@ export const info = async function manifestInfo( * @returns command output */ export const validate = async function manifestValidate(args: ProjectCommandArguments): Promise { - const cmd = new SlackCLIProcess('manifest validate', args); + const cmd = new SlackCLIProcess(['manifest', 'validate'], args); const proc = await cmd.execAsync({ cwd: args.appPath, }); diff --git a/packages/cli-test/src/cli/commands/platform.spec.ts b/packages/cli-test/src/cli/commands/platform.spec.ts index d027b3a80..74bf29d72 100644 --- a/packages/cli-test/src/cli/commands/platform.spec.ts +++ b/packages/cli-test/src/cli/commands/platform.spec.ts @@ -31,19 +31,21 @@ describe('platform commands', () => { describe('activity method', () => { it('should invoke `activity`', async () => { await platform.activity({ appPath: '/some/path' }); - sandbox.assert.calledWith(spawnSpy, sinon.match('activity')); + sandbox.assert.calledWith(spawnSpy, sinon.match.string, sinon.match.array.contains(['activity'])); }); it('should invoke `activity` with specified `source`', async () => { await platform.activity({ appPath: '/some/path', source: 'slack' }); - sandbox.assert.calledWith(spawnSpy, sinon.match('activity')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--source slack')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains(['activity', '--source', 'slack']), + ); }); }); describe('activityTailStart method', () => { it('should invoke `activity --tail`', async () => { await platform.activityTailStart({ appPath: '/some/path', stringToWaitFor: 'poop' }); - sandbox.assert.calledWith(spawnSpy, sinon.match('activity')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--tail')); + sandbox.assert.calledWith(spawnSpy, sinon.match.string, sinon.match.array.contains(['activity', '--tail'])); }); }); describe('activityTailStop method', () => { @@ -64,31 +66,36 @@ describe('platform commands', () => { describe('deploy method', () => { it('should invoke `deploy` with --hide-triggers by default', async () => { await platform.deploy({ appPath: '/some/path' }); - sandbox.assert.calledWith(spawnSpy, sinon.match('deploy')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--hide-triggers')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains(['deploy', '--hide-triggers']), + ); }); it('should invoke `deploy` without --hide-triggers if hideTriggers=false', async () => { await platform.deploy({ appPath: '/some/path', hideTriggers: false }); - sandbox.assert.calledWith(spawnSpy, sinon.match('deploy')); - sandbox.assert.neverCalledWith(spawnSpy, sinon.match('--hide-triggers')); + sandbox.assert.calledWith(spawnSpy, sinon.match.string, sinon.match.array.contains(['deploy'])); + sandbox.assert.neverCalledWith(spawnSpy, sinon.match.string, sinon.match.array.contains(['--hide-triggers'])); }); }); describe('runStart method', () => { it('should invoke `run` with --cleanup and --hide-triggers by default', async () => { await platform.runStart({ appPath: '/some/path' }); - sandbox.assert.calledWith(spawnSpy, sinon.match('run')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--cleanup')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--hide-triggers')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains(['run', '--cleanup', '--hide-triggers']), + ); }); it('should invoke `run` without --hide-triggers if hideTriggers=false', async () => { await platform.runStart({ appPath: '/some/path', hideTriggers: false }); - sandbox.assert.calledWith(spawnSpy, sinon.match('run')); - sandbox.assert.neverCalledWith(spawnSpy, sinon.match('--hide-triggers')); + sandbox.assert.calledWith(spawnSpy, sinon.match.string, sinon.match.array.contains(['run'])); + sandbox.assert.neverCalledWith(spawnSpy, sinon.match.string, sinon.match.array.contains(['--hide-triggers'])); }); it('should invoke `run` without --cleanup if cleanup=false', async () => { await platform.runStart({ appPath: '/some/path', cleanup: false }); - sandbox.assert.calledWith(spawnSpy, sinon.match('run')); - sandbox.assert.neverCalledWith(spawnSpy, sinon.match('--cleanup')); + sandbox.assert.calledWith(spawnSpy, sinon.match.string, sinon.match.array.contains(['run'])); + sandbox.assert.neverCalledWith(spawnSpy, sinon.match.string, sinon.match.array.contains(['--cleanup'])); }); }); describe('runStop method', () => { diff --git a/packages/cli-test/src/cli/commands/platform.ts b/packages/cli-test/src/cli/commands/platform.ts index 61da631fd..1afd83746 100644 --- a/packages/cli-test/src/cli/commands/platform.ts +++ b/packages/cli-test/src/cli/commands/platform.ts @@ -42,7 +42,7 @@ export const activity = async function activity( if ('source' in args) { cmdOpts['--source'] = args.source; } - const cmd = new SlackCLIProcess('activity', args, cmdOpts); + const cmd = new SlackCLIProcess(['activity'], args, cmdOpts); const proc = await cmd.execAsync({ cwd: args.appPath, }); @@ -57,7 +57,7 @@ export const activity = async function activity( export const activityTailStart = async function activityTailStart( args: ProjectCommandArguments & StringWaitArgument & TimeoutArgument, ): Promise { - const cmd = new SlackCLIProcess('activity', args, { '--tail': true }); + const cmd = new SlackCLIProcess(['activity'], args, { '--tail': true }); const proc = await cmd.execAsyncUntilOutputPresent(args.stringToWaitFor, { cwd: args.appPath, timeout: args.timeout, @@ -98,7 +98,7 @@ export const activityTailStop = async function activityTailStop( export const deploy = async function deploy( args: ProjectCommandArguments & Omit, ): Promise { - const cmd = new SlackCLIProcess('deploy', args, { + const cmd = new SlackCLIProcess(['deploy'], args, { '--hide-triggers': typeof args.hideTriggers !== 'undefined' ? args.hideTriggers : true, '--org-workspace-grant': args.orgWorkspaceGrantFlag, }); @@ -115,7 +115,7 @@ export const deploy = async function deploy( export const runStart = async function runStart( args: ProjectCommandArguments & RunDeployArguments & TimeoutArgument, ): Promise { - const cmd = new SlackCLIProcess('run', args, { + const cmd = new SlackCLIProcess(['run'], args, { '--app': 'local', '--cleanup': typeof args.cleanup !== 'undefined' ? args.cleanup : true, '--hide-triggers': typeof args.hideTriggers !== 'undefined' ? args.hideTriggers : true, diff --git a/packages/cli-test/src/cli/commands/trigger.spec.ts b/packages/cli-test/src/cli/commands/trigger.spec.ts index 7c4a066d1..69970b6dc 100644 --- a/packages/cli-test/src/cli/commands/trigger.spec.ts +++ b/packages/cli-test/src/cli/commands/trigger.spec.ts @@ -25,68 +25,93 @@ describe('trigger commands', () => { describe('access method', () => { it('should invoke `trigger access --info` if info=true', async () => { await trigger.access({ appPath: '/some/path', info: true, triggerId: 'T1234' }); - sandbox.assert.calledWith(spawnSpy, sinon.match('trigger access')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--info')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--trigger-id T1234')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains(['trigger', 'access', '--info', '--trigger-id', 'T1234']), + ); }); it('should invoke `trigger access --app-collaborators` if `appCollaborators` specified', async () => { await trigger.access({ appPath: '/some/path', triggerId: 'T1234', appCollaborators: true }); - sandbox.assert.calledWith(spawnSpy, sinon.match('trigger access')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--app-collaborators')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains(['trigger', 'access', '--app-collaborators']), + ); }); it('should invoke `trigger access --everyone` if `everyone` specified', async () => { await trigger.access({ appPath: '/some/path', triggerId: 'T1234', everyone: true }); - sandbox.assert.calledWith(spawnSpy, sinon.match('trigger access')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--everyone')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains(['trigger', 'access', '--everyone']), + ); }); it('should invoke `trigger access --grant --users` if `grant` and `users` specified', async () => { await trigger.access({ appPath: '/some/path', triggerId: 'T1234', grant: true, users: ['U1234'] }); - sandbox.assert.calledWith(spawnSpy, sinon.match('trigger access')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--grant')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--users U1234')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains(['trigger', 'access', '--grant', '--users', 'U1234']), + ); }); it('should invoke `trigger access --revoke --users` if `revoke` and `users` specified', async () => { await trigger.access({ appPath: '/some/path', triggerId: 'T1234', revoke: true, users: ['U1234'] }); - sandbox.assert.calledWith(spawnSpy, sinon.match('trigger access')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--revoke')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--users U1234')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains(['trigger', 'access', '--revoke', '--users', 'U1234']), + ); }); it('should invoke `trigger access --grant --channels` if `grant` and `channels` specified', async () => { await trigger.access({ appPath: '/some/path', triggerId: 'T1234', grant: true, channels: ['C1234'] }); - sandbox.assert.calledWith(spawnSpy, sinon.match('trigger access')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--grant')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--channels C1234')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains(['trigger', 'access', '--grant', '--channels', 'C1234']), + ); }); it('should invoke `trigger access --revoke --channels` if `revoke` and `channels` specified', async () => { await trigger.access({ appPath: '/some/path', triggerId: 'T1234', revoke: true, channels: ['C1234'] }); - sandbox.assert.calledWith(spawnSpy, sinon.match('trigger access')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--revoke')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--channels C1234')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains(['trigger', 'access', '--revoke', '--channels', 'C1234']), + ); }); it('should invoke `trigger access --grant --organizations` if `grant` and `organizations` specified', async () => { await trigger.access({ appPath: '/some/path', triggerId: 'T1234', grant: true, organizations: ['E1234'] }); - sandbox.assert.calledWith(spawnSpy, sinon.match('trigger access')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--grant')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--organizations E1234')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains(['trigger', 'access', '--grant', '--organizations', 'E1234']), + ); }); it('should invoke `trigger access --revoke --organizations` if `revoke` and `organizations` specified', async () => { await trigger.access({ appPath: '/some/path', triggerId: 'T1234', revoke: true, organizations: ['E1234'] }); - sandbox.assert.calledWith(spawnSpy, sinon.match('trigger access')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--revoke')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--organizations E1234')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains(['trigger', 'access', '--revoke', '--organizations', 'E1234']), + ); }); }); describe('create method', () => { it('should invoke `trigger create --trigger-def` if triggerDef specified', async () => { await trigger.create({ appPath: '/some/path', triggerDef: 'some/file.json' }); - sandbox.assert.calledWith(spawnSpy, sinon.match('trigger create')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--trigger-def some/file.json')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains(['trigger', 'create', '--trigger-def', 'some/file.json']), + ); }); it('should invoke `trigger create --workflow --title` if workflow specified', async () => { await trigger.create({ appPath: '/some/path', workflow: 'some#/callback_id', title: 'Title' }); - sandbox.assert.calledWith(spawnSpy, sinon.match('trigger create')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--title Title')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--workflow some#/callback_id')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains(['trigger', 'create', '--title', 'Title', '--workflow', 'some#/callback_id']), + ); }); it('should invoke `trigger create --description` if description specified', async () => { await trigger.create({ @@ -95,8 +120,11 @@ describe('trigger commands', () => { title: 'Title', description: 'test', }); - sandbox.assert.calledWith(spawnSpy, sinon.match('trigger create')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--description test')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains(['trigger', 'create', '--description', 'test']), + ); }); it('should invoke `trigger create --interactivity` if interactivity specified', async () => { await trigger.create({ @@ -106,77 +134,103 @@ describe('trigger commands', () => { interactivity: true, interactivityName: 'test', }); - sandbox.assert.calledWith(spawnSpy, sinon.match('trigger create')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--interactivity')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--interactivity-name test')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains(['trigger', 'create', '--interactivity', '--interactivity-name', 'test']), + ); }); }); describe('delete method', () => { it('should invoke `trigger delete --trigger-id`', async () => { await trigger.delete({ appPath: '/some/path', triggerId: 'T1234' }); - sandbox.assert.calledWith(spawnSpy, sinon.match('trigger delete')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--trigger-id T1234')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains(['trigger', 'delete', '--trigger-id', 'T1234']), + ); }); }); describe('info method', () => { it('should invoke `trigger info --trigger-id`', async () => { await trigger.info({ appPath: '/some/path', triggerId: 'T1234' }); - sandbox.assert.calledWith(spawnSpy, sinon.match('trigger info')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--trigger-id T1234')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains(['trigger', 'info', '--trigger-id', 'T1234']), + ); }); }); describe('list method', () => { it('should invoke `trigger list`', async () => { await trigger.list({ appPath: '/some/path' }); - sandbox.assert.calledWith(spawnSpy, sinon.match('trigger list')); + sandbox.assert.calledWith(spawnSpy, sinon.match.string, sinon.match.array.contains(['trigger', 'list'])); }); it('should invoke `trigger list --limit` if limit specified', async () => { await trigger.list({ appPath: '/some/path', limit: 10 }); - sandbox.assert.calledWith(spawnSpy, sinon.match('trigger list')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--limit 10')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains(['trigger', 'list', '--limit', '10']), + ); }); it('should invoke `trigger list --type` if type specified', async () => { await trigger.list({ appPath: '/some/path', type: 'event' }); - sandbox.assert.calledWith(spawnSpy, sinon.match('trigger list')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--type event')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains(['trigger', 'list', '--type', 'event']), + ); }); }); describe('update method', () => { it('should invoke `trigger update --trigger-def` if triggerDef specified', async () => { await trigger.update({ appPath: '/some/path', triggerDef: 'some/file.json', triggerId: 'T1234' }); - sandbox.assert.calledWith(spawnSpy, sinon.match('trigger update')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--trigger-def some/file.json')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--trigger-id T1234')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains(['trigger', 'update', '--trigger-def', 'some/file.json', '--trigger-id', 'T1234']), + ); }); it('should invoke `trigger update --workflow` if workflow specified', async () => { await trigger.update({ appPath: '/some/path', workflow: 'some#/callback_id', triggerId: 'T1234' }); - sandbox.assert.calledWith(spawnSpy, sinon.match('trigger update')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--workflow some#/callback_id')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--trigger-id T1234')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains(['trigger', 'update', '--workflow', 'some#/callback_id', '--trigger-id', 'T1234']), + ); }); it('should invoke `trigger update --title` if title specified', async () => { await trigger.update({ appPath: '/some/path', title: 'something', triggerId: 'T1234' }); - sandbox.assert.calledWith(spawnSpy, sinon.match('trigger update')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--title something')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--trigger-id T1234')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains(['trigger', 'update', '--title', 'something', '--trigger-id', 'T1234']), + ); }); it('should invoke `trigger update --description` if description specified', async () => { await trigger.update({ appPath: '/some/path', description: 'test', triggerId: 'T1234' }); - sandbox.assert.calledWith(spawnSpy, sinon.match('trigger update')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--description test')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--trigger-id T1234')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains(['trigger', 'update', '--description', 'test', '--trigger-id', 'T1234']), + ); }); it('should invoke `trigger update --interactivity` if interactivity specified', async () => { await trigger.update({ appPath: '/some/path', triggerId: 'T1234', interactivity: true }); - sandbox.assert.calledWith(spawnSpy, sinon.match('trigger update')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--interactivity')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--trigger-id T1234')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains(['trigger', 'update', '--interactivity', '--trigger-id', 'T1234']), + ); }); it('should invoke `trigger update --interactivity-name` if interactivityName specified', async () => { await trigger.update({ appPath: '/some/path', triggerId: 'T1234', interactivityName: 'poop' }); - sandbox.assert.calledWith(spawnSpy, sinon.match('trigger update')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--interactivity-name poop')); - sandbox.assert.calledWith(spawnSpy, sinon.match('--trigger-id T1234')); + sandbox.assert.calledWith( + spawnSpy, + sinon.match.string, + sinon.match.array.contains(['trigger', 'update', '--interactivity-name', 'poop', '--trigger-id', 'T1234']), + ); }); }); }); diff --git a/packages/cli-test/src/cli/commands/trigger.ts b/packages/cli-test/src/cli/commands/trigger.ts index 17909f251..6f3f6e886 100644 --- a/packages/cli-test/src/cli/commands/trigger.ts +++ b/packages/cli-test/src/cli/commands/trigger.ts @@ -12,11 +12,11 @@ import { type SlackCLICommandOptions, SlackCLIProcess } from '../cli-process'; type AccessChangeArguments = { info?: boolean; } & ( - | GroupAccessChangeArguments - | UserAccessChangeArguments - | ChannelAccessChangeArguments - | OrganizationAccessChangeArguments - ); + | GroupAccessChangeArguments + | UserAccessChangeArguments + | ChannelAccessChangeArguments + | OrganizationAccessChangeArguments +); export interface TriggerIdArgument { /** @description ID of the trigger being targeted. */ @@ -69,7 +69,7 @@ export const access = async function triggerAccess( } else { throw new Error('When setting trigger access, you must specify a target for whom to give access to.'); } - const cmd = new SlackCLIProcess('trigger access', args, cmdOpts); + const cmd = new SlackCLIProcess(['trigger', 'access'], args, cmdOpts); const proc = await cmd.execAsync({ cwd: args.appPath, }); @@ -122,7 +122,7 @@ export const create = async function triggerCreate(args: ProjectCommandArguments } } } - const cmd = new SlackCLIProcess('trigger create', args, cmdOpts); + const cmd = new SlackCLIProcess(['trigger', 'create'], args, cmdOpts); const proc = await cmd.execAsync({ cwd: args.appPath, }); @@ -134,7 +134,7 @@ export const create = async function triggerCreate(args: ProjectCommandArguments * @returns command output */ export const del = async function triggerDelete(args: ProjectCommandArguments & TriggerIdArgument): Promise { - const cmd = new SlackCLIProcess('trigger delete', args, { + const cmd = new SlackCLIProcess(['trigger', 'delete'], args, { '--trigger-id': args.triggerId, }); const proc = await cmd.execAsync({ @@ -148,7 +148,7 @@ export const del = async function triggerDelete(args: ProjectCommandArguments & * @returns command output */ export const info = async function triggerInfo(args: ProjectCommandArguments & TriggerIdArgument): Promise { - const cmd = new SlackCLIProcess('trigger info', args, { + const cmd = new SlackCLIProcess(['trigger', 'info'], args, { '--trigger-id': args.triggerId, }); const proc = await cmd.execAsync({ @@ -179,7 +179,7 @@ export const list = async function triggerList( if (args.type) { cmdOpts['--type'] = args.type; } - const cmd = new SlackCLIProcess('trigger list', args, cmdOpts); + const cmd = new SlackCLIProcess(['trigger', 'list'], args, cmdOpts); const proc = await cmd.execAsync({ cwd: args.appPath, }); @@ -215,7 +215,7 @@ export const update = async function triggerUpdate( cmdOpts['--interactivity-name'] = args.interactivityName; } } - const cmd = new SlackCLIProcess('trigger update', args, cmdOpts); + const cmd = new SlackCLIProcess(['trigger', 'update'], args, cmdOpts); const proc = await cmd.execAsync({ cwd: args.appPath, }); diff --git a/packages/cli-test/src/cli/shell.spec.ts b/packages/cli-test/src/cli/shell.spec.ts index 2fc03843c..69d0f15f0 100644 --- a/packages/cli-test/src/cli/shell.spec.ts +++ b/packages/cli-test/src/cli/shell.spec.ts @@ -61,34 +61,104 @@ describe('shell module', () => { it('should invoke `assembleShellEnv` and pass as child_process.spawnSync `env` parameter', () => { const fakeEnv = { HEY: 'yo' }; const assembleSpy = sandbox.stub(shell, 'assembleShellEnv').returns(fakeEnv); - const fakeCmd = 'echo "hi"'; - shell.runCommandSync(fakeCmd); + const fakeCmd = 'echo'; + const fakeArgs = ['"hi there"']; + shell.runCommandSync(fakeCmd, fakeArgs); sandbox.assert.calledOnce(assembleSpy); - sandbox.assert.calledWithMatch(runSpy, fakeCmd, sinon.match({ shell: true, env: fakeEnv })); + sandbox.assert.calledWithMatch( + runSpy, + sinon.match.string, + sinon.match.array, + sinon.match({ shell: true, env: fakeEnv }), + ); }); it('should raise bubble error details up', () => { runSpy.throws(new Error('this is bat country')); assert.throw(() => { - shell.runCommandSync('about to explode'); + shell.runCommandSync('about to explode', []); }, /this is bat country/); }); + if (process.platform === 'win32') { + it('on Windows, should wrap command to shell out in a `cmd /s /c` wrapper process', () => { + const fakeEnv = { HEY: 'yo' }; + sandbox.stub(shell, 'assembleShellEnv').returns(fakeEnv); + const fakeCmd = 'echo'; + const fakeArgs = ['"hi there"']; + shell.runCommandSync(fakeCmd, fakeArgs); + sandbox.assert.calledWithMatch( + runSpy, + 'cmd', + sinon.match.array.contains(['/s', '/c', fakeCmd, ...fakeArgs]), + sinon.match({ shell: true, env: fakeEnv }), + ); + }); + } else { + it('on non-Windows, should shell out to provided command directly', () => { + const fakeEnv = { HEY: 'yo' }; + sandbox.stub(shell, 'assembleShellEnv').returns(fakeEnv); + const fakeCmd = 'echo'; + const fakeArgs = ['"hi there"']; + shell.runCommandSync(fakeCmd, fakeArgs); + sandbox.assert.calledWithMatch( + runSpy, + fakeCmd, + sinon.match.array.contains(fakeArgs), + sinon.match({ shell: true, env: fakeEnv }), + ); + }); + } }); describe('spawnProcess method', () => { it('should invoke `assembleShellEnv` and pass as child_process.spawn `env` parameter', () => { const fakeEnv = { HEY: 'yo' }; const assembleSpy = sandbox.stub(shell, 'assembleShellEnv').returns(fakeEnv); - const fakeCmd = 'echo "hi"'; - shell.spawnProcess(fakeCmd); + const fakeCmd = 'echo'; + const fakeArgs = ['"hi there"']; + shell.spawnProcess(fakeCmd, fakeArgs); sandbox.assert.calledOnce(assembleSpy); - sandbox.assert.calledWithMatch(spawnSpy, fakeCmd, sinon.match({ shell: true, env: fakeEnv })); + sandbox.assert.calledWithMatch( + spawnSpy, + sinon.match.string, + sinon.match.array, + sinon.match({ shell: true, env: fakeEnv }), + ); }); it('should raise bubble error details up', () => { spawnSpy.throws(new Error('this is bat country')); assert.throw(() => { - shell.spawnProcess('about to explode'); + shell.spawnProcess('about to explode', []); }, /this is bat country/); }); + if (process.platform === 'win32') { + it('on Windows, should wrap command to shell out in a `cmd /s /c` wrapper process', () => { + const fakeEnv = { HEY: 'yo' }; + sandbox.stub(shell, 'assembleShellEnv').returns(fakeEnv); + const fakeCmd = 'echo'; + const fakeArgs = ['"hi there"']; + shell.spawnProcess(fakeCmd, fakeArgs); + sandbox.assert.calledWithMatch( + spawnSpy, + 'cmd', + sinon.match.array.contains(['/s', '/c', fakeCmd, ...fakeArgs]), + sinon.match({ shell: true, env: fakeEnv }), + ); + }); + } else { + it('on non-Windows, should shell out to provided command directly', () => { + const fakeEnv = { HEY: 'yo' }; + sandbox.stub(shell, 'assembleShellEnv').returns(fakeEnv); + const fakeCmd = 'echo'; + const fakeArgs = ['"hi there"']; + shell.spawnProcess(fakeCmd, fakeArgs); + sandbox.assert.calledWithMatch( + spawnSpy, + fakeCmd, + sinon.match.array.contains(fakeArgs), + sinon.match({ shell: true, env: fakeEnv }), + ); + }); + } }); describe('waitForOutput method', () => { diff --git a/packages/cli-test/src/cli/shell.ts b/packages/cli-test/src/cli/shell.ts index 161011644..99354e606 100644 --- a/packages/cli-test/src/cli/shell.ts +++ b/packages/cli-test/src/cli/shell.ts @@ -12,28 +12,26 @@ export const shell = { * Spawns a shell command * - Start child process with the command * - Listen to data output events and collect them - * @param command The command to run, e.g. `echo "hi"` + * @param command The command to run, e.g. echo, cat, slack.exe + * @param args The arguments for the command, e.g. 'hi', '--skip-update' * @param shellOpts Options to customize shell execution * @returns command output */ spawnProcess: function spawnProcess( command: string, + args: string[], shellOpts?: Partial, ): ShellProcess { + const cmdString = `${command} ${args.join(' ')}`; try { - // Start child process - const childProcess = child.spawn(`${command}`, { - shell: true, - env: shell.assembleShellEnv(), - ...shellOpts, - }); + const childProcess = child.spawn(...getSpawnArguments(command, args, shell.assembleShellEnv(), shellOpts)); // Set shell object const sh: ShellProcess = { process: childProcess, output: '', finished: false, - command, + command: cmdString, }; // Log command @@ -63,7 +61,7 @@ export const shell = { return sh; } catch (error) { - throw new Error(`spawnProcess failed!\nCommand: ${command}\nError: ${error}`); + throw new Error(`spawnProcess failed!\nCommand: ${cmdString}\nError: ${error}`); } }, @@ -71,31 +69,30 @@ export const shell = { * Run shell command synchronously * - Execute child process with the command * - Wait for the command to complete and return the standard output - * @param command cli command + * @param command The command to run, e.g. echo, cat, slack.exe + * @param args The arguments for the command, e.g. 'hi', '--skip-update' * @param shellOpts various shell spawning options available to customize * @returns command stdout */ runCommandSync: function runSyncCommand( command: string, + args: string[], shellOpts?: Partial, ): string { + const cmdString = `${command} ${args.join(' ')}`; try { // Log command - logger.info(`CLI Command started: ${command}`); + logger.info(`CLI Command started: ${cmdString}`); // Start child process - const result = child.spawnSync(`${command}`, { - shell: true, - env: shell.assembleShellEnv(), - ...shellOpts, - }); + const result = child.spawnSync(...getSpawnArguments(command, args, shell.assembleShellEnv(), shellOpts)); // Log command - logger.info(`CLI Command finished: ${command}`); + logger.info(`CLI Command finished: ${cmdString}`); return this.removeANSIcolors(result.stdout.toString()); } catch (error) { - throw new Error(`runCommandSync failed!\nCommand: ${command}\nError: ${error}`); + throw new Error(`runCommandSync failed!\nCommand: ${cmdString}\nError: ${error}`); } }, @@ -252,3 +249,44 @@ export const shell = { }); }, }; + +/** + * @description Returns arguments used to pass into child_process.spawn or spawnSync. Handles Windows-specifics hacks. + */ +function getSpawnArguments( + command: string, + args: string[], + env: ReturnType, + shellOpts?: Partial, +): [string, string[], child.SpawnOptionsWithoutStdio] { + if (process.platform === 'win32') { + // In windows, we actually spawn a command prompt and tell it to invoke the CLI command. + // The combination of windows and node's child_process spawning is complicated: on windows, child_process strips quotes from arguments. This makes passing JSON difficult. + // As a workaround, we: + // 1. Wrap the CLI command with a Windows Command Prompt (cmd.exe) process, and + // 2. Execute the command to completion (via the /c option), and + // 3. Leave spaces intact (via the /s option), and + // 4. Feed the arguments as an argument array into `child_process.spawn`. + // End-result is a process that looks like: + // cmd.exe "/s" "/c" "slack" "app" "list" + const windowsArgs = ['/s', '/c'].concat([command]).concat(args); + return [ + 'cmd', + windowsArgs, + { + shell: true, + env, + ...shellOpts, + }, + ]; + } + return [ + command, + args, + { + shell: true, + env, + ...shellOpts, + }, + ]; +} diff --git a/packages/cli-test/tsconfig.json b/packages/cli-test/tsconfig.json index 83670b354..de909a10b 100644 --- a/packages/cli-test/tsconfig.json +++ b/packages/cli-test/tsconfig.json @@ -13,16 +13,12 @@ "sourceMap": true }, "extends": "@tsconfig/recommended/tsconfig.json", - "include": [ - "src/**/*" - ], + "include": ["src/**/*"], "jsdoc": { "out": "support/jsdoc", "access": "public" }, - "exclude": [ - "src/**/*.spec.*" - ], + "exclude": ["src/**/*.spec.*"], "ts-node": { "esm": true }