Bug 1713030 - [puppeteer] Support for custom user data (profile) directory for Firefox. r=webdriver-reviewers,jdescottes

When using a custom Firefox profile for Puppeteer the modified
preferences as present in prefs.js need to be reset once the
profile is no longer needed by Puppeteer. If not done this could
cause side-effects when the profile is used next time outside
of Puppeteer.

Differential Revision: https://phabricator.services.mozilla.com/D128452
This commit is contained in:
Henrik Skupin 2021-11-10 17:21:03 +00:00
Родитель e679d9a2c5
Коммит 4f094b2f9b
4 изменённых файлов: 191 добавлений и 77 удалений

Просмотреть файл

@ -14,23 +14,29 @@
* limitations under the License.
*/
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 { debug } from '../common/Debug.js';
import { helper, debugError } from '../common/helper.js';
import { LaunchOptions } from './LaunchOptions.js';
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 +46,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 +60,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 {
@ -94,15 +103,40 @@ export class BrowserRunner {
this.proc.stdout.pipe(process.stdout);
}
this._closed = false;
this._processClosing = new Promise((fulfill) => {
this.proc.once('exit', () => {
this._processClosing = new Promise((fulfill, reject) => {
this.proc.once('exit', async () => {
this._closed = true;
// Cleanup as processes exit.
if (this._tempDirectory) {
removeFolderAsync(this._tempDirectory)
.then(() => fulfill())
.catch((error) => console.error(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);
}
}
fulfill();
}
});
@ -129,7 +163,7 @@ export class BrowserRunner {
close(): Promise<void> {
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
@ -147,7 +181,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

Просмотреть файл

@ -22,6 +22,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);
@ -78,7 +79,6 @@ class ChromeLauncher implements ProductLauncher {
waitForInitialPage = true,
} = options;
const profilePath = path.join(os.tmpdir(), 'puppeteer_dev_chrome_profile-');
const chromeArguments = [];
if (!ignoreDefaultArgs) chromeArguments.push(...this.defaultArgs(options));
else if (Array.isArray(ignoreDefaultArgs))
@ -89,19 +89,37 @@ class ChromeLauncher implements ProductLauncher {
);
else chromeArguments.push(...args);
let temporaryUserDataDir = null;
if (
!chromeArguments.some((argument) =>
argument.startsWith('--remote-debugging-')
)
)
) {
chromeArguments.push(
pipe ? '--remote-debugging-pipe' : '--remote-debugging-port=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(os.tmpdir(), 'puppeteer_dev_chrome_profile-')
);
chromeArguments.push(`--user-data-dir=${userDataDir}`);
}
let chromeExecutable = executablePath;
@ -122,7 +140,8 @@ class ChromeLauncher implements ProductLauncher {
this.product,
chromeExecutable,
chromeArguments,
temporaryUserDataDir
userDataDir,
isTempUserDataDir
);
runner.start({
handleSIGHUP,
@ -267,15 +286,30 @@ class FirefoxLauncher implements ProductLauncher {
)
firefoxArguments.push('--remote-debugging-port=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();
@ -290,7 +324,8 @@ class FirefoxLauncher implements ProductLauncher {
this.product,
firefoxExecutable,
firefoxArguments,
temporaryUserDataDir
userDataDir,
isTempUserDataDir
);
runner.start({
handleSIGHUP,
@ -370,9 +405,9 @@ class FirefoxLauncher implements ProductLauncher {
return firefoxArguments;
}
defaultPreferences(extraPrefs: {
defaultPreferences(extraPrefs: { [x: string]: unknown }): {
[x: string]: unknown;
}): { [x: string]: unknown } {
} {
const server = 'dummy.test';
const defaultPrefs = {
@ -580,33 +615,43 @@ class FirefoxLauncher implements ProductLauncher {
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<void> {
const prefsJS = [];
const userJS = [];
const lines = Object.entries(prefs).map(([key, value]) => {
return `user_pref(${JSON.stringify(key)}, ${JSON.stringify(value)});`;
});
for (const [key, value] of Object.entries(prefs))
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')
);
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<string> {
const profilePath = await mkdtempAsync(
const temporaryProfilePath = await mkdtempAsync(
path.join(os.tmpdir(), 'puppeteer_dev_firefox_profile-')
);
const prefs = this.defaultPreferences(extraPrefs);
await this.writePreferences(prefs, profilePath);
await this.writePreferences(prefs, temporaryProfilePath);
return profilePath;
return temporaryProfilePath;
}
}

Просмотреть файл

@ -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;
@ -251,6 +254,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 option should restore state', async () => {
const { server, puppeteer, defaultBrowserOptions } = getTestState();
@ -380,22 +405,19 @@ describe('Launcher specs', function () {
expect(pages).toEqual(['about:blank']);
await browser.close();
});
it(
'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 set the default viewport', async () => {
const { puppeteer, defaultBrowserOptions } = getTestState();
const options = Object.assign({}, defaultBrowserOptions, {
@ -479,19 +501,22 @@ 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');
// @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(
'should be able to launch Firefox',

Просмотреть файл

@ -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