feat: implement screencasting (#11084)
This commit is contained in:
parent
ddbb43cd09
commit
f060d467c0
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@ -171,6 +171,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
cache: npm
|
cache: npm
|
||||||
node-version: lts/*
|
node-version: lts/*
|
||||||
|
- name: Set up FFmpeg
|
||||||
|
uses: FedericoCarboni/setup-ffmpeg@5058c9851b649ced05c3e73a4fb5ef2995a89127 # v2.0.0
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
env:
|
env:
|
||||||
@ -261,6 +263,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
cache: npm
|
cache: npm
|
||||||
node-version: lts/*
|
node-version: lts/*
|
||||||
|
- name: Set up FFmpeg
|
||||||
|
uses: FedericoCarboni/setup-ffmpeg@5058c9851b649ced05c3e73a4fb5ef2995a89127 # v2.0.0
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
env:
|
env:
|
||||||
|
@ -36,6 +36,7 @@ sidebar_label: API
|
|||||||
| [ProtocolError](./puppeteer.protocolerror.md) | ProtocolError is emitted whenever there is an error from the protocol. |
|
| [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> |
|
| [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> |
|
| [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. |
|
| [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. |
|
| [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. |
|
| [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) | |
|
| [PuppeteerLaunchOptions](./puppeteer.puppeteerlaunchoptions.md) | |
|
||||||
| [RemoteAddress](./puppeteer.remoteaddress.md) | |
|
| [RemoteAddress](./puppeteer.remoteaddress.md) | |
|
||||||
| [ResponseForRequest](./puppeteer.responseforrequest.md) | Required response data to fulfill a request with. |
|
| [ResponseForRequest](./puppeteer.responseforrequest.md) | Required response data to fulfill a request with. |
|
||||||
|
| [ScreencastOptions](./puppeteer.screencastoptions.md) | |
|
||||||
| [ScreenshotClip](./puppeteer.screenshotclip.md) | |
|
| [ScreenshotClip](./puppeteer.screenshotclip.md) | |
|
||||||
| [ScreenshotOptions](./puppeteer.screenshotoptions.md) | |
|
| [ScreenshotOptions](./puppeteer.screenshotoptions.md) | |
|
||||||
| [SerializedAXNode](./puppeteer.serializedaxnode.md) | Represents a Node and the properties of it that are relevant to Accessibility. |
|
| [SerializedAXNode](./puppeteer.serializedaxnode.md) | Represents a Node and the properties of it that are relevant to Accessibility. |
|
||||||
|
@ -127,6 +127,7 @@ page.off('request', logRequest);
|
|||||||
| [reload(options)](./puppeteer.page.reload.md) | | Reloads the page. |
|
| [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. |
|
| [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. |
|
| [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.md) | | Captures a screenshot of this [page](./puppeteer.page.md). |
|
||||||
| [screenshot(options)](./puppeteer.page.screenshot_1.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><select></code> element matching <code>selector</code>, the method throws an error. |
|
| [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><select></code> element matching <code>selector</code>, the method throws an error. |
|
||||||
|
58
docs/api/puppeteer.page.screencast.md
Normal file
58
docs/api/puppeteer.page.screencast.md
Normal 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<[ScreencastOptions](./puppeteer.screencastoptions.md)> | _(Optional)_ Configures screencast behavior. |
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
|
||||||
|
Promise<[ScreenRecorder](./puppeteer.screenrecorder.md)>
|
||||||
|
|
||||||
|
## 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();
|
||||||
|
```
|
21
docs/api/puppeteer.screencastoptions.md
Normal file
21
docs/api/puppeteer.screencastoptions.md
Normal 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> |
|
23
docs/api/puppeteer.screenrecorder.md
Normal file
23
docs/api/puppeteer.screenrecorder.md
Normal 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. |
|
19
docs/api/puppeteer.screenrecorder.stop.md
Normal file
19
docs/api/puppeteer.screenrecorder.stop.md
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
sidebar_label: ScreenRecorder.stop
|
||||||
|
---
|
||||||
|
|
||||||
|
# ScreenRecorder.stop() method
|
||||||
|
|
||||||
|
Stops the recorder.
|
||||||
|
|
||||||
|
#### Signature:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class ScreenRecorder {
|
||||||
|
stop(): Promise<void>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
|
||||||
|
Promise<void>
|
@ -7,15 +7,13 @@ sidebar_label: ScreenshotClip
|
|||||||
#### Signature:
|
#### Signature:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
export interface ScreenshotClip
|
export interface ScreenshotClip extends BoundingBox
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Extends:** [BoundingBox](./puppeteer.boundingbox.md)
|
||||||
|
|
||||||
## Properties
|
## Properties
|
||||||
|
|
||||||
| Property | Modifiers | Type | Description | Default |
|
| Property | Modifiers | Type | Description | Default |
|
||||||
| -------- | --------------------- | ------ | ----------- | -------------- |
|
| -------- | --------------------- | ------ | ----------- | -------------- |
|
||||||
| height | | number | | |
|
|
||||||
| scale | <code>optional</code> | number | | <code>1</code> |
|
| scale | <code>optional</code> | number | | <code>1</code> |
|
||||||
| width | | number | | |
|
|
||||||
| x | | number | | |
|
|
||||||
| y | | number | | |
|
|
||||||
|
@ -80,12 +80,14 @@ import {
|
|||||||
withSourcePuppeteerURLIfNone,
|
withSourcePuppeteerURLIfNone,
|
||||||
} from '../common/util.js';
|
} from '../common/util.js';
|
||||||
import type {Viewport} from '../common/Viewport.js';
|
import type {Viewport} from '../common/Viewport.js';
|
||||||
|
import type {ScreenRecorder} from '../node/ScreenRecorder.js';
|
||||||
import {assert} from '../util/assert.js';
|
import {assert} from '../util/assert.js';
|
||||||
import {guarded} from '../util/decorators.js';
|
import {guarded} from '../util/decorators.js';
|
||||||
import type {Deferred} from '../util/Deferred.js';
|
import type {Deferred} from '../util/Deferred.js';
|
||||||
import {
|
import {
|
||||||
AsyncDisposableStack,
|
AsyncDisposableStack,
|
||||||
asyncDisposeSymbol,
|
asyncDisposeSymbol,
|
||||||
|
DisposableStack,
|
||||||
disposeSymbol,
|
disposeSymbol,
|
||||||
} from '../util/disposable.js';
|
} from '../util/disposable.js';
|
||||||
|
|
||||||
@ -93,7 +95,11 @@ import type {Browser} from './Browser.js';
|
|||||||
import type {BrowserContext} from './BrowserContext.js';
|
import type {BrowserContext} from './BrowserContext.js';
|
||||||
import type {CDPSession} from './CDPSession.js';
|
import type {CDPSession} from './CDPSession.js';
|
||||||
import type {Dialog} from './Dialog.js';
|
import type {Dialog} from './Dialog.js';
|
||||||
import type {ClickOptions, ElementHandle} from './ElementHandle.js';
|
import type {
|
||||||
|
BoundingBox,
|
||||||
|
ClickOptions,
|
||||||
|
ElementHandle,
|
||||||
|
} from './ElementHandle.js';
|
||||||
import type {
|
import type {
|
||||||
Frame,
|
Frame,
|
||||||
FrameAddScriptTagOptions,
|
FrameAddScriptTagOptions,
|
||||||
@ -212,11 +218,7 @@ export interface MediaFeature {
|
|||||||
/**
|
/**
|
||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
export interface ScreenshotClip {
|
export interface ScreenshotClip extends BoundingBox {
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
/**
|
/**
|
||||||
* @defaultValue `1`
|
* @defaultValue `1`
|
||||||
*/
|
*/
|
||||||
@ -290,6 +292,44 @@ export interface ScreenshotOptions {
|
|||||||
allowViewportExpansion?: boolean;
|
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.
|
* 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);
|
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}.
|
* Captures a screenshot of this {@link Page | page}.
|
||||||
*
|
*
|
||||||
@ -2374,7 +2587,8 @@ export abstract class Page extends EventEmitter<PageEvents> {
|
|||||||
|
|
||||||
setDefaultScreenshotOptions(options);
|
setDefaultScreenshotOptions(options);
|
||||||
|
|
||||||
options.clip = options.clip && roundClip(normalizeClip(options.clip));
|
options.clip =
|
||||||
|
options.clip && roundRectangle(normalizeRectangle(options.clip));
|
||||||
|
|
||||||
await using stack = new AsyncDisposableStack();
|
await using stack = new AsyncDisposableStack();
|
||||||
if (options.allowViewportExpansion || options.captureBeyondViewport) {
|
if (options.allowViewportExpansion || options.captureBeyondViewport) {
|
||||||
@ -3030,8 +3244,11 @@ function convertPrintParameterToInches(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @see https://w3c.github.io/webdriver-bidi/#normalize-rect */
|
/** @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 {
|
return {
|
||||||
|
...clip,
|
||||||
...(clip.width < 0
|
...(clip.width < 0
|
||||||
? {
|
? {
|
||||||
x: clip.x + clip.width,
|
x: clip.x + clip.width,
|
||||||
@ -3050,14 +3267,15 @@ function normalizeClip(clip: Readonly<ScreenshotClip>): ScreenshotClip {
|
|||||||
y: clip.y,
|
y: clip.y,
|
||||||
height: clip.height,
|
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 x = Math.round(clip.x);
|
||||||
const y = Math.round(clip.y);
|
const y = Math.round(clip.y);
|
||||||
const width = Math.round(clip.width + clip.x - x);
|
const width = Math.round(clip.width + clip.x - x);
|
||||||
const height = Math.round(clip.height + clip.y - y);
|
const height = Math.round(clip.height + clip.y - y);
|
||||||
return {x, y, width, height, scale: clip.scale};
|
return {...clip, x, y, width, height};
|
||||||
}
|
}
|
||||||
|
272
packages/puppeteer-core/src/node/ScreenRecorder.ts
Normal file
272
packages/puppeteer-core/src/node/ScreenRecorder.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
@ -20,3 +20,4 @@ export * from './LaunchOptions.js';
|
|||||||
export * from './PipeTransport.js';
|
export * from './PipeTransport.js';
|
||||||
export * from './ProductLauncher.js';
|
export * from './ProductLauncher.js';
|
||||||
export * from './PuppeteerNode.js';
|
export * from './PuppeteerNode.js';
|
||||||
|
export * from './ScreenRecorder.js';
|
||||||
|
@ -14,7 +14,9 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
export {
|
export {
|
||||||
|
bufferCount,
|
||||||
catchError,
|
catchError,
|
||||||
|
concatMap,
|
||||||
defaultIfEmpty,
|
defaultIfEmpty,
|
||||||
defer,
|
defer,
|
||||||
delay,
|
delay,
|
||||||
@ -26,6 +28,7 @@ export {
|
|||||||
fromEvent,
|
fromEvent,
|
||||||
identity,
|
identity,
|
||||||
ignoreElements,
|
ignoreElements,
|
||||||
|
lastValueFrom,
|
||||||
map,
|
map,
|
||||||
merge,
|
merge,
|
||||||
mergeMap,
|
mergeMap,
|
||||||
@ -40,12 +43,13 @@ export {
|
|||||||
retry,
|
retry,
|
||||||
startWith,
|
startWith,
|
||||||
switchMap,
|
switchMap,
|
||||||
|
takeUntil,
|
||||||
tap,
|
tap,
|
||||||
throwIfEmpty,
|
throwIfEmpty,
|
||||||
timer,
|
timer,
|
||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
|
|
||||||
import {mergeMap, from, filter, map, type Observable} from 'rxjs';
|
import {filter, from, map, mergeMap, type Observable} from 'rxjs';
|
||||||
|
|
||||||
export function filterAsync<T>(
|
export function filterAsync<T>(
|
||||||
predicate: (value: T) => boolean | PromiseLike<boolean>
|
predicate: (value: T) => boolean | PromiseLike<boolean>
|
||||||
|
@ -323,6 +323,12 @@
|
|||||||
"parameters": ["webDriverBiDi"],
|
"parameters": ["webDriverBiDi"],
|
||||||
"expectations": ["PASS"]
|
"expectations": ["PASS"]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"testIdPattern": "[screencast.spec] *",
|
||||||
|
"platforms": ["darwin", "linux", "win32"],
|
||||||
|
"parameters": ["firefox"],
|
||||||
|
"expectations": ["FAIL"]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"testIdPattern": "[screenshot.spec] *",
|
"testIdPattern": "[screenshot.spec] *",
|
||||||
"platforms": ["darwin", "linux", "win32"],
|
"platforms": ["darwin", "linux", "win32"],
|
||||||
@ -1109,6 +1115,18 @@
|
|||||||
"parameters": ["webDriverBiDi"],
|
"parameters": ["webDriverBiDi"],
|
||||||
"expectations": ["FAIL", "PASS", "TIMEOUT"]
|
"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] *",
|
"testIdPattern": "[proxy.spec] *",
|
||||||
"platforms": ["darwin", "linux", "win32"],
|
"platforms": ["darwin", "linux", "win32"],
|
||||||
@ -1187,6 +1205,12 @@
|
|||||||
"parameters": ["cdp", "firefox"],
|
"parameters": ["cdp", "firefox"],
|
||||||
"expectations": ["FAIL", "SKIP"]
|
"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\"",
|
"testIdPattern": "[screenshot.spec] Screenshots Page.screenshot should clip clip bigger than the viewport without \"captureBeyondViewport\"",
|
||||||
"platforms": ["darwin", "linux", "win32"],
|
"platforms": ["darwin", "linux", "win32"],
|
||||||
@ -3299,6 +3323,12 @@
|
|||||||
"parameters": ["firefox", "headful"],
|
"parameters": ["firefox", "headful"],
|
||||||
"expectations": ["PASS", "TIMEOUT"]
|
"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",
|
"testIdPattern": "[proxy.spec] request proxy in incognito browser context should proxy requests when configured at browser level",
|
||||||
"platforms": ["darwin", "linux", "win32"],
|
"platforms": ["darwin", "linux", "win32"],
|
||||||
|
@ -2,4 +2,4 @@
|
|||||||
<head>
|
<head>
|
||||||
<script>fetch('target.html?fromPrerendered')</script>
|
<script>fetch('target.html?fromPrerendered')</script>
|
||||||
</head>
|
</head>
|
||||||
<body>target</body>
|
<body>target<input></input></body>
|
||||||
|
@ -14,9 +14,12 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import {statSync} from 'fs';
|
||||||
|
|
||||||
import expect from 'expect';
|
import expect from 'expect';
|
||||||
|
|
||||||
import {getTestState, setupTestBrowserHooks} from '../mocha-utils.js';
|
import {getTestState, setupTestBrowserHooks} from '../mocha-utils.js';
|
||||||
|
import {getUniqueVideoFilePlaceholder} from '../utils.js';
|
||||||
|
|
||||||
describe('Prerender', function () {
|
describe('Prerender', function () {
|
||||||
setupTestBrowserHooks();
|
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', () => {
|
describe('with network requests', () => {
|
||||||
it('can receive requests from the prerendered page', async () => {
|
it('can receive requests from the prerendered page', async () => {
|
||||||
const {page, server} = await getTestState();
|
const {page, server} = await getTestState();
|
||||||
|
109
test/src/screencast.spec.ts
Normal file
109
test/src/screencast.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -14,6 +14,8 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import {rm} from 'fs/promises';
|
||||||
|
import {tmpdir} from 'os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import expect from 'expect';
|
import expect from 'expect';
|
||||||
@ -157,3 +159,23 @@ export const waitEvent = async <T = any>(
|
|||||||
emitter.off(eventName, handler);
|
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(() => {});
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user