feat: routeWebSocket (#32675)

This introduces `WebSocketRoute` class and
`page/context.routeWebSocket()` methods.
This commit is contained in:
Dmitry Gozman 2024-09-20 03:20:06 -07:00 committed by GitHub
parent ace8cb2427
commit cdcaa7fab6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1970 additions and 7 deletions

View File

@ -1266,6 +1266,99 @@ When set to `minimal`, only record information necessary for routing from HAR. T
Optional setting to control resource content management. If `attach` is specified, resources are persisted as separate files or entries in the ZIP archive. If `embed` is specified, content is stored inline the HAR file.
## async method: BrowserContext.routeWebSocket
* since: v1.48
This method allows to modify websocket connections that are made by any page in the browser context.
Note that only `WebSocket`s created after this method was called will be routed. It is recommended to call this method before creating any pages.
**Usage**
Below is an example of a simple handler that blocks some websocket messages.
See [WebSocketRoute] for more details and examples.
```js
await context.routeWebSocket('/ws', async ws => {
ws.routeSend(message => {
if (message === 'to-be-blocked')
return;
ws.send(message);
});
await ws.connect();
});
```
```java
context.routeWebSocket("/ws", ws -> {
ws.routeSend(message -> {
if ("to-be-blocked".equals(message))
return;
ws.send(message);
});
ws.connect();
});
```
```python async
def message_handler(ws: WebSocketRoute, message: Union[str, bytes]):
if message == "to-be-blocked":
return
ws.send(message)
async def handler(ws: WebSocketRoute):
ws.route_send(lambda message: message_handler(ws, message))
await ws.connect()
await context.route_web_socket("/ws", handler)
```
```python sync
def message_handler(ws: WebSocketRoute, message: Union[str, bytes]):
if message == "to-be-blocked":
return
ws.send(message)
def handler(ws: WebSocketRoute):
ws.route_send(lambda message: message_handler(ws, message))
ws.connect()
context.route_web_socket("/ws", handler)
```
```csharp
await context.RouteWebSocketAsync("/ws", async ws => {
ws.RouteSend(message => {
if (message == "to-be-blocked")
return;
ws.Send(message);
});
await ws.ConnectAsync();
});
```
### param: BrowserContext.routeWebSocket.url
* since: v1.48
- `url` <[string]|[RegExp]|[function]\([URL]\):[boolean]>
Only WebSockets with the url matching this pattern will be routed. A string pattern can be relative to the [`option: baseURL`] from the context options.
### param: BrowserContext.routeWebSocket.handler
* since: v1.48
* langs: js, python
- `handler` <[function]\([WebSocketRoute]\): [Promise<any>|any]>
Handler function to route the WebSocket.
### param: BrowserContext.routeWebSocket.handler
* since: v1.48
* langs: csharp, java
- `handler` <[function]\([WebSocketRoute]\)>
Handler function to route the WebSocket.
## method: BrowserContext.serviceWorkers
* since: v1.11
* langs: js, python

View File

@ -3632,6 +3632,99 @@ When set to `minimal`, only record information necessary for routing from HAR. T
Optional setting to control resource content management. If `attach` is specified, resources are persisted as separate files or entries in the ZIP archive. If `embed` is specified, content is stored inline the HAR file.
## async method: Page.routeWebSocket
* since: v1.48
This method allows to modify websocket connections that are made by the page.
Note that only `WebSocket`s created after this method was called will be routed. It is recommended to call this method before navigating the page.
**Usage**
Below is an example of a simple handler that blocks some websocket messages.
See [WebSocketRoute] for more details and examples.
```js
await page.routeWebSocket('/ws', async ws => {
ws.routeSend(message => {
if (message === 'to-be-blocked')
return;
ws.send(message);
});
await ws.connect();
});
```
```java
page.routeWebSocket("/ws", ws -> {
ws.routeSend(message -> {
if ("to-be-blocked".equals(message))
return;
ws.send(message);
});
ws.connect();
});
```
```python async
def message_handler(ws: WebSocketRoute, message: Union[str, bytes]):
if message == "to-be-blocked":
return
ws.send(message)
async def handler(ws: WebSocketRoute):
ws.route_send(lambda message: message_handler(ws, message))
await ws.connect()
await page.route_web_socket("/ws", handler)
```
```python sync
def message_handler(ws: WebSocketRoute, message: Union[str, bytes]):
if message == "to-be-blocked":
return
ws.send(message)
def handler(ws: WebSocketRoute):
ws.route_send(lambda message: message_handler(ws, message))
ws.connect()
page.route_web_socket("/ws", handler)
```
```csharp
await page.RouteWebSocketAsync("/ws", async ws => {
ws.RouteSend(message => {
if (message == "to-be-blocked")
return;
ws.Send(message);
});
await ws.ConnectAsync();
});
```
### param: Page.routeWebSocket.url
* since: v1.48
- `url` <[string]|[RegExp]|[function]\([URL]\):[boolean]>
Only WebSockets with the url matching this pattern will be routed. A string pattern can be relative to the [`option: baseURL`] from the context options.
### param: Page.routeWebSocket.handler
* since: v1.48
* langs: js, python
- `handler` <[function]\([WebSocketRoute]\): [Promise<any>|any]>
Handler function to route the WebSocket.
### param: Page.routeWebSocket.handler
* since: v1.48
* langs: csharp, java
- `handler` <[function]\([WebSocketRoute]\)>
Handler function to route the WebSocket.
## async method: Page.screenshot
* since: v1.8
- returns: <[Buffer]>

View File

@ -0,0 +1,166 @@
# class: WebSocketRoute
* since: v1.48
Whenever a [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) route is set up with [`method: Page.routeWebSocket`] or [`method: BrowserContext.routeWebSocket`], the `WebSocketRoute` object allows to handle the WebSocket.
By default, the routed WebSocket will not actually connect to the server. This way, you can mock entire communcation over the WebSocket. Here is an example that responds to a `"query"` with a `"result"`.
```js
await page.routeWebSocket('/ws', async ws => {
ws.routeSend(message => {
if (message === 'query')
ws.receive('result');
});
});
```
```java
page.routeWebSocket("/ws", ws -> {
ws.routeSend(message -> {
if ("query".equals(message))
ws.receive("result");
});
});
```
```python async
def message_handler(ws, message):
if message == "query":
ws.receive("result")
await page.route_web_socket("/ws", lambda ws: ws.route_send(
lambda message: message_handler(ws, message)
))
```
```python sync
def message_handler(ws, message):
if message == "query":
ws.receive("result")
page.route_web_socket("/ws", lambda ws: ws.route_send(
lambda message: message_handler(ws, message)
))
```
```csharp
await page.RouteWebSocketAsync("/ws", async ws => {
ws.RouteSend(message => {
if (message == "query")
ws.receive("result");
});
});
```
## event: WebSocketRoute.close
* since: v1.48
Emitted when the [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) closes.
## async method: WebSocketRoute.close
* since: v1.48
Closes the server connection and the [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) object in the page.
### option: WebSocketRoute.close.code
* since: v1.48
- `code` <[int]>
Optional [close code](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close#code).
### option: WebSocketRoute.close.reason
* since: v1.48
- `reason` <[string]>
Optional [close reason](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close#reason).
## async method: WebSocketRoute.connect
* since: v1.48
By default, routed WebSocket does not connect to the server, so you can mock entire WebSocket communication. This method connects to the actual WebSocket server, giving the ability to send and receive messages from the server.
Once connected:
* Messages received from the server will be automatically dispatched to the [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) object in the page, unless [`method: WebSocketRoute.routeReceive`] is called.
* Messages sent by the `WebSocket.send()` call in the page will be automatically sent to the server, unless [`method: WebSocketRoute.routeSend`] is called.
## method: WebSocketRoute.receive
* since: v1.48
Dispatches a message to the [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) object in the page, like it was received from the server.
### param: WebSocketRoute.receive.message
* since: v1.48
- `message` <[string]|[Buffer]>
Message to receive.
## async method: WebSocketRoute.routeReceive
* since: v1.48
This method allows to route messages that are received by the [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) object in the page from the server. This method only makes sense if you are also calling [`method: WebSocketRoute.connect`].
Once this method is called, received messages are not automatically dispatched to the [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) object in the page - you should do that manually by calling [`method: WebSocketRoute.receive`].
Calling this method again times will override the handler with a new one.
### param: WebSocketRoute.routeReceive.handler
* since: v1.48
* langs: js, python
- `handler` <[function]\([string]\): [Promise<any>|any]>
Handler function to route received messages.
### param: WebSocketRoute.routeReceive.handler
* since: v1.48
* langs: csharp, java
- `handler` <[function]\([WebSocketFrame]\)>
Handler function to route received messages.
## async method: WebSocketRoute.routeSend
* since: v1.48
This method allows to route messages that are sent by `WebSocket.send()` call in the page, instead of actually sending them to the server. Once this method is called, sent messages **are not** automatically forwarded to the server - you should do that manually by calling [`method: WebSocketRoute.send`].
Calling this method again times will override the handler with a new one.
### param: WebSocketRoute.routeSend.handler
* since: v1.48
* langs: js, python
- `handler` <[function]\([string]|[Buffer]\): [Promise<any>|any]>
Handler function to route sent messages.
### param: WebSocketRoute.routeSend.handler
* since: v1.48
* langs: csharp, java
- `handler` <[function]\([WebSocketFrame]\)>
Handler function to route sent messages.
## method: WebSocketRoute.send
* since: v1.48
Sends a message to the server, like it was sent in the page with `WebSocket.send()`.
### param: WebSocketRoute.send.message
* since: v1.48
- `message` <[string]|[Buffer]>
Message to send.
## method: WebSocketRoute.url
* since: v1.48
- returns: <[string]>
URL of the WebSocket created in the page.

View File

@ -34,7 +34,7 @@ export { TimeoutError } from './errors';
export { Frame } from './frame';
export { Keyboard, Mouse, Touchscreen } from './input';
export { JSHandle } from './jsHandle';
export { Request, Response, Route, WebSocket } from './network';
export { Request, Response, Route, WebSocket, WebSocketRoute } from './network';
export { APIRequest, APIRequestContext, APIResponse } from './fetch';
export { Page } from './page';
export { Selectors } from './selectors';

View File

@ -48,6 +48,7 @@ import { Clock } from './clock';
export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel> implements api.BrowserContext {
_pages = new Set<Page>();
_routes: network.RouteHandler[] = [];
_webSocketRoutes: network.WebSocketRouteHandler[] = [];
readonly _browser: Browser | null = null;
_browserType: BrowserType | undefined;
readonly _bindings = new Map<string, (source: structs.BindingSource, ...args: any[]) => any>();
@ -90,6 +91,7 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
this._channel.on('close', () => this._onClose());
this._channel.on('page', ({ page }) => this._onPage(Page.from(page)));
this._channel.on('route', ({ route }) => this._onRoute(network.Route.from(route)));
this._channel.on('webSocketRoute', ({ webSocketRoute }) => this._onWebSocketRoute(network.WebSocketRoute.from(webSocketRoute)));
this._channel.on('backgroundPage', ({ page }) => {
const backgroundPage = Page.from(page);
this._backgroundPages.add(backgroundPage);
@ -221,6 +223,14 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
await route._innerContinue(true).catch(() => {});
}
async _onWebSocketRoute(webSocketRoute: network.WebSocketRoute) {
const routeHandler = this._webSocketRoutes.find(route => route.matches(webSocketRoute.url()));
if (routeHandler)
await routeHandler.handle(webSocketRoute);
else
await webSocketRoute.connect();
}
async _onBinding(bindingCall: BindingCall) {
const func = this._bindings.get(bindingCall._initializer.name);
if (!func)
@ -328,6 +338,11 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
await this._updateInterceptionPatterns();
}
async routeWebSocket(url: URLMatch, handler: network.WebSocketRouteHandlerCallback): Promise<void> {
this._webSocketRoutes.unshift(new network.WebSocketRouteHandler(this._options.baseURL, url, handler));
await this._updateWebSocketInterceptionPatterns();
}
async _recordIntoHAR(har: string, page: Page | null, options: { url?: string | RegExp, notFound?: 'abort' | 'fallback', update?: boolean, updateContent?: 'attach' | 'embed', updateMode?: 'minimal' | 'full'} = {}): Promise<void> {
const { harId } = await this._channel.harStart({
page: page?._channel,
@ -387,6 +402,11 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
await this._channel.setNetworkInterceptionPatterns({ patterns });
}
private async _updateWebSocketInterceptionPatterns() {
const patterns = network.WebSocketRouteHandler.prepareInterceptionPatterns(this._webSocketRoutes);
await this._channel.setWebSocketInterceptionPatterns({ patterns });
}
_effectiveCloseReason(): string | undefined {
return this._closeReason || this._browser?._closeReason;
}

View File

@ -21,7 +21,7 @@ import { ChannelOwner } from './channelOwner';
import { ElementHandle } from './elementHandle';
import { Frame } from './frame';
import { JSHandle } from './jsHandle';
import { Request, Response, Route, WebSocket } from './network';
import { Request, Response, Route, WebSocket, WebSocketRoute } from './network';
import { Page, BindingCall } from './page';
import { Worker } from './worker';
import { Dialog } from './dialog';
@ -309,6 +309,9 @@ export class Connection extends EventEmitter {
case 'WebSocket':
result = new WebSocket(parent, type, guid, initializer);
break;
case 'WebSocketRoute':
result = new WebSocketRoute(parent, type, guid, initializer);
break;
case 'Worker':
result = new Worker(parent, type, guid, initializer);
break;

View File

@ -85,6 +85,10 @@ export const Events = {
FrameSent: 'framesent',
},
WebSocketRoute: {
Close: 'close',
},
Worker: {
Close: 'close',
},

View File

@ -451,7 +451,131 @@ export class Route extends ChannelOwner<channels.RouteChannel> implements api.Ro
}
}
export class WebSocketRoute extends ChannelOwner<channels.WebSocketRouteChannel> implements api.WebSocketRoute {
static from(route: channels.WebSocketRouteChannel): WebSocketRoute {
return (route as any)._object;
}
private _routeSendHandler?: (message: string | Buffer) => any;
private _routeReceiveHandler?: (message: string | Buffer) => any;
private _connected = false;
constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.WebSocketRouteInitializer) {
super(parent, type, guid, initializer);
this._channel.on('messageFromPage', ({ message, isBase64 }) => {
if (this._routeSendHandler)
this._routeSendHandler(isBase64 ? Buffer.from(message, 'base64') : message);
else
this._channel.sendToServer({ message, isBase64 }).catch(() => {});
});
this._channel.on('messageFromServer', ({ message, isBase64 }) => {
if (this._routeReceiveHandler)
this._routeReceiveHandler(isBase64 ? Buffer.from(message, 'base64') : message);
else
this._channel.sendToPage({ message, isBase64 }).catch(() => {});
});
this._channel.on('close', () => this.emit(Events.WebSocketRoute.Close));
}
url() {
return this._initializer.url;
}
async close(options: { code?: number, reason?: string } = {}) {
try {
await this._channel.close(options);
} catch (e) {
if (isTargetClosedError(e))
return;
throw e;
}
}
async connect() {
this._connected = true;
await this._channel.connect();
}
send(message: string | Buffer) {
if (isString(message))
this._channel.sendToServer({ message, isBase64: false }).catch(() => {});
else
this._channel.sendToServer({ message: message.toString('base64'), isBase64: true }).catch(() => {});
}
receive(message: string | Buffer) {
if (isString(message))
this._channel.sendToPage({ message, isBase64: false }).catch(() => {});
else
this._channel.sendToPage({ message: message.toString('base64'), isBase64: true }).catch(() => {});
}
routeSend(handler: (message: string | Buffer) => any) {
this._routeSendHandler = handler;
}
routeReceive(handler: (message: string | Buffer) => any) {
this._routeReceiveHandler = handler;
}
async [Symbol.asyncDispose]() {
await this.close();
}
async _afterHandle() {
if (this._connected)
return;
if (this._routeReceiveHandler)
throw new Error(`WebSocketRoute.routeReceive() call had no effect. Make sure to call WebSocketRoute.connect() as well.`);
// Ensure that websocket is "open", so that test can send messages to it
// without an actual server connection.
await this._channel.ensureOpened();
}
}
export class WebSocketRouteHandler {
private readonly _baseURL: string | undefined;
readonly url: URLMatch;
readonly handler: WebSocketRouteHandlerCallback;
constructor(baseURL: string | undefined, url: URLMatch, handler: WebSocketRouteHandlerCallback) {
this._baseURL = baseURL;
this.url = url;
this.handler = handler;
}
static prepareInterceptionPatterns(handlers: WebSocketRouteHandler[]) {
const patterns: channels.BrowserContextSetWebSocketInterceptionPatternsParams['patterns'] = [];
let all = false;
for (const handler of handlers) {
if (isString(handler.url))
patterns.push({ glob: handler.url });
else if (isRegExp(handler.url))
patterns.push({ regexSource: handler.url.source, regexFlags: handler.url.flags });
else
all = true;
}
if (all)
return [{ glob: '**/*' }];
return patterns;
}
public matches(wsURL: string): boolean {
return urlMatches(this._baseURL, wsURL, this.url);
}
public async handle(webSocketRoute: WebSocketRoute) {
const handler = this.handler;
await handler(webSocketRoute);
await webSocketRoute._afterHandle();
}
}
export type RouteHandlerCallback = (route: Route, request: Request) => Promise<any> | void;
export type WebSocketRouteHandlerCallback = (ws: WebSocketRoute) => Promise<any> | void;
export type ResourceTiming = {
startTime: number;

View File

@ -40,7 +40,7 @@ import { Keyboard, Mouse, Touchscreen } from './input';
import { assertMaxArguments, JSHandle, parseResult, serializeArgument } from './jsHandle';
import type { FrameLocator, Locator, LocatorOptions } from './locator';
import type { ByRoleOptions } from '../utils/isomorphic/locatorUtils';
import { type RouteHandlerCallback, type Request, Response, Route, RouteHandler, validateHeaders, WebSocket } from './network';
import { type RouteHandlerCallback, type Request, Response, Route, RouteHandler, validateHeaders, WebSocket, type WebSocketRouteHandlerCallback, WebSocketRoute, WebSocketRouteHandler } from './network';
import type { FilePayload, Headers, LifecycleEvent, SelectOption, SelectOptionOptions, Size, WaitForEventOptions, WaitForFunctionOptions } from './types';
import { Video } from './video';
import { Waiter } from './waiter';
@ -78,6 +78,7 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
readonly _closedOrCrashedScope = new LongStandingScope();
private _viewportSize: Size | null;
_routes: RouteHandler[] = [];
_webSocketRoutes: WebSocketRouteHandler[] = [];
readonly accessibility: Accessibility;
readonly coverage: Coverage;
@ -137,6 +138,7 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
this._channel.on('frameDetached', ({ frame }) => this._onFrameDetached(Frame.from(frame)));
this._channel.on('locatorHandlerTriggered', ({ uid }) => this._onLocatorHandlerTriggered(uid));
this._channel.on('route', ({ route }) => this._onRoute(Route.from(route)));
this._channel.on('webSocketRoute', ({ webSocketRoute }) => this._onWebSocketRoute(WebSocketRoute.from(webSocketRoute)));
this._channel.on('video', ({ artifact }) => {
const artifactObject = Artifact.from(artifact);
this._forceVideo()._artifactReady(artifactObject);
@ -200,6 +202,14 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
await this._browserContext._onRoute(route);
}
private async _onWebSocketRoute(webSocketRoute: WebSocketRoute) {
const routeHandler = this._webSocketRoutes.find(route => route.matches(webSocketRoute.url()));
if (routeHandler)
await routeHandler.handle(webSocketRoute);
else
await this._browserContext._onWebSocketRoute(webSocketRoute);
}
async _onBinding(bindingCall: BindingCall) {
const func = this._bindings.get(bindingCall._initializer.name);
if (func) {
@ -515,6 +525,11 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
await harRouter.addPageRoute(this);
}
async routeWebSocket(url: URLMatch, handler: WebSocketRouteHandlerCallback): Promise<void> {
this._webSocketRoutes.unshift(new WebSocketRouteHandler(this._browserContext._options.baseURL, url, handler));
await this._updateWebSocketInterceptionPatterns();
}
private _disposeHarRouters() {
this._harRouters.forEach(router => router.dispose());
this._harRouters = [];
@ -551,6 +566,11 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
await this._channel.setNetworkInterceptionPatterns({ patterns });
}
private async _updateWebSocketInterceptionPatterns() {
const patterns = WebSocketRouteHandler.prepareInterceptionPatterns(this._webSocketRoutes);
await this._channel.setWebSocketInterceptionPatterns({ patterns });
}
async screenshot(options: Omit<channels.PageScreenshotOptions, 'mask'> & { path?: string, mask?: Locator[] } = {}): Promise<Buffer> {
const copy: channels.PageScreenshotOptions = { ...options, mask: undefined };
if (!copy.type)

View File

@ -832,6 +832,9 @@ scheme.BrowserContextPageErrorEvent = tObject({
scheme.BrowserContextRouteEvent = tObject({
route: tChannel(['Route']),
});
scheme.BrowserContextWebSocketRouteEvent = tObject({
webSocketRoute: tChannel(['WebSocketRoute']),
});
scheme.BrowserContextVideoEvent = tObject({
artifact: tChannel(['Artifact']),
});
@ -943,6 +946,14 @@ scheme.BrowserContextSetNetworkInterceptionPatternsParams = tObject({
})),
});
scheme.BrowserContextSetNetworkInterceptionPatternsResult = tOptional(tObject({}));
scheme.BrowserContextSetWebSocketInterceptionPatternsParams = tObject({
patterns: tArray(tObject({
glob: tOptional(tString),
regexSource: tOptional(tString),
regexFlags: tOptional(tString),
})),
});
scheme.BrowserContextSetWebSocketInterceptionPatternsResult = tOptional(tObject({}));
scheme.BrowserContextSetOfflineParams = tObject({
offline: tBoolean,
});
@ -1070,6 +1081,9 @@ scheme.PageLocatorHandlerTriggeredEvent = tObject({
scheme.PageRouteEvent = tObject({
route: tChannel(['Route']),
});
scheme.PageWebSocketRouteEvent = tObject({
webSocketRoute: tChannel(['WebSocketRoute']),
});
scheme.PageVideoEvent = tObject({
artifact: tChannel(['Artifact']),
});
@ -1211,6 +1225,14 @@ scheme.PageSetNetworkInterceptionPatternsParams = tObject({
})),
});
scheme.PageSetNetworkInterceptionPatternsResult = tOptional(tObject({}));
scheme.PageSetWebSocketInterceptionPatternsParams = tObject({
patterns: tArray(tObject({
glob: tOptional(tString),
regexSource: tOptional(tString),
regexFlags: tOptional(tString),
})),
});
scheme.PageSetWebSocketInterceptionPatternsResult = tOptional(tObject({}));
scheme.PageSetViewportSizeParams = tObject({
viewportSize: tObject({
width: tNumber,
@ -2114,6 +2136,37 @@ scheme.RouteFulfillParams = tObject({
requestUrl: tString,
});
scheme.RouteFulfillResult = tOptional(tObject({}));
scheme.WebSocketRouteInitializer = tObject({
url: tString,
});
scheme.WebSocketRouteMessageFromPageEvent = tObject({
message: tString,
isBase64: tBoolean,
});
scheme.WebSocketRouteMessageFromServerEvent = tObject({
message: tString,
isBase64: tBoolean,
});
scheme.WebSocketRouteCloseEvent = tOptional(tObject({}));
scheme.WebSocketRouteConnectParams = tOptional(tObject({}));
scheme.WebSocketRouteConnectResult = tOptional(tObject({}));
scheme.WebSocketRouteEnsureOpenedParams = tOptional(tObject({}));
scheme.WebSocketRouteEnsureOpenedResult = tOptional(tObject({}));
scheme.WebSocketRouteSendToPageParams = tObject({
message: tString,
isBase64: tBoolean,
});
scheme.WebSocketRouteSendToPageResult = tOptional(tObject({}));
scheme.WebSocketRouteSendToServerParams = tObject({
message: tString,
isBase64: tBoolean,
});
scheme.WebSocketRouteSendToServerResult = tOptional(tObject({}));
scheme.WebSocketRouteCloseParams = tObject({
code: tOptional(tNumber),
reason: tOptional(tString),
});
scheme.WebSocketRouteCloseResult = tOptional(tObject({}));
scheme.ResourceTiming = tObject({
startTime: tNumber,
domainLookupStart: tNumber,

View File

@ -1,5 +1,6 @@
[*]
../../common/
../../generated/
../../protocol/
../../utils/
../../zipBundle.ts

View File

@ -42,12 +42,14 @@ import { ElementHandleDispatcher } from './elementHandlerDispatcher';
import { RecorderInTraceViewer } from '../recorder/recorderInTraceViewer';
import { RecorderApp } from '../recorder/recorderApp';
import type { IRecorderAppFactory } from '../recorder/recorderFrontend';
import { WebSocketRouteDispatcher } from './webSocketRouteDispatcher';
export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channels.BrowserContextChannel, DispatcherScope> implements channels.BrowserContextChannel {
_type_EventTarget = true;
_type_BrowserContext = true;
private _context: BrowserContext;
private _subscriptions = new Set<channels.BrowserContextUpdateSubscriptionParams['event']>();
_webSocketInterceptionPatterns: channels.BrowserContextSetWebSocketInterceptionPatternsParams['patterns'] = [];
constructor(parentScope: DispatcherScope, context: BrowserContext) {
// We will reparent these to the context below.
@ -284,6 +286,12 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
});
}
async setWebSocketInterceptionPatterns(params: channels.PageSetWebSocketInterceptionPatternsParams, metadata: CallMetadata): Promise<void> {
this._webSocketInterceptionPatterns = params.patterns;
if (params.patterns.length)
await WebSocketRouteDispatcher.installIfNeeded(this, this._context);
}
async storageState(params: channels.BrowserContextStorageStateParams, metadata: CallMetadata): Promise<channels.BrowserContextStorageStateResult> {
return await this._context.storageState();
}

View File

@ -35,12 +35,14 @@ import { ArtifactDispatcher } from './artifactDispatcher';
import type { Download } from '../download';
import { createGuid, urlMatches } from '../../utils';
import type { BrowserContextDispatcher } from './browserContextDispatcher';
import { WebSocketRouteDispatcher } from './webSocketRouteDispatcher';
export class PageDispatcher extends Dispatcher<Page, channels.PageChannel, BrowserContextDispatcher> implements channels.PageChannel {
_type_EventTarget = true;
_type_Page = true;
private _page: Page;
_subscriptions = new Set<channels.PageUpdateSubscriptionParams['event']>();
_webSocketInterceptionPatterns: channels.PageSetWebSocketInterceptionPatternsParams['patterns'] = [];
static from(parentScope: BrowserContextDispatcher, page: Page): PageDispatcher {
return PageDispatcher.fromNullable(parentScope, page)!;
@ -186,6 +188,12 @@ export class PageDispatcher extends Dispatcher<Page, channels.PageChannel, Brows
});
}
async setWebSocketInterceptionPatterns(params: channels.PageSetWebSocketInterceptionPatternsParams, metadata: CallMetadata): Promise<void> {
this._webSocketInterceptionPatterns = params.patterns;
if (params.patterns.length)
await WebSocketRouteDispatcher.installIfNeeded(this.parentScope(), this._page);
}
async expectScreenshot(params: channels.PageExpectScreenshotParams, metadata: CallMetadata): Promise<channels.PageExpectScreenshotResult> {
const mask: { frame: Frame, selector: string }[] = (params.mask || []).map(({ frame, selector }) => ({
frame: (frame as FrameDispatcher)._object,

View File

@ -0,0 +1,150 @@
/**
* 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 type { BrowserContext } from '../browserContext';
import type { Frame } from '../frames';
import { Page } from '../page';
import type * as channels from '@protocol/channels';
import { Dispatcher } from './dispatcher';
import { createGuid, urlMatches } from '../../utils';
import { PageDispatcher } from './pageDispatcher';
import type { BrowserContextDispatcher } from './browserContextDispatcher';
import * as webSocketMockSource from '../../generated/webSocketMockSource';
import type * as ws from '../injected/webSocketMock';
import { eventsHelper } from '../../utils/eventsHelper';
const kBindingInstalledSymbol = Symbol('webSocketRouteBindingInstalled');
const kInitScriptInstalledSymbol = Symbol('webSocketRouteInitScriptInstalled');
export class WebSocketRouteDispatcher extends Dispatcher<{ guid: string }, channels.WebSocketRouteChannel, PageDispatcher | BrowserContextDispatcher> implements channels.WebSocketRouteChannel {
_type_WebSocketRoute = true;
private _id: string;
private _frame: Frame;
private static _idToDispatcher = new Map<string, WebSocketRouteDispatcher>();
constructor(scope: PageDispatcher | BrowserContextDispatcher, id: string, url: string, frame: Frame) {
super(scope, { guid: 'webSocketRoute@' + createGuid() }, 'WebSocketRoute', { url });
this._id = id;
this._frame = frame;
this._eventListeners.push(
// When the frame navigates or detaches, there will be no more communication
// from the mock websocket, so pretend like it was closed.
eventsHelper.addEventListener(frame._page, Page.Events.InternalFrameNavigatedToNewDocument, (frame: Frame) => {
if (frame === this._frame)
this._onClose();
}),
eventsHelper.addEventListener(frame._page, Page.Events.FrameDetached, (frame: Frame) => {
if (frame === this._frame)
this._onClose();
}),
eventsHelper.addEventListener(frame._page, Page.Events.Close, () => this._onClose()),
eventsHelper.addEventListener(frame._page, Page.Events.Crash, () => this._onClose()),
);
WebSocketRouteDispatcher._idToDispatcher.set(this._id, this);
(scope as any)._dispatchEvent('webSocketRoute', { webSocketRoute: this });
}
static async installIfNeeded(contextDispatcher: BrowserContextDispatcher, target: Page | BrowserContext) {
const context = target instanceof Page ? target.context() : target;
if (!(context as any)[kBindingInstalledSymbol]) {
(context as any)[kBindingInstalledSymbol] = true;
await context.exposeBinding('__pwWebSocketBinding', false, (source, payload: ws.BindingPayload) => {
if (payload.type === 'onCreate') {
const pageDispatcher = PageDispatcher.fromNullable(contextDispatcher, source.page);
let scope: PageDispatcher | BrowserContextDispatcher | undefined;
if (pageDispatcher && matchesPattern(pageDispatcher, context._options.baseURL, payload.url))
scope = pageDispatcher;
else if (matchesPattern(contextDispatcher, context._options.baseURL, payload.url))
scope = contextDispatcher;
if (scope) {
new WebSocketRouteDispatcher(scope, payload.id, payload.url, source.frame);
} else {
const request: ws.PassthroughRequest = { id: payload.id, type: 'passthrough' };
source.frame.evaluateExpression(`globalThis.__pwWebSocketDispatch(${JSON.stringify(request)})`).catch(() => {});
}
return;
}
const dispatcher = WebSocketRouteDispatcher._idToDispatcher.get(payload.id);
if (payload.type === 'onMessageFromPage')
dispatcher?._dispatchEvent('messageFromPage', { message: payload.data.data, isBase64: payload.data.isBase64 });
if (payload.type === 'onMessageFromServer')
dispatcher?._dispatchEvent('messageFromServer', { message: payload.data.data, isBase64: payload.data.isBase64 });
if (payload.type === 'onClose')
dispatcher?._onClose();
});
}
if (!(target as any)[kInitScriptInstalledSymbol]) {
(target as any)[kInitScriptInstalledSymbol] = true;
await target.addInitScript(`
(() => {
const module = {};
${webSocketMockSource.source}
(module.exports.inject())(globalThis);
})();
`);
}
}
async connect(params: channels.WebSocketRouteConnectParams) {
await this._evaluateAPIRequest({ id: this._id, type: 'connect' });
}
async ensureOpened(params: channels.WebSocketRouteEnsureOpenedParams) {
await this._evaluateAPIRequest({ id: this._id, type: 'ensureOpened' });
}
async sendToPage(params: channels.WebSocketRouteSendToPageParams) {
await this._evaluateAPIRequest({ id: this._id, type: 'sendToPage', data: { data: params.message, isBase64: params.isBase64 } });
}
async sendToServer(params: channels.WebSocketRouteSendToServerParams) {
await this._evaluateAPIRequest({ id: this._id, type: 'sendToServer', data: { data: params.message, isBase64: params.isBase64 } });
}
async close(params: channels.WebSocketRouteCloseParams) {
await this._evaluateAPIRequest({ id: this._id, type: 'close', code: params.code, reason: params.reason, wasClean: true });
}
private async _evaluateAPIRequest(request: ws.APIRequest) {
await this._frame.evaluateExpression(`globalThis.__pwWebSocketDispatch(${JSON.stringify(request)})`).catch(() => {});
}
override _onDispose() {
WebSocketRouteDispatcher._idToDispatcher.delete(this._id);
}
_onClose() {
// We could enter here twice upon page closure:
// - first from the recursive dispose inintiated by PageDispatcher;
// - then from our own page.on('close') listener.
if (this._disposed)
return;
this._dispatchEvent('close');
this._dispose();
}
}
function matchesPattern(dispatcher: PageDispatcher | BrowserContextDispatcher, baseURL: string | undefined, url: string) {
for (const pattern of dispatcher._webSocketInterceptionPatterns || []) {
const urlMatch = pattern.regexSource ? new RegExp(pattern.regexSource, pattern.regexFlags) : pattern.glob;
if (urlMatches(baseURL, url, urlMatch))
return true;
}
return false;
}

View File

@ -0,0 +1,343 @@
/**
* 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.
*/
export type WebSocketMessage = string | ArrayBufferLike | Blob | ArrayBufferView;
export type WSData = { data: string, isBase64: boolean };
export type OnCreatePayload = { type: 'onCreate', id: string, url: string };
export type OnMessageFromPagePayload = { type: 'onMessageFromPage', id: string, data: WSData };
export type OnClosePayload = { type: 'onClose', id: string, code: number | undefined, reason: string | undefined, wasClean: boolean };
export type OnMessageFromServerPayload = { type: 'onMessageFromServer', id: string, data: WSData };
export type BindingPayload = OnCreatePayload | OnMessageFromPagePayload | OnMessageFromServerPayload | OnClosePayload;
export type ConnectRequest = { type: 'connect', id: string };
export type PassthroughRequest = { type: 'passthrough', id: string };
export type EnsureOpenedRequest = { type: 'ensureOpened', id: string };
export type SendToPageRequest = { type: 'sendToPage', id: string, data: WSData };
export type SendToServerRequest = { type: 'sendToServer', id: string, data: WSData };
export type CloseRequest = { type: 'close', id: string, code: number | undefined, reason: string | undefined, wasClean: boolean };
export type APIRequest = ConnectRequest | PassthroughRequest | EnsureOpenedRequest | SendToPageRequest | SendToServerRequest | CloseRequest;
// eslint-disable-next-line no-restricted-globals
type GlobalThis = typeof globalThis;
export function inject(globalThis: GlobalThis) {
if ((globalThis as any).__pwWebSocketDispatch)
return;
function generateId() {
const bytes = new Uint8Array(32);
globalThis.crypto.getRandomValues(bytes);
const hex = '0123456789abcdef';
return [...bytes].map(value => {
const high = Math.floor(value / 16);
const low = value % 16;
return hex[high] + hex[low];
}).join('');
}
function bufferToData(b: Uint8Array): WSData {
let s = '';
for (let i = 0; i < b.length; i++)
s += String.fromCharCode(b[i]);
return { data: globalThis.btoa(s), isBase64: true };
}
function stringToBuffer(s: string): ArrayBuffer {
s = globalThis.atob(s);
const b = new Uint8Array(s.length);
for (let i = 0; i < s.length; i++)
b[i] = s.charCodeAt(i);
return b.buffer;
}
// Note: this function tries to be synchronous when it can to preserve the ability to send
// multiple messages synchronously in the same order and then synchronously close.
function messageToData(message: WebSocketMessage, cb: (data: WSData) => any) {
if (message instanceof globalThis.Blob)
return message.arrayBuffer().then(buffer => cb(bufferToData(new Uint8Array(buffer))));
if (typeof message === 'string')
return cb({ data: message, isBase64: false });
if (ArrayBuffer.isView(message))
return cb(bufferToData(new Uint8Array(message.buffer, message.byteOffset, message.byteLength)));
return cb(bufferToData(new Uint8Array(message)));
}
function dataToMessage(data: WSData, binaryType: 'blob' | 'arraybuffer'): WebSocketMessage {
if (!data.isBase64)
return data.data;
const buffer = stringToBuffer(data.data);
return binaryType === 'arraybuffer' ? buffer : new Blob([buffer]);
}
const binding = (globalThis as any).__pwWebSocketBinding as (message: BindingPayload) => void;
const NativeWebSocket: typeof WebSocket = globalThis.WebSocket;
const idToWebSocket = new Map<string, WebSocketMock>();
(globalThis as any).__pwWebSocketDispatch = (request: APIRequest) => {
const ws = idToWebSocket.get(request.id);
if (!ws)
return;
if (request.type === 'connect')
ws._apiConnect();
if (request.type === 'passthrough')
ws._apiPassThrough();
if (request.type === 'ensureOpened')
ws._apiEnsureOpened();
if (request.type === 'sendToPage')
ws._apiSendToPage(dataToMessage(request.data, ws.binaryType));
if (request.type === 'close')
ws._apiClose(request.code, request.reason, request.wasClean);
if (request.type === 'sendToServer')
ws._apiSendToServer(dataToMessage(request.data, ws.binaryType));
};
class WebSocketMock extends EventTarget {
static readonly CONNECTING: 0 = 0; // WebSocket.CONNECTING
static readonly OPEN: 1 = 1; // WebSocket.OPEN
static readonly CLOSING: 2 = 2; // WebSocket.CLOSING
static readonly CLOSED: 3 = 3; // WebSocket.CLOSED
CONNECTING: 0 = 0; // WebSocket.CONNECTING
OPEN: 1 = 1; // WebSocket.OPEN
CLOSING: 2 = 2; // WebSocket.CLOSING
CLOSED: 3 = 3; // WebSocket.CLOSED
private _oncloseListener: WebSocket['onclose'] = null;
private _onerrorListener: WebSocket['onerror'] = null;
private _onmessageListener: WebSocket['onmessage'] = null;
private _onopenListener: WebSocket['onopen'] = null;
bufferedAmount: number = 0;
extensions: string = '';
protocol: string = '';
readyState: number = 0;
readonly url: string;
private _id: string;
private _origin: string = '';
private _protocols?: string | string[];
private _ws?: WebSocket;
private _passthrough = false;
private _wsBufferedMessages: WebSocketMessage[] = [];
private _binaryType: BinaryType = 'blob';
constructor(url: string | URL, protocols?: string | string[]) {
super();
this.url = typeof url === 'string' ? url : url.href;
try {
this._origin = new URL(url).origin;
} catch {
}
this._protocols = protocols;
this._id = generateId();
idToWebSocket.set(this._id, this);
binding({ type: 'onCreate', id: this._id, url: this.url });
}
// --- native WebSocket implementation ---
get binaryType() {
return this._binaryType;
}
set binaryType(type) {
this._binaryType = type;
if (this._ws)
this._ws.binaryType = type;
}
get onclose() {
return this._oncloseListener;
}
set onclose(listener) {
if (this._oncloseListener)
this.removeEventListener('close', this._oncloseListener as any);
this._oncloseListener = listener;
if (this._oncloseListener)
this.addEventListener('close', this._oncloseListener as any);
}
get onerror() {
return this._onerrorListener;
}
set onerror(listener) {
if (this._onerrorListener)
this.removeEventListener('error', this._onerrorListener);
this._onerrorListener = listener;
if (this._onerrorListener)
this.addEventListener('error', this._onerrorListener);
}
get onopen() {
return this._onopenListener;
}
set onopen(listener) {
if (this._onopenListener)
this.removeEventListener('open', this._onopenListener);
this._onopenListener = listener;
if (this._onopenListener)
this.addEventListener('open', this._onopenListener);
}
get onmessage() {
return this._onmessageListener;
}
set onmessage(listener) {
if (this._onmessageListener)
this.removeEventListener('message', this._onmessageListener as any);
this._onmessageListener = listener;
if (this._onmessageListener)
this.addEventListener('message', this._onmessageListener as any);
}
send(message: WebSocketMessage): void {
if (this.readyState === WebSocketMock.CONNECTING)
throw new DOMException(`Failed to execute 'send' on 'WebSocket': Still in CONNECTING state.`);
if (this.readyState !== WebSocketMock.OPEN)
throw new DOMException(`WebSocket is already in CLOSING or CLOSED state.`);
if (this._passthrough)
this._apiSendToServer(message);
else
messageToData(message, data => binding({ type: 'onMessageFromPage', id: this._id, data }));
}
close(code?: number, reason?: string): void {
if (code !== undefined && code !== 1000 && (code < 3000 || code > 4999))
throw new DOMException(`Failed to execute 'close' on 'WebSocket': The close code must be either 1000, or between 3000 and 4999. ${code} is neither.`);
if (this.readyState === WebSocketMock.OPEN || this.readyState === WebSocketMock.CONNECTING)
this.readyState = WebSocketMock.CLOSING;
if (this._ws)
this._ws.close(code, reason);
else
this._onWSClose(code, reason, true);
}
// --- methods called from the routing API ---
_apiEnsureOpened() {
// This is called at the end of the route handler. If we did not connect to the server,
// assume that websocket will be fully mocked. In this case, pretend that server
// connection is established right away.
if (!this._ws)
this._ensureOpened();
}
_apiSendToPage(message: WebSocketMessage) {
// Calling "sendToPage()" from the route handler. Allow this for easier testing.
this._ensureOpened();
if (this.readyState !== WebSocketMock.OPEN)
throw new DOMException(`WebSocket is already in CLOSING or CLOSED state.`);
this.dispatchEvent(new MessageEvent('message', { data: message, origin: this._origin, cancelable: true }));
}
_apiSendToServer(message: WebSocketMessage) {
if (!this._ws)
throw new Error('Cannot send a message before connecting to the server');
if (this._ws.readyState === WebSocketMock.CONNECTING)
this._wsBufferedMessages.push(message);
else
this._ws.send(message);
}
_apiConnect() {
if (this._ws)
throw new Error('Can only connect to the server once');
this._ws = new NativeWebSocket(this.url, this._protocols);
this._ws.binaryType = this._binaryType;
this._ws.onopen = () => {
for (const message of this._wsBufferedMessages)
this._ws!.send(message);
this._wsBufferedMessages = [];
this._ensureOpened();
};
this._ws.onclose = event => {
this._onWSClose(event.code, event.reason, event.wasClean);
};
this._ws.onmessage = event => {
if (this._passthrough)
this._apiSendToPage(event.data);
else
messageToData(event.data, data => binding({ type: 'onMessageFromServer', id: this._id, data }));
};
this._ws.onerror = () => {
// We do not expose errors in the API, so short-curcuit the error event.
const event = new Event('error', { cancelable: true });
this.dispatchEvent(event);
};
}
// This method connects to the server, and passes all messages through,
// as if WebSocketMock was not engaged.
_apiPassThrough() {
this._passthrough = true;
this._apiConnect();
}
_apiClose(code: number | undefined, reason: string | undefined, wasClean: boolean) {
if (this.readyState !== WebSocketMock.CLOSED) {
this.readyState = WebSocketMock.CLOSED;
this.dispatchEvent(new CloseEvent('close', { code, reason, wasClean, cancelable: true }));
}
// Immediately close the real WS and imitate that it has closed.
this._ws?.close(code, reason);
this._cleanupWS();
binding({ type: 'onClose', id: this._id, code, reason, wasClean });
idToWebSocket.delete(this._id);
}
// --- internals ---
_ensureOpened() {
if (this.readyState !== WebSocketMock.CONNECTING)
return;
this.readyState = WebSocketMock.OPEN;
this.dispatchEvent(new Event('open', { cancelable: true }));
}
private _onWSClose(code: number | undefined, reason: string | undefined, wasClean: boolean) {
this._cleanupWS();
if (this.readyState !== WebSocketMock.CLOSED) {
this.readyState = WebSocketMock.CLOSED;
this.dispatchEvent(new CloseEvent('close', { code, reason, wasClean, cancelable: true }));
}
binding({ type: 'onClose', id: this._id, code, reason, wasClean });
idToWebSocket.delete(this._id);
}
private _cleanupWS() {
if (!this._ws)
return;
this._ws.onopen = null;
this._ws.onclose = null;
this._ws.onmessage = null;
this._ws.onerror = null;
this._ws = undefined;
this._wsBufferedMessages = [];
}
}
globalThis.WebSocket = class WebSocket extends WebSocketMock {};
}

View File

@ -3826,6 +3826,34 @@ export interface Page {
url?: string|RegExp;
}): Promise<void>;
/**
* This method allows to modify websocket connections that are made by the page.
*
* Note that only `WebSocket`s created after this method was called will be routed. It is recommended to call this
* method before navigating the page.
*
* **Usage**
*
* Below is an example of a simple handler that blocks some websocket messages. See {@link WebSocketRoute} for more
* details and examples.
*
* ```js
* await page.routeWebSocket('/ws', async ws => {
* ws.routeSend(message => {
* if (message === 'to-be-blocked')
* return;
* ws.send(message);
* });
* await ws.connect();
* });
* ```
*
* @param url Only WebSockets with the url matching this pattern will be routed. A string pattern can be relative to the
* `baseURL` from the context options.
* @param handler Handler function to route the WebSocket.
*/
routeWebSocket(url: string|RegExp|((url: URL) => boolean), handler: ((websocketroute: WebSocketRoute) => Promise<any>|any)): Promise<void>;
/**
* Returns the buffer with the captured screenshot.
* @param options
@ -8658,6 +8686,34 @@ export interface BrowserContext {
url?: string|RegExp;
}): Promise<void>;
/**
* This method allows to modify websocket connections that are made by any page in the browser context.
*
* Note that only `WebSocket`s created after this method was called will be routed. It is recommended to call this
* method before creating any pages.
*
* **Usage**
*
* Below is an example of a simple handler that blocks some websocket messages. See {@link WebSocketRoute} for more
* details and examples.
*
* ```js
* await context.routeWebSocket('/ws', async ws => {
* ws.routeSend(message => {
* if (message === 'to-be-blocked')
* return;
* ws.send(message);
* });
* await ws.connect();
* });
* ```
*
* @param url Only WebSockets with the url matching this pattern will be routed. A string pattern can be relative to the
* `baseURL` from the context options.
* @param handler Handler function to route the WebSocket.
*/
routeWebSocket(url: string|RegExp|((url: URL) => boolean), handler: ((websocketroute: WebSocketRoute) => Promise<any>|any)): Promise<void>;
/**
* **NOTE** Service workers are only supported on Chromium-based browsers.
*
@ -14567,6 +14623,134 @@ export interface CDPSession {
detach(): Promise<void>;
}
/**
* Whenever a [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) route is set up with
* [page.routeWebSocket(url, handler)](https://playwright.dev/docs/api/class-page#page-route-web-socket) or
* [browserContext.routeWebSocket(url, handler)](https://playwright.dev/docs/api/class-browsercontext#browser-context-route-web-socket),
* the `WebSocketRoute` object allows to handle the WebSocket.
*
* By default, the routed WebSocket will not actually connect to the server. This way, you can mock entire
* communcation over the WebSocket. Here is an example that responds to a `"query"` with a `"result"`.
*
* ```js
* await page.routeWebSocket('/ws', async ws => {
* ws.routeSend(message => {
* if (message === 'query')
* ws.receive('result');
* });
* });
* ```
*
*/
export interface WebSocketRoute {
/**
* This method allows to route messages that are sent by `WebSocket.send()` call in the page, instead of actually
* sending them to the server. Once this method is called, sent messages **are not** automatically forwarded to the
* server - you should do that manually by calling
* [webSocketRoute.send(message)](https://playwright.dev/docs/api/class-websocketroute#web-socket-route-send).
*
* Calling this method again times will override the handler with a new one.
* @param handler Handler function to route sent messages.
*/
routeSend(handler: (message: string | Buffer) => any): void;
/**
* This method allows to route messages that are received by the
* [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) object in the page from the server. This
* method only makes sense if you are also calling
* [webSocketRoute.connect()](https://playwright.dev/docs/api/class-websocketroute#web-socket-route-connect).
*
* Once this method is called, received messages are not automatically dispatched to the
* [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) object in the page - you should do that
* manually by calling
* [webSocketRoute.receive(message)](https://playwright.dev/docs/api/class-websocketroute#web-socket-route-receive).
*
* Calling this method again times will override the handler with a new one.
* @param handler Handler function to route received messages.
*/
routeReceive(handler: (message: string | Buffer) => any): void;
/**
* Emitted when the [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) closes.
*/
on(event: 'close', listener: () => any): this;
/**
* Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event.
*/
once(event: 'close', listener: () => any): this;
/**
* Emitted when the [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) closes.
*/
addListener(event: 'close', listener: () => any): this;
/**
* Removes an event listener added by `on` or `addListener`.
*/
removeListener(event: 'close', listener: () => any): this;
/**
* Removes an event listener added by `on` or `addListener`.
*/
off(event: 'close', listener: () => any): this;
/**
* Emitted when the [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) closes.
*/
prependListener(event: 'close', listener: () => any): this;
/**
* Closes the server connection and the [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket)
* object in the page.
* @param options
*/
close(options?: {
/**
* Optional [close code](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close#code).
*/
code?: number;
/**
* Optional [close reason](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close#reason).
*/
reason?: string;
}): Promise<void>;
/**
* By default, routed WebSocket does not connect to the server, so you can mock entire WebSocket communication. This
* method connects to the actual WebSocket server, giving the ability to send and receive messages from the server.
*
* Once connected:
* - Messages received from the server will be automatically dispatched to the
* [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) object in the page, unless
* [webSocketRoute.routeReceive(handler)](https://playwright.dev/docs/api/class-websocketroute#web-socket-route-route-receive)
* is called.
* - Messages sent by the `WebSocket.send()` call in the page will be automatically sent to the server, unless
* [webSocketRoute.routeSend(handler)](https://playwright.dev/docs/api/class-websocketroute#web-socket-route-route-send)
* is called.
*/
connect(): Promise<void>;
/**
* Dispatches a message to the [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) object in the
* page, like it was received from the server.
* @param message Message to receive.
*/
receive(message: string|Buffer): void;
/**
* Sends a message to the server, like it was sent in the page with `WebSocket.send()`.
* @param message Message to send.
*/
send(message: string|Buffer): void;
/**
* URL of the WebSocket created in the page.
*/
url(): string;
[Symbol.asyncDispose](): Promise<void>;
}
type DeviceDescriptor = {
viewport: ViewportSize;
userAgent: string;

View File

@ -40,6 +40,7 @@ export type InitializerTraits<T> =
T extends BindingCallChannel ? BindingCallInitializer :
T extends WebSocketChannel ? WebSocketInitializer :
T extends ResponseChannel ? ResponseInitializer :
T extends WebSocketRouteChannel ? WebSocketRouteInitializer :
T extends RouteChannel ? RouteInitializer :
T extends RequestChannel ? RequestInitializer :
T extends ElementHandleChannel ? ElementHandleInitializer :
@ -77,6 +78,7 @@ export type EventsTraits<T> =
T extends BindingCallChannel ? BindingCallEvents :
T extends WebSocketChannel ? WebSocketEvents :
T extends ResponseChannel ? ResponseEvents :
T extends WebSocketRouteChannel ? WebSocketRouteEvents :
T extends RouteChannel ? RouteEvents :
T extends RequestChannel ? RequestEvents :
T extends ElementHandleChannel ? ElementHandleEvents :
@ -114,6 +116,7 @@ export type EventTargetTraits<T> =
T extends BindingCallChannel ? BindingCallEventTarget :
T extends WebSocketChannel ? WebSocketEventTarget :
T extends ResponseChannel ? ResponseEventTarget :
T extends WebSocketRouteChannel ? WebSocketRouteEventTarget :
T extends RouteChannel ? RouteEventTarget :
T extends RequestChannel ? RequestEventTarget :
T extends ElementHandleChannel ? ElementHandleEventTarget :
@ -1493,6 +1496,7 @@ export interface BrowserContextEventTarget {
on(event: 'page', callback: (params: BrowserContextPageEvent) => void): this;
on(event: 'pageError', callback: (params: BrowserContextPageErrorEvent) => void): this;
on(event: 'route', callback: (params: BrowserContextRouteEvent) => void): this;
on(event: 'webSocketRoute', callback: (params: BrowserContextWebSocketRouteEvent) => void): this;
on(event: 'video', callback: (params: BrowserContextVideoEvent) => void): this;
on(event: 'backgroundPage', callback: (params: BrowserContextBackgroundPageEvent) => void): this;
on(event: 'serviceWorker', callback: (params: BrowserContextServiceWorkerEvent) => void): this;
@ -1518,6 +1522,7 @@ export interface BrowserContextChannel extends BrowserContextEventTarget, EventT
setGeolocation(params: BrowserContextSetGeolocationParams, metadata?: CallMetadata): Promise<BrowserContextSetGeolocationResult>;
setHTTPCredentials(params: BrowserContextSetHTTPCredentialsParams, metadata?: CallMetadata): Promise<BrowserContextSetHTTPCredentialsResult>;
setNetworkInterceptionPatterns(params: BrowserContextSetNetworkInterceptionPatternsParams, metadata?: CallMetadata): Promise<BrowserContextSetNetworkInterceptionPatternsResult>;
setWebSocketInterceptionPatterns(params: BrowserContextSetWebSocketInterceptionPatternsParams, metadata?: CallMetadata): Promise<BrowserContextSetWebSocketInterceptionPatternsResult>;
setOffline(params: BrowserContextSetOfflineParams, metadata?: CallMetadata): Promise<BrowserContextSetOfflineResult>;
storageState(params?: BrowserContextStorageStateParams, metadata?: CallMetadata): Promise<BrowserContextStorageStateResult>;
pause(params?: BrowserContextPauseParams, metadata?: CallMetadata): Promise<BrowserContextPauseResult>;
@ -1563,6 +1568,9 @@ export type BrowserContextPageErrorEvent = {
export type BrowserContextRouteEvent = {
route: RouteChannel,
};
export type BrowserContextWebSocketRouteEvent = {
webSocketRoute: WebSocketRouteChannel,
};
export type BrowserContextVideoEvent = {
artifact: ArtifactChannel,
};
@ -1731,6 +1739,17 @@ export type BrowserContextSetNetworkInterceptionPatternsOptions = {
};
export type BrowserContextSetNetworkInterceptionPatternsResult = void;
export type BrowserContextSetWebSocketInterceptionPatternsParams = {
patterns: {
glob?: string,
regexSource?: string,
regexFlags?: string,
}[],
};
export type BrowserContextSetWebSocketInterceptionPatternsOptions = {
};
export type BrowserContextSetWebSocketInterceptionPatternsResult = void;
export type BrowserContextSetOfflineParams = {
offline: boolean,
};
@ -1890,6 +1909,7 @@ export interface BrowserContextEvents {
'page': BrowserContextPageEvent;
'pageError': BrowserContextPageErrorEvent;
'route': BrowserContextRouteEvent;
'webSocketRoute': BrowserContextWebSocketRouteEvent;
'video': BrowserContextVideoEvent;
'backgroundPage': BrowserContextBackgroundPageEvent;
'serviceWorker': BrowserContextServiceWorkerEvent;
@ -1919,6 +1939,7 @@ export interface PageEventTarget {
on(event: 'frameDetached', callback: (params: PageFrameDetachedEvent) => void): this;
on(event: 'locatorHandlerTriggered', callback: (params: PageLocatorHandlerTriggeredEvent) => void): this;
on(event: 'route', callback: (params: PageRouteEvent) => void): this;
on(event: 'webSocketRoute', callback: (params: PageWebSocketRouteEvent) => void): this;
on(event: 'video', callback: (params: PageVideoEvent) => void): this;
on(event: 'webSocket', callback: (params: PageWebSocketEvent) => void): this;
on(event: 'worker', callback: (params: PageWorkerEvent) => void): this;
@ -1942,6 +1963,7 @@ export interface PageChannel extends PageEventTarget, EventTargetChannel {
screenshot(params: PageScreenshotParams, metadata?: CallMetadata): Promise<PageScreenshotResult>;
setExtraHTTPHeaders(params: PageSetExtraHTTPHeadersParams, metadata?: CallMetadata): Promise<PageSetExtraHTTPHeadersResult>;
setNetworkInterceptionPatterns(params: PageSetNetworkInterceptionPatternsParams, metadata?: CallMetadata): Promise<PageSetNetworkInterceptionPatternsResult>;
setWebSocketInterceptionPatterns(params: PageSetWebSocketInterceptionPatternsParams, metadata?: CallMetadata): Promise<PageSetWebSocketInterceptionPatternsResult>;
setViewportSize(params: PageSetViewportSizeParams, metadata?: CallMetadata): Promise<PageSetViewportSizeResult>;
keyboardDown(params: PageKeyboardDownParams, metadata?: CallMetadata): Promise<PageKeyboardDownResult>;
keyboardUp(params: PageKeyboardUpParams, metadata?: CallMetadata): Promise<PageKeyboardUpResult>;
@ -1989,6 +2011,9 @@ export type PageLocatorHandlerTriggeredEvent = {
export type PageRouteEvent = {
route: RouteChannel,
};
export type PageWebSocketRouteEvent = {
webSocketRoute: WebSocketRouteChannel,
};
export type PageVideoEvent = {
artifact: ArtifactChannel,
};
@ -2221,6 +2246,17 @@ export type PageSetNetworkInterceptionPatternsOptions = {
};
export type PageSetNetworkInterceptionPatternsResult = void;
export type PageSetWebSocketInterceptionPatternsParams = {
patterns: {
glob?: string,
regexSource?: string,
regexFlags?: string,
}[],
};
export type PageSetWebSocketInterceptionPatternsOptions = {
};
export type PageSetWebSocketInterceptionPatternsResult = void;
export type PageSetViewportSizeParams = {
viewportSize: {
width: number,
@ -2448,6 +2484,7 @@ export interface PageEvents {
'frameDetached': PageFrameDetachedEvent;
'locatorHandlerTriggered': PageLocatorHandlerTriggeredEvent;
'route': PageRouteEvent;
'webSocketRoute': PageWebSocketRouteEvent;
'video': PageVideoEvent;
'webSocket': PageWebSocketEvent;
'worker': PageWorkerEvent;
@ -3773,6 +3810,70 @@ export type RouteFulfillResult = void;
export interface RouteEvents {
}
// ----------- WebSocketRoute -----------
export type WebSocketRouteInitializer = {
url: string,
};
export interface WebSocketRouteEventTarget {
on(event: 'messageFromPage', callback: (params: WebSocketRouteMessageFromPageEvent) => void): this;
on(event: 'messageFromServer', callback: (params: WebSocketRouteMessageFromServerEvent) => void): this;
on(event: 'close', callback: (params: WebSocketRouteCloseEvent) => void): this;
}
export interface WebSocketRouteChannel extends WebSocketRouteEventTarget, Channel {
_type_WebSocketRoute: boolean;
connect(params?: WebSocketRouteConnectParams, metadata?: CallMetadata): Promise<WebSocketRouteConnectResult>;
ensureOpened(params?: WebSocketRouteEnsureOpenedParams, metadata?: CallMetadata): Promise<WebSocketRouteEnsureOpenedResult>;
sendToPage(params: WebSocketRouteSendToPageParams, metadata?: CallMetadata): Promise<WebSocketRouteSendToPageResult>;
sendToServer(params: WebSocketRouteSendToServerParams, metadata?: CallMetadata): Promise<WebSocketRouteSendToServerResult>;
close(params: WebSocketRouteCloseParams, metadata?: CallMetadata): Promise<WebSocketRouteCloseResult>;
}
export type WebSocketRouteMessageFromPageEvent = {
message: string,
isBase64: boolean,
};
export type WebSocketRouteMessageFromServerEvent = {
message: string,
isBase64: boolean,
};
export type WebSocketRouteCloseEvent = {};
export type WebSocketRouteConnectParams = {};
export type WebSocketRouteConnectOptions = {};
export type WebSocketRouteConnectResult = void;
export type WebSocketRouteEnsureOpenedParams = {};
export type WebSocketRouteEnsureOpenedOptions = {};
export type WebSocketRouteEnsureOpenedResult = void;
export type WebSocketRouteSendToPageParams = {
message: string,
isBase64: boolean,
};
export type WebSocketRouteSendToPageOptions = {
};
export type WebSocketRouteSendToPageResult = void;
export type WebSocketRouteSendToServerParams = {
message: string,
isBase64: boolean,
};
export type WebSocketRouteSendToServerOptions = {
};
export type WebSocketRouteSendToServerResult = void;
export type WebSocketRouteCloseParams = {
code?: number,
reason?: string,
};
export type WebSocketRouteCloseOptions = {
code?: number,
reason?: string,
};
export type WebSocketRouteCloseResult = void;
export interface WebSocketRouteEvents {
'messageFromPage': WebSocketRouteMessageFromPageEvent;
'messageFromServer': WebSocketRouteMessageFromServerEvent;
'close': WebSocketRouteCloseEvent;
}
export type ResourceTiming = {
startTime: number,
domainLookupStart: number,

View File

@ -1160,6 +1160,17 @@ BrowserContext:
regexSource: string?
regexFlags: string?
setWebSocketInterceptionPatterns:
parameters:
patterns:
type: array
items:
type: object
properties:
glob: string?
regexSource: string?
regexFlags: string?
setOffline:
parameters:
offline: boolean
@ -1305,6 +1316,10 @@ BrowserContext:
parameters:
route: Route
webSocketRoute:
parameters:
webSocketRoute: WebSocketRoute
video:
parameters:
artifact: Artifact
@ -1520,6 +1535,17 @@ Page:
regexSource: string?
regexFlags: string?
setWebSocketInterceptionPatterns:
parameters:
patterns:
type: array
items:
type: object
properties:
glob: string?
regexSource: string?
regexFlags: string?
setViewportSize:
parameters:
viewportSize:
@ -1771,6 +1797,10 @@ Page:
parameters:
route: Route
webSocketRoute:
parameters:
webSocketRoute: WebSocketRoute
video:
parameters:
artifact: Artifact
@ -2936,6 +2966,49 @@ Route:
fetchResponseUid: string?
requestUrl: string
WebSocketRoute:
type: interface
initializer:
url: string
commands:
connect:
ensureOpened:
sendToPage:
parameters:
message: string
isBase64: boolean
sendToServer:
parameters:
message: string
isBase64: boolean
close:
parameters:
code: number?
reason: string?
events:
messageFromPage:
parameters:
message: string
isBase64: boolean
messageFromServer:
parameters:
message: string
isBase64: boolean
close:
ResourceTiming:
type: object
properties:

View File

@ -22,6 +22,7 @@ import type net from 'net';
import path from 'path';
import url from 'url';
import util from 'util';
import type stream from 'stream';
import ws from 'ws';
import zlib, { gzip } from 'zlib';
import { createHttpServer, createHttpsServer } from '../../../packages/playwright-core/lib/utils/network';
@ -31,6 +32,11 @@ const rejectSymbol = Symbol('reject callback');
const gzipAsync = util.promisify(gzip.bind(zlib));
type UpgradeActions = {
doUpgrade: () => void;
socket: stream.Duplex;
};
export class TestServer {
private _server: http.Server;
private _wsServer: ws.WebSocketServer;
@ -44,6 +50,7 @@ export class TestServer {
private _extraHeaders = new Map<string, object>();
private _gzipRoutes = new Set<string>();
private _requestSubscribers = new Map<string, Promise<any>>();
private _upgradeCallback: (actions: UpgradeActions) => void | undefined;
readonly PORT: number;
readonly PREFIX: string;
readonly CROSS_PROCESS_PREFIX: string;
@ -73,6 +80,16 @@ export class TestServer {
this._server.on('connection', socket => this._onSocket(socket));
this._wsServer = new ws.WebSocketServer({ noServer: true });
this._server.on('upgrade', async (request, socket, head) => {
const doUpgrade = () => {
this._wsServer.handleUpgrade(request, socket, head, ws => {
// Next emit is only for our internal 'connection' listeners.
this._wsServer.emit('connection', ws, request);
});
};
if (this._upgradeCallback) {
this._upgradeCallback({ doUpgrade, socket });
return;
}
const pathname = url.parse(request.url!).path;
if (pathname === '/ws-401') {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\nUnauthorized body');
@ -86,10 +103,7 @@ export class TestServer {
socket.destroy();
return;
}
this._wsServer.handleUpgrade(request, socket, head, ws => {
// Next emit is only for our internal 'connection' listeners.
this._wsServer.emit('connection', ws, request);
});
doUpgrade();
});
this._server.listen(port);
this._dirPath = dirPath;
@ -177,6 +191,8 @@ export class TestServer {
this._csp.clear();
this._extraHeaders.clear();
this._gzipRoutes.clear();
this._upgradeCallback = undefined;
this._wsServer.removeAllListeners('connection');
this._server.closeAllConnections();
const error = new Error('Static Server has been reset');
for (const subscriber of this._requestSubscribers.values())
@ -294,6 +310,14 @@ export class TestServer {
});
}
waitForUpgrade() {
return new Promise<UpgradeActions>(fulfill => this._upgradeCallback = fulfill);
}
waitForWebSocket() {
return new Promise<ws.WebSocket>(fulfill => this._wsServer.once('connection', (ws, req) => fulfill(ws)));
}
sendOnWebSocketConnection(data) {
this.onceWebSocketConnection(ws => ws.send(data));
}

View File

@ -0,0 +1,484 @@
/**
* 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 { attachFrame, detachFrame } from '../config/utils';
import { contextTest as test, expect } from '../config/browserTest';
import type { Frame, Page, WebSocketRoute } from '@playwright/test';
declare global {
interface Window {
ws: WebSocket;
wsOpened: Promise<void>;
log: string[];
}
}
// Polyfill for Promise.withResolvers, not available in older Node.
function withResolvers<T = void>() {
let resolve: (value: T) => void;
const promise = new Promise<T>(f => resolve = f);
return { promise, resolve };
}
async function setupWS(target: Page | Frame, port: number, binaryType: 'blob' | 'arraybuffer') {
await target.goto('about:blank');
await target.evaluate(({ port, binaryType }) => {
window.log = [];
window.ws = new WebSocket('ws://localhost:' + port + '/ws');
window.ws.binaryType = binaryType;
window.ws.addEventListener('open', () => window.log.push('open'));
window.ws.addEventListener('close', event => window.log.push(`close code=${event.code} reason=${event.reason} wasClean=${event.wasClean}`));
window.ws.addEventListener('error', event => window.log.push(`error`));
window.ws.addEventListener('message', async event => {
let data;
if (typeof event.data === 'string')
data = event.data;
else if (event.data instanceof Blob)
data = 'blob:' + await event.data.text();
else
data = 'arraybuffer:' + await (new Blob([event.data])).text();
window.log.push(`message: data=${data} origin=${event.origin} lastEventId=${event.lastEventId}`);
});
window.wsOpened = new Promise(f => window.ws.addEventListener('open', () => f()));
}, { port, binaryType });
}
for (const mock of ['no-mock', 'no-match', 'pass-through']) {
test.describe(mock, async () => {
test.beforeEach(async ({ page }) => {
if (mock === 'no-match') {
await page.routeWebSocket(/zzz/, () => {});
} else if (mock === 'pass-through') {
await page.routeWebSocket(/.*/, async ws => {
ws.routeSend(message => ws.send(message));
ws.routeReceive(message => ws.receive(message));
await ws.connect();
});
}
});
test('should work with text message', async ({ page, server }) => {
const wsPromise = server.waitForWebSocket();
const upgradePromise = server.waitForUpgrade();
await setupWS(page, server.PORT, 'blob');
expect(await page.evaluate(() => window.ws.readyState)).toBe(0);
const { doUpgrade } = await upgradePromise;
expect(await page.evaluate(() => window.ws.readyState)).toBe(0);
expect(await page.evaluate(() => window.log)).toEqual([]);
doUpgrade();
await expect.poll(() => page.evaluate(() => window.ws.readyState)).toBe(1);
expect(await page.evaluate(() => window.log)).toEqual(['open']);
const ws = await wsPromise;
ws.send('hello');
await expect.poll(() => page.evaluate(() => window.log)).toEqual([
'open',
`message: data=hello origin=ws://localhost:${server.PORT} lastEventId=`,
]);
expect(await page.evaluate(() => window.ws.readyState)).toBe(1);
const messagePromise = new Promise(f => ws.once('message', data => f(data.toString())));
await page.evaluate(() => window.ws.send('hi'));
expect(await messagePromise).toBe('hi');
ws.close(1008, 'oops');
await expect.poll(() => page.evaluate(() => window.ws.readyState)).toBe(3);
expect(await page.evaluate(() => window.log)).toEqual([
'open',
`message: data=hello origin=ws://localhost:${server.PORT} lastEventId=`,
'close code=1008 reason=oops wasClean=true',
]);
});
test('should work with binaryType=blob', async ({ page, server }) => {
const wsPromise = server.waitForWebSocket();
await setupWS(page, server.PORT, 'blob');
const ws = await wsPromise;
ws.send(Buffer.from('hi'));
await expect.poll(() => page.evaluate(() => window.log)).toEqual([
'open',
`message: data=blob:hi origin=ws://localhost:${server.PORT} lastEventId=`,
]);
const messagePromise = new Promise(f => ws.once('message', data => f(data.toString())));
await page.evaluate(() => window.ws.send(new Blob([new Uint8Array(['h'.charCodeAt(0), 'i'.charCodeAt(0)])])));
expect(await messagePromise).toBe('hi');
});
test('should work with binaryType=arraybuffer', async ({ page, server }) => {
const wsPromise = server.waitForWebSocket();
await setupWS(page, server.PORT, 'arraybuffer');
const ws = await wsPromise;
ws.send(Buffer.from('hi'));
await expect.poll(() => page.evaluate(() => window.log)).toEqual([
'open',
`message: data=arraybuffer:hi origin=ws://localhost:${server.PORT} lastEventId=`,
]);
const messagePromise = new Promise(f => ws.once('message', data => f(data.toString())));
await page.evaluate(() => window.ws.send(new Uint8Array(['h'.charCodeAt(0), 'i'.charCodeAt(0)]).buffer));
expect(await messagePromise).toBe('hi');
});
test('should work when connection errors out', async ({ page, server, browserName }) => {
test.skip(browserName === 'webkit', 'WebKit ignores the connection error and fires no events!');
const upgradePromise = server.waitForUpgrade();
await setupWS(page, server.PORT, 'blob');
const { socket } = await upgradePromise;
expect(await page.evaluate(() => window.ws.readyState)).toBe(0);
expect(await page.evaluate(() => window.log)).toEqual([]);
socket.destroy();
await expect.poll(() => page.evaluate(() => window.ws.readyState)).toBe(3);
expect(await page.evaluate(() => window.log)).toEqual([
'error',
expect.stringMatching(/close code=\d+ reason= wasClean=false/),
]);
});
test('should work with error after successful open', async ({ page, server, browserName, isLinux }) => {
test.skip(browserName === 'firefox', 'Firefox does not close the websocket upon a bad frame');
test.skip(browserName === 'webkit' && isLinux, 'WebKit linux does not close the websocket upon a bad frame');
const upgradePromise = server.waitForUpgrade();
await setupWS(page, server.PORT, 'blob');
const { socket, doUpgrade } = await upgradePromise;
doUpgrade();
await expect.poll(() => page.evaluate(() => window.ws.readyState)).toBe(1);
expect(await page.evaluate(() => window.log)).toEqual(['open']);
socket.write('garbage');
await expect.poll(() => page.evaluate(() => window.ws.readyState)).toBe(3);
expect(await page.evaluate(() => window.log)).toEqual([
'open',
'error',
expect.stringMatching(/close code=\d+ reason= wasClean=false/),
]);
});
test('should work with client-side close', async ({ page, server }) => {
const wsPromise = server.waitForWebSocket();
const upgradePromise = server.waitForUpgrade();
await setupWS(page, server.PORT, 'blob');
expect(await page.evaluate(() => window.ws.readyState)).toBe(0);
const { doUpgrade } = await upgradePromise;
expect(await page.evaluate(() => window.ws.readyState)).toBe(0);
expect(await page.evaluate(() => window.log)).toEqual([]);
doUpgrade();
await expect.poll(() => page.evaluate(() => window.ws.readyState)).toBe(1);
expect(await page.evaluate(() => window.log)).toEqual(['open']);
const ws = await wsPromise;
const closedPromise = new Promise<{ code: number, reason: Buffer }>(f => ws.once('close', (code, reason) => f({ code, reason })));
const readyState = await page.evaluate(() => {
window.ws.close(3002, 'oops');
return window.ws.readyState;
});
expect(readyState).toBe(2);
await expect.poll(() => page.evaluate(() => window.ws.readyState)).toBe(3);
expect(await page.evaluate(() => window.log)).toEqual([
'open',
'close code=3002 reason=oops wasClean=true',
]);
const closed = await closedPromise;
expect(closed.code).toBe(3002);
expect(closed.reason.toString()).toBe('oops');
});
});
}
test('should work with ws.close', async ({ page, server }) => {
const { promise, resolve } = withResolvers<WebSocketRoute>();
await page.routeWebSocket(/.*/, async route => {
await route.connect();
resolve(route);
});
const wsPromise = server.waitForWebSocket();
await setupWS(page, server.PORT, 'blob');
const ws = await wsPromise;
const route = await promise;
route.receive('hello');
await expect.poll(() => page.evaluate(() => window.log)).toEqual([
'open',
`message: data=hello origin=ws://localhost:${server.PORT} lastEventId=`,
]);
const closedPromise = new Promise(f => ws.once('close', (code, reason) => f({ code, reason: reason.toString() })));
await route.close({ code: 3009, reason: 'oops' });
await expect.poll(() => page.evaluate(() => window.log)).toEqual([
'open',
`message: data=hello origin=ws://localhost:${server.PORT} lastEventId=`,
'close code=3009 reason=oops wasClean=true',
]);
expect(await closedPromise).toEqual({ code: 3009, reason: 'oops' });
});
test('should pattern match', async ({ page, server }) => {
await page.routeWebSocket(/.*\/ws$/, async ws => {
await ws.connect();
});
await page.routeWebSocket('**/mock-ws', ws => {
ws.routeSend(message => {
ws.receive('mock-response');
});
});
const wsPromise = server.waitForWebSocket();
await page.goto('about:blank');
await page.evaluate(async ({ port }) => {
window.log = [];
(window as any).ws1 = new WebSocket('ws://localhost:' + port + '/ws');
(window as any).ws1.addEventListener('message', event => window.log.push(`ws1:${event.data}`));
(window as any).ws2 = new WebSocket('ws://localhost:' + port + '/something/something/mock-ws');
(window as any).ws2.addEventListener('message', event => window.log.push(`ws2:${event.data}`));
await Promise.all([
new Promise(f => (window as any).ws1.addEventListener('open', f)),
new Promise(f => (window as any).ws2.addEventListener('open', f)),
]);
}, { port: server.PORT });
const ws = await wsPromise;
ws.on('message', () => ws.send('response'));
await page.evaluate(() => (window as any).ws1.send('request'));
await expect.poll(() => page.evaluate(() => window.log)).toEqual([`ws1:response`]);
await page.evaluate(() => (window as any).ws2.send('request'));
await expect.poll(() => page.evaluate(() => window.log)).toEqual([`ws1:response`, `ws2:mock-response`]);
});
test('should work with server', async ({ page, server }) => {
const { promise, resolve } = withResolvers<WebSocketRoute>();
await page.routeWebSocket(/.*/, async route => {
route.routeSend(message => {
switch (message) {
case 'to-respond':
route.receive('response');
return;
case 'to-block':
return;
case 'to-modify':
route.send('modified');
return;
}
route.send(message);
});
route.routeReceive(message => {
switch (message) {
case 'to-block':
return;
case 'to-modify':
route.receive('modified');
return;
}
route.receive(message);
});
await route.connect();
route.send('fake');
resolve(route);
});
const wsPromise = server.waitForWebSocket();
const log: string[] = [];
server.onceWebSocketConnection(ws => {
ws.on('message', data => log.push(`message: ${data.toString()}`));
ws.on('close', (code, reason) => log.push(`close: code=${code} reason=${reason.toString()}`));
});
await setupWS(page, server.PORT, 'blob');
const ws = await wsPromise;
await expect.poll(() => log).toEqual(['message: fake']);
ws.send('to-modify');
ws.send('to-block');
ws.send('pass-server');
await expect.poll(() => page.evaluate(() => window.log)).toEqual([
'open',
`message: data=modified origin=ws://localhost:${server.PORT} lastEventId=`,
`message: data=pass-server origin=ws://localhost:${server.PORT} lastEventId=`,
]);
await page.evaluate(() => {
window.ws.send('to-respond');
window.ws.send('to-modify');
window.ws.send('to-block');
window.ws.send('pass-client');
});
await expect.poll(() => log).toEqual(['message: fake', 'message: modified', 'message: pass-client']);
await expect.poll(() => page.evaluate(() => window.log)).toEqual([
'open',
`message: data=modified origin=ws://localhost:${server.PORT} lastEventId=`,
`message: data=pass-server origin=ws://localhost:${server.PORT} lastEventId=`,
`message: data=response origin=ws://localhost:${server.PORT} lastEventId=`,
]);
const route = await promise;
route.receive('another');
await expect.poll(() => page.evaluate(() => window.log)).toEqual([
'open',
`message: data=modified origin=ws://localhost:${server.PORT} lastEventId=`,
`message: data=pass-server origin=ws://localhost:${server.PORT} lastEventId=`,
`message: data=response origin=ws://localhost:${server.PORT} lastEventId=`,
`message: data=another origin=ws://localhost:${server.PORT} lastEventId=`,
]);
await page.evaluate(() => {
window.ws.send('pass-client-2');
});
await expect.poll(() => log).toEqual(['message: fake', 'message: modified', 'message: pass-client', 'message: pass-client-2']);
await page.evaluate(() => {
window.ws.close(3009, 'problem');
});
await expect.poll(() => log).toEqual(['message: fake', 'message: modified', 'message: pass-client', 'message: pass-client-2', 'close: code=3009 reason=problem']);
});
test('should work without server', async ({ page, server }) => {
const { promise, resolve } = withResolvers<WebSocketRoute>();
await page.routeWebSocket(/.*/, route => {
route.routeSend(message => {
switch (message) {
case 'to-respond':
route.receive('response');
return;
}
});
resolve(route);
});
await setupWS(page, server.PORT, 'blob');
await page.evaluate(async () => {
await window.wsOpened;
window.ws.send('to-respond');
window.ws.send('to-block');
window.ws.send('to-respond');
});
await expect.poll(() => page.evaluate(() => window.log)).toEqual([
'open',
`message: data=response origin=ws://localhost:${server.PORT} lastEventId=`,
`message: data=response origin=ws://localhost:${server.PORT} lastEventId=`,
]);
const route = await promise;
route.receive('another');
await route.close({ code: 3008, reason: 'oops' });
await expect.poll(() => page.evaluate(() => window.log)).toEqual([
'open',
`message: data=response origin=ws://localhost:${server.PORT} lastEventId=`,
`message: data=response origin=ws://localhost:${server.PORT} lastEventId=`,
`message: data=another origin=ws://localhost:${server.PORT} lastEventId=`,
'close code=3008 reason=oops wasClean=true',
]);
});
test('should emit close upon frame navigation', async ({ page, server }) => {
const { promise, resolve } = withResolvers<WebSocketRoute>();
await page.routeWebSocket(/.*/, async route => {
await route.connect();
resolve(route);
});
await setupWS(page, server.PORT, 'blob');
const route = await promise;
route.receive('hello');
await expect.poll(() => page.evaluate(() => window.log)).toEqual([
'open',
`message: data=hello origin=ws://localhost:${server.PORT} lastEventId=`,
]);
const closedPromise = new Promise<void>(f => route.addListener('close', f));
await page.goto(server.EMPTY_PAGE);
await closedPromise;
});
test('should emit close upon frame detach', async ({ page, server }) => {
const { promise, resolve } = withResolvers<WebSocketRoute>();
await page.routeWebSocket(/.*/, async route => {
await route.connect();
resolve(route);
});
const frame = await attachFrame(page, 'frame1', server.EMPTY_PAGE);
await setupWS(frame, server.PORT, 'blob');
const route = await promise;
route.receive('hello');
await expect.poll(() => frame.evaluate(() => window.log)).toEqual([
'open',
`message: data=hello origin=ws://localhost:${server.PORT} lastEventId=`,
]);
const closedPromise = new Promise<void>(f => route.addListener('close', f));
await detachFrame(page, 'frame1');
await closedPromise;
});
test('should route on context', async ({ page, server }) => {
await page.routeWebSocket(/ws1/, ws => {
ws.routeSend(message => {
ws.receive('page-mock-1');
});
});
await page.routeWebSocket(/ws1/, ws => {
ws.routeSend(message => {
ws.receive('page-mock-2');
});
});
await page.context().routeWebSocket(/.*/, ws => {
ws.routeSend(message => {
ws.receive('context-mock-1');
});
ws.routeSend(message => {
ws.receive('context-mock-2');
});
});
await page.goto('about:blank');
await page.evaluate(({ port }) => {
window.log = [];
(window as any).ws1 = new WebSocket('ws://localhost:' + port + '/ws1');
(window as any).ws1.addEventListener('message', event => window.log.push(`ws1:${event.data}`));
(window as any).ws2 = new WebSocket('ws://localhost:' + port + '/ws2');
(window as any).ws2.addEventListener('message', event => window.log.push(`ws2:${event.data}`));
}, { port: server.PORT });
await page.evaluate(() => (window as any).ws1.send('request'));
await expect.poll(() => page.evaluate(() => window.log)).toEqual([`ws1:page-mock-2`]);
await page.evaluate(() => (window as any).ws2.send('request'));
await expect.poll(() => page.evaluate(() => window.log)).toEqual([`ws1:page-mock-2`, `ws2:context-mock-2`]);
});
test('should not throw after page closure', async ({ page, server }) => {
const { promise, resolve } = withResolvers<WebSocketRoute>();
await page.routeWebSocket(/.*/, async route => {
await route.connect();
resolve(route);
});
await setupWS(page, server.PORT, 'blob');
const route = await promise;
await Promise.all([
page.close(),
route.receive('hello'),
]);
});

View File

@ -56,6 +56,12 @@ const injectedScripts = [
path.join(ROOT, 'packages', 'playwright-core', 'src', 'generated'),
true,
],
[
path.join(ROOT, 'packages', 'playwright-core', 'src', 'server', 'injected', 'webSocketMock.ts'),
path.join(ROOT, 'packages', 'playwright-core', 'lib', 'server', 'injected', 'packed'),
path.join(ROOT, 'packages', 'playwright-core', 'src', 'generated'),
true,
],
[
path.join(ROOT, 'packages', 'playwright-ct-core', 'src', 'injected', 'index.ts'),
path.join(ROOT, 'packages', 'playwright-ct-core', 'lib', 'injected', 'packed'),

View File

@ -225,6 +225,11 @@ export interface CDPSession {
): Promise<Protocol.CommandReturnValues[T]>;
}
export interface WebSocketRoute {
routeSend(handler: (message: string | Buffer) => any): void;
routeReceive(handler: (message: string | Buffer) => any): void;
}
type DeviceDescriptor = {
viewport: ViewportSize;
userAgent: string;