diff --git a/src/node/BrowserRunner.ts b/src/node/BrowserRunner.ts index 98e9a502933dc..4c4e3c2597c92 100644 --- a/src/node/BrowserRunner.ts +++ b/src/node/BrowserRunner.ts @@ -16,8 +16,13 @@ import { debug } from '../common/Debug.js'; -import removeFolder from 'rimraf'; import * as childProcess from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as readline from 'readline'; +import removeFolder from 'rimraf'; +import { promisify } from 'util'; + import { assert } from '../common/assert.js'; import { helper, debugError } from '../common/helper.js'; import { LaunchOptions } from './LaunchOptions.js'; @@ -25,12 +30,14 @@ import { Connection } from '../common/Connection.js'; import { NodeWebSocketTransport as WebSocketTransport } from '../node/NodeWebSocketTransport.js'; import { PipeTransport } from './PipeTransport.js'; import { Product } from '../common/Product.js'; -import * as readline from 'readline'; import { TimeoutError } from '../common/Errors.js'; -import { promisify } from 'util'; const removeFolderAsync = promisify(removeFolder); +const renameAsync = promisify(fs.rename); +const unlinkAsync = promisify(fs.unlink); + const debugLauncher = debug('puppeteer:launcher'); + const PROCESS_ERROR_EXPLANATION = `Puppeteer was unable to kill the process which ran the browser binary. This means that, on future Puppeteer launches, Puppeteer might not be able to launch the browser. Please check your open processes and ensure that the browser processes that Puppeteer launched have been killed. @@ -40,7 +47,8 @@ export class BrowserRunner { private _product: Product; private _executablePath: string; private _processArguments: string[]; - private _tempDirectory?: string; + private _userDataDir: string; + private _isTempUserDataDir?: boolean; proc = null; connection = null; @@ -53,12 +61,14 @@ export class BrowserRunner { product: Product, executablePath: string, processArguments: string[], - tempDirectory?: string + userDataDir: string, + isTempUserDataDir?: boolean ) { this._product = product; this._executablePath = executablePath; this._processArguments = processArguments; - this._tempDirectory = tempDirectory; + this._userDataDir = userDataDir; + this._isTempUserDataDir = isTempUserDataDir; } start(options: LaunchOptions): void { @@ -95,17 +105,39 @@ export class BrowserRunner { } this._closed = false; this._processClosing = new Promise((fulfill, reject) => { - this.proc.once('exit', () => { + this.proc.once('exit', async () => { this._closed = true; // Cleanup as processes exit. - if (this._tempDirectory) { - removeFolderAsync(this._tempDirectory) - .then(() => fulfill()) - .catch((error) => { + if (this._isTempUserDataDir) { + try { + await removeFolderAsync(this._userDataDir); + fulfill(); + } catch (error) { + console.error(error); + reject(error); + } + } else { + if (this._product === 'firefox') { + try { + // When an existing user profile has been used remove the user + // preferences file and restore possibly backuped preferences. + await unlinkAsync(path.join(this._userDataDir, 'user.js')); + + const prefsBackupPath = path.join( + this._userDataDir, + 'prefs.js.puppeteer' + ); + if (fs.existsSync(prefsBackupPath)) { + const prefsPath = path.join(this._userDataDir, 'prefs.js'); + await unlinkAsync(prefsPath); + await renameAsync(prefsBackupPath, prefsPath); + } + } catch (error) { console.error(error); reject(error); - }); - } else { + } + } + fulfill(); } }); @@ -132,7 +164,7 @@ export class BrowserRunner { close(): Promise { if (this._closed) return Promise.resolve(); - if (this._tempDirectory && this._product !== 'firefox') { + if (this._isTempUserDataDir && this._product !== 'firefox') { this.kill(); } else if (this.connection) { // Attempt to close the browser gracefully @@ -150,7 +182,9 @@ export class BrowserRunner { kill(): void { // Attempt to remove temporary profile directory to avoid littering. try { - removeFolder.sync(this._tempDirectory); + if (this._isTempUserDataDir) { + removeFolder.sync(this._userDataDir); + } } catch (error) {} // If the process failed to launch (for example if the browser executable path diff --git a/src/node/Launcher.ts b/src/node/Launcher.ts index dd2a0c9abd5b7..9a79350d6a9cd 100644 --- a/src/node/Launcher.ts +++ b/src/node/Launcher.ts @@ -23,6 +23,7 @@ import { Browser } from '../common/Browser.js'; import { BrowserRunner } from './BrowserRunner.js'; import { promisify } from 'util'; +const copyFileAsync = promisify(fs.copyFile); const mkdtempAsync = promisify(fs.mkdtemp); const writeFileAsync = promisify(fs.writeFile); @@ -85,7 +86,6 @@ class ChromeLauncher implements ProductLauncher { debuggingPort = null, } = options; - const profilePath = path.join(tmpDir(), 'puppeteer_dev_chrome_profile-'); const chromeArguments = []; if (!ignoreDefaultArgs) chromeArguments.push(...this.defaultArgs(options)); else if (Array.isArray(ignoreDefaultArgs)) @@ -96,8 +96,6 @@ class ChromeLauncher implements ProductLauncher { ); else chromeArguments.push(...args); - let temporaryUserDataDir = null; - if ( !chromeArguments.some((argument) => argument.startsWith('--remote-debugging-') @@ -113,9 +111,28 @@ class ChromeLauncher implements ProductLauncher { chromeArguments.push(`--remote-debugging-port=${debuggingPort || 0}`); } } - if (!chromeArguments.some((arg) => arg.startsWith('--user-data-dir'))) { - temporaryUserDataDir = await mkdtempAsync(profilePath); - chromeArguments.push(`--user-data-dir=${temporaryUserDataDir}`); + + let userDataDir; + let isTempUserDataDir = true; + + // Check for the user data dir argument, which will always be set even + // with a custom directory specified via the userDataDir option. + const userDataDirIndex = chromeArguments.findIndex((arg) => { + return arg.startsWith('--user-data-dir'); + }); + + if (userDataDirIndex !== -1) { + userDataDir = chromeArguments[userDataDirIndex].split('=')[1]; + if (!fs.existsSync(userDataDir)) { + throw new Error(`Chrome user data dir not found at '${userDataDir}'`); + } + + isTempUserDataDir = false; + } else { + userDataDir = await mkdtempAsync( + path.join(tmpDir(), 'puppeteer_dev_chrome_profile-') + ); + chromeArguments.push(`--user-data-dir=${userDataDir}`); } let chromeExecutable = executablePath; @@ -145,7 +162,8 @@ class ChromeLauncher implements ProductLauncher { this.product, chromeExecutable, chromeArguments, - temporaryUserDataDir + userDataDir, + isTempUserDataDir ); runner.start({ handleSIGHUP, @@ -303,15 +321,30 @@ class FirefoxLauncher implements ProductLauncher { firefoxArguments.push(`--remote-debugging-port=${debuggingPort || 0}`); } - let temporaryUserDataDir = null; + let userDataDir = null; + let isTempUserDataDir = true; - if ( - !firefoxArguments.includes('-profile') && - !firefoxArguments.includes('--profile') - ) { - temporaryUserDataDir = await this._createProfile(extraPrefsFirefox); + // Check for the profile argument, which will always be set even + // with a custom directory specified via the userDataDir option. + const profileArgIndex = firefoxArguments.findIndex((arg) => { + return ['-profile', '--profile'].includes(arg); + }); + + if (profileArgIndex !== -1) { + userDataDir = firefoxArguments[profileArgIndex + 1]; + if (!fs.existsSync(userDataDir)) { + throw new Error(`Firefox profile not found at '${userDataDir}'`); + } + + // When using a custom Firefox profile it needs to be populated + // with required preferences. + isTempUserDataDir = false; + const prefs = this.defaultPreferences(extraPrefsFirefox); + this.writePreferences(prefs, userDataDir); + } else { + userDataDir = await this._createProfile(extraPrefsFirefox); firefoxArguments.push('--profile'); - firefoxArguments.push(temporaryUserDataDir); + firefoxArguments.push(userDataDir); } await this._updateRevision(); @@ -326,7 +359,8 @@ class FirefoxLauncher implements ProductLauncher { this.product, firefoxExecutable, firefoxArguments, - temporaryUserDataDir + userDataDir, + isTempUserDataDir ); runner.start({ handleSIGHUP, @@ -381,16 +415,19 @@ class FirefoxLauncher implements ProductLauncher { } defaultArgs(options: BrowserLaunchArgumentOptions = {}): string[] { - const firefoxArguments = ['--no-remote', '--foreground']; - if (os.platform().startsWith('win')) { - firefoxArguments.push('--wait-for-browser'); - } const { devtools = false, headless = !devtools, args = [], userDataDir = null, } = options; + + const firefoxArguments = ['--no-remote']; + + if (os.platform() === 'darwin') firefoxArguments.push('--foreground'); + else if (os.platform().startsWith('win')) { + firefoxArguments.push('--wait-for-browser'); + } if (userDataDir) { firefoxArguments.push('--profile'); firefoxArguments.push(userDataDir); @@ -403,14 +440,12 @@ class FirefoxLauncher implements ProductLauncher { return firefoxArguments; } - async _createProfile(extraPrefs: { [x: string]: unknown }): Promise { - const profilePath = await mkdtempAsync( - path.join(tmpDir(), 'puppeteer_dev_firefox_profile-') - ); - const prefsJS = []; - const userJS = []; + defaultPreferences(extraPrefs: { [x: string]: unknown }): { + [x: string]: unknown; + } { const server = 'dummy.test'; - const defaultPreferences = { + + const defaultPrefs = { // Make sure Shield doesn't hit the network. 'app.normandy.api_url': '', // Disable Firefox old build background check @@ -612,17 +647,46 @@ class FirefoxLauncher implements ProductLauncher { 'toolkit.startup.max_resumed_crashes': -1, }; - Object.assign(defaultPreferences, extraPrefs); - for (const [key, value] of Object.entries(defaultPreferences)) - userJS.push( - `user_pref(${JSON.stringify(key)}, ${JSON.stringify(value)});` - ); - await writeFileAsync(path.join(profilePath, 'user.js'), userJS.join('\n')); - await writeFileAsync( - path.join(profilePath, 'prefs.js'), - prefsJS.join('\n') + return Object.assign(defaultPrefs, extraPrefs); + } + + /** + * Populates the user.js file with custom preferences as needed to allow + * Firefox's CDP support to properly function. These preferences will be + * automatically copied over to prefs.js during startup of Firefox. To be + * able to restore the original values of preferences a backup of prefs.js + * will be created. + * + * @param prefs List of preferences to add. + * @param profilePath Firefox profile to write the preferences to. + */ + async writePreferences( + prefs: { [x: string]: unknown }, + profilePath: string + ): Promise { + const lines = Object.entries(prefs).map(([key, value]) => { + return `user_pref(${JSON.stringify(key)}, ${JSON.stringify(value)});`; + }); + + await writeFileAsync(path.join(profilePath, 'user.js'), lines.join('\n')); + + // Create a backup of the preferences file if it already exitsts. + const prefsPath = path.join(profilePath, 'prefs.js'); + if (fs.existsSync(prefsPath)) { + const prefsBackupPath = path.join(profilePath, 'prefs.js.puppeteer'); + await copyFileAsync(prefsPath, prefsBackupPath); + } + } + + async _createProfile(extraPrefs: { [x: string]: unknown }): Promise { + const temporaryProfilePath = await mkdtempAsync( + path.join(tmpDir(), 'puppeteer_dev_firefox_profile-') ); - return profilePath; + + const prefs = this.defaultPreferences(extraPrefs); + await this.writePreferences(prefs, temporaryProfilePath); + + return temporaryProfilePath; } } diff --git a/test/launcher.spec.ts b/test/launcher.spec.ts index 614161c99dc77..1570870337ddd 100644 --- a/test/launcher.spec.ts +++ b/test/launcher.spec.ts @@ -23,6 +23,7 @@ import { getTestState, itChromeOnly, itFailsFirefox, + itFirefoxOnly, itOnlyRegularInstall, } from './mocha-utils'; // eslint-disable-line import/extensions import utils from './utils.js'; @@ -30,10 +31,12 @@ import expect from 'expect'; import rimraf from 'rimraf'; import { Page } from '../lib/cjs/puppeteer/common/Page.js'; -const rmAsync = promisify(rimraf); const mkdtempAsync = promisify(fs.mkdtemp); const readFileAsync = promisify(fs.readFile); +const rmAsync = promisify(rimraf); const statAsync = promisify(fs.stat); +const writeFileAsync = promisify(fs.writeFile); + const TMP_FOLDER = path.join(os.tmpdir(), 'pptr_tmp_folder-'); const FIREFOX_TIMEOUT = 30 * 1000; @@ -227,6 +230,28 @@ describe('Launcher specs', function () { // This might throw. See https://github.com/puppeteer/puppeteer/issues/2778 await rmAsync(userDataDir).catch(() => {}); }); + itFirefoxOnly('userDataDir option restores preferences', async () => { + const { defaultBrowserOptions, puppeteer } = getTestState(); + + const userDataDir = await mkdtempAsync(TMP_FOLDER); + + const prefsJSPath = path.join(userDataDir, 'prefs.js'); + const prefsJSContent = 'user_pref("browser.warnOnQuit", true)'; + await writeFileAsync(prefsJSPath, prefsJSContent); + + const options = Object.assign({ userDataDir }, defaultBrowserOptions); + const browser = await puppeteer.launch(options); + // Open a page to make sure its functional. + await browser.newPage(); + expect(fs.readdirSync(userDataDir).length).toBeGreaterThan(0); + await browser.close(); + expect(fs.readdirSync(userDataDir).length).toBeGreaterThan(0); + + expect(await readFileAsync(prefsJSPath, 'utf8')).toBe(prefsJSContent); + + // This might throw. See https://github.com/puppeteer/puppeteer/issues/2778 + await rmAsync(userDataDir).catch(() => {}); + }); it('userDataDir argument', async () => { const { isChrome, puppeteer, defaultBrowserOptions } = getTestState(); @@ -240,7 +265,7 @@ describe('Launcher specs', function () { } else { options.args = [ ...(defaultBrowserOptions.args || []), - `-profile`, + '-profile', userDataDir, ]; } @@ -312,7 +337,11 @@ describe('Launcher specs', function () { } else if (isFirefox) { expect(puppeteer.defaultArgs()).toContain('--headless'); expect(puppeteer.defaultArgs()).toContain('--no-remote'); - expect(puppeteer.defaultArgs()).toContain('--foreground'); + if (os.platform() === 'darwin') { + expect(puppeteer.defaultArgs()).toContain('--foreground'); + } else { + expect(puppeteer.defaultArgs()).not.toContain('--foreground'); + } expect(puppeteer.defaultArgs({ headless: false })).not.toContain( '--headless' ); @@ -340,7 +369,7 @@ describe('Launcher specs', function () { if (isChrome) expect(puppeteer.product).toBe('chrome'); else if (isFirefox) expect(puppeteer.product).toBe('firefox'); }); - itFailsFirefox('should work with no default arguments', async () => { + it('should work with no default arguments', async () => { const { defaultBrowserOptions, puppeteer } = getTestState(); const options = Object.assign({}, defaultBrowserOptions); options.ignoreDefaultArgs = true; @@ -376,22 +405,19 @@ describe('Launcher specs', function () { expect(pages).toEqual(['about:blank']); await browser.close(); }); - itFailsFirefox( - 'should have custom URL when launching browser', - async () => { - const { server, puppeteer, defaultBrowserOptions } = getTestState(); + it('should have custom URL when launching browser', async () => { + const { server, puppeteer, defaultBrowserOptions } = getTestState(); - const options = Object.assign({}, defaultBrowserOptions); - options.args = [server.EMPTY_PAGE].concat(options.args || []); - const browser = await puppeteer.launch(options); - const pages = await browser.pages(); - expect(pages.length).toBe(1); - const page = pages[0]; - if (page.url() !== server.EMPTY_PAGE) await page.waitForNavigation(); - expect(page.url()).toBe(server.EMPTY_PAGE); - await browser.close(); - } - ); + const options = Object.assign({}, defaultBrowserOptions); + options.args = [server.EMPTY_PAGE].concat(options.args || []); + const browser = await puppeteer.launch(options); + const pages = await browser.pages(); + expect(pages.length).toBe(1); + const page = pages[0]; + if (page.url() !== server.EMPTY_PAGE) await page.waitForNavigation(); + expect(page.url()).toBe(server.EMPTY_PAGE); + await browser.close(); + }); it('should pass the timeout parameter to browser.waitForTarget', async () => { const { puppeteer, defaultBrowserOptions } = getTestState(); const options = Object.assign({}, defaultBrowserOptions, { @@ -509,19 +535,24 @@ describe('Launcher specs', function () { expect(userAgent).toContain('Chrome'); }); - it('falls back to launching chrome if there is an unknown product but logs a warning', async () => { - const { puppeteer } = getTestState(); - const consoleStub = sinon.stub(console, 'warn'); - // @ts-expect-error purposeful bad input - const browser = await puppeteer.launch({ product: 'SO_NOT_A_PRODUCT' }); - const userAgent = await browser.userAgent(); - await browser.close(); - expect(userAgent).toContain('Chrome'); - expect(consoleStub.callCount).toEqual(1); - expect(consoleStub.firstCall.args).toEqual([ - 'Warning: unknown product name SO_NOT_A_PRODUCT. Falling back to chrome.', - ]); - }); + itOnlyRegularInstall( + 'falls back to launching chrome if there is an unknown product but logs a warning', + async () => { + const { puppeteer } = getTestState(); + const consoleStub = sinon.stub(console, 'warn'); + const browser = await puppeteer.launch({ + // @ts-expect-error purposeful bad input + product: 'SO_NOT_A_PRODUCT', + }); + const userAgent = await browser.userAgent(); + await browser.close(); + expect(userAgent).toContain('Chrome'); + expect(consoleStub.callCount).toEqual(1); + expect(consoleStub.firstCall.args).toEqual([ + 'Warning: unknown product name SO_NOT_A_PRODUCT. Falling back to chrome.', + ]); + } + ); itOnlyRegularInstall( 'should be able to launch Firefox', diff --git a/test/mocha-utils.ts b/test/mocha-utils.ts index 6f701f470c707..6c405ac052f1a 100644 --- a/test/mocha-utils.ts +++ b/test/mocha-utils.ts @@ -157,6 +157,14 @@ export const itChromeOnly = ( else return xit(description, body); }; +export const itFirefoxOnly = ( + description: string, + body: Mocha.Func +): Mocha.Test => { + if (isFirefox) return it(description, body); + else return xit(description, body); +}; + export const itOnlyRegularInstall = ( description: string, body: Mocha.Func