diff --git a/docs/src/trace-viewer.md b/docs/src/trace-viewer.md index 0fe4dafcd7..cbf07bbaa8 100644 --- a/docs/src/trace-viewer.md +++ b/docs/src/trace-viewer.md @@ -198,3 +198,25 @@ Here is what the typical Action snapshot looks like: Notice how it highlights both, the DOM Node as well as the exact click position. + + +## Viewing remote Traces + +You can open remote traces using it's URL. +They could be generated in a CI run and makes it easy to view the remote trace without having to manually download the file. + +```bash js +npx playwright show-trace https://example.com/trace.zip +``` + +```bash java +mvn exec:java -e -Dexec.mainClass=com.microsoft.playwright.CLI -Dexec.args="show-trace https://example.com/trace.zip" +``` + +```bash python +playwright show-trace https://example.com/trace.zip +``` + +```bash csharp +playwright show-trace https://example.com/trace.zip +``` diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 761e8144d6..361372e96d 100755 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -222,7 +222,8 @@ program }).addHelpText('afterAll', ` Examples: - $ show-trace trace/directory`); + $ show-trace trace/directory + $ show-trace https://example.com/trace.zip`); if (!process.env.PW_CLI_TARGET_LANG) { let playwrightTestPackagePath = null; diff --git a/src/server/trace/viewer/traceViewer.ts b/src/server/trace/viewer/traceViewer.ts index 11fba5d72c..be44bd8694 100644 --- a/src/server/trace/viewer/traceViewer.ts +++ b/src/server/trace/viewer/traceViewer.ts @@ -25,12 +25,13 @@ import { PersistentSnapshotStorage, TraceModel } from './traceModel'; import { ServerRouteHandler, HttpServer } from '../../../utils/httpServer'; import { SnapshotServer } from '../../snapshot/snapshotServer'; import * as consoleApiSource from '../../../generated/consoleApiSource'; -import { isUnderTest } from '../../../utils/utils'; +import { isUnderTest, download } from '../../../utils/utils'; import { internalCallMetadata } from '../../instrumentation'; import { ProgressController } from '../../progress'; import { BrowserContext } from '../../browserContext'; import { registry } from '../../../utils/registry'; import { installAppIcon } from '../../chromium/crApp'; +import { debugLogger } from '../../../utils/debugLogger'; export class TraceViewer { private _server: HttpServer; @@ -196,6 +197,23 @@ async function appendTraceEvents(model: TraceModel, file: string) { } export async function showTraceViewer(tracePath: string, browserName: string, headless = false): Promise { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `playwright-trace`)); + process.on('exit', () => rimraf.sync(dir)); + + if (/^https?:\/\//i.test(tracePath)){ + const downloadZipPath = path.join(dir, 'trace.zip'); + try { + await download(tracePath, downloadZipPath, { + progressBarName: tracePath, + log: debugLogger.log.bind(debugLogger, 'download') + }); + } catch (error) { + console.log(`${error?.message || ''}`); // eslint-disable-line no-console + return; + } + tracePath = downloadZipPath; + } + let stat; try { stat = fs.statSync(tracePath); @@ -210,8 +228,6 @@ export async function showTraceViewer(tracePath: string, browserName: string, he } const zipFile = tracePath; - const dir = fs.mkdtempSync(path.join(os.tmpdir(), `playwright-trace`)); - process.on('exit', () => rimraf.sync(dir)); try { await extract(zipFile, { dir }); } catch (e) { diff --git a/src/utils/browserFetcher.ts b/src/utils/browserFetcher.ts index 441c971bb5..8836c1379e 100644 --- a/src/utils/browserFetcher.ts +++ b/src/utils/browserFetcher.ts @@ -19,8 +19,7 @@ import extract from 'extract-zip'; import fs from 'fs'; import os from 'os'; import path from 'path'; -import ProgressBar from 'progress'; -import { downloadFile, existsAsync } from './utils'; +import { existsAsync, download } from './utils'; import { debugLogger } from './debugLogger'; export async function downloadBrowserWithProgressBar(title: string, browserDirectory: string, executablePath: string, downloadURL: string, downloadFileName: string): Promise { @@ -31,46 +30,13 @@ export async function downloadBrowserWithProgressBar(title: string, browserDirec return false; } - let progressBar: ProgressBar; - let lastDownloadedBytes = 0; - - function progress(downloadedBytes: number, totalBytes: number) { - if (!process.stderr.isTTY) - return; - 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 = downloadURL; const zipPath = path.join(os.tmpdir(), downloadFileName); try { - for (let attempt = 1, N = 3; attempt <= N; ++attempt) { - debugLogger.log('install', `downloading ${progressBarName} - attempt #${attempt}`); - const { error } = await downloadFile(url, zipPath, { progressCallback: progress, log: debugLogger.log.bind(debugLogger, 'install') }); - if (!error) { - debugLogger.log('install', `SUCCESS downloading ${progressBarName}`); - break; - } - const errorMessage = typeof error === 'object' && typeof error.message === 'string' ? error.message : ''; - debugLogger.log('install', `attempt #${attempt} - ERROR: ${errorMessage}`); - if (attempt < N && (errorMessage.includes('ECONNRESET') || errorMessage.includes('ETIMEDOUT'))) { - // Maximum delay is 3rd retry: 1337.5ms - const millis = (Math.random() * 200) + (250 * Math.pow(1.5, attempt)); - debugLogger.log('install', `sleeping ${millis}ms before retry...`); - await new Promise(c => setTimeout(c, millis)); - } else { - throw error; - } - } + await download(url, zipPath, { + progressBarName, + log: debugLogger.log.bind(debugLogger, 'install') + }); debugLogger.log('install', `extracting archive`); debugLogger.log('install', `-- zip: ${zipPath}`); debugLogger.log('install', `-- location: ${browserDirectory}`); @@ -89,10 +55,6 @@ export async function downloadBrowserWithProgressBar(title: string, browserDirec return true; } -function toMegabytes(bytes: number) { - const mb = bytes / 1024 / 1024; - return `${Math.round(mb * 10) / 10} Mb`; -} export function logPolitely(toBeLogged: string) { const logLevel = process.env.npm_config_loglevel; diff --git a/src/utils/debugLogger.ts b/src/utils/debugLogger.ts index 3f6fad4000..6302fb3b30 100644 --- a/src/utils/debugLogger.ts +++ b/src/utils/debugLogger.ts @@ -21,6 +21,7 @@ const debugLoggerColorMap = { 'api': 45, // cyan 'protocol': 34, // green 'install': 34, // green + 'download': 34, // green 'browser': 0, // reset 'proxy': 92, // purple 'error': 160, // red, diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 9fae9a87ed..3663186184 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -27,6 +27,7 @@ import { getProxyForUrl } from 'proxy-from-env'; import * as URL from 'url'; import { getUbuntuVersionSync } from './ubuntuVersion'; import { NameValue } from '../protocol/channels'; +import ProgressBar from 'progress'; // `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 @@ -115,7 +116,7 @@ export function fetchData(params: HTTPRequestParams, onError?: (response: http.I type OnProgressCallback = (downloadedBytes: number, totalBytes: number) => void; type DownloadFileLogger = (message: string) => void; -export function downloadFile(url: string, destinationPath: string, options: {progressCallback?: OnProgressCallback, log?: DownloadFileLogger} = {}): Promise<{error: any}> { +function downloadFile(url: string, destinationPath: string, options: {progressCallback?: OnProgressCallback, log?: DownloadFileLogger} = {}): Promise<{error: any}> { const { progressCallback, log = () => {}, @@ -155,6 +156,76 @@ export function downloadFile(url: string, destinationPath: string, options: {pro } } +export async function download( + url: string, + destination: string, + options: { + progressBarName?: string, + retryCount?: number + log?: DownloadFileLogger + } = {} +) { + const { progressBarName = 'file', retryCount = 3, log = () => {} } = options; + for (let attempt = 1; attempt <= retryCount; ++attempt) { + log( + `downloading ${progressBarName} - attempt #${attempt}` + ); + const { error } = await downloadFile(url, destination, { + progressCallback: getDownloadProgress(progressBarName), + log, + }); + if (!error) { + log(`SUCCESS downloading ${progressBarName}`); + break; + } + const errorMessage = error?.message || ''; + log(`attempt #${attempt} - ERROR: ${errorMessage}`); + if ( + attempt < retryCount && + (errorMessage.includes('ECONNRESET') || + errorMessage.includes('ETIMEDOUT')) + ) { + // Maximum default delay is 3rd retry: 1337.5ms + const millis = Math.random() * 200 + 250 * Math.pow(1.5, attempt); + log(`sleeping ${millis}ms before retry...`); + await new Promise(c => setTimeout(c, millis)); + } else { + throw error; + } + } +} + +function getDownloadProgress(progressBarName: string): OnProgressCallback { + let progressBar: ProgressBar; + let lastDownloadedBytes = 0; + + return (downloadedBytes: number, totalBytes: number) => { + if (!process.stderr.isTTY) + return; + 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); + }; +} + +function toMegabytes(bytes: number) { + const mb = bytes / 1024 / 1024; + return `${Math.round(mb * 10) / 10} Mb`; +} + export function spawnAsync(cmd: string, args: string[], options?: SpawnOptions): Promise<{stdout: string, stderr: string, code: number, error?: Error}> { const process = spawn(cmd, args, options);