playwright/src/install/browserFetcher.ts

270 lines
11 KiB
TypeScript

/**
* Copyright 2017 Google Inc. All rights reserved.
* Modifications copyright (c) Microsoft Corporation.
*
* 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 extract from 'extract-zip';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as ProgressBar from 'progress';
import { getProxyForUrl } from 'proxy-from-env';
import * as URL from 'url';
import * as util from 'util';
import { assert, getFromENV } from '../utils/utils';
import * as browserPaths from '../utils/browserPaths';
import { BrowserName, BrowserPlatform, BrowserDescriptor } from '../utils/browserPaths';
// `https-proxy-agent` v5 is written in Typescript and exposes generated types.
// However, as of June 2020, its types are generated with tsconfig that enables
// `esModuleInterop` option.
//
// As a result, we can't depend on the package unless we enable the option
// for our codebase. Instead of doing this, we abuse "require" to import module
// without types.
const ProxyAgent = require('https-proxy-agent');
const unlinkAsync = util.promisify(fs.unlink.bind(fs));
const chmodAsync = util.promisify(fs.chmod.bind(fs));
const existsAsync = (path: string): Promise<boolean> => new Promise(resolve => fs.stat(path, err => resolve(!err)));
export type OnProgressCallback = (downloadedBytes: number, totalBytes: number) => void;
const CHROMIUM_MOVE_TO_AZURE_CDN_REVISION = 792639;
function getDownloadHost(browserName: BrowserName, revision: number): string {
// Only old chromium revisions are downloaded from gbucket.
const defaultDownloadHost = browserName === 'chromium' && revision < CHROMIUM_MOVE_TO_AZURE_CDN_REVISION ? 'https://storage.googleapis.com' : 'https://playwright.azureedge.net';
const envDownloadHost: { [key: string]: string } = {
chromium: 'PLAYWRIGHT_CHROMIUM_DOWNLOAD_HOST',
firefox: 'PLAYWRIGHT_FIREFOX_DOWNLOAD_HOST',
webkit: 'PLAYWRIGHT_WEBKIT_DOWNLOAD_HOST',
};
return getFromENV(envDownloadHost[browserName]) ||
getFromENV('PLAYWRIGHT_DOWNLOAD_HOST') ||
defaultDownloadHost;
}
function getDownloadUrl(browserName: BrowserName, revision: number, platform: BrowserPlatform): string | undefined {
if (browserName === 'chromium') {
return revision < CHROMIUM_MOVE_TO_AZURE_CDN_REVISION ?
new Map<BrowserPlatform, string>([
['ubuntu18.04', '%s/chromium-browser-snapshots/Linux_x64/%d/chrome-linux.zip'],
['ubuntu20.04', '%s/chromium-browser-snapshots/Linux_x64/%d/chrome-linux.zip'],
['mac10.13', '%s/chromium-browser-snapshots/Mac/%d/chrome-mac.zip'],
['mac10.14', '%s/chromium-browser-snapshots/Mac/%d/chrome-mac.zip'],
['mac10.15', '%s/chromium-browser-snapshots/Mac/%d/chrome-mac.zip'],
['mac11', '%s/chromium-browser-snapshots/Mac/%d/chrome-mac.zip'],
['win32', '%s/chromium-browser-snapshots/Win/%d/chrome-win.zip'],
['win64', '%s/chromium-browser-snapshots/Win_x64/%d/chrome-win.zip'],
]).get(platform) :
new Map<BrowserPlatform, string>([
['ubuntu18.04', '%s/builds/chromium/%s/chromium-linux.zip'],
['ubuntu20.04', '%s/builds/chromium/%s/chromium-linux.zip'],
['mac10.13', '%s/builds/chromium/%s/chromium-mac.zip'],
['mac10.14', '%s/builds/chromium/%s/chromium-mac.zip'],
['mac10.15', '%s/builds/chromium/%s/chromium-mac.zip'],
['mac11', '%s/builds/chromium/%s/chromium-mac.zip'],
['mac11-arm64', '%s/builds/chromium/%s/chromium-mac-arm64.zip'],
['win32', '%s/builds/chromium/%s/chromium-win32.zip'],
['win64', '%s/builds/chromium/%s/chromium-win64.zip'],
]).get(platform);
}
if (browserName === 'firefox') {
const FIREFOX_NORMALIZE_CDN_NAMES_REVISION = 1140;
return revision < FIREFOX_NORMALIZE_CDN_NAMES_REVISION ?
new Map<BrowserPlatform, string>([
['ubuntu18.04', '%s/builds/firefox/%s/firefox-linux.zip'],
['ubuntu20.04', '%s/builds/firefox/%s/firefox-linux.zip'],
['mac10.13', '%s/builds/firefox/%s/firefox-mac.zip'],
['mac10.14', '%s/builds/firefox/%s/firefox-mac.zip'],
['mac10.15', '%s/builds/firefox/%s/firefox-mac.zip'],
['mac11', '%s/builds/firefox/%s/firefox-mac.zip'],
['win32', '%s/builds/firefox/%s/firefox-win32.zip'],
['win64', '%s/builds/firefox/%s/firefox-win64.zip'],
]).get(platform) :
new Map<BrowserPlatform, string>([
['ubuntu18.04', '%s/builds/firefox/%s/firefox-ubuntu-18.04.zip'],
['ubuntu20.04', '%s/builds/firefox/%s/firefox-ubuntu-18.04.zip'],
['mac10.13', '%s/builds/firefox/%s/firefox-mac-10.14.zip'],
['mac10.14', '%s/builds/firefox/%s/firefox-mac-10.14.zip'],
['mac10.15', '%s/builds/firefox/%s/firefox-mac-10.14.zip'],
['mac11', '%s/builds/firefox/%s/firefox-mac-10.14.zip'],
['mac11-arm64', '%s/builds/firefox/%s/firefox-mac-11.0-arm64.zip'],
['win32', '%s/builds/firefox/%s/firefox-win32.zip'],
['win64', '%s/builds/firefox/%s/firefox-win64.zip'],
]).get(platform);
}
if (browserName === 'webkit') {
const WEBKIT_NORMALIZE_CDN_NAMES_REVISION = 1317;
return revision < WEBKIT_NORMALIZE_CDN_NAMES_REVISION ?
new Map<BrowserPlatform, string | undefined>([
['ubuntu18.04', '%s/builds/webkit/%s/minibrowser-gtk-wpe.zip'],
['ubuntu20.04', '%s/builds/webkit/%s/minibrowser-gtk-wpe.zip'],
['mac10.13', undefined],
['mac10.14', '%s/builds/webkit/%s/minibrowser-mac-10.14.zip'],
['mac10.15', '%s/builds/webkit/%s/minibrowser-mac-10.15.zip'],
['mac11', '%s/builds/webkit/%s/minibrowser-mac-10.15.zip'],
['win32', '%s/builds/webkit/%s/minibrowser-win64.zip'],
['win64', '%s/builds/webkit/%s/minibrowser-win64.zip'],
]).get(platform) :
new Map<BrowserPlatform, string | undefined>([
['ubuntu18.04', '%s/builds/webkit/%s/webkit-ubuntu-18.04.zip'],
['ubuntu20.04', '%s/builds/webkit/%s/webkit-ubuntu-20.04.zip'],
['mac10.13', undefined],
['mac10.14', '%s/builds/webkit/%s/webkit-mac-10.14.zip'],
['mac10.15', '%s/builds/webkit/%s/webkit-mac-10.15.zip'],
['mac11', '%s/builds/webkit/%s/webkit-mac-10.15.zip'],
['mac11-arm64', '%s/builds/webkit/%s/webkit-mac-11.0-arm64.zip'],
['win32', '%s/builds/webkit/%s/webkit-win64.zip'],
['win64', '%s/builds/webkit/%s/webkit-win64.zip'],
]).get(platform);
}
}
function revisionURL(browser: BrowserDescriptor, platform = browserPaths.hostPlatform): string {
const revision = parseInt(browser.revision, 10);
const serverHost = getDownloadHost(browser.name, revision);
const urlTemplate = getDownloadUrl(browser.name, revision, platform);
assert(urlTemplate, `ERROR: Playwright does not support ${browser.name} on ${platform}`);
return util.format(urlTemplate, serverHost, browser.revision);
}
export async function downloadBrowserWithProgressBar(browsersPath: string, browser: BrowserDescriptor): Promise<boolean> {
const browserPath = browserPaths.browserDirectory(browsersPath, browser);
const progressBarName = `${browser.name} v${browser.revision}`;
if (await existsAsync(browserPath)) {
// Already downloaded.
return false;
}
let progressBar: ProgressBar;
let lastDownloadedBytes = 0;
function progress(downloadedBytes: number, totalBytes: number) {
if (!progressBar) {
progressBar = new ProgressBar(`Downloading ${progressBarName} - ${toMegabytes(totalBytes)} [:bar] :percent :etas `, {
complete: '=',
incomplete: ' ',
width: 20,
total: totalBytes,
});
}
const delta = downloadedBytes - lastDownloadedBytes;
lastDownloadedBytes = downloadedBytes;
progressBar.tick(delta);
}
const url = revisionURL(browser);
const zipPath = path.join(os.tmpdir(), `playwright-download-${browser.name}-${browserPaths.hostPlatform}-${browser.revision}.zip`);
try {
await downloadFile(url, zipPath, progress);
await extract(zipPath, { dir: browserPath});
await chmodAsync(browserPaths.executablePath(browserPath, browser)!, 0o755);
} catch (e) {
process.exitCode = 1;
throw e;
} finally {
if (await existsAsync(zipPath))
await unlinkAsync(zipPath);
}
logPolitely(`${progressBarName} downloaded to ${browserPath}`);
return true;
}
function toMegabytes(bytes: number) {
const mb = bytes / 1024 / 1024;
return `${Math.round(mb * 10) / 10} Mb`;
}
function downloadFile(url: string, destinationPath: string, progressCallback: OnProgressCallback | undefined): Promise<any> {
let fulfill: () => void = () => {};
let reject: (error: any) => void = () => {};
let downloadedBytes = 0;
let totalBytes = 0;
const promise = new Promise((x, y) => { fulfill = x; reject = y; });
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', () => fulfill());
file.on('error', error => reject(error));
response.pipe(file);
totalBytes = parseInt(response.headers['content-length'], 10);
if (progressCallback)
response.on('data', onData);
});
request.on('error', (error: any) => reject(error));
return promise;
function onData(chunk: string) {
downloadedBytes += chunk.length;
progressCallback!(downloadedBytes, totalBytes);
}
}
function httpRequest(url: string, method: string, response: (r: any) => void) {
let options: any = URL.parse(url);
options.method = method;
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: any = URL.parse(proxyURL);
parsedProxyURL.secureProxy = parsedProxyURL.protocol === 'https:';
options.agent = new ProxyAgent(parsedProxyURL);
options.rejectUnauthorized = false;
}
}
const requestCallback = (res: any) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location)
httpRequest(res.headers.location, method, response);
else
response(res);
};
const request = options.protocol === 'https:' ?
require('https').request(options, requestCallback) :
require('http').request(options, requestCallback);
request.end();
return request;
}
export function logPolitely(toBeLogged: string) {
const logLevel = process.env.npm_config_loglevel;
const logLevelDisplay = ['silent', 'error', 'warn'].indexOf(logLevel || '') > -1;
if (!logLevelDisplay)
console.log(toBeLogged); // eslint-disable-line no-console
}