diff --git a/src/browserContext.ts b/src/browserContext.ts index f5880abaa3..f0f37be30d 100644 --- a/src/browserContext.ts +++ b/src/browserContext.ts @@ -15,8 +15,10 @@ * limitations under the License. */ +import * as fs from 'fs'; import { helper } from './helper'; import * as network from './network'; +import * as path from 'path'; import { Page, PageBinding } from './page'; import { TimeoutSettings } from './timeoutSettings'; import * as frames from './frames'; @@ -28,10 +30,20 @@ import { EventEmitter } from 'events'; import { Progress } from './progress'; import { DebugController } from './debug/debugController'; +export class Screencast { + readonly path: string; + readonly page: Page; + constructor(path: string, page: Page) { + this.path = path; + this.page = page; + } +} + export abstract class BrowserContext extends EventEmitter { readonly _timeoutSettings = new TimeoutSettings(); readonly _pageBindings = new Map(); readonly _options: types.BrowserContextOptions; + _screencastOptions: types.ContextScreencastOptions | null = null; _requestInterceptor?: network.RouteHandler; private _isPersistentContext: boolean; private _closedStatus: 'open' | 'closing' | 'closed' = 'open'; @@ -142,6 +154,15 @@ export abstract class BrowserContext extends EventEmitter { this._timeoutSettings.setDefaultTimeout(timeout); } + _enableScreencast(options: types.ContextScreencastOptions) { + this._screencastOptions = options; + fs.mkdirSync(path.dirname(options.dir), {recursive: true}); + } + + _disableScreencast() { + this._screencastOptions = null; + } + async _loadDefaultContext(progress: Progress) { if (!this.pages().length) { const waitForEvent = helper.waitForEvent(progress, this, Events.BrowserContext.Page); diff --git a/src/chromium/crPage.ts b/src/chromium/crPage.ts index ccde06d880..391c419483 100644 --- a/src/chromium/crPage.ts +++ b/src/chromium/crPage.ts @@ -209,11 +209,11 @@ export class CRPage implements PageDelegate { await this._mainFrameSession._client.send('Emulation.setDefaultBackgroundColorOverride', { color }); } - async startVideoRecording(options: types.VideoRecordingOptions): Promise { + async startScreencast(options: types.PageScreencastOptions): Promise { throw new Error('Not implemented'); } - async stopVideoRecording(): Promise { + async stopScreencast(): Promise { throw new Error('Not implemented'); } diff --git a/src/events.ts b/src/events.ts index 589f19c273..f8208d2edd 100644 --- a/src/events.ts +++ b/src/events.ts @@ -23,6 +23,8 @@ export const Events = { BrowserContext: { Close: 'close', Page: 'page', + ScreencastStarted: 'screencaststarted', + ScreencastStopped: 'screencaststopped', }, BrowserServer: { diff --git a/src/firefox/ffPage.ts b/src/firefox/ffPage.ts index fdccd7ebc2..b4bb22845f 100644 --- a/src/firefox/ffPage.ts +++ b/src/firefox/ffPage.ts @@ -346,7 +346,7 @@ export class FFPage implements PageDelegate { throw new Error('Not implemented'); } - async startVideoRecording(options: types.VideoRecordingOptions): Promise { + async startScreencast(options: types.PageScreencastOptions): Promise { this._session.send('Page.startVideoRecording', { file: options.outputFile, width: options.width, @@ -355,7 +355,7 @@ export class FFPage implements PageDelegate { }); } - async stopVideoRecording(): Promise { + async stopScreencast(): Promise { await this._session.send('Page.stopVideoRecording'); } diff --git a/src/page.ts b/src/page.ts index d0c81ffc5f..ac3ed42074 100644 --- a/src/page.ts +++ b/src/page.ts @@ -57,8 +57,8 @@ export interface PageDelegate { canScreenshotOutsideViewport(): boolean; resetViewport(): Promise; // Only called if canScreenshotOutsideViewport() returns false. setBackgroundColor(color?: { r: number; g: number; b: number; a: number; }): Promise; - startVideoRecording(options: types.VideoRecordingOptions): Promise; - stopVideoRecording(): Promise; + startScreencast(options: types.PageScreencastOptions): Promise; + stopScreencast(): Promise; takeScreenshot(format: string, documentRect: types.Rect | undefined, viewportRect: types.Rect | undefined, quality: number | undefined): Promise; isElementHandle(remoteObject: any): boolean; diff --git a/src/types.ts b/src/types.ts index 7804b05118..f592c694d7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -60,13 +60,20 @@ export type ScreenshotOptions = ElementScreenshotOptions & { clip?: Rect, }; -export type VideoRecordingOptions = { - outputFile: string, +export type ScreencastOptions = { width: number, height: number, scale?: number, }; +export type PageScreencastOptions = ScreencastOptions & { + outputFile: string, +}; + +export type ContextScreencastOptions = ScreencastOptions & { + dir: string, +}; + export type URLMatch = string | RegExp | ((url: URL) => boolean); export type Credentials = { diff --git a/src/webkit/wkBrowser.ts b/src/webkit/wkBrowser.ts index 73b130b489..fb13858354 100644 --- a/src/webkit/wkBrowser.ts +++ b/src/webkit/wkBrowser.ts @@ -21,6 +21,7 @@ import { Events } from '../events'; import { helper, RegisteredListener, assert } from '../helper'; import * as network from '../network'; import { Page, PageBinding } from '../page'; +import * as path from 'path'; import { ConnectionTransport } from '../transport'; import * as types from '../types'; import { Protocol } from './protocol'; @@ -247,12 +248,17 @@ export class WKBrowserContext extends BrowserContext { const { pageProxyId } = await this._browser._browserSession.send('Playwright.createPage', { browserContextId: this._browserContextId }); const wkPage = this._browser._wkPages.get(pageProxyId)!; const result = await wkPage.pageOrError(); - if (result instanceof Page) { - if (result.isClosed()) - throw new Error('Page has been closed.'); - return result; + if (!(result instanceof Page)) + throw result; + if (result.isClosed()) + throw new Error('Page has been closed.'); + if (result._browserContext._screencastOptions) { + const contextOptions = result._browserContext._screencastOptions; + const outputFile = path.join(contextOptions.dir, helper.guid() + '.webm'); + const options = Object.assign({}, contextOptions, {outputFile}); + await wkPage.startScreencast(options); } - throw result; + return result; } async _doCookies(urls: string[]): Promise { diff --git a/src/webkit/wkPage.ts b/src/webkit/wkPage.ts index 25803d337d..bcfd7c23e7 100644 --- a/src/webkit/wkPage.ts +++ b/src/webkit/wkPage.ts @@ -15,6 +15,7 @@ * limitations under the License. */ +import { Screencast } from '../browserContext'; import * as frames from '../frames'; import { helper, RegisteredListener, assert, debugAssert } from '../helper'; import * as dom from '../dom'; @@ -68,6 +69,7 @@ export class WKPage implements PageDelegate { // Holds window features for the next popup being opened via window.open, // until the popup page proxy arrives. private _nextWindowOpenPopupFeatures?: string[]; + private _recordingVideoFile: string | null = null; constructor(browserContext: WKBrowserContext, pageProxySession: WKSession, opener: WKPage | null) { this._pageProxySession = pageProxySession; @@ -689,7 +691,9 @@ export class WKPage implements PageDelegate { } async closePage(runBeforeUnload: boolean): Promise { - this._pageProxySession.sendMayFail('Target.close', { + if (this._recordingVideoFile) + await this.stopScreencast(); + await this._pageProxySession.sendMayFail('Target.close', { targetId: this._session.sessionId, runBeforeUnload }); @@ -703,17 +707,31 @@ export class WKPage implements PageDelegate { await this._session.send('Page.setDefaultBackgroundColorOverride', { color }); } - async startVideoRecording(options: types.VideoRecordingOptions): Promise { - this._pageProxySession.send('Screencast.startVideoRecording', { - file: options.outputFile, - width: options.width, - height: options.height, - scale: options.scale, - }); + async startScreencast(options: types.PageScreencastOptions): Promise { + if (this._recordingVideoFile) + throw new Error('Already recording'); + this._recordingVideoFile = options.outputFile; + try { + await this._pageProxySession.send('Screencast.startVideoRecording', { + file: options.outputFile, + width: options.width, + height: options.height, + scale: options.scale, + }); + this._browserContext.emit(Events.BrowserContext.ScreencastStarted, new Screencast(options.outputFile, this._initializedPage!)); + } catch (e) { + this._recordingVideoFile = null; + throw e; + } } - async stopVideoRecording(): Promise { + async stopScreencast(): Promise { + if (!this._recordingVideoFile) + throw new Error('No video recording in progress'); + const fileName = this._recordingVideoFile; + this._recordingVideoFile = null; await this._pageProxySession.send('Screencast.stopVideoRecording'); + this._browserContext.emit(Events.BrowserContext.ScreencastStopped, new Screencast(fileName, this._initializedPage!)); } async takeScreenshot(format: string, documentRect: types.Rect | undefined, viewportRect: types.Rect | undefined, quality: number | undefined): Promise { diff --git a/test/runner/checkCoverage.js b/test/runner/checkCoverage.js index 8db519b333..2c5f82dae2 100644 --- a/test/runner/checkCoverage.js +++ b/test/runner/checkCoverage.js @@ -38,6 +38,10 @@ if (browserName !== 'chromium') { if (browserName === 'webkit') api.delete('browserContext.clearPermissions'); +// Screencast APIs that are not publicly available. +api.delete('browserContext.emit("screencaststarted")'); +api.delete('browserContext.emit("screencaststopped")'); + const coverageDir = path.join(__dirname, '..', 'coverage-report', 'coverage'); const coveredMethods = new Set(); diff --git a/test/screencast.spec.ts b/test/screencast.spec.ts index eeb2fff3ed..a10dc6b351 100644 --- a/test/screencast.spec.ts +++ b/test/screencast.spec.ts @@ -179,13 +179,13 @@ it.fail(options.CHROMIUM)('should capture static page', async({page, tmpDir, vid return; const videoFile = path.join(tmpDir, 'v.webm'); await page.evaluate(() => document.body.style.backgroundColor = 'red'); - await toImpl(page)._delegate.startVideoRecording({outputFile: videoFile, width: 640, height: 480}); + await toImpl(page)._delegate.startScreencast({outputFile: videoFile, width: 640, height: 480}); // TODO: in WebKit figure out why video size is not reported correctly for // static pictures. if (HEADLESS && options.WEBKIT) await page.setViewportSize({width: 1270, height: 950}); await new Promise(r => setTimeout(r, 300)); - await toImpl(page)._delegate.stopVideoRecording(); + await toImpl(page)._delegate.stopScreencast(); expect(fs.existsSync(videoFile)).toBe(true); await videoPlayer.load(videoFile); @@ -205,7 +205,7 @@ it.fail(options.CHROMIUM)('should capture navigation', async({page, tmpDir, serv return; const videoFile = path.join(tmpDir, 'v.webm'); await page.goto(server.PREFIX + '/background-color.html#rgb(0,0,0)'); - await toImpl(page)._delegate.startVideoRecording({outputFile: videoFile, width: 640, height: 480}); + await toImpl(page)._delegate.startScreencast({outputFile: videoFile, width: 640, height: 480}); // TODO: in WebKit figure out why video size is not reported correctly for // static pictures. if (HEADLESS && options.WEBKIT) @@ -213,7 +213,7 @@ it.fail(options.CHROMIUM)('should capture navigation', async({page, tmpDir, serv await new Promise(r => setTimeout(r, 300)); await page.goto(server.CROSS_PROCESS_PREFIX + '/background-color.html#rgb(100,100,100)'); await new Promise(r => setTimeout(r, 300)); - await toImpl(page)._delegate.stopVideoRecording(); + await toImpl(page)._delegate.stopScreencast(); expect(fs.existsSync(videoFile)).toBe(true); await videoPlayer.load(videoFile); @@ -239,13 +239,13 @@ it.fail(options.CHROMIUM || (options.WEBKIT && WIN))('should capture css transfo return; const videoFile = path.join(tmpDir, 'v.webm'); await page.goto(server.PREFIX + '/rotate-z.html'); - await toImpl(page)._delegate.startVideoRecording({outputFile: videoFile, width: 640, height: 480}); + await toImpl(page)._delegate.startScreencast({outputFile: videoFile, width: 640, height: 480}); // TODO: in WebKit figure out why video size is not reported correctly for // static pictures. if (HEADLESS && options.WEBKIT) await page.setViewportSize({width: 1270, height: 950}); await new Promise(r => setTimeout(r, 300)); - await toImpl(page)._delegate.stopVideoRecording(); + await toImpl(page)._delegate.stopScreencast(); expect(fs.existsSync(videoFile)).toBe(true); await videoPlayer.load(videoFile); @@ -258,3 +258,26 @@ it.fail(options.CHROMIUM || (options.WEBKIT && WIN))('should capture css transfo expectAll(pixels, almostRed); } }); + +it.fail(options.CHROMIUM || options.FFOX)('should fire start/stop events when page created/closed', async({browser, tmpDir, server, toImpl}) => { + if (!toImpl) + return; + // Use server side of the context. All the code below also uses server side APIs. + const context = toImpl(await browser.newContext()); + context._enableScreencast({width: 640, height: 480, dir: tmpDir}); + expect(context._screencastOptions).toBeTruthy(); + + const [startEvent, newPage] = await Promise.all([ + new Promise(resolve => context.on('screencaststarted', resolve)) as Promise, + context.newPage(), + ]); + expect(startEvent.page === newPage).toBe(true); + expect(startEvent.path).toBeTruthy(); + + const [stopEvent] = await Promise.all([ + new Promise(resolve => context.on('screencaststopped', resolve)) as Promise, + newPage.close(), + ]); + expect(stopEvent.page === newPage).toBe(true); + await context.close(); +});