diff options
Diffstat (limited to '')
-rw-r--r-- | remote/test/puppeteer/src/node/BrowserFetcher.ts | 701 | ||||
-rw-r--r-- | remote/test/puppeteer/src/node/BrowserRunner.ts | 359 | ||||
-rw-r--r-- | remote/test/puppeteer/src/node/ChromeLauncher.ts | 265 | ||||
-rw-r--r-- | remote/test/puppeteer/src/node/FirefoxLauncher.ts | 502 | ||||
-rw-r--r-- | remote/test/puppeteer/src/node/LaunchOptions.ts | 144 | ||||
-rw-r--r-- | remote/test/puppeteer/src/node/NodeWebSocketTransport.ts | 69 | ||||
-rw-r--r-- | remote/test/puppeteer/src/node/PipeTransport.ts | 93 | ||||
-rw-r--r-- | remote/test/puppeteer/src/node/ProductLauncher.ts | 217 | ||||
-rw-r--r-- | remote/test/puppeteer/src/node/Puppeteer.ts | 244 | ||||
-rw-r--r-- | remote/test/puppeteer/src/node/install.ts | 232 | ||||
-rw-r--r-- | remote/test/puppeteer/src/node/util.ts | 13 |
11 files changed, 2839 insertions, 0 deletions
diff --git a/remote/test/puppeteer/src/node/BrowserFetcher.ts b/remote/test/puppeteer/src/node/BrowserFetcher.ts new file mode 100644 index 0000000000..7c425c2dbb --- /dev/null +++ b/remote/test/puppeteer/src/node/BrowserFetcher.ts @@ -0,0 +1,701 @@ +/** + * Copyright 2017 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 os from 'os'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as util from 'util'; +import * as childProcess from 'child_process'; +import * as https from 'https'; +import * as http from 'http'; + +import {Product} from '../common/Product.js'; +import extractZip from 'extract-zip'; +import {debug} from '../common/Debug.js'; +import {promisify} from 'util'; +import removeRecursive from 'rimraf'; +import * as URL from 'url'; +import createHttpsProxyAgent, { + HttpsProxyAgent, + HttpsProxyAgentOptions, +} from 'https-proxy-agent'; +import {getProxyForUrl} from 'proxy-from-env'; +import {assert} from '../util/assert.js'; + +import tar from 'tar-fs'; +import bzip from 'unbzip2-stream'; + +const {PUPPETEER_EXPERIMENTAL_CHROMIUM_MAC_ARM} = process.env; + +const debugFetcher = debug('puppeteer:fetcher'); + +const downloadURLs: Record<Product, Partial<Record<Platform, string>>> = { + chrome: { + linux: '%s/chromium-browser-snapshots/Linux_x64/%d/%s.zip', + mac: '%s/chromium-browser-snapshots/Mac/%d/%s.zip', + mac_arm: '%s/chromium-browser-snapshots/Mac_Arm/%d/%s.zip', + win32: '%s/chromium-browser-snapshots/Win/%d/%s.zip', + win64: '%s/chromium-browser-snapshots/Win_x64/%d/%s.zip', + }, + firefox: { + linux: '%s/firefox-%s.en-US.%s-x86_64.tar.bz2', + mac: '%s/firefox-%s.en-US.%s.dmg', + win32: '%s/firefox-%s.en-US.%s.zip', + win64: '%s/firefox-%s.en-US.%s.zip', + }, +}; + +const browserConfig = { + chrome: { + host: 'https://storage.googleapis.com', + destination: '.local-chromium', + }, + firefox: { + host: 'https://archive.mozilla.org/pub/firefox/nightly/latest-mozilla-central', + destination: '.local-firefox', + }, +} as const; + +/** + * Supported platforms. + * + * @public + */ +export type Platform = 'linux' | 'mac' | 'mac_arm' | 'win32' | 'win64'; + +function archiveName( + product: Product, + platform: Platform, + revision: string +): string { + switch (product) { + case 'chrome': + switch (platform) { + case 'linux': + return 'chrome-linux'; + case 'mac_arm': + case 'mac': + return 'chrome-mac'; + case 'win32': + case 'win64': + // Windows archive name changed at r591479. + return parseInt(revision, 10) > 591479 + ? 'chrome-win' + : 'chrome-win32'; + } + case 'firefox': + return platform; + } +} + +function downloadURL( + product: Product, + platform: Platform, + host: string, + revision: string +): string { + const url = util.format( + downloadURLs[product][platform], + host, + revision, + archiveName(product, platform, revision) + ); + return url; +} + +function handleArm64(): void { + let exists = fs.existsSync('/usr/bin/chromium-browser'); + if (exists) { + return; + } + exists = fs.existsSync('/usr/bin/chromium'); + if (exists) { + return; + } + console.error( + 'The chromium binary is not available for arm64.' + + '\nIf you are on Ubuntu, you can install with: ' + + '\n\n sudo apt install chromium\n' + + '\n\n sudo apt install chromium-browser\n' + ); + throw new Error(); +} + +const readdirAsync = promisify(fs.readdir.bind(fs)); +const mkdirAsync = promisify(fs.mkdir.bind(fs)); +const unlinkAsync = promisify(fs.unlink.bind(fs)); +const chmodAsync = promisify(fs.chmod.bind(fs)); + +function existsAsync(filePath: string): Promise<boolean> { + return new Promise(resolve => { + fs.access(filePath, err => { + return resolve(!err); + }); + }); +} + +/** + * @public + */ +export interface BrowserFetcherOptions { + platform?: Platform; + product?: string; + path?: string; + host?: string; +} + +/** + * @public + */ +export interface BrowserFetcherRevisionInfo { + folderPath: string; + executablePath: string; + url: string; + local: boolean; + revision: string; + product: string; +} +/** + * BrowserFetcher can download and manage different versions of Chromium and Firefox. + * + * @remarks + * BrowserFetcher operates on revision strings that specify a precise version of Chromium, e.g. `"533271"`. Revision strings can be obtained from {@link http://omahaproxy.appspot.com/ | omahaproxy.appspot.com}. + * In the Firefox case, BrowserFetcher downloads Firefox Nightly and + * operates on version numbers such as `"75"`. + * + * @example + * An example of using BrowserFetcher to download a specific version of Chromium + * and running Puppeteer against it: + * + * ```ts + * const browserFetcher = puppeteer.createBrowserFetcher(); + * const revisionInfo = await browserFetcher.download('533271'); + * const browser = await puppeteer.launch({ + * executablePath: revisionInfo.executablePath, + * }); + * ``` + * + * **NOTE** BrowserFetcher is not designed to work concurrently with other + * instances of BrowserFetcher that share the same downloads directory. + * + * @public + */ + +export class BrowserFetcher { + #product: Product; + #downloadsFolder: string; + #downloadHost: string; + #platform: Platform; + + /** + * @internal + */ + constructor(projectRoot: string, options: BrowserFetcherOptions = {}) { + this.#product = (options.product || 'chrome').toLowerCase() as Product; + assert( + this.#product === 'chrome' || this.#product === 'firefox', + `Unknown product: "${options.product}"` + ); + + this.#downloadsFolder = + options.path || + path.join(projectRoot, browserConfig[this.#product].destination); + this.#downloadHost = options.host || browserConfig[this.#product].host; + + if (options.platform) { + this.#platform = options.platform; + } else { + const platform = os.platform(); + switch (platform) { + case 'darwin': + switch (this.#product) { + case 'chrome': + this.#platform = + os.arch() === 'arm64' && PUPPETEER_EXPERIMENTAL_CHROMIUM_MAC_ARM + ? 'mac_arm' + : 'mac'; + break; + case 'firefox': + this.#platform = 'mac'; + break; + } + break; + case 'linux': + this.#platform = 'linux'; + break; + case 'win32': + this.#platform = os.arch() === 'x64' ? 'win64' : 'win32'; + return; + default: + assert(false, 'Unsupported platform: ' + platform); + } + } + + assert( + downloadURLs[this.#product][this.#platform], + 'Unsupported platform: ' + this.#platform + ); + } + + /** + * @returns Returns the current `Platform`, which is one of `mac`, `linux`, + * `win32` or `win64`. + */ + platform(): Platform { + return this.#platform; + } + + /** + * @returns Returns the current `Product`, which is one of `chrome` or + * `firefox`. + */ + product(): Product { + return this.#product; + } + + /** + * @returns The download host being used. + */ + host(): string { + return this.#downloadHost; + } + + /** + * Initiates a HEAD request to check if the revision is available. + * @remarks + * This method is affected by the current `product`. + * @param revision - The revision to check availability for. + * @returns A promise that resolves to `true` if the revision could be downloaded + * from the host. + */ + canDownload(revision: string): Promise<boolean> { + const url = downloadURL( + this.#product, + this.#platform, + this.#downloadHost, + revision + ); + return new Promise(resolve => { + const request = httpRequest( + url, + 'HEAD', + response => { + resolve(response.statusCode === 200); + }, + false + ); + request.on('error', error => { + console.error(error); + resolve(false); + }); + }); + } + + /** + * Initiates a GET request to download the revision from the host. + * @remarks + * This method is affected by the current `product`. + * @param revision - The revision to download. + * @param progressCallback - A function that will be called with two arguments: + * How many bytes have been downloaded and the total number of bytes of the download. + * @returns A promise with revision information when the revision is downloaded + * and extracted. + */ + async download( + revision: string, + progressCallback: (x: number, y: number) => void = (): void => {} + ): Promise<BrowserFetcherRevisionInfo | undefined> { + const url = downloadURL( + this.#product, + this.#platform, + this.#downloadHost, + revision + ); + const fileName = url.split('/').pop(); + assert(fileName, `A malformed download URL was found: ${url}.`); + const archivePath = path.join(this.#downloadsFolder, fileName); + const outputPath = this.#getFolderPath(revision); + if (await existsAsync(outputPath)) { + return this.revisionInfo(revision); + } + if (!(await existsAsync(this.#downloadsFolder))) { + await mkdirAsync(this.#downloadsFolder); + } + + // Use system Chromium builds on Linux ARM devices + if (os.platform() !== 'darwin' && os.arch() === 'arm64') { + handleArm64(); + return; + } + try { + await _downloadFile(url, archivePath, progressCallback); + await install(archivePath, outputPath); + } finally { + if (await existsAsync(archivePath)) { + await unlinkAsync(archivePath); + } + } + const revisionInfo = this.revisionInfo(revision); + if (revisionInfo) { + await chmodAsync(revisionInfo.executablePath, 0o755); + } + return revisionInfo; + } + + /** + * @remarks + * This method is affected by the current `product`. + * @returns A promise with a list of all revision strings (for the current `product`) + * available locally on disk. + */ + async localRevisions(): Promise<string[]> { + if (!(await existsAsync(this.#downloadsFolder))) { + return []; + } + const fileNames = await readdirAsync(this.#downloadsFolder); + return fileNames + .map(fileName => { + return parseFolderPath(this.#product, fileName); + }) + .filter( + ( + entry + ): entry is {product: string; platform: string; revision: string} => { + return (entry && entry.platform === this.#platform) ?? false; + } + ) + .map(entry => { + return entry.revision; + }); + } + + /** + * @remarks + * This method is affected by the current `product`. + * @param revision - A revision to remove for the current `product`. + * @returns A promise that resolves when the revision has been removes or + * throws if the revision has not been downloaded. + */ + async remove(revision: string): Promise<void> { + const folderPath = this.#getFolderPath(revision); + assert( + await existsAsync(folderPath), + `Failed to remove: revision ${revision} is not downloaded` + ); + await new Promise(fulfill => { + return removeRecursive(folderPath, fulfill); + }); + } + + /** + * @param revision - The revision to get info for. + * @returns The revision info for the given revision. + */ + revisionInfo(revision: string): BrowserFetcherRevisionInfo { + const folderPath = this.#getFolderPath(revision); + let executablePath = ''; + if (this.#product === 'chrome') { + if (this.#platform === 'mac' || this.#platform === 'mac_arm') { + executablePath = path.join( + folderPath, + archiveName(this.#product, this.#platform, revision), + 'Chromium.app', + 'Contents', + 'MacOS', + 'Chromium' + ); + } else if (this.#platform === 'linux') { + executablePath = path.join( + folderPath, + archiveName(this.#product, this.#platform, revision), + 'chrome' + ); + } else if (this.#platform === 'win32' || this.#platform === 'win64') { + executablePath = path.join( + folderPath, + archiveName(this.#product, this.#platform, revision), + 'chrome.exe' + ); + } else { + throw new Error('Unsupported platform: ' + this.#platform); + } + } else if (this.#product === 'firefox') { + if (this.#platform === 'mac' || this.#platform === 'mac_arm') { + executablePath = path.join( + folderPath, + 'Firefox Nightly.app', + 'Contents', + 'MacOS', + 'firefox' + ); + } else if (this.#platform === 'linux') { + executablePath = path.join(folderPath, 'firefox', 'firefox'); + } else if (this.#platform === 'win32' || this.#platform === 'win64') { + executablePath = path.join(folderPath, 'firefox', 'firefox.exe'); + } else { + throw new Error('Unsupported platform: ' + this.#platform); + } + } else { + throw new Error('Unsupported product: ' + this.#product); + } + const url = downloadURL( + this.#product, + this.#platform, + this.#downloadHost, + revision + ); + const local = fs.existsSync(folderPath); + debugFetcher({ + revision, + executablePath, + folderPath, + local, + url, + product: this.#product, + }); + return { + revision, + executablePath, + folderPath, + local, + url, + product: this.#product, + }; + } + + #getFolderPath(revision: string): string { + return path.resolve(this.#downloadsFolder, `${this.#platform}-${revision}`); + } +} + +function parseFolderPath( + product: Product, + folderPath: string +): {product: string; platform: string; revision: string} | undefined { + const name = path.basename(folderPath); + const splits = name.split('-'); + if (splits.length !== 2) { + return; + } + const [platform, revision] = splits; + if (!revision || !platform || !(platform in downloadURLs[product])) { + return; + } + return {product, platform, revision}; +} + +/** + * @internal + */ +function _downloadFile( + url: string, + destinationPath: string, + progressCallback?: (x: number, y: number) => void +): Promise<void> { + debugFetcher(`Downloading binary from ${url}`); + let fulfill: (value: void | PromiseLike<void>) => void; + let reject: (err: Error) => void; + const promise = new Promise<void>((x, y) => { + fulfill = x; + reject = y; + }); + + let downloadedBytes = 0; + let totalBytes = 0; + + const request = httpRequest(url, 'GET', response => { + if (response.statusCode !== 200) { + const error = new Error( + `Download failed: server returned code ${response.statusCode}. URL: ${url}` + ); + // consume response data to free up memory + response.resume(); + reject(error); + return; + } + const file = fs.createWriteStream(destinationPath); + file.on('finish', () => { + return fulfill(); + }); + file.on('error', error => { + return reject(error); + }); + response.pipe(file); + totalBytes = parseInt(response.headers['content-length']!, 10); + if (progressCallback) { + response.on('data', onData); + } + }); + request.on('error', error => { + return reject(error); + }); + return promise; + + function onData(chunk: string): void { + downloadedBytes += chunk.length; + progressCallback!(downloadedBytes, totalBytes); + } +} + +function install(archivePath: string, folderPath: string): Promise<unknown> { + debugFetcher(`Installing ${archivePath} to ${folderPath}`); + if (archivePath.endsWith('.zip')) { + return extractZip(archivePath, {dir: folderPath}); + } else if (archivePath.endsWith('.tar.bz2')) { + return _extractTar(archivePath, folderPath); + } else if (archivePath.endsWith('.dmg')) { + return mkdirAsync(folderPath).then(() => { + return _installDMG(archivePath, folderPath); + }); + } else { + throw new Error(`Unsupported archive format: ${archivePath}`); + } +} + +/** + * @internal + */ +function _extractTar(tarPath: string, folderPath: string): Promise<unknown> { + return new Promise((fulfill, reject) => { + const tarStream = tar.extract(folderPath); + tarStream.on('error', reject); + tarStream.on('finish', fulfill); + const readStream = fs.createReadStream(tarPath); + readStream.pipe(bzip()).pipe(tarStream); + }); +} + +/** + * @internal + */ +function _installDMG(dmgPath: string, folderPath: string): Promise<void> { + let mountPath: string | undefined; + + return new Promise<void>((fulfill, reject): void => { + const mountCommand = `hdiutil attach -nobrowse -noautoopen "${dmgPath}"`; + childProcess.exec(mountCommand, (err, stdout) => { + if (err) { + return reject(err); + } + const volumes = stdout.match(/\/Volumes\/(.*)/m); + if (!volumes) { + return reject(new Error(`Could not find volume path in ${stdout}`)); + } + mountPath = volumes[0]!; + readdirAsync(mountPath) + .then(fileNames => { + const appName = fileNames.find(item => { + return typeof item === 'string' && item.endsWith('.app'); + }); + if (!appName) { + return reject(new Error(`Cannot find app in ${mountPath}`)); + } + const copyPath = path.join(mountPath!, appName); + debugFetcher(`Copying ${copyPath} to ${folderPath}`); + childProcess.exec(`cp -R "${copyPath}" "${folderPath}"`, err => { + if (err) { + reject(err); + } else { + fulfill(); + } + }); + }) + .catch(reject); + }); + }) + .catch(error => { + console.error(error); + }) + .finally((): void => { + if (!mountPath) { + return; + } + const unmountCommand = `hdiutil detach "${mountPath}" -quiet`; + debugFetcher(`Unmounting ${mountPath}`); + childProcess.exec(unmountCommand, err => { + if (err) { + console.error(`Error unmounting dmg: ${err}`); + } + }); + }); +} + +function httpRequest( + url: string, + method: string, + response: (x: http.IncomingMessage) => void, + keepAlive = true +): http.ClientRequest { + const urlParsed = URL.parse(url); + + type Options = Partial<URL.UrlWithStringQuery> & { + method?: string; + agent?: HttpsProxyAgent; + rejectUnauthorized?: boolean; + headers?: http.OutgoingHttpHeaders | undefined; + }; + + let options: Options = { + ...urlParsed, + method, + headers: keepAlive + ? { + Connection: 'keep-alive', + } + : undefined, + }; + + const proxyURL = getProxyForUrl(url); + if (proxyURL) { + if (url.startsWith('http:')) { + const proxy = URL.parse(proxyURL); + options = { + path: options.href, + host: proxy.hostname, + port: proxy.port, + }; + } else { + const parsedProxyURL = URL.parse(proxyURL); + + const proxyOptions = { + ...parsedProxyURL, + secureProxy: parsedProxyURL.protocol === 'https:', + } as HttpsProxyAgentOptions; + + options.agent = createHttpsProxyAgent(proxyOptions); + options.rejectUnauthorized = false; + } + } + + const requestCallback = (res: http.IncomingMessage): void => { + if ( + res.statusCode && + res.statusCode >= 300 && + res.statusCode < 400 && + res.headers.location + ) { + httpRequest(res.headers.location, method, response); + } else { + response(res); + } + }; + const request = + options.protocol === 'https:' + ? https.request(options, requestCallback) + : http.request(options, requestCallback); + request.end(); + return request; +} diff --git a/remote/test/puppeteer/src/node/BrowserRunner.ts b/remote/test/puppeteer/src/node/BrowserRunner.ts new file mode 100644 index 0000000000..c18350a6da --- /dev/null +++ b/remote/test/puppeteer/src/node/BrowserRunner.ts @@ -0,0 +1,359 @@ +/** + * 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 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 '../util/assert.js'; +import {Connection} from '../common/Connection.js'; +import {debug} from '../common/Debug.js'; +import {TimeoutError} from '../common/Errors.js'; +import { + debugError, + addEventListener, + PuppeteerEventListener, + removeEventListeners, +} from '../common/util.js'; +import {isErrnoException, isErrorLike} from '../util/ErrorLike.js'; +import {Product} from '../common/Product.js'; +import {NodeWebSocketTransport as WebSocketTransport} from '../node/NodeWebSocketTransport.js'; +import {LaunchOptions} from './LaunchOptions.js'; +import {PipeTransport} from './PipeTransport.js'; + +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. +If you think this is a bug, please report it on the Puppeteer issue tracker.`; + +/** + * @internal + */ +export class BrowserRunner { + #product: Product; + #executablePath: string; + #processArguments: string[]; + #userDataDir: string; + #isTempUserDataDir?: boolean; + #closed = true; + #listeners: PuppeteerEventListener[] = []; + #processClosing!: Promise<void>; + + proc?: childProcess.ChildProcess; + connection?: Connection; + + constructor( + product: Product, + executablePath: string, + processArguments: string[], + userDataDir: string, + isTempUserDataDir?: boolean + ) { + this.#product = product; + this.#executablePath = executablePath; + this.#processArguments = processArguments; + this.#userDataDir = userDataDir; + this.#isTempUserDataDir = isTempUserDataDir; + } + + start(options: LaunchOptions): void { + const {handleSIGINT, handleSIGTERM, handleSIGHUP, dumpio, env, pipe} = + options; + let stdio: Array<'ignore' | 'pipe'>; + if (pipe) { + if (dumpio) { + stdio = ['ignore', 'pipe', 'pipe', 'pipe', 'pipe']; + } else { + stdio = ['ignore', 'ignore', 'ignore', 'pipe', 'pipe']; + } + } else { + if (dumpio) { + stdio = ['pipe', 'pipe', 'pipe']; + } else { + stdio = ['pipe', 'ignore', '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, reject) => { + this.proc!.once('exit', async () => { + this.#closed = true; + // Cleanup as processes exit. + if (this.#isTempUserDataDir) { + try { + await removeFolderAsync(this.#userDataDir); + fulfill(); + } catch (error) { + debugError(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) { + debugError(error); + reject(error); + } + } + + fulfill(); + } + }); + }); + this.#listeners = [addEventListener(process, 'exit', this.kill.bind(this))]; + if (handleSIGINT) { + this.#listeners.push( + addEventListener(process, 'SIGINT', () => { + this.kill(); + process.exit(130); + }) + ); + } + if (handleSIGTERM) { + this.#listeners.push( + addEventListener(process, 'SIGTERM', this.close.bind(this)) + ); + } + if (handleSIGHUP) { + this.#listeners.push( + addEventListener(process, 'SIGHUP', this.close.bind(this)) + ); + } + } + + close(): Promise<void> { + if (this.#closed) { + return Promise.resolve(); + } + if (this.#isTempUserDataDir) { + this.kill(); + } else if (this.connection) { + // Attempt to close the browser gracefully + this.connection.send('Browser.close').catch(error => { + debugError(error); + this.kill(); + }); + } + // Cleanup this listener last, as that makes sure the full callback runs. If we + // perform this earlier, then the previous function calls would not happen. + removeEventListeners(this.#listeners); + return this.#processClosing; + } + + kill(): void { + // If the process failed to launch (for example if the browser executable path + // is invalid), then the process does not get a pid assigned. A call to + // `proc.kill` would error, as the `pid` to-be-killed can not be found. + if (this.proc && this.proc.pid && pidExists(this.proc.pid)) { + const proc = this.proc; + try { + if (process.platform === 'win32') { + childProcess.exec(`taskkill /pid ${this.proc.pid} /T /F`, error => { + if (error) { + // taskkill can fail to kill the process e.g. due to missing permissions. + // Let's kill the process via Node API. This delays killing of all child + // processes of `this.proc` until the main Node.js process dies. + proc.kill(); + } + }); + } else { + // on linux the process group can be killed with the group id prefixed with + // a minus sign. The process group id is the group leader's pid. + const processGroupId = -this.proc.pid; + + try { + process.kill(processGroupId, 'SIGKILL'); + } catch (error) { + // Killing the process group can fail due e.g. to missing permissions. + // Let's kill the process via Node API. This delays killing of all child + // processes of `this.proc` until the main Node.js process dies. + proc.kill('SIGKILL'); + } + } + } catch (error) { + throw new Error( + `${PROCESS_ERROR_EXPLANATION}\nError cause: ${ + isErrorLike(error) ? error.stack : error + }` + ); + } + } + + // Attempt to remove temporary profile directory to avoid littering. + try { + if (this.#isTempUserDataDir) { + removeFolder.sync(this.#userDataDir); + } + } catch (error) {} + + // Cleanup this listener last, as that makes sure the full callback runs. If we + // perform this earlier, then the previous function calls would not happen. + removeEventListeners(this.#listeners); + } + + async setupConnection(options: { + usePipe?: boolean; + timeout: number; + slowMo: number; + preferredRevision: string; + }): Promise<Connection> { + assert(this.proc, 'BrowserRunner not started.'); + + 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> { + assert(browserProcess.stderr, '`browserProcess` does not have stderr.'); + const rl = readline.createInterface(browserProcess.stderr); + let stderr = ''; + + return new Promise((resolve, reject) => { + const listeners = [ + addEventListener(rl, 'line', onLine), + addEventListener(rl, 'close', () => { + return onClose(); + }), + addEventListener(browserProcess, 'exit', () => { + return onClose(); + }), + addEventListener(browserProcess, 'error', error => { + return onClose(error); + }), + ]; + const timeoutId = timeout ? setTimeout(onTimeout, timeout) : 0; + + 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/main/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(); + // The RegExp matches, so this will obviously exist. + resolve(match[1]!); + } + + function cleanup(): void { + if (timeoutId) { + clearTimeout(timeoutId); + } + removeEventListeners(listeners); + } + }); +} + +function pidExists(pid: number): boolean { + try { + return process.kill(pid, 0); + } catch (error) { + if (isErrnoException(error)) { + if (error.code && error.code === 'ESRCH') { + return false; + } + } + throw error; + } +} diff --git a/remote/test/puppeteer/src/node/ChromeLauncher.ts b/remote/test/puppeteer/src/node/ChromeLauncher.ts new file mode 100644 index 0000000000..ab07b56f6e --- /dev/null +++ b/remote/test/puppeteer/src/node/ChromeLauncher.ts @@ -0,0 +1,265 @@ +import fs from 'fs'; +import path from 'path'; +import {assert} from '../util/assert.js'; +import {Browser} from '../common/Browser.js'; +import {Product} from '../common/Product.js'; +import {BrowserRunner} from './BrowserRunner.js'; +import { + BrowserLaunchArgumentOptions, + ChromeReleaseChannel, + PuppeteerNodeLaunchOptions, +} from './LaunchOptions.js'; +import { + executablePathForChannel, + ProductLauncher, + resolveExecutablePath, +} from './ProductLauncher.js'; +import {tmpdir} from './util.js'; + +/** + * @internal + */ +export class ChromeLauncher implements ProductLauncher { + /** + * @internal + */ + _projectRoot: string | undefined; + /** + * @internal + */ + _preferredRevision: string; + /** + * @internal + */ + _isPuppeteerCore: boolean; + + constructor( + projectRoot: string | undefined, + preferredRevision: string, + isPuppeteerCore: boolean + ) { + this._projectRoot = projectRoot; + this._preferredRevision = preferredRevision; + this._isPuppeteerCore = isPuppeteerCore; + } + + async launch(options: PuppeteerNodeLaunchOptions = {}): Promise<Browser> { + const { + ignoreDefaultArgs = false, + args = [], + dumpio = false, + channel, + executablePath, + pipe = false, + env = process.env, + handleSIGINT = true, + handleSIGTERM = true, + handleSIGHUP = true, + ignoreHTTPSErrors = false, + defaultViewport = {width: 800, height: 600}, + slowMo = 0, + timeout = 30000, + waitForInitialPage = true, + debuggingPort, + } = options; + + const chromeArguments = []; + if (!ignoreDefaultArgs) { + chromeArguments.push(...this.defaultArgs(options)); + } else if (Array.isArray(ignoreDefaultArgs)) { + chromeArguments.push( + ...this.defaultArgs(options).filter(arg => { + return !ignoreDefaultArgs.includes(arg); + }) + ); + } else { + chromeArguments.push(...args); + } + + if ( + !chromeArguments.some(argument => { + return argument.startsWith('--remote-debugging-'); + }) + ) { + if (pipe) { + assert( + !debuggingPort, + 'Browser should be launched with either pipe or debugging port - not both.' + ); + chromeArguments.push('--remote-debugging-pipe'); + } else { + chromeArguments.push(`--remote-debugging-port=${debuggingPort || 0}`); + } + } + + let isTempUserDataDir = false; + + // Check for the user data dir argument, which will always be set even + // with a custom directory specified via the userDataDir option. + let userDataDirIndex = chromeArguments.findIndex(arg => { + return arg.startsWith('--user-data-dir'); + }); + if (userDataDirIndex < 0) { + isTempUserDataDir = true; + chromeArguments.push( + `--user-data-dir=${await fs.promises.mkdtemp( + path.join(tmpdir(), 'puppeteer_dev_chrome_profile-') + )}` + ); + userDataDirIndex = chromeArguments.length - 1; + } + + const userDataDir = chromeArguments[userDataDirIndex]!.split('=', 2)[1]; + assert(typeof userDataDir === 'string', '`--user-data-dir` is malformed'); + + let chromeExecutable = executablePath; + if (channel) { + // executablePath is detected by channel, so it should not be specified by user. + assert( + !chromeExecutable, + '`executablePath` must not be specified when `channel` is given.' + ); + + chromeExecutable = executablePathForChannel(channel); + } else if (!chromeExecutable) { + const {missingText, executablePath} = resolveExecutablePath(this); + if (missingText) { + throw new Error(missingText); + } + chromeExecutable = executablePath; + } + + const usePipe = chromeArguments.includes('--remote-debugging-pipe'); + const runner = new BrowserRunner( + this.product, + chromeExecutable, + chromeArguments, + userDataDir, + isTempUserDataDir + ); + runner.start({ + handleSIGHUP, + handleSIGTERM, + handleSIGINT, + dumpio, + env, + pipe: usePipe, + }); + + let browser; + try { + const connection = await runner.setupConnection({ + usePipe, + timeout, + slowMo, + preferredRevision: this._preferredRevision, + }); + browser = await Browser._create( + this.product, + connection, + [], + ignoreHTTPSErrors, + defaultViewport, + runner.proc, + runner.close.bind(runner), + options.targetFilter + ); + } catch (error) { + runner.kill(); + throw error; + } + + if (waitForInitialPage) { + try { + await browser.waitForTarget( + t => { + return t.type() === 'page'; + }, + {timeout} + ); + } catch (error) { + await browser.close(); + throw error; + } + } + + return browser; + } + + defaultArgs(options: BrowserLaunchArgumentOptions = {}): string[] { + const chromeArguments = [ + '--allow-pre-commit-input', + '--disable-background-networking', + '--enable-features=NetworkServiceInProcess2', + '--disable-background-timer-throttling', + '--disable-backgrounding-occluded-windows', + '--disable-breakpad', + '--disable-client-side-phishing-detection', + '--disable-component-extensions-with-background-pages', + '--disable-default-apps', + '--disable-dev-shm-usage', + '--disable-extensions', + // TODO: remove AvoidUnnecessaryBeforeUnloadCheckSync below + // once crbug.com/1324138 is fixed and released. + // AcceptCHFrame disabled because of crbug.com/1348106. + '--disable-features=Translate,BackForwardCache,AcceptCHFrame,AvoidUnnecessaryBeforeUnloadCheckSync', + '--disable-hang-monitor', + '--disable-ipc-flooding-protection', + '--disable-popup-blocking', + '--disable-prompt-on-repost', + '--disable-renderer-backgrounding', + '--disable-sync', + '--force-color-profile=srgb', + '--metrics-recording-only', + '--no-first-run', + '--enable-automation', + '--password-store=basic', + '--use-mock-keychain', + // TODO(sadym): remove '--enable-blink-features=IdleDetection' + // once IdleDetection is turned on by default. + '--enable-blink-features=IdleDetection', + '--export-tagged-pdf', + ]; + const { + devtools = false, + headless = !devtools, + args = [], + userDataDir, + } = options; + if (userDataDir) { + chromeArguments.push(`--user-data-dir=${path.resolve(userDataDir)}`); + } + if (devtools) { + chromeArguments.push('--auto-open-devtools-for-tabs'); + } + if (headless) { + chromeArguments.push( + headless === 'chrome' ? '--headless=chrome' : '--headless', + '--hide-scrollbars', + '--mute-audio' + ); + } + if ( + args.every(arg => { + return arg.startsWith('-'); + }) + ) { + chromeArguments.push('about:blank'); + } + chromeArguments.push(...args); + return chromeArguments; + } + + executablePath(channel?: ChromeReleaseChannel): string { + if (channel) { + return executablePathForChannel(channel); + } else { + const results = resolveExecutablePath(this); + return results.executablePath; + } + } + + get product(): Product { + return 'chrome'; + } +} diff --git a/remote/test/puppeteer/src/node/FirefoxLauncher.ts b/remote/test/puppeteer/src/node/FirefoxLauncher.ts new file mode 100644 index 0000000000..c27f1b8d8b --- /dev/null +++ b/remote/test/puppeteer/src/node/FirefoxLauncher.ts @@ -0,0 +1,502 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import {assert} from '../util/assert.js'; +import {Browser} from '../common/Browser.js'; +import {Product} from '../common/Product.js'; +import {BrowserFetcher} from './BrowserFetcher.js'; +import {BrowserRunner} from './BrowserRunner.js'; +import { + BrowserLaunchArgumentOptions, + PuppeteerNodeLaunchOptions, +} from './LaunchOptions.js'; +import {ProductLauncher, resolveExecutablePath} from './ProductLauncher.js'; +import {tmpdir} from './util.js'; + +/** + * @internal + */ +export class FirefoxLauncher implements ProductLauncher { + /** + * @internal + */ + _projectRoot: string | undefined; + /** + * @internal + */ + _preferredRevision: string; + /** + * @internal + */ + _isPuppeteerCore: boolean; + + constructor( + projectRoot: string | undefined, + preferredRevision: string, + isPuppeteerCore: boolean + ) { + this._projectRoot = projectRoot; + this._preferredRevision = preferredRevision; + this._isPuppeteerCore = isPuppeteerCore; + } + + async launch(options: PuppeteerNodeLaunchOptions = {}): Promise<Browser> { + const { + ignoreDefaultArgs = false, + args = [], + dumpio = false, + executablePath = null, + pipe = false, + env = process.env, + handleSIGINT = true, + handleSIGTERM = true, + handleSIGHUP = true, + ignoreHTTPSErrors = false, + defaultViewport = {width: 800, height: 600}, + slowMo = 0, + timeout = 30000, + extraPrefsFirefox = {}, + waitForInitialPage = true, + debuggingPort = null, + } = options; + + const firefoxArguments = []; + if (!ignoreDefaultArgs) { + firefoxArguments.push(...this.defaultArgs(options)); + } else if (Array.isArray(ignoreDefaultArgs)) { + firefoxArguments.push( + ...this.defaultArgs(options).filter(arg => { + return !ignoreDefaultArgs.includes(arg); + }) + ); + } else { + firefoxArguments.push(...args); + } + + if ( + !firefoxArguments.some(argument => { + return argument.startsWith('--remote-debugging-'); + }) + ) { + if (pipe) { + assert( + debuggingPort === null, + 'Browser should be launched with either pipe or debugging port - not both.' + ); + } + firefoxArguments.push(`--remote-debugging-port=${debuggingPort || 0}`); + } + + let userDataDir: string | undefined; + 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) { + userDataDir = firefoxArguments[profileArgIndex + 1]; + if (!userDataDir || !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(userDataDir); + } + + await this._updateRevision(); + let firefoxExecutable = executablePath; + if (!executablePath) { + const {missingText, executablePath} = resolveExecutablePath(this); + if (missingText) { + throw new Error(missingText); + } + firefoxExecutable = executablePath; + } + + if (!firefoxExecutable) { + throw new Error('firefoxExecutable is not found.'); + } + + const runner = new BrowserRunner( + this.product, + firefoxExecutable, + firefoxArguments, + userDataDir, + isTempUserDataDir + ); + runner.start({ + handleSIGHUP, + handleSIGTERM, + handleSIGINT, + dumpio, + env, + pipe, + }); + + let browser; + try { + const connection = await runner.setupConnection({ + usePipe: pipe, + timeout, + slowMo, + preferredRevision: this._preferredRevision, + }); + browser = await Browser._create( + this.product, + connection, + [], + ignoreHTTPSErrors, + defaultViewport, + runner.proc, + runner.close.bind(runner), + options.targetFilter + ); + } catch (error) { + runner.kill(); + throw error; + } + + if (waitForInitialPage) { + try { + await browser.waitForTarget( + t => { + return t.type() === 'page'; + }, + {timeout} + ); + } catch (error) { + await browser.close(); + throw error; + } + } + + return browser; + } + + executablePath(): string { + return resolveExecutablePath(this).executablePath; + } + + async _updateRevision(): Promise<void> { + // replace 'latest' placeholder with actual downloaded revision + if (this._preferredRevision === 'latest') { + if (!this._projectRoot) { + throw new Error( + '_projectRoot is undefined. Unable to create a BrowserFetcher.' + ); + } + const browserFetcher = new BrowserFetcher(this._projectRoot, { + product: this.product, + }); + const localRevisions = await browserFetcher.localRevisions(); + if (localRevisions[0]) { + this._preferredRevision = localRevisions[0]; + } + } + } + + get product(): Product { + return 'firefox'; + } + + defaultArgs(options: BrowserLaunchArgumentOptions = {}): string[] { + const { + devtools = false, + headless = !devtools, + args = [], + userDataDir = null, + } = options; + + const firefoxArguments = ['--no-remote']; + + switch (os.platform()) { + case 'darwin': + firefoxArguments.push('--foreground'); + break; + case 'win32': + firefoxArguments.push('--wait-for-browser'); + break; + } + if (userDataDir) { + firefoxArguments.push('--profile'); + firefoxArguments.push(userDataDir); + } + if (headless) { + firefoxArguments.push('--headless'); + } + if (devtools) { + firefoxArguments.push('--devtools'); + } + if ( + args.every(arg => { + return arg.startsWith('-'); + }) + ) { + firefoxArguments.push('about:blank'); + } + firefoxArguments.push(...args); + return firefoxArguments; + } + + defaultPreferences(extraPrefs: {[x: string]: unknown}): { + [x: string]: unknown; + } { + const server = 'dummy.test'; + + const defaultPrefs = { + // Make sure Shield doesn't hit the network. + 'app.normandy.api_url': '', + // Disable Firefox old build background check + 'app.update.checkInstallTime': false, + // Disable automatically upgrading Firefox + 'app.update.disabledForTesting': true, + + // Increase the APZ content response timeout to 1 minute + 'apz.content_response_timeout': 60000, + + // Prevent various error message on the console + // jest-puppeteer asserts that no error message is emitted by the console + 'browser.contentblocking.features.standard': + '-tp,tpPrivate,cookieBehavior0,-cm,-fp', + + // Enable the dump function: which sends messages to the system + // console + // https://bugzilla.mozilla.org/show_bug.cgi?id=1543115 + 'browser.dom.window.dump.enabled': true, + // Disable topstories + 'browser.newtabpage.activity-stream.feeds.system.topstories': false, + // Always display a blank page + 'browser.newtabpage.enabled': false, + // Background thumbnails in particular cause grief: and disabling + // thumbnails in general cannot hurt + 'browser.pagethumbnails.capturing_disabled': true, + + // Disable safebrowsing components. + 'browser.safebrowsing.blockedURIs.enabled': false, + 'browser.safebrowsing.downloads.enabled': false, + 'browser.safebrowsing.malware.enabled': false, + 'browser.safebrowsing.passwords.enabled': false, + 'browser.safebrowsing.phishing.enabled': false, + + // Disable updates to search engines. + 'browser.search.update': false, + // Do not restore the last open set of tabs if the browser has crashed + 'browser.sessionstore.resume_from_crash': false, + // Skip check for default browser on startup + 'browser.shell.checkDefaultBrowser': false, + + // Disable newtabpage + 'browser.startup.homepage': 'about:blank', + // Do not redirect user when a milstone upgrade of Firefox is detected + 'browser.startup.homepage_override.mstone': 'ignore', + // Start with a blank page about:blank + 'browser.startup.page': 0, + + // Do not allow background tabs to be zombified on Android: otherwise for + // tests that open additional tabs: the test harness tab itself might get + // unloaded + 'browser.tabs.disableBackgroundZombification': false, + // Do not warn when closing all other open tabs + 'browser.tabs.warnOnCloseOtherTabs': false, + // Do not warn when multiple tabs will be opened + 'browser.tabs.warnOnOpen': false, + + // Disable the UI tour. + 'browser.uitour.enabled': false, + // Turn off search suggestions in the location bar so as not to trigger + // network connections. + 'browser.urlbar.suggest.searches': false, + // Disable first run splash page on Windows 10 + 'browser.usedOnWindows10.introURL': '', + // Do not warn on quitting Firefox + 'browser.warnOnQuit': false, + + // Defensively disable data reporting systems + 'datareporting.healthreport.documentServerURI': `http://${server}/dummy/healthreport/`, + 'datareporting.healthreport.logging.consoleEnabled': false, + 'datareporting.healthreport.service.enabled': false, + 'datareporting.healthreport.service.firstRun': false, + 'datareporting.healthreport.uploadEnabled': false, + + // Do not show datareporting policy notifications which can interfere with tests + 'datareporting.policy.dataSubmissionEnabled': false, + 'datareporting.policy.dataSubmissionPolicyBypassNotification': true, + + // DevTools JSONViewer sometimes fails to load dependencies with its require.js. + // This doesn't affect Puppeteer but spams console (Bug 1424372) + 'devtools.jsonview.enabled': false, + + // Disable popup-blocker + 'dom.disable_open_during_load': false, + + // Enable the support for File object creation in the content process + // Required for |Page.setFileInputFiles| protocol method. + 'dom.file.createInChild': true, + + // Disable the ProcessHangMonitor + 'dom.ipc.reportProcessHangs': false, + + // Disable slow script dialogues + 'dom.max_chrome_script_run_time': 0, + 'dom.max_script_run_time': 0, + + // Only load extensions from the application and user profile + // AddonManager.SCOPE_PROFILE + AddonManager.SCOPE_APPLICATION + 'extensions.autoDisableScopes': 0, + 'extensions.enabledScopes': 5, + + // Disable metadata caching for installed add-ons by default + 'extensions.getAddons.cache.enabled': false, + + // Disable installing any distribution extensions or add-ons. + 'extensions.installDistroAddons': false, + + // Disabled screenshots extension + 'extensions.screenshots.disabled': true, + + // Turn off extension updates so they do not bother tests + 'extensions.update.enabled': false, + + // Turn off extension updates so they do not bother tests + 'extensions.update.notifyUser': false, + + // Make sure opening about:addons will not hit the network + 'extensions.webservice.discoverURL': `http://${server}/dummy/discoveryURL`, + + // Temporarily force disable BFCache in parent (https://bit.ly/bug-1732263) + 'fission.bfcacheInParent': false, + + // Force all web content to use a single content process + 'fission.webContentIsolationStrategy': 0, + + // Allow the application to have focus even it runs in the background + 'focusmanager.testmode': true, + // Disable useragent updates + 'general.useragent.updates.enabled': false, + // Always use network provider for geolocation tests so we bypass the + // macOS dialog raised by the corelocation provider + 'geo.provider.testing': true, + // Do not scan Wifi + 'geo.wifi.scan': false, + // No hang monitor + 'hangmonitor.timeout': 0, + // Show chrome errors and warnings in the error console + 'javascript.options.showInConsole': true, + + // Disable download and usage of OpenH264: and Widevine plugins + 'media.gmp-manager.updateEnabled': false, + // Prevent various error message on the console + // jest-puppeteer asserts that no error message is emitted by the console + 'network.cookie.cookieBehavior': 0, + + // Disable experimental feature that is only available in Nightly + 'network.cookie.sameSite.laxByDefault': false, + + // Do not prompt for temporary redirects + 'network.http.prompt-temp-redirect': false, + + // Disable speculative connections so they are not reported as leaking + // when they are hanging around + 'network.http.speculative-parallel-limit': 0, + + // Do not automatically switch between offline and online + 'network.manage-offline-status': false, + + // Make sure SNTP requests do not hit the network + 'network.sntp.pools': server, + + // Disable Flash. + 'plugin.state.flash': 0, + + 'privacy.trackingprotection.enabled': false, + + // Can be removed once Firefox 89 is no longer supported + // https://bugzilla.mozilla.org/show_bug.cgi?id=1710839 + 'remote.enabled': true, + + // Don't do network connections for mitm priming + 'security.certerrors.mitm.priming.enabled': false, + // Local documents have access to all other local documents, + // including directory listings + 'security.fileuri.strict_origin_policy': false, + // Do not wait for the notification button security delay + 'security.notification_enable_delay': 0, + + // Ensure blocklist updates do not hit the network + 'services.settings.server': `http://${server}/dummy/blocklist/`, + + // Do not automatically fill sign-in forms with known usernames and + // passwords + 'signon.autofillForms': false, + // Disable password capture, so that tests that include forms are not + // influenced by the presence of the persistent doorhanger notification + 'signon.rememberSignons': false, + + // Disable first-run welcome page + 'startup.homepage_welcome_url': 'about:blank', + + // Disable first-run welcome page + 'startup.homepage_welcome_url.additional': '', + + // Disable browser animations (tabs, fullscreen, sliding alerts) + 'toolkit.cosmeticAnimations.enabled': false, + + // Prevent starting into safe mode after application crashes + 'toolkit.startup.max_resumed_crashes': -1, + }; + + 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 lines = Object.entries(prefs).map(([key, value]) => { + return `user_pref(${JSON.stringify(key)}, ${JSON.stringify(value)});`; + }); + + await fs.promises.writeFile( + 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 fs.promises.copyFile(prefsPath, prefsBackupPath); + } + } + + async _createProfile(extraPrefs: {[x: string]: unknown}): Promise<string> { + const temporaryProfilePath = await fs.promises.mkdtemp( + path.join(tmpdir(), 'puppeteer_dev_firefox_profile-') + ); + + const prefs = this.defaultPreferences(extraPrefs); + await this.writePreferences(prefs, temporaryProfilePath); + + return temporaryProfilePath; + } +} diff --git a/remote/test/puppeteer/src/node/LaunchOptions.ts b/remote/test/puppeteer/src/node/LaunchOptions.ts new file mode 100644 index 0000000000..ab10642d1b --- /dev/null +++ b/remote/test/puppeteer/src/node/LaunchOptions.ts @@ -0,0 +1,144 @@ +/** + * 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 {BrowserConnectOptions} from '../common/BrowserConnector.js'; +import {Product} from '../common/Product.js'; + +/** + * Launcher options that only apply to Chrome. + * + * @public + */ +export interface BrowserLaunchArgumentOptions { + /** + * Whether to run the browser in headless mode. + * @defaultValue true + */ + headless?: boolean | 'chrome'; + /** + * Path to a user data directory. + * {@link https://chromium.googlesource.com/chromium/src/+/refs/heads/main/docs/user_data_dir.md | see the Chromium docs} + * for more info. + */ + userDataDir?: string; + /** + * Whether to auto-open a DevTools panel for each tab. If this is set to + * `true`, then `headless` will be forced to `false`. + * @defaultValue `false` + */ + devtools?: boolean; + /** + * + */ + debuggingPort?: number; + /** + * Additional command line arguments to pass to the browser instance. + */ + args?: string[]; +} +/** + * @public + */ +export type ChromeReleaseChannel = + | 'chrome' + | 'chrome-beta' + | 'chrome-canary' + | 'chrome-dev'; + +/** + * Generic launch options that can be passed when launching any browser. + * @public + */ +export interface LaunchOptions { + /** + * Chrome Release Channel + */ + channel?: ChromeReleaseChannel; + /** + * Path to a browser executable to use instead of the bundled Chromium. Note + * that Puppeteer is only guaranteed to work with the bundled Chromium, so use + * this setting at your own risk. + */ + executablePath?: string; + /** + * If `true`, do not use `puppeteer.defaultArgs()` when creating a browser. If + * an array is provided, these args will be filtered out. Use this with care - + * you probably want the default arguments Puppeteer uses. + * @defaultValue false + */ + ignoreDefaultArgs?: boolean | string[]; + /** + * Close the browser process on `Ctrl+C`. + * @defaultValue `true` + */ + handleSIGINT?: boolean; + /** + * Close the browser process on `SIGTERM`. + * @defaultValue `true` + */ + handleSIGTERM?: boolean; + /** + * Close the browser process on `SIGHUP`. + * @defaultValue `true` + */ + handleSIGHUP?: boolean; + /** + * Maximum time in milliseconds to wait for the browser to start. + * Pass `0` to disable the timeout. + * @defaultValue 30000 (30 seconds). + */ + timeout?: number; + /** + * If true, pipes the browser process stdout and stderr to `process.stdout` + * and `process.stderr`. + * @defaultValue false + */ + dumpio?: boolean; + /** + * Specify environment variables that will be visible to the browser. + * @defaultValue The contents of `process.env`. + */ + env?: Record<string, string | undefined>; + /** + * Connect to a browser over a pipe instead of a WebSocket. + * @defaultValue false + */ + pipe?: boolean; + /** + * Which browser to launch. + * @defaultValue `chrome` + */ + product?: Product; + /** + * {@link https://searchfox.org/mozilla-release/source/modules/libpref/init/all.js | Additional preferences } that can be passed when launching with Firefox. + */ + extraPrefsFirefox?: Record<string, unknown>; + /** + * Whether to wait for the initial page to be ready. + * Useful when a user explicitly disables that (e.g. `--no-startup-window` for Chrome). + * @defaultValue true + */ + waitForInitialPage?: boolean; +} + +/** + * Utility type exposed to enable users to define options that can be passed to + * `puppeteer.launch` without having to list the set of all types. + * @public + */ +export type PuppeteerNodeLaunchOptions = BrowserLaunchArgumentOptions & + LaunchOptions & + BrowserConnectOptions; diff --git a/remote/test/puppeteer/src/node/NodeWebSocketTransport.ts b/remote/test/puppeteer/src/node/NodeWebSocketTransport.ts new file mode 100644 index 0000000000..fbe8e7c92c --- /dev/null +++ b/remote/test/puppeteer/src/node/NodeWebSocketTransport.ts @@ -0,0 +1,69 @@ +/** + * Copyright 2018 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 NodeWebSocket from 'ws'; +import {ConnectionTransport} from '../common/ConnectionTransport.js'; +import {packageVersion} from '../generated/version.js'; + +/** + * @internal + */ +export class NodeWebSocketTransport implements ConnectionTransport { + static create(url: string): Promise<NodeWebSocketTransport> { + return new Promise((resolve, reject) => { + const ws = new NodeWebSocket(url, [], { + followRedirects: true, + perMessageDeflate: false, + maxPayload: 256 * 1024 * 1024, // 256Mb + headers: { + 'User-Agent': `Puppeteer ${packageVersion}`, + }, + }); + + ws.addEventListener('open', () => { + return resolve(new NodeWebSocketTransport(ws)); + }); + ws.addEventListener('error', reject); + }); + } + + #ws: NodeWebSocket; + onmessage?: (message: NodeWebSocket.Data) => void; + onclose?: () => void; + + constructor(ws: NodeWebSocket) { + this.#ws = ws; + this.#ws.addEventListener('message', event => { + if (this.onmessage) { + this.onmessage.call(null, event.data); + } + }); + this.#ws.addEventListener('close', () => { + if (this.onclose) { + this.onclose.call(null); + } + }); + // Silently ignore all errors - we don't know what to do with them. + this.#ws.addEventListener('error', () => {}); + } + + send(message: string): void { + this.#ws.send(message); + } + + close(): void { + this.#ws.close(); + } +} diff --git a/remote/test/puppeteer/src/node/PipeTransport.ts b/remote/test/puppeteer/src/node/PipeTransport.ts new file mode 100644 index 0000000000..3b7b042b0e --- /dev/null +++ b/remote/test/puppeteer/src/node/PipeTransport.ts @@ -0,0 +1,93 @@ +/** + * Copyright 2018 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 {assert} from '../util/assert.js'; +import {ConnectionTransport} from '../common/ConnectionTransport.js'; +import { + addEventListener, + debugError, + PuppeteerEventListener, + removeEventListeners, +} from '../common/util.js'; + +/** + * @internal + */ +export class PipeTransport implements ConnectionTransport { + #pipeWrite: NodeJS.WritableStream; + #eventListeners: PuppeteerEventListener[]; + + #isClosed = false; + #pendingMessage = ''; + + onclose?: () => void; + onmessage?: (value: string) => void; + + constructor( + pipeWrite: NodeJS.WritableStream, + pipeRead: NodeJS.ReadableStream + ) { + this.#pipeWrite = pipeWrite; + this.#eventListeners = [ + addEventListener(pipeRead, 'data', buffer => { + return this.#dispatch(buffer); + }), + addEventListener(pipeRead, 'close', () => { + if (this.onclose) { + this.onclose.call(null); + } + }), + addEventListener(pipeRead, 'error', debugError), + addEventListener(pipeWrite, 'error', debugError), + ]; + } + + send(message: string): void { + assert(!this.#isClosed, '`PipeTransport` is closed.'); + + this.#pipeWrite.write(message); + this.#pipeWrite.write('\0'); + } + + #dispatch(buffer: Buffer): void { + assert(!this.#isClosed, '`PipeTransport` is closed.'); + + let end = buffer.indexOf('\0'); + if (end === -1) { + this.#pendingMessage += buffer.toString(); + return; + } + const message = this.#pendingMessage + buffer.toString(undefined, 0, end); + if (this.onmessage) { + this.onmessage.call(null, message); + } + + let start = end + 1; + end = buffer.indexOf('\0', start); + while (end !== -1) { + if (this.onmessage) { + this.onmessage.call(null, buffer.toString(undefined, start, end)); + } + start = end + 1; + end = buffer.indexOf('\0', start); + } + this.#pendingMessage = buffer.toString(undefined, start); + } + + close(): void { + this.#isClosed = true; + removeEventListeners(this.#eventListeners); + } +} diff --git a/remote/test/puppeteer/src/node/ProductLauncher.ts b/remote/test/puppeteer/src/node/ProductLauncher.ts new file mode 100644 index 0000000000..a1d7c84f46 --- /dev/null +++ b/remote/test/puppeteer/src/node/ProductLauncher.ts @@ -0,0 +1,217 @@ +/** + * Copyright 2017 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 os from 'os'; + +import {Browser} from '../common/Browser.js'; +import {BrowserFetcher} from './BrowserFetcher.js'; + +import { + BrowserLaunchArgumentOptions, + ChromeReleaseChannel, + PuppeteerNodeLaunchOptions, +} from './LaunchOptions.js'; + +import {Product} from '../common/Product.js'; +import {ChromeLauncher} from './ChromeLauncher.js'; +import {FirefoxLauncher} from './FirefoxLauncher.js'; +import {accessSync, existsSync} from 'fs'; + +/** + * Describes a launcher - a class that is able to create and launch a browser instance. + * @public + */ +export interface ProductLauncher { + launch(object: PuppeteerNodeLaunchOptions): Promise<Browser>; + executablePath: (path?: any) => string; + defaultArgs(object: BrowserLaunchArgumentOptions): string[]; + product: Product; +} + +/** + * @internal + */ +export function executablePathForChannel( + channel: ChromeReleaseChannel +): string { + const platform = os.platform(); + + let chromePath: string | undefined; + switch (platform) { + case 'win32': + switch (channel) { + case 'chrome': + chromePath = `${process.env['PROGRAMFILES']}\\Google\\Chrome\\Application\\chrome.exe`; + break; + case 'chrome-beta': + chromePath = `${process.env['PROGRAMFILES']}\\Google\\Chrome Beta\\Application\\chrome.exe`; + break; + case 'chrome-canary': + chromePath = `${process.env['PROGRAMFILES']}\\Google\\Chrome SxS\\Application\\chrome.exe`; + break; + case 'chrome-dev': + chromePath = `${process.env['PROGRAMFILES']}\\Google\\Chrome Dev\\Application\\chrome.exe`; + break; + } + break; + case 'darwin': + switch (channel) { + case 'chrome': + chromePath = + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; + break; + case 'chrome-beta': + chromePath = + '/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta'; + break; + case 'chrome-canary': + chromePath = + '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary'; + break; + case 'chrome-dev': + chromePath = + '/Applications/Google Chrome Dev.app/Contents/MacOS/Google Chrome Dev'; + break; + } + break; + case 'linux': + switch (channel) { + case 'chrome': + chromePath = '/opt/google/chrome/chrome'; + break; + case 'chrome-beta': + chromePath = '/opt/google/chrome-beta/chrome'; + break; + case 'chrome-dev': + chromePath = '/opt/google/chrome-unstable/chrome'; + break; + } + break; + } + + if (!chromePath) { + throw new Error( + `Unable to detect browser executable path for '${channel}' on ${platform}.` + ); + } + + // Check if Chrome exists and is accessible. + try { + accessSync(chromePath); + } catch (error) { + throw new Error( + `Could not find Google Chrome executable for channel '${channel}' at '${chromePath}'.` + ); + } + + return chromePath; +} + +/** + * @internal + */ +export function resolveExecutablePath( + launcher: ChromeLauncher | FirefoxLauncher +): { + executablePath: string; + missingText?: string; +} { + const {product, _isPuppeteerCore, _projectRoot, _preferredRevision} = + launcher; + let downloadPath: string | undefined; + // puppeteer-core doesn't take into account PUPPETEER_* env variables. + if (!_isPuppeteerCore) { + const executablePath = + process.env['PUPPETEER_EXECUTABLE_PATH'] || + process.env['npm_config_puppeteer_executable_path'] || + process.env['npm_package_config_puppeteer_executable_path']; + if (executablePath) { + const missingText = !existsSync(executablePath) + ? 'Tried to use PUPPETEER_EXECUTABLE_PATH env variable to launch browser but did not find any executable at: ' + + executablePath + : undefined; + return {executablePath, missingText}; + } + const ubuntuChromiumPath = '/usr/bin/chromium-browser'; + if ( + product === 'chrome' && + os.platform() !== 'darwin' && + os.arch() === 'arm64' && + existsSync(ubuntuChromiumPath) + ) { + return {executablePath: ubuntuChromiumPath, missingText: undefined}; + } + downloadPath = + process.env['PUPPETEER_DOWNLOAD_PATH'] || + process.env['npm_config_puppeteer_download_path'] || + process.env['npm_package_config_puppeteer_download_path']; + } + if (!_projectRoot) { + throw new Error( + '_projectRoot is undefined. Unable to create a BrowserFetcher.' + ); + } + const browserFetcher = new BrowserFetcher(_projectRoot, { + product: product, + path: downloadPath, + }); + + if (!_isPuppeteerCore && product === 'chrome') { + const revision = process.env['PUPPETEER_CHROMIUM_REVISION']; + if (revision) { + const revisionInfo = browserFetcher.revisionInfo(revision); + const missingText = !revisionInfo.local + ? 'Tried to use PUPPETEER_CHROMIUM_REVISION env variable to launch browser but did not find executable at: ' + + revisionInfo.executablePath + : undefined; + return {executablePath: revisionInfo.executablePath, missingText}; + } + } + const revisionInfo = browserFetcher.revisionInfo(_preferredRevision); + + const firefoxHelp = `Run \`PUPPETEER_PRODUCT=firefox npm install\` to download a supported Firefox browser binary.`; + const chromeHelp = `Run \`npm install\` to download the correct Chromium revision (${launcher._preferredRevision}).`; + const missingText = !revisionInfo.local + ? `Could not find expected browser (${product}) locally. ${ + product === 'chrome' ? chromeHelp : firefoxHelp + }` + : undefined; + return {executablePath: revisionInfo.executablePath, missingText}; +} + +/** + * @internal + */ +export function createLauncher( + projectRoot: string | undefined, + preferredRevision: string, + isPuppeteerCore: boolean, + product: Product = 'chrome' +): ProductLauncher { + switch (product) { + case 'firefox': + return new FirefoxLauncher( + projectRoot, + preferredRevision, + isPuppeteerCore + ); + case 'chrome': + return new ChromeLauncher( + projectRoot, + preferredRevision, + isPuppeteerCore + ); + } +} diff --git a/remote/test/puppeteer/src/node/Puppeteer.ts b/remote/test/puppeteer/src/node/Puppeteer.ts new file mode 100644 index 0000000000..374b861648 --- /dev/null +++ b/remote/test/puppeteer/src/node/Puppeteer.ts @@ -0,0 +1,244 @@ +/** + * 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 { + Puppeteer, + CommonPuppeteerSettings, + ConnectOptions, +} from '../common/Puppeteer.js'; +import {BrowserFetcher, BrowserFetcherOptions} from './BrowserFetcher.js'; +import {LaunchOptions, BrowserLaunchArgumentOptions} from './LaunchOptions.js'; +import {BrowserConnectOptions} from '../common/BrowserConnector.js'; +import {Browser} from '../common/Browser.js'; +import {createLauncher, ProductLauncher} from './ProductLauncher.js'; +import {PUPPETEER_REVISIONS} from '../revisions.js'; +import {Product} from '../common/Product.js'; + +/** + * @public + */ +export interface PuppeteerLaunchOptions + extends LaunchOptions, + BrowserLaunchArgumentOptions, + BrowserConnectOptions { + product?: Product; + extraPrefsFirefox?: Record<string, unknown>; +} + +/** + * Extends the main {@link Puppeteer} class with Node specific behaviour for + * fetching and downloading browsers. + * + * If you're using Puppeteer in a Node environment, this is the class you'll get + * when you run `require('puppeteer')` (or the equivalent ES `import`). + * + * @remarks + * The most common method to use is {@link PuppeteerNode.launch | launch}, which + * is used to launch and connect to a new browser instance. + * + * See {@link Puppeteer | the main Puppeteer class} for methods common to all + * environments, such as {@link Puppeteer.connect}. + * + * @example + * The following is a typical example of using Puppeteer to drive automation: + * + * ```ts + * const puppeteer = require('puppeteer'); + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * await page.goto('https://www.google.com'); + * // other actions... + * await browser.close(); + * })(); + * ``` + * + * Once you have created a `page` you have access to a large API to interact + * with the page, navigate, or find certain elements in that page. + * The {@link Page | `page` documentation} lists all the available methods. + * + * @public + */ +export class PuppeteerNode extends Puppeteer { + #launcher?: ProductLauncher; + #projectRoot?: string; + #productName?: Product; + + _preferredRevision: string; + + /** + * @internal + */ + constructor( + settings: { + projectRoot?: string; + preferredRevision: string; + productName?: Product; + } & CommonPuppeteerSettings + ) { + const {projectRoot, preferredRevision, productName, ...commonSettings} = + settings; + super(commonSettings); + this.#projectRoot = projectRoot; + this.#productName = productName; + this._preferredRevision = preferredRevision; + + this.connect = this.connect.bind(this); + this.launch = this.launch.bind(this); + this.executablePath = this.executablePath.bind(this); + this.defaultArgs = this.defaultArgs.bind(this); + this.createBrowserFetcher = this.createBrowserFetcher.bind(this); + } + + /** + * This method attaches Puppeteer to an existing browser instance. + * + * @param options - Set of configurable options to set on the browser. + * @returns Promise which resolves to browser instance. + */ + override connect(options: ConnectOptions): Promise<Browser> { + return super.connect(options); + } + + /** + * @internal + */ + get _productName(): Product | undefined { + return this.#productName; + } + set _productName(name: Product | undefined) { + if (this.#productName !== name) { + this._changedProduct = true; + } + this.#productName = name; + } + + /** + * Launches puppeteer and launches a browser instance with given arguments and + * options when specified. + * + * @example + * You can use `ignoreDefaultArgs` to filter out `--mute-audio` from default arguments: + * + * ```ts + * const browser = await puppeteer.launch({ + * ignoreDefaultArgs: ['--mute-audio'], + * }); + * ``` + * + * @remarks + * **NOTE** Puppeteer can also be used to control the Chrome browser, but it + * works best with the version of Chromium it is bundled with. There is no + * guarantee it will work with any other version. Use `executablePath` option + * with extreme caution. If Google Chrome (rather than Chromium) is preferred, + * a {@link https://www.google.com/chrome/browser/canary.html | Chrome Canary} + * or + * {@link https://www.chromium.org/getting-involved/dev-channel | Dev Channel} + * build is suggested. In `puppeteer.launch([options])`, any mention of + * Chromium also applies to Chrome. See + * {@link https://www.howtogeek.com/202825/what%E2%80%99s-the-difference-between-chromium-and-chrome/ | this article} + * for a description of the differences between Chromium and Chrome. + * {@link https://chromium.googlesource.com/chromium/src/+/lkgr/docs/chromium_browser_vs_google_chrome.md | This article} + * describes some differences for Linux users. + * + * @param options - Set of configurable options to set on the browser. + * @returns Promise which resolves to browser instance. + */ + launch(options: PuppeteerLaunchOptions = {}): Promise<Browser> { + if (options.product) { + this._productName = options.product; + } + return this._launcher.launch(options); + } + + /** + * @remarks + * **NOTE** `puppeteer.executablePath()` is affected by the + * `PUPPETEER_EXECUTABLE_PATH` and `PUPPETEER_CHROMIUM_REVISION` environment + * variables. + * + * @returns A path where Puppeteer expects to find the bundled browser. The + * browser binary might not be there if the download was skipped with the + * `PUPPETEER_SKIP_DOWNLOAD` environment variable. + */ + executablePath(channel?: string): string { + return this._launcher.executablePath(channel); + } + + /** + * @internal + */ + get _launcher(): ProductLauncher { + if ( + !this.#launcher || + this.#launcher.product !== this._productName || + this._changedProduct + ) { + switch (this._productName) { + case 'firefox': + this._preferredRevision = PUPPETEER_REVISIONS.firefox; + break; + case 'chrome': + default: + this._preferredRevision = PUPPETEER_REVISIONS.chromium; + } + this._changedProduct = false; + this.#launcher = createLauncher( + this.#projectRoot, + this._preferredRevision, + this._isPuppeteerCore, + this._productName + ); + } + return this.#launcher; + } + + /** + * The name of the browser that is under automation (`"chrome"` or + * `"firefox"`) + * + * @remarks + * The product is set by the `PUPPETEER_PRODUCT` environment variable or the + * `product` option in `puppeteer.launch([options])` and defaults to `chrome`. + * Firefox support is experimental. + */ + get product(): string { + return this._launcher.product; + } + + /** + * @param options - Set of configurable options to set on the browser. + * @returns The default flags that Chromium will be launched with. + */ + defaultArgs(options: BrowserLaunchArgumentOptions = {}): string[] { + return this._launcher.defaultArgs(options); + } + + /** + * @param options - Set of configurable options to specify the settings of the + * BrowserFetcher. + * @returns A new BrowserFetcher instance. + */ + createBrowserFetcher(options: BrowserFetcherOptions): BrowserFetcher { + if (!this.#projectRoot) { + throw new Error( + '_projectRoot is undefined. Unable to create a BrowserFetcher.' + ); + } + return new BrowserFetcher(this.#projectRoot, options); + } +} diff --git a/remote/test/puppeteer/src/node/install.ts b/remote/test/puppeteer/src/node/install.ts new file mode 100644 index 0000000000..9480a33470 --- /dev/null +++ b/remote/test/puppeteer/src/node/install.ts @@ -0,0 +1,232 @@ +/** + * 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 https, {RequestOptions} from 'https'; +import ProgressBar from 'progress'; +import URL from 'url'; +import puppeteer from '../puppeteer.js'; +import {PUPPETEER_REVISIONS} from '../revisions.js'; +import {PuppeteerNode} from './Puppeteer.js'; +import createHttpsProxyAgent, {HttpsProxyAgentOptions} from 'https-proxy-agent'; +import {getProxyForUrl} from 'proxy-from-env'; + +/** + * @internal + */ +const supportedProducts = { + chrome: 'Chromium', + firefox: 'Firefox Nightly', +} as const; + +/** + * @internal + */ +function getProduct(input: string): 'chrome' | 'firefox' { + if (input !== 'chrome' && input !== 'firefox') { + throw new Error(`Unsupported product ${input}`); + } + return input; +} + +/** + * @internal + */ +export async function downloadBrowser(): Promise<void> { + const downloadHost = + process.env['PUPPETEER_DOWNLOAD_HOST'] || + process.env['npm_config_puppeteer_download_host'] || + process.env['npm_package_config_puppeteer_download_host']; + const product = getProduct( + process.env['PUPPETEER_PRODUCT'] || + process.env['npm_config_puppeteer_product'] || + process.env['npm_package_config_puppeteer_product'] || + 'chrome' + ); + const downloadPath = + process.env['PUPPETEER_DOWNLOAD_PATH'] || + process.env['npm_config_puppeteer_download_path'] || + process.env['npm_package_config_puppeteer_download_path']; + const browserFetcher = (puppeteer as PuppeteerNode).createBrowserFetcher({ + product, + host: downloadHost, + path: downloadPath, + }); + const revision = await getRevision(); + await fetchBinary(revision); + + async function getRevision(): Promise<string> { + if (product === 'chrome') { + return ( + process.env['PUPPETEER_CHROMIUM_REVISION'] || + process.env['npm_config_puppeteer_chromium_revision'] || + PUPPETEER_REVISIONS.chromium + ); + } else if (product === 'firefox') { + (puppeteer as PuppeteerNode)._preferredRevision = + PUPPETEER_REVISIONS.firefox; + return getFirefoxNightlyVersion().catch(error => { + console.error(error); + process.exit(1); + }); + } else { + throw new Error(`Unsupported product ${product}`); + } + } + + function fetchBinary(revision: string) { + const revisionInfo = browserFetcher.revisionInfo(revision); + + // Do nothing if the revision is already downloaded. + if (revisionInfo.local) { + logPolitely( + `${supportedProducts[product]} is already in ${revisionInfo.folderPath}; skipping download.` + ); + return; + } + + // Override current environment proxy settings with npm configuration, if any. + const NPM_HTTPS_PROXY = + process.env['npm_config_https_proxy'] || process.env['npm_config_proxy']; + const NPM_HTTP_PROXY = + process.env['npm_config_http_proxy'] || process.env['npm_config_proxy']; + const NPM_NO_PROXY = process.env['npm_config_no_proxy']; + + if (NPM_HTTPS_PROXY) { + process.env['HTTPS_PROXY'] = NPM_HTTPS_PROXY; + } + if (NPM_HTTP_PROXY) { + process.env['HTTP_PROXY'] = NPM_HTTP_PROXY; + } + if (NPM_NO_PROXY) { + process.env['NO_PROXY'] = NPM_NO_PROXY; + } + + function onSuccess(localRevisions: string[]): void { + logPolitely( + `${supportedProducts[product]} (${revisionInfo.revision}) downloaded to ${revisionInfo.folderPath}` + ); + localRevisions = localRevisions.filter(revision => { + return revision !== revisionInfo.revision; + }); + const cleanupOldVersions = localRevisions.map(revision => { + return browserFetcher.remove(revision); + }); + Promise.all([...cleanupOldVersions]); + } + + function onError(error: Error) { + console.error( + `ERROR: Failed to set up ${supportedProducts[product]} r${revision}! Set "PUPPETEER_SKIP_DOWNLOAD" env variable to skip download.` + ); + console.error(error); + process.exit(1); + } + + let progressBar: ProgressBar | null = null; + let lastDownloadedBytes = 0; + function onProgress(downloadedBytes: number, totalBytes: number) { + if (!progressBar) { + progressBar = new ProgressBar( + `Downloading ${ + supportedProducts[product] + } r${revision} - ${toMegabytes(totalBytes)} [:bar] :percent :etas `, + { + complete: '=', + incomplete: ' ', + width: 20, + total: totalBytes, + } + ); + } + const delta = downloadedBytes - lastDownloadedBytes; + lastDownloadedBytes = downloadedBytes; + progressBar.tick(delta); + } + + return browserFetcher + .download(revisionInfo.revision, onProgress) + .then(() => { + return browserFetcher.localRevisions(); + }) + .then(onSuccess) + .catch(onError); + } + + function toMegabytes(bytes: number) { + const mb = bytes / 1024 / 1024; + return `${Math.round(mb * 10) / 10} Mb`; + } + + async function getFirefoxNightlyVersion(): Promise<string> { + const firefoxVersionsUrl = + 'https://product-details.mozilla.org/1.0/firefox_versions.json'; + + const proxyURL = getProxyForUrl(firefoxVersionsUrl); + + const requestOptions: RequestOptions = {}; + + if (proxyURL) { + const parsedProxyURL = URL.parse(proxyURL); + + const proxyOptions = { + ...parsedProxyURL, + secureProxy: parsedProxyURL.protocol === 'https:', + } as HttpsProxyAgentOptions; + + requestOptions.agent = createHttpsProxyAgent(proxyOptions); + requestOptions.rejectUnauthorized = false; + } + + const promise = new Promise<string>((resolve, reject) => { + let data = ''; + logPolitely( + `Requesting latest Firefox Nightly version from ${firefoxVersionsUrl}` + ); + https + .get(firefoxVersionsUrl, requestOptions, r => { + if (r.statusCode && r.statusCode >= 400) { + return reject(new Error(`Got status code ${r.statusCode}`)); + } + r.on('data', chunk => { + data += chunk; + }); + r.on('end', () => { + try { + const versions = JSON.parse(data); + return resolve(versions.FIREFOX_NIGHTLY); + } catch { + return reject(new Error('Firefox version not found')); + } + }); + }) + .on('error', reject); + }); + return promise; + } +} + +/** + * @internal + */ +export function logPolitely(toBeLogged: unknown): void { + const logLevel = process.env['npm_config_loglevel'] || ''; + const logLevelDisplay = ['silent', 'error', 'warn'].indexOf(logLevel) > -1; + + // eslint-disable-next-line no-console + if (!logLevelDisplay) { + console.log(toBeLogged); + } +} diff --git a/remote/test/puppeteer/src/node/util.ts b/remote/test/puppeteer/src/node/util.ts new file mode 100644 index 0000000000..c362a39e65 --- /dev/null +++ b/remote/test/puppeteer/src/node/util.ts @@ -0,0 +1,13 @@ +import * as os from 'os'; + +/** + * Gets the temporary directory, either from the environmental variable + * `PUPPETEER_TMP_DIR` or the `os.tmpdir`. + * + * @returns The temporary directory path. + * + * @internal + */ +export const tmpdir = (): string => { + return process.env['PUPPETEER_TMP_DIR'] || os.tmpdir(); +}; |