diff --git a/src/cmd/run.js b/src/cmd/run.js index c6e40e0ccb..ba5fc1c995 100644 --- a/src/cmd/run.js +++ b/src/cmd/run.js @@ -1,45 +1,186 @@ /* @flow */ import buildExtension from './build'; import * as defaultFirefox from '../firefox'; +import defaultFirefoxConnector from '../firefox/remote'; +import {onlyErrorsWithCode} from '../errors'; import {withTempDir} from '../util/temp-dir'; import {createLogger} from '../util/logger'; import getValidatedManifest from '../util/manifest'; +import defaultSourceWatcher from '../watcher'; const log = createLogger(__filename); +export function defaultWatcherCreator( + {profile, client, sourceDir, artifactsDir, createRunner, + onSourceChange=defaultSourceWatcher}: Object): Object { + return onSourceChange({ + sourceDir, artifactsDir, onChange: () => createRunner( + (runner) => runner.buildExtension() + .then((buildResult) => runner.install(buildResult, {profile})) + .then(() => { + log.debug('Attempting to reload extension'); + const addonId = runner.manifestData.applications.gecko.id; + log.debug(`Reloading add-on ID ${addonId}`); + return client.reloadAddon(addonId); + }) + .catch((error) => { + log.error(error.stack); + throw error; + }) + ), + }); +} + + +export function defaultReloadStrategy( + {firefox, profile, sourceDir, artifactsDir, createRunner}: Object, + {connectToFirefox=defaultFirefoxConnector, + maxRetries=25, retryInterval=120, + createWatcher=defaultWatcherCreator}: Object = {}): Promise { + var watcher; + var client; + var retries = 0; + + firefox.on('close', () => { + if (client) { + client.disconnect(); + } + if (watcher) { + watcher.close(); + } + }); + + function establishConnection() { + return new Promise((resolve, reject) => { + connectToFirefox() + .then((connectedClient) => { + log.debug('Connected to the Firefox debugger'); + client = connectedClient; + watcher = createWatcher({ + profile, client, sourceDir, artifactsDir, createRunner, + }); + resolve(); + }) + .catch(onlyErrorsWithCode('ECONNREFUSED', (error) => { + if (retries >= maxRetries) { + log.debug('Connect to Firefox debugger: too many retries'); + throw error; + } else { + setTimeout(() => { + retries ++; + log.debug( + `Retrying Firefox (${retries}); connection error: ${error}`); + resolve(establishConnection()); + }, retryInterval); + } + })) + .catch((error) => { + log.error(error.stack); + reject(error); + }); + }); + } + + return establishConnection(); +} + + export default function run( - {sourceDir, firefoxBinary, firefoxProfile}: Object, - {firefox=defaultFirefox}: Object = {}): Promise { + {sourceDir, artifactsDir, firefoxBinary, firefoxProfile, noReload}: Object, + {firefox=defaultFirefox, reloadStrategy=defaultReloadStrategy} + : Object = {}): Promise { log.info(`Running web extension from ${sourceDir}`); - return getValidatedManifest(sourceDir) - .then((manifestData) => withTempDir( - (tmpDir) => - Promise.all([ - buildExtension({sourceDir, artifactsDir: tmpDir.path()}, - {manifestData}), - new Promise((resolve) => { - if (firefoxProfile) { - log.debug(`Copying Firefox profile from ${firefoxProfile}`); - resolve(firefox.copyProfile(firefoxProfile)); - } else { - log.debug('Creating new Firefox profile'); - resolve(firefox.createProfile()); - } - }), - ]) - .then((result) => { - let [buildResult, profile] = result; - return firefox.installExtension( - { - manifestData, - extensionPath: buildResult.extensionPath, - profile, - }) - .then(() => profile); + function createRunner(callback) { + return getValidatedManifest(sourceDir) + .then((manifestData) => withTempDir( + (tmpDir) => { + const runner = new ExtensionRunner({ + sourceDir, + firefox, + firefoxBinary, + tmpDirPath: tmpDir.path(), + manifestData, + firefoxProfile, + }); + return callback(runner); + } + )); + } + + return createRunner( + (runner) => runner.buildExtension() + .then((buildResult) => runner.install(buildResult)) + .then((profile) => runner.run(profile).then((firefox) => { + return {firefox, profile}; + })) + .then(({firefox, profile}) => { + if (noReload) { + log.debug('Extension auto-reloading has been disabled'); + } else { + log.debug('Reloading extension when the source changes'); + reloadStrategy( + {firefox, profile, sourceDir, artifactsDir, createRunner}); + } + return firefox; + }) + ); +} + + +export class ExtensionRunner { + sourceDir: string; + tmpDirPath: string; + manifestData: Object; + firefoxProfile: Object; + firefox: Object; + firefoxBinary: string; + + constructor({firefox, sourceDir, tmpDirPath, manifestData, + firefoxProfile, firefoxBinary}: Object) { + this.sourceDir = sourceDir; + this.tmpDirPath = tmpDirPath; + this.manifestData = manifestData; + this.firefoxProfile = firefoxProfile; + this.firefox = firefox; + this.firefoxBinary = firefoxBinary; + } + + buildExtension(): Promise { + const {sourceDir, tmpDirPath, manifestData} = this; + return buildExtension({sourceDir, artifactsDir: tmpDirPath}, + {manifestData}); + } + + getProfile(): Promise { + const {firefox, firefoxProfile} = this; + return new Promise((resolve) => { + if (firefoxProfile) { + log.debug(`Copying Firefox profile from ${firefoxProfile}`); + resolve(firefox.copyProfile(firefoxProfile)); + } else { + log.debug('Creating new Firefox profile'); + resolve(firefox.createProfile()); + } + }); + } + + install(buildResult: Object, {profile}: Object = {}): Promise { + const {firefox, manifestData} = this; + return Promise.resolve(profile ? profile : this.getProfile()) + .then((profile) => firefox.installExtension( + { + manifestData, + extensionPath: buildResult.extensionPath, + profile, }) - .then((profile) => firefox.run(profile, {firefoxBinary})) - )); + .then(() => profile)); + } + + run(profile: Object): Promise { + const {firefox, firefoxBinary} = this; + return firefox.run(profile, {firefoxBinary}); + } } diff --git a/src/firefox/index.js b/src/firefox/index.js index 6099e0531f..e227dbde0f 100644 --- a/src/firefox/index.js +++ b/src/firefox/index.js @@ -26,15 +26,17 @@ export const defaultFirefoxEnv = { */ export function run( profile: FirefoxProfile, - {fxRunner=defaultFxRunner, firefoxBinary}: Object = {}): Promise { + {fxRunner=defaultFxRunner, firefoxBinary, binaryArgs} + : Object = {}): Promise { log.info(`Running Firefox with profile at ${profile.path()}`); return fxRunner( { // if this is falsey, fxRunner tries to find the default one. 'binary': firefoxBinary, - 'binary-args': null, - 'no-remote': true, + 'binary-args': binaryArgs, + 'no-remote': false, + 'listen': '6000', 'foreground': true, 'profile': profile.path(), 'env': { @@ -48,7 +50,7 @@ export function run( let firefox = results.process; log.debug(`Executing Firefox binary: ${results.binary}`); - log.debug(`Executing Firefox with args: ${results.args.join(' ')}`); + log.debug(`Firefox args: ${results.args.join(' ')}`); firefox.on('error', (error) => { // TODO: show a nice error when it can't find Firefox. @@ -67,8 +69,9 @@ export function run( firefox.on('close', () => { log.debug('Firefox closed'); - resolve(); }); + + resolve(firefox); }); }); } diff --git a/src/firefox/preferences.js b/src/firefox/preferences.js index adc8106f43..6408a8bf52 100644 --- a/src/firefox/preferences.js +++ b/src/firefox/preferences.js @@ -25,6 +25,8 @@ prefs.common = { // Allow remote connections to the debugger. 'devtools.debugger.remote-enabled' : true, + // Disable the prompt for allowing connections. + 'devtools.debugger.prompt-connection' : false, // Turn off platform logging because it is a lot of info. 'extensions.logging.enabled': false, diff --git a/src/program.js b/src/program.js index c12262859e..158756651d 100644 --- a/src/program.js +++ b/src/program.js @@ -28,7 +28,7 @@ export class Program { } command(name: string, description: string, executor: Function, - commandOptions: ?Object): Program { + commandOptions: Object = {}): Program { this.yargs.command(name, description, (yargs) => { if (!commandOptions) { return; @@ -184,6 +184,10 @@ Example: $0 --help run. demand: false, type: 'string', }, + 'no-reload': { + describe: 'Do not reload the extension as the source changes', + type: 'boolean', + }, }); return program.run(runOptions); diff --git a/src/watcher.js b/src/watcher.js index 64491f7615..80592ae57b 100644 --- a/src/watcher.js +++ b/src/watcher.js @@ -3,6 +3,7 @@ import Watchpack from 'watchpack'; import debounce from 'debounce'; import {createLogger} from './util/logger'; +import {FileFilter} from './cmd/build'; const log = createLogger(__filename); @@ -21,16 +22,19 @@ export default function onSourceChange( log.debug(`Watching for file changes in ${sourceDir}`); watcher.watch([], [sourceDir], Date.now()); - // TODO: support windows See: - // http://stackoverflow.com/questions/10021373/what-is-the-windows-equivalent-of-process-onsigint-in-node-js + // TODO: support interrupting the watcher on Windows. + // https://github.com/mozilla/web-ext/issues/225 process.on('SIGINT', () => watcher.close()); return watcher; } export function proxyFileChanges( - {artifactsDir, onChange, filePath, shouldWatchFile=() => true} - : Object) { + {artifactsDir, onChange, filePath, shouldWatchFile}: Object) { + if (!shouldWatchFile) { + const fileFilter = new FileFilter(); + shouldWatchFile = (...args) => fileFilter.wantFile(...args); + } if (filePath.indexOf(artifactsDir) === 0 || !shouldWatchFile(filePath)) { log.debug(`Ignoring change to: ${filePath}`); } else { diff --git a/tests/test-cmd/test.run.js b/tests/test-cmd/test.run.js index 8f1342d413..ea8070b0b0 100644 --- a/tests/test-cmd/test.run.js +++ b/tests/test-cmd/test.run.js @@ -1,17 +1,46 @@ /* @flow */ +import path from 'path'; +import {EventEmitter} from 'events'; +import deepcopy from 'deepcopy'; import {describe, it} from 'mocha'; import {assert} from 'chai'; +import sinon from 'sinon'; -import run from '../../src/cmd/run'; +import {ExtendableError} from '../../src/util/es6-modules'; +import run, {defaultWatcherCreator, defaultReloadStrategy, ExtensionRunner} + from '../../src/cmd/run'; import * as firefox from '../../src/firefox'; -import {fake, fixturePath} from '../helpers'; +import {RemoteFirefox} from '../../src/firefox/remote'; +import {makeSureItFails, fake, fixturePath} from '../helpers'; +import {createLogger} from '../../src/util/logger'; +import {basicManifest} from '../test-util/test.manifest'; + +const log = createLogger(__filename); describe('run', () => { - function runMinimalExt(argv={}, ...optionalArgs) { - return run({sourceDir: fixturePath('minimal-web-ext'), ...argv}, - ...optionalArgs); + function prepareRun() { + const sourceDir = fixturePath('minimal-web-ext'); + let argv = { + artifactsDir: path.join(sourceDir, 'web-ext-artifacts'), + sourceDir, + noReload: true, + }; + let options = { + firefox: getFakeFirefox(), + reloadStrategy: sinon.spy(() => { + log.debug('fake: reloadStrategy()'); + }), + }; + + return { + argv, options, + run: (customArgv={}, customOpt={}) => run( + {...argv, ...customArgv}, + {...options, ...customOpt} + ), + }; } function getFakeFirefox(implementations={}) { @@ -20,6 +49,7 @@ describe('run', () => { createProfile: () => Promise.resolve(profile), copyProfile: () => Promise.resolve(profile), installExtension: () => Promise.resolve(), + run: () => Promise.resolve(), ...implementations, }; return fake(firefox, allImplementations); @@ -28,48 +58,279 @@ describe('run', () => { it('installs and runs the extension', () => { let profile = {}; - let fakeFirefox = getFakeFirefox({ - createProfile: () => Promise.resolve(profile), - }); - return runMinimalExt({}, {firefox: fakeFirefox}) - .then(() => { + const cmd = prepareRun(); + const {firefox} = cmd.options; - let install = fakeFirefox.installExtension; - assert.equal(install.called, true); - assert.equal( - install.firstCall.args[0].manifestData.applications.gecko.id, - 'minimal-example@web-ext-test-suite'); - assert.deepEqual(install.firstCall.args[0].profile, profile); - assert.match(install.firstCall.args[0].extensionPath, - /minimal_extension-1\.0\.xpi/); + return cmd.run().then(() => { + let install = cmd.options.firefox.installExtension; + assert.equal(install.called, true); + assert.equal( + install.firstCall.args[0].manifestData.applications.gecko.id, + 'minimal-example@web-ext-test-suite'); + assert.deepEqual(install.firstCall.args[0].profile, profile); + assert.match(install.firstCall.args[0].extensionPath, + /minimal_extension-1\.0\.xpi/); - assert.equal(fakeFirefox.run.called, true); - assert.deepEqual(fakeFirefox.run.firstCall.args[0], profile); - }); + assert.equal(firefox.run.called, true); + assert.deepEqual(firefox.run.firstCall.args[0], profile); + }); }); it('passes a custom Firefox binary when specified', () => { - let firefoxBinary = '/pretend/path/to/Firefox/firefox-bin'; - let fakeFirefox = getFakeFirefox(); - return runMinimalExt({firefoxBinary}, {firefox: fakeFirefox}) - .then(() => { - assert.equal(fakeFirefox.run.called, true); - assert.equal(fakeFirefox.run.firstCall.args[1].firefoxBinary, - firefoxBinary); - }); + const firefoxBinary = '/pretend/path/to/Firefox/firefox-bin'; + const cmd = prepareRun(); + const {firefox} = cmd.options; + + return cmd.run({firefoxBinary}).then(() => { + assert.equal(firefox.run.called, true); + assert.equal(firefox.run.firstCall.args[1].firefoxBinary, + firefoxBinary); + }); }); it('passes a custom Firefox profile when specified', () => { - let firefoxProfile = '/pretend/path/to/firefox/profile'; - let fakeFirefox = getFakeFirefox(); - return runMinimalExt({firefoxProfile}, {firefox: fakeFirefox}) - .then(() => { - assert.equal(fakeFirefox.createProfile.called, false); - assert.equal(fakeFirefox.copyProfile.called, true); - assert.equal(fakeFirefox.copyProfile.firstCall.args[0], - firefoxProfile); + const firefoxProfile = '/pretend/path/to/firefox/profile'; + const cmd = prepareRun(); + const {firefox} = cmd.options; + + return cmd.run({firefoxProfile}).then(() => { + assert.equal(firefox.createProfile.called, false); + assert.equal(firefox.copyProfile.called, true); + assert.equal(firefox.copyProfile.firstCall.args[0], + firefoxProfile); + }); + }); + + it('can watch and reload the extension', () => { + const cmd = prepareRun(); + const {sourceDir, artifactsDir} = cmd.argv; + const {reloadStrategy} = cmd.options; + + return cmd.run({noReload: false}).then(() => { + assert.equal(reloadStrategy.called, true); + const args = reloadStrategy.firstCall.args[0]; + assert.equal(args.sourceDir, sourceDir); + assert.equal(args.artifactsDir, artifactsDir); + assert.typeOf(args.createRunner, 'function'); + }); + }); + + it('allows you to opt out of extension reloading', () => { + const cmd = prepareRun(); + const {reloadStrategy} = cmd.options; + + return cmd.run({noReload: true}).then(() => { + assert.equal(reloadStrategy.called, false); + }); + }); + + describe('defaultWatcherCreator', () => { + + function prepare() { + const config = { + profile: {}, + client: fake(RemoteFirefox.prototype), + sourceDir: '/path/to/extension/source/', + artifactsDir: '/path/to/web-ext-artifacts', + createRunner: (cb) => cb(fake(ExtensionRunner.prototype)), + onSourceChange: sinon.spy(() => {}), + }; + return { + config, + createWatcher: (customConfig={}) => { + return defaultWatcherCreator({...config, ...customConfig}); + }, + }; + } + + it('configures a source watcher', () => { + const {config, createWatcher} = prepare(); + createWatcher(); + assert.equal(config.onSourceChange.called, true); + const callArgs = config.onSourceChange.firstCall.args[0]; + assert.equal(callArgs.sourceDir, config.sourceDir); + assert.equal(callArgs.artifactsDir, config.artifactsDir); + assert.typeOf(callArgs.onChange, 'function'); + }); + + it('returns a watcher', () => { + const watcher = {}; + const onSourceChange = sinon.spy(() => watcher); + const createdWatcher = prepare().createWatcher({onSourceChange}); + assert.equal(createdWatcher, watcher); + }); + + it('builds, installs, and reloads the extension', () => { + const {config, createWatcher} = prepare(); + + const runner = fake(ExtensionRunner.prototype, { + install: sinon.spy(() => Promise.resolve()), + buildExtension: sinon.spy(() => Promise.resolve({})), }); + runner.manifestData = deepcopy(basicManifest); + createWatcher({createRunner: (cb) => cb(runner)}); + + const callArgs = config.onSourceChange.firstCall.args[0]; + assert.typeOf(callArgs.onChange, 'function'); + // Simulate executing the handler when a source file changes. + return callArgs.onChange() + .then(() => { + assert.equal(runner.buildExtension.called, true); + assert.equal(runner.install.called, true); + + assert.equal(config.client.reloadAddon.called, true); + const reloadArgs = config.client.reloadAddon.firstCall.args; + assert.equal(reloadArgs[0], 'basic-manifest@web-ext-test-suite'); + }); + }); + + it('throws errors from source change handler', () => { + const createRunner = (cb) => cb(fake(ExtensionRunner.prototype, { + buildExtension: () => Promise.resolve({}), + install: () => Promise.reject(new Error('fake installation error')), + })); + const {createWatcher, config} = prepare(); + createWatcher({createRunner}); + + assert.equal(config.onSourceChange.called, true); + // Simulate an error triggered from the source change handler. + return config.onSourceChange.firstCall.args[0].onChange() + .then(makeSureItFails()) + .catch((error) => { + assert.equal(error.message, 'fake installation error'); + }); + }); + + }); + + describe('defaultReloadStrategy', () => { + + function prepare() { + const client = { + disconnect: sinon.spy(() => {}), + }; + const watcher = { + close: sinon.spy(() => {}), + }; + const args = { + firefox: new EventEmitter(), + profile: {}, + sourceDir: '/path/to/extension/source', + artifactsDir: '/path/to/web-ext-artifacts/', + createRunner: sinon.spy((cb) => cb(fake(ExtensionRunner.prototype))), + }; + const options = { + connectToFirefox: sinon.spy(() => Promise.resolve(client)), + createWatcher: sinon.spy(() => watcher), + maxRetries: 0, + retryInterval: 1, + }; + return { + ...args, + ...options, + client, + watcher, + reloadStrategy: (argOverride={}, optOverride={}) => { + return defaultReloadStrategy( + {...args, ...argOverride}, + {...options, ...optOverride}); + }, + }; + } + + class ConnError extends ExtendableError { + code: string; + constructor(msg) { + super(msg); + this.code = 'ECONNREFUSED'; + } + } + + it('cleans up connections when firefox closes', () => { + const {firefox, client, watcher, reloadStrategy} = prepare(); + return reloadStrategy() + .then(() => { + firefox.emit('close'); + assert.equal(client.disconnect.called, true); + assert.equal(watcher.close.called, true); + }); + }); + + it('ignores uninitialized objects when firefox closes', () => { + const {firefox, client, watcher, reloadStrategy} = prepare(); + return reloadStrategy( + {}, { + connectToFirefox: () => Promise.reject( + new ConnError('connect error')), + }) + .then(makeSureItFails()) + .catch(() => { + firefox.emit('close'); + assert.equal(client.disconnect.called, false); + assert.equal(watcher.close.called, false); + }); + }); + + it('configures a watcher', () => { + const {createWatcher, reloadStrategy, ...sentArgs} = prepare(); + return reloadStrategy().then(() => { + assert.equal(createWatcher.called, true); + const receivedArgs = createWatcher.firstCall.args[0]; + assert.equal(receivedArgs.profile, sentArgs.profile); + assert.equal(receivedArgs.client, sentArgs.client); + assert.equal(receivedArgs.sourceDir, sentArgs.sourceDir); + assert.equal(receivedArgs.artifactsDir, sentArgs.artifactsDir); + assert.equal(receivedArgs.createRunner, sentArgs.createRunner); + }); + }); + + it('retries after a connection error', () => { + const {reloadStrategy} = prepare(); + var tryCount = 0; + const connectToFirefox = sinon.spy(() => new Promise( + (resolve, reject) => { + tryCount ++; + if (tryCount === 1) { + reject(new ConnError('first connection fails')); + } else { + // The second connection succeeds. + resolve(); + } + })); + + return reloadStrategy({}, {connectToFirefox, maxRetries: 3}) + .then(() => { + assert.equal(connectToFirefox.callCount, 2); + }); + }); + + it('only retries connection errors', () => { + const {reloadStrategy} = prepare(); + const connectToFirefox = sinon.spy( + () => Promise.reject(new Error('not a connection error'))); + + return reloadStrategy({}, {connectToFirefox, maxRetries: 2}) + .then(makeSureItFails()) + .catch((error) => { + assert.equal(connectToFirefox.callCount, 1); + assert.equal(error.message, 'not a connection error'); + }); + }); + + it('gives up connecting after too many retries', () => { + const {reloadStrategy} = prepare(); + const connectToFirefox = sinon.spy( + () => Promise.reject(new ConnError('failure'))); + + return reloadStrategy({}, {connectToFirefox, maxRetries: 2}) + .then(makeSureItFails()) + .catch((error) => { + assert.equal(connectToFirefox.callCount, 3); + assert.equal(error.message, 'failure'); + }); + }); + }); }); diff --git a/tests/test-firefox/test.firefox.js b/tests/test-firefox/test.firefox.js index d31f451d4c..4d80571b59 100644 --- a/tests/test-firefox/test.firefox.js +++ b/tests/test-firefox/test.firefox.js @@ -55,6 +55,17 @@ describe('firefox', () => { }); }); + it('passes binary args to Firefox', () => { + const fxRunner = createFakeFxRunner(); + const binaryArgs = '--safe-mode'; + return firefox.run(fakeProfile, {fxRunner, binaryArgs}) + .then(() => { + assert.equal(fxRunner.called, true); + assert.equal(fxRunner.firstCall.args[0]['binary-args'], + binaryArgs); + }); + }); + it('sets up a Firefox process environment', () => { let runner = createFakeFxRunner(); // Make sure it passes through process environment variables. diff --git a/tests/test.watcher.js b/tests/test.watcher.js index 3e6334cacb..7e51f2be0d 100644 --- a/tests/test.watcher.js +++ b/tests/test.watcher.js @@ -81,7 +81,7 @@ describe('watcher', () => { it('provides a callback for ignoring files', () => { function shouldWatchFile(filePath) { - if (filePath === '/somewhere/.git') { + if (filePath === '/somewhere/freaky') { return false; } else { return true; @@ -93,7 +93,7 @@ describe('watcher', () => { onChange: sinon.spy(() => {}), }; - proxyFileChanges({...conf, filePath: '/somewhere/.git'}); + proxyFileChanges({...conf, filePath: '/somewhere/freaky'}); assert.equal(conf.onChange.called, false); proxyFileChanges({...conf, filePath: '/any/file/'}); @@ -101,6 +101,15 @@ describe('watcher', () => { }); + it('filters out commonly unwanted files by default', () => { + const conf = { + ...defaults, shouldWatchFile: undefined, + onChange: sinon.spy(() => {}), + }; + proxyFileChanges({...conf, filePath: '/somewhere/.git'}); + assert.equal(conf.onChange.called, false); + }); + }); });