From ab7b18e36c03ac92bec08733a8dd9ce528aa4c32 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Fri, 20 Jun 2025 09:23:56 +0200 Subject: [PATCH 01/15] fix(ct): fsWatcher update comparison (#36366) --- packages/playwright/src/fsWatcher.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playwright/src/fsWatcher.ts b/packages/playwright/src/fsWatcher.ts index 4078e4f9945c7..a6989defcfcd7 100644 --- a/packages/playwright/src/fsWatcher.ts +++ b/packages/playwright/src/fsWatcher.ts @@ -33,7 +33,7 @@ export class Watcher { } async update(watchedPaths: string[], ignoredFolders: string[], reportPending: boolean) { - if (JSON.stringify([this._watchedPaths, this._ignoredFolders]) === JSON.stringify(watchedPaths, ignoredFolders)) + if (JSON.stringify([this._watchedPaths, this._ignoredFolders]) === JSON.stringify([watchedPaths, ignoredFolders])) return; if (reportPending) From d4c0d752371136c422434b28200cf0cbb68c7fc5 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Fri, 20 Jun 2025 09:01:46 +0100 Subject: [PATCH 02/15] chore: make fetch progress "strict" (#36318) --- packages/playwright-core/src/server/fetch.ts | 49 +++++++------------- 1 file changed, 17 insertions(+), 32 deletions(-) diff --git a/packages/playwright-core/src/server/fetch.ts b/packages/playwright-core/src/server/fetch.ts index 6d0d2281fc3f5..26339540fa015 100644 --- a/packages/playwright-core/src/server/fetch.ts +++ b/packages/playwright-core/src/server/fetch.ts @@ -27,7 +27,7 @@ import { BrowserContext, verifyClientCertificates } from './browserContext'; import { Cookie, CookieStore, domainMatches, parseRawCookie } from './cookieStore'; import { MultipartFormData } from './formData'; import { SdkObject } from './instrumentation'; -import { ProgressController } from './progress'; +import { isAbortError, ProgressController } from './progress'; import { getMatchingTLSOptionsForOrigin, rewriteOpenSSLErrorIfNeeded } from './socksClientCertificatesInterceptor'; import { httpHappyEyeballsAgent, httpsHappyEyeballsAgent, timingForSocket } from './utils/happyEyeballs'; import { Tracing } from './trace/recorder/tracing'; @@ -84,11 +84,12 @@ export type APIRequestFinishedEvent = { type SendRequestOptions = https.RequestOptions & { maxRedirects: number, - deadline: number, headers: HeadersObject, __testHookLookup?: (hostname: string) => LookupAddress[] }; +type SendRequestResult = Omit & { body: Buffer }; + export abstract class APIRequestContext extends SdkObject { static Events = { Dispose: 'dispose', @@ -185,16 +186,11 @@ export abstract class APIRequestContext extends SdkObject { let maxRedirects = params.maxRedirects ?? (defaults.maxRedirects ?? 20); maxRedirects = maxRedirects === 0 ? -1 : maxRedirects; - const timeout = params.timeout; - const deadline = timeout && (monotonicTime() + timeout); - const options: SendRequestOptions = { method, headers, agent, maxRedirects, - timeout, - deadline, ...getMatchingTLSOptionsForOrigin(this._defaultOptions().clientCertificates, requestUrl.origin), __testHookLookup: (params as any).__testHookLookup, }; @@ -205,10 +201,10 @@ export abstract class APIRequestContext extends SdkObject { const postData = serializePostData(params, headers); if (postData) setHeader(headers, 'content-length', String(postData.byteLength)); - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); const fetchResponse = await controller.run(progress => { return this._sendRequestWithRetries(progress, requestUrl, options, postData, params.maxRetries); - }, timeout); + }, params.timeout); const fetchUid = this._storeResponseBody(fetchResponse.body); this.fetchLog.set(fetchUid, controller.metadata.log); const failOnStatusCode = params.failOnStatusCode !== undefined ? params.failOnStatusCode : !!defaults.failOnStatusCode; @@ -252,10 +248,10 @@ export abstract class APIRequestContext extends SdkObject { return cookies; } - private async _updateRequestCookieHeader(url: URL, headers: HeadersObject) { + private async _updateRequestCookieHeader(progress: Progress, url: URL, headers: HeadersObject) { if (getHeader(headers, 'cookie') !== undefined) return; - const contextCookies = await this._cookies(url); + const contextCookies = await progress.race(this._cookies(url)); // Browser context returns cookies with domain matching both .example.com and // example.com. Those without leading dot are only sent when domain is strictly // matching example.com, but not for sub.example.com. @@ -266,31 +262,33 @@ export abstract class APIRequestContext extends SdkObject { } } - private async _sendRequestWithRetries(progress: Progress, url: URL, options: SendRequestOptions, postData?: Buffer, maxRetries?: number): Promise & { body: Buffer }>{ + private async _sendRequestWithRetries(progress: Progress, url: URL, options: SendRequestOptions, postData?: Buffer, maxRetries?: number): Promise{ maxRetries ??= 0; let backoff = 250; for (let i = 0; i <= maxRetries; i++) { try { return await this._sendRequest(progress, url, options, postData); } catch (e) { + if (isAbortError(e)) + throw e; e = rewriteOpenSSLErrorIfNeeded(e); if (maxRetries === 0) throw e; - if (i === maxRetries || (options.deadline && monotonicTime() + backoff > options.deadline)) + if (i === maxRetries) throw new Error(`Failed after ${i + 1} attempt(s): ${e}`); // Retry on connection reset only. if (e.code !== 'ECONNRESET') throw e; progress.log(` Received ECONNRESET, will retry after ${backoff}ms.`); - await new Promise(f => setTimeout(f, backoff)); + await progress.wait(backoff); backoff *= 2; } } throw new Error('Unreachable'); } - private async _sendRequest(progress: Progress, url: URL, options: SendRequestOptions, postData?: Buffer): Promise & { body: Buffer }>{ - await this._updateRequestCookieHeader(url, options.headers); + private async _sendRequest(progress: Progress, url: URL, options: SendRequestOptions, postData?: Buffer): Promise{ + await this._updateRequestCookieHeader(progress, url, options.headers); const requestCookies = getHeader(options.headers, 'cookie')?.split(';').map(p => { const [name, value] = p.split('=').map(v => v.trim()); @@ -305,7 +303,7 @@ export abstract class APIRequestContext extends SdkObject { }; this.emit(APIRequestContext.Events.Request, requestEvent); - return new Promise((fulfill, reject) => { + const resultPromise = new Promise((fulfill, reject) => { const requestConstructor: ((url: URL, options: http.RequestOptions, callback?: (res: http.IncomingMessage) => void) => http.ClientRequest) = (url.protocol === 'https:' ? https : http).request; // If we have a proxy agent already, do not override it. @@ -402,8 +400,6 @@ export abstract class APIRequestContext extends SdkObject { headers, agent: options.agent, maxRedirects: options.maxRedirects - 1, - timeout: options.timeout, - deadline: options.deadline, ...getMatchingTLSOptionsForOrigin(this._defaultOptions().clientCertificates, url.origin), __testHookLookup: options.__testHookLookup, }; @@ -492,6 +488,7 @@ export abstract class APIRequestContext extends SdkObject { body.on('end', notifyBodyFinished); }); request.on('error', reject); + progress.cleanupWhenAborted(() => request.destroy()); listeners.push( eventsHelper.addEventListener(this, APIRequestContext.Events.Dispose, () => { @@ -543,23 +540,11 @@ export abstract class APIRequestContext extends SdkObject { progress.log(` ${name}: ${value}`); } - if (options.deadline) { - const rejectOnTimeout = () => { - reject(new Error(`Request timed out after ${options.timeout}ms`)); - request.destroy(); - }; - const remaining = options.deadline - monotonicTime(); - if (remaining <= 0) { - rejectOnTimeout(); - return; - } - request.setTimeout(remaining, rejectOnTimeout); - } - if (postData) request.write(postData); request.end(); }); + return progress.race(resultPromise); } private _getHttpCredentials(url: URL) { From 55cb7c916c820c85813ce7eab40914a35586c714 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Fri, 20 Jun 2025 09:02:00 +0100 Subject: [PATCH 03/15] chore: make navigation actions' progress "strict" (#36321) --- packages/playwright-core/src/server/frames.ts | 41 ++++++++++--------- packages/playwright-core/src/server/helper.ts | 2 +- .../playwright-core/src/server/network.ts | 2 +- packages/playwright-core/src/server/page.ts | 26 +++++++----- tests/page/page-set-content.spec.ts | 28 +++++++++++++ 5 files changed, 66 insertions(+), 33 deletions(-) diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index 8623e4b1303ad..4d2d62df9ab23 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -583,7 +583,7 @@ export class Frame extends SdkObject { } } - async raceNavigationAction(progress: Progress, options: types.GotoOptions, action: () => Promise): Promise { + async raceNavigationAction(progress: Progress, action: () => Promise): Promise { return LongStandingScope.raceMultiple([ this._detachedScope, this._page.openScope, @@ -592,7 +592,7 @@ export class Frame extends SdkObject { const data = this._redirectedNavigations.get(e.documentId); if (data) { progress.log(`waiting for redirected navigation to "${data.url}"`); - return data.gotoPromise; + return progress.race(data.gotoPromise); } } throw e; @@ -600,7 +600,7 @@ export class Frame extends SdkObject { } redirectNavigation(url: string, documentId: string, referer: string | undefined) { - const controller = new ProgressController(serverSideCallMetadata(), this); + const controller = new ProgressController(serverSideCallMetadata(), this, 'strict'); const data = { url, gotoPromise: controller.run(progress => this.gotoImpl(progress, url, { referer }), 0), @@ -610,10 +610,10 @@ export class Frame extends SdkObject { } async goto(metadata: CallMetadata, url: string, options: types.GotoOptions): Promise { - const constructedNavigationURL = constructURLBasedOnBaseURL(this._page.browserContext._options.baseURL, url); - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); return controller.run(progress => { - return this.raceNavigationAction(progress, options, async () => this.gotoImpl(progress, constructedNavigationURL, options)); + const constructedNavigationURL = constructURLBasedOnBaseURL(this._page.browserContext._options.baseURL, url); + return this.raceNavigationAction(progress, async () => this.gotoImpl(progress, constructedNavigationURL, options)); }, options.timeout); } @@ -633,7 +633,7 @@ export class Frame extends SdkObject { const navigationEvents: NavigationEvent[] = []; const collectNavigations = (arg: NavigationEvent) => navigationEvents.push(arg); this.on(Frame.Events.InternalNavigation, collectNavigations); - const navigateResult = await this._page.delegate.navigateFrame(this, url, referer).finally( + const navigateResult = await progress.race(this._page.delegate.navigateFrame(this, url, referer)).finally( () => this.off(Frame.Events.InternalNavigation, collectNavigations)); let event: NavigationEvent; @@ -669,7 +669,7 @@ export class Frame extends SdkObject { await helper.waitForEvent(progress, this, Frame.Events.AddLifecycle, (e: types.LifecycleEvent) => e === waitUntil).promise; const request = event.newDocument ? event.newDocument.request : undefined; - const response = request ? request._finalRequest().response() : null; + const response = request ? progress.race(request._finalRequest().response()) : null; return response; } @@ -693,7 +693,7 @@ export class Frame extends SdkObject { await helper.waitForEvent(progress, this, Frame.Events.AddLifecycle, (e: types.LifecycleEvent) => e === waitUntil).promise; const request = navigationEvent.newDocument ? navigationEvent.newDocument.request : undefined; - return request ? request._finalRequest().response() : null; + return request ? progress.race(request._finalRequest().response()) : null; } async _waitForLoadState(progress: Progress, state: types.LifecycleEvent): Promise { @@ -871,26 +871,27 @@ export class Frame extends SdkObject { } async setContent(metadata: CallMetadata, html: string, options: types.NavigateOptions): Promise { - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); return controller.run(async progress => { - await this.raceNavigationAction(progress, options, async () => { + await this.raceNavigationAction(progress, async () => { const waitUntil = options.waitUntil === undefined ? 'load' : options.waitUntil; progress.log(`setting frame content, waiting until "${waitUntil}"`); const tag = `--playwright--set--content--${this._id}--${++this._setContentCounter}--`; - const context = await this._utilityContext(); - const lifecyclePromise = new Promise((resolve, reject) => { - this._page.frameManager._consoleMessageTags.set(tag, () => { - // Clear lifecycle right after document.open() - see 'tag' below. - this._onClearLifecycle(); - this._waitForLoadState(progress, waitUntil).then(resolve).catch(reject); - }); + const context = await progress.race(this._utilityContext()); + const tagPromise = new ManualPromise(); + this._page.frameManager._consoleMessageTags.set(tag, () => { + // Clear lifecycle right after document.open() - see 'tag' below. + this._onClearLifecycle(); + tagPromise.resolve(); }); - const contentPromise = context.evaluate(({ html, tag }) => { + progress.cleanupWhenAborted(() => this._page.frameManager._consoleMessageTags.delete(tag)); + const lifecyclePromise = progress.race(tagPromise).then(() => this._waitForLoadState(progress, waitUntil)); + const contentPromise = progress.race(context.evaluate(({ html, tag }) => { document.open(); console.debug(tag); // eslint-disable-line no-console document.write(html); document.close(); - }, { html, tag }); + }, { html, tag })); await Promise.all([contentPromise, lifecyclePromise]); return null; }); diff --git a/packages/playwright-core/src/server/helper.ts b/packages/playwright-core/src/server/helper.ts index e5bf38dd7777c..fb6622846ca99 100644 --- a/packages/playwright-core/src/server/helper.ts +++ b/packages/playwright-core/src/server/helper.ts @@ -72,7 +72,7 @@ class Helper { }); const dispose = () => eventsHelper.removeEventListeners(listeners); progress.cleanupWhenAborted(dispose); - return { promise, dispose }; + return { promise: progress.race(promise), dispose }; } static secondsToRoundishMillis(value: number): number { diff --git a/packages/playwright-core/src/server/network.ts b/packages/playwright-core/src/server/network.ts index 739c109d8c48d..5536693c75ab7 100644 --- a/packages/playwright-core/src/server/network.ts +++ b/packages/playwright-core/src/server/network.ts @@ -190,7 +190,7 @@ export class Request extends SdkObject { return this._overrides?.headers || this._rawRequestHeadersPromise; } - response(): PromiseLike { + response(): Promise { return this._waitForResponsePromise; } diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index 13da603752150..7e56b07abccb5 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -369,22 +369,22 @@ export class Page extends SdkObject { } async reload(metadata: CallMetadata, options: types.NavigateOptions): Promise { - const controller = new ProgressController(metadata, this); - return controller.run(progress => this.mainFrame().raceNavigationAction(progress, options, async () => { + const controller = new ProgressController(metadata, this, 'strict'); + return controller.run(progress => this.mainFrame().raceNavigationAction(progress, async () => { // Note: waitForNavigation may fail before we get response to reload(), // so we should await it immediately. const [response] = await Promise.all([ // Reload must be a new document, and should not be confused with a stray pushState. this.mainFrame()._waitForNavigation(progress, true /* requiresNewDocument */, options), - this.delegate.reload(), + progress.race(this.delegate.reload()), ]); return response; }), options.timeout); } async goBack(metadata: CallMetadata, options: types.NavigateOptions): Promise { - const controller = new ProgressController(metadata, this); - return controller.run(progress => this.mainFrame().raceNavigationAction(progress, options, async () => { + const controller = new ProgressController(metadata, this, 'strict'); + return controller.run(progress => this.mainFrame().raceNavigationAction(progress, async () => { // Note: waitForNavigation may fail before we get response to goBack, // so we should catch it immediately. let error: Error | undefined; @@ -392,9 +392,11 @@ export class Page extends SdkObject { error = e; return null; }); - const result = await this.delegate.goBack(); - if (!result) + const result = await progress.race(this.delegate.goBack()); + if (!result) { + waitPromise.catch(() => {}); // Avoid an unhandled rejection. return null; + } const response = await waitPromise; if (error) throw error; @@ -403,8 +405,8 @@ export class Page extends SdkObject { } async goForward(metadata: CallMetadata, options: types.NavigateOptions): Promise { - const controller = new ProgressController(metadata, this); - return controller.run(progress => this.mainFrame().raceNavigationAction(progress, options, async () => { + const controller = new ProgressController(metadata, this, 'strict'); + return controller.run(progress => this.mainFrame().raceNavigationAction(progress, async () => { // Note: waitForNavigation may fail before we get response to goForward, // so we should catch it immediately. let error: Error | undefined; @@ -412,9 +414,11 @@ export class Page extends SdkObject { error = e; return null; }); - const result = await this.delegate.goForward(); - if (!result) + const result = await progress.race(this.delegate.goForward()); + if (!result) { + waitPromise.catch(() => {}); // Avoid an unhandled rejection. return null; + } const response = await waitPromise; if (error) throw error; diff --git a/tests/page/page-set-content.spec.ts b/tests/page/page-set-content.spec.ts index 27fe18c860af3..38f5f111cc02a 100644 --- a/tests/page/page-set-content.spec.ts +++ b/tests/page/page-set-content.spec.ts @@ -129,3 +129,31 @@ it('should return empty content there is no iframe src', async ({ page, browserN expect(page.frames().length).toBe(2); expect(await page.frames()[1].content()).toBe(''); }); + +it('should handle timeout properly', async ({ page, toImpl, browserName }) => { + it.skip(browserName === 'firefox', 'tampering with console.debug in utility world does not work'); + + await toImpl(page).mainFrame().evaluateExpression(String(() => { + window['saved'] = console.debug.bind(console); + console.debug = () => {}; + }), { isFunction: true, world: 'utility' }); + const error = await page.setContent(`
hello
`, { timeout: 1000 }).catch(e => e); + expect(error.message).toContain('page.setContent: Timeout 1000ms exceeded'); + + // Should recover after timeout. + await toImpl(page).mainFrame().evaluateExpression(String(() => { + console.debug = window['saved']; + }), { isFunction: true, world: 'utility' }); + await page.setContent(`
world
`); + await expect(page.locator('div')).toHaveText('world'); +}); + +it('should handle timeout properly 2', async ({ page, toImpl }) => { + await toImpl(page).mainFrame().evaluateExpression(String(() => { + document.close = () => { + while (true) {} + }; + }), { isFunction: true, world: 'utility' }); + const error = await page.setContent(`
hello
`, { timeout: 1000 }).catch(e => e); + expect(error.message).toContain('page.setContent: Timeout 1000ms exceeded'); +}); From 20b8784c918555e8cc4241d1e3b2e47a92f7b99d Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Fri, 20 Jun 2025 09:02:14 +0100 Subject: [PATCH 04/15] chore: make screenshot progress "strict" (#36323) --- .../src/server/dispatchers/frameDispatcher.ts | 2 +- packages/playwright-core/src/server/dom.ts | 2 +- .../src/server/firefox/ffPage.ts | 1 - packages/playwright-core/src/server/frames.ts | 120 +++++++++--------- packages/playwright-core/src/server/page.ts | 7 +- .../src/server/screenshotter.ts | 51 +++----- 6 files changed, 85 insertions(+), 98 deletions(-) diff --git a/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts b/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts index 3afab78c254f7..38c31056f2304 100644 --- a/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts @@ -246,7 +246,7 @@ export class FrameDispatcher extends Dispatcher { - return { handle: ElementHandleDispatcher.fromJSOrElementHandle(this, await this._frame._waitForFunctionExpression(metadata, params.expression, params.isFunction, parseArgument(params.arg), params)) }; + return { handle: ElementHandleDispatcher.fromJSOrElementHandle(this, await this._frame.waitForFunctionExpression(metadata, params.expression, params.isFunction, parseArgument(params.arg), params)) }; } async title(params: channels.FrameTitleParams, metadata: CallMetadata): Promise { diff --git a/packages/playwright-core/src/server/dom.ts b/packages/playwright-core/src/server/dom.ts index 989c31fa38cb2..ce9ce887c03b3 100644 --- a/packages/playwright-core/src/server/dom.ts +++ b/packages/playwright-core/src/server/dom.ts @@ -833,7 +833,7 @@ export class ElementHandle extends js.JSHandle { } async screenshot(metadata: CallMetadata, options: ScreenshotOptions & types.TimeoutOptions): Promise { - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); return controller.run( progress => this._page.screenshotter.screenshotElement(progress, this, options), options.timeout); diff --git a/packages/playwright-core/src/server/firefox/ffPage.ts b/packages/playwright-core/src/server/firefox/ffPage.ts index 49ddf5a5c9238..a096946457b69 100644 --- a/packages/playwright-core/src/server/firefox/ffPage.ts +++ b/packages/playwright-core/src/server/firefox/ffPage.ts @@ -419,7 +419,6 @@ export class FFPage implements PageDelegate { height: viewportRect!.height, }; } - progress.throwIfAborted(); const { data } = await this._session.send('Page.screenshot', { mimeType: ('image/' + format) as ('image/png' | 'image/jpeg'), clip: documentRect, diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index 4d2d62df9ab23..5ce23ffb5cbe0 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -1136,7 +1136,7 @@ export class Frame extends SdkObject { async rafrafTimeoutScreenshotElementWithProgress(progress: Progress, selector: string, timeout: number, options: ScreenshotOptions): Promise { return await this._retryWithProgressIfNotConnected(progress, selector, true /* strict */, true /* performActionPreChecks */, async handle => { - await handle._frame.rafrafTimeout(timeout); + await handle._frame.rafrafTimeout(progress, timeout); return await this._page.screenshotter.screenshotElement(progress, handle, options); }); } @@ -1484,63 +1484,67 @@ export class Frame extends SdkObject { return { matches, received }; } - async _waitForFunctionExpression(metadata: CallMetadata, expression: string, isFunction: boolean | undefined, arg: any, options: types.WaitForFunctionOptions, world: types.World = 'main'): Promise> { - const controller = new ProgressController(metadata, this); + async waitForFunctionExpression(metadata: CallMetadata, expression: string, isFunction: boolean | undefined, arg: any, options: types.WaitForFunctionOptions): Promise> { + const controller = new ProgressController(metadata, this, 'strict'); + return controller.run(progress => this.waitForFunctionExpressionImpl(progress, expression, isFunction, arg, options, 'main'), options.timeout); + } + + async waitForFunctionExpressionImpl(progress: Progress, expression: string, isFunction: boolean | undefined, arg: any, options: { pollingInterval?: number }, world: types.World = 'main'): Promise> { if (typeof options.pollingInterval === 'number') assert(options.pollingInterval > 0, 'Cannot poll with non-positive interval: ' + options.pollingInterval); expression = js.normalizeEvaluationExpression(expression, isFunction); - return controller.run(async progress => { - return this.retryWithProgressAndTimeouts(progress, [100], async () => { - const context = world === 'main' ? await this._mainContext() : await this._utilityContext(); - const injectedScript = await context.injectedScript(); - const handle = await injectedScript.evaluateHandle((injected, { expression, isFunction, polling, arg }) => { - const predicate = (): R => { - // NOTE: make sure to use `globalThis.eval` instead of `self.eval` due to a bug with sandbox isolation - // in firefox. - // See https://bugzilla.mozilla.org/show_bug.cgi?id=1814898 - let result = globalThis.eval(expression); - if (isFunction === true) { + return this.retryWithProgressAndTimeouts(progress, [100], async () => { + const context = world === 'main' ? await progress.race(this._mainContext()) : await progress.race(this._utilityContext()); + const injectedScript = await progress.race(context.injectedScript()); + const handle = await progress.race(injectedScript.evaluateHandle((injected, { expression, isFunction, polling, arg }) => { + const predicate = (): R => { + // NOTE: make sure to use `globalThis.eval` instead of `self.eval` due to a bug with sandbox isolation + // in firefox. + // See https://bugzilla.mozilla.org/show_bug.cgi?id=1814898 + let result = globalThis.eval(expression); + if (isFunction === true) { + result = result(arg); + } else if (isFunction === false) { + result = result; + } else { + // auto detect. + if (typeof result === 'function') result = result(arg); - } else if (isFunction === false) { - result = result; - } else { - // auto detect. - if (typeof result === 'function') - result = result(arg); - } - return result; - }; - - let fulfill: (result: R) => void; - let reject: (error: Error) => void; - let aborted = false; - const result = new Promise((f, r) => { fulfill = f; reject = r; }); - - const next = () => { - if (aborted) + } + return result; + }; + + let fulfill: (result: R) => void; + let reject: (error: Error) => void; + let aborted = false; + const result = new Promise((f, r) => { fulfill = f; reject = r; }); + + const next = () => { + if (aborted) + return; + try { + const success = predicate(); + if (success) { + fulfill(success); return; - try { - const success = predicate(); - if (success) { - fulfill(success); - return; - } - if (typeof polling !== 'number') - injected.utils.builtins.requestAnimationFrame(next); - else - injected.utils.builtins.setTimeout(next, polling); - } catch (e) { - reject(e); } - }; - - next(); - return { result, abort: () => aborted = true }; - }, { expression, isFunction, polling: options.pollingInterval, arg }); - progress.cleanupWhenAborted(() => handle.evaluate(h => h.abort()).catch(() => {})); - return handle.evaluateHandle(h => h.result); - }); - }, options.timeout); + if (typeof polling !== 'number') + injected.utils.builtins.requestAnimationFrame(next); + else + injected.utils.builtins.setTimeout(next, polling); + } catch (e) { + reject(e); + } + }; + + next(); + return { result, abort: () => aborted = true }; + }, { expression, isFunction, polling: options.pollingInterval, arg })); + progress.cleanupWhenAborted(() => handle.evaluate(h => h.abort()).finally(() => handle.dispose())); + const result = await progress.race(handle.evaluateHandle(h => h.result)); + handle.dispose(); + return result; + }); } async waitForFunctionValueInUtility(progress: Progress, pageFunction: js.Func1) { @@ -1550,7 +1554,7 @@ export class Frame extends SdkObject { return result; return JSON.stringify(result); }`; - const handle = await this._waitForFunctionExpression(serverSideCallMetadata(), expression, true, undefined, { timeout: progress.timeUntilDeadline() }, 'utility'); + const handle = await this.waitForFunctionExpressionImpl(progress, expression, true, undefined, {}, 'utility'); return JSON.parse(handle.rawValue()) as R; } @@ -1559,18 +1563,18 @@ export class Frame extends SdkObject { return context.evaluate(() => document.title); } - async rafrafTimeout(timeout: number): Promise { + async rafrafTimeout(progress: Progress, timeout: number): Promise { if (timeout === 0) return; - const context = await this._utilityContext(); + const context = await progress.race(this._utilityContext()); await Promise.all([ // wait for double raf - context.evaluate(() => new Promise(x => { + progress.race(context.evaluate(() => new Promise(x => { requestAnimationFrame(() => { requestAnimationFrame(x); }); - })), - new Promise(fulfill => setTimeout(fulfill, timeout)), + }))), + progress.wait(timeout), ]); } diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index 7e56b07abccb5..162a2b6d91dda 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -595,12 +595,12 @@ export class Page extends SdkObject { return await locator.frame.rafrafTimeoutScreenshotElementWithProgress(progress, locator.selector, timeout, options || {}); } : async (progress: Progress, timeout: number) => { await this.performActionPreChecks(progress); - await this.mainFrame().rafrafTimeout(timeout); + await this.mainFrame().rafrafTimeout(progress, timeout); return await this.screenshotter.screenshotPage(progress, options || {}); }; const comparator = getComparator('image/png'); - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); if (!options.expected && options.isNot) return { errorMessage: '"not" matcher requires expected result' }; try { @@ -636,7 +636,6 @@ export class Page extends SdkObject { progress.log(` generating new stable screenshot expectation`); let isFirstIteration = true; while (true) { - progress.throwIfAborted(); if (this.isClosed()) throw new Error('The page has closed'); const screenshotTimeout = pollIntervals.shift() ?? 1000; @@ -644,6 +643,8 @@ export class Page extends SdkObject { progress.log(`waiting ${screenshotTimeout}ms before taking screenshot`); previous = actual; actual = await rafrafScreenshot(progress, screenshotTimeout).catch(e => { + if (isAbortError(e)) + throw e; progress.log(`failed to take screenshot - ` + e.message); return undefined; }); diff --git a/packages/playwright-core/src/server/screenshotter.ts b/packages/playwright-core/src/server/screenshotter.ts index 30e8c9ae1f8da..306857ee2836e 100644 --- a/packages/playwright-core/src/server/screenshotter.ts +++ b/packages/playwright-core/src/server/screenshotter.ts @@ -206,7 +206,6 @@ export class Screenshotter { progress.log('taking page screenshot'); const viewportSize = await this._originalViewportSize(progress); await this._preparePageForScreenshot(progress, this._page.mainFrame(), options.style, options.caret !== 'initial', options.animations === 'disabled'); - progress.throwIfAborted(); // Avoid restoring after failure - should be done by cleanup. if (options.fullPage) { const fullPageSize = await this._fullPageSize(progress); @@ -215,14 +214,12 @@ export class Screenshotter { if (options.clip) documentRect = trimClipToSize(options.clip, documentRect); const buffer = await this._screenshot(progress, format, documentRect, undefined, fitsViewport, options); - progress.throwIfAborted(); // Avoid restoring after failure - should be done by cleanup. await this._restorePageAfterScreenshot(); return buffer; } const viewportRect = options.clip ? trimClipToSize(options.clip, viewportSize) : { x: 0, y: 0, ...viewportSize }; const buffer = await this._screenshot(progress, format, undefined, viewportRect, true, options); - progress.throwIfAborted(); // Avoid restoring after failure - should be done by cleanup. await this._restorePageAfterScreenshot(); return buffer; }); @@ -235,24 +232,19 @@ export class Screenshotter { const viewportSize = await this._originalViewportSize(progress); await this._preparePageForScreenshot(progress, handle._frame, options.style, options.caret !== 'initial', options.animations === 'disabled'); - progress.throwIfAborted(); // Do not do extra work. - await handle._waitAndScrollIntoViewIfNeeded(progress, true /* waitForVisible */); - progress.throwIfAborted(); // Do not do extra work. - const boundingBox = await handle.boundingBox(); + const boundingBox = await progress.race(handle.boundingBox()); assert(boundingBox, 'Node is either not visible or not an HTMLElement'); assert(boundingBox.width !== 0, 'Node has 0 width.'); assert(boundingBox.height !== 0, 'Node has 0 height.'); const fitsViewport = boundingBox.width <= viewportSize.width && boundingBox.height <= viewportSize.height; - progress.throwIfAborted(); // Avoid extra work. const scrollOffset = await this._page.mainFrame().waitForFunctionValueInUtility(progress, () => ({ x: window.scrollX, y: window.scrollY })); const documentRect = { ...boundingBox }; documentRect.x += scrollOffset.x; documentRect.y += scrollOffset.y; const buffer = await this._screenshot(progress, format, helper.enclosingIntRect(documentRect), undefined, fitsViewport, options); - progress.throwIfAborted(); // Avoid restoring after failure - should be done by cleanup. await this._restorePageAfterScreenshot(); return buffer; }); @@ -262,13 +254,13 @@ export class Screenshotter { if (disableAnimations) progress.log(' disabled all CSS animations'); const syncAnimations = this._page.delegate.shouldToggleStyleSheetToSyncAnimations(); - await this._page.safeNonStallingEvaluateInAllFrames('(' + inPagePrepareForScreenshots.toString() + `)(${JSON.stringify(screenshotStyle)}, ${hideCaret}, ${disableAnimations}, ${syncAnimations})`, 'utility'); + progress.cleanupWhenAborted(() => this._restorePageAfterScreenshot()); + await progress.race(this._page.safeNonStallingEvaluateInAllFrames('(' + inPagePrepareForScreenshots.toString() + `)(${JSON.stringify(screenshotStyle)}, ${hideCaret}, ${disableAnimations}, ${syncAnimations})`, 'utility')); if (!process.env.PW_TEST_SCREENSHOT_NO_FONTS_READY) { progress.log('waiting for fonts to load...'); - await frame.nonStallingEvaluateInExistingContext('document.fonts.ready', 'utility').catch(() => {}); + await progress.race(frame.nonStallingEvaluateInExistingContext('document.fonts.ready', 'utility').catch(() => {})); progress.log('fonts loaded'); } - progress.cleanupWhenAborted(() => this._restorePageAfterScreenshot()); } async _restorePageAfterScreenshot() { @@ -276,6 +268,9 @@ export class Screenshotter { } async _maskElements(progress: Progress, options: ScreenshotOptions): Promise<() => Promise> { + if (!options.mask || !options.mask.length) + return () => Promise.resolve(); + const framesToParsedSelectors: MultiMap = new MultiMap(); const cleanup = async () => { @@ -283,50 +278,38 @@ export class Screenshotter { await frame.hideHighlight(); })); }; + progress.cleanupWhenAborted(cleanup); - if (!options.mask || !options.mask.length) - return cleanup; - - await Promise.all((options.mask || []).map(async ({ frame, selector }) => { + await progress.race(Promise.all((options.mask || []).map(async ({ frame, selector }) => { const pair = await frame.selectors.resolveFrameForSelector(selector); if (pair) framesToParsedSelectors.set(pair.frame, pair.info.parsed); - })); - progress.throwIfAborted(); // Avoid extra work. + }))); - await Promise.all([...framesToParsedSelectors.keys()].map(async frame => { + await progress.race(Promise.all([...framesToParsedSelectors.keys()].map(async frame => { await frame.maskSelectors(framesToParsedSelectors.get(frame), options.maskColor || '#F0F'); - })); - progress.cleanupWhenAborted(cleanup); + }))); return cleanup; } private async _screenshot(progress: Progress, format: 'png' | 'jpeg', documentRect: types.Rect | undefined, viewportRect: types.Rect | undefined, fitsViewport: boolean, options: ScreenshotOptions): Promise { if ((options as any).__testHookBeforeScreenshot) - await (options as any).__testHookBeforeScreenshot(); - progress.throwIfAborted(); // Screenshotting is expensive - avoid extra work. + await progress.race((options as any).__testHookBeforeScreenshot()); const shouldSetDefaultBackground = options.omitBackground && format === 'png'; if (shouldSetDefaultBackground) { - await this._page.delegate.setBackgroundColor({ r: 0, g: 0, b: 0, a: 0 }); progress.cleanupWhenAborted(() => this._page.delegate.setBackgroundColor()); + await progress.race(this._page.delegate.setBackgroundColor({ r: 0, g: 0, b: 0, a: 0 })); } - progress.throwIfAborted(); // Avoid extra work. const cleanupHighlight = await this._maskElements(progress, options); - progress.throwIfAborted(); // Avoid extra work. - const quality = format === 'jpeg' ? options.quality ?? 80 : undefined; - const buffer = await this._page.delegate.takeScreenshot(progress, format, documentRect, viewportRect, quality, fitsViewport, options.scale || 'device'); - progress.throwIfAborted(); // Avoid restoring after failure - should be done by cleanup. - + const buffer = await progress.race(this._page.delegate.takeScreenshot(progress, format, documentRect, viewportRect, quality, fitsViewport, options.scale || 'device')); await cleanupHighlight(); - progress.throwIfAborted(); // Avoid restoring after failure - should be done by cleanup. if (shouldSetDefaultBackground) - await this._page.delegate.setBackgroundColor(); - progress.throwIfAborted(); // Avoid side effects. + await progress.race(this._page.delegate.setBackgroundColor()); if ((options as any).__testHookAfterScreenshot) - await (options as any).__testHookAfterScreenshot(); + await progress.race((options as any).__testHookAfterScreenshot()); return buffer; } } From 777d1e54b6f1389902ed25ead1c899fb4e1a6da7 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Fri, 20 Jun 2025 10:10:54 +0200 Subject: [PATCH 05/15] chore: use different babel import in tsxTransform (#36370) --- packages/playwright-ct-core/src/tsxTransform.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playwright-ct-core/src/tsxTransform.ts b/packages/playwright-ct-core/src/tsxTransform.ts index f2fad4efa80bd..fbf3d0cc182cb 100644 --- a/packages/playwright-ct-core/src/tsxTransform.ts +++ b/packages/playwright-ct-core/src/tsxTransform.ts @@ -19,7 +19,7 @@ import path from 'path'; import { declare, traverse, types } from 'playwright/lib/transform/babelBundle'; import { setTransformData } from 'playwright/lib/transform/transform'; -import type { BabelAPI, PluginObj, T } from 'playwright/src/transform/babelBundle'; +import type { BabelAPI, PluginObj, T } from 'playwright/lib/transform/babelBundle'; const t: typeof T = types; let jsxComponentNames: Set; From 173b455941b6f0fd9320f53127f975d0cff63a90 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Fri, 20 Jun 2025 10:22:19 +0200 Subject: [PATCH 06/15] fix(html-reporter): show filtered stats when filtering for labels/annots (#36368) --- packages/html-reporter/src/filter.ts | 5 ++++- tests/playwright-test/reporter-html.spec.ts | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/html-reporter/src/filter.ts b/packages/html-reporter/src/filter.ts index 135336e869907..04034c8ceb3fc 100644 --- a/packages/html-reporter/src/filter.ts +++ b/packages/html-reporter/src/filter.ts @@ -29,7 +29,10 @@ export class Filter { annotations: FilterToken[] = []; empty(): boolean { - return this.project.length + this.status.length + this.text.length === 0; + return ( + this.project.length + this.status.length + this.text.length + + this.labels.length + this.annotations.length + ) === 0; } static parse(expression: string): Filter { diff --git a/tests/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index 83465d8d06bbf..4e9547e45b12b 100644 --- a/tests/playwright-test/reporter-html.spec.ts +++ b/tests/playwright-test/reporter-html.spec.ts @@ -1933,6 +1933,7 @@ for (const useIntermediateMergeReport of [true, false] as const) { const names = ['one foo', 'two foo', 'three bar', 'four bar', 'five baz']; for (const name of names) { test('b-' + name, async ({}) => { + test.info().annotations.push({ type: 'issue', description: 'test issue' }); expect(name).not.toContain('one'); await new Promise(f => setTimeout(f, 1100)); }); @@ -1988,6 +1989,9 @@ for (const useIntermediateMergeReport of [true, false] as const) { await expect(page.locator('.subnav-item:has-text("Failed") .counter')).toHaveText('3'); await expect(page.locator('.subnav-item:has-text("Flaky") .counter')).toHaveText('0'); await expect(page.locator('.subnav-item:has-text("Skipped") .counter')).toHaveText('0'); + + await searchInput.fill('annot:issue'); + await expect(page.getByTestId('filtered-tests-count')).toContainText(`Filtered: 5`); }); test('labels should be applied together with status filter', async ({ runInlineTest, showReport, page }) => { From 1357f0ab8300a27b756e2cb1c788ce3a5ad76760 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Fri, 20 Jun 2025 10:00:21 +0100 Subject: [PATCH 07/15] chore: simplify bidi browsers handling (#36363) --- .../playwright-core/src/browserServerImpl.ts | 4 +-- .../playwright-core/src/client/playwright.ts | 4 +-- .../playwright-core/src/inProcessFactory.ts | 4 +-- .../playwright-core/src/protocol/validator.ts | 4 +-- .../src/remote/playwrightConnection.ts | 9 +----- .../src/server/bidi/bidiChromium.ts | 2 +- .../src/server/bidi/bidiFirefox.ts | 6 +++- .../dispatchers/playwrightDispatcher.ts | 17 +++------- .../playwright-core/src/server/playwright.ts | 8 ++--- .../src/server/registry/index.ts | 31 ++++++------------- packages/protocol/src/channels.d.ts | 4 +-- packages/protocol/src/protocol.yml | 4 +-- tests/bidi/playwright.config.ts | 2 +- tests/config/remoteServer.ts | 6 ---- tests/library/browsertype-connect.spec.ts | 4 +-- tests/library/har.spec.ts | 8 ++--- 16 files changed, 41 insertions(+), 76 deletions(-) diff --git a/packages/playwright-core/src/browserServerImpl.ts b/packages/playwright-core/src/browserServerImpl.ts index ee20e3a102d01..9a8b295202e40 100644 --- a/packages/playwright-core/src/browserServerImpl.ts +++ b/packages/playwright-core/src/browserServerImpl.ts @@ -32,9 +32,9 @@ import type { WebSocketEventEmitter } from './utilsBundle'; import type { Browser } from './server/browser'; export class BrowserServerLauncherImpl implements BrowserServerLauncher { - private _browserName: 'chromium' | 'firefox' | 'webkit' | 'bidiFirefox' | 'bidiChromium'; + private _browserName: 'chromium' | 'firefox' | 'webkit' | '_bidiFirefox' | '_bidiChromium'; - constructor(browserName: 'chromium' | 'firefox' | 'webkit' | 'bidiFirefox' | 'bidiChromium') { + constructor(browserName: 'chromium' | 'firefox' | 'webkit' | '_bidiFirefox' | '_bidiChromium') { this._browserName = browserName; } diff --git a/packages/playwright-core/src/client/playwright.ts b/packages/playwright-core/src/client/playwright.ts index fac9a671ed172..48c6349080f15 100644 --- a/packages/playwright-core/src/client/playwright.ts +++ b/packages/playwright-core/src/client/playwright.ts @@ -58,9 +58,9 @@ export class Playwright extends ChannelOwner { this._android._playwright = this; this._electron = Electron.from(initializer.electron); this._electron._playwright = this; - this._bidiChromium = BrowserType.from(initializer.bidiChromium); + this._bidiChromium = BrowserType.from(initializer._bidiChromium); this._bidiChromium._playwright = this; - this._bidiFirefox = BrowserType.from(initializer.bidiFirefox); + this._bidiFirefox = BrowserType.from(initializer._bidiFirefox); this._bidiFirefox._playwright = this; this.devices = this._connection.localUtils()?.devices ?? {}; this.selectors = new Selectors(this._connection._platform); diff --git a/packages/playwright-core/src/inProcessFactory.ts b/packages/playwright-core/src/inProcessFactory.ts index 0bb1d89d4b7b6..0dac70918cb08 100644 --- a/packages/playwright-core/src/inProcessFactory.ts +++ b/packages/playwright-core/src/inProcessFactory.ts @@ -42,8 +42,8 @@ export function createInProcessPlaywright(): PlaywrightAPI { playwrightAPI.firefox._serverLauncher = new BrowserServerLauncherImpl('firefox'); playwrightAPI.webkit._serverLauncher = new BrowserServerLauncherImpl('webkit'); playwrightAPI._android._serverLauncher = new AndroidServerLauncherImpl(); - playwrightAPI._bidiChromium._serverLauncher = new BrowserServerLauncherImpl('bidiChromium'); - playwrightAPI._bidiFirefox._serverLauncher = new BrowserServerLauncherImpl('bidiFirefox'); + playwrightAPI._bidiChromium._serverLauncher = new BrowserServerLauncherImpl('_bidiChromium'); + playwrightAPI._bidiFirefox._serverLauncher = new BrowserServerLauncherImpl('_bidiFirefox'); // Switch to async dispatch after we got Playwright object. dispatcherConnection.onmessage = message => setImmediate(() => clientConnection.dispatch(message)); diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 7e9073a25166d..2475526a43eb5 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -376,8 +376,8 @@ scheme.PlaywrightInitializer = tObject({ chromium: tChannel(['BrowserType']), firefox: tChannel(['BrowserType']), webkit: tChannel(['BrowserType']), - bidiChromium: tChannel(['BrowserType']), - bidiFirefox: tChannel(['BrowserType']), + _bidiChromium: tChannel(['BrowserType']), + _bidiFirefox: tChannel(['BrowserType']), android: tChannel(['Android']), electron: tChannel(['Electron']), utils: tOptional(tChannel(['LocalUtils'])), diff --git a/packages/playwright-core/src/remote/playwrightConnection.ts b/packages/playwright-core/src/remote/playwrightConnection.ts index f96eded9149f7..1a3dcb2a32327 100644 --- a/packages/playwright-core/src/remote/playwrightConnection.ts +++ b/packages/playwright-core/src/remote/playwrightConnection.ts @@ -118,14 +118,7 @@ export class PlaywrightConnection { private async _initLaunchBrowserMode(scope: RootDispatcher, options: channels.RootInitializeParams) { debugLogger.log('server', `[${this._id}] engaged launch mode for "${this._options.browserName}"`); const ownedSocksProxy = await this._createOwnedSocksProxy(); - let browserName = this._options.browserName; - if ('bidi' === browserName) { - if (this._options.launchOptions?.channel?.toLocaleLowerCase().includes('firefox')) - browserName = 'bidiFirefox'; - else - browserName = 'bidiChromium'; - } - const browser = await this._playwright[browserName as 'chromium'].launch(serverSideCallMetadata(), this._options.launchOptions); + const browser = await this._playwright[this._options.browserName as 'chromium'].launch(serverSideCallMetadata(), this._options.launchOptions); browser.options.sdkLanguage = options.sdkLanguage; this._cleanups.push(() => browser.close({ reason: 'Connection terminated' })); diff --git a/packages/playwright-core/src/server/bidi/bidiChromium.ts b/packages/playwright-core/src/server/bidi/bidiChromium.ts index 4347d0bcd62a1..38a23580c4e40 100644 --- a/packages/playwright-core/src/server/bidi/bidiChromium.ts +++ b/packages/playwright-core/src/server/bidi/bidiChromium.ts @@ -34,7 +34,7 @@ import type * as types from '../types'; export class BidiChromium extends BrowserType { constructor(parent: SdkObject) { - super(parent, 'bidi'); + super(parent, '_bidiChromium'); } override async connectToTransport(transport: ConnectionTransport, options: BrowserOptions, browserLogsCollector: RecentLogsCollector): Promise { diff --git a/packages/playwright-core/src/server/bidi/bidiFirefox.ts b/packages/playwright-core/src/server/bidi/bidiFirefox.ts index 28c2af7c1db86..f10808a13dd36 100644 --- a/packages/playwright-core/src/server/bidi/bidiFirefox.ts +++ b/packages/playwright-core/src/server/bidi/bidiFirefox.ts @@ -35,7 +35,11 @@ import type { RecentLogsCollector } from '../utils/debugLogger'; export class BidiFirefox extends BrowserType { constructor(parent: SdkObject) { - super(parent, 'bidi'); + super(parent, '_bidiFirefox'); + } + + override executablePath(): string { + return ''; } override async connectToTransport(transport: ConnectionTransport, options: BrowserOptions): Promise { diff --git a/packages/playwright-core/src/server/dispatchers/playwrightDispatcher.ts b/packages/playwright-core/src/server/dispatchers/playwrightDispatcher.ts index 74ad4b4ab06f8..04bb144d1176a 100644 --- a/packages/playwright-core/src/server/dispatchers/playwrightDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/playwrightDispatcher.ts @@ -52,15 +52,15 @@ export class PlaywrightDispatcher extends Dispatcher(); @@ -65,8 +65,8 @@ export class Playwright extends SdkObject { } }, null); this.chromium = new Chromium(this); - this.bidiChromium = new BidiChromium(this); - this.bidiFirefox = new BidiFirefox(this); + this._bidiChromium = new BidiChromium(this); + this._bidiFirefox = new BidiFirefox(this); this.firefox = new Firefox(this); this.webkit = new WebKit(this); this.electron = new Electron(this); diff --git a/packages/playwright-core/src/server/registry/index.ts b/packages/playwright-core/src/server/registry/index.ts index 1a6f5f2f2b177..4a0e7cb989be7 100644 --- a/packages/playwright-core/src/server/registry/index.ts +++ b/packages/playwright-core/src/server/registry/index.ts @@ -384,7 +384,9 @@ const DOWNLOAD_PATHS: Record = { 'win64': 'builds/android/%s/android.zip', }, // TODO(bidi): implement downloads. - 'bidi': { + '_bidiFirefox': { + } as DownloadPaths, + '_bidiChromium': { } as DownloadPaths, }; @@ -480,7 +482,7 @@ function readDescriptors(browsersJSON: BrowsersJSON): BrowsersJSONDescriptor[] { }); } -export type BrowserName = 'chromium' | 'firefox' | 'webkit' | 'bidi'; +export type BrowserName = 'chromium' | 'firefox' | 'webkit' | '_bidiFirefox' | '_bidiChromium'; type InternalTool = 'ffmpeg' | 'winldd' | 'firefox-beta' | 'chromium-tip-of-tree' | 'chromium-headless-shell' | 'chromium-tip-of-tree-headless-shell' | 'android'; type BidiChannel = 'moz-firefox' | 'moz-firefox-beta' | 'moz-firefox-nightly' | 'bidi-chrome-canary' | 'bidi-chrome-stable' | 'bidi-chromium'; type ChromiumChannel = 'chrome' | 'chrome-beta' | 'chrome-dev' | 'chrome-canary' | 'msedge' | 'msedge-beta' | 'msedge-dev' | 'msedge-canary'; @@ -717,8 +719,8 @@ export class Registry { })); this._executables.push({ type: 'browser', - name: 'bidi-chromium', - browserName: 'bidi', + name: '_bidiChromium', + browserName: '_bidiChromium', directory: chromium.dir, executablePath: () => chromiumExecutable, executablePathOrDie: (sdkLanguage: string) => executablePathOrDie('chromium', chromiumExecutable, chromium.installByDefault, sdkLanguage), @@ -842,21 +844,6 @@ export class Registry { _dependencyGroup: 'tools', _isHermeticInstallation: true, }); - - this._executables.push({ - type: 'browser', - name: 'bidi', - browserName: 'bidi', - directory: undefined, - executablePath: () => undefined, - executablePathOrDie: () => '', - installType: 'none', - _validateHostRequirements: () => Promise.resolve(), - downloadURLs: [], - _install: () => Promise.resolve(), - _dependencyGroup: 'tools', - _isHermeticInstallation: true, - }); } private _createChromiumChannel(name: ChromiumChannel, lookAt: Record<'linux' | 'darwin' | 'win32', string>, install?: () => Promise): ExecutableImpl { @@ -931,7 +918,7 @@ export class Registry { return { type: 'channel', name, - browserName: 'bidi', + browserName: '_bidiFirefox', directory: undefined, executablePath: (sdkLanguage: string) => executablePath(sdkLanguage, false), executablePathOrDie: (sdkLanguage: string) => executablePath(sdkLanguage, true)!, @@ -947,7 +934,7 @@ export class Registry { const suffix = lookAt[process.platform as 'linux' | 'darwin' | 'win32']; if (!suffix) { if (shouldThrow) - throw new Error(`Firefox distribution '${name}' is not supported on ${process.platform}`); + throw new Error(`Chromium distribution '${name}' is not supported on ${process.platform}`); return undefined; } const prefixes = (process.platform === 'win32' ? [ @@ -974,7 +961,7 @@ export class Registry { return { type: 'channel', name, - browserName: 'bidi', + browserName: '_bidiChromium', directory: undefined, executablePath: (sdkLanguage: string) => executablePath(sdkLanguage, false), executablePathOrDie: (sdkLanguage: string) => executablePath(sdkLanguage, true)!, diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index 1afeaeec8b835..3e052e3d5726e 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -624,8 +624,8 @@ export type PlaywrightInitializer = { chromium: BrowserTypeChannel, firefox: BrowserTypeChannel, webkit: BrowserTypeChannel, - bidiChromium: BrowserTypeChannel, - bidiFirefox: BrowserTypeChannel, + _bidiChromium: BrowserTypeChannel, + _bidiFirefox: BrowserTypeChannel, android: AndroidChannel, electron: ElectronChannel, utils?: LocalUtilsChannel, diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index c33c381bdfdcc..e9c765d045421 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -791,8 +791,8 @@ Playwright: chromium: BrowserType firefox: BrowserType webkit: BrowserType - bidiChromium: BrowserType - bidiFirefox: BrowserType + _bidiChromium: BrowserType + _bidiFirefox: BrowserType android: Android electron: Electron utils: LocalUtils? diff --git a/tests/bidi/playwright.config.ts b/tests/bidi/playwright.config.ts index d650233e7ceb2..a26b72bd53e8b 100644 --- a/tests/bidi/playwright.config.ts +++ b/tests/bidi/playwright.config.ts @@ -106,7 +106,7 @@ for (const [key, channels] of Object.entries(browserToChannels)) { use: { browserName, headless: !headed, - channel, + channel: channel === 'bidi-chromium' ? undefined : channel, video: 'off', launchOptions: { executablePath, diff --git a/tests/config/remoteServer.ts b/tests/config/remoteServer.ts index 94b252a86c838..4c25934581abc 100644 --- a/tests/config/remoteServer.ts +++ b/tests/config/remoteServer.ts @@ -103,12 +103,6 @@ export class RemoteServer implements PlaywrightServer { launchOptions, ...remoteServerOptions, }; - if ('bidi' === browserType.name()) { - if (channel.toLocaleLowerCase().includes('firefox')) - options.browserTypeName = '_bidiFirefox'; - else - options.browserTypeName = '_bidiChromium'; - } this._process = childProcess({ command: ['node', path.join(__dirname, 'remote-server-impl.js'), JSON.stringify(options)], }); diff --git a/tests/library/browsertype-connect.spec.ts b/tests/library/browsertype-connect.spec.ts index 80653941e60b6..1eb1342e8d5b5 100644 --- a/tests/library/browsertype-connect.spec.ts +++ b/tests/library/browsertype-connect.spec.ts @@ -258,9 +258,7 @@ for (const kind of ['launchServer', 'run-server'] as const) { }).catch(() => {}) ]); expect(request.headers['user-agent']).toBe(getUserAgent()); - // _bidiFirefox and _bidiChromium are initialized with 'bidi' as browser name. - const bidiAwareBrowserName = browserName.startsWith('_bidi') ? 'bidi' : browserName; - expect(request.headers['x-playwright-browser']).toBe(bidiAwareBrowserName); + expect(request.headers['x-playwright-browser']).toBe(browserName); expect(request.headers['foo']).toBe('bar'); }); diff --git a/tests/library/har.spec.ts b/tests/library/har.spec.ts index 82c1148289ed1..db5b14a5d135e 100644 --- a/tests/library/har.spec.ts +++ b/tests/library/har.spec.ts @@ -56,9 +56,7 @@ it('should have browser', async ({ browserName, browser, contextFactory, server await page.goto(server.EMPTY_PAGE); const log = await getLog(); - // _bidiFirefox and _bidiChromium are initialized with 'bidi' as browser name. - const harBrowserName = browserName.startsWith('_bidi') ? 'bidi' : browserName; - expect(log.browser!.name.toLowerCase()).toBe(harBrowserName); + expect(log.browser!.name.toLowerCase()).toBe(browserName); expect(log.browser!.version).toBe(browser.version()); }); @@ -915,8 +913,6 @@ it('should not hang on slow chunked response', async ({ browserName, browser, co await page.evaluate(() => (window as any).receivedFirstData); const log = await getLog(); - // _bidiFirefox and _bidiChromium are initialized with 'bidi' as browser name. - const harBrowserName = browserName.startsWith('_bidi') ? 'bidi' : browserName; - expect(log.browser!.name.toLowerCase()).toBe(harBrowserName); + expect(log.browser!.name.toLowerCase()).toBe(browserName); expect(log.browser!.version).toBe(browser.version()); }); From 84c69edbb85422820551403a9e0e76b4dcc26573 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Fri, 20 Jun 2025 10:00:34 +0100 Subject: [PATCH 08/15] chore: make launch, newContext and newPage progress "strict" (#36336) --- .../playwright-core/src/server/browser.ts | 27 +++---- .../src/server/browserContext.ts | 71 ++++++++++--------- .../playwright-core/src/server/browserType.ts | 54 ++++++++------ .../src/server/chromium/chromium.ts | 13 ++-- .../src/server/debugController.ts | 2 +- .../dispatchers/browserContextDispatcher.ts | 2 +- .../server/dispatchers/browserDispatcher.ts | 4 +- .../server/dispatchers/electronDispatcher.ts | 5 +- .../dispatchers/localUtilsDispatcher.ts | 3 +- .../src/server/electron/electron.ts | 34 ++++----- .../socksClientCertificatesInterceptor.ts | 13 ++-- .../playwright-core/src/server/transport.ts | 11 +-- .../src/server/utils/processLauncher.ts | 2 +- tests/library/browser.spec.ts | 8 +++ 14 files changed, 136 insertions(+), 113 deletions(-) diff --git a/packages/playwright-core/src/server/browser.ts b/packages/playwright-core/src/server/browser.ts index fc2fcccdf7407..d985e96fa04b0 100644 --- a/packages/playwright-core/src/server/browser.ts +++ b/packages/playwright-core/src/server/browser.ts @@ -20,6 +20,7 @@ import { Download } from './download'; import { SdkObject } from './instrumentation'; import { Page } from './page'; import { ClientCertificatesProxy } from './socksClientCertificatesInterceptor'; +import { ProgressController } from './progress'; import type { CallMetadata } from './instrumentation'; import type * as types from './types'; @@ -28,6 +29,7 @@ import type { RecentLogsCollector } from './utils/debugLogger'; import type * as channels from '@protocol/channels'; import type { ChildProcess } from 'child_process'; import type { Language } from '../utils'; +import type { Progress } from './progress'; export interface BrowserProcess { @@ -90,25 +92,26 @@ export abstract class Browser extends SdkObject { return this.options.sdkLanguage || this.attribution.playwright.options.sdkLanguage; } - async newContext(metadata: CallMetadata, options: types.BrowserContextOptions): Promise { + newContextFromMetadata(metadata: CallMetadata, options: types.BrowserContextOptions): Promise { + const controller = new ProgressController(metadata, this, 'strict'); + return controller.run(progress => this.newContext(progress, options)); + } + + async newContext(progress: Progress, options: types.BrowserContextOptions): Promise { validateBrowserContextOptions(options, this.options); let clientCertificatesProxy: ClientCertificatesProxy | undefined; if (options.clientCertificates?.length) { - clientCertificatesProxy = new ClientCertificatesProxy(options); + clientCertificatesProxy = await progress.raceWithCleanup(ClientCertificatesProxy.create(options), proxy => proxy.close()); options = { ...options }; - options.proxyOverride = await clientCertificatesProxy.listen(); + options.proxyOverride = clientCertificatesProxy.proxySettings(); options.internalIgnoreHTTPSErrors = true; } - let context; - try { - context = await this.doCreateNewContext(options); - } catch (error) { - await clientCertificatesProxy?.close(); - throw error; - } + const context = await progress.raceWithCleanup(this.doCreateNewContext(options), context => context.close({ reason: 'Failed to create context' })); context._clientCertificatesProxy = clientCertificatesProxy; + if ((options as any).__testHookBeforeSetStorageState) + await progress.race((options as any).__testHookBeforeSetStorageState()); if (options.storageState) - await context.setStorageState(metadata, options.storageState); + await context.setStorageState(progress, options.storageState); this.emit(Browser.Events.Context, context); return context; } @@ -118,7 +121,7 @@ export abstract class Browser extends SdkObject { if (!this._contextForReuse || hash !== this._contextForReuse.hash || !this._contextForReuse.context.canResetForReuse()) { if (this._contextForReuse) await this._contextForReuse.context.close({ reason: 'Context reused' }); - this._contextForReuse = { context: await this.newContext(metadata, params), hash }; + this._contextForReuse = { context: await this.newContextFromMetadata(metadata, params), hash }; return { context: this._contextForReuse.context, needsReset: false }; } await this._contextForReuse.context.stopPendingOperations('Context recreated'); diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index 5bd2577dad98f..0a5b7eac3aab5 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -218,7 +218,7 @@ export abstract class BrowserContext extends SdkObject { // Navigate to about:blank first to ensure no page scripts are running after this point. await page?.mainFrame().gotoImpl(progress, 'about:blank', {}); - await this._resetStorage(); + await this._resetStorage(progress); await this.clock.resetForReuse(); // TODO: following can be optimized to not perform noops. if (this._options.permissions) @@ -379,14 +379,13 @@ export abstract class BrowserContext extends SdkObject { async _loadDefaultContextAsIs(progress: Progress): Promise { if (!this.possiblyUninitializedPages().length) { const waitForEvent = helper.waitForEvent(progress, this, BrowserContext.Events.Page); - progress.cleanupWhenAborted(() => waitForEvent.dispose); // Race against BrowserContext.close await Promise.race([waitForEvent.promise, this._closePromise]); } const page = this.possiblyUninitializedPages()[0]; if (!page) return; - const pageOrError = await page.waitForInitializedOrError(); + const pageOrError = await progress.race(page.waitForInitializedOrError()); if (pageOrError instanceof Error) throw pageOrError; await page.mainFrame()._waitForLoadState(progress, 'load'); @@ -402,7 +401,7 @@ export abstract class BrowserContext extends SdkObject { // Workaround for: // - chromium fails to change isMobile for existing page; // - webkit fails to change locale for existing page. - await this.newPage(progress.metadata); + await this.newPage(progress, false); await defaultPage.close(); } } @@ -511,9 +510,14 @@ export abstract class BrowserContext extends SdkObject { await this._closePromise; } - async newPage(metadata: CallMetadata): Promise { - const page = await this.doCreateNewPage(metadata.isServerSide); - const pageOrError = await page.waitForInitializedOrError(); + newPageFromMetadata(metadata: CallMetadata): Promise { + const contoller = new ProgressController(metadata, this, 'strict'); + return contoller.run(progress => this.newPage(progress, false)); + } + + async newPage(progress: Progress, isServerSide: boolean): Promise { + const page = await progress.raceWithCleanup(this.doCreateNewPage(isServerSide), page => page.close()); + const pageOrError = await progress.race(page.waitForInitializedOrError()); if (pageOrError instanceof Page) { if (pageOrError.isClosed()) throw new Error('Page has been closed.'); @@ -526,7 +530,12 @@ export abstract class BrowserContext extends SdkObject { this._origins.add(origin); } - async storageState(indexedDB = false): Promise { + storageState(indexedDB = false): Promise { + const controller = new ProgressController(serverSideCallMetadata(), this, 'strict'); + return controller.run(progress => this.storageStateImpl(progress, indexedDB)); + } + + async storageStateImpl(progress: Progress, indexedDB: boolean): Promise { const result: channels.BrowserContextStorageStateResult = { cookies: await this.cookies(), origins: [] @@ -557,15 +566,14 @@ export abstract class BrowserContext extends SdkObject { // If there are still origins to save, create a blank page to iterate over origins. if (originsToSave.size) { - const internalMetadata = serverSideCallMetadata(); - const page = await this.newPage(internalMetadata); - page.addRequestInterceptor(route => { + const page = await this.newPage(progress, true); + await progress.race(page.addRequestInterceptor(route => { route.fulfill({ body: '' }).catch(() => {}); - }, 'prepend'); + }, 'prepend')); for (const origin of originsToSave) { const frame = page.mainFrame(); - await frame.goto(internalMetadata, origin, { timeout: 0 }); - const storage: SerializedStorage = await frame.evaluateExpression(collectScript, { world: 'utility' }); + await frame.gotoImpl(progress, origin, {}); + const storage: SerializedStorage = await progress.race(frame.evaluateExpression(collectScript, { world: 'utility' })); if (storage.localStorage.length || storage.indexedDB?.length) result.origins.push({ origin, localStorage: storage.localStorage, indexedDB: storage.indexedDB }); } @@ -574,29 +582,27 @@ export abstract class BrowserContext extends SdkObject { return result; } - async _resetStorage() { + async _resetStorage(progress: Progress) { const oldOrigins = this._origins; const newOrigins = new Map(this._options.storageState?.origins?.map(p => [p.origin, p]) || []); if (!oldOrigins.size && !newOrigins.size) return; let page = this.pages()[0]; - const internalMetadata = serverSideCallMetadata(); - page = page || await this.newPage({ - ...internalMetadata, - // Do not mark this page as internal, because we will leave it for later reuse - // as a user-visible page. - isServerSide: false, - }); + // Do not mark this page as internal, because we will leave it for later reuse + // as a user-visible page. + page = page || await this.newPage(progress, false); const interceptor = (route: network.Route) => { route.fulfill({ body: '' }).catch(() => {}); }; - await page.addRequestInterceptor(interceptor, 'prepend'); + + progress.cleanupWhenAborted(() => page.removeRequestInterceptor(interceptor)); + await progress.race(page.addRequestInterceptor(interceptor, 'prepend')); for (const origin of new Set([...oldOrigins, ...newOrigins.keys()])) { const frame = page.mainFrame(); - await frame.goto(internalMetadata, origin, { timeout: 0 }); - await frame.resetStorageForCurrentOriginBestEffort(newOrigins.get(origin)); + await frame.gotoImpl(progress, origin, {}); + await progress.race(frame.resetStorageForCurrentOriginBestEffort(newOrigins.get(origin))); } await page.removeRequestInterceptor(interceptor); @@ -615,27 +621,26 @@ export abstract class BrowserContext extends SdkObject { return this._settingStorageState; } - async setStorageState(metadata: CallMetadata, state: NonNullable) { + async setStorageState(progress: Progress, state: NonNullable) { this._settingStorageState = true; try { if (state.cookies) - await this.addCookies(state.cookies); + await progress.race(this.addCookies(state.cookies)); if (state.origins && state.origins.length) { - const internalMetadata = serverSideCallMetadata(); - const page = await this.newPage(internalMetadata); - await page.addRequestInterceptor(route => { + const page = await this.newPage(progress, true); + await progress.race(page.addRequestInterceptor(route => { route.fulfill({ body: '' }).catch(() => {}); - }, 'prepend'); + }, 'prepend')); for (const originState of state.origins) { const frame = page.mainFrame(); - await frame.goto(metadata, originState.origin, { timeout: 0 }); + await frame.gotoImpl(progress, originState.origin, {}); const restoreScript = `(() => { const module = {}; ${rawStorageSource.source} const script = new (module.exports.StorageScript())(${this._browser.options.name === 'firefox'}); return script.restore(${JSON.stringify(originState)}); })()`; - await frame.evaluateExpression(restoreScript, { world: 'utility' }); + await progress.race(frame.evaluateExpression(restoreScript, { world: 'utility' })); } await page.close(); } diff --git a/packages/playwright-core/src/server/browserType.ts b/packages/playwright-core/src/server/browserType.ts index 5882687e90f1c..9febf7367aa82 100644 --- a/packages/playwright-core/src/server/browserType.ts +++ b/packages/playwright-core/src/server/browserType.ts @@ -23,7 +23,7 @@ import { debugMode } from './utils/debug'; import { assert } from '../utils/isomorphic/assert'; import { ManualPromise } from '../utils/isomorphic/manualPromise'; import { DEFAULT_PLAYWRIGHT_TIMEOUT } from '../utils/isomorphic/time'; -import { existsAsync } from './utils/fileUtils'; +import { existsAsync, removeFolders } from './utils/fileUtils'; import { helper } from './helper'; import { SdkObject } from './instrumentation'; import { PipeTransport } from './pipeTransport'; @@ -69,7 +69,7 @@ export abstract class BrowserType extends SdkObject { async launch(metadata: CallMetadata, options: types.LaunchOptions, protocolLogger?: types.ProtocolLogger): Promise { options = this._validateLaunchOptions(options); - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); const browser = await controller.run(progress => { const seleniumHubUrl = (options as any).__testHookSeleniumRemoteURL || process.env.SELENIUM_REMOTE_URL; if (seleniumHubUrl) @@ -81,17 +81,16 @@ export abstract class BrowserType extends SdkObject { async launchPersistentContext(metadata: CallMetadata, userDataDir: string, options: channels.BrowserTypeLaunchPersistentContextOptions & { timeout: number, cdpPort?: number, internalIgnoreHTTPSErrors?: boolean, socksProxyPort?: number }): Promise { const launchOptions = this._validateLaunchOptions(options); - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); const browser = await controller.run(async progress => { // Note: Any initial TLS requests will fail since we rely on the Page/Frames initialize which sets ignoreHTTPSErrors. let clientCertificatesProxy: ClientCertificatesProxy | undefined; if (options.clientCertificates?.length) { - clientCertificatesProxy = new ClientCertificatesProxy(options); - launchOptions.proxyOverride = await clientCertificatesProxy?.listen(); + clientCertificatesProxy = await progress.raceWithCleanup(ClientCertificatesProxy.create(options), proxy => proxy.close()); + launchOptions.proxyOverride = clientCertificatesProxy.proxySettings(); options = { ...options }; options.internalIgnoreHTTPSErrors = true; } - progress.cleanupWhenAborted(() => clientCertificatesProxy?.close()); const browser = await this._innerLaunchWithRetries(progress, launchOptions, options, helper.debugProtocolLogger(), userDataDir).catch(e => { throw this._rewriteStartupLog(e); }); browser._defaultContext!._clientCertificatesProxy = clientCertificatesProxy; return browser; @@ -118,7 +117,7 @@ export abstract class BrowserType extends SdkObject { const browserLogsCollector = new RecentLogsCollector(); const { browserProcess, userDataDir, artifactsDir, transport } = await this._launchProcess(progress, options, !!persistent, browserLogsCollector, maybeUserDataDir); if ((options as any).__testHookBeforeCreateBrowser) - await (options as any).__testHookBeforeCreateBrowser(); + await progress.race((options as any).__testHookBeforeCreateBrowser()); const browserOptions: BrowserOptions = { name: this._name, isChromium: this._name === 'chromium', @@ -140,7 +139,7 @@ export abstract class BrowserType extends SdkObject { if (persistent) validateBrowserContextOptions(persistent, browserOptions); copyTestHooks(options, browserOptions); - const browser = await this.connectToTransport(transport, browserOptions, browserLogsCollector); + const browser = await progress.race(this.connectToTransport(transport, browserOptions, browserLogsCollector)); (browser as any)._userDataDirForTest = userDataDir; // We assume no control when using custom arguments, and do not prepare the default context in that case. if (persistent && !options.ignoreAllDefaultArgs) @@ -148,22 +147,16 @@ export abstract class BrowserType extends SdkObject { return browser; } - private async _launchProcess(progress: Progress, options: types.LaunchOptions, isPersistent: boolean, browserLogsCollector: RecentLogsCollector, userDataDir?: string): Promise<{ browserProcess: BrowserProcess, artifactsDir: string, userDataDir: string, transport: ConnectionTransport }> { + private async _prepareToLaunch(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string | undefined) { const { ignoreDefaultArgs, ignoreAllDefaultArgs, args = [], executablePath = null, - handleSIGINT = true, - handleSIGTERM = true, - handleSIGHUP = true, } = options; - - const env = options.env ? envArrayToObject(options.env) : process.env; - await this._createArtifactDirs(options); - const tempDirectories = []; + const tempDirectories: string[] = []; const artifactsDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'playwright-artifacts-')); tempDirectories.push(artifactsDir); @@ -178,7 +171,7 @@ export abstract class BrowserType extends SdkObject { } await this.prepareUserDataDir(options, userDataDir); - const browserArguments = []; + const browserArguments: string[] = []; if (ignoreAllDefaultArgs) browserArguments.push(...args); else if (ignoreDefaultArgs) @@ -199,15 +192,29 @@ export abstract class BrowserType extends SdkObject { await registry.validateHostRequirementsForExecutablesIfNeeded([registryExecutable], this.attribution.playwright.options.sdkLanguage); } + return { executable, browserArguments, userDataDir, artifactsDir, tempDirectories }; + } + + private async _launchProcess(progress: Progress, options: types.LaunchOptions, isPersistent: boolean, browserLogsCollector: RecentLogsCollector, userDataDir?: string): Promise<{ browserProcess: BrowserProcess, artifactsDir: string, userDataDir: string, transport: ConnectionTransport }> { + const { + handleSIGINT = true, + handleSIGTERM = true, + handleSIGHUP = true, + } = options; + + const env = options.env ? envArrayToObject(options.env) : process.env; + const prepared = await progress.race(this._prepareToLaunch(options, isPersistent, userDataDir)); + progress.cleanupWhenAborted(() => removeFolders(prepared.tempDirectories)); + // Note: it is important to define these variables before launchProcess, so that we don't get // "Cannot access 'browserServer' before initialization" if something went wrong. let transport: ConnectionTransport | undefined = undefined; let browserProcess: BrowserProcess | undefined = undefined; const exitPromise = new ManualPromise(); const { launchedProcess, gracefullyClose, kill } = await launchProcess({ - command: executable, - args: browserArguments, - env: this.amendEnvironment(env, userDataDir, executable, browserArguments), + command: prepared.executable, + args: prepared.browserArguments, + env: this.amendEnvironment(env, prepared.userDataDir, prepared.executable, prepared.browserArguments), handleSIGINT, handleSIGTERM, handleSIGHUP, @@ -216,7 +223,7 @@ export abstract class BrowserType extends SdkObject { browserLogsCollector.log(message); }, stdio: 'pipe', - tempDirectories, + tempDirectories: prepared.tempDirectories, attemptToGracefullyClose: async () => { if ((options as any).__testHookGracefullyClose) await (options as any).__testHookGracefullyClose(); @@ -253,7 +260,7 @@ export abstract class BrowserType extends SdkObject { kill }; progress.cleanupWhenAborted(() => closeOrKill(progress.timeUntilDeadline())); - const { wsEndpoint } = await Promise.race([ + const { wsEndpoint } = await progress.race([ this.waitForReadyState(options, browserLogsCollector), exitPromise.then(() => ({ wsEndpoint: undefined })), ]); @@ -263,7 +270,8 @@ export abstract class BrowserType extends SdkObject { const stdio = launchedProcess.stdio as unknown as [NodeJS.ReadableStream, NodeJS.WritableStream, NodeJS.WritableStream, NodeJS.WritableStream, NodeJS.ReadableStream]; transport = new PipeTransport(stdio[3], stdio[4]); } - return { browserProcess, artifactsDir, userDataDir, transport }; + progress.cleanupWhenAborted(() => transport.close()); + return { browserProcess, artifactsDir: prepared.artifactsDir, userDataDir: prepared.userDataDir, transport }; } async _createArtifactDirs(options: types.LaunchOptions): Promise { diff --git a/packages/playwright-core/src/server/chromium/chromium.ts b/packages/playwright-core/src/server/chromium/chromium.ts index 075e56a4690e9..b35fbb83f0828 100644 --- a/packages/playwright-core/src/server/chromium/chromium.ts +++ b/packages/playwright-core/src/server/chromium/chromium.ts @@ -63,7 +63,7 @@ export class Chromium extends BrowserType { } override async connectOverCDP(metadata: CallMetadata, endpointURL: string, options: { slowMo?: number, headers?: types.HeadersArray, timeout: number }) { - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); return controller.run(async progress => { return await this._connectOverCDPInternal(progress, endpointURL, options); }, options.timeout); @@ -79,12 +79,11 @@ export class Chromium extends BrowserType { else if (headersMap && !Object.keys(headersMap).some(key => key.toLowerCase() === 'user-agent')) headersMap['User-Agent'] = getUserAgent(); - const artifactsDir = await fs.promises.mkdtemp(ARTIFACTS_FOLDER); + const artifactsDir = await progress.race(fs.promises.mkdtemp(ARTIFACTS_FOLDER)); const wsEndpoint = await urlToWSEndpoint(progress, endpointURL, headersMap); - progress.throwIfAborted(); - const chromeTransport = await WebSocketTransport.connect(progress, wsEndpoint, { headers: headersMap }); + progress.cleanupWhenAborted(() => chromeTransport.close()); const cleanedUp = new ManualPromise(); const doCleanup = async () => { await removeFolders([artifactsDir]); @@ -111,8 +110,7 @@ export class Chromium extends BrowserType { originalLaunchOptions: { timeout: options.timeout }, }; validateBrowserContextOptions(persistent, browserOptions); - progress.throwIfAborted(); - const browser = await CRBrowser.connect(this.attribution.playwright, chromeTransport, browserOptions); + const browser = await progress.race(CRBrowser.connect(this.attribution.playwright, chromeTransport, browserOptions)); browser._isCollocatedWithServer = false; browser.on(Browser.Events.Disconnected, doCleanup); return browser; @@ -174,7 +172,7 @@ export class Chromium extends BrowserType { } override async _launchWithSeleniumHub(progress: Progress, hubUrl: string, options: types.LaunchOptions): Promise { - await this._createArtifactDirs(options); + await progress.race(this._createArtifactDirs(options)); if (!hubUrl.endsWith('/')) hubUrl = hubUrl + '/'; @@ -390,6 +388,7 @@ async function urlToWSEndpoint(progress: Progress, endpointURL: string, headers: const json = await fetchData({ url: httpURL, headers, + timeout: progress.timeUntilDeadline(), }, async (_, resp) => new Error(`Unexpected status ${resp.statusCode} when connecting to ${httpURL}.\n` + `This does not look like a DevTools server, try connecting via ws://.`) ); diff --git a/packages/playwright-core/src/server/debugController.ts b/packages/playwright-core/src/server/debugController.ts index 9338b59406dd5..92b94211a612f 100644 --- a/packages/playwright-core/src/server/debugController.ts +++ b/packages/playwright-core/src/server/debugController.ts @@ -106,7 +106,7 @@ export class DebugController extends SdkObject { if (!pages.length) { const [browser] = this._playwright.allBrowsers(); const { context } = await browser.newContextForReuse({}, internalMetadata); - await context.newPage(internalMetadata); + await context.newPageFromMetadata(internalMetadata); } // Update test id attribute. if (params.testIdAttributeName) { diff --git a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts index 033efda435e5d..9609d62a26730 100644 --- a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts @@ -245,7 +245,7 @@ export class BrowserContextDispatcher extends Dispatcher { - return { page: PageDispatcher.from(this, await this._context.newPage(metadata)) }; + return { page: PageDispatcher.from(this, await this._context.newPageFromMetadata(metadata)) }; } async cookies(params: channels.BrowserContextCookiesParams): Promise { diff --git a/packages/playwright-core/src/server/dispatchers/browserDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserDispatcher.ts index 186ec73faa03c..448440069a939 100644 --- a/packages/playwright-core/src/server/dispatchers/browserDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserDispatcher.ts @@ -60,14 +60,14 @@ export class BrowserDispatcher extends Dispatcher { if (!this._options.isolateContexts) { - const context = await this._object.newContext(metadata, params); + const context = await this._object.newContextFromMetadata(metadata, params); const contextDispatcher = BrowserContextDispatcher.from(this, context); return { context: contextDispatcher }; } if (params.recordVideo) params.recordVideo.dir = this._object.options.artifactsDir; - const context = await this._object.newContext(metadata, params); + const context = await this._object.newContextFromMetadata(metadata, params); this._isolatedContexts.add(context); context.on(BrowserContext.Events.Close, () => this._isolatedContexts.delete(context)); const contextDispatcher = BrowserContextDispatcher.from(this, context); diff --git a/packages/playwright-core/src/server/dispatchers/electronDispatcher.ts b/packages/playwright-core/src/server/dispatchers/electronDispatcher.ts index 7660526176980..8ca84caadeca0 100644 --- a/packages/playwright-core/src/server/dispatchers/electronDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/electronDispatcher.ts @@ -24,6 +24,7 @@ import type { PageDispatcher } from './pageDispatcher'; import type { ConsoleMessage } from '../console'; import type { Electron } from '../electron/electron'; import type * as channels from '@protocol/channels'; +import type { CallMetadata } from '@protocol/callMetadata'; export class ElectronDispatcher extends Dispatcher implements channels.ElectronChannel { @@ -35,10 +36,10 @@ export class ElectronDispatcher extends Dispatcher { + async launch(params: channels.ElectronLaunchParams, metadata: CallMetadata): Promise { if (this._denyLaunch) throw new Error(`Launching more browsers is not allowed.`); - const electronApplication = await this._object.launch(params); + const electronApplication = await this._object.launch(metadata, params); return { electronApplication: new ElectronApplicationDispatcher(this, electronApplication) }; } } diff --git a/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts b/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts index 1a00d8c7f1f82..e614aae6219ef 100644 --- a/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts @@ -83,7 +83,7 @@ export class LocalUtilsDispatcher extends Dispatcher { - const controller = new ProgressController(metadata, this._object); + const controller = new ProgressController(metadata, this._object, 'strict'); return await controller.run(async progress => { const wsHeaders = { 'User-Agent': getUserAgent(), @@ -146,7 +146,6 @@ async function urlToWSEndpoint(progress: Progress, endpointURL: string): Promise return new Error(`Unexpected status ${response.statusCode} when connecting to ${fetchUrl.toString()}.\n` + `This does not look like a Playwright server, try connecting via ws://.`); }); - progress.throwIfAborted(); const wsUrl = new URL(endpointURL); let wsEndpointPath = JSON.parse(json).wsEndpointPath; diff --git a/packages/playwright-core/src/server/electron/electron.ts b/packages/playwright-core/src/server/electron/electron.ts index b0c154effa1ad..d687571d47973 100644 --- a/packages/playwright-core/src/server/electron/electron.ts +++ b/packages/playwright-core/src/server/electron/electron.ts @@ -19,7 +19,7 @@ import os from 'os'; import path from 'path'; import * as readline from 'readline'; -import { ManualPromise } from '../../utils'; +import { ManualPromise, removeFolders } from '../../utils'; import { wrapInASCIIBox } from '../utils/ascii'; import { RecentLogsCollector } from '../utils/debugLogger'; import { eventsHelper } from '../utils/eventsHelper'; @@ -30,7 +30,7 @@ import { createHandle, CRExecutionContext } from '../chromium/crExecutionContext import { toConsoleMessageLocation } from '../chromium/crProtocolHelper'; import { ConsoleMessage } from '../console'; import { helper } from '../helper'; -import { SdkObject, serverSideCallMetadata } from '../instrumentation'; +import { CallMetadata, SdkObject } from '../instrumentation'; import * as js from '../javascript'; import { envArrayToObject, launchProcess } from '../utils/processLauncher'; import { ProgressController } from '../progress'; @@ -152,18 +152,15 @@ export class ElectronApplication extends SdkObject { export class Electron extends SdkObject { constructor(playwright: Playwright) { super(playwright, 'electron'); + this.logName = 'browser'; } - async launch(options: channels.ElectronLaunchParams): Promise { - const { - args = [], - } = options; - const controller = new ProgressController(serverSideCallMetadata(), this); - controller.setLogName('browser'); + async launch(metadata: CallMetadata, options: channels.ElectronLaunchParams): Promise { + const controller = new ProgressController(metadata, this, 'strict'); return controller.run(async progress => { let app: ElectronApplication | undefined = undefined; // --remote-debugging-port=0 must be the last playwright's argument, loader.ts relies on it. - let electronArguments = ['--inspect=0', '--remote-debugging-port=0', ...args]; + let electronArguments = ['--inspect=0', '--remote-debugging-port=0', ...(options.args || [])]; if (os.platform() === 'linux') { const runningAsRoot = process.geteuid && process.geteuid() === 0; @@ -171,7 +168,8 @@ export class Electron extends SdkObject { electronArguments.unshift('--no-sandbox'); } - const artifactsDir = await fs.promises.mkdtemp(ARTIFACTS_FOLDER); + const artifactsDir = await progress.race(fs.promises.mkdtemp(ARTIFACTS_FOLDER)); + progress.cleanupWhenAborted(() => removeFolders([artifactsDir])); const browserLogsCollector = new RecentLogsCollector(); const env = options.env ? envArrayToObject(options.env) : process.env; @@ -233,8 +231,8 @@ export class Electron extends SdkObject { // All waitForLines must be started immediately. // Otherwise the lines might come before we are ready. - const waitForXserverError = new Promise(async (resolve, reject) => { - waitForLine(progress, launchedProcess, /Unable to open X display/).then(() => reject(new Error([ + const waitForXserverError = waitForLine(progress, launchedProcess, /Unable to open X display/).then(() => { + throw new Error([ 'Unable to open X display!', `================================`, 'Most likely this is because there is no X server available.', @@ -242,7 +240,7 @@ export class Electron extends SdkObject { "For example: 'xvfb-run npm run test:e2e'", `================================`, progress.metadata.log - ].join('\n')))).catch(() => {}); + ].join('\n')); }); const nodeMatchPromise = waitForLine(progress, launchedProcess, /^Debugger listening on (ws:\/\/.*)$/); const chromeMatchPromise = waitForLine(progress, launchedProcess, /^DevTools listening on (ws:\/\/.*)$/); @@ -250,6 +248,7 @@ export class Electron extends SdkObject { const nodeMatch = await nodeMatchPromise; const nodeTransport = await WebSocketTransport.connect(progress, nodeMatch[1]); + progress.cleanupWhenAborted(() => nodeTransport.close()); const nodeConnection = new CRConnection(this, nodeTransport, helper.debugProtocolLogger(), browserLogsCollector); // Immediately release exiting process under debug. @@ -261,6 +260,7 @@ export class Electron extends SdkObject { waitForXserverError, ]) as RegExpMatchArray; const chromeTransport = await WebSocketTransport.connect(progress, chromeMatch[1]); + progress.cleanupWhenAborted(() => chromeTransport.close()); const browserProcess: BrowserProcess = { onclose: undefined, process: launchedProcess, @@ -285,16 +285,16 @@ export class Electron extends SdkObject { originalLaunchOptions: { timeout: options.timeout }, }; validateBrowserContextOptions(contextOptions, browserOptions); - const browser = await CRBrowser.connect(this.attribution.playwright, chromeTransport, browserOptions); + const browser = await progress.race(CRBrowser.connect(this.attribution.playwright, chromeTransport, browserOptions)); app = new ElectronApplication(this, browser, nodeConnection, launchedProcess); - await app.initialize(); + await progress.race(app.initialize()); return app; }, options.timeout); } } function waitForLine(progress: Progress, process: childProcess.ChildProcess, regex: RegExp): Promise { - return new Promise((resolve, reject) => { + return progress.race(new Promise((resolve, reject) => { const rl = readline.createInterface({ input: process.stderr! }); const failError = new Error('Process failed to launch!'); const listeners = [ @@ -318,5 +318,5 @@ function waitForLine(progress: Progress, process: childProcess.ChildProcess, reg function cleanup() { eventsHelper.removeEventListeners(listeners); } - }); + })); } diff --git a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts index 2cb588378208b..896dd2f091d8b 100644 --- a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts +++ b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts @@ -244,7 +244,7 @@ export class ClientCertificatesProxy { alpnCache: ALPNCache; proxyAgentFromOptions: ReturnType; - constructor( + private constructor( contextOptions: Pick ) { verifyClientCertificates(contextOptions.clientCertificates); @@ -294,9 +294,14 @@ export class ClientCertificatesProxy { } } - public async listen() { - const port = await this._socksProxy.listen(0, '127.0.0.1'); - return { server: `socks5://127.0.0.1:${port}` }; + public static async create(contextOptions: Pick) { + const proxy = new ClientCertificatesProxy(contextOptions); + await proxy._socksProxy.listen(0, '127.0.0.1'); + return proxy; + } + + public proxySettings(): types.ProxySettings { + return { server: `socks5://127.0.0.1:${this._socksProxy.port()}` }; } public async close() { diff --git a/packages/playwright-core/src/server/transport.ts b/packages/playwright-core/src/server/transport.ts index e95ec8eaee429..30e87ae0c2eca 100644 --- a/packages/playwright-core/src/server/transport.ts +++ b/packages/playwright-core/src/server/transport.ts @@ -84,12 +84,8 @@ export class WebSocketTransport implements ConnectionTransport { const logUrl = stripQueryParams(url); progress?.log(` ${logUrl}`); const transport = new WebSocketTransport(progress, url, logUrl, { ...options, followRedirects: !!options.followRedirects && hadRedirects }); - let success = false; - progress?.cleanupWhenAborted(async () => { - if (!success) - await transport.closeAndWait().catch(e => null); - }); - const result = await new Promise<{ transport?: WebSocketTransport, redirect?: IncomingMessage }>((fulfill, reject) => { + progress?.cleanupWhenAborted(() => transport.closeAndWait()); + const resultPromise = new Promise<{ transport?: WebSocketTransport, redirect?: IncomingMessage }>((fulfill, reject) => { transport._ws.on('open', async () => { progress?.log(` ${logUrl}`); fulfill({ transport }); @@ -120,6 +116,7 @@ export class WebSocketTransport implements ConnectionTransport { }); }); }); + const result = progress ? await progress.race(resultPromise) : await resultPromise; if (result.redirect) { // Strip authorization headers from the redirected request. @@ -128,8 +125,6 @@ export class WebSocketTransport implements ConnectionTransport { })); return WebSocketTransport._connect(progress, result.redirect.headers.location!, { ...options, headers: newHeaders }, true /* hadRedirects */); } - - success = true; return transport; } diff --git a/packages/playwright-core/src/server/utils/processLauncher.ts b/packages/playwright-core/src/server/utils/processLauncher.ts index 90bc1507fc74a..2f204674ec5db 100644 --- a/packages/playwright-core/src/server/utils/processLauncher.ts +++ b/packages/playwright-core/src/server/utils/processLauncher.ts @@ -164,7 +164,7 @@ export async function launchProcess(options: LaunchProcessOptions): Promise { failed(new Error('Failed to launch: ' + error)); }); - return cleanup().then(() => failedPromise).then(e => Promise.reject(e)); + return failedPromise.then(e => Promise.reject(e)); } options.log(` pid=${spawnedProcess.pid}`); diff --git a/tests/library/browser.spec.ts b/tests/library/browser.spec.ts index 17bdc142131d0..19d47f871a4ea 100644 --- a/tests/library/browser.spec.ts +++ b/tests/library/browser.spec.ts @@ -62,3 +62,11 @@ test('should dispatch page.on(close) upon browser.close and reject evaluate', as const error = await promise; expect(error.message).toContain(kTargetClosedErrorMessage); }); + +test('newContext should not leave a context upon failure', async ({ browser, toImpl }) => { + const error = await browser.newContext({ + __testHookBeforeSetStorageState: () => Promise.reject(new Error('Oh my')), + } as any).catch(e => e); + expect(error.message).toContain('Oh my'); + await expect.poll(() => toImpl(browser).contexts().length).toBe(0); +}); From c0da19366092bddc35dae1f5b5f2cd072079604d Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Fri, 20 Jun 2025 10:47:36 +0100 Subject: [PATCH 09/15] chore: make various progress instances "strict" (#36349) --- .../src/server/android/android.ts | 62 ++++++++++--------- .../src/server/browserContext.ts | 32 +++++----- .../src/server/chromium/videoRecorder.ts | 3 +- .../server/dispatchers/androidDispatcher.ts | 8 +-- packages/playwright-core/src/server/frames.ts | 22 +++---- packages/playwright-core/src/server/page.ts | 54 ++++++++-------- .../src/server/recorder/recorderApp.ts | 2 +- .../src/server/trace/viewer/traceViewer.ts | 2 +- 8 files changed, 97 insertions(+), 88 deletions(-) diff --git a/packages/playwright-core/src/server/android/android.ts b/packages/playwright-core/src/server/android/android.ts index b8a4c4772f428..3b7e4cebf34e3 100644 --- a/packages/playwright-core/src/server/android/android.ts +++ b/packages/playwright-core/src/server/android/android.ts @@ -32,9 +32,9 @@ import { chromiumSwitches } from '../chromium/chromiumSwitches'; import { CRBrowser } from '../chromium/crBrowser'; import { removeFolders } from '../utils/fileUtils'; import { helper } from '../helper'; -import { SdkObject, serverSideCallMetadata } from '../instrumentation'; +import { CallMetadata, SdkObject } from '../instrumentation'; import { gracefullyCloseSet } from '../utils/processLauncher'; -import { ProgressController } from '../progress'; +import { Progress, ProgressController } from '../progress'; import { registry } from '../registry'; import type { BrowserOptions, BrowserProcess } from '../browser'; @@ -122,6 +122,7 @@ export class AndroidDevice extends SdkObject { this.model = model; this.serial = backend.serial; this._options = options; + this.logName = 'browser'; } static async create(android: Android, backend: DeviceBackend, options: channels.AndroidDevicesOptions): Promise { @@ -258,18 +259,21 @@ export class AndroidDevice extends SdkObject { this.emit(AndroidDevice.Events.Close); } - async launchBrowser(pkg: string = 'com.android.chrome', options: channels.AndroidDeviceLaunchBrowserParams): Promise { - debug('pw:android')('Force-stopping', pkg); - await this._backend.runCommand(`shell:am force-stop ${pkg}`); - const socketName = isUnderTest() ? 'webview_devtools_remote_playwright_test' : ('playwright_' + createGuid() + '_devtools_remote'); - const commandLine = this._defaultArgs(options, socketName).join(' '); - debug('pw:android')('Starting', pkg, commandLine); - // encode commandLine to base64 to avoid issues (bash encoding) with special characters - await this._backend.runCommand(`shell:echo "${Buffer.from(commandLine).toString('base64')}" | base64 -d > /data/local/tmp/chrome-command-line`); - await this._backend.runCommand(`shell:am start -a android.intent.action.VIEW -d about:blank ${pkg}`); - const browserContext = await this._connectToBrowser(socketName, options); - await this._backend.runCommand(`shell:rm /data/local/tmp/chrome-command-line`); - return browserContext; + async launchBrowser(metadata: CallMetadata, pkg: string = 'com.android.chrome', options: channels.AndroidDeviceLaunchBrowserParams): Promise { + const controller = new ProgressController(metadata, this, 'strict'); + return controller.run(async progress => { + debug('pw:android')('Force-stopping', pkg); + await this._backend.runCommand(`shell:am force-stop ${pkg}`); + const socketName = isUnderTest() ? 'webview_devtools_remote_playwright_test' : ('playwright_' + createGuid() + '_devtools_remote'); + const commandLine = this._defaultArgs(options, socketName).join(' '); + debug('pw:android')('Starting', pkg, commandLine); + // encode commandLine to base64 to avoid issues (bash encoding) with special characters + await progress.race(this._backend.runCommand(`shell:echo "${Buffer.from(commandLine).toString('base64')}" | base64 -d > /data/local/tmp/chrome-command-line`)); + await progress.race(this._backend.runCommand(`shell:am start -a android.intent.action.VIEW -d about:blank ${pkg}`)); + const browserContext = await this._connectToBrowser(progress, socketName, options); + await progress.race(this._backend.runCommand(`shell:rm /data/local/tmp/chrome-command-line`)); + return browserContext; + }); } private _defaultArgs(options: channels.AndroidDeviceLaunchBrowserParams, socketName: string): string[] { @@ -301,25 +305,30 @@ export class AndroidDevice extends SdkObject { return chromeArguments; } - async connectToWebView(socketName: string): Promise { - const webView = this._webViews.get(socketName); - if (!webView) - throw new Error('WebView has been closed'); - return await this._connectToBrowser(socketName); + async connectToWebView(metadata: CallMetadata, socketName: string): Promise { + const controller = new ProgressController(metadata, this, 'strict'); + return controller.run(async progress => { + const webView = this._webViews.get(socketName); + if (!webView) + throw new Error('WebView has been closed'); + return await this._connectToBrowser(progress, socketName); + }); } - private async _connectToBrowser(socketName: string, options: types.BrowserContextOptions = {}): Promise { - const socket = await this._waitForLocalAbstract(socketName); + private async _connectToBrowser(progress: Progress, socketName: string, options: types.BrowserContextOptions = {}): Promise { + const socket = await progress.race(this._waitForLocalAbstract(socketName)); const androidBrowser = new AndroidBrowser(this, socket); - await androidBrowser._init(); + progress.cleanupWhenAborted(() => androidBrowser.close()); + await progress.race(androidBrowser._init()); this._browserConnections.add(androidBrowser); - const artifactsDir = await fs.promises.mkdtemp(ARTIFACTS_FOLDER); + const artifactsDir = await progress.race(fs.promises.mkdtemp(ARTIFACTS_FOLDER)); const cleanupArtifactsDir = async () => { const errors = (await removeFolders([artifactsDir])).filter(Boolean); for (let i = 0; i < (errors || []).length; ++i) debug('pw:android')(`exception while removing ${artifactsDir}: ${errors[i]}`); }; + progress.cleanupWhenAborted(cleanupArtifactsDir); gracefullyCloseSet.add(cleanupArtifactsDir); socket.on('close', async () => { gracefullyCloseSet.delete(cleanupArtifactsDir); @@ -341,12 +350,9 @@ export class AndroidDevice extends SdkObject { }; validateBrowserContextOptions(options, browserOptions); - const browser = await CRBrowser.connect(this.attribution.playwright, androidBrowser, browserOptions); - const controller = new ProgressController(serverSideCallMetadata(), this); + const browser = await progress.race(CRBrowser.connect(this.attribution.playwright, androidBrowser, browserOptions)); const defaultContext = browser._defaultContext!; - await controller.run(async progress => { - await defaultContext._loadDefaultContextAsIs(progress); - }); + await defaultContext._loadDefaultContextAsIs(progress); return defaultContext; } diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index 0a5b7eac3aab5..bd7e934e7ddc2 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -191,12 +191,12 @@ export abstract class BrowserContext extends SdkObject { } async resetForReuse(metadata: CallMetadata, params: channels.BrowserNewContextForReuseParams | null) { - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); return controller.run(progress => this.resetForReuseImpl(progress, params)); } async resetForReuseImpl(progress: Progress, params: channels.BrowserNewContextForReuseParams | null) { - await this.tracing.resetForReuse(); + await progress.race(this.tracing.resetForReuse()); if (params) { for (const key of paramsThatAllowContextReuse) @@ -219,18 +219,22 @@ export abstract class BrowserContext extends SdkObject { await page?.mainFrame().gotoImpl(progress, 'about:blank', {}); await this._resetStorage(progress); - await this.clock.resetForReuse(); - // TODO: following can be optimized to not perform noops. - if (this._options.permissions) - await this.grantPermissions(this._options.permissions); - else - await this.clearPermissions(); - await this.setExtraHTTPHeaders(this._options.extraHTTPHeaders || []); - await this.setGeolocation(this._options.geolocation); - await this.setOffline(!!this._options.offline); - await this.setUserAgent(this._options.userAgent); - await this.clearCache(); - await this._resetCookies(); + + const resetOptions = async () => { + await this.clock.resetForReuse(); + // TODO: following can be optimized to not perform noops. + if (this._options.permissions) + await this.grantPermissions(this._options.permissions); + else + await this.clearPermissions(); + await this.setExtraHTTPHeaders(this._options.extraHTTPHeaders || []); + await this.setGeolocation(this._options.geolocation); + await this.setOffline(!!this._options.offline); + await this.setUserAgent(this._options.userAgent); + await this.clearCache(); + await this._resetCookies(); + }; + await progress.race(resetOptions()); await page?.resetForReuse(progress); } diff --git a/packages/playwright-core/src/server/chromium/videoRecorder.ts b/packages/playwright-core/src/server/chromium/videoRecorder.ts index 160fd354812b6..5b15cf5a20344 100644 --- a/packages/playwright-core/src/server/chromium/videoRecorder.ts +++ b/packages/playwright-core/src/server/chromium/videoRecorder.ts @@ -42,10 +42,11 @@ export class VideoRecorder { if (!options.outputFile.endsWith('.webm')) throw new Error('File must have .webm extension'); - const controller = new ProgressController(serverSideCallMetadata(), page); + const controller = new ProgressController(serverSideCallMetadata(), page, 'strict'); controller.setLogName('browser'); return await controller.run(async progress => { const recorder = new VideoRecorder(page, ffmpegPath, progress); + progress.cleanupWhenAborted(() => recorder.stop()); await recorder._launch(options); return recorder; }); diff --git a/packages/playwright-core/src/server/dispatchers/androidDispatcher.ts b/packages/playwright-core/src/server/dispatchers/androidDispatcher.ts index ea5d31461c961..6972d50f2d21c 100644 --- a/packages/playwright-core/src/server/dispatchers/androidDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/androidDispatcher.ts @@ -160,10 +160,10 @@ export class AndroidDeviceDispatcher extends Dispatcher { + async launchBrowser(params: channels.AndroidDeviceLaunchBrowserParams, metadata: CallMetadata): Promise { if (this.parentScope()._denyLaunch) throw new Error(`Launching more browsers is not allowed.`); - const context = await this._object.launchBrowser(params.pkg, params); + const context = await this._object.launchBrowser(metadata, params.pkg, params); return { context: BrowserContextDispatcher.from(this, context) }; } @@ -171,10 +171,10 @@ export class AndroidDeviceDispatcher extends Dispatcher { + async connectToWebView(params: channels.AndroidDeviceConnectToWebViewParams, metadata: CallMetadata): Promise { if (this.parentScope()._denyLaunch) throw new Error(`Launching more browsers is not allowed.`); - return { context: BrowserContextDispatcher.from(this, await this._object.connectToWebView(params.socketName)) }; + return { context: BrowserContextDispatcher.from(this, await this._object.connectToWebView(metadata, params.socketName)) }; } } diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index 5ce23ffb5cbe0..a0bc51b7f37c4 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -1370,9 +1370,9 @@ export class Frame extends SdkObject { } async ariaSnapshot(metadata: CallMetadata, selector: string, options: { forAI?: boolean } & types.TimeoutOptions): Promise { - const controller = new ProgressController(metadata, this); + const controller = new ProgressController(metadata, this, 'strict'); return controller.run(async progress => { - return await this._retryWithProgressIfNotConnected(progress, selector, true /* strict */, true /* performActionPreChecks */, handle => handle.ariaSnapshot(options)); + return await this._retryWithProgressIfNotConnected(progress, selector, true /* strict */, true /* performActionPreChecks */, handle => progress.race(handle.ariaSnapshot(options))); }, options.timeout); } @@ -1391,7 +1391,7 @@ export class Frame extends SdkObject { const start = timeout > 0 ? monotonicTime() : 0; // Step 1: perform locator handlers checkpoint with a specified timeout. - await (new ProgressController(metadata, this)).run(async progress => { + await (new ProgressController(metadata, this, 'strict')).run(async progress => { progress.log(`${renderTitleForCall(metadata)}${timeout ? ` with timeout ${timeout}ms` : ''}`); if (selector) progress.log(`waiting for ${this._asLocator(selector)}`); @@ -1402,7 +1402,7 @@ export class Frame extends SdkObject { // Supports the case of `expect(locator).toBeVisible({ timeout: 1 })` // that should succeed when the locator is already visible. try { - const resultOneShot = await (new ProgressController(metadata, this)).run(async progress => { + const resultOneShot = await (new ProgressController(metadata, this, 'strict')).run(async progress => { return await this._expectInternal(progress, selector, options, lastIntermediateResult); }); if (resultOneShot.matches !== options.isNot) @@ -1420,7 +1420,7 @@ export class Frame extends SdkObject { return { matches: options.isNot, log: compressCallLog(metadata.log), timedOut: true, received: lastIntermediateResult.received }; // Step 3: auto-retry expect with increasing timeouts. Bounded by the total remaining time. - return await (new ProgressController(metadata, this)).run(async progress => { + return await (new ProgressController(metadata, this, 'strict')).run(async progress => { return await this.retryWithProgressAndTimeouts(progress, [100, 250, 500, 1000], async continuePolling => { await this._page.performActionPreChecks(progress); const { matches, received } = await this._expectInternal(progress, selector, options, lastIntermediateResult); @@ -1448,16 +1448,14 @@ export class Frame extends SdkObject { } private async _expectInternal(progress: Progress, selector: string | undefined, options: FrameExpectParams, lastIntermediateResult: { received?: any, isSet: boolean }) { - const selectorInFrame = selector ? await this.selectors.resolveFrameForSelector(selector, { strict: true }) : undefined; - progress.throwIfAborted(); + const selectorInFrame = selector ? await progress.race(this.selectors.resolveFrameForSelector(selector, { strict: true })) : undefined; const { frame, info } = selectorInFrame || { frame: this, info: undefined }; const world = options.expression === 'to.have.property' ? 'main' : (info?.world ?? 'utility'); - const context = await frame._context(world); - const injected = await context.injectedScript(); - progress.throwIfAborted(); + const context = await progress.race(frame._context(world)); + const injected = await progress.race(context.injectedScript()); - const { log, matches, received, missingReceived } = await injected.evaluate(async (injected, { info, options, callId }) => { + const { log, matches, received, missingReceived } = await progress.race(injected.evaluate(async (injected, { info, options, callId }) => { const elements = info ? injected.querySelectorAll(info.parsed, document) : []; if (callId) injected.markTargetElements(new Set(elements), callId); @@ -1470,7 +1468,7 @@ export class Frame extends SdkObject { else if (elements.length) log = ` locator resolved to ${injected.previewNode(elements[0])}`; return { log, ...await injected.expect(elements[0], options, elements) }; - }, { info, options, callId: progress.metadata.id }); + }, { info, options, callId: progress.metadata.id })); if (log) progress.log(log); diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index 162a2b6d91dda..9a9bf878a1ab1 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -812,10 +812,13 @@ export class Page extends SdkObject { this._isServerSideOnly = true; } - async snapshotForAI(metadata: CallMetadata): Promise { - this.lastSnapshotFrameIds = []; - const snapshot = await snapshotFrameForAI(metadata, this.mainFrame(), 0, this.lastSnapshotFrameIds); - return snapshot.join('\n'); + snapshotForAI(metadata: CallMetadata): Promise { + const controller = new ProgressController(metadata, this, 'strict'); + return controller.run(async progress => { + this.lastSnapshotFrameIds = []; + const snapshot = await snapshotFrameForAI(progress, this.mainFrame(), 0, this.lastSnapshotFrameIds); + return snapshot.join('\n'); + }); } } @@ -991,29 +994,26 @@ class FrameThrottler { } } -async function snapshotFrameForAI(metadata: CallMetadata, frame: frames.Frame, frameOrdinal: number, frameIds: string[]): Promise { +async function snapshotFrameForAI(progress: Progress, frame: frames.Frame, frameOrdinal: number, frameIds: string[]): Promise { // Only await the topmost navigations, inner frames will be empty when racing. - const controller = new ProgressController(metadata, frame); - const snapshot = await controller.run(progress => { - return frame.retryWithProgressAndTimeouts(progress, [1000, 2000, 4000, 8000], async continuePolling => { - try { - const context = await frame._utilityContext(); - const injectedScript = await context.injectedScript(); - const snapshotOrRetry = await injectedScript.evaluate((injected, refPrefix) => { - const node = injected.document.body; - if (!node) - return true; - return injected.ariaSnapshot(node, { forAI: true, refPrefix }); - }, frameOrdinal ? 'f' + frameOrdinal : ''); - if (snapshotOrRetry === true) - return continuePolling; - return snapshotOrRetry; - } catch (e) { - if (isAbortError(e) || isSessionClosedError(e) || js.isJavaScriptErrorInEvaluate(e)) - throw e; + const snapshot = await frame.retryWithProgressAndTimeouts(progress, [1000, 2000, 4000, 8000], async continuePolling => { + try { + const context = await progress.race(frame._utilityContext()); + const injectedScript = await progress.race(context.injectedScript()); + const snapshotOrRetry = await progress.race(injectedScript.evaluate((injected, refPrefix) => { + const node = injected.document.body; + if (!node) + return true; + return injected.ariaSnapshot(node, { forAI: true, refPrefix }); + }, frameOrdinal ? 'f' + frameOrdinal : '')); + if (snapshotOrRetry === true) return continuePolling; - } - }); + return snapshotOrRetry; + } catch (e) { + if (isAbortError(e) || isSessionClosedError(e) || js.isJavaScriptErrorInEvaluate(e)) + throw e; + return continuePolling; + } }); const lines = snapshot.split('\n'); @@ -1029,7 +1029,7 @@ async function snapshotFrameForAI(metadata: CallMetadata, frame: frames.Frame, f const ref = match[2]; const frameSelector = `aria-ref=${ref} >> internal:control=enter-frame`; const frameBodySelector = `${frameSelector} >> body`; - const child = await frame.selectors.resolveFrameForSelector(frameBodySelector, { strict: true }); + const child = await progress.race(frame.selectors.resolveFrameForSelector(frameBodySelector, { strict: true })); if (!child) { result.push(line); continue; @@ -1037,7 +1037,7 @@ async function snapshotFrameForAI(metadata: CallMetadata, frame: frames.Frame, f const frameOrdinal = frameIds.length + 1; frameIds.push(child.frame._id); try { - const childSnapshot = await snapshotFrameForAI(metadata, child.frame, frameOrdinal, frameIds); + const childSnapshot = await snapshotFrameForAI(progress, child.frame, frameOrdinal, frameIds); result.push(line + ':', ...childSnapshot.map(l => leadingSpace + ' ' + l)); } catch { result.push(line); diff --git a/packages/playwright-core/src/server/recorder/recorderApp.ts b/packages/playwright-core/src/server/recorder/recorderApp.ts index 5f69e225f2cfc..f9aea392e86da 100644 --- a/packages/playwright-core/src/server/recorder/recorderApp.ts +++ b/packages/playwright-core/src/server/recorder/recorderApp.ts @@ -121,7 +121,7 @@ export class RecorderApp extends EventEmitter implements IRecorderApp { timeout: 0, } }); - const controller = new ProgressController(serverSideCallMetadata(), context._browser); + const controller = new ProgressController(serverSideCallMetadata(), context._browser, 'strict'); await controller.run(async progress => { await context._browser._defaultContext!._loadDefaultContextAsIs(progress); }); diff --git a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts index 68a5fdd5c76b7..c202d65e6fc38 100644 --- a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts +++ b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts @@ -180,7 +180,7 @@ export async function openTraceViewerApp(url: string, browserName: string, optio }, }); - const controller = new ProgressController(serverSideCallMetadata(), context._browser); + const controller = new ProgressController(serverSideCallMetadata(), context._browser, 'strict'); await controller.run(async progress => { await context._browser._defaultContext!._loadDefaultContextAsIs(progress); }); From d3970a22d1df8ff2c96e0cb101d88738f3d63d70 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Fri, 20 Jun 2025 13:04:41 +0200 Subject: [PATCH 10/15] chore: smaller codex fixes (#36374) --- packages/playwright/src/matchers/toMatchSnapshot.ts | 2 +- packages/playwright/src/runner/testServer.ts | 2 +- packages/playwright/src/util.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/playwright/src/matchers/toMatchSnapshot.ts b/packages/playwright/src/matchers/toMatchSnapshot.ts index 98fc72a6f2f85..b2ef3c332326e 100644 --- a/packages/playwright/src/matchers/toMatchSnapshot.ts +++ b/packages/playwright/src/matchers/toMatchSnapshot.ts @@ -135,7 +135,7 @@ class SnapshotHelper { this.locator = locator; this.updateSnapshots = testInfo.config.updateSnapshots; - this.mimeType = mime.getType(path.basename(this.expectedPath)) ?? 'application/octet-string'; + this.mimeType = mime.getType(path.basename(this.expectedPath)) ?? 'application/octet-stream'; this.comparator = getComparator(this.mimeType); this.testInfo = testInfo; diff --git a/packages/playwright/src/runner/testServer.ts b/packages/playwright/src/runner/testServer.ts index 61be3b2929960..4a182fbdc72ee 100644 --- a/packages/playwright/src/runner/testServer.ts +++ b/packages/playwright/src/runner/testServer.ts @@ -142,7 +142,7 @@ export class TestServerDispatcher implements TestServerInterface { process.stdout.columns = params.cols; process.stdout.rows = params.rows; process.stderr.columns = params.cols; - process.stderr.columns = params.rows; + process.stderr.rows = params.rows; } async checkBrowsers(): Promise<{ hasBrowsers: boolean; }> { diff --git a/packages/playwright/src/util.ts b/packages/playwright/src/util.ts index 00a839cb19eb8..a7d120770ff44 100644 --- a/packages/playwright/src/util.ts +++ b/packages/playwright/src/util.ts @@ -147,7 +147,7 @@ export function createTitleMatcher(patterns: RegExp | RegExp[]): Matcher { }; } -export function mergeObjects(a: A | undefined | void, b: B | undefined | void, c: B | undefined | void): A & B & C { +export function mergeObjects(a: A | undefined | void, b: B | undefined | void, c: C | undefined | void): A & B & C { const result = { ...a } as any; for (const x of [b, c].filter(Boolean)) { for (const [name, value] of Object.entries(x as any)) { From 71088f6e9dce156ceecb3e381368942f9b6b0a8e Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Fri, 20 Jun 2025 13:40:11 +0200 Subject: [PATCH 11/15] chore: refactor browser creation from PlaywrightConnection into PlaywrightServer (#36369) Signed-off-by: Simon Knott Co-authored-by: Dmitry Gozman --- .../src/remote/playwrightConnection.ts | 249 +++--------------- .../src/remote/playwrightServer.ts | 227 ++++++++++++++-- .../dispatchers/playwrightDispatcher.ts | 2 +- 3 files changed, 233 insertions(+), 245 deletions(-) diff --git a/packages/playwright-core/src/remote/playwrightConnection.ts b/packages/playwright-core/src/remote/playwrightConnection.ts index 1a3dcb2a32327..d92caeee78835 100644 --- a/packages/playwright-core/src/remote/playwrightConnection.ts +++ b/packages/playwright-core/src/remote/playwrightConnection.ts @@ -14,63 +14,35 @@ * limitations under the License. */ -import { SocksProxy } from '../server/utils/socksProxy'; import { DispatcherConnection, PlaywrightDispatcher, RootDispatcher } from '../server'; import { AndroidDevice } from '../server/android/android'; import { Browser } from '../server/browser'; import { DebugControllerDispatcher } from '../server/dispatchers/debugControllerDispatcher'; -import { serverSideCallMetadata } from '../server/instrumentation'; -import { assert } from '../utils/isomorphic/assert'; -import { isUnderTest } from '../server/utils/debug'; import { startProfiling, stopProfiling } from '../server/utils/profiler'; -import { monotonicTime } from '../utils'; +import { monotonicTime, Semaphore } from '../utils'; import { debugLogger } from '../server/utils/debugLogger'; +import { PlaywrightDispatcherOptions } from '../server/dispatchers/playwrightDispatcher'; import type { DispatcherScope, Playwright } from '../server'; -import type { LaunchOptions } from '../server/types'; import type { WebSocket } from '../utilsBundle'; -import type * as channels from '@protocol/channels'; - -export type ClientType = 'controller' | 'launch-browser' | 'reuse-browser' | 'pre-launched-browser-or-android'; - -type Options = { - allowFSPaths: boolean, - socksProxyPattern: string | undefined, - browserName: string | null, - launchOptions: LaunchOptions, - sharedBrowser?: boolean, -}; - -type PreLaunched = { - browser?: Browser | undefined; - androidDevice?: AndroidDevice | undefined; - socksProxy?: SocksProxy | undefined; -}; export class PlaywrightConnection { private _ws: WebSocket; - private _onClose: () => void; + private _semaphore: Semaphore; private _dispatcherConnection: DispatcherConnection; private _cleanups: (() => Promise)[] = []; private _id: string; private _disconnected = false; - private _playwright: Playwright; - private _preLaunched: PreLaunched; - private _options: Options; private _root: DispatcherScope; private _profileName: string; - constructor(lock: Promise, clientType: ClientType, ws: WebSocket, options: Options, playwright: Playwright, preLaunched: PreLaunched, id: string, onClose: () => void) { + constructor(semaphore: Semaphore, ws: WebSocket, controller: boolean, playwright: Playwright, initialize: () => Promise }>, id: string) { this._ws = ws; - this._playwright = playwright; - this._preLaunched = preLaunched; - this._options = options; - options.launchOptions = filterLaunchOptions(options.launchOptions, options.allowFSPaths); - if (clientType === 'pre-launched-browser-or-android') - assert(preLaunched.browser || preLaunched.androidDevice); - this._onClose = onClose; + this._semaphore = semaphore; this._id = id; - this._profileName = `${new Date().toISOString()}-${clientType}`; + this._profileName = new Date().toISOString(); + + const lock = this._semaphore.acquire(); this._dispatcherConnection = new DispatcherConnection(); this._dispatcherConnection.onmessage = async message => { @@ -98,148 +70,39 @@ export class PlaywrightConnection { ws.on('close', () => this._onDisconnect()); ws.on('error', (error: Error) => this._onDisconnect(error)); - if (clientType === 'controller') { - this._root = this._initDebugControllerMode(); + if (controller) { + debugLogger.log('server', `[${this._id}] engaged reuse controller mode`); + this._root = new DebugControllerDispatcher(this._dispatcherConnection, playwright.debugController); return; } - this._root = new RootDispatcher(this._dispatcherConnection, async (scope, options) => { + this._root = new RootDispatcher(this._dispatcherConnection, async (scope, params) => { await startProfiling(); - if (clientType === 'reuse-browser') - return await this._initReuseBrowsersMode(scope, options); - if (clientType === 'pre-launched-browser-or-android') - return this._preLaunched.browser ? await this._initPreLaunchedBrowserMode(scope, options) : await this._initPreLaunchedAndroidMode(scope); - if (clientType === 'launch-browser') - return await this._initLaunchBrowserMode(scope, options); - throw new Error('Unsupported client type: ' + clientType); - }); - } - - private async _initLaunchBrowserMode(scope: RootDispatcher, options: channels.RootInitializeParams) { - debugLogger.log('server', `[${this._id}] engaged launch mode for "${this._options.browserName}"`); - const ownedSocksProxy = await this._createOwnedSocksProxy(); - const browser = await this._playwright[this._options.browserName as 'chromium'].launch(serverSideCallMetadata(), this._options.launchOptions); - browser.options.sdkLanguage = options.sdkLanguage; - - this._cleanups.push(() => browser.close({ reason: 'Connection terminated' })); - browser.on(Browser.Events.Disconnected, () => { - // Underlying browser did close for some reason - force disconnect the client. - this.close({ code: 1001, reason: 'Browser closed' }); - }); - - return new PlaywrightDispatcher(scope, this._playwright, { socksProxy: ownedSocksProxy, preLaunchedBrowser: browser, denyLaunch: true, }); - } - - private async _initPreLaunchedBrowserMode(scope: RootDispatcher, options: channels.RootInitializeParams) { - debugLogger.log('server', `[${this._id}] engaged pre-launched (browser) mode`); - // Note: connected client owns the socks proxy and configures the pattern. - this._preLaunched.socksProxy?.setPattern(this._options.socksProxyPattern); - - const browser = this._preLaunched.browser!; - browser.options.sdkLanguage = options.sdkLanguage; - browser.on(Browser.Events.Disconnected, () => { - // Underlying browser did close for some reason - force disconnect the client. - this.close({ code: 1001, reason: 'Browser closed' }); - }); - - const playwrightDispatcher = new PlaywrightDispatcher(scope, this._playwright, { - socksProxy: this._preLaunched.socksProxy, - preLaunchedBrowser: browser, - sharedBrowser: this._options.sharedBrowser, - denyLaunch: true, - }); - // In pre-launched mode, keep only the pre-launched browser. - for (const b of this._playwright.allBrowsers()) { - if (b !== browser) - await b.close({ reason: 'Connection terminated' }); - } - this._cleanups.push(() => playwrightDispatcher.cleanup()); - return playwrightDispatcher; - } - - private async _initPreLaunchedAndroidMode(scope: RootDispatcher) { - debugLogger.log('server', `[${this._id}] engaged pre-launched (Android) mode`); - const androidDevice = this._preLaunched.androidDevice!; - androidDevice.on(AndroidDevice.Events.Close, () => { - // Underlying browser did close for some reason - force disconnect the client. - this.close({ code: 1001, reason: 'Android device disconnected' }); - }); - const playwrightDispatcher = new PlaywrightDispatcher(scope, this._playwright, { preLaunchedAndroidDevice: androidDevice, denyLaunch: true }); - this._cleanups.push(() => playwrightDispatcher.cleanup()); - return playwrightDispatcher; - } - - private _initDebugControllerMode(): DebugControllerDispatcher { - debugLogger.log('server', `[${this._id}] engaged reuse controller mode`); - // Always create new instance based on the reused Playwright instance. - return new DebugControllerDispatcher(this._dispatcherConnection, this._playwright.debugController); - } - - private async _initReuseBrowsersMode(scope: RootDispatcher, options: channels.RootInitializeParams) { - // Note: reuse browser mode does not support socks proxy, because - // clients come and go, while the browser stays the same. - - debugLogger.log('server', `[${this._id}] engaged reuse browsers mode for ${this._options.browserName}`); - - const requestedOptions = launchOptionsHash(this._options.launchOptions); - let browser = this._playwright.allBrowsers().find(b => { - if (b.options.name !== this._options.browserName) - return false; - const existingOptions = launchOptionsHash(b.options.originalLaunchOptions); - return existingOptions === requestedOptions; - }); - - // Close remaining browsers of this type+channel. Keep different browser types for the speed. - for (const b of this._playwright.allBrowsers()) { - if (b === browser) - continue; - if (b.options.name === this._options.browserName && b.options.channel === this._options.launchOptions.channel) - await b.close({ reason: 'Connection terminated' }); - } - - if (!browser) { - browser = await this._playwright[(this._options.browserName || 'chromium') as 'chromium'].launch(serverSideCallMetadata(), { - ...this._options.launchOptions, - headless: !!process.env.PW_DEBUG_CONTROLLER_HEADLESS, - }); - browser.on(Browser.Events.Disconnected, () => { - // Underlying browser did close for some reason - force disconnect the client. - this.close({ code: 1001, reason: 'Browser closed' }); - }); - } - browser.options.sdkLanguage = options.sdkLanguage; - - this._cleanups.push(async () => { - // Don't close the pages so that user could debug them, - // but close all the empty browsers and contexts to clean up. - for (const browser of this._playwright.allBrowsers()) { - for (const context of browser.contexts()) { - if (!context.pages().length) - await context.close({ reason: 'Connection terminated' }); - else - await context.stopPendingOperations('Connection closed'); - } - if (!browser.contexts()) - await browser.close({ reason: 'Connection terminated' }); + const options = await initialize(); + if (options.preLaunchedBrowser) { + const browser = options.preLaunchedBrowser; + browser.options.sdkLanguage = params.sdkLanguage; + browser.on(Browser.Events.Disconnected, () => { + // Underlying browser did close for some reason - force disconnect the client. + this.close({ code: 1001, reason: 'Browser closed' }); + }); } - }); + if (options.preLaunchedAndroidDevice) { + const androidDevice = options.preLaunchedAndroidDevice; + androidDevice.on(AndroidDevice.Events.Close, () => { + // Underlying android device did close for some reason - force disconnect the client. + this.close({ code: 1001, reason: 'Android device disconnected' }); + }); + } + if (options.dispose) + this._cleanups.push(options.dispose); - const playwrightDispatcher = new PlaywrightDispatcher(scope, this._playwright, { preLaunchedBrowser: browser, denyLaunch: true }); - return playwrightDispatcher; - } + const dispatcher = new PlaywrightDispatcher(scope, playwright, options); + this._cleanups.push(() => dispatcher.cleanup()); - private async _createOwnedSocksProxy(): Promise { - if (!this._options.socksProxyPattern) { - this._options.launchOptions.socksProxyPort = undefined; - return; - } - const socksProxy = new SocksProxy(); - socksProxy.setPattern(this._options.socksProxyPattern); - this._options.launchOptions.socksProxyPort = await socksProxy.listen(0); - debugLogger.log('server', `[${this._id}] started socks proxy on port ${this._options.launchOptions.socksProxyPort}`); - this._cleanups.push(() => socksProxy.close()); - return socksProxy; + return dispatcher; + }); } private async _onDisconnect(error?: Error) { @@ -250,7 +113,7 @@ export class PlaywrightConnection { for (const cleanup of this._cleanups) await cleanup().catch(() => {}); await stopProfiling(this._profileName); - this._onClose(); + this._semaphore.release(); debugLogger.log('server', `[${this._id}] finished cleanup`); } @@ -275,47 +138,3 @@ export class PlaywrightConnection { } } } - -function launchOptionsHash(options: LaunchOptions) { - const copy = { ...options }; - for (const k of Object.keys(copy)) { - const key = k as keyof LaunchOptions; - if (copy[key] === defaultLaunchOptions[key]) - delete copy[key]; - } - for (const key of optionsThatAllowBrowserReuse) - delete copy[key]; - return JSON.stringify(copy); -} - -function filterLaunchOptions(options: LaunchOptions, allowFSPaths: boolean): LaunchOptions { - return { - channel: options.channel, - args: options.args, - ignoreAllDefaultArgs: options.ignoreAllDefaultArgs, - ignoreDefaultArgs: options.ignoreDefaultArgs, - timeout: options.timeout, - headless: options.headless, - proxy: options.proxy, - chromiumSandbox: options.chromiumSandbox, - firefoxUserPrefs: options.firefoxUserPrefs, - slowMo: options.slowMo, - executablePath: (isUnderTest() || allowFSPaths) ? options.executablePath : undefined, - downloadsPath: allowFSPaths ? options.downloadsPath : undefined, - }; -} - -const defaultLaunchOptions: Partial = { - ignoreAllDefaultArgs: false, - handleSIGINT: false, - handleSIGTERM: false, - handleSIGHUP: false, - headless: true, - devtools: false, -}; - -const optionsThatAllowBrowserReuse: (keyof LaunchOptions)[] = [ - 'headless', - 'timeout', - 'tracesDir', -]; diff --git a/packages/playwright-core/src/remote/playwrightServer.ts b/packages/playwright-core/src/remote/playwrightServer.ts index a6add53eb4e14..ced787c64bc40 100644 --- a/packages/playwright-core/src/remote/playwrightServer.ts +++ b/packages/playwright-core/src/remote/playwrightServer.ts @@ -21,9 +21,10 @@ import { DEFAULT_PLAYWRIGHT_LAUNCH_TIMEOUT } from '../utils/isomorphic/time'; import { WSServer } from '../server/utils/wsServer'; import { wrapInASCIIBox } from '../server/utils/ascii'; import { getPlaywrightVersion } from '../server/utils/userAgent'; +import { debugLogger, isUnderTest } from '../utils'; +import { serverSideCallMetadata } from '../server'; +import { SocksProxy } from '../server/utils/socksProxy'; -import type { ClientType } from './playwrightConnection'; -import type { SocksProxy } from '../server/utils/socksProxy'; import type { AndroidDevice } from '../server/android/android'; import type { Browser } from '../server/browser'; import type { Playwright } from '../server/playwright'; @@ -94,42 +95,166 @@ export class PlaywrightServer { } catch (e) { } - // Instantiate playwright for the extension modes. const isExtension = this._options.mode === 'extension'; - let clientType: ClientType = 'launch-browser'; - let semaphore: Semaphore = browserSemaphore; - if (isExtension && url.searchParams.has('debug-controller')) { - clientType = 'controller'; - semaphore = controllerSemaphore; - } else if (isExtension) { - clientType = 'reuse-browser'; - semaphore = reuseBrowserSemaphore; - } else if (this._options.mode === 'launchServer' || this._options.mode === 'launchServerShared') { - clientType = 'pre-launched-browser-or-android'; - semaphore = browserSemaphore; + const allowFSPaths = isExtension; + launchOptions = filterLaunchOptions(launchOptions, allowFSPaths); + + if (isExtension) { + if (url.searchParams.has('debug-controller')) { + return new PlaywrightConnection( + controllerSemaphore, + ws, + true, + this._playwright, + async () => { throw new Error('shouldnt be used'); }, + id, + ); + } + return new PlaywrightConnection( + reuseBrowserSemaphore, + ws, + false, + this._playwright, + () => this._initReuseBrowsersMode(browserName, launchOptions, id), + id, + ); + } + + if (this._options.mode === 'launchServer' || this._options.mode === 'launchServerShared') { + if (this._options.preLaunchedBrowser) { + return new PlaywrightConnection( + browserSemaphore, + ws, + false, + this._playwright, + () => this._initPreLaunchedBrowserMode(id), + id, + ); + } + + return new PlaywrightConnection( + browserSemaphore, + ws, + false, + this._playwright, + () => this._initPreLaunchedAndroidMode(id), + id, + ); } return new PlaywrightConnection( - semaphore.acquire(), - clientType, ws, - { - socksProxyPattern: proxyValue, - browserName, - launchOptions, - allowFSPaths: this._options.mode === 'extension', - sharedBrowser: this._options.mode === 'launchServerShared', - }, + browserSemaphore, + ws, + false, this._playwright, - { - browser: this._options.preLaunchedBrowser, - androidDevice: this._options.preLaunchedAndroidDevice, - socksProxy: this._options.preLaunchedSocksProxy, - }, - id, () => semaphore.release()); + () => this._initLaunchBrowserMode(browserName, proxyValue, launchOptions, id), + id, + ); }, }); } + private async _initReuseBrowsersMode(browserName: string | null, launchOptions: LaunchOptions, id: string) { + // Note: reuse browser mode does not support socks proxy, because + // clients come and go, while the browser stays the same. + + debugLogger.log('server', `[${id}] engaged reuse browsers mode for ${browserName}`); + + const requestedOptions = launchOptionsHash(launchOptions); + let browser = this._playwright.allBrowsers().find(b => { + if (b.options.name !== browserName) + return false; + const existingOptions = launchOptionsHash(b.options.originalLaunchOptions); + return existingOptions === requestedOptions; + }); + + // Close remaining browsers of this type+channel. Keep different browser types for the speed. + for (const b of this._playwright.allBrowsers()) { + if (b === browser) + continue; + if (b.options.name === browserName && b.options.channel === launchOptions.channel) + await b.close({ reason: 'Connection terminated' }); + } + + if (!browser) { + browser = await this._playwright[(browserName || 'chromium') as 'chromium'].launch(serverSideCallMetadata(), { + ...launchOptions, + headless: !!process.env.PW_DEBUG_CONTROLLER_HEADLESS, + }); + } + + return { + preLaunchedBrowser: browser, + denyLaunch: true, + dispose: async () => { + // Don't close the pages so that user could debug them, + // but close all the empty browsers and contexts to clean up. + for (const browser of this._playwright.allBrowsers()) { + for (const context of browser.contexts()) { + if (!context.pages().length) + await context.close({ reason: 'Connection terminated' }); + else + await context.stopPendingOperations('Connection closed'); + } + if (!browser.contexts()) + await browser.close({ reason: 'Connection terminated' }); + } + } + }; + } + + private async _initPreLaunchedBrowserMode(id: string) { + debugLogger.log('server', `[${id}] engaged pre-launched (browser) mode`); + + const browser = this._options.preLaunchedBrowser!; + + // In pre-launched mode, keep only the pre-launched browser. + for (const b of this._playwright.allBrowsers()) { + if (b !== browser) + await b.close({ reason: 'Connection terminated' }); + } + + return { + preLaunchedBrowser: browser, + socksProxy: this._options.preLaunchedSocksProxy, + sharedBrowser: this._options.mode === 'launchServerShared', + denyLaunch: true, + }; + } + + private async _initPreLaunchedAndroidMode(id: string) { + debugLogger.log('server', `[${id}] engaged pre-launched (Android) mode`); + const androidDevice = this._options.preLaunchedAndroidDevice!; + return { + preLaunchedAndroidDevice: androidDevice, + denyLaunch: true, + }; + } + + private async _initLaunchBrowserMode(browserName: string | null, proxyValue: string | undefined, launchOptions: LaunchOptions, id: string) { + debugLogger.log('server', `[${id}] engaged launch mode for "${browserName}"`); + let socksProxy: SocksProxy | undefined; + if (proxyValue) { + socksProxy = new SocksProxy(); + socksProxy.setPattern(proxyValue); + launchOptions.socksProxyPort = await socksProxy.listen(0); + debugLogger.log('server', `[${id}] started socks proxy on port ${launchOptions.socksProxyPort}`); + } else { + launchOptions.socksProxyPort = undefined; + } + const browser = await this._playwright[browserName as 'chromium'].launch(serverSideCallMetadata(), launchOptions); + return { + preLaunchedBrowser: browser, + socksProxy, + sharedBrowser: true, + denyLaunch: true, + dispose: async () => { + await browser.close({ reason: 'Connection terminated' }); + socksProxy?.close(); + }, + }; + } + async listen(port: number = 0, hostname?: string): Promise { return this._wsServer.listen(port, hostname, this._options.path); } @@ -163,3 +288,47 @@ function userAgentVersionMatchesErrorMessage(userAgent: string) { ].join('\n'), 1); } } + +function launchOptionsHash(options: LaunchOptions) { + const copy = { ...options }; + for (const k of Object.keys(copy)) { + const key = k as keyof LaunchOptions; + if (copy[key] === defaultLaunchOptions[key]) + delete copy[key]; + } + for (const key of optionsThatAllowBrowserReuse) + delete copy[key]; + return JSON.stringify(copy); +} + +function filterLaunchOptions(options: LaunchOptions, allowFSPaths: boolean): LaunchOptions { + return { + channel: options.channel, + args: options.args, + ignoreAllDefaultArgs: options.ignoreAllDefaultArgs, + ignoreDefaultArgs: options.ignoreDefaultArgs, + timeout: options.timeout, + headless: options.headless, + proxy: options.proxy, + chromiumSandbox: options.chromiumSandbox, + firefoxUserPrefs: options.firefoxUserPrefs, + slowMo: options.slowMo, + executablePath: (isUnderTest() || allowFSPaths) ? options.executablePath : undefined, + downloadsPath: allowFSPaths ? options.downloadsPath : undefined, + }; +} + +const defaultLaunchOptions: Partial = { + ignoreAllDefaultArgs: false, + handleSIGINT: false, + handleSIGTERM: false, + handleSIGHUP: false, + headless: true, + devtools: false, +}; + +const optionsThatAllowBrowserReuse: (keyof LaunchOptions)[] = [ + 'headless', + 'timeout', + 'tracesDir', +]; diff --git a/packages/playwright-core/src/server/dispatchers/playwrightDispatcher.ts b/packages/playwright-core/src/server/dispatchers/playwrightDispatcher.ts index 04bb144d1176a..a80aa49456299 100644 --- a/packages/playwright-core/src/server/dispatchers/playwrightDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/playwrightDispatcher.ts @@ -35,7 +35,7 @@ import type { Browser } from '../browser'; import type { Playwright } from '../playwright'; import type * as channels from '@protocol/channels'; -type PlaywrightDispatcherOptions = { +export type PlaywrightDispatcherOptions = { socksProxy?: SocksProxy; denyLaunch?: boolean; preLaunchedBrowser?: Browser; From 73f840c1d014bbdf4d8d8da594e89430cdbcffa4 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Fri, 20 Jun 2025 12:50:52 +0100 Subject: [PATCH 12/15] chore: use isNonRetriableError in more places (#36373) --- packages/playwright-core/src/server/dom.ts | 7 +++---- packages/playwright-core/src/server/frames.ts | 12 ++++++------ packages/playwright-core/src/server/page.ts | 9 ++++----- 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/packages/playwright-core/src/server/dom.ts b/packages/playwright-core/src/server/dom.ts index ce9ce887c03b3..35ed3cabd2bcf 100644 --- a/packages/playwright-core/src/server/dom.ts +++ b/packages/playwright-core/src/server/dom.ts @@ -17,10 +17,9 @@ import fs from 'fs'; import * as js from './javascript'; -import { isAbortError, ProgressController } from './progress'; +import { ProgressController } from './progress'; import { asLocator, isUnderTest } from '../utils'; import { prepareFilesForUpload } from './fileUploadUtils'; -import { isSessionClosedError } from './protocolError'; import * as rawInjectedScriptSource from '../generated/injectedScriptSource'; import type * as frames from './frames'; @@ -141,7 +140,7 @@ export class ElementHandle extends js.JSHandle { const utility = await this._frame._utilityContext(); return await utility.evaluate(pageFunction, [await utility.injectedScript(), this, arg]); } catch (e) { - if (isAbortError(e) || js.isJavaScriptErrorInEvaluate(e) || isSessionClosedError(e)) + if (this._frame.isNonRetriableError(e)) throw e; return 'error:notconnected'; } @@ -152,7 +151,7 @@ export class ElementHandle extends js.JSHandle { const utility = await this._frame._utilityContext(); return await utility.evaluateHandle(pageFunction, [await utility.injectedScript(), this, arg]); } catch (e) { - if (isAbortError(e) || js.isJavaScriptErrorInEvaluate(e) || isSessionClosedError(e)) + if (this._frame.isNonRetriableError(e)) throw e; return 'error:notconnected'; } diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index a0bc51b7f37c4..9e32a7d8370a2 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -864,7 +864,7 @@ export class Frame extends SdkObject { return retVal; }); } catch (e) { - if (isAbortError(e) || js.isJavaScriptErrorInEvaluate(e) || isSessionClosedError(e)) + if (this.isNonRetriableError(e)) throw e; throw new Error(`Unable to retrieve content because the page is navigating and changing the content.`); } @@ -1060,14 +1060,14 @@ export class Frame extends SdkObject { continue; return result as R; } catch (e) { - if (this._isErrorThatCannotBeRetried(e)) + if (this.isNonRetriableError(e)) throw e; continue; } } } - private _isErrorThatCannotBeRetried(e: Error) { + isNonRetriableError(e: Error) { if (isAbortError(e)) return true; // Always fail on JavaScript errors or when the main connection is closed. @@ -1288,7 +1288,7 @@ export class Frame extends SdkObject { return state.matches; }, { info: resolved.info, root: resolved.frame === this ? scope : undefined })); } catch (e) { - if (isAbortError(e) || js.isJavaScriptErrorInEvaluate(e) || isInvalidSelectorError(e) || isSessionClosedError(e)) + if (this.isNonRetriableError(e)) throw e; return false; } @@ -1408,7 +1408,7 @@ export class Frame extends SdkObject { if (resultOneShot.matches !== options.isNot) return resultOneShot; } catch (e) { - if (isAbortError(e) || js.isJavaScriptErrorInEvaluate(e) || isInvalidSelectorError(e)) + if (this.isNonRetriableError(e)) throw e; // Ignore any other errors from one-shot, we'll handle them during retries. } @@ -1434,7 +1434,7 @@ export class Frame extends SdkObject { }); }, timeout); } catch (e) { - // Q: Why not throw upon isSessionClosedError(e) as in other places? + // Q: Why not throw upon isNonRetriableError(e) as in other places? // A: We want user to receive a friendly message containing the last intermediate result. if (js.isJavaScriptErrorInEvaluate(e) || isInvalidSelectorError(e)) throw e; diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index 9a9bf878a1ab1..42ad32e9c020e 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -25,7 +25,7 @@ import { helper } from './helper'; import * as input from './input'; import { SdkObject } from './instrumentation'; import * as js from './javascript'; -import { isAbortError, ProgressController } from './progress'; +import { ProgressController } from './progress'; import { Screenshotter, validateScreenshotOptions } from './screenshotter'; import { LongStandingScope, assert, renderTitleForCall, trimStringWithEllipsis } from '../utils'; import { asLocator } from '../utils'; @@ -36,7 +36,6 @@ import { ManualPromise } from '../utils/isomorphic/manualPromise'; import { parseEvaluationResultValue } from '../utils/isomorphic/utilityScriptSerializers'; import { compressCallLog } from './callLog'; import * as rawBindingsControllerSource from '../generated/bindingsControllerSource'; -import { isSessionClosedError } from './protocolError'; import type { Artifact } from './artifact'; import type * as dom from './dom'; @@ -643,7 +642,7 @@ export class Page extends SdkObject { progress.log(`waiting ${screenshotTimeout}ms before taking screenshot`); previous = actual; actual = await rafrafScreenshot(progress, screenshotTimeout).catch(e => { - if (isAbortError(e)) + if (this.mainFrame().isNonRetriableError(e)) throw e; progress.log(`failed to take screenshot - ` + e.message); return undefined; @@ -676,7 +675,7 @@ export class Page extends SdkObject { } throw new Error(intermediateResult!.errorMessage); }, callTimeout).catch(e => { - // Q: Why not throw upon isSessionClosedError(e) as in other places? + // Q: Why not throw upon isNonRetriableError(e) as in other places? // A: We want user to receive a friendly diff between actual and expected/previous. if (js.isJavaScriptErrorInEvaluate(e) || isInvalidSelectorError(e)) throw e; @@ -1010,7 +1009,7 @@ async function snapshotFrameForAI(progress: Progress, frame: frames.Frame, frame return continuePolling; return snapshotOrRetry; } catch (e) { - if (isAbortError(e) || isSessionClosedError(e) || js.isJavaScriptErrorInEvaluate(e)) + if (frame.isNonRetriableError(e)) throw e; return continuePolling; } From 556fea9444681220982cf7797cb98fdf33522d96 Mon Sep 17 00:00:00 2001 From: Kevin Tan Date: Fri, 20 Jun 2025 20:09:32 +0800 Subject: [PATCH 13/15] fix: adding trialing slash detection logic back in urlToWSEndpoint (#36357) --- .../src/server/chromium/chromium.ts | 2 ++ tests/library/chromium/connect-over-cdp.spec.ts | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/packages/playwright-core/src/server/chromium/chromium.ts b/packages/playwright-core/src/server/chromium/chromium.ts index b35fbb83f0828..fdeabb672656a 100644 --- a/packages/playwright-core/src/server/chromium/chromium.ts +++ b/packages/playwright-core/src/server/chromium/chromium.ts @@ -382,6 +382,8 @@ async function urlToWSEndpoint(progress: Progress, endpointURL: string, headers: return endpointURL; progress.log(` retrieving websocket url from ${endpointURL}`); const url = new URL(endpointURL); + if (!url.pathname.endsWith('/')) + url.pathname = url.pathname + '/'; url.pathname += 'json/version/'; const httpURL = url.toString(); diff --git a/tests/library/chromium/connect-over-cdp.spec.ts b/tests/library/chromium/connect-over-cdp.spec.ts index d5ff7915675a4..389f8838eef37 100644 --- a/tests/library/chromium/connect-over-cdp.spec.ts +++ b/tests/library/chromium/connect-over-cdp.spec.ts @@ -41,6 +41,23 @@ test('should connect to an existing cdp session', async ({ browserType, mode }, } }); +test('should connect to an existing cdp session with verbose path', async ({ browserType, mode }, testInfo) => { + const port = 9339 + testInfo.workerIndex; + const browserServer = await browserType.launch({ + args: ['--remote-debugging-port=' + port] + }); + try { + const cdpBrowser = await browserType.connectOverCDP({ + endpointURL: `http://127.0.0.1:${port}/json/version/abcdefg`, + }); + const contexts = cdpBrowser.contexts(); + expect(contexts.length).toBe(1); + await cdpBrowser.close(); + } finally { + await browserServer.close(); + } +}); + test('should use logger in default context', async ({ browserType }, testInfo) => { test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/28813' }); const port = 9339 + testInfo.workerIndex; From 0027bd97cb080220051cadc8f67ed66c3caf5404 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Fri, 20 Jun 2025 16:26:43 +0200 Subject: [PATCH 14/15] chore: browserserver, design two (#36382) --- .../playwright-core/src/client/browserType.ts | 4 +- .../src/remote/playwrightConnection.ts | 6 +- .../src/remote/playwrightServer.ts | 62 ++++++++++++++++--- 3 files changed, 62 insertions(+), 10 deletions(-) diff --git a/packages/playwright-core/src/client/browserType.ts b/packages/playwright-core/src/client/browserType.ts index a9116101b6cab..e41b0db69a325 100644 --- a/packages/playwright-core/src/client/browserType.ts +++ b/packages/playwright-core/src/client/browserType.ts @@ -119,8 +119,8 @@ export class BrowserType extends ChannelOwner imple }); } - connect(options: api.ConnectOptions & { wsEndpoint: string }): Promise; - connect(wsEndpoint: string, options?: api.ConnectOptions): Promise; + connect(options: api.ConnectOptions & { wsEndpoint: string }): Promise; + connect(wsEndpoint: string, options?: api.ConnectOptions): Promise; async connect(optionsOrWsEndpoint: string | (api.ConnectOptions & { wsEndpoint: string }), options?: api.ConnectOptions): Promise{ if (typeof optionsOrWsEndpoint === 'string') return await this._connect({ ...options, wsEndpoint: optionsOrWsEndpoint }); diff --git a/packages/playwright-core/src/remote/playwrightConnection.ts b/packages/playwright-core/src/remote/playwrightConnection.ts index d92caeee78835..8a529f4b7a48a 100644 --- a/packages/playwright-core/src/remote/playwrightConnection.ts +++ b/packages/playwright-core/src/remote/playwrightConnection.ts @@ -26,6 +26,10 @@ import { PlaywrightDispatcherOptions } from '../server/dispatchers/playwrightDis import type { DispatcherScope, Playwright } from '../server'; import type { WebSocket } from '../utilsBundle'; +export interface PlaywrightInitializeResult extends PlaywrightDispatcherOptions { + dispose?(): Promise; +} + export class PlaywrightConnection { private _ws: WebSocket; private _semaphore: Semaphore; @@ -36,7 +40,7 @@ export class PlaywrightConnection { private _root: DispatcherScope; private _profileName: string; - constructor(semaphore: Semaphore, ws: WebSocket, controller: boolean, playwright: Playwright, initialize: () => Promise }>, id: string) { + constructor(semaphore: Semaphore, ws: WebSocket, controller: boolean, playwright: Playwright, initialize: () => Promise, id: string) { this._ws = ws; this._semaphore = semaphore; this._id = id; diff --git a/packages/playwright-core/src/remote/playwrightServer.ts b/packages/playwright-core/src/remote/playwrightServer.ts index ced787c64bc40..6eb077fa929e7 100644 --- a/packages/playwright-core/src/remote/playwrightServer.ts +++ b/packages/playwright-core/src/remote/playwrightServer.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { PlaywrightConnection } from './playwrightConnection'; +import { PlaywrightConnection, PlaywrightInitializeResult } from './playwrightConnection'; import { createPlaywright } from '../server/playwright'; import { Semaphore } from '../utils/isomorphic/semaphore'; import { DEFAULT_PLAYWRIGHT_LAUNCH_TIMEOUT } from '../utils/isomorphic/time'; @@ -24,9 +24,9 @@ import { getPlaywrightVersion } from '../server/utils/userAgent'; import { debugLogger, isUnderTest } from '../utils'; import { serverSideCallMetadata } from '../server'; import { SocksProxy } from '../server/utils/socksProxy'; +import { Browser } from '../server/browser'; import type { AndroidDevice } from '../server/android/android'; -import type { Browser } from '../server/browser'; import type { Playwright } from '../server/playwright'; import type { LaunchOptions } from '../server/types'; @@ -45,10 +45,14 @@ export class PlaywrightServer { private _options: ServerOptions; private _wsServer: WSServer; + private _dontReuseBrowsers = new Set(); + constructor(options: ServerOptions) { this._options = options; - if (options.preLaunchedBrowser) + if (options.preLaunchedBrowser) { this._playwright = options.preLaunchedBrowser.attribution.playwright; + this._dontReuse(options.preLaunchedBrowser); + } if (options.preLaunchedAndroidDevice) this._playwright = options.preLaunchedAndroidDevice._android.attribution.playwright; this._playwright ??= createPlaywright({ sdkLanguage: 'javascript', isServer: true }); @@ -99,6 +103,20 @@ export class PlaywrightServer { const allowFSPaths = isExtension; launchOptions = filterLaunchOptions(launchOptions, allowFSPaths); + if (process.env.PW_BROWSER_SERVER && url.searchParams.has('connect')) { + const filter = url.searchParams.get('connect'); + if (filter !== 'first') + throw new Error(`Unknown connect filter: ${filter}`); + return new PlaywrightConnection( + browserSemaphore, + ws, + false, + this._playwright, + () => this._initConnectMode(id, filter, browserName, launchOptions), + id, + ); + } + if (isExtension) { if (url.searchParams.has('debug-controller')) { return new PlaywrightConnection( @@ -154,7 +172,7 @@ export class PlaywrightServer { }); } - private async _initReuseBrowsersMode(browserName: string | null, launchOptions: LaunchOptions, id: string) { + private async _initReuseBrowsersMode(browserName: string | null, launchOptions: LaunchOptions, id: string): Promise { // Note: reuse browser mode does not support socks proxy, because // clients come and go, while the browser stays the same. @@ -164,6 +182,8 @@ export class PlaywrightServer { let browser = this._playwright.allBrowsers().find(b => { if (b.options.name !== browserName) return false; + if (this._dontReuseBrowsers.has(b)) + return false; const existingOptions = launchOptionsHash(b.options.originalLaunchOptions); return existingOptions === requestedOptions; }); @@ -172,6 +192,8 @@ export class PlaywrightServer { for (const b of this._playwright.allBrowsers()) { if (b === browser) continue; + if (this._dontReuseBrowsers.has(b)) + continue; if (b.options.name === browserName && b.options.channel === launchOptions.channel) await b.close({ reason: 'Connection terminated' }); } @@ -203,7 +225,25 @@ export class PlaywrightServer { }; } - private async _initPreLaunchedBrowserMode(id: string) { + private async _initConnectMode(id: string, filter: 'first', browserName: string | null, launchOptions: LaunchOptions): Promise { + browserName ??= 'chromium'; + + debugLogger.log('server', `[${id}] engaged connect mode`); + + let browser = this._playwright.allBrowsers().find(b => b.options.name === browserName); + if (!browser) { + browser = await this._playwright[browserName as 'chromium'].launch(serverSideCallMetadata(), launchOptions); + this._dontReuse(browser); + } + + return { + preLaunchedBrowser: browser, + denyLaunch: true, + sharedBrowser: true, + }; + } + + private async _initPreLaunchedBrowserMode(id: string): Promise { debugLogger.log('server', `[${id}] engaged pre-launched (browser) mode`); const browser = this._options.preLaunchedBrowser!; @@ -222,7 +262,7 @@ export class PlaywrightServer { }; } - private async _initPreLaunchedAndroidMode(id: string) { + private async _initPreLaunchedAndroidMode(id: string): Promise { debugLogger.log('server', `[${id}] engaged pre-launched (Android) mode`); const androidDevice = this._options.preLaunchedAndroidDevice!; return { @@ -231,7 +271,7 @@ export class PlaywrightServer { }; } - private async _initLaunchBrowserMode(browserName: string | null, proxyValue: string | undefined, launchOptions: LaunchOptions, id: string) { + private async _initLaunchBrowserMode(browserName: string | null, proxyValue: string | undefined, launchOptions: LaunchOptions, id: string): Promise { debugLogger.log('server', `[${id}] engaged launch mode for "${browserName}"`); let socksProxy: SocksProxy | undefined; if (proxyValue) { @@ -243,6 +283,7 @@ export class PlaywrightServer { launchOptions.socksProxyPort = undefined; } const browser = await this._playwright[browserName as 'chromium'].launch(serverSideCallMetadata(), launchOptions); + this._dontReuseBrowsers.add(browser); return { preLaunchedBrowser: browser, socksProxy, @@ -255,6 +296,13 @@ export class PlaywrightServer { }; } + private _dontReuse(browser: Browser) { + this._dontReuseBrowsers.add(browser); + browser.on(Browser.Events.Disconnected, () => { + this._dontReuseBrowsers.delete(browser); + }); + } + async listen(port: number = 0, hostname?: string): Promise { return this._wsServer.listen(port, hostname, this._options.path); } From d8c257f79b2729190be78ce8b6fad6664226ff16 Mon Sep 17 00:00:00 2001 From: "microsoft-playwright-automation[bot]" <203992400+microsoft-playwright-automation[bot]@users.noreply.github.com> Date: Fri, 20 Jun 2025 17:48:52 +0200 Subject: [PATCH 15/15] feat(chromium): roll to r1180 (#36384) Co-authored-by: microsoft-playwright-automation[bot] <203992400+microsoft-playwright-automation[bot]@users.noreply.github.com> --- README.md | 4 +- packages/playwright-core/browsers.json | 8 +- .../src/server/deviceDescriptorsSource.json | 108 +++++++++--------- 3 files changed, 60 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index d912d0d166770..96ab31796073a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 🎭 Playwright -[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) [![Chromium version](https://img.shields.io/badge/chromium-138.0.7204.23-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-139.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-18.5-blue.svg?logo=safari)](https://webkit.org/) [![Join Discord](https://img.shields.io/badge/join-discord-informational)](https://aka.ms/playwright/discord) +[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) [![Chromium version](https://img.shields.io/badge/chromium-138.0.7204.35-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-139.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-18.5-blue.svg?logo=safari)](https://webkit.org/) [![Join Discord](https://img.shields.io/badge/join-discord-informational)](https://aka.ms/playwright/discord) ## [Documentation](https://playwright.dev) | [API reference](https://playwright.dev/docs/api/class-playwright) @@ -8,7 +8,7 @@ Playwright is a framework for Web Testing and Automation. It allows testing [Chr | | Linux | macOS | Windows | | :--- | :---: | :---: | :---: | -| Chromium 138.0.7204.23 | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Chromium 138.0.7204.35 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | WebKit 18.5 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Firefox 139.0 | :white_check_mark: | :white_check_mark: | :white_check_mark: | diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index ae4ec5d7beb55..fe6214087fb93 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -3,15 +3,15 @@ "browsers": [ { "name": "chromium", - "revision": "1179", + "revision": "1180", "installByDefault": true, - "browserVersion": "138.0.7204.23" + "browserVersion": "138.0.7204.35" }, { "name": "chromium-headless-shell", - "revision": "1179", + "revision": "1180", "installByDefault": true, - "browserVersion": "138.0.7204.23" + "browserVersion": "138.0.7204.35" }, { "name": "chromium-tip-of-tree", diff --git a/packages/playwright-core/src/server/deviceDescriptorsSource.json b/packages/playwright-core/src/server/deviceDescriptorsSource.json index c42a1abf3e4b0..f260c0d5e39d9 100644 --- a/packages/playwright-core/src/server/deviceDescriptorsSource.json +++ b/packages/playwright-core/src/server/deviceDescriptorsSource.json @@ -110,7 +110,7 @@ "defaultBrowserType": "webkit" }, "Galaxy S5": { - "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 360, "height": 640 @@ -121,7 +121,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S5 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 640, "height": 360 @@ -132,7 +132,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S8": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 360, "height": 740 @@ -143,7 +143,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S8 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 740, "height": 360 @@ -154,7 +154,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S9+": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 320, "height": 658 @@ -165,7 +165,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S9+ landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 658, "height": 320 @@ -176,7 +176,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S24": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; SM-S921U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; SM-S921U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 480, "height": 1040 @@ -187,7 +187,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S24 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; SM-S921U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; SM-S921U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 1040, "height": 480 @@ -198,7 +198,7 @@ "defaultBrowserType": "chromium" }, "Galaxy A55": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; SM-A556B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; SM-A556B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 480, "height": 1040 @@ -209,7 +209,7 @@ "defaultBrowserType": "chromium" }, "Galaxy A55 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; SM-A556B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; SM-A556B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 1040, "height": 480 @@ -220,7 +220,7 @@ "defaultBrowserType": "chromium" }, "Galaxy Tab S4": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Safari/537.36", "viewport": { "width": 712, "height": 1138 @@ -231,7 +231,7 @@ "defaultBrowserType": "chromium" }, "Galaxy Tab S4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Safari/537.36", "viewport": { "width": 1138, "height": 712 @@ -242,7 +242,7 @@ "defaultBrowserType": "chromium" }, "Galaxy Tab S9": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; SM-X710) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; SM-X710) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Safari/537.36", "viewport": { "width": 640, "height": 1024 @@ -253,7 +253,7 @@ "defaultBrowserType": "chromium" }, "Galaxy Tab S9 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; SM-X710) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; SM-X710) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Safari/537.36", "viewport": { "width": 1024, "height": 640 @@ -1208,7 +1208,7 @@ "defaultBrowserType": "webkit" }, "LG Optimus L70": { - "userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 384, "height": 640 @@ -1219,7 +1219,7 @@ "defaultBrowserType": "chromium" }, "LG Optimus L70 landscape": { - "userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 640, "height": 384 @@ -1230,7 +1230,7 @@ "defaultBrowserType": "chromium" }, "Microsoft Lumia 550": { - "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36 Edge/14.14263", + "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 360, "height": 640 @@ -1241,7 +1241,7 @@ "defaultBrowserType": "chromium" }, "Microsoft Lumia 550 landscape": { - "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36 Edge/14.14263", + "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 640, "height": 360 @@ -1252,7 +1252,7 @@ "defaultBrowserType": "chromium" }, "Microsoft Lumia 950": { - "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36 Edge/14.14263", + "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 360, "height": 640 @@ -1263,7 +1263,7 @@ "defaultBrowserType": "chromium" }, "Microsoft Lumia 950 landscape": { - "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36 Edge/14.14263", + "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 640, "height": 360 @@ -1274,7 +1274,7 @@ "defaultBrowserType": "chromium" }, "Nexus 10": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Safari/537.36", "viewport": { "width": 800, "height": 1280 @@ -1285,7 +1285,7 @@ "defaultBrowserType": "chromium" }, "Nexus 10 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Safari/537.36", "viewport": { "width": 1280, "height": 800 @@ -1296,7 +1296,7 @@ "defaultBrowserType": "chromium" }, "Nexus 4": { - "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 384, "height": 640 @@ -1307,7 +1307,7 @@ "defaultBrowserType": "chromium" }, "Nexus 4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 640, "height": 384 @@ -1318,7 +1318,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 360, "height": 640 @@ -1329,7 +1329,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 640, "height": 360 @@ -1340,7 +1340,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5X": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1351,7 +1351,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5X landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1362,7 +1362,7 @@ "defaultBrowserType": "chromium" }, "Nexus 6": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1373,7 +1373,7 @@ "defaultBrowserType": "chromium" }, "Nexus 6 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1384,7 +1384,7 @@ "defaultBrowserType": "chromium" }, "Nexus 6P": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1395,7 +1395,7 @@ "defaultBrowserType": "chromium" }, "Nexus 6P landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1406,7 +1406,7 @@ "defaultBrowserType": "chromium" }, "Nexus 7": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Safari/537.36", "viewport": { "width": 600, "height": 960 @@ -1417,7 +1417,7 @@ "defaultBrowserType": "chromium" }, "Nexus 7 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Safari/537.36", "viewport": { "width": 960, "height": 600 @@ -1472,7 +1472,7 @@ "defaultBrowserType": "webkit" }, "Pixel 2": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 411, "height": 731 @@ -1483,7 +1483,7 @@ "defaultBrowserType": "chromium" }, "Pixel 2 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 731, "height": 411 @@ -1494,7 +1494,7 @@ "defaultBrowserType": "chromium" }, "Pixel 2 XL": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 411, "height": 823 @@ -1505,7 +1505,7 @@ "defaultBrowserType": "chromium" }, "Pixel 2 XL landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 823, "height": 411 @@ -1516,7 +1516,7 @@ "defaultBrowserType": "chromium" }, "Pixel 3": { - "userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 393, "height": 786 @@ -1527,7 +1527,7 @@ "defaultBrowserType": "chromium" }, "Pixel 3 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 786, "height": 393 @@ -1538,7 +1538,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 353, "height": 745 @@ -1549,7 +1549,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 745, "height": 353 @@ -1560,7 +1560,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4a (5G)": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "screen": { "width": 412, "height": 892 @@ -1575,7 +1575,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4a (5G) landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "screen": { "height": 892, "width": 412 @@ -1590,7 +1590,7 @@ "defaultBrowserType": "chromium" }, "Pixel 5": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "screen": { "width": 393, "height": 851 @@ -1605,7 +1605,7 @@ "defaultBrowserType": "chromium" }, "Pixel 5 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "screen": { "width": 851, "height": 393 @@ -1620,7 +1620,7 @@ "defaultBrowserType": "chromium" }, "Pixel 7": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "screen": { "width": 412, "height": 915 @@ -1635,7 +1635,7 @@ "defaultBrowserType": "chromium" }, "Pixel 7 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "screen": { "width": 915, "height": 412 @@ -1650,7 +1650,7 @@ "defaultBrowserType": "chromium" }, "Moto G4": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 360, "height": 640 @@ -1661,7 +1661,7 @@ "defaultBrowserType": "chromium" }, "Moto G4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Mobile Safari/537.36", "viewport": { "width": 640, "height": 360 @@ -1672,7 +1672,7 @@ "defaultBrowserType": "chromium" }, "Desktop Chrome HiDPI": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Safari/537.36", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Safari/537.36", "screen": { "width": 1792, "height": 1120 @@ -1687,7 +1687,7 @@ "defaultBrowserType": "chromium" }, "Desktop Edge HiDPI": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Safari/537.36 Edg/138.0.7204.23", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Safari/537.36 Edg/138.0.7204.35", "screen": { "width": 1792, "height": 1120 @@ -1732,7 +1732,7 @@ "defaultBrowserType": "webkit" }, "Desktop Chrome": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Safari/537.36", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Safari/537.36", "screen": { "width": 1920, "height": 1080 @@ -1747,7 +1747,7 @@ "defaultBrowserType": "chromium" }, "Desktop Edge": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.23 Safari/537.36 Edg/138.0.7204.23", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.35 Safari/537.36 Edg/138.0.7204.35", "screen": { "width": 1920, "height": 1080