Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add informative message for missing executable on Windows #2291

36 changes: 28 additions & 8 deletions lib/command.js
Original file line number Diff line number Diff line change
Expand Up @@ -1094,6 +1094,24 @@ Expecting one of '${allowedValues.join("', '")}'`);
return this;
}

/**
* Note: masked in some unit tests.
* @param {string} executableFile
* @param {string} executableDir
* @param {string} subcommandName
*/
_throwForMissingExecutable(executableFile, executableDir, subcommandName) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The method name is _throwForMissingExecutable, but it may not throw, so why not name it something like _validateExecutable?

Or how about doing the checks outside of the method?

if (!fs.existsSync(executableFile)) {
 _throwForMissingExecutable(...);
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

method name

Oh, yes. I originally had the check outside the routine. Will change name.

Or how about doing the checks outside of the method?

I agree that would be tidier for the code and usage. However, I started that way, and multiple unit tests check whether fs.existsSync has been called and broke. I didn't think of an easy way of fixing the tests, other than moving the fs. fs.existsSync inside the routine. Then easy to completely bypass the new code and run the old tests.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have several _checkForX routines already so following that style, _checkForMissingExecutable.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lots!

  • _checkForMissingMandatoryOptions
  • _checkForConflictingLocalOptions
  • _checkForConflictingOptions
  • _checkForBrokenPassThrough

if (fs.existsSync(executableFile)) return;
const executableDirMessage = executableDir
? `searched for local subcommand relative to directory '${executableDir}'`
: 'no directory for search for local subcommand, use .executableDir() to supply a custom directory';
const executableMissing = `'${executableFile}' does not exist
- if '${subcommandName}' is not meant to be an executable command, remove description parameter from '.command()' and use '.description()' instead
- if the default executable name is not suitable, use the executableFile option to supply a custom name or path
- ${executableDirMessage}`;
throw new Error(executableMissing);
}

/**
* Execute a sub-command executable.
*
Expand Down Expand Up @@ -1177,6 +1195,11 @@ Expecting one of '${allowedValues.join("', '")}'`);
proc = childProcess.spawn(executableFile, args, { stdio: 'inherit' });
}
} else {
this._throwForMissingExecutable(
executableFile,
executableDir,
subcommand._name,
);
args.unshift(executableFile);
// add executable arguments to spawn
args = incrementNodeInspectorPort(process.execArgv).concat(args);
Expand Down Expand Up @@ -1215,14 +1238,11 @@ Expecting one of '${allowedValues.join("', '")}'`);
proc.on('error', (err) => {
// @ts-ignore: because err.code is an unknown property
if (err.code === 'ENOENT') {
const executableDirMessage = executableDir
? `searched for local subcommand relative to directory '${executableDir}'`
: 'no directory for search for local subcommand, use .executableDir() to supply a custom directory';
const executableMissing = `'${executableFile}' does not exist
- if '${subcommand._name}' is not meant to be an executable command, remove description parameter from '.command()' and use '.description()' instead
- if the default executable name is not suitable, use the executableFile option to supply a custom name or path
- ${executableDirMessage}`;
throw new Error(executableMissing);
this._throwForMissingExecutable(
executableFile,
executableDir,
subcommand._name,
);
// @ts-ignore: because err.code is an unknown property
} else if (err.code === 'EACCES') {
throw new Error(`'${executableFile}' not executable`);
Expand Down
80 changes: 48 additions & 32 deletions tests/command.executableSubcommand.mock.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,47 +12,62 @@ function makeSystemError(code) {
return err;
}

test('when subcommand executable missing (ENOENT) then throw custom message', () => {
// If the command is not found, we show a custom error with an explanation and offer
// some advice for possible fixes.
const mockProcess = new EventEmitter();
const spawnSpy = jest.spyOn(childProcess, 'spawn').mockImplementation(() => {
return mockProcess;
});
const program = new commander.Command();
program.exitOverride();
program.command('executable', 'executable description');
program.parse(['executable'], { from: 'user' });
expect(() => {
mockProcess.emit('error', makeSystemError('ENOENT'));
}).toThrow('use the executableFile option to supply a custom name'); // part of custom message
spawnSpy.mockRestore();
});
// Suppress false positive warnings due to use of testOrSkipOnWindows
/* eslint-disable jest/no-standalone-expect */

test('when subcommand executable not executable (EACCES) then throw custom message', () => {
// Side note: this error does not actually happen on Windows! But we can still simulate the behaviour on other platforms.
const mockProcess = new EventEmitter();
const spawnSpy = jest.spyOn(childProcess, 'spawn').mockImplementation(() => {
return mockProcess;
});
const program = new commander.Command();
program.exitOverride();
program.command('executable', 'executable description');
program.parse(['executable'], { from: 'user' });
expect(() => {
mockProcess.emit('error', makeSystemError('EACCES'));
}).toThrow('not executable'); // part of custom message
spawnSpy.mockRestore();
});
const testOrSkipOnWindows = process.platform === 'win32' ? test.skip : test;

testOrSkipOnWindows(
'when subcommand executable missing (ENOENT) then throw custom message',
() => {
// If the command is not found, we show a custom error with an explanation and offer
// some advice for possible fixes.
const mockProcess = new EventEmitter();
const spawnSpy = jest
.spyOn(childProcess, 'spawn')
.mockImplementation(() => {
return mockProcess;
});
const program = new commander.Command();
program.exitOverride();
program.command('executable', 'executable description');
program.parse(['executable'], { from: 'user' });
expect(() => {
mockProcess.emit('error', makeSystemError('ENOENT'));
}).toThrow('use the executableFile option to supply a custom name'); // part of custom message
spawnSpy.mockRestore();
},
);

testOrSkipOnWindows(
'when subcommand executable not executable (EACCES) then throw custom message',
() => {
const mockProcess = new EventEmitter();
const spawnSpy = jest
.spyOn(childProcess, 'spawn')
.mockImplementation(() => {
return mockProcess;
});
const program = new commander.Command();
program.exitOverride();
program.command('executable', 'executable description');
program.parse(['executable'], { from: 'user' });
expect(() => {
mockProcess.emit('error', makeSystemError('EACCES'));
}).toThrow('not executable'); // part of custom message
spawnSpy.mockRestore();
},
);

test('when subcommand executable fails with other error and exitOverride then return in custom wrapper', () => {
test('when subcommand executable fails with other error and exitOverride then return in custom wrapper', () => {
// The existing behaviour is to just silently fail for unexpected errors, as it is happening
// asynchronously in spawned process and client can not catch errors.
const mockProcess = new EventEmitter();
const spawnSpy = jest.spyOn(childProcess, 'spawn').mockImplementation(() => {
return mockProcess;
});
const program = new commander.Command();
program._throwForMissingExecutable = () => {}; // suppress error, call mocked spawn
program.exitOverride((err) => {
throw err;
});
Expand All @@ -78,6 +93,7 @@ test('when subcommand executable fails with other error then exit', () => {
});
const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {});
const program = new commander.Command();
program._throwForMissingExecutable = () => {}; // suppress error, call mocked spawn
program.command('executable', 'executable description');
program.parse(['executable'], { from: 'user' });
mockProcess.emit('error', makeSystemError('OTHER'));
Expand Down
5 changes: 5 additions & 0 deletions tests/command.executableSubcommand.search.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,15 @@ describe('search for subcommand', () => {
spawnSpy.mockRestore();
});

// fs.existsSync gets called on Windows outside the search, so skip the tests (or come up with a different way of checking).
describe('whether perform search for local files', () => {
beforeEach(() => {
existsSpy.mockImplementation(() => false);
});

test('when no script arg or executableDir then no search for local file', () => {
const program = new commander.Command();
program._throwForMissingExecutable = () => {}; // suppress error, call mocked spawn
program.name('pm');
program.command('sub', 'executable description');
program.parse(['sub'], { from: 'user' });
Expand All @@ -66,6 +68,7 @@ describe('search for subcommand', () => {

test('when script arg then search for local files', () => {
const program = new commander.Command();
program._throwForMissingExecutable = () => {}; // suppress error, call mocked spawn
program.name('pm');
program.command('sub', 'executable description');
program.parse(['node', 'script-name', 'sub']);
Expand All @@ -75,6 +78,7 @@ describe('search for subcommand', () => {

test('when executableDir then search for local files)', () => {
const program = new commander.Command();
program._throwForMissingExecutable = () => {}; // suppress error, call mocked spawn
program.name('pm');
program.executableDir(__dirname);
program.command('sub', 'executable description');
Expand Down Expand Up @@ -301,6 +305,7 @@ describe('search for subcommand', () => {
test('when script arg then search for local script-sub.js, .ts, .tsx, .mpjs, .cjs', () => {
existsSpy.mockImplementation((path) => false);
const program = new commander.Command();
program._throwForMissingExecutable = () => {}; // suppress error, call mocked spawn
program.command('sub', 'executable description');
const scriptPath = path.resolve(gLocalDirectory, 'script');
program.parse(['node', scriptPath, 'sub']);
Expand Down
Loading