Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions docs/src/api/class-websocketroute.md
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,65 @@ Message to send.



## method: WebSocketRoute.protocols
* since: v1.60
- returns: <[Array]<[string]>>

The list of WebSocket subprotocols requested by the page, as passed via the second argument to the [`WebSocket` constructor](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/WebSocket). Corresponds to the `Sec-WebSocket-Protocol` request header.

Returns an empty array if no protocols were specified.

**Usage**

```js
await page.routeWebSocket('wss://example.com/ws', ws => {
if (ws.protocols().includes('chat.v2'))
ws.onMessage(message => ws.send(JSON.stringify({ version: 2, echo: message })));
else
ws.close({ code: 1002, reason: 'Unsupported protocol' });
});
```

```java
page.routeWebSocket("wss://example.com/ws", ws -> {
if (ws.protocols().contains("chat.v2")) {
ws.onMessage(frame -> ws.send("v2:" + frame.text()));
} else {
ws.close(1002, "Unsupported protocol");
}
});
```

```python async
async def handler(ws: WebSocketRoute):
if "chat.v2" in ws.protocols:
ws.on_message(lambda message: ws.send(f"v2:{message}"))
else:
await ws.close(code=1002, reason="Unsupported protocol")

await page.route_web_socket("wss://example.com/ws", handler)
```

```python sync
def handler(ws: WebSocketRoute):
if "chat.v2" in ws.protocols:
ws.on_message(lambda message: ws.send(f"v2:{message}"))
else:
ws.close(code=1002, reason="Unsupported protocol")

page.route_web_socket("wss://example.com/ws", handler)
```

```csharp
await page.RouteWebSocketAsync("wss://example.com/ws", ws => {
if (ws.Protocols.Contains("chat.v2"))
ws.OnMessage(frame => ws.Send($"v2:{frame.Text}"));
else
ws.CloseAsync(new() { Code = 1002, Reason = "Unsupported protocol" });
});
```


## method: WebSocketRoute.url
* since: v1.48
- returns: <[string]>
Expand Down
5 changes: 3 additions & 2 deletions packages/injected/src/webSocketMock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
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 OnCreatePayload = { type: 'onCreate', id: string, url: string, protocols: string[] };
export type OnMessageFromPagePayload = { type: 'onMessageFromPage', id: string, data: WSData };
export type OnClosePagePayload = { type: 'onClosePage', id: string, code: number | undefined, reason: string | undefined, wasClean: boolean };
export type OnMessageFromServerPayload = { type: 'onMessageFromServer', id: string, data: WSData };
Expand Down Expand Up @@ -147,7 +147,8 @@ export function inject(globalThis: GlobalThis) {

this._id = generateId();
idToWebSocket.set(this._id, this);
binding({ type: 'onCreate', id: this._id, url: this.url });
const protocolsList = Array.isArray(protocols) ? [...protocols] : (protocols ? [protocols] : []);
binding({ type: 'onCreate', id: this._id, url: this.url, protocols: protocolsList });
}

// --- native WebSocket implementation ---
Expand Down
21 changes: 21 additions & 0 deletions packages/playwright-client/types/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16538,6 +16538,27 @@ export interface WebSocketRoute {
*/
connectToServer(): WebSocketRoute;

/**
* The list of WebSocket subprotocols requested by the page, as passed via the second argument to the
* [`WebSocket` constructor](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/WebSocket). Corresponds to the
* `Sec-WebSocket-Protocol` request header.
*
* Returns an empty array if no protocols were specified.
*
* **Usage**
*
* ```js
* await page.routeWebSocket('wss://example.com/ws', ws => {
* if (ws.protocols().includes('chat.v2'))
* ws.onMessage(message => ws.send(JSON.stringify({ version: 2, echo: message })));
* else
* ws.close({ code: 1002, reason: 'Unsupported protocol' });
* });
* ```
*
*/
protocols(): Array<string>;

/**
* Sends a message to the WebSocket. When called on the original WebSocket, sends the message to the page. When called
* on the result of
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright-core/browsers.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
},
{
"name": "webkit",
"revision": "2282",
"revision": "2283",
"installByDefault": true,
"revisionOverrides": {
"mac14": "2251",
Expand Down
8 changes: 8 additions & 0 deletions packages/playwright-core/src/client/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,10 @@ export class WebSocketRoute extends ChannelOwner<channels.WebSocketRouteChannel>
return this._initializer.url;
},

protocols: () => {
return [...this._initializer.protocols];
},

close: async (options: { code?: number, reason?: string } = {}) => {
await this._channel.closeServer({ ...options, wasClean: true }).catch(() => {});
},
Expand Down Expand Up @@ -534,6 +538,10 @@ export class WebSocketRoute extends ChannelOwner<channels.WebSocketRouteChannel>
return this._initializer.url;
}

protocols(): string[] {
return [...this._initializer.protocols];
}

async close(options: { code?: number, reason?: string } = {}) {
await this._channel.closePage({ ...options, wasClean: true }).catch(() => {});
}
Expand Down
1 change: 1 addition & 0 deletions packages/playwright-core/src/protocol/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2430,6 +2430,7 @@ scheme.RouteFulfillParams = tObject({
scheme.RouteFulfillResult = tOptional(tObject({}));
scheme.WebSocketRouteInitializer = tObject({
url: tString,
protocols: tArray(tString),
});
scheme.WebSocketRouteMessageFromPageEvent = tObject({
message: tString,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ export class WebSocketRouteDispatcher extends Dispatcher<SdkObject, channels.Web
private _frame: Frame;
private static _idToDispatcher = new Map<string, WebSocketRouteDispatcher>();

constructor(scope: PageDispatcher | BrowserContextDispatcher, id: string, url: string, frame: Frame) {
super(scope, new SdkObject(scope._object, 'webSocketRoute'), 'WebSocketRoute', { url });
constructor(scope: PageDispatcher | BrowserContextDispatcher, id: string, url: string, protocols: string[], frame: Frame) {
super(scope, new SdkObject(scope._object, 'webSocketRoute'), 'WebSocketRoute', { url, protocols });
this._id = id;
this._frame = frame;
this._eventListeners.push(
Expand Down Expand Up @@ -76,7 +76,7 @@ export class WebSocketRouteDispatcher extends Dispatcher<SdkObject, channels.Web
else if (contextDispatcher && matchesPattern(contextDispatcher, context._options.baseURL, payload.url))
scope = contextDispatcher;
if (scope) {
new WebSocketRouteDispatcher(scope, payload.id, payload.url, source.frame);
new WebSocketRouteDispatcher(scope, payload.id, payload.url, payload.protocols, source.frame);
} else {
const request: ws.PassthroughRequest = { id: payload.id, type: 'passthrough' };
source.frame.evaluateExpression(progress, `globalThis.__pwWebSocketDispatch(${JSON.stringify(request)})`).catch(() => {});
Expand Down
16 changes: 15 additions & 1 deletion packages/playwright-core/src/server/screencast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export class Screencast implements InstrumentationListener {
private _clients = new Set<ScreencastClient>();
private _actions: ActionOptions | undefined;
private _size: types.Size | undefined;
private _lastFrame: types.ScreencastFrame | undefined;

constructor(page: Page) {
this.page = page;
Expand Down Expand Up @@ -73,9 +74,20 @@ export class Screencast implements InstrumentationListener {
}

addClient(client: ScreencastClient): { size: types.Size } {
const isFirst = this._clients.size === 0;
this._clients.add(client);
if (this._clients.size === 1)
if (isFirst) {
this._startScreencast(client.size, client.quality);
} else if (this._lastFrame) {
// Deliver the cached last frame to the new client so it does not have
// to wait for the next browser repaint. setTimeout(0) ensures the caller
// of addClient() finishes before the frame is dispatched.
const frame = this._lastFrame;
setTimeout(() => {
if (this._clients.has(client))
void client.onFrame(frame);
}, 0);
}
return { size: this._size! };
}

Expand Down Expand Up @@ -112,10 +124,12 @@ export class Screencast implements InstrumentationListener {
}

private _stopScreencast() {
this._lastFrame = undefined;
this.page.delegate.stopScreencast();
}

onScreencastFrame(frame: types.ScreencastFrame, ack?: () => void) {
this._lastFrame = frame;
const asyncResults: Promise<void>[] = [];
for (const client of this._clients) {
const result = client.onFrame(frame);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -574,8 +574,6 @@ class AttachedPage {
size: { width: 1280, height: 800 },
...(this._recordingPath ? { path: this._recordingPath } : {}),
});
// TODO: this is necessary to trigger a first frame - should this be in screencast.start() implementation?
await page.screenshot().catch(() => {});
}

private async _restartScreencast(page: api.Page) {
Expand Down
21 changes: 21 additions & 0 deletions packages/playwright-core/types/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16538,6 +16538,27 @@ export interface WebSocketRoute {
*/
connectToServer(): WebSocketRoute;

/**
* The list of WebSocket subprotocols requested by the page, as passed via the second argument to the
* [`WebSocket` constructor](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/WebSocket). Corresponds to the
* `Sec-WebSocket-Protocol` request header.
*
* Returns an empty array if no protocols were specified.
*
* **Usage**
*
* ```js
* await page.routeWebSocket('wss://example.com/ws', ws => {
* if (ws.protocols().includes('chat.v2'))
* ws.onMessage(message => ws.send(JSON.stringify({ version: 2, echo: message })));
* else
* ws.close({ code: 1002, reason: 'Unsupported protocol' });
* });
* ```
*
*/
protocols(): Array<string>;

/**
* Sends a message to the WebSocket. When called on the original WebSocket, sends the message to the page. When called
* on the result of
Expand Down
1 change: 1 addition & 0 deletions packages/protocol/src/channels.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4197,6 +4197,7 @@ export interface RouteEvents {
// ----------- WebSocketRoute -----------
export type WebSocketRouteInitializer = {
url: string,
protocols: string[],
};
export interface WebSocketRouteEventTarget {
on(event: 'messageFromPage', callback: (params: WebSocketRouteMessageFromPageEvent) => void): this;
Expand Down
3 changes: 3 additions & 0 deletions packages/protocol/src/protocol.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3744,6 +3744,9 @@ WebSocketRoute:

initializer:
url: string
protocols:
type: array
items: string

commands:

Expand Down
25 changes: 25 additions & 0 deletions tests/library/multiclient.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import { kTargetClosedErrorMessage } from '../config/errors';
import { expect, playwrightTest } from '../config/browserTest';
import { ensureSomeFrames } from '../config/utils';
import type { Browser, BrowserContext, BrowserServer, ConnectOptions, Page } from 'playwright-core';

type ExtraFixtures = {
Expand Down Expand Up @@ -381,6 +382,30 @@ test('should avoid side effects upon disconnect', async ({ twoPages, server }) =
expect(counter).toBe(savedCounter);
});

test('screencast should deliver cached last frame to a new client', async ({ twoPages, server, trace, video }) => {
test.skip(trace === 'on', 'trace=on has screencast active on the page already');
test.skip(video === 'on', 'video=on has screencast active on the page already');

const { pageA, pageB } = twoPages;
await pageA.goto(server.EMPTY_PAGE);
await pageA.evaluate(() => document.body.style.backgroundColor = 'red');

const framesA: Buffer[] = [];
await pageA.screencast.start({ onFrame: ({ data }) => framesA.push(data), size: { width: 320, height: 240 } });
await ensureSomeFrames(pageA);
expect(framesA.length).toBeGreaterThan(0);
const lastFrameA = framesA[framesA.length - 1];

const framesB: Buffer[] = [];
await pageB.screencast.start({ onFrame: ({ data }) => framesB.push(data) });
// Second client should receive the cached last frame without waiting for a browser repaint.
await expect.poll(() => framesB.length, { timeout: 5000 }).toBeGreaterThan(0);
expect(framesB[0].equals(lastFrameA)).toBe(true);

await pageA.screencast.stop();
await pageB.screencast.stop();
});

test('should stop tracing upon disconnect', async ({ twoPages, trace }) => {
test.skip(trace === 'on');

Expand Down
34 changes: 34 additions & 0 deletions tests/library/route-web-socket.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -578,3 +578,37 @@ test('should work with baseURL', async ({ contextFactory, server }) => {
`message: data=echo origin=ws://${server.HOST} lastEventId=`,
]);
});

test('should expose protocols to the route handler', async ({ page, server }) => {
const routes: WebSocketRoute[] = [];
await page.routeWebSocket(/.*/, ws => {
routes.push(ws);
});

await page.goto(server.EMPTY_PAGE);
await page.evaluate(({ host }) => {
(window as any).wsNone = new WebSocket('ws://' + host + '/ws-none');
(window as any).wsString = new WebSocket('ws://' + host + '/ws-string', 'chat.v1');
(window as any).wsArray = new WebSocket('ws://' + host + '/ws-array', ['chat.v2', 'chat.v1']);
}, { host: server.HOST });

await expect.poll(() => routes.length).toBe(3);

const byUrl = new Map(routes.map(r => [new URL(r.url()).pathname, r] as const));
expect(byUrl.get('/ws-none')!.protocols()).toEqual([]);
expect(byUrl.get('/ws-string')!.protocols()).toEqual(['chat.v1']);
expect(byUrl.get('/ws-array')!.protocols()).toEqual(['chat.v2', 'chat.v1']);
});

test('should expose protocols on server-side route', async ({ page, server }) => {
const { promise, resolve } = withResolvers<{ page: WebSocketRoute, server: WebSocketRoute }>();
await page.routeWebSocket(/.*/, ws => {
const serverRoute = ws.connectToServer();
resolve({ page: ws, server: serverRoute });
});

await setupWS(page, server, 'blob', ['chat.v2', 'chat.v1']);
const { page: pageRoute, server: serverRoute } = await promise;
expect(pageRoute.protocols()).toEqual(['chat.v2', 'chat.v1']);
expect(serverRoute.protocols()).toEqual(['chat.v2', 'chat.v1']);
});
1 change: 0 additions & 1 deletion tests/page/page-network-response.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,6 @@ it('should return http version', async ({ page, server }) => {

it('Response.formData() should parse multipart/form-data in page context', async ({ page, server, browserName }) => {
it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/40244' });
it.fail(browserName === 'webkit', 'WebKit 26.4 upstream regression: rejects multipart body without trailing CRLF after closing boundary');
await page.goto(server.EMPTY_PAGE);
const result = await page.evaluate(async () => {
const boundary = '----WebKitFormBoundary1234';
Expand Down
Loading