diff --git a/src/cmd/run.js b/src/cmd/run.js index ba5fc1c995..f3732a0604 100644 --- a/src/cmd/run.js +++ b/src/cmd/run.js @@ -1,9 +1,7 @@ /* @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'; @@ -12,13 +10,12 @@ const log = createLogger(__filename); export function defaultWatcherCreator( - {profile, client, sourceDir, artifactsDir, createRunner, + {client, sourceDir, artifactsDir, createRunner, onSourceChange=defaultSourceWatcher}: Object): Object { return onSourceChange({ - sourceDir, artifactsDir, onChange: () => createRunner( - (runner) => runner.buildExtension() - .then((buildResult) => runner.install(buildResult, {profile})) - .then(() => { + sourceDir, artifactsDir, onChange: () => { + return createRunner() + .then((runner) => { log.debug('Attempting to reload extension'); const addonId = runner.manifestData.applications.gecko.id; log.debug(`Reloading add-on ID ${addonId}`); @@ -27,40 +24,39 @@ export function defaultWatcherCreator( .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, client, profile, sourceDir, artifactsDir, createRunner}: Object, + {createWatcher=defaultWatcherCreator}: Object = {}) { + let watcher; firefox.on('close', () => { - if (client) { - client.disconnect(); - } - if (watcher) { - watcher.close(); - } + client.disconnect(); + watcher.close(); + }); + + watcher = createWatcher({ + client, sourceDir, artifactsDir, createRunner, }); +} + + +export function defaultFirefoxClient( + {connectToFirefox=defaultFirefoxConnector, + maxRetries=25, retryInterval=120}: Object = {}) { + var retries = 0; 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(); + resolve(connectedClient); }) .catch(onlyErrorsWithCode('ECONNREFUSED', (error) => { if (retries >= maxRetries) { @@ -88,72 +84,76 @@ export function defaultReloadStrategy( export default function run( {sourceDir, artifactsDir, firefoxBinary, firefoxProfile, noReload}: Object, - {firefox=defaultFirefox, reloadStrategy=defaultReloadStrategy} + {firefoxClient=defaultFirefoxClient, firefox=defaultFirefox, + reloadStrategy=defaultReloadStrategy} : Object = {}): Promise { log.info(`Running web extension from ${sourceDir}`); - function createRunner(callback) { + function createRunner() { return getValidatedManifest(sourceDir) - .then((manifestData) => withTempDir( - (tmpDir) => { - const runner = new ExtensionRunner({ - sourceDir, - firefox, - firefoxBinary, - tmpDirPath: tmpDir.path(), - manifestData, - firefoxProfile, - }); - return callback(runner); - } - )); + .then((manifestData) => { + return new ExtensionRunner({ + sourceDir, + firefox, + firefoxBinary, + manifestData, + firefoxProfile, + }); + }); } - 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; - }) - ); + return createRunner() + .then((runner) => { + return runner.getProfile().then((profile) => { + return {runner, profile}; + }); + }) + .then(({runner, profile}) => { + return runner.run(profile).then((firefox) => { + return {runner, profile, firefox}; + }); + }) + .then((config) => { + return firefoxClient().then((client) => { + return {client, ...config}; + }); + }) + .then((config) => { + const {runner, client} = config; + return runner.install(client).then(() => { + return config; + }); + }) + .then(({firefox, profile, client}) => { + if (noReload) { + log.debug('Extension auto-reloading has been disabled'); + } else { + log.debug('Reloading extension when the source changes'); + reloadStrategy({firefox, profile, client, 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, + constructor({firefox, sourceDir, 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) => { @@ -167,16 +167,8 @@ export class ExtensionRunner { }); } - 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)); + install(client: Object): Promise { + return client.installTemporaryAddon(this.sourceDir); } run(profile: Object): Promise { diff --git a/src/firefox/index.js b/src/firefox/index.js index 3d5008a7c6..625a505977 100644 --- a/src/firefox/index.js +++ b/src/firefox/index.js @@ -12,6 +12,7 @@ import {promisify} from '../util/es6-modules'; import {onlyErrorsWithCode, WebExtError} from '../errors'; import {getPrefs as defaultPrefGetter} from './preferences'; import {createLogger} from '../util/logger'; +import {default as defaultFirefoxConnector, REMOTE_PORT} from './remote'; const log = createLogger(__filename); @@ -21,22 +22,48 @@ export const defaultFirefoxEnv = { NS_TRACE_MALLOC_DISABLE_STACKS: '1', }; + +export function defaultRemotePortFinder( + {portToTry=REMOTE_PORT, connectToFirefox=defaultFirefoxConnector} + : Object = {}) { + log.debug(`Checking if remote Firefox port ${portToTry} is available`); + + return connectToFirefox(portToTry) + .then((client) => { + log.debug(`Remote Firefox port ${portToTry} is in use`); + client.disconnect(); + // TODO: instead of throw an error, pick a new random port until + // one of them is available. + // https://github.com/mozilla/web-ext/issues/283 + throw new WebExtError( + `Cannot listen on port ${portToTry} because it's in use`); + }) + .catch(onlyErrorsWithCode('ECONNREFUSED', () => { + // The connection was refused so this port is good to use. + return portToTry; + })); +} + + /* * Runs Firefox with the given profile object and resolves a promise on exit. */ export function run( profile: FirefoxProfile, - {fxRunner=defaultFxRunner, firefoxBinary, binaryArgs} + {fxRunner=defaultFxRunner, findRemotePort=defaultRemotePortFinder, + firefoxBinary, binaryArgs} : Object = {}): Promise { log.info(`Running Firefox with profile at ${profile.path()}`); - return fxRunner( - { + return findRemotePort() + .then((remotePort) => fxRunner({ // if this is falsey, fxRunner tries to find the default one. 'binary': firefoxBinary, 'binary-args': binaryArgs, - 'no-remote': false, - 'listen': '6000', + // This ensures a new instance of Firefox is created. It has nothing + // to do with the devtools remote debugger. + 'no-remote': true, + 'listen': remotePort, 'foreground': true, 'profile': profile.path(), 'env': { @@ -44,7 +71,7 @@ export function run( ...defaultFirefoxEnv, }, 'verbose': true, - }) + })) .then((results) => { return new Promise((resolve) => { let firefox = results.process; @@ -59,8 +86,12 @@ export function run( throw error; }); + log.info( + 'Use --verbose or open Tools > Web Developer > Browser Console ' + + 'to see logging'); + firefox.stderr.on('data', (data) => { - log.error(`Firefox stderr: ${data.toString().trim()}`); + log.debug(`Firefox stderr: ${data.toString().trim()}`); }); firefox.stdout.on('data', (data) => { diff --git a/src/firefox/remote.js b/src/firefox/remote.js index f67161b9ce..aac9081200 100644 --- a/src/firefox/remote.js +++ b/src/firefox/remote.js @@ -4,11 +4,15 @@ import {WebExtError} from '../errors'; import defaultFirefoxConnector from 'node-firefox-connect'; const log = createLogger(__filename); +// The default port that Firefox's remote debugger will listen on and the +// client will connect to. +export const REMOTE_PORT = 6005; export default function connect( - port: number = 6000, + port: number = REMOTE_PORT, {connectToFirefox=defaultFirefoxConnector}: Object = {}): Promise { + log.debug(`Connecting to Firefox on port ${port}`); return connectToFirefox(port) .then((client) => { log.info('Connected to the Firefox remote debugger'); @@ -27,6 +31,17 @@ export class RemoteFirefox { constructor(client: Object) { this.client = client; this.checkedForAddonReloading = false; + + client.client.on('disconnect', () => { + log.debug('Received "disconnect" from Firefox client'); + }); + client.client.on('end', () => { + log.debug('Received "end" from Firefox client'); + }); + client.client.on('message', (info) => { + // These are arbitrary messages that the client library ignores. + log.debug(`Received message from client: ${JSON.stringify(info)}`); + }); } disconnect() { @@ -47,6 +62,36 @@ export class RemoteFirefox { }); } + installTemporaryAddon(addonPath: string) { + return new Promise((resolve, reject) => { + this.client.request('listTabs', (error, response) => { + if (error) { + return reject(new WebExtError( + `Remote Firefox: listTabs() error: ${error}`)); + } + if (!response.addonsActor) { + log.debug( + `listTabs returned a falsey addonsActor: ${response.addonsActor}`); + throw new WebExtError( + 'This is an older version of Firefox that does not provide an ' + + 'add-ons actor for remote control'); + } + this.client.client.makeRequest( + {to: response.addonsActor, type: 'installTemporaryAddon', addonPath}, + (response) => { + if (response.error) { + return reject(new WebExtError( + 'installTemporaryAddon: Error: ' + + `${response.error}: ${response.message}`)); + } + log.debug(`installTemporaryAddon: ${JSON.stringify(response)}`); + log.info(`Installed ${addonPath} as a temporary add-on`); + resolve(); + }); + }); + }); + } + getInstalledAddon(addonId: string): Promise { return new Promise( (resolve, reject) => { diff --git a/src/watcher.js b/src/watcher.js index 80592ae57b..bfe5048564 100644 --- a/src/watcher.js +++ b/src/watcher.js @@ -13,11 +13,12 @@ export default function onSourceChange( // TODO: For network disks, we would need to add {poll: true}. const watcher = new Watchpack(); - const onFileChange = (filePath) => { - proxyFileChanges({artifactsDir, onChange, filePath, shouldWatchFile}); - }; const executeImmediately = true; - watcher.on('change', debounce(onFileChange, 1000, executeImmediately)); + onChange = debounce(onChange, 1000, executeImmediately); + + watcher.on('change', (filePath) => { + proxyFileChanges({artifactsDir, onChange, filePath, shouldWatchFile}); + }); log.debug(`Watching for file changes in ${sourceDir}`); watcher.watch([], [sourceDir], Date.now()); diff --git a/tests/helpers.js b/tests/helpers.js index ca4b0a4020..0e35b52b78 100644 --- a/tests/helpers.js +++ b/tests/helpers.js @@ -3,7 +3,7 @@ import path from 'path'; import sinon from 'sinon'; import yauzl from 'yauzl'; -import {promisify} from '../src/util/es6-modules'; +import {ExtendableError, promisify} from '../src/util/es6-modules'; import {createLogger} from '../src/util/logger'; const log = createLogger(__filename); @@ -176,3 +176,58 @@ export function fake(original: Object, methods: Object = {}): Object { return stub; } + + +/* + * Returns a fake Firefox client as would be returned by + * connect() of 'node-firefox-connect' + */ +export function fakeFirefoxClient( + {requestResult={}, requestError=null, + makeRequestResult={}, makeRequestError=null}: Object = {}) { + return { + disconnect: sinon.spy(() => {}), + request: sinon.spy( + (request, callback) => callback(requestError, requestResult)), + // This is client.client, the actual underlying connection. + client: { + on: () => {}, + makeRequest: sinon.spy((request, callback) => { + // + // The real function returns a response object that you + // use like this: + // if (response.error) { + // ... + // } else { + // response.something; // ... + // } + // + if (makeRequestError) { + let error; + if (typeof makeRequestError === 'object') { + error = makeRequestError; + } else { + error = {error: makeRequestError}; + } + callback(error); + } else { + callback(makeRequestResult); + } + }), + }, + }; +} + + +/* + * A simulated TCP connection error. + * + * By default, the error code will be ECONNREFUSED. + */ +export class TCPConnectError extends ExtendableError { + code: string; + constructor(msg: string = 'simulated connection error') { + super(msg); + this.code = 'ECONNREFUSED'; + } +} diff --git a/tests/test-cmd/test.run.js b/tests/test-cmd/test.run.js index ea8070b0b0..bdf7d204b5 100644 --- a/tests/test-cmd/test.run.js +++ b/tests/test-cmd/test.run.js @@ -6,12 +6,14 @@ import {describe, it} from 'mocha'; import {assert} from 'chai'; import sinon from 'sinon'; -import {ExtendableError} from '../../src/util/es6-modules'; -import run, {defaultWatcherCreator, defaultReloadStrategy, ExtensionRunner} - from '../../src/cmd/run'; +import run, { + defaultFirefoxClient, defaultWatcherCreator, defaultReloadStrategy, + ExtensionRunner, +} from '../../src/cmd/run'; import * as firefox from '../../src/firefox'; import {RemoteFirefox} from '../../src/firefox/remote'; -import {makeSureItFails, fake, fixturePath} from '../helpers'; +import {TCPConnectError, fakeFirefoxClient, makeSureItFails, fake, fixturePath} + from '../helpers'; import {createLogger} from '../../src/util/logger'; import {basicManifest} from '../test-util/test.manifest'; @@ -29,6 +31,11 @@ describe('run', () => { }; let options = { firefox: getFakeFirefox(), + firefoxClient: sinon.spy(() => { + return Promise.resolve(fake(RemoteFirefox.prototype, { + installTemporaryAddon: () => Promise.resolve(), + })); + }), reloadStrategy: sinon.spy(() => { log.debug('fake: reloadStrategy()'); }), @@ -61,16 +68,18 @@ describe('run', () => { const cmd = prepareRun(); const {firefox} = cmd.options; + const firefoxClient = fake(RemoteFirefox.prototype, { + installTemporaryAddon: () => Promise.resolve(), + }); - return cmd.run().then(() => { - let install = cmd.options.firefox.installExtension; + return cmd.run({}, { + firefoxClient: sinon.spy(() => { + return Promise.resolve(firefoxClient); + }), + }).then(() => { + const install = firefoxClient.installTemporaryAddon; 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(install.firstCall.args[0], cmd.argv.sourceDir); assert.equal(firefox.run.called, true); assert.deepEqual(firefox.run.firstCall.args[0], profile); @@ -130,10 +139,13 @@ describe('run', () => { function prepare() { const config = { profile: {}, - client: fake(RemoteFirefox.prototype), + client: fake(RemoteFirefox.prototype, { + reloadAddon: () => Promise.resolve(), + }), sourceDir: '/path/to/extension/source/', artifactsDir: '/path/to/web-ext-artifacts', - createRunner: (cb) => cb(fake(ExtensionRunner.prototype)), + createRunner: + () => Promise.resolve(fake(ExtensionRunner.prototype)), onSourceChange: sinon.spy(() => {}), }; return { @@ -161,24 +173,18 @@ describe('run', () => { assert.equal(createdWatcher, watcher); }); - it('builds, installs, and reloads the extension', () => { + it('reloads the extension', () => { const {config, createWatcher} = prepare(); - const runner = fake(ExtensionRunner.prototype, { - install: sinon.spy(() => Promise.resolve()), - buildExtension: sinon.spy(() => Promise.resolve({})), - }); + const runner = fake(ExtensionRunner.prototype); runner.manifestData = deepcopy(basicManifest); - createWatcher({createRunner: (cb) => cb(runner)}); + createWatcher({createRunner: () => Promise.resolve(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'); @@ -186,19 +192,19 @@ describe('run', () => { }); 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 runner = fake(ExtensionRunner.prototype); + runner.manifestData = deepcopy(basicManifest); + const {createWatcher, config} = prepare(); - createWatcher({createRunner}); + config.client.reloadAddon = () => Promise.reject(new Error('an error')); + createWatcher({createRunner: () => Promise.resolve(runner)}); assert.equal(config.onSourceChange.called, true); - // Simulate an error triggered from the source change handler. + // Simulate executing the handler when a source file changes. return config.onSourceChange.firstCall.args[0].onChange() .then(makeSureItFails()) .catch((error) => { - assert.equal(error.message, 'fake installation error'); + assert.equal(error.message, 'an error'); }); }); @@ -207,24 +213,21 @@ describe('run', () => { describe('defaultReloadStrategy', () => { function prepare() { - const client = { - disconnect: sinon.spy(() => {}), - }; + const client = new RemoteFirefox(fakeFirefoxClient()); const watcher = { close: sinon.spy(() => {}), }; const args = { + client, firefox: new EventEmitter(), profile: {}, sourceDir: '/path/to/extension/source', artifactsDir: '/path/to/web-ext-artifacts/', - createRunner: sinon.spy((cb) => cb(fake(ExtensionRunner.prototype))), + createRunner: sinon.spy( + () => Promise.resolve(fake(ExtensionRunner.prototype))), }; const options = { - connectToFirefox: sinon.spy(() => Promise.resolve(client)), createWatcher: sinon.spy(() => watcher), - maxRetries: 0, - retryInterval: 1, }; return { ...args, @@ -239,78 +242,58 @@ describe('run', () => { }; } - 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); - }); + reloadStrategy(); + firefox.emit('close'); + assert.equal(client.client.disconnect.called, true); + assert.equal(watcher.close.called, true); }); 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); - }); + reloadStrategy(); + assert.equal(createWatcher.called, true); + const receivedArgs = createWatcher.firstCall.args[0]; + assert.equal(receivedArgs.client, sentArgs.client); + assert.equal(receivedArgs.sourceDir, sentArgs.sourceDir); + assert.equal(receivedArgs.artifactsDir, sentArgs.artifactsDir); + assert.equal(receivedArgs.createRunner, sentArgs.createRunner); }); + }); + + describe('firefoxClient', () => { + + function firefoxClient(opt = {}) { + return defaultFirefoxClient({maxRetries: 0, retryInterval: 1, ...opt}); + } + it('retries after a connection error', () => { - const {reloadStrategy} = prepare(); + const client = new RemoteFirefox(fakeFirefoxClient()); var tryCount = 0; const connectToFirefox = sinon.spy(() => new Promise( (resolve, reject) => { tryCount ++; if (tryCount === 1) { - reject(new ConnError('first connection fails')); + reject(new TCPConnectError('first connection fails')); } else { // The second connection succeeds. - resolve(); + resolve(client); } })); - return reloadStrategy({}, {connectToFirefox, maxRetries: 3}) + return firefoxClient({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}) + return firefoxClient({connectToFirefox, maxRetries: 2}) .then(makeSureItFails()) .catch((error) => { assert.equal(connectToFirefox.callCount, 1); @@ -319,11 +302,10 @@ describe('run', () => { }); it('gives up connecting after too many retries', () => { - const {reloadStrategy} = prepare(); const connectToFirefox = sinon.spy( - () => Promise.reject(new ConnError('failure'))); + () => Promise.reject(new TCPConnectError('failure'))); - return reloadStrategy({}, {connectToFirefox, maxRetries: 2}) + return firefoxClient({connectToFirefox, maxRetries: 2}) .then(makeSureItFails()) .catch((error) => { assert.equal(connectToFirefox.callCount, 3); diff --git a/tests/test-firefox/test.firefox.js b/tests/test-firefox/test.firefox.js index 7055761c8a..f786220660 100644 --- a/tests/test-firefox/test.firefox.js +++ b/tests/test-firefox/test.firefox.js @@ -10,9 +10,10 @@ import * as firefox from '../../src/firefox'; import {onlyInstancesOf, WebExtError} from '../../src/errors'; import fs from 'mz/fs'; import {withTempDir} from '../../src/util/temp-dir'; -import {fixturePath, makeSureItFails} from '../helpers'; +import {TCPConnectError, fixturePath, fake, makeSureItFails} from '../helpers'; import {basicManifest} from '../test-util/test.manifest'; import {defaultFirefoxEnv} from '../../src/firefox/'; +import {RemoteFirefox} from '../../src/firefox/remote'; describe('firefox', () => { @@ -45,20 +46,40 @@ describe('firefox', () => { })); } + function runFirefox({profile=fakeProfile, ...args}: Object = {}) { + return firefox.run(profile, { + fxRunner: createFakeFxRunner(), + findRemotePort: () => Promise.resolve(6000), + ...args, + }); + } + it('executes the Firefox runner with a given profile', () => { - let runner = createFakeFxRunner(); - return firefox.run(fakeProfile, {fxRunner: runner}) + const runner = createFakeFxRunner(); + const profile = fakeProfile; + return runFirefox({fxRunner: runner, profile}) .then(() => { assert.equal(runner.called, true); assert.equal(runner.firstCall.args[0].profile, - fakeProfile.path()); + profile.path()); + }); + }); + + it('starts the remote debugger on a discovered port', () => { + const port = 6001; + const runner = createFakeFxRunner(); + const findRemotePort = sinon.spy(() => Promise.resolve(port)); + return runFirefox({fxRunner: runner, findRemotePort}) + .then(() => { + assert.equal(runner.called, true); + assert.equal(runner.firstCall.args[0].listen, port); }); }); it('passes binary args to Firefox', () => { const fxRunner = createFakeFxRunner(); const binaryArgs = '--safe-mode'; - return firefox.run(fakeProfile, {fxRunner, binaryArgs}) + return runFirefox({fxRunner, binaryArgs}) .then(() => { assert.equal(fxRunner.called, true); assert.equal(fxRunner.firstCall.args[0]['binary-args'], @@ -70,7 +91,7 @@ describe('firefox', () => { let runner = createFakeFxRunner(); // Make sure it passes through process environment variables. process.env._WEB_EXT_FIREFOX_ENV_TEST = 'thing'; - return firefox.run(fakeProfile, {fxRunner: runner}) + return runFirefox({fxRunner: runner}) .then(() => { let declaredEnv = runner.firstCall.args[0].env; for (let key in defaultFirefoxEnv) { @@ -91,7 +112,7 @@ describe('firefox', () => { }, }); - return firefox.run(fakeProfile, {fxRunner: runner}) + return runFirefox({fxRunner: runner}) .then(makeSureItFails()) .catch((error) => { assert.equal(error.message, someError.message); @@ -101,7 +122,7 @@ describe('firefox', () => { it('passes a custom Firefox binary when specified', () => { let runner = createFakeFxRunner(); let firefoxBinary = '/pretend/path/to/firefox-bin'; - return firefox.run(fakeProfile, {fxRunner: runner, firefoxBinary}) + return runFirefox({fxRunner: runner, firefoxBinary}) .then(() => { assert.equal(runner.called, true); assert.equal(runner.firstCall.args[0].binary, @@ -125,7 +146,7 @@ describe('firefox', () => { }, }); - return firefox.run(fakeProfile, {fxRunner: runner}) + return runFirefox({fxRunner: runner}) .then(() => { // This makes sure that when each handler writes to the // logger they don't raise any exceptions. @@ -387,4 +408,44 @@ describe('firefox', () => { }); + describe('defaultRemotePortFinder', () => { + + function findRemotePort({...args}: Object = {}) { + return firefox.defaultRemotePortFinder({...args}); + } + + it('resolves to an open port', () => { + const connectToFirefox = sinon.spy( + () => Promise.reject(new TCPConnectError())); + return findRemotePort({connectToFirefox}) + .then((port) => { + assert.isNumber(port); + }); + }); + + it('throws an error when the port is occupied', () => { + // TODO: add a retry for occupied ports. + // https://github.com/mozilla/web-ext/issues/283 + const client = fake(RemoteFirefox.prototype); + const connectToFirefox = sinon.spy(() => Promise.resolve(client)); + return findRemotePort({connectToFirefox}) + .then(makeSureItFails()) + .catch(onlyInstancesOf(WebExtError, (error) => { + assert.match(error.message, /Cannot listen on port/); + assert.equal(client.disconnect.called, true); + })); + }); + + it('re-throws unexpected connection errors', () => { + const connectToFirefox = sinon.spy( + () => Promise.reject(new Error('not a connection error'))); + return findRemotePort({connectToFirefox}) + .then(makeSureItFails()) + .catch((error) => { + assert.match(error.message, /not a connection error/); + }); + }); + + }); + }); diff --git a/tests/test-firefox/test.remote.js b/tests/test-firefox/test.remote.js index 14d88d0b13..e138a5bdf8 100644 --- a/tests/test-firefox/test.remote.js +++ b/tests/test-firefox/test.remote.js @@ -4,7 +4,7 @@ import {assert} from 'chai'; import sinon from 'sinon'; import {WebExtError, onlyInstancesOf} from '../../src/errors'; -import {makeSureItFails} from '../helpers'; +import {fakeFirefoxClient, makeSureItFails} from '../helpers'; import {default as defaultConnector, RemoteFirefox} from '../../src/firefox/remote'; @@ -15,7 +15,8 @@ describe('firefox.remote', () => { function prepareConnection(port=undefined, options={}) { options = { - connectToFirefox: sinon.spy(() => Promise.resolve({})), + connectToFirefox: + sinon.spy(() => Promise.resolve(fakeFirefoxClient())), ...options, }; const connect = defaultConnector(port, options); @@ -31,7 +32,7 @@ describe('firefox.remote', () => { it('connects on the default port', () => { const {connect, options} = prepareConnection(); return connect.then(() => { - assert.equal(options.connectToFirefox.firstCall.args[0], 6000); + assert.equal(options.connectToFirefox.firstCall.args[0], 6005); }); }); @@ -46,46 +47,29 @@ describe('firefox.remote', () => { describe('RemoteFirefox', () => { - function fakeClient( - {requestResult={}, requestError=null, - makeRequestResult={}, makeRequestError=null}: Object = {}) { - return { - disconnect: sinon.spy(() => {}), - request: sinon.spy( - (request, callback) => callback(requestError, requestResult)), - // This is client.client, the actual underlying connection. - client: { - makeRequest: sinon.spy((request, callback) => { - // - // The real function returns a response object that you - // use like this: - // if (response.error) { - // ... - // } else { - // response.something; // ... - // } - // - if (makeRequestError) { - callback({error: makeRequestError}); - } else { - callback(makeRequestResult); - } - }), - }, - }; - } - function fakeAddon() { return {id: 'some-id', actor: 'serv1.localhost'}; } - function makeInstance(client=fakeClient()) { + function makeInstance(client=fakeFirefoxClient()) { return new RemoteFirefox(client); } + it('listens to client events', () => { + const client = fakeFirefoxClient(); + const listener = sinon.spy(() => {}); + client.client.on = listener; + makeInstance(client); // this will register listeners + // Make sure no errors are thrown when the client emits + // events and calls each handler. + listener.firstCall.args[1](); // disconnect + listener.secondCall.args[1](); // end + listener.thirdCall.args[1]({}); // message + }); + describe('disconnect', () => { it('lets you disconnect', () => { - const client = fakeClient(); + const client = fakeFirefoxClient(); const conn = makeInstance(client); conn.disconnect(); assert.equal(client.disconnect.called, true); @@ -97,7 +81,7 @@ describe('firefox.remote', () => { it('makes requests to an add-on actor', () => { const addon = fakeAddon(); const stubResponse = {requestTypes: ['reload']}; - const client = fakeClient({ + const client = fakeFirefoxClient({ makeRequestResult: stubResponse, }); @@ -116,8 +100,8 @@ describe('firefox.remote', () => { it('throws when add-on actor requests fail', () => { const addon = fakeAddon(); - const client = fakeClient({ - makeRequestError: new Error('some actor request failure'), + const client = fakeFirefoxClient({ + makeRequestError: 'some actor request failure', }); const conn = makeInstance(client); @@ -126,7 +110,7 @@ describe('firefox.remote', () => { .catch(onlyInstancesOf(WebExtError, (error) => { assert.equal( error.message, - 'requestTypes response error: Error: some actor request failure'); + 'requestTypes response error: some actor request failure'); })); }); }); @@ -135,7 +119,7 @@ describe('firefox.remote', () => { it('gets an installed add-on by ID', () => { const someAddonId = 'some-id'; - const client = fakeClient({ + const client = fakeFirefoxClient({ requestResult: { addons: [{id: 'another-id'}, {id: someAddonId}, {id: 'bazinga'}], }, @@ -148,7 +132,7 @@ describe('firefox.remote', () => { }); it('throws an error when the add-on is not installed', () => { - const client = fakeClient({ + const client = fakeFirefoxClient({ requestResult: { addons: [{id: 'one-id'}, {id: 'other-id'}], }, @@ -163,7 +147,7 @@ describe('firefox.remote', () => { }); it('throws an error when listAddons() fails', () => { - const client = fakeClient({ + const client = fakeFirefoxClient({ requestError: new Error('some internal error'), }); const conn = makeInstance(client); @@ -226,6 +210,68 @@ describe('firefox.remote', () => { }); }); + describe('installTemporaryAddon', () => { + + it('throws listTabs errors', () => { + const client = fakeFirefoxClient({ + // listTabs response: + requestError: new Error('some listTabs error'), + }); + const conn = makeInstance(client); + return conn.installTemporaryAddon('/path/to/addon') + .then(makeSureItFails()) + .catch(onlyInstancesOf(WebExtError, (error) => { + assert.match(error.message, /some listTabs error/); + })); + }); + + it('fails when there is no add-ons actor', () => { + const client = fakeFirefoxClient({ + // A listTabs response that does not contain addonsActor. + requestResult: {}, + }); + const conn = makeInstance(client); + return conn.installTemporaryAddon('/path/to/addon') + .then(makeSureItFails()) + .catch(onlyInstancesOf(WebExtError, (error) => { + assert.match(error.message, /does not provide an add-ons actor/); + })); + }); + + it('lets you install an add-on temporarily', () => { + const client = fakeFirefoxClient({ + // listTabs response: + requestResult: { + addonsActor: 'addons1.actor.conn', + }, + // installTemporaryAddon response: + makeRequestResult: {}, + }); + const conn = makeInstance(client); + // Make sure this resolves Okay. + return conn.installTemporaryAddon('/path/to/addon'); + }); + + it('throws install errors', () => { + const client = fakeFirefoxClient({ + // listTabs response: + requestResult: { + addonsActor: 'addons1.actor.conn', + }, + // installTemporaryAddon response: + makeRequestError: {error: 'install error', + message: 'error message'}, + }); + const conn = makeInstance(client); + return conn.installTemporaryAddon('/path/to/addon') + .then(makeSureItFails()) + .catch(onlyInstancesOf(WebExtError, (error) => { + assert.match(error.message, /install error: error message/); + })); + }); + + }); + describe('reloadAddon', () => { it('asks the actor to reload the add-on', () => {