Skip to content

Commit

Permalink
feat!(cli-test): Use child_process spawn arguments properly, fixi…
Browse files Browse the repository at this point in the history
…ng JSON encoding on the command line on Windows (#2090)

Co-authored-by: Michael Brooks <[email protected]>
  • Loading branch information
filmaj and mwbrooks authored Nov 4, 2024
1 parent 806d2fc commit dcd0183
Show file tree
Hide file tree
Showing 28 changed files with 602 additions and 311 deletions.
10 changes: 2 additions & 8 deletions packages/cli-test/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
138 changes: 93 additions & 45 deletions packages/cli-test/src/cli/cli-process.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
Expand All @@ -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 `--<key> 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']),
);
});
});
});
Expand Down
47 changes: 27 additions & 20 deletions packages/cli-test/src/cli/cli-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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!');
}
Expand All @@ -75,7 +79,8 @@ export class SlackCLIProcess {
*/
public async execAsync(shellOpts?: Partial<SpawnOptionsWithoutStdio>): Promise<ShellProcess> {
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;
}
Expand All @@ -88,7 +93,8 @@ export class SlackCLIProcess {
shellOpts?: Partial<SpawnOptionsWithoutStdio>,
): Promise<ShellProcess> {
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,
});
Expand All @@ -100,53 +106,54 @@ export class SlackCLIProcess {
*/
public execSync(shellOpts?: Partial<SpawnOptionsWithoutStdio>): 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));
}
}
}
Expand Down
11 changes: 5 additions & 6 deletions packages/cli-test/src/cli/commands/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']));
});
});
});
Loading

0 comments on commit dcd0183

Please sign in to comment.