diff --git a/docs/api.md b/docs/api.md index 5d4cbb9f8b..4a6f7af10a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -14,6 +14,7 @@ - [class: ConsoleMessage](#class-consolemessage) - [class: Dialog](#class-dialog) - [class: Download](#class-download) +- [class: Video](#class-video) - [class: FileChooser](#class-filechooser) - [class: Keyboard](#class-keyboard) - [class: Mouse](#class-mouse) @@ -787,6 +788,7 @@ page.removeListener('request', logRequest); - [page.uncheck(selector, [options])](#pageuncheckselector-options) - [page.unroute(url[, handler])](#pageunrouteurl-handler) - [page.url()](#pageurl) +- [page.video()](#pagevideo) - [page.viewportSize()](#pageviewportsize) - [page.waitForEvent(event[, optionsOrPredicate])](#pagewaitforeventevent-optionsorpredicate) - [page.waitForFunction(pageFunction[, arg, options])](#pagewaitforfunctionpagefunction-arg-options) @@ -1900,6 +1902,11 @@ Removes a route created with [page.route(url, handler)](#pagerouteurl-handler). This is a shortcut for [page.mainFrame().url()](#frameurl) +#### page.video() +- returns: <[null]|[Video]> + +Video object associated with this page. + #### page.viewportSize() - returns: <[null]|[Object]> - `width` <[number]> page width in pixels. @@ -3429,6 +3436,24 @@ Returns suggested filename for this download. It is typically computed by the br Returns downloaded url. +### class: Video + +When browser context is created with the `videosPath` option, each page has a video object associated with it. + +```js +console.log(await page.video().path()); +``` + + +- [video.path()](#videopath) + + +#### video.path() +- returns: <[string]> + +Returns the file system path this video will be recorded to. The video is guaranteed to be written to the filesystem upon closing the browser context. + + ### class: FileChooser [FileChooser] objects are dispatched by the page in the ['filechooser'](#event-filechooser) event. @@ -4818,6 +4843,7 @@ const { chromium } = require('playwright'); [URL]: https://nodejs.org/api/url.html [USKeyboardLayout]: ../src/usKeyboardLayout.ts "USKeyboardLayout" [UnixTime]: https://en.wikipedia.org/wiki/Unix_time "Unix Time" +[Video]: #class-video "Video" [WebKitBrowser]: #class-webkitbrowser "WebKitBrowser" [WebSocket]: #class-websocket "WebSocket" [Worker]: #class-worker "Worker" diff --git a/src/browserServerImpl.ts b/src/browserServerImpl.ts index 159a5c86d1..9bd913f1dd 100644 --- a/src/browserServerImpl.ts +++ b/src/browserServerImpl.ts @@ -181,7 +181,11 @@ class ConnectedBrowser extends BrowserDispatcher { const readable = fs.createReadStream(video._path); await new Promise(f => readable.on('readable', f)); const stream = new StreamDispatcher(this._remoteBrowser!._scope, readable); - this._remoteBrowser!._dispatchEvent('video', { stream, context: contextDispatcher }); + this._remoteBrowser!._dispatchEvent('video', { + stream, + context: contextDispatcher, + relativePath: video._relativePath + }); await new Promise(resolve => { readable.on('close', resolve); readable.on('end', resolve); diff --git a/src/client/api.ts b/src/client/api.ts index a3e64ab400..9364a09470 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -32,6 +32,7 @@ export { JSHandle } from './jsHandle'; export { Request, Response, Route } from './network'; export { Page } from './page'; export { Selectors } from './selectors'; +export { Video } from './video'; export { Worker } from './worker'; export { ChromiumBrowser } from './chromiumBrowser'; diff --git a/src/client/browser.ts b/src/client/browser.ts index bc31095803..f34705889e 100644 --- a/src/client/browser.ts +++ b/src/client/browser.ts @@ -58,8 +58,7 @@ export class Browser extends ChannelOwner; - _videosPathForRemote?: string; + _options: channels.BrowserNewContextParams = {}; static from(context: channels.BrowserContextChannel): BrowserContext { return (context as any)._object; diff --git a/src/client/browserType.ts b/src/client/browserType.ts index 193d4ac68a..760e97c30a 100644 --- a/src/client/browserType.ts +++ b/src/client/browserType.ts @@ -29,7 +29,7 @@ import { TimeoutSettings } from '../utils/timeoutSettings'; import { ChildProcess } from 'child_process'; import { envObjectToArray } from './clientHelper'; import { validateHeaders } from './network'; -import { assert, makeWaitForNextTask, headersObjectToArray, createGuid, mkdirIfNeeded } from '../utils/utils'; +import { assert, makeWaitForNextTask, headersObjectToArray, mkdirIfNeeded } from '../utils/utils'; import { SelectorsOwner, sharedSelectors } from './selectors'; import { kBrowserClosedError } from '../utils/errors'; import { Stream } from './stream'; @@ -108,6 +108,7 @@ export class BrowserType extends ChannelOwner { constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.RemoteBrowserInitializer) { super(parent, type, guid, initializer); - this._channel.on('video', ({ context, stream }) => this._onVideo(BrowserContext.from(context), Stream.from(stream))); + this._channel.on('video', ({ context, stream, relativePath }) => this._onVideo(BrowserContext.from(context), Stream.from(stream), relativePath)); } - private async _onVideo(context: BrowserContext, stream: Stream) { - if (!context._videosPathForRemote) { - stream._channel.close().catch(e => null); - return; - } - - const videoFile = path.join(context._videosPathForRemote, createGuid() + '.webm'); + private async _onVideo(context: BrowserContext, stream: Stream, relativePath: string) { + const videoFile = path.join(context._options.videosPath!, relativePath); await mkdirIfNeeded(videoFile); stream.stream().pipe(fs.createWriteStream(videoFile)); } diff --git a/src/client/page.ts b/src/client/page.ts index 99cedd5498..acaa9a7631 100644 --- a/src/client/page.ts +++ b/src/client/page.ts @@ -44,6 +44,7 @@ import { Size, URLMatch, Headers, LifecycleEvent, WaitForEventOptions, SelectOpt import { evaluationScript, urlMatches } from './clientHelper'; import { isString, isRegExp, isObject, mkdirIfNeeded, headersObjectToArray } from '../utils/utils'; import { isSafeCloseError } from '../utils/errors'; +import { Video } from './video'; const fsWriteFileAsync = util.promisify(fs.writeFile.bind(fs)); const mkdirAsync = util.promisify(fs.mkdir); @@ -82,6 +83,7 @@ export class Page extends ChannelOwner(); readonly _timeoutSettings: TimeoutSettings; _isPageCall = false; + private _video: Video | null = null; static from(page: channels.PageChannel): Page { return (page as any)._object; @@ -125,6 +127,7 @@ export class Page extends ChannelOwner this.emit(Events.Page.RequestFinished, Request.from(request))); this._channel.on('response', ({ response }) => 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('video', ({ relativePath }) => this.video()!._setRelativePath(relativePath)); this._channel.on('worker', ({ worker }) => this._onWorker(Worker.from(worker))); if (this._browserContext._browserName === 'chromium') { @@ -226,6 +229,15 @@ export class Page extends ChannelOwner(func: () => T): T { try { this._isPageCall = true; diff --git a/src/client/video.ts b/src/client/video.ts new file mode 100644 index 0000000000..e4b2f6ae65 --- /dev/null +++ b/src/client/video.ts @@ -0,0 +1,37 @@ +/** + * 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 path from 'path'; +import { Page } from './page'; + +export class Video { + private _page: Page; + private _pathCallback: ((path: string) => void) | undefined; + private _pathPromise: Promise; + + constructor(page: Page) { + this._page = page; + this._pathPromise = new Promise(f => this._pathCallback = f); + } + + _setRelativePath(relativePath: string) { + this._pathCallback!(path.join(this._page.context()._options.videosPath!, relativePath)); + } + + path(): Promise { + return this._pathPromise; + } +} diff --git a/src/dispatchers/pageDispatcher.ts b/src/dispatchers/pageDispatcher.ts index b70fb00d96..d9de32ead0 100644 --- a/src/dispatchers/pageDispatcher.ts +++ b/src/dispatchers/pageDispatcher.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { BrowserContext, runAction } from '../server/browserContext'; +import { BrowserContext, runAction, Video } from '../server/browserContext'; import { Frame } from '../server/frames'; import { Request } from '../server/network'; import { Page, Worker } from '../server/page'; @@ -66,6 +66,7 @@ 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, (video: Video) => this._dispatchEvent('video', { relativePath: video._relativePath })); page.on(Page.Events.Worker, worker => this._dispatchEvent('worker', { worker: new WorkerDispatcher(this._scope, worker) })); } diff --git a/src/protocol/channels.ts b/src/protocol/channels.ts index fd03c66437..65cf1ad7e5 100644 --- a/src/protocol/channels.ts +++ b/src/protocol/channels.ts @@ -125,6 +125,7 @@ export interface RemoteBrowserChannel extends Channel { export type RemoteBrowserVideoEvent = { context: BrowserContextChannel, stream: StreamChannel, + relativePath: string, }; // ----------- Selectors ----------- @@ -683,6 +684,7 @@ 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: 'video', callback: (params: PageVideoEvent) => void): this; on(event: 'worker', callback: (params: PageWorkerEvent) => void): this; setDefaultNavigationTimeoutNoReply(params: PageSetDefaultNavigationTimeoutNoReplyParams, metadata?: Metadata): Promise; setDefaultTimeoutNoReply(params: PageSetDefaultTimeoutNoReplyParams, metadata?: Metadata): Promise; @@ -765,6 +767,9 @@ export type PageRouteEvent = { route: RouteChannel, request: RequestChannel, }; +export type PageVideoEvent = { + relativePath: string, +}; export type PageWorkerEvent = { worker: WorkerChannel, }; diff --git a/src/protocol/protocol.yml b/src/protocol/protocol.yml index d4ef239bee..3127d6df99 100644 --- a/src/protocol/protocol.yml +++ b/src/protocol/protocol.yml @@ -175,6 +175,7 @@ RemoteBrowser: parameters: context: BrowserContext stream: Stream + relativePath: string Selectors: @@ -927,6 +928,10 @@ Page: route: Route request: Request + video: + parameters: + relativePath: string + worker: parameters: worker: Worker diff --git a/src/server/browserContext.ts b/src/server/browserContext.ts index b8a9a835ef..8dd8b84477 100644 --- a/src/server/browserContext.ts +++ b/src/server/browserContext.ts @@ -33,14 +33,16 @@ import * as path from 'path'; export class Video { readonly _videoId: string; readonly _path: string; + readonly _relativePath: string; readonly _context: BrowserContext; readonly _finishedPromise: Promise; private _finishCallback: () => void = () => {}; private _callbackOnFinish?: () => Promise; - constructor(context: BrowserContext, videoId: string, path: string) { + constructor(context: BrowserContext, videoId: string, p: string) { this._videoId = videoId; - this._path = path; + this._path = p; + this._relativePath = path.relative(context._options.videosPath!, p); this._context = context; this._finishedPromise = new Promise(fulfill => this._finishCallback = fulfill); } diff --git a/test/screencast.spec.ts b/test/screencast.spec.ts index d1a0da0aaf..65597f08d1 100644 --- a/test/screencast.spec.ts +++ b/test/screencast.spec.ts @@ -180,6 +180,22 @@ describe('screencast', suite => { } }); + it('should emit video event', async ({browser, testInfo}) => { + const videosPath = testInfo.outputPath(''); + const size = { width: 320, height: 240 }; + const context = await browser.newContext({ + videosPath, + viewport: size, + videoSize: size + }); + const page = await context.newPage(); + await page.evaluate(() => document.body.style.backgroundColor = 'red'); + await new Promise(r => setTimeout(r, 1000)); + await context.close(); + const path = await page.video()!.path(); + expect(path).toContain(videosPath); + }); + it('should capture navigation', async ({browser, server, testInfo}) => { const videosPath = testInfo.outputPath(''); const context = await browser.newContext({