diff --git a/experimental/puppeteer-firefox/lib/Launcher.js b/experimental/puppeteer-firefox/lib/Launcher.js index 9b1a1c97..d0fdd696 100644 --- a/experimental/puppeteer-firefox/lib/Launcher.js +++ b/experimental/puppeteer-firefox/lib/Launcher.js @@ -32,6 +32,11 @@ const removeFolderAsync = util.promisify(removeFolder); const FIREFOX_PROFILE_PATH = path.join(os.tmpdir(), 'puppeteer_firefox_profile-'); +const DEFAULT_ARGS = [ + '-no-remote', + '-foreground', +]; + /** * @internal */ @@ -41,15 +46,34 @@ class Launcher { this._preferredRevision = preferredRevision; } + defaultArgs(options = {}) { + const { + headless = true, + args = [], + userDataDir = null, + } = options; + const firefoxArguments = [...DEFAULT_ARGS]; + if (userDataDir) + firefoxArguments.push('-profile', userDataDir); + if (headless) + firefoxArguments.push('-headless'); + firefoxArguments.push(...args); + if (args.every(arg => arg.startsWith('-'))) + firefoxArguments.push('about:blank'); + return firefoxArguments; + } + /** * @param {Object} options * @return {!Promise} */ async launch(options = {}) { const { + ignoreDefaultArgs = false, args = [], dumpio = false, - executablePath = this.executablePath(), + executablePath = null, + env = process.env, handleSIGHUP = true, handleSIGINT = true, handleSIGTERM = true, @@ -57,25 +81,36 @@ class Launcher { headless = true, defaultViewport = {width: 800, height: 600}, slowMo = 0, + timeout = 30000, } = options; - const firefoxArguments = args.slice(); - firefoxArguments.push('-no-remote'); - firefoxArguments.push('-juggler', '0'); - firefoxArguments.push('-foreground'); - if (headless) - firefoxArguments.push('-headless'); + const firefoxArguments = []; + if (!ignoreDefaultArgs) + firefoxArguments.push(...this.defaultArgs(options)); + else if (Array.isArray(ignoreDefaultArgs)) + firefoxArguments.push(...this.defaultArgs(options).filter(arg => !ignoreDefaultArgs.includes(arg))); + else + firefoxArguments.push(...args); + + if (!firefoxArguments.includes('-juggler')) + firefoxArguments.push('-juggler', '0'); + let temporaryProfileDir = null; - if (!firefoxArguments.some(arg => arg.startsWith('-profile') || arg.startsWith('--profile'))) { + if (!firefoxArguments.includes('-profile') && !firefoxArguments.includes('--profile')) { temporaryProfileDir = await mkdtempAsync(FIREFOX_PROFILE_PATH); firefoxArguments.push(`-profile`, temporaryProfileDir); } - if (firefoxArguments.every(arg => arg.startsWith('--') || arg.startsWith('-'))) - firefoxArguments.push('about:blank'); + let firefoxExecutable = executablePath; + if (!firefoxExecutable) { + const {missingText, executablePath} = this._resolveExecutablePath(); + if (missingText) + throw new Error(missingText); + firefoxExecutable = executablePath; + } const stdio = ['pipe', 'pipe', 'pipe']; const firefoxProcess = childProcess.spawn( - executablePath, + firefoxExecutable, firefoxArguments, { // On non-windows platforms, `detached: false` makes child process a leader of a new @@ -85,9 +120,9 @@ class Launcher { stdio, // On linux Juggler ships the libstdc++ it was linked against. env: os.platform() === 'linux' ? { - ...process.env, - LD_LIBRARY_PATH: `${path.dirname(executablePath)}:${process.env.LD_LIBRARY_PATH}`, - } : process.env, + ...env, + LD_LIBRARY_PATH: `${path.dirname(firefoxExecutable)}:${process.env.LD_LIBRARY_PATH}`, + } : env, } ); @@ -115,16 +150,16 @@ class Launcher { if (handleSIGINT) listeners.push(helper.addEventListener(process, 'SIGINT', () => { killFirefox(); process.exit(130); })); if (handleSIGTERM) - listeners.push(helper.addEventListener(process, 'SIGTERM', killFirefox)); + listeners.push(helper.addEventListener(process, 'SIGTERM', gracefullyCloseFirefox)); if (handleSIGHUP) - listeners.push(helper.addEventListener(process, 'SIGHUP', killFirefox)); + listeners.push(helper.addEventListener(process, 'SIGHUP', gracefullyCloseFirefox)); /** @type {?Connection} */ let connection = null; try { - const url = await waitForWSEndpoint(firefoxProcess, 30000); + const url = await waitForWSEndpoint(firefoxProcess, timeout); const transport = await WebSocketTransport.create(url); connection = new Connection(url, transport, slowMo); - const browser = await Browser.create(connection, defaultViewport, firefoxProcess, killFirefox); + const browser = await Browser.create(connection, defaultViewport, firefoxProcess, gracefullyCloseFirefox); if (ignoreHTTPSErrors) await connection.send('Browser.setIgnoreHTTPSErrors', {enabled: true}); await browser.waitForTarget(t => t.type() === 'page'); @@ -134,6 +169,19 @@ class Launcher { throw e; } + function gracefullyCloseFirefox() { + helper.removeEventListeners(listeners); + if (temporaryProfileDir) { + killFirefox(); + } else if (connection) { + connection.send('Browser.close').catch(error => { + debugError(error); + killFirefox(); + }); + } + return waitForFirefoxToClose; + } + // This method has to be sync to be used as 'exit' event handler. function killFirefox() { helper.removeEventListeners(listeners); @@ -179,9 +227,14 @@ class Launcher { * @return {string} */ executablePath() { + return this._resolveExecutablePath().executablePath; + } + + _resolveExecutablePath() { const browserFetcher = new BrowserFetcher(this._projectRoot, { product: 'firefox' }); const revisionInfo = browserFetcher.revisionInfo(this._preferredRevision); - return revisionInfo.executablePath; + const missingText = !revisionInfo.local ? `Firefox revision is not downloaded. Run "npm install" or "yarn install"` : null; + return {executablePath: revisionInfo.executablePath, missingText}; } } diff --git a/experimental/puppeteer-firefox/lib/NavigationWatchdog.js b/experimental/puppeteer-firefox/lib/NavigationWatchdog.js index 8eb8b20b..2d5a8837 100644 --- a/experimental/puppeteer-firefox/lib/NavigationWatchdog.js +++ b/experimental/puppeteer-firefox/lib/NavigationWatchdog.js @@ -62,6 +62,7 @@ class NavigationWatchdog { const check = this._checkNavigationComplete.bind(this); this._eventListeners = [ + helper.addEventListener(session, Events.JugglerSession.Disconnected, () => this._resolveCallback(new Error('Navigation failed because browser has disconnected!'))), helper.addEventListener(session, 'Page.eventFired', check), helper.addEventListener(session, 'Page.frameAttached', check), helper.addEventListener(session, 'Page.frameDetached', check), diff --git a/experimental/puppeteer-firefox/lib/Puppeteer.js b/experimental/puppeteer-firefox/lib/Puppeteer.js index 031aac34..e1bceaac 100644 --- a/experimental/puppeteer-firefox/lib/Puppeteer.js +++ b/experimental/puppeteer-firefox/lib/Puppeteer.js @@ -26,6 +26,10 @@ class Puppeteer { executablePath() { return this._launcher.executablePath(); } + + defaultArgs(options) { + return this._launcher.defaultArgs(options); + } } module.exports = {Puppeteer}; diff --git a/experimental/puppeteer-firefox/misc/puppeteer.cfg b/experimental/puppeteer-firefox/misc/puppeteer.cfg index 2d886e40..24527c68 100644 --- a/experimental/puppeteer-firefox/misc/puppeteer.cfg +++ b/experimental/puppeteer-firefox/misc/puppeteer.cfg @@ -57,8 +57,8 @@ pref("browser.startup.homepage_override.mstone", "ignore"); // Disable browser animations (tabs, fullscreen, sliding alerts) pref("toolkit.cosmeticAnimations.enabled", false); -// Do not close the window when the last tab gets closed -pref("browser.tabs.closeWindowWithLastTab", false); +// Close the window when the last tab gets closed +pref("browser.tabs.closeWindowWithLastTab", true); // Do not allow background tabs to be zombified on Android, otherwise for // tests that open additional tabs, the test harness tab itself might get diff --git a/lib/Launcher.js b/lib/Launcher.js index 4b731a7c..de612c79 100644 --- a/lib/Launcher.js +++ b/lib/Launcher.js @@ -174,32 +174,13 @@ class Launcher { connection = new Connection('', transport, slowMo); } const browser = await Browser.create(connection, [], ignoreHTTPSErrors, defaultViewport, chromeProcess, gracefullyCloseChrome); - await ensureInitialPage(browser); + await browser.waitForTarget(t => t.type() === 'page'); return browser; } catch (e) { killChrome(); throw e; } - /** - * @param {!Browser} browser - */ - async function ensureInitialPage(browser) { - // Wait for initial page target to be created. - if (browser.targets().find(target => target.type() === 'page')) - return; - - let initialPageCallback; - const initialPagePromise = new Promise(resolve => initialPageCallback = resolve); - const listeners = [helper.addEventListener(browser, 'targetcreated', target => { - if (target.type() === 'page') - initialPageCallback(); - })]; - - await initialPagePromise; - helper.removeEventListeners(listeners); - } - /** * @return {Promise} */ diff --git a/test/launcher.spec.js b/test/launcher.spec.js index 1342b978..0f312840 100644 --- a/test/launcher.spec.js +++ b/test/launcher.spec.js @@ -24,7 +24,7 @@ const statAsync = helper.promisify(fs.stat); const TMP_FOLDER = path.join(os.tmpdir(), 'pptr_tmp_folder-'); const utils = require('./utils'); -module.exports.addTests = function({testRunner, expect, defaultBrowserOptions, puppeteer}) { +module.exports.addTests = function({testRunner, expect, defaultBrowserOptions, puppeteer, CHROME}) { const {describe, xdescribe, fdescribe, describe_fails_ffox} = testRunner; const {it, fit, xit, it_fails_ffox} = testRunner; const {beforeAll, beforeEach, afterAll, afterEach} = testRunner; @@ -59,7 +59,7 @@ module.exports.addTests = function({testRunner, expect, defaultBrowserOptions, p await rmAsync(downloadsFolder); }); }); - describe_fails_ffox('Browser.disconnect', function() { + describe('Browser.disconnect', function() { it('should reject navigation when browser closes', async({server}) => { server.setRoute('/one-style.css', () => {}); const browser = await puppeteer.launch(defaultBrowserOptions); @@ -80,7 +80,7 @@ module.exports.addTests = function({testRunner, expect, defaultBrowserOptions, p const watchdog = page.waitForSelector('div', {timeout: 60000}).catch(e => e); await remote.disconnect(); const error = await watchdog; - expect(error.message).toBe('Protocol error (Runtime.callFunctionOn): Session closed. Most likely the page has been closed.'); + expect(error.message).toContain('Protocol error'); await browser.close(); }); }); @@ -94,13 +94,13 @@ module.exports.addTests = function({testRunner, expect, defaultBrowserOptions, p await neverResolves; expect(error.message).toContain('Protocol error'); }); - it_fails_ffox('should reject if executable path is invalid', async({server}) => { + it('should reject if executable path is invalid', async({server}) => { let waitError = null; const options = Object.assign({}, defaultBrowserOptions, {executablePath: 'random-invalid-path'}); await puppeteer.launch(options).catch(e => waitError = e); - expect(waitError.message.startsWith('Failed to launch chrome! spawn random-invalid-path ENOENT')).toBe(true); + expect(waitError.message).toContain('Failed to launch'); }); - it_fails_ffox('userDataDir option', async({server}) => { + it('userDataDir option', async({server}) => { const userDataDir = await mkdtempAsync(TMP_FOLDER); const options = Object.assign({userDataDir}, defaultBrowserOptions); const browser = await puppeteer.launch(options); @@ -112,10 +112,21 @@ module.exports.addTests = function({testRunner, expect, defaultBrowserOptions, p // This might throw. See https://github.com/GoogleChrome/puppeteer/issues/2778 await rmAsync(userDataDir).catch(e => {}); }); - it_fails_ffox('userDataDir argument', async({server}) => { + it('userDataDir argument', async({server}) => { const userDataDir = await mkdtempAsync(TMP_FOLDER); const options = Object.assign({}, defaultBrowserOptions); - options.args = [`--user-data-dir=${userDataDir}`].concat(options.args || []); + if (CHROME) { + options.args = [ + ...(defaultBrowserOptions.args || []), + `--user-data-dir=${userDataDir}` + ]; + } else { + options.args = [ + ...(defaultBrowserOptions.args || []), + `-profile`, + userDataDir, + ]; + } const browser = await puppeteer.launch(options); expect(fs.readdirSync(userDataDir).length).toBeGreaterThan(0); await browser.close(); @@ -123,7 +134,7 @@ module.exports.addTests = function({testRunner, expect, defaultBrowserOptions, p // This might throw. See https://github.com/GoogleChrome/puppeteer/issues/2778 await rmAsync(userDataDir).catch(e => {}); }); - it_fails_ffox('userDataDir option should restore state', async({server}) => { + it('userDataDir option should restore state', async({server}) => { const userDataDir = await mkdtempAsync(TMP_FOLDER); const options = Object.assign({userDataDir}, defaultBrowserOptions); const browser = await puppeteer.launch(options); @@ -140,7 +151,7 @@ module.exports.addTests = function({testRunner, expect, defaultBrowserOptions, p // This might throw. See https://github.com/GoogleChrome/puppeteer/issues/2778 await rmAsync(userDataDir).catch(e => {}); }); - it_fails_ffox('userDataDir option should restore cookies', async({server}) => { + it('userDataDir option should restore cookies', async({server}) => { const userDataDir = await mkdtempAsync(TMP_FOLDER); const options = Object.assign({userDataDir}, defaultBrowserOptions); const browser = await puppeteer.launch(options); @@ -157,11 +168,18 @@ module.exports.addTests = function({testRunner, expect, defaultBrowserOptions, p // This might throw. See https://github.com/GoogleChrome/puppeteer/issues/2778 await rmAsync(userDataDir).catch(e => {}); }); - it_fails_ffox('should return the default chrome arguments', async() => { - expect(puppeteer.defaultArgs()).toContain('--no-first-run'); - expect(puppeteer.defaultArgs()).toContain('--headless'); - expect(puppeteer.defaultArgs({headless: false})).not.toContain('--headless'); - expect(puppeteer.defaultArgs({userDataDir: 'foo'})).toContain('--user-data-dir=foo'); + it('should return the default arguments', async() => { + if (CHROME) { + expect(puppeteer.defaultArgs()).toContain('--no-first-run'); + expect(puppeteer.defaultArgs()).toContain('--headless'); + expect(puppeteer.defaultArgs({headless: false})).not.toContain('--headless'); + expect(puppeteer.defaultArgs({userDataDir: 'foo'})).toContain('--user-data-dir=foo'); + } else { + expect(puppeteer.defaultArgs()).toContain('-headless'); + expect(puppeteer.defaultArgs({headless: false})).not.toContain('-headless'); + expect(puppeteer.defaultArgs({userDataDir: 'foo'})).toContain('-profile'); + expect(puppeteer.defaultArgs({userDataDir: 'foo'})).toContain('foo'); + } }); it('should dump browser process stderr', async({server}) => { const dumpioTextToLog = 'MAGIC_DUMPIO_TEST'; @@ -196,34 +214,6 @@ module.exports.addTests = function({testRunner, expect, defaultBrowserOptions, p process.kill(res.pid); await Promise.all(promises); }); - it_fails_ffox('should support the pipe option', async() => { - const options = Object.assign({pipe: true}, defaultBrowserOptions); - const browser = await puppeteer.launch(options); - expect((await browser.pages()).length).toBe(1); - expect(browser.wsEndpoint()).toBe(''); - const page = await browser.newPage(); - expect(await page.evaluate('11 * 11')).toBe(121); - await page.close(); - await browser.close(); - }); - it_fails_ffox('should support the pipe argument', async() => { - const options = Object.assign({}, defaultBrowserOptions); - options.args = ['--remote-debugging-pipe'].concat(options.args || []); - const browser = await puppeteer.launch(options); - expect(browser.wsEndpoint()).toBe(''); - const page = await browser.newPage(); - expect(await page.evaluate('11 * 11')).toBe(121); - await page.close(); - await browser.close(); - }); - it_fails_ffox('should fire "disconnected" when closing with pipe', async() => { - const options = Object.assign({pipe: true}, defaultBrowserOptions); - const browser = await puppeteer.launch(options); - const disconnectedEventPromise = new Promise(resolve => browser.once('disconnected', resolve)); - // Emulate user exiting browser. - browser.process().kill(); - await disconnectedEventPromise; - }); it('should work with no default arguments', async() => { const options = Object.assign({}, defaultBrowserOptions); options.ignoreDefaultArgs = true; @@ -233,7 +223,7 @@ module.exports.addTests = function({testRunner, expect, defaultBrowserOptions, p await page.close(); await browser.close(); }); - it_fails_ffox('should filter out ignored default arguments', async() => { + it('should filter out ignored default arguments', async() => { // Make sure we launch with `--enable-automation` by default. const defaultArgs = puppeteer.defaultArgs(); const browser = await puppeteer.launch(Object.assign({}, defaultBrowserOptions, { @@ -253,15 +243,14 @@ module.exports.addTests = function({testRunner, expect, defaultBrowserOptions, p await browser.close(); }); it_fails_ffox('should have custom url when launching browser', async function({server}) { - const customUrl = server.PREFIX + '/empty.html'; const options = Object.assign({}, defaultBrowserOptions); - options.args = [customUrl].concat(options.args || []); + options.args = [server.EMPTY_PAGE].concat(options.args || []); const browser = await puppeteer.launch(options); const pages = await browser.pages(); expect(pages.length).toBe(1); - if (pages[0].url() !== customUrl) + if (pages[0].url() !== server.EMPTY_PAGE) await pages[0].waitForNavigation(); - expect(pages[0].url()).toBe(customUrl); + expect(pages[0].url()).toBe(server.EMPTY_PAGE); await browser.close(); }); it('should set the default viewport', async() => { @@ -300,6 +289,85 @@ module.exports.addTests = function({testRunner, expect, defaultBrowserOptions, p await browser.close(); }); }); + // "browserURL" is a chrome-only option. + (CHROME ? describe : xdescribe)('|browserURL| option', function() { + it('should be able to connect using browserUrl, with and without trailing slash', async({server}) => { + const originalBrowser = await puppeteer.launch(Object.assign({}, defaultBrowserOptions, { + args: ['--remote-debugging-port=21222'] + })); + const browserURL = 'http://127.0.0.1:21222'; + + const browser1 = await puppeteer.connect({browserURL}); + const page1 = await browser1.newPage(); + expect(await page1.evaluate(() => 7 * 8)).toBe(56); + browser1.disconnect(); + + const browser2 = await puppeteer.connect({browserURL: browserURL + '/'}); + const page2 = await browser2.newPage(); + expect(await page2.evaluate(() => 8 * 7)).toBe(56); + browser2.disconnect(); + originalBrowser.close(); + }); + it('should throw when using both browserWSEndpoint and browserURL', async({server}) => { + const originalBrowser = await puppeteer.launch(Object.assign({}, defaultBrowserOptions, { + args: ['--remote-debugging-port=21222'] + })); + const browserURL = 'http://127.0.0.1:21222'; + + let error = null; + await puppeteer.connect({browserURL, browserWSEndpoint: originalBrowser.wsEndpoint()}).catch(e => error = e); + expect(error.message).toContain('Exactly one of browserWSEndpoint, browserURL or transport'); + + originalBrowser.close(); + }); + it('should throw when trying to connect to non-existing browser', async({server}) => { + const originalBrowser = await puppeteer.launch(Object.assign({}, defaultBrowserOptions, { + args: ['--remote-debugging-port=21222'] + })); + const browserURL = 'http://127.0.0.1:32333'; + + let error = null; + await puppeteer.connect({browserURL}).catch(e => error = e); + if (CHROME) + expect(error.message).toContain('Failed to fetch browser webSocket url from'); + else + expect(error.message).toContain('Invalid URL'); + + originalBrowser.close(); + }); + }); + + // "pipe" is a chrome-only feature. + (CHROME ? describe : xdescribe)('|pipe| option', function() { + it('should support the pipe option', async() => { + const options = Object.assign({pipe: true}, defaultBrowserOptions); + const browser = await puppeteer.launch(options); + expect((await browser.pages()).length).toBe(1); + expect(browser.wsEndpoint()).toBe(''); + const page = await browser.newPage(); + expect(await page.evaluate('11 * 11')).toBe(121); + await page.close(); + await browser.close(); + }); + it('should support the pipe argument', async() => { + const options = Object.assign({}, defaultBrowserOptions); + options.args = ['--remote-debugging-pipe'].concat(options.args || []); + const browser = await puppeteer.launch(options); + expect(browser.wsEndpoint()).toBe(''); + const page = await browser.newPage(); + expect(await page.evaluate('11 * 11')).toBe(121); + await page.close(); + await browser.close(); + }); + it('should fire "disconnected" when closing with pipe', async() => { + const options = Object.assign({pipe: true}, defaultBrowserOptions); + const browser = await puppeteer.launch(options); + const disconnectedEventPromise = new Promise(resolve => browser.once('disconnected', resolve)); + // Emulate user exiting browser. + browser.process().kill(); + await disconnectedEventPromise; + }); + }); describe('Puppeteer.connect', function() { it('should be able to connect multiple times to the same browser', async({server}) => { const originalBrowser = await puppeteer.launch(defaultBrowserOptions); @@ -359,47 +427,6 @@ module.exports.addTests = function({testRunner, expect, defaultBrowserOptions, p expect(await restoredPage.evaluate(() => 7 * 8)).toBe(56); await browser.close(); }); - it_fails_ffox('should be able to connect using browserUrl, with and without trailing slash', async({server}) => { - const originalBrowser = await puppeteer.launch(Object.assign({}, defaultBrowserOptions, { - args: ['--remote-debugging-port=21222'] - })); - const browserURL = 'http://127.0.0.1:21222'; - - const browser1 = await puppeteer.connect({browserURL}); - const page1 = await browser1.newPage(); - expect(await page1.evaluate(() => 7 * 8)).toBe(56); - browser1.disconnect(); - - const browser2 = await puppeteer.connect({browserURL: browserURL + '/'}); - const page2 = await browser2.newPage(); - expect(await page2.evaluate(() => 8 * 7)).toBe(56); - browser2.disconnect(); - originalBrowser.close(); - }); - it_fails_ffox('should throw when using both browserWSEndpoint and browserURL', async({server}) => { - const originalBrowser = await puppeteer.launch(Object.assign({}, defaultBrowserOptions, { - args: ['--remote-debugging-port=21222'] - })); - const browserURL = 'http://127.0.0.1:21222'; - - let error = null; - await puppeteer.connect({browserURL, browserWSEndpoint: originalBrowser.wsEndpoint()}).catch(e => error = e); - expect(error.message).toContain('Exactly one of browserWSEndpoint, browserURL or transport'); - - originalBrowser.close(); - }); - it_fails_ffox('should throw when trying to connect to non-existing browser', async({server}) => { - const originalBrowser = await puppeteer.launch(Object.assign({}, defaultBrowserOptions, { - args: ['--remote-debugging-port=21222'] - })); - const browserURL = 'http://127.0.0.1:32333'; - - let error = null; - await puppeteer.connect({browserURL}).catch(e => error = e); - expect(error.message).toContain('Failed to fetch browser webSocket url from'); - - originalBrowser.close(); - }); }); describe('Puppeteer.executablePath', function() { it('should work', async({server}) => {