diff --git a/.gitignore b/.gitignore index 8150e8620b..6cd316572f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,7 @@ /test/output-webkit /test/test-user-data-dir* /.local-chromium/ -/.local-browser/ +/.local-firefox/ /.local-webkit/ /.dev_profile* .DS_Store diff --git a/src/webkit/BrowserFetcher.ts b/src/browserFetcher.ts similarity index 70% rename from src/webkit/BrowserFetcher.ts rename to src/browserFetcher.ts index 7b72dedb04..c61be4e7eb 100644 --- a/src/webkit/BrowserFetcher.ts +++ b/src/browserFetcher.ts @@ -18,36 +18,12 @@ 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'; -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); -} +import { assert, helper } from './helper'; const readdirAsync = helper.promisify(fs.readdir.bind(fs)); const mkdirAsync = helper.promisify(fs.mkdir.bind(fs)); @@ -61,34 +37,23 @@ function existsAsync(filePath) { return promise; } +type ParamsGetter = (platform: string, revision: string) => { downloadUrl: string, executablePath: string }; + +export type OnProgressCallback = (downloadedBytes: number, totalBytes: number) => void; + export class BrowserFetcher { private _downloadsFolder: string; - private _downloadHost: string; private _platform: string; + private _params: ParamsGetter; - constructor(projectRoot: string, options: BrowserFetcherOptions = {}) { - this._downloadsFolder = options.path || path.join(projectRoot, '.local-webkit'); - 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 = '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; + constructor(downloadsFolder: string, platform: string, params: ParamsGetter) { + this._downloadsFolder = downloadsFolder; + this._platform = platform; + this._params = params; } canDownload(revision: string): Promise { - const url = downloadURL(this._platform, this._downloadHost, revision); + const url = this._params(this._platform, revision).downloadUrl; let resolve; const promise = new Promise(x => resolve = x); const request = httpRequest(url, 'HEAD', response => { @@ -100,8 +65,9 @@ export class BrowserFetcher { }); return promise; } - async download(revision: string, progressCallback: ((arg0: number, arg1: number) => void) | null): Promise { - const url = downloadURL(this._platform, this._downloadHost, revision); + + async download(revision: string, progressCallback: OnProgressCallback | null): Promise { + const url = this._params(this._platform, revision).downloadUrl; const zipPath = path.join(this._downloadsFolder, `download-${this._platform}-${revision}.zip`); const folderPath = this._getFolderPath(revision); if (await existsAsync(folderPath)) @@ -136,14 +102,9 @@ export class BrowserFetcher { revisionInfo(revision: string): BrowserFetcherRevisionInfo { const folderPath = this._getFolderPath(revision); - let executablePath = ''; - 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 params = this._params(this._platform, revision); 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 { @@ -157,12 +118,10 @@ function parseFolderPath(folderPath: string): { platform: string; revision: stri 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 { +function downloadFile(url: string, destinationPath: string, progressCallback: OnProgressCallback | null): Promise { let fulfill, reject; let downloadedBytes = 0; let totalBytes = 0; @@ -244,7 +203,7 @@ export type BrowserFetcherOptions = { host ?: string, }; -type BrowserFetcherRevisionInfo = { +export type BrowserFetcherRevisionInfo = { folderPath: string, executablePath: string, url: string, diff --git a/src/chromium/BrowserFetcher.ts b/src/chromium/BrowserFetcher.ts deleted file mode 100644 index 10ca8ffad4..0000000000 --- a/src/chromium/BrowserFetcher.ts +++ /dev/null @@ -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 { - const url = downloadURL(this._platform, this._downloadHost, revision); - let resolve; - const promise = new Promise(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 { - 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 { - 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 { - 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 { - 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, -}; diff --git a/src/chromium/Launcher.ts b/src/chromium/Launcher.ts index 66453557de..22eb323c92 100644 --- a/src/chromium/Launcher.ts +++ b/src/chromium/Launcher.ts @@ -24,7 +24,7 @@ import * as readline from 'readline'; import * as removeFolder from 'rimraf'; import * as URL from 'url'; import { Browser } from './Browser'; -import { BrowserFetcher } from './BrowserFetcher'; +import { BrowserFetcher, BrowserFetcherOptions } from '../browserFetcher'; import { Connection } from './Connection'; import { TimeoutError } from '../Errors'; import { assert, debugError, helper } from '../helper'; @@ -32,6 +32,7 @@ import * as types from '../types'; import { PipeTransport } from './PipeTransport'; import { WebSocketTransport } from './WebSocketTransport'; import { ConnectionTransport } from '../ConnectionTransport'; +import * as util from 'util'; const mkdtempAsync = helper.promisify(fs.mkdtemp); const removeFolderAsync = helper.promisify(removeFolder); @@ -289,7 +290,7 @@ export class Launcher { } _resolveExecutablePath(): { executablePath: string; missingText: string | null; } { - const browserFetcher = new BrowserFetcher(this._projectRoot); + const browserFetcher = createBrowserFetcher(this._projectRoot); const revisionInfo = browserFetcher.revisionInfo(this._preferredRevision); const missingText = !revisionInfo.local ? `Chromium revision is not downloaded. Run "npm install" or "yarn install"` : null; return {executablePath: revisionInfo.executablePath, missingText}; @@ -395,3 +396,52 @@ export type LauncherBrowserOptions = { defaultViewport?: types.Viewport | null, 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 + }; + }); +} diff --git a/src/chromium/Playwright.ts b/src/chromium/Playwright.ts index b9e22f27c5..588c257cfb 100644 --- a/src/chromium/Playwright.ts +++ b/src/chromium/Playwright.ts @@ -15,24 +15,28 @@ * limitations under the License. */ import { Browser } from './Browser'; -import { BrowserFetcher, BrowserFetcherOptions } from './BrowserFetcher'; +import { BrowserFetcher, BrowserFetcherOptions, BrowserFetcherRevisionInfo, OnProgressCallback } from '../browserFetcher'; import { ConnectionTransport } from '../ConnectionTransport'; import { DeviceDescriptors } from '../DeviceDescriptors'; import * as Errors from '../Errors'; -import { Launcher, LauncherBrowserOptions, LauncherChromeArgOptions, LauncherLaunchOptions } from './Launcher'; -import {download, RevisionInfo} from '../download'; +import { Launcher, LauncherBrowserOptions, LauncherChromeArgOptions, LauncherLaunchOptions, createBrowserFetcher } from './Launcher'; export class Playwright { private _projectRoot: string; private _launcher: Launcher; readonly _revision: string; - downloadBrowser: (options?: { onProgress?: (downloadedBytes: number, totalBytes: number) => void; }) => Promise; constructor(projectRoot: string, preferredRevision: string) { this._projectRoot = projectRoot; this._launcher = new Launcher(projectRoot, preferredRevision); this._revision = preferredRevision; - this.downloadBrowser = download.bind(null, this.createBrowserFetcher(), preferredRevision, 'Chromium'); + } + + async downloadBrowser(options?: BrowserFetcherOptions & { onProgress?: OnProgressCallback }): Promise { + 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 { @@ -65,7 +69,7 @@ export class Playwright { return this._launcher.defaultArgs(options); } - createBrowserFetcher(options?: BrowserFetcherOptions | undefined): BrowserFetcher { - return new BrowserFetcher(this._projectRoot, options); + createBrowserFetcher(options?: BrowserFetcherOptions): BrowserFetcher { + return createBrowserFetcher(this._projectRoot, options); } } diff --git a/src/chromium/api.ts b/src/chromium/api.ts index 6cab510735..728e9ba384 100644 --- a/src/chromium/api.ts +++ b/src/chromium/api.ts @@ -11,7 +11,7 @@ export { ExecutionContext, JSHandle } from '../javascript'; export { Request, Response } from '../network'; export { Browser } from './Browser'; export { BrowserContext } from './BrowserContext'; -export { BrowserFetcher } from './BrowserFetcher'; +export { BrowserFetcher } from '../browserFetcher'; export { CDPSession } from './Connection'; export { Accessibility } from './features/accessibility'; export { Chromium } from './features/chromium'; diff --git a/src/download.ts b/src/download.ts deleted file mode 100644 index e97e7a85ef..0000000000 --- a/src/download.ts +++ /dev/null @@ -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 { - 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, -}; diff --git a/src/firefox/BrowserFetcher.ts b/src/firefox/BrowserFetcher.ts deleted file mode 100644 index 4cb6f21a29..0000000000 --- a/src/firefox/BrowserFetcher.ts +++ /dev/null @@ -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 { - const url = downloadURL(this._product, this._platform, this._downloadHost, revision); - let resolve; - const promise = new Promise(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 { - 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> { - 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 { - 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 { - 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; -} diff --git a/src/firefox/Launcher.ts b/src/firefox/Launcher.ts index 5017a4fcbd..883240410e 100644 --- a/src/firefox/Launcher.ts +++ b/src/firefox/Launcher.ts @@ -20,11 +20,11 @@ import * as removeFolder from 'rimraf'; import * as childProcess from 'child_process'; import {Connection} from './Connection'; import {Browser} from './Browser'; -import {BrowserFetcher} from './BrowserFetcher'; +import {BrowserFetcher, BrowserFetcherOptions} from '../browserFetcher'; import * as readline from 'readline'; import * as fs from 'fs'; import * as util from 'util'; -import {helper, debugError} from '../helper'; +import {helper, debugError, assert} from '../helper'; import {TimeoutError} from '../Errors'; import {WebSocketTransport} from './WebSocketTransport'; @@ -227,7 +227,7 @@ export class Launcher { } _resolveExecutablePath() { - const browserFetcher = new BrowserFetcher(this._projectRoot, { browser: 'firefox' }); + const browserFetcher = createBrowserFetcher(this._projectRoot); const revisionInfo = browserFetcher.revisionInfo(this._preferredRevision); const missingText = !revisionInfo.local ? `Firefox revision is not downloaded. Run "npm install" or "yarn install"` : null; 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 + }; + }); +} diff --git a/src/firefox/Playwright.ts b/src/firefox/Playwright.ts index dcf0061312..13e7542bf1 100644 --- a/src/firefox/Playwright.ts +++ b/src/firefox/Playwright.ts @@ -15,24 +15,28 @@ * limitations under the License. */ import { Browser } from './Browser'; -import { BrowserFetcher } from './BrowserFetcher'; +import { BrowserFetcher, BrowserFetcherOptions, OnProgressCallback, BrowserFetcherRevisionInfo } from '../browserFetcher'; import { ConnectionTransport } from '../ConnectionTransport'; import { DeviceDescriptors } from '../DeviceDescriptors'; import * as Errors from '../Errors'; -import { Launcher } from './Launcher'; -import {download, RevisionInfo} from '../download'; +import { Launcher, createBrowserFetcher } from './Launcher'; export class Playwright { private _projectRoot: string; private _launcher: Launcher; readonly _revision: string; - downloadBrowser: (options?: { onProgress?: (downloadedBytes: number, totalBytes: number) => void; }) => Promise; constructor(projectRoot: string, preferredRevision: string) { this._projectRoot = projectRoot; this._launcher = new Launcher(projectRoot, preferredRevision); this._revision = preferredRevision; - this.downloadBrowser = download.bind(null, this.createBrowserFetcher(), preferredRevision, 'Chromium'); + } + + async downloadBrowser(options?: BrowserFetcherOptions & { onProgress?: OnProgressCallback }): Promise { + 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 { @@ -65,7 +69,7 @@ export class Playwright { return this._launcher.defaultArgs(options); } - createBrowserFetcher(options?: any | undefined): BrowserFetcher { - return new BrowserFetcher(this._projectRoot, { browser: 'firefox', ...options }); + createBrowserFetcher(options?: BrowserFetcherOptions): BrowserFetcher { + return createBrowserFetcher(this._projectRoot, options); } } diff --git a/src/firefox/api.ts b/src/firefox/api.ts index 90d0f12cba..b6e684c276 100644 --- a/src/firefox/api.ts +++ b/src/firefox/api.ts @@ -4,7 +4,7 @@ export { TimeoutError } from '../Errors'; export { Keyboard, Mouse } from '../input'; export { Browser, BrowserContext } from './Browser'; -export { BrowserFetcher } from './BrowserFetcher'; +export { BrowserFetcher } from '../browserFetcher'; export { Dialog } from '../dialog'; export { ExecutionContext, JSHandle } from '../javascript'; export { ElementHandle } from '../dom'; diff --git a/src/webkit/Launcher.ts b/src/webkit/Launcher.ts index d33160aa31..0ea69fbb85 100644 --- a/src/webkit/Launcher.ts +++ b/src/webkit/Launcher.ts @@ -15,12 +15,16 @@ * limitations under the License. */ import * as childProcess from 'child_process'; -import { debugError, helper } from '../helper'; +import { debugError, helper, assert } from '../helper'; import { Browser } from './Browser'; -import { BrowserFetcher } from './BrowserFetcher'; +import { BrowserFetcher, BrowserFetcherOptions } from '../browserFetcher'; import { Connection } from './Connection'; import * as types from '../types'; 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 = [ ]; @@ -168,7 +172,7 @@ export class Launcher { } _resolveExecutablePath(): { executablePath: string; missingText: string | null; } { - const browserFetcher = new BrowserFetcher(this._projectRoot); + const browserFetcher = createBrowserFetcher(this._projectRoot); const revisionInfo = browserFetcher.revisionInfo(this._preferredRevision); const missingText = !revisionInfo.local ? `WebKit revision is not downloaded. Run "npm install" or "yarn install"` : null; return {executablePath: revisionInfo.executablePath, missingText}; @@ -189,3 +193,48 @@ export type LauncherLaunchOptions = { defaultViewport?: types.Viewport | null, 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', + }; + }); +} diff --git a/src/webkit/Playwright.ts b/src/webkit/Playwright.ts index 83faf63d2d..4a37cb2693 100644 --- a/src/webkit/Playwright.ts +++ b/src/webkit/Playwright.ts @@ -15,23 +15,27 @@ * limitations under the License. */ import { Browser } from './Browser'; -import { BrowserFetcher, BrowserFetcherOptions } from './BrowserFetcher'; +import { BrowserFetcher, BrowserFetcherOptions, OnProgressCallback, BrowserFetcherRevisionInfo } from '../browserFetcher'; import { DeviceDescriptors } from '../DeviceDescriptors'; import * as Errors from '../Errors'; -import { Launcher, LauncherLaunchOptions } from './Launcher'; -import { download, RevisionInfo } from '../download'; +import { Launcher, LauncherLaunchOptions, createBrowserFetcher } from './Launcher'; export class Playwright { private _projectRoot: string; private _launcher: Launcher; readonly _revision: string; - downloadBrowser: (options?: { onProgress?: (downloadedBytes: number, totalBytes: number) => void; }) => Promise; constructor(projectRoot: string, preferredRevision: string) { this._projectRoot = projectRoot; this._launcher = new Launcher(projectRoot, preferredRevision); this._revision = preferredRevision; - this.downloadBrowser = download.bind(null, this.createBrowserFetcher(), preferredRevision, 'WebKit'); + } + + async downloadBrowser(options?: BrowserFetcherOptions & { onProgress?: OnProgressCallback }): Promise { + 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 { @@ -57,7 +61,7 @@ export class Playwright { return this._launcher.defaultArgs(options); } - createBrowserFetcher(options?: BrowserFetcherOptions | undefined): BrowserFetcher { - return new BrowserFetcher(this._projectRoot, options); + createBrowserFetcher(options?: BrowserFetcherOptions): BrowserFetcher { + return createBrowserFetcher(this._projectRoot, options); } } diff --git a/src/webkit/api.ts b/src/webkit/api.ts index 5b0fd55bf2..60c5522886 100644 --- a/src/webkit/api.ts +++ b/src/webkit/api.ts @@ -3,7 +3,7 @@ export { TimeoutError } from '../Errors'; export { Browser, BrowserContext } from './Browser'; -export { BrowserFetcher } from './BrowserFetcher'; +export { BrowserFetcher } from '../browserFetcher'; export { ExecutionContext, JSHandle } from '../javascript'; export { ElementHandle } from '../dom'; export { Frame } from '../frames';