feat: implement screencasting (#11084)

This commit is contained in:
jrandolf 2023-10-06 16:50:39 +02:00 committed by GitHub
parent ddbb43cd09
commit f060d467c0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 830 additions and 18 deletions

View File

@ -171,6 +171,8 @@ jobs:
with:
cache: npm
node-version: lts/*
- name: Set up FFmpeg
uses: FedericoCarboni/setup-ffmpeg@5058c9851b649ced05c3e73a4fb5ef2995a89127 # v2.0.0
- name: Install dependencies
run: npm ci
env:
@ -261,6 +263,8 @@ jobs:
with:
cache: npm
node-version: lts/*
- name: Set up FFmpeg
uses: FedericoCarboni/setup-ffmpeg@5058c9851b649ced05c3e73a4fb5ef2995a89127 # v2.0.0
- name: Install dependencies
run: npm ci
env:

View File

@ -36,6 +36,7 @@ sidebar_label: API
| [ProtocolError](./puppeteer.protocolerror.md) | ProtocolError is emitted whenever there is an error from the protocol. |
| [Puppeteer](./puppeteer.puppeteer.md) | <p>The main Puppeteer class.</p><p>IMPORTANT: if you are using Puppeteer in a Node environment, you will get an instance of [PuppeteerNode](./puppeteer.puppeteernode.md) when you import or require <code>puppeteer</code>. That class extends <code>Puppeteer</code>, so has all the methods documented below as well as all that are defined on [PuppeteerNode](./puppeteer.puppeteernode.md).</p> |
| [PuppeteerNode](./puppeteer.puppeteernode.md) | <p>Extends the main [Puppeteer](./puppeteer.puppeteer.md) class with Node specific behaviour for fetching and downloading browsers.</p><p>If you're using Puppeteer in a Node environment, this is the class you'll get when you run <code>require('puppeteer')</code> (or the equivalent ES <code>import</code>).</p> |
| [ScreenRecorder](./puppeteer.screenrecorder.md) | |
| [SecurityDetails](./puppeteer.securitydetails.md) | The SecurityDetails class represents the security details of a response that was received over a secure connection. |
| [Target](./puppeteer.target.md) | Target represents a [CDP target](https://chromedevtools.github.io/devtools-protocol/tot/Target/). In CDP a target is something that can be debugged such a frame, a page or a worker. |
| [TimeoutError](./puppeteer.timeouterror.md) | TimeoutError is emitted whenever certain operations are terminated due to timeout. |
@ -124,6 +125,7 @@ sidebar_label: API
| [PuppeteerLaunchOptions](./puppeteer.puppeteerlaunchoptions.md) | |
| [RemoteAddress](./puppeteer.remoteaddress.md) | |
| [ResponseForRequest](./puppeteer.responseforrequest.md) | Required response data to fulfill a request with. |
| [ScreencastOptions](./puppeteer.screencastoptions.md) | |
| [ScreenshotClip](./puppeteer.screenshotclip.md) | |
| [ScreenshotOptions](./puppeteer.screenshotoptions.md) | |
| [SerializedAXNode](./puppeteer.serializedaxnode.md) | Represents a Node and the properties of it that are relevant to Accessibility. |

View File

@ -127,6 +127,7 @@ page.off('request', logRequest);
| [reload(options)](./puppeteer.page.reload.md) | | Reloads the page. |
| [removeExposedFunction(name)](./puppeteer.page.removeexposedfunction.md) | | The method removes a previously added function via $[Page.exposeFunction()](./puppeteer.page.exposefunction.md) called <code>name</code> from the page's <code>window</code> object. |
| [removeScriptToEvaluateOnNewDocument(identifier)](./puppeteer.page.removescripttoevaluateonnewdocument.md) | | Removes script that injected into page by Page.evaluateOnNewDocument. |
| [screencast(options)](./puppeteer.page.screencast.md) | | Captures a screencast of this [page](./puppeteer.page.md). |
| [screenshot(options)](./puppeteer.page.screenshot.md) | | Captures a screenshot of this [page](./puppeteer.page.md). |
| [screenshot(options)](./puppeteer.page.screenshot_1.md) | | |
| [select(selector, values)](./puppeteer.page.select.md) | | Triggers a <code>change</code> and <code>input</code> event once all the provided options have been selected. If there's no <code>&lt;select&gt;</code> element matching <code>selector</code>, the method throws an error. |

View File

@ -0,0 +1,58 @@
---
sidebar_label: Page.screencast
---
# Page.screencast() method
Captures a screencast of this [page](./puppeteer.page.md).
#### Signature:
```typescript
class Page {
screencast(options?: Readonly<ScreencastOptions>): Promise<ScreenRecorder>;
}
```
## Parameters
| Parameter | Type | Description |
| --------- | --------------------------------------------------------------------- | -------------------------------------------- |
| options | Readonly&lt;[ScreencastOptions](./puppeteer.screencastoptions.md)&gt; | _(Optional)_ Configures screencast behavior. |
**Returns:**
Promise&lt;[ScreenRecorder](./puppeteer.screenrecorder.md)&gt;
## Remarks
All recordings will be \[WebM\](https://www.webmproject.org/) format using the \[VP9\](https://www.webmproject.org/vp9/) video codec. The FPS is 30.
You must have \[ffmpeg\](https://ffmpeg.org/) installed on your system.
## Example
Recording a [page](./puppeteer.page.md):
```
import puppeteer from 'puppeteer';
// Launch a browser
const browser = await puppeteer.launch();
// Create a new page
const page = await browser.newPage();
// Go to your site.
await page.goto("https://www.example.com");
// Start recording.
const recorder = await page.screencast({path: 'recording.webm'});
// Do something.
// Stop recording.
await recorder.stop();
browser.close();
```

View File

@ -0,0 +1,21 @@
---
sidebar_label: ScreencastOptions
---
# ScreencastOptions interface
#### Signature:
```typescript
export interface ScreencastOptions
```
## Properties
| Property | Modifiers | Type | Description | Default |
| ---------- | --------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------- |
| crop | <code>optional</code> | [BoundingBox](./puppeteer.boundingbox.md) | Specifies the region of the viewport to crop. | |
| ffmpegPath | <code>optional</code> | string | <p>Path to the \[ffmpeg\](https://ffmpeg.org/).</p><p>Required if <code>ffmpeg</code> is not in your PATH.</p> | |
| path | <code>optional</code> | \`${string}.webm\` | File path to save the screencast to. | |
| scale | <code>optional</code> | number | <p>Scales the output video.</p><p>For example, <code>0.5</code> will shrink the width and height of the output video by half. <code>2</code> will double the width and height of the output video.</p> | <code>1</code> |
| speed | <code>optional</code> | number | <p>Specifies the speed to record at.</p><p>For example, <code>0.5</code> will slowdown the output video by 50%. <code>2</code> will double the speed of the output video.</p> | <code>1</code> |

View File

@ -0,0 +1,23 @@
---
sidebar_label: ScreenRecorder
---
# ScreenRecorder class
#### Signature:
```typescript
export declare class ScreenRecorder extends PassThrough
```
**Extends:** PassThrough
## Remarks
The constructor for this class is marked as internal. Third-party code should not call the constructor directly or create subclasses that extend the `ScreenRecorder` class.
## Methods
| Method | Modifiers | Description |
| -------------------------------------------- | --------- | ------------------- |
| [stop()](./puppeteer.screenrecorder.stop.md) | | Stops the recorder. |

View File

@ -0,0 +1,19 @@
---
sidebar_label: ScreenRecorder.stop
---
# ScreenRecorder.stop() method
Stops the recorder.
#### Signature:
```typescript
class ScreenRecorder {
stop(): Promise<void>;
}
```
**Returns:**
Promise&lt;void&gt;

View File

@ -7,15 +7,13 @@ sidebar_label: ScreenshotClip
#### Signature:
```typescript
export interface ScreenshotClip
export interface ScreenshotClip extends BoundingBox
```
**Extends:** [BoundingBox](./puppeteer.boundingbox.md)
## Properties
| Property | Modifiers | Type | Description | Default |
| -------- | --------------------- | ------ | ----------- | -------------- |
| height | | number | | |
| scale | <code>optional</code> | number | | <code>1</code> |
| width | | number | | |
| x | | number | | |
| y | | number | | |

View File

@ -80,12 +80,14 @@ import {
withSourcePuppeteerURLIfNone,
} from '../common/util.js';
import type {Viewport} from '../common/Viewport.js';
import type {ScreenRecorder} from '../node/ScreenRecorder.js';
import {assert} from '../util/assert.js';
import {guarded} from '../util/decorators.js';
import type {Deferred} from '../util/Deferred.js';
import {
AsyncDisposableStack,
asyncDisposeSymbol,
DisposableStack,
disposeSymbol,
} from '../util/disposable.js';
@ -93,7 +95,11 @@ import type {Browser} from './Browser.js';
import type {BrowserContext} from './BrowserContext.js';
import type {CDPSession} from './CDPSession.js';
import type {Dialog} from './Dialog.js';
import type {ClickOptions, ElementHandle} from './ElementHandle.js';
import type {
BoundingBox,
ClickOptions,
ElementHandle,
} from './ElementHandle.js';
import type {
Frame,
FrameAddScriptTagOptions,
@ -212,11 +218,7 @@ export interface MediaFeature {
/**
* @public
*/
export interface ScreenshotClip {
x: number;
y: number;
width: number;
height: number;
export interface ScreenshotClip extends BoundingBox {
/**
* @defaultValue `1`
*/
@ -290,6 +292,44 @@ export interface ScreenshotOptions {
allowViewportExpansion?: boolean;
}
/**
* @experimental
*/
export interface ScreencastOptions {
/**
* File path to save the screencast to.
*/
path?: `${string}.webm`;
/**
* Specifies the region of the viewport to crop.
*/
crop?: BoundingBox;
/**
* Scales the output video.
*
* For example, `0.5` will shrink the width and height of the output video by
* half. `2` will double the width and height of the output video.
*
* @defaultValue `1`
*/
scale?: number;
/**
* Specifies the speed to record at.
*
* For example, `0.5` will slowdown the output video by 50%. `2` will double the
* speed of the output video.
*
* @defaultValue `1`
*/
speed?: number;
/**
* Path to the [ffmpeg](https://ffmpeg.org/).
*
* Required if `ffmpeg` is not in your PATH.
*/
ffmpegPath?: string;
}
/**
* All the events that a page instance may emit.
*
@ -2274,6 +2314,179 @@ export abstract class Page extends EventEmitter<PageEvents> {
await fs.writeFile(path, buffer);
}
/**
* Captures a screencast of this {@link Page | page}.
*
* @remarks All recordings will be [WebM](https://www.webmproject.org/) format using
* the [VP9](https://www.webmproject.org/vp9/) video codec. The FPS is 30.
*
* You must have [ffmpeg](https://ffmpeg.org/) installed on your system.
*
* @example Recording a {@link Page | page}:
*
* ```
* import puppeteer from 'puppeteer';
*
* // Launch a browser
* const browser = await puppeteer.launch();
*
* // Create a new page
* const page = await browser.newPage();
*
* // Go to your site.
* await page.goto("https://www.example.com");
*
* // Start recording.
* const recorder = await page.screencast({path: 'recording.webm'});
*
* // Do something.
*
* // Stop recording.
* await recorder.stop();
*
* browser.close();
* ```
*
* @param options - Configures screencast behavior.
*
* @experimental
*/
async screencast(
options: Readonly<ScreencastOptions> = {}
): Promise<ScreenRecorder> {
const [{ScreenRecorder}, [width, height, devicePixelRatio]] =
await Promise.all([
import('../node/ScreenRecorder.js'),
this.#getNativePixelDimensions(),
]);
let crop: BoundingBox | undefined;
if (options.crop) {
const {
x,
y,
width: cropWidth,
height: cropHeight,
} = roundRectangle(normalizeRectangle(options.crop));
if (x < 0 || y < 0) {
throw new Error(
`\`crop.x\` and \`crop.x\` must be greater than or equal to 0.`
);
}
if (cropWidth <= 0 || cropHeight <= 0) {
throw new Error(
`\`crop.height\` and \`crop.width\` must be greater than or equal to 0.`
);
}
const viewportWidth = width / devicePixelRatio;
const viewportHeight = width / devicePixelRatio;
if (x + cropWidth > viewportWidth) {
throw new Error(
`\`crop.width\` cannot be larger than the viewport width (${viewportWidth}).`
);
}
if (y + cropHeight > viewportHeight) {
throw new Error(
`\`crop.height\` cannot be larger than the viewport height (${viewportHeight}).`
);
}
crop = {
x: x * devicePixelRatio,
y: y * devicePixelRatio,
width: cropWidth * devicePixelRatio,
height: cropHeight * devicePixelRatio,
};
}
if (options.speed !== undefined && options.speed <= 0) {
throw new Error(`\`speed\` must be greater than 0.`);
}
if (options.scale !== undefined && options.scale <= 0) {
throw new Error(`\`scale\` must be greater than 0.`);
}
const recorder = new ScreenRecorder(this, width, height, {
...options,
path: options.ffmpegPath,
crop,
});
try {
await this._startScreencast();
} catch (error) {
void recorder.stop();
throw error;
}
if (options.path) {
const {createWriteStream} = await import('fs');
const stream = createWriteStream(options.path, 'binary');
recorder.pipe(stream);
}
return recorder;
}
#screencastSessionCount = 0;
#startScreencastPromise: Promise<void> | undefined;
/**
* @internal
*/
async _startScreencast(): Promise<void> {
++this.#screencastSessionCount;
if (!this.#startScreencastPromise) {
this.#startScreencastPromise = this.mainFrame()
.client.send('Page.startScreencast', {format: 'png'})
.then(() => {
// Wait for the first frame.
return new Promise(resolve => {
return this.mainFrame().client.once('Page.screencastFrame', () => {
return resolve();
});
});
});
}
await this.#startScreencastPromise;
}
/**
* @internal
*/
async _stopScreencast(): Promise<void> {
--this.#screencastSessionCount;
if (!this.#startScreencastPromise) {
return;
}
this.#startScreencastPromise = undefined;
if (this.#screencastSessionCount === 0) {
await this.mainFrame().client.send('Page.stopScreencast');
}
}
/**
* Gets the native, non-emulated dimensions of the viewport.
*/
async #getNativePixelDimensions(): Promise<
readonly [width: number, height: number, devicePixelRatio: number]
> {
const viewport = this.viewport();
using stack = new DisposableStack();
if (viewport && viewport.deviceScaleFactor !== 0) {
await this.setViewport({...viewport, deviceScaleFactor: 0});
stack.defer(() => {
void this.setViewport(viewport).catch(debugError);
});
}
return await this.mainFrame()
.isolatedRealm()
.evaluate(() => {
return [
window.visualViewport!.width * window.devicePixelRatio,
window.visualViewport!.height * window.devicePixelRatio,
window.devicePixelRatio,
] as const;
});
}
/**
* Captures a screenshot of this {@link Page | page}.
*
@ -2374,7 +2587,8 @@ export abstract class Page extends EventEmitter<PageEvents> {
setDefaultScreenshotOptions(options);
options.clip = options.clip && roundClip(normalizeClip(options.clip));
options.clip =
options.clip && roundRectangle(normalizeRectangle(options.clip));
await using stack = new AsyncDisposableStack();
if (options.allowViewportExpansion || options.captureBeyondViewport) {
@ -3030,8 +3244,11 @@ function convertPrintParameterToInches(
}
/** @see https://w3c.github.io/webdriver-bidi/#normalize-rect */
function normalizeClip(clip: Readonly<ScreenshotClip>): ScreenshotClip {
function normalizeRectangle<BoundingBoxType extends BoundingBox>(
clip: Readonly<BoundingBoxType>
): BoundingBoxType {
return {
...clip,
...(clip.width < 0
? {
x: clip.x + clip.width,
@ -3050,14 +3267,15 @@ function normalizeClip(clip: Readonly<ScreenshotClip>): ScreenshotClip {
y: clip.y,
height: clip.height,
}),
scale: clip.scale,
};
}
function roundClip(clip: Readonly<ScreenshotClip>): ScreenshotClip {
function roundRectangle<BoundingBoxType extends BoundingBox>(
clip: Readonly<BoundingBoxType>
): BoundingBoxType {
const x = Math.round(clip.x);
const y = Math.round(clip.y);
const width = Math.round(clip.width + clip.x - x);
const height = Math.round(clip.height + clip.y - y);
return {x, y, width, height, scale: clip.scale};
return {...clip, x, y, width, height};
}

View File

@ -0,0 +1,272 @@
/**
* Copyright 2023 Google Inc. All rights reserved.
*
* 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 type {ChildProcessWithoutNullStreams} from 'child_process';
import {spawn, spawnSync} from 'child_process';
import {PassThrough} from 'stream';
import debug from 'debug';
import type Protocol from 'devtools-protocol';
import type {
Observable,
OperatorFunction,
} from '../../third_party/rxjs/rxjs.js';
import {
bufferCount,
concatMap,
filter,
from,
fromEvent,
lastValueFrom,
map,
takeUntil,
tap,
} from '../../third_party/rxjs/rxjs.js';
import {CDPSessionEvent} from '../api/CDPSession.js';
import type {BoundingBox} from '../api/ElementHandle.js';
import type {Page} from '../api/Page.js';
import {debugError} from '../common/util.js';
import {guarded} from '../util/decorators.js';
import {asyncDisposeSymbol} from '../util/disposable.js';
const CRF_VALUE = 30;
const DEFAULT_FPS = 30;
const debugFfmpeg = debug('puppeteer:ffmpeg');
/**
* @internal
*/
export interface ScreenRecorderOptions {
speed?: number;
crop?: BoundingBox;
format?: 'gif' | 'webm';
scale?: number;
path?: string;
}
/**
* @public
*/
export class ScreenRecorder extends PassThrough {
#page: Page;
#process: ChildProcessWithoutNullStreams;
#controller = new AbortController();
#lastFrame: Promise<readonly [Buffer, number]>;
/**
* @internal
*/
constructor(
page: Page,
width: number,
height: number,
{speed, scale, crop, format, path}: ScreenRecorderOptions = {}
) {
super({allowHalfOpen: false});
path ??= 'ffmpeg';
// Tests if `ffmpeg` exists.
const {error} = spawnSync(path);
if (error) {
throw error;
}
this.#process = spawn(
path,
// See https://trac.ffmpeg.org/wiki/Encode/VP9 for more information on flags.
[
['-loglevel', 'error'],
// Reduces general buffering.
['-avioflags', 'direct'],
// Reduces initial buffering while analyzing input fps and other stats.
[
'-fpsprobesize',
`${0}`,
'-probesize',
`${32}`,
'-analyzeduration',
`${0}`,
'-fflags',
'nobuffer',
],
// Forces input to be read from standard input, and forces png input
// image format.
['-f', 'image2pipe', '-c:v', 'png', '-i', 'pipe:0'],
// Overwrite output and no audio.
['-y', '-an'],
// This drastically reduces stalling when cpu is overbooked. By default
// VP9 tries to use all available threads?
['-threads', '1'],
// Specifies the frame rate we are giving ffmpeg.
['-framerate', `${DEFAULT_FPS}`],
// Specifies the encoding and format we are using.
this.#getFormatArgs(format ?? 'webm'),
// Disable bitrate.
['-b:v', `${0}`],
// Filters to ensure the images are piped correctly.
[
'-vf',
`${
speed ? `setpts=${1 / speed}*PTS,` : ''
}crop='min(${width},iw):min(${height},ih):${0}:${0}',pad=${width}:${height}:${0}:${0}${
crop ? `,crop=${crop.width}:${crop.height}:${crop.x}:${crop.y}` : ''
}${scale ? `,scale=iw*${scale}:-1` : ''}`,
],
'pipe:1',
].flat(),
{stdio: ['pipe', 'pipe', 'pipe']}
);
this.#process.stdout.pipe(this);
this.#process.stderr.on('data', (data: Buffer) => {
debugFfmpeg(data.toString('utf8'));
});
this.#page = page;
const {client} = this.#page.mainFrame();
client.once(CDPSessionEvent.Disconnected, () => {
void this.stop().catch(debugError);
});
this.#lastFrame = lastValueFrom(
(
fromEvent(
client,
'Page.screencastFrame'
) as Observable<Protocol.Page.ScreencastFrameEvent>
).pipe(
tap(event => {
void client.send('Page.screencastFrameAck', {
sessionId: event.sessionId,
});
}),
filter(event => {
return event.metadata.timestamp !== undefined;
}),
map(event => {
return {
buffer: Buffer.from(event.data, 'base64'),
timestamp: event.metadata.timestamp!,
};
}),
bufferCount(2, 1) as OperatorFunction<
{buffer: Buffer; timestamp: number},
[
{buffer: Buffer; timestamp: number},
{buffer: Buffer; timestamp: number},
]
>,
concatMap(([{timestamp: previousTimestamp, buffer}, {timestamp}]) => {
return from(
Array<Buffer>(
Math.round(DEFAULT_FPS * (timestamp - previousTimestamp))
).fill(buffer)
);
}),
map(buffer => {
void this.#writeFrame(buffer);
return [buffer, performance.now()] as const;
}),
takeUntil(fromEvent(this.#controller.signal, 'abort'))
),
{defaultValue: [Buffer.from([]), performance.now()] as const}
);
}
#getFormatArgs(format: 'webm' | 'gif') {
switch (format) {
case 'webm':
return [
// Sets the codec to use.
['-c:v', 'vp9'],
// Sets the format
['-f', 'webm'],
// Sets the quality. Lower the better.
['-crf', `${CRF_VALUE}`],
// Sets the quality and how efficient the compression will be.
['-deadline', 'realtime', '-cpu-used', `${8}`],
].flat();
case 'gif':
return [
// Sets the frame rate and uses a custom palette generated from the
// input.
[
'-vf',
'fps=5,split[s0][s1];[s0]palettegen=stats_mode=diff[p];[s1][p]paletteuse',
],
// Sets the format
['-f', 'gif'],
].flat();
}
}
@guarded()
async #writeFrame(buffer: Buffer) {
const error = await new Promise<Error | null | undefined>(resolve => {
this.#process.stdin.write(buffer, resolve);
});
if (error) {
console.log(`ffmpeg failed to write: ${error.message}.`);
}
}
/**
* Stops the recorder.
*
* @public
*/
@guarded()
async stop(): Promise<void> {
if (this.#controller.signal.aborted) {
return;
}
// Stopping the screencast will flush the frames.
await this.#page._stopScreencast().catch(debugError);
this.#controller.abort();
// Repeat the last frame for the remaining frames.
const [buffer, timestamp] = await this.#lastFrame;
await Promise.all(
Array<Buffer>(
Math.max(
1,
Math.round((DEFAULT_FPS * (performance.now() - timestamp)) / 1000)
)
)
.fill(buffer)
.map(this.#writeFrame.bind(this))
);
// Close stdin to notify FFmpeg we are done.
this.#process.stdin.end();
await new Promise(resolve => {
this.#process.once('close', resolve);
});
}
/**
* @internal
*/
async [asyncDisposeSymbol](): Promise<void> {
await this.stop();
}
}

View File

@ -20,3 +20,4 @@ export * from './LaunchOptions.js';
export * from './PipeTransport.js';
export * from './ProductLauncher.js';
export * from './PuppeteerNode.js';
export * from './ScreenRecorder.js';

View File

@ -14,7 +14,9 @@
* limitations under the License.
*/
export {
bufferCount,
catchError,
concatMap,
defaultIfEmpty,
defer,
delay,
@ -26,6 +28,7 @@ export {
fromEvent,
identity,
ignoreElements,
lastValueFrom,
map,
merge,
mergeMap,
@ -40,12 +43,13 @@ export {
retry,
startWith,
switchMap,
takeUntil,
tap,
throwIfEmpty,
timer,
} from 'rxjs';
import {mergeMap, from, filter, map, type Observable} from 'rxjs';
import {filter, from, map, mergeMap, type Observable} from 'rxjs';
export function filterAsync<T>(
predicate: (value: T) => boolean | PromiseLike<boolean>

View File

@ -323,6 +323,12 @@
"parameters": ["webDriverBiDi"],
"expectations": ["PASS"]
},
{
"testIdPattern": "[screencast.spec] *",
"platforms": ["darwin", "linux", "win32"],
"parameters": ["firefox"],
"expectations": ["FAIL"]
},
{
"testIdPattern": "[screenshot.spec] *",
"platforms": ["darwin", "linux", "win32"],
@ -1109,6 +1115,18 @@
"parameters": ["webDriverBiDi"],
"expectations": ["FAIL", "PASS", "TIMEOUT"]
},
{
"testIdPattern": "[prerender.spec] Prerender can screencast",
"platforms": ["darwin", "linux", "win32"],
"parameters": ["firefox"],
"expectations": ["FAIL"]
},
{
"testIdPattern": "[prerender.spec] Prerender can screencast",
"platforms": ["darwin", "linux", "win32"],
"parameters": ["new-headless"],
"expectations": ["FAIL", "PASS"]
},
{
"testIdPattern": "[proxy.spec] *",
"platforms": ["darwin", "linux", "win32"],
@ -1187,6 +1205,12 @@
"parameters": ["cdp", "firefox"],
"expectations": ["FAIL", "SKIP"]
},
{
"testIdPattern": "[screencast.spec] Screencasts Page.screencast should validate options",
"platforms": ["darwin", "linux", "win32"],
"parameters": ["firefox"],
"expectations": ["PASS"]
},
{
"testIdPattern": "[screenshot.spec] Screenshots Page.screenshot should clip clip bigger than the viewport without \"captureBeyondViewport\"",
"platforms": ["darwin", "linux", "win32"],
@ -3299,6 +3323,12 @@
"parameters": ["firefox", "headful"],
"expectations": ["PASS", "TIMEOUT"]
},
{
"testIdPattern": "[prerender.spec] Prerender can screencast",
"platforms": ["darwin", "linux", "win32"],
"parameters": ["chrome", "webDriverBiDi"],
"expectations": ["TIMEOUT"]
},
{
"testIdPattern": "[proxy.spec] request proxy in incognito browser context should proxy requests when configured at browser level",
"platforms": ["darwin", "linux", "win32"],

View File

@ -2,4 +2,4 @@
<head>
<script>fetch('target.html?fromPrerendered')</script>
</head>
<body>target</body>
<body>target<input></input></body>

View File

@ -14,9 +14,12 @@
* limitations under the License.
*/
import {statSync} from 'fs';
import expect from 'expect';
import {getTestState, setupTestBrowserHooks} from '../mocha-utils.js';
import {getUniqueVideoFilePlaceholder} from '../utils.js';
describe('Prerender', function () {
setupTestBrowserHooks();
@ -90,6 +93,33 @@ describe('Prerender', function () {
});
});
it('can screencast', async () => {
using file = getUniqueVideoFilePlaceholder();
const {page, server} = await getTestState();
const recorder = await page.screencast({
path: file.filename,
scale: 0.5,
crop: {width: 100, height: 100, x: 0, y: 0},
speed: 0.5,
});
await page.goto(server.PREFIX + '/prerender/index.html');
using button = await page.waitForSelector('button');
await button?.click();
using link = await page.locator('a').waitHandle();
await Promise.all([page.waitForNavigation(), link.click()]);
using input = await page.locator('input').waitHandle();
await input.type('ab', {delay: 100});
await recorder.stop();
expect(statSync(file.filename).size).toBeGreaterThan(0);
});
describe('with network requests', () => {
it('can receive requests from the prerendered page', async () => {
const {page, server} = await getTestState();

109
test/src/screencast.spec.ts Normal file
View File

@ -0,0 +1,109 @@
/**
* Copyright 2023 Google Inc. All rights reserved.
*
* 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 {statSync} from 'fs';
import expect from 'expect';
import {getTestState, setupTestBrowserHooks} from './mocha-utils.js';
import {getUniqueVideoFilePlaceholder} from './utils.js';
describe('Screencasts', function () {
setupTestBrowserHooks();
describe('Page.screencast', function () {
it('should work', async () => {
using file = getUniqueVideoFilePlaceholder();
const {page} = await getTestState();
const recorder = await page.screencast({
path: file.filename,
scale: 0.5,
crop: {width: 100, height: 100, x: 0, y: 0},
speed: 0.5,
});
await page.goto('data:text/html,<input>');
using input = await page.locator('input').waitHandle();
await input.type('ab', {delay: 100});
await recorder.stop();
expect(statSync(file.filename).size).toBeGreaterThan(0);
});
it('should work concurrently', async () => {
using file1 = getUniqueVideoFilePlaceholder();
using file2 = getUniqueVideoFilePlaceholder();
const {page} = await getTestState();
const recorder = await page.screencast({path: file1.filename});
const recorder2 = await page.screencast({path: file2.filename});
await page.goto('data:text/html,<input>');
using input = await page.locator('input').waitHandle();
await input.type('ab', {delay: 100});
await recorder.stop();
await input.type('ab', {delay: 100});
await recorder2.stop();
// Since file2 spent about double the time of file1 recording, so file2
// should be around double the size of file1.
const ratio =
statSync(file2.filename).size / statSync(file1.filename).size;
// We use a range because we cannot be precise.
const DELTA = 1.3;
expect(ratio).toBeGreaterThan(2 - DELTA);
expect(ratio).toBeLessThan(2 + DELTA);
});
it('should validate options', async () => {
const {page} = await getTestState();
await expect(page.screencast({scale: 0})).rejects.toBeDefined();
await expect(page.screencast({scale: -1})).rejects.toBeDefined();
await expect(page.screencast({speed: 0})).rejects.toBeDefined();
await expect(page.screencast({speed: -1})).rejects.toBeDefined();
await expect(
page.screencast({crop: {x: 0, y: 0, height: 1, width: 0}})
).rejects.toBeDefined();
await expect(
page.screencast({crop: {x: 0, y: 0, height: 0, width: 1}})
).rejects.toBeDefined();
await expect(
page.screencast({crop: {x: -1, y: 0, height: 1, width: 1}})
).rejects.toBeDefined();
await expect(
page.screencast({crop: {x: 0, y: -1, height: 1, width: 1}})
).rejects.toBeDefined();
await expect(
page.screencast({crop: {x: 0, y: 0, height: 10000, width: 1}})
).rejects.toBeDefined();
await expect(
page.screencast({crop: {x: 0, y: 0, height: 1, width: 10000}})
).rejects.toBeDefined();
await expect(
page.screencast({ffmpegPath: 'non-existent-path'})
).rejects.toBeDefined();
});
});
});

View File

@ -14,6 +14,8 @@
* limitations under the License.
*/
import {rm} from 'fs/promises';
import {tmpdir} from 'os';
import path from 'path';
import expect from 'expect';
@ -157,3 +159,23 @@ export const waitEvent = async <T = any>(
emitter.off(eventName, handler);
}
};
export interface FilePlaceholder {
filename: `${string}.webm`;
[Symbol.dispose](): void;
}
export function getUniqueVideoFilePlaceholder(): FilePlaceholder {
return {
filename: `${tmpdir()}/test-video-${Math.round(
Math.random() * 10000
)}.webm`,
[Symbol.dispose]() {
void rmIfExists(this.filename);
},
};
}
export function rmIfExists(file: string): Promise<void> {
return rm(file).catch(() => {});
}