diff --git a/docs/src/api/class-video.md b/docs/src/api/class-video.md index 4dad21d483145..13e19cb87e411 100644 --- a/docs/src/api/class-video.md +++ b/docs/src/api/class-video.md @@ -28,31 +28,36 @@ Alternatively, you can use [`method: Video.start`] and [`method: Video.stop`] to ```js await page.video().start(); // ... perform actions ... -await page.video().stop({ path: 'video.webm' }); +await page.video().stop(); +await page.video().saveAs('video.webm'); ``` ```java page.video().start(); // ... perform actions ... -page.video().stop(new Video.StopOptions().setPath(Paths.get("video.webm"))); +page.video().stop(); +page.video().saveAs(Paths.get("video.webm")); ``` ```python async await page.video.start() # ... perform actions ... -await page.video.stop(path="video.webm") +await page.video.stop() +await page.video.save_as("video.webm") ``` ```python sync page.video.start() # ... perform actions ... -page.video.stop(path="video.webm") +page.video.stop() +page.video.save_as("video.webm") ``` ```csharp await page.Video.StartAsync(); // ... perform actions ... -await page.Video.StopAsync(new() { Path = "video.webm" }); +await page.Video.StopAsync(); +await page.Video.SaveAsAsync("video.webm"); ``` ## async method: Video.delete @@ -140,10 +145,4 @@ Optional dimensions of the recorded video. If not specified the size will be equ ## async method: Video.stop * since: v1.59 -Stops video recording started with [`method: Video.start`] and either saves or discards the video file. - -### option: Video.stop.path -* since: v1.59 -- `path` <[path]> - -Path where the video should be saved. +Stops video recording started with [`method: Video.start`]. Use [`method: Video.path`] or [`method: Video.saveAs`] methods to access the video file. diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index add5421430fa2..d907c86570656 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -4808,8 +4808,8 @@ export interface Page { /** * Video object associated with this page. Can be used to control video recording with * [video.start([options])](https://playwright.dev/docs/api/class-video#video-start) and - * [video.stop([options])](https://playwright.dev/docs/api/class-video#video-stop), or to access the video file when - * using the `recordVideo` context option. + * [video.stop()](https://playwright.dev/docs/api/class-video#video-stop), or to access the video file when using the + * `recordVideo` context option. */ video(): Video; @@ -21835,13 +21835,14 @@ export interface Tracing { * ``` * * Alternatively, you can use [video.start([options])](https://playwright.dev/docs/api/class-video#video-start) and - * [video.stop([options])](https://playwright.dev/docs/api/class-video#video-stop) to record video manually. This - * approach is mutually exclusive with the `recordVideo` option. + * [video.stop()](https://playwright.dev/docs/api/class-video#video-stop) to record video manually. This approach is + * mutually exclusive with the `recordVideo` option. * * ```js * await page.video().start(); * // ... perform actions ... - * await page.video().stop({ path: 'video.webm' }); + * await page.video().stop(); + * await page.video().saveAs('video.webm'); * ``` * */ @@ -21897,16 +21898,11 @@ export interface Video { /** * Stops video recording started with - * [video.start([options])](https://playwright.dev/docs/api/class-video#video-start) and either saves or discards the - * video file. - * @param options + * [video.start([options])](https://playwright.dev/docs/api/class-video#video-start). Use + * [video.path()](https://playwright.dev/docs/api/class-video#video-path) or + * [video.saveAs(path)](https://playwright.dev/docs/api/class-video#video-save-as) methods to access the video file. */ - stop(options?: { - /** - * Path where the video should be saved. - */ - path?: string; - }): Promise; + stop(): Promise; } /** diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index 3750a936fb8b0..4800723f0c023 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -99,7 +99,7 @@ export class Page extends ChannelOwner implements api.Page readonly _bindings = new Map any>(); readonly _timeoutSettings: TimeoutSettings; - private _video: Video | null = null; + private _video: Video; readonly _opener: Page | null; private _closeReason: string | undefined; _closeWasCalled: boolean = false; @@ -133,6 +133,7 @@ export class Page extends ChannelOwner implements api.Page this._viewportSize = initializer.viewportSize; this._closed = initializer.isClosed; this._opener = Page.fromNullable(initializer.opener); + this._video = new Video(this, this._connection, initializer.video ? Artifact.from(initializer.video) : undefined); this._channel.on('bindingCall', ({ binding }) => this._onBinding(BindingCall.from(binding))); this._channel.on('close', () => this._onClose()); @@ -147,10 +148,6 @@ export class Page extends ChannelOwner implements api.Page this._channel.on('locatorHandlerTriggered', ({ uid }) => this._onLocatorHandlerTriggered(uid)); this._channel.on('route', ({ route }) => this._onRoute(Route.from(route))); this._channel.on('webSocketRoute', ({ webSocketRoute }) => this._onWebSocketRoute(WebSocketRoute.from(webSocketRoute))); - this._channel.on('video', ({ artifact }) => { - const artifactObject = Artifact.from(artifact); - this._forceVideo()._artifactReady(artifactObject); - }); this._channel.on('viewportSizeChanged', ({ viewportSize }) => this._viewportSize = viewportSize); this._channel.on('webSocket', ({ webSocket }) => this.emit(Events.Page.WebSocket, WebSocket.from(webSocket))); this._channel.on('worker', ({ worker }) => this._onWorker(Worker.from(worker))); @@ -282,17 +279,8 @@ export class Page extends ChannelOwner implements api.Page this._timeoutSettings.setDefaultTimeout(timeout); } - private _forceVideo(): Video { - if (!this._video) - this._video = new Video(this, this._connection); - return this._video; - } - video(): Video { - // Note: we are creating Video object lazily, because we do not know - // BrowserContextOptions when constructing the page - it is assigned - // too late during launchPersistentContext. - return this._forceVideo(); + return this._video; } async $(selector: string, options?: { strict?: boolean }): Promise | null> { diff --git a/packages/playwright-core/src/client/video.ts b/packages/playwright-core/src/client/video.ts index 6a022647c65d2..93678a09cd7e7 100644 --- a/packages/playwright-core/src/client/video.ts +++ b/packages/playwright-core/src/client/video.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import { ManualPromise } from '../utils/isomorphic/manualPromise'; import { Artifact } from './artifact'; import type { Connection } from './connection'; @@ -22,60 +21,41 @@ import type { Page } from './page'; import type * as api from '../../types/types'; export class Video implements api.Video { - private _artifact: Promise | null = null; - private _artifactReadyPromise: ManualPromise; + private _artifact: Artifact | undefined; private _isRemote = false; private _page: Page; - private _path: string | undefined; - constructor(page: Page, connection: Connection) { + constructor(page: Page, connection: Connection, artifact: Artifact | undefined) { this._page = page; this._isRemote = connection.isRemote(); - this._artifactReadyPromise = new ManualPromise(); - this._artifact = page._closedOrCrashedScope.safeRace(this._artifactReadyPromise); - } - - _artifactReady(artifact: Artifact) { - this._artifactReadyPromise.resolve(artifact); + this._artifact = artifact; } async start(options: { size?: { width: number, height: number } } = {}): Promise { const result = await this._page._channel.videoStart(options); - this._path = result.path; - this._artifactReadyPromise = new ManualPromise(); - this._artifact = this._page._closedOrCrashedScope.safeRace(this._artifactReadyPromise); + this._artifact = Artifact.from(result.artifact); } - async stop(options: { path?: string } = {}): Promise { - await this._page._wrapApiCall(async () => { - await this._page._channel.videoStop(); - if (options.path) - await this.saveAs(options.path); - }); + async stop(): Promise { + await this._page._channel.videoStop(); } async path(): Promise { if (this._isRemote) throw new Error(`Path is not available when connecting remotely. Use saveAs() to save a local copy.`); - if (this._path) - return this._path; - - const artifact = await this._artifact; - if (!artifact) - throw new Error('Page did not produce any video frames'); - return artifact._initializer.absolutePath; + if (!this._artifact) + throw new Error('Video recording has not been started.'); + return this._artifact._initializer.absolutePath; } async saveAs(path: string): Promise { - const artifact = await this._artifact; - if (!artifact) - throw new Error('Page did not produce any video frames'); - return await artifact.saveAs(path); + if (!this._artifact) + throw new Error('Video recording has not been started.'); + return await this._artifact.saveAs(path); } async delete(): Promise { - const artifact = await this._artifact; - if (artifact) - await artifact.delete(); + if (this._artifact) + await this._artifact.delete(); } } diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 37ede45c1869a..2c94b0b45ae3f 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -928,9 +928,6 @@ scheme.BrowserContextRouteEvent = tObject({ scheme.BrowserContextWebSocketRouteEvent = tObject({ webSocketRoute: tChannel(['WebSocketRoute']), }); -scheme.BrowserContextVideoEvent = tObject({ - artifact: tChannel(['Artifact']), -}); scheme.BrowserContextServiceWorkerEvent = tObject({ worker: tChannel(['Worker']), }); @@ -1167,6 +1164,7 @@ scheme.PageInitializer = tObject({ })), isClosed: tBoolean, opener: tOptional(tChannel(['Page'])), + video: tOptional(tChannel(['Artifact'])), }); scheme.PageBindingCallEvent = tObject({ binding: tChannel(['BindingCall']), @@ -1203,9 +1201,6 @@ scheme.PageRouteEvent = tObject({ scheme.PageWebSocketRouteEvent = tObject({ webSocketRoute: tChannel(['WebSocketRoute']), }); -scheme.PageVideoEvent = tObject({ - artifact: tChannel(['Artifact']), -}); scheme.PageWebSocketEvent = tObject({ webSocket: tChannel(['WebSocket']), }); @@ -1510,7 +1505,7 @@ scheme.PageVideoStartParams = tObject({ })), }); scheme.PageVideoStartResult = tObject({ - path: tString, + artifact: tChannel(['Artifact']), }); scheme.PageVideoStopParams = tOptional(tObject({})); scheme.PageVideoStopResult = tOptional(tObject({})); diff --git a/packages/playwright-core/src/server/browser.ts b/packages/playwright-core/src/server/browser.ts index 615a372d980c8..d974cbe7e44dd 100644 --- a/packages/playwright-core/src/server/browser.ts +++ b/packages/playwright-core/src/server/browser.ts @@ -151,16 +151,11 @@ export abstract class Browser extends SdkObject { this._downloads.delete(uuid); } - _videoStarted(context: BrowserContext, videoId: string, path: string, pageOrError: Promise) { - const artifact = new Artifact(context, path); - this._idToVideo.set(videoId, { context, artifact }); - pageOrError.then(page => { - if (page instanceof Page) { - page.video = artifact; - page.emitOnContext(BrowserContext.Events.VideoStarted, artifact); - page.emit(Page.Events.Video, artifact); - } - }); + _videoStarted(page: Page, videoId: string, path: string) { + const artifact = new Artifact(page.browserContext, path); + page.video = artifact; + this._idToVideo.set(videoId, { context: page.browserContext, artifact }); + return artifact; } _takeVideo(videoId: string): Artifact | undefined { diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index f948fb180abd0..1bad49f02279a 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -63,7 +63,6 @@ const BrowserContextEvent = { RequestFulfilled: 'requestfulfilled', RequestContinued: 'requestcontinued', BeforeClose: 'beforeclose', - VideoStarted: 'videostarted', RecorderEvent: 'recorderevent', } as const; @@ -80,7 +79,6 @@ export type BrowserContextEventMap = { [BrowserContextEvent.RequestFulfilled]: [request: network.Request]; [BrowserContextEvent.RequestContinued]: [request: network.Request]; [BrowserContextEvent.BeforeClose]: []; - [BrowserContextEvent.VideoStarted]: [artifact: Artifact]; [BrowserContextEvent.RecorderEvent]: [event: { event: 'actionAdded' | 'actionUpdated' | 'signalAdded', data: any, page: Page, code: string }]; }; diff --git a/packages/playwright-core/src/server/chromium/crPage.ts b/packages/playwright-core/src/server/chromium/crPage.ts index d5710ca3720a1..04402b3298d35 100644 --- a/packages/playwright-core/src/server/chromium/crPage.ts +++ b/packages/playwright-core/src/server/chromium/crPage.ts @@ -438,7 +438,7 @@ class FrameSession { } let videoOptions: types.VideoOptions | undefined; - if (!this._page.isStorageStatePage && this._isMainFrame() && hasUIWindow) + if (this._isMainFrame() && hasUIWindow) videoOptions = this._crPage._page.screencast.launchAutomaticVideoRecorder(); let lifecycleEventsEnabled: Promise; diff --git a/packages/playwright-core/src/server/dispatchers/artifactDispatcher.ts b/packages/playwright-core/src/server/dispatchers/artifactDispatcher.ts index 556b0ae7ec408..0279fdbad96c1 100644 --- a/packages/playwright-core/src/server/dispatchers/artifactDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/artifactDispatcher.ts @@ -32,7 +32,7 @@ export class ArtifactDispatcher extends Dispatcher(artifact); diff --git a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts index 45a20e33c678b..2c5bce258311d 100644 --- a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts @@ -37,7 +37,6 @@ import { RecorderApp } from '../recorder/recorderApp'; import { ElementHandleDispatcher } from './elementHandlerDispatcher'; import { JSHandleDispatcher } from './jsHandleDispatcher'; -import type { Artifact } from '../artifact'; import type { ConsoleMessage } from '../console'; import type { Dialog } from '../dialog'; import type { Request, Response, RouteHandler } from '../network'; @@ -97,18 +96,6 @@ export class BrowserContextDispatcher extends Dispatcher { - // Note: Video must outlive Page and BrowserContext, so that client can saveAs it - // after closing the context. We use |scope| for it. - const artifactDispatcher = ArtifactDispatcher.from(parentScope, artifact); - this._dispatchEvent('video', { artifact: artifactDispatcher }); - }; - this.addObjectListener(BrowserContext.Events.VideoStarted, onVideo); - for (const video of context._browser._idToVideo.values()) { - if (video.context === context) - onVideo(video.artifact); - } - for (const page of context.pages()) this._dispatchEvent('page', { page: PageDispatcher.from(this, page) }); this.addObjectListener(BrowserContext.Events.Page, page => { diff --git a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts index c34ab698574c2..3ba9cc20d8cc6 100644 --- a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts @@ -80,7 +80,8 @@ export class PageDispatcher extends Dispatcher this._dispatchEvent('locatorHandlerTriggered', { uid })); this.addObjectListener(Page.Events.WebSocket, webSocket => this._dispatchEvent('webSocket', { webSocket: new WebSocketDispatcher(this, webSocket) })); this.addObjectListener(Page.Events.Worker, worker => this._dispatchEvent('worker', { worker: new WorkerDispatcher(this, worker) })); - this.addObjectListener(Page.Events.Video, (artifact: Artifact) => this._dispatchEvent('video', { artifact: ArtifactDispatcher.from(parentScope, artifact) })); - if (page.video) - this._dispatchEvent('video', { artifact: ArtifactDispatcher.from(this.parentScope(), page.video) }); // Ensure client knows about all frames. const frames = page.frameManager.frames(); for (let i = 1; i < frames.length; i++) @@ -339,7 +337,8 @@ export class PageDispatcher extends Dispatcher { - return await this._page.screencast.startExplicitVideoRecording(params); + const artifact = await this._page.screencast.startExplicitVideoRecording(params); + return { artifact: createVideoDispatcher(this.parentScope(), artifact) }; } async videoStop(params: channels.PageVideoStopParams, progress: Progress): Promise { @@ -481,3 +480,9 @@ export class BindingCallDispatcher extends Dispatcher; private _eventListeners: RegisteredListener[]; private _workers = new Map(); - private _screencastId: string | undefined; private _initScripts: { initScript: InitScript, worldName?: string }[] = []; constructor(session: FFSession, browserContext: FFBrowserContext, opener: FFPage | null) { @@ -98,23 +96,18 @@ export class FFPage implements PageDelegate { ]; - const screencast = this._page.screencast; - const videoOptions = screencast.launchAutomaticVideoRecorder(); + const promises: Promise[] = []; + + const videoOptions = this._page.screencast.launchAutomaticVideoRecorder(); if (videoOptions) - screencast.startVideoRecording(videoOptions).catch(e => debugLogger.log('error', e)); + promises.push(this._page.screencast.startVideoRecording(videoOptions)); + promises.push(this.addInitScript(new InitScript(''), UTILITY_WORLD_NAME)); + promises.push(new Promise(f => this._session.once('Page.ready', f))); - this._session.once('Page.ready', () => { - if (this._reportedAsNew) - return; - this._reportedAsNew = true; - this._page.reportAsNew(this._opener?._page); - }); - // Ideally, we somehow ensure that utility world is created before Page.ready arrives, but currently it is racy. - // Therefore, we can end up with an initialized page without utility world, although very unlikely. - this.addInitScript(new InitScript(''), UTILITY_WORLD_NAME).catch(e => this._markAsError(e)); + Promise.all(promises).then(() => this._reportAsNew(), error => this._reportAsNew(error)); } - async _markAsError(error: Error) { + _reportAsNew(error?: Error) { // Same error may be reported twice: channel disconnected and session.send fails. if (this._reportedAsNew) return; @@ -318,7 +311,7 @@ export class FFPage implements PageDelegate { didClose() { - this._markAsError(new TargetClosedError(this._page.closeReason())); + this._reportAsNew(new TargetClosedError(this._page.closeReason())); this._session.dispose(); eventsHelper.removeEventListeners(this._eventListeners); this._networkManager.dispose(); diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index 278b89a10475e..c85fb7776754e 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -130,7 +130,6 @@ const PageEvent = { InternalFrameNavigatedToNewDocument: 'internalframenavigatedtonewdocument', LocatorHandlerTriggered: 'locatorhandlertriggered', ScreencastFrame: 'screencastframe', - Video: 'video', WebSocket: 'websocket', Worker: 'worker', } as const; @@ -146,7 +145,6 @@ export type PageEventMap = { [PageEvent.InternalFrameNavigatedToNewDocument]: [frame: frames.Frame]; [PageEvent.LocatorHandlerTriggered]: [uid: number]; [PageEvent.ScreencastFrame]: [frame: types.ScreencastFrame]; - [PageEvent.Video]: [artifact: Artifact]; [PageEvent.WebSocket]: [webSocket: network.WebSocket]; [PageEvent.Worker]: [worker: Worker]; }; @@ -179,7 +177,7 @@ export class Page extends SdkObject { readonly pdf: ((options: channels.PagePdfParams) => Promise) | undefined; readonly coverage: any; readonly requestInterceptors: network.RouteHandler[] = []; - video: Artifact | null = null; + video: Artifact | undefined; private _opener: Page | undefined; readonly isStorageStatePage: boolean; private _locatorHandlers = new Map }>(); diff --git a/packages/playwright-core/src/server/screencast.ts b/packages/playwright-core/src/server/screencast.ts index a51996c14de38..bad18985a9f85 100644 --- a/packages/playwright-core/src/server/screencast.ts +++ b/packages/playwright-core/src/server/screencast.ts @@ -61,7 +61,7 @@ export class Screencast { // and it is equally important to send Screencast.startScreencast before sending Target.resume. launchAutomaticVideoRecorder(): types.VideoOptions | undefined { const recordVideo = this._page.browserContext._options.recordVideo; - if (!recordVideo) + if (!recordVideo || this._page.isStorageStatePage) return; // validateBrowserContextOptions ensures correct video size. return this._launchVideoRecorder(recordVideo.dir, recordVideo.size!); @@ -92,16 +92,12 @@ export class Screencast { const videoId = this._videoId; assert(videoId); this._page.once(Page.Events.Close, () => this.stopVideoRecording().catch(() => {})); - const gotFirstFrame = new Promise(f => this._page.once(Page.Events.ScreencastFrame, f)); await this._startScreencast(this._videoRecorder, { quality: 90, width: options.width, height: options.height, }); - // Wait for the first frame before reporting video to the client. - gotFirstFrame.then(() => { - this._page.browserContext._browser._videoStarted(this._page.browserContext, videoId, options.outputFile, this._page.waitForInitializedOrError()); - }); + return this._page.browserContext._browser._videoStarted(this._page, videoId, options.outputFile); } async stopVideoRecording(): Promise { @@ -127,8 +123,7 @@ export class Screencast { throw new Error('Video is already being recorded'); const size = validateVideoSize(options.size, this._page.emulatedSize()?.viewport); const videoOptions = this._launchVideoRecorder(this._page.browserContext._browser.options.artifactsDir, size); - await this.startVideoRecording(videoOptions); - return { path: videoOptions.outputFile }; + return await this.startVideoRecording(videoOptions); } async stopExplicitVideoRecording() { diff --git a/packages/playwright-core/src/server/videoRecorder.ts b/packages/playwright-core/src/server/videoRecorder.ts index 5a91f25fb0fd3..77a150f54dc71 100644 --- a/packages/playwright-core/src/server/videoRecorder.ts +++ b/packages/playwright-core/src/server/videoRecorder.ts @@ -16,6 +16,7 @@ import { assert, debugLogger, mkdirIfNeeded, monotonicTime } from '../utils'; import { launchProcess } from './utils/processLauncher'; +import { jpegjs } from '../utilsBundle'; import type * as types from './types'; import type { ChildProcess } from 'child_process'; @@ -23,6 +24,7 @@ import type { ChildProcess } from 'child_process'; const fps = 25; export class VideoRecorder { + private _options: types.VideoOptions; private _process: ChildProcess | null = null; private _gracefullyClose: (() => Promise) | null = null; private _lastWritePromise: Promise = Promise.resolve(); @@ -38,11 +40,12 @@ export class VideoRecorder { this._ffmpegPath = ffmpegPath; if (!options.outputFile.endsWith('.webm')) throw new Error('File must have .webm extension'); - this._launchPromise = this._launch(options).catch(e => e); + this._options = options; + this._launchPromise = this._launch().catch(e => e); } - private async _launch(options: types.VideoOptions) { - await mkdirIfNeeded(options.outputFile); + private async _launch() { + await mkdirIfNeeded(this._options.outputFile); // How to tune the codec: // 1. Read vp8 documentation to figure out the options. // https://www.webmproject.org/docs/encoder-parameters/ @@ -82,10 +85,10 @@ export class VideoRecorder { // "-threads 1" means using one thread. This drastically reduces stalling when // cpu is overbooked. By default vp8 tries to use all available threads? - const w = options.width; - const h = options.height; + const w = this._options.width; + const h = this._options.height; const args = `-loglevel error -f image2pipe -avioflags direct -fpsprobesize 0 -probesize 32 -analyzeduration 0 -c:v mjpeg -i pipe:0 -y -an -r ${fps} -c:v vp8 -qmin 0 -qmax 50 -crf 8 -deadline realtime -speed 8 -b:v 1M -threads 1 -vf pad=${w}:${h}:0:0:gray,crop=${w}:${h}:0:0`.split(' '); - args.push(options.outputFile); + args.push(this._options.outputFile); const { launchedProcess, gracefullyClose } = await launchProcess({ command: this._ffmpegPath, @@ -157,12 +160,16 @@ export class VideoRecorder { const error = await this._launchPromise; if (error) throw error; - if (this._isStopped || !this._lastFrame) + if (this._isStopped) return; + if (!this._lastFrame) { + // ffmpeg only creates a file upon some non-empty input + this._writeFrame(createWhiteImage(this._options.width, this._options.height), monotonicTime()); + } // Pad with at least 1s of the last frame in the end for convenience. // This also ensures non-empty videos with 1 frame. const addTime = Math.max((monotonicTime() - this._lastWriteNodeTime) / 1000, 1); - this._writeFrame(Buffer.from([]), this._lastFrame.timestamp + addTime); + this._writeFrame(Buffer.from([]), this._lastFrame!.timestamp + addTime); this._isStopped = true; try { await this._lastWritePromise; @@ -172,3 +179,8 @@ export class VideoRecorder { } } } + +function createWhiteImage(width: number, height: number): Buffer { + const data = Buffer.alloc(width * height * 4, 255); + return jpegjs.encode({ data, width, height }, 80).data; +} diff --git a/packages/playwright-core/src/server/webkit/wkBrowser.ts b/packages/playwright-core/src/server/webkit/wkBrowser.ts index 442e99f77bf6b..b02830787e972 100644 --- a/packages/playwright-core/src/server/webkit/wkBrowser.ts +++ b/packages/playwright-core/src/server/webkit/wkBrowser.ts @@ -21,7 +21,6 @@ import { BrowserContext, verifyGeolocation } from '../browserContext'; import * as network from '../network'; import { WKConnection, WKSession, kPageProxyMessageReceived } from './wkConnection'; import { WKPage } from './wkPage'; -import { TargetClosedError } from '../errors'; import { translatePathToWSL } from './webkit'; import type { BrowserOptions } from '../browser'; @@ -76,9 +75,6 @@ export class WKBrowser extends Browser { for (const wkPage of this._wkPages.values()) wkPage.didClose(); this._wkPages.clear(); - for (const video of this._idToVideo.values()) - video.artifact.reportFinished(new TargetClosedError(this.closeReason())); - this._idToVideo.clear(); this._didClose(); } diff --git a/packages/playwright-core/src/utils/isomorphic/manualPromise.ts b/packages/playwright-core/src/utils/isomorphic/manualPromise.ts index 467b8de0dba2e..3b237a0fe0257 100644 --- a/packages/playwright-core/src/utils/isomorphic/manualPromise.ts +++ b/packages/playwright-core/src/utils/isomorphic/manualPromise.ts @@ -88,7 +88,9 @@ export class LongStandingScope { return this._race(Array.isArray(promise) ? promise : [promise], false) as Promise; } - async safeRace(promise: Promise, defaultValue?: T): Promise { + async safeRace(promise: Promise, defaultValue: T): Promise; + async safeRace(promise: Promise): Promise; + async safeRace(promise: Promise, defaultValue?: T): Promise { return this._race([promise], true, defaultValue); } diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index add5421430fa2..d907c86570656 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -4808,8 +4808,8 @@ export interface Page { /** * Video object associated with this page. Can be used to control video recording with * [video.start([options])](https://playwright.dev/docs/api/class-video#video-start) and - * [video.stop([options])](https://playwright.dev/docs/api/class-video#video-stop), or to access the video file when - * using the `recordVideo` context option. + * [video.stop()](https://playwright.dev/docs/api/class-video#video-stop), or to access the video file when using the + * `recordVideo` context option. */ video(): Video; @@ -21835,13 +21835,14 @@ export interface Tracing { * ``` * * Alternatively, you can use [video.start([options])](https://playwright.dev/docs/api/class-video#video-start) and - * [video.stop([options])](https://playwright.dev/docs/api/class-video#video-stop) to record video manually. This - * approach is mutually exclusive with the `recordVideo` option. + * [video.stop()](https://playwright.dev/docs/api/class-video#video-stop) to record video manually. This approach is + * mutually exclusive with the `recordVideo` option. * * ```js * await page.video().start(); * // ... perform actions ... - * await page.video().stop({ path: 'video.webm' }); + * await page.video().stop(); + * await page.video().saveAs('video.webm'); * ``` * */ @@ -21897,16 +21898,11 @@ export interface Video { /** * Stops video recording started with - * [video.start([options])](https://playwright.dev/docs/api/class-video#video-start) and either saves or discards the - * video file. - * @param options + * [video.start([options])](https://playwright.dev/docs/api/class-video#video-start). Use + * [video.path()](https://playwright.dev/docs/api/class-video#video-path) or + * [video.saveAs(path)](https://playwright.dev/docs/api/class-video#video-save-as) methods to access the video file. */ - stop(options?: { - /** - * Path where the video should be saved. - */ - path?: string; - }): Promise; + stop(): Promise; } /** diff --git a/packages/playwright/src/mcp/browser/tools/video.ts b/packages/playwright/src/mcp/browser/tools/video.ts index 4bd777f318d3e..607abed0478e5 100644 --- a/packages/playwright/src/mcp/browser/tools/video.ts +++ b/packages/playwright/src/mcp/browser/tools/video.ts @@ -54,7 +54,8 @@ const stopVideo = defineTabTool({ handle: async (tab, params, response) => { const resolvedFile = await response.resolveFile({ prefix: 'video', ext: 'webm', suggestedFilename: params.filename }, 'Video'); - await tab.page.video().stop({ path: resolvedFile.fileName }); + await tab.page.video().stop(); + await tab.page.video().saveAs(resolvedFile.fileName); await response.addFileResult(resolvedFile, null); }, }); diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index 595177f745ed3..b65e5cbd8fba4 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -1580,7 +1580,6 @@ export interface BrowserContextEventTarget { on(event: 'pageError', callback: (params: BrowserContextPageErrorEvent) => void): this; on(event: 'route', callback: (params: BrowserContextRouteEvent) => void): this; on(event: 'webSocketRoute', callback: (params: BrowserContextWebSocketRouteEvent) => void): this; - on(event: 'video', callback: (params: BrowserContextVideoEvent) => void): this; on(event: 'serviceWorker', callback: (params: BrowserContextServiceWorkerEvent) => void): this; on(event: 'request', callback: (params: BrowserContextRequestEvent) => void): this; on(event: 'requestFailed', callback: (params: BrowserContextRequestFailedEvent) => void): this; @@ -1658,9 +1657,6 @@ export type BrowserContextRouteEvent = { export type BrowserContextWebSocketRouteEvent = { webSocketRoute: WebSocketRouteChannel, }; -export type BrowserContextVideoEvent = { - artifact: ArtifactChannel, -}; export type BrowserContextServiceWorkerEvent = { worker: WorkerChannel, }; @@ -2029,7 +2025,6 @@ export interface BrowserContextEvents { 'pageError': BrowserContextPageErrorEvent; 'route': BrowserContextRouteEvent; 'webSocketRoute': BrowserContextWebSocketRouteEvent; - 'video': BrowserContextVideoEvent; 'serviceWorker': BrowserContextServiceWorkerEvent; 'request': BrowserContextRequestEvent; 'requestFailed': BrowserContextRequestFailedEvent; @@ -2047,6 +2042,7 @@ export type PageInitializer = { }, isClosed: boolean, opener?: PageChannel, + video?: ArtifactChannel, }; export interface PageEventTarget { on(event: 'bindingCall', callback: (params: PageBindingCallEvent) => void): this; @@ -2060,7 +2056,6 @@ export interface PageEventTarget { on(event: 'locatorHandlerTriggered', callback: (params: PageLocatorHandlerTriggeredEvent) => void): this; on(event: 'route', callback: (params: PageRouteEvent) => void): this; on(event: 'webSocketRoute', callback: (params: PageWebSocketRouteEvent) => void): this; - on(event: 'video', callback: (params: PageVideoEvent) => void): this; on(event: 'webSocket', callback: (params: PageWebSocketEvent) => void): this; on(event: 'worker', callback: (params: PageWorkerEvent) => void): this; } @@ -2144,9 +2139,6 @@ export type PageRouteEvent = { export type PageWebSocketRouteEvent = { webSocketRoute: WebSocketRouteChannel, }; -export type PageVideoEvent = { - artifact: ArtifactChannel, -}; export type PageWebSocketEvent = { webSocket: WebSocketChannel, }; @@ -2618,7 +2610,7 @@ export type PageVideoStartOptions = { }, }; export type PageVideoStartResult = { - path: string, + artifact: ArtifactChannel, }; export type PageVideoStopParams = {}; export type PageVideoStopOptions = {}; @@ -2679,7 +2671,6 @@ export interface PageEvents { 'locatorHandlerTriggered': PageLocatorHandlerTriggeredEvent; 'route': PageRouteEvent; 'webSocketRoute': PageWebSocketRouteEvent; - 'video': PageVideoEvent; 'webSocket': PageWebSocketEvent; 'worker': PageWorkerEvent; } diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index e0da82b597845..13f13ca3b4bce 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -1500,10 +1500,6 @@ BrowserContext: parameters: webSocketRoute: WebSocketRoute - video: - parameters: - artifact: Artifact - serviceWorker: parameters: worker: Worker @@ -1558,6 +1554,7 @@ Page: height: int isClosed: boolean opener: Page? + video: Artifact? commands: @@ -2039,7 +2036,7 @@ Page: width: int height: int returns: - path: string + artifact: Artifact videoStop: title: Stop video recording @@ -2131,10 +2128,6 @@ Page: parameters: webSocketRoute: WebSocketRoute - video: - parameters: - artifact: Artifact - webSocket: parameters: webSocket: WebSocket diff --git a/tests/library/video.spec.ts b/tests/library/video.spec.ts index a260ba6ae4838..11a796a253079 100644 --- a/tests/library/video.spec.ts +++ b/tests/library/video.spec.ts @@ -21,6 +21,7 @@ import { PNG, jpegjs } from 'playwright-core/lib/utilsBundle'; import { registry } from '../../packages/playwright-core/lib/server'; import { expect, browserTest as it } from '../config/browserTest'; import { parseTraceRaw, rafraf } from '../config/utils'; +import { kTargetClosedErrorMessage } from '../config/errors'; export class VideoPlayer { fileName: string; @@ -81,6 +82,10 @@ export class VideoPlayer { type Pixel = { r: number, g: number, b: number, alpha: number }; type PixelPredicate = (pixel: Pixel) => boolean; +function isAlmostWhite({ r, g, b, alpha }: Pixel): boolean { + return r > 185 && g > 185 && b > 185 && alpha === 255; +} + function isAlmostRed({ r, g, b, alpha }: Pixel): boolean { return r > 185 && g < 70 && b < 70 && alpha === 255; } @@ -254,36 +259,6 @@ it.describe('screencast', () => { expect(fs.existsSync(path)).toBeTruthy(); }); - it('saveAs should throw when no video frames', async ({ browser }, testInfo) => { - const videosPath = testInfo.outputPath(''); - const size = { width: 320, height: 240 }; - const context = await browser.newContext({ - recordVideo: { - dir: videosPath, - size - }, - viewport: size, - }); - - const page = await context.newPage(); - const [popup] = await Promise.all([ - page.context().waitForEvent('page'), - page.evaluate(() => { - const win = window.open('about:blank'); - win.close(); - }), - ]); - await page.close(); - - const saveAsPath = testInfo.outputPath('my-video.webm'); - const error = await popup.video().saveAs(saveAsPath).catch(e => e); - // WebKit pauses renderer before win.close() and actually writes something, - // and other browsers are sometimes fast as well. - if (!fs.existsSync(saveAsPath)) - expect(error.message).toContain('Page did not produce any video frames'); - await context.close(); - }); - it('should delete video', async ({ browser }, testInfo) => { const videosPath = testInfo.outputPath(''); const size = { width: 320, height: 240 }; @@ -697,7 +672,6 @@ it.describe('screencast', () => { }); it('should not create video for internal pages', async ({ browser, server }, testInfo) => { - it.fixme(true, 'https://github.com/microsoft/playwright/issues/6743'); server.setRoute('/empty.html', (req, res) => { res.setHeader('Set-Cookie', 'name=value'); res.end(); @@ -843,22 +817,20 @@ it.describe('screencast', () => { const page = await context.newPage(); await page.video().start({ size }); - const tmpPath1 = await page.video().path(); - expect(tmpPath1).toBeDefined(); await page.evaluate(() => document.body.style.backgroundColor = 'red'); await rafraf(page, 100); - const videoPath1 = testInfo.outputPath('video1.webm'); - await page.video().stop({ path: videoPath1 }); + const videoPath1 = await page.video().path(); + expect(videoPath1).toBeDefined(); + await page.video().stop(); expectRedFrames(videoPath1, size); await page.video().start({ size }); - const tmpPath2 = await page.video().path(); - expect(tmpPath2).toBeDefined(); - expect(tmpPath2).not.toEqual(tmpPath1); await page.evaluate(() => document.body.style.backgroundColor = 'rgb(100,100,100)'); await rafraf(page, 100); - const videoPath2 = testInfo.outputPath('video2.webm'); - await page.video().stop({ path: videoPath2 }); + const videoPath2 = await page.video().path(); + expect(videoPath2).toBeDefined(); + expect(videoPath2).not.toEqual(videoPath1); + await page.video().stop(); expectFrames(videoPath2, size, isAlmostGray); const videoPath3 = testInfo.outputPath('video3.webm'); @@ -870,33 +842,64 @@ it.describe('screencast', () => { await context.close(); }); - it('should fail when recordVideo is set', async ({ browser }, testInfo) => { - const size = { width: 320, height: 240 }; + it('video.start should fail when recordVideo is set, but stop should work', async ({ browser }, testInfo) => { const context = await browser.newContext({ recordVideo: { dir: testInfo.outputPath(''), - size }, - viewport: size, }); const page = await context.newPage(); + const error = await page.video().start().catch(e => e); + expect(error.message).toContain('Video is already being recorded'); + await page.video().stop(); + await page.video().saveAs(testInfo.outputPath('video.webm')); + await context.close(); + }); + it('video.start should fail when another recording is in progress', async ({ browser }, testInfo) => { + const context = await browser.newContext(); + const page = await context.newPage(); + await page.video().start(); const error = await page.video().start().catch(e => e); expect(error.message).toContain('Video is already being recorded'); await context.close(); }); - it('should fail when another recording is in progress', async ({ browser }, testInfo) => { - const size = { width: 320, height: 240 }; - const context = await browser.newContext({ - viewport: size, - }); + it('video.stop should fail when no recording is in progress', async ({ browser }, testInfo) => { + const context = await browser.newContext(); const page = await context.newPage(); + const error = await page.video().stop().catch(e => e); + expect(error.message).toContain('Video is not being recorded'); + await context.close(); + }); + it('video.start should finish when page is closed', async ({ browser, browserName }, testInfo) => { + const context = await browser.newContext(); + const page = await context.newPage(); + await page.video().start(); + await page.evaluate(() => document.body.style.backgroundColor = 'red'); + await rafraf(page, 100); + const videoPath = await page.video().path(); + expect(videoPath).toBeDefined(); + await page.close(); + const error = await page.video().stop().catch(e => e); + expect(error.message).toContain(kTargetClosedErrorMessage); + const newPath = testInfo.outputPath('video.webm'); + await page.video().saveAs(newPath); + expect(fs.existsSync(newPath)).toBeTruthy(); + await context.close(); + }); + + it('empty video', async ({ browser, browserName }, testInfo) => { + const size = browserName === 'firefox' ? { width: 500, height: 400 } : { width: 320, height: 240 }; + const context = await browser.newContext({ viewport: size }); + const page = await context.newPage(); await page.video().start({ size }); - const error = await page.video().start().catch(e => e); - expect(error.message).toContain('Video is already being recorded'); + await page.video().stop(); + const videoPath = testInfo.outputPath('empty-video.webm'); + await page.video().saveAs(videoPath); await context.close(); + expectFrames(videoPath, size, isAlmostWhite); }); });