api(video): implement video.saveAs and video.delete (#6005)
These methods are safe to call while the page is still open, or when it is already closed. Works in remotely connected browser as well. Also makes video.path() to throw for remotely connected browser. Under the hood migrated Download and Video to use the common Artifact object.
This commit is contained in:
parent
9532d0bde0
commit
9d9599c6a6
|
|
@ -18,7 +18,7 @@ const path = await download.path();
|
||||||
|
|
||||||
```java
|
```java
|
||||||
// wait for download to start
|
// wait for download to start
|
||||||
Download download = page.waitForDownload(() -> page.click("a"));
|
Download download = page.waitForDownload(() -> page.click("a"));
|
||||||
// wait for download to complete
|
// wait for download to complete
|
||||||
Path path = download.path();
|
Path path = download.path();
|
||||||
```
|
```
|
||||||
|
|
@ -73,7 +73,7 @@ Returns download error if any. Will wait for the download to finish if necessary
|
||||||
- returns: <[null]|[path]>
|
- returns: <[null]|[path]>
|
||||||
|
|
||||||
Returns path to the downloaded file in case of successful download. The method will
|
Returns path to the downloaded file in case of successful download. The method will
|
||||||
wait for the download to finish if necessary.
|
wait for the download to finish if necessary. The method throws when connected remotely via [`method: BrowserType.connect`].
|
||||||
|
|
||||||
## async method: Download.saveAs
|
## async method: Download.saveAs
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# class: Video
|
# class: Video
|
||||||
|
|
||||||
When browser context is created with the `videosPath` option, each page has a video object associated with it.
|
When browser context is created with the `recordVideo` option, each page has a video object associated with it.
|
||||||
|
|
||||||
```js
|
```js
|
||||||
console.log(await page.video().path());
|
console.log(await page.video().path());
|
||||||
|
|
@ -18,8 +18,22 @@ print(await page.video.path())
|
||||||
print(page.video.path())
|
print(page.video.path())
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## async method: Video.delete
|
||||||
|
|
||||||
|
Deletes the video file. Will wait for the video to finish if necessary.
|
||||||
|
|
||||||
## async method: Video.path
|
## async method: Video.path
|
||||||
- returns: <[path]>
|
- returns: <[path]>
|
||||||
|
|
||||||
Returns the file system path this video will be recorded to. The video is guaranteed to be written to the filesystem
|
Returns the file system path this video will be recorded to. The video is guaranteed to be written to the filesystem
|
||||||
upon closing the browser context.
|
upon closing the browser context. This method throws when connected remotely via [`method: BrowserType.connect`].
|
||||||
|
|
||||||
|
## async method: Video.saveAs
|
||||||
|
|
||||||
|
Saves the video to a user-specified path. It is safe to call this method while the video
|
||||||
|
is still in progress, or after the page has closed. This method waits until the page is closed and the video is fully saved.
|
||||||
|
|
||||||
|
### param: Video.saveAs.path
|
||||||
|
- `path` <[path]>
|
||||||
|
|
||||||
|
Path where the video should be saved.
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@
|
||||||
import { LaunchServerOptions, Logger } from './client/types';
|
import { LaunchServerOptions, Logger } from './client/types';
|
||||||
import { BrowserType } from './server/browserType';
|
import { BrowserType } from './server/browserType';
|
||||||
import * as ws from 'ws';
|
import * as ws from 'ws';
|
||||||
import fs from 'fs';
|
|
||||||
import { Browser } from './server/browser';
|
import { Browser } from './server/browser';
|
||||||
import { ChildProcess } from 'child_process';
|
import { ChildProcess } from 'child_process';
|
||||||
import { EventEmitter } from 'ws';
|
import { EventEmitter } from 'ws';
|
||||||
|
|
@ -30,8 +29,6 @@ import { envObjectToArray } from './client/clientHelper';
|
||||||
import { createGuid } from './utils/utils';
|
import { createGuid } from './utils/utils';
|
||||||
import { SelectorsDispatcher } from './dispatchers/selectorsDispatcher';
|
import { SelectorsDispatcher } from './dispatchers/selectorsDispatcher';
|
||||||
import { Selectors } from './server/selectors';
|
import { Selectors } from './server/selectors';
|
||||||
import { BrowserContext, Video } from './server/browserContext';
|
|
||||||
import { StreamDispatcher } from './dispatchers/streamDispatcher';
|
|
||||||
import { ProtocolLogger } from './server/types';
|
import { ProtocolLogger } from './server/types';
|
||||||
import { CallMetadata, internalCallMetadata, SdkObject } from './server/instrumentation';
|
import { CallMetadata, internalCallMetadata, SdkObject } from './server/instrumentation';
|
||||||
|
|
||||||
|
|
@ -163,7 +160,6 @@ class ConnectedBrowser extends BrowserDispatcher {
|
||||||
}
|
}
|
||||||
const result = await super.newContext(params, metadata);
|
const result = await super.newContext(params, metadata);
|
||||||
const dispatcher = result.context as BrowserContextDispatcher;
|
const dispatcher = result.context as BrowserContextDispatcher;
|
||||||
dispatcher._object.on(BrowserContext.Events.VideoStarted, (video: Video) => this._sendVideo(dispatcher, video));
|
|
||||||
dispatcher._object._setSelectors(this._selectors);
|
dispatcher._object._setSelectors(this._selectors);
|
||||||
this._contexts.push(dispatcher);
|
this._contexts.push(dispatcher);
|
||||||
return result;
|
return result;
|
||||||
|
|
@ -184,24 +180,6 @@ class ConnectedBrowser extends BrowserDispatcher {
|
||||||
super._didClose();
|
super._didClose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _sendVideo(contextDispatcher: BrowserContextDispatcher, video: Video) {
|
|
||||||
video._waitForCallbackOnFinish(async () => {
|
|
||||||
const readable = fs.createReadStream(video._path);
|
|
||||||
await new Promise(f => readable.on('readable', f));
|
|
||||||
const stream = new StreamDispatcher(this._remoteBrowser!._scope, readable);
|
|
||||||
this._remoteBrowser!._dispatchEvent('video', {
|
|
||||||
stream,
|
|
||||||
context: contextDispatcher,
|
|
||||||
relativePath: video._relativePath
|
|
||||||
});
|
|
||||||
await new Promise<void>(resolve => {
|
|
||||||
readable.on('close', resolve);
|
|
||||||
readable.on('end', resolve);
|
|
||||||
readable.on('error', resolve);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toProtocolLogger(logger: Logger | undefined): ProtocolLogger | undefined {
|
function toProtocolLogger(logger: Logger | undefined): ProtocolLogger | undefined {
|
||||||
|
|
|
||||||
79
src/client/artifact.ts
Normal file
79
src/client/artifact.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as channels from '../protocol/channels';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import { Stream } from './stream';
|
||||||
|
import { mkdirIfNeeded } from '../utils/utils';
|
||||||
|
import { ChannelOwner } from './channelOwner';
|
||||||
|
import { Readable } from 'stream';
|
||||||
|
|
||||||
|
export class Artifact extends ChannelOwner<channels.ArtifactChannel, channels.ArtifactInitializer> {
|
||||||
|
_isRemote = false;
|
||||||
|
_apiName: string = '';
|
||||||
|
|
||||||
|
static from(channel: channels.ArtifactChannel): Artifact {
|
||||||
|
return (channel as any)._object;
|
||||||
|
}
|
||||||
|
|
||||||
|
async pathAfterFinished(): Promise<string | null> {
|
||||||
|
if (this._isRemote)
|
||||||
|
throw new Error(`Path is not available when using browserType.connect(). Use saveAs() to save a local copy.`);
|
||||||
|
return this._wrapApiCall(`${this._apiName}.path`, async (channel: channels.ArtifactChannel) => {
|
||||||
|
return (await channel.pathAfterFinished()).value || null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveAs(path: string): Promise<void> {
|
||||||
|
return this._wrapApiCall(`${this._apiName}.saveAs`, async (channel: channels.ArtifactChannel) => {
|
||||||
|
if (!this._isRemote) {
|
||||||
|
await channel.saveAs({ path });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await channel.saveAsStream();
|
||||||
|
const stream = Stream.from(result.stream);
|
||||||
|
await mkdirIfNeeded(path);
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
stream.stream().pipe(fs.createWriteStream(path))
|
||||||
|
.on('finish' as any, resolve)
|
||||||
|
.on('error' as any, reject);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async failure(): Promise<string | null> {
|
||||||
|
return this._wrapApiCall(`${this._apiName}.failure`, async (channel: channels.ArtifactChannel) => {
|
||||||
|
return (await channel.failure()).error || null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createReadStream(): Promise<Readable | null> {
|
||||||
|
return this._wrapApiCall(`${this._apiName}.createReadStream`, async (channel: channels.ArtifactChannel) => {
|
||||||
|
const result = await channel.stream();
|
||||||
|
if (!result.stream)
|
||||||
|
return null;
|
||||||
|
const stream = Stream.from(result.stream);
|
||||||
|
return stream.stream();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(): Promise<void> {
|
||||||
|
return this._wrapApiCall(`${this._apiName}.delete`, async (channel: channels.ArtifactChannel) => {
|
||||||
|
return channel.delete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,7 +26,6 @@ import { Page, BindingCall } from './page';
|
||||||
import { Worker } from './worker';
|
import { Worker } from './worker';
|
||||||
import { ConsoleMessage } from './consoleMessage';
|
import { ConsoleMessage } from './consoleMessage';
|
||||||
import { Dialog } from './dialog';
|
import { Dialog } from './dialog';
|
||||||
import { Download } from './download';
|
|
||||||
import { parseError } from '../protocol/serializers';
|
import { parseError } from '../protocol/serializers';
|
||||||
import { CDPSession } from './cdpSession';
|
import { CDPSession } from './cdpSession';
|
||||||
import { Playwright } from './playwright';
|
import { Playwright } from './playwright';
|
||||||
|
|
@ -42,6 +41,7 @@ import { SelectorsOwner } from './selectors';
|
||||||
import { isUnderTest } from '../utils/utils';
|
import { isUnderTest } from '../utils/utils';
|
||||||
import { Android, AndroidSocket, AndroidDevice } from './android';
|
import { Android, AndroidSocket, AndroidDevice } from './android';
|
||||||
import { captureStackTrace } from '../utils/stackTrace';
|
import { captureStackTrace } from '../utils/stackTrace';
|
||||||
|
import { Artifact } from './artifact';
|
||||||
|
|
||||||
class Root extends ChannelOwner<channels.Channel, {}> {
|
class Root extends ChannelOwner<channels.Channel, {}> {
|
||||||
constructor(connection: Connection) {
|
constructor(connection: Connection) {
|
||||||
|
|
@ -156,6 +156,9 @@ export class Connection {
|
||||||
case 'AndroidDevice':
|
case 'AndroidDevice':
|
||||||
result = new AndroidDevice(parent, type, guid, initializer);
|
result = new AndroidDevice(parent, type, guid, initializer);
|
||||||
break;
|
break;
|
||||||
|
case 'Artifact':
|
||||||
|
result = new Artifact(parent, type, guid, initializer);
|
||||||
|
break;
|
||||||
case 'BindingCall':
|
case 'BindingCall':
|
||||||
result = new BindingCall(parent, type, guid, initializer);
|
result = new BindingCall(parent, type, guid, initializer);
|
||||||
break;
|
break;
|
||||||
|
|
@ -191,9 +194,6 @@ export class Connection {
|
||||||
case 'Dialog':
|
case 'Dialog':
|
||||||
result = new Dialog(parent, type, guid, initializer);
|
result = new Dialog(parent, type, guid, initializer);
|
||||||
break;
|
break;
|
||||||
case 'Download':
|
|
||||||
result = new Download(parent, type, guid, initializer);
|
|
||||||
break;
|
|
||||||
case 'Electron':
|
case 'Electron':
|
||||||
result = new Electron(parent, type, guid, initializer);
|
result = new Electron(parent, type, guid, initializer);
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -14,81 +14,46 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as channels from '../protocol/channels';
|
|
||||||
import { ChannelOwner } from './channelOwner';
|
|
||||||
import { Readable } from 'stream';
|
import { Readable } from 'stream';
|
||||||
import { Stream } from './stream';
|
|
||||||
import { Browser } from './browser';
|
|
||||||
import { BrowserContext } from './browserContext';
|
|
||||||
import fs from 'fs';
|
|
||||||
import { mkdirIfNeeded } from '../utils/utils';
|
|
||||||
import * as api from '../../types/types';
|
import * as api from '../../types/types';
|
||||||
|
import { Artifact } from './artifact';
|
||||||
|
|
||||||
export class Download extends ChannelOwner<channels.DownloadChannel, channels.DownloadInitializer> implements api.Download {
|
export class Download implements api.Download {
|
||||||
private _browser: Browser | null;
|
private _url: string;
|
||||||
|
private _suggestedFilename: string;
|
||||||
|
private _artifact: Artifact;
|
||||||
|
|
||||||
static from(download: channels.DownloadChannel): Download {
|
constructor(url: string, suggestedFilename: string, artifact: Artifact) {
|
||||||
return (download as any)._object;
|
this._url = url;
|
||||||
}
|
this._suggestedFilename = suggestedFilename;
|
||||||
|
this._artifact = artifact;
|
||||||
constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.DownloadInitializer) {
|
|
||||||
super(parent, type, guid, initializer);
|
|
||||||
this._browser = (parent as BrowserContext)._browser;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
url(): string {
|
url(): string {
|
||||||
return this._initializer.url;
|
return this._url;
|
||||||
}
|
}
|
||||||
|
|
||||||
suggestedFilename(): string {
|
suggestedFilename(): string {
|
||||||
return this._initializer.suggestedFilename;
|
return this._suggestedFilename;
|
||||||
}
|
}
|
||||||
|
|
||||||
async path(): Promise<string | null> {
|
async path(): Promise<string | null> {
|
||||||
if (this._browser && this._browser._isRemote)
|
return this._artifact.pathAfterFinished();
|
||||||
throw new Error(`Path is not available when using browserType.connect(). Use download.saveAs() to save a local copy.`);
|
|
||||||
return this._wrapApiCall('download.path', async (channel: channels.DownloadChannel) => {
|
|
||||||
return (await channel.path()).value || null;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveAs(path: string): Promise<void> {
|
async saveAs(path: string): Promise<void> {
|
||||||
return this._wrapApiCall('download.saveAs', async (channel: channels.DownloadChannel) => {
|
return this._artifact.saveAs(path);
|
||||||
if (!this._browser || !this._browser._isRemote) {
|
|
||||||
await channel.saveAs({ path });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await channel.saveAsStream();
|
|
||||||
const stream = Stream.from(result.stream);
|
|
||||||
await mkdirIfNeeded(path);
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
stream.stream().pipe(fs.createWriteStream(path))
|
|
||||||
.on('finish' as any, resolve)
|
|
||||||
.on('error' as any, reject);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async failure(): Promise<string | null> {
|
async failure(): Promise<string | null> {
|
||||||
return this._wrapApiCall('download.failure', async (channel: channels.DownloadChannel) => {
|
return this._artifact.failure();
|
||||||
return (await channel.failure()).error || null;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async createReadStream(): Promise<Readable | null> {
|
async createReadStream(): Promise<Readable | null> {
|
||||||
return this._wrapApiCall('download.createReadStream', async (channel: channels.DownloadChannel) => {
|
return this._artifact.createReadStream();
|
||||||
const result = await channel.stream();
|
|
||||||
if (!result.stream)
|
|
||||||
return null;
|
|
||||||
const stream = Stream.from(result.stream);
|
|
||||||
return stream.stream();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(): Promise<void> {
|
async delete(): Promise<void> {
|
||||||
return this._wrapApiCall('download.delete', async (channel: channels.DownloadChannel) => {
|
return this._artifact.delete();
|
||||||
return channel.delete();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ import { isString, isRegExp, isObject, mkdirIfNeeded, headersObjectToArray } fro
|
||||||
import { isSafeCloseError } from '../utils/errors';
|
import { isSafeCloseError } from '../utils/errors';
|
||||||
import { Video } from './video';
|
import { Video } from './video';
|
||||||
import type { ChromiumBrowserContext } from './chromiumBrowserContext';
|
import type { ChromiumBrowserContext } from './chromiumBrowserContext';
|
||||||
|
import { Artifact } from './artifact';
|
||||||
|
|
||||||
const fsWriteFileAsync = util.promisify(fs.writeFile.bind(fs));
|
const fsWriteFileAsync = util.promisify(fs.writeFile.bind(fs));
|
||||||
const mkdirAsync = util.promisify(fs.mkdir);
|
const mkdirAsync = util.promisify(fs.mkdir);
|
||||||
|
|
@ -72,6 +73,7 @@ export class Page extends ChannelOwner<channels.PageChannel, channels.PageInitia
|
||||||
private _frames = new Set<Frame>();
|
private _frames = new Set<Frame>();
|
||||||
_workers = new Set<Worker>();
|
_workers = new Set<Worker>();
|
||||||
private _closed = false;
|
private _closed = false;
|
||||||
|
_closedOrCrashedPromise: Promise<void>;
|
||||||
private _viewportSize: Size | null;
|
private _viewportSize: Size | null;
|
||||||
private _routes: { url: URLMatch, handler: RouteHandler }[] = [];
|
private _routes: { url: URLMatch, handler: RouteHandler }[] = [];
|
||||||
|
|
||||||
|
|
@ -120,7 +122,11 @@ export class Page extends ChannelOwner<channels.PageChannel, channels.PageInitia
|
||||||
dialog.dismiss().catch(() => {});
|
dialog.dismiss().catch(() => {});
|
||||||
});
|
});
|
||||||
this._channel.on('domcontentloaded', () => this.emit(Events.Page.DOMContentLoaded, this));
|
this._channel.on('domcontentloaded', () => this.emit(Events.Page.DOMContentLoaded, this));
|
||||||
this._channel.on('download', ({ download }) => this.emit(Events.Page.Download, Download.from(download)));
|
this._channel.on('download', ({ url, suggestedFilename, artifact }) => {
|
||||||
|
const artifactObject = Artifact.from(artifact);
|
||||||
|
artifactObject._isRemote = !!this._browserContext._browser && this._browserContext._browser._isRemote;
|
||||||
|
this.emit(Events.Page.Download, new Download(url, suggestedFilename, artifactObject));
|
||||||
|
});
|
||||||
this._channel.on('fileChooser', ({ element, isMultiple }) => this.emit(Events.Page.FileChooser, new FileChooser(this, ElementHandle.from(element), isMultiple)));
|
this._channel.on('fileChooser', ({ element, isMultiple }) => this.emit(Events.Page.FileChooser, new FileChooser(this, ElementHandle.from(element), isMultiple)));
|
||||||
this._channel.on('frameAttached', ({ frame }) => this._onFrameAttached(Frame.from(frame)));
|
this._channel.on('frameAttached', ({ frame }) => this._onFrameAttached(Frame.from(frame)));
|
||||||
this._channel.on('frameDetached', ({ frame }) => this._onFrameDetached(Frame.from(frame)));
|
this._channel.on('frameDetached', ({ frame }) => this._onFrameDetached(Frame.from(frame)));
|
||||||
|
|
@ -132,7 +138,10 @@ export class Page extends ChannelOwner<channels.PageChannel, channels.PageInitia
|
||||||
this._channel.on('requestFinished', ({ request, responseEndTiming }) => this._onRequestFinished(Request.from(request), responseEndTiming));
|
this._channel.on('requestFinished', ({ request, responseEndTiming }) => this._onRequestFinished(Request.from(request), responseEndTiming));
|
||||||
this._channel.on('response', ({ response }) => this.emit(Events.Page.Response, Response.from(response)));
|
this._channel.on('response', ({ response }) => this.emit(Events.Page.Response, Response.from(response)));
|
||||||
this._channel.on('route', ({ route, request }) => this._onRoute(Route.from(route), Request.from(request)));
|
this._channel.on('route', ({ route, request }) => this._onRoute(Route.from(route), Request.from(request)));
|
||||||
this._channel.on('video', ({ relativePath }) => this.video()!._setRelativePath(relativePath));
|
this._channel.on('video', ({ artifact }) => {
|
||||||
|
const artifactObject = Artifact.from(artifact);
|
||||||
|
this._forceVideo()._artifactReady(artifactObject);
|
||||||
|
});
|
||||||
this._channel.on('webSocket', ({ webSocket }) => this.emit(Events.Page.WebSocket, WebSocket.from(webSocket)));
|
this._channel.on('webSocket', ({ webSocket }) => this.emit(Events.Page.WebSocket, WebSocket.from(webSocket)));
|
||||||
this._channel.on('worker', ({ worker }) => this._onWorker(Worker.from(worker)));
|
this._channel.on('worker', ({ worker }) => this._onWorker(Worker.from(worker)));
|
||||||
|
|
||||||
|
|
@ -142,6 +151,11 @@ export class Page extends ChannelOwner<channels.PageChannel, channels.PageInitia
|
||||||
} else {
|
} else {
|
||||||
this.pdf = undefined as any;
|
this.pdf = undefined as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this._closedOrCrashedPromise = Promise.race([
|
||||||
|
new Promise<void>(f => this.once(Events.Page.Close, f)),
|
||||||
|
new Promise<void>(f => this.once(Events.Page.Crash, f)),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _onRequestFailed(request: Request, responseEndTiming: number, failureText: string | undefined) {
|
private _onRequestFailed(request: Request, responseEndTiming: number, failureText: string | undefined) {
|
||||||
|
|
@ -247,16 +261,19 @@ export class Page extends ChannelOwner<channels.PageChannel, channels.PageInitia
|
||||||
this._channel.setDefaultTimeoutNoReply({ timeout });
|
this._channel.setDefaultTimeoutNoReply({ timeout });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _forceVideo(): Video {
|
||||||
|
if (!this._video)
|
||||||
|
this._video = new Video(this);
|
||||||
|
return this._video;
|
||||||
|
}
|
||||||
|
|
||||||
video(): Video | null {
|
video(): Video | null {
|
||||||
if (this._video)
|
// Note: we are creating Video object lazily, because we do not know
|
||||||
return this._video;
|
// BrowserContextOptions when constructing the page - it is assigned
|
||||||
|
// too late during launchPersistentContext.
|
||||||
if (!this._browserContext._options.recordVideo)
|
if (!this._browserContext._options.recordVideo)
|
||||||
return null;
|
return null;
|
||||||
this._video = new Video(this);
|
return this._forceVideo();
|
||||||
// In case of persistent profile, we already have it.
|
|
||||||
if (this._initializer.videoRelativePath)
|
|
||||||
this._video._setRelativePath(this._initializer.videoRelativePath);
|
|
||||||
return this._video;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _attributeToPage<T>(func: () => T): T {
|
private _attributeToPage<T>(func: () => T): T {
|
||||||
|
|
|
||||||
|
|
@ -14,25 +14,48 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import path from 'path';
|
|
||||||
import { Page } from './page';
|
import { Page } from './page';
|
||||||
import * as api from '../../types/types';
|
import * as api from '../../types/types';
|
||||||
|
import { Artifact } from './artifact';
|
||||||
|
|
||||||
export class Video implements api.Video {
|
export class Video implements api.Video {
|
||||||
private _page: Page;
|
private _artifact: Promise<Artifact | null> | null = null;
|
||||||
private _pathCallback: ((path: string) => void) | undefined;
|
private _artifactCallback = (artifact: Artifact) => {};
|
||||||
private _pathPromise: Promise<string>;
|
private _isRemote = false;
|
||||||
|
|
||||||
constructor(page: Page) {
|
constructor(page: Page) {
|
||||||
this._page = page;
|
const browser = page.context()._browser;
|
||||||
this._pathPromise = new Promise(f => this._pathCallback = f);
|
this._isRemote = !!browser && browser._isRemote;
|
||||||
|
this._artifact = Promise.race([
|
||||||
|
new Promise<Artifact>(f => this._artifactCallback = f),
|
||||||
|
page._closedOrCrashedPromise.then(() => null),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
_setRelativePath(relativePath: string) {
|
_artifactReady(artifact: Artifact) {
|
||||||
this._pathCallback!(path.join(this._page.context()._options.recordVideo!.dir, relativePath));
|
artifact._isRemote = this._isRemote;
|
||||||
|
this._artifactCallback(artifact);
|
||||||
}
|
}
|
||||||
|
|
||||||
path(): Promise<string> {
|
async path(): Promise<string> {
|
||||||
return this._pathPromise;
|
if (this._isRemote)
|
||||||
|
throw new Error(`Path is not available when using browserType.connect(). Use saveAs() to save a local copy.`);
|
||||||
|
const artifact = await this._artifact;
|
||||||
|
if (!artifact)
|
||||||
|
throw new Error('Page did not produce any video frames');
|
||||||
|
return artifact._initializer.absolutePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveAs(path: string): Promise<void> {
|
||||||
|
const artifact = await this._artifact;
|
||||||
|
if (!artifact)
|
||||||
|
throw new Error('Page did not produce any video frames');
|
||||||
|
return artifact.saveAs(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(): Promise<void> {
|
||||||
|
const artifact = await this._artifact;
|
||||||
|
if (artifact)
|
||||||
|
await artifact.delete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,35 +14,33 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Download } from '../server/download';
|
|
||||||
import * as channels from '../protocol/channels';
|
import * as channels from '../protocol/channels';
|
||||||
import { Dispatcher, DispatcherScope } from './dispatcher';
|
import { Dispatcher, DispatcherScope } from './dispatcher';
|
||||||
import { StreamDispatcher } from './streamDispatcher';
|
import { StreamDispatcher } from './streamDispatcher';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import * as util from 'util';
|
import * as util from 'util';
|
||||||
import { mkdirIfNeeded } from '../utils/utils';
|
import { mkdirIfNeeded } from '../utils/utils';
|
||||||
|
import { Artifact } from '../server/artifact';
|
||||||
|
|
||||||
export class DownloadDispatcher extends Dispatcher<Download, channels.DownloadInitializer> implements channels.DownloadChannel {
|
export class ArtifactDispatcher extends Dispatcher<Artifact, channels.ArtifactInitializer> implements channels.ArtifactChannel {
|
||||||
constructor(scope: DispatcherScope, download: Download) {
|
constructor(scope: DispatcherScope, artifact: Artifact) {
|
||||||
super(scope, download, 'Download', {
|
super(scope, artifact, 'Artifact', {
|
||||||
url: download.url(),
|
absolutePath: artifact.localPath(),
|
||||||
suggestedFilename: download.suggestedFilename(),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async path(): Promise<channels.DownloadPathResult> {
|
async pathAfterFinished(): Promise<channels.ArtifactPathAfterFinishedResult> {
|
||||||
const path = await this._object.localPath();
|
const path = await this._object.localPathAfterFinished();
|
||||||
return { value: path || undefined };
|
return { value: path || undefined };
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveAs(params: channels.DownloadSaveAsParams): Promise<channels.DownloadSaveAsResult> {
|
async saveAs(params: channels.ArtifactSaveAsParams): Promise<channels.ArtifactSaveAsResult> {
|
||||||
return await new Promise((resolve, reject) => {
|
return await new Promise((resolve, reject) => {
|
||||||
this._object.saveAs(async (localPath, error) => {
|
this._object.saveAs(async (localPath, error) => {
|
||||||
if (error !== undefined) {
|
if (error !== undefined) {
|
||||||
reject(new Error(error));
|
reject(new Error(error));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await mkdirIfNeeded(params.path);
|
await mkdirIfNeeded(params.path);
|
||||||
await util.promisify(fs.copyFile)(localPath, params.path);
|
await util.promisify(fs.copyFile)(localPath, params.path);
|
||||||
|
|
@ -54,21 +52,20 @@ export class DownloadDispatcher extends Dispatcher<Download, channels.DownloadIn
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveAsStream(): Promise<channels.DownloadSaveAsStreamResult> {
|
async saveAsStream(): Promise<channels.ArtifactSaveAsStreamResult> {
|
||||||
return await new Promise((resolve, reject) => {
|
return await new Promise((resolve, reject) => {
|
||||||
this._object.saveAs(async (localPath, error) => {
|
this._object.saveAs(async (localPath, error) => {
|
||||||
if (error !== undefined) {
|
if (error !== undefined) {
|
||||||
reject(new Error(error));
|
reject(new Error(error));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const readable = fs.createReadStream(localPath);
|
const readable = fs.createReadStream(localPath);
|
||||||
await new Promise(f => readable.on('readable', f));
|
await new Promise(f => readable.on('readable', f));
|
||||||
const stream = new StreamDispatcher(this._scope, readable);
|
const stream = new StreamDispatcher(this._scope, readable);
|
||||||
// Resolve with a stream, so that client starts saving the data.
|
// Resolve with a stream, so that client starts saving the data.
|
||||||
resolve({ stream });
|
resolve({ stream });
|
||||||
// Block the download until the stream is consumed.
|
// Block the Artifact until the stream is consumed.
|
||||||
await new Promise<void>(resolve => {
|
await new Promise<void>(resolve => {
|
||||||
readable.on('close', resolve);
|
readable.on('close', resolve);
|
||||||
readable.on('end', resolve);
|
readable.on('end', resolve);
|
||||||
|
|
@ -81,8 +78,8 @@ export class DownloadDispatcher extends Dispatcher<Download, channels.DownloadIn
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async stream(): Promise<channels.DownloadStreamResult> {
|
async stream(): Promise<channels.ArtifactStreamResult> {
|
||||||
const fileName = await this._object.localPath();
|
const fileName = await this._object.localPathAfterFinished();
|
||||||
if (!fileName)
|
if (!fileName)
|
||||||
return {};
|
return {};
|
||||||
const readable = fs.createReadStream(fileName);
|
const readable = fs.createReadStream(fileName);
|
||||||
|
|
@ -90,8 +87,8 @@ export class DownloadDispatcher extends Dispatcher<Download, channels.DownloadIn
|
||||||
return { stream: new StreamDispatcher(this._scope, readable) };
|
return { stream: new StreamDispatcher(this._scope, readable) };
|
||||||
}
|
}
|
||||||
|
|
||||||
async failure(): Promise<channels.DownloadFailureResult> {
|
async failure(): Promise<channels.ArtifactFailureResult> {
|
||||||
const error = await this._object.failure();
|
const error = await this._object.failureError();
|
||||||
return { error: error || undefined };
|
return { error: error || undefined };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -23,6 +23,8 @@ import { CRBrowserContext } from '../server/chromium/crBrowser';
|
||||||
import { CDPSessionDispatcher } from './cdpSessionDispatcher';
|
import { CDPSessionDispatcher } from './cdpSessionDispatcher';
|
||||||
import { RecorderSupplement } from '../server/supplements/recorderSupplement';
|
import { RecorderSupplement } from '../server/supplements/recorderSupplement';
|
||||||
import { CallMetadata } from '../server/instrumentation';
|
import { CallMetadata } from '../server/instrumentation';
|
||||||
|
import { ArtifactDispatcher } from './artifactDispatcher';
|
||||||
|
import { Artifact } from '../server/artifact';
|
||||||
|
|
||||||
export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channels.BrowserContextInitializer> implements channels.BrowserContextChannel {
|
export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channels.BrowserContextInitializer> implements channels.BrowserContextChannel {
|
||||||
private _context: BrowserContext;
|
private _context: BrowserContext;
|
||||||
|
|
@ -30,6 +32,20 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
|
||||||
constructor(scope: DispatcherScope, context: BrowserContext) {
|
constructor(scope: DispatcherScope, context: BrowserContext) {
|
||||||
super(scope, context, 'BrowserContext', { isChromium: context._browser.options.isChromium }, true);
|
super(scope, context, 'BrowserContext', { isChromium: context._browser.options.isChromium }, true);
|
||||||
this._context = context;
|
this._context = context;
|
||||||
|
// Note: when launching persistent context, dispatcher is created very late,
|
||||||
|
// so we can already have pages, videos and everything else.
|
||||||
|
|
||||||
|
const onVideo = (artifact: Artifact) => {
|
||||||
|
// Note: Video must outlive Page and BrowserContext, so that client can saveAs it
|
||||||
|
// after closing the context. We use |scope| for it.
|
||||||
|
const artifactDispatcher = new ArtifactDispatcher(scope, artifact);
|
||||||
|
this._dispatchEvent('video', { artifact: artifactDispatcher });
|
||||||
|
};
|
||||||
|
context.on(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())
|
for (const page of context.pages())
|
||||||
this._dispatchEvent('page', { page: new PageDispatcher(this._scope, page) });
|
this._dispatchEvent('page', { page: new PageDispatcher(this._scope, page) });
|
||||||
|
|
|
||||||
|
|
@ -14,16 +14,15 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { BrowserContext, Video } from '../server/browserContext';
|
import { BrowserContext } from '../server/browserContext';
|
||||||
import { Frame } from '../server/frames';
|
import { Frame } from '../server/frames';
|
||||||
import { Request } from '../server/network';
|
import { Request } from '../server/network';
|
||||||
import { Page, Worker } from '../server/page';
|
import { Page, Worker } from '../server/page';
|
||||||
import * as channels from '../protocol/channels';
|
import * as channels from '../protocol/channels';
|
||||||
import { Dispatcher, DispatcherScope, lookupDispatcher, lookupNullableDispatcher } from './dispatcher';
|
import { Dispatcher, DispatcherScope, existingDispatcher, lookupDispatcher, lookupNullableDispatcher } from './dispatcher';
|
||||||
import { parseError, serializeError } from '../protocol/serializers';
|
import { parseError, serializeError } from '../protocol/serializers';
|
||||||
import { ConsoleMessageDispatcher } from './consoleMessageDispatcher';
|
import { ConsoleMessageDispatcher } from './consoleMessageDispatcher';
|
||||||
import { DialogDispatcher } from './dialogDispatcher';
|
import { DialogDispatcher } from './dialogDispatcher';
|
||||||
import { DownloadDispatcher } from './downloadDispatcher';
|
|
||||||
import { FrameDispatcher } from './frameDispatcher';
|
import { FrameDispatcher } from './frameDispatcher';
|
||||||
import { RequestDispatcher, ResponseDispatcher, RouteDispatcher, WebSocketDispatcher } from './networkDispatchers';
|
import { RequestDispatcher, ResponseDispatcher, RouteDispatcher, WebSocketDispatcher } from './networkDispatchers';
|
||||||
import { serializeResult, parseArgument } from './jsHandleDispatcher';
|
import { serializeResult, parseArgument } from './jsHandleDispatcher';
|
||||||
|
|
@ -32,6 +31,9 @@ import { FileChooser } from '../server/fileChooser';
|
||||||
import { CRCoverage } from '../server/chromium/crCoverage';
|
import { CRCoverage } from '../server/chromium/crCoverage';
|
||||||
import { JSHandle } from '../server/javascript';
|
import { JSHandle } from '../server/javascript';
|
||||||
import { CallMetadata } from '../server/instrumentation';
|
import { CallMetadata } from '../server/instrumentation';
|
||||||
|
import { Artifact } from '../server/artifact';
|
||||||
|
import { ArtifactDispatcher } from './artifactDispatcher';
|
||||||
|
import { Download } from '../server/download';
|
||||||
|
|
||||||
export class PageDispatcher extends Dispatcher<Page, channels.PageInitializer> implements channels.PageChannel {
|
export class PageDispatcher extends Dispatcher<Page, channels.PageInitializer> implements channels.PageChannel {
|
||||||
private _page: Page;
|
private _page: Page;
|
||||||
|
|
@ -41,7 +43,6 @@ export class PageDispatcher extends Dispatcher<Page, channels.PageInitializer> i
|
||||||
// If we split pageCreated and pageReady, there should be no main frame during pageCreated.
|
// If we split pageCreated and pageReady, there should be no main frame during pageCreated.
|
||||||
super(scope, page, 'Page', {
|
super(scope, page, 'Page', {
|
||||||
mainFrame: FrameDispatcher.from(scope, page.mainFrame()),
|
mainFrame: FrameDispatcher.from(scope, page.mainFrame()),
|
||||||
videoRelativePath: page._video ? page._video._relativePath : undefined,
|
|
||||||
viewportSize: page.viewportSize() || undefined,
|
viewportSize: page.viewportSize() || undefined,
|
||||||
isClosed: page.isClosed()
|
isClosed: page.isClosed()
|
||||||
}, true);
|
}, true);
|
||||||
|
|
@ -54,7 +55,9 @@ export class PageDispatcher extends Dispatcher<Page, channels.PageInitializer> i
|
||||||
page.on(Page.Events.Crash, () => this._dispatchEvent('crash'));
|
page.on(Page.Events.Crash, () => this._dispatchEvent('crash'));
|
||||||
page.on(Page.Events.DOMContentLoaded, () => this._dispatchEvent('domcontentloaded'));
|
page.on(Page.Events.DOMContentLoaded, () => this._dispatchEvent('domcontentloaded'));
|
||||||
page.on(Page.Events.Dialog, dialog => this._dispatchEvent('dialog', { dialog: new DialogDispatcher(this._scope, dialog) }));
|
page.on(Page.Events.Dialog, dialog => this._dispatchEvent('dialog', { dialog: new DialogDispatcher(this._scope, dialog) }));
|
||||||
page.on(Page.Events.Download, download => this._dispatchEvent('download', { download: new DownloadDispatcher(scope, download) }));
|
page.on(Page.Events.Download, (download: Download) => {
|
||||||
|
this._dispatchEvent('download', { url: download.url, suggestedFilename: download.suggestedFilename(), artifact: new ArtifactDispatcher(scope, download.artifact) });
|
||||||
|
});
|
||||||
this._page.on(Page.Events.FileChooser, (fileChooser: FileChooser) => this._dispatchEvent('fileChooser', {
|
this._page.on(Page.Events.FileChooser, (fileChooser: FileChooser) => this._dispatchEvent('fileChooser', {
|
||||||
element: new ElementHandleDispatcher(this._scope, fileChooser.element()),
|
element: new ElementHandleDispatcher(this._scope, fileChooser.element()),
|
||||||
isMultiple: fileChooser.isMultiple()
|
isMultiple: fileChooser.isMultiple()
|
||||||
|
|
@ -75,9 +78,11 @@ export class PageDispatcher extends Dispatcher<Page, channels.PageInitializer> i
|
||||||
responseEndTiming: request._responseEndTiming
|
responseEndTiming: request._responseEndTiming
|
||||||
}));
|
}));
|
||||||
page.on(Page.Events.Response, response => this._dispatchEvent('response', { response: new ResponseDispatcher(this._scope, response) }));
|
page.on(Page.Events.Response, response => this._dispatchEvent('response', { response: new ResponseDispatcher(this._scope, response) }));
|
||||||
page.on(Page.Events.VideoStarted, (video: Video) => this._dispatchEvent('video', { relativePath: video._relativePath }));
|
|
||||||
page.on(Page.Events.WebSocket, webSocket => this._dispatchEvent('webSocket', { webSocket: new WebSocketDispatcher(this._scope, webSocket) }));
|
page.on(Page.Events.WebSocket, webSocket => this._dispatchEvent('webSocket', { webSocket: new WebSocketDispatcher(this._scope, webSocket) }));
|
||||||
page.on(Page.Events.Worker, worker => this._dispatchEvent('worker', { worker: new WorkerDispatcher(this._scope, worker) }));
|
page.on(Page.Events.Worker, worker => this._dispatchEvent('worker', { worker: new WorkerDispatcher(this._scope, worker) }));
|
||||||
|
page.on(Page.Events.Video, (artifact: Artifact) => this._dispatchEvent('video', { artifact: existingDispatcher<ArtifactDispatcher>(artifact) }));
|
||||||
|
if (page._video)
|
||||||
|
this._dispatchEvent('video', { artifact: existingDispatcher<ArtifactDispatcher>(page._video) });
|
||||||
}
|
}
|
||||||
|
|
||||||
async setDefaultNavigationTimeoutNoReply(params: channels.PageSetDefaultNavigationTimeoutNoReplyParams, metadata: CallMetadata): Promise<void> {
|
async setDefaultNavigationTimeoutNoReply(params: channels.PageSetDefaultNavigationTimeoutNoReplyParams, metadata: CallMetadata): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -187,13 +187,7 @@ export type RemoteBrowserInitializer = {
|
||||||
selectors: SelectorsChannel,
|
selectors: SelectorsChannel,
|
||||||
};
|
};
|
||||||
export interface RemoteBrowserChannel extends Channel {
|
export interface RemoteBrowserChannel extends Channel {
|
||||||
on(event: 'video', callback: (params: RemoteBrowserVideoEvent) => void): this;
|
|
||||||
}
|
}
|
||||||
export type RemoteBrowserVideoEvent = {
|
|
||||||
context: BrowserContextChannel,
|
|
||||||
stream: StreamChannel,
|
|
||||||
relativePath: string,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ----------- Selectors -----------
|
// ----------- Selectors -----------
|
||||||
export type SelectorsInitializer = {};
|
export type SelectorsInitializer = {};
|
||||||
|
|
@ -595,6 +589,7 @@ export interface BrowserContextChannel extends Channel {
|
||||||
on(event: 'close', callback: (params: BrowserContextCloseEvent) => void): this;
|
on(event: 'close', callback: (params: BrowserContextCloseEvent) => void): this;
|
||||||
on(event: 'page', callback: (params: BrowserContextPageEvent) => void): this;
|
on(event: 'page', callback: (params: BrowserContextPageEvent) => void): this;
|
||||||
on(event: 'route', callback: (params: BrowserContextRouteEvent) => void): this;
|
on(event: 'route', callback: (params: BrowserContextRouteEvent) => void): this;
|
||||||
|
on(event: 'video', callback: (params: BrowserContextVideoEvent) => void): this;
|
||||||
on(event: 'crBackgroundPage', callback: (params: BrowserContextCrBackgroundPageEvent) => void): this;
|
on(event: 'crBackgroundPage', callback: (params: BrowserContextCrBackgroundPageEvent) => void): this;
|
||||||
on(event: 'crServiceWorker', callback: (params: BrowserContextCrServiceWorkerEvent) => void): this;
|
on(event: 'crServiceWorker', callback: (params: BrowserContextCrServiceWorkerEvent) => void): this;
|
||||||
addCookies(params: BrowserContextAddCookiesParams, metadata?: Metadata): Promise<BrowserContextAddCookiesResult>;
|
addCookies(params: BrowserContextAddCookiesParams, metadata?: Metadata): Promise<BrowserContextAddCookiesResult>;
|
||||||
|
|
@ -629,6 +624,9 @@ export type BrowserContextRouteEvent = {
|
||||||
route: RouteChannel,
|
route: RouteChannel,
|
||||||
request: RequestChannel,
|
request: RequestChannel,
|
||||||
};
|
};
|
||||||
|
export type BrowserContextVideoEvent = {
|
||||||
|
artifact: ArtifactChannel,
|
||||||
|
};
|
||||||
export type BrowserContextCrBackgroundPageEvent = {
|
export type BrowserContextCrBackgroundPageEvent = {
|
||||||
page: PageChannel,
|
page: PageChannel,
|
||||||
};
|
};
|
||||||
|
|
@ -799,7 +797,6 @@ export type PageInitializer = {
|
||||||
height: number,
|
height: number,
|
||||||
},
|
},
|
||||||
isClosed: boolean,
|
isClosed: boolean,
|
||||||
videoRelativePath?: string,
|
|
||||||
};
|
};
|
||||||
export interface PageChannel extends Channel {
|
export interface PageChannel extends Channel {
|
||||||
on(event: 'bindingCall', callback: (params: PageBindingCallEvent) => void): this;
|
on(event: 'bindingCall', callback: (params: PageBindingCallEvent) => void): this;
|
||||||
|
|
@ -868,7 +865,9 @@ export type PageDialogEvent = {
|
||||||
dialog: DialogChannel,
|
dialog: DialogChannel,
|
||||||
};
|
};
|
||||||
export type PageDownloadEvent = {
|
export type PageDownloadEvent = {
|
||||||
download: DownloadChannel,
|
url: string,
|
||||||
|
suggestedFilename: string,
|
||||||
|
artifact: ArtifactChannel,
|
||||||
};
|
};
|
||||||
export type PageDomcontentloadedEvent = {};
|
export type PageDomcontentloadedEvent = {};
|
||||||
export type PageFileChooserEvent = {
|
export type PageFileChooserEvent = {
|
||||||
|
|
@ -908,7 +907,7 @@ export type PageRouteEvent = {
|
||||||
request: RequestChannel,
|
request: RequestChannel,
|
||||||
};
|
};
|
||||||
export type PageVideoEvent = {
|
export type PageVideoEvent = {
|
||||||
relativePath: string,
|
artifact: ArtifactChannel,
|
||||||
};
|
};
|
||||||
export type PageWebSocketEvent = {
|
export type PageWebSocketEvent = {
|
||||||
webSocket: WebSocketChannel,
|
webSocket: WebSocketChannel,
|
||||||
|
|
@ -2410,49 +2409,48 @@ export type DialogDismissParams = {};
|
||||||
export type DialogDismissOptions = {};
|
export type DialogDismissOptions = {};
|
||||||
export type DialogDismissResult = void;
|
export type DialogDismissResult = void;
|
||||||
|
|
||||||
// ----------- Download -----------
|
// ----------- Artifact -----------
|
||||||
export type DownloadInitializer = {
|
export type ArtifactInitializer = {
|
||||||
url: string,
|
absolutePath: string,
|
||||||
suggestedFilename: string,
|
|
||||||
};
|
};
|
||||||
export interface DownloadChannel extends Channel {
|
export interface ArtifactChannel extends Channel {
|
||||||
path(params?: DownloadPathParams, metadata?: Metadata): Promise<DownloadPathResult>;
|
pathAfterFinished(params?: ArtifactPathAfterFinishedParams, metadata?: Metadata): Promise<ArtifactPathAfterFinishedResult>;
|
||||||
saveAs(params: DownloadSaveAsParams, metadata?: Metadata): Promise<DownloadSaveAsResult>;
|
saveAs(params: ArtifactSaveAsParams, metadata?: Metadata): Promise<ArtifactSaveAsResult>;
|
||||||
saveAsStream(params?: DownloadSaveAsStreamParams, metadata?: Metadata): Promise<DownloadSaveAsStreamResult>;
|
saveAsStream(params?: ArtifactSaveAsStreamParams, metadata?: Metadata): Promise<ArtifactSaveAsStreamResult>;
|
||||||
failure(params?: DownloadFailureParams, metadata?: Metadata): Promise<DownloadFailureResult>;
|
failure(params?: ArtifactFailureParams, metadata?: Metadata): Promise<ArtifactFailureResult>;
|
||||||
stream(params?: DownloadStreamParams, metadata?: Metadata): Promise<DownloadStreamResult>;
|
stream(params?: ArtifactStreamParams, metadata?: Metadata): Promise<ArtifactStreamResult>;
|
||||||
delete(params?: DownloadDeleteParams, metadata?: Metadata): Promise<DownloadDeleteResult>;
|
delete(params?: ArtifactDeleteParams, metadata?: Metadata): Promise<ArtifactDeleteResult>;
|
||||||
}
|
}
|
||||||
export type DownloadPathParams = {};
|
export type ArtifactPathAfterFinishedParams = {};
|
||||||
export type DownloadPathOptions = {};
|
export type ArtifactPathAfterFinishedOptions = {};
|
||||||
export type DownloadPathResult = {
|
export type ArtifactPathAfterFinishedResult = {
|
||||||
value?: string,
|
value?: string,
|
||||||
};
|
};
|
||||||
export type DownloadSaveAsParams = {
|
export type ArtifactSaveAsParams = {
|
||||||
path: string,
|
path: string,
|
||||||
};
|
};
|
||||||
export type DownloadSaveAsOptions = {
|
export type ArtifactSaveAsOptions = {
|
||||||
|
|
||||||
};
|
};
|
||||||
export type DownloadSaveAsResult = void;
|
export type ArtifactSaveAsResult = void;
|
||||||
export type DownloadSaveAsStreamParams = {};
|
export type ArtifactSaveAsStreamParams = {};
|
||||||
export type DownloadSaveAsStreamOptions = {};
|
export type ArtifactSaveAsStreamOptions = {};
|
||||||
export type DownloadSaveAsStreamResult = {
|
export type ArtifactSaveAsStreamResult = {
|
||||||
stream: StreamChannel,
|
stream: StreamChannel,
|
||||||
};
|
};
|
||||||
export type DownloadFailureParams = {};
|
export type ArtifactFailureParams = {};
|
||||||
export type DownloadFailureOptions = {};
|
export type ArtifactFailureOptions = {};
|
||||||
export type DownloadFailureResult = {
|
export type ArtifactFailureResult = {
|
||||||
error?: string,
|
error?: string,
|
||||||
};
|
};
|
||||||
export type DownloadStreamParams = {};
|
export type ArtifactStreamParams = {};
|
||||||
export type DownloadStreamOptions = {};
|
export type ArtifactStreamOptions = {};
|
||||||
export type DownloadStreamResult = {
|
export type ArtifactStreamResult = {
|
||||||
stream?: StreamChannel,
|
stream?: StreamChannel,
|
||||||
};
|
};
|
||||||
export type DownloadDeleteParams = {};
|
export type ArtifactDeleteParams = {};
|
||||||
export type DownloadDeleteOptions = {};
|
export type ArtifactDeleteOptions = {};
|
||||||
export type DownloadDeleteResult = void;
|
export type ArtifactDeleteResult = void;
|
||||||
|
|
||||||
// ----------- Stream -----------
|
// ----------- Stream -----------
|
||||||
export type StreamInitializer = {};
|
export type StreamInitializer = {};
|
||||||
|
|
|
||||||
|
|
@ -380,16 +380,6 @@ RemoteBrowser:
|
||||||
browser: Browser
|
browser: Browser
|
||||||
selectors: Selectors
|
selectors: Selectors
|
||||||
|
|
||||||
events:
|
|
||||||
|
|
||||||
# Video stream blocks owner context from closing until the stream is closed.
|
|
||||||
# Make sure to close the stream!
|
|
||||||
video:
|
|
||||||
parameters:
|
|
||||||
context: BrowserContext
|
|
||||||
stream: Stream
|
|
||||||
relativePath: string
|
|
||||||
|
|
||||||
|
|
||||||
Selectors:
|
Selectors:
|
||||||
type: interface
|
type: interface
|
||||||
|
|
@ -631,6 +621,10 @@ BrowserContext:
|
||||||
route: Route
|
route: Route
|
||||||
request: Request
|
request: Request
|
||||||
|
|
||||||
|
video:
|
||||||
|
parameters:
|
||||||
|
artifact: Artifact
|
||||||
|
|
||||||
crBackgroundPage:
|
crBackgroundPage:
|
||||||
parameters:
|
parameters:
|
||||||
page: Page
|
page: Page
|
||||||
|
|
@ -650,7 +644,6 @@ Page:
|
||||||
width: number
|
width: number
|
||||||
height: number
|
height: number
|
||||||
isClosed: boolean
|
isClosed: boolean
|
||||||
videoRelativePath: string?
|
|
||||||
|
|
||||||
commands:
|
commands:
|
||||||
|
|
||||||
|
|
@ -940,7 +933,9 @@ Page:
|
||||||
|
|
||||||
download:
|
download:
|
||||||
parameters:
|
parameters:
|
||||||
download: Download
|
url: string
|
||||||
|
suggestedFilename: string
|
||||||
|
artifact: Artifact
|
||||||
|
|
||||||
domcontentloaded:
|
domcontentloaded:
|
||||||
|
|
||||||
|
|
@ -993,7 +988,7 @@ Page:
|
||||||
|
|
||||||
video:
|
video:
|
||||||
parameters:
|
parameters:
|
||||||
relativePath: string
|
artifact: Artifact
|
||||||
|
|
||||||
webSocket:
|
webSocket:
|
||||||
parameters:
|
parameters:
|
||||||
|
|
@ -1991,16 +1986,15 @@ Dialog:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Download:
|
Artifact:
|
||||||
type: interface
|
type: interface
|
||||||
|
|
||||||
initializer:
|
initializer:
|
||||||
url: string
|
absolutePath: string
|
||||||
suggestedFilename: string
|
|
||||||
|
|
||||||
commands:
|
commands:
|
||||||
|
|
||||||
path:
|
pathAfterFinished:
|
||||||
returns:
|
returns:
|
||||||
value: string?
|
value: string?
|
||||||
|
|
||||||
|
|
@ -2025,7 +2019,6 @@ Download:
|
||||||
delete:
|
delete:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Stream:
|
Stream:
|
||||||
type: interface
|
type: interface
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -926,14 +926,14 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme {
|
||||||
promptText: tOptional(tString),
|
promptText: tOptional(tString),
|
||||||
});
|
});
|
||||||
scheme.DialogDismissParams = tOptional(tObject({}));
|
scheme.DialogDismissParams = tOptional(tObject({}));
|
||||||
scheme.DownloadPathParams = tOptional(tObject({}));
|
scheme.ArtifactPathAfterFinishedParams = tOptional(tObject({}));
|
||||||
scheme.DownloadSaveAsParams = tObject({
|
scheme.ArtifactSaveAsParams = tObject({
|
||||||
path: tString,
|
path: tString,
|
||||||
});
|
});
|
||||||
scheme.DownloadSaveAsStreamParams = tOptional(tObject({}));
|
scheme.ArtifactSaveAsStreamParams = tOptional(tObject({}));
|
||||||
scheme.DownloadFailureParams = tOptional(tObject({}));
|
scheme.ArtifactFailureParams = tOptional(tObject({}));
|
||||||
scheme.DownloadStreamParams = tOptional(tObject({}));
|
scheme.ArtifactStreamParams = tOptional(tObject({}));
|
||||||
scheme.DownloadDeleteParams = tOptional(tObject({}));
|
scheme.ArtifactDeleteParams = tOptional(tObject({}));
|
||||||
scheme.StreamReadParams = tObject({
|
scheme.StreamReadParams = tObject({
|
||||||
size: tOptional(tNumber),
|
size: tOptional(tNumber),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
117
src/server/artifact.ts
Normal file
117
src/server/artifact.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import * as util from 'util';
|
||||||
|
|
||||||
|
type SaveCallback = (localPath: string, error?: string) => Promise<void>;
|
||||||
|
|
||||||
|
export class Artifact {
|
||||||
|
private _localPath: string;
|
||||||
|
private _unaccessibleErrorMessage: string | undefined;
|
||||||
|
private _finishedCallback: () => void;
|
||||||
|
private _finishedPromise: Promise<void>;
|
||||||
|
private _saveCallbacks: SaveCallback[] = [];
|
||||||
|
private _finished: boolean = false;
|
||||||
|
private _deleted = false;
|
||||||
|
private _failureError: string | null = null;
|
||||||
|
|
||||||
|
constructor(localPath: string, unaccessibleErrorMessage?: string) {
|
||||||
|
this._localPath = localPath;
|
||||||
|
this._unaccessibleErrorMessage = unaccessibleErrorMessage;
|
||||||
|
this._finishedCallback = () => {};
|
||||||
|
this._finishedPromise = new Promise(f => this._finishedCallback = f);
|
||||||
|
}
|
||||||
|
|
||||||
|
finishedPromise() {
|
||||||
|
return this._finishedPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
localPath() {
|
||||||
|
return this._localPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
async localPathAfterFinished(): Promise<string | null> {
|
||||||
|
if (this._unaccessibleErrorMessage)
|
||||||
|
throw new Error(this._unaccessibleErrorMessage);
|
||||||
|
await this._finishedPromise;
|
||||||
|
if (this._failureError)
|
||||||
|
return null;
|
||||||
|
return this._localPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveAs(saveCallback: SaveCallback) {
|
||||||
|
if (this._unaccessibleErrorMessage)
|
||||||
|
throw new Error(this._unaccessibleErrorMessage);
|
||||||
|
if (this._deleted)
|
||||||
|
throw new Error(`File already deleted. Save before deleting.`);
|
||||||
|
if (this._failureError)
|
||||||
|
throw new Error(`File not found on disk. Check download.failure() for details.`);
|
||||||
|
|
||||||
|
if (this._finished) {
|
||||||
|
saveCallback(this._localPath).catch(e => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._saveCallbacks.push(saveCallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
async failureError(): Promise<string | null> {
|
||||||
|
if (this._unaccessibleErrorMessage)
|
||||||
|
return this._unaccessibleErrorMessage;
|
||||||
|
await this._finishedPromise;
|
||||||
|
return this._failureError;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(): Promise<void> {
|
||||||
|
if (this._unaccessibleErrorMessage)
|
||||||
|
return;
|
||||||
|
const fileName = await this.localPathAfterFinished();
|
||||||
|
if (this._deleted)
|
||||||
|
return;
|
||||||
|
this._deleted = true;
|
||||||
|
if (fileName)
|
||||||
|
await util.promisify(fs.unlink)(fileName).catch(e => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteOnContextClose(): Promise<void> {
|
||||||
|
// Compared to "delete", this method does not wait for the artifact to finish.
|
||||||
|
// We use it when closing the context to avoid stalling.
|
||||||
|
if (this._deleted)
|
||||||
|
return;
|
||||||
|
this._deleted = true;
|
||||||
|
if (!this._unaccessibleErrorMessage)
|
||||||
|
await util.promisify(fs.unlink)(this._localPath).catch(e => {});
|
||||||
|
await this.reportFinished('File deleted upon browser context closure.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async reportFinished(error?: string) {
|
||||||
|
if (this._finished)
|
||||||
|
return;
|
||||||
|
this._finished = true;
|
||||||
|
this._failureError = error || null;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
for (const callback of this._saveCallbacks)
|
||||||
|
await callback('', error);
|
||||||
|
} else {
|
||||||
|
for (const callback of this._saveCallbacks)
|
||||||
|
await callback(this._localPath);
|
||||||
|
}
|
||||||
|
this._saveCallbacks = [];
|
||||||
|
|
||||||
|
this._finishedCallback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as types from './types';
|
import * as types from './types';
|
||||||
import { BrowserContext, Video } from './browserContext';
|
import { BrowserContext } from './browserContext';
|
||||||
import { Page } from './page';
|
import { Page } from './page';
|
||||||
import { Download } from './download';
|
import { Download } from './download';
|
||||||
import { ProxySettings } from './types';
|
import { ProxySettings } from './types';
|
||||||
|
|
@ -23,6 +23,7 @@ import { ChildProcess } from 'child_process';
|
||||||
import { RecentLogsCollector } from '../utils/debugLogger';
|
import { RecentLogsCollector } from '../utils/debugLogger';
|
||||||
import * as registry from '../utils/registry';
|
import * as registry from '../utils/registry';
|
||||||
import { SdkObject } from './instrumentation';
|
import { SdkObject } from './instrumentation';
|
||||||
|
import { Artifact } from './artifact';
|
||||||
|
|
||||||
export interface BrowserProcess {
|
export interface BrowserProcess {
|
||||||
onclose?: ((exitCode: number | null, signal: string | null) => void);
|
onclose?: ((exitCode: number | null, signal: string | null) => void);
|
||||||
|
|
@ -60,7 +61,7 @@ export abstract class Browser extends SdkObject {
|
||||||
private _downloads = new Map<string, Download>();
|
private _downloads = new Map<string, Download>();
|
||||||
_defaultContext: BrowserContext | null = null;
|
_defaultContext: BrowserContext | null = null;
|
||||||
private _startedClosing = false;
|
private _startedClosing = false;
|
||||||
readonly _idToVideo = new Map<string, Video>();
|
readonly _idToVideo = new Map<string, { context: BrowserContext, artifact: Artifact }>();
|
||||||
|
|
||||||
constructor(options: BrowserOptions) {
|
constructor(options: BrowserOptions) {
|
||||||
super(options.rootSdkObject);
|
super(options.rootSdkObject);
|
||||||
|
|
@ -89,24 +90,26 @@ export abstract class Browser extends SdkObject {
|
||||||
const download = this._downloads.get(uuid);
|
const download = this._downloads.get(uuid);
|
||||||
if (!download)
|
if (!download)
|
||||||
return;
|
return;
|
||||||
download._reportFinished(error);
|
download.artifact.reportFinished(error);
|
||||||
this._downloads.delete(uuid);
|
this._downloads.delete(uuid);
|
||||||
}
|
}
|
||||||
|
|
||||||
_videoStarted(context: BrowserContext, videoId: string, path: string, pageOrError: Promise<Page | Error>) {
|
_videoStarted(context: BrowserContext, videoId: string, path: string, pageOrError: Promise<Page | Error>) {
|
||||||
const video = new Video(context, videoId, path);
|
const artifact = new Artifact(path);
|
||||||
this._idToVideo.set(videoId, video);
|
this._idToVideo.set(videoId, { context, artifact });
|
||||||
context.emit(BrowserContext.Events.VideoStarted, video);
|
context.emit(BrowserContext.Events.VideoStarted, artifact);
|
||||||
pageOrError.then(pageOrError => {
|
pageOrError.then(page => {
|
||||||
if (pageOrError instanceof Page)
|
if (page instanceof Page) {
|
||||||
pageOrError.videoStarted(video);
|
page._video = artifact;
|
||||||
|
page.emit(Page.Events.Video, artifact);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
_videoFinished(videoId: string) {
|
_videoFinished(videoId: string) {
|
||||||
const video = this._idToVideo.get(videoId)!;
|
const video = this._idToVideo.get(videoId);
|
||||||
this._idToVideo.delete(videoId);
|
this._idToVideo.delete(videoId);
|
||||||
video._finish();
|
video?.artifact.reportFinished();
|
||||||
}
|
}
|
||||||
|
|
||||||
_didClose() {
|
_didClose() {
|
||||||
|
|
|
||||||
|
|
@ -29,40 +29,12 @@ import * as types from './types';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { CallMetadata, internalCallMetadata, SdkObject } from './instrumentation';
|
import { CallMetadata, internalCallMetadata, SdkObject } from './instrumentation';
|
||||||
|
|
||||||
export class Video {
|
|
||||||
readonly _videoId: string;
|
|
||||||
readonly _path: string;
|
|
||||||
readonly _relativePath: string;
|
|
||||||
readonly _context: BrowserContext;
|
|
||||||
readonly _finishedPromise: Promise<void>;
|
|
||||||
private _finishCallback: () => void = () => {};
|
|
||||||
private _callbackOnFinish?: () => Promise<void>;
|
|
||||||
|
|
||||||
constructor(context: BrowserContext, videoId: string, p: string) {
|
|
||||||
this._videoId = videoId;
|
|
||||||
this._path = p;
|
|
||||||
this._relativePath = path.relative(context._options.recordVideo!.dir, p);
|
|
||||||
this._context = context;
|
|
||||||
this._finishedPromise = new Promise(fulfill => this._finishCallback = fulfill);
|
|
||||||
}
|
|
||||||
|
|
||||||
async _finish() {
|
|
||||||
if (this._callbackOnFinish)
|
|
||||||
await this._callbackOnFinish();
|
|
||||||
this._finishCallback();
|
|
||||||
}
|
|
||||||
|
|
||||||
_waitForCallbackOnFinish(callback: () => Promise<void>) {
|
|
||||||
this._callbackOnFinish = callback;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export abstract class BrowserContext extends SdkObject {
|
export abstract class BrowserContext extends SdkObject {
|
||||||
static Events = {
|
static Events = {
|
||||||
Close: 'close',
|
Close: 'close',
|
||||||
Page: 'page',
|
Page: 'page',
|
||||||
VideoStarted: 'videostarted',
|
|
||||||
BeforeClose: 'beforeclose',
|
BeforeClose: 'beforeclose',
|
||||||
|
VideoStarted: 'videostarted',
|
||||||
};
|
};
|
||||||
|
|
||||||
readonly _timeoutSettings = new TimeoutSettings();
|
readonly _timeoutSettings = new TimeoutSettings();
|
||||||
|
|
@ -121,6 +93,10 @@ export abstract class BrowserContext extends SdkObject {
|
||||||
}
|
}
|
||||||
this._closedStatus = 'closed';
|
this._closedStatus = 'closed';
|
||||||
this._downloads.clear();
|
this._downloads.clear();
|
||||||
|
for (const [id, video] of this._browser._idToVideo) {
|
||||||
|
if (video.context === this)
|
||||||
|
this._browser._idToVideo.delete(id);
|
||||||
|
}
|
||||||
this._closePromiseFulfill!(new Error('Context closed'));
|
this._closePromiseFulfill!(new Error('Context closed'));
|
||||||
this.emit(BrowserContext.Events.Close);
|
this.emit(BrowserContext.Events.Close);
|
||||||
}
|
}
|
||||||
|
|
@ -267,15 +243,15 @@ export abstract class BrowserContext extends SdkObject {
|
||||||
|
|
||||||
// Cleanup.
|
// Cleanup.
|
||||||
const promises: Promise<void>[] = [];
|
const promises: Promise<void>[] = [];
|
||||||
for (const video of this._browser._idToVideo.values()) {
|
for (const { context, artifact } of this._browser._idToVideo.values()) {
|
||||||
// Wait for the videos to finish.
|
// Wait for the videos to finish.
|
||||||
if (video._context === this)
|
if (context === this)
|
||||||
promises.push(video._finishedPromise);
|
promises.push(artifact.finishedPromise());
|
||||||
}
|
}
|
||||||
for (const download of this._downloads) {
|
for (const download of this._downloads) {
|
||||||
// We delete downloads after context closure
|
// We delete downloads after context closure
|
||||||
// so that browser does not write to the download file anymore.
|
// so that browser does not write to the download file anymore.
|
||||||
promises.push(download.deleteOnContextClose());
|
promises.push(download.artifact.deleteOnContextClose());
|
||||||
}
|
}
|
||||||
await Promise.all(promises);
|
await Promise.all(promises);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -863,7 +863,8 @@ class FrameSession {
|
||||||
}
|
}
|
||||||
|
|
||||||
async _startScreencast(options: types.PageScreencastOptions) {
|
async _startScreencast(options: types.PageScreencastOptions) {
|
||||||
assert(this._screencastId);
|
const screencastId = this._screencastId;
|
||||||
|
assert(screencastId);
|
||||||
const gotFirstFrame = new Promise(f => this._client.once('Page.screencastFrame', f));
|
const gotFirstFrame = new Promise(f => this._client.once('Page.screencastFrame', f));
|
||||||
await this._client.send('Page.startScreencast', {
|
await this._client.send('Page.startScreencast', {
|
||||||
format: 'jpeg',
|
format: 'jpeg',
|
||||||
|
|
@ -872,7 +873,9 @@ class FrameSession {
|
||||||
maxHeight: options.height,
|
maxHeight: options.height,
|
||||||
});
|
});
|
||||||
// Wait for the first frame before reporting video to the client.
|
// Wait for the first frame before reporting video to the client.
|
||||||
this._crPage._browserContext._browser._videoStarted(this._crPage._browserContext, this._screencastId, options.outputFile, gotFirstFrame.then(() => this._crPage.pageOrError()));
|
gotFirstFrame.then(() => {
|
||||||
|
this._crPage._browserContext._browser._videoStarted(this._crPage._browserContext, screencastId, options.outputFile, this._crPage.pageOrError());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async _stopScreencast(): Promise<void> {
|
async _stopScreencast(): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -15,37 +15,23 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
|
||||||
import * as util from 'util';
|
|
||||||
import { Page } from './page';
|
import { Page } from './page';
|
||||||
import { assert } from '../utils/utils';
|
import { assert } from '../utils/utils';
|
||||||
|
import { Artifact } from './artifact';
|
||||||
type SaveCallback = (localPath: string, error?: string) => Promise<void>;
|
|
||||||
|
|
||||||
export class Download {
|
export class Download {
|
||||||
private _downloadsPath: string;
|
readonly artifact: Artifact;
|
||||||
private _uuid: string;
|
readonly url: string;
|
||||||
private _finishedCallback: () => void;
|
|
||||||
private _finishedPromise: Promise<void>;
|
|
||||||
private _saveCallbacks: SaveCallback[] = [];
|
|
||||||
private _finished: boolean = false;
|
|
||||||
private _page: Page;
|
private _page: Page;
|
||||||
private _acceptDownloads: boolean;
|
|
||||||
private _failure: string | null = null;
|
|
||||||
private _deleted = false;
|
|
||||||
private _url: string;
|
|
||||||
private _suggestedFilename: string | undefined;
|
private _suggestedFilename: string | undefined;
|
||||||
|
|
||||||
constructor(page: Page, downloadsPath: string, uuid: string, url: string, suggestedFilename?: string) {
|
constructor(page: Page, downloadsPath: string, uuid: string, url: string, suggestedFilename?: string) {
|
||||||
|
const unaccessibleErrorMessage = !page._browserContext._options.acceptDownloads ? 'Pass { acceptDownloads: true } when you are creating your browser context.' : undefined;
|
||||||
|
this.artifact = new Artifact(path.join(downloadsPath, uuid), unaccessibleErrorMessage);
|
||||||
this._page = page;
|
this._page = page;
|
||||||
this._downloadsPath = downloadsPath;
|
this.url = url;
|
||||||
this._uuid = uuid;
|
|
||||||
this._url = url;
|
|
||||||
this._suggestedFilename = suggestedFilename;
|
this._suggestedFilename = suggestedFilename;
|
||||||
this._finishedCallback = () => {};
|
|
||||||
this._finishedPromise = new Promise(f => this._finishedCallback = f);
|
|
||||||
page._browserContext._downloads.add(this);
|
page._browserContext._downloads.add(this);
|
||||||
this._acceptDownloads = !!this._page._browserContext._options.acceptDownloads;
|
|
||||||
if (suggestedFilename !== undefined)
|
if (suggestedFilename !== undefined)
|
||||||
this._page.emit(Page.Events.Download, this);
|
this._page.emit(Page.Events.Download, this);
|
||||||
}
|
}
|
||||||
|
|
@ -56,86 +42,7 @@ export class Download {
|
||||||
this._page.emit(Page.Events.Download, this);
|
this._page.emit(Page.Events.Download, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
url(): string {
|
|
||||||
return this._url;
|
|
||||||
}
|
|
||||||
|
|
||||||
suggestedFilename(): string {
|
suggestedFilename(): string {
|
||||||
return this._suggestedFilename!;
|
return this._suggestedFilename!;
|
||||||
}
|
}
|
||||||
|
|
||||||
async localPath(): Promise<string | null> {
|
|
||||||
if (!this._acceptDownloads)
|
|
||||||
throw new Error('Pass { acceptDownloads: true } when you are creating your browser context.');
|
|
||||||
const fileName = path.join(this._downloadsPath, this._uuid);
|
|
||||||
await this._finishedPromise;
|
|
||||||
if (this._failure)
|
|
||||||
return null;
|
|
||||||
return fileName;
|
|
||||||
}
|
|
||||||
|
|
||||||
saveAs(saveCallback: SaveCallback) {
|
|
||||||
if (!this._acceptDownloads)
|
|
||||||
throw new Error('Pass { acceptDownloads: true } when you are creating your browser context.');
|
|
||||||
if (this._deleted)
|
|
||||||
throw new Error('Download already deleted. Save before deleting.');
|
|
||||||
if (this._failure)
|
|
||||||
throw new Error('Download not found on disk. Check download.failure() for details.');
|
|
||||||
|
|
||||||
if (this._finished) {
|
|
||||||
saveCallback(path.join(this._downloadsPath, this._uuid)).catch(e => {});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._saveCallbacks.push(saveCallback);
|
|
||||||
}
|
|
||||||
|
|
||||||
async failure(): Promise<string | null> {
|
|
||||||
if (!this._acceptDownloads)
|
|
||||||
return 'Pass { acceptDownloads: true } when you are creating your browser context.';
|
|
||||||
await this._finishedPromise;
|
|
||||||
return this._failure;
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete(): Promise<void> {
|
|
||||||
if (!this._acceptDownloads)
|
|
||||||
return;
|
|
||||||
const fileName = await this.localPath();
|
|
||||||
if (this._deleted)
|
|
||||||
return;
|
|
||||||
this._deleted = true;
|
|
||||||
if (fileName)
|
|
||||||
await util.promisify(fs.unlink)(fileName).catch(e => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteOnContextClose(): Promise<void> {
|
|
||||||
// Compared to "delete", this method does not wait for the download to finish.
|
|
||||||
// We use it when closing the context to avoid stalling.
|
|
||||||
if (this._deleted)
|
|
||||||
return;
|
|
||||||
this._deleted = true;
|
|
||||||
if (this._acceptDownloads) {
|
|
||||||
const fileName = path.join(this._downloadsPath, this._uuid);
|
|
||||||
await util.promisify(fs.unlink)(fileName).catch(e => {});
|
|
||||||
}
|
|
||||||
await this._reportFinished('Download deleted upon browser context closure.');
|
|
||||||
}
|
|
||||||
|
|
||||||
async _reportFinished(error?: string) {
|
|
||||||
if (this._finished)
|
|
||||||
return;
|
|
||||||
this._finished = true;
|
|
||||||
this._failure = error || null;
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
for (const callback of this._saveCallbacks)
|
|
||||||
await callback('', error);
|
|
||||||
} else {
|
|
||||||
const fullPath = path.join(this._downloadsPath, this._uuid);
|
|
||||||
for (const callback of this._saveCallbacks)
|
|
||||||
await callback(fullPath);
|
|
||||||
}
|
|
||||||
this._saveCallbacks = [];
|
|
||||||
|
|
||||||
this._finishedCallback();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -310,6 +310,8 @@ export class FFPage implements PageDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
didClose() {
|
didClose() {
|
||||||
|
if (!this._initializedPage)
|
||||||
|
this._markAsError(new Error('Page has been closed'));
|
||||||
this._session.dispose();
|
this._session.dispose();
|
||||||
helper.removeEventListeners(this._eventListeners);
|
helper.removeEventListeners(this._eventListeners);
|
||||||
this._networkManager.dispose();
|
this._networkManager.dispose();
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ import * as network from './network';
|
||||||
import { Screenshotter } from './screenshotter';
|
import { Screenshotter } from './screenshotter';
|
||||||
import { TimeoutSettings } from '../utils/timeoutSettings';
|
import { TimeoutSettings } from '../utils/timeoutSettings';
|
||||||
import * as types from './types';
|
import * as types from './types';
|
||||||
import { BrowserContext, Video } from './browserContext';
|
import { BrowserContext } from './browserContext';
|
||||||
import { ConsoleMessage } from './console';
|
import { ConsoleMessage } from './console';
|
||||||
import * as accessibility from './accessibility';
|
import * as accessibility from './accessibility';
|
||||||
import { FileChooser } from './fileChooser';
|
import { FileChooser } from './fileChooser';
|
||||||
|
|
@ -32,6 +32,7 @@ import { assert, createGuid, isError } from '../utils/utils';
|
||||||
import { debugLogger } from '../utils/debugLogger';
|
import { debugLogger } from '../utils/debugLogger';
|
||||||
import { Selectors } from './selectors';
|
import { Selectors } from './selectors';
|
||||||
import { CallMetadata, SdkObject } from './instrumentation';
|
import { CallMetadata, SdkObject } from './instrumentation';
|
||||||
|
import { Artifact } from './artifact';
|
||||||
|
|
||||||
export interface PageDelegate {
|
export interface PageDelegate {
|
||||||
readonly rawMouse: input.RawMouse;
|
readonly rawMouse: input.RawMouse;
|
||||||
|
|
@ -114,9 +115,9 @@ export class Page extends SdkObject {
|
||||||
InternalFrameNavigatedToNewDocument: 'internalframenavigatedtonewdocument',
|
InternalFrameNavigatedToNewDocument: 'internalframenavigatedtonewdocument',
|
||||||
Load: 'load',
|
Load: 'load',
|
||||||
Popup: 'popup',
|
Popup: 'popup',
|
||||||
|
Video: 'video',
|
||||||
WebSocket: 'websocket',
|
WebSocket: 'websocket',
|
||||||
Worker: 'worker',
|
Worker: 'worker',
|
||||||
VideoStarted: 'videostarted',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private _closedState: 'open' | 'closing' | 'closed' = 'open';
|
private _closedState: 'open' | 'closing' | 'closed' = 'open';
|
||||||
|
|
@ -146,9 +147,9 @@ export class Page extends SdkObject {
|
||||||
private _serverRequestInterceptor: network.RouteHandler | undefined;
|
private _serverRequestInterceptor: network.RouteHandler | undefined;
|
||||||
_ownedContext: BrowserContext | undefined;
|
_ownedContext: BrowserContext | undefined;
|
||||||
readonly selectors: Selectors;
|
readonly selectors: Selectors;
|
||||||
_video: Video | null = null;
|
|
||||||
readonly uniqueId: string;
|
readonly uniqueId: string;
|
||||||
_pageIsError: Error | undefined;
|
_pageIsError: Error | undefined;
|
||||||
|
_video: Artifact | null = null;
|
||||||
|
|
||||||
constructor(delegate: PageDelegate, browserContext: BrowserContext) {
|
constructor(delegate: PageDelegate, browserContext: BrowserContext) {
|
||||||
super(browserContext);
|
super(browserContext);
|
||||||
|
|
@ -181,7 +182,7 @@ export class Page extends SdkObject {
|
||||||
this.selectors = browserContext.selectors();
|
this.selectors = browserContext.selectors();
|
||||||
}
|
}
|
||||||
|
|
||||||
async reportAsNew(error?: Error) {
|
reportAsNew(error?: Error) {
|
||||||
if (error) {
|
if (error) {
|
||||||
// Initialization error could have happened because of
|
// Initialization error could have happened because of
|
||||||
// context/browser closure. Just ignore the page.
|
// context/browser closure. Just ignore the page.
|
||||||
|
|
@ -478,11 +479,6 @@ export class Page extends SdkObject {
|
||||||
await this._delegate.setFileChooserIntercepted(enabled);
|
await this._delegate.setFileChooserIntercepted(enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
videoStarted(video: Video) {
|
|
||||||
this._video = video;
|
|
||||||
this.emit(Page.Events.VideoStarted, video);
|
|
||||||
}
|
|
||||||
|
|
||||||
frameNavigatedToNewDocument(frame: frames.Frame) {
|
frameNavigatedToNewDocument(frame: frames.Frame) {
|
||||||
this.emit(Page.Events.InternalFrameNavigatedToNewDocument, frame);
|
this.emit(Page.Events.InternalFrameNavigatedToNewDocument, frame);
|
||||||
const url = frame.url();
|
const url = frame.url();
|
||||||
|
|
|
||||||
|
|
@ -242,7 +242,7 @@ describe('connect', (suite, { mode }) => {
|
||||||
await page.close();
|
await page.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should save videos from remote browser', async ({browserType, remoteServer, testInfo}) => {
|
it('should saveAs videos from remote browser', async ({browserType, remoteServer, testInfo}) => {
|
||||||
const remote = await browserType.connect({ wsEndpoint: remoteServer.wsEndpoint() });
|
const remote = await browserType.connect({ wsEndpoint: remoteServer.wsEndpoint() });
|
||||||
const videosPath = testInfo.outputPath();
|
const videosPath = testInfo.outputPath();
|
||||||
const context = await remote.newContext({
|
const context = await remote.newContext({
|
||||||
|
|
@ -253,8 +253,11 @@ describe('connect', (suite, { mode }) => {
|
||||||
await new Promise(r => setTimeout(r, 1000));
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
await context.close();
|
await context.close();
|
||||||
|
|
||||||
const files = fs.readdirSync(videosPath);
|
const savedAsPath = testInfo.outputPath('my-video.webm');
|
||||||
expect(files.some(file => file.endsWith('webm'))).toBe(true);
|
await page.video().saveAs(savedAsPath);
|
||||||
|
expect(fs.existsSync(savedAsPath)).toBeTruthy();
|
||||||
|
const error = await page.video().path().catch(e => e);
|
||||||
|
expect(error.message).toContain('Path is not available when using browserType.connect(). Use saveAs() to save a local copy.');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be able to connect 20 times to a single server without warnings', async ({browserType, remoteServer, server}) => {
|
it('should be able to connect 20 times to a single server without warnings', async ({browserType, remoteServer, server}) => {
|
||||||
|
|
|
||||||
|
|
@ -189,7 +189,7 @@ describe('download event', () => {
|
||||||
expect(fs.existsSync(nestedPath)).toBeTruthy();
|
expect(fs.existsSync(nestedPath)).toBeTruthy();
|
||||||
expect(fs.readFileSync(nestedPath).toString()).toBe('Hello world');
|
expect(fs.readFileSync(nestedPath).toString()).toBe('Hello world');
|
||||||
const error = await download.path().catch(e => e);
|
const error = await download.path().catch(e => e);
|
||||||
expect(error.message).toContain('Path is not available when using browserType.connect(). Use download.saveAs() to save a local copy.');
|
expect(error.message).toContain('Path is not available when using browserType.connect(). Use saveAs() to save a local copy.');
|
||||||
await browser.close();
|
await browser.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -216,7 +216,7 @@ describe('download event', () => {
|
||||||
const userPath = testInfo.outputPath('download.txt');
|
const userPath = testInfo.outputPath('download.txt');
|
||||||
await download.delete();
|
await download.delete();
|
||||||
const { message } = await download.saveAs(userPath).catch(e => e);
|
const { message } = await download.saveAs(userPath).catch(e => e);
|
||||||
expect(message).toContain('Download already deleted. Save before deleting.');
|
expect(message).toContain('File already deleted. Save before deleting.');
|
||||||
await page.close();
|
await page.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -233,7 +233,7 @@ describe('download event', () => {
|
||||||
const userPath = testInfo.outputPath('download.txt');
|
const userPath = testInfo.outputPath('download.txt');
|
||||||
await download.delete();
|
await download.delete();
|
||||||
const { message } = await download.saveAs(userPath).catch(e => e);
|
const { message } = await download.saveAs(userPath).catch(e => e);
|
||||||
expect(message).toContain('Download already deleted. Save before deleting.');
|
expect(message).toContain('File already deleted. Save before deleting.');
|
||||||
await browser.close();
|
await browser.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -413,7 +413,7 @@ describe('download event', () => {
|
||||||
page.context().close(),
|
page.context().close(),
|
||||||
]);
|
]);
|
||||||
expect(downloadPath).toBe(null);
|
expect(downloadPath).toBe(null);
|
||||||
expect(saveError.message).toContain('Download deleted upon browser context closure.');
|
expect(saveError.message).toContain('File deleted upon browser context closure.');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should close the context without awaiting the download', (test, { browserName, platform }) => {
|
it('should close the context without awaiting the download', (test, { browserName, platform }) => {
|
||||||
|
|
@ -440,6 +440,6 @@ describe('download event', () => {
|
||||||
page.context().close(),
|
page.context().close(),
|
||||||
]);
|
]);
|
||||||
expect(downloadPath).toBe(null);
|
expect(downloadPath).toBe(null);
|
||||||
expect(saveError.message).toContain('Download deleted upon browser context closure.');
|
expect(saveError.message).toContain('File deleted upon browser context closure.');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -219,6 +219,77 @@ describe('screencast', suite => {
|
||||||
expect(fs.existsSync(path)).toBeTruthy();
|
expect(fs.existsSync(path)).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should saveAs video', 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();
|
||||||
|
await page.evaluate(() => document.body.style.backgroundColor = 'red');
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
await context.close();
|
||||||
|
|
||||||
|
const saveAsPath = testInfo.outputPath('my-video.webm');
|
||||||
|
await page.video().saveAs(saveAsPath);
|
||||||
|
expect(fs.existsSync(saveAsPath)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saveAs should throw when no video frames', async ({browser, browserName, 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.
|
||||||
|
if (browserName === 'webkit')
|
||||||
|
expect(fs.existsSync(saveAsPath)).toBeTruthy();
|
||||||
|
else
|
||||||
|
expect(error.message).toContain('Page did not produce any video frames');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete video', 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 deletePromise = page.video().delete();
|
||||||
|
await page.evaluate(() => document.body.style.backgroundColor = 'red');
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
await context.close();
|
||||||
|
|
||||||
|
const videoPath = await page.video().path();
|
||||||
|
await deletePromise;
|
||||||
|
expect(fs.existsSync(videoPath)).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
it('should expose video path blank page', async ({browser, testInfo}) => {
|
it('should expose video path blank page', async ({browser, testInfo}) => {
|
||||||
const videosPath = testInfo.outputPath('');
|
const videosPath = testInfo.outputPath('');
|
||||||
const size = { width: 320, height: 240 };
|
const size = { width: 320, height: 240 };
|
||||||
|
|
|
||||||
20
types/types.d.ts
vendored
20
types/types.d.ts
vendored
|
|
@ -9256,7 +9256,8 @@ export interface Download {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns path to the downloaded file in case of successful download. The method will wait for the download to finish if
|
* Returns path to the downloaded file in case of successful download. The method will wait for the download to finish if
|
||||||
* necessary.
|
* necessary. The method throws when connected remotely via
|
||||||
|
* [browserType.connect(params)](https://playwright.dev/docs/api/class-browsertype#browsertypeconnectparams).
|
||||||
*/
|
*/
|
||||||
path(): Promise<null|string>;
|
path(): Promise<null|string>;
|
||||||
|
|
||||||
|
|
@ -10225,7 +10226,7 @@ export interface Touchscreen {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* When browser context is created with the `videosPath` option, each page has a video object associated with it.
|
* When browser context is created with the `recordVideo` option, each page has a video object associated with it.
|
||||||
*
|
*
|
||||||
* ```js
|
* ```js
|
||||||
* console.log(await page.video().path());
|
* console.log(await page.video().path());
|
||||||
|
|
@ -10233,11 +10234,24 @@ export interface Touchscreen {
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export interface Video {
|
export interface Video {
|
||||||
|
/**
|
||||||
|
* Deletes the video file. Will wait for the video to finish if necessary.
|
||||||
|
*/
|
||||||
|
delete(): Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the file system path this video will be recorded to. The video is guaranteed to be written to the filesystem
|
* Returns the file system path this video will be recorded to. The video is guaranteed to be written to the filesystem
|
||||||
* upon closing the browser context.
|
* upon closing the browser context. This method throws when connected remotely via
|
||||||
|
* [browserType.connect(params)](https://playwright.dev/docs/api/class-browsertype#browsertypeconnectparams).
|
||||||
*/
|
*/
|
||||||
path(): Promise<string>;
|
path(): Promise<string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves the video to a user-specified path. It is safe to call this method while the video is still in progress, or after
|
||||||
|
* the page has closed. This method waits until the page is closed and the video is fully saved.
|
||||||
|
* @param path Path where the video should be saved.
|
||||||
|
*/
|
||||||
|
saveAs(path: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue