chore: extract BrowserRunner into its own module (#5850)

* chore: extract `BrowserRunner` into its own module

`src/Launcher.ts` is large and hard to work in. It has multiple objects
defined in it:

* ChromeLauncher
* FirefoxLauncher
* BrowserRunner
* Launcher

This change moves BrowserRunner into its own module. More refactorings
like this will follow but this is the first step.
This commit is contained in:
Jack Franklin 2020-05-12 16:30:13 +01:00 committed by GitHub
parent b38bb4334f
commit c6d01c950e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 305 additions and 255 deletions

View File

@ -19,26 +19,23 @@ import * as http from 'http';
import * as https from 'https';
import * as URL from 'url';
import * as fs from 'fs';
import * as readline from 'readline';
import * as debug from 'debug';
import * as removeFolder from 'rimraf';
import * as childProcess from 'child_process';
import { BrowserFetcher } from './BrowserFetcher';
import { Connection } from './Connection';
import { Browser } from './Browser';
import { helper, assert, debugError } from './helper';
import { TimeoutError } from './Errors';
import type { ConnectionTransport } from './ConnectionTransport';
import { WebSocketTransport } from './WebSocketTransport';
import { PipeTransport } from './PipeTransport';
import type { Viewport } from './PuppeteerViewport';
import { BrowserRunner } from './launcher/BrowserRunner';
const mkdtempAsync = helper.promisify(fs.mkdtemp);
const removeFolderAsync = helper.promisify(removeFolder);
const writeFileAsync = helper.promisify(fs.writeFile);
const debugLauncher = debug('puppeteer:launcher');
import type {
ChromeArgOptions,
LaunchOptions,
BrowserOptions,
} from './launcher/LaunchOptions';
export interface ProductLauncher {
launch(object);
@ -48,186 +45,6 @@ export interface ProductLauncher {
product: string;
}
export interface ChromeArgOptions {
headless?: boolean;
args?: string[];
userDataDir?: string;
devtools?: boolean;
}
export interface LaunchOptions {
executablePath?: string;
ignoreDefaultArgs?: boolean | string[];
handleSIGINT?: boolean;
handleSIGTERM?: boolean;
handleSIGHUP?: boolean;
timeout?: number;
dumpio?: boolean;
env?: Record<string, string | undefined>;
pipe?: boolean;
}
export interface BrowserOptions {
ignoreHTTPSErrors?: boolean;
defaultViewport?: Viewport;
slowMo?: number;
}
class BrowserRunner {
_executablePath: string;
_processArguments: string[];
_tempDirectory?: string;
proc = null;
connection = null;
_closed = true;
_listeners = [];
_processClosing: Promise<void>;
constructor(
executablePath: string,
processArguments: string[],
tempDirectory?: string
) {
this._executablePath = executablePath;
this._processArguments = processArguments;
this._tempDirectory = tempDirectory;
}
start(options: LaunchOptions = {}): void {
const {
handleSIGINT,
handleSIGTERM,
handleSIGHUP,
dumpio,
env,
pipe,
} = options;
let stdio: Array<'ignore' | 'pipe'> = ['pipe', 'pipe', 'pipe'];
if (pipe) {
if (dumpio) stdio = ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'];
else stdio = ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'];
}
assert(!this.proc, 'This process has previously been started.');
debugLauncher(
`Calling ${this._executablePath} ${this._processArguments.join(' ')}`
);
this.proc = childProcess.spawn(
this._executablePath,
this._processArguments,
{
// On non-windows platforms, `detached: true` makes child process a leader of a new
// process group, making it possible to kill child process tree with `.kill(-pid)` command.
// @see https://nodejs.org/api/child_process.html#child_process_options_detached
detached: process.platform !== 'win32',
env,
stdio,
}
);
if (dumpio) {
this.proc.stderr.pipe(process.stderr);
this.proc.stdout.pipe(process.stdout);
}
this._closed = false;
this._processClosing = new Promise((fulfill) => {
this.proc.once('exit', () => {
this._closed = true;
// Cleanup as processes exit.
if (this._tempDirectory) {
removeFolderAsync(this._tempDirectory)
.then(() => fulfill())
.catch((error) => console.error(error));
} else {
fulfill();
}
});
});
this._listeners = [
helper.addEventListener(process, 'exit', this.kill.bind(this)),
];
if (handleSIGINT)
this._listeners.push(
helper.addEventListener(process, 'SIGINT', () => {
this.kill();
process.exit(130);
})
);
if (handleSIGTERM)
this._listeners.push(
helper.addEventListener(process, 'SIGTERM', this.close.bind(this))
);
if (handleSIGHUP)
this._listeners.push(
helper.addEventListener(process, 'SIGHUP', this.close.bind(this))
);
}
close(): Promise<void> {
if (this._closed) return Promise.resolve();
helper.removeEventListeners(this._listeners);
if (this._tempDirectory) {
this.kill();
} else if (this.connection) {
// Attempt to close the browser gracefully
this.connection.send('Browser.close').catch((error) => {
debugError(error);
this.kill();
});
}
return this._processClosing;
}
kill(): void {
helper.removeEventListeners(this._listeners);
if (this.proc && this.proc.pid && !this.proc.killed && !this._closed) {
try {
if (process.platform === 'win32')
childProcess.execSync(`taskkill /pid ${this.proc.pid} /T /F`);
else process.kill(-this.proc.pid, 'SIGKILL');
} catch (error) {
// the process might have already stopped
}
}
// Attempt to remove temporary profile directory to avoid littering.
try {
removeFolder.sync(this._tempDirectory);
} catch (error) {}
}
/**
* @param {!({usePipe?: boolean, timeout: number, slowMo: number, preferredRevision: string})} options
*
* @return {!Promise<!Connection>}
*/
async setupConnection(options: {
usePipe?: boolean;
timeout: number;
slowMo: number;
preferredRevision: string;
}): Promise<Connection> {
const { usePipe, timeout, slowMo, preferredRevision } = options;
if (!usePipe) {
const browserWSEndpoint = await waitForWSEndpoint(
this.proc,
timeout,
preferredRevision
);
const transport = await WebSocketTransport.create(browserWSEndpoint);
this.connection = new Connection(browserWSEndpoint, transport, slowMo);
} else {
// stdio was assigned during start(), and the 'pipe' option there adds the 4th and 5th items to stdio array
const { 3: pipeWrite, 4: pipeRead } = this.proc.stdio;
const transport = new PipeTransport(
pipeWrite as NodeJS.WritableStream,
pipeRead as NodeJS.ReadableStream
);
this.connection = new Connection('', transport, slowMo);
}
return this.connection;
}
}
class ChromeLauncher implements ProductLauncher {
_projectRoot: string;
_preferredRevision: string;
@ -871,67 +688,6 @@ class FirefoxLauncher implements ProductLauncher {
}
}
function waitForWSEndpoint(
browserProcess: childProcess.ChildProcess,
timeout: number,
preferredRevision: string
): Promise<string> {
return new Promise((resolve, reject) => {
const rl = readline.createInterface({ input: browserProcess.stderr });
let stderr = '';
const listeners = [
helper.addEventListener(rl, 'line', onLine),
helper.addEventListener(rl, 'close', () => onClose()),
helper.addEventListener(browserProcess, 'exit', () => onClose()),
helper.addEventListener(browserProcess, 'error', (error) =>
onClose(error)
),
];
const timeoutId = timeout ? setTimeout(onTimeout, timeout) : 0;
/**
* @param {!Error=} error
*/
function onClose(error?: Error): void {
cleanup();
reject(
new Error(
[
'Failed to launch the browser process!' +
(error ? ' ' + error.message : ''),
stderr,
'',
'TROUBLESHOOTING: https://github.com/puppeteer/puppeteer/blob/master/docs/troubleshooting.md',
'',
].join('\n')
)
);
}
function onTimeout(): void {
cleanup();
reject(
new TimeoutError(
`Timed out after ${timeout} ms while trying to connect to the browser! Only Chrome at revision r${preferredRevision} is guaranteed to work.`
)
);
}
function onLine(line: string): void {
stderr += line + '\n';
const match = line.match(/^DevTools listening on (ws:\/\/.*)$/);
if (!match) return;
cleanup();
resolve(match[1]);
}
function cleanup(): void {
if (timeoutId) clearTimeout(timeoutId);
helper.removeEventListeners(listeners);
}
});
}
function getWSEndpoint(browserURL: string): Promise<string> {
let resolve, reject;
const promise = new Promise<string>((res, rej) => {

View File

@ -18,8 +18,8 @@ import type {
LaunchOptions,
ChromeArgOptions,
BrowserOptions,
ProductLauncher,
} from './Launcher';
} from './launcher/LaunchOptions';
import type { ProductLauncher } from './Launcher';
import { BrowserFetcher, BrowserFetcherOptions } from './BrowserFetcher';
import { puppeteerErrors, PuppeteerErrors } from './Errors';
import type { ConnectionTransport } from './ConnectionTransport';

View File

@ -0,0 +1,241 @@
/**
* Copyright 2020 Google Inc. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as debug from 'debug';
import * as removeFolder from 'rimraf';
import * as childProcess from 'child_process';
import { helper, assert, debugError } from '../helper';
import type { LaunchOptions } from './LaunchOptions';
import { Connection } from '../Connection';
import { WebSocketTransport } from '../WebSocketTransport';
import { PipeTransport } from '../PipeTransport';
import * as readline from 'readline';
import { TimeoutError } from '../Errors';
const removeFolderAsync = helper.promisify(removeFolder);
const debugLauncher = debug('puppeteer:launcher');
export class BrowserRunner {
private _executablePath: string;
private _processArguments: string[];
private _tempDirectory?: string;
proc = null;
connection = null;
private _closed = true;
private _listeners = [];
private _processClosing: Promise<void>;
constructor(
executablePath: string,
processArguments: string[],
tempDirectory?: string
) {
this._executablePath = executablePath;
this._processArguments = processArguments;
this._tempDirectory = tempDirectory;
}
start(options: LaunchOptions = {}): void {
const {
handleSIGINT,
handleSIGTERM,
handleSIGHUP,
dumpio,
env,
pipe,
} = options;
let stdio: Array<'ignore' | 'pipe'> = ['pipe', 'pipe', 'pipe'];
if (pipe) {
if (dumpio) stdio = ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'];
else stdio = ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'];
}
assert(!this.proc, 'This process has previously been started.');
debugLauncher(
`Calling ${this._executablePath} ${this._processArguments.join(' ')}`
);
this.proc = childProcess.spawn(
this._executablePath,
this._processArguments,
{
// On non-windows platforms, `detached: true` makes child process a leader of a new
// process group, making it possible to kill child process tree with `.kill(-pid)` command.
// @see https://nodejs.org/api/child_process.html#child_process_options_detached
detached: process.platform !== 'win32',
env,
stdio,
}
);
if (dumpio) {
this.proc.stderr.pipe(process.stderr);
this.proc.stdout.pipe(process.stdout);
}
this._closed = false;
this._processClosing = new Promise((fulfill) => {
this.proc.once('exit', () => {
this._closed = true;
// Cleanup as processes exit.
if (this._tempDirectory) {
removeFolderAsync(this._tempDirectory)
.then(() => fulfill())
.catch((error) => console.error(error));
} else {
fulfill();
}
});
});
this._listeners = [
helper.addEventListener(process, 'exit', this.kill.bind(this)),
];
if (handleSIGINT)
this._listeners.push(
helper.addEventListener(process, 'SIGINT', () => {
this.kill();
process.exit(130);
})
);
if (handleSIGTERM)
this._listeners.push(
helper.addEventListener(process, 'SIGTERM', this.close.bind(this))
);
if (handleSIGHUP)
this._listeners.push(
helper.addEventListener(process, 'SIGHUP', this.close.bind(this))
);
}
close(): Promise<void> {
if (this._closed) return Promise.resolve();
helper.removeEventListeners(this._listeners);
if (this._tempDirectory) {
this.kill();
} else if (this.connection) {
// Attempt to close the browser gracefully
this.connection.send('Browser.close').catch((error) => {
debugError(error);
this.kill();
});
}
return this._processClosing;
}
kill(): void {
helper.removeEventListeners(this._listeners);
if (this.proc && this.proc.pid && !this.proc.killed && !this._closed) {
try {
if (process.platform === 'win32')
childProcess.execSync(`taskkill /pid ${this.proc.pid} /T /F`);
else process.kill(-this.proc.pid, 'SIGKILL');
} catch (error) {
// the process might have already stopped
}
}
// Attempt to remove temporary profile directory to avoid littering.
try {
removeFolder.sync(this._tempDirectory);
} catch (error) {}
}
async setupConnection(options: {
usePipe?: boolean;
timeout: number;
slowMo: number;
preferredRevision: string;
}): Promise<Connection> {
const { usePipe, timeout, slowMo, preferredRevision } = options;
if (!usePipe) {
const browserWSEndpoint = await waitForWSEndpoint(
this.proc,
timeout,
preferredRevision
);
const transport = await WebSocketTransport.create(browserWSEndpoint);
this.connection = new Connection(browserWSEndpoint, transport, slowMo);
} else {
// stdio was assigned during start(), and the 'pipe' option there adds the 4th and 5th items to stdio array
const { 3: pipeWrite, 4: pipeRead } = this.proc.stdio;
const transport = new PipeTransport(
pipeWrite as NodeJS.WritableStream,
pipeRead as NodeJS.ReadableStream
);
this.connection = new Connection('', transport, slowMo);
}
return this.connection;
}
}
function waitForWSEndpoint(
browserProcess: childProcess.ChildProcess,
timeout: number,
preferredRevision: string
): Promise<string> {
return new Promise((resolve, reject) => {
const rl = readline.createInterface({ input: browserProcess.stderr });
let stderr = '';
const listeners = [
helper.addEventListener(rl, 'line', onLine),
helper.addEventListener(rl, 'close', () => onClose()),
helper.addEventListener(browserProcess, 'exit', () => onClose()),
helper.addEventListener(browserProcess, 'error', (error) =>
onClose(error)
),
];
const timeoutId = timeout ? setTimeout(onTimeout, timeout) : 0;
/**
* @param {!Error=} error
*/
function onClose(error?: Error): void {
cleanup();
reject(
new Error(
[
'Failed to launch the browser process!' +
(error ? ' ' + error.message : ''),
stderr,
'',
'TROUBLESHOOTING: https://github.com/puppeteer/puppeteer/blob/master/docs/troubleshooting.md',
'',
].join('\n')
)
);
}
function onTimeout(): void {
cleanup();
reject(
new TimeoutError(
`Timed out after ${timeout} ms while trying to connect to the browser! Only Chrome at revision r${preferredRevision} is guaranteed to work.`
)
);
}
function onLine(line: string): void {
stderr += line + '\n';
const match = line.match(/^DevTools listening on (ws:\/\/.*)$/);
if (!match) return;
cleanup();
resolve(match[1]);
}
function cleanup(): void {
if (timeoutId) clearTimeout(timeoutId);
helper.removeEventListeners(listeners);
}
});
}

View File

@ -0,0 +1,42 @@
/**
* Copyright 2020 Google Inc. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Viewport } from '../PuppeteerViewport';
export interface ChromeArgOptions {
headless?: boolean;
args?: string[];
userDataDir?: string;
devtools?: boolean;
}
export interface LaunchOptions {
executablePath?: string;
ignoreDefaultArgs?: boolean | string[];
handleSIGINT?: boolean;
handleSIGTERM?: boolean;
handleSIGHUP?: boolean;
timeout?: number;
dumpio?: boolean;
env?: Record<string, string | undefined>;
pipe?: boolean;
}
export interface BrowserOptions {
ignoreHTTPSErrors?: boolean;
defaultViewport?: Viewport;
slowMo?: number;
}

View File

@ -106,7 +106,11 @@ class Source {
const fileNames = await readdirAsync(dirPath);
const filePaths = fileNames
.filter((fileName) => fileName.endsWith(extension))
.map((fileName) => path.join(dirPath, fileName));
.map((fileName) => path.join(dirPath, fileName))
.filter((filePath) => {
const stats = fs.lstatSync(filePath);
return stats.isDirectory() === false;
});
return Promise.all(filePaths.map((filePath) => Source.readFile(filePath)));
}
}

View File

@ -55,7 +55,14 @@ async function run() {
const browser = await puppeteer.launch();
const page = await browser.newPage();
const checkPublicAPI = require('./check_public_api');
const tsSources = await Source.readdir(path.join(PROJECT_DIR, 'src'), 'ts');
const tsSources = [
/* Source.readdir doesn't deal with nested directories well.
* Rather than invest time here when we're going to remove this Doc tooling soon
* we'll just list the directories manually.
*/
...(await Source.readdir(path.join(PROJECT_DIR, 'src'), 'ts')),
...(await Source.readdir(path.join(PROJECT_DIR, 'src', 'launcher'), 'ts')),
];
const tsSourcesNoDefinitions = tsSources.filter(
(source) => !source.filePath().endsWith('.d.ts')