Skip to content

Commit

Permalink
chore: refactor handling of user data directory for clearing of prefe…
Browse files Browse the repository at this point in the history
…rences for Firefox
  • Loading branch information
whimboo committed Nov 8, 2021
1 parent 5ddca21 commit 80eb9ec
Show file tree
Hide file tree
Showing 3 changed files with 129 additions and 64 deletions.
64 changes: 49 additions & 15 deletions src/node/BrowserRunner.ts
Expand Up @@ -16,21 +16,28 @@

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';
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.
Expand All @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -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();
}
});
Expand All @@ -132,7 +164,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
Expand All @@ -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
Expand Down
98 changes: 66 additions & 32 deletions src/node/Launcher.ts
Expand Up @@ -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);

Expand Down Expand Up @@ -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))
Expand All @@ -96,8 +96,6 @@ class ChromeLauncher implements ProductLauncher {
);
else chromeArguments.push(...args);

let temporaryUserDataDir = null;

if (
!chromeArguments.some((argument) =>
argument.startsWith('--remote-debugging-')
Expand All @@ -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;
Expand Down Expand Up @@ -145,7 +162,8 @@ class ChromeLauncher implements ProductLauncher {
this.product,
chromeExecutable,
chromeArguments,
temporaryUserDataDir
userDataDir,
isTempUserDataDir
);
runner.start({
handleSIGHUP,
Expand Down Expand Up @@ -303,25 +321,30 @@ class FirefoxLauncher implements ProductLauncher {
firefoxArguments.push(`--remote-debugging-port=${debuggingPort || 0}`);
}

let temporaryUserDataDir = null;
let userDataDir = null;
let isTempUserDataDir = true;

// 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) {
const profilePath = firefoxArguments[profileArgIndex + 1];
if (!profilePath) {
temporaryUserDataDir = await this._createProfile(extraPrefsFirefox);
firefoxArguments.push(temporaryUserDataDir);
} else {
const prefs = this.defaultPreferences(extraPrefsFirefox);
await this.writePreferences(prefs, profilePath);
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 {
temporaryUserDataDir = await this._createProfile(extraPrefsFirefox);
userDataDir = await this._createProfile(extraPrefsFirefox);
firefoxArguments.push('--profile');
firefoxArguments.push(temporaryUserDataDir);
firefoxArguments.push(userDataDir);
}

await this._updateRevision();
Expand All @@ -336,7 +359,8 @@ class FirefoxLauncher implements ProductLauncher {
this.product,
firefoxExecutable,
firefoxArguments,
temporaryUserDataDir
userDataDir,
isTempUserDataDir
);
runner.start({
handleSIGHUP,
Expand Down Expand Up @@ -626,33 +650,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(tmpDir(), 'puppeteer_dev_firefox_profile-')
);

const prefs = this.defaultPreferences(extraPrefs);
await this.writePreferences(prefs, profilePath);
await this.writePreferences(prefs, temporaryProfilePath);

return profilePath;
return temporaryProfilePath;
}
}

Expand Down
31 changes: 14 additions & 17 deletions test/launcher.spec.ts
Expand Up @@ -240,7 +240,7 @@ describe('Launcher specs', function () {
} else {
options.args = [
...(defaultBrowserOptions.args || []),
`-profile`,
'-profile',
userDataDir,
];
}
Expand Down Expand Up @@ -344,7 +344,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;
Expand Down Expand Up @@ -380,22 +380,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, {
Expand Down

0 comments on commit 80eb9ec

Please sign in to comment.