chore: migrate src/BrowserFetcher to TypeScript (#5727)

* chore: migrate src/BrowserFetcher to TypeScript
This commit is contained in:
Jack Franklin 2020-04-24 08:57:53 +01:00 committed by GitHub
parent 8509f4660e
commit 1a4e260458
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 170 additions and 178 deletions

View File

@ -467,8 +467,8 @@ This methods attaches Puppeteer to an existing browser instance.
- `options` <[Object]> - `options` <[Object]>
- `host` <[string]> A download host to be used. Defaults to `https://storage.googleapis.com`. If the `product` is `firefox`, this defaults to `https://archive.mozilla.org/pub/firefox/nightly/latest-mozilla-central`. - `host` <[string]> A download host to be used. Defaults to `https://storage.googleapis.com`. If the `product` is `firefox`, this defaults to `https://archive.mozilla.org/pub/firefox/nightly/latest-mozilla-central`.
- `path` <[string]> A path for the downloads folder. Defaults to `<root>/.local-chromium`, where `<root>` is puppeteer's package root. If the `product` is `firefox`, this defaults to `<root>/.local-firefox`. - `path` <[string]> A path for the downloads folder. Defaults to `<root>/.local-chromium`, where `<root>` is puppeteer's package root. If the `product` is `firefox`, this defaults to `<root>/.local-firefox`.
- `platform` <[string]> Possible values are: `mac`, `win32`, `win64`, `linux`. Defaults to the current platform. - `platform` <"linux"|"win32"|"mac"|"win64"> [string] for the current platform. Possible values are: `mac`, `win32`, `win64`, `linux`. Defaults to the current platform.
- `product` <[string]> Possible values are: `chrome`, `firefox`. Defaults to `chrome`. - `product` <"chrome"|"firefox"> [string] for the product to run. Possible values are: `chrome`, `firefox`. Defaults to `chrome`.
- returns: <[BrowserFetcher]> - returns: <[BrowserFetcher]>
#### puppeteer.defaultArgs([options]) #### puppeteer.defaultArgs([options])

View File

@ -14,20 +14,23 @@
* limitations under the License. * limitations under the License.
*/ */
const os = require('os'); import * as os from 'os';
const fs = require('fs'); import * as fs from 'fs';
const path = require('path'); import * as path from 'path';
const util = require('util'); import * as util from 'util';
const childProcess = require('child_process'); import * as childProcess from 'child_process';
const extract = require('extract-zip'); import * as https from 'https';
const debugFetcher = require('debug')(`puppeteer:fetcher`); import * as http from 'http';
const URL = require('url');
const {helper, assert} = require('./helper'); import * as extract from 'extract-zip';
const removeRecursive = require('rimraf'); import * as debug from 'debug';
// @ts-ignore import * as removeRecursive from 'rimraf';
const ProxyAgent = require('https-proxy-agent'); import * as URL from 'url';
// @ts-ignore import * as ProxyAgent from 'https-proxy-agent';
const getProxyForUrl = require('proxy-from-env').getProxyForUrl; import {getProxyForUrl} from 'proxy-from-env';
import {helper, assert} from './helper';
const debugFetcher = debug(`puppeteer:fetcher`);
const downloadURLs = { const downloadURLs = {
chrome: { chrome: {
@ -42,7 +45,7 @@ const downloadURLs = {
win32: '%s/firefox-%s.0a1.en-US.%s.zip', win32: '%s/firefox-%s.0a1.en-US.%s.zip',
win64: '%s/firefox-%s.0a1.en-US.%s.zip', win64: '%s/firefox-%s.0a1.en-US.%s.zip',
}, },
}; } as const;
const browserConfig = { const browserConfig = {
chrome: { chrome: {
@ -53,15 +56,12 @@ const browserConfig = {
host: 'https://archive.mozilla.org/pub/firefox/nightly/latest-mozilla-central', host: 'https://archive.mozilla.org/pub/firefox/nightly/latest-mozilla-central',
destination: '.local-firefox', destination: '.local-firefox',
} }
}; } as const;
/** type Platform = 'linux' | 'mac' | 'win32' | 'win64';
* @param {string} product type Product = 'chrome' | 'firefox';
* @param {string} platform
* @param {string} revision function archiveName(product: Product, platform: Platform, revision: string): string {
* @return {string}
*/
function archiveName(product, platform, revision) {
if (product === 'chrome') { if (product === 'chrome') {
if (platform === 'linux') if (platform === 'linux')
return 'chrome-linux'; return 'chrome-linux';
@ -74,7 +74,6 @@ function archiveName(product, platform, revision) {
} else if (product === 'firefox') { } else if (product === 'firefox') {
return platform; return platform;
} }
return null;
} }
/** /**
@ -84,7 +83,7 @@ function archiveName(product, platform, revision) {
* @param {string} revision * @param {string} revision
* @return {string} * @return {string}
*/ */
function downloadURL(product, platform, host, revision) { function downloadURL(product: Product, platform: Platform, host: string, revision: string): string {
const url = util.format(downloadURLs[product][platform], host, revision, archiveName(product, platform, revision)); const url = util.format(downloadURLs[product][platform], host, revision, archiveName(product, platform, revision));
return url; return url;
} }
@ -94,74 +93,90 @@ const mkdirAsync = helper.promisify(fs.mkdir.bind(fs));
const unlinkAsync = helper.promisify(fs.unlink.bind(fs)); const unlinkAsync = helper.promisify(fs.unlink.bind(fs));
const chmodAsync = helper.promisify(fs.chmod.bind(fs)); const chmodAsync = helper.promisify(fs.chmod.bind(fs));
function existsAsync(filePath) { function existsAsync(filePath: string): Promise<boolean> {
let fulfill = null; return new Promise(resolve => {
const promise = new Promise(x => fulfill = x); fs.access(filePath, err => resolve(!err));
fs.access(filePath, err => fulfill(!err)); });
return promise;
} }
class BrowserFetcher { /**
/** * @typedef {Object} BrowserFetcher.Options
* @param {string} projectRoot */
* @param {!BrowserFetcher.Options=} options
*/ export interface BrowserFetcherOptions {
constructor(projectRoot, options = {}) { platform?: Platform;
this._product = (options.product || 'chrome').toLowerCase(); product?: string;
path?: string;
host?: string;
}
interface BrowserFetcherRevisionInfo {
folderPath: string;
executablePath: string;
url: string;
local: boolean;
revision: string;
product: string;
}
/**
*/
export class BrowserFetcher {
private _product: Product;
private _downloadsFolder: string;
private _downloadHost: string;
private _platform: Platform;
constructor(projectRoot: string, options: BrowserFetcherOptions = {}) {
this._product = (options.product || 'chrome').toLowerCase() as Product;
assert(this._product === 'chrome' || this._product === 'firefox', `Unknown product: "${options.product}"`); assert(this._product === 'chrome' || this._product === 'firefox', `Unknown product: "${options.product}"`);
this._downloadsFolder = options.path || path.join(projectRoot, browserConfig[this._product].destination); this._downloadsFolder = options.path || path.join(projectRoot, browserConfig[this._product].destination);
this._downloadHost = options.host || browserConfig[this._product].host; this._downloadHost = options.host || browserConfig[this._product].host;
this._platform = options.platform || ''; this.setPlatform(options.platform);
if (!this._platform) {
const platform = os.platform();
if (platform === 'darwin')
this._platform = 'mac';
else if (platform === 'linux')
this._platform = 'linux';
else if (platform === 'win32')
this._platform = os.arch() === 'x64' ? 'win64' : 'win32';
assert(this._platform, 'Unsupported platform: ' + os.platform());
}
assert(downloadURLs[this._product][this._platform], 'Unsupported platform: ' + this._platform); assert(downloadURLs[this._product][this._platform], 'Unsupported platform: ' + this._platform);
} }
/** private setPlatform(platformFromOptions?: Platform): void {
* @return {string} if (platformFromOptions) {
*/ this._platform = platformFromOptions;
platform() { return;
}
const platform = os.platform();
if (platform === 'darwin')
this._platform = 'mac';
else if (platform === 'linux')
this._platform = 'linux';
else if (platform === 'win32')
this._platform = os.arch() === 'x64' ? 'win64' : 'win32';
else
assert(this._platform, 'Unsupported platform: ' + os.platform());
}
platform(): string {
return this._platform; return this._platform;
} }
/** product(): string {
* @return {string}
*/
product() {
return this._product; return this._product;
} }
/** host(): string {
* @return {string}
*/
host() {
return this._downloadHost; return this._downloadHost;
} }
/** canDownload(revision: string): Promise<boolean> {
* @param {string} revision
* @return {!Promise<boolean>}
*/
canDownload(revision) {
const url = downloadURL(this._product, this._platform, this._downloadHost, revision); const url = downloadURL(this._product, this._platform, this._downloadHost, revision);
let resolve; return new Promise(resolve => {
const promise = new Promise(x => resolve = x); const request = httpRequest(url, 'HEAD', response => {
const request = httpRequest(url, 'HEAD', response => { resolve(response.statusCode === 200);
resolve(response.statusCode === 200); });
request.on('error', error => {
console.error(error);
resolve(false);
});
}); });
request.on('error', error => {
console.error(error);
resolve(false);
});
return promise;
} }
/** /**
@ -169,7 +184,7 @@ class BrowserFetcher {
* @param {?function(number, number):void} progressCallback * @param {?function(number, number):void} progressCallback
* @return {!Promise<!BrowserFetcher.RevisionInfo>} * @return {!Promise<!BrowserFetcher.RevisionInfo>}
*/ */
async download(revision, progressCallback) { async download(revision: string, progressCallback: (x: number, y: number) => void): Promise<BrowserFetcherRevisionInfo> {
const url = downloadURL(this._product, this._platform, this._downloadHost, revision); const url = downloadURL(this._product, this._platform, this._downloadHost, revision);
const fileName = url.split('/').pop(); const fileName = url.split('/').pop();
const archivePath = path.join(this._downloadsFolder, fileName); const archivePath = path.join(this._downloadsFolder, fileName);
@ -191,30 +206,20 @@ class BrowserFetcher {
return revisionInfo; return revisionInfo;
} }
/** async localRevisions(): Promise<string[]> {
* @return {!Promise<!Array<string>>}
*/
async localRevisions() {
if (!await existsAsync(this._downloadsFolder)) if (!await existsAsync(this._downloadsFolder))
return []; return [];
const fileNames = await readdirAsync(this._downloadsFolder); const fileNames = await readdirAsync(this._downloadsFolder);
return fileNames.map(fileName => parseFolderPath(this._product, fileName)).filter(entry => entry && entry.platform === this._platform).map(entry => entry.revision); return fileNames.map(fileName => parseFolderPath(this._product, fileName)).filter(entry => entry && entry.platform === this._platform).map(entry => entry.revision);
} }
/** async remove(revision: string): Promise<void> {
* @param {string} revision
*/
async remove(revision) {
const folderPath = this._getFolderPath(revision); const folderPath = this._getFolderPath(revision);
assert(await existsAsync(folderPath), `Failed to remove: revision ${revision} is not downloaded`); assert(await existsAsync(folderPath), `Failed to remove: revision ${revision} is not downloaded`);
await new Promise(fulfill => removeRecursive(folderPath, fulfill)); await new Promise(fulfill => removeRecursive(folderPath, fulfill));
} }
/** revisionInfo(revision: string): BrowserFetcherRevisionInfo {
* @param {string} revision
* @return {!BrowserFetcher.RevisionInfo}
*/
revisionInfo(revision) {
const folderPath = this._getFolderPath(revision); const folderPath = this._getFolderPath(revision);
let executablePath = ''; let executablePath = '';
if (this._product === 'chrome') { if (this._product === 'chrome') {
@ -248,18 +253,12 @@ class BrowserFetcher {
* @param {string} revision * @param {string} revision
* @return {string} * @return {string}
*/ */
_getFolderPath(revision) { _getFolderPath(revision: string): string {
return path.join(this._downloadsFolder, this._platform + '-' + revision); return path.join(this._downloadsFolder, this._platform + '-' + revision);
} }
} }
module.exports = BrowserFetcher; function parseFolderPath(product: Product, folderPath: string): {product: string; platform: string; revision: string} | null {
/**
* @param {string} folderPath
* @return {?{product: string, platform: string, revision: string}}
*/
function parseFolderPath(product, folderPath) {
const name = path.basename(folderPath); const name = path.basename(folderPath);
const splits = name.split('-'); const splits = name.split('-');
if (splits.length !== 2) if (splits.length !== 2)
@ -276,13 +275,13 @@ function parseFolderPath(product, folderPath) {
* @param {?function(number, number):void} progressCallback * @param {?function(number, number):void} progressCallback
* @return {!Promise} * @return {!Promise}
*/ */
function downloadFile(url, destinationPath, progressCallback) { function downloadFile(url: string, destinationPath: string, progressCallback: (x: number, y: number) => void): Promise<void> {
debugFetcher(`Downloading binary from ${url}`); debugFetcher(`Downloading binary from ${url}`);
let fulfill, reject; let fulfill, reject;
let downloadedBytes = 0; let downloadedBytes = 0;
let totalBytes = 0; let totalBytes = 0;
const promise = new Promise((x, y) => { fulfill = x; reject = y; }); const promise = new Promise<void>((x, y) => { fulfill = x; reject = y; });
const request = httpRequest(url, 'GET', response => { const request = httpRequest(url, 'GET', response => {
if (response.statusCode !== 200) { if (response.statusCode !== 200) {
@ -303,21 +302,13 @@ function downloadFile(url, destinationPath, progressCallback) {
request.on('error', error => reject(error)); request.on('error', error => reject(error));
return promise; return promise;
function onData(chunk) { function onData(chunk: string): void {
downloadedBytes += chunk.length; downloadedBytes += chunk.length;
progressCallback(downloadedBytes, totalBytes); progressCallback(downloadedBytes, totalBytes);
} }
} }
function install(archivePath: string, folderPath: string): Promise<unknown> {
/**
* Install from a zip, tar.bz2 or dmg file.
*
* @param {string} archivePath
* @param {string} folderPath
* @return {!Promise<?Error>}
*/
function install(archivePath, folderPath) {
debugFetcher(`Installing ${archivePath} to ${folderPath}`); debugFetcher(`Installing ${archivePath} to ${folderPath}`);
if (archivePath.endsWith('.zip')) if (archivePath.endsWith('.zip'))
return extractZip(archivePath, folderPath); return extractZip(archivePath, folderPath);
@ -329,12 +320,7 @@ function install(archivePath, folderPath) {
throw new Error(`Unsupported archive format: ${archivePath}`); throw new Error(`Unsupported archive format: ${archivePath}`);
} }
/** async function extractZip(zipPath: string, folderPath: string): Promise<void> {
* @param {string} zipPath
* @param {string} folderPath
* @return {!Promise<?Error>}
*/
async function extractZip(zipPath, folderPath) {
try { try {
await extract(zipPath, {dir: folderPath}); await extract(zipPath, {dir: folderPath});
} catch (error) { } catch (error) {
@ -347,9 +333,10 @@ async function extractZip(zipPath, folderPath) {
* @param {string} folderPath * @param {string} folderPath
* @return {!Promise<?Error>} * @return {!Promise<?Error>}
*/ */
function extractTar(tarPath, folderPath) { function extractTar(tarPath: string, folderPath: string): Promise<unknown> {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const tar = require('tar-fs'); const tar = require('tar-fs');
// @ts-ignore // eslint-disable-next-line @typescript-eslint/no-var-requires
const bzip = require('unbzip2-stream'); const bzip = require('unbzip2-stream');
return new Promise((fulfill, reject) => { return new Promise((fulfill, reject) => {
const tarStream = tar.extract(folderPath); const tarStream = tar.extract(folderPath);
@ -368,12 +355,12 @@ function extractTar(tarPath, folderPath) {
* @param {string} folderPath * @param {string} folderPath
* @return {!Promise<?Error>} * @return {!Promise<?Error>}
*/ */
function installDMG(dmgPath, folderPath) { function installDMG(dmgPath: string, folderPath: string): Promise<void> {
let mountPath; let mountPath;
function mountAndCopy(fulfill, reject) { function mountAndCopy(fulfill: () => void, reject: (Error) => void): void {
const mountCommand = `hdiutil attach -nobrowse -noautoopen "${dmgPath}"`; const mountCommand = `hdiutil attach -nobrowse -noautoopen "${dmgPath}"`;
childProcess.exec(mountCommand, (err, stdout, stderr) => { childProcess.exec(mountCommand, (err, stdout) => {
if (err) if (err)
return reject(err); return reject(err);
const volumes = stdout.match(/\/Volumes\/(.*)/m); const volumes = stdout.match(/\/Volumes\/(.*)/m);
@ -386,7 +373,7 @@ function installDMG(dmgPath, folderPath) {
return reject(new Error(`Cannot find app in ${mountPath}`)); return reject(new Error(`Cannot find app in ${mountPath}`));
const copyPath = path.join(mountPath, appName); const copyPath = path.join(mountPath, appName);
debugFetcher(`Copying ${copyPath} to ${folderPath}`); debugFetcher(`Copying ${copyPath} to ${folderPath}`);
childProcess.exec(`cp -R "${copyPath}" "${folderPath}"`, (err, stdout) => { childProcess.exec(`cp -R "${copyPath}" "${folderPath}"`, err => {
if (err) if (err)
reject(err); reject(err);
else else
@ -396,7 +383,7 @@ function installDMG(dmgPath, folderPath) {
}); });
} }
function unmount() { function unmount(): void {
if (!mountPath) if (!mountPath)
return; return;
const unmountCommand = `hdiutil detach "${mountPath}" -quiet`; const unmountCommand = `hdiutil detach "${mountPath}" -quiet`;
@ -407,60 +394,55 @@ function installDMG(dmgPath, folderPath) {
}); });
} }
return new Promise(mountAndCopy).catch(err => { console.error(err); }).finally(unmount); return new Promise<void>(mountAndCopy).catch(err => { console.error(err); }).finally(unmount);
} }
function httpRequest(url, method, response) { function httpRequest(url: string, method: string, response: (x: http.IncomingMessage) => void): http.ClientRequest {
/** @type {Object} */ const urlParsed = URL.parse(url);
let options = URL.parse(url);
options.method = method;
const proxyURL = getProxyForUrl(url); type Options = Partial<URL.UrlWithStringQuery> & {
if (proxyURL) { method?: string;
if (url.startsWith('http:')) { agent?: ProxyAgent;
const proxy = URL.parse(proxyURL); rejectUnauthorized?: boolean;
options = { };
path: options.href,
host: proxy.hostname,
port: proxy.port,
};
} else {
/** @type {Object} */
const parsedProxyURL = URL.parse(proxyURL);
parsedProxyURL.secureProxy = parsedProxyURL.protocol === 'https:';
options.agent = new ProxyAgent(parsedProxyURL); let options: Options = {
options.rejectUnauthorized = false; ...urlParsed,
} method,
} };
const requestCallback = res => { const proxyURL = getProxyForUrl(url);
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) if (proxyURL) {
httpRequest(res.headers.location, method, response); if (url.startsWith('http:')) {
else const proxy = URL.parse(proxyURL);
response(res); options = {
}; path: options.href,
const request = options.protocol === 'https:' ? host: proxy.hostname,
require('https').request(options, requestCallback) : port: proxy.port,
require('http').request(options, requestCallback); };
request.end(); } else {
return request; const parsedProxyURL = URL.parse(proxyURL);
const proxyOptions = {
...parsedProxyURL,
secureProxy: parsedProxyURL.protocol === 'https:',
} as ProxyAgent.HttpsProxyAgentOptions;
options.agent = new ProxyAgent(proxyOptions);
options.rejectUnauthorized = false;
}
}
const requestCallback = (res: http.IncomingMessage): void => {
if (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;
} }
/**
* @typedef {Object} BrowserFetcher.Options
* @property {string=} platform
* @property {string=} product
* @property {string=} path
* @property {string=} host
*/
/**
* @typedef {Object} BrowserFetcher.RevisionInfo
* @property {string} folderPath
* @property {string} executablePath
* @property {string} url
* @property {boolean} local
* @property {string} revision
* @property {string} product
*/

View File

@ -20,7 +20,7 @@ const https = require('https');
const URL = require('url'); const URL = require('url');
const removeFolder = require('rimraf'); const removeFolder = require('rimraf');
const childProcess = require('child_process'); const childProcess = require('child_process');
const BrowserFetcher = require('./BrowserFetcher'); const {BrowserFetcher} = require('./BrowserFetcher');
const {Connection} = require('./Connection'); const {Connection} = require('./Connection');
const {Browser} = require('./Browser'); const {Browser} = require('./Browser');
const readline = require('readline'); const readline = require('readline');

View File

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
const Launcher = require('./Launcher'); const Launcher = require('./Launcher');
const BrowserFetcher = require('./BrowserFetcher'); const {BrowserFetcher} = require('./BrowserFetcher');
const Errors = require('./Errors'); const Errors = require('./Errors');
const DeviceDescriptors = require('./DeviceDescriptors'); const DeviceDescriptors = require('./DeviceDescriptors');
@ -126,12 +126,22 @@ module.exports = class {
return this._launcher.defaultArgs(options); return this._launcher.defaultArgs(options);
} }
/** TODO(jacktfranklin@): Once this file is TS we can type this
* using the BrowserFectcherOptions interface.
*/
/** /**
* @param {!BrowserFetcher.Options=} options * @typedef {Object} BrowserFetcherOptions
* @property {('linux'|'mac'|'win32'|'win64')=} platform
* @property {('chrome'|'firefox')=} product
* @property {string=} path
* @property {string=} host
*/
/**
* @param {!BrowserFetcherOptions} options
* @return {!BrowserFetcher} * @return {!BrowserFetcher}
*/ */
createBrowserFetcher(options) { createBrowserFetcher(options) {
return new BrowserFetcher(this._projectRoot, options); return new BrowserFetcher(this._projectRoot, options);
} }
}; };

View File

@ -18,7 +18,7 @@ module.exports = {
Accessibility: require('./Accessibility').Accessibility, Accessibility: require('./Accessibility').Accessibility,
Browser: require('./Browser').Browser, Browser: require('./Browser').Browser,
BrowserContext: require('./Browser').BrowserContext, BrowserContext: require('./Browser').BrowserContext,
BrowserFetcher: require('./BrowserFetcher'), BrowserFetcher: require('./BrowserFetcher').BrowserFetcher,
CDPSession: require('./Connection').CDPSession, CDPSession: require('./Connection').CDPSession,
ConsoleMessage: require('./Page').ConsoleMessage, ConsoleMessage: require('./Page').ConsoleMessage,
Coverage: require('./Coverage').Coverage, Coverage: require('./Coverage').Coverage,