chore: reuse BrowserFetcher between browsers (#177)

This commit is contained in:
Dmitry Gozman 2019-12-08 13:29:03 -08:00 committed by Pavel Feldman
parent ba4cfe908c
commit f38ab5d80f
14 changed files with 206 additions and 659 deletions

2
.gitignore vendored
View file

@ -4,7 +4,7 @@
/test/output-webkit /test/output-webkit
/test/test-user-data-dir* /test/test-user-data-dir*
/.local-chromium/ /.local-chromium/
/.local-browser/ /.local-firefox/
/.local-webkit/ /.local-webkit/
/.dev_profile* /.dev_profile*
.DS_Store .DS_Store

View file

@ -18,36 +18,12 @@
import * as extract from 'extract-zip'; import * as extract from 'extract-zip';
import * as fs from 'fs'; import * as fs from 'fs';
import * as ProxyAgent from 'https-proxy-agent'; import * as ProxyAgent from 'https-proxy-agent';
import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
// @ts-ignore
import { getProxyForUrl } from 'proxy-from-env'; import { getProxyForUrl } from 'proxy-from-env';
import * as removeRecursive from 'rimraf'; import * as removeRecursive from 'rimraf';
import * as URL from 'url'; import * as URL from 'url';
import * as util from 'util'; import { assert, helper } from './helper';
import { assert, helper } from '../helper';
import {execSync} from 'child_process';
const DEFAULT_DOWNLOAD_HOST = 'https://playwrightaccount.blob.core.windows.net';
const supportedPlatforms = ['linux', 'mac'];
const downloadURLs = {
linux: '%s/builds/webkit/%s/minibrowser-linux.zip',
mac: '%s/builds/webkit/%s/minibrowser-mac-%s.zip',
};
let cachedMacVersion = undefined;
function getMacVersion() {
if (!cachedMacVersion) {
const [major, minor] = execSync('sw_vers -productVersion').toString('utf8').trim().split('.');
cachedMacVersion = major + '.' + minor;
}
return cachedMacVersion;
}
function downloadURL(platform: string, host: string, revision: string): string {
if (platform === 'mac')
return util.format(downloadURLs['mac'], host, revision, getMacVersion());
return util.format(downloadURLs[platform], host, revision);
}
const readdirAsync = helper.promisify(fs.readdir.bind(fs)); const readdirAsync = helper.promisify(fs.readdir.bind(fs));
const mkdirAsync = helper.promisify(fs.mkdir.bind(fs)); const mkdirAsync = helper.promisify(fs.mkdir.bind(fs));
@ -61,34 +37,23 @@ function existsAsync(filePath) {
return promise; return promise;
} }
type ParamsGetter = (platform: string, revision: string) => { downloadUrl: string, executablePath: string };
export type OnProgressCallback = (downloadedBytes: number, totalBytes: number) => void;
export class BrowserFetcher { export class BrowserFetcher {
private _downloadsFolder: string; private _downloadsFolder: string;
private _downloadHost: string;
private _platform: string; private _platform: string;
private _params: ParamsGetter;
constructor(projectRoot: string, options: BrowserFetcherOptions = {}) { constructor(downloadsFolder: string, platform: string, params: ParamsGetter) {
this._downloadsFolder = options.path || path.join(projectRoot, '.local-webkit'); this._downloadsFolder = downloadsFolder;
this._downloadHost = options.host || DEFAULT_DOWNLOAD_HOST; this._platform = platform;
this._platform = options.platform || ''; this._params = params;
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 = 'linux'; // Windows gets linux binaries and uses WSL
assert(this._platform, 'Unsupported platform: ' + os.platform());
}
assert(supportedPlatforms.includes(this._platform), 'Unsupported platform: ' + this._platform);
}
platform(): string {
return this._platform;
} }
canDownload(revision: string): Promise<boolean> { canDownload(revision: string): Promise<boolean> {
const url = downloadURL(this._platform, this._downloadHost, revision); const url = this._params(this._platform, revision).downloadUrl;
let resolve; let resolve;
const promise = new Promise<boolean>(x => resolve = x); const promise = new Promise<boolean>(x => resolve = x);
const request = httpRequest(url, 'HEAD', response => { const request = httpRequest(url, 'HEAD', response => {
@ -100,8 +65,9 @@ export class BrowserFetcher {
}); });
return promise; return promise;
} }
async download(revision: string, progressCallback: ((arg0: number, arg1: number) => void) | null): Promise<BrowserFetcherRevisionInfo> {
const url = downloadURL(this._platform, this._downloadHost, revision); async download(revision: string, progressCallback: OnProgressCallback | null): Promise<BrowserFetcherRevisionInfo> {
const url = this._params(this._platform, revision).downloadUrl;
const zipPath = path.join(this._downloadsFolder, `download-${this._platform}-${revision}.zip`); const zipPath = path.join(this._downloadsFolder, `download-${this._platform}-${revision}.zip`);
const folderPath = this._getFolderPath(revision); const folderPath = this._getFolderPath(revision);
if (await existsAsync(folderPath)) if (await existsAsync(folderPath))
@ -136,14 +102,9 @@ export class BrowserFetcher {
revisionInfo(revision: string): BrowserFetcherRevisionInfo { revisionInfo(revision: string): BrowserFetcherRevisionInfo {
const folderPath = this._getFolderPath(revision); const folderPath = this._getFolderPath(revision);
let executablePath = ''; const params = this._params(this._platform, revision);
if (this._platform === 'linux' || this._platform === 'mac')
executablePath = path.join(folderPath, 'pw_run.sh');
else
throw new Error('Unsupported platform: ' + this._platform);
const url = downloadURL(this._platform, this._downloadHost, revision);
const local = fs.existsSync(folderPath); const local = fs.existsSync(folderPath);
return {revision, executablePath, folderPath, local, url}; return {revision, executablePath: path.join(folderPath, params.executablePath), folderPath, local, url: params.downloadUrl};
} }
_getFolderPath(revision: string): string { _getFolderPath(revision: string): string {
@ -157,12 +118,10 @@ function parseFolderPath(folderPath: string): { platform: string; revision: stri
if (splits.length !== 2) if (splits.length !== 2)
return null; return null;
const [platform, revision] = splits; const [platform, revision] = splits;
if (!supportedPlatforms.includes(platform))
return null;
return {platform, revision}; return {platform, revision};
} }
function downloadFile(url: string, destinationPath: string, progressCallback: ((arg0: number, arg1: number) => void) | null): Promise<any> { function downloadFile(url: string, destinationPath: string, progressCallback: OnProgressCallback | null): Promise<any> {
let fulfill, reject; let fulfill, reject;
let downloadedBytes = 0; let downloadedBytes = 0;
let totalBytes = 0; let totalBytes = 0;
@ -244,7 +203,7 @@ export type BrowserFetcherOptions = {
host ?: string, host ?: string,
}; };
type BrowserFetcherRevisionInfo = { export type BrowserFetcherRevisionInfo = {
folderPath: string, folderPath: string,
executablePath: string, executablePath: string,
url: string, url: string,

View file

@ -1,261 +0,0 @@
/**
* 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 ProxyAgent from 'https-proxy-agent';
import * as os from 'os';
import * as path from 'path';
// @ts-ignore
import { getProxyForUrl } from 'proxy-from-env';
import * as removeRecursive from 'rimraf';
import * as URL from 'url';
import * as util from 'util';
import { assert, helper } from '../helper';
const DEFAULT_DOWNLOAD_HOST = 'https://storage.googleapis.com';
const supportedPlatforms = ['mac', 'linux', 'win32', 'win64'];
const downloadURLs = {
linux: '%s/chromium-browser-snapshots/Linux_x64/%d/%s.zip',
mac: '%s/chromium-browser-snapshots/Mac/%d/%s.zip',
win32: '%s/chromium-browser-snapshots/Win/%d/%s.zip',
win64: '%s/chromium-browser-snapshots/Win_x64/%d/%s.zip',
};
function archiveName(platform: string, revision: string): string {
if (platform === 'linux')
return 'chrome-linux';
if (platform === 'mac')
return 'chrome-mac';
if (platform === 'win32' || platform === 'win64') {
// Windows archive name changed at r591479.
return parseInt(revision, 10) > 591479 ? 'chrome-win' : 'chrome-win32';
}
return null;
}
function downloadURL(platform: string, host: string, revision: string): string {
return util.format(downloadURLs[platform], host, revision, archiveName(platform, revision));
}
const readdirAsync = helper.promisify(fs.readdir.bind(fs));
const mkdirAsync = helper.promisify(fs.mkdir.bind(fs));
const unlinkAsync = helper.promisify(fs.unlink.bind(fs));
const chmodAsync = helper.promisify(fs.chmod.bind(fs));
function existsAsync(filePath) {
let fulfill = null;
const promise = new Promise(x => fulfill = x);
fs.access(filePath, err => fulfill(!err));
return promise;
}
export class BrowserFetcher {
private _downloadsFolder: string;
private _downloadHost: string;
private _platform: string;
constructor(projectRoot: string, options: BrowserFetcherOptions = {}) {
this._downloadsFolder = options.path || path.join(projectRoot, '.local-chromium');
this._downloadHost = options.host || DEFAULT_DOWNLOAD_HOST;
this._platform = 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(supportedPlatforms.includes(this._platform), 'Unsupported platform: ' + this._platform);
}
platform(): string {
return this._platform;
}
canDownload(revision: string): Promise<boolean> {
const url = downloadURL(this._platform, this._downloadHost, revision);
let resolve;
const promise = new Promise<boolean>(x => resolve = x);
const request = httpRequest(url, 'HEAD', response => {
resolve(response.statusCode === 200);
});
request.on('error', error => {
console.error(error);
resolve(false);
});
return promise;
}
async download(revision: string, progressCallback: ((arg0: number, arg1: number) => void) | null): Promise<BrowserFetcherRevisionInfo> {
const url = downloadURL(this._platform, this._downloadHost, revision);
const zipPath = path.join(this._downloadsFolder, `download-${this._platform}-${revision}.zip`);
const folderPath = this._getFolderPath(revision);
if (await existsAsync(folderPath))
return this.revisionInfo(revision);
if (!(await existsAsync(this._downloadsFolder)))
await mkdirAsync(this._downloadsFolder);
try {
await downloadFile(url, zipPath, progressCallback);
await extractZip(zipPath, folderPath);
} finally {
if (await existsAsync(zipPath))
await unlinkAsync(zipPath);
}
const revisionInfo = this.revisionInfo(revision);
if (revisionInfo)
await chmodAsync(revisionInfo.executablePath, 0o755);
return revisionInfo;
}
async localRevisions(): Promise<string[]> {
if (!await existsAsync(this._downloadsFolder))
return [];
const fileNames = await readdirAsync(this._downloadsFolder);
return fileNames.map(fileName => parseFolderPath(fileName)).filter(entry => entry && entry.platform === this._platform).map(entry => entry.revision);
}
async remove(revision: string) {
const folderPath = this._getFolderPath(revision);
assert(await existsAsync(folderPath), `Failed to remove: revision ${revision} is not downloaded`);
await new Promise(fulfill => removeRecursive(folderPath, fulfill));
}
revisionInfo(revision: string): BrowserFetcherRevisionInfo {
const folderPath = this._getFolderPath(revision);
let executablePath = '';
if (this._platform === 'mac')
executablePath = path.join(folderPath, archiveName(this._platform, revision), 'Chromium.app', 'Contents', 'MacOS', 'Chromium');
else if (this._platform === 'linux')
executablePath = path.join(folderPath, archiveName(this._platform, revision), 'chrome');
else if (this._platform === 'win32' || this._platform === 'win64')
executablePath = path.join(folderPath, archiveName(this._platform, revision), 'chrome.exe');
else
throw new Error('Unsupported platform: ' + this._platform);
const url = downloadURL(this._platform, this._downloadHost, revision);
const local = fs.existsSync(folderPath);
return {revision, executablePath, folderPath, local, url};
}
_getFolderPath(revision: string): string {
return path.join(this._downloadsFolder, this._platform + '-' + revision);
}
}
function parseFolderPath(folderPath: string): { platform: string; revision: string; } | null {
const name = path.basename(folderPath);
const splits = name.split('-');
if (splits.length !== 2)
return null;
const [platform, revision] = splits;
if (!supportedPlatforms.includes(platform))
return null;
return {platform, revision};
}
function downloadFile(url: string, destinationPath: string, progressCallback: ((arg0: number, arg1: number) => void) | null): Promise<any> {
let fulfill, reject;
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 => reject(error));
return promise;
function onData(chunk) {
downloadedBytes += chunk.length;
progressCallback(downloadedBytes, totalBytes);
}
}
function extractZip(zipPath: string, folderPath: string): Promise<Error | null> {
return new Promise((fulfill, reject) => extract(zipPath, {dir: folderPath}, err => {
if (err)
reject(err);
else
fulfill();
}));
}
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 => {
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 type BrowserFetcherOptions = {
platform?: string,
path?: string,
host ?: string,
};
type BrowserFetcherRevisionInfo = {
folderPath: string,
executablePath: string,
url: string,
local: boolean,
revision: string,
};

View file

@ -24,7 +24,7 @@ import * as readline from 'readline';
import * as removeFolder from 'rimraf'; import * as removeFolder from 'rimraf';
import * as URL from 'url'; import * as URL from 'url';
import { Browser } from './Browser'; import { Browser } from './Browser';
import { BrowserFetcher } from './BrowserFetcher'; import { BrowserFetcher, BrowserFetcherOptions } from '../browserFetcher';
import { Connection } from './Connection'; import { Connection } from './Connection';
import { TimeoutError } from '../Errors'; import { TimeoutError } from '../Errors';
import { assert, debugError, helper } from '../helper'; import { assert, debugError, helper } from '../helper';
@ -32,6 +32,7 @@ import * as types from '../types';
import { PipeTransport } from './PipeTransport'; import { PipeTransport } from './PipeTransport';
import { WebSocketTransport } from './WebSocketTransport'; import { WebSocketTransport } from './WebSocketTransport';
import { ConnectionTransport } from '../ConnectionTransport'; import { ConnectionTransport } from '../ConnectionTransport';
import * as util from 'util';
const mkdtempAsync = helper.promisify(fs.mkdtemp); const mkdtempAsync = helper.promisify(fs.mkdtemp);
const removeFolderAsync = helper.promisify(removeFolder); const removeFolderAsync = helper.promisify(removeFolder);
@ -289,7 +290,7 @@ export class Launcher {
} }
_resolveExecutablePath(): { executablePath: string; missingText: string | null; } { _resolveExecutablePath(): { executablePath: string; missingText: string | null; } {
const browserFetcher = new BrowserFetcher(this._projectRoot); const browserFetcher = createBrowserFetcher(this._projectRoot);
const revisionInfo = browserFetcher.revisionInfo(this._preferredRevision); const revisionInfo = browserFetcher.revisionInfo(this._preferredRevision);
const missingText = !revisionInfo.local ? `Chromium revision is not downloaded. Run "npm install" or "yarn install"` : null; const missingText = !revisionInfo.local ? `Chromium revision is not downloaded. Run "npm install" or "yarn install"` : null;
return {executablePath: revisionInfo.executablePath, missingText}; return {executablePath: revisionInfo.executablePath, missingText};
@ -395,3 +396,52 @@ export type LauncherBrowserOptions = {
defaultViewport?: types.Viewport | null, defaultViewport?: types.Viewport | null,
slowMo?: number, slowMo?: number,
}; };
export function createBrowserFetcher(projectRoot: string, options: BrowserFetcherOptions = {}): BrowserFetcher {
const downloadURLs = {
linux: '%s/chromium-browser-snapshots/Linux_x64/%d/%s.zip',
mac: '%s/chromium-browser-snapshots/Mac/%d/%s.zip',
win32: '%s/chromium-browser-snapshots/Win/%d/%s.zip',
win64: '%s/chromium-browser-snapshots/Win_x64/%d/%s.zip',
};
const defaultOptions = {
path: path.join(projectRoot, '.local-chromium'),
host: 'https://storage.googleapis.com',
platform: (() => {
const platform = os.platform();
if (platform === 'darwin')
return 'mac';
if (platform === 'linux')
return 'linux';
if (platform === 'win32')
return os.arch() === 'x64' ? 'win64' : 'win32';
return platform;
})()
};
options = {
...defaultOptions,
...options,
};
assert(!!downloadURLs[options.platform], 'Unsupported platform: ' + options.platform);
return new BrowserFetcher(options.path, options.platform, (platform: string, revision: string) => {
let archiveName = '';
let executablePath = '';
if (platform === 'linux') {
archiveName = 'chrome-linux';
executablePath = path.join(archiveName, 'chrome');
} else if (platform === 'mac') {
archiveName = 'chrome-mac';
executablePath = path.join(archiveName, 'Chromium.app', 'Contents', 'MacOS', 'Chromium');
} else if (platform === 'win32' || platform === 'win64') {
// Windows archive name changed at r591479.
archiveName = parseInt(revision, 10) > 591479 ? 'chrome-win' : 'chrome-win32';
executablePath = path.join(archiveName, 'chrome.exe');
}
return {
downloadUrl: util.format(downloadURLs[platform], options.host, revision, archiveName),
executablePath
};
});
}

View file

@ -15,24 +15,28 @@
* limitations under the License. * limitations under the License.
*/ */
import { Browser } from './Browser'; import { Browser } from './Browser';
import { BrowserFetcher, BrowserFetcherOptions } from './BrowserFetcher'; import { BrowserFetcher, BrowserFetcherOptions, BrowserFetcherRevisionInfo, OnProgressCallback } from '../browserFetcher';
import { ConnectionTransport } from '../ConnectionTransport'; import { ConnectionTransport } from '../ConnectionTransport';
import { DeviceDescriptors } from '../DeviceDescriptors'; import { DeviceDescriptors } from '../DeviceDescriptors';
import * as Errors from '../Errors'; import * as Errors from '../Errors';
import { Launcher, LauncherBrowserOptions, LauncherChromeArgOptions, LauncherLaunchOptions } from './Launcher'; import { Launcher, LauncherBrowserOptions, LauncherChromeArgOptions, LauncherLaunchOptions, createBrowserFetcher } from './Launcher';
import {download, RevisionInfo} from '../download';
export class Playwright { export class Playwright {
private _projectRoot: string; private _projectRoot: string;
private _launcher: Launcher; private _launcher: Launcher;
readonly _revision: string; readonly _revision: string;
downloadBrowser: (options?: { onProgress?: (downloadedBytes: number, totalBytes: number) => void; }) => Promise<RevisionInfo>;
constructor(projectRoot: string, preferredRevision: string) { constructor(projectRoot: string, preferredRevision: string) {
this._projectRoot = projectRoot; this._projectRoot = projectRoot;
this._launcher = new Launcher(projectRoot, preferredRevision); this._launcher = new Launcher(projectRoot, preferredRevision);
this._revision = preferredRevision; this._revision = preferredRevision;
this.downloadBrowser = download.bind(null, this.createBrowserFetcher(), preferredRevision, 'Chromium'); }
async downloadBrowser(options?: BrowserFetcherOptions & { onProgress?: OnProgressCallback }): Promise<BrowserFetcherRevisionInfo> {
const fetcher = this.createBrowserFetcher(options);
const revisionInfo = fetcher.revisionInfo(this._revision);
await fetcher.download(this._revision, options ? options.onProgress : undefined);
return revisionInfo;
} }
launch(options?: (LauncherLaunchOptions & LauncherChromeArgOptions & LauncherBrowserOptions) | undefined): Promise<Browser> { launch(options?: (LauncherLaunchOptions & LauncherChromeArgOptions & LauncherBrowserOptions) | undefined): Promise<Browser> {
@ -65,7 +69,7 @@ export class Playwright {
return this._launcher.defaultArgs(options); return this._launcher.defaultArgs(options);
} }
createBrowserFetcher(options?: BrowserFetcherOptions | undefined): BrowserFetcher { createBrowserFetcher(options?: BrowserFetcherOptions): BrowserFetcher {
return new BrowserFetcher(this._projectRoot, options); return createBrowserFetcher(this._projectRoot, options);
} }
} }

View file

@ -11,7 +11,7 @@ export { ExecutionContext, JSHandle } from '../javascript';
export { Request, Response } from '../network'; export { Request, Response } from '../network';
export { Browser } from './Browser'; export { Browser } from './Browser';
export { BrowserContext } from './BrowserContext'; export { BrowserContext } from './BrowserContext';
export { BrowserFetcher } from './BrowserFetcher'; export { BrowserFetcher } from '../browserFetcher';
export { CDPSession } from './Connection'; export { CDPSession } from './Connection';
export { Accessibility } from './features/accessibility'; export { Accessibility } from './features/accessibility';
export { Chromium } from './features/chromium'; export { Chromium } from './features/chromium';

View file

@ -1,22 +0,0 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
export async function download(
browserFetcher:
import('./chromium/BrowserFetcher').BrowserFetcher |
import('./firefox/BrowserFetcher').BrowserFetcher |
import('./webkit/BrowserFetcher').BrowserFetcher,
revision: string,
browserName: string,
{onProgress}: {onProgress?: (downloadedBytes: number, totalBytes: number) => void} = {}) : Promise<RevisionInfo> {
const revisionInfo = browserFetcher.revisionInfo(revision);
await browserFetcher.download(revision, onProgress);
return revisionInfo;
}
export type RevisionInfo = {
folderPath: string,
executablePath: string,
url: string,
local: boolean,
revision: string,
};

View file

@ -1,283 +0,0 @@
/**
* 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 os from 'os';
import * as fs from 'fs';
import * as path from 'path';
import * as extract from 'extract-zip';
import * as util from 'util';
import * as URL from 'url';
import {helper, assert} from '../helper';
import * as removeRecursive from 'rimraf';
// @ts-ignore
import * as ProxyAgent from 'https-proxy-agent';
// @ts-ignore
import {getProxyForUrl} from 'proxy-from-env';
const DEFAULT_DOWNLOAD_HOST = 'https://playwrightaccount.blob.core.windows.net/builds';
const downloadURLs = {
chromium: {
linux: '%s/chromium-browser-snapshots/Linux_x64/%s/%s.zip',
mac: '%s/chromium-browser-snapshots/Mac/%s/%s.zip',
win32: '%s/chromium-browser-snapshots/Win/%s/%s.zip',
win64: '%s/chromium-browser-snapshots/Win_x64/%s/%s.zip',
},
firefox: {
linux: '%s/firefox/%s/%s.zip',
mac: '%s/firefox/%s/%s.zip',
win32: '%s/firefox/%s/%s.zip',
win64: '%s/firefox/%s/%s.zip',
},
};
function archiveName(product: string, platform: string, revision: string): string {
if (product === 'chromium') {
if (platform === 'linux')
return 'chrome-linux';
if (platform === 'mac')
return 'chrome-mac';
if (platform === 'win32' || platform === 'win64') {
// Windows archive name changed at r591479.
return parseInt(revision, 10) > 591479 ? 'chrome-win' : 'chrome-win32';
}
} else if (product === 'firefox') {
if (platform === 'linux')
return 'firefox-linux';
if (platform === 'mac')
return 'firefox-mac';
if (platform === 'win32' || platform === 'win64')
return 'firefox-' + platform;
}
return null;
}
function downloadURL(product: string, platform: string, host: string, revision: string): string {
return util.format(downloadURLs[product][platform], host, revision, archiveName(product, platform, revision));
}
const readdirAsync = helper.promisify(fs.readdir.bind(fs));
const mkdirAsync = helper.promisify(fs.mkdir.bind(fs));
const unlinkAsync = helper.promisify(fs.unlink.bind(fs));
const chmodAsync = helper.promisify(fs.chmod.bind(fs));
function existsAsync(filePath) {
let fulfill = null;
const promise = new Promise(x => fulfill = x);
fs.access(filePath, err => fulfill(!err));
return promise;
}
export class BrowserFetcher {
_product: string;
_downloadsFolder: string;
_downloadHost: string;
_platform: string;
constructor(projectRoot: string, options: BrowserFetcherOptions | undefined = {}) {
this._product = (options.browser || 'chromium').toLowerCase();
assert(this._product === 'chromium' || this._product === 'firefox', `Unkown product: "${options.browser}"`);
this._downloadsFolder = options.path || path.join(projectRoot, '.local-browser');
this._downloadHost = options.host || DEFAULT_DOWNLOAD_HOST;
this._platform = 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);
}
platform(): string {
return this._platform;
}
canDownload(revision: string): Promise<boolean> {
const url = downloadURL(this._product, this._platform, this._downloadHost, revision);
let resolve;
const promise = new Promise<boolean>(x => resolve = x);
const request = httpRequest(url, 'HEAD', response => {
resolve(response.statusCode === 200);
});
request.on('error', error => {
console.error(error);
resolve(false);
});
return promise;
}
async download(revision: string, progressCallback: ((arg0: number, arg1: number) => void) | null): Promise<RevisionInfo> {
const url = downloadURL(this._product, this._platform, this._downloadHost, revision);
const zipPath = path.join(this._downloadsFolder, `download-${this._product}-${this._platform}-${revision}.zip`);
const folderPath = this._getFolderPath(revision);
if (await existsAsync(folderPath))
return this.revisionInfo(revision);
if (!(await existsAsync(this._downloadsFolder)))
await mkdirAsync(this._downloadsFolder);
try {
await downloadFile(url, zipPath, progressCallback);
await extractZip(zipPath, folderPath);
} finally {
if (await existsAsync(zipPath))
await unlinkAsync(zipPath);
}
const revisionInfo = this.revisionInfo(revision);
if (revisionInfo)
await chmodAsync(revisionInfo.executablePath, 0o755);
return revisionInfo;
}
async localRevisions(): Promise<Array<string>> {
if (!await existsAsync(this._downloadsFolder))
return [];
const fileNames = await readdirAsync(this._downloadsFolder);
return fileNames.map(fileName => parseFolderPath(fileName)).filter(entry => entry && entry.platform === this._platform).map(entry => entry.revision);
}
async remove(revision: string) {
const folderPath = this._getFolderPath(revision);
assert(await existsAsync(folderPath), `Failed to remove: revision ${revision} is not downloaded`);
await new Promise(fulfill => removeRecursive(folderPath, fulfill));
}
revisionInfo(revision: string): RevisionInfo {
const folderPath = this._getFolderPath(revision);
let executablePath = '';
if (this._product === 'chromium') {
if (this._platform === 'mac')
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')
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);
}
const url = downloadURL(this._product, this._platform, this._downloadHost, revision);
const local = fs.existsSync(folderPath);
return {revision, executablePath, folderPath, local, url};
}
_getFolderPath(revision: string): string {
return path.join(this._downloadsFolder, this._product + '-' + this._platform + '-' + revision);
}
}
function parseFolderPath(folderPath: string): { platform: string; revision: string; } | null {
const name = path.basename(folderPath);
const splits = name.split('-');
if (splits.length !== 3)
return null;
const [product, platform, revision] = splits;
if (!downloadURLs[product][platform])
return null;
return {platform, revision};
}
function downloadFile(url: string, destinationPath: string, progressCallback: ((arg0: number, arg1: number) => void) | null): Promise<any> {
let fulfill, reject;
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 => reject(error));
return promise;
function onData(chunk) {
downloadedBytes += chunk.length;
progressCallback(downloadedBytes, totalBytes);
}
}
function extractZip(zipPath: string, folderPath: string): Promise<Error | null> {
return new Promise((fulfill, reject) => extract(zipPath, {dir: folderPath}, err => {
if (err)
reject(err);
else
fulfill();
}));
}
function httpRequest(url: string, method: string, onResponse: (response: any) => void) {
const options: any = URL.parse(url);
options.method = method;
const proxyURL = getProxyForUrl(url);
if (proxyURL) {
const parsedProxyURL: any = URL.parse(proxyURL);
parsedProxyURL.secureProxy = parsedProxyURL.protocol === 'https:';
options.agent = new ProxyAgent(parsedProxyURL);
options.rejectUnauthorized = false;
}
const requestCallback = res => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location)
httpRequest(res.headers.location, method, onResponse);
else
onResponse(res);
};
const request = options.protocol === 'https:' ?
require('https').request(options, requestCallback) :
require('http').request(options, requestCallback);
request.end();
return request;
}
interface BrowserFetcherOptions {
browser?: string;
platform?: string;
path?: string;
host?: string;
}
interface RevisionInfo {
folderPath: string;
executablePath: string;
url: string;
local: boolean;
revision: string;
}

View file

@ -20,11 +20,11 @@ import * as removeFolder from 'rimraf';
import * as childProcess from 'child_process'; import * as childProcess from 'child_process';
import {Connection} from './Connection'; import {Connection} from './Connection';
import {Browser} from './Browser'; import {Browser} from './Browser';
import {BrowserFetcher} from './BrowserFetcher'; import {BrowserFetcher, BrowserFetcherOptions} from '../browserFetcher';
import * as readline from 'readline'; import * as readline from 'readline';
import * as fs from 'fs'; import * as fs from 'fs';
import * as util from 'util'; import * as util from 'util';
import {helper, debugError} from '../helper'; import {helper, debugError, assert} from '../helper';
import {TimeoutError} from '../Errors'; import {TimeoutError} from '../Errors';
import {WebSocketTransport} from './WebSocketTransport'; import {WebSocketTransport} from './WebSocketTransport';
@ -227,7 +227,7 @@ export class Launcher {
} }
_resolveExecutablePath() { _resolveExecutablePath() {
const browserFetcher = new BrowserFetcher(this._projectRoot, { browser: 'firefox' }); const browserFetcher = createBrowserFetcher(this._projectRoot);
const revisionInfo = browserFetcher.revisionInfo(this._preferredRevision); const revisionInfo = browserFetcher.revisionInfo(this._preferredRevision);
const missingText = !revisionInfo.local ? `Firefox revision is not downloaded. Run "npm install" or "yarn install"` : null; const missingText = !revisionInfo.local ? `Firefox revision is not downloaded. Run "npm install" or "yarn install"` : null;
return {executablePath: revisionInfo.executablePath, missingText}; return {executablePath: revisionInfo.executablePath, missingText};
@ -275,3 +275,46 @@ function waitForWSEndpoint(firefoxProcess: import('child_process').ChildProcess,
} }
}); });
} }
export function createBrowserFetcher(projectRoot: string, options: BrowserFetcherOptions = {}): BrowserFetcher {
const downloadURLs = {
linux: '%s/builds/firefox/%s/firefox-linux.zip',
mac: '%s/builds/firefox/%s/firefox-mac.zip',
win32: '%s/builds/firefox/%s/firefox-win32.zip',
win64: '%s/builds/firefox/%s/firefox-win64.zip',
};
const defaultOptions = {
path: path.join(projectRoot, '.local-firefox'),
host: 'https://playwrightaccount.blob.core.windows.net',
platform: (() => {
const platform = os.platform();
if (platform === 'darwin')
return 'mac';
if (platform === 'linux')
return 'linux';
if (platform === 'win32')
return os.arch() === 'x64' ? 'win64' : 'win32';
return platform;
})()
};
options = {
...defaultOptions,
...options,
};
assert(!!downloadURLs[options.platform], 'Unsupported platform: ' + options.platform);
return new BrowserFetcher(options.path, options.platform, (platform: string, revision: string) => {
let executablePath = '';
if (platform === 'linux')
executablePath = path.join('firefox', 'firefox');
else if (platform === 'mac')
executablePath = path.join('firefox', 'Nightly.app', 'Contents', 'MacOS', 'firefox');
else if (platform === 'win32' || platform === 'win64')
executablePath = path.join('firefox', 'firefox.exe');
return {
downloadUrl: util.format(downloadURLs[platform], options.host, revision),
executablePath
};
});
}

View file

@ -15,24 +15,28 @@
* limitations under the License. * limitations under the License.
*/ */
import { Browser } from './Browser'; import { Browser } from './Browser';
import { BrowserFetcher } from './BrowserFetcher'; import { BrowserFetcher, BrowserFetcherOptions, OnProgressCallback, BrowserFetcherRevisionInfo } from '../browserFetcher';
import { ConnectionTransport } from '../ConnectionTransport'; import { ConnectionTransport } from '../ConnectionTransport';
import { DeviceDescriptors } from '../DeviceDescriptors'; import { DeviceDescriptors } from '../DeviceDescriptors';
import * as Errors from '../Errors'; import * as Errors from '../Errors';
import { Launcher } from './Launcher'; import { Launcher, createBrowserFetcher } from './Launcher';
import {download, RevisionInfo} from '../download';
export class Playwright { export class Playwright {
private _projectRoot: string; private _projectRoot: string;
private _launcher: Launcher; private _launcher: Launcher;
readonly _revision: string; readonly _revision: string;
downloadBrowser: (options?: { onProgress?: (downloadedBytes: number, totalBytes: number) => void; }) => Promise<RevisionInfo>;
constructor(projectRoot: string, preferredRevision: string) { constructor(projectRoot: string, preferredRevision: string) {
this._projectRoot = projectRoot; this._projectRoot = projectRoot;
this._launcher = new Launcher(projectRoot, preferredRevision); this._launcher = new Launcher(projectRoot, preferredRevision);
this._revision = preferredRevision; this._revision = preferredRevision;
this.downloadBrowser = download.bind(null, this.createBrowserFetcher(), preferredRevision, 'Chromium'); }
async downloadBrowser(options?: BrowserFetcherOptions & { onProgress?: OnProgressCallback }): Promise<BrowserFetcherRevisionInfo> {
const fetcher = this.createBrowserFetcher(options);
const revisionInfo = fetcher.revisionInfo(this._revision);
await fetcher.download(this._revision, options ? options.onProgress : undefined);
return revisionInfo;
} }
launch(options: any): Promise<Browser> { launch(options: any): Promise<Browser> {
@ -65,7 +69,7 @@ export class Playwright {
return this._launcher.defaultArgs(options); return this._launcher.defaultArgs(options);
} }
createBrowserFetcher(options?: any | undefined): BrowserFetcher { createBrowserFetcher(options?: BrowserFetcherOptions): BrowserFetcher {
return new BrowserFetcher(this._projectRoot, { browser: 'firefox', ...options }); return createBrowserFetcher(this._projectRoot, options);
} }
} }

View file

@ -4,7 +4,7 @@
export { TimeoutError } from '../Errors'; export { TimeoutError } from '../Errors';
export { Keyboard, Mouse } from '../input'; export { Keyboard, Mouse } from '../input';
export { Browser, BrowserContext } from './Browser'; export { Browser, BrowserContext } from './Browser';
export { BrowserFetcher } from './BrowserFetcher'; export { BrowserFetcher } from '../browserFetcher';
export { Dialog } from '../dialog'; export { Dialog } from '../dialog';
export { ExecutionContext, JSHandle } from '../javascript'; export { ExecutionContext, JSHandle } from '../javascript';
export { ElementHandle } from '../dom'; export { ElementHandle } from '../dom';

View file

@ -15,12 +15,16 @@
* limitations under the License. * limitations under the License.
*/ */
import * as childProcess from 'child_process'; import * as childProcess from 'child_process';
import { debugError, helper } from '../helper'; import { debugError, helper, assert } from '../helper';
import { Browser } from './Browser'; import { Browser } from './Browser';
import { BrowserFetcher } from './BrowserFetcher'; import { BrowserFetcher, BrowserFetcherOptions } from '../browserFetcher';
import { Connection } from './Connection'; import { Connection } from './Connection';
import * as types from '../types'; import * as types from '../types';
import { PipeTransport } from './PipeTransport'; import { PipeTransport } from './PipeTransport';
import { execSync } from 'child_process';
import * as path from 'path';
import * as util from 'util';
import * as os from 'os';
const DEFAULT_ARGS = [ const DEFAULT_ARGS = [
]; ];
@ -168,7 +172,7 @@ export class Launcher {
} }
_resolveExecutablePath(): { executablePath: string; missingText: string | null; } { _resolveExecutablePath(): { executablePath: string; missingText: string | null; } {
const browserFetcher = new BrowserFetcher(this._projectRoot); const browserFetcher = createBrowserFetcher(this._projectRoot);
const revisionInfo = browserFetcher.revisionInfo(this._preferredRevision); const revisionInfo = browserFetcher.revisionInfo(this._preferredRevision);
const missingText = !revisionInfo.local ? `WebKit revision is not downloaded. Run "npm install" or "yarn install"` : null; const missingText = !revisionInfo.local ? `WebKit revision is not downloaded. Run "npm install" or "yarn install"` : null;
return {executablePath: revisionInfo.executablePath, missingText}; return {executablePath: revisionInfo.executablePath, missingText};
@ -189,3 +193,48 @@ export type LauncherLaunchOptions = {
defaultViewport?: types.Viewport | null, defaultViewport?: types.Viewport | null,
slowMo?: number, slowMo?: number,
}; };
let cachedMacVersion = undefined;
function getMacVersion() {
if (!cachedMacVersion) {
const [major, minor] = execSync('sw_vers -productVersion').toString('utf8').trim().split('.');
cachedMacVersion = major + '.' + minor;
}
return cachedMacVersion;
}
export function createBrowserFetcher(projectRoot: string, options: BrowserFetcherOptions = {}): BrowserFetcher {
const downloadURLs = {
linux: '%s/builds/webkit/%s/minibrowser-linux.zip',
mac: '%s/builds/webkit/%s/minibrowser-mac-%s.zip',
};
const defaultOptions = {
path: path.join(projectRoot, '.local-webkit'),
host: 'https://playwrightaccount.blob.core.windows.net',
platform: (() => {
const platform = os.platform();
if (platform === 'darwin')
return 'mac';
if (platform === 'linux')
return 'linux';
if (platform === 'win32')
return 'linux'; // Windows gets linux binaries and uses WSL
return platform;
})()
};
options = {
...defaultOptions,
...options,
};
assert(!!downloadURLs[options.platform], 'Unsupported platform: ' + options.platform);
return new BrowserFetcher(options.path, options.platform, (platform: string, revision: string) => {
return {
downloadUrl: (platform === 'mac') ?
util.format(downloadURLs[platform], options.host, revision, getMacVersion()) :
util.format(downloadURLs[platform], options.host, revision),
executablePath: 'pw_run.sh',
};
});
}

View file

@ -15,23 +15,27 @@
* limitations under the License. * limitations under the License.
*/ */
import { Browser } from './Browser'; import { Browser } from './Browser';
import { BrowserFetcher, BrowserFetcherOptions } from './BrowserFetcher'; import { BrowserFetcher, BrowserFetcherOptions, OnProgressCallback, BrowserFetcherRevisionInfo } from '../browserFetcher';
import { DeviceDescriptors } from '../DeviceDescriptors'; import { DeviceDescriptors } from '../DeviceDescriptors';
import * as Errors from '../Errors'; import * as Errors from '../Errors';
import { Launcher, LauncherLaunchOptions } from './Launcher'; import { Launcher, LauncherLaunchOptions, createBrowserFetcher } from './Launcher';
import { download, RevisionInfo } from '../download';
export class Playwright { export class Playwright {
private _projectRoot: string; private _projectRoot: string;
private _launcher: Launcher; private _launcher: Launcher;
readonly _revision: string; readonly _revision: string;
downloadBrowser: (options?: { onProgress?: (downloadedBytes: number, totalBytes: number) => void; }) => Promise<RevisionInfo>;
constructor(projectRoot: string, preferredRevision: string) { constructor(projectRoot: string, preferredRevision: string) {
this._projectRoot = projectRoot; this._projectRoot = projectRoot;
this._launcher = new Launcher(projectRoot, preferredRevision); this._launcher = new Launcher(projectRoot, preferredRevision);
this._revision = preferredRevision; this._revision = preferredRevision;
this.downloadBrowser = download.bind(null, this.createBrowserFetcher(), preferredRevision, 'WebKit'); }
async downloadBrowser(options?: BrowserFetcherOptions & { onProgress?: OnProgressCallback }): Promise<BrowserFetcherRevisionInfo> {
const fetcher = this.createBrowserFetcher(options);
const revisionInfo = fetcher.revisionInfo(this._revision);
await fetcher.download(this._revision, options ? options.onProgress : undefined);
return revisionInfo;
} }
launch(options: (LauncherLaunchOptions) | undefined): Promise<Browser> { launch(options: (LauncherLaunchOptions) | undefined): Promise<Browser> {
@ -57,7 +61,7 @@ export class Playwright {
return this._launcher.defaultArgs(options); return this._launcher.defaultArgs(options);
} }
createBrowserFetcher(options?: BrowserFetcherOptions | undefined): BrowserFetcher { createBrowserFetcher(options?: BrowserFetcherOptions): BrowserFetcher {
return new BrowserFetcher(this._projectRoot, options); return createBrowserFetcher(this._projectRoot, options);
} }
} }

View file

@ -3,7 +3,7 @@
export { TimeoutError } from '../Errors'; export { TimeoutError } from '../Errors';
export { Browser, BrowserContext } from './Browser'; export { Browser, BrowserContext } from './Browser';
export { BrowserFetcher } from './BrowserFetcher'; export { BrowserFetcher } from '../browserFetcher';
export { ExecutionContext, JSHandle } from '../javascript'; export { ExecutionContext, JSHandle } from '../javascript';
export { ElementHandle } from '../dom'; export { ElementHandle } from '../dom';
export { Frame } from '../frames'; export { Frame } from '../frames';