From df777344a3e5de2700fa7f0ecc4031f3f4a18e11 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Fri, 18 Sep 2020 17:36:43 -0700 Subject: [PATCH] api(video): simplify video api (#3924) - This leaves just `recordVideos` and `videoSize` options on the context. - Videos are saved to `artifactsPath`. We also save their ids to trace. - `context.close()` waits for the processed videos. --- docs/api.md | 45 +--- packages/installation-tests/screencast.js | 17 +- src/client/connection.ts | 4 - src/client/events.ts | 1 - src/client/page.ts | 6 - src/client/types.ts | 1 - src/client/video.ts | 70 ------ src/dispatchers/pageDispatcher.ts | 2 - src/dispatchers/videoDispatcher.ts | 47 ---- src/protocol/channels.ts | 41 +-- src/protocol/protocol.yml | 30 +-- src/protocol/validator.ts | 11 +- src/server/browser.ts | 15 +- src/server/browserContext.ts | 41 ++- src/server/browserType.ts | 14 +- src/server/chromium/crBrowser.ts | 2 +- src/server/chromium/crPage.ts | 16 +- src/server/firefox/ffBrowser.ts | 18 +- src/server/firefox/ffPage.ts | 4 +- src/server/types.ts | 5 +- src/server/webkit/wkBrowser.ts | 2 +- src/server/webkit/wkPage.ts | 16 +- src/trace/snapshotter.ts | 14 +- src/trace/traceTypes.ts | 8 + src/trace/tracer.ts | 20 +- test/playwright.fixtures.ts | 3 + test/screencast.spec.ts | 293 +++++++++------------- 27 files changed, 246 insertions(+), 500 deletions(-) delete mode 100644 src/client/video.ts delete mode 100644 src/dispatchers/videoDispatcher.ts diff --git a/docs/api.md b/docs/api.md index 7e0cf63e67..3102b70790 100644 --- a/docs/api.md +++ b/docs/api.md @@ -221,8 +221,8 @@ Indicates that the browser is connected. - `colorScheme` <"light"|"dark"|"no-preference"> Emulates `'prefers-colors-scheme'` media feature, supported values are `'light'`, `'dark'`, `'no-preference'`. See [page.emulateMedia(options)](#pageemulatemediaoptions) for more details. Defaults to '`light`'. - `logger` <[Logger]> Logger sink for Playwright logging. - `relativeArtifactsPath` <[string]> Specifies a folder for artifacts like downloads, videos and traces, relative to `artifactsPath` from [`browserType.launch`](#browsertypelaunchoptions). Defaults to `.`. - - `_recordVideos` <[boolean]> **experimental** Enables automatic video recording for new pages. - - `_videoSize` <[Object]> **experimental** Specifies dimensions of the automatically recorded video. Can only be used if `_recordVideos` is true. If not specified the size will be equal to `viewport`. If `viewport` is not configured explicitly the video size defaults to 1280x720. Actual picture of the page will be scaled down if necessary to fit specified size. + - `recordVideos` <[boolean]> Enables video recording for all pages to the `relativeArtifactsPath` folder. + - `videoSize` <[Object]> Specifies dimensions of the automatically recorded video. Can only be used if `recordVideos` is true. If not specified the size will be equal to `viewport`. If `viewport` is not configured explicitly the video size defaults to 1280x720. Actual picture of the page will be scaled down if necessary to fit specified size. - `width` <[number]> Video frame width. - `height` <[number]> Video frame height. - `recordTrace` <[boolean]> Enables trace recording to the `relativeArtifactsPath` folder. @@ -269,8 +269,8 @@ Creates a new browser context. It won't share cookies/cache with other browser c - `colorScheme` <"light"|"dark"|"no-preference"> Emulates `'prefers-colors-scheme'` media feature, supported values are `'light'`, `'dark'`, `'no-preference'`. See [page.emulateMedia(options)](#pageemulatemediaoptions) for more details. Defaults to '`light`'. - `logger` <[Logger]> Logger sink for Playwright logging. - `relativeArtifactsPath` <[string]> Specifies a folder for artifacts like downloads, videos and traces, relative to `artifactsPath` from [`browserType.launch`](#browsertypelaunchoptions). Defaults to `.`. - - `_recordVideos` <[boolean]> **experimental** Enables automatic video recording for the new page. - - `_videoSize` <[Object]> **experimental** Specifies dimensions of the automatically recorded video. Can only be used if `_recordVideos` is true. If not specified the size will be equal to `viewport`. If `viewport` is not configured explicitly the video size defaults to 1280x720. Actual picture of the page will be scaled down if necessary to fit specified size. + - `recordVideos` <[boolean]> Enables video recording for all pages to the `relativeArtifactsPath` folder. + - `videoSize` <[Object]> Specifies dimensions of the automatically recorded video. Can only be used if `recordVideos` is true. If not specified the size will be equal to `viewport`. If `viewport` is not configured explicitly the video size defaults to 1280x720. Actual picture of the page will be scaled down if necessary to fit specified size. - `width` <[number]> Video frame width. - `height` <[number]> Video frame height. - `recordTrace` <[boolean]> Enables trace recording to the `relativeArtifactsPath` folder. @@ -701,7 +701,6 @@ page.removeListener('request', logRequest); ``` -- [event: '_videostarted'](#event-_videostarted) - [event: 'close'](#event-close-1) - [event: 'console'](#event-console) - [event: 'crash'](#event-crash) @@ -788,35 +787,6 @@ page.removeListener('request', logRequest); - [page.workers()](#pageworkers) -#### event: '_videostarted' -- <[Object]> Video object. Provides access to the video after it has been written to a file. - -**experimental** -Emitted when video recording has started for this page. The event will fire only if [`_recordVideos`](#browsernewcontextoptions) option is configured on the parent context. - -An example of recording a video for single page. -```js -const { webkit } = require('playwright'); // Or 'chromium' or 'firefox'. - -(async () => { - const browser = await webkit.launch({ - _videosPath: __dirname // Save videos to custom directory - }); - const context = await browser.newContext({ - _recordVideos: true, - _videoSize: { width: 640, height: 360 } - }); - const page = await context.newPage(); - const video = await page.waitForEvent('_videostarted'); - await page.goto('https://github.com/microsoft/playwright'); - // Video recording will stop automaticall when the page closes. - await page.close(); - // Wait for the path to the video. It will become available - // after the video has been completely written to the the file. - console.log('Recorded video: ' + await video.path()); -})(); -``` - #### event: 'close' Emitted when the page closes. @@ -4205,7 +4175,6 @@ This methods attaches Playwright to an existing browser instance. - `password` <[string]> Optional password to use if HTTP proxy requires authentication. - `downloadsPath` <[string]> If specified, accepted downloads are downloaded into this folder. Otherwise, temporary folder is created and is deleted when browser is closed. - `artifactsPath` <[string]> Specifies a folder for various artifacts like downloads, videos and traces. If not specified, artifacts are not collected. - - `_videosPath` <[string]> **experimental** If specified, recorded videos are saved into this folder. Otherwise, temporary folder is created and is deleted when browser is closed. - `chromiumSandbox` <[boolean]> Enable Chromium sandboxing. Defaults to `true`. - `firefoxUserPrefs` <[Object]<[string], [string]|[number]|[boolean]>> Firefox user preferences. Learn more about the Firefox user preferences at [`about:config`](https://support.mozilla.org/en-US/kb/about-config-editor-firefox). - `handleSIGINT` <[boolean]> Close the browser process on Ctrl-C. Defaults to `true`. @@ -4282,9 +4251,8 @@ const browser = await chromium.launch({ // Or 'firefox' or 'webkit'. - `password` <[string]> - `colorScheme` <"light"|"dark"|"no-preference"> Emulates `'prefers-colors-scheme'` media feature, supported values are `'light'`, `'dark'`, `'no-preference'`. See [page.emulateMedia(options)](#pageemulatemediaoptions) for more details. Defaults to '`light`'. - `relativeArtifactsPath` <[string]> Specifies a folder for artifacts like downloads, videos and traces, relative to `artifactsPath`. Defaults to `.`. - - `_videosPath` <[string]> **experimental** If specified, recorded videos are saved into this folder. Otherwise, temporary folder is created and is deleted when browser is closed. - - `_recordVideos` <[boolean]> **experimental** Enables automatic video recording for new pages. - - `_videoSize` <[Object]> **experimental** Specifies dimensions of the automatically recorded video. Can only be used if `_recordVideos` is true. If not specified the size will be equal to `viewport`. If `viewport` is not configured explicitly the video size defaults to 1280x720. Actual picture of the page will be scaled down if necessary to fit specified size. + - `recordVideos` <[boolean]> Enables video recording for all pages to the `relativeArtifactsPath` folder. + - `videoSize` <[Object]> Specifies dimensions of the automatically recorded video. Can only be used if `recordVideos` is true. If not specified the size will be equal to `viewport`. If `viewport` is not configured explicitly the video size defaults to 1280x720. Actual picture of the page will be scaled down if necessary to fit specified size. - `width` <[number]> Video frame width. - `height` <[number]> Video frame height. - `recordTrace` <[boolean]> Enables trace recording to the `relativeArtifactsPath` folder. @@ -4306,7 +4274,6 @@ Launches browser that uses persistent storage located at `userDataDir` and retur - `password` <[string]> Optional password to use if HTTP proxy requires authentication. - `downloadsPath` <[string]> If specified, accepted downloads are downloaded into this folder. Otherwise, temporary folder is created and is deleted when browser is closed. - `artifactsPath` <[string]> Specifies a folder for various artifacts like downloads, videos and traces. If not specified, artifacts are not collected. - - `_videosPath` <[string]> **experimental** If specified, recorded videos are saved into this folder. Otherwise, temporary folder is created and is deleted when browser is closed. - `chromiumSandbox` <[boolean]> Enable Chromium sandboxing. Defaults to `true`. - `firefoxUserPrefs` <[Object]<[string], [string]|[number]|[boolean]>> Firefox user preferences. Learn more about the Firefox user preferences at [`about:config`](https://support.mozilla.org/en-US/kb/about-config-editor-firefox). - `handleSIGINT` <[boolean]> Close the browser process on Ctrl-C. Defaults to `true`. diff --git a/packages/installation-tests/screencast.js b/packages/installation-tests/screencast.js index 23376835e5..fe3522710e 100644 --- a/packages/installation-tests/screencast.js +++ b/packages/installation-tests/screencast.js @@ -34,22 +34,19 @@ const fs = require('fs'); for (const browserType of success) { try { const browser = await playwright[browserType].launch({ - _videosPath: __dirname, + artifactsPath: __dirname, }); const context = await browser.newContext({ - _recordVideos: true, - _videoSize: {width: 320, height: 240}, + recordVideos: true, + videoSize: {width: 320, height: 240}, }); - const page = await context.newPage(); - const video = await page.waitForEvent('_videostarted'); + await context.newPage(); // Wait fo 1 second to actually record something. await new Promise(x => setTimeout(x, 1000)); - const [videoFile] = await Promise.all([ - video.path(), - context.close(), - ]); + await context.close(); await browser.close(); - if (!fs.existsSync(videoFile)) { + const videoFile = fs.readdirSync(__dirname).find(name => name.endsWith('webm')); + if (!videoFile) { console.error(`ERROR: Package "${requireName}", browser "${browserType}" should have created screencast!`); process.exit(1); } diff --git a/src/client/connection.ts b/src/client/connection.ts index ad6d526d76..fe0408b23a 100644 --- a/src/client/connection.ts +++ b/src/client/connection.ts @@ -40,7 +40,6 @@ import { WebKitBrowser } from './webkitBrowser'; import { FirefoxBrowser } from './firefoxBrowser'; import { debugLogger } from '../utils/debugLogger'; import { SelectorsOwner } from './selectors'; -import { Video } from './video'; import { isUnderTest } from '../utils/utils'; class Root extends ChannelOwner { @@ -221,9 +220,6 @@ export class Connection { case 'Route': result = new Route(parent, type, guid, initializer); break; - case 'Video': - result = new Video(parent, type, guid, initializer); - break; case 'Stream': result = new Stream(parent, type, guid, initializer); break; diff --git a/src/client/events.ts b/src/client/events.ts index 6b0a733ec6..2c02e19ef8 100644 --- a/src/client/events.ts +++ b/src/client/events.ts @@ -50,7 +50,6 @@ export const Events = { Load: 'load', Popup: 'popup', Worker: 'worker', - _VideoStarted: '_videostarted', }, Worker: { diff --git a/src/client/page.ts b/src/client/page.ts index 47e074afe2..fd0bed3493 100644 --- a/src/client/page.ts +++ b/src/client/page.ts @@ -42,7 +42,6 @@ import * as util from 'util'; import { Size, URLMatch, Headers, LifecycleEvent, WaitForEventOptions, SelectOption, SelectOptionOptions, FilePayload, WaitForFunctionOptions } from './types'; import { evaluationScript, urlMatches } from './clientHelper'; import { isString, isRegExp, isObject, mkdirIfNeeded, headersObjectToArray } from '../utils/utils'; -import { Video } from './video'; type PDFOptions = Omit & { width?: string | number, @@ -123,7 +122,6 @@ export class Page extends ChannelOwner this.emit(Events.Page.Response, Response.from(response))); this._channel.on('route', ({ route, request }) => this._onRoute(Route.from(route), Request.from(request))); this._channel.on('worker', ({ worker }) => this._onWorker(Worker.from(worker))); - this._channel.on('videoStarted', params => this._onVideoStarted(params)); if (this._browserContext._browserName === 'chromium') { this.coverage = new ChromiumCoverage(this._channel); @@ -177,10 +175,6 @@ export class Page extends ChannelOwner { - private _browser: Browser | null; - - static from(channel: channels.VideoChannel): Video { - return (channel as any)._object; - } - - constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.VideoInitializer) { - super(parent, type, guid, initializer); - this._browser = (parent as BrowserContext)._browser; - } - - async path(): Promise { - if (this._browser && this._browser._isRemote) - throw new Error(`Path is not available when using browserType.connect().`); - return (await this._channel.path()).value; - } - - async saveAs(path: string): Promise { - return this._wrapApiCall('video.saveAs', async () => { - if (!this._browser || !this._browser._isRemote) { - await this._channel.saveAs({ path }); - return; - } - - const stream = await this.createReadStream(); - if (!stream) - throw new Error('Failed to copy video from server'); - await mkdirIfNeeded(path); - await new Promise((resolve, reject) => { - stream.pipe(fs.createWriteStream(path)) - .on('finish' as any, resolve) - .on('error' as any, reject); - }); - }); - } - - async createReadStream(): Promise { - const result = await this._channel.stream(); - if (!result.stream) - return null; - const stream = Stream.from(result.stream); - return stream.stream(); - } -} diff --git a/src/dispatchers/pageDispatcher.ts b/src/dispatchers/pageDispatcher.ts index de7bf1bf4a..832585f594 100644 --- a/src/dispatchers/pageDispatcher.ts +++ b/src/dispatchers/pageDispatcher.ts @@ -30,7 +30,6 @@ import { serializeResult, parseArgument } from './jsHandleDispatcher'; import { ElementHandleDispatcher, createHandle } from './elementHandlerDispatcher'; import { FileChooser } from '../server/fileChooser'; import { CRCoverage } from '../server/chromium/crCoverage'; -import { VideoDispatcher } from './videoDispatcher'; export class PageDispatcher extends Dispatcher implements channels.PageChannel { private _page: Page; @@ -66,7 +65,6 @@ export class PageDispatcher extends Dispatcher i })); page.on(Page.Events.RequestFinished, request => this._dispatchEvent('requestFinished', { request: RequestDispatcher.from(scope, request) })); page.on(Page.Events.Response, response => this._dispatchEvent('response', { response: new ResponseDispatcher(this._scope, response) })); - page.on(Page.Events.VideoStarted, screencast => this._dispatchEvent('videoStarted', { video: new VideoDispatcher(this._scope, screencast) })); page.on(Page.Events.Worker, worker => this._dispatchEvent('worker', { worker: new WorkerDispatcher(this._scope, worker) })); } diff --git a/src/dispatchers/videoDispatcher.ts b/src/dispatchers/videoDispatcher.ts deleted file mode 100644 index b1290a2aed..0000000000 --- a/src/dispatchers/videoDispatcher.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * 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 fs from 'fs'; -import * as util from 'util'; -import * as channels from '../protocol/channels'; -import { Video } from '../server/browserContext'; -import { mkdirIfNeeded } from '../utils/utils'; -import { Dispatcher, DispatcherScope } from './dispatcher'; -import { StreamDispatcher } from './streamDispatcher'; - -export class VideoDispatcher extends Dispatcher implements channels.VideoChannel { - constructor(scope: DispatcherScope, screencast: Video) { - super(scope, screencast, 'Video', {}); - } - - async path(): Promise { - return { value: await this._object.path() }; - } - - async saveAs(params: channels.VideoSaveAsParams): Promise { - const fileName = await this._object.path(); - await mkdirIfNeeded(params.path); - await util.promisify(fs.copyFile)(fileName, params.path); - } - - async stream(): Promise { - const fileName = await this._object.path(); - const readable = fs.createReadStream(fileName); - await new Promise(f => readable.on('readable', f)); - return { stream: new StreamDispatcher(this._scope, readable) }; - } - -} diff --git a/src/protocol/channels.ts b/src/protocol/channels.ts index a37c147013..2a006b0e59 100644 --- a/src/protocol/channels.ts +++ b/src/protocol/channels.ts @@ -169,7 +169,6 @@ export type BrowserTypeLaunchParams = { }, downloadsPath?: string, artifactsPath?: string, - _videosPath?: string, firefoxUserPrefs?: any, chromiumSandbox?: boolean, slowMo?: number, @@ -197,7 +196,6 @@ export type BrowserTypeLaunchOptions = { }, downloadsPath?: string, artifactsPath?: string, - _videosPath?: string, firefoxUserPrefs?: any, chromiumSandbox?: boolean, slowMo?: number, @@ -229,7 +227,6 @@ export type BrowserTypeLaunchPersistentContextParams = { }, downloadsPath?: string, artifactsPath?: string, - _videosPath?: string, chromiumSandbox?: boolean, slowMo?: number, noDefaultViewport?: boolean, @@ -289,7 +286,6 @@ export type BrowserTypeLaunchPersistentContextOptions = { }, downloadsPath?: string, artifactsPath?: string, - _videosPath?: string, chromiumSandbox?: boolean, slowMo?: number, noDefaultViewport?: boolean, @@ -381,8 +377,8 @@ export type BrowserNewContextParams = { acceptDownloads?: boolean, relativeArtifactsPath?: string, recordTrace?: boolean, - _recordVideos?: boolean, - _videoSize?: { + recordVideos?: boolean, + videoSize?: { width: number, height: number, }, @@ -421,8 +417,8 @@ export type BrowserNewContextOptions = { acceptDownloads?: boolean, relativeArtifactsPath?: string, recordTrace?: boolean, - _recordVideos?: boolean, - _videoSize?: { + recordVideos?: boolean, + videoSize?: { width: number, height: number, }, @@ -675,7 +671,6 @@ export interface PageChannel extends Channel { on(event: 'requestFinished', callback: (params: PageRequestFinishedEvent) => void): this; on(event: 'response', callback: (params: PageResponseEvent) => void): this; on(event: 'route', callback: (params: PageRouteEvent) => void): this; - on(event: 'videoStarted', callback: (params: PageVideoStartedEvent) => void): this; on(event: 'worker', callback: (params: PageWorkerEvent) => void): this; setDefaultNavigationTimeoutNoReply(params: PageSetDefaultNavigationTimeoutNoReplyParams, metadata?: Metadata): Promise; setDefaultTimeoutNoReply(params: PageSetDefaultTimeoutNoReplyParams, metadata?: Metadata): Promise; @@ -758,9 +753,6 @@ export type PageRouteEvent = { route: RouteChannel, request: RequestChannel, }; -export type PageVideoStartedEvent = { - video: VideoChannel, -}; export type PageWorkerEvent = { worker: WorkerChannel, }; @@ -2154,31 +2146,6 @@ export type DialogDismissParams = {}; export type DialogDismissOptions = {}; export type DialogDismissResult = void; -// ----------- Video ----------- -export type VideoInitializer = {}; -export interface VideoChannel extends Channel { - path(params?: VideoPathParams, metadata?: Metadata): Promise; - saveAs(params: VideoSaveAsParams, metadata?: Metadata): Promise; - stream(params?: VideoStreamParams, metadata?: Metadata): Promise; -} -export type VideoPathParams = {}; -export type VideoPathOptions = {}; -export type VideoPathResult = { - value: string, -}; -export type VideoSaveAsParams = { - path: string, -}; -export type VideoSaveAsOptions = { - -}; -export type VideoSaveAsResult = void; -export type VideoStreamParams = {}; -export type VideoStreamOptions = {}; -export type VideoStreamResult = { - stream?: StreamChannel, -}; - // ----------- Download ----------- export type DownloadInitializer = { url: string, diff --git a/src/protocol/protocol.yml b/src/protocol/protocol.yml index 285e3c0bee..a221d95e0b 100644 --- a/src/protocol/protocol.yml +++ b/src/protocol/protocol.yml @@ -221,7 +221,6 @@ BrowserType: password: string? downloadsPath: string? artifactsPath: string? - _videosPath: string? firefoxUserPrefs: json? chromiumSandbox: boolean? slowMo: number? @@ -261,7 +260,6 @@ BrowserType: password: string? downloadsPath: string? artifactsPath: string? - _videosPath: string? chromiumSandbox: boolean? slowMo: number? noDefaultViewport: boolean? @@ -373,8 +371,8 @@ Browser: acceptDownloads: boolean? relativeArtifactsPath: string? recordTrace: boolean? - _recordVideos: boolean? - _videoSize: + recordVideos: boolean? + videoSize: type: object? properties: width: number @@ -914,10 +912,6 @@ Page: route: Route request: Request - videoStarted: - parameters: - video: Video - worker: parameters: worker: Worker @@ -1815,26 +1809,6 @@ Dialog: -Video: - type: interface - - commands: - - path: - returns: - value: string - - # Blocks path until saved to the local |path|. - saveAs: - parameters: - path: string - - stream: - returns: - stream: Stream? - - - Download: type: interface diff --git a/src/protocol/validator.ts b/src/protocol/validator.ts index f6717e1bef..53a8ee21f0 100644 --- a/src/protocol/validator.ts +++ b/src/protocol/validator.ts @@ -122,7 +122,6 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { })), downloadsPath: tOptional(tString), artifactsPath: tOptional(tString), - _videosPath: tOptional(tString), firefoxUserPrefs: tOptional(tAny), chromiumSandbox: tOptional(tBoolean), slowMo: tOptional(tNumber), @@ -151,7 +150,6 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { })), downloadsPath: tOptional(tString), artifactsPath: tOptional(tString), - _videosPath: tOptional(tString), chromiumSandbox: tOptional(tBoolean), slowMo: tOptional(tNumber), noDefaultViewport: tOptional(tBoolean), @@ -223,8 +221,8 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { acceptDownloads: tOptional(tBoolean), relativeArtifactsPath: tOptional(tString), recordTrace: tOptional(tBoolean), - _recordVideos: tOptional(tBoolean), - _videoSize: tOptional(tObject({ + recordVideos: tOptional(tBoolean), + videoSize: tOptional(tObject({ width: tNumber, height: tNumber, })), @@ -821,11 +819,6 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { promptText: tOptional(tString), }); scheme.DialogDismissParams = tOptional(tObject({})); - scheme.VideoPathParams = tOptional(tObject({})); - scheme.VideoSaveAsParams = tObject({ - path: tString, - }); - scheme.VideoStreamParams = tOptional(tObject({})); scheme.DownloadPathParams = tOptional(tObject({})); scheme.DownloadSaveAsParams = tObject({ path: tString, diff --git a/src/server/browser.ts b/src/server/browser.ts index da7c11a0e8..ab1bd92ee0 100644 --- a/src/server/browser.ts +++ b/src/server/browser.ts @@ -21,7 +21,6 @@ import { EventEmitter } from 'events'; import { Download } from './download'; import { ProxySettings } from './types'; import { ChildProcess } from 'child_process'; -import { makeWaitForNextTask } from '../utils/utils'; export interface BrowserProcess { onclose: ((exitCode: number | null, signal: string | null) => void) | undefined; @@ -34,7 +33,6 @@ export type BrowserOptions = types.UIOptions & { name: string, artifactsPath?: string, downloadsPath?: string, - _videosPath?: string, headful?: boolean, persistent?: types.BrowserContextOptions, // Undefined means no persistent context. browserProcess: BrowserProcess, @@ -50,7 +48,7 @@ export abstract class Browser extends EventEmitter { private _downloads = new Map(); _defaultContext: BrowserContext | null = null; private _startedClosing = false; - private readonly _idToVideo = new Map(); + readonly _idToVideo = new Map(); constructor(options: BrowserOptions) { super(); @@ -89,20 +87,19 @@ export abstract class Browser extends EventEmitter { this._downloads.delete(uuid); } - _videoStarted(videoId: string, file: string, pageOrError: Promise) { - const video = new Video(file); + _videoStarted(context: BrowserContext, videoId: string, path: string, pageOrError: Promise) { + const video = new Video(context, videoId, path); this._idToVideo.set(videoId, video); pageOrError.then(pageOrError => { - // Emit the event in another task to ensure that newPage response is handled before. if (pageOrError instanceof Page) - makeWaitForNextTask()(() => pageOrError.emit(Page.Events.VideoStarted, video)); + pageOrError.emit(Page.Events.VideoStarted, video); }); } _videoFinished(videoId: string) { - const video = this._idToVideo.get(videoId); + const video = this._idToVideo.get(videoId)!; this._idToVideo.delete(videoId); - video!._finishCallback(); + video._finishCallback(); } _didClose() { diff --git a/src/server/browserContext.ts b/src/server/browserContext.ts index ba9fb7b139..455c53b250 100644 --- a/src/server/browserContext.ts +++ b/src/server/browserContext.ts @@ -17,7 +17,8 @@ import { EventEmitter } from 'events'; import { TimeoutSettings } from '../utils/timeoutSettings'; -import { Browser } from './browser'; +import { mkdirIfNeeded } from '../utils/utils'; +import { Browser, BrowserOptions } from './browser'; import * as dom from './dom'; import { Download } from './download'; import * as frames from './frames'; @@ -30,17 +31,17 @@ import * as types from './types'; import * as path from 'path'; export class Video { - private readonly _path: string; + readonly _videoId: string; + readonly _path: string; + readonly _context: BrowserContext; + readonly _finishedPromise: Promise; _finishCallback: () => void = () => {}; - private readonly _finishedPromise: Promise; - constructor(path: string) { - this._path = path; - this._finishedPromise = new Promise(fulfill => this._finishCallback = fulfill); - } - async path(): Promise { - await this._finishedPromise; - return this._path; + constructor(context: BrowserContext, videoId: string, path: string) { + this._videoId = videoId; + this._path = path; + this._context = context; + this._finishedPromise = new Promise(fulfill => this._finishCallback = fulfill); } } @@ -122,6 +123,11 @@ export abstract class BrowserContext extends EventEmitter { await listener.onContextCreated(this); } + async _ensureArtifactsPath() { + if (this._artifactsPath) + await mkdirIfNeeded(path.join(this._artifactsPath, 'dummy')); + } + _browserClosed() { for (const page of this.pages()) page._didClose(); @@ -262,7 +268,14 @@ export abstract class BrowserContext extends EventEmitter { if (this._closedStatus === 'open') { this._closedStatus = 'closing'; await this._doClose(); - await Promise.all([...this._downloads].map(d => d.delete())); + const promises: Promise[] = []; + for (const download of this._downloads) + promises.push(download.delete()); + for (const video of this._browser._idToVideo.values()) { + if (video._context === this) + promises.push(video._finishedPromise); + } + await Promise.all(promises); for (const listener of contextListeners) await listener.onContextDestroyed(this); this._didCloseInternal(); @@ -278,7 +291,7 @@ export function assertBrowserContextIsNotOwned(context: BrowserContext) { } } -export function validateBrowserContextOptions(options: types.BrowserContextOptions) { +export function validateBrowserContextOptions(options: types.BrowserContextOptions, browserOptions: BrowserOptions) { if (options.noDefaultViewport && options.deviceScaleFactor !== undefined) throw new Error(`"deviceScaleFactor" option is not supported with null "viewport"`); if (options.noDefaultViewport && options.isMobile !== undefined) @@ -286,6 +299,10 @@ export function validateBrowserContextOptions(options: types.BrowserContextOptio if (!options.viewport && !options.noDefaultViewport) options.viewport = { width: 1280, height: 720 }; verifyGeolocation(options.geolocation); + if (options.recordTrace && !browserOptions.artifactsPath) + throw new Error(`"recordTrace" option requires "artifactsPath" to be specified`); + if (options.recordVideos && !browserOptions.artifactsPath) + throw new Error(`"recordVideos" option requires "artifactsPath" to be specified`); } export function verifyGeolocation(geolocation?: types.Geolocation) { diff --git a/src/server/browserType.ts b/src/server/browserType.ts index 48b9c1f0bd..432fbae0af 100644 --- a/src/server/browserType.ts +++ b/src/server/browserType.ts @@ -34,7 +34,6 @@ const mkdirAsync = util.promisify(fs.mkdir); const mkdtempAsync = util.promisify(fs.mkdtemp); const existsAsync = (path: string): Promise => new Promise(resolve => fs.stat(path, err => resolve(!err))); const DOWNLOADS_FOLDER = path.join(os.tmpdir(), 'playwright_downloads-'); -const VIDEOS_FOLDER = path.join(os.tmpdir(), 'playwright_videos-'); type WebSocketNotPipe = { webSocketRegex: RegExp, stream: 'stdout' | 'stderr' }; @@ -77,7 +76,6 @@ export abstract class BrowserType { async launchPersistentContext(userDataDir: string, options: types.LaunchPersistentOptions = {}): Promise { options = validateLaunchOptions(options); const persistent: types.BrowserContextOptions = options; - validateBrowserContextOptions(persistent); const controller = new ProgressController(); controller.setLogName('browser'); const browser = await controller.run(progress => { @@ -88,7 +86,7 @@ export abstract class BrowserType { async _innerLaunch(progress: Progress, options: types.LaunchOptions, persistent: types.BrowserContextOptions | undefined, userDataDir?: string): Promise { options.proxy = options.proxy ? normalizeProxySettings(options.proxy) : undefined; - const { browserProcess, downloadsPath, _videosPath, transport } = await this._launchProcess(progress, options, !!persistent, userDataDir); + const { browserProcess, downloadsPath, transport } = await this._launchProcess(progress, options, !!persistent, userDataDir); if ((options as any).__testHookBeforeCreateBrowser) await (options as any).__testHookBeforeCreateBrowser(); const browserOptions: BrowserOptions = { @@ -98,10 +96,11 @@ export abstract class BrowserType { headful: !options.headless, artifactsPath: options.artifactsPath, downloadsPath, - _videosPath, browserProcess, proxy: options.proxy, }; + if (persistent) + validateBrowserContextOptions(persistent, browserOptions); copyTestHooks(options, browserOptions); const browser = await this._connectToTransport(transport, browserOptions); // We assume no control when using custom arguments, and do not prepare the default context in that case. @@ -110,7 +109,7 @@ export abstract class BrowserType { return browser; } - private async _launchProcess(progress: Progress, options: types.LaunchOptions, isPersistent: boolean, userDataDir?: string): Promise<{ browserProcess: BrowserProcess, downloadsPath: string, _videosPath: string, transport: ConnectionTransport }> { + private async _launchProcess(progress: Progress, options: types.LaunchOptions, isPersistent: boolean, userDataDir?: string): Promise<{ browserProcess: BrowserProcess, downloadsPath: string, transport: ConnectionTransport }> { const { ignoreDefaultArgs, ignoreAllDefaultArgs, @@ -135,9 +134,8 @@ export abstract class BrowserType { } return dir; }; - // TODO: use artifactsPath for downloads and videos. + // TODO: use artifactsPath for downloads. const downloadsPath = await ensurePath(DOWNLOADS_FOLDER, options.downloadsPath); - const _videosPath = await ensurePath(VIDEOS_FOLDER, options._videosPath); if (!userDataDir) { userDataDir = await mkdtempAsync(path.join(os.tmpdir(), `playwright_${this._name}dev_profile-`)); @@ -211,7 +209,7 @@ export abstract class BrowserType { const stdio = launchedProcess.stdio as unknown as [NodeJS.ReadableStream, NodeJS.WritableStream, NodeJS.WritableStream, NodeJS.WritableStream, NodeJS.ReadableStream]; transport = new PipeTransport(stdio[3], stdio[4]); } - return { browserProcess, downloadsPath, _videosPath, transport }; + return { browserProcess, downloadsPath, transport }; } abstract _defaultArgs(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string): string[]; diff --git a/src/server/chromium/crBrowser.ts b/src/server/chromium/crBrowser.ts index 35385cd0ba..737c83dfb6 100644 --- a/src/server/chromium/crBrowser.ts +++ b/src/server/chromium/crBrowser.ts @@ -98,7 +98,7 @@ export class CRBrowser extends Browser { } async newContext(options: types.BrowserContextOptions = {}): Promise { - validateBrowserContextOptions(options); + validateBrowserContextOptions(options, this._options); const { browserContextId } = await this._session.send('Target.createBrowserContext', { disposeOnDetach: true }); const context = new CRBrowserContext(this, browserContextId, options); await context._initialize(); diff --git a/src/server/chromium/crPage.ts b/src/server/chromium/crPage.ts index fb9d928d35..428b8415ba 100644 --- a/src/server/chromium/crPage.ts +++ b/src/server/chromium/crPage.ts @@ -458,13 +458,15 @@ class FrameSession { promises.push(this._evaluateOnNewDocument(source)); for (const source of this._crPage._page._evaluateOnNewDocumentSources) promises.push(this._evaluateOnNewDocument(source)); - if (this._crPage._browserContext._options._recordVideos) { - const size = this._crPage._browserContext._options._videoSize || this._crPage._browserContext._options.viewport || { width: 1280, height: 720 }; + if (this._isMainFrame() && this._crPage._browserContext._options.recordVideos) { + const size = this._crPage._browserContext._options.videoSize || this._crPage._browserContext._options.viewport || { width: 1280, height: 720 }; const screencastId = createGuid(); - const outputFile = path.join(this._crPage._browserContext._browser._options._videosPath!, screencastId + '.webm'); - promises.push(this._startScreencast(screencastId, { - ...size, - outputFile, + const outputFile = path.join(this._crPage._browserContext._artifactsPath!, screencastId + '.webm'); + promises.push(this._crPage._browserContext._ensureArtifactsPath().then(() => { + return this._startScreencast(screencastId, { + ...size, + outputFile, + }); })); } promises.push(this._client.send('Runtime.runIfWaitingForDebugger')); @@ -764,7 +766,7 @@ class FrameSession { this._screencastState = 'started'; this._videoRecorder = videoRecorder; this._screencastId = screencastId; - this._crPage._browserContext._browser._videoStarted(screencastId, options.outputFile, this._crPage.pageOrError()); + this._crPage._browserContext._browser._videoStarted(this._crPage._browserContext, screencastId, options.outputFile, this._crPage.pageOrError()); } catch (e) { videoRecorder.stop().catch(() => {}); throw e; diff --git a/src/server/firefox/ffBrowser.ts b/src/server/firefox/ffBrowser.ts index 678c2acd5d..7783f108aa 100644 --- a/src/server/firefox/ffBrowser.ts +++ b/src/server/firefox/ffBrowser.ts @@ -99,7 +99,7 @@ export class FFBrowser extends Browser { } async newContext(options: types.BrowserContextOptions = {}): Promise { - validateBrowserContextOptions(options); + validateBrowserContextOptions(options, this._options); if (options.isMobile) throw new Error('options.isMobile is not supported in Firefox'); const { browserContextId } = await this._connection.send('Browser.createBrowserContext', { removeOnDetach: true }); @@ -229,13 +229,15 @@ export class FFBrowserContext extends BrowserContext { promises.push(this.setOffline(this._options.offline)); if (this._options.colorScheme) promises.push(this._browser._connection.send('Browser.setColorScheme', { browserContextId, colorScheme: this._options.colorScheme })); - if (this._options._recordVideos) { - const size = this._options._videoSize || this._options.viewport || { width: 1280, height: 720 }; - await this._browser._connection.send('Browser.setScreencastOptions', { - ...size, - dir: this._browser._options._videosPath!, - browserContextId: this._browserContextId - }); + if (this._options.recordVideos) { + const size = this._options.videoSize || this._options.viewport || { width: 1280, height: 720 }; + promises.push(this._ensureArtifactsPath().then(() => { + return this._browser._connection.send('Browser.setScreencastOptions', { + ...size, + dir: this._artifactsPath!, + browserContextId: this._browserContextId + }); + })); } await Promise.all(promises); diff --git a/src/server/firefox/ffPage.ts b/src/server/firefox/ffPage.ts index 8aa2c3bef9..e0a2c00b48 100644 --- a/src/server/firefox/ffPage.ts +++ b/src/server/firefox/ffPage.ts @@ -31,7 +31,6 @@ import { RawKeyboardImpl, RawMouseImpl } from './ffInput'; import { FFNetworkManager } from './ffNetworkManager'; import { Protocol } from './protocol'; import { rewriteErrorMessage } from '../../utils/stackTrace'; -import { Video } from '../browserContext'; const UTILITY_WORLD_NAME = '__playwright_utility_world__'; @@ -50,7 +49,6 @@ export class FFPage implements PageDelegate { private readonly _contextIdToContext: Map; private _eventListeners: RegisteredListener[]; private _workers = new Map(); - private readonly _idToScreencast = new Map(); constructor(session: FFSession, browserContext: FFBrowserContext, opener: FFPage | null) { this._session = session; @@ -258,7 +256,7 @@ export class FFPage implements PageDelegate { } _onScreencastStarted(event: Protocol.Page.screencastStartedPayload) { - this._browserContext._browser._videoStarted(event.screencastId, event.file, this.pageOrError()); + this._browserContext._browser._videoStarted(this._browserContext, event.screencastId, event.file, this.pageOrError()); } async exposeBinding(binding: PageBinding) { diff --git a/src/server/types.ts b/src/server/types.ts index 1b9d0150e2..a05cf352db 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -238,8 +238,8 @@ export type BrowserContextOptions = { hasTouch?: boolean, colorScheme?: ColorScheme, acceptDownloads?: boolean, - _recordVideos?: boolean, - _videoSize?: Size, + recordVideos?: boolean, + videoSize?: Size, recordTrace?: boolean, relativeArtifactsPath?: string, }; @@ -261,7 +261,6 @@ type LaunchOptionsBase = { proxy?: ProxySettings, artifactsPath?: string, downloadsPath?: string, - _videosPath?: string, chromiumSandbox?: boolean, slowMo?: number, }; diff --git a/src/server/webkit/wkBrowser.ts b/src/server/webkit/wkBrowser.ts index 649fc097d5..e2310ffaf7 100644 --- a/src/server/webkit/wkBrowser.ts +++ b/src/server/webkit/wkBrowser.ts @@ -74,7 +74,7 @@ export class WKBrowser extends Browser { } async newContext(options: types.BrowserContextOptions = {}): Promise { - validateBrowserContextOptions(options); + validateBrowserContextOptions(options, this._options); const { browserContextId } = await this._browserSession.send('Playwright.createContext'); options.userAgent = options.userAgent || DEFAULT_USER_AGENT; const context = new WKBrowserContext(this, browserContextId, options); diff --git a/src/server/webkit/wkPage.ts b/src/server/webkit/wkPage.ts index c72fab2241..aab0669a45 100644 --- a/src/server/webkit/wkPage.ts +++ b/src/server/webkit/wkPage.ts @@ -113,12 +113,14 @@ export class WKPage implements PageDelegate { for (const [key, value] of this._browserContext._permissions) this._grantPermissions(key, value); } - if (this._browserContext._options._recordVideos) { - const size = this._browserContext._options._videoSize || this._browserContext._options.viewport || { width: 1280, height: 720 }; - const outputFile = path.join(this._browserContext._browser._options._videosPath!, createGuid() + '.webm'); - promises.push(this.startScreencast({ - ...size, - outputFile, + if (this._browserContext._options.recordVideos) { + const size = this._browserContext._options.videoSize || this._browserContext._options.viewport || { width: 1280, height: 720 }; + const outputFile = path.join(this._browserContext._artifactsPath!, createGuid() + '.webm'); + promises.push(this._browserContext._ensureArtifactsPath().then(() => { + return this.startScreencast({ + ...size, + outputFile, + }); })); } await Promise.all(promises); @@ -723,7 +725,7 @@ export class WKPage implements PageDelegate { width: options.width, height: options.height, }) as any; - this._browserContext._browser._videoStarted(screencastId, options.outputFile, this.pageOrError()); + this._browserContext._browser._videoStarted(this._browserContext, screencastId, options.outputFile, this.pageOrError()); } catch (e) { this._recordingVideoFile = null; throw e; diff --git a/src/trace/snapshotter.ts b/src/trace/snapshotter.ts index cebfec5efe..ed86ab86e0 100644 --- a/src/trace/snapshotter.ts +++ b/src/trace/snapshotter.ts @@ -115,7 +115,7 @@ export class Snapshotter { return frameResult; const frameSnapshot = { frameId: frame._id, - url: frame.url(), + url: removeHash(frame.url()), html: 'Snapshot is not available', resourceOverrides: [], }; @@ -190,7 +190,7 @@ export class Snapshotter { const snapshot: FrameSnapshot = { frameId: frame._id, - url: frame.url(), + url: removeHash(frame.url()), html: data.html, resourceOverrides: [], }; @@ -216,6 +216,16 @@ export class Snapshotter { } } +function removeHash(url: string) { + try { + const u = new URL(url); + u.hash = ''; + return u.toString(); + } catch (e) { + return url; + } +} + type FrameSnapshotAndMapping = { snapshot: FrameSnapshot, mapping: Map, diff --git a/src/trace/traceTypes.ts b/src/trace/traceTypes.ts index 0eeceef77a..54b31d68a4 100644 --- a/src/trace/traceTypes.ts +++ b/src/trace/traceTypes.ts @@ -51,6 +51,13 @@ export type PageDestroyedTraceEvent = { pageId: string, }; +export type PageVideoTraceEvent = { + type: 'page-video', + contextId: string, + pageId: string, + fileName: string, +}; + export type ActionTraceEvent = { type: 'action', contextId: string, @@ -75,6 +82,7 @@ export type TraceEvent = ContextDestroyedTraceEvent | PageCreatedTraceEvent | PageDestroyedTraceEvent | + PageVideoTraceEvent | NetworkResourceTraceEvent | ActionTraceEvent; diff --git a/src/trace/tracer.ts b/src/trace/tracer.ts index 8ad4ca917b..7da65302ed 100644 --- a/src/trace/tracer.ts +++ b/src/trace/tracer.ts @@ -14,9 +14,9 @@ * limitations under the License. */ -import { ActionListener, ActionMetadata, BrowserContext, ContextListener, contextListeners } from '../server/browserContext'; +import { ActionListener, ActionMetadata, BrowserContext, ContextListener, contextListeners, Video } from '../server/browserContext'; import type { SnapshotterResource as SnapshotterResource, SnapshotterBlob, SnapshotterDelegate } from './snapshotter'; -import { ContextCreatedTraceEvent, ContextDestroyedTraceEvent, NetworkResourceTraceEvent, ActionTraceEvent, PageCreatedTraceEvent, PageDestroyedTraceEvent } from './traceTypes'; +import { ContextCreatedTraceEvent, ContextDestroyedTraceEvent, NetworkResourceTraceEvent, ActionTraceEvent, PageCreatedTraceEvent, PageDestroyedTraceEvent, PageVideoTraceEvent } from './traceTypes'; import * as path from 'path'; import * as util from 'util'; import * as fs from 'fs'; @@ -42,10 +42,8 @@ class Tracer implements ContextListener { async onContextCreated(context: BrowserContext): Promise { if (!context._options.recordTrace) return; - if (!context._artifactsPath) - throw new Error(`"recordTrace" option requires "artifactsPath" to be specified`); const traceStorageDir = path.join(context._browser._options.artifactsPath!, '.playwright-shared'); - const traceFile = path.join(context._artifactsPath, 'playwright.trace'); + const traceFile = path.join(context._artifactsPath!, 'playwright.trace'); const contextTracer = new ContextTracer(context, traceStorageDir, traceFile); this._contextTracers.set(context, contextTracer); } @@ -147,6 +145,18 @@ class ContextTracer implements SnapshotterDelegate, ActionListener { }; this._appendTraceEvent(event); + page.on(Page.Events.VideoStarted, (video: Video) => { + if (this._disposed) + return; + const event: PageVideoTraceEvent = { + type: 'page-video', + contextId: this._contextId, + pageId, + fileName: path.basename(video._path), + }; + this._appendTraceEvent(event); + }); + page.once(Page.Events.Close, () => { this._pageToId.delete(page); if (this._disposed) diff --git a/test/playwright.fixtures.ts b/test/playwright.fixtures.ts index 1891217e17..afd591dba3 100644 --- a/test/playwright.fixtures.ts +++ b/test/playwright.fixtures.ts @@ -279,6 +279,9 @@ defineTestFixture('context', async ({browser, testOutputDir}, runTest, info) => const contextOptions: BrowserContextOptions = { relativeArtifactsPath: path.relative(config.outputDir, testOutputDir), recordTrace: !!options.TRACING, + // TODO: enable videos. Currently, long videos are slowly processed by Chromium + // and (sometimes) Firefox, which causes test timeouts. + // recordVideos: !!options.TRACING, }; const context = await browser.newContext(contextOptions); await runTest(context); diff --git a/test/screencast.spec.ts b/test/screencast.spec.ts index 0407ce1ee7..a1e93f148a 100644 --- a/test/screencast.spec.ts +++ b/test/screencast.spec.ts @@ -15,59 +15,57 @@ */ import { options, playwrightFixtures } from './playwright.fixtures'; -import type { Page } from '..'; +import type { Page, Browser } from '..'; import fs from 'fs'; import path from 'path'; import { TestServer } from '../utils/testserver'; -import { mkdirIfNeeded } from '../lib/utils/utils'; type WorkerState = { - videoDir: string; + videoPlayerBrowser: Browser, }; type TestState = { videoPlayer: VideoPlayer; - videoFile: string; + relativeArtifactsPath: string; + videoDir: string; }; const fixtures = playwrightFixtures.declareWorkerFixtures().declareTestFixtures(); const { it, expect, describe, defineTestFixture, defineWorkerFixture, overrideWorkerFixture } = fixtures; -defineWorkerFixture('videoDir', async ({}, test, config) => { - await test(path.join(config.outputDir, 'screencast')); -}); - -overrideWorkerFixture('browser', async ({browserType, defaultBrowserOptions, videoDir}, test) => { +overrideWorkerFixture('browser', async ({browserType, defaultBrowserOptions}, test, config) => { const browser = await browserType.launch({ ...defaultBrowserOptions, // Make sure videos are stored on the same volume as the test output dir. - _videosPath: videoDir, + artifactsPath: path.join(config.outputDir, '.screencast'), }); await test(browser); await browser.close(); }); -defineTestFixture('videoPlayer', async ({playwright, context, server}, test) => { +defineWorkerFixture('videoPlayerBrowser', async ({playwright}, runTest) => { // WebKit on Mac & Windows cannot replay webm/vp8 video, is unrelyable // on Linux (times out) and in Firefox, so we always launch chromium for // playback. - const chromium = await playwright.chromium.launch(); - context = await chromium.newContext(); - - const page = await context.newPage(); - const player = new VideoPlayer(page, server); - await test(player); - if (chromium) - await chromium.close(); - else - await page.close(); + const browser = await playwright.chromium.launch(); + await runTest(browser); + await browser.close(); }); -defineTestFixture('videoFile', async ({browserType, videoDir}, runTest, info) => { +defineTestFixture('videoPlayer', async ({videoPlayerBrowser, server}, test) => { + const page = await videoPlayerBrowser.newPage(); + await test(new VideoPlayer(page, server)); + await page.close(); +}); + +defineTestFixture('relativeArtifactsPath', async ({browserType}, runTest, info) => { const { test } = info; const sanitizedTitle = test.title.replace(/[^\w\d]+/g, '_'); - const videoFile = path.join(videoDir, `${browserType.name()}-${sanitizedTitle}-${test.results.length}_v.webm`); - await mkdirIfNeeded(videoFile); - await runTest(videoFile); + const relativeArtifactsPath = `${browserType.name()}-${sanitizedTitle}-${test.results.length}`; + await runTest(relativeArtifactsPath); +}); + +defineTestFixture('videoDir', async ({relativeArtifactsPath}, runTest, info) => { + await runTest(path.join(info.config.outputDir, '.screencast', relativeArtifactsPath)); }); function almostRed(r, g, b, alpha) { @@ -112,9 +110,20 @@ function expectAll(pixels, rgbaPredicate) { } } +async function findVideo(videoDir: string) { + const files = await fs.promises.readdir(videoDir); + return path.join(videoDir, files.find(file => file.endsWith('webm'))); +} + +async function findVideos(videoDir: string) { + const files = await fs.promises.readdir(videoDir); + return files.filter(file => file.endsWith('webm')).map(file => path.join(videoDir, file)); +} + class VideoPlayer { private readonly _page: Page; private readonly _server: TestServer; + constructor(page: Page, server: TestServer) { this._page = page; this._server = server; @@ -189,19 +198,29 @@ class VideoPlayer { describe('screencast', suite => { suite.slow(); }, () => { - it('should capture static page', async ({browser, videoPlayer, videoFile}) => { - const context = await browser.newContext({ _recordVideos: true, _videoSize: { width: 320, height: 240 } }); + it('should require artifactsPath', async ({browserType, defaultBrowserOptions}) => { + const browser = await browserType.launch({ + ...defaultBrowserOptions, + artifactsPath: undefined, + }); + const error = await browser.newContext({ recordVideos: true }).catch(e => e); + expect(error.message).toContain('"recordVideos" option requires "artifactsPath" to be specified'); + await browser.close(); + }); + + it('should capture static page', async ({browser, videoPlayer, relativeArtifactsPath, videoDir}) => { + const context = await browser.newContext({ + relativeArtifactsPath, + recordVideos: true, + videoSize: { width: 320, height: 240 } + }); const page = await context.newPage(); - const video = await page.waitForEvent('_videostarted') as any; await page.evaluate(() => document.body.style.backgroundColor = 'red'); await new Promise(r => setTimeout(r, 1000)); - await page.close(); - - const tmpPath = await video.path(); - expect(fs.existsSync(tmpPath)).toBe(true); - fs.renameSync(tmpPath, videoFile); + await context.close(); + const videoFile = await findVideo(videoDir); await videoPlayer.load(videoFile); const duration = await videoPlayer.duration(); expect(duration).toBeGreaterThan(0); @@ -216,21 +235,21 @@ describe('screencast', suite => { it('should capture navigation', (test, parameters) => { test.flaky(); - }, async ({browser, server, videoPlayer, videoFile}) => { - const context = await browser.newContext({ _recordVideos: true, _videoSize: { width: 1280, height: 720 } }); + }, async ({browser, server, videoPlayer, relativeArtifactsPath, videoDir}) => { + const context = await browser.newContext({ + relativeArtifactsPath, + recordVideos: true, + videoSize: { width: 1280, height: 720 } + }); const page = await context.newPage(); - const video = await page.waitForEvent('_videostarted') as any; await page.goto(server.PREFIX + '/background-color.html#rgb(0,0,0)'); await new Promise(r => setTimeout(r, 1000)); await page.goto(server.CROSS_PROCESS_PREFIX + '/background-color.html#rgb(100,100,100)'); await new Promise(r => setTimeout(r, 1000)); - await page.close(); - - const tmpPath = await video.path(); - expect(fs.existsSync(tmpPath)).toBe(true); - fs.renameSync(tmpPath, videoFile); + await context.close(); + const videoFile = await findVideo(videoDir); await videoPlayer.load(videoFile); const duration = await videoPlayer.duration(); expect(duration).toBeGreaterThan(0); @@ -250,21 +269,22 @@ describe('screencast', suite => { it('should capture css transformation', (test, parameters) => { test.fail(options.WEBKIT(parameters) && options.WIN(parameters), 'Does not work on WebKit Windows'); - }, async ({browser, server, videoPlayer, videoFile}) => { + }, async ({browser, server, videoPlayer, relativeArtifactsPath, videoDir}) => { const size = {width: 320, height: 240}; // Set viewport equal to screencast frame size to avoid scaling. - const context = await browser.newContext({ _recordVideos: true, _videoSize: size, viewport: size }); + const context = await browser.newContext({ + relativeArtifactsPath, + recordVideos: true, + videoSize: size, + viewport: size, + }); const page = await context.newPage(); - const video = await page.waitForEvent('_videostarted') as any; await page.goto(server.PREFIX + '/rotate-z.html'); await new Promise(r => setTimeout(r, 1000)); - await page.close(); - - const tmpPath = await video.path(); - expect(fs.existsSync(tmpPath)).toBe(true); - fs.renameSync(tmpPath, videoFile); + await context.close(); + const videoFile = await findVideo(videoDir); await videoPlayer.load(videoFile); const duration = await videoPlayer.duration(); expect(duration).toBeGreaterThan(0); @@ -276,73 +296,35 @@ describe('screencast', suite => { } }); - it('should automatically start/finish when new page is created/closed', async ({browser, videoDir}) => { - const context = await browser.newContext({ _recordVideos: true, _videoSize: { width: 320, height: 240 }}); - const [screencast, newPage] = await Promise.all([ - new Promise(r => context.on('page', page => page.on('_videostarted', r))), - context.newPage(), - ]); - - const [videoFile] = await Promise.all([ - screencast.path(), - newPage.close(), - ]); - expect(path.dirname(videoFile)).toBe(videoDir); - await context.close(); - }); - - it('should finish when contex closes', async ({browser, videoDir}) => { - const context = await browser.newContext({ _recordVideos: true, _videoSize: { width: 320, height: 240 } }); - - const [video] = await Promise.all([ - new Promise(r => context.on('page', page => page.on('_videostarted', r))), - context.newPage(), - ]); - - const [videoFile] = await Promise.all([ - video.path(), - context.close(), - ]); - expect(path.dirname(videoFile)).toBe(videoDir); - }); - - it('should fire striclty after context.newPage', async ({browser}) => { - const context = await browser.newContext({ _recordVideos: true, _videoSize: { width: 320, height: 240 } }); - const page = await context.newPage(); - // Should not hang. - await page.waitForEvent('_videostarted'); - await context.close(); - }); - - it('should fire start event for popups', async ({browser, videoDir, server}) => { - const context = await browser.newContext({ _recordVideos: true, _videoSize: { width: 320, height: 240 } }); - - const [page] = await Promise.all([ - context.newPage(), - new Promise(r => context.on('page', page => page.on('_videostarted', r))), - ]); - await page.goto(server.EMPTY_PAGE); - const [video, popup] = await Promise.all([ - new Promise(r => context.on('page', page => page.on('_videostarted', r))), - new Promise(resolve => context.on('page', resolve)), - page.evaluate(() => { window.open('about:blank'); }) - ]); - const [videoFile] = await Promise.all([ - video.path(), - popup.close() - ]); - expect(path.dirname(videoFile)).toBe(videoDir); - }); - - it('should scale frames down to the requested size ', async ({browser, videoPlayer, videoFile, server}) => { + it('should work for popups', async ({browser, relativeArtifactsPath, videoDir, server}) => { const context = await browser.newContext({ + relativeArtifactsPath, + recordVideos: true, + videoSize: { width: 320, height: 240 } + }); + + const page = await context.newPage(); + await page.goto(server.EMPTY_PAGE); + await Promise.all([ + page.waitForEvent('popup'), + page.evaluate(() => { window.open('about:blank'); }), + ]); + await new Promise(r => setTimeout(r, 1000)); + await context.close(); + + const videoFiles = await findVideos(videoDir); + expect(videoFiles.length).toBe(2); + }); + + it('should scale frames down to the requested size ', async ({browser, videoPlayer, relativeArtifactsPath, videoDir, server}) => { + const context = await browser.newContext({ + relativeArtifactsPath, + recordVideos: true, viewport: {width: 640, height: 480}, // Set size to 1/2 of the viewport. - _recordVideos: true, - _videoSize: { width: 320, height: 240 }, + videoSize: { width: 320, height: 240 }, }); const page = await context.newPage(); - const video = await page.waitForEvent('_videostarted') as any; await page.goto(server.PREFIX + '/checkerboard.html'); // Update the picture to ensure enough frames are generated. @@ -354,12 +336,9 @@ describe('screencast', suite => { container.firstElementChild.classList.add('red'); }); await new Promise(r => setTimeout(r, 1000)); - await page.close(); - - const tmp = await video.path(); - expect(fs.existsSync(tmp)).toBe(true); - fs.renameSync(tmp, videoFile); + await context.close(); + const videoFile = await findVideo(videoDir); await videoPlayer.load(videoFile); const duration = await videoPlayer.duration(); expect(duration).toBeGreaterThan(0); @@ -383,83 +362,37 @@ describe('screencast', suite => { } }); - it('should use viewport as default size', async ({browser, videoPlayer, videoFile}) => { + it('should use viewport as default size', async ({browser, videoPlayer, relativeArtifactsPath, videoDir}) => { const size = {width: 800, height: 600}; - const context = await browser.newContext({_recordVideos: true, viewport: size}); + const context = await browser.newContext({ + relativeArtifactsPath, + recordVideos: true, + viewport: size, + }); - const [video] = await Promise.all([ - new Promise(r => context.on('page', page => page.on('_videostarted', r))), - context.newPage(), - ]); + await context.newPage(); await new Promise(r => setTimeout(r, 1000)); - const [tmpPath] = await Promise.all([ - video.path(), - context.close(), - ]); + await context.close(); - expect(fs.existsSync(tmpPath)).toBe(true); - fs.renameSync(tmpPath, videoFile); + const videoFile = await findVideo(videoDir); await videoPlayer.load(videoFile); expect(await videoPlayer.videoWidth()).toBe(size.width); expect(await videoPlayer.videoHeight()).toBe(size.height); }); - it('should be 1280x720 by default', async ({browser, videoPlayer, videoFile}) => { - const context = await browser.newContext({_recordVideos: true}); + it('should be 1280x720 by default', async ({browser, videoPlayer, relativeArtifactsPath, videoDir}) => { + const context = await browser.newContext({ + relativeArtifactsPath, + recordVideos: true, + }); - const [video] = await Promise.all([ - new Promise(r => context.on('page', page => page.on('_videostarted', r))), - context.newPage(), - ]); + await context.newPage(); await new Promise(r => setTimeout(r, 1000)); - const [tmpPath] = await Promise.all([ - video.path(), - context.close(), - ]); + await context.close(); - expect(fs.existsSync(tmpPath)).toBe(true); - fs.renameSync(tmpPath, videoFile); + const videoFile = await findVideo(videoDir); await videoPlayer.load(videoFile); expect(await videoPlayer.videoWidth()).toBe(1280); expect(await videoPlayer.videoHeight()).toBe(720); }); - - it('should create read stream', async ({browser, server}) => { - const context = await browser.newContext({_recordVideos: true}); - - const page = await context.newPage(); - const video = await page.waitForEvent('_videostarted') as any; - await page.goto(server.PREFIX + '/grid.html'); - await new Promise(r => setTimeout(r, 1000)); - const [stream, path] = await Promise.all([ - video.createReadStream(), - video.path(), - // TODO: make it work with dead context! - page.close(), - ]); - - const bufs = []; - stream.on('data', data => bufs.push(data)); - await new Promise(f => stream.on('end', f)); - const streamedData = Buffer.concat(bufs); - expect(fs.readFileSync(path).compare(streamedData)).toBe(0); - }); - - it('should saveAs', async ({browser, server, tmpDir}) => { - const context = await browser.newContext({_recordVideos: true}); - - const page = await context.newPage(); - const video = await page.waitForEvent('_videostarted') as any; - await page.goto(server.PREFIX + '/grid.html'); - await new Promise(r => setTimeout(r, 1000)); - const saveAsPath = path.join(tmpDir, 'v.webm'); - const [videoPath] = await Promise.all([ - video.path(), - video.saveAs(saveAsPath), - // TODO: make it work with dead context! - page.close(), - ]); - - expect(fs.readFileSync(videoPath).compare(fs.readFileSync(saveAsPath))).toBe(0); - }); -}); \ No newline at end of file +});