diff --git a/docs/src/api/class-websocketroute.md b/docs/src/api/class-websocketroute.md index fcbe1b21f107a..c4c8dfc0b7dc4 100644 --- a/docs/src/api/class-websocketroute.md +++ b/docs/src/api/class-websocketroute.md @@ -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]> diff --git a/packages/injected/src/webSocketMock.ts b/packages/injected/src/webSocketMock.ts index 2ae2188e85607..9285dea5dc9d1 100644 --- a/packages/injected/src/webSocketMock.ts +++ b/packages/injected/src/webSocketMock.ts @@ -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 }; @@ -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 --- diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index db04320501773..30e475fcdea33 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -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; + /** * Sends a message to the WebSocket. When called on the original WebSocket, sends the message to the page. When called * on the result of diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 5afb1f923658e..225284fed7e0b 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -45,7 +45,7 @@ }, { "name": "webkit", - "revision": "2282", + "revision": "2283", "installByDefault": true, "revisionOverrides": { "mac14": "2251", diff --git a/packages/playwright-core/src/client/network.ts b/packages/playwright-core/src/client/network.ts index 146d0679da057..396b8e97795f1 100644 --- a/packages/playwright-core/src/client/network.ts +++ b/packages/playwright-core/src/client/network.ts @@ -485,6 +485,10 @@ export class WebSocketRoute extends ChannelOwner return this._initializer.url; }, + protocols: () => { + return [...this._initializer.protocols]; + }, + close: async (options: { code?: number, reason?: string } = {}) => { await this._channel.closeServer({ ...options, wasClean: true }).catch(() => {}); }, @@ -534,6 +538,10 @@ export class WebSocketRoute extends ChannelOwner 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(() => {}); } diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 142ad0dcbb9f9..3c24734e63a40 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -2430,6 +2430,7 @@ scheme.RouteFulfillParams = tObject({ scheme.RouteFulfillResult = tOptional(tObject({})); scheme.WebSocketRouteInitializer = tObject({ url: tString, + protocols: tArray(tString), }); scheme.WebSocketRouteMessageFromPageEvent = tObject({ message: tString, diff --git a/packages/playwright-core/src/server/dispatchers/webSocketRouteDispatcher.ts b/packages/playwright-core/src/server/dispatchers/webSocketRouteDispatcher.ts index 35f50af8ba1b0..dc33c47af92e2 100644 --- a/packages/playwright-core/src/server/dispatchers/webSocketRouteDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/webSocketRouteDispatcher.ts @@ -37,8 +37,8 @@ export class WebSocketRouteDispatcher extends Dispatcher(); - 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( @@ -76,7 +76,7 @@ export class WebSocketRouteDispatcher extends Dispatcher {}); diff --git a/packages/playwright-core/src/server/screencast.ts b/packages/playwright-core/src/server/screencast.ts index fc913390bd1cb..f8729b0ac80d5 100644 --- a/packages/playwright-core/src/server/screencast.ts +++ b/packages/playwright-core/src/server/screencast.ts @@ -42,6 +42,7 @@ export class Screencast implements InstrumentationListener { private _clients = new Set(); private _actions: ActionOptions | undefined; private _size: types.Size | undefined; + private _lastFrame: types.ScreencastFrame | undefined; constructor(page: Page) { this.page = page; @@ -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! }; } @@ -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[] = []; for (const client of this._clients) { const result = client.onFrame(frame); diff --git a/packages/playwright-core/src/tools/dashboard/dashboardController.ts b/packages/playwright-core/src/tools/dashboard/dashboardController.ts index 2b3a89e0ccc80..38b9cf7d5ad44 100644 --- a/packages/playwright-core/src/tools/dashboard/dashboardController.ts +++ b/packages/playwright-core/src/tools/dashboard/dashboardController.ts @@ -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) { diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index db04320501773..30e475fcdea33 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -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; + /** * Sends a message to the WebSocket. When called on the original WebSocket, sends the message to the page. When called * on the result of diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index 98b15dc23cc2b..d03bbcdd712ec 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -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; diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index aecc537c57831..45970c3cc14d6 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -3744,6 +3744,9 @@ WebSocketRoute: initializer: url: string + protocols: + type: array + items: string commands: diff --git a/tests/library/multiclient.spec.ts b/tests/library/multiclient.spec.ts index 50e1582bc2bdd..f290cebbcf4ef 100644 --- a/tests/library/multiclient.spec.ts +++ b/tests/library/multiclient.spec.ts @@ -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 = { @@ -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'); diff --git a/tests/library/route-web-socket.spec.ts b/tests/library/route-web-socket.spec.ts index 67438cf21ad63..eb0cb80beb726 100644 --- a/tests/library/route-web-socket.spec.ts +++ b/tests/library/route-web-socket.spec.ts @@ -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']); +}); diff --git a/tests/page/page-network-response.spec.ts b/tests/page/page-network-response.spec.ts index 3be69f7082639..66b3c5effbcae 100644 --- a/tests/page/page-network-response.spec.ts +++ b/tests/page/page-network-response.spec.ts @@ -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';