From dbbdabfd1bbebb3a8fd195a9bbd16f17c7b45d1b Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 24 Feb 2025 12:11:17 -0800 Subject: [PATCH 01/26] chore: pass JSHandles instead of ObjectId to/from context delegate (#34895) --- .../src/server/bidi/bidiExecutionContext.ts | 14 ++++--- .../src/server/chromium/crExecutionContext.ts | 14 ++++--- packages/playwright-core/src/server/dom.ts | 8 +++- .../src/server/firefox/ffExecutionContext.ts | 14 ++++--- .../playwright-core/src/server/javascript.ts | 40 ++++++++++--------- .../src/server/webkit/wkExecutionContext.ts | 14 ++++--- 6 files changed, 59 insertions(+), 45 deletions(-) diff --git a/packages/playwright-core/src/server/bidi/bidiExecutionContext.ts b/packages/playwright-core/src/server/bidi/bidiExecutionContext.ts index c9b1890af9..431e1b9841 100644 --- a/packages/playwright-core/src/server/bidi/bidiExecutionContext.ts +++ b/packages/playwright-core/src/server/bidi/bidiExecutionContext.ts @@ -61,7 +61,7 @@ export class BidiExecutionContext implements js.ExecutionContextDelegate { throw new js.JavaScriptErrorInEvaluate('Unexpected response type: ' + JSON.stringify(response)); } - async rawEvaluateHandle(expression: string): Promise { + async rawEvaluateHandle(context: js.ExecutionContext, expression: string): Promise { const response = await this._session.send('script.evaluate', { expression, target: this._target, @@ -72,7 +72,7 @@ export class BidiExecutionContext implements js.ExecutionContextDelegate { }); if (response.type === 'success') { if ('handle' in response.result) - return response.result.handle!; + return createHandle(context, response.result); throw new js.JavaScriptErrorInEvaluate('Cannot get handle: ' + JSON.stringify(response.result)); } if (response.type === 'exception') @@ -80,14 +80,14 @@ export class BidiExecutionContext implements js.ExecutionContextDelegate { throw new js.JavaScriptErrorInEvaluate('Unexpected response type: ' + JSON.stringify(response)); } - async evaluateWithArguments(functionDeclaration: string, returnByValue: boolean, utilityScript: js.JSHandle, values: any[], objectIds: string[]): Promise { + async evaluateWithArguments(functionDeclaration: string, returnByValue: boolean, utilityScript: js.JSHandle, values: any[], handles: js.JSHandle[]): Promise { const response = await this._session.send('script.callFunction', { functionDeclaration, target: this._target, arguments: [ { handle: utilityScript._objectId! }, ...values.map(BidiSerializer.serialize), - ...objectIds.map(handle => ({ handle })), + ...handles.map(handle => ({ handle: handle._objectId! })), ], resultOwnership: returnByValue ? undefined : bidi.Script.ResultOwnership.Root, // Necessary for the handle to be returned. serializationOptions: returnByValue ? {} : { maxObjectDepth: 0, maxDomDepth: 0 }, @@ -121,10 +121,12 @@ export class BidiExecutionContext implements js.ExecutionContextDelegate { return map; } - async releaseHandle(objectId: js.ObjectId): Promise { + async releaseHandle(handle: js.JSHandle): Promise { + if (!handle._objectId) + return; await this._session.send('script.disown', { target: this._target, - handles: [objectId], + handles: [handle._objectId], }); } diff --git a/packages/playwright-core/src/server/chromium/crExecutionContext.ts b/packages/playwright-core/src/server/chromium/crExecutionContext.ts index 4f4c505422..da11f1e686 100644 --- a/packages/playwright-core/src/server/chromium/crExecutionContext.ts +++ b/packages/playwright-core/src/server/chromium/crExecutionContext.ts @@ -46,24 +46,24 @@ export class CRExecutionContext implements js.ExecutionContextDelegate { return remoteObject.value; } - async rawEvaluateHandle(expression: string): Promise { + async rawEvaluateHandle(context: js.ExecutionContext, expression: string): Promise { const { exceptionDetails, result: remoteObject } = await this._client.send('Runtime.evaluate', { expression, contextId: this._contextId, }).catch(rewriteError); if (exceptionDetails) throw new js.JavaScriptErrorInEvaluate(getExceptionMessage(exceptionDetails)); - return remoteObject.objectId!; + return createHandle(context, remoteObject); } - async evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: js.JSHandle, values: any[], objectIds: string[]): Promise { + async evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: js.JSHandle, values: any[], handles: js.JSHandle[]): Promise { const { exceptionDetails, result: remoteObject } = await this._client.send('Runtime.callFunctionOn', { functionDeclaration: expression, objectId: utilityScript._objectId, arguments: [ { objectId: utilityScript._objectId }, ...values.map(value => ({ value })), - ...objectIds.map(objectId => ({ objectId })), + ...handles.map(handle => ({ objectId: handle._objectId! })), ], returnByValue, awaitPromise: true, @@ -88,8 +88,10 @@ export class CRExecutionContext implements js.ExecutionContextDelegate { return result; } - async releaseHandle(objectId: js.ObjectId): Promise { - await releaseObject(this._client, objectId); + async releaseHandle(handle: js.JSHandle): Promise { + if (!handle._objectId) + return; + await releaseObject(this._client, handle._objectId); } } diff --git a/packages/playwright-core/src/server/dom.ts b/packages/playwright-core/src/server/dom.ts index 3366a1a50d..fbea38b814 100644 --- a/packages/playwright-core/src/server/dom.ts +++ b/packages/playwright-core/src/server/dom.ts @@ -105,7 +105,11 @@ export class FrameExecutionContext extends js.ExecutionContext { ); })(); `; - this._injectedScriptPromise = this.rawEvaluateHandle(source).then(objectId => new js.JSHandle(this, 'object', 'InjectedScript', objectId)); + this._injectedScriptPromise = this.rawEvaluateHandle(source) + .then(handle => { + handle._setPreview('InjectedScript'); + return handle; + }); } return this._injectedScriptPromise; } @@ -118,7 +122,7 @@ export class ElementHandle extends js.JSHandle { declare readonly _objectId: string; readonly _frame: frames.Frame; - constructor(context: FrameExecutionContext, objectId: js.ObjectId) { + constructor(context: FrameExecutionContext, objectId: string) { super(context, 'node', undefined, objectId); this._page = context.frame._page; this._frame = context.frame; diff --git a/packages/playwright-core/src/server/firefox/ffExecutionContext.ts b/packages/playwright-core/src/server/firefox/ffExecutionContext.ts index d0388ae2fe..1b9af6bfaa 100644 --- a/packages/playwright-core/src/server/firefox/ffExecutionContext.ts +++ b/packages/playwright-core/src/server/firefox/ffExecutionContext.ts @@ -44,23 +44,23 @@ export class FFExecutionContext implements js.ExecutionContextDelegate { return payload.result!.value; } - async rawEvaluateHandle(expression: string): Promise { + async rawEvaluateHandle(context: js.ExecutionContext, expression: string): Promise { const payload = await this._session.send('Runtime.evaluate', { expression, returnByValue: false, executionContextId: this._executionContextId, }).catch(rewriteError); checkException(payload.exceptionDetails); - return payload.result!.objectId!; + return createHandle(context, payload.result!); } - async evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: js.JSHandle, values: any[], objectIds: string[]): Promise { + async evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: js.JSHandle, values: any[], handles: js.JSHandle[]): Promise { const payload = await this._session.send('Runtime.callFunction', { functionDeclaration: expression, args: [ { objectId: utilityScript._objectId, value: undefined }, ...values.map(value => ({ value })), - ...objectIds.map(objectId => ({ objectId, value: undefined })), + ...handles.map(handle => ({ objectId: handle._objectId!, value: undefined })), ], returnByValue, executionContextId: this._executionContextId @@ -82,10 +82,12 @@ export class FFExecutionContext implements js.ExecutionContextDelegate { return result; } - async releaseHandle(objectId: js.ObjectId): Promise { + async releaseHandle(handle: js.JSHandle): Promise { + if (!handle._objectId) + return; await this._session.send('Runtime.disposeObject', { executionContextId: this._executionContextId, - objectId + objectId: handle._objectId, }); } } diff --git a/packages/playwright-core/src/server/javascript.ts b/packages/playwright-core/src/server/javascript.ts index edb0d95bc9..f01cb88bcf 100644 --- a/packages/playwright-core/src/server/javascript.ts +++ b/packages/playwright-core/src/server/javascript.ts @@ -23,8 +23,6 @@ import { LongStandingScope } from '../utils/isomorphic/manualPromise'; import type * as dom from './dom'; import type { UtilityScript } from './injected/utilityScript'; -export type ObjectId = string; - interface TaggedAsJSHandle { __jshandle: T; } @@ -49,10 +47,10 @@ export type SmartHandle = T extends Node ? dom.ElementHandle : JSHandle export interface ExecutionContextDelegate { rawEvaluateJSON(expression: string): Promise; - rawEvaluateHandle(expression: string): Promise; - evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: JSHandle, values: any[], objectIds: ObjectId[]): Promise; + rawEvaluateHandle(context: ExecutionContext, expression: string): Promise; + evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: JSHandle, values: any[], handles: JSHandle[]): Promise; getProperties(object: JSHandle): Promise>; - releaseHandle(objectId: ObjectId): Promise; + releaseHandle(handle: JSHandle): Promise; } export class ExecutionContext extends SdkObject { @@ -79,21 +77,21 @@ export class ExecutionContext extends SdkObject { return this._raceAgainstContextDestroyed(this.delegate.rawEvaluateJSON(expression)); } - rawEvaluateHandle(expression: string): Promise { - return this._raceAgainstContextDestroyed(this.delegate.rawEvaluateHandle(expression)); + rawEvaluateHandle(expression: string): Promise { + return this._raceAgainstContextDestroyed(this.delegate.rawEvaluateHandle(this, expression)); } - async evaluateWithArguments(expression: string, returnByValue: boolean, values: any[], objectIds: ObjectId[]): Promise { + async evaluateWithArguments(expression: string, returnByValue: boolean, values: any[], handles: JSHandle[]): Promise { const utilityScript = await this._utilityScript(); - return this._raceAgainstContextDestroyed(this.delegate.evaluateWithArguments(expression, returnByValue, utilityScript, values, objectIds)); + return this._raceAgainstContextDestroyed(this.delegate.evaluateWithArguments(expression, returnByValue, utilityScript, values, handles)); } getProperties(object: JSHandle): Promise> { return this._raceAgainstContextDestroyed(this.delegate.getProperties(object)); } - releaseHandle(objectId: ObjectId): Promise { - return this.delegate.releaseHandle(objectId); + releaseHandle(handle: JSHandle): Promise { + return this.delegate.releaseHandle(handle); } adoptIfNeeded(handle: JSHandle): Promise | null { @@ -108,7 +106,11 @@ export class ExecutionContext extends SdkObject { ${utilityScriptSource.source} return new (module.exports.UtilityScript())(${isUnderTest()}); })();`; - this._utilityScriptPromise = this._raceAgainstContextDestroyed(this.delegate.rawEvaluateHandle(source).then(objectId => new JSHandle(this, 'object', 'UtilityScript', objectId))); + this._utilityScriptPromise = this._raceAgainstContextDestroyed(this.delegate.rawEvaluateHandle(this, source)) + .then(handle => { + handle._setPreview('UtilityScript'); + return handle; + }); } return this._utilityScriptPromise; } @@ -122,13 +124,13 @@ export class JSHandle extends SdkObject { __jshandle: T = true as any; readonly _context: ExecutionContext; _disposed = false; - readonly _objectId: ObjectId | undefined; + readonly _objectId: string | undefined; readonly _value: any; private _objectType: string; protected _preview: string; private _previewCallback: ((preview: string) => void) | undefined; - constructor(context: ExecutionContext, type: string, preview: string | undefined, objectId?: ObjectId, value?: any) { + constructor(context: ExecutionContext, type: string, preview: string | undefined, objectId?: string, value?: any) { super(context, 'handle'); this._context = context; this._objectId = objectId; @@ -185,7 +187,7 @@ export class JSHandle extends SdkObject { if (!this._objectId) return this._value; const script = `(utilityScript, ...args) => utilityScript.jsonValue(...args)`; - return this._context.evaluateWithArguments(script, true, [true], [this._objectId]); + return this._context.evaluateWithArguments(script, true, [true], [this]); } asElement(): dom.ElementHandle | null { @@ -197,7 +199,7 @@ export class JSHandle extends SdkObject { return; this._disposed = true; if (this._objectId) { - this._context.releaseHandle(this._objectId).catch(e => {}); + this._context.releaseHandle(this).catch(e => {}); if ((globalThis as any).leakedJSHandles) (globalThis as any).leakedJSHandles.delete(this); } @@ -254,11 +256,11 @@ export async function evaluateExpression(context: ExecutionContext, expression: return { fallThrough: handle }; })); - const utilityScriptObjectIds: ObjectId[] = []; + const utilityScriptObjects: JSHandle[] = []; for (const handle of await Promise.all(handles)) { if (handle._context !== context) throw new JavaScriptErrorInEvaluate('JSHandles can be evaluated only in the context they were created!'); - utilityScriptObjectIds.push(handle._objectId!); + utilityScriptObjects.push(handle); } // See UtilityScript for arguments. @@ -266,7 +268,7 @@ export async function evaluateExpression(context: ExecutionContext, expression: const script = `(utilityScript, ...args) => utilityScript.evaluate(...args)`; try { - return await context.evaluateWithArguments(script, options.returnByValue || false, utilityScriptValues, utilityScriptObjectIds); + return await context.evaluateWithArguments(script, options.returnByValue || false, utilityScriptValues, utilityScriptObjects); } finally { toDispose.map(handlePromise => handlePromise.then(handle => handle.dispose())); } diff --git a/packages/playwright-core/src/server/webkit/wkExecutionContext.ts b/packages/playwright-core/src/server/webkit/wkExecutionContext.ts index f9386d3adc..c7533d1ac5 100644 --- a/packages/playwright-core/src/server/webkit/wkExecutionContext.ts +++ b/packages/playwright-core/src/server/webkit/wkExecutionContext.ts @@ -48,7 +48,7 @@ export class WKExecutionContext implements js.ExecutionContextDelegate { } } - async rawEvaluateHandle(expression: string): Promise { + async rawEvaluateHandle(context: js.ExecutionContext, expression: string): Promise { try { const response = await this._session.send('Runtime.evaluate', { expression, @@ -57,13 +57,13 @@ export class WKExecutionContext implements js.ExecutionContextDelegate { }); if (response.wasThrown) throw new js.JavaScriptErrorInEvaluate(response.result.description); - return response.result.objectId!; + return createHandle(context, response.result); } catch (error) { throw rewriteError(error); } } - async evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: js.JSHandle, values: any[], objectIds: string[]): Promise { + async evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: js.JSHandle, values: any[], handles: js.JSHandle[]): Promise { try { const response = await this._session.send('Runtime.callFunctionOn', { functionDeclaration: expression, @@ -71,7 +71,7 @@ export class WKExecutionContext implements js.ExecutionContextDelegate { arguments: [ { objectId: utilityScript._objectId }, ...values.map(value => ({ value })), - ...objectIds.map(objectId => ({ objectId })), + ...handles.map(handle => ({ objectId: handle._objectId! })), ], returnByValue, emulateUserGesture: true, @@ -101,8 +101,10 @@ export class WKExecutionContext implements js.ExecutionContextDelegate { return result; } - async releaseHandle(objectId: js.ObjectId): Promise { - await this._session.send('Runtime.releaseObject', { objectId }); + async releaseHandle(handle: js.JSHandle): Promise { + if (!handle._objectId) + return; + await this._session.send('Runtime.releaseObject', { objectId: handle._objectId }); } } From 1a8f00c45f9ae0be9c5db94a44d5e19ff309853e Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Mon, 24 Feb 2025 21:15:21 +0100 Subject: [PATCH 02/26] fix: don't wrap button contents in two lines (#34887) --- packages/trace-viewer/src/ui/errorsTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/trace-viewer/src/ui/errorsTab.tsx b/packages/trace-viewer/src/ui/errorsTab.tsx index d7b9ac7eba..a5d9a17aa6 100644 --- a/packages/trace-viewer/src/ui/errorsTab.tsx +++ b/packages/trace-viewer/src/ui/errorsTab.tsx @@ -72,7 +72,7 @@ const CopyPromptButton: React.FC<{ value={prompt} description='Copy as Prompt' copiedDescription={<>Copied } - style={{ width: '90px', justifyContent: 'center' }} + style={{ width: '120px', justifyContent: 'center' }} /> ); }; From c4be54b45c1f48c3d931a1d1bbbba2f09b793bfe Mon Sep 17 00:00:00 2001 From: Vitaliy Potapov Date: Tue, 25 Feb 2025 00:22:24 +0400 Subject: [PATCH 03/26] Docs: improve docs and tests for URL glob pattern (#34899) --- docs/src/network.md | 4 ++++ tests/page/interception.spec.ts | 9 ++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/src/network.md b/docs/src/network.md index fc2471a321..bdb5b71c6e 100644 --- a/docs/src/network.md +++ b/docs/src/network.md @@ -708,9 +708,13 @@ Playwright uses simplified glob patterns for URL matching in network interceptio - A double `**` matches any characters including `/` 1. Question mark `?` matches any single character except `/` 1. Curly braces `{}` can be used to match a list of options separated by commas `,` +1. Square brackets `[]` can be used to match a set of characters +1. Backslash `\` can be used to escape any of special characters (note to escape backslash itself as `\\`) Examples: - `https://example.com/*.js` matches `https://example.com/file.js` but not `https://example.com/path/file.js` +- `https://example.com/\\?page=1` matches `https://example.com/?page=1` but not `https://example.com` +- `**/v[0-9]*` matches `https://example.com/v1/` but not `https://example.com/vote/` - `**/*.js` matches both `https://example.com/file.js` and `https://example.com/path/file.js` - `**/*.{png,jpg,jpeg}` matches all image requests diff --git a/tests/page/interception.spec.ts b/tests/page/interception.spec.ts index 9447a80fcd..d3443f9015 100644 --- a/tests/page/interception.spec.ts +++ b/tests/page/interception.spec.ts @@ -90,7 +90,14 @@ it('should work with glob', async () => { expect(globToRegex('http://localhost:3000/signin-oidc*').test('http://localhost:3000/signin-oidc/foo')).toBeFalsy(); expect(globToRegex('http://localhost:3000/signin-oidc*').test('http://localhost:3000/signin-oidcnice')).toBeTruthy(); - expect(globToRegex('**/three-columns/settings.html?**id=[a-z]**').test('http://mydomain:8080/blah/blah/three-columns/settings.html?id=settings-e3c58efe-02e9-44b0-97ac-dd138100cf7c&blah')).toBeTruthy(); + // range [] + expect(globToRegex('**/api/v[0-9]').test('http://example.com/api/v1')).toBeTruthy(); + expect(globToRegex('**/api/v[0-9]').test('http://example.com/api/version')).toBeFalsy(); + + // query params + expect(globToRegex('**/api\\?param').test('http://example.com/api?param')).toBeTruthy(); + expect(globToRegex('**/api\\?param').test('http://example.com/api-param')).toBeFalsy(); + expect(globToRegex('**/three-columns/settings.html\\?**id=[a-z]**').test('http://mydomain:8080/blah/blah/three-columns/settings.html?id=settings-e3c58efe-02e9-44b0-97ac-dd138100cf7c&blah')).toBeTruthy(); expect(globToRegex('\\?')).toEqual(/^\?$/); expect(globToRegex('\\')).toEqual(/^\\$/); From 9b633ddd2f889941f356a965cb94a60ed4d859b3 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 24 Feb 2025 14:13:27 -0800 Subject: [PATCH 04/26] docs: touch events guide improvements (#34903) --- docs/src/api/class-locator.md | 2 +- docs/src/api/class-touchscreen.md | 2 ++ docs/src/touch-events.md | 14 ++++---------- packages/playwright-client/types/types.d.ts | 6 +++++- packages/playwright-core/types/types.d.ts | 6 +++++- 5 files changed, 17 insertions(+), 13 deletions(-) diff --git a/docs/src/api/class-locator.md b/docs/src/api/class-locator.md index 0fdb1d1229..85e3f0396a 100644 --- a/docs/src/api/class-locator.md +++ b/docs/src/api/class-locator.md @@ -2332,7 +2332,7 @@ This method expects [Locator] to point to an ## async method: Locator.tap * since: v1.14 -Perform a tap gesture on the element matching the locator. +Perform a tap gesture on the element matching the locator. For examples of emulating other gestures by manually dispatching touch events, see the [emulating legacy touch events](../touch-events.md) page. **Details** diff --git a/docs/src/api/class-touchscreen.md b/docs/src/api/class-touchscreen.md index bd6e1ed9f0..d7871addd7 100644 --- a/docs/src/api/class-touchscreen.md +++ b/docs/src/api/class-touchscreen.md @@ -4,6 +4,8 @@ The Touchscreen class operates in main-frame CSS pixels relative to the top-left corner of the viewport. Methods on the touchscreen can only be used in browser contexts that have been initialized with `hasTouch` set to true. +This class is limited to emulating tap gestures. For examples of other gestures simulated by manually dispatching touch events, see the [emulating legacy touch events](../touch-events.md) page. + ## async method: Touchscreen.tap * since: v1.8 diff --git a/docs/src/touch-events.md b/docs/src/touch-events.md index a1d394dc62..de42abac7a 100644 --- a/docs/src/touch-events.md +++ b/docs/src/touch-events.md @@ -1,19 +1,13 @@ --- id: touch-events -title: "Emulating touch events" +title: "Emulating legacy touch events" --- ## Introduction -Mobile web sites may listen to [touch events](https://developer.mozilla.org/en-US/docs/Web/API/Touch_events) and react to user touch gestures such as swipe, pinch, tap etc. To test this functionality you can manually generate [TouchEvent]s in the page context using [`method: Locator.evaluate`]. +Web applications that handle [touch events](https://developer.mozilla.org/en-US/docs/Web/API/Touch_events) to respond to gestures like swipe, pinch, and tap can be tested by manually dispatching [TouchEvent](https://developer.mozilla.org/en-US/docs/Web/API/TouchEvent/TouchEvent)s to the page. The examples below demonstrate how to use [`method: Locator.dispatchEvent`] and pass [Touch](https://developer.mozilla.org/en-US/docs/Web/API/Touch) points as arguments. -If your web application relies on [pointer events](https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events) instead of touch events, you can use [`method: Locator.click`] and raw [`Mouse`] events to simulate a single-finger touch, and this will trigger all the same pointer events. - -### Dispatching TouchEvent - -You can dispatch touch events to the page using [`method: Locator.dispatchEvent`]. [Touch](https://developer.mozilla.org/en-US/docs/Web/API/Touch) points can be passed as arguments, see examples below. - -#### Emulating pan gesture +### Emulating pan gesture In the example below, we emulate pan gesture that is expected to move the map. The app under test only uses `clientX/clientY` coordinates of the touch point, so we initialize just that. In a more complex scenario you may need to also set `pageX/pageY/screenX/screenY`, if your app needs them. @@ -69,7 +63,7 @@ test(`pan gesture to move the map`, async ({ page }) => { }); ``` -#### Emulating pinch gesture +### Emulating pinch gesture In the example below, we emulate pinch gesture, i.e. two touch points moving closer to each other. It is expected to zoom out the map. The app under test only uses `clientX/clientY` coordinates of touch points, so we initialize just that. In a more complex scenario you may need to also set `pageX/pageY/screenX/screenY`, if your app needs them. diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index ff2ef43aa1..72c502d913 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -14426,7 +14426,8 @@ export interface Locator { }): Promise; /** - * Perform a tap gesture on the element matching the locator. + * Perform a tap gesture on the element matching the locator. For examples of emulating other gestures by manually + * dispatching touch events, see the [emulating legacy touch events](https://playwright.dev/docs/touch-events) page. * * **Details** * @@ -21310,6 +21311,9 @@ export interface Selectors { /** * The Touchscreen class operates in main-frame CSS pixels relative to the top-left corner of the viewport. Methods on * the touchscreen can only be used in browser contexts that have been initialized with `hasTouch` set to true. + * + * This class is limited to emulating tap gestures. For examples of other gestures simulated by manually dispatching + * touch events, see the [emulating legacy touch events](https://playwright.dev/docs/touch-events) page. */ export interface Touchscreen { /** diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index ff2ef43aa1..72c502d913 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -14426,7 +14426,8 @@ export interface Locator { }): Promise; /** - * Perform a tap gesture on the element matching the locator. + * Perform a tap gesture on the element matching the locator. For examples of emulating other gestures by manually + * dispatching touch events, see the [emulating legacy touch events](https://playwright.dev/docs/touch-events) page. * * **Details** * @@ -21310,6 +21311,9 @@ export interface Selectors { /** * The Touchscreen class operates in main-frame CSS pixels relative to the top-left corner of the viewport. Methods on * the touchscreen can only be used in browser contexts that have been initialized with `hasTouch` set to true. + * + * This class is limited to emulating tap gestures. For examples of other gestures simulated by manually dispatching + * touch events, see the [emulating legacy touch events](https://playwright.dev/docs/touch-events) page. */ export interface Touchscreen { /** From ed0bf354354acc854a3d2410df177a824d0ac85e Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Tue, 25 Feb 2025 11:48:15 +0000 Subject: [PATCH 05/26] feat: locator.visible (#34905) --- docs/src/api/class-locator.md | 12 +++++++++++ docs/src/locators.md | 10 ++++----- packages/playwright-client/types/types.d.ts | 11 ++++++++++ .../playwright-core/src/client/locator.ts | 5 +++++ .../src/utils/isomorphic/locatorGenerators.ts | 14 ++++++++++++- .../src/utils/isomorphic/locatorParser.ts | 3 +++ packages/playwright-core/types/types.d.ts | 11 ++++++++++ tests/library/locator-generator.spec.ts | 21 +++++++++++++++++++ tests/page/locator-misc-2.spec.ts | 17 +++++++++++++++ 9 files changed, 98 insertions(+), 6 deletions(-) diff --git a/docs/src/api/class-locator.md b/docs/src/api/class-locator.md index 85e3f0396a..c7680319d4 100644 --- a/docs/src/api/class-locator.md +++ b/docs/src/api/class-locator.md @@ -2478,6 +2478,18 @@ When all steps combined have not finished during the specified [`option: timeout ### option: Locator.uncheck.trial = %%-input-trial-%% * since: v1.14 +## method: Locator.visible +* since: v1.51 +- returns: <[Locator]> + +Returns a locator that only matches [visible](../actionability.md#visible) elements. + +### option: Locator.visible.visible +* since: v1.51 +- `visible` <[boolean]> + +Whether to match visible or invisible elements. + ## async method: Locator.waitFor * since: v1.16 diff --git a/docs/src/locators.md b/docs/src/locators.md index c3a2817670..ed15a82762 100644 --- a/docs/src/locators.md +++ b/docs/src/locators.md @@ -1310,19 +1310,19 @@ Consider a page with two buttons, the first invisible and the second [visible](. * This will only find a second button, because it is visible, and then click it. ```js - await page.locator('button').locator('visible=true').click(); + await page.locator('button').visible().click(); ``` ```java - page.locator("button").locator("visible=true").click(); + page.locator("button").visible().click(); ``` ```python async - await page.locator("button").locator("visible=true").click() + await page.locator("button").visible().click() ``` ```python sync - page.locator("button").locator("visible=true").click() + page.locator("button").visible().click() ``` ```csharp - await page.Locator("button").Locator("visible=true").ClickAsync(); + await page.Locator("button").Visible().ClickAsync(); ``` ## Lists diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 72c502d913..874f072035 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -14615,6 +14615,17 @@ export interface Locator { trial?: boolean; }): Promise; + /** + * Returns a locator that only matches [visible](https://playwright.dev/docs/actionability#visible) elements. + * @param options + */ + visible(options?: { + /** + * Whether to match visible or invisible elements. + */ + visible?: boolean; + }): Locator; + /** * Returns when element specified by locator satisfies the * [`state`](https://playwright.dev/docs/api/class-locator#locator-wait-for-option-state) option. diff --git a/packages/playwright-core/src/client/locator.ts b/packages/playwright-core/src/client/locator.ts index 5d0f0aa0c3..1ed86b847d 100644 --- a/packages/playwright-core/src/client/locator.ts +++ b/packages/playwright-core/src/client/locator.ts @@ -218,6 +218,11 @@ export class Locator implements api.Locator { return new Locator(this._frame, this._selector + ` >> nth=${index}`); } + visible(options: { visible?: boolean } = {}): Locator { + const { visible = true } = options; + return new Locator(this._frame, this._selector + ` >> visible=${visible ? 'true' : 'false'}`); + } + and(locator: Locator): Locator { if (locator._frame !== this._frame) throw new Error(`Locators must belong to the same frame.`); diff --git a/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts b/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts index 77ce63f313..283c4e492a 100644 --- a/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts +++ b/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts @@ -21,7 +21,7 @@ import type { NestedSelectorBody } from './selectorParser'; import type { ParsedSelector } from './selectorParser'; export type Language = 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl'; -export type LocatorType = 'default' | 'role' | 'text' | 'label' | 'placeholder' | 'alt' | 'title' | 'test-id' | 'nth' | 'first' | 'last' | 'has-text' | 'has-not-text' | 'has' | 'hasNot' | 'frame' | 'frame-locator' | 'and' | 'or' | 'chain'; +export type LocatorType = 'default' | 'role' | 'text' | 'label' | 'placeholder' | 'alt' | 'title' | 'test-id' | 'nth' | 'first' | 'last' | 'visible' | 'has-text' | 'has-not-text' | 'has' | 'hasNot' | 'frame' | 'frame-locator' | 'and' | 'or' | 'chain'; export type LocatorBase = 'page' | 'locator' | 'frame-locator'; export type Quote = '\'' | '"' | '`'; @@ -68,6 +68,10 @@ function innerAsLocators(factory: LocatorFactory, parsed: ParsedSelector, isFram tokens.push([factory.generateLocator(base, 'nth', part.body as string)]); continue; } + if (part.name === 'visible') { + tokens.push([factory.generateLocator(base, 'visible', part.body as string), factory.generateLocator(base, 'default', `visible=${part.body}`)]); + continue; + } if (part.name === 'internal:text') { const { exact, text } = detectExact(part.body as string); tokens.push([factory.generateLocator(base, 'text', text, { exact })]); @@ -275,6 +279,8 @@ export class JavaScriptLocatorFactory implements LocatorFactory { return `first()`; case 'last': return `last()`; + case 'visible': + return `visible(${body === 'true' ? '' : '{ visible: false }'})`; case 'role': const attrs: string[] = []; if (isRegExp(options.name)) { @@ -369,6 +375,8 @@ export class PythonLocatorFactory implements LocatorFactory { return `first`; case 'last': return `last`; + case 'visible': + return `visible(${body === 'true' ? '' : 'visible=False'})`; case 'role': const attrs: string[] = []; if (isRegExp(options.name)) { @@ -476,6 +484,8 @@ export class JavaLocatorFactory implements LocatorFactory { return `first()`; case 'last': return `last()`; + case 'visible': + return `visible(${body === 'true' ? '' : `new ${clazz}.VisibleOptions().setVisible(false)`})`; case 'role': const attrs: string[] = []; if (isRegExp(options.name)) { @@ -573,6 +583,8 @@ export class CSharpLocatorFactory implements LocatorFactory { return `First`; case 'last': return `Last`; + case 'visible': + return `Visible(${body === 'true' ? '' : 'new() { Visible = false }'})`; case 'role': const attrs: string[] = []; if (isRegExp(options.name)) { diff --git a/packages/playwright-core/src/utils/isomorphic/locatorParser.ts b/packages/playwright-core/src/utils/isomorphic/locatorParser.ts index f89dbb50f7..e3481b0973 100644 --- a/packages/playwright-core/src/utils/isomorphic/locatorParser.ts +++ b/packages/playwright-core/src/utils/isomorphic/locatorParser.ts @@ -170,6 +170,9 @@ function transform(template: string, params: TemplateParams, testIdAttributeName .replace(/first(\(\))?/g, 'nth=0') .replace(/last(\(\))?/g, 'nth=-1') .replace(/nth\(([^)]+)\)/g, 'nth=$1') + .replace(/visible\(,?visible=true\)/g, 'visible=true') + .replace(/visible\(,?visible=false\)/g, 'visible=false') + .replace(/visible\(\)/g, 'visible=true') .replace(/filter\(,?hastext=([^)]+)\)/g, 'internal:has-text=$1') .replace(/filter\(,?hasnottext=([^)]+)\)/g, 'internal:has-not-text=$1') .replace(/filter\(,?has2=([^)]+)\)/g, 'internal:has=$1') diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 72c502d913..874f072035 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -14615,6 +14615,17 @@ export interface Locator { trial?: boolean; }): Promise; + /** + * Returns a locator that only matches [visible](https://playwright.dev/docs/actionability#visible) elements. + * @param options + */ + visible(options?: { + /** + * Whether to match visible or invisible elements. + */ + visible?: boolean; + }): Locator; + /** * Returns when element specified by locator satisfies the * [`state`](https://playwright.dev/docs/api/class-locator#locator-wait-for-option-state) option. diff --git a/tests/library/locator-generator.spec.ts b/tests/library/locator-generator.spec.ts index 417e096e29..0a2ccb333a 100644 --- a/tests/library/locator-generator.spec.ts +++ b/tests/library/locator-generator.spec.ts @@ -320,6 +320,27 @@ it('reverse engineer hasNotText', async ({ page }) => { }); }); +it('reverse engineer visible', async ({ page }) => { + expect.soft(generate(page.getByText('Hello').visible().locator('div'))).toEqual({ + csharp: `GetByText("Hello").Visible().Locator("div")`, + java: `getByText("Hello").visible().locator("div")`, + javascript: `getByText('Hello').visible().locator('div')`, + python: `get_by_text("Hello").visible().locator("div")`, + }); + expect.soft(generate(page.getByText('Hello').visible({ visible: true }).locator('div'))).toEqual({ + csharp: `GetByText("Hello").Visible().Locator("div")`, + java: `getByText("Hello").visible().locator("div")`, + javascript: `getByText('Hello').visible().locator('div')`, + python: `get_by_text("Hello").visible().locator("div")`, + }); + expect.soft(generate(page.getByText('Hello').visible({ visible: false }).locator('div'))).toEqual({ + csharp: `GetByText("Hello").Visible(new() { Visible = false }).Locator("div")`, + java: `getByText("Hello").visible(new Locator.VisibleOptions().setVisible(false)).locator("div")`, + javascript: `getByText('Hello').visible({ visible: false }).locator('div')`, + python: `get_by_text("Hello").visible(visible=False).locator("div")`, + }); +}); + it('reverse engineer has', async ({ page }) => { expect.soft(generate(page.getByText('Hello').filter({ has: page.locator('div').getByText('bye') }))).toEqual({ csharp: `GetByText("Hello").Filter(new() { Has = Locator("div").GetByText("bye") })`, diff --git a/tests/page/locator-misc-2.spec.ts b/tests/page/locator-misc-2.spec.ts index 202b9a2957..478d86c30c 100644 --- a/tests/page/locator-misc-2.spec.ts +++ b/tests/page/locator-misc-2.spec.ts @@ -150,6 +150,23 @@ it('should combine visible with other selectors', async ({ page }) => { await expect(page.locator('.item >> visible=true >> text=data3')).toHaveText('visible data3'); }); +it('should support .visible()', async ({ page }) => { + await page.setContent(`
+ +
visible data1
+ +
visible data2
+ +
visible data3
+
+ `); + const locator = page.locator('.item').visible().nth(1); + await expect(locator).toHaveText('visible data2'); + await expect(page.locator('.item').visible().getByText('data3')).toHaveText('visible data3'); + await expect(page.locator('.item').visible({ visible: true }).getByText('data2')).toHaveText('visible data2'); + await expect(page.locator('.item').visible({ visible: false }).getByText('data1')).toHaveText('Hidden data1'); +}); + it('locator.count should work with deleted Map in main world', async ({ page }) => { it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/11254' }); await page.evaluate('Map = 1'); From 81855d11e480add370e045845559262b00650c0b Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Tue, 25 Feb 2025 13:23:38 +0000 Subject: [PATCH 06/26] chore: add playwright-client to the list of packages (#34916) --- utils/workspace.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/utils/workspace.js b/utils/workspace.js index 7e6850a262..ef03a1fc10 100755 --- a/utils/workspace.js +++ b/utils/workspace.js @@ -125,7 +125,7 @@ class Workspace { } await maybeWriteJSON(pkg.packageJSONPath, pkg.packageJSON); } - + // Re-run npm i to make package-lock dirty. child_process.execSync('npm i'); return hasChanges; @@ -167,6 +167,11 @@ const workspace = new Workspace(ROOT_PATH, [ path: path.join(ROOT_PATH, 'packages', 'playwright-chromium'), files: LICENCE_FILES, }), + new PWPackage({ + name: '@playwright/client', + path: path.join(ROOT_PATH, 'packages', 'playwright-client'), + files: LICENCE_FILES, + }), new PWPackage({ name: '@playwright/experimental-tools', path: path.join(ROOT_PATH, 'packages', 'playwright-tools'), From 9e384733099f5bb02a7673d7811534142f274c38 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Tue, 25 Feb 2025 14:26:54 +0100 Subject: [PATCH 07/26] fix(runner): hide `APIResponse.*` calls from results (#34909) --- packages/playwright-core/src/client/fetch.ts | 22 +++++++++++--------- tests/playwright-test/test-step.spec.ts | 2 -- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/playwright-core/src/client/fetch.ts b/packages/playwright-core/src/client/fetch.ts index 8bdf38631d..dada11ed30 100644 --- a/packages/playwright-core/src/client/fetch.ts +++ b/packages/playwright-core/src/client/fetch.ts @@ -338,16 +338,18 @@ export class APIResponse implements api.APIResponse { } async body(): Promise { - try { - const result = await this._request._channel.fetchResponseBody({ fetchUid: this._fetchUid() }); - if (result.binary === undefined) - throw new Error('Response has been disposed'); - return result.binary; - } catch (e) { - if (isTargetClosedError(e)) - throw new Error('Response has been disposed'); - throw e; - } + return await this._request._wrapApiCall(async () => { + try { + const result = await this._request._channel.fetchResponseBody({ fetchUid: this._fetchUid() }); + if (result.binary === undefined) + throw new Error('Response has been disposed'); + return result.binary; + } catch (e) { + if (isTargetClosedError(e)) + throw new Error('Response has been disposed'); + throw e; + } + }, true); } async text(): Promise { diff --git a/tests/playwright-test/test-step.spec.ts b/tests/playwright-test/test-step.spec.ts index d35e9de57a..1cb8950ccf 100644 --- a/tests/playwright-test/test-step.spec.ts +++ b/tests/playwright-test/test-step.spec.ts @@ -1520,9 +1520,7 @@ pw:api | browserContext.newPage test.step |custom step @ a.test.ts:4 pw:api | page.route @ a.test.ts:5 pw:api | page.goto(${server.EMPTY_PAGE}) @ a.test.ts:12 -pw:api | apiResponse.text @ a.test.ts:7 expect | expect.toBe @ a.test.ts:8 -pw:api | apiResponse.text @ a.test.ts:9 hook |After Hooks fixture | fixture: page fixture | fixture: context From a9bbf4b56d4bc4fdbee37a6e39109eea36b63f08 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Tue, 25 Feb 2025 15:40:38 +0100 Subject: [PATCH 08/26] docs: recommend localhost over 127.0.0.1 (#34918) --- docs/src/test-api/class-testconfig.md | 8 ++++---- docs/src/test-configuration-js.md | 4 ++-- docs/src/test-use-options-js.md | 2 +- docs/src/test-webserver-js.md | 18 +++++++++--------- packages/playwright/types/test.d.ts | 8 ++++---- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/docs/src/test-api/class-testconfig.md b/docs/src/test-api/class-testconfig.md index 0acafa16c5..0d30f612ac 100644 --- a/docs/src/test-api/class-testconfig.md +++ b/docs/src/test-api/class-testconfig.md @@ -680,7 +680,7 @@ import { defineConfig } from '@playwright/test'; export default defineConfig({ webServer: { command: 'npm run start', - url: 'http://127.0.0.1:3000', + url: 'http://localhost:3000', timeout: 120 * 1000, reuseExistingServer: !process.env.CI, }, @@ -709,19 +709,19 @@ export default defineConfig({ webServer: [ { command: 'npm run start', - url: 'http://127.0.0.1:3000', + url: 'http://localhost:3000', timeout: 120 * 1000, reuseExistingServer: !process.env.CI, }, { command: 'npm run backend', - url: 'http://127.0.0.1:3333', + url: 'http://localhost:3333', timeout: 120 * 1000, reuseExistingServer: !process.env.CI, } ], use: { - baseURL: 'http://127.0.0.1:3000', + baseURL: 'http://localhost:3000', }, }); ``` diff --git a/docs/src/test-configuration-js.md b/docs/src/test-configuration-js.md index 822bd4ea0d..e3831c1911 100644 --- a/docs/src/test-configuration-js.md +++ b/docs/src/test-configuration-js.md @@ -35,7 +35,7 @@ export default defineConfig({ use: { // Base URL to use in actions like `await page.goto('/')`. - baseURL: 'http://127.0.0.1:3000', + baseURL: 'http://localhost:3000', // Collect trace when retrying the failed test. trace: 'on-first-retry', @@ -50,7 +50,7 @@ export default defineConfig({ // Run your local dev server before starting the tests. webServer: { command: 'npm run start', - url: 'http://127.0.0.1:3000', + url: 'http://localhost:3000', reuseExistingServer: !process.env.CI, }, }); diff --git a/docs/src/test-use-options-js.md b/docs/src/test-use-options-js.md index 6e1da0a228..12f97db46d 100644 --- a/docs/src/test-use-options-js.md +++ b/docs/src/test-use-options-js.md @@ -17,7 +17,7 @@ import { defineConfig } from '@playwright/test'; export default defineConfig({ use: { // Base URL to use in actions like `await page.goto('/')`. - baseURL: 'http://127.0.0.1:3000', + baseURL: 'http://localhost:3000', // Populates context with given storage state. storageState: 'state.json', diff --git a/docs/src/test-webserver-js.md b/docs/src/test-webserver-js.md index 985b487677..d4a7bbb19b 100644 --- a/docs/src/test-webserver-js.md +++ b/docs/src/test-webserver-js.md @@ -18,7 +18,7 @@ export default defineConfig({ // Run your local dev server before starting the tests webServer: { command: 'npm run start', - url: 'http://127.0.0.1:3000', + url: 'http://localhost:3000', reuseExistingServer: !process.env.CI, stdout: 'ignore', stderr: 'pipe', @@ -52,7 +52,7 @@ export default defineConfig({ // Run your local dev server before starting the tests webServer: { command: 'npm run start', - url: 'http://127.0.0.1:3000', + url: 'http://localhost:3000', reuseExistingServer: !process.env.CI, timeout: 120 * 1000, }, @@ -63,7 +63,7 @@ export default defineConfig({ It is also recommended to specify the `baseURL` in the `use: {}` section of your config, so that tests can use relative urls and you don't have to specify the full URL over and over again. -When using [`method: Page.goto`], [`method: Page.route`], [`method: Page.waitForURL`], [`method: Page.waitForRequest`], or [`method: Page.waitForResponse`] it takes the base URL in consideration by using the [`URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor for building the corresponding URL. For Example, by setting the baseURL to `http://127.0.0.1:3000` and navigating to `/login` in your tests, Playwright will run the test using `http://127.0.0.1:3000/login`. +When using [`method: Page.goto`], [`method: Page.route`], [`method: Page.waitForURL`], [`method: Page.waitForRequest`], or [`method: Page.waitForResponse`] it takes the base URL in consideration by using the [`URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor for building the corresponding URL. For Example, by setting the baseURL to `http://localhost:3000` and navigating to `/login` in your tests, Playwright will run the test using `http://localhost:3000/login`. ```js title="playwright.config.ts" import { defineConfig } from '@playwright/test'; @@ -74,11 +74,11 @@ export default defineConfig({ // Run your local dev server before starting the tests webServer: { command: 'npm run start', - url: 'http://127.0.0.1:3000', + url: 'http://localhost:3000', reuseExistingServer: !process.env.CI, }, use: { - baseURL: 'http://127.0.0.1:3000', + baseURL: 'http://localhost:3000', }, }); ``` @@ -89,7 +89,7 @@ Now you can use a relative path when navigating the page: import { test } from '@playwright/test'; test('test', async ({ page }) => { - // This will navigate to http://127.0.0.1:3000/login + // This will navigate to http://localhost:3000/login await page.goto('./login'); }); ``` @@ -106,19 +106,19 @@ export default defineConfig({ webServer: [ { command: 'npm run start', - url: 'http://127.0.0.1:3000', + url: 'http://localhost:3000', timeout: 120 * 1000, reuseExistingServer: !process.env.CI, }, { command: 'npm run backend', - url: 'http://127.0.0.1:3333', + url: 'http://localhost:3333', timeout: 120 * 1000, reuseExistingServer: !process.env.CI, } ], use: { - baseURL: 'http://127.0.0.1:3000', + baseURL: 'http://localhost:3000', }, }); ``` diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index 4c2766232d..e7cb99bf79 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -884,7 +884,7 @@ interface TestConfig { * export default defineConfig({ * webServer: { * command: 'npm run start', - * url: 'http://127.0.0.1:3000', + * url: 'http://localhost:3000', * timeout: 120 * 1000, * reuseExistingServer: !process.env.CI, * }, @@ -915,19 +915,19 @@ interface TestConfig { * webServer: [ * { * command: 'npm run start', - * url: 'http://127.0.0.1:3000', + * url: 'http://localhost:3000', * timeout: 120 * 1000, * reuseExistingServer: !process.env.CI, * }, * { * command: 'npm run backend', - * url: 'http://127.0.0.1:3333', + * url: 'http://localhost:3333', * timeout: 120 * 1000, * reuseExistingServer: !process.env.CI, * } * ], * use: { - * baseURL: 'http://127.0.0.1:3000', + * baseURL: 'http://localhost:3000', * }, * }); * ``` From b148cbad76f38679aeaf7f6e99998dc8fa42e985 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Tue, 25 Feb 2025 16:54:02 +0000 Subject: [PATCH 09/26] fix: remove unicode soft hyphen in normalizeWhitespace (#34920) --- packages/playwright-core/src/server/injected/roleUtils.ts | 2 +- .../playwright-core/src/utils/isomorphic/ariaSnapshot.ts | 3 ++- .../playwright-core/src/utils/isomorphic/stringUtils.ts | 2 +- tests/library/role-utils.spec.ts | 7 +++++++ tests/page/expect-to-have-text.spec.ts | 2 ++ tests/page/page-aria-snapshot.spec.ts | 3 +++ 6 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/playwright-core/src/server/injected/roleUtils.ts b/packages/playwright-core/src/server/injected/roleUtils.ts index d56075ea58..5b4e25940d 100644 --- a/packages/playwright-core/src/server/injected/roleUtils.ts +++ b/packages/playwright-core/src/server/injected/roleUtils.ts @@ -337,7 +337,7 @@ function trimFlatString(s: string): string { function asFlatString(s: string): string { // "Flat string" at https://w3c.github.io/accname/#terminology // Note that non-breaking spaces are preserved. - return s.split('\u00A0').map(chunk => chunk.replace(/\r\n/g, '\n').replace(/\s\s*/g, ' ')).join('\u00A0').trim(); + return s.split('\u00A0').map(chunk => chunk.replace(/\r\n/g, '\n').replace(/[\u200b\u00ad]/g, '').replace(/\s\s*/g, ' ')).join('\u00A0').trim(); } function queryInAriaOwned(element: Element, selector: string): Element[] { diff --git a/packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts b/packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts index 7ffe9b4de9..5fbc23ec44 100644 --- a/packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts +++ b/packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts @@ -220,7 +220,8 @@ export function parseAriaSnapshot(yaml: YamlLibrary, text: string, options: yaml const emptyFragment: AriaTemplateRoleNode = { kind: 'role', role: 'fragment' }; function normalizeWhitespace(text: string) { - return text.replace(/[\r\n\s\t]+/g, ' ').trim(); + // TODO: why is this different from normalizeWhitespace in stringUtils.ts? + return text.replace(/[\u200b\u00ad]/g, '').replace(/[\r\n\s\t]+/g, ' ').trim(); } export function valueOrRegex(value: string): string | AriaRegex { diff --git a/packages/playwright-core/src/utils/isomorphic/stringUtils.ts b/packages/playwright-core/src/utils/isomorphic/stringUtils.ts index ed81c9a033..5a7602c97a 100644 --- a/packages/playwright-core/src/utils/isomorphic/stringUtils.ts +++ b/packages/playwright-core/src/utils/isomorphic/stringUtils.ts @@ -83,7 +83,7 @@ export function cacheNormalizedWhitespaces() { export function normalizeWhiteSpace(text: string): string { let result = normalizedWhitespaceCache?.get(text); if (result === undefined) { - result = text.replace(/\u200b/g, '').trim().replace(/\s+/g, ' '); + result = text.replace(/[\u200b\u00ad]/g, '').trim().replace(/\s+/g, ' '); normalizedWhitespaceCache?.set(text, result); } return result; diff --git a/tests/library/role-utils.spec.ts b/tests/library/role-utils.spec.ts index 1b625a106a..777d32c58f 100644 --- a/tests/library/role-utils.spec.ts +++ b/tests/library/role-utils.spec.ts @@ -372,6 +372,13 @@ test('display:contents should be visible when contents are visible', async ({ pa await expect(page.getByRole('button')).toHaveCount(1); }); +test('should remove soft hyphens and zero-width spaces', async ({ page }) => { + await page.setContent(` + + `); + expect.soft(await getNameAndRole(page, 'button')).toEqual({ role: 'button', name: '123' }); +}); + test('label/labelled-by aria-hidden with descendants', async ({ page }) => { test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/29796' }); diff --git a/tests/page/expect-to-have-text.spec.ts b/tests/page/expect-to-have-text.spec.ts index d10700e98b..45ab745579 100644 --- a/tests/page/expect-to-have-text.spec.ts +++ b/tests/page/expect-to-have-text.spec.ts @@ -71,6 +71,8 @@ test.describe('toHaveText with text', () => { await expect(locator).toHaveText('text CONTENT', { ignoreCase: true }); // Should support falsy ignoreCase. await expect(locator).not.toHaveText('TEXT', { ignoreCase: false }); + // Should normalize soft hyphens. + await expect(locator).toHaveText('T\u00ade\u00adxt content'); }); test('pass contain', async ({ page }) => { diff --git a/tests/page/page-aria-snapshot.spec.ts b/tests/page/page-aria-snapshot.spec.ts index 78bfb667d8..e5ba5a6c4b 100644 --- a/tests/page/page-aria-snapshot.spec.ts +++ b/tests/page/page-aria-snapshot.spec.ts @@ -515,6 +515,7 @@ it('should normalize whitespace', async ({ page }) => { one \n two link  \n 1 + `); await checkAndMatchSnapshot(page.locator('body'), ` @@ -522,6 +523,7 @@ it('should normalize whitespace', async ({ page }) => { - text: one two - link "link 1" - textbox: hello world + - button "helloworld" `); // Weird whitespace in the template should be normalized. @@ -532,6 +534,7 @@ it('should normalize whitespace', async ({ page }) => { two - link " link 1 " - textbox: hello world + - button "he\u00adlloworld\u200b" `); }); From 411f93829603d5c8fedfb0a053634357b59bceef Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Tue, 25 Feb 2025 09:21:17 -0800 Subject: [PATCH 10/26] chore: clean up git commit metadata props and UI (#34867) --- docs/src/test-api/class-testconfig.md | 22 +----- packages/html-reporter/src/metadataView.css | 7 +- packages/html-reporter/src/metadataView.tsx | 67 +++++++---------- packages/html-reporter/src/testErrorView.tsx | 2 +- packages/playwright/src/common/config.ts | 3 - packages/playwright/src/isomorphic/types.d.ts | 30 +++++--- .../src/plugins/gitCommitInfoPlugin.ts | 75 +++++++++++-------- packages/playwright/types/test.d.ts | 28 +------ packages/trace-viewer/src/ui/errorsTab.tsx | 2 +- tests/playwright-test/reporter-html.spec.ts | 51 ++++++++----- .../playwright-test/ui-mode-metadata.spec.ts | 4 +- 11 files changed, 129 insertions(+), 162 deletions(-) diff --git a/docs/src/test-api/class-testconfig.md b/docs/src/test-api/class-testconfig.md index 0d30f612ac..33419387c7 100644 --- a/docs/src/test-api/class-testconfig.md +++ b/docs/src/test-api/class-testconfig.md @@ -239,7 +239,7 @@ export default defineConfig({ Metadata contains key-value pairs to be included in the report. For example, HTML report will display it as key-value pairs, and JSON report will include metadata serialized as json. -See also [`property: TestConfig.populateGitInfo`] that populates metadata. +Providing `'git.commit.info': {}` property will populate it with the git commit details. This is useful for CI/CD environments. **Usage** @@ -326,26 +326,6 @@ This path will serve as the base directory for each test file snapshot directory ## property: TestConfig.snapshotPathTemplate = %%-test-config-snapshot-path-template-%% * since: v1.28 -## property: TestConfig.populateGitInfo -* since: v1.51 -- type: ?<[boolean]> - -Whether to populate `'git.commit.info'` field of the [`property: TestConfig.metadata`] with Git commit info and CI/CD information. - -This information will appear in the HTML and JSON reports and is available in the Reporter API. - -On Github Actions, this feature is enabled by default. - -**Usage** - -```js title="playwright.config.ts" -import { defineConfig } from '@playwright/test'; - -export default defineConfig({ - populateGitInfo: !!process.env.CI, -}); -``` - ## property: TestConfig.preserveOutput * since: v1.10 - type: ?<[PreserveOutput]<"always"|"never"|"failures-only">> diff --git a/packages/html-reporter/src/metadataView.css b/packages/html-reporter/src/metadataView.css index 0cbced5250..c383d6776a 100644 --- a/packages/html-reporter/src/metadataView.css +++ b/packages/html-reporter/src/metadataView.css @@ -37,10 +37,6 @@ line-height: 24px; } -.metadata-section { - align-items: center; -} - .metadata-properties { display: flex; flex-direction: column; @@ -57,9 +53,8 @@ border-bottom: 1px solid var(--color-border-default); } -.git-commit-info a { +.metadata-view a { color: var(--color-fg-default); - font-weight: 600; } .copyable-property { diff --git a/packages/html-reporter/src/metadataView.tsx b/packages/html-reporter/src/metadataView.tsx index 0f54bb4249..a7db657b4d 100644 --- a/packages/html-reporter/src/metadataView.tsx +++ b/packages/html-reporter/src/metadataView.tsx @@ -87,12 +87,12 @@ const InnerMetadataView = () => { {entries.length > 0 &&
} } -
+
{entries.map(([propertyName, value]) => { const valueString = typeof value !== 'object' || value === null || value === undefined ? String(value) : JSON.stringify(value); const trimmedValue = valueString.length > 1000 ? valueString.slice(0, 1000) + '\u2026' : valueString; return ( -
+
{propertyName} : {linkifyText(trimmedValue)} @@ -105,47 +105,38 @@ const InnerMetadataView = () => { }; const GitCommitInfoView: React.FC<{ info: GitCommitInfo }> = ({ info }) => { - const email = info['revision.email'] ? ` <${info['revision.email']}>` : ''; - const author = `${info['revision.author'] || ''}${email}`; + const email = info.revision?.email ? ` <${info.revision?.email}>` : ''; + const author = `${info.revision?.author || ''}${email}`; - let subject = info['revision.subject'] || ''; - let link = info['revision.link']; - let shortSubject = info['revision.id']?.slice(0, 7) || 'unknown'; + let subject = info.revision?.subject || ''; + let link = info.revision?.link; - if (info['pull.link'] && info['pull.title']) { - subject = info['pull.title']; - link = info['pull.link']; - shortSubject = link ? 'Pull Request' : ''; + if (info.pull_request?.link && info.pull_request?.title) { + subject = info.pull_request?.title; + link = info.pull_request?.link; } - const shortTimestamp = Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(info['revision.timestamp']); - const longTimestamp = Intl.DateTimeFormat(undefined, { dateStyle: 'full', timeStyle: 'long' }).format(info['revision.timestamp']); - return
-
-
- {link ? ( - - {subject} - - ) : + const shortTimestamp = Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(info.revision?.timestamp); + const longTimestamp = Intl.DateTimeFormat(undefined, { dateStyle: 'full', timeStyle: 'long' }).format(info.revision?.timestamp); + return +
+ {author} + on {shortTimestamp} + {info.ci?.link && ( + <> + · + Logs + + )}
- {link ? ( - - {shortSubject} - - ) : !!shortSubject && {shortSubject}}
; }; diff --git a/packages/html-reporter/src/testErrorView.tsx b/packages/html-reporter/src/testErrorView.tsx index 42ec8d9ff6..9134f082d0 100644 --- a/packages/html-reporter/src/testErrorView.tsx +++ b/packages/html-reporter/src/testErrorView.tsx @@ -50,7 +50,7 @@ const PromptButton: React.FC<{ const gitCommitInfo = useGitCommitInfo(); const prompt = React.useMemo(() => fixTestPrompt( error, - gitCommitInfo?.['pull.diff'] ?? gitCommitInfo?.['revision.diff'], + gitCommitInfo?.pull_request?.diff ?? gitCommitInfo?.revision?.diff, result?.attachments.find(a => a.name === 'pageSnapshot')?.body ), [gitCommitInfo, result, error]); diff --git a/packages/playwright/src/common/config.ts b/packages/playwright/src/common/config.ts index 4578c3771a..4e266110fd 100644 --- a/packages/playwright/src/common/config.ts +++ b/packages/playwright/src/common/config.ts @@ -48,7 +48,6 @@ export class FullConfigInternal { readonly plugins: TestRunnerPluginRegistration[]; readonly projects: FullProjectInternal[] = []; readonly singleTSConfigPath?: string; - readonly populateGitInfo: boolean; cliArgs: string[] = []; cliGrep: string | undefined; cliGrepInvert: string | undefined; @@ -78,7 +77,6 @@ export class FullConfigInternal { const privateConfiguration = (userConfig as any)['@playwright/test']; this.plugins = (privateConfiguration?.plugins || []).map((p: any) => ({ factory: p })); this.singleTSConfigPath = pathResolve(configDir, userConfig.tsconfig); - this.populateGitInfo = takeFirst(userConfig.populateGitInfo, defaultPopulateGitInfo); this.globalSetups = (Array.isArray(userConfig.globalSetup) ? userConfig.globalSetup : [userConfig.globalSetup]).map(s => resolveScript(s, configDir)).filter(script => script !== undefined); this.globalTeardowns = (Array.isArray(userConfig.globalTeardown) ? userConfig.globalTeardown : [userConfig.globalTeardown]).map(s => resolveScript(s, configDir)).filter(script => script !== undefined); @@ -301,7 +299,6 @@ function resolveScript(id: string | undefined, rootDir: string): string | undefi export const defaultGrep = /.*/; export const defaultReporter = process.env.CI ? 'dot' : 'list'; -const defaultPopulateGitInfo = process.env.GITHUB_ACTIONS === 'true'; const configInternalSymbol = Symbol('configInternalSymbol'); diff --git a/packages/playwright/src/isomorphic/types.d.ts b/packages/playwright/src/isomorphic/types.d.ts index 2619c0df33..2d54911c6e 100644 --- a/packages/playwright/src/isomorphic/types.d.ts +++ b/packages/playwright/src/isomorphic/types.d.ts @@ -15,16 +15,22 @@ */ export interface GitCommitInfo { - 'revision.id'?: string; - 'revision.author'?: string; - 'revision.email'?: string; - 'revision.subject'?: string; - 'revision.timestamp'?: number | Date; - 'revision.link'?: string; - 'revision.diff'?: string; - 'pull.link'?: string; - 'pull.diff'?: string; - 'pull.base'?: string; - 'pull.title'?: string; - 'ci.link'?: string; + revision?: { + id?: string; + author?: string; + email?: string; + subject?: string; + timestamp?: number; + link?: string; + diff?: string; + }, + pull_request?: { + link?: string; + diff?: string; + base?: string; + title?: string; + }, + ci?: { + link?: string; + } } diff --git a/packages/playwright/src/plugins/gitCommitInfoPlugin.ts b/packages/playwright/src/plugins/gitCommitInfoPlugin.ts index 7ade4a005c..20945f6332 100644 --- a/packages/playwright/src/plugins/gitCommitInfoPlugin.ts +++ b/packages/playwright/src/plugins/gitCommitInfoPlugin.ts @@ -26,7 +26,8 @@ import type { GitCommitInfo } from '../isomorphic/types'; const GIT_OPERATIONS_TIMEOUT_MS = 1500; export const addGitCommitInfoPlugin = (fullConfig: FullConfigInternal) => { - if (fullConfig.populateGitInfo) + const commitProperty = fullConfig.config.metadata['git.commit.info']; + if (commitProperty && typeof commitProperty === 'object' && Object.keys(commitProperty).length === 0) fullConfig.plugins.push({ factory: gitCommitInfo }); }; @@ -35,10 +36,10 @@ export const gitCommitInfo = (options?: GitCommitInfoPluginOptions): TestRunnerP name: 'playwright:git-commit-info', setup: async (config: FullConfig, configDir: string) => { - const fromEnv = await linksFromEnv(); - const fromCLI = await gitStatusFromCLI(options?.directory || configDir, fromEnv); + const commitInfo = await linksFromEnv(); + await enrichStatusFromCLI(options?.directory || configDir, commitInfo); config.metadata = config.metadata || {}; - config.metadata['git.commit.info'] = { ...fromEnv, ...fromCLI }; + config.metadata['git.commit.info'] = commitInfo; }, }; }; @@ -47,28 +48,39 @@ interface GitCommitInfoPluginOptions { directory?: string; } -async function linksFromEnv() { - const out: Partial = {}; +async function linksFromEnv(): Promise { + const out: GitCommitInfo = {}; // Jenkins: https://www.jenkins.io/doc/book/pipeline/jenkinsfile/#using-environment-variables - if (process.env.BUILD_URL) - out['ci.link'] = process.env.BUILD_URL; + if (process.env.BUILD_URL) { + out.ci = out.ci || {}; + out.ci.link = process.env.BUILD_URL; + } // GitLab: https://docs.gitlab.com/ee/ci/variables/predefined_variables.html - if (process.env.CI_PROJECT_URL && process.env.CI_COMMIT_SHA) - out['revision.link'] = `${process.env.CI_PROJECT_URL}/-/commit/${process.env.CI_COMMIT_SHA}`; - if (process.env.CI_JOB_URL) - out['ci.link'] = process.env.CI_JOB_URL; + if (process.env.CI_PROJECT_URL && process.env.CI_COMMIT_SHA) { + out.revision = out.revision || {}; + out.revision.link = `${process.env.CI_PROJECT_URL}/-/commit/${process.env.CI_COMMIT_SHA}`; + } + if (process.env.CI_JOB_URL) { + out.ci = out.ci || {}; + out.ci.link = process.env.CI_JOB_URL; + } // GitHub: https://docs.github.com/en/actions/learn-github-actions/environment-variables#default-environment-variables - if (process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY && process.env.GITHUB_SHA) - out['revision.link'] = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/commit/${process.env.GITHUB_SHA}`; - if (process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY && process.env.GITHUB_RUN_ID) - out['ci.link'] = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`; + if (process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY && process.env.GITHUB_SHA) { + out.revision = out.revision || {}; + out.revision.link = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/commit/${process.env.GITHUB_SHA}`; + } + if (process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY && process.env.GITHUB_RUN_ID) { + out.ci = out.ci || {}; + out.ci.link = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`; + } if (process.env.GITHUB_EVENT_PATH) { try { const json = JSON.parse(await fs.promises.readFile(process.env.GITHUB_EVENT_PATH, 'utf8')); if (json.pull_request) { - out['pull.title'] = json.pull_request.title; - out['pull.link'] = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/pull/${json.pull_request.number}`; - out['pull.base'] = json.pull_request.base.ref; + out.pull_request = out.pull_request || {}; + out.pull_request.title = json.pull_request.title; + out.pull_request.link = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/pull/${json.pull_request.number}`; + out.pull_request.base = json.pull_request.base.ref; } } catch { } @@ -76,7 +88,7 @@ async function linksFromEnv() { return out; } -async function gitStatusFromCLI(gitDir: string, envInfo: Pick): Promise { +async function enrichStatusFromCLI(gitDir: string, commitInfo: GitCommitInfo) { const separator = `:${createGuid().slice(0, 4)}:`; const commitInfoResult = await spawnAsync( 'git', @@ -90,23 +102,24 @@ async function gitStatusFromCLI(gitDir: string, envInfo: Pick { * Metadata contains key-value pairs to be included in the report. For example, HTML report will display it as * key-value pairs, and JSON report will include metadata serialized as json. * - * See also - * [testConfig.populateGitInfo](https://playwright.dev/docs/api/class-testconfig#test-config-populate-git-info) that - * populates metadata. + * Providing `'git.commit.info': {}` property will populate it with the git commit details. This is useful for CI/CD + * environments. * * **Usage** * @@ -1360,29 +1359,6 @@ interface TestConfig { */ outputDir?: string; - /** - * Whether to populate `'git.commit.info'` field of the - * [testConfig.metadata](https://playwright.dev/docs/api/class-testconfig#test-config-metadata) with Git commit info - * and CI/CD information. - * - * This information will appear in the HTML and JSON reports and is available in the Reporter API. - * - * On Github Actions, this feature is enabled by default. - * - * **Usage** - * - * ```js - * // playwright.config.ts - * import { defineConfig } from '@playwright/test'; - * - * export default defineConfig({ - * populateGitInfo: !!process.env.CI, - * }); - * ``` - * - */ - populateGitInfo?: boolean; - /** * Whether to preserve test output in the * [testConfig.outputDir](https://playwright.dev/docs/api/class-testconfig#test-config-output-dir). Defaults to diff --git a/packages/trace-viewer/src/ui/errorsTab.tsx b/packages/trace-viewer/src/ui/errorsTab.tsx index a5d9a17aa6..5cb28299df 100644 --- a/packages/trace-viewer/src/ui/errorsTab.tsx +++ b/packages/trace-viewer/src/ui/errorsTab.tsx @@ -101,7 +101,7 @@ function Error({ message, error, errorId, sdkLanguage, pageSnapshot, revealInSou const [showLLM, setShowLLM] = React.useState(false); const llmAvailable = useIsLLMAvailable(); const gitCommitInfo = useGitCommitInfo(); - const diff = gitCommitInfo?.['pull.diff'] ?? gitCommitInfo?.['revision.diff']; + const diff = gitCommitInfo?.pull_request?.diff ?? gitCommitInfo?.revision?.diff; let location: string | undefined; let longLocation: string | undefined; diff --git a/tests/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index 9783194073..7e9c550619 100644 --- a/tests/playwright-test/reporter-html.spec.ts +++ b/tests/playwright-test/reporter-html.spec.ts @@ -1187,13 +1187,12 @@ for (const useIntermediateMergeReport of [true, false] as const) { ]); }); - test('should include metadata with populateGitInfo = true', async ({ runInlineTest, writeFiles, showReport, page }) => { + test('should include metadata with git.commit.info', async ({ runInlineTest, writeFiles, showReport, page }) => { const files = { 'uncommitted.txt': `uncommitted file`, 'playwright.config.ts': ` export default { - populateGitInfo: true, - metadata: { foo: 'value1', bar: { prop: 'value2' }, baz: ['value3', 123] } + metadata: { 'git.commit.info': {}, foo: 'value1', bar: { prop: 'value2' }, baz: ['value3', 123] } }; `, 'example.spec.ts': ` @@ -1230,20 +1229,23 @@ for (const useIntermediateMergeReport of [true, false] as const) { expect(result.exitCode).toBe(0); await page.getByRole('button', { name: 'Metadata' }).click(); await expect(page.locator('.metadata-view')).toMatchAriaSnapshot(` - - 'link "chore(html): make this test look nice"' - - text: /^William on/ - - link /^[a-f0-9]{7}$/ - - text: 'foo : value1 bar : {"prop":"value2"} baz : ["value3",123]' + - list: + - listitem: + - 'link "chore(html): make this test look nice"' + - listitem: /William / + - list: + - listitem: "foo : value1" + - listitem: "bar : {\\"prop\\":\\"value2\\"}" + - listitem: "baz : [\\"value3\\",123]" `); }); - test('should include metadata with populateGitInfo on GHA', async ({ runInlineTest, writeFiles, showReport, page }) => { + test('should include metadata with git.commit.info on GHA', async ({ runInlineTest, writeFiles, showReport, page }) => { const files = { 'uncommitted.txt': `uncommitted file`, 'playwright.config.ts': ` export default { - populateGitInfo: true, - metadata: { foo: 'value1', bar: { prop: 'value2' }, baz: ['value3', 123] } + metadata: { 'git.commit.info': {}, foo: 'value1', bar: { prop: 'value2' }, baz: ['value3', 123] } }; `, 'example.spec.ts': ` @@ -1291,18 +1293,23 @@ for (const useIntermediateMergeReport of [true, false] as const) { expect(result.exitCode).toBe(0); await page.getByRole('button', { name: 'Metadata' }).click(); await expect(page.locator('.metadata-view')).toMatchAriaSnapshot(` - - 'link "My PR"' - - text: /^William on/ - - link "Logs" - - link "Pull Request" - - text: 'foo : value1 bar : {"prop":"value2"} baz : ["value3",123]' + - list: + - listitem: + - link "My PR" + - listitem: + - text: /William / + - link "Logs" + - list: + - listitem: "foo : value1" + - listitem: "bar : {\\"prop\\":\\"value2\\"}" + - listitem: "baz : [\\"value3\\",123]" `); }); - test('should not include git metadata with populateGitInfo = false', async ({ runInlineTest, showReport, page }) => { + test('should not include git metadata w/o git.commit.info', async ({ runInlineTest, showReport, page }) => { const result = await runInlineTest({ 'playwright.config.ts': ` - export default { populateGitInfo: false }; + export default {}; `, 'example.spec.ts': ` import { test, expect } from '@playwright/test'; @@ -1323,7 +1330,7 @@ for (const useIntermediateMergeReport of [true, false] as const) { 'playwright.config.ts': ` export default { metadata: { - 'git.commit.info': { 'revision.timestamp': 'hi' } + 'git.commit.info': { revision: { timestamp: 'hi' } } }, }; `, @@ -2757,8 +2764,12 @@ for (const useIntermediateMergeReport of [true, false] as const) { 'uncommitted.txt': `uncommitted file`, 'playwright.config.ts': ` export default { - populateGitInfo: true, - metadata: { foo: 'value1', bar: { prop: 'value2' }, baz: ['value3', 123] } + metadata: { + 'git.commit.info': {}, + foo: 'value1', + bar: { prop: 'value2' }, + baz: ['value3', 123] + } }; `, 'example.spec.ts': ` diff --git a/tests/playwright-test/ui-mode-metadata.spec.ts b/tests/playwright-test/ui-mode-metadata.spec.ts index 8f032ea987..c6127cb7cb 100644 --- a/tests/playwright-test/ui-mode-metadata.spec.ts +++ b/tests/playwright-test/ui-mode-metadata.spec.ts @@ -21,14 +21,14 @@ test('should render html report git info metadata', async ({ runUITest }) => { 'reporter.ts': ` module.exports = class Reporter { onBegin(config, suite) { - console.log('ci.link:', config.metadata['git.commit.info']['ci.link']); + console.log('ci.link:', config.metadata['git.commit.info'].ci.link); } } `, 'playwright.config.ts': ` import { defineConfig } from '@playwright/test'; export default defineConfig({ - populateGitInfo: true, + metadata: { 'git.commit.info': {} }, reporter: './reporter.ts', }); `, From aaac9923fddfd782e3fb573a71787bb77b8f40a8 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Tue, 25 Feb 2025 11:33:15 -0800 Subject: [PATCH 11/26] fix: disable global timeout when debugging (#34922) --- packages/playwright/src/common/config.ts | 2 +- tests/playwright-test/timeout.spec.ts | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/playwright/src/common/config.ts b/packages/playwright/src/common/config.ts index 4e266110fd..4ebc900872 100644 --- a/packages/playwright/src/common/config.ts +++ b/packages/playwright/src/common/config.ts @@ -92,7 +92,7 @@ export class FullConfigInternal { fullyParallel: takeFirst(configCLIOverrides.fullyParallel, userConfig.fullyParallel, false), globalSetup: this.globalSetups[0] ?? null, globalTeardown: this.globalTeardowns[0] ?? null, - globalTimeout: takeFirst(configCLIOverrides.globalTimeout, userConfig.globalTimeout, 0), + globalTimeout: takeFirst(configCLIOverrides.debug ? 0 : undefined, configCLIOverrides.globalTimeout, userConfig.globalTimeout, 0), grep: takeFirst(userConfig.grep, defaultGrep), grepInvert: takeFirst(userConfig.grepInvert, null), maxFailures: takeFirst(configCLIOverrides.debug ? 1 : undefined, configCLIOverrides.maxFailures, userConfig.maxFailures, 0), diff --git a/tests/playwright-test/timeout.spec.ts b/tests/playwright-test/timeout.spec.ts index cb4e365b4b..9d6001078d 100644 --- a/tests/playwright-test/timeout.spec.ts +++ b/tests/playwright-test/timeout.spec.ts @@ -164,6 +164,26 @@ test('should ignore test.setTimeout when debugging', async ({ runInlineTest }) = expect(result.passed).toBe(1); }); +test('should ignore globalTimeout when debugging', { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/34911' }, +}, async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.ts': ` + export default { + globalTimeout: 100, + }; + `, + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('my test', async ({ }) => { + await new Promise(f => setTimeout(f, 2000)); + }); + ` + }, { debug: true }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); +}); + test('should respect fixture timeout', async ({ runInlineTest }) => { const result = await runInlineTest({ 'a.spec.ts': ` From 439427c14e8a59a86b322165e48cd41b3dac7dae Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Wed, 26 Feb 2025 10:55:02 +0000 Subject: [PATCH 12/26] chore: remove stages from TestInfoImpl (#34919) --- .../playwright/src/worker/fixtureRunner.ts | 32 ++++------ packages/playwright/src/worker/testInfo.ts | 61 ++++++++----------- .../playwright/src/worker/timeoutManager.ts | 14 ++++- packages/playwright/src/worker/workerMain.ts | 26 ++++---- 4 files changed, 62 insertions(+), 71 deletions(-) diff --git a/packages/playwright/src/worker/fixtureRunner.ts b/packages/playwright/src/worker/fixtureRunner.ts index 555f86ab57..51bd517dde 100644 --- a/packages/playwright/src/worker/fixtureRunner.ts +++ b/packages/playwright/src/worker/fixtureRunner.ts @@ -35,7 +35,7 @@ class Fixture { private _selfTeardownComplete: Promise | undefined; private _setupDescription: FixtureDescription; private _teardownDescription: FixtureDescription; - private _stepInfo: { category: 'fixture', location?: Location } | undefined; + private _stepInfo: { title: string, category: 'fixture', location?: Location } | undefined; _deps = new Set(); _usages = new Set(); @@ -47,7 +47,7 @@ class Fixture { const isUserFixture = this.registration.location && filterStackFile(this.registration.location.file); const title = this.registration.customTitle || this.registration.name; const location = isUserFixture ? this.registration.location : undefined; - this._stepInfo = shouldGenerateStep ? { category: 'fixture', location } : undefined; + this._stepInfo = shouldGenerateStep ? { title: `fixture: ${title}`, category: 'fixture', location } : undefined; this._setupDescription = { title, phase: 'setup', @@ -68,13 +68,11 @@ class Fixture { return; } - await testInfo._runAsStage({ - title: `fixture: ${this.registration.customTitle ?? this.registration.name}`, - runnable: { ...runnable, fixture: this._setupDescription }, - stepInfo: this._stepInfo, - }, async () => { - await this._setupInternal(testInfo); - }); + const run = () => testInfo._runWithTimeout({ ...runnable, fixture: this._setupDescription }, () => this._setupInternal(testInfo)); + if (this._stepInfo) + await testInfo._runAsStep(this._stepInfo, run); + else + await run(); } private async _setupInternal(testInfo: TestInfoImpl) { @@ -133,13 +131,11 @@ class Fixture { // Do not even start the teardown for a fixture that does not have any // time remaining in the time slot. This avoids cascading timeouts. if (!testInfo._timeoutManager.isTimeExhaustedFor(fixtureRunnable)) { - await testInfo._runAsStage({ - title: `fixture: ${this.registration.customTitle ?? this.registration.name}`, - runnable: fixtureRunnable, - stepInfo: this._stepInfo, - }, async () => { - await this._teardownInternal(); - }); + const run = () => testInfo._runWithTimeout(fixtureRunnable, () => this._teardownInternal()); + if (this._stepInfo) + await testInfo._runAsStep(this._stepInfo, run); + else + await run(); } } finally { // To preserve fixtures integrity, forcefully cleanup fixtures @@ -268,9 +264,7 @@ export class FixtureRunner { // Do not run the function when fixture setup has already failed. return null; } - await testInfo._runAsStage({ title: 'run function', runnable }, async () => { - await fn(params, testInfo); - }); + await testInfo._runWithTimeout(runnable, () => fn(params, testInfo)); } private async _setupFixtureForRegistration(registration: FixtureRegistration, testInfo: TestInfoImpl, runnable: RunnableDescription): Promise { diff --git a/packages/playwright/src/worker/testInfo.ts b/packages/playwright/src/worker/testInfo.ts index 5bac56776d..2a0557d0b8 100644 --- a/packages/playwright/src/worker/testInfo.ts +++ b/packages/playwright/src/worker/testInfo.ts @@ -20,7 +20,7 @@ import path from 'path'; import { captureRawStack, monotonicTime, sanitizeForFilePath, stringifyStackFrames, currentZone } from 'playwright-core/lib/utils'; import { TimeoutManager, TimeoutManagerError, kMaxDeadline } from './timeoutManager'; -import { debugTest, filteredStackTrace, formatLocation, getContainedPath, normalizeAndSaveAttachment, trimLongString, windowsFilesystemFriendlyLength } from '../util'; +import { filteredStackTrace, getContainedPath, normalizeAndSaveAttachment, trimLongString, windowsFilesystemFriendlyLength } from '../util'; import { TestTracing } from './testTracing'; import { testInfoError } from './util'; import { FloatingPromiseScope } from './floatingPromiseScope'; @@ -50,16 +50,8 @@ export interface TestStepInternal { error?: TestInfoErrorImpl; infectParentStepsWithError?: boolean; box?: boolean; - isStage?: boolean; } -export type TestStage = { - title: string; - stepInfo?: { category: 'hook' | 'fixture', location?: Location }; - runnable?: RunnableDescription; - step?: TestStepInternal; -}; - export class TestInfoImpl implements TestInfo { private _onStepBegin: (payload: StepBeginPayload) => void; private _onStepEnd: (payload: StepEndPayload) => void; @@ -235,28 +227,27 @@ export class TestInfoImpl implements TestInfo { } } - private _findLastStageStep(steps: TestStepInternal[]): TestStepInternal | undefined { - // Find the deepest step that is marked as isStage and has not finished yet. + private _findLastPredefinedStep(steps: TestStepInternal[]): TestStepInternal | undefined { + // Find the deepest predefined step that has not finished yet. for (let i = steps.length - 1; i >= 0; i--) { - const child = this._findLastStageStep(steps[i].steps); + const child = this._findLastPredefinedStep(steps[i].steps); if (child) return child; - if (steps[i].isStage && !steps[i].endWallTime) + if ((steps[i].category === 'hook' || steps[i].category === 'fixture') && !steps[i].endWallTime) return steps[i]; } } private _parentStep() { - return currentZone().data('stepZone') - ?? this._findLastStageStep(this._steps); // If no parent step on stack, assume the current stage as parent. + return currentZone().data('stepZone') ?? this._findLastPredefinedStep(this._steps); } _addStep(data: Omit, parentStep?: TestStepInternal): TestStepInternal { const stepId = `${data.category}@${++this._lastStepId}`; - if (data.isStage) { - // Predefined stages form a fixed hierarchy - use the current one as parent. - parentStep = this._findLastStageStep(this._steps); + if (data.category === 'hook' || data.category === 'fixture') { + // Predefined steps form a fixed hierarchy - use the current one as parent. + parentStep = this._findLastPredefinedStep(this._steps); } else { if (!parentStep) parentStep = this._parentStep(); @@ -355,21 +346,23 @@ export class TestInfoImpl implements TestInfo { this._tracing.appendForError(serialized); } - async _runAsStage(stage: TestStage, cb: () => Promise) { - if (debugTest.enabled) { - const location = stage.runnable?.location ? ` at "${formatLocation(stage.runnable.location)}"` : ``; - debugTest(`started stage "${stage.title}"${location}`); - } - stage.step = stage.stepInfo ? this._addStep({ ...stage.stepInfo, title: stage.title, isStage: true }) : undefined; - + async _runAsStep(stepInfo: { title: string, category: 'hook' | 'fixture', location?: Location }, cb: () => Promise) { + const step = this._addStep(stepInfo); try { - await this._timeoutManager.withRunnable(stage.runnable, async () => { + await cb(); + step.complete({}); + } catch (error) { + step.complete({ error }); + throw error; + } + } + + async _runWithTimeout(runnable: RunnableDescription, cb: () => Promise) { + try { + await this._timeoutManager.withRunnable(runnable, async () => { try { await cb(); } catch (e) { - // Only handle errors directly thrown by the user code. - if (!stage.runnable) - throw e; if (this._allowSkips && (e instanceof SkipError)) { if (this.status === 'passed') this.status = 'skipped'; @@ -377,7 +370,7 @@ export class TestInfoImpl implements TestInfo { // Unfortunately, we have to handle user errors and timeout errors differently. // Consider the following scenario: // - locator.click times out - // - all stages containing the test function finish with TimeoutManagerError + // - all steps containing the test function finish with TimeoutManagerError // - test finishes, the page is closed and this triggers locator.click error // - we would like to present the locator.click error to the user // - therefore, we need a try/catch inside the "run with timeout" block and capture the error @@ -386,16 +379,12 @@ export class TestInfoImpl implements TestInfo { throw e; } }); - stage.step?.complete({}); } catch (error) { // When interrupting, we arrive here with a TimeoutManagerError, but we should not // consider it a timeout. - if (!this._wasInterrupted && (error instanceof TimeoutManagerError) && stage.runnable) + if (!this._wasInterrupted && (error instanceof TimeoutManagerError)) this._failWithError(error); - stage.step?.complete({ error }); throw error; - } finally { - debugTest(`finished stage "${stage.title}"`); } } @@ -430,7 +419,7 @@ export class TestInfoImpl implements TestInfo { } else { // trace viewer has no means of representing attachments outside of a step, so we create an artificial action const callId = `attach@${++this._lastStepId}`; - this._tracing.appendBeforeActionForStep(callId, this._findLastStageStep(this._steps)?.stepId, 'attach', `attach "${attachment.name}"`, undefined, []); + this._tracing.appendBeforeActionForStep(callId, this._findLastPredefinedStep(this._steps)?.stepId, 'attach', `attach "${attachment.name}"`, undefined, []); this._tracing.appendAfterActionForStep(callId, undefined, [attachment]); } diff --git a/packages/playwright/src/worker/timeoutManager.ts b/packages/playwright/src/worker/timeoutManager.ts index 65e97c9f36..c003342d15 100644 --- a/packages/playwright/src/worker/timeoutManager.ts +++ b/packages/playwright/src/worker/timeoutManager.ts @@ -17,6 +17,8 @@ import { ManualPromise, monotonicTime } from 'playwright-core/lib/utils'; import { colors } from 'playwright-core/lib/utils'; +import { debugTest, formatLocation } from '../util'; + import type { Location } from '../../types/testReporter'; export type TimeSlot = { @@ -76,9 +78,7 @@ export class TimeoutManager { return slot.timeout > 0 && (slot.elapsed >= slot.timeout - 1); } - async withRunnable(runnable: RunnableDescription | undefined, cb: () => Promise): Promise { - if (!runnable) - return await cb(); + async withRunnable(runnable: RunnableDescription, cb: () => Promise): Promise { if (this._running) throw new Error(`Internal error: duplicate runnable`); const running = this._running = { @@ -89,7 +89,13 @@ export class TimeoutManager { timer: undefined, timeoutPromise: new ManualPromise(), }; + let debugTitle = ''; try { + if (debugTest.enabled) { + debugTitle = runnable.fixture ? `${runnable.fixture.phase} "${runnable.fixture.title}"` : runnable.type; + const location = runnable.location ? ` at "${formatLocation(runnable.location)}"` : ``; + debugTest(`started ${debugTitle}${location}`); + } this._updateTimeout(running); return await Promise.race([ cb(), @@ -101,6 +107,8 @@ export class TimeoutManager { running.timer = undefined; running.slot.elapsed += monotonicTime() - running.start; this._running = undefined; + if (debugTest.enabled) + debugTest(`finished ${debugTitle}`); } } diff --git a/packages/playwright/src/worker/workerMain.ts b/packages/playwright/src/worker/workerMain.ts index 8ff6d31fd0..91abde8a2e 100644 --- a/packages/playwright/src/worker/workerMain.ts +++ b/packages/playwright/src/worker/workerMain.ts @@ -115,12 +115,12 @@ export class WorkerMain extends ProcessRunner { const fakeTestInfo = new TestInfoImpl(this._config, this._project, this._params, undefined, 0, () => {}, () => {}, () => {}); const runnable = { type: 'teardown' } as const; // We have to load the project to get the right deadline below. - await fakeTestInfo._runAsStage({ title: 'worker cleanup', runnable }, () => this._loadIfNeeded()).catch(() => {}); + await fakeTestInfo._runWithTimeout(runnable, () => this._loadIfNeeded()).catch(() => {}); await this._fixtureRunner.teardownScope('test', fakeTestInfo, runnable).catch(() => {}); await this._fixtureRunner.teardownScope('worker', fakeTestInfo, runnable).catch(() => {}); // Close any other browsers launched in this process. This includes anything launched // manually in the test/hooks and internal browsers like Playwright Inspector. - await fakeTestInfo._runAsStage({ title: 'worker cleanup', runnable }, () => gracefullyCloseAll()).catch(() => {}); + await fakeTestInfo._runWithTimeout(runnable, () => gracefullyCloseAll()).catch(() => {}); this._fatalErrors.push(...fakeTestInfo.errors); } catch (e) { this._fatalErrors.push(testInfoError(e)); @@ -330,8 +330,8 @@ export class WorkerMain extends ProcessRunner { testInfo._floatingPromiseScope.clear(); }; - await testInfo._runAsStage({ title: 'setup and test' }, async () => { - await testInfo._runAsStage({ title: 'start tracing', runnable: { type: 'test' } }, async () => { + await (async () => { + await testInfo._runWithTimeout({ type: 'test' }, async () => { // Ideally, "trace" would be an config-level option belonging to the // test runner instead of a fixture belonging to Playwright. // However, for backwards compatibility, we have to read it from a fixture today. @@ -356,7 +356,7 @@ export class WorkerMain extends ProcessRunner { await removeFolders([testInfo.outputDir]); let testFunctionParams: object | null = null; - await testInfo._runAsStage({ title: 'Before Hooks', stepInfo: { category: 'hook' } }, async () => { + await testInfo._runAsStep({ title: 'Before Hooks', category: 'hook' }, async () => { // Run "beforeAll" hooks, unless already run during previous tests. for (const suite of suites) await this._runBeforeAllHooksForSuite(suite, testInfo); @@ -376,13 +376,13 @@ export class WorkerMain extends ProcessRunner { return; } - await testInfo._runAsStage({ title: 'test function', runnable: { type: 'test' } }, async () => { + await testInfo._runWithTimeout({ type: 'test' }, async () => { // Now run the test itself. const fn = test.fn; // Extract a variable to get a better stack trace ("myTest" vs "TestCase.myTest [as fn]"). await fn(testFunctionParams, testInfo); checkForFloatingPromises('the test'); }); - }).catch(() => {}); // Ignore the top-level error, it is already inside TestInfo.errors. + })().catch(() => {}); // Ignore the top-level error, it is already inside TestInfo.errors. // Update duration, so it is available in fixture teardown and afterEach hooks. testInfo.duration = testInfo._timeoutManager.defaultSlot().elapsed | 0; @@ -393,12 +393,12 @@ export class WorkerMain extends ProcessRunner { // After hooks get an additional timeout. const afterHooksTimeout = calculateMaxTimeout(this._project.project.timeout, testInfo.timeout); const afterHooksSlot = { timeout: afterHooksTimeout, elapsed: 0 }; - await testInfo._runAsStage({ title: 'After Hooks', stepInfo: { category: 'hook' } }, async () => { + await testInfo._runAsStep({ title: 'After Hooks', category: 'hook' }, async () => { let firstAfterHooksError: Error | undefined; try { // Run "immediately upon test function finish" callback. - await testInfo._runAsStage({ title: 'on-test-function-finish', runnable: { type: 'test', slot: afterHooksSlot } }, async () => testInfo._onDidFinishTestFunction?.()); + await testInfo._runWithTimeout({ type: 'test', slot: afterHooksSlot }, async () => testInfo._onDidFinishTestFunction?.()); } catch (error) { firstAfterHooksError = firstAfterHooksError ?? error; } @@ -448,7 +448,7 @@ export class WorkerMain extends ProcessRunner { // Mark as "cleaned up" early to avoid running cleanup twice. this._didRunFullCleanup = true; - await testInfo._runAsStage({ title: 'Worker Cleanup', stepInfo: { category: 'hook' } }, async () => { + await testInfo._runAsStep({ title: 'Worker Cleanup', category: 'hook' }, async () => { let firstWorkerCleanupError: Error | undefined; // Give it more time for the full cleanup. @@ -481,7 +481,7 @@ export class WorkerMain extends ProcessRunner { } const tracingSlot = { timeout: this._project.project.timeout, elapsed: 0 }; - await testInfo._runAsStage({ title: 'stop tracing', runnable: { type: 'test', slot: tracingSlot } }, async () => { + await testInfo._runWithTimeout({ type: 'test', slot: tracingSlot }, async () => { await testInfo._tracing.stopIfNeeded(); }).catch(() => {}); // Ignore the top-level error, it is already inside TestInfo.errors. @@ -534,7 +534,7 @@ export class WorkerMain extends ProcessRunner { let firstError: Error | undefined; for (const hook of this._collectHooksAndModifiers(suite, type, testInfo)) { try { - await testInfo._runAsStage({ title: hook.title, stepInfo: { category: 'hook', location: hook.location } }, async () => { + await testInfo._runAsStep({ title: hook.title, category: 'hook', location: hook.location }, async () => { // Separate time slot for each beforeAll/afterAll hook. const timeSlot = { timeout: this._project.project.timeout, elapsed: 0 }; const runnable = { type: hook.type, slot: timeSlot, location: hook.location }; @@ -587,7 +587,7 @@ export class WorkerMain extends ProcessRunner { continue; } try { - await testInfo._runAsStage({ title: hook.title, stepInfo: { category: 'hook', location: hook.location } }, async () => { + await testInfo._runAsStep({ title: hook.title, category: 'hook', location: hook.location }, async () => { await this._fixtureRunner.resolveParametersAndRunFunction(hook.fn, testInfo, 'test', runnable); }); } catch (error) { From a04a93c1fd0e6a9742d02833f9d54753ab5c6d93 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Wed, 26 Feb 2025 14:27:17 +0100 Subject: [PATCH 13/26] chore: change pageSnapshot extension to `.snapshot.yml` (#34930) --- packages/playwright/src/index.ts | 2 +- .../playwright.artifacts.spec.ts | 42 +++++++++---------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts index e8afe5a74a..68b29bf3d6 100644 --- a/packages/playwright/src/index.ts +++ b/packages/playwright/src/index.ts @@ -628,7 +628,7 @@ class ArtifactsRecorder { await page.screenshot({ ...screenshotOptions, timeout: 5000, path, caret: 'initial' }); }); - this._pageSnapshotRecorder = new SnapshotRecorder(this, pageSnapshot, 'pageSnapshot', 'text/plain', '.ariasnapshot', async (page, path) => { + this._pageSnapshotRecorder = new SnapshotRecorder(this, pageSnapshot, 'pageSnapshot', 'text/plain', '.snapshot.yml', async (page, path) => { const ariaSnapshot = await page.locator('body').ariaSnapshot({ timeout: 5000 }); await fs.promises.writeFile(path, ariaSnapshot); }); diff --git a/tests/playwright-test/playwright.artifacts.spec.ts b/tests/playwright-test/playwright.artifacts.spec.ts index 5a8a6fcbef..d54fe5265c 100644 --- a/tests/playwright-test/playwright.artifacts.spec.ts +++ b/tests/playwright-test/playwright.artifacts.spec.ts @@ -435,29 +435,29 @@ test('should work with pageSnapshot: on', async ({ runInlineTest }, testInfo) => expect(listFiles(testInfo.outputPath('test-results'))).toEqual([ '.last-run.json', 'artifacts-failing', - ' test-failed-1.ariasnapshot', + ' test-failed-1.snapshot.yml', 'artifacts-own-context-failing', - ' test-failed-1.ariasnapshot', + ' test-failed-1.snapshot.yml', 'artifacts-own-context-passing', - ' test-finished-1.ariasnapshot', + ' test-finished-1.snapshot.yml', 'artifacts-passing', - ' test-finished-1.ariasnapshot', + ' test-finished-1.snapshot.yml', 'artifacts-persistent-failing', - ' test-failed-1.ariasnapshot', + ' test-failed-1.snapshot.yml', 'artifacts-persistent-passing', - ' test-finished-1.ariasnapshot', + ' test-finished-1.snapshot.yml', 'artifacts-shared-shared-failing', - ' test-failed-1.ariasnapshot', - ' test-failed-2.ariasnapshot', + ' test-failed-1.snapshot.yml', + ' test-failed-2.snapshot.yml', 'artifacts-shared-shared-passing', - ' test-finished-1.ariasnapshot', - ' test-finished-2.ariasnapshot', + ' test-finished-1.snapshot.yml', + ' test-finished-2.snapshot.yml', 'artifacts-two-contexts', - ' test-finished-1.ariasnapshot', - ' test-finished-2.ariasnapshot', + ' test-finished-1.snapshot.yml', + ' test-finished-2.snapshot.yml', 'artifacts-two-contexts-failing', - ' test-failed-1.ariasnapshot', - ' test-failed-2.ariasnapshot', + ' test-failed-1.snapshot.yml', + ' test-failed-2.snapshot.yml', ]); }); @@ -475,16 +475,16 @@ test('should work with pageSnapshot: only-on-failure', async ({ runInlineTest }, expect(listFiles(testInfo.outputPath('test-results'))).toEqual([ '.last-run.json', 'artifacts-failing', - ' test-failed-1.ariasnapshot', + ' test-failed-1.snapshot.yml', 'artifacts-own-context-failing', - ' test-failed-1.ariasnapshot', + ' test-failed-1.snapshot.yml', 'artifacts-persistent-failing', - ' test-failed-1.ariasnapshot', + ' test-failed-1.snapshot.yml', 'artifacts-shared-shared-failing', - ' test-failed-1.ariasnapshot', - ' test-failed-2.ariasnapshot', + ' test-failed-1.snapshot.yml', + ' test-failed-2.snapshot.yml', 'artifacts-two-contexts-failing', - ' test-failed-1.ariasnapshot', - ' test-failed-2.ariasnapshot', + ' test-failed-1.snapshot.yml', + ' test-failed-2.snapshot.yml', ]); }); From 17c4d8e5ec7440a5a3c61a049c45bff679a798e0 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Wed, 26 Feb 2025 16:29:56 +0100 Subject: [PATCH 14/26] chore: change generated filename for `toMatchAriaSnapshot` from `.yml` to `.snapshot.yml` (#34931) Signed-off-by: Simon Knott Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Dmitry Gozman --- docs/src/api/class-locatorassertions.md | 4 +- docs/src/aria-snapshots.md | 4 +- .../src/matchers/toMatchAriaSnapshot.ts | 14 +++- packages/playwright/src/util.ts | 13 ++- packages/playwright/src/worker/testInfo.ts | 10 ++- packages/playwright/types/test.d.ts | 6 +- .../aria-snapshot-file.spec.ts | 80 ++++++++++++------- 7 files changed, 84 insertions(+), 47 deletions(-) diff --git a/docs/src/api/class-locatorassertions.md b/docs/src/api/class-locatorassertions.md index d7b4463265..26b487d0ff 100644 --- a/docs/src/api/class-locatorassertions.md +++ b/docs/src/api/class-locatorassertions.md @@ -2263,13 +2263,13 @@ assertThat(page.locator("body")).matchesAriaSnapshot(""" Asserts that the target element matches the given [accessibility snapshot](../aria-snapshots.md). -Snapshot is stored in a separate `.yml` file in a location configured by `expect.toMatchAriaSnapshot.pathTemplate` and/or `snapshotPathTemplate` properties in the configuration file. +Snapshot is stored in a separate `.snapshot.yml` file in a location configured by `expect.toMatchAriaSnapshot.pathTemplate` and/or `snapshotPathTemplate` properties in the configuration file. **Usage** ```js await expect(page.locator('body')).toMatchAriaSnapshot(); -await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'body.yml' }); +await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'body.snapshot.yml' }); ``` ### option: LocatorAssertions.toMatchAriaSnapshot#2.name diff --git a/docs/src/aria-snapshots.md b/docs/src/aria-snapshots.md index 25b05164ad..0467cf3878 100644 --- a/docs/src/aria-snapshots.md +++ b/docs/src/aria-snapshots.md @@ -339,10 +339,10 @@ npx playwright test --update-snapshots --update-source-mode=3way #### Snapshots as separate files -To store your snapshots in a separate file, use the `toMatchAriaSnapshot` method with the `name` option, specifying a `.yml` file extension. +To store your snapshots in a separate file, use the `toMatchAriaSnapshot` method with the `name` option, specifying a `.snapshot.yml` file extension. ```js -await expect(page.getByRole('main')).toMatchAriaSnapshot({ name: 'main-snapshot.yml' }); +await expect(page.getByRole('main')).toMatchAriaSnapshot({ name: 'main.snapshot.yml' }); ``` By default, snapshots from a test file `example.spec.ts` are placed in the `example.spec.ts-snapshots` directory. As snapshots should be the same across browsers, only one snapshot is saved even if testing with multiple browsers. Should you wish, you can customize the [snapshot path template](./api/class-testconfig#test-config-snapshot-path-template) using the following configuration: diff --git a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts index ad1855a88e..0c76a97c68 100644 --- a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts +++ b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts @@ -22,7 +22,7 @@ import { escapeTemplateString, isString, sanitizeForFilePath } from 'playwright- import { kNoElementsFoundError, matcherHint } from './matcherHint'; import { EXPECTED_COLOR } from '../common/expectBundle'; -import { callLogText, sanitizeFilePathBeforeExtension, trimLongString } from '../util'; +import { callLogText, fileExistsAsync, sanitizeFilePathBeforeExtension, trimLongString } from '../util'; import { printReceivedStringContainExpectedSubstring } from './expect'; import { currentTestInfo } from '../common/globals'; @@ -70,7 +70,8 @@ export async function toMatchAriaSnapshot( timeout = options.timeout ?? this.timeout; } else { if (expectedParam?.name) { - expectedPath = testInfo._resolveSnapshotPath(pathTemplate, defaultTemplate, [sanitizeFilePathBeforeExtension(expectedParam.name)]); + const ext = expectedParam.name!.endsWith('.snapshot.yml') ? '.snapshot.yml' : undefined; + expectedPath = testInfo._resolveSnapshotPath(pathTemplate, defaultTemplate, [sanitizeFilePathBeforeExtension(expectedParam.name, ext)]); } else { let snapshotNames = (testInfo as any)[snapshotNamesSymbol] as SnapshotNames; if (!snapshotNames) { @@ -78,7 +79,14 @@ export async function toMatchAriaSnapshot( (testInfo as any)[snapshotNamesSymbol] = snapshotNames; } const fullTitleWithoutSpec = [...testInfo.titlePath.slice(1), ++snapshotNames.anonymousSnapshotIndex].join(' '); - expectedPath = testInfo._resolveSnapshotPath(pathTemplate, defaultTemplate, [sanitizeForFilePath(trimLongString(fullTitleWithoutSpec)) + '.yml']); + expectedPath = testInfo._resolveSnapshotPath(pathTemplate, defaultTemplate, [sanitizeForFilePath(trimLongString(fullTitleWithoutSpec))], '.snapshot.yml'); + // in 1.51, we changed the default template to use .snapshot.yml extension + // for backwards compatibility, we check for the legacy .yml extension + if (!(await fileExistsAsync(expectedPath))) { + const legacyPath = testInfo._resolveSnapshotPath(pathTemplate, defaultTemplate, [sanitizeForFilePath(trimLongString(fullTitleWithoutSpec))], '.yml'); + if (await fileExistsAsync(legacyPath)) + expectedPath = legacyPath; + } } expected = await fs.promises.readFile(expectedPath, 'utf8').catch(() => ''); timeout = expectedParam?.timeout ?? this.timeout; diff --git a/packages/playwright/src/util.ts b/packages/playwright/src/util.ts index 53760970bd..c992313e06 100644 --- a/packages/playwright/src/util.ts +++ b/packages/playwright/src/util.ts @@ -206,8 +206,8 @@ export function addSuffixToFilePath(filePath: string, suffix: string): string { return base + suffix + ext; } -export function sanitizeFilePathBeforeExtension(filePath: string): string { - const ext = path.extname(filePath); +export function sanitizeFilePathBeforeExtension(filePath: string, ext?: string): string { + ext ??= path.extname(filePath); const base = filePath.substring(0, filePath.length - ext.length); return sanitizeForFilePath(base) + ext; } @@ -391,6 +391,15 @@ function fileExists(resolved: string) { return fs.statSync(resolved, { throwIfNoEntry: false })?.isFile(); } +export async function fileExistsAsync(resolved: string) { + try { + const stat = await fs.promises.stat(resolved); + return stat.isFile(); + } catch { + return false; + } +} + function dirExists(resolved: string) { return fs.statSync(resolved, { throwIfNoEntry: false })?.isDirectory(); } diff --git a/packages/playwright/src/worker/testInfo.ts b/packages/playwright/src/worker/testInfo.ts index 2a0557d0b8..72bb9fb025 100644 --- a/packages/playwright/src/worker/testInfo.ts +++ b/packages/playwright/src/worker/testInfo.ts @@ -452,9 +452,11 @@ export class TestInfoImpl implements TestInfo { return sanitizeForFilePath(trimLongString(fullTitleWithoutSpec)); } - _resolveSnapshotPath(template: string | undefined, defaultTemplate: string, pathSegments: string[]) { + _resolveSnapshotPath(template: string | undefined, defaultTemplate: string, pathSegments: string[], extension?: string) { const subPath = path.join(...pathSegments); - const parsedSubPath = path.parse(subPath); + const dir = path.dirname(subPath); + const ext = extension ?? path.extname(subPath); + const name = path.basename(subPath, ext); const relativeTestFilePath = path.relative(this.project.testDir, this._requireFile); const parsedRelativeTestFilePath = path.parse(relativeTestFilePath); const projectNamePathSegment = sanitizeForFilePath(this.project.name); @@ -470,8 +472,8 @@ export class TestInfoImpl implements TestInfo { .replace(/\{(.)?testName\}/g, '$1' + this._fsSanitizedTestName()) .replace(/\{(.)?testFileName\}/g, '$1' + parsedRelativeTestFilePath.base) .replace(/\{(.)?testFilePath\}/g, '$1' + relativeTestFilePath) - .replace(/\{(.)?arg\}/g, '$1' + path.join(parsedSubPath.dir, parsedSubPath.name)) - .replace(/\{(.)?ext\}/g, parsedSubPath.ext ? '$1' + parsedSubPath.ext : ''); + .replace(/\{(.)?arg\}/g, '$1' + path.join(dir, name)) + .replace(/\{(.)?ext\}/g, ext ? '$1' + ext : ''); return path.normalize(path.resolve(this._configInternal.configDir, snapshotPath)); } diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index 76d2b31ffd..8653327f0e 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -8791,14 +8791,14 @@ interface LocatorAssertions { /** * Asserts that the target element matches the given [accessibility snapshot](https://playwright.dev/docs/aria-snapshots). * - * Snapshot is stored in a separate `.yml` file in a location configured by `expect.toMatchAriaSnapshot.pathTemplate` - * and/or `snapshotPathTemplate` properties in the configuration file. + * Snapshot is stored in a separate `.snapshot.yml` file in a location configured by + * `expect.toMatchAriaSnapshot.pathTemplate` and/or `snapshotPathTemplate` properties in the configuration file. * * **Usage** * * ```js * await expect(page.locator('body')).toMatchAriaSnapshot(); - * await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'body.yml' }); + * await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'body.snapshot.yml' }); * ``` * * @param options diff --git a/tests/playwright-test/aria-snapshot-file.spec.ts b/tests/playwright-test/aria-snapshot-file.spec.ts index c121d623d1..f02ae3b26b 100644 --- a/tests/playwright-test/aria-snapshot-file.spec.ts +++ b/tests/playwright-test/aria-snapshot-file.spec.ts @@ -22,14 +22,14 @@ test.describe.configure({ mode: 'parallel' }); test('should match snapshot with name', async ({ runInlineTest }, testInfo) => { const result = await runInlineTest({ - 'a.spec.ts-snapshots/test.yml': ` + 'a.spec.ts-snapshots/test.snapshot.yml': ` - heading "hello world" `, 'a.spec.ts': ` import { test, expect } from '@playwright/test'; test('test', async ({ page }) => { await page.setContent(\`

hello world

\`); - await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test.yml' }); + await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test.snapshot.yml' }); }); ` }); @@ -43,66 +43,66 @@ test('should generate multiple missing', async ({ runInlineTest }, testInfo) => import { test, expect } from '@playwright/test'; test('test', async ({ page }) => { await page.setContent(\`

hello world

\`); - await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test-1.yml' }); + await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test-1.snapshot.yml' }); await page.setContent(\`

hello world 2

\`); - await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test-2.yml' }); + await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test-2.snapshot.yml' }); }); ` }); expect(result.exitCode).toBe(1); - expect(result.output).toContain(`A snapshot doesn't exist at a.spec.ts-snapshots${path.sep}test-1.yml, writing actual`); - expect(result.output).toContain(`A snapshot doesn't exist at a.spec.ts-snapshots${path.sep}test-2.yml, writing actual`); - const snapshot1 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-1.yml'), 'utf8'); + expect(result.output).toContain(`A snapshot doesn't exist at a.spec.ts-snapshots${path.sep}test-1.snapshot.yml, writing actual`); + expect(result.output).toContain(`A snapshot doesn't exist at a.spec.ts-snapshots${path.sep}test-2.snapshot.yml, writing actual`); + const snapshot1 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-1.snapshot.yml'), 'utf8'); expect(snapshot1).toBe('- heading "hello world" [level=1]'); - const snapshot2 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-2.yml'), 'utf8'); + const snapshot2 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-2.snapshot.yml'), 'utf8'); expect(snapshot2).toBe('- heading "hello world 2" [level=1]'); }); test('should rebaseline all', async ({ runInlineTest }, testInfo) => { const result = await runInlineTest({ - 'a.spec.ts-snapshots/test-1.yml': ` + 'a.spec.ts-snapshots/test-1.snapshot.yml': ` - heading "foo" `, - 'a.spec.ts-snapshots/test-2.yml': ` + 'a.spec.ts-snapshots/test-2.snapshot.yml': ` - heading "bar" `, 'a.spec.ts': ` import { test, expect } from '@playwright/test'; test('test', async ({ page }) => { await page.setContent(\`

hello world

\`); - await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test-1.yml' }); + await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test-1.snapshot.yml' }); await page.setContent(\`

hello world 2

\`); - await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test-2.yml' }); + await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test-2.snapshot.yml' }); }); ` }, { 'update-snapshots': 'all' }); expect(result.exitCode).toBe(0); - expect(result.output).toContain(`A snapshot is generated at a.spec.ts-snapshots${path.sep}test-1.yml`); - expect(result.output).toContain(`A snapshot is generated at a.spec.ts-snapshots${path.sep}test-2.yml`); - const snapshot1 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-1.yml'), 'utf8'); + expect(result.output).toContain(`A snapshot is generated at a.spec.ts-snapshots${path.sep}test-1.snapshot.yml`); + expect(result.output).toContain(`A snapshot is generated at a.spec.ts-snapshots${path.sep}test-2.snapshot.yml`); + const snapshot1 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-1.snapshot.yml'), 'utf8'); expect(snapshot1).toBe('- heading "hello world" [level=1]'); - const snapshot2 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-2.yml'), 'utf8'); + const snapshot2 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-2.snapshot.yml'), 'utf8'); expect(snapshot2).toBe('- heading "hello world 2" [level=1]'); }); test('should not rebaseline matching', async ({ runInlineTest }, testInfo) => { const result = await runInlineTest({ - 'a.spec.ts-snapshots/test.yml': ` + 'a.spec.ts-snapshots/test.snapshot.yml': ` - heading "hello world" `, 'a.spec.ts': ` import { test, expect } from '@playwright/test'; test('test', async ({ page }) => { await page.setContent(\`

hello world

\`); - await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test.yml' }); + await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test.snapshot.yml' }); }); ` }, { 'update-snapshots': 'changed' }); expect(result.exitCode).toBe(0); - const snapshot1 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test.yml'), 'utf8'); + const snapshot1 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test.snapshot.yml'), 'utf8'); expect(snapshot1.trim()).toBe('- heading "hello world"'); }); @@ -120,14 +120,32 @@ test('should generate snapshot name', async ({ runInlineTest }, testInfo) => { }); expect(result.exitCode).toBe(1); - expect(result.output).toContain(`A snapshot doesn't exist at a.spec.ts-snapshots${path.sep}test-name-1.yml, writing actual`); - expect(result.output).toContain(`A snapshot doesn't exist at a.spec.ts-snapshots${path.sep}test-name-2.yml, writing actual`); - const snapshot1 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-name-1.yml'), 'utf8'); + expect(result.output).toContain(`A snapshot doesn't exist at a.spec.ts-snapshots${path.sep}test-name-1.snapshot.yml, writing actual`); + expect(result.output).toContain(`A snapshot doesn't exist at a.spec.ts-snapshots${path.sep}test-name-2.snapshot.yml, writing actual`); + const snapshot1 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-name-1.snapshot.yml'), 'utf8'); expect(snapshot1).toBe('- heading "hello world" [level=1]'); - const snapshot2 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-name-2.yml'), 'utf8'); + const snapshot2 = await fs.promises.readFile(testInfo.outputPath('a.spec.ts-snapshots/test-name-2.snapshot.yml'), 'utf8'); expect(snapshot2).toBe('- heading "hello world 2" [level=1]'); }); +test('backwards compat with .yml extension', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts-snapshots/test-1.yml': ` + - heading "hello old world" + `, + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('test', async ({ page }) => { + await page.setContent(\`

hello new world

\`); + await expect(page.locator('body')).toMatchAriaSnapshot(); + }); + ` + }, { 'update-snapshots': 'changed' }); + + expect(result.exitCode).toBe(0); + expect(result.output).toContain(`A snapshot is generated at a.spec.ts-snapshots${path.sep}test-1.yml.`); +}); + for (const updateSnapshots of ['all', 'changed', 'missing', 'none']) { test(`should update snapshot with the update-snapshots=${updateSnapshots} (config)`, async ({ runInlineTest }, testInfo) => { const result = await runInlineTest({ @@ -143,13 +161,13 @@ for (const updateSnapshots of ['all', 'changed', 'missing', 'none']) { await expect(page.locator('body')).toMatchAriaSnapshot({ timeout: 1 }); }); `, - 'a.spec.ts-snapshots/test-1.yml': '- heading "Old content" [level=1]', + 'a.spec.ts-snapshots/test-1.snapshot.yml': '- heading "Old content" [level=1]', }); const rebase = updateSnapshots === 'all' || updateSnapshots === 'changed'; expect(result.exitCode).toBe(rebase ? 0 : 1); if (rebase) { - const snapshotOutputPath = testInfo.outputPath('a.spec.ts-snapshots/test-1.yml'); + const snapshotOutputPath = testInfo.outputPath('a.spec.ts-snapshots/test-1.snapshot.yml'); expect(result.output).toContain(`A snapshot is generated at`); const data = fs.readFileSync(snapshotOutputPath); expect(data.toString()).toBe('- heading "New content" [level=1]'); @@ -169,7 +187,7 @@ test('should respect timeout', async ({ runInlineTest }, testInfo) => { await expect(page.locator('body')).toMatchAriaSnapshot({ timeout: 1 }); }); `, - 'a.spec.ts-snapshots/test-1.yml': '- heading "new world" [level=1]', + 'a.spec.ts-snapshots/test-1.snapshot.yml': '- heading "new world" [level=1]', }); expect(result.exitCode).toBe(1); @@ -183,14 +201,14 @@ test('should respect config.snapshotPathTemplate', async ({ runInlineTest }, tes snapshotPathTemplate: 'my-snapshots/{testFilePath}/{arg}{ext}', }; `, - 'my-snapshots/dir/a.spec.ts/test.yml': ` + 'my-snapshots/dir/a.spec.ts/test.snapshot.yml': ` - heading "hello world" `, 'dir/a.spec.ts': ` import { test, expect } from '@playwright/test'; test('test', async ({ page }) => { await page.setContent(\`

hello world

\`); - await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test.yml' }); + await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test.snapshot.yml' }); }); ` }); @@ -210,17 +228,17 @@ test('should respect config.expect.toMatchAriaSnapshot.pathTemplate', async ({ r }, }; `, - 'my-snapshots/dir/a.spec.ts/test.yml': ` + 'my-snapshots/dir/a.spec.ts/test.snapshot.yml': ` - heading "wrong one" `, - 'actual-snapshots/dir/a.spec.ts/test.yml': ` + 'actual-snapshots/dir/a.spec.ts/test.snapshot.yml': ` - heading "hello world" `, 'dir/a.spec.ts': ` import { test, expect } from '@playwright/test'; test('test', async ({ page }) => { await page.setContent(\`

hello world

\`); - await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test.yml' }); + await expect(page.locator('body')).toMatchAriaSnapshot({ name: 'test.snapshot.yml' }); }); ` }); From cd23a224f6bf5e44de0a3121025fdd35c6920ef4 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Wed, 26 Feb 2025 08:40:30 -0800 Subject: [PATCH 15/26] chore: another iteration on gitCommit/gitDiff props (#34926) --- docs/src/test-api/class-testconfig.md | 5 +- packages/html-reporter/src/metadataView.tsx | 90 ++++---- packages/html-reporter/src/reportContext.tsx | 29 +++ packages/html-reporter/src/reportView.tsx | 6 +- packages/html-reporter/src/testErrorView.tsx | 16 +- packages/html-reporter/src/testFilesView.tsx | 7 +- packages/playwright/src/isomorphic/types.d.ts | 57 +++-- .../src/plugins/gitCommitInfoPlugin.ts | 198 ++++++++++-------- packages/playwright/src/plugins/index.ts | 1 - packages/playwright/types/test.d.ts | 6 +- packages/trace-viewer/src/ui/errorsTab.tsx | 19 +- packages/trace-viewer/src/ui/uiModeView.tsx | 7 +- tests/playwright-test/reporter-html.spec.ts | 21 +- .../playwright-test/ui-mode-metadata.spec.ts | 4 +- 14 files changed, 261 insertions(+), 205 deletions(-) create mode 100644 packages/html-reporter/src/reportContext.tsx diff --git a/docs/src/test-api/class-testconfig.md b/docs/src/test-api/class-testconfig.md index 33419387c7..213928e2e7 100644 --- a/docs/src/test-api/class-testconfig.md +++ b/docs/src/test-api/class-testconfig.md @@ -239,7 +239,10 @@ export default defineConfig({ Metadata contains key-value pairs to be included in the report. For example, HTML report will display it as key-value pairs, and JSON report will include metadata serialized as json. -Providing `'git.commit.info': {}` property will populate it with the git commit details. This is useful for CI/CD environments. +* Providing `gitCommit: 'generate'` property will populate it with the git commit details. +* Providing `gitDiff: 'generate'` property will populate it with the git diff details. + +On selected CI providers, both will be generated automatically. Specifying values will prevent the automatic generation. **Usage** diff --git a/packages/html-reporter/src/metadataView.tsx b/packages/html-reporter/src/metadataView.tsx index a7db657b4d..b210e844c8 100644 --- a/packages/html-reporter/src/metadataView.tsx +++ b/packages/html-reporter/src/metadataView.tsx @@ -20,32 +20,10 @@ import './common.css'; import './theme.css'; import './metadataView.css'; import type { Metadata } from '@playwright/test'; -import type { GitCommitInfo } from '@testIsomorphic/types'; +import type { CIInfo, GitCommitInfo, MetadataWithCommitInfo } from '@testIsomorphic/types'; import { CopyToClipboardContainer } from './copyToClipboard'; import { linkifyText } from '@web/renderUtils'; -type MetadataEntries = [string, unknown][]; - -export const MetadataContext = React.createContext([]); - -export function MetadataProvider({ metadata, children }: React.PropsWithChildren<{ metadata: Metadata }>) { - const entries = React.useMemo(() => { - // TODO: do not plumb actualWorkers through metadata. - return Object.entries(metadata).filter(([key]) => key !== 'actualWorkers'); - }, [metadata]); - - return {children}; -} - -export function useMetadata() { - return React.useContext(MetadataContext); -} - -export function useGitCommitInfo() { - const metadataEntries = useMetadata(); - return metadataEntries.find(([key]) => key === 'git.commit.info')?.[1] as GitCommitInfo | undefined; -} - class ErrorBoundary extends React.Component, { error: Error | null, errorInfo: React.ErrorInfo | null }> { override state: { error: Error | null, errorInfo: React.ErrorInfo | null } = { error: null, @@ -72,23 +50,22 @@ class ErrorBoundary extends React.Component, { error } } -export const MetadataView = () => { - return ; +export const MetadataView: React.FC<{ metadata: Metadata }> = params => { + return ; }; -const InnerMetadataView = () => { - const metadataEntries = useMetadata(); - const gitCommitInfo = useGitCommitInfo(); - const entries = metadataEntries.filter(([key]) => key !== 'git.commit.info'); - if (!gitCommitInfo && !entries.length) - return null; +const InnerMetadataView: React.FC<{ metadata: Metadata }> = params => { + const commitInfo = params.metadata as MetadataWithCommitInfo; + const otherEntries = Object.entries(params.metadata).filter(([key]) => !ignoreKeys.has(key)); + const hasMetadata = commitInfo.ci || commitInfo.gitCommit || otherEntries.length > 0; + if (!hasMetadata) + return; return
- {gitCommitInfo && <> - - {entries.length > 0 &&
} - } + {commitInfo.ci && } + {commitInfo.gitCommit && } + {otherEntries.length > 0 && (commitInfo.gitCommit || commitInfo.ci) &&
}
- {entries.map(([propertyName, value]) => { + {otherEntries.map(([propertyName, value]) => { const valueString = typeof value !== 'object' || value === null || value === undefined ? String(value) : JSON.stringify(value); const trimmedValue = valueString.length > 1000 ? valueString.slice(0, 1000) + '\u2026' : valueString; return ( @@ -104,20 +81,24 @@ const InnerMetadataView = () => {
; }; -const GitCommitInfoView: React.FC<{ info: GitCommitInfo }> = ({ info }) => { - const email = info.revision?.email ? ` <${info.revision?.email}>` : ''; - const author = `${info.revision?.author || ''}${email}`; +const CiInfoView: React.FC<{ info: CIInfo }> = ({ info }) => { + const link = info.commitHref; + return
+ +
; +}; - let subject = info.revision?.subject || ''; - let link = info.revision?.link; +const GitCommitInfoView: React.FC<{ link?: string, info: GitCommitInfo }> = ({ link, info }) => { + const subject = info.subject; + const email = ` <${info.author.email}>`; + const author = `${info.author.name}${email}`; + const shortTimestamp = Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(info.committer.time); + const longTimestamp = Intl.DateTimeFormat(undefined, { dateStyle: 'full', timeStyle: 'long' }).format(info.committer.time); - if (info.pull_request?.link && info.pull_request?.title) { - subject = info.pull_request?.title; - link = info.pull_request?.link; - } - - const shortTimestamp = Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(info.revision?.timestamp); - const longTimestamp = Intl.DateTimeFormat(undefined, { dateStyle: 'full', timeStyle: 'long' }).format(info.revision?.timestamp); return
{link ? ( @@ -131,12 +112,13 @@ const GitCommitInfoView: React.FC<{ info: GitCommitInfo }> = ({ info }) => {
{author} on {shortTimestamp} - {info.ci?.link && ( - <> - · - Logs - - )}
; }; + +const ignoreKeys = new Set(['ci', 'gitCommit', 'gitDiff', 'actualWorkers']); + +export const isMetadataEmpty = (metadata: MetadataWithCommitInfo): boolean => { + const otherEntries = Object.entries(metadata).filter(([key]) => !ignoreKeys.has(key)); + return !metadata.ci && !metadata.gitCommit && !otherEntries.length; +}; diff --git a/packages/html-reporter/src/reportContext.tsx b/packages/html-reporter/src/reportContext.tsx new file mode 100644 index 0000000000..0ea8ab1e50 --- /dev/null +++ b/packages/html-reporter/src/reportContext.tsx @@ -0,0 +1,29 @@ +/* + Copyright (c) Microsoft Corporation. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import * as React from 'react'; +import type { HTMLReport } from './types'; + + +const HTMLReportContext = React.createContext(undefined); + +export function HTMLReportContextProvider({ report, children }: React.PropsWithChildren<{ report: HTMLReport | undefined }>) { + return {children}; +} + +export function useHTMLReport() { + return React.useContext(HTMLReportContext); +} diff --git a/packages/html-reporter/src/reportView.tsx b/packages/html-reporter/src/reportView.tsx index baf85f32a8..12b97584f3 100644 --- a/packages/html-reporter/src/reportView.tsx +++ b/packages/html-reporter/src/reportView.tsx @@ -26,7 +26,7 @@ import './reportView.css'; import { TestCaseView } from './testCaseView'; import { TestFilesHeader, TestFilesView } from './testFilesView'; import './theme.css'; -import { MetadataProvider } from './metadataView'; +import { HTMLReportContextProvider } from './reportContext'; declare global { interface Window { @@ -73,7 +73,7 @@ export const ReportView: React.FC<{ return result; }, [report, filter]); - return
+ return
{report?.json() && } @@ -89,7 +89,7 @@ export const ReportView: React.FC<{ {!!report && }
-
; +
; }; const TestCaseViewLoader: React.FC<{ diff --git a/packages/html-reporter/src/testErrorView.tsx b/packages/html-reporter/src/testErrorView.tsx index 9134f082d0..3d253459ba 100644 --- a/packages/html-reporter/src/testErrorView.tsx +++ b/packages/html-reporter/src/testErrorView.tsx @@ -21,9 +21,14 @@ import type { ImageDiff } from '@web/shared/imageDiffView'; import { ImageDiffView } from '@web/shared/imageDiffView'; import type { TestResult } from './types'; import { fixTestPrompt } from '@web/components/prompts'; -import { useGitCommitInfo } from './metadataView'; +import { useHTMLReport } from './reportContext'; +import type { MetadataWithCommitInfo } from '@playwright/isomorphic/types'; -export const TestErrorView: React.FC<{ error: string; testId?: string; result?: TestResult }> = ({ error, testId, result }) => { +export const TestErrorView: React.FC<{ + error: string; + testId?: string; + result?: TestResult +}> = ({ error, testId, result }) => { return (
@@ -47,12 +52,13 @@ const PromptButton: React.FC<{ error: string; result?: TestResult; }> = ({ error, result }) => { - const gitCommitInfo = useGitCommitInfo(); + const report = useHTMLReport(); + const commitInfo = report?.metadata as MetadataWithCommitInfo | undefined; const prompt = React.useMemo(() => fixTestPrompt( error, - gitCommitInfo?.pull_request?.diff ?? gitCommitInfo?.revision?.diff, + commitInfo?.gitDiff, result?.attachments.find(a => a.name === 'pageSnapshot')?.body - ), [gitCommitInfo, result, error]); + ), [commitInfo, result, error]); const [copied, setCopied] = React.useState(false); diff --git a/packages/html-reporter/src/testFilesView.tsx b/packages/html-reporter/src/testFilesView.tsx index 49e2233669..4b2c48ae1d 100644 --- a/packages/html-reporter/src/testFilesView.tsx +++ b/packages/html-reporter/src/testFilesView.tsx @@ -22,7 +22,7 @@ import { msToString } from './utils'; import { AutoChip } from './chip'; import { TestErrorView } from './testErrorView'; import * as icons from './icons'; -import { MetadataView, useMetadata } from './metadataView'; +import { isMetadataEmpty, MetadataView } from './metadataView'; export const TestFilesView: React.FC<{ tests: TestFileSummary[], @@ -67,13 +67,12 @@ export const TestFilesHeader: React.FC<{ metadataVisible: boolean, toggleMetadataVisible: () => void, }> = ({ report, filteredStats, metadataVisible, toggleMetadataVisible }) => { - const metadataEntries = useMetadata(); if (!report) return null; return <>
- {metadataEntries.length > 0 &&
+ {!isMetadataEmpty(report.metadata) &&
{metadataVisible ? icons.downArrow() : icons.rightArrow()}Metadata
} {report.projectNames.length === 1 && !!report.projectNames[0] &&
Project: {report.projectNames[0]}
} @@ -83,7 +82,7 @@ export const TestFilesHeader: React.FC<{
{report ? new Date(report.startTime).toLocaleString() : ''}
Total time: {msToString(report.duration ?? 0)}
- {metadataVisible && } + {metadataVisible && } {!!report.errors.length && {report.errors.map((error, index) => )} } diff --git a/packages/playwright/src/isomorphic/types.d.ts b/packages/playwright/src/isomorphic/types.d.ts index 2d54911c6e..2f0b85e1f0 100644 --- a/packages/playwright/src/isomorphic/types.d.ts +++ b/packages/playwright/src/isomorphic/types.d.ts @@ -14,23 +14,40 @@ * limitations under the License. */ -export interface GitCommitInfo { - revision?: { - id?: string; - author?: string; - email?: string; - subject?: string; - timestamp?: number; - link?: string; - diff?: string; - }, - pull_request?: { - link?: string; - diff?: string; - base?: string; - title?: string; - }, - ci?: { - link?: string; - } -} +export type GitCommitInfo = { + shortHash: string; + hash: string; + subject: string; + body: string; + author: { + name: string; + email: string; + time: number; + }; + committer: { + name: string; + email: string + time: number; + }; + branch: string; +}; + +export type CIInfo = { + commitHref: string; + buildHref?: string; + commitHash?: string; + baseHash?: string; + branch?: string; +}; + +export type UserMetadataWithCommitInfo = { + ci?: CIInfo; + gitCommit?: GitCommitInfo | 'generate'; + gitDiff?: string | 'generate'; +}; + +export type MetadataWithCommitInfo = { + ci?: CIInfo; + gitCommit?: GitCommitInfo; + gitDiff?: string; +}; diff --git a/packages/playwright/src/plugins/gitCommitInfoPlugin.ts b/packages/playwright/src/plugins/gitCommitInfoPlugin.ts index 20945f6332..a80ef3b705 100644 --- a/packages/playwright/src/plugins/gitCommitInfoPlugin.ts +++ b/packages/playwright/src/plugins/gitCommitInfoPlugin.ts @@ -14,119 +14,139 @@ * limitations under the License. */ -import fs from 'fs'; - -import { createGuid, spawnAsync } from 'playwright-core/lib/utils'; +import { spawnAsync } from 'playwright-core/lib/utils'; import type { TestRunnerPlugin } from './'; import type { FullConfig } from '../../types/testReporter'; import type { FullConfigInternal } from '../common/config'; -import type { GitCommitInfo } from '../isomorphic/types'; +import type { GitCommitInfo, CIInfo, UserMetadataWithCommitInfo } from '../isomorphic/types'; -const GIT_OPERATIONS_TIMEOUT_MS = 1500; +const GIT_OPERATIONS_TIMEOUT_MS = 3000; export const addGitCommitInfoPlugin = (fullConfig: FullConfigInternal) => { - const commitProperty = fullConfig.config.metadata['git.commit.info']; - if (commitProperty && typeof commitProperty === 'object' && Object.keys(commitProperty).length === 0) - fullConfig.plugins.push({ factory: gitCommitInfo }); + fullConfig.plugins.push({ factory: gitCommitInfoPlugin }); }; -export const gitCommitInfo = (options?: GitCommitInfoPluginOptions): TestRunnerPlugin => { +type GitCommitInfoPluginOptions = { + directory?: string; +}; + +export const gitCommitInfoPlugin = (options?: GitCommitInfoPluginOptions): TestRunnerPlugin => { return { name: 'playwright:git-commit-info', setup: async (config: FullConfig, configDir: string) => { - const commitInfo = await linksFromEnv(); - await enrichStatusFromCLI(options?.directory || configDir, commitInfo); - config.metadata = config.metadata || {}; - config.metadata['git.commit.info'] = commitInfo; + const metadata = config.metadata as UserMetadataWithCommitInfo; + const ci = ciInfo(); + if (!metadata.ci && ci) + metadata.ci = ci; + + if ((ci && !metadata.gitCommit) || metadata.gitCommit === 'generate') { + const git = await gitCommitInfo(options?.directory || configDir).catch(e => { + // eslint-disable-next-line no-console + console.error('Failed to get git commit info', e); + }); + if (git) + metadata.gitCommit = git; + } + + if ((ci && !metadata.gitDiff) || metadata.gitDiff === 'generate') { + const diffResult = await gitDiff(options?.directory || configDir, ci).catch(e => { + // eslint-disable-next-line no-console + console.error('Failed to get git diff', e); + }); + if (diffResult) + metadata.gitDiff = diffResult; + } }, }; }; -interface GitCommitInfoPluginOptions { - directory?: string; +function ciInfo(): CIInfo | undefined { + if (process.env.GITHUB_ACTIONS) { + return { + commitHref: `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/commit/${process.env.GITHUB_SHA}`, + buildHref: `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`, + commitHash: process.env.GITHUB_SHA, + baseHash: process.env.GITHUB_BASE_REF, + branch: process.env.GITHUB_REF_NAME, + }; + } + + if (process.env.GITLAB_CI) { + return { + commitHref: `${process.env.CI_PROJECT_URL}/-/commit/${process.env.CI_COMMIT_SHA}`, + buildHref: process.env.CI_JOB_URL, + commitHash: process.env.CI_COMMIT_SHA, + baseHash: process.env.CI_COMMIT_BEFORE_SHA, + branch: process.env.CI_COMMIT_REF_NAME, + }; + } + + if (process.env.JENKINS_URL && process.env.BUILD_URL) { + return { + commitHref: process.env.BUILD_URL, + commitHash: process.env.GIT_COMMIT, + baseHash: process.env.GIT_PREVIOUS_COMMIT, + branch: process.env.GIT_BRANCH, + }; + } + + // Open to PRs. } -async function linksFromEnv(): Promise { - const out: GitCommitInfo = {}; - // Jenkins: https://www.jenkins.io/doc/book/pipeline/jenkinsfile/#using-environment-variables - if (process.env.BUILD_URL) { - out.ci = out.ci || {}; - out.ci.link = process.env.BUILD_URL; - } - // GitLab: https://docs.gitlab.com/ee/ci/variables/predefined_variables.html - if (process.env.CI_PROJECT_URL && process.env.CI_COMMIT_SHA) { - out.revision = out.revision || {}; - out.revision.link = `${process.env.CI_PROJECT_URL}/-/commit/${process.env.CI_COMMIT_SHA}`; - } - if (process.env.CI_JOB_URL) { - out.ci = out.ci || {}; - out.ci.link = process.env.CI_JOB_URL; - } - // GitHub: https://docs.github.com/en/actions/learn-github-actions/environment-variables#default-environment-variables - if (process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY && process.env.GITHUB_SHA) { - out.revision = out.revision || {}; - out.revision.link = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/commit/${process.env.GITHUB_SHA}`; - } - if (process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY && process.env.GITHUB_RUN_ID) { - out.ci = out.ci || {}; - out.ci.link = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`; - } - if (process.env.GITHUB_EVENT_PATH) { - try { - const json = JSON.parse(await fs.promises.readFile(process.env.GITHUB_EVENT_PATH, 'utf8')); - if (json.pull_request) { - out.pull_request = out.pull_request || {}; - out.pull_request.title = json.pull_request.title; - out.pull_request.link = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/pull/${json.pull_request.number}`; - out.pull_request.base = json.pull_request.base.ref; - } - } catch { - } - } - return out; -} - -async function enrichStatusFromCLI(gitDir: string, commitInfo: GitCommitInfo) { - const separator = `:${createGuid().slice(0, 4)}:`; +async function gitCommitInfo(gitDir: string): Promise { + const separator = `---786eec917292---`; + const tokens = [ + '%H', // commit hash + '%h', // abbreviated commit hash + '%s', // subject + '%B', // raw body (unwrapped subject and body) + '%an', // author name + '%ae', // author email + '%at', // author date, UNIX timestamp + '%cn', // committer name + '%ce', // committer email + '%ct', // committer date, UNIX timestamp + '', // branch + ]; const commitInfoResult = await spawnAsync( - 'git', - ['show', '-s', `--format=%H${separator}%s${separator}%an${separator}%ae${separator}%ct`, 'HEAD'], - { stdio: 'pipe', cwd: gitDir, timeout: GIT_OPERATIONS_TIMEOUT_MS } + `git log -1 --pretty=format:"${tokens.join(separator)}" && git rev-parse --abbrev-ref HEAD`, [], + { stdio: 'pipe', cwd: gitDir, timeout: GIT_OPERATIONS_TIMEOUT_MS, shell: true } ); if (commitInfoResult.code) - return; + return undefined; const showOutput = commitInfoResult.stdout.trim(); - const [id, subject, author, email, rawTimestamp] = showOutput.split(separator); - let timestamp: number = Number.parseInt(rawTimestamp, 10); - timestamp = Number.isInteger(timestamp) ? timestamp * 1000 : 0; + const [hash, shortHash, subject, body, authorName, authorEmail, authorTime, committerName, committerEmail, committerTime, branch] = showOutput.split(separator); - commitInfo.revision = { - ...commitInfo.revision, - id, - author, - email, + return { + shortHash, + hash, subject, - timestamp, + body, + author: { + name: authorName, + email: authorEmail, + time: +authorTime * 1000, + }, + committer: { + name: committerName, + email: committerEmail, + time: +committerTime * 1000, + }, + branch: branch.trim(), }; - - const diffLimit = 1_000_000; // 1MB - if (commitInfo.pull_request?.base) { - const pullDiffResult = await spawnAsync( - 'git', - ['diff', commitInfo.pull_request?.base], - { stdio: 'pipe', cwd: gitDir, timeout: GIT_OPERATIONS_TIMEOUT_MS } - ); - if (!pullDiffResult.code) - commitInfo.pull_request!.diff = pullDiffResult.stdout.substring(0, diffLimit); - } else { - const diffResult = await spawnAsync( - 'git', - ['diff', 'HEAD~1'], - { stdio: 'pipe', cwd: gitDir, timeout: GIT_OPERATIONS_TIMEOUT_MS } - ); - if (!diffResult.code) - commitInfo.revision!.diff = diffResult.stdout.substring(0, diffLimit); - } +} + +async function gitDiff(gitDir: string, ci?: CIInfo): Promise { + const diffLimit = 100_000; + const baseHash = ci?.baseHash ?? 'HEAD~1'; + + const pullDiffResult = await spawnAsync( + 'git', + ['diff', baseHash], + { stdio: 'pipe', cwd: gitDir, timeout: GIT_OPERATIONS_TIMEOUT_MS } + ); + if (!pullDiffResult.code) + return pullDiffResult.stdout.substring(0, diffLimit); } diff --git a/packages/playwright/src/plugins/index.ts b/packages/playwright/src/plugins/index.ts index 2f7995cb2f..7734145468 100644 --- a/packages/playwright/src/plugins/index.ts +++ b/packages/playwright/src/plugins/index.ts @@ -35,4 +35,3 @@ export type TestRunnerPluginRegistration = { }; export { webServer } from './webServerPlugin'; -export { gitCommitInfo } from './gitCommitInfoPlugin'; diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index 8653327f0e..1e9fef6b6c 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -1284,9 +1284,11 @@ interface TestConfig { /** * Metadata contains key-value pairs to be included in the report. For example, HTML report will display it as * key-value pairs, and JSON report will include metadata serialized as json. + * - Providing `gitCommit: 'generate'` property will populate it with the git commit details. + * - Providing `gitDiff: 'generate'` property will populate it with the git diff details. * - * Providing `'git.commit.info': {}` property will populate it with the git commit details. This is useful for CI/CD - * environments. + * On selected CI providers, both will be generated automatically. Specifying values will prevent the automatic + * generation. * * **Usage** * diff --git a/packages/trace-viewer/src/ui/errorsTab.tsx b/packages/trace-viewer/src/ui/errorsTab.tsx index 5cb28299df..a5fd9b071a 100644 --- a/packages/trace-viewer/src/ui/errorsTab.tsx +++ b/packages/trace-viewer/src/ui/errorsTab.tsx @@ -24,20 +24,20 @@ import type { StackFrame } from '@protocol/channels'; import { CopyToClipboardTextButton } from './copyToClipboard'; import { attachmentURL } from './attachmentsTab'; import { fixTestPrompt } from '@web/components/prompts'; -import type { GitCommitInfo } from '@testIsomorphic/types'; +import type { MetadataWithCommitInfo } from '@testIsomorphic/types'; import { AIConversation } from './aiConversation'; import { ToolbarButton } from '@web/components/toolbarButton'; import { useIsLLMAvailable, useLLMChat } from './llm'; import { useAsyncMemo } from '@web/uiUtils'; -const GitCommitInfoContext = React.createContext(undefined); +const CommitInfoContext = React.createContext(undefined); -export function GitCommitInfoProvider({ children, gitCommitInfo }: React.PropsWithChildren<{ gitCommitInfo: GitCommitInfo }>) { - return {children}; +export function CommitInfoProvider({ children, commitInfo }: React.PropsWithChildren<{ commitInfo: MetadataWithCommitInfo }>) { + return {children}; } -export function useGitCommitInfo() { - return React.useContext(GitCommitInfoContext); +export function useCommitInfo() { + return React.useContext(CommitInfoContext); } function usePageSnapshot(actions: modelUtil.ActionTraceEventInContext[]) { @@ -100,8 +100,7 @@ export function useErrorsTabModel(model: modelUtil.MultiTraceModel | undefined): function Error({ message, error, errorId, sdkLanguage, pageSnapshot, revealInSource }: { message: string, error: ErrorDescription, errorId: string, sdkLanguage: Language, pageSnapshot?: string, revealInSource: (error: ErrorDescription) => void }) { const [showLLM, setShowLLM] = React.useState(false); const llmAvailable = useIsLLMAvailable(); - const gitCommitInfo = useGitCommitInfo(); - const diff = gitCommitInfo?.pull_request?.diff ?? gitCommitInfo?.revision?.diff; + const metadata = useCommitInfo(); let location: string | undefined; let longLocation: string | undefined; @@ -127,8 +126,8 @@ function Error({ message, error, errorId, sdkLanguage, pageSnapshot, revealInSou
} {llmAvailable - ? - : } + ? + : }
diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index 441163c5d9..4762b6057e 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -37,8 +37,9 @@ import { TestListView } from './uiModeTestListView'; import { TraceView } from './uiModeTraceView'; import { SettingsView } from './settingsView'; import { DefaultSettingsView } from './defaultSettingsView'; -import { GitCommitInfoProvider } from './errorsTab'; +import { CommitInfoProvider } from './errorsTab'; import { LLMProvider } from './llm'; +import type { MetadataWithCommitInfo } from '@testIsomorphic/types'; let xtermSize = { cols: 80, rows: 24 }; const xtermDataSource: XtermDataSource = { @@ -432,7 +433,7 @@ export const UIModeView: React.FC<{}> = ({
- + = ({ revealSource={revealSource} onOpenExternally={location => testServerConnection?.openNoReply({ location: { file: location.file, line: location.line, column: location.column } })} /> - +
} sidebar={
diff --git a/tests/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index 7e9c550619..22f0064eac 100644 --- a/tests/playwright-test/reporter-html.spec.ts +++ b/tests/playwright-test/reporter-html.spec.ts @@ -1187,12 +1187,12 @@ for (const useIntermediateMergeReport of [true, false] as const) { ]); }); - test('should include metadata with git.commit.info', async ({ runInlineTest, writeFiles, showReport, page }) => { + test('should include metadata with gitCommit', async ({ runInlineTest, writeFiles, showReport, page }) => { const files = { 'uncommitted.txt': `uncommitted file`, 'playwright.config.ts': ` export default { - metadata: { 'git.commit.info': {}, foo: 'value1', bar: { prop: 'value2' }, baz: ['value3', 123] } + metadata: { foo: 'value1', bar: { prop: 'value2' }, baz: ['value3', 123] } }; `, 'example.spec.ts': ` @@ -1219,6 +1219,7 @@ for (const useIntermediateMergeReport of [true, false] as const) { const result = await runInlineTest(files, { reporter: 'dot,html' }, { PLAYWRIGHT_HTML_OPEN: 'never', + GITHUB_ACTIONS: '1', GITHUB_REPOSITORY: 'microsoft/playwright-example-for-test', GITHUB_SERVER_URL: 'https://playwright.dev', GITHUB_SHA: 'example-sha', @@ -1240,12 +1241,12 @@ for (const useIntermediateMergeReport of [true, false] as const) { `); }); - test('should include metadata with git.commit.info on GHA', async ({ runInlineTest, writeFiles, showReport, page }) => { + test('should include metadata on GHA', async ({ runInlineTest, writeFiles, showReport, page }) => { const files = { 'uncommitted.txt': `uncommitted file`, 'playwright.config.ts': ` export default { - metadata: { 'git.commit.info': {}, foo: 'value1', bar: { prop: 'value2' }, baz: ['value3', 123] } + metadata: { foo: 'value1', bar: { prop: 'value2' }, baz: ['value3', 123] } }; `, 'example.spec.ts': ` @@ -1281,6 +1282,7 @@ for (const useIntermediateMergeReport of [true, false] as const) { const result = await runInlineTest(files, { reporter: 'dot,html' }, { PLAYWRIGHT_HTML_OPEN: 'never', + GITHUB_ACTIONS: '1', GITHUB_REPOSITORY: 'microsoft/playwright-example-for-test', GITHUB_RUN_ID: 'example-run-id', GITHUB_SERVER_URL: 'https://playwright.dev', @@ -1295,10 +1297,7 @@ for (const useIntermediateMergeReport of [true, false] as const) { await expect(page.locator('.metadata-view')).toMatchAriaSnapshot(` - list: - listitem: - - link "My PR" - - listitem: - - text: /William / - - link "Logs" + - link "https://playwright.dev/microsoft/playwright-example-for-test/commit/example-sha" - list: - listitem: "foo : value1" - listitem: "bar : {\\"prop\\":\\"value2\\"}" @@ -1306,7 +1305,7 @@ for (const useIntermediateMergeReport of [true, false] as const) { `); }); - test('should not include git metadata w/o git.commit.info', async ({ runInlineTest, showReport, page }) => { + test('should not include git metadata w/o gitCommit', async ({ runInlineTest, showReport, page }) => { const result = await runInlineTest({ 'playwright.config.ts': ` export default {}; @@ -1330,7 +1329,7 @@ for (const useIntermediateMergeReport of [true, false] as const) { 'playwright.config.ts': ` export default { metadata: { - 'git.commit.info': { revision: { timestamp: 'hi' } } + gitCommit: { author: { date: 'hi' } } }, }; `, @@ -2765,7 +2764,6 @@ for (const useIntermediateMergeReport of [true, false] as const) { 'playwright.config.ts': ` export default { metadata: { - 'git.commit.info': {}, foo: 'value1', bar: { prop: 'value2' }, baz: ['value3', 123] @@ -2799,6 +2797,7 @@ for (const useIntermediateMergeReport of [true, false] as const) { const result = await runInlineTest(files, { reporter: 'dot,html' }, { PLAYWRIGHT_HTML_OPEN: 'never', + GITHUB_ACTIONS: '1', GITHUB_REPOSITORY: 'microsoft/playwright-example-for-test', GITHUB_RUN_ID: 'example-run-id', GITHUB_SERVER_URL: 'https://playwright.dev', diff --git a/tests/playwright-test/ui-mode-metadata.spec.ts b/tests/playwright-test/ui-mode-metadata.spec.ts index c6127cb7cb..bfbbba08a5 100644 --- a/tests/playwright-test/ui-mode-metadata.spec.ts +++ b/tests/playwright-test/ui-mode-metadata.spec.ts @@ -21,14 +21,13 @@ test('should render html report git info metadata', async ({ runUITest }) => { 'reporter.ts': ` module.exports = class Reporter { onBegin(config, suite) { - console.log('ci.link:', config.metadata['git.commit.info'].ci.link); + console.log('ci.link:', config.metadata['ci'].commitHref); } } `, 'playwright.config.ts': ` import { defineConfig } from '@playwright/test'; export default defineConfig({ - metadata: { 'git.commit.info': {} }, reporter: './reporter.ts', }); `, @@ -37,6 +36,7 @@ test('should render html report git info metadata', async ({ runUITest }) => { test('should work', async ({}) => {}); ` }, { + JENKINS_URL: '1', BUILD_URL: 'https://playwright.dev', }); From b5fe029c1b400464f3c223a696a47cd0a1c01ab9 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Wed, 26 Feb 2025 15:03:21 -0800 Subject: [PATCH 16/26] chore: remove failOnStatusCode from Browser.newContext, rename (#34936) --- docs/src/api/class-apirequest.md | 6 +- docs/src/api/params.md | 6 -- packages/playwright-client/types/types.d.ts | 36 ++-------- .../playwright-core/src/protocol/validator.ts | 6 +- packages/playwright-core/src/server/fetch.ts | 8 +-- packages/playwright-core/types/types.d.ts | 36 ++-------- packages/protocol/src/channels.d.ts | 12 +--- packages/protocol/src/protocol.yml | 3 +- ...owsercontext-fetchFailOnStatusCode.spec.ts | 67 ------------------- tests/library/global-fetch.spec.ts | 8 +-- tests/page/page-check.spec.ts | 1 + 11 files changed, 30 insertions(+), 159 deletions(-) delete mode 100644 tests/library/browsercontext-fetchFailOnStatusCode.spec.ts diff --git a/docs/src/api/class-apirequest.md b/docs/src/api/class-apirequest.md index 37bb2cb999..42071c770a 100644 --- a/docs/src/api/class-apirequest.md +++ b/docs/src/api/class-apirequest.md @@ -21,8 +21,12 @@ Creates new instances of [APIRequestContext]. ### option: APIRequest.newContext.extraHTTPHeaders = %%-context-option-extrahttpheaders-%% * since: v1.16 -### option: APIRequest.newContext.apiRequestFailsOnErrorStatus = %%-context-option-apiRequestFailsOnErrorStatus-%% +### option: APIRequest.newContext.failOnStatusCode * since: v1.51 +- `failOnStatusCode` <[boolean]> + +Whether to throw on response codes other than 2xx and 3xx. By default response object is returned +for all status codes. ### option: APIRequest.newContext.httpCredentials = %%-context-option-httpcredentials-%% * since: v1.16 diff --git a/docs/src/api/params.md b/docs/src/api/params.md index 9a87db7f6a..80fb59d587 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -639,11 +639,6 @@ A list of permissions to grant to all pages in this context. See An object containing additional HTTP headers to be sent with every request. Defaults to none. -## context-option-apiRequestFailsOnErrorStatus -- `apiRequestFailsOnErrorStatus` <[boolean]> - -An object containing an option to throw an error when API request returns status codes other than 2xx and 3xx. By default, response object is returned for all status codes. - ## context-option-offline - `offline` <[boolean]> @@ -1001,7 +996,6 @@ between the same pixel in compared images, between zero (strict) and one (lax), - %%-context-option-locale-%% - %%-context-option-permissions-%% - %%-context-option-extrahttpheaders-%% -- %%-context-option-apiRequestFailsOnErrorStatus-%% - %%-context-option-offline-%% - %%-context-option-httpcredentials-%% - %%-context-option-colorscheme-%% diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 874f072035..a54ce42b85 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -9741,12 +9741,6 @@ export interface Browser { */ acceptDownloads?: boolean; - /** - * An object containing an option to throw an error when API request returns status codes other than 2xx and 3xx. By - * default, response object is returned for all status codes. - */ - apiRequestFailsOnErrorStatus?: boolean; - /** * When using [page.goto(url[, options])](https://playwright.dev/docs/api/class-page#page-goto), * [page.route(url, handler[, options])](https://playwright.dev/docs/api/class-page#page-route), @@ -14824,12 +14818,6 @@ export interface BrowserType { */ acceptDownloads?: boolean; - /** - * An object containing an option to throw an error when API request returns status codes other than 2xx and 3xx. By - * default, response object is returned for all status codes. - */ - apiRequestFailsOnErrorStatus?: boolean; - /** * **NOTE** Use custom browser args at your own risk, as some of them may break Playwright functionality. * @@ -16720,12 +16708,6 @@ export interface AndroidDevice { */ acceptDownloads?: boolean; - /** - * An object containing an option to throw an error when API request returns status codes other than 2xx and 3xx. By - * default, response object is returned for all status codes. - */ - apiRequestFailsOnErrorStatus?: boolean; - /** * **NOTE** Use custom browser args at your own risk, as some of them may break Playwright functionality. * @@ -17572,12 +17554,6 @@ export interface APIRequest { * @param options */ newContext(options?: { - /** - * An object containing an option to throw an error when API request returns status codes other than 2xx and 3xx. By - * default, response object is returned for all status codes. - */ - apiRequestFailsOnErrorStatus?: boolean; - /** * Methods like * [apiRequestContext.get(url[, options])](https://playwright.dev/docs/api/class-apirequestcontext#api-request-context-get) @@ -17653,6 +17629,12 @@ export interface APIRequest { */ extraHTTPHeaders?: { [key: string]: string; }; + /** + * Whether to throw on response codes other than 2xx and 3xx. By default response object is returned for all status + * codes. + */ + failOnStatusCode?: boolean; + /** * Credentials for [HTTP authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication). If no * origin is specified, the username and password are sent to any servers upon unauthorized responses. @@ -22157,12 +22139,6 @@ export interface BrowserContextOptions { */ acceptDownloads?: boolean; - /** - * An object containing an option to throw an error when API request returns status codes other than 2xx and 3xx. By - * default, response object is returned for all status codes. - */ - apiRequestFailsOnErrorStatus?: boolean; - /** * When using [page.goto(url[, options])](https://playwright.dev/docs/api/class-page#page-goto), * [page.route(url, handler[, options])](https://playwright.dev/docs/api/class-page#page-route), diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index bd36e905b4..f4944d6e09 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -370,7 +370,7 @@ scheme.PlaywrightNewRequestParams = tObject({ userAgent: tOptional(tString), ignoreHTTPSErrors: tOptional(tBoolean), extraHTTPHeaders: tOptional(tArray(tType('NameValue'))), - apiRequestFailsOnErrorStatus: tOptional(tBoolean), + failOnStatusCode: tOptional(tBoolean), clientCertificates: tOptional(tArray(tObject({ origin: tString, cert: tOptional(tBinary), @@ -600,7 +600,6 @@ scheme.BrowserTypeLaunchPersistentContextParams = tObject({ })), permissions: tOptional(tArray(tString)), extraHTTPHeaders: tOptional(tArray(tType('NameValue'))), - apiRequestFailsOnErrorStatus: tOptional(tBoolean), offline: tOptional(tBoolean), httpCredentials: tOptional(tObject({ username: tString, @@ -688,7 +687,6 @@ scheme.BrowserNewContextParams = tObject({ })), permissions: tOptional(tArray(tString)), extraHTTPHeaders: tOptional(tArray(tType('NameValue'))), - apiRequestFailsOnErrorStatus: tOptional(tBoolean), offline: tOptional(tBoolean), httpCredentials: tOptional(tObject({ username: tString, @@ -759,7 +757,6 @@ scheme.BrowserNewContextForReuseParams = tObject({ })), permissions: tOptional(tArray(tString)), extraHTTPHeaders: tOptional(tArray(tType('NameValue'))), - apiRequestFailsOnErrorStatus: tOptional(tBoolean), offline: tOptional(tBoolean), httpCredentials: tOptional(tObject({ username: tString, @@ -2667,7 +2664,6 @@ scheme.AndroidDeviceLaunchBrowserParams = tObject({ })), permissions: tOptional(tArray(tString)), extraHTTPHeaders: tOptional(tArray(tType('NameValue'))), - apiRequestFailsOnErrorStatus: tOptional(tBoolean), offline: tOptional(tBoolean), httpCredentials: tOptional(tObject({ username: tString, diff --git a/packages/playwright-core/src/server/fetch.ts b/packages/playwright-core/src/server/fetch.ts index 984d5e3067..739af0218e 100644 --- a/packages/playwright-core/src/server/fetch.ts +++ b/packages/playwright-core/src/server/fetch.ts @@ -51,7 +51,7 @@ import type { Readable, TransformCallback } from 'stream'; type FetchRequestOptions = { userAgent: string; extraHTTPHeaders?: HeadersArray; - apiRequestFailsOnErrorStatus?: boolean; + failOnStatusCode?: boolean; httpCredentials?: HTTPCredentials; proxy?: ProxySettings; timeoutSettings: TimeoutSettings; @@ -212,7 +212,7 @@ export abstract class APIRequestContext extends SdkObject { }); const fetchUid = this._storeResponseBody(fetchResponse.body); this.fetchLog.set(fetchUid, controller.metadata.log); - const failOnStatusCode = params.failOnStatusCode !== undefined ? params.failOnStatusCode : !!defaults.apiRequestFailsOnErrorStatus; + const failOnStatusCode = params.failOnStatusCode !== undefined ? params.failOnStatusCode : !!defaults.failOnStatusCode; if (failOnStatusCode && (fetchResponse.status < 200 || fetchResponse.status >= 400)) { let responseText = ''; if (fetchResponse.body.byteLength) { @@ -608,7 +608,7 @@ export class BrowserContextAPIRequestContext extends APIRequestContext { return { userAgent: this._context._options.userAgent || this._context._browser.userAgent(), extraHTTPHeaders: this._context._options.extraHTTPHeaders, - apiRequestFailsOnErrorStatus: this._context._options.apiRequestFailsOnErrorStatus, + failOnStatusCode: undefined, httpCredentials: this._context._options.httpCredentials, proxy: this._context._options.proxy || this._context._browser.options.proxy, timeoutSettings: this._context._timeoutSettings, @@ -660,7 +660,7 @@ export class GlobalAPIRequestContext extends APIRequestContext { baseURL: options.baseURL, userAgent: options.userAgent || getUserAgent(), extraHTTPHeaders: options.extraHTTPHeaders, - apiRequestFailsOnErrorStatus: !!options.apiRequestFailsOnErrorStatus, + failOnStatusCode: !!options.failOnStatusCode, ignoreHTTPSErrors: !!options.ignoreHTTPSErrors, httpCredentials: options.httpCredentials, clientCertificates: options.clientCertificates, diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 874f072035..a54ce42b85 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -9741,12 +9741,6 @@ export interface Browser { */ acceptDownloads?: boolean; - /** - * An object containing an option to throw an error when API request returns status codes other than 2xx and 3xx. By - * default, response object is returned for all status codes. - */ - apiRequestFailsOnErrorStatus?: boolean; - /** * When using [page.goto(url[, options])](https://playwright.dev/docs/api/class-page#page-goto), * [page.route(url, handler[, options])](https://playwright.dev/docs/api/class-page#page-route), @@ -14824,12 +14818,6 @@ export interface BrowserType { */ acceptDownloads?: boolean; - /** - * An object containing an option to throw an error when API request returns status codes other than 2xx and 3xx. By - * default, response object is returned for all status codes. - */ - apiRequestFailsOnErrorStatus?: boolean; - /** * **NOTE** Use custom browser args at your own risk, as some of them may break Playwright functionality. * @@ -16720,12 +16708,6 @@ export interface AndroidDevice { */ acceptDownloads?: boolean; - /** - * An object containing an option to throw an error when API request returns status codes other than 2xx and 3xx. By - * default, response object is returned for all status codes. - */ - apiRequestFailsOnErrorStatus?: boolean; - /** * **NOTE** Use custom browser args at your own risk, as some of them may break Playwright functionality. * @@ -17572,12 +17554,6 @@ export interface APIRequest { * @param options */ newContext(options?: { - /** - * An object containing an option to throw an error when API request returns status codes other than 2xx and 3xx. By - * default, response object is returned for all status codes. - */ - apiRequestFailsOnErrorStatus?: boolean; - /** * Methods like * [apiRequestContext.get(url[, options])](https://playwright.dev/docs/api/class-apirequestcontext#api-request-context-get) @@ -17653,6 +17629,12 @@ export interface APIRequest { */ extraHTTPHeaders?: { [key: string]: string; }; + /** + * Whether to throw on response codes other than 2xx and 3xx. By default response object is returned for all status + * codes. + */ + failOnStatusCode?: boolean; + /** * Credentials for [HTTP authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication). If no * origin is specified, the username and password are sent to any servers upon unauthorized responses. @@ -22157,12 +22139,6 @@ export interface BrowserContextOptions { */ acceptDownloads?: boolean; - /** - * An object containing an option to throw an error when API request returns status codes other than 2xx and 3xx. By - * default, response object is returned for all status codes. - */ - apiRequestFailsOnErrorStatus?: boolean; - /** * When using [page.goto(url[, options])](https://playwright.dev/docs/api/class-page#page-goto), * [page.route(url, handler[, options])](https://playwright.dev/docs/api/class-page#page-route), diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index 117f0e287e..1c5a201c40 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -623,7 +623,7 @@ export type PlaywrightNewRequestParams = { userAgent?: string, ignoreHTTPSErrors?: boolean, extraHTTPHeaders?: NameValue[], - apiRequestFailsOnErrorStatus?: boolean, + failOnStatusCode?: boolean, clientCertificates?: { origin: string, cert?: Binary, @@ -655,7 +655,7 @@ export type PlaywrightNewRequestOptions = { userAgent?: string, ignoreHTTPSErrors?: boolean, extraHTTPHeaders?: NameValue[], - apiRequestFailsOnErrorStatus?: boolean, + failOnStatusCode?: boolean, clientCertificates?: { origin: string, cert?: Binary, @@ -1029,7 +1029,6 @@ export type BrowserTypeLaunchPersistentContextParams = { }, permissions?: string[], extraHTTPHeaders?: NameValue[], - apiRequestFailsOnErrorStatus?: boolean, offline?: boolean, httpCredentials?: { username: string, @@ -1111,7 +1110,6 @@ export type BrowserTypeLaunchPersistentContextOptions = { }, permissions?: string[], extraHTTPHeaders?: NameValue[], - apiRequestFailsOnErrorStatus?: boolean, offline?: boolean, httpCredentials?: { username: string, @@ -1228,7 +1226,6 @@ export type BrowserNewContextParams = { }, permissions?: string[], extraHTTPHeaders?: NameValue[], - apiRequestFailsOnErrorStatus?: boolean, offline?: boolean, httpCredentials?: { username: string, @@ -1296,7 +1293,6 @@ export type BrowserNewContextOptions = { }, permissions?: string[], extraHTTPHeaders?: NameValue[], - apiRequestFailsOnErrorStatus?: boolean, offline?: boolean, httpCredentials?: { username: string, @@ -1367,7 +1363,6 @@ export type BrowserNewContextForReuseParams = { }, permissions?: string[], extraHTTPHeaders?: NameValue[], - apiRequestFailsOnErrorStatus?: boolean, offline?: boolean, httpCredentials?: { username: string, @@ -1435,7 +1430,6 @@ export type BrowserNewContextForReuseOptions = { }, permissions?: string[], extraHTTPHeaders?: NameValue[], - apiRequestFailsOnErrorStatus?: boolean, offline?: boolean, httpCredentials?: { username: string, @@ -4802,7 +4796,6 @@ export type AndroidDeviceLaunchBrowserParams = { }, permissions?: string[], extraHTTPHeaders?: NameValue[], - apiRequestFailsOnErrorStatus?: boolean, offline?: boolean, httpCredentials?: { username: string, @@ -4868,7 +4861,6 @@ export type AndroidDeviceLaunchBrowserOptions = { }, permissions?: string[], extraHTTPHeaders?: NameValue[], - apiRequestFailsOnErrorStatus?: boolean, offline?: boolean, httpCredentials?: { username: string, diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 1044f8ccb2..5b2f136386 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -520,7 +520,6 @@ ContextOptions: extraHTTPHeaders: type: array? items: NameValue - apiRequestFailsOnErrorStatus: boolean? offline: boolean? httpCredentials: type: object? @@ -752,7 +751,7 @@ Playwright: extraHTTPHeaders: type: array? items: NameValue - apiRequestFailsOnErrorStatus: boolean? + failOnStatusCode: boolean? clientCertificates: type: array? items: diff --git a/tests/library/browsercontext-fetchFailOnStatusCode.spec.ts b/tests/library/browsercontext-fetchFailOnStatusCode.spec.ts deleted file mode 100644 index e922218d68..0000000000 --- a/tests/library/browsercontext-fetchFailOnStatusCode.spec.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Copyright (c) Microsoft Corporation. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { browserTest as it, expect } from '../config/browserTest'; - -it('should throw when apiRequestFailsOnErrorStatus is set to true inside BrowserContext options', async ({ browser, server }) => { - it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/34204' }); - const context = await browser.newContext({ apiRequestFailsOnErrorStatus: true }); - server.setRoute('/empty.html', (req, res) => { - res.writeHead(404, { 'Content-Length': 10, 'Content-Type': 'text/plain' }); - res.end('Not found.'); - }); - const error = await context.request.fetch(server.EMPTY_PAGE).catch(e => e); - expect(error.message).toContain('404 Not Found'); - await context.close(); -}); - -it('should not throw when failOnStatusCode is set to false inside BrowserContext options', async ({ browser, server }) => { - it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/34204' }); - const context = await browser.newContext({ apiRequestFailsOnErrorStatus: false }); - server.setRoute('/empty.html', (req, res) => { - res.writeHead(404, { 'Content-Length': 10, 'Content-Type': 'text/plain' }); - res.end('Not found.'); - }); - const error = await context.request.fetch(server.EMPTY_PAGE).catch(e => e); - expect(error.message).toBeUndefined(); - await context.close(); -}); - -it('should throw when apiRequestFailsOnErrorStatus is set to true inside browserType.launchPersistentContext options', async ({ browserType, server, createUserDataDir }) => { - it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/34204' }); - const userDataDir = await createUserDataDir(); - const context = await browserType.launchPersistentContext(userDataDir, { apiRequestFailsOnErrorStatus: true }); - server.setRoute('/empty.html', (req, res) => { - res.writeHead(404, { 'Content-Length': 10, 'Content-Type': 'text/plain' }); - res.end('Not found.'); - }); - const error = await context.request.fetch(server.EMPTY_PAGE).catch(e => e); - expect(error.message).toContain('404 Not Found'); - await context.close(); -}); - -it('should not throw when apiRequestFailsOnErrorStatus is set to false inside browserType.launchPersistentContext options', async ({ browserType, server, createUserDataDir }) => { - it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/34204' }); - const userDataDir = await createUserDataDir(); - const context = await browserType.launchPersistentContext(userDataDir, { apiRequestFailsOnErrorStatus: false }); - server.setRoute('/empty.html', (req, res) => { - res.writeHead(404, { 'Content-Length': 10, 'Content-Type': 'text/plain' }); - res.end('Not found.'); - }); - const error = await context.request.fetch(server.EMPTY_PAGE).catch(e => e); - expect(error.message).toBeUndefined(); - await context.close(); -}); diff --git a/tests/library/global-fetch.spec.ts b/tests/library/global-fetch.spec.ts index 9a402ed152..53db88e325 100644 --- a/tests/library/global-fetch.spec.ts +++ b/tests/library/global-fetch.spec.ts @@ -537,9 +537,9 @@ it('should retry ECONNRESET', { await request.dispose(); }); -it('should throw when apiRequestFailsOnErrorStatus is set to true inside APIRequest context options', async ({ playwright, server }) => { +it('should throw when failOnStatusCode is set to true inside APIRequest context options', async ({ playwright, server }) => { it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/34204' }); - const request = await playwright.request.newContext({ apiRequestFailsOnErrorStatus: true }); + const request = await playwright.request.newContext({ failOnStatusCode: true }); server.setRoute('/empty.html', (req, res) => { res.writeHead(404, { 'Content-Length': 10, 'Content-Type': 'text/plain' }); res.end('Not found.'); @@ -549,9 +549,9 @@ it('should throw when apiRequestFailsOnErrorStatus is set to true inside APIRequ await request.dispose(); }); -it('should not throw when apiRequestFailsOnErrorStatus is set to false inside APIRequest context options', async ({ playwright, server }) => { +it('should not throw when failOnStatusCode is set to false inside APIRequest context options', async ({ playwright, server }) => { it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/34204' }); - const request = await playwright.request.newContext({ apiRequestFailsOnErrorStatus: false }); + const request = await playwright.request.newContext({ failOnStatusCode: false }); server.setRoute('/empty.html', (req, res) => { res.writeHead(404, { 'Content-Length': 10, 'Content-Type': 'text/plain' }); res.end('Not found.'); diff --git a/tests/page/page-check.spec.ts b/tests/page/page-check.spec.ts index 01b00ddc55..4ea0f9e50c 100644 --- a/tests/page/page-check.spec.ts +++ b/tests/page/page-check.spec.ts @@ -21,6 +21,7 @@ it('should check the box @smoke', async ({ page }) => { await page.setContent(``); await page.check('input'); expect(await page.evaluate(() => window['checkbox'].checked)).toBe(true); + await expect(page.locator('input[type="checkbox"]')).toBeChecked({ timeout: 1000 }); }); it('should not check the checked box', async ({ page }) => { From a803e6053a1ee17d921041867dfbd817bf0d7b0b Mon Sep 17 00:00:00 2001 From: Henrik Skupin Date: Thu, 27 Feb 2025 01:28:14 +0100 Subject: [PATCH 17/26] chore(bidi): use fractional coordinates for pointerAction (#34929) --- packages/playwright-core/src/server/bidi/bidiInput.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/playwright-core/src/server/bidi/bidiInput.ts b/packages/playwright-core/src/server/bidi/bidiInput.ts index e40b13bb2e..e67c07ba8f 100644 --- a/packages/playwright-core/src/server/bidi/bidiInput.ts +++ b/packages/playwright-core/src/server/bidi/bidiInput.ts @@ -79,9 +79,6 @@ export class RawMouseImpl implements input.RawMouse { } async move(x: number, y: number, button: types.MouseButton | 'none', buttons: Set, modifiers: Set, forClick: boolean): Promise { - // Bidi throws when x/y are not integers. - x = Math.floor(x); - y = Math.floor(y); await this._performActions([{ type: 'pointerMove', x, y }]); } From 58db3f7e3f897327ea031c6ad1d8483bb88a09b7 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Wed, 26 Feb 2025 19:22:31 -0800 Subject: [PATCH 18/26] chore: restore pr title in html report (#34937) --- packages/html-reporter/src/metadataView.tsx | 33 ++++++++----------- packages/playwright/src/isomorphic/types.d.ts | 2 ++ .../src/plugins/gitCommitInfoPlugin.ts | 16 +++++++-- tests/playwright-test/reporter-html.spec.ts | 3 +- 4 files changed, 32 insertions(+), 22 deletions(-) diff --git a/packages/html-reporter/src/metadataView.tsx b/packages/html-reporter/src/metadataView.tsx index b210e844c8..dcc27acb25 100644 --- a/packages/html-reporter/src/metadataView.tsx +++ b/packages/html-reporter/src/metadataView.tsx @@ -61,8 +61,8 @@ const InnerMetadataView: React.FC<{ metadata: Metadata }> = params => { if (!hasMetadata) return; return
- {commitInfo.ci && } - {commitInfo.gitCommit && } + {commitInfo.ci && !commitInfo.gitCommit && } + {commitInfo.gitCommit && } {otherEntries.length > 0 && (commitInfo.gitCommit || commitInfo.ci) &&
}
{otherEntries.map(([propertyName, value]) => { @@ -82,32 +82,27 @@ const InnerMetadataView: React.FC<{ metadata: Metadata }> = params => { }; const CiInfoView: React.FC<{ info: CIInfo }> = ({ info }) => { - const link = info.commitHref; + const title = info.prTitle || `Commit ${info.commitHash}`; + const link = info.prHref || info.commitHref; return ; }; -const GitCommitInfoView: React.FC<{ link?: string, info: GitCommitInfo }> = ({ link, info }) => { - const subject = info.subject; - const email = ` <${info.author.email}>`; - const author = `${info.author.name}${email}`; - const shortTimestamp = Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(info.committer.time); - const longTimestamp = Intl.DateTimeFormat(undefined, { dateStyle: 'full', timeStyle: 'long' }).format(info.committer.time); +const GitCommitInfoView: React.FC<{ ci?: CIInfo, commit: GitCommitInfo }> = ({ ci, commit }) => { + const title = ci?.prTitle || commit.subject; + const link = ci?.prHref || ci?.commitHref; + const email = ` <${commit.author.email}>`; + const author = `${commit.author.name}${email}`; + const shortTimestamp = Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(commit.committer.time); + const longTimestamp = Intl.DateTimeFormat(undefined, { dateStyle: 'full', timeStyle: 'long' }).format(commit.committer.time); return
- {link ? ( - - {subject} - - ) : - {subject} - } + {link && {title}} + {!link && {title}}
{author} diff --git a/packages/playwright/src/isomorphic/types.d.ts b/packages/playwright/src/isomorphic/types.d.ts index 2f0b85e1f0..cbdb01cc52 100644 --- a/packages/playwright/src/isomorphic/types.d.ts +++ b/packages/playwright/src/isomorphic/types.d.ts @@ -34,6 +34,8 @@ export type GitCommitInfo = { export type CIInfo = { commitHref: string; + prHref?: string; + prTitle?: string; buildHref?: string; commitHash?: string; baseHash?: string; diff --git a/packages/playwright/src/plugins/gitCommitInfoPlugin.ts b/packages/playwright/src/plugins/gitCommitInfoPlugin.ts index a80ef3b705..bba2acfb73 100644 --- a/packages/playwright/src/plugins/gitCommitInfoPlugin.ts +++ b/packages/playwright/src/plugins/gitCommitInfoPlugin.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import * as fs from 'fs'; + import { spawnAsync } from 'playwright-core/lib/utils'; import type { TestRunnerPlugin } from './'; @@ -37,7 +39,7 @@ export const gitCommitInfoPlugin = (options?: GitCommitInfoPluginOptions): TestR setup: async (config: FullConfig, configDir: string) => { const metadata = config.metadata as UserMetadataWithCommitInfo; - const ci = ciInfo(); + const ci = await ciInfo(); if (!metadata.ci && ci) metadata.ci = ci; @@ -62,10 +64,19 @@ export const gitCommitInfoPlugin = (options?: GitCommitInfoPluginOptions): TestR }; }; -function ciInfo(): CIInfo | undefined { +async function ciInfo(): Promise { if (process.env.GITHUB_ACTIONS) { + let pr: { title: string, number: number } | undefined; + try { + const json = JSON.parse(await fs.promises.readFile(process.env.GITHUB_EVENT_PATH!, 'utf8')); + pr = { title: json.pull_request.title, number: json.pull_request.number }; + } catch { + } + return { commitHref: `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/commit/${process.env.GITHUB_SHA}`, + prHref: pr ? `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/pull/${pr.number}` : undefined, + prTitle: pr ? pr.title : undefined, buildHref: `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`, commitHash: process.env.GITHUB_SHA, baseHash: process.env.GITHUB_BASE_REF, @@ -76,6 +87,7 @@ function ciInfo(): CIInfo | undefined { if (process.env.GITLAB_CI) { return { commitHref: `${process.env.CI_PROJECT_URL}/-/commit/${process.env.CI_COMMIT_SHA}`, + prHref: process.env.CI_MERGE_REQUEST_IID ? `${process.env.CI_PROJECT_URL}/-/merge_requests/${process.env.CI_MERGE_REQUEST_IID}` : undefined, buildHref: process.env.CI_JOB_URL, commitHash: process.env.CI_COMMIT_SHA, baseHash: process.env.CI_COMMIT_BEFORE_SHA, diff --git a/tests/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index 22f0064eac..3d3a927b04 100644 --- a/tests/playwright-test/reporter-html.spec.ts +++ b/tests/playwright-test/reporter-html.spec.ts @@ -1297,7 +1297,8 @@ for (const useIntermediateMergeReport of [true, false] as const) { await expect(page.locator('.metadata-view')).toMatchAriaSnapshot(` - list: - listitem: - - link "https://playwright.dev/microsoft/playwright-example-for-test/commit/example-sha" + - link "My PR" + - listitem: /William / - list: - listitem: "foo : value1" - listitem: "bar : {\\"prop\\":\\"value2\\"}" From 10fc0ef221dde296e97430a632e04de5e47febbf Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Thu, 27 Feb 2025 14:27:54 +0100 Subject: [PATCH 19/26] chore: make indexeddb opt-in (#34942) --- docs/src/api/class-apirequestcontext.md | 2 +- docs/src/api/class-browsercontext.md | 11 ++++++----- packages/playwright-client/types/types.d.ts | 11 ++++++----- packages/playwright-core/src/server/browserContext.ts | 2 +- packages/playwright-core/src/server/fetch.ts | 2 +- packages/playwright-core/types/types.d.ts | 11 ++++++----- tests/library/browsercontext-storage-state.spec.ts | 8 ++++---- tests/library/global-fetch-cookie.spec.ts | 2 +- 8 files changed, 26 insertions(+), 23 deletions(-) diff --git a/docs/src/api/class-apirequestcontext.md b/docs/src/api/class-apirequestcontext.md index 165b9f3bd6..8cced36cc8 100644 --- a/docs/src/api/class-apirequestcontext.md +++ b/docs/src/api/class-apirequestcontext.md @@ -914,4 +914,4 @@ Returns storage state for this request context, contains current cookies and loc * since: v1.51 - `indexedDB` ? -Defaults to `true`. Set to `false` to omit IndexedDB from snapshot. +Set to `true` to include IndexedDB in the storage state snapshot. diff --git a/docs/src/api/class-browsercontext.md b/docs/src/api/class-browsercontext.md index f831bd4cae..211074d438 100644 --- a/docs/src/api/class-browsercontext.md +++ b/docs/src/api/class-browsercontext.md @@ -1533,10 +1533,6 @@ Whether to emulate network being offline for the browser context. Returns storage state for this browser context, contains current cookies, local storage snapshot and IndexedDB snapshot. -:::note -IndexedDBs with typed arrays are currently not supported. -::: - ## async method: BrowserContext.storageState * since: v1.8 * langs: csharp, java @@ -1549,7 +1545,12 @@ IndexedDBs with typed arrays are currently not supported. * since: v1.51 - `indexedDB` ? -Defaults to `true`. Set to `false` to omit IndexedDB from snapshot. +Set to `true` to include IndexedDB in the storage state snapshot. +If your application uses IndexedDB to store authentication tokens, like Firebase Authentication, enable this. + +:::note +IndexedDBs with typed arrays are currently not supported. +::: ## property: BrowserContext.tracing * since: v1.12 diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index a54ce42b85..5ea9ec4def 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -9267,14 +9267,15 @@ export interface BrowserContext { /** * Returns storage state for this browser context, contains current cookies, local storage snapshot and IndexedDB * snapshot. - * - * **NOTE** IndexedDBs with typed arrays are currently not supported. - * * @param options */ storageState(options?: { /** - * Defaults to `true`. Set to `false` to omit IndexedDB from snapshot. + * Set to `true` to include IndexedDB in the storage state snapshot. If your application uses IndexedDB to store + * authentication tokens, like Firebase Authentication, enable this. + * + * **NOTE** IndexedDBs with typed arrays are currently not supported. + * */ indexedDB?: boolean; @@ -18558,7 +18559,7 @@ export interface APIRequestContext { */ storageState(options?: { /** - * Defaults to `true`. Set to `false` to omit IndexedDB from snapshot. + * Set to `true` to include IndexedDB in the storage state snapshot. */ indexedDB?: boolean; diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index a57af3bf12..b2bf4bf202 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -511,7 +511,7 @@ export abstract class BrowserContext extends SdkObject { this._origins.add(origin); } - async storageState(indexedDB = true): Promise { + async storageState(indexedDB = false): Promise { const result: channels.BrowserContextStorageStateResult = { cookies: await this.cookies(), origins: [] diff --git a/packages/playwright-core/src/server/fetch.ts b/packages/playwright-core/src/server/fetch.ts index 739af0218e..e7647759a2 100644 --- a/packages/playwright-core/src/server/fetch.ts +++ b/packages/playwright-core/src/server/fetch.ts @@ -693,7 +693,7 @@ export class GlobalAPIRequestContext extends APIRequestContext { return this._cookieStore.cookies(url); } - override async storageState(indexedDB = true): Promise { + override async storageState(indexedDB = false): Promise { return { cookies: this._cookieStore.allCookies(), origins: (this._origins || []).map(origin => ({ ...origin, indexedDB: indexedDB ? origin.indexedDB : [] })), diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index a54ce42b85..5ea9ec4def 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -9267,14 +9267,15 @@ export interface BrowserContext { /** * Returns storage state for this browser context, contains current cookies, local storage snapshot and IndexedDB * snapshot. - * - * **NOTE** IndexedDBs with typed arrays are currently not supported. - * * @param options */ storageState(options?: { /** - * Defaults to `true`. Set to `false` to omit IndexedDB from snapshot. + * Set to `true` to include IndexedDB in the storage state snapshot. If your application uses IndexedDB to store + * authentication tokens, like Firebase Authentication, enable this. + * + * **NOTE** IndexedDBs with typed arrays are currently not supported. + * */ indexedDB?: boolean; @@ -18558,7 +18559,7 @@ export interface APIRequestContext { */ storageState(options?: { /** - * Defaults to `true`. Set to `false` to omit IndexedDB from snapshot. + * Set to `true` to include IndexedDB in the storage state snapshot. */ indexedDB?: boolean; diff --git a/tests/library/browsercontext-storage-state.spec.ts b/tests/library/browsercontext-storage-state.spec.ts index d1431e88fd..e22c66f23c 100644 --- a/tests/library/browsercontext-storage-state.spec.ts +++ b/tests/library/browsercontext-storage-state.spec.ts @@ -110,7 +110,7 @@ it('should round-trip through the file', async ({ contextFactory }, testInfo) => }); const path = testInfo.outputPath('storage-state.json'); - const state = await context.storageState({ path }); + const state = await context.storageState({ path, indexedDB: true }); const written = await fs.promises.readFile(path, 'utf8'); expect(JSON.stringify(state, undefined, 2)).toBe(written); @@ -365,7 +365,7 @@ it('should support IndexedDB', async ({ page, server, contextFactory }) => { await page.getByLabel('Mins').fill('1'); await page.getByText('Add Task').click(); - const storageState = await page.context().storageState(); + const storageState = await page.context().storageState({ indexedDB: true }); expect(storageState.origins).toEqual([ { origin: server.PREFIX, @@ -438,7 +438,7 @@ it('should support IndexedDB', async ({ page, server, contextFactory }) => { ]); const context = await contextFactory({ storageState }); - expect(await context.storageState()).toEqual(storageState); + expect(await context.storageState({ indexedDB: true })).toEqual(storageState); const recreatedPage = await context.newPage(); await recreatedPage.goto(server.PREFIX + '/to-do-notifications/index.html'); @@ -448,5 +448,5 @@ it('should support IndexedDB', async ({ page, server, contextFactory }) => { - text: /Pet the cat/ `); - expect(await context.storageState({ indexedDB: false })).toEqual({ cookies: [], origins: [] }); + expect(await context.storageState()).toEqual({ cookies: [], origins: [] }); }); diff --git a/tests/library/global-fetch-cookie.spec.ts b/tests/library/global-fetch-cookie.spec.ts index f2f522a619..e34cf1561b 100644 --- a/tests/library/global-fetch-cookie.spec.ts +++ b/tests/library/global-fetch-cookie.spec.ts @@ -376,7 +376,7 @@ it('should preserve local storage on import/export of storage state', async ({ p }; const request = await playwright.request.newContext({ storageState }); await request.get(server.EMPTY_PAGE); - const exportedState = await request.storageState(); + const exportedState = await request.storageState({ indexedDB: true }); expect(exportedState).toEqual(storageState); await request.dispose(); }); From 837abfbc154fb0a5f1a65b1339784f893598b563 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Thu, 27 Feb 2025 14:29:22 +0100 Subject: [PATCH 20/26] chore: document indexeddb type as "unknown" (#34944) --- docs/src/api/class-apirequest.md | 20 +- docs/src/api/class-apirequestcontext.md | 20 +- docs/src/api/class-browsercontext.md | 20 +- docs/src/api/params.md | 20 +- packages/playwright-client/types/types.d.ts | 238 +----------------- .../src/client/browserContext.ts | 2 +- packages/playwright-core/src/client/types.ts | 4 +- packages/playwright-core/types/types.d.ts | 238 +----------------- 8 files changed, 17 insertions(+), 545 deletions(-) diff --git a/docs/src/api/class-apirequest.md b/docs/src/api/class-apirequest.md index 42071c770a..686be4303c 100644 --- a/docs/src/api/class-apirequest.md +++ b/docs/src/api/class-apirequest.md @@ -71,25 +71,7 @@ Methods like [`method: APIRequestContext.get`] take the base URL into considerat - `localStorage` <[Array]<[Object]>> - `name` <[string]> - `value` <[string]> - - `indexedDB` ?<[Array]<[Object]>> indexedDB to set for context - - `name` <[string]> database name - - `version` <[int]> database version - - `stores` <[Array]<[Object]>> - - `name` <[string]> - - `keyPath` ?<[string]> - - `keyPathArray` ?<[Array]<[string]>> - - `autoIncrement` <[boolean]> - - `indexes` <[Array]<[Object]>> - - `name` <[string]> - - `keyPath` ?<[string]> - - `keyPathArray` ?<[Array]<[string]>> - - `unique` <[boolean]> - - `multiEntry` <[boolean]> - - `records` <[Array]<[Object]>> - - `key` ?<[Object]> - - `keyEncoded` ?<[Object]> if `key` is not JSON-serializable, this contains an encoded version that preserves types. - - `value` ?<[Object]> - - `valueEncoded` ?<[Object]> if `value` is not JSON-serializable, this contains an encoded version that preserves types. + - `indexedDB` ?<[Array]<[unknown]>> indexedDB to set for context Populates context with given storage state. This option can be used to initialize context with logged-in information obtained via [`method: BrowserContext.storageState`] or [`method: APIRequestContext.storageState`]. Either a path to the diff --git a/docs/src/api/class-apirequestcontext.md b/docs/src/api/class-apirequestcontext.md index 8cced36cc8..a9b7170e84 100644 --- a/docs/src/api/class-apirequestcontext.md +++ b/docs/src/api/class-apirequestcontext.md @@ -880,25 +880,7 @@ context cookies from the response. The method will automatically follow redirect - `localStorage` <[Array]<[Object]>> - `name` <[string]> - `value` <[string]> - - `indexedDB` <[Array]<[Object]>> - - `name` <[string]> - - `version` <[int]> - - `stores` <[Array]<[Object]>> - - `name` <[string]> - - `keyPath` ?<[string]> - - `keyPathArray` ?<[Array]<[string]>> - - `autoIncrement` <[boolean]> - - `indexes` <[Array]<[Object]>> - - `name` <[string]> - - `keyPath` ?<[string]> - - `keyPathArray` ?<[Array]<[string]>> - - `unique` <[boolean]> - - `multiEntry` <[boolean]> - - `records` <[Array]<[Object]>> - - `key` ?<[Object]> - - `keyEncoded` ?<[Object]> if `key` is not JSON-serializable, this contains an encoded version that preserves types. - - `value` ?<[Object]> - - `valueEncoded` ?<[Object]> if `value` is not JSON-serializable, this contains an encoded version that preserves types. + - `indexedDB` <[Array]<[unknown]>> Returns storage state for this request context, contains current cookies and local storage snapshot if it was passed to the constructor. diff --git a/docs/src/api/class-browsercontext.md b/docs/src/api/class-browsercontext.md index 211074d438..91e43e22dd 100644 --- a/docs/src/api/class-browsercontext.md +++ b/docs/src/api/class-browsercontext.md @@ -1511,25 +1511,7 @@ Whether to emulate network being offline for the browser context. - `localStorage` <[Array]<[Object]>> - `name` <[string]> - `value` <[string]> - - `indexedDB` <[Array]<[Object]>> - - `name` <[string]> - - `version` <[int]> - - `stores` <[Array]<[Object]>> - - `name` <[string]> - - `keyPath` ?<[string]> - - `keyPathArray` ?<[Array]<[string]>> - - `autoIncrement` <[boolean]> - - `indexes` <[Array]<[Object]>> - - `name` <[string]> - - `keyPath` ?<[string]> - - `keyPathArray` ?<[Array]<[string]>> - - `unique` <[boolean]> - - `multiEntry` <[boolean]> - - `records` <[Array]<[Object]>> - - `key` ?<[Object]> - - `keyEncoded` ?<[Object]> if `key` is not JSON-serializable, this contains an encoded version that preserves types. - - `value` ?<[Object]> - - `valueEncoded` ?<[Object]> if `value` is not JSON-serializable, this contains an encoded version that preserves types. + - `indexedDB` <[Array]<[unknown]>> Returns storage state for this browser context, contains current cookies, local storage snapshot and IndexedDB snapshot. diff --git a/docs/src/api/params.md b/docs/src/api/params.md index 80fb59d587..de67c7f1c7 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -264,25 +264,7 @@ Specify environment variables that will be visible to the browser. Defaults to ` - `localStorage` <[Array]<[Object]>> localStorage to set for context - `name` <[string]> - `value` <[string]> - - `indexedDB` ?<[Array]<[Object]>> indexedDB to set for context - - `name` <[string]> database name - - `version` <[int]> database version - - `stores` <[Array]<[Object]>> - - `name` <[string]> - - `keyPath` ?<[string]> - - `keyPathArray` ?<[Array]<[string]>> - - `autoIncrement` <[boolean]> - - `indexes` <[Array]<[Object]>> - - `name` <[string]> - - `keyPath` ?<[string]> - - `keyPathArray` ?<[Array]<[string]>> - - `unique` <[boolean]> - - `multiEntry` <[boolean]> - - `records` <[Array]<[Object]>> - - `key` ?<[Object]> - - `keyEncoded` ?<[Object]> if `key` is not JSON-serializable, this contains an encoded version that preserves types. - - `value` ?<[Object]> - - `valueEncoded` ?<[Object]> if `value` is not JSON-serializable, this contains an encoded version that preserves types. + - `indexedDB` ?<[Array]<[unknown]>> indexedDB to set for context Learn more about [storage state and auth](../auth.md). diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 5ea9ec4def..26115f729a 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -9317,49 +9317,7 @@ export interface BrowserContext { value: string; }>; - indexedDB: Array<{ - name: string; - - version: number; - - stores: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - autoIncrement: boolean; - - indexes: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - unique: boolean; - - multiEntry: boolean; - }>; - - records: Array<{ - key?: Object; - - /** - * if `key` is not JSON-serializable, this contains an encoded version that preserves types. - */ - keyEncoded?: Object; - - value?: Object; - - /** - * if `value` is not JSON-serializable, this contains an encoded version that preserves types. - */ - valueEncoded?: Object; - }>; - }>; - }>; + indexedDB: Array; }>; }>; @@ -10130,55 +10088,7 @@ export interface Browser { /** * indexedDB to set for context */ - indexedDB?: Array<{ - /** - * database name - */ - name: string; - - /** - * database version - */ - version: number; - - stores: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - autoIncrement: boolean; - - indexes: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - unique: boolean; - - multiEntry: boolean; - }>; - - records: Array<{ - key?: Object; - - /** - * if `key` is not JSON-serializable, this contains an encoded version that preserves types. - */ - keyEncoded?: Object; - - value?: Object; - - /** - * if `value` is not JSON-serializable, this contains an encoded version that preserves types. - */ - valueEncoded?: Object; - }>; - }>; - }>; + indexedDB?: Array; }>; }; @@ -17737,55 +17647,7 @@ export interface APIRequest { /** * indexedDB to set for context */ - indexedDB?: Array<{ - /** - * database name - */ - name: string; - - /** - * database version - */ - version: number; - - stores: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - autoIncrement: boolean; - - indexes: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - unique: boolean; - - multiEntry: boolean; - }>; - - records: Array<{ - key?: Object; - - /** - * if `key` is not JSON-serializable, this contains an encoded version that preserves types. - */ - keyEncoded?: Object; - - value?: Object; - - /** - * if `value` is not JSON-serializable, this contains an encoded version that preserves types. - */ - valueEncoded?: Object; - }>; - }>; - }>; + indexedDB?: Array; }>; }; @@ -18601,49 +18463,7 @@ export interface APIRequestContext { value: string; }>; - indexedDB: Array<{ - name: string; - - version: number; - - stores: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - autoIncrement: boolean; - - indexes: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - unique: boolean; - - multiEntry: boolean; - }>; - - records: Array<{ - key?: Object; - - /** - * if `key` is not JSON-serializable, this contains an encoded version that preserves types. - */ - keyEncoded?: Object; - - value?: Object; - - /** - * if `value` is not JSON-serializable, this contains an encoded version that preserves types. - */ - valueEncoded?: Object; - }>; - }>; - }>; + indexedDB: Array; }>; }>; @@ -22495,55 +22315,7 @@ export interface BrowserContextOptions { /** * indexedDB to set for context */ - indexedDB?: Array<{ - /** - * database name - */ - name: string; - - /** - * database version - */ - version: number; - - stores: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - autoIncrement: boolean; - - indexes: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - unique: boolean; - - multiEntry: boolean; - }>; - - records: Array<{ - key?: Object; - - /** - * if `key` is not JSON-serializable, this contains an encoded version that preserves types. - */ - keyEncoded?: Object; - - value?: Object; - - /** - * if `value` is not JSON-serializable, this contains an encoded version that preserves types. - */ - valueEncoded?: Object; - }>; - }>; - }>; + indexedDB?: Array; }>; }; diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index 445379083e..accee95288 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -510,7 +510,7 @@ export class BrowserContext extends ChannelOwner async function prepareStorageState(platform: Platform, options: BrowserContextOptions): Promise { if (typeof options.storageState !== 'string') - return options.storageState; + return options.storageState as any; try { return JSON.parse(await platform.fs().promises.readFile(options.storageState, 'utf8')); } catch (e) { diff --git a/packages/playwright-core/src/client/types.ts b/packages/playwright-core/src/client/types.ts index 29dcd5112c..53c805a496 100644 --- a/packages/playwright-core/src/client/types.ts +++ b/packages/playwright-core/src/client/types.ts @@ -37,11 +37,11 @@ export type SelectOptionOptions = { force?: boolean, timeout?: number }; export type FilePayload = { name: string, mimeType: string, buffer: Buffer }; export type StorageState = { cookies: channels.NetworkCookie[], - origins: channels.OriginStorage[], + origins: (Omit & { indexedDB: unknown[] })[], }; export type SetStorageState = { cookies?: channels.SetNetworkCookie[], - origins?: channels.SetOriginStorage[] + origins?: (Omit & { indexedDB?: unknown[] })[] }; export type LifecycleEvent = channels.LifecycleEvent; diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 5ea9ec4def..26115f729a 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -9317,49 +9317,7 @@ export interface BrowserContext { value: string; }>; - indexedDB: Array<{ - name: string; - - version: number; - - stores: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - autoIncrement: boolean; - - indexes: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - unique: boolean; - - multiEntry: boolean; - }>; - - records: Array<{ - key?: Object; - - /** - * if `key` is not JSON-serializable, this contains an encoded version that preserves types. - */ - keyEncoded?: Object; - - value?: Object; - - /** - * if `value` is not JSON-serializable, this contains an encoded version that preserves types. - */ - valueEncoded?: Object; - }>; - }>; - }>; + indexedDB: Array; }>; }>; @@ -10130,55 +10088,7 @@ export interface Browser { /** * indexedDB to set for context */ - indexedDB?: Array<{ - /** - * database name - */ - name: string; - - /** - * database version - */ - version: number; - - stores: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - autoIncrement: boolean; - - indexes: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - unique: boolean; - - multiEntry: boolean; - }>; - - records: Array<{ - key?: Object; - - /** - * if `key` is not JSON-serializable, this contains an encoded version that preserves types. - */ - keyEncoded?: Object; - - value?: Object; - - /** - * if `value` is not JSON-serializable, this contains an encoded version that preserves types. - */ - valueEncoded?: Object; - }>; - }>; - }>; + indexedDB?: Array; }>; }; @@ -17737,55 +17647,7 @@ export interface APIRequest { /** * indexedDB to set for context */ - indexedDB?: Array<{ - /** - * database name - */ - name: string; - - /** - * database version - */ - version: number; - - stores: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - autoIncrement: boolean; - - indexes: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - unique: boolean; - - multiEntry: boolean; - }>; - - records: Array<{ - key?: Object; - - /** - * if `key` is not JSON-serializable, this contains an encoded version that preserves types. - */ - keyEncoded?: Object; - - value?: Object; - - /** - * if `value` is not JSON-serializable, this contains an encoded version that preserves types. - */ - valueEncoded?: Object; - }>; - }>; - }>; + indexedDB?: Array; }>; }; @@ -18601,49 +18463,7 @@ export interface APIRequestContext { value: string; }>; - indexedDB: Array<{ - name: string; - - version: number; - - stores: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - autoIncrement: boolean; - - indexes: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - unique: boolean; - - multiEntry: boolean; - }>; - - records: Array<{ - key?: Object; - - /** - * if `key` is not JSON-serializable, this contains an encoded version that preserves types. - */ - keyEncoded?: Object; - - value?: Object; - - /** - * if `value` is not JSON-serializable, this contains an encoded version that preserves types. - */ - valueEncoded?: Object; - }>; - }>; - }>; + indexedDB: Array; }>; }>; @@ -22495,55 +22315,7 @@ export interface BrowserContextOptions { /** * indexedDB to set for context */ - indexedDB?: Array<{ - /** - * database name - */ - name: string; - - /** - * database version - */ - version: number; - - stores: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - autoIncrement: boolean; - - indexes: Array<{ - name: string; - - keyPath?: string; - - keyPathArray?: Array; - - unique: boolean; - - multiEntry: boolean; - }>; - - records: Array<{ - key?: Object; - - /** - * if `key` is not JSON-serializable, this contains an encoded version that preserves types. - */ - keyEncoded?: Object; - - value?: Object; - - /** - * if `value` is not JSON-serializable, this contains an encoded version that preserves types. - */ - valueEncoded?: Object; - }>; - }>; - }>; + indexedDB?: Array; }>; }; From 3ce9ae6a7d7ec35d4f7af4619e40bb99e0cbeb02 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Thu, 27 Feb 2025 13:44:53 +0000 Subject: [PATCH 21/26] chore: replace locator.visible with filter({ visible }) (#34947) --- docs/src/api/class-locator.md | 15 +++-------- docs/src/api/params.md | 5 ++++ docs/src/locators.md | 14 +++++----- packages/playwright-client/types/types.d.ts | 16 ++++-------- .../playwright-core/src/client/locator.ts | 11 ++++---- .../src/server/injected/consoleApi.ts | 8 +++--- .../src/utils/isomorphic/locatorGenerators.ts | 8 +++--- .../src/utils/isomorphic/locatorParser.ts | 5 ++-- packages/playwright-core/types/types.d.ts | 16 ++++-------- tests/library/inspector/console-api.spec.ts | 3 ++- tests/library/locator-generator.spec.ts | 26 +++++++------------ tests/page/locator-misc-2.spec.ts | 9 +++---- 12 files changed, 57 insertions(+), 79 deletions(-) diff --git a/docs/src/api/class-locator.md b/docs/src/api/class-locator.md index c7680319d4..42c09c79f1 100644 --- a/docs/src/api/class-locator.md +++ b/docs/src/api/class-locator.md @@ -1090,6 +1090,9 @@ await rowLocator ### option: Locator.filter.hasNotText = %%-locator-option-has-not-text-%% * since: v1.33 +### option: Locator.filter.visible = %%-locator-option-visible-%% +* since: v1.51 + ## method: Locator.first * since: v1.14 - returns: <[Locator]> @@ -2478,18 +2481,6 @@ When all steps combined have not finished during the specified [`option: timeout ### option: Locator.uncheck.trial = %%-input-trial-%% * since: v1.14 -## method: Locator.visible -* since: v1.51 -- returns: <[Locator]> - -Returns a locator that only matches [visible](../actionability.md#visible) elements. - -### option: Locator.visible.visible -* since: v1.51 -- `visible` <[boolean]> - -Whether to match visible or invisible elements. - ## async method: Locator.waitFor * since: v1.16 diff --git a/docs/src/api/params.md b/docs/src/api/params.md index de67c7f1c7..a31ea6047c 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -1155,6 +1155,11 @@ Note that outer and inner locators must belong to the same frame. Inner locator Matches elements that do not contain specified text somewhere inside, possibly in a child or a descendant element. When passed a [string], matching is case-insensitive and searches for a substring. +## locator-option-visible +- `visible` <[boolean]> + +Only matches visible or invisible elements. + ## locator-options-list-v1.14 - %%-locator-option-has-text-%% - %%-locator-option-has-%% diff --git a/docs/src/locators.md b/docs/src/locators.md index ed15a82762..b0a1c0e8f5 100644 --- a/docs/src/locators.md +++ b/docs/src/locators.md @@ -751,10 +751,10 @@ page.locator("x-details", new Page.LocatorOptions().setHasText("Details")) .click(); ``` ```python async -await page.locator("x-details", has_text="Details" ).click() +await page.locator("x-details", has_text="Details").click() ``` ```python sync -page.locator("x-details", has_text="Details" ).click() +page.locator("x-details", has_text="Details").click() ``` ```csharp await page @@ -1310,19 +1310,19 @@ Consider a page with two buttons, the first invisible and the second [visible](. * This will only find a second button, because it is visible, and then click it. ```js - await page.locator('button').visible().click(); + await page.locator('button').filter({ visible: true }).click(); ``` ```java - page.locator("button").visible().click(); + page.locator("button").filter(new Locator.FilterOptions.setVisible(true)).click(); ``` ```python async - await page.locator("button").visible().click() + await page.locator("button").filter(visible=True).click() ``` ```python sync - page.locator("button").visible().click() + page.locator("button").filter(visible=True).click() ``` ```csharp - await page.Locator("button").Visible().ClickAsync(); + await page.Locator("button").Filter(new() { Visible = true }).ClickAsync(); ``` ## Lists diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 26115f729a..abdc85e923 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -13129,6 +13129,11 @@ export interface Locator { * `
Playwright
`. */ hasText?: string|RegExp; + + /** + * Only matches visible or invisible elements. + */ + visible?: boolean; }): Locator; /** @@ -14520,17 +14525,6 @@ export interface Locator { trial?: boolean; }): Promise; - /** - * Returns a locator that only matches [visible](https://playwright.dev/docs/actionability#visible) elements. - * @param options - */ - visible(options?: { - /** - * Whether to match visible or invisible elements. - */ - visible?: boolean; - }): Locator; - /** * Returns when element specified by locator satisfies the * [`state`](https://playwright.dev/docs/api/class-locator#locator-wait-for-option-state) option. diff --git a/packages/playwright-core/src/client/locator.ts b/packages/playwright-core/src/client/locator.ts index 1ed86b847d..a915c0a8ef 100644 --- a/packages/playwright-core/src/client/locator.ts +++ b/packages/playwright-core/src/client/locator.ts @@ -35,6 +35,7 @@ export type LocatorOptions = { hasNotText?: string | RegExp; has?: Locator; hasNot?: Locator; + visible?: boolean; }; export class Locator implements api.Locator { @@ -65,6 +66,9 @@ export class Locator implements api.Locator { this._selector += ` >> internal:has-not=` + JSON.stringify(locator._selector); } + if (options?.visible !== undefined) + this._selector += ` >> visible=${options.visible ? 'true' : 'false'}`; + if (this._frame._platform.inspectCustom) (this as any)[this._frame._platform.inspectCustom] = () => this._inspect(); } @@ -150,7 +154,7 @@ export class Locator implements api.Locator { return await this._frame._highlight(this._selector); } - locator(selectorOrLocator: string | Locator, options?: LocatorOptions): Locator { + locator(selectorOrLocator: string | Locator, options?: Omit): Locator { if (isString(selectorOrLocator)) return new Locator(this._frame, this._selector + ' >> ' + selectorOrLocator, options); if (selectorOrLocator._frame !== this._frame) @@ -218,11 +222,6 @@ export class Locator implements api.Locator { return new Locator(this._frame, this._selector + ` >> nth=${index}`); } - visible(options: { visible?: boolean } = {}): Locator { - const { visible = true } = options; - return new Locator(this._frame, this._selector + ` >> visible=${visible ? 'true' : 'false'}`); - } - and(locator: Locator): Locator { if (locator._frame !== this._frame) throw new Error(`Locators must belong to the same frame.`); diff --git a/packages/playwright-core/src/server/injected/consoleApi.ts b/packages/playwright-core/src/server/injected/consoleApi.ts index c79ee6610c..5bcd94a5bf 100644 --- a/packages/playwright-core/src/server/injected/consoleApi.ts +++ b/packages/playwright-core/src/server/injected/consoleApi.ts @@ -29,7 +29,7 @@ class Locator { element: Element | undefined; elements: Element[] | undefined; - constructor(injectedScript: InjectedScript, selector: string, options?: { hasText?: string | RegExp, hasNotText?: string | RegExp, has?: Locator, hasNot?: Locator }) { + constructor(injectedScript: InjectedScript, selector: string, options?: { hasText?: string | RegExp, hasNotText?: string | RegExp, has?: Locator, hasNot?: Locator, visible?: boolean }) { if (options?.hasText) selector += ` >> internal:has-text=${escapeForTextSelector(options.hasText, false)}`; if (options?.hasNotText) @@ -38,6 +38,8 @@ class Locator { selector += ` >> internal:has=` + JSON.stringify(options.has[selectorSymbol]); if (options?.hasNot) selector += ` >> internal:has-not=` + JSON.stringify(options.hasNot[selectorSymbol]); + if (options?.visible !== undefined) + selector += ` >> visible=${options.visible ? 'true' : 'false'}`; this[selectorSymbol] = selector; if (selector) { const parsed = injectedScript.parseSelector(selector); @@ -46,7 +48,7 @@ class Locator { } const selectorBase = selector; const self = this as any; - self.locator = (selector: string, options?: { hasText?: string | RegExp, has?: Locator }): Locator => { + self.locator = (selector: string, options?: { hasText?: string | RegExp, hasNotText?: string | RegExp, has?: Locator, hasNot?: Locator }): Locator => { return new Locator(injectedScript, selectorBase ? selectorBase + ' >> ' + selector : selector, options); }; self.getByTestId = (testId: string): Locator => self.locator(getByTestIdSelector(injectedScript.testIdAttributeNameForStrictErrorAndConsoleCodegen(), testId)); @@ -56,7 +58,7 @@ class Locator { self.getByText = (text: string | RegExp, options?: { exact?: boolean }): Locator => self.locator(getByTextSelector(text, options)); self.getByTitle = (text: string | RegExp, options?: { exact?: boolean }): Locator => self.locator(getByTitleSelector(text, options)); self.getByRole = (role: string, options: ByRoleOptions = {}): Locator => self.locator(getByRoleSelector(role, options)); - self.filter = (options?: { hasText?: string | RegExp, has?: Locator }): Locator => new Locator(injectedScript, selector, options); + self.filter = (options?: { hasText?: string | RegExp, hasNotText?: string | RegExp, has?: Locator, hasNot?: Locator, visible?: boolean }): Locator => new Locator(injectedScript, selector, options); self.first = (): Locator => self.locator('nth=0'); self.last = (): Locator => self.locator('nth=-1'); self.nth = (index: number): Locator => self.locator(`nth=${index}`); diff --git a/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts b/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts index 283c4e492a..5be1f36b96 100644 --- a/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts +++ b/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts @@ -280,7 +280,7 @@ export class JavaScriptLocatorFactory implements LocatorFactory { case 'last': return `last()`; case 'visible': - return `visible(${body === 'true' ? '' : '{ visible: false }'})`; + return `filter({ visible: ${body === 'true' ? 'true' : 'false'} })`; case 'role': const attrs: string[] = []; if (isRegExp(options.name)) { @@ -376,7 +376,7 @@ export class PythonLocatorFactory implements LocatorFactory { case 'last': return `last`; case 'visible': - return `visible(${body === 'true' ? '' : 'visible=False'})`; + return `filter(visible=${body === 'true' ? 'True' : 'False'})`; case 'role': const attrs: string[] = []; if (isRegExp(options.name)) { @@ -485,7 +485,7 @@ export class JavaLocatorFactory implements LocatorFactory { case 'last': return `last()`; case 'visible': - return `visible(${body === 'true' ? '' : `new ${clazz}.VisibleOptions().setVisible(false)`})`; + return `filter(new ${clazz}.FilterOptions().setVisible(${body === 'true' ? 'true' : 'false'}))`; case 'role': const attrs: string[] = []; if (isRegExp(options.name)) { @@ -584,7 +584,7 @@ export class CSharpLocatorFactory implements LocatorFactory { case 'last': return `Last`; case 'visible': - return `Visible(${body === 'true' ? '' : 'new() { Visible = false }'})`; + return `Filter(new() { Visible = ${body === 'true' ? 'true' : 'false'} })`; case 'role': const attrs: string[] = []; if (isRegExp(options.name)) { diff --git a/packages/playwright-core/src/utils/isomorphic/locatorParser.ts b/packages/playwright-core/src/utils/isomorphic/locatorParser.ts index e3481b0973..6dcbc1cbcc 100644 --- a/packages/playwright-core/src/utils/isomorphic/locatorParser.ts +++ b/packages/playwright-core/src/utils/isomorphic/locatorParser.ts @@ -170,9 +170,8 @@ function transform(template: string, params: TemplateParams, testIdAttributeName .replace(/first(\(\))?/g, 'nth=0') .replace(/last(\(\))?/g, 'nth=-1') .replace(/nth\(([^)]+)\)/g, 'nth=$1') - .replace(/visible\(,?visible=true\)/g, 'visible=true') - .replace(/visible\(,?visible=false\)/g, 'visible=false') - .replace(/visible\(\)/g, 'visible=true') + .replace(/filter\(,?visible=true\)/g, 'visible=true') + .replace(/filter\(,?visible=false\)/g, 'visible=false') .replace(/filter\(,?hastext=([^)]+)\)/g, 'internal:has-text=$1') .replace(/filter\(,?hasnottext=([^)]+)\)/g, 'internal:has-not-text=$1') .replace(/filter\(,?has2=([^)]+)\)/g, 'internal:has=$1') diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 26115f729a..abdc85e923 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -13129,6 +13129,11 @@ export interface Locator { * `
Playwright
`. */ hasText?: string|RegExp; + + /** + * Only matches visible or invisible elements. + */ + visible?: boolean; }): Locator; /** @@ -14520,17 +14525,6 @@ export interface Locator { trial?: boolean; }): Promise; - /** - * Returns a locator that only matches [visible](https://playwright.dev/docs/actionability#visible) elements. - * @param options - */ - visible(options?: { - /** - * Whether to match visible or invisible elements. - */ - visible?: boolean; - }): Locator; - /** * Returns when element specified by locator satisfies the * [`state`](https://playwright.dev/docs/api/class-locator#locator-wait-for-option-state) option. diff --git a/tests/library/inspector/console-api.spec.ts b/tests/library/inspector/console-api.spec.ts index 50f4c5f063..2b40b8943a 100644 --- a/tests/library/inspector/console-api.spec.ts +++ b/tests/library/inspector/console-api.spec.ts @@ -92,13 +92,14 @@ it('should support locator.or()', async ({ page }) => { }); it('should support playwright.getBy*', async ({ page }) => { - await page.setContent('HelloWorld'); + await page.setContent('HelloWorld
one
two
'); expect(await page.evaluate(`playwright.getByText('hello').element.innerHTML`)).toContain('Hello'); expect(await page.evaluate(`playwright.getByTitle('world').element.innerHTML`)).toContain('World'); expect(await page.evaluate(`playwright.locator('span').filter({ hasText: 'hello' }).element.innerHTML`)).toContain('Hello'); expect(await page.evaluate(`playwright.locator('span').first().element.innerHTML`)).toContain('Hello'); expect(await page.evaluate(`playwright.locator('span').last().element.innerHTML`)).toContain('World'); expect(await page.evaluate(`playwright.locator('span').nth(1).element.innerHTML`)).toContain('World'); + expect(await page.evaluate(`playwright.locator('div').filter({ visible: false }).element.innerHTML`)).toContain('two'); }); it('expected properties on playwright object', async ({ page }) => { diff --git a/tests/library/locator-generator.spec.ts b/tests/library/locator-generator.spec.ts index 0a2ccb333a..cc54a143e8 100644 --- a/tests/library/locator-generator.spec.ts +++ b/tests/library/locator-generator.spec.ts @@ -321,23 +321,17 @@ it('reverse engineer hasNotText', async ({ page }) => { }); it('reverse engineer visible', async ({ page }) => { - expect.soft(generate(page.getByText('Hello').visible().locator('div'))).toEqual({ - csharp: `GetByText("Hello").Visible().Locator("div")`, - java: `getByText("Hello").visible().locator("div")`, - javascript: `getByText('Hello').visible().locator('div')`, - python: `get_by_text("Hello").visible().locator("div")`, + expect.soft(generate(page.getByText('Hello').filter({ visible: true }).locator('div'))).toEqual({ + csharp: `GetByText("Hello").Filter(new() { Visible = true }).Locator("div")`, + java: `getByText("Hello").filter(new Locator.FilterOptions().setVisible(true)).locator("div")`, + javascript: `getByText('Hello').filter({ visible: true }).locator('div')`, + python: `get_by_text("Hello").filter(visible=True).locator("div")`, }); - expect.soft(generate(page.getByText('Hello').visible({ visible: true }).locator('div'))).toEqual({ - csharp: `GetByText("Hello").Visible().Locator("div")`, - java: `getByText("Hello").visible().locator("div")`, - javascript: `getByText('Hello').visible().locator('div')`, - python: `get_by_text("Hello").visible().locator("div")`, - }); - expect.soft(generate(page.getByText('Hello').visible({ visible: false }).locator('div'))).toEqual({ - csharp: `GetByText("Hello").Visible(new() { Visible = false }).Locator("div")`, - java: `getByText("Hello").visible(new Locator.VisibleOptions().setVisible(false)).locator("div")`, - javascript: `getByText('Hello').visible({ visible: false }).locator('div')`, - python: `get_by_text("Hello").visible(visible=False).locator("div")`, + expect.soft(generate(page.getByText('Hello').filter({ visible: false }).locator('div'))).toEqual({ + csharp: `GetByText("Hello").Filter(new() { Visible = false }).Locator("div")`, + java: `getByText("Hello").filter(new Locator.FilterOptions().setVisible(false)).locator("div")`, + javascript: `getByText('Hello').filter({ visible: false }).locator('div')`, + python: `get_by_text("Hello").filter(visible=False).locator("div")`, }); }); diff --git a/tests/page/locator-misc-2.spec.ts b/tests/page/locator-misc-2.spec.ts index 478d86c30c..f09749eca5 100644 --- a/tests/page/locator-misc-2.spec.ts +++ b/tests/page/locator-misc-2.spec.ts @@ -150,7 +150,7 @@ it('should combine visible with other selectors', async ({ page }) => { await expect(page.locator('.item >> visible=true >> text=data3')).toHaveText('visible data3'); }); -it('should support .visible()', async ({ page }) => { +it('should support filter(visible)', async ({ page }) => { await page.setContent(`
visible data1
@@ -160,11 +160,10 @@ it('should support .visible()', async ({ page }) => {
visible data3
`); - const locator = page.locator('.item').visible().nth(1); + const locator = page.locator('.item').filter({ visible: true }).nth(1); await expect(locator).toHaveText('visible data2'); - await expect(page.locator('.item').visible().getByText('data3')).toHaveText('visible data3'); - await expect(page.locator('.item').visible({ visible: true }).getByText('data2')).toHaveText('visible data2'); - await expect(page.locator('.item').visible({ visible: false }).getByText('data1')).toHaveText('Hidden data1'); + await expect(page.locator('.item').filter({ visible: true }).getByText('data3')).toHaveText('visible data3'); + await expect(page.locator('.item').filter({ visible: false }).getByText('data1')).toHaveText('Hidden data1'); }); it('locator.count should work with deleted Map in main world', async ({ page }) => { From a1146fd4a33bbff69462dc4547f00d7a76c3465f Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Thu, 27 Feb 2025 15:50:46 +0100 Subject: [PATCH 22/26] chore: copy as prompt in ui should have codeframe (#34943) Signed-off-by: Simon Knott Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/trace-viewer/src/ui/errorsTab.tsx | 47 ++++++++++++++++++--- packages/trace-viewer/src/ui/sourceTab.tsx | 42 +++++++++--------- packages/trace-viewer/src/ui/workbench.tsx | 2 +- tests/playwright-test/ui-mode-trace.spec.ts | 17 +++++--- 4 files changed, 76 insertions(+), 32 deletions(-) diff --git a/packages/trace-viewer/src/ui/errorsTab.tsx b/packages/trace-viewer/src/ui/errorsTab.tsx index a5fd9b071a..eca5e2ea66 100644 --- a/packages/trace-viewer/src/ui/errorsTab.tsx +++ b/packages/trace-viewer/src/ui/errorsTab.tsx @@ -29,6 +29,7 @@ import { AIConversation } from './aiConversation'; import { ToolbarButton } from '@web/components/toolbarButton'; import { useIsLLMAvailable, useLLMChat } from './llm'; import { useAsyncMemo } from '@web/uiUtils'; +import { useSources } from './sourceTab'; const CommitInfoContext = React.createContext(undefined); @@ -53,18 +54,47 @@ function usePageSnapshot(actions: modelUtil.ActionTraceEventInContext[]) { }, [actions], undefined); } +function useCodeFrame(stack: StackFrame[] | undefined, sources: Map, width: number) { + const selectedFrame = stack?.[0]; + const { source } = useSources(stack, 0, sources); + return React.useMemo(() => { + if (!source.content) + return ''; + + const targetLine = selectedFrame?.line ?? 0; + + const lines = source.content.split('\n'); + const start = Math.max(0, targetLine - width); + const end = Math.min(lines.length, targetLine + width); + const lineNumberWidth = String(end).length; + const codeFrame = lines.slice(start, end).map((line, i) => { + const lineNumber = start + i + 1; + const paddedLineNumber = String(lineNumber).padStart(lineNumberWidth, ' '); + if (lineNumber !== targetLine) + return ` ${(paddedLineNumber)} | ${line}`; + + let highlightLine = `> ${paddedLineNumber} | ${line}`; + if (selectedFrame?.column) + highlightLine += `\n${' '.repeat(4 + lineNumberWidth + selectedFrame.column)}^`; + return highlightLine; + }).join('\n'); + return codeFrame; + }, [source, selectedFrame, width]); +} + const CopyPromptButton: React.FC<{ error: string; + codeFrame: string; pageSnapshot?: string; diff?: string; -}> = ({ error, pageSnapshot, diff }) => { +}> = ({ error, codeFrame, pageSnapshot, diff }) => { const prompt = React.useMemo( () => fixTestPrompt( - error, + error + '\n\n' + codeFrame, diff, pageSnapshot ), - [error, diff, pageSnapshot] + [error, diff, codeFrame, pageSnapshot] ); return ( @@ -97,7 +127,7 @@ export function useErrorsTabModel(model: modelUtil.MultiTraceModel | undefined): }, [model]); } -function Error({ message, error, errorId, sdkLanguage, pageSnapshot, revealInSource }: { message: string, error: ErrorDescription, errorId: string, sdkLanguage: Language, pageSnapshot?: string, revealInSource: (error: ErrorDescription) => void }) { +function Error({ message, error, errorId, sdkLanguage, pageSnapshot, revealInSource, sources }: { message: string, error: ErrorDescription, errorId: string, sdkLanguage: Language, pageSnapshot?: string, revealInSource: (error: ErrorDescription) => void, sources: Map }) { const [showLLM, setShowLLM] = React.useState(false); const llmAvailable = useIsLLMAvailable(); const metadata = useCommitInfo(); @@ -111,6 +141,8 @@ function Error({ message, error, errorId, sdkLanguage, pageSnapshot, revealInSou longLocation = stackFrame.file + ':' + stackFrame.line; } + const codeFrame = useCodeFrame(error.stack, sources, 3); + return
{llmAvailable ? - : } + : }
@@ -179,9 +211,10 @@ export const ErrorsTab: React.FunctionComponent<{ errorsModel: ErrorsTabModel, actions: modelUtil.ActionTraceEventInContext[], wallTime: number, + sources: Map, sdkLanguage: Language, revealInSource: (error: ErrorDescription) => void, -}> = ({ errorsModel, sdkLanguage, revealInSource, actions, wallTime }) => { +}> = ({ errorsModel, sdkLanguage, revealInSource, actions, wallTime, sources }) => { const pageSnapshot = usePageSnapshot(actions); if (!errorsModel.errors.size) @@ -190,7 +223,7 @@ export const ErrorsTab: React.FunctionComponent<{ return
{[...errorsModel.errors.entries()].map(([message, error]) => { const errorId = `error-${wallTime}-${message}`; - return ; + return ; })}
; }; diff --git a/packages/trace-viewer/src/ui/sourceTab.tsx b/packages/trace-viewer/src/ui/sourceTab.tsx index 1dd9170f67..fa205da661 100644 --- a/packages/trace-viewer/src/ui/sourceTab.tsx +++ b/packages/trace-viewer/src/ui/sourceTab.tsx @@ -27,25 +27,8 @@ import { CopyToClipboard } from './copyToClipboard'; import { ToolbarButton } from '@web/components/toolbarButton'; import { Toolbar } from '@web/components/toolbar'; -export const SourceTab: React.FunctionComponent<{ - stack?: StackFrame[], - stackFrameLocation: 'bottom' | 'right', - sources: Map, - rootDir?: string, - fallbackLocation?: SourceLocation, - onOpenExternally?: (location: SourceLocation) => void, -}> = ({ stack, sources, rootDir, fallbackLocation, stackFrameLocation, onOpenExternally }) => { - const [lastStack, setLastStack] = React.useState(); - const [selectedFrame, setSelectedFrame] = React.useState(0); - - React.useEffect(() => { - if (lastStack !== stack) { - setLastStack(stack); - setSelectedFrame(0); - } - }, [stack, lastStack, setLastStack, setSelectedFrame]); - - const { source, highlight, targetLine, fileName, location } = useAsyncMemo<{ source: SourceModel, targetLine?: number, fileName?: string, highlight: SourceHighlight[], location?: SourceLocation }>(async () => { +export function useSources(stack: StackFrame[] | undefined, selectedFrame: number, sources: Map, rootDir?: string, fallbackLocation?: SourceLocation) { + return useAsyncMemo<{ source: SourceModel, targetLine?: number, fileName?: string, highlight: SourceHighlight[], location?: SourceLocation }>(async () => { const actionLocation = stack?.[selectedFrame]; const shouldUseFallback = !actionLocation?.file; if (shouldUseFallback && !fallbackLocation) @@ -84,6 +67,27 @@ export const SourceTab: React.FunctionComponent<{ } return { source, highlight, targetLine, fileName, location }; }, [stack, selectedFrame, rootDir, fallbackLocation], { source: { errors: [], content: 'Loading\u2026' }, highlight: [] }); +} + +export const SourceTab: React.FunctionComponent<{ + stack?: StackFrame[], + stackFrameLocation: 'bottom' | 'right', + sources: Map, + rootDir?: string, + fallbackLocation?: SourceLocation, + onOpenExternally?: (location: SourceLocation) => void, +}> = ({ stack, sources, rootDir, fallbackLocation, stackFrameLocation, onOpenExternally }) => { + const [lastStack, setLastStack] = React.useState(); + const [selectedFrame, setSelectedFrame] = React.useState(0); + + React.useEffect(() => { + if (lastStack !== stack) { + setLastStack(stack); + setSelectedFrame(0); + } + }, [stack, lastStack, setLastStack, setSelectedFrame]); + + const { source, highlight, targetLine, fileName, location } = useSources(stack, selectedFrame, sources, rootDir, fallbackLocation); const openExternally = React.useCallback(() => { if (!location) diff --git a/packages/trace-viewer/src/ui/workbench.tsx b/packages/trace-viewer/src/ui/workbench.tsx index 34f27d65cb..3e361ad14c 100644 --- a/packages/trace-viewer/src/ui/workbench.tsx +++ b/packages/trace-viewer/src/ui/workbench.tsx @@ -193,7 +193,7 @@ export const Workbench: React.FunctionComponent<{ id: 'errors', title: 'Errors', errorCount: errorsModel.errors.size, - render: () => { + render: () => { if (error.action) setSelectedAction(error.action); else diff --git a/tests/playwright-test/ui-mode-trace.spec.ts b/tests/playwright-test/ui-mode-trace.spec.ts index 61278ce362..e25161f475 100644 --- a/tests/playwright-test/ui-mode-trace.spec.ts +++ b/tests/playwright-test/ui-mode-trace.spec.ts @@ -503,11 +503,11 @@ test('skipped steps should have an indicator', async ({ runUITest }) => { test('should show copy prompt button in errors tab', async ({ runUITest }) => { const { page } = await runUITest({ 'a.spec.ts': ` - import { test, expect } from '@playwright/test'; - test('fails', async () => { - expect(1).toBe(2); - }); - `, +import { test, expect } from '@playwright/test'; +test('fails', async () => { + expect(1).toBe(2); +}); + `.trim(), }); await page.getByText('fails').dblclick(); @@ -517,4 +517,11 @@ test('should show copy prompt button in errors tab', async ({ runUITest }) => { await page.locator('.tab-errors').getByRole('button', { name: 'Copy as Prompt' }).click(); const prompt = await page.evaluate(() => navigator.clipboard.readText()); expect(prompt, 'contains error').toContain('expect(received).toBe(expected)'); + expect(prompt, 'contains codeframe').toContain(` + 1 | import { test, expect } from '@playwright/test'; + 2 | test('fails', async () => { +> 3 | expect(1).toBe(2); + ^ + 4 | }); + `.trim()); }); From ad64f8d859bc154fc41c1beb8e8dd719497eb12a Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Thu, 27 Feb 2025 08:01:07 -0800 Subject: [PATCH 23/26] feat(chromium): roll to r1161 (#34952) Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- README.md | 4 +- packages/playwright-core/browsers.json | 8 +- .../src/server/deviceDescriptorsSource.json | 96 +++++++++---------- 3 files changed, 54 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 5f85005dac..e784fceda6 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-134.0.6998.23-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-135.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-18.2-blue.svg?logo=safari)](https://webkit.org/) [![Join Discord](https://img.shields.io/badge/join-discord-infomational)](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-134.0.6998.35-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-135.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-18.2-blue.svg?logo=safari)](https://webkit.org/) [![Join Discord](https://img.shields.io/badge/join-discord-infomational)](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 134.0.6998.23 | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Chromium 134.0.6998.35 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | WebKit 18.2 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Firefox 135.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 f992c357eb..b858a735d5 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -3,15 +3,15 @@ "browsers": [ { "name": "chromium", - "revision": "1160", + "revision": "1161", "installByDefault": true, - "browserVersion": "134.0.6998.23" + "browserVersion": "134.0.6998.35" }, { "name": "chromium-headless-shell", - "revision": "1160", + "revision": "1161", "installByDefault": true, - "browserVersion": "134.0.6998.23" + "browserVersion": "134.0.6998.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 867ee2bc49..3c510bd73f 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/134.0.6998.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.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/134.0.6998.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.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/134.0.6998.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.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/134.0.6998.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.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/134.0.6998.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/134.0.6998.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/134.0.6998.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/134.0.6998.35 Mobile Safari/537.36", "viewport": { "width": 658, "height": 320 @@ -176,7 +176,7 @@ "defaultBrowserType": "chromium" }, "Galaxy Tab S4": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Safari/537.36", "viewport": { "width": 712, "height": 1138 @@ -187,7 +187,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/134.0.6998.23 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Safari/537.36", "viewport": { "width": 1138, "height": 712 @@ -1098,7 +1098,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/134.0.6998.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/134.0.6998.35 Mobile Safari/537.36", "viewport": { "width": 384, "height": 640 @@ -1109,7 +1109,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/134.0.6998.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/134.0.6998.35 Mobile Safari/537.36", "viewport": { "width": 640, "height": 384 @@ -1120,7 +1120,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/134.0.6998.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/134.0.6998.35 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 360, "height": 640 @@ -1131,7 +1131,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/134.0.6998.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/134.0.6998.35 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 640, "height": 360 @@ -1142,7 +1142,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/134.0.6998.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/134.0.6998.35 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 360, "height": 640 @@ -1153,7 +1153,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/134.0.6998.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/134.0.6998.35 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 640, "height": 360 @@ -1164,7 +1164,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/134.0.6998.23 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Safari/537.36", "viewport": { "width": 800, "height": 1280 @@ -1175,7 +1175,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/134.0.6998.23 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Safari/537.36", "viewport": { "width": 1280, "height": 800 @@ -1186,7 +1186,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/134.0.6998.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/134.0.6998.35 Mobile Safari/537.36", "viewport": { "width": 384, "height": 640 @@ -1197,7 +1197,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/134.0.6998.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/134.0.6998.35 Mobile Safari/537.36", "viewport": { "width": 640, "height": 384 @@ -1208,7 +1208,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Mobile Safari/537.36", "viewport": { "width": 360, "height": 640 @@ -1219,7 +1219,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/134.0.6998.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Mobile Safari/537.36", "viewport": { "width": 640, "height": 360 @@ -1230,7 +1230,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/134.0.6998.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/134.0.6998.35 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1241,7 +1241,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/134.0.6998.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/134.0.6998.35 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1252,7 +1252,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/134.0.6998.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/134.0.6998.35 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1263,7 +1263,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/134.0.6998.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/134.0.6998.35 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1274,7 +1274,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/134.0.6998.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/134.0.6998.35 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1285,7 +1285,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/134.0.6998.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/134.0.6998.35 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1296,7 +1296,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/134.0.6998.23 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Safari/537.36", "viewport": { "width": 600, "height": 960 @@ -1307,7 +1307,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/134.0.6998.23 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Safari/537.36", "viewport": { "width": 960, "height": 600 @@ -1362,7 +1362,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/134.0.6998.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/134.0.6998.35 Mobile Safari/537.36", "viewport": { "width": 411, "height": 731 @@ -1373,7 +1373,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/134.0.6998.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/134.0.6998.35 Mobile Safari/537.36", "viewport": { "width": 731, "height": 411 @@ -1384,7 +1384,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/134.0.6998.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/134.0.6998.35 Mobile Safari/537.36", "viewport": { "width": 411, "height": 823 @@ -1395,7 +1395,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/134.0.6998.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/134.0.6998.35 Mobile Safari/537.36", "viewport": { "width": 823, "height": 411 @@ -1406,7 +1406,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/134.0.6998.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/134.0.6998.35 Mobile Safari/537.36", "viewport": { "width": 393, "height": 786 @@ -1417,7 +1417,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/134.0.6998.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/134.0.6998.35 Mobile Safari/537.36", "viewport": { "width": 786, "height": 393 @@ -1428,7 +1428,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Mobile Safari/537.36", "viewport": { "width": 353, "height": 745 @@ -1439,7 +1439,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Mobile Safari/537.36", "viewport": { "width": 745, "height": 353 @@ -1450,7 +1450,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4a (5G)": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Mobile Safari/537.36", "screen": { "width": 412, "height": 892 @@ -1465,7 +1465,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4a (5G) landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Mobile Safari/537.36", "screen": { "height": 892, "width": 412 @@ -1480,7 +1480,7 @@ "defaultBrowserType": "chromium" }, "Pixel 5": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Mobile Safari/537.36", "screen": { "width": 393, "height": 851 @@ -1495,7 +1495,7 @@ "defaultBrowserType": "chromium" }, "Pixel 5 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Mobile Safari/537.36", "screen": { "width": 851, "height": 393 @@ -1510,7 +1510,7 @@ "defaultBrowserType": "chromium" }, "Pixel 7": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Mobile Safari/537.36", "screen": { "width": 412, "height": 915 @@ -1525,7 +1525,7 @@ "defaultBrowserType": "chromium" }, "Pixel 7 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Mobile Safari/537.36", "screen": { "width": 915, "height": 412 @@ -1540,7 +1540,7 @@ "defaultBrowserType": "chromium" }, "Moto G4": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Mobile Safari/537.36", "viewport": { "width": 360, "height": 640 @@ -1551,7 +1551,7 @@ "defaultBrowserType": "chromium" }, "Moto G4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Mobile Safari/537.36", "viewport": { "width": 640, "height": 360 @@ -1562,7 +1562,7 @@ "defaultBrowserType": "chromium" }, "Desktop Chrome HiDPI": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Safari/537.36", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Safari/537.36", "screen": { "width": 1792, "height": 1120 @@ -1577,7 +1577,7 @@ "defaultBrowserType": "chromium" }, "Desktop Edge HiDPI": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Safari/537.36 Edg/134.0.6998.23", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Safari/537.36 Edg/134.0.6998.35", "screen": { "width": 1792, "height": 1120 @@ -1622,7 +1622,7 @@ "defaultBrowserType": "webkit" }, "Desktop Chrome": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Safari/537.36", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Safari/537.36", "screen": { "width": 1920, "height": 1080 @@ -1637,7 +1637,7 @@ "defaultBrowserType": "chromium" }, "Desktop Edge": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.23 Safari/537.36 Edg/134.0.6998.23", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.35 Safari/537.36 Edg/134.0.6998.35", "screen": { "width": 1920, "height": 1080 From 70cc2b14e22ba668c7e2365e051eddd03d893efe Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Thu, 27 Feb 2025 16:31:05 +0000 Subject: [PATCH 24/26] chore: apply "injected" eslint rules to "isomorphic" (#34953) --- eslint.config.mjs | 6 +++++- .../src/server/isomorphic/utilityScriptSerializers.ts | 3 +++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index b5486d8760..41799d05f9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -253,7 +253,11 @@ export default [{ 'no-console': 'off' } }, { - files: ['packages/playwright-core/src/server/injected/**/*.ts'], + files: [ + 'packages/playwright-core/src/server/injected/**/*.ts', + 'packages/playwright-core/src/server/isomorphic/**/*.ts', + 'packages/playwright-core/src/utils/isomorphic/**/*.ts', + ], languageOptions: languageOptionsWithTsConfig, rules: { ...noWebGlobalsRules, diff --git a/packages/playwright-core/src/server/isomorphic/utilityScriptSerializers.ts b/packages/playwright-core/src/server/isomorphic/utilityScriptSerializers.ts index 6df7988caf..08a167240f 100644 --- a/packages/playwright-core/src/server/isomorphic/utilityScriptSerializers.ts +++ b/packages/playwright-core/src/server/isomorphic/utilityScriptSerializers.ts @@ -129,10 +129,13 @@ export function source() { function serialize(value: any, handleSerializer: (value: any) => HandleOrValue, visitorInfo: VisitorInfo): SerializedValue { if (value && typeof value === 'object') { + // eslint-disable-next-line no-restricted-globals if (typeof globalThis.Window === 'function' && value instanceof globalThis.Window) return 'ref: '; + // eslint-disable-next-line no-restricted-globals if (typeof globalThis.Document === 'function' && value instanceof globalThis.Document) return 'ref: '; + // eslint-disable-next-line no-restricted-globals if (typeof globalThis.Node === 'function' && value instanceof globalThis.Node) return 'ref: '; } From 08ea36caa2b79ca74e3aade3798d7863840ed785 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Thu, 27 Feb 2025 16:31:32 +0000 Subject: [PATCH 25/26] chore: default reportSlowTests to 5 minutes (#34950) --- docs/src/test-api/class-fullconfig.md | 4 ++-- docs/src/test-api/class-testconfig.md | 2 +- packages/playwright/src/common/config.ts | 2 +- packages/playwright/src/isomorphic/teleReceiver.ts | 2 +- packages/playwright/types/test.d.ts | 6 +++--- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/src/test-api/class-fullconfig.md b/docs/src/test-api/class-fullconfig.md index 923c9fa858..edbc991bfd 100644 --- a/docs/src/test-api/class-fullconfig.md +++ b/docs/src/test-api/class-fullconfig.md @@ -93,8 +93,8 @@ See [`property: TestConfig.reporter`]. ## property: FullConfig.reportSlowTests * since: v1.10 - type: <[null]|[Object]> - - `max` <[int]> The maximum number of slow test files to report. Defaults to `5`. - - `threshold` <[float]> Test duration in milliseconds that is considered slow. Defaults to 15 seconds. + - `max` <[int]> The maximum number of slow test files to report. + - `threshold` <[float]> Test file duration in milliseconds that is considered slow. See [`property: TestConfig.reportSlowTests`]. diff --git a/docs/src/test-api/class-testconfig.md b/docs/src/test-api/class-testconfig.md index 213928e2e7..f837ddbfe8 100644 --- a/docs/src/test-api/class-testconfig.md +++ b/docs/src/test-api/class-testconfig.md @@ -429,7 +429,7 @@ export default defineConfig({ * since: v1.10 - type: ?<[null]|[Object]> - `max` <[int]> The maximum number of slow test files to report. Defaults to `5`. - - `threshold` <[float]> Test duration in milliseconds that is considered slow. Defaults to 15 seconds. + - `threshold` <[float]> Test file duration in milliseconds that is considered slow. Defaults to 5 minutes. Whether to report slow test files. Pass `null` to disable this feature. diff --git a/packages/playwright/src/common/config.ts b/packages/playwright/src/common/config.ts index 4ebc900872..8d213226ff 100644 --- a/packages/playwright/src/common/config.ts +++ b/packages/playwright/src/common/config.ts @@ -99,7 +99,7 @@ export class FullConfigInternal { metadata: userConfig.metadata, preserveOutput: takeFirst(userConfig.preserveOutput, 'always'), reporter: takeFirst(configCLIOverrides.reporter, resolveReporters(userConfig.reporter, configDir), [[defaultReporter]]), - reportSlowTests: takeFirst(userConfig.reportSlowTests, { max: 5, threshold: 15000 }), + reportSlowTests: takeFirst(userConfig.reportSlowTests, { max: 5, threshold: 300_000 /* 5 minutes */ }), quiet: takeFirst(configCLIOverrides.quiet, userConfig.quiet, false), projects: [], shard: takeFirst(configCLIOverrides.shard, userConfig.shard, null), diff --git a/packages/playwright/src/isomorphic/teleReceiver.ts b/packages/playwright/src/isomorphic/teleReceiver.ts index b9aa5edba9..29b1beb74d 100644 --- a/packages/playwright/src/isomorphic/teleReceiver.ts +++ b/packages/playwright/src/isomorphic/teleReceiver.ts @@ -605,7 +605,7 @@ export const baseFullConfig: reporterTypes.FullConfig = { preserveOutput: 'always', projects: [], reporter: [[process.env.CI ? 'dot' : 'list']], - reportSlowTests: { max: 5, threshold: 15000 }, + reportSlowTests: { max: 5, threshold: 300_000 /* 5 minutes */ }, configFile: '', rootDir: '', quiet: false, diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index 1e9fef6b6c..4cc816993a 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -1443,7 +1443,7 @@ interface TestConfig { max: number; /** - * Test duration in milliseconds that is considered slow. Defaults to 15 seconds. + * Test file duration in milliseconds that is considered slow. Defaults to 5 minutes. */ threshold: number; }; @@ -1895,12 +1895,12 @@ export interface FullConfig { */ reportSlowTests: null|{ /** - * The maximum number of slow test files to report. Defaults to `5`. + * The maximum number of slow test files to report. */ max: number; /** - * Test duration in milliseconds that is considered slow. Defaults to 15 seconds. + * Test file duration in milliseconds that is considered slow. */ threshold: number; }; From b0ceed51a54f9c11dd8ed553396b4350bdd6548e Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Thu, 27 Feb 2025 11:00:50 -0800 Subject: [PATCH 26/26] docs: Improve toHaveURL doc clarity (#34935) --- docs/src/api/class-pageassertions.md | 15 +++++++++++++-- packages/playwright/types/test.d.ts | 22 +++++++++++++++++----- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/docs/src/api/class-pageassertions.md b/docs/src/api/class-pageassertions.md index 38a05b9df8..ef8c7d4f6e 100644 --- a/docs/src/api/class-pageassertions.md +++ b/docs/src/api/class-pageassertions.md @@ -296,7 +296,18 @@ Ensures the page is navigated to the given URL. **Usage** ```js -await expect(page).toHaveURL(/.*checkout/); +// Check for the page URL to be 'https://playwright.dev/docs/intro' (including query string) +await expect(page).toHaveURL('https://playwright.dev/docs/intro'); + +// Check for the page URL to contain 'doc', followed by an optional 's', followed by '/' +await expect(page).toHaveURL(/docs?\//); + +// Check for the predicate to be satisfied +// For example: verify query strings +await expect(page).toHaveURL(url => { + const params = url.searchParams; + return params.has('search') && params.has('options') && params.get('id') === '5'; +}); ``` ```java @@ -328,7 +339,7 @@ await Expect(Page).ToHaveURLAsync(new Regex(".*checkout")); - `url` <[string]|[RegExp]|[function]\([URL]\):[boolean]> Expected URL string, RegExp, or predicate receiving [URL] to match. -When a [`option: Browser.newContext.baseURL`] via the context options was provided and the passed URL is a path, it gets merged via the [`new URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor. +When [`option: Browser.newContext.baseURL`] is provided via the context options and the `url` argument is a string, the two values are merged via the [`new URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor and used for the comparison against the current browser URL. ### option: PageAssertions.toHaveURL.ignoreCase * since: v1.44 diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index 4cc816993a..bdb45169b6 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -8902,13 +8902,25 @@ interface PageAssertions { * **Usage** * * ```js - * await expect(page).toHaveURL(/.*checkout/); + * // Check for the page URL to be 'https://playwright.dev/docs/intro' (including query string) + * await expect(page).toHaveURL('https://playwright.dev/docs/intro'); + * + * // Check for the page URL to contain 'doc', followed by an optional 's', followed by '/' + * await expect(page).toHaveURL(/docs?\//); + * + * // Check for the predicate to be satisfied + * // For example: verify query strings + * await expect(page).toHaveURL(url => { + * const params = url.searchParams; + * return params.has('search') && params.has('options') && params.get('id') === '5'; + * }); * ``` * - * @param url Expected URL string, RegExp, or predicate receiving [URL] to match. When a - * [`baseURL`](https://playwright.dev/docs/api/class-browser#browser-new-context-option-base-url) via the context - * options was provided and the passed URL is a path, it gets merged via the - * [`new URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor. + * @param url Expected URL string, RegExp, or predicate receiving [URL] to match. When + * [`baseURL`](https://playwright.dev/docs/api/class-browser#browser-new-context-option-base-url) is provided via the + * context options and the `url` argument is a string, the two values are merged via the + * [`new URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor and used for the comparison + * against the current browser URL. * @param options */ toHaveURL(url: string|RegExp|((url: URL) => boolean), options?: {