diff --git a/package-lock.json b/package-lock.json index b5d7345..74c5918 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,20 @@ { "name": "playwright-intercept", - "version": "1.0.0", + "version": "1.0.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "playwright-intercept", - "version": "1.0.0", + "version": "1.0.10", "license": "GPL", "dependencies": { "esprima": "^4.0.1", "mime-types": "^2.1.35", "path-to-regexp": "^6.2.1", - "vue": "^3.3.8" + "portfinder": "^1.0.32", + "vue": "^3.3.8", + "ws": "^8.18.0" }, "devDependencies": { "@babel/types": "^7.25.4", @@ -20,6 +22,7 @@ "@types/esprima": "^4.0.6", "@types/mime-types": "^2.1.4", "@types/node": "^18.14.6", + "@types/ws": "^8.5.12", "@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/parser": "^6.14.0", "eslint": "^8.55.0", @@ -349,6 +352,15 @@ "integrity": "sha512-/wdoPq1QqkSj9/QOeKkFquEuPzQbHTWAMPH/PaUMB+JuR31lXhlWXRZ52IpfDYVlDOUBvX09uBrPwxGT1hjNBg==", "dev": true }, + "node_modules/@types/ws": { + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.12.tgz", + "integrity": "sha512-3tPRkv1EtkDpzlgyKyI8pGsGZAGPEaXeu0DOj5DI25Ja91bdAYddYHbADRYVrZMRbfW+1l5YwXVDKohDJNQxkQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", @@ -824,6 +836,14 @@ "node": ">=8" } }, + "node_modules/async": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", + "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", + "dependencies": { + "lodash": "^4.17.14" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1832,6 +1852,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -2012,11 +2037,29 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/nanoid": { "version": "3.3.7", @@ -2251,6 +2294,27 @@ "node": ">=16" } }, + "node_modules/portfinder": { + "version": "1.0.32", + "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.32.tgz", + "integrity": "sha512-on2ZJVVDXRADWE6jnQaX0ioEylzgBpQk8r55NE4wjXW1ZxO+BgDlY6DXwj20i0V8eB4SenDQ00WEaxfiIQPcxg==", + "dependencies": { + "async": "^2.6.4", + "debug": "^3.2.7", + "mkdirp": "^0.5.6" + }, + "engines": { + "node": ">= 0.12.0" + } + }, + "node_modules/portfinder/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dependencies": { + "ms": "^2.1.1" + } + }, "node_modules/postcss": { "version": "8.4.35", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz", @@ -2804,6 +2868,26 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yaml": { "version": "2.3.4", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.4.tgz", diff --git a/package.json b/package.json index 55f778e..9480863 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@types/esprima": "^4.0.6", "@types/mime-types": "^2.1.4", "@types/node": "^18.14.6", + "@types/ws": "^8.5.12", "@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/parser": "^6.14.0", "eslint": "^8.55.0", @@ -40,7 +41,9 @@ "esprima": "^4.0.1", "mime-types": "^2.1.35", "path-to-regexp": "^6.2.1", - "vue": "^3.3.8" + "portfinder": "^1.0.32", + "vue": "^3.3.8", + "ws": "^8.18.0" }, "lint-staged": { "*.ts": [ diff --git a/src/index.ts b/src/index.ts index d8c362a..b305472 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,18 +1,21 @@ +import { reactive, ref } from 'vue'; import type { InterceptOptions } from './types/options/intercept'; import type { InterceptSurface } from './types/intercept-surface'; import type { InterceptSetup } from './types/intercept-setup'; import type { GlobalOptions } from './types/options/global'; +import type { WebsocketOptions } from './types/options/websocket'; +import type { WaitOptions } from './types/options/wait'; +import type { UpdateOptions } from './types/options/update'; +import type { InterceptWebsocketSurface } from './types/intercept-websocket-surface'; import type { Page, Route, Request } from '@playwright/test'; import { routeHandler } from './lib/route-handler'; import { createMatchFunction } from './lib/create-match-function'; import { getMimeType } from './lib/get-mime-type'; import { normalizeBody } from './lib/normalize-body'; -import { reactive, ref } from 'vue'; -import type { WaitOptions } from './types/options/wait'; -import type { UpdateOptions } from './types/options/update'; import { wait } from './lib/wait'; import { update } from './lib/update'; import { getRouteRegex } from './lib/get-route-regex'; +import { websocketSetup } from './lib/websocket-setup'; export { createMatchFunction, getMimeType, normalizeBody }; @@ -110,6 +113,10 @@ export class Intercept { delete(options: InterceptOptions): InterceptSurface { return this.setup(options, 'DELETE'); } + + async wss(options: WebsocketOptions): Promise { + return await websocketSetup(this.page, options); + } } export type { InterceptOptions, InterceptSurface }; diff --git a/src/lib/route-handler.ts b/src/lib/route-handler.ts index 6612c89..614d43d 100644 --- a/src/lib/route-handler.ts +++ b/src/lib/route-handler.ts @@ -33,7 +33,7 @@ export async function routeHandler({ }: RouteHandlerParams) { const intercept = intercepts.find( (intercept) => - intercept.match(route.request().url()) && + intercept.match?.(route.request().url()) && intercept.method === route.request().method() ); @@ -46,10 +46,10 @@ export async function routeHandler({ // Ensure the response has been downloaded by client and then // as an anti-flake measure, push the request upon next tick. page.once('requestfinished', () => { - nextTick(() => intercept.requests.push(route.request())); + nextTick(() => intercept.requests?.push(route.request())); }); - const { params } = intercept.match(route.request().url()) as MatchResult; + const { params } = intercept.match?.(route.request().url()) as MatchResult; if ('body' in intercept || 'fixture' in intercept) { let body = ''; diff --git a/src/lib/wait.ts b/src/lib/wait.ts index 2b1ccd7..f74fcc6 100644 --- a/src/lib/wait.ts +++ b/src/lib/wait.ts @@ -15,20 +15,22 @@ export function wait( return expect .poll( () => { + const requests = intercept.wssPayloads ?? intercept.requests; + const hasFirstRequest = Boolean( intercept.previousRequestCount.value === 0 && - intercept.requests.length > 0 + requests!.length > 0 ); const hasNewRequests = Boolean( intercept.previousRequestCount.value > 0 && - intercept.previousRequestCount.value < intercept.requests.length + intercept.previousRequestCount.value < requests!.length ); const shouldPass = Boolean(hasFirstRequest || hasNewRequests); if (shouldPass) { - intercept.previousRequestCount.value = intercept.requests.length; + intercept.previousRequestCount.value = requests!.length; return true; } diff --git a/src/lib/websocket-setup.ts b/src/lib/websocket-setup.ts new file mode 100644 index 0000000..05eadb6 --- /dev/null +++ b/src/lib/websocket-setup.ts @@ -0,0 +1,74 @@ +import type { Page } from '@playwright/test'; +import type { WebsocketOptions } from '../types/options/websocket'; +import type { InterceptWebsocketSurface } from '../types/intercept-websocket-surface'; +import WebSocket, { WebSocketServer } from 'ws'; +import portfinder from 'portfinder'; +import type { InterceptSetup } from '../types/intercept-setup'; +import { reactive, ref } from 'vue'; +import { wait } from './wait'; +import type { WaitOptions } from '../types/options/wait'; + +export async function websocketSetup(page: Page, options: WebsocketOptions): Promise { + const interceptSetup = { + wssPayloads: reactive([]), + previousRequestCount: ref(0), + method: 'WSS', + ...options, + }; + + const port = await portfinder.getPortPromise(); + + const localWssUrl = `ws://localhost:${port}`; + + // @todo: ...ugh + await page.route("**", async (route) => { + try { + const response = await route.fetch(); + const pageHtml = await response.text(); + + if (!pageHtml.includes(options.url)) { + route.continue(); + return; + } + + const body = pageHtml.replaceAll(options.url, localWssUrl); + const headers = response.headers(); + const csp = headers['content-security-policy']; + headers['content-security-policy'] = csp?.replace( + "connect-src 'self'", + "connect-src 'self' localhost:* ws://localhost:*" + ); + + await route.fulfill({ response, body, headers }); + } catch (e) { + try { + route?.continue(); + } catch { + // + } + } + }); + + const wss = new WebSocketServer({ port }); + + let send: (message: string) => void; + + wss.on('connection', (ws: WebSocket) => { + send = ws.send.bind(ws); + + const handshakeMessage = 'intercept:handshake'; + interceptSetup.wssPayloads?.push(handshakeMessage); + options.handler({ message: handshakeMessage, send }); + + ws.on('message', (data) => { + interceptSetup.wssPayloads?.push(data.toString()); + options.handler({ message: data.toString(), send }); + }); + }); + + return { + wait: (waitOptions?: WaitOptions) => wait(interceptSetup, waitOptions), + send: (message: string) => send(message), + wssPayloads: interceptSetup.wssPayloads!, + } +} \ No newline at end of file diff --git a/src/types/intercept-setup.ts b/src/types/intercept-setup.ts index c80fa5a..3480bd9 100644 --- a/src/types/intercept-setup.ts +++ b/src/types/intercept-setup.ts @@ -8,8 +8,9 @@ import type { Ref } from 'vue'; */ export type InterceptSetup = InterceptOptions & { - method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; - match: MatchFunction; - requests: Request[]; + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'WSS'; + match?: MatchFunction; + requests?: Request[]; + wssPayloads?: string[]; previousRequestCount: Ref; }; diff --git a/src/types/intercept-websocket-surface.ts b/src/types/intercept-websocket-surface.ts new file mode 100644 index 0000000..9edc943 --- /dev/null +++ b/src/types/intercept-websocket-surface.ts @@ -0,0 +1,7 @@ +import type { WaitOptions } from './options/wait'; + +export interface InterceptWebsocketSurface { + wait: (options?: WaitOptions | undefined) => Promise; + send: (message: string) => void; + wssPayloads: string[]; +} diff --git a/src/types/options/intercept.ts b/src/types/options/intercept.ts index 1a5efed..e2d5995 100644 --- a/src/types/options/intercept.ts +++ b/src/types/options/intercept.ts @@ -33,13 +33,22 @@ type FixtureOption = { | ((args: { route: Route; params: Record }) => string); }; +export type WebsocketHandlerArgs = { + message?: string; + send?: (message: string) => void; +}; + +type WebsocketHandlerOption = { + handler: (args: WebsocketHandlerArgs) => void; +}; + type HandlerOption = { handler: (args: { route: Route; params: Record; request: Request; }) => void; -}; +} | WebsocketHandlerOption; export type InterceptOptions = BaseOptions & ( diff --git a/src/types/options/websocket.ts b/src/types/options/websocket.ts new file mode 100644 index 0000000..ec40040 --- /dev/null +++ b/src/types/options/websocket.ts @@ -0,0 +1,6 @@ +import type { WebsocketHandlerArgs } from "./intercept"; + +export interface WebsocketOptions { + url: string; + handler: (args: WebsocketHandlerArgs) => void; +} diff --git a/tests/e2e/websockets.spec.ts b/tests/e2e/websockets.spec.ts new file mode 100644 index 0000000..579e4fe --- /dev/null +++ b/tests/e2e/websockets.spec.ts @@ -0,0 +1,49 @@ +import { Intercept } from '../../dist'; +import { test as base, expect } from '@playwright/test'; + +type BaseFixtures = { + intercept: Intercept; +}; + +const test = base.extend({ + intercept: async ({ page }, use) => { + await use( + new Intercept(page) + ); + }, +}); + +test.describe('Websockets', () => { + test('Intercept websockets', async ({ page, intercept }) => { + const mockWebsocketServer = await intercept.wss({ + url: 'wss://demo.piesocket.com/v3/channel_123?api_key=VCXCEuvhGcBDP7XhiJJUDvR1e1D3eiVjgZ9VRiaV¬ify_self', + handler: ({ message, send }) => { + if (message === "Hello PieSocket!") { + send!("Hi there"); + } + + if (message === "intercept:handshake") { + send!("I have intercepted the handshake!"); + } + } + }); + + await page.goto('https://piehost.com/websocket-tester'); + + await page.getByRole('button', { name: 'Connect' }).click(); + + await mockWebsocketServer.wait(); + + await expect(page.getByText('Connection Established')).toBeVisible(); + + await page.getByRole('button', { name: 'Send' }).click(); + + await mockWebsocketServer.wait(); + + await expect(page.getByText('Hi there')).toBeVisible(); + + expect(mockWebsocketServer.wssPayloads?.length).toBe(2); + + expect(mockWebsocketServer.wssPayloads?.[1]).toBe("Hello PieSocket!"); + }); +}); \ No newline at end of file