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

feat: basic implementation of command line config option #1080

Merged
merged 13 commits into from
Dec 13, 2017
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import camelCase from 'camelcase';
import decamelize from 'decamelize';

import {createLogger} from './util/logger';
import {UsageError, WebExtError} from './errors';
import {UsageError} from './errors';

const log = createLogger(__filename);

Expand Down Expand Up @@ -50,7 +50,7 @@ export function applyConfigToArgv({
`an unknown option: "${option}"`);
}
if (options[decamelizedOptName].type === undefined) {
throw new WebExtError(
throw new UsageError(
Copy link
Contributor

Choose a reason for hiding this comment

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

I think this should be a WebExtError because when an option is defined without a type it's a programming error within web-ext itself (option types are defined at the bottom of program.js). It's not due to incorrect usage and it's not something the developer would be able to fix.

Copy link
Member

Choose a reason for hiding this comment

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

@kumar303 ah, you are definitely right, and this change is my fault, it is related to this comment #1080 (comment) (Sorry @saintsebastian).

Nevertheless, to prevent this check from be read wrong in the future, how about adding an inline comment near this throw to briefly explain what this error is about?

Another interesting option (I can file a bug so that we can handle it in a follow up) would be to use a more specific type in the flow annotations here:

setGlobalOptions(options: Object): Program {

so that we can make flow to raise an error during its static analysis if any of the options defined in program.js is missing the type property.

Copy link
Contributor

Choose a reason for hiding this comment

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

@rpl good idea about defining the shape of options

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Is this PR a good place to add that type? @kumar303 @rpl

Copy link
Member

Choose a reason for hiding this comment

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

@kumar303 @saintsebastian we could definitely define and add the additional flow types for the options in this PR, the amount of changes should not be that big, but it could be reasonable to handle it in as a separate issue and pull requests, so that we are free to discuss the details of it without blocking this PR.

In the meantime, I added an issue related to it as #1144 (which also contains some additional info about the change, e.g. where these new flow types would be used and what their flow type definitions could probably look like).

`Option: ${option} was defined without a type.`);
}

Expand Down Expand Up @@ -109,4 +109,4 @@ export function loadJSConfigFile(filePath: string): Object {
'Did you set module.exports = {...}?');
}
return configObject;
}
}
32 changes: 30 additions & 2 deletions src/program.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ import {UsageError} from './errors';
import {createLogger, consoleStream as defaultLogStream} from './util/logger';
import {coerceCLICustomPreference} from './firefox/preferences';
import {checkForUpdates as defaultUpdateChecker} from './util/updates';
import {
loadJSConfigFile as defaultLoadJSConfigFile,
applyConfigToArgv as defaultApplyConfigToArgv,
} from './config';

const log = createLogger(__filename);
const envPrefix = 'WEB_EXT';
Expand All @@ -20,13 +24,17 @@ type ProgramOptions = {|
absolutePackageDir?: string,
|}

export type VersionGetterFn = (absolutePackageDir: string) => string;

// TODO: add pipes to Flow type after https://github.com/facebook/flow/issues/2405 is fixed

type ExecuteOptions = {
checkForUpdates?: Function,
systemProcess?: typeof process,
logStream?: typeof defaultLogStream,
getVersion?: Function,
getVersion?: VersionGetterFn,
Copy link
Member

Choose a reason for hiding this comment

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

@saintsebastian ❤️ thank you so much! this type annotation is way better!

applyConfigToArgv?: typeof defaultApplyConfigToArgv,
loadJSConfigFile?: typeof defaultLoadJSConfigFile,
shouldExitProgram?: boolean,
globalEnv?: string,
}
Expand Down Expand Up @@ -115,6 +123,8 @@ export class Program {
{
checkForUpdates = defaultUpdateChecker, systemProcess = process,
logStream = defaultLogStream, getVersion = defaultVersionGetter,
applyConfigToArgv = defaultApplyConfigToArgv,
loadJSConfigFile = defaultLoadJSConfigFile,
shouldExitProgram = true, globalEnv = WEBEXT_BUILD_ENV,
}: ExecuteOptions = {}
): Promise<void> {
Expand Down Expand Up @@ -145,6 +155,17 @@ export class Program {
});
}

if (argv.config) {
const configFileName = path.resolve(argv.config);
const configObject = loadJSConfigFile(configFileName);
applyConfigToArgv({
argv,
configFileName,
configObject,
options: this.options,
});
}

await runCommand(argv, {shouldExitProgram});

} catch (error) {
Expand Down Expand Up @@ -194,7 +215,7 @@ export function defaultVersionGetter(
// TODO: add pipes to Flow type after https://github.com/facebook/flow/issues/2405 is fixed

type MainParams = {
getVersion?: Function,
getVersion?: VersionGetterFn,
commands?: Object,
argv: Array<any>,
runOptions?: Object,
Expand Down Expand Up @@ -265,6 +286,13 @@ Example: $0 --help run.
describe: 'Disable all features that require standard input',
type: 'boolean',
},
'config': {
alias: 'c',
describe: 'Location of the config file',
Copy link
Contributor

Choose a reason for hiding this comment

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

I think 'Path to the config file' would be a bit more explicit

default: undefined,
requiresArg: true,
type: 'string',
},
});

program
Expand Down
2 changes: 1 addition & 1 deletion src/util/logger.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ export type CreateBunyanLogParams = {|
export type CreateBunyanLogFn = (params: CreateBunyanLogParams) => Logger;

export type CreateLoggerOptions = {|
createBunyanLog: CreateBunyanLogFn,
createBunyanLog?: CreateBunyanLogFn,
Copy link
Member

Choose a reason for hiding this comment

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

@saintsebastian is this change to the type annotation needed?

Copy link
Contributor

Choose a reason for hiding this comment

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

Was this type change necessary?

|};

export function createLogger(
Expand Down
10 changes: 5 additions & 5 deletions tests/unit/test.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
loadJSConfigFile,
} from '../../src/config';
import {withTempDir} from '../../src/util/temp-dir';
import {UsageError, WebExtError} from '../../src/errors';
import {UsageError} from '../../src/errors';

type MakeArgvParams = {|
userCmd?: Array<string>,
Expand Down Expand Up @@ -657,8 +657,8 @@ describe('config', () => {
};
assert.throws(() => {
applyConf({...params, configObject});
}, WebExtError,
'WebExtError: Option: apiUrl was defined without a type.');
}, UsageError,
'UsageError: Option: apiUrl was defined without a type.');
});

it('throws an error when the type of one of them is in config' +
Copy link
Member

Choose a reason for hiding this comment

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

@saintsebastian This test seems basically the same of the above test, I see that you have only changed the expected error here, but given that you looked into this test recently I'm wondering if you could check if I'm right and this test case is redundant.

Copy link
Contributor

@kumar303 kumar303 Nov 14, 2017

Choose a reason for hiding this comment

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

This test looks like a duplicate of https://github.com/saintsebastian/web-ext/blob/737dd7495f090e5334b9ccd7ee8ad9a809ef4031/tests/unit/test.config.js#L640 You can delete it. It looks like the intention of the second test was to see if the code still works when one of the options has a type and the other does not but I don't think that's a useful test.

Expand Down Expand Up @@ -688,8 +688,8 @@ describe('config', () => {
};
assert.throws(() => {
applyConf({...params, configObject});
}, WebExtError,
'WebExtError: Option: apiUrl was defined without a type.');
}, UsageError,
'UsageError: Option: apiUrl was defined without a type.');
});

it('throws an error when type of unrelated sub option is invalid', () => {
Expand Down
212 changes: 208 additions & 4 deletions tests/unit/test.program.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,20 @@ import {assert} from 'chai';

import {defaultVersionGetter, main, Program} from '../../src/program';
import commands from '../../src/cmd';
import {onlyInstancesOf, UsageError} from '../../src/errors';
import {
onlyInstancesOf,
UsageError,
} from '../../src/errors';
import {
createFakeProcess,
fake,
makeSureItFails,
ErrorWithCode,
} from './helpers';
import {ConsoleStream} from '../../src/util/logger';

import {
consoleStream,
Copy link
Member

Choose a reason for hiding this comment

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

@saintsebastian nit, an inline comment here could be useful here (just to confirm to the next developer that reads this test file that we are importing both consoleStream and ConsoleStream on purpose, one to be able to inspect the messages logged by the logger instance created internally in the tested module and the other to create new fake console streams)

ConsoleStream,
} from '../../src/util/logger';

describe('program.Program', () => {

Expand All @@ -26,7 +31,7 @@ describe('program.Program', () => {
const absolutePackageDir = path.join(__dirname, '..', '..');
return program.execute(
absolutePackageDir, {
getVersion: () => spy(),
getVersion: () => 'not-a-real-version',
Copy link
Member

Choose a reason for hiding this comment

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

argh... This is why it is better to use a more specific type signature instead of Function as a flow type signature :-)

Do you mind to file a follow up issue related to a more specific type signature for getVersion in "src/program.js" (or we can check how many flow errors it produces, if they are not too much we can fix it in this patch, on the contrary we can move it into a separate issue and pull request)

A reasonable flow type could be just () => string (a function which takes no parameters and returns a string).

checkForUpdates: spy(),
systemProcess: fakeProcess,
shouldExitProgram: false,
Expand Down Expand Up @@ -430,6 +435,205 @@ describe('program.main', () => {
assert.strictEqual(options.shouldExitProgram, false);
});
});

it('applies options from the specified config file', () => {
const fakeCommands = fake(commands, {
lint: () => Promise.resolve(),
});
const fakePath = 'path/to/web-ext-config.js';
const resolvedFakePath = path.resolve(fakePath);
const fakeLoadJSConfigFile = sinon.spy(() => {});
const fakeApplyConfigToArgv = sinon.spy(() => {});

return execProgram(
['lint', '--config', fakePath],
{
commands: fakeCommands,
runOptions: {
applyConfigToArgv: fakeApplyConfigToArgv,
loadJSConfigFile: fakeLoadJSConfigFile,
},
})
.then(() => {
const options = fakeCommands.lint.firstCall.args[0];
assert.strictEqual(options.config, fakePath);
sinon.assert.calledOnce(fakeLoadJSConfigFile);
Copy link
Member

Choose a reason for hiding this comment

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

I would prefer to see the sinon.assert.calledOnce and sinon.assert.calledWith related to the same spy grouped together.

sinon.assert.calledWith(fakeLoadJSConfigFile,
sinon.match(resolvedFakePath));
sinon.assert.calledOnce(fakeApplyConfigToArgv);
sinon.assert.calledWith(fakeApplyConfigToArgv, sinon.match({
Copy link
Contributor

Choose a reason for hiding this comment

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

I think it would be better to check all parameters for the call to fakeApplyConfigToArgv because Flow isn't going to catch all configuration problem, especially the options parameter.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I assume options will always be the same (set in program.js), but i added a check for argv.

configFileName: resolvedFakePath,
}));
});
});

it('throws a UsageError when the config file can\'t be loaded', () => {
Copy link
Contributor

@kumar303 kumar303 Nov 14, 2017

Choose a reason for hiding this comment

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

I don't think you need this test because the behavior of handling thrown errors is already well defined.

I can see that perhaps you wanted to be sure that the config checking code is executed from within the catch block but I don't think it's necessary to cover that logic. If one day we encounter a bug where someone accidentally moved it outside of the catch block then we could add a test for it. Until then, I think code review is a better way to make sure the config logic is executed with the catch block.

const fakeProcess = createFakeProcess();
const fakeCommands = fake(commands, {
lint: () => Promise.resolve(),
});
const fakePath = 'path/to/web-ext-config.js';
const fakeLoadJSConfigFile = sinon.spy(() => {
throw new UsageError('bad path');
});
const fakeApplyConfigToArgv = sinon.spy(() => {});

consoleStream.stopCapturing();
consoleStream.flushCapturedLogs();
consoleStream.startCapturing();
Copy link
Contributor

Choose a reason for hiding this comment

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

This is interesting. I see what you're doing here but I'd like to see it moved only to a single test instead of multiple tests. The test should be an assertion of how Program() will log any UsageError to the console. Actually, I was surprised that we did not have test coverage of this already. Good catch.

After you add a single test for this logic, your assertion of the UsageError messages (like assert.match(error.message, /bad path/)) will be enough. This is because we will know a thrown UsageError will always be logged -- you don't have to test the console stream every time.

Also, you should add a comment around the stream capturing/re-capturing logic because it's not obvious that it is working around the test suite itself.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That makes a lot of sense. I was not sure what source of error should be, so I made command itself throw, let me know if it should be a dependency.


return execProgram(
['lint', '--config', fakePath],
{
commands: fakeCommands,
runOptions: {
shouldExitProgram: false,
systemProcess: fakeProcess,
applyConfigToArgv: fakeApplyConfigToArgv,
loadJSConfigFile: fakeLoadJSConfigFile,
},
})
.then(makeSureItFails())
.catch(onlyInstancesOf(UsageError, (error) => {
const {capturedMessages} = consoleStream;
consoleStream.stopCapturing();

assert.match(error.message, /bad path/);
assert.ok(
capturedMessages.some(
(message) => message.match(/bad path/)
));
sinon.assert.notCalled(fakeApplyConfigToArgv);
sinon.assert.notCalled(fakeProcess.exit);
}));
});

it('throws when config file loading raises an unexpected error', () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Similar to my last comment, I also don't think you need this test.

const fakeProcess = createFakeProcess();
const fakeCommands = fake(commands, {
lint: () => Promise.resolve(),
});
const fakePath = 'path/to/web-ext-config.js';
const fakeLoadJSConfigFile = sinon.spy(() => {
throw new Error('some error');
});
const fakeApplyConfigToArgv = sinon.spy(() => {});

consoleStream.stopCapturing();
consoleStream.flushCapturedLogs();
consoleStream.startCapturing();

return execProgram(
['lint', '--config', fakePath],
{
commands: fakeCommands,
runOptions: {
shouldExitProgram: false,
systemProcess: fakeProcess,
applyConfigToArgv: fakeApplyConfigToArgv,
loadJSConfigFile: fakeLoadJSConfigFile,
},
})
.then(makeSureItFails())
.catch((error) => {
const {capturedMessages} = consoleStream;
consoleStream.stopCapturing();

if (error instanceof UsageError) {
throw error;
}

assert.match(error.message, /some error/);
assert.ok(capturedMessages.some(
(message) => message.match(/some error/)
));
sinon.assert.notCalled(fakeApplyConfigToArgv);
sinon.assert.notCalled(fakeProcess.exit);
});
});

it('throws a UsageError when the loaded config can\'t be applied', () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Same here :) I say delete the test.

const fakeProcess = createFakeProcess();
const fakeCommands = fake(commands, {
lint: () => Promise.resolve(),
});
const fakePath = 'path/to/web-ext-config.js';
const fakeLoadJSConfigFile = sinon.spy(() => {});
const fakeApplyConfigToArgv = sinon.spy(() => {
throw new UsageError('bad config option');
});

consoleStream.stopCapturing();
consoleStream.flushCapturedLogs();
consoleStream.startCapturing();

return execProgram(
['lint', '--config', fakePath],
{
commands: fakeCommands,
runOptions: {
shouldExitProgram: false,
systemProcess: fakeProcess,
applyConfigToArgv: fakeApplyConfigToArgv,
loadJSConfigFile: fakeLoadJSConfigFile,
},
})
.then(makeSureItFails())
.catch(onlyInstancesOf(UsageError, (error) => {
const {capturedMessages} = consoleStream;
consoleStream.stopCapturing();

assert.match(error.message, /bad config option/);
assert.ok(
capturedMessages.some(
(message) => message.match(/bad config option/)
));
sinon.assert.notCalled(fakeProcess.exit);
}));
});

it('throws when fakeApplyConfigToArgv throws an unexpected error', () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

This can be deleted. I don't think it's helpful to check that an error is thrown.

const fakeProcess = createFakeProcess();
const fakeCommands = fake(commands, {
lint: () => Promise.resolve(),
});
const fakePath = 'path/to/web-ext-config.js';
const fakeLoadJSConfigFile = sinon.spy(() => {});
const fakeApplyConfigToArgv = sinon.spy(() => {
throw new Error('some error');
});

consoleStream.stopCapturing();
Copy link
Member

Choose a reason for hiding this comment

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

@saintsebastian nit: we should probably add a comment near here to explain that:

consoleStream.stopCapturing();
consoleStream.flushCapturedLogs();

are used here to ensure that we empty any captured log.

consoleStream.flushCapturedLogs();
consoleStream.startCapturing();

return execProgram(
['lint', '--config', fakePath],
{
commands: fakeCommands,
runOptions: {
shouldExitProgram: false,
systemProcess: fakeProcess,
applyConfigToArgv: fakeApplyConfigToArgv,
loadJSConfigFile: fakeLoadJSConfigFile,
},
})
.then(makeSureItFails())
.catch((error) => {
const {capturedMessages} = consoleStream;
consoleStream.stopCapturing();

if (error instanceof UsageError) {
throw error;
}

assert.match(error.message, /some error/);
assert.ok(capturedMessages.some(
(message) => message.match(/some error/)
));
sinon.assert.notCalled(fakeProcess.exit);
});
});
});

describe('program.defaultVersionGetter', () => {
Expand Down