diff --git a/packages/playwright-core/src/client/network.ts b/packages/playwright-core/src/client/network.ts index 4e9ea285ced70..88c34c6785ed9 100644 --- a/packages/playwright-core/src/client/network.ts +++ b/packages/playwright-core/src/client/network.ts @@ -564,7 +564,8 @@ export class WebSocketRoute extends ChannelOwner if (this._connected) return; // Ensure that websocket is "open" and can send messages without an actual server connection. - await this._channel.ensureOpened(); + // If this happens after the page has been closed, ignore the error. + await this._channel.ensureOpened().catch(() => {}); } } diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index e78d7ce49878c..ad3a75ff5c8ec 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -297,8 +297,8 @@ export abstract class BrowserContext extends SdkObject { return this.doSetHTTPCredentials(httpCredentials); } - hasBinding(name: string) { - return this._pageBindings.has(name); + getBindingClient(name: string): unknown | undefined { + return this._pageBindings.get(name)?.forClient; } async exposePlaywrightBindingIfNeeded() { @@ -317,7 +317,7 @@ export abstract class BrowserContext extends SdkObject { return this._playwrightBindingExposed; } - async exposeBinding(progress: Progress, name: string, needsHandle: boolean, playwrightBinding: frames.FunctionWithSource): Promise { + async exposeBinding(progress: Progress, name: string, needsHandle: boolean, playwrightBinding: frames.FunctionWithSource, forClient?: unknown): Promise { if (this._pageBindings.has(name)) throw new Error(`Function "${name}" has been already registered`); for (const page of this.pages()) { @@ -326,6 +326,7 @@ export abstract class BrowserContext extends SdkObject { } await progress.race(this.exposePlaywrightBindingIfNeeded()); const binding = new PageBinding(name, playwrightBinding, needsHandle); + binding.forClient = forClient; this._pageBindings.set(name, binding); progress.cleanupWhenAborted(() => this._pageBindings.delete(name)); await progress.race(this.doAddInitScript(binding.initScript)); @@ -432,8 +433,8 @@ export abstract class BrowserContext extends SdkObject { this._options.httpCredentials = { username, password: password || '' }; } - async addInitScript(progress: Progress | undefined, source: string, name?: string) { - const initScript = new InitScript(source, name); + async addInitScript(progress: Progress | undefined, source: string) { + const initScript = new InitScript(source); this.initScripts.push(initScript); progress?.cleanupWhenAborted(() => this.removeInitScripts([initScript])); const promise = this.doAddInitScript(initScript); diff --git a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts index 9059389958e5f..089cb0a6ea008 100644 --- a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts @@ -57,6 +57,7 @@ export class BrowserContextDispatcher extends Dispatcher(context); @@ -317,8 +318,8 @@ export class BrowserContextDispatcher extends Dispatcher { this._webSocketInterceptionPatterns = params.patterns; - if (params.patterns.length) - await WebSocketRouteDispatcher.installIfNeeded(progress, this.connection, this._context); + if (params.patterns.length && !this._routeWebSocketInitScript) + this._routeWebSocketInitScript = await WebSocketRouteDispatcher.install(progress, this.connection, this._context); } async storageState(params: channels.BrowserContextStorageStateParams, progress: Progress): Promise { @@ -417,6 +418,9 @@ export class BrowserContextDispatcher extends Dispatcher {}); this._initScripts = []; + if (this._routeWebSocketInitScript) + WebSocketRouteDispatcher.uninstall(this.connection, this._context, this._routeWebSocketInitScript).catch(() => {}); + this._routeWebSocketInitScript = undefined; if (this._clockPaused) this._context.clock.resumeNoReply(); this._clockPaused = false; diff --git a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts index 0760b340f8c92..4a55c099b2822 100644 --- a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts @@ -51,6 +51,7 @@ export class PageDispatcher extends Dispatcher(); private _jsCoverageActive = false; private _cssCoverageActive = false; @@ -207,8 +208,8 @@ export class PageDispatcher extends Dispatcher { this._webSocketInterceptionPatterns = params.patterns; - if (params.patterns.length) - await WebSocketRouteDispatcher.installIfNeeded(progress, this.connection, this._page); + if (params.patterns.length && !this._routeWebSocketInitScript) + this._routeWebSocketInitScript = await WebSocketRouteDispatcher.install(progress, this.connection, this._page); } async expectScreenshot(params: channels.PageExpectScreenshotParams, progress: Progress): Promise { @@ -367,6 +368,9 @@ export class PageDispatcher extends Dispatcher {}); this._initScripts = []; + if (this._routeWebSocketInitScript) + WebSocketRouteDispatcher.uninstall(this.connection, this._page, this._routeWebSocketInitScript).catch(() => {}); + this._routeWebSocketInitScript = undefined; for (const uid of this._locatorHandlers) this._page.unregisterLocatorHandler(uid); this._locatorHandlers.clear(); diff --git a/packages/playwright-core/src/server/dispatchers/webSocketRouteDispatcher.ts b/packages/playwright-core/src/server/dispatchers/webSocketRouteDispatcher.ts index 6d2e5cc2ce740..53384dded91d7 100644 --- a/packages/playwright-core/src/server/dispatchers/webSocketRouteDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/webSocketRouteDispatcher.ts @@ -29,6 +29,7 @@ import type { Frame } from '../frames'; import type * as ws from '@injected/webSocketMock'; import type * as channels from '@protocol/channels'; import type { Progress } from '@protocol/progress'; +import type { InitScript, PageBinding } from '../page'; export class WebSocketRouteDispatcher extends Dispatcher implements channels.WebSocketRouteChannel { _type_WebSocketRoute = true; @@ -58,11 +59,14 @@ export class WebSocketRouteDispatcher extends Dispatcher { const context = target instanceof Page ? target.browserContext : target; - if (!context.hasBinding(kBindingName)) { - await context.exposeBinding(progress, kBindingName, false, (source, payload: ws.BindingPayload) => { + let data = context.getBindingClient(kBindingName) as BindingData | undefined; + if (data && data.connection !== connection) + throw new Error('Another client is already routing WebSockets'); + if (!data) { + data = { counter: 0, connection, binding: null as any }; + data.binding = await context.exposeBinding(progress, kBindingName, false, (source, payload: ws.BindingPayload) => { if (payload.type === 'onCreate') { const contextDispatcher = connection.existingDispatcher(context); const pageDispatcher = contextDispatcher ? PageDispatcher.fromNullable(contextDispatcher, source.page) : undefined; @@ -89,19 +93,27 @@ export class WebSocketRouteDispatcher extends Dispatcher { + const module = {}; + ${rawWebSocketMockSource.source} + (module.exports.inject())(globalThis); + })(); + `); + } - const kInitScriptName = 'webSocketMockSource'; - if (!target.initScripts.find(s => s.name === kInitScriptName)) { - await target.addInitScript(progress, ` - (() => { - const module = {}; - ${rawWebSocketMockSource.source} - (module.exports.inject())(globalThis); - })(); - `, kInitScriptName); - } + static async uninstall(connection: DispatcherConnection, target: Page | BrowserContext, initScript: InitScript) { + const context = target instanceof Page ? target.browserContext : target; + const data = context.getBindingClient(kBindingName) as BindingData | undefined; + if (!data || data.connection !== connection) + return; + if (--data.counter <= 0) + await context.removeExposedBindings([data.binding]); + await target.removeInitScripts([initScript]); } async connect(params: channels.WebSocketRouteConnectParams, progress: Progress) { @@ -155,3 +167,6 @@ function matchesPattern(dispatcher: PageDispatcher | BrowserContextDispatcher, b } return false; } + +const kBindingName = '__pwWebSocketBinding'; +type BindingData = { counter: number, connection: DispatcherConnection, binding: PageBinding }; diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index 4eef603fe46fb..1f13027005c3b 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -562,8 +562,8 @@ export class Page extends SdkObject { await this.delegate.bringToFront(); } - async addInitScript(progress: Progress, source: string, name?: string) { - const initScript = new InitScript(source, name); + async addInitScript(progress: Progress, source: string) { + const initScript = new InitScript(source); this.initScripts.push(initScript); progress.cleanupWhenAborted(() => this.removeInitScripts([initScript])); await progress.race(this.delegate.addInitScript(initScript)); @@ -889,6 +889,7 @@ export class PageBinding { readonly initScript: InitScript; readonly needsHandle: boolean; readonly cleanupScript: string; + forClient?: unknown; constructor(name: string, playwrightFunction: frames.FunctionWithSource, needsHandle: boolean) { this.name = name; @@ -924,13 +925,11 @@ export class PageBinding { export class InitScript { readonly source: string; - readonly name?: string; - constructor(source: string, name?: string) { + constructor(source: string) { this.source = `(() => { ${source} })();`; - this.name = name; } } diff --git a/tests/library/browsercontext-reuse.spec.ts b/tests/library/browsercontext-reuse.spec.ts index 104c81bbaf281..39a40a4c8b311 100644 --- a/tests/library/browsercontext-reuse.spec.ts +++ b/tests/library/browsercontext-reuse.spec.ts @@ -16,332 +16,403 @@ import { browserTest, expect } from '../config/browserTest'; import { verifyViewport } from '../config/utils'; -import type { BrowserContext, Page, BrowserContextOptions } from '@playwright/test'; - -const test = browserTest.extend<{ reusedContext: (options?: BrowserContextOptions) => Promise }>({ - reusedContext: async ({ browserType, browser }, use) => { - let context: BrowserContext | undefined; - await use(async options => { - if (context) - await (browser as any)._disconnectFromReusedContext('Disconnected'); - const defaultContextOptions = (browserType as any)._playwright._defaultContextOptions; - context = await (browser as any)._newContextForReuse({ ...defaultContextOptions, ...options }); - return context; - }); - if (context) - await (browser as any)._disconnectFromReusedContext('Disconnected'); - }, -}); +import type { Browser, BrowserServer, BrowserContext, Page, BrowserContextOptions, BrowserType } from '@playwright/test'; -test('should re-add binding after reset', async ({ reusedContext }) => { - let context = await reusedContext(); +class LaunchScenario { + private _browserType: BrowserType; + private _browser: Browser | undefined; + private _context: BrowserContext | undefined; - await context.exposeFunction('add', function(a, b) { - return Promise.resolve(a - b); - }); - let page = await context.newPage(); - expect(await page.evaluate('add(7, 6)')).toBe(1); + constructor(browserType: BrowserType) { + this._browserType = browserType; + } - context = await reusedContext(); - await context.exposeFunction('add', function(a, b) { - return Promise.resolve(a + b); - }); + async browser() { + if (!this._browser) + this._browser = await this._browserType.launch(); + return this._browser; + } - page = context.pages()[0]; - expect(await page.evaluate('add(5, 6)')).toBe(11); - await page.reload(); - expect(await page.evaluate('add(5, 6)')).toBe(11); -}); + async reusedContext(options?: BrowserContextOptions): Promise { + const browser = await this.browser(); + if (this._context) + await (browser as any)._disconnectFromReusedContext('reusedContext'); + const defaultContextOptions = (this._browserType as any)._playwright._defaultContextOptions; + this._context = await (browser as any)._newContextForReuse({ ...defaultContextOptions, ...options }); + return this._context; + } -test('should reset serviceworker', async ({ reusedContext, server }) => { - server.setRoute('/page.html', (req, res) => { - res.setHeader('Content-Type', 'text/html'); - res.end(` - Page Title - - `); - }); - server.setRoute('/sw.js', (req, res) => { - res.setHeader('Content-Type', 'application/javascript'); - res.end(` - self.addEventListener('fetch', event => { - const blob = new Blob(['Wrong Title'], { type : 'text/html' }); - const response = new Response(blob, { status: 200 , statusText: 'OK' }); - event.respondWith(response); - }); + async close() { + await this._browser?.close(); + } +} - self.addEventListener('activate', event => { - event.waitUntil(clients.claim()); - }); - `); - }); +class ConnectScenario { + private _browserType: BrowserType; + private _server: BrowserServer | undefined; + private _browser: Browser | undefined; - let context = await reusedContext(); - let page = await context.newPage(); - await page.goto(server.PREFIX + '/page.html'); - await expect(page).toHaveTitle('Page Title'); + constructor(browserType: BrowserType) { + this._browserType = browserType; + } + + async server() { + if (!this._server) + this._server = await this._browserType.launchServer(); + return this._server; + } - context = await reusedContext(); - page = context.pages()[0]; - await page.goto(server.PREFIX + '/page.html'); - await expect(page).toHaveTitle('Page Title'); + async reusedContext(options?: BrowserContextOptions): Promise { + const server = await this.server(); + if (this._browser) + await this._browser.close(); + this._browser = await this._browserType.connect(server.wsEndpoint()); + const defaultContextOptions = (this._browserType as any)._playwright._defaultContextOptions; + return await (this._browser as any)._newContextForReuse({ ...defaultContextOptions, ...options }); + } + + async close() { + await this._browser?.close(); + await this._server?.close(); + } +} + +const test = browserTest.extend<{ scenario: 'launch' | 'connect', reusedContext: (options?: BrowserContextOptions) => Promise }>({ + scenario: 'launch', + reusedContext: async ({ scenario, browserType }, use) => { + const instance = scenario === 'launch' ? new LaunchScenario(browserType) : new ConnectScenario(browserType); + await use(options => instance.reusedContext(options)); + await instance.close(); + }, }); -test('should reset serviceworker that hangs in importScripts', async ({ reusedContext, server }) => { - server.setRoute('/page.html', (req, res) => { - res.setHeader('Content-Type', 'text/html'); - res.end(` - Page Title - - `); - }); - server.setRoute('/sw.js', (req, res) => { - res.setHeader('Content-Type', 'application/javascript'); - res.end(` - importScripts('helper.js'); - - self.addEventListener('fetch', event => { - const blob = new Blob(['Wrong Title'], { type : 'text/html' }); - const response = new Response(blob, { status: 200 , statusText: 'OK' }); - event.respondWith(response); +for (const scenario of ['launch', 'connect'] as const) { + test.describe('reuse ' + scenario, () => { + test.use({ scenario }); + + test('should re-add binding after reset', async ({ reusedContext }) => { + let context = await reusedContext(); + + await context.exposeFunction('add', function(a, b) { + return Promise.resolve(a - b); }); + let page = await context.newPage(); + expect(await page.evaluate('add(7, 6)')).toBe(1); - self.addEventListener('activate', event => { - event.waitUntil(clients.claim()); + context = await reusedContext(); + await context.exposeFunction('add', function(a, b) { + return Promise.resolve(a + b); }); - `); - }); - server.setRoute('/helper.js', (req, res) => { - res.setHeader('Content-Type', 'application/javascript'); - // Sending excessive content length makes importScripts hang for - // 5 seconds in Chromium, 6 seconds in Firefox and long time in WebKit. - res.setHeader('Content-Length', 1000); - res.end(`1`); - }); - let context = await reusedContext(); - let page = await context.newPage(); - await page.goto(server.PREFIX + '/page.html'); - await expect(page).toHaveTitle('Page Title'); + page = context.pages()[0]; + expect(await page.evaluate('add(5, 6)')).toBe(11); + await page.reload(); + expect(await page.evaluate('add(5, 6)')).toBe(11); + }); - context = await reusedContext(); - page = context.pages()[0]; - await page.goto(server.PREFIX + '/page.html'); - await expect(page).toHaveTitle('Page Title'); -}); + test('should reset serviceworker', async ({ reusedContext, server }) => { + server.setRoute('/page.html', (req, res) => { + res.setHeader('Content-Type', 'text/html'); + res.end(` + Page Title + + `); + }); + server.setRoute('/sw.js', (req, res) => { + res.setHeader('Content-Type', 'application/javascript'); + res.end(` + self.addEventListener('fetch', event => { + const blob = new Blob(['Wrong Title'], { type : 'text/html' }); + const response = new Response(blob, { status: 200 , statusText: 'OK' }); + event.respondWith(response); + }); + + self.addEventListener('activate', event => { + event.waitUntil(clients.claim()); + }); + `); + }); -test('should not cache resources', async ({ reusedContext, server }) => { - test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/19926' }); - const requestCountMap = new Map(); - server.setRoute('/page.html', (req, res) => { - res.setHeader('Content-Type', 'text/html'); - res.setHeader(`Cache-Control`, `max-age=3600`); - const requestCount = requestCountMap.get(req.url) || 0; - res.end(` - - - - - Count: ${requestCount} - - - - - `); - requestCountMap.set(req.url, requestCount + 1); - }); - server.setRoute('/style.css', (req, res) => { - res.setHeader('Content-Type', 'text/css'); - res.setHeader(`Cache-Control`, `max-age=3600`); - res.end(`body { background-color: red; }`); - requestCountMap.set(req.url, (requestCountMap.get(req.url) || 0) + 1); - }); - server.setRoute('/simple.json', (req, res) => { - res.setHeader(`Cache-Control`, `max-age=3600`); - res.setHeader('Content-Type', 'application/json'); - res.end(`{ "foo": "bar" }`); - requestCountMap.set(req.url, (requestCountMap.get(req.url) || 0) + 1); - }); + let context = await reusedContext(); + let page = await context.newPage(); + await page.goto(server.PREFIX + '/page.html'); + await expect(page).toHaveTitle('Page Title'); - { - const context = await reusedContext(); - const page = await context.newPage(); - await page.goto(server.PREFIX + '/page.html'); - await expect(page).toHaveTitle('Count: 0'); - expect(requestCountMap.get('/page.html')).toBe(1); - expect(requestCountMap.get('/style.css')).toBe(1); - expect(requestCountMap.get('/simple.json')).toBe(1); - } - { - const context = await reusedContext(); - const page = context.pages()[0]; - await page.goto(server.PREFIX + '/page.html'); - await expect(page).toHaveTitle('Count: 1'); - expect(requestCountMap.get('/page.html')).toBe(2); - expect(requestCountMap.get('/style.css')).toBe(2); - expect(requestCountMap.get('/simple.json')).toBe(2); - } -}); + context = await reusedContext(); + page = context.pages()[0]; + await page.goto(server.PREFIX + '/page.html'); + await expect(page).toHaveTitle('Page Title'); + }); -test('should ignore binding from beforeunload', async ({ reusedContext }) => { - test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/22803' }); + test('should reset serviceworker that hangs in importScripts', async ({ reusedContext, server }) => { + server.setRoute('/page.html', (req, res) => { + res.setHeader('Content-Type', 'text/html'); + res.end(` + Page Title + + `); + }); + server.setRoute('/sw.js', (req, res) => { + res.setHeader('Content-Type', 'application/javascript'); + res.end(` + importScripts('helper.js'); + + self.addEventListener('fetch', event => { + const blob = new Blob(['Wrong Title'], { type : 'text/html' }); + const response = new Response(blob, { status: 200 , statusText: 'OK' }); + event.respondWith(response); + }); + + self.addEventListener('activate', event => { + event.waitUntil(clients.claim()); + }); + `); + }); + server.setRoute('/helper.js', (req, res) => { + res.setHeader('Content-Type', 'application/javascript'); + // Sending excessive content length makes importScripts hang for + // 5 seconds in Chromium, 6 seconds in Firefox and long time in WebKit. + res.setHeader('Content-Length', 1000); + res.end(`1`); + }); - let context = await reusedContext(); + let context = await reusedContext(); + let page = await context.newPage(); + await page.goto(server.PREFIX + '/page.html'); + await expect(page).toHaveTitle('Page Title'); - let called = false; - await context.exposeFunction('binding', () => called = true); + context = await reusedContext(); + page = context.pages()[0]; + await page.goto(server.PREFIX + '/page.html'); + await expect(page).toHaveTitle('Page Title'); + }); - let page = await context.newPage(); - await page.evaluate(() => { - window.addEventListener('beforeunload', () => window['binding']()); - }); + test('should not cache resources', async ({ reusedContext, server }) => { + test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/19926' }); + const requestCountMap = new Map(); + server.setRoute('/page.html', (req, res) => { + res.setHeader('Content-Type', 'text/html'); + res.setHeader(`Cache-Control`, `max-age=3600`); + const requestCount = requestCountMap.get(req.url) || 0; + res.end(` + + + + + Count: ${requestCount} + + + + + `); + requestCountMap.set(req.url, requestCount + 1); + }); + server.setRoute('/style.css', (req, res) => { + res.setHeader('Content-Type', 'text/css'); + res.setHeader(`Cache-Control`, `max-age=3600`); + res.end(`body { background-color: red; }`); + requestCountMap.set(req.url, (requestCountMap.get(req.url) || 0) + 1); + }); + server.setRoute('/simple.json', (req, res) => { + res.setHeader(`Cache-Control`, `max-age=3600`); + res.setHeader('Content-Type', 'application/json'); + res.end(`{ "foo": "bar" }`); + requestCountMap.set(req.url, (requestCountMap.get(req.url) || 0) + 1); + }); - context = await reusedContext(); - page = context.pages()[0]; - await page.setContent('hello'); + { + const context = await reusedContext(); + const page = await context.newPage(); + await page.goto(server.PREFIX + '/page.html'); + await expect(page).toHaveTitle('Count: 0'); + expect(requestCountMap.get('/page.html')).toBe(1); + expect(requestCountMap.get('/style.css')).toBe(1); + expect(requestCountMap.get('/simple.json')).toBe(1); + } + { + const context = await reusedContext(); + const page = context.pages()[0]; + await page.goto(server.PREFIX + '/page.html'); + await expect(page).toHaveTitle('Count: 1'); + expect(requestCountMap.get('/page.html')).toBe(2); + expect(requestCountMap.get('/style.css')).toBe(2); + expect(requestCountMap.get('/simple.json')).toBe(2); + } + }); - expect(called).toBe(false); -}); + test('should ignore binding from beforeunload', async ({ reusedContext }) => { + test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/22803' }); -test('should reset mouse position', { - annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/22432' }, -}, async ({ reusedContext, browserName, platform }) => { - // Note: this test only reproduces the issue locally when run with --repeat-each=20. - - const pageContent = ` - -
one
-
one
-
two
- `; - - let context = await reusedContext(); - let page = await context.newPage(); - await page.setContent(pageContent); - await expect(page.locator('#one')).toHaveCSS('background-color', 'rgb(0, 0, 255)'); - await expect(page.locator('#two')).toHaveCSS('background-color', 'rgb(0, 0, 255)'); - - await page.mouse.move(10, 75); - await expect(page.locator('#one')).toHaveCSS('background-color', 'rgb(0, 0, 255)'); - await expect(page.locator('#two')).toHaveCSS('background-color', 'rgb(255, 0, 0)'); - - context = await reusedContext(); - page = context.pages()[0]; - await page.setContent(pageContent); - await expect(page.locator('#one')).toHaveCSS('background-color', 'rgb(0, 0, 255)'); - await expect(page.locator('#two')).toHaveCSS('background-color', 'rgb(0, 0, 255)'); -}); + let context = await reusedContext(); -test('should reset tracing', async ({ reusedContext, trace }, testInfo) => { - test.skip(trace === 'on'); + let called = false; + await context.exposeFunction('binding', () => called = true); - let context = await reusedContext(); - await context.tracing.start(); + let page = await context.newPage(); + await page.evaluate(() => { + window.addEventListener('beforeunload', () => window['binding']()); + }); - let page = await context.newPage(); - await page.evaluate('1 + 1'); + context = await reusedContext(); + page = context.pages()[0]; + await page.setContent('hello'); - context = await reusedContext(); - page = context.pages()[0]; - await page.evaluate('2 + 2'); + expect(called).toBe(false); + }); - const error = await context.tracing.stopChunk({ path: testInfo.outputPath('trace.zip') }).catch(e => e); - expect(error.message).toContain('Must start tracing before stopping'); -}); + test('should reset mouse position', { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/22432' }, + }, async ({ reusedContext, browserName, platform }) => { + // Note: this test only reproduces the issue locally when run with --repeat-each=20. + + const pageContent = ` + +
one
+
one
+
two
+ `; + + let context = await reusedContext(); + let page = await context.newPage(); + await page.setContent(pageContent); + await expect(page.locator('#one')).toHaveCSS('background-color', 'rgb(0, 0, 255)'); + await expect(page.locator('#two')).toHaveCSS('background-color', 'rgb(0, 0, 255)'); + + await page.mouse.move(10, 75); + await expect(page.locator('#one')).toHaveCSS('background-color', 'rgb(0, 0, 255)'); + await expect(page.locator('#two')).toHaveCSS('background-color', 'rgb(255, 0, 0)'); + + context = await reusedContext(); + page = context.pages()[0]; + await page.setContent(pageContent); + await expect(page.locator('#one')).toHaveCSS('background-color', 'rgb(0, 0, 255)'); + await expect(page.locator('#two')).toHaveCSS('background-color', 'rgb(0, 0, 255)'); + }); -test('should work with clock emulation', async ({ reusedContext, trace }, testInfo) => { - let context = await reusedContext(); + test('should reset tracing', async ({ reusedContext, trace }, testInfo) => { + test.skip(trace === 'on'); - let page = await context.newPage(); - await page.clock.setFixedTime(new Date('2020-01-01T00:00:00.000Z')); - expect(await page.evaluate('new Date().toISOString()')).toBe('2020-01-01T00:00:00.000Z'); + let context = await reusedContext(); + await context.tracing.start(); - context = await reusedContext(); - page = context.pages()[0]; - await page.clock.setFixedTime(new Date('2020-01-01T00:00:00Z')); - expect(await page.evaluate('new Date().toISOString()')).toBe('2020-01-01T00:00:00.000Z'); -}); + let page = await context.newPage(); + await page.evaluate('1 + 1'); -test('should continue issuing events after closing the reused page', async ({ reusedContext, server }) => { - test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/24574' }); - - { - const context = await reusedContext(); - const page = await context.newPage(); - await Promise.all([ - page.waitForRequest(server.PREFIX + '/one-style.css'), - page.goto(server.PREFIX + '/one-style.html'), - ]); - await page.close(); - } - { - const context = await reusedContext(); - const page = context.pages()[0]; - await Promise.all([ - page.waitForRequest(server.PREFIX + '/one-style.css', { timeout: 10000 }), - page.goto(server.PREFIX + '/one-style.html'), - ]); - } -}); + context = await reusedContext(); + page = context.pages()[0]; + await page.evaluate('2 + 2'); -test('should work with routeWebSocket', async ({ reusedContext, server, browser }, testInfo) => { - async function setup(page: Page, suffix: string) { - await page.routeWebSocket(/ws1/, ws => { - ws.onMessage(message => { - ws.send('page-mock-' + suffix); - }); + const error = await context.tracing.stopChunk({ path: testInfo.outputPath('trace.zip') }).catch(e => e); + expect(error.message).toContain('Must start tracing before stopping'); }); - await page.context().routeWebSocket(/.*/, ws => { - ws.onMessage(message => { - ws.send('context-mock-' + suffix); - }); + + test('should work with clock emulation', async ({ reusedContext, trace }, testInfo) => { + let context = await reusedContext(); + + let page = await context.newPage(); + await page.clock.setFixedTime(new Date('2020-01-01T00:00:00.000Z')); + expect(await page.evaluate('new Date().toISOString()')).toBe('2020-01-01T00:00:00.000Z'); + + context = await reusedContext(); + page = context.pages()[0]; + await page.clock.setFixedTime(new Date('2020-01-01T00:00:00Z')); + expect(await page.evaluate('new Date().toISOString()')).toBe('2020-01-01T00:00:00.000Z'); }); - 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 }); - } - let context = await reusedContext(); - let page = await context.newPage(); - await setup(page, 'before'); - await page.evaluate(() => (window as any).ws1.send('request')); - await expect.poll(() => page.evaluate(() => window.log)).toEqual([`ws1:page-mock-before`]); - await page.evaluate(() => (window as any).ws2.send('request')); - await expect.poll(() => page.evaluate(() => window.log)).toEqual([`ws1:page-mock-before`, `ws2:context-mock-before`]); - - context = await reusedContext(); - page = context.pages()[0]; - await setup(page, 'after'); - await page.evaluate(() => (window as any).ws1.send('request')); - await expect.poll(() => page.evaluate(() => window.log)).toEqual([`ws1:page-mock-after`]); - await page.evaluate(() => (window as any).ws2.send('request')); - await expect.poll(() => page.evaluate(() => window.log)).toEqual([`ws1:page-mock-after`, `ws2:context-mock-after`]); -}); + test('should continue issuing events after closing the reused page', async ({ reusedContext, server }) => { + test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/24574' }); + + { + const context = await reusedContext(); + const page = await context.newPage(); + await Promise.all([ + page.waitForRequest(server.PREFIX + '/one-style.css'), + page.goto(server.PREFIX + '/one-style.html'), + ]); + await page.close(); + } + { + const context = await reusedContext(); + const page = context.pages()[0]; + await Promise.all([ + page.waitForRequest(server.PREFIX + '/one-style.css', { timeout: 10000 }), + page.goto(server.PREFIX + '/one-style.html'), + ]); + } + }); -test('should update viewport and media', async ({ reusedContext }) => { - let context = await reusedContext({ viewport: { width: 800, height: 600 }, colorScheme: 'dark' }); - let page = await context.newPage(); - expect(await page.evaluate(() => matchMedia('(prefers-color-scheme: dark)').matches)).toBe(true); - await verifyViewport(page, 800, 600); - await page.close(); - - context = await reusedContext({ viewport: { width: 600, height: 800 }, colorScheme: 'light' }); - page = await context.newPage(); - expect(await page.evaluate(() => matchMedia('(prefers-color-scheme: light)').matches)).toBe(true); - await verifyViewport(page, 600, 800); -}); + test('should work with routeWebSocket', { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/36480' }, + }, async ({ reusedContext, server }, testInfo) => { + async function setup(page: Page, suffix: string) { + await page.routeWebSocket(/ws1/, ws => { + ws.onMessage(message => { + ws.send('page-mock-' + suffix); + }); + }); + await page.context().routeWebSocket(/.*/, ws => { + const server = ws.connectToServer(); + ws.onMessage(message => { + ws.send('context-mock-' + suffix); + }); + server.onMessage(message => ws.send(message)); + }); + await page.goto('about:blank'); + await page.evaluate(host => { + window.log = []; + (window as any).ws1 = new WebSocket('ws://' + host + '/ws1'); + (window as any).ws1.addEventListener('message', event => window.log.push(`ws1:${event.data}`)); + (window as any).ws = new WebSocket('ws://' + host + '/ws'); + (window as any).ws.addEventListener('message', event => window.log.push(`ws:${event.data}`)); + }, server.HOST); + } + + let context = await reusedContext(); + let page = await context.newPage(); + const wsPromise = server.waitForWebSocket(); + await setup(page, 'before'); + const ws = await wsPromise; + await page.evaluate(() => (window as any).ws1.send('request')); + await expect.poll(() => page.evaluate(() => window.log)).toEqual([`ws1:page-mock-before`]); + await page.evaluate(() => (window as any).ws.send('request')); + await expect.poll(() => page.evaluate(() => window.log)).toEqual([`ws1:page-mock-before`, `ws:context-mock-before`]); + ws.send('hi'); + await expect.poll(() => page.evaluate(() => window.log)).toEqual([`ws1:page-mock-before`, `ws:context-mock-before`, `ws:hi`]); + + context = await reusedContext(); + page = context.pages()[0]; + const newWSPromise = server.waitForWebSocket(); + await setup(page, 'after'); + const newWS = await newWSPromise; + await page.evaluate(() => (window as any).ws1.send('request')); + await expect.poll(() => page.evaluate(() => window.log)).toEqual([`ws1:page-mock-after`]); + await page.evaluate(() => (window as any).ws.send('request')); + await expect.poll(() => page.evaluate(() => window.log)).toEqual([`ws1:page-mock-after`, `ws:context-mock-after`]); + newWS.send('hello'); + await expect.poll(() => page.evaluate(() => window.log)).toEqual([`ws1:page-mock-after`, `ws:context-mock-after`, `ws:hello`]); + }); + + test('should update viewport and media', async ({ reusedContext }) => { + let context = await reusedContext({ viewport: { width: 800, height: 600 }, colorScheme: 'dark' }); + let page = await context.newPage(); + expect(await page.evaluate(() => matchMedia('(prefers-color-scheme: dark)').matches)).toBe(true); + await verifyViewport(page, 800, 600); + await page.close(); + + context = await reusedContext({ viewport: { width: 600, height: 800 }, colorScheme: 'light' }); + page = await context.newPage(); + expect(await page.evaluate(() => matchMedia('(prefers-color-scheme: light)').matches)).toBe(true); + await verifyViewport(page, 600, 800); + }); + }); +} diff --git a/tests/library/multiclient.spec.ts b/tests/library/multiclient.spec.ts index 989b1b5803fc2..ee5e45d749779 100644 --- a/tests/library/multiclient.spec.ts +++ b/tests/library/multiclient.spec.ts @@ -258,6 +258,27 @@ test('should remove exposed bindings upon disconnect', async ({ twoPages }) => { expect(await pageB.evaluate(() => (window as any).pageBindingB())).toBe('pageBindingBResult'); }); +test('should unroute websockets', async ({ twoPages, server }) => { + const { pageA, pageB } = twoPages; + + await pageA.goto(server.EMPTY_PAGE); + await pageA.routeWebSocket(/.*/, () => {}); + await pageA.routeWebSocket(/.*/, () => {}); + await pageA.routeWebSocket(/.*/, () => {}); + + const error = await pageB.routeWebSocket(/.*/, () => {}).catch(e => e); + expect(error.message).toContain('Another client is already routing WebSockets'); + + await disconnect(pageA); + + let resolve; + const promise = new Promise(f => resolve = f); + await pageB.routeWebSocket(/.*/, resolve); + await pageB.goto(server.EMPTY_PAGE); + await pageB.evaluate(host => (window as any).ws = new WebSocket('ws://' + host + '/ws'), server.HOST); + await promise; +}); + test('should remove init scripts upon disconnect', async ({ twoPages, server }) => { const { pageA, pageB } = twoPages;