From c6d01c950e94ca39bd82523e80f2fc5d39db2d3b Mon Sep 17 00:00:00 2001 From: Jack Franklin Date: Tue, 12 May 2020 16:30:13 +0100 Subject: [PATCH] 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. --- src/Launcher.ts | 258 +--------------------------------- src/Puppeteer.ts | 4 +- src/launcher/BrowserRunner.ts | 241 +++++++++++++++++++++++++++++++ src/launcher/LaunchOptions.ts | 42 ++++++ utils/doclint/Source.js | 6 +- utils/doclint/cli.js | 9 +- 6 files changed, 305 insertions(+), 255 deletions(-) create mode 100644 src/launcher/BrowserRunner.ts create mode 100644 src/launcher/LaunchOptions.ts diff --git a/src/Launcher.ts b/src/Launcher.ts index be29f99719dc0..11a132da1bb5f 100644 --- a/src/Launcher.ts +++ b/src/Launcher.ts @@ -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; - 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; - - 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 { - 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} - */ - async setupConnection(options: { - usePipe?: boolean; - timeout: number; - slowMo: number; - preferredRevision: string; - }): Promise { - 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 { - 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 { let resolve, reject; const promise = new Promise((res, rej) => { diff --git a/src/Puppeteer.ts b/src/Puppeteer.ts index 5a059c43d49d8..794d85660d283 100644 --- a/src/Puppeteer.ts +++ b/src/Puppeteer.ts @@ -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'; diff --git a/src/launcher/BrowserRunner.ts b/src/launcher/BrowserRunner.ts new file mode 100644 index 0000000000000..2d6a629b736c5 --- /dev/null +++ b/src/launcher/BrowserRunner.ts @@ -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; + + 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 { + 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 { + 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 { + 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); + } + }); +} diff --git a/src/launcher/LaunchOptions.ts b/src/launcher/LaunchOptions.ts new file mode 100644 index 0000000000000..9e5e537baa21c --- /dev/null +++ b/src/launcher/LaunchOptions.ts @@ -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; + pipe?: boolean; +} + +export interface BrowserOptions { + ignoreHTTPSErrors?: boolean; + defaultViewport?: Viewport; + slowMo?: number; +} diff --git a/utils/doclint/Source.js b/utils/doclint/Source.js index 38f7b16f833a2..60a3d77b0a652 100644 --- a/utils/doclint/Source.js +++ b/utils/doclint/Source.js @@ -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))); } } diff --git a/utils/doclint/cli.js b/utils/doclint/cli.js index 1bdf697dd8ec8..8384ffcfc23fb 100755 --- a/utils/doclint/cli.js +++ b/utils/doclint/cli.js @@ -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')