diff --git a/.github/workflows/tests_bidi.yml b/.github/workflows/tests_bidi.yml index 8224d24883..34af9e7096 100644 --- a/.github/workflows/tests_bidi.yml +++ b/.github/workflows/tests_bidi.yml @@ -26,7 +26,7 @@ jobs: strategy: fail-fast: false matrix: - channel: [bidi-chromium, bidi-firefox-beta] + channel: [bidi-chromium, bidi-firefox-nightly] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -38,8 +38,8 @@ jobs: - run: npm run build - run: npx playwright install --with-deps chromium if: matrix.channel == 'bidi-chromium' - - run: npx -y @puppeteer/browsers install firefox@beta - if: matrix.channel == 'bidi-firefox-beta' + - run: npx -y @puppeteer/browsers install firefox@nightly + if: matrix.channel == 'bidi-firefox-nightly' - name: Run tests run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run biditest -- --project=${{ matrix.channel }}* env: diff --git a/README.md b/README.md index d0d8e53dd0..4796c77121 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-129.0.6668.42-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-130.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-18.0-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-130.0.6723.6-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-130.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-18.0-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 129.0.6668.42 | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Chromium 130.0.6723.6 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | WebKit 18.0 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Firefox 130.0 | :white_check_mark: | :white_check_mark: | :white_check_mark: | diff --git a/docs/src/api/class-browsercontext.md b/docs/src/api/class-browsercontext.md index 43396f4957..90ee0337d3 100644 --- a/docs/src/api/class-browsercontext.md +++ b/docs/src/api/class-browsercontext.md @@ -1266,6 +1266,99 @@ When set to `minimal`, only record information necessary for routing from HAR. T Optional setting to control resource content management. If `attach` is specified, resources are persisted as separate files or entries in the ZIP archive. If `embed` is specified, content is stored inline the HAR file. + +## async method: BrowserContext.routeWebSocket +* since: v1.48 + +This method allows to modify websocket connections that are made by any page in the browser context. + +Note that only `WebSocket`s created after this method was called will be routed. It is recommended to call this method before creating any pages. + +**Usage** + +Below is an example of a simple handler that blocks some websocket messages. +See [WebSocketRoute] for more details and examples. + +```js +await context.routeWebSocket('/ws', async ws => { + ws.routeSend(message => { + if (message === 'to-be-blocked') + return; + ws.send(message); + }); + await ws.connect(); +}); +``` + +```java +context.routeWebSocket("/ws", ws -> { + ws.routeSend(message -> { + if ("to-be-blocked".equals(message)) + return; + ws.send(message); + }); + ws.connect(); +}); +``` + +```python async +def message_handler(ws: WebSocketRoute, message: Union[str, bytes]): + if message == "to-be-blocked": + return + ws.send(message) + +async def handler(ws: WebSocketRoute): + ws.route_send(lambda message: message_handler(ws, message)) + await ws.connect() + +await context.route_web_socket("/ws", handler) +``` + +```python sync +def message_handler(ws: WebSocketRoute, message: Union[str, bytes]): + if message == "to-be-blocked": + return + ws.send(message) + +def handler(ws: WebSocketRoute): + ws.route_send(lambda message: message_handler(ws, message)) + ws.connect() + +context.route_web_socket("/ws", handler) +``` + +```csharp +await context.RouteWebSocketAsync("/ws", async ws => { + ws.RouteSend(message => { + if (message == "to-be-blocked") + return; + ws.Send(message); + }); + await ws.ConnectAsync(); +}); +``` + +### param: BrowserContext.routeWebSocket.url +* since: v1.48 +- `url` <[string]|[RegExp]|[function]\([URL]\):[boolean]> + +Only WebSockets with the url matching this pattern will be routed. A string pattern can be relative to the [`option: baseURL`] from the context options. + +### param: BrowserContext.routeWebSocket.handler +* since: v1.48 +* langs: js, python +- `handler` <[function]\([WebSocketRoute]\): [Promise|any]> + +Handler function to route the WebSocket. + +### param: BrowserContext.routeWebSocket.handler +* since: v1.48 +* langs: csharp, java +- `handler` <[function]\([WebSocketRoute]\)> + +Handler function to route the WebSocket. + + ## method: BrowserContext.serviceWorkers * since: v1.11 * langs: js, python diff --git a/docs/src/api/class-framelocator.md b/docs/src/api/class-framelocator.md index 9851a35f96..7b6fb8f1bc 100644 --- a/docs/src/api/class-framelocator.md +++ b/docs/src/api/class-framelocator.md @@ -1,30 +1,30 @@ # class: FrameLocator * since: v1.17 -FrameLocator represents a view to the `iframe` on the page. It captures the logic sufficient to retrieve the `iframe` and locate elements in that iframe. FrameLocator can be created with either [`method: Page.frameLocator`] or [`method: Locator.frameLocator`] method. +FrameLocator represents a view to the `iframe` on the page. It captures the logic sufficient to retrieve the `iframe` and locate elements in that iframe. FrameLocator can be created with either [`method: Locator.contentFrame`], [`method: Page.frameLocator`] or [`method: Locator.frameLocator`] method. ```js -const locator = page.frameLocator('#my-frame').getByText('Submit'); +const locator = page.locator('#my-frame').contentFrame().getByText('Submit'); await locator.click(); ``` ```java -Locator locator = page.frameLocator("#my-frame").getByText("Submit"); +Locator locator = page.locator("#my-frame").contentFrame().getByText("Submit"); locator.click(); ``` ```python async -locator = page.frame_locator("#my-frame").get_by_text("Submit") +locator = page.locator("#my-frame").content_frame.get_by_text("Submit") await locator.click() ``` ```python sync -locator = page.frame_locator("my-frame").get_by_text("Submit") +locator = page.locator("my-frame").content_frame.get_by_text("Submit") locator.click() ``` ```csharp -var locator = page.FrameLocator("#my-frame").GetByText("Submit"); +var locator = page.Locator("#my-frame").ContentFrame.GetByText("Submit"); await locator.ClickAsync(); ``` @@ -34,42 +34,42 @@ Frame locators are strict. This means that all operations on frame locators will ```js // Throws if there are several frames in DOM: -await page.frameLocator('.result-frame').getByRole('button').click(); +await page.locator('.result-frame').contentFrame().getByRole('button').click(); // Works because we explicitly tell locator to pick the first frame: -await page.frameLocator('.result-frame').first().getByRole('button').click(); +await page.locator('.result-frame').contentFrame().first().getByRole('button').click(); ``` ```python async # Throws if there are several frames in DOM: -await page.frame_locator('.result-frame').get_by_role('button').click() +await page.locator('.result-frame').content_frame.get_by_role('button').click() # Works because we explicitly tell locator to pick the first frame: -await page.frame_locator('.result-frame').first.get_by_role('button').click() +await page.locator('.result-frame').first.content_frame.get_by_role('button').click() ``` ```python sync # Throws if there are several frames in DOM: -page.frame_locator('.result-frame').get_by_role('button').click() +page.locator('.result-frame').content_frame.get_by_role('button').click() # Works because we explicitly tell locator to pick the first frame: -page.frame_locator('.result-frame').first.get_by_role('button').click() +page.locator('.result-frame').first.content_frame.get_by_role('button').click() ``` ```java // Throws if there are several frames in DOM: -page.frame_locator(".result-frame").getByRole(AriaRole.BUTTON).click(); +page.locator(".result-frame").contentFrame().getByRole(AriaRole.BUTTON).click(); // Works because we explicitly tell locator to pick the first frame: -page.frame_locator(".result-frame").first().getByRole(AriaRole.BUTTON).click(); +page.locator(".result-frame").first().contentFrame().getByRole(AriaRole.BUTTON).click(); ``` ```csharp // Throws if there are several frames in DOM: -await page.FrameLocator(".result-frame").GetByRole(AriaRole.Button).ClickAsync(); +await page.Locator(".result-frame").ContentFrame.GetByRole(AriaRole.Button).ClickAsync(); // Works because we explicitly tell locator to pick the first frame: -await page.FrameLocator(".result-frame").First.getByRole(AriaRole.Button).ClickAsync(); +await page.Locator(".result-frame").First.ContentFrame.getByRole(AriaRole.Button).ClickAsync(); ``` **Converting Locator to FrameLocator** @@ -82,6 +82,7 @@ If you have a [FrameLocator] object it can be converted to [Locator] pointing to ## method: FrameLocator.first +* deprecated: Use [`method: Locator.first`] followed by [`method: Locator.contentFrame`] instead. * since: v1.17 - returns: <[FrameLocator]> @@ -171,6 +172,7 @@ in that iframe. ### option: FrameLocator.getByTitle.exact = %%-locator-get-by-text-exact-%% ## method: FrameLocator.last +* deprecated: Use [`method: Locator.last`] followed by [`method: Locator.contentFrame`] instead. * since: v1.17 - returns: <[FrameLocator]> @@ -195,6 +197,7 @@ Returns locator to the last matching frame. * since: v1.33 ## method: FrameLocator.nth +* deprecated: Use [`method: Locator.nth`] followed by [`method: Locator.contentFrame`] instead. * since: v1.17 - returns: <[FrameLocator]> @@ -217,37 +220,36 @@ For a reverse operation, use [`method: Locator.contentFrame`]. **Usage** ```js -const frameLocator = page.frameLocator('iframe[name="embedded"]'); +const frameLocator = page.locator('iframe[name="embedded"]').contentFrame(); // ... const locator = frameLocator.owner(); await expect(locator).toBeVisible(); ``` ```java -FrameLocator frameLocator = page.frameLocator("iframe[name=\"embedded\"]"); +FrameLocator frameLocator = page.locator("iframe[name=\"embedded\"]").contentFrame(); // ... Locator locator = frameLocator.owner(); assertThat(locator).isVisible(); ``` ```python async -frame_locator = page.frame_locator("iframe[name=\"embedded\"]") +frame_locator = page.locator("iframe[name=\"embedded\"]").content_frame # ... locator = frame_locator.owner await expect(locator).to_be_visible() ``` ```python sync -frame_locator = page.frame_locator("iframe[name=\"embedded\"]") +frame_locator = page.locator("iframe[name=\"embedded\"]").content_frame # ... locator = frame_locator.owner expect(locator).to_be_visible() ``` ```csharp -var frameLocator = Page.FrameLocator("iframe[name=\"embedded\"]"); +var frameLocator = Page.Locator("iframe[name=\"embedded\"]").ContentFrame; // ... var locator = frameLocator.Owner; await Expect(locator).ToBeVisibleAsync(); ``` - diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index d36a96ee72..c0890d04ff 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -3632,6 +3632,99 @@ When set to `minimal`, only record information necessary for routing from HAR. T Optional setting to control resource content management. If `attach` is specified, resources are persisted as separate files or entries in the ZIP archive. If `embed` is specified, content is stored inline the HAR file. + +## async method: Page.routeWebSocket +* since: v1.48 + +This method allows to modify websocket connections that are made by the page. + +Note that only `WebSocket`s created after this method was called will be routed. It is recommended to call this method before navigating the page. + +**Usage** + +Below is an example of a simple handler that blocks some websocket messages. +See [WebSocketRoute] for more details and examples. + +```js +await page.routeWebSocket('/ws', async ws => { + ws.routeSend(message => { + if (message === 'to-be-blocked') + return; + ws.send(message); + }); + await ws.connect(); +}); +``` + +```java +page.routeWebSocket("/ws", ws -> { + ws.routeSend(message -> { + if ("to-be-blocked".equals(message)) + return; + ws.send(message); + }); + ws.connect(); +}); +``` + +```python async +def message_handler(ws: WebSocketRoute, message: Union[str, bytes]): + if message == "to-be-blocked": + return + ws.send(message) + +async def handler(ws: WebSocketRoute): + ws.route_send(lambda message: message_handler(ws, message)) + await ws.connect() + +await page.route_web_socket("/ws", handler) +``` + +```python sync +def message_handler(ws: WebSocketRoute, message: Union[str, bytes]): + if message == "to-be-blocked": + return + ws.send(message) + +def handler(ws: WebSocketRoute): + ws.route_send(lambda message: message_handler(ws, message)) + ws.connect() + +page.route_web_socket("/ws", handler) +``` + +```csharp +await page.RouteWebSocketAsync("/ws", async ws => { + ws.RouteSend(message => { + if (message == "to-be-blocked") + return; + ws.Send(message); + }); + await ws.ConnectAsync(); +}); +``` + +### param: Page.routeWebSocket.url +* since: v1.48 +- `url` <[string]|[RegExp]|[function]\([URL]\):[boolean]> + +Only WebSockets with the url matching this pattern will be routed. A string pattern can be relative to the [`option: baseURL`] from the context options. + +### param: Page.routeWebSocket.handler +* since: v1.48 +* langs: js, python +- `handler` <[function]\([WebSocketRoute]\): [Promise|any]> + +Handler function to route the WebSocket. + +### param: Page.routeWebSocket.handler +* since: v1.48 +* langs: csharp, java +- `handler` <[function]\([WebSocketRoute]\)> + +Handler function to route the WebSocket. + + ## async method: Page.screenshot * since: v1.8 - returns: <[Buffer]> diff --git a/docs/src/api/class-websocketroute.md b/docs/src/api/class-websocketroute.md new file mode 100644 index 0000000000..4c7e07eb7d --- /dev/null +++ b/docs/src/api/class-websocketroute.md @@ -0,0 +1,166 @@ +# class: WebSocketRoute +* since: v1.48 + +Whenever a [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) route is set up with [`method: Page.routeWebSocket`] or [`method: BrowserContext.routeWebSocket`], the `WebSocketRoute` object allows to handle the WebSocket. + +By default, the routed WebSocket will not actually connect to the server. This way, you can mock entire communcation over the WebSocket. Here is an example that responds to a `"query"` with a `"result"`. + +```js +await page.routeWebSocket('/ws', async ws => { + ws.routeSend(message => { + if (message === 'query') + ws.receive('result'); + }); +}); +``` + +```java +page.routeWebSocket("/ws", ws -> { + ws.routeSend(message -> { + if ("query".equals(message)) + ws.receive("result"); + }); +}); +``` + +```python async +def message_handler(ws, message): + if message == "query": + ws.receive("result") + +await page.route_web_socket("/ws", lambda ws: ws.route_send( + lambda message: message_handler(ws, message) +)) +``` + +```python sync +def message_handler(ws, message): + if message == "query": + ws.receive("result") + +page.route_web_socket("/ws", lambda ws: ws.route_send( + lambda message: message_handler(ws, message) +)) +``` + +```csharp +await page.RouteWebSocketAsync("/ws", async ws => { + ws.RouteSend(message => { + if (message == "query") + ws.receive("result"); + }); +}); +``` + + +## event: WebSocketRoute.close +* since: v1.48 + +Emitted when the [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) closes. + + + +## async method: WebSocketRoute.close +* since: v1.48 + +Closes the server connection and the [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) object in the page. + +### option: WebSocketRoute.close.code +* since: v1.48 +- `code` <[int]> + +Optional [close code](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close#code). + +### option: WebSocketRoute.close.reason +* since: v1.48 +- `reason` <[string]> + +Optional [close reason](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close#reason). + + +## async method: WebSocketRoute.connect +* since: v1.48 + +By default, routed WebSocket does not connect to the server, so you can mock entire WebSocket communication. This method connects to the actual WebSocket server, giving the ability to send and receive messages from the server. + +Once connected: +* Messages received from the server will be automatically dispatched to the [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) object in the page, unless [`method: WebSocketRoute.routeReceive`] is called. +* Messages sent by the `WebSocket.send()` call in the page will be automatically sent to the server, unless [`method: WebSocketRoute.routeSend`] is called. + + +## method: WebSocketRoute.receive +* since: v1.48 + +Dispatches a message to the [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) object in the page, like it was received from the server. + +### param: WebSocketRoute.receive.message +* since: v1.48 +- `message` <[string]|[Buffer]> + +Message to receive. + + +## async method: WebSocketRoute.routeReceive +* since: v1.48 + +This method allows to route messages that are received by the [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) object in the page from the server. This method only makes sense if you are also calling [`method: WebSocketRoute.connect`]. + +Once this method is called, received messages are not automatically dispatched to the [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) object in the page - you should do that manually by calling [`method: WebSocketRoute.receive`]. + +Calling this method again times will override the handler with a new one. + +### param: WebSocketRoute.routeReceive.handler +* since: v1.48 +* langs: js, python +- `handler` <[function]\([string]\): [Promise|any]> + +Handler function to route received messages. + +### param: WebSocketRoute.routeReceive.handler +* since: v1.48 +* langs: csharp, java +- `handler` <[function]\([WebSocketFrame]\)> + +Handler function to route received messages. + + + +## async method: WebSocketRoute.routeSend +* since: v1.48 + +This method allows to route messages that are sent by `WebSocket.send()` call in the page, instead of actually sending them to the server. Once this method is called, sent messages **are not** automatically forwarded to the server - you should do that manually by calling [`method: WebSocketRoute.send`]. + +Calling this method again times will override the handler with a new one. + +### param: WebSocketRoute.routeSend.handler +* since: v1.48 +* langs: js, python +- `handler` <[function]\([string]|[Buffer]\): [Promise|any]> + +Handler function to route sent messages. + +### param: WebSocketRoute.routeSend.handler +* since: v1.48 +* langs: csharp, java +- `handler` <[function]\([WebSocketFrame]\)> + +Handler function to route sent messages. + + +## method: WebSocketRoute.send +* since: v1.48 + +Sends a message to the server, like it was sent in the page with `WebSocket.send()`. + +### param: WebSocketRoute.send.message +* since: v1.48 +- `message` <[string]|[Buffer]> + +Message to send. + + +## method: WebSocketRoute.url +* since: v1.48 +- returns: <[string]> + +URL of the WebSocket created in the page. diff --git a/docs/src/test-reporter-api/class-location.md b/docs/src/test-api/class-location.md similarity index 100% rename from docs/src/test-reporter-api/class-location.md rename to docs/src/test-api/class-location.md diff --git a/package-lock.json b/package-lock.json index b7cdad125e..d113e1c42e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,7 +40,7 @@ "@zip.js/zip.js": "^2.7.29", "ansi-styles": "^4.3.0", "chokidar": "^3.5.3", - "chromium-bidi": "^0.6.4", + "chromium-bidi": "^0.7.1", "colors": "^1.4.0", "concurrently": "^6.2.1", "cross-env": "^7.0.3", @@ -61,7 +61,7 @@ "react-dom": "^18.1.0", "ssim.js": "^3.5.0", "typescript": "^5.5.3", - "vite": "^5.0.13", + "vite": "^5.4.6", "ws": "^8.17.1", "xml2js": "^0.5.0", "yaml": "^2.2.2" @@ -852,9 +852,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", - "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", "cpu": [ "ppc64" ], @@ -1517,9 +1517,9 @@ "link": true }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.14.0.tgz", - "integrity": "sha512-jwXtxYbRt1V+CdQSy6Z+uZti7JF5irRKF8hlKfEnF/xJpcNGuuiZMBvuoYM+x9sr9iWGnzrlM0+9hvQ1kgkf1w==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.21.3.tgz", + "integrity": "sha512-MmKSfaB9GX+zXl6E8z4koOr/xU63AMVleLEa64v7R0QF/ZloMs5vcD1sHgM64GXXS1csaJutG+ddtzcueI/BLg==", "cpu": [ "arm" ], @@ -1529,9 +1529,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.14.0.tgz", - "integrity": "sha512-fI9nduZhCccjzlsA/OuAwtFGWocxA4gqXGTLvOyiF8d+8o0fZUeSztixkYjcGq1fGZY3Tkq4yRvHPFxU+jdZ9Q==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.21.3.tgz", + "integrity": "sha512-zrt8ecH07PE3sB4jPOggweBjJMzI1JG5xI2DIsUbkA+7K+Gkjys6eV7i9pOenNSDJH3eOr/jLb/PzqtmdwDq5g==", "cpu": [ "arm64" ], @@ -1541,9 +1541,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.14.0.tgz", - "integrity": "sha512-BcnSPRM76/cD2gQC+rQNGBN6GStBs2pl/FpweW8JYuz5J/IEa0Fr4AtrPv766DB/6b2MZ/AfSIOSGw3nEIP8SA==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.21.3.tgz", + "integrity": "sha512-P0UxIOrKNBFTQaXTxOH4RxuEBVCgEA5UTNV6Yz7z9QHnUJ7eLX9reOd/NYMO3+XZO2cco19mXTxDMXxit4R/eQ==", "cpu": [ "arm64" ], @@ -1553,9 +1553,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.14.0.tgz", - "integrity": "sha512-LDyFB9GRolGN7XI6955aFeI3wCdCUszFWumWU0deHA8VpR3nWRrjG6GtGjBrQxQKFevnUTHKCfPR4IvrW3kCgQ==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.21.3.tgz", + "integrity": "sha512-L1M0vKGO5ASKntqtsFEjTq/fD91vAqnzeaF6sfNAy55aD+Hi2pBI5DKwCO+UNDQHWsDViJLqshxOahXyLSh3EA==", "cpu": [ "x64" ], @@ -1565,9 +1565,21 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.14.0.tgz", - "integrity": "sha512-ygrGVhQP47mRh0AAD0zl6QqCbNsf0eTo+vgwkY6LunBcg0f2Jv365GXlDUECIyoXp1kKwL5WW6rsO429DBY/bA==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.21.3.tgz", + "integrity": "sha512-btVgIsCjuYFKUjopPoWiDqmoUXQDiW2A4C3Mtmp5vACm7/GnyuprqIDPNczeyR5W8rTXEbkmrJux7cJmD99D2g==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.21.3.tgz", + "integrity": "sha512-zmjbSphplZlau6ZTkxd3+NMtE4UKVy7U4aVFMmHcgO5CUbw17ZP6QCgyxhzGaU/wFFdTfiojjbLG3/0p9HhAqA==", "cpu": [ "arm" ], @@ -1577,9 +1589,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.14.0.tgz", - "integrity": "sha512-x+uJ6MAYRlHGe9wi4HQjxpaKHPM3d3JjqqCkeC5gpnnI6OWovLdXTpfa8trjxPLnWKyBsSi5kne+146GAxFt4A==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.21.3.tgz", + "integrity": "sha512-nSZfcZtAnQPRZmUkUQwZq2OjQciR6tEoJaZVFvLHsj0MF6QhNMg0fQ6mUOsiCUpTqxTx0/O6gX0V/nYc7LrgPw==", "cpu": [ "arm64" ], @@ -1589,9 +1601,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.14.0.tgz", - "integrity": "sha512-nrRw8ZTQKg6+Lttwqo6a2VxR9tOroa2m91XbdQ2sUUzHoedXlsyvY1fN4xWdqz8PKmf4orDwejxXHjh7YBGUCA==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.21.3.tgz", + "integrity": "sha512-MnvSPGO8KJXIMGlQDYfvYS3IosFN2rKsvxRpPO2l2cum+Z3exiExLwVU+GExL96pn8IP+GdH8Tz70EpBhO0sIQ==", "cpu": [ "arm64" ], @@ -1601,11 +1613,11 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.14.0.tgz", - "integrity": "sha512-xV0d5jDb4aFu84XKr+lcUJ9y3qpIWhttO3Qev97z8DKLXR62LC3cXT/bMZXrjLF9X+P5oSmJTzAhqwUbY96PnA==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.21.3.tgz", + "integrity": "sha512-+W+p/9QNDr2vE2AXU0qIy0qQE75E8RTwTwgqS2G5CRQ11vzq0tbnfBd6brWhS9bCRjAjepJe2fvvkvS3dno+iw==", "cpu": [ - "ppc64le" + "ppc64" ], "optional": true, "os": [ @@ -1613,9 +1625,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.14.0.tgz", - "integrity": "sha512-SDDhBQwZX6LPRoPYjAZWyL27LbcBo7WdBFWJi5PI9RPCzU8ijzkQn7tt8NXiXRiFMJCVpkuMkBf4OxSxVMizAw==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.21.3.tgz", + "integrity": "sha512-yXH6K6KfqGXaxHrtr+Uoy+JpNlUlI46BKVyonGiaD74ravdnF9BUNC+vV+SIuB96hUMGShhKV693rF9QDfO6nQ==", "cpu": [ "riscv64" ], @@ -1625,9 +1637,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.14.0.tgz", - "integrity": "sha512-RxB/qez8zIDshNJDufYlTT0ZTVut5eCpAZ3bdXDU9yTxBzui3KhbGjROK2OYTTor7alM7XBhssgoO3CZ0XD3qA==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.21.3.tgz", + "integrity": "sha512-R8cwY9wcnApN/KDYWTH4gV/ypvy9yZUHlbJvfaiXSB48JO3KpwSpjOGqO4jnGkLDSk1hgjYkTbTt6Q7uvPf8eg==", "cpu": [ "s390x" ], @@ -1637,9 +1649,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.14.0.tgz", - "integrity": "sha512-C6y6z2eCNCfhZxT9u+jAM2Fup89ZjiG5pIzZIDycs1IwESviLxwkQcFRGLjnDrP+PT+v5i4YFvlcfAs+LnreXg==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.21.3.tgz", + "integrity": "sha512-kZPbX/NOPh0vhS5sI+dR8L1bU2cSO9FgxwM8r7wHzGydzfSjLRCFAT87GR5U9scj2rhzN3JPYVC7NoBbl4FZ0g==", "cpu": [ "x64" ], @@ -1649,9 +1661,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.14.0.tgz", - "integrity": "sha512-i0QwbHYfnOMYsBEyjxcwGu5SMIi9sImDVjDg087hpzXqhBSosxkE7gyIYFHgfFl4mr7RrXksIBZ4DoLoP4FhJg==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.21.3.tgz", + "integrity": "sha512-S0Yq+xA1VEH66uiMNhijsWAafffydd2X5b77eLHfRmfLsRSpbiAWiRHV6DEpz6aOToPsgid7TI9rGd6zB1rhbg==", "cpu": [ "x64" ], @@ -1661,9 +1673,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.14.0.tgz", - "integrity": "sha512-Fq52EYb0riNHLBTAcL0cun+rRwyZ10S9vKzhGKKgeD+XbwunszSY0rVMco5KbOsTlwovP2rTOkiII/fQ4ih/zQ==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.21.3.tgz", + "integrity": "sha512-9isNzeL34yquCPyerog+IMCNxKR8XYmGd0tHSV+OVx0TmE0aJOo9uw4fZfUuk2qxobP5sug6vNdZR6u7Mw7Q+Q==", "cpu": [ "arm64" ], @@ -1673,9 +1685,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.14.0.tgz", - "integrity": "sha512-e/PBHxPdJ00O9p5Ui43+vixSgVf4NlLsmV6QneGERJ3lnjIua/kim6PRFe3iDueT1rQcgSkYP8ZBBXa/h4iPvw==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.21.3.tgz", + "integrity": "sha512-nMIdKnfZfzn1Vsk+RuOvl43ONTZXoAPUUxgcU0tXooqg4YrAqzfKzVenqqk2g5efWh46/D28cKFrOzDSW28gTA==", "cpu": [ "ia32" ], @@ -1685,9 +1697,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.14.0.tgz", - "integrity": "sha512-aGg7iToJjdklmxlUlJh/PaPNa4PmqHfyRMLunbL3eaMO0gp656+q1zOKkpJ/CVe9CryJv6tAN1HDoR8cNGzkag==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.21.3.tgz", + "integrity": "sha512-fOvu7PCQjAj4eWDEuD8Xz5gpzFqXzGlxHZozHP4b9Jxv9APtdxL6STqztDzMLuRXEc4UpXGGhx029Xgm91QBeA==", "cpu": [ "x64" ], @@ -2876,9 +2888,9 @@ } }, "node_modules/chromium-bidi": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.6.5.tgz", - "integrity": "sha512-RuLrmzYrxSb0s9SgpB+QN5jJucPduZQ/9SIe76MDxYJuecPW5mxMdacJ1f4EtgiV+R0p3sCkznTMvH0MPGFqjA==", + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.7.1.tgz", + "integrity": "sha512-am9lR+HidiBtPtEYV7aFBpFJaQZhwJbYKr37cOHw0GGR+uiG0O79f20JNLjR0qEwPMuxOHvdBu4HHfimClBOCg==", "dev": true, "dependencies": { "mitt": "3.0.1", @@ -5862,9 +5874,9 @@ } }, "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==" }, "node_modules/picomatch": { "version": "2.3.1", @@ -5917,9 +5929,9 @@ } }, "node_modules/postcss": { - "version": "8.4.38", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", - "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", "funding": [ { "type": "opencollective", @@ -5936,8 +5948,8 @@ ], "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.2.0" + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -6301,9 +6313,9 @@ } }, "node_modules/rollup": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.14.0.tgz", - "integrity": "sha512-Qe7w62TyawbDzB4yt32R0+AbIo6m1/sqO7UPzFS8Z/ksL5mrfhA0v4CavfdmFav3D+ub4QeAgsGEe84DoWe/nQ==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.21.3.tgz", + "integrity": "sha512-7sqRtBNnEbcBtMeRVc6VRsJMmpI+JU1z9VTvW8D4gXIYQFz0aLcsE6rRkyghZkLfEgUZgVvOG7A5CVz/VW5GIA==", "dependencies": { "@types/estree": "1.0.5" }, @@ -6315,21 +6327,22 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.14.0", - "@rollup/rollup-android-arm64": "4.14.0", - "@rollup/rollup-darwin-arm64": "4.14.0", - "@rollup/rollup-darwin-x64": "4.14.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.14.0", - "@rollup/rollup-linux-arm64-gnu": "4.14.0", - "@rollup/rollup-linux-arm64-musl": "4.14.0", - "@rollup/rollup-linux-powerpc64le-gnu": "4.14.0", - "@rollup/rollup-linux-riscv64-gnu": "4.14.0", - "@rollup/rollup-linux-s390x-gnu": "4.14.0", - "@rollup/rollup-linux-x64-gnu": "4.14.0", - "@rollup/rollup-linux-x64-musl": "4.14.0", - "@rollup/rollup-win32-arm64-msvc": "4.14.0", - "@rollup/rollup-win32-ia32-msvc": "4.14.0", - "@rollup/rollup-win32-x64-msvc": "4.14.0", + "@rollup/rollup-android-arm-eabi": "4.21.3", + "@rollup/rollup-android-arm64": "4.21.3", + "@rollup/rollup-darwin-arm64": "4.21.3", + "@rollup/rollup-darwin-x64": "4.21.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.21.3", + "@rollup/rollup-linux-arm-musleabihf": "4.21.3", + "@rollup/rollup-linux-arm64-gnu": "4.21.3", + "@rollup/rollup-linux-arm64-musl": "4.21.3", + "@rollup/rollup-linux-powerpc64le-gnu": "4.21.3", + "@rollup/rollup-linux-riscv64-gnu": "4.21.3", + "@rollup/rollup-linux-s390x-gnu": "4.21.3", + "@rollup/rollup-linux-x64-gnu": "4.21.3", + "@rollup/rollup-linux-x64-musl": "4.21.3", + "@rollup/rollup-win32-arm64-msvc": "4.21.3", + "@rollup/rollup-win32-ia32-msvc": "4.21.3", + "@rollup/rollup-win32-x64-msvc": "4.21.3", "fsevents": "~2.3.2" } }, @@ -6589,9 +6602,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "engines": { "node": ">=0.10.0" } @@ -7171,13 +7184,13 @@ } }, "node_modules/vite": { - "version": "5.2.8", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.8.tgz", - "integrity": "sha512-OyZR+c1CE8yeHw5V5t59aXsUPPVTHMDjEZz8MgguLL/Q7NblxhZUlTu9xSPqlsUO/y+X7dlU05jdhvyycD55DA==", + "version": "5.4.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.6.tgz", + "integrity": "sha512-IeL5f8OO5nylsgzd9tq4qD2QqI0k2CQLGrWD0rCN0EQJZpBK5vJAx0I+GDkMOXxQX/OfFHMuLIx6ddAxGX/k+Q==", "dependencies": { - "esbuild": "^0.20.1", - "postcss": "^8.4.38", - "rollup": "^4.13.0" + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" }, "bin": { "vite": "bin/vite.js" @@ -7196,6 +7209,7 @@ "less": "*", "lightningcss": "^1.21.0", "sass": "*", + "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" @@ -7213,6 +7227,9 @@ "sass": { "optional": true }, + "sass-embedded": { + "optional": true + }, "stylus": { "optional": true }, @@ -7243,9 +7260,9 @@ } }, "node_modules/vite/node_modules/@esbuild/android-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", - "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", "cpu": [ "arm" ], @@ -7258,9 +7275,9 @@ } }, "node_modules/vite/node_modules/@esbuild/android-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", - "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", "cpu": [ "arm64" ], @@ -7273,9 +7290,9 @@ } }, "node_modules/vite/node_modules/@esbuild/android-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", - "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", "cpu": [ "x64" ], @@ -7288,9 +7305,9 @@ } }, "node_modules/vite/node_modules/@esbuild/darwin-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", - "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", "cpu": [ "arm64" ], @@ -7303,9 +7320,9 @@ } }, "node_modules/vite/node_modules/@esbuild/darwin-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", - "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", "cpu": [ "x64" ], @@ -7318,9 +7335,9 @@ } }, "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", - "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", "cpu": [ "arm64" ], @@ -7333,9 +7350,9 @@ } }, "node_modules/vite/node_modules/@esbuild/freebsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", - "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", "cpu": [ "x64" ], @@ -7348,9 +7365,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", - "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", "cpu": [ "arm" ], @@ -7363,9 +7380,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", - "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", "cpu": [ "arm64" ], @@ -7378,9 +7395,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", - "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", "cpu": [ "ia32" ], @@ -7393,9 +7410,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-loong64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", - "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", "cpu": [ "loong64" ], @@ -7408,9 +7425,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-mips64el": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", - "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", "cpu": [ "mips64el" ], @@ -7423,9 +7440,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", - "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", "cpu": [ "ppc64" ], @@ -7438,9 +7455,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-riscv64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", - "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", "cpu": [ "riscv64" ], @@ -7453,9 +7470,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-s390x": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", - "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", "cpu": [ "s390x" ], @@ -7468,9 +7485,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", - "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", "cpu": [ "x64" ], @@ -7483,9 +7500,9 @@ } }, "node_modules/vite/node_modules/@esbuild/netbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", - "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", "cpu": [ "x64" ], @@ -7498,9 +7515,9 @@ } }, "node_modules/vite/node_modules/@esbuild/openbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", - "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", "cpu": [ "x64" ], @@ -7513,9 +7530,9 @@ } }, "node_modules/vite/node_modules/@esbuild/sunos-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", - "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", "cpu": [ "x64" ], @@ -7528,9 +7545,9 @@ } }, "node_modules/vite/node_modules/@esbuild/win32-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", - "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", "cpu": [ "arm64" ], @@ -7543,9 +7560,9 @@ } }, "node_modules/vite/node_modules/@esbuild/win32-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", - "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", "cpu": [ "ia32" ], @@ -7558,9 +7575,9 @@ } }, "node_modules/vite/node_modules/@esbuild/win32-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", - "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", "cpu": [ "x64" ], @@ -7573,9 +7590,9 @@ } }, "node_modules/vite/node_modules/esbuild": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", - "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" @@ -7584,29 +7601,29 @@ "node": ">=12" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.20.2", - "@esbuild/android-arm": "0.20.2", - "@esbuild/android-arm64": "0.20.2", - "@esbuild/android-x64": "0.20.2", - "@esbuild/darwin-arm64": "0.20.2", - "@esbuild/darwin-x64": "0.20.2", - "@esbuild/freebsd-arm64": "0.20.2", - "@esbuild/freebsd-x64": "0.20.2", - "@esbuild/linux-arm": "0.20.2", - "@esbuild/linux-arm64": "0.20.2", - "@esbuild/linux-ia32": "0.20.2", - "@esbuild/linux-loong64": "0.20.2", - "@esbuild/linux-mips64el": "0.20.2", - "@esbuild/linux-ppc64": "0.20.2", - "@esbuild/linux-riscv64": "0.20.2", - "@esbuild/linux-s390x": "0.20.2", - "@esbuild/linux-x64": "0.20.2", - "@esbuild/netbsd-x64": "0.20.2", - "@esbuild/openbsd-x64": "0.20.2", - "@esbuild/sunos-x64": "0.20.2", - "@esbuild/win32-arm64": "0.20.2", - "@esbuild/win32-ia32": "0.20.2", - "@esbuild/win32-x64": "0.20.2" + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" } }, "node_modules/vitefu": { diff --git a/package.json b/package.json index c095546c7d..4d982f6e98 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "@zip.js/zip.js": "^2.7.29", "ansi-styles": "^4.3.0", "chokidar": "^3.5.3", - "chromium-bidi": "^0.6.4", + "chromium-bidi": "^0.7.1", "colors": "^1.4.0", "concurrently": "^6.2.1", "cross-env": "^7.0.3", @@ -100,7 +100,7 @@ "react-dom": "^18.1.0", "ssim.js": "^3.5.0", "typescript": "^5.5.3", - "vite": "^5.0.13", + "vite": "^5.4.6", "ws": "^8.17.1", "xml2js": "^0.5.0", "yaml": "^2.2.2" diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index dc91235659..5fd2927700 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -3,15 +3,15 @@ "browsers": [ { "name": "chromium", - "revision": "1135", + "revision": "1136", "installByDefault": true, - "browserVersion": "129.0.6668.42" + "browserVersion": "130.0.6723.6" }, { "name": "chromium-tip-of-tree", - "revision": "1259", + "revision": "1261", "installByDefault": false, - "browserVersion": "130.0.6713.0" + "browserVersion": "131.0.6726.0" }, { "name": "firefox", @@ -27,7 +27,7 @@ }, { "name": "webkit", - "revision": "2077", + "revision": "2080", "installByDefault": true, "revisionOverrides": { "mac10.14": "1446", diff --git a/packages/playwright-core/src/cli/program.ts b/packages/playwright-core/src/cli/program.ts index d8fa8230c6..1895f2dfcf 100644 --- a/packages/playwright-core/src/cli/program.ts +++ b/packages/playwright-core/src/cli/program.ts @@ -397,7 +397,7 @@ async function launchContext(options: Options, extraOptions: LaunchOptions): Pro process.stdout.write('\n-------------8<-------------\n'); const autoExitCondition = process.env.PWTEST_CLI_AUTO_EXIT_WHEN; if (autoExitCondition && text.includes(autoExitCondition)) - Promise.all(context.pages().map(async p => p.close())); + closeBrowser(); }; // Make sure we exit abnormally when browser crashes. const logs: string[] = []; @@ -504,7 +504,7 @@ async function launchContext(options: Options, extraOptions: LaunchOptions): Pro if (hasPage) return; // Avoid the error when the last page is closed because the browser has been closed. - closeBrowser().catch(e => null); + closeBrowser().catch(() => {}); }); }); process.on('SIGINT', async () => { @@ -560,17 +560,13 @@ async function open(options: Options, url: string | undefined, language: string) async function codegen(options: Options & { target: string, output?: string, testIdAttribute?: string }, url: string | undefined) { const { target: language, output: outputFile, testIdAttribute: testIdAttributeName } = options; - const tracesDir = path.join(os.tmpdir(), `recorder-trace-${Date.now()}`); + const tracesDir = path.join(os.tmpdir(), `playwright-recorder-trace-${Date.now()}`); const { context, launchOptions, contextOptions } = await launchContext(options, { headless: !!process.env.PWTEST_CLI_HEADLESS, executablePath: process.env.PWTEST_CLI_EXECUTABLE_PATH, tracesDir, }); dotenv.config({ path: 'playwright.env' }); - if (process.env.PW_RECORDER_IS_TRACE_VIEWER) { - await fs.promises.mkdir(tracesDir, { recursive: true }); - await context.tracing.start({ name: 'trace', _live: true }); - } await context._enableRecorder({ language, launchOptions, @@ -578,6 +574,7 @@ async function codegen(options: Options & { target: string, output?: string, tes device: options.device, saveStorage: options.saveStorage, mode: 'recording', + codegenMode: process.env.PW_RECORDER_IS_TRACE_VIEWER ? 'trace-events' : 'actions', testIdAttributeName, outputFile: outputFile ? path.resolve(outputFile) : undefined, }); diff --git a/packages/playwright-core/src/client/api.ts b/packages/playwright-core/src/client/api.ts index 6eab70e159..0d3d2448fe 100644 --- a/packages/playwright-core/src/client/api.ts +++ b/packages/playwright-core/src/client/api.ts @@ -34,7 +34,7 @@ export { TimeoutError } from './errors'; export { Frame } from './frame'; export { Keyboard, Mouse, Touchscreen } from './input'; export { JSHandle } from './jsHandle'; -export { Request, Response, Route, WebSocket } from './network'; +export { Request, Response, Route, WebSocket, WebSocketRoute } from './network'; export { APIRequest, APIRequestContext, APIResponse } from './fetch'; export { Page } from './page'; export { Selectors } from './selectors'; diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index ef222136dd..72ef29d6a6 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -48,6 +48,7 @@ import { Clock } from './clock'; export class BrowserContext extends ChannelOwner implements api.BrowserContext { _pages = new Set(); _routes: network.RouteHandler[] = []; + _webSocketRoutes: network.WebSocketRouteHandler[] = []; readonly _browser: Browser | null = null; _browserType: BrowserType | undefined; readonly _bindings = new Map any>(); @@ -90,6 +91,7 @@ export class BrowserContext extends ChannelOwner this._channel.on('close', () => this._onClose()); this._channel.on('page', ({ page }) => this._onPage(Page.from(page))); this._channel.on('route', ({ route }) => this._onRoute(network.Route.from(route))); + this._channel.on('webSocketRoute', ({ webSocketRoute }) => this._onWebSocketRoute(network.WebSocketRoute.from(webSocketRoute))); this._channel.on('backgroundPage', ({ page }) => { const backgroundPage = Page.from(page); this._backgroundPages.add(backgroundPage); @@ -218,7 +220,15 @@ export class BrowserContext extends ChannelOwner } // If the page is closed or unrouteAll() was called without waiting and interception disabled, // the method will throw an error - silence it. - await route._innerContinue(true).catch(() => {}); + await route._innerContinue(true /* isFallback */).catch(() => {}); + } + + async _onWebSocketRoute(webSocketRoute: network.WebSocketRoute) { + const routeHandler = this._webSocketRoutes.find(route => route.matches(webSocketRoute.url())); + if (routeHandler) + await routeHandler.handle(webSocketRoute); + else + await webSocketRoute.connect(); } async _onBinding(bindingCall: BindingCall) { @@ -328,6 +338,11 @@ export class BrowserContext extends ChannelOwner await this._updateInterceptionPatterns(); } + async routeWebSocket(url: URLMatch, handler: network.WebSocketRouteHandlerCallback): Promise { + this._webSocketRoutes.unshift(new network.WebSocketRouteHandler(this._options.baseURL, url, handler)); + await this._updateWebSocketInterceptionPatterns(); + } + async _recordIntoHAR(har: string, page: Page | null, options: { url?: string | RegExp, notFound?: 'abort' | 'fallback', update?: boolean, updateContent?: 'attach' | 'embed', updateMode?: 'minimal' | 'full'} = {}): Promise { const { harId } = await this._channel.harStart({ page: page?._channel, @@ -387,6 +402,11 @@ export class BrowserContext extends ChannelOwner await this._channel.setNetworkInterceptionPatterns({ patterns }); } + private async _updateWebSocketInterceptionPatterns() { + const patterns = network.WebSocketRouteHandler.prepareInterceptionPatterns(this._webSocketRoutes); + await this._channel.setWebSocketInterceptionPatterns({ patterns }); + } + _effectiveCloseReason(): string | undefined { return this._closeReason || this._browser?._closeReason; } @@ -472,17 +492,8 @@ export class BrowserContext extends ChannelOwner await this._closedPromise; } - async _enableRecorder(params: { - language: string, - launchOptions?: LaunchOptions, - contextOptions?: BrowserContextOptions, - device?: string, - saveStorage?: string, - mode?: 'recording' | 'inspecting', - testIdAttributeName?: string, - outputFile?: string, - }) { - await this._channel.recorderSupplementEnable(params); + async _enableRecorder(params: channels.BrowserContextEnableRecorderParams) { + await this._channel.enableRecorder(params); } } diff --git a/packages/playwright-core/src/client/channelOwner.ts b/packages/playwright-core/src/client/channelOwner.ts index abe1cbf254..89f3edced3 100644 --- a/packages/playwright-core/src/client/channelOwner.ts +++ b/packages/playwright-core/src/client/channelOwner.ts @@ -40,6 +40,7 @@ export abstract class ChannelOwner = new Map(); + private _isInternalType = false; _wasCollected: boolean = false; constructor(parent: ChannelOwner | Connection, type: string, guid: string, initializer: channels.InitializerTraits) { @@ -61,6 +62,10 @@ export abstract class ChannelOwner) { this._eventToSubscriptionMapping = mapping; } @@ -173,7 +178,7 @@ export abstract class ChannelOwner { constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.LocalUtilsInitializer) { super(parent, type, guid, initializer); + this.markAsInternalType(); this.devices = {}; for (const { name, descriptor } of initializer.deviceDescriptors) this.devices[name] = descriptor; diff --git a/packages/playwright-core/src/client/network.ts b/packages/playwright-core/src/client/network.ts index 164af0f7e2..85bec1bf8d 100644 --- a/packages/playwright-core/src/client/network.ts +++ b/packages/playwright-core/src/client/network.ts @@ -299,6 +299,7 @@ export class Route extends ChannelOwner implements api.Ro constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.RouteInitializer) { super(parent, type, guid, initializer); + this.markAsInternalType(); } request(): Request { @@ -325,7 +326,7 @@ export class Route extends ChannelOwner implements api.Ro async abort(errorCode?: string) { await this._handleRoute(async () => { - await this._raceWithTargetClose(this._channel.abort({ requestUrl: this.request()._initializer.url, errorCode })); + await this._raceWithTargetClose(this._channel.abort({ errorCode })); }); } @@ -409,7 +410,6 @@ export class Route extends ChannelOwner implements api.Ro headers['content-length'] = String(length); await this._raceWithTargetClose(this._channel.fulfill({ - requestUrl: this.request()._initializer.url, status: statusOption || 200, headers: headersObjectToArray(headers), body, @@ -421,7 +421,7 @@ export class Route extends ChannelOwner implements api.Ro async continue(options: FallbackOverrides = {}) { await this._handleRoute(async () => { this.request()._applyFallbackOverrides(options); - await this._innerContinue(); + await this._innerContinue(false /* isFallback */); }); } @@ -436,22 +436,143 @@ export class Route extends ChannelOwner implements api.Ro chain.resolve(done); } - async _innerContinue(internal = false) { + async _innerContinue(isFallback: boolean) { const options = this.request()._fallbackOverridesForContinue(); - return await this._wrapApiCall(async () => { - await this._raceWithTargetClose(this._channel.continue({ - requestUrl: this.request()._initializer.url, - url: options.url, - method: options.method, - headers: options.headers ? headersObjectToArray(options.headers) : undefined, - postData: options.postDataBuffer, - isFallback: internal, - })); - }, !!internal); + return await this._raceWithTargetClose(this._channel.continue({ + url: options.url, + method: options.method, + headers: options.headers ? headersObjectToArray(options.headers) : undefined, + postData: options.postDataBuffer, + isFallback, + })); + } +} + +export class WebSocketRoute extends ChannelOwner implements api.WebSocketRoute { + static from(route: channels.WebSocketRouteChannel): WebSocketRoute { + return (route as any)._object; + } + + private _routeSendHandler?: (message: string | Buffer) => any; + private _routeReceiveHandler?: (message: string | Buffer) => any; + private _connected = false; + + constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.WebSocketRouteInitializer) { + super(parent, type, guid, initializer); + + this._channel.on('messageFromPage', ({ message, isBase64 }) => { + if (this._routeSendHandler) + this._routeSendHandler(isBase64 ? Buffer.from(message, 'base64') : message); + else + this._channel.sendToServer({ message, isBase64 }).catch(() => {}); + }); + + this._channel.on('messageFromServer', ({ message, isBase64 }) => { + if (this._routeReceiveHandler) + this._routeReceiveHandler(isBase64 ? Buffer.from(message, 'base64') : message); + else + this._channel.sendToPage({ message, isBase64 }).catch(() => {}); + }); + + this._channel.on('close', () => this.emit(Events.WebSocketRoute.Close)); + } + + url() { + return this._initializer.url; + } + + async close(options: { code?: number, reason?: string } = {}) { + try { + await this._channel.close(options); + } catch (e) { + if (isTargetClosedError(e)) + return; + throw e; + } + } + + async connect() { + this._connected = true; + await this._channel.connect(); + } + + send(message: string | Buffer) { + if (isString(message)) + this._channel.sendToServer({ message, isBase64: false }).catch(() => {}); + else + this._channel.sendToServer({ message: message.toString('base64'), isBase64: true }).catch(() => {}); + } + + receive(message: string | Buffer) { + if (isString(message)) + this._channel.sendToPage({ message, isBase64: false }).catch(() => {}); + else + this._channel.sendToPage({ message: message.toString('base64'), isBase64: true }).catch(() => {}); + } + + routeSend(handler: (message: string | Buffer) => any) { + this._routeSendHandler = handler; + } + + routeReceive(handler: (message: string | Buffer) => any) { + this._routeReceiveHandler = handler; + } + + async [Symbol.asyncDispose]() { + await this.close(); + } + + async _afterHandle() { + if (this._connected) + return; + if (this._routeReceiveHandler) + throw new Error(`WebSocketRoute.routeReceive() call had no effect. Make sure to call WebSocketRoute.connect() as well.`); + // Ensure that websocket is "open", so that test can send messages to it + // without an actual server connection. + await this._channel.ensureOpened(); + } +} + +export class WebSocketRouteHandler { + private readonly _baseURL: string | undefined; + readonly url: URLMatch; + readonly handler: WebSocketRouteHandlerCallback; + + constructor(baseURL: string | undefined, url: URLMatch, handler: WebSocketRouteHandlerCallback) { + this._baseURL = baseURL; + this.url = url; + this.handler = handler; + } + + static prepareInterceptionPatterns(handlers: WebSocketRouteHandler[]) { + const patterns: channels.BrowserContextSetWebSocketInterceptionPatternsParams['patterns'] = []; + let all = false; + for (const handler of handlers) { + if (isString(handler.url)) + patterns.push({ glob: handler.url }); + else if (isRegExp(handler.url)) + patterns.push({ regexSource: handler.url.source, regexFlags: handler.url.flags }); + else + all = true; + } + if (all) + return [{ glob: '**/*' }]; + return patterns; + } + + public matches(wsURL: string): boolean { + return urlMatches(this._baseURL, wsURL, this.url); + } + + public async handle(webSocketRoute: WebSocketRoute) { + const handler = this.handler; + await handler(webSocketRoute); + await webSocketRoute._afterHandle(); } } export type RouteHandlerCallback = (route: Route, request: Request) => Promise | void; +export type WebSocketRouteHandlerCallback = (ws: WebSocketRoute) => Promise | void; export type ResourceTiming = { startTime: number; diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index 0bbe78f4c8..66842cad0b 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -40,7 +40,7 @@ import { Keyboard, Mouse, Touchscreen } from './input'; import { assertMaxArguments, JSHandle, parseResult, serializeArgument } from './jsHandle'; import type { FrameLocator, Locator, LocatorOptions } from './locator'; import type { ByRoleOptions } from '../utils/isomorphic/locatorUtils'; -import { type RouteHandlerCallback, type Request, Response, Route, RouteHandler, validateHeaders, WebSocket } from './network'; +import { type RouteHandlerCallback, type Request, Response, Route, RouteHandler, validateHeaders, WebSocket, type WebSocketRouteHandlerCallback, WebSocketRoute, WebSocketRouteHandler } from './network'; import type { FilePayload, Headers, LifecycleEvent, SelectOption, SelectOptionOptions, Size, WaitForEventOptions, WaitForFunctionOptions } from './types'; import { Video } from './video'; import { Waiter } from './waiter'; @@ -78,6 +78,7 @@ export class Page extends ChannelOwner implements api.Page readonly _closedOrCrashedScope = new LongStandingScope(); private _viewportSize: Size | null; _routes: RouteHandler[] = []; + _webSocketRoutes: WebSocketRouteHandler[] = []; readonly accessibility: Accessibility; readonly coverage: Coverage; @@ -137,6 +138,7 @@ export class Page extends ChannelOwner implements api.Page this._channel.on('frameDetached', ({ frame }) => this._onFrameDetached(Frame.from(frame))); this._channel.on('locatorHandlerTriggered', ({ uid }) => this._onLocatorHandlerTriggered(uid)); this._channel.on('route', ({ route }) => this._onRoute(Route.from(route))); + this._channel.on('webSocketRoute', ({ webSocketRoute }) => this._onWebSocketRoute(WebSocketRoute.from(webSocketRoute))); this._channel.on('video', ({ artifact }) => { const artifactObject = Artifact.from(artifact); this._forceVideo()._artifactReady(artifactObject); @@ -200,6 +202,14 @@ export class Page extends ChannelOwner implements api.Page await this._browserContext._onRoute(route); } + private async _onWebSocketRoute(webSocketRoute: WebSocketRoute) { + const routeHandler = this._webSocketRoutes.find(route => route.matches(webSocketRoute.url())); + if (routeHandler) + await routeHandler.handle(webSocketRoute); + else + await this._browserContext._onWebSocketRoute(webSocketRoute); + } + async _onBinding(bindingCall: BindingCall) { const func = this._bindings.get(bindingCall._initializer.name); if (func) { @@ -515,6 +525,11 @@ export class Page extends ChannelOwner implements api.Page await harRouter.addPageRoute(this); } + async routeWebSocket(url: URLMatch, handler: WebSocketRouteHandlerCallback): Promise { + this._webSocketRoutes.unshift(new WebSocketRouteHandler(this._browserContext._options.baseURL, url, handler)); + await this._updateWebSocketInterceptionPatterns(); + } + private _disposeHarRouters() { this._harRouters.forEach(router => router.dispose()); this._harRouters = []; @@ -551,6 +566,11 @@ export class Page extends ChannelOwner implements api.Page await this._channel.setNetworkInterceptionPatterns({ patterns }); } + private async _updateWebSocketInterceptionPatterns() { + const patterns = WebSocketRouteHandler.prepareInterceptionPatterns(this._webSocketRoutes); + await this._channel.setWebSocketInterceptionPatterns({ patterns }); + } + async screenshot(options: Omit & { path?: string, mask?: Locator[] } = {}): Promise { const copy: channels.PageScreenshotOptions = { ...options, mask: undefined }; if (!copy.type) diff --git a/packages/playwright-core/src/client/tracing.ts b/packages/playwright-core/src/client/tracing.ts index 7330cd9f26..b5c411cc65 100644 --- a/packages/playwright-core/src/client/tracing.ts +++ b/packages/playwright-core/src/client/tracing.ts @@ -31,20 +31,18 @@ export class Tracing extends ChannelOwner implements ap constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.TracingInitializer) { super(parent, type, guid, initializer); + this.markAsInternalType(); } async start(options: { name?: string, title?: string, snapshots?: boolean, screenshots?: boolean, sources?: boolean, _live?: boolean } = {}) { this._includeSources = !!options.sources; - const traceName = await this._wrapApiCall(async () => { - await this._channel.tracingStart({ - name: options.name, - snapshots: options.snapshots, - screenshots: options.screenshots, - live: options._live, - }); - const response = await this._channel.tracingStartChunk({ name: options.name, title: options.title }); - return response.traceName; - }, true); + await this._channel.tracingStart({ + name: options.name, + snapshots: options.snapshots, + screenshots: options.screenshots, + live: options._live, + }); + const { traceName } = await this._channel.tracingStartChunk({ name: options.name, title: options.title }); await this._startCollectingStacks(traceName); } @@ -63,16 +61,12 @@ export class Tracing extends ChannelOwner implements ap } async stopChunk(options: { path?: string } = {}) { - await this._wrapApiCall(async () => { - await this._doStopChunk(options.path); - }, true); + await this._doStopChunk(options.path); } async stop(options: { path?: string } = {}) { - await this._wrapApiCall(async () => { - await this._doStopChunk(options.path); - await this._channel.tracingStop(); - }, true); + await this._doStopChunk(options.path); + await this._channel.tracingStop(); } private async _doStopChunk(filePath: string | undefined) { diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index abea7f8fce..9b36ad8883 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -832,6 +832,9 @@ scheme.BrowserContextPageErrorEvent = tObject({ scheme.BrowserContextRouteEvent = tObject({ route: tChannel(['Route']), }); +scheme.BrowserContextWebSocketRouteEvent = tObject({ + webSocketRoute: tChannel(['WebSocketRoute']), +}); scheme.BrowserContextVideoEvent = tObject({ artifact: tChannel(['Artifact']), }); @@ -943,6 +946,14 @@ scheme.BrowserContextSetNetworkInterceptionPatternsParams = tObject({ })), }); scheme.BrowserContextSetNetworkInterceptionPatternsResult = tOptional(tObject({})); +scheme.BrowserContextSetWebSocketInterceptionPatternsParams = tObject({ + patterns: tArray(tObject({ + glob: tOptional(tString), + regexSource: tOptional(tString), + regexFlags: tOptional(tString), + })), +}); +scheme.BrowserContextSetWebSocketInterceptionPatternsResult = tOptional(tObject({})); scheme.BrowserContextSetOfflineParams = tObject({ offline: tBoolean, }); @@ -954,9 +965,10 @@ scheme.BrowserContextStorageStateResult = tObject({ }); scheme.BrowserContextPauseParams = tOptional(tObject({})); scheme.BrowserContextPauseResult = tOptional(tObject({})); -scheme.BrowserContextRecorderSupplementEnableParams = tObject({ +scheme.BrowserContextEnableRecorderParams = tObject({ language: tOptional(tString), mode: tOptional(tEnum(['inspecting', 'recording'])), + codegenMode: tOptional(tEnum(['actions', 'trace-events'])), pauseOnNextStatement: tOptional(tBoolean), testIdAttributeName: tOptional(tString), launchOptions: tOptional(tAny), @@ -966,7 +978,7 @@ scheme.BrowserContextRecorderSupplementEnableParams = tObject({ outputFile: tOptional(tString), omitCallTracking: tOptional(tBoolean), }); -scheme.BrowserContextRecorderSupplementEnableResult = tOptional(tObject({})); +scheme.BrowserContextEnableRecorderResult = tOptional(tObject({})); scheme.BrowserContextNewCDPSessionParams = tObject({ page: tOptional(tChannel(['Page'])), frame: tOptional(tChannel(['Frame'])), @@ -1070,6 +1082,9 @@ scheme.PageLocatorHandlerTriggeredEvent = tObject({ scheme.PageRouteEvent = tObject({ route: tChannel(['Route']), }); +scheme.PageWebSocketRouteEvent = tObject({ + webSocketRoute: tChannel(['WebSocketRoute']), +}); scheme.PageVideoEvent = tObject({ artifact: tChannel(['Artifact']), }); @@ -1211,6 +1226,14 @@ scheme.PageSetNetworkInterceptionPatternsParams = tObject({ })), }); scheme.PageSetNetworkInterceptionPatternsResult = tOptional(tObject({})); +scheme.PageSetWebSocketInterceptionPatternsParams = tObject({ + patterns: tArray(tObject({ + glob: tOptional(tString), + regexSource: tOptional(tString), + regexFlags: tOptional(tString), + })), +}); +scheme.PageSetWebSocketInterceptionPatternsResult = tOptional(tObject({})); scheme.PageSetViewportSizeParams = tObject({ viewportSize: tObject({ width: tNumber, @@ -2093,7 +2116,6 @@ scheme.RouteRedirectNavigationRequestParams = tObject({ scheme.RouteRedirectNavigationRequestResult = tOptional(tObject({})); scheme.RouteAbortParams = tObject({ errorCode: tOptional(tString), - requestUrl: tString, }); scheme.RouteAbortResult = tOptional(tObject({})); scheme.RouteContinueParams = tObject({ @@ -2101,7 +2123,6 @@ scheme.RouteContinueParams = tObject({ method: tOptional(tString), headers: tOptional(tArray(tType('NameValue'))), postData: tOptional(tBinary), - requestUrl: tString, isFallback: tBoolean, }); scheme.RouteContinueResult = tOptional(tObject({})); @@ -2111,9 +2132,39 @@ scheme.RouteFulfillParams = tObject({ body: tOptional(tString), isBase64: tOptional(tBoolean), fetchResponseUid: tOptional(tString), - requestUrl: tString, }); scheme.RouteFulfillResult = tOptional(tObject({})); +scheme.WebSocketRouteInitializer = tObject({ + url: tString, +}); +scheme.WebSocketRouteMessageFromPageEvent = tObject({ + message: tString, + isBase64: tBoolean, +}); +scheme.WebSocketRouteMessageFromServerEvent = tObject({ + message: tString, + isBase64: tBoolean, +}); +scheme.WebSocketRouteCloseEvent = tOptional(tObject({})); +scheme.WebSocketRouteConnectParams = tOptional(tObject({})); +scheme.WebSocketRouteConnectResult = tOptional(tObject({})); +scheme.WebSocketRouteEnsureOpenedParams = tOptional(tObject({})); +scheme.WebSocketRouteEnsureOpenedResult = tOptional(tObject({})); +scheme.WebSocketRouteSendToPageParams = tObject({ + message: tString, + isBase64: tBoolean, +}); +scheme.WebSocketRouteSendToPageResult = tOptional(tObject({})); +scheme.WebSocketRouteSendToServerParams = tObject({ + message: tString, + isBase64: tBoolean, +}); +scheme.WebSocketRouteSendToServerResult = tOptional(tObject({})); +scheme.WebSocketRouteCloseParams = tObject({ + code: tOptional(tNumber), + reason: tOptional(tString), +}); +scheme.WebSocketRouteCloseResult = tOptional(tObject({})); scheme.ResourceTiming = tObject({ startTime: tNumber, domainLookupStart: tNumber, diff --git a/packages/playwright-core/src/server/bidi/bidiConnection.ts b/packages/playwright-core/src/server/bidi/bidiConnection.ts index 7138f2e06a..f348815940 100644 --- a/packages/playwright-core/src/server/bidi/bidiConnection.ts +++ b/packages/playwright-core/src/server/bidi/bidiConnection.ts @@ -72,7 +72,7 @@ export class BidiConnection { let context; if ('context' in object.params) context = object.params.context; - else if (object.method === 'log.entryAdded') + else if (object.method === 'log.entryAdded' || object.method === 'script.message') context = object.params.source?.context; if (context) { const session = this._browsingContextToSession.get(context); diff --git a/packages/playwright-core/src/server/bidi/bidiExecutionContext.ts b/packages/playwright-core/src/server/bidi/bidiExecutionContext.ts index eaacb629e6..c037ba44b4 100644 --- a/packages/playwright-core/src/server/bidi/bidiExecutionContext.ts +++ b/packages/playwright-core/src/server/bidi/bidiExecutionContext.ts @@ -23,7 +23,7 @@ import { BidiSerializer } from './third_party/bidiSerializer'; export class BidiExecutionContext implements js.ExecutionContextDelegate { private readonly _session: BidiSession; - private readonly _target: bidi.Script.Target; + readonly _target: bidi.Script.Target; constructor(session: BidiSession, realmInfo: bidi.Script.RealmInfo) { this._session = session; diff --git a/packages/playwright-core/src/server/bidi/bidiFirefox.ts b/packages/playwright-core/src/server/bidi/bidiFirefox.ts index 3fb7c15b90..737fc97eaa 100644 --- a/packages/playwright-core/src/server/bidi/bidiFirefox.ts +++ b/packages/playwright-core/src/server/bidi/bidiFirefox.ts @@ -51,6 +51,14 @@ export class BidiFirefox extends BrowserType { override amendEnvironment(env: Env, userDataDir: string, executable: string, browserArguments: string[]): Env { if (!path.isAbsolute(os.homedir())) throw new Error(`Cannot launch Firefox with relative home directory. Did you set ${os.platform() === 'win32' ? 'USERPROFILE' : 'HOME'} to a relative path?`); + + env = { + ...env, + 'MOZ_CRASHREPORTER': '1', + 'MOZ_CRASHREPORTER_NO_REPORT': '1', + 'MOZ_CRASHREPORTER_SHUTDOWN': '1', + }; + if (os.platform() === 'linux') { // Always remove SNAP_NAME and SNAP_INSTANCE_NAME env variables since they // confuse Firefox: in our case, builds never come from SNAP. diff --git a/packages/playwright-core/src/server/bidi/bidiPage.ts b/packages/playwright-core/src/server/bidi/bidiPage.ts index c2d499bd67..180e8a651e 100644 --- a/packages/playwright-core/src/server/bidi/bidiPage.ts +++ b/packages/playwright-core/src/server/bidi/bidiPage.ts @@ -21,7 +21,8 @@ import type * as accessibility from '../accessibility'; import * as dom from '../dom'; import * as dialog from '../dialog'; import type * as frames from '../frames'; -import { type InitScript, Page, type PageDelegate } from '../page'; +import { Page } from '../page'; +import type { InitScript, PageDelegate } from '../page'; import type { Progress } from '../progress'; import type * as types from '../types'; import type { BidiBrowserContext } from './bidiBrowser'; @@ -33,6 +34,7 @@ import { BidiNetworkManager } from './bidiNetworkManager'; import { BrowserContext } from '../browserContext'; const UTILITY_WORLD_NAME = '__playwright_utility_world__'; +const kPlaywrightBindingChannel = 'playwrightChannel'; export class BidiPage implements PageDelegate { readonly rawMouse: RawMouseImpl; @@ -62,6 +64,7 @@ export class BidiPage implements PageDelegate { this._page.on(Page.Events.FrameDetached, (frame: frames.Frame) => this._removeContextsForFrame(frame, false)); this._sessionListeners = [ eventsHelper.addEventListener(bidiSession, 'script.realmCreated', this._onRealmCreated.bind(this)), + eventsHelper.addEventListener(bidiSession, 'script.message', this._onScriptMessage.bind(this)), eventsHelper.addEventListener(bidiSession, 'browsingContext.contextDestroyed', this._onBrowsingContextDestroyed.bind(this)), eventsHelper.addEventListener(bidiSession, 'browsingContext.navigationStarted', this._onNavigationStarted.bind(this)), eventsHelper.addEventListener(bidiSession, 'browsingContext.navigationAborted', this._onNavigationAborted.bind(this)), @@ -93,6 +96,7 @@ export class BidiPage implements PageDelegate { this.updateHttpCredentials(), this.updateRequestInterception(), this._updateViewport(), + this._installMainBinding(), this._addAllInitScripts(), ]); } @@ -315,18 +319,63 @@ export class BidiPage implements PageDelegate { }); } - goBack(): Promise { - throw new Error('Method not implemented.'); + async goBack(): Promise { + return await this._session.send('browsingContext.traverseHistory', { + context: this._session.sessionId, + delta: -1, + }).then(() => true).catch(() => false); } - goForward(): Promise { - throw new Error('Method not implemented.'); + async goForward(): Promise { + return await this._session.send('browsingContext.traverseHistory', { + context: this._session.sessionId, + delta: +1, + }).then(() => true).catch(() => false); } async forceGarbageCollection(): Promise { throw new Error('Method not implemented.'); } + // TODO: consider calling this only when bindings are added. + private async _installMainBinding() { + const functionDeclaration = addMainBinding.toString(); + const args: bidi.Script.ChannelValue[] = [{ + type: 'channel', + value: { + channel: kPlaywrightBindingChannel, + ownership: bidi.Script.ResultOwnership.Root, + } + }]; + const promises = []; + promises.push(this._session.send('script.addPreloadScript', { + functionDeclaration, + arguments: args, + })); + promises.push(this._session.send('script.callFunction', { + functionDeclaration, + arguments: args, + target: toBidiExecutionContext(await this._page.mainFrame()._mainContext())._target, + awaitPromise: false, + userActivation: false, + })); + await Promise.all(promises); + } + + private async _onScriptMessage(event: bidi.Script.MessageParameters) { + if (event.channel !== kPlaywrightBindingChannel) + return; + const pageOrError = await this.pageOrError(); + if (pageOrError instanceof Error) + return; + const context = this._realmToContext.get(event.source.realm); + if (!context) + return; + if (event.data.type !== 'string') + return; + await this._page._onBindingCalled(event.data.value, context); + } + async addInitScript(initScript: InitScript): Promise { const { script } = await this._session.send('script.addPreloadScript', { // TODO: remove function call from the source. @@ -355,7 +404,20 @@ export class BidiPage implements PageDelegate { } async takeScreenshot(progress: Progress, format: string, documentRect: types.Rect | undefined, viewportRect: types.Rect | undefined, quality: number | undefined, fitsViewport: boolean, scale: 'css' | 'device'): Promise { - throw new Error('Method not implemented.'); + const rect = (documentRect || viewportRect)!; + const { data } = await this._session.send('browsingContext.captureScreenshot', { + context: this._session.sessionId, + format: { + type: `image/${format === 'png' ? 'png' : 'jpeg'}`, + quality: quality || 80, + }, + origin: documentRect ? 'document' : 'viewport', + clip: { + type: 'box', + ...rect, + } + }); + return Buffer.from(data, 'base64'); } async getContentFrame(handle: dom.ElementHandle): Promise { @@ -522,6 +584,10 @@ export class BidiPage implements PageDelegate { } } +function addMainBinding(callback: (arg: any) => void) { + (globalThis as any)['__playwright__binding__'] = callback; +} + function toBidiExecutionContext(executionContext: dom.FrameExecutionContext): BidiExecutionContext { return (executionContext as any)[contextDelegateSymbol] as BidiExecutionContext; } diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index 499356ca49..025bd0f388 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -131,15 +131,15 @@ export abstract class BrowserContext extends SdkObject { // When PWDEBUG=1, show inspector for each context. if (debugMode() === 'inspector') - await Recorder.show(this, RecorderApp.factory(this), { pauseOnNextStatement: true }); + await Recorder.show('actions', this, RecorderApp.factory(this), { pauseOnNextStatement: true }); // When paused, show inspector. if (this._debugger.isPaused()) - Recorder.showInspector(this, RecorderApp.factory(this)); + Recorder.showInspectorNoReply(this, RecorderApp.factory(this)); this._debugger.on(Debugger.Events.PausedStateChanged, () => { if (this._debugger.isPaused()) - Recorder.showInspector(this, RecorderApp.factory(this)); + Recorder.showInspectorNoReply(this, RecorderApp.factory(this)); }); if (debugMode() === 'console') @@ -525,7 +525,7 @@ export abstract class BrowserContext extends SdkObject { const internalMetadata = serverSideCallMetadata(); const page = await this.newPage(internalMetadata); await page._setServerRequestInterceptor(handler => { - handler.fulfill({ body: '', requestUrl: handler.request().url() }).catch(() => {}); + handler.fulfill({ body: '' }).catch(() => {}); return true; }); for (const origin of originsToSave) { @@ -559,7 +559,7 @@ export abstract class BrowserContext extends SdkObject { isServerSide: false, }); await page._setServerRequestInterceptor(handler => { - handler.fulfill({ body: '', requestUrl: handler.request().url() }).catch(() => {}); + handler.fulfill({ body: '' }).catch(() => {}); return true; }); @@ -594,7 +594,7 @@ export abstract class BrowserContext extends SdkObject { const internalMetadata = serverSideCallMetadata(); const page = await this.newPage(internalMetadata); await page._setServerRequestInterceptor(handler => { - handler.fulfill({ body: '', requestUrl: handler.request().url() }).catch(() => {}); + handler.fulfill({ body: '' }).catch(() => {}); return true; }); for (const originState of state.origins) { diff --git a/packages/playwright-core/src/server/chromium/chromiumSwitches.ts b/packages/playwright-core/src/server/chromium/chromiumSwitches.ts index 54b80a5d82..5064145fb8 100644 --- a/packages/playwright-core/src/server/chromium/chromiumSwitches.ts +++ b/packages/playwright-core/src/server/chromium/chromiumSwitches.ts @@ -35,8 +35,8 @@ export const chromiumSwitches = [ // Translate - https://github.com/microsoft/playwright/issues/16126 // HttpsUpgrades - https://github.com/microsoft/playwright/pull/27605 // PaintHolding - https://github.com/microsoft/playwright/issues/28023 - // PlzDedicatedWorker - https://github.com/microsoft/playwright/issues/31747 - '--disable-features=ImprovedCookieControls,LazyFrameLoading,GlobalMediaControls,DestroyProfileOnBrowserClose,MediaRouter,DialMediaRouteProvider,AcceptCHFrame,AutoExpandDetailsElement,CertificateTransparencyComponentUpdater,AvoidUnnecessaryBeforeUnloadCheckSync,Translate,HttpsUpgrades,PaintHolding,PlzDedicatedWorker', + // ThirdPartyStoragePartitioning - https://github.com/microsoft/playwright/issues/32230 + '--disable-features=ImprovedCookieControls,LazyFrameLoading,GlobalMediaControls,DestroyProfileOnBrowserClose,MediaRouter,DialMediaRouteProvider,AcceptCHFrame,AutoExpandDetailsElement,CertificateTransparencyComponentUpdater,AvoidUnnecessaryBeforeUnloadCheckSync,Translate,HttpsUpgrades,PaintHolding,ThirdPartyStoragePartitioning', '--allow-pre-commit-input', '--disable-hang-monitor', '--disable-ipc-flooding-protection', diff --git a/packages/playwright-core/src/server/chromium/protocol.d.ts b/packages/playwright-core/src/server/chromium/protocol.d.ts index caadb2a577..14416bde1e 100644 --- a/packages/playwright-core/src/server/chromium/protocol.d.ts +++ b/packages/playwright-core/src/server/chromium/protocol.d.ts @@ -840,7 +840,7 @@ CORS RFC1918 enforcement. resourceIPAddressSpace?: Network.IPAddressSpace; clientSecurityState?: Network.ClientSecurityState; } - export type AttributionReportingIssueType = "PermissionPolicyDisabled"|"UntrustworthyReportingOrigin"|"InsecureContext"|"InvalidHeader"|"InvalidRegisterTriggerHeader"|"SourceAndTriggerHeaders"|"SourceIgnored"|"TriggerIgnored"|"OsSourceIgnored"|"OsTriggerIgnored"|"InvalidRegisterOsSourceHeader"|"InvalidRegisterOsTriggerHeader"|"WebAndOsHeaders"|"NoWebOrOsSupport"|"NavigationRegistrationWithoutTransientUserActivation"|"InvalidInfoHeader"|"NoRegisterSourceHeader"|"NoRegisterTriggerHeader"|"NoRegisterOsSourceHeader"|"NoRegisterOsTriggerHeader"; + export type AttributionReportingIssueType = "PermissionPolicyDisabled"|"UntrustworthyReportingOrigin"|"InsecureContext"|"InvalidHeader"|"InvalidRegisterTriggerHeader"|"SourceAndTriggerHeaders"|"SourceIgnored"|"TriggerIgnored"|"OsSourceIgnored"|"OsTriggerIgnored"|"InvalidRegisterOsSourceHeader"|"InvalidRegisterOsTriggerHeader"|"WebAndOsHeaders"|"NoWebOrOsSupport"|"NavigationRegistrationWithoutTransientUserActivation"|"InvalidInfoHeader"|"NoRegisterSourceHeader"|"NoRegisterTriggerHeader"|"NoRegisterOsSourceHeader"|"NoRegisterOsTriggerHeader"|"NavigationRegistrationUniqueScopeAlreadySet"; export type SharedDictionaryError = "UseErrorCrossOriginNoCorsRequest"|"UseErrorDictionaryLoadFailure"|"UseErrorMatchingDictionaryNotUsed"|"UseErrorUnexpectedContentDictionaryHeader"|"WriteErrorCossOriginNoCorsRequest"|"WriteErrorDisallowedBySettings"|"WriteErrorExpiredResponse"|"WriteErrorFeatureDisabled"|"WriteErrorInsufficientResources"|"WriteErrorInvalidMatchField"|"WriteErrorInvalidStructuredHeader"|"WriteErrorNavigationRequest"|"WriteErrorNoMatchField"|"WriteErrorNonListMatchDestField"|"WriteErrorNonSecureContext"|"WriteErrorNonStringIdField"|"WriteErrorNonStringInMatchDestList"|"WriteErrorNonStringMatchField"|"WriteErrorNonTokenTypeField"|"WriteErrorRequestAborted"|"WriteErrorShuttingDown"|"WriteErrorTooLongIdField"|"WriteErrorUnsupportedType"; /** * Details for issues around "Attribution Reporting API" usage. @@ -1534,7 +1534,7 @@ events afterwards if enabled and recording. */ windowState?: WindowState; } - export type PermissionType = "accessibilityEvents"|"audioCapture"|"backgroundSync"|"backgroundFetch"|"capturedSurfaceControl"|"clipboardReadWrite"|"clipboardSanitizedWrite"|"displayCapture"|"durableStorage"|"flash"|"geolocation"|"idleDetection"|"localFonts"|"midi"|"midiSysex"|"nfc"|"notifications"|"paymentHandler"|"periodicBackgroundSync"|"protectedMediaIdentifier"|"sensors"|"storageAccess"|"speakerSelection"|"topLevelStorageAccess"|"videoCapture"|"videoCapturePanTiltZoom"|"wakeLockScreen"|"wakeLockSystem"|"windowManagement"; + export type PermissionType = "accessibilityEvents"|"audioCapture"|"backgroundSync"|"backgroundFetch"|"capturedSurfaceControl"|"clipboardReadWrite"|"clipboardSanitizedWrite"|"displayCapture"|"durableStorage"|"flash"|"geolocation"|"idleDetection"|"localFonts"|"midi"|"midiSysex"|"nfc"|"notifications"|"paymentHandler"|"periodicBackgroundSync"|"protectedMediaIdentifier"|"sensors"|"storageAccess"|"speakerSelection"|"topLevelStorageAccess"|"videoCapture"|"videoCapturePanTiltZoom"|"wakeLockScreen"|"wakeLockSystem"|"webAppInstallation"|"windowManagement"; export type PermissionSetting = "granted"|"denied"|"prompt"; /** * Definition of PermissionDescriptor defined in the Permissions API: @@ -3561,7 +3561,7 @@ front-end. /** * Pseudo element type. */ - export type PseudoType = "first-line"|"first-letter"|"before"|"after"|"marker"|"backdrop"|"selection"|"search-text"|"target-text"|"spelling-error"|"grammar-error"|"highlight"|"first-line-inherited"|"scroll-marker"|"scroll-marker-group"|"scroll-next-button"|"scroll-prev-button"|"scrollbar"|"scrollbar-thumb"|"scrollbar-button"|"scrollbar-track"|"scrollbar-track-piece"|"scrollbar-corner"|"resizer"|"input-list-button"|"view-transition"|"view-transition-group"|"view-transition-image-pair"|"view-transition-old"|"view-transition-new"; + export type PseudoType = "first-line"|"first-letter"|"before"|"after"|"marker"|"backdrop"|"column"|"selection"|"search-text"|"target-text"|"spelling-error"|"grammar-error"|"highlight"|"first-line-inherited"|"scroll-marker"|"scroll-marker-group"|"scroll-next-button"|"scroll-prev-button"|"scrollbar"|"scrollbar-thumb"|"scrollbar-button"|"scrollbar-track"|"scrollbar-track-piece"|"scrollbar-corner"|"resizer"|"input-list-button"|"view-transition"|"view-transition-group"|"view-transition-image-pair"|"view-transition-old"|"view-transition-new"|"placeholder"|"file-selector-button"|"details-content"|"select-fallback-button"|"select-fallback-button-text"|"picker"; /** * Shadow root type. */ @@ -3710,6 +3710,7 @@ The property is always undefined now. isSVG?: boolean; compatibilityMode?: CompatibilityMode; assignedSlot?: BackendNode; + isScrollable?: boolean; } /** * A structure to hold the top-level node of a detached tree and an array of its retained descendants. @@ -3954,6 +3955,19 @@ The property is always undefined now. * Called when top layer elements are changed. */ export type topLayerElementsUpdatedPayload = void; + /** + * Fired when a node's scrollability state changes. + */ + export type scrollableFlagUpdatedPayload = { + /** + * The id of the node. + */ + nodeId: DOM.NodeId; + /** + * If the node is scrollable. + */ + isScrollable: boolean; + } /** * Called when a pseudo element is removed from an element. */ @@ -8102,8 +8116,25 @@ or hexadecimal (0x prefixed) string. */ size: number; } + /** + * DOM object counter data. + */ + export interface DOMCounter { + /** + * Object name. Note: object names should be presumed volatile and clients should not expect +the returned names to be consistent across runs. + */ + name: string; + /** + * Object count. + */ + count: number; + } + /** + * Retruns current DOM object counters. + */ export type getDOMCountersParameters = { } export type getDOMCountersReturnValue = { @@ -8111,6 +8142,21 @@ or hexadecimal (0x prefixed) string. nodes: number; jsEventListeners: number; } + /** + * Retruns DOM object counters after preparing renderer for leak detection. + */ + export type getDOMCountersForLeakDetectionParameters = { + } + export type getDOMCountersForLeakDetectionReturnValue = { + /** + * DOM object counters. + */ + counters: DOMCounter[]; + } + /** + * Prepares for leak detection by terminating workers, stopping spellcheckers, +dropping non-essential internal caches, running garbage collections, etc. + */ export type prepareForLeakDetectionParameters = { } export type prepareForLeakDetectionReturnValue = { @@ -8902,7 +8948,7 @@ This is a temporary ability and it will be removed in the future. /** * Types of reasons why a cookie should have been blocked by 3PCD but is exempted for the request. */ - export type CookieExemptionReason = "None"|"UserSetting"|"TPCDMetadata"|"TPCDDeprecationTrial"|"TPCDHeuristics"|"EnterprisePolicy"|"StorageAccess"|"TopLevelStorageAccess"|"CorsOptIn"|"Scheme"; + export type CookieExemptionReason = "None"|"UserSetting"|"TPCDMetadata"|"TPCDDeprecationTrial"|"TopLevelTPCDDeprecationTrial"|"TPCDHeuristics"|"EnterprisePolicy"|"StorageAccess"|"TopLevelStorageAccess"|"Scheme"; /** * A cookie which was not stored from a response with the corresponding reason. */ @@ -11452,7 +11498,7 @@ as an ad. * All Permissions Policy features. This enum should match the one defined in third_party/blink/renderer/core/permissions_policy/permissions_policy_features.json5. */ - export type PermissionsPolicyFeature = "accelerometer"|"all-screens-capture"|"ambient-light-sensor"|"attribution-reporting"|"autoplay"|"bluetooth"|"browsing-topics"|"camera"|"captured-surface-control"|"ch-dpr"|"ch-device-memory"|"ch-downlink"|"ch-ect"|"ch-prefers-color-scheme"|"ch-prefers-reduced-motion"|"ch-prefers-reduced-transparency"|"ch-rtt"|"ch-save-data"|"ch-ua"|"ch-ua-arch"|"ch-ua-bitness"|"ch-ua-platform"|"ch-ua-model"|"ch-ua-mobile"|"ch-ua-form-factors"|"ch-ua-full-version"|"ch-ua-full-version-list"|"ch-ua-platform-version"|"ch-ua-wow64"|"ch-viewport-height"|"ch-viewport-width"|"ch-width"|"clipboard-read"|"clipboard-write"|"compute-pressure"|"cross-origin-isolated"|"deferred-fetch"|"digital-credentials-get"|"direct-sockets"|"display-capture"|"document-domain"|"encrypted-media"|"execution-while-out-of-viewport"|"execution-while-not-rendered"|"focus-without-user-activation"|"fullscreen"|"frobulate"|"gamepad"|"geolocation"|"gyroscope"|"hid"|"identity-credentials-get"|"idle-detection"|"interest-cohort"|"join-ad-interest-group"|"keyboard-map"|"local-fonts"|"magnetometer"|"media-playback-while-not-visible"|"microphone"|"midi"|"otp-credentials"|"payment"|"picture-in-picture"|"private-aggregation"|"private-state-token-issuance"|"private-state-token-redemption"|"publickey-credentials-create"|"publickey-credentials-get"|"run-ad-auction"|"screen-wake-lock"|"serial"|"shared-autofill"|"shared-storage"|"shared-storage-select-url"|"smart-card"|"speaker-selection"|"storage-access"|"sub-apps"|"sync-xhr"|"unload"|"usb"|"usb-unrestricted"|"vertical-scroll"|"web-printing"|"web-share"|"window-management"|"xr-spatial-tracking"; + export type PermissionsPolicyFeature = "accelerometer"|"all-screens-capture"|"ambient-light-sensor"|"attribution-reporting"|"autoplay"|"bluetooth"|"browsing-topics"|"camera"|"captured-surface-control"|"ch-dpr"|"ch-device-memory"|"ch-downlink"|"ch-ect"|"ch-prefers-color-scheme"|"ch-prefers-reduced-motion"|"ch-prefers-reduced-transparency"|"ch-rtt"|"ch-save-data"|"ch-ua"|"ch-ua-arch"|"ch-ua-bitness"|"ch-ua-platform"|"ch-ua-model"|"ch-ua-mobile"|"ch-ua-form-factors"|"ch-ua-full-version"|"ch-ua-full-version-list"|"ch-ua-platform-version"|"ch-ua-wow64"|"ch-viewport-height"|"ch-viewport-width"|"ch-width"|"clipboard-read"|"clipboard-write"|"compute-pressure"|"controlled-frame"|"cross-origin-isolated"|"deferred-fetch"|"digital-credentials-get"|"direct-sockets"|"display-capture"|"document-domain"|"encrypted-media"|"execution-while-out-of-viewport"|"execution-while-not-rendered"|"focus-without-user-activation"|"fullscreen"|"frobulate"|"gamepad"|"geolocation"|"gyroscope"|"hid"|"identity-credentials-get"|"idle-detection"|"interest-cohort"|"join-ad-interest-group"|"keyboard-map"|"local-fonts"|"magnetometer"|"media-playback-while-not-visible"|"microphone"|"midi"|"otp-credentials"|"payment"|"picture-in-picture"|"popins"|"private-aggregation"|"private-state-token-issuance"|"private-state-token-redemption"|"publickey-credentials-create"|"publickey-credentials-get"|"run-ad-auction"|"screen-wake-lock"|"serial"|"shared-autofill"|"shared-storage"|"shared-storage-select-url"|"smart-card"|"speaker-selection"|"storage-access"|"sub-apps"|"sync-xhr"|"unload"|"usb"|"usb-unrestricted"|"vertical-scroll"|"web-app-installation"|"web-printing"|"web-share"|"window-management"|"xr-spatial-tracking"; /** * Reason for a permissions policy feature to be disabled. */ @@ -12040,7 +12086,7 @@ https://github.com/WICG/manifest-incubations/blob/gh-pages/scope_extensions-expl /** * List of not restored reasons for back-forward cache. */ - export type BackForwardCacheNotRestoredReason = "NotPrimaryMainFrame"|"BackForwardCacheDisabled"|"RelatedActiveContentsExist"|"HTTPStatusNotOK"|"SchemeNotHTTPOrHTTPS"|"Loading"|"WasGrantedMediaAccess"|"DisableForRenderFrameHostCalled"|"DomainNotAllowed"|"HTTPMethodNotGET"|"SubframeIsNavigating"|"Timeout"|"CacheLimit"|"JavaScriptExecution"|"RendererProcessKilled"|"RendererProcessCrashed"|"SchedulerTrackedFeatureUsed"|"ConflictingBrowsingInstance"|"CacheFlushed"|"ServiceWorkerVersionActivation"|"SessionRestored"|"ServiceWorkerPostMessage"|"EnteredBackForwardCacheBeforeServiceWorkerHostAdded"|"RenderFrameHostReused_SameSite"|"RenderFrameHostReused_CrossSite"|"ServiceWorkerClaim"|"IgnoreEventAndEvict"|"HaveInnerContents"|"TimeoutPuttingInCache"|"BackForwardCacheDisabledByLowMemory"|"BackForwardCacheDisabledByCommandLine"|"NetworkRequestDatapipeDrainedAsBytesConsumer"|"NetworkRequestRedirected"|"NetworkRequestTimeout"|"NetworkExceedsBufferLimit"|"NavigationCancelledWhileRestoring"|"NotMostRecentNavigationEntry"|"BackForwardCacheDisabledForPrerender"|"UserAgentOverrideDiffers"|"ForegroundCacheLimit"|"BrowsingInstanceNotSwapped"|"BackForwardCacheDisabledForDelegate"|"UnloadHandlerExistsInMainFrame"|"UnloadHandlerExistsInSubFrame"|"ServiceWorkerUnregistration"|"CacheControlNoStore"|"CacheControlNoStoreCookieModified"|"CacheControlNoStoreHTTPOnlyCookieModified"|"NoResponseHead"|"Unknown"|"ActivationNavigationsDisallowedForBug1234857"|"ErrorDocument"|"FencedFramesEmbedder"|"CookieDisabled"|"HTTPAuthRequired"|"CookieFlushed"|"BroadcastChannelOnMessage"|"WebViewSettingsChanged"|"WebViewJavaScriptObjectChanged"|"WebViewMessageListenerInjected"|"WebViewSafeBrowsingAllowlistChanged"|"WebViewDocumentStartJavascriptChanged"|"WebSocket"|"WebTransport"|"WebRTC"|"MainResourceHasCacheControlNoStore"|"MainResourceHasCacheControlNoCache"|"SubresourceHasCacheControlNoStore"|"SubresourceHasCacheControlNoCache"|"ContainsPlugins"|"DocumentLoaded"|"OutstandingNetworkRequestOthers"|"RequestedMIDIPermission"|"RequestedAudioCapturePermission"|"RequestedVideoCapturePermission"|"RequestedBackForwardCacheBlockedSensors"|"RequestedBackgroundWorkPermission"|"BroadcastChannel"|"WebXR"|"SharedWorker"|"WebLocks"|"WebHID"|"WebShare"|"RequestedStorageAccessGrant"|"WebNfc"|"OutstandingNetworkRequestFetch"|"OutstandingNetworkRequestXHR"|"AppBanner"|"Printing"|"WebDatabase"|"PictureInPicture"|"SpeechRecognizer"|"IdleManager"|"PaymentManager"|"SpeechSynthesis"|"KeyboardLock"|"WebOTPService"|"OutstandingNetworkRequestDirectSocket"|"InjectedJavascript"|"InjectedStyleSheet"|"KeepaliveRequest"|"IndexedDBEvent"|"Dummy"|"JsNetworkRequestReceivedCacheControlNoStoreResource"|"WebRTCSticky"|"WebTransportSticky"|"WebSocketSticky"|"SmartCard"|"LiveMediaStreamTrack"|"UnloadHandler"|"ParserAborted"|"ContentSecurityHandler"|"ContentWebAuthenticationAPI"|"ContentFileChooser"|"ContentSerial"|"ContentFileSystemAccess"|"ContentMediaDevicesDispatcherHost"|"ContentWebBluetooth"|"ContentWebUSB"|"ContentMediaSessionService"|"ContentScreenReader"|"EmbedderPopupBlockerTabHelper"|"EmbedderSafeBrowsingTriggeredPopupBlocker"|"EmbedderSafeBrowsingThreatDetails"|"EmbedderAppBannerManager"|"EmbedderDomDistillerViewerSource"|"EmbedderDomDistillerSelfDeletingRequestDelegate"|"EmbedderOomInterventionTabHelper"|"EmbedderOfflinePage"|"EmbedderChromePasswordManagerClientBindCredentialManager"|"EmbedderPermissionRequestManager"|"EmbedderModalDialog"|"EmbedderExtensions"|"EmbedderExtensionMessaging"|"EmbedderExtensionMessagingForOpenPort"|"EmbedderExtensionSentMessageToCachedFrame"|"RequestedByWebViewClient"; + export type BackForwardCacheNotRestoredReason = "NotPrimaryMainFrame"|"BackForwardCacheDisabled"|"RelatedActiveContentsExist"|"HTTPStatusNotOK"|"SchemeNotHTTPOrHTTPS"|"Loading"|"WasGrantedMediaAccess"|"DisableForRenderFrameHostCalled"|"DomainNotAllowed"|"HTTPMethodNotGET"|"SubframeIsNavigating"|"Timeout"|"CacheLimit"|"JavaScriptExecution"|"RendererProcessKilled"|"RendererProcessCrashed"|"SchedulerTrackedFeatureUsed"|"ConflictingBrowsingInstance"|"CacheFlushed"|"ServiceWorkerVersionActivation"|"SessionRestored"|"ServiceWorkerPostMessage"|"EnteredBackForwardCacheBeforeServiceWorkerHostAdded"|"RenderFrameHostReused_SameSite"|"RenderFrameHostReused_CrossSite"|"ServiceWorkerClaim"|"IgnoreEventAndEvict"|"HaveInnerContents"|"TimeoutPuttingInCache"|"BackForwardCacheDisabledByLowMemory"|"BackForwardCacheDisabledByCommandLine"|"NetworkRequestDatapipeDrainedAsBytesConsumer"|"NetworkRequestRedirected"|"NetworkRequestTimeout"|"NetworkExceedsBufferLimit"|"NavigationCancelledWhileRestoring"|"NotMostRecentNavigationEntry"|"BackForwardCacheDisabledForPrerender"|"UserAgentOverrideDiffers"|"ForegroundCacheLimit"|"BrowsingInstanceNotSwapped"|"BackForwardCacheDisabledForDelegate"|"UnloadHandlerExistsInMainFrame"|"UnloadHandlerExistsInSubFrame"|"ServiceWorkerUnregistration"|"CacheControlNoStore"|"CacheControlNoStoreCookieModified"|"CacheControlNoStoreHTTPOnlyCookieModified"|"NoResponseHead"|"Unknown"|"ActivationNavigationsDisallowedForBug1234857"|"ErrorDocument"|"FencedFramesEmbedder"|"CookieDisabled"|"HTTPAuthRequired"|"CookieFlushed"|"BroadcastChannelOnMessage"|"WebViewSettingsChanged"|"WebViewJavaScriptObjectChanged"|"WebViewMessageListenerInjected"|"WebViewSafeBrowsingAllowlistChanged"|"WebViewDocumentStartJavascriptChanged"|"WebSocket"|"WebTransport"|"WebRTC"|"MainResourceHasCacheControlNoStore"|"MainResourceHasCacheControlNoCache"|"SubresourceHasCacheControlNoStore"|"SubresourceHasCacheControlNoCache"|"ContainsPlugins"|"DocumentLoaded"|"OutstandingNetworkRequestOthers"|"RequestedMIDIPermission"|"RequestedAudioCapturePermission"|"RequestedVideoCapturePermission"|"RequestedBackForwardCacheBlockedSensors"|"RequestedBackgroundWorkPermission"|"BroadcastChannel"|"WebXR"|"SharedWorker"|"WebLocks"|"WebHID"|"WebShare"|"RequestedStorageAccessGrant"|"WebNfc"|"OutstandingNetworkRequestFetch"|"OutstandingNetworkRequestXHR"|"AppBanner"|"Printing"|"WebDatabase"|"PictureInPicture"|"SpeechRecognizer"|"IdleManager"|"PaymentManager"|"SpeechSynthesis"|"KeyboardLock"|"WebOTPService"|"OutstandingNetworkRequestDirectSocket"|"InjectedJavascript"|"InjectedStyleSheet"|"KeepaliveRequest"|"IndexedDBEvent"|"Dummy"|"JsNetworkRequestReceivedCacheControlNoStoreResource"|"WebRTCSticky"|"WebTransportSticky"|"WebSocketSticky"|"SmartCard"|"LiveMediaStreamTrack"|"UnloadHandler"|"ParserAborted"|"ContentSecurityHandler"|"ContentWebAuthenticationAPI"|"ContentFileChooser"|"ContentSerial"|"ContentFileSystemAccess"|"ContentMediaDevicesDispatcherHost"|"ContentWebBluetooth"|"ContentWebUSB"|"ContentMediaSessionService"|"ContentScreenReader"|"ContentDiscarded"|"EmbedderPopupBlockerTabHelper"|"EmbedderSafeBrowsingTriggeredPopupBlocker"|"EmbedderSafeBrowsingThreatDetails"|"EmbedderAppBannerManager"|"EmbedderDomDistillerViewerSource"|"EmbedderDomDistillerSelfDeletingRequestDelegate"|"EmbedderOomInterventionTabHelper"|"EmbedderOfflinePage"|"EmbedderChromePasswordManagerClientBindCredentialManager"|"EmbedderPermissionRequestManager"|"EmbedderModalDialog"|"EmbedderExtensions"|"EmbedderExtensionMessaging"|"EmbedderExtensionMessagingForOpenPort"|"EmbedderExtensionSentMessageToCachedFrame"|"RequestedByWebViewClient"; /** * Types of not restored reasons for back-forward cache. */ @@ -12151,6 +12197,16 @@ dependent on the reason: frameId: FrameId; reason: "remove"|"swap"; } + /** + * Fired before frame subtree is detached. Emitted before any frame of the +subtree is actually detached. + */ + export type frameSubtreeWillBeDetachedPayload = { + /** + * Id of the frame that is the root of the subtree that will be detached. + */ + frameId: FrameId; + } /** * Fired once navigation of the frame has completed. Frame is now associated with the new loader. */ @@ -14250,6 +14306,15 @@ int, only present for source registrations debugData: AttributionReportingAggregatableDebugReportingData[]; aggregationCoordinatorOrigin?: string; } + export interface AttributionScopesData { + values: string[]; + /** + * number instead of integer because not all uint32 can be represented by +int + */ + limit: number; + maxEventStates: number; + } export interface AttributionReportingSourceRegistration { time: Network.TimeSinceEpoch; /** @@ -14273,8 +14338,9 @@ int, only present for source registrations triggerDataMatching: AttributionReportingTriggerDataMatching; destinationLimitPriority: SignedInt64AsBase10; aggregatableDebugReportingConfig: AttributionReportingAggregatableDebugReportingConfig; + scopesData?: AttributionScopesData; } - export type AttributionReportingSourceRegistrationResult = "success"|"internalError"|"insufficientSourceCapacity"|"insufficientUniqueDestinationCapacity"|"excessiveReportingOrigins"|"prohibitedByBrowserPolicy"|"successNoised"|"destinationReportingLimitReached"|"destinationGlobalLimitReached"|"destinationBothLimitsReached"|"reportingOriginsPerSiteLimitReached"|"exceedsMaxChannelCapacity"|"exceedsMaxTriggerStateCardinality"|"destinationPerDayReportingLimitReached"; + export type AttributionReportingSourceRegistrationResult = "success"|"internalError"|"insufficientSourceCapacity"|"insufficientUniqueDestinationCapacity"|"excessiveReportingOrigins"|"prohibitedByBrowserPolicy"|"successNoised"|"destinationReportingLimitReached"|"destinationGlobalLimitReached"|"destinationBothLimitsReached"|"reportingOriginsPerSiteLimitReached"|"exceedsMaxChannelCapacity"|"exceedsMaxScopesChannelCapacity"|"exceedsMaxTriggerStateCardinality"|"exceedsMaxEventStatesLimit"|"destinationPerDayReportingLimitReached"; export type AttributionReportingSourceRegistrationTimeConfig = "include"|"exclude"; export interface AttributionReportingAggregatableValueDictEntry { key: string; @@ -14317,6 +14383,7 @@ int sourceRegistrationTimeConfig: AttributionReportingSourceRegistrationTimeConfig; triggerContextId?: string; aggregatableDebugReportingConfig: AttributionReportingAggregatableDebugReportingConfig; + scopes: string[]; } export type AttributionReportingEventLevelResult = "success"|"successDroppedLowerPriority"|"internalError"|"noCapacityForAttributionDestination"|"noMatchingSources"|"deduplicated"|"excessiveAttributions"|"priorityTooLow"|"neverAttributedSource"|"excessiveReportingOrigins"|"noMatchingSourceFilterData"|"prohibitedByBrowserPolicy"|"noMatchingConfigurations"|"excessiveReports"|"falselyAttributedSource"|"reportWindowPassed"|"notRegistered"|"reportWindowNotStarted"|"noMatchingTriggerData"; export type AttributionReportingAggregatableResult = "success"|"internalError"|"noCapacityForAttributionDestination"|"noMatchingSources"|"excessiveAttributions"|"excessiveReportingOrigins"|"noHistograms"|"insufficientBudget"|"noMatchingSourceFilterData"|"notRegistered"|"prohibitedByBrowserPolicy"|"deduplicated"|"reportWindowPassed"|"excessiveReports"; @@ -17019,7 +17086,7 @@ status is shared by prefetchStatusUpdated and prerenderStatusUpdated. * TODO(https://crbug.com/1384419): revisit the list of PrefetchStatus and filter out the ones that aren't necessary to the developers. */ - export type PrefetchStatus = "PrefetchAllowed"|"PrefetchFailedIneligibleRedirect"|"PrefetchFailedInvalidRedirect"|"PrefetchFailedMIMENotSupported"|"PrefetchFailedNetError"|"PrefetchFailedNon2XX"|"PrefetchFailedPerPageLimitExceeded"|"PrefetchEvictedAfterCandidateRemoved"|"PrefetchEvictedForNewerPrefetch"|"PrefetchHeldback"|"PrefetchIneligibleRetryAfter"|"PrefetchIsPrivacyDecoy"|"PrefetchIsStale"|"PrefetchNotEligibleBrowserContextOffTheRecord"|"PrefetchNotEligibleDataSaverEnabled"|"PrefetchNotEligibleExistingProxy"|"PrefetchNotEligibleHostIsNonUnique"|"PrefetchNotEligibleNonDefaultStoragePartition"|"PrefetchNotEligibleSameSiteCrossOriginPrefetchRequiredProxy"|"PrefetchNotEligibleSchemeIsNotHttps"|"PrefetchNotEligibleUserHasCookies"|"PrefetchNotEligibleUserHasServiceWorker"|"PrefetchNotEligibleBatterySaverEnabled"|"PrefetchNotEligiblePreloadingDisabled"|"PrefetchNotFinishedInTime"|"PrefetchNotStarted"|"PrefetchNotUsedCookiesChanged"|"PrefetchProxyNotAvailable"|"PrefetchResponseUsed"|"PrefetchSuccessfulButNotUsed"|"PrefetchNotUsedProbeFailed"; + export type PrefetchStatus = "PrefetchAllowed"|"PrefetchFailedIneligibleRedirect"|"PrefetchFailedInvalidRedirect"|"PrefetchFailedMIMENotSupported"|"PrefetchFailedNetError"|"PrefetchFailedNon2XX"|"PrefetchEvictedAfterCandidateRemoved"|"PrefetchEvictedForNewerPrefetch"|"PrefetchHeldback"|"PrefetchIneligibleRetryAfter"|"PrefetchIsPrivacyDecoy"|"PrefetchIsStale"|"PrefetchNotEligibleBrowserContextOffTheRecord"|"PrefetchNotEligibleDataSaverEnabled"|"PrefetchNotEligibleExistingProxy"|"PrefetchNotEligibleHostIsNonUnique"|"PrefetchNotEligibleNonDefaultStoragePartition"|"PrefetchNotEligibleSameSiteCrossOriginPrefetchRequiredProxy"|"PrefetchNotEligibleSchemeIsNotHttps"|"PrefetchNotEligibleUserHasCookies"|"PrefetchNotEligibleUserHasServiceWorker"|"PrefetchNotEligibleBatterySaverEnabled"|"PrefetchNotEligiblePreloadingDisabled"|"PrefetchNotFinishedInTime"|"PrefetchNotStarted"|"PrefetchNotUsedCookiesChanged"|"PrefetchProxyNotAvailable"|"PrefetchResponseUsed"|"PrefetchSuccessfulButNotUsed"|"PrefetchNotUsedProbeFailed"; /** * Information of headers to be displayed when the header mismatch occurred. */ @@ -20115,6 +20182,7 @@ Error was thrown. "DOM.inlineStyleInvalidated": DOM.inlineStyleInvalidatedPayload; "DOM.pseudoElementAdded": DOM.pseudoElementAddedPayload; "DOM.topLayerElementsUpdated": DOM.topLayerElementsUpdatedPayload; + "DOM.scrollableFlagUpdated": DOM.scrollableFlagUpdatedPayload; "DOM.pseudoElementRemoved": DOM.pseudoElementRemovedPayload; "DOM.setChildNodes": DOM.setChildNodesPayload; "DOM.shadowRootPopped": DOM.shadowRootPoppedPayload; @@ -20173,6 +20241,7 @@ Error was thrown. "Page.frameAttached": Page.frameAttachedPayload; "Page.frameClearedScheduledNavigation": Page.frameClearedScheduledNavigationPayload; "Page.frameDetached": Page.frameDetachedPayload; + "Page.frameSubtreeWillBeDetached": Page.frameSubtreeWillBeDetachedPayload; "Page.frameNavigated": Page.frameNavigatedPayload; "Page.documentOpened": Page.documentOpenedPayload; "Page.frameResized": Page.frameResizedPayload; @@ -20539,6 +20608,7 @@ Error was thrown. "Log.startViolationsReport": Log.startViolationsReportParameters; "Log.stopViolationsReport": Log.stopViolationsReportParameters; "Memory.getDOMCounters": Memory.getDOMCountersParameters; + "Memory.getDOMCountersForLeakDetection": Memory.getDOMCountersForLeakDetectionParameters; "Memory.prepareForLeakDetection": Memory.prepareForLeakDetectionParameters; "Memory.forciblyPurgeJavaScriptMemory": Memory.forciblyPurgeJavaScriptMemoryParameters; "Memory.setPressureNotificationsSuppressed": Memory.setPressureNotificationsSuppressedParameters; @@ -21148,6 +21218,7 @@ Error was thrown. "Log.startViolationsReport": Log.startViolationsReportReturnValue; "Log.stopViolationsReport": Log.stopViolationsReportReturnValue; "Memory.getDOMCounters": Memory.getDOMCountersReturnValue; + "Memory.getDOMCountersForLeakDetection": Memory.getDOMCountersForLeakDetectionReturnValue; "Memory.prepareForLeakDetection": Memory.prepareForLeakDetectionReturnValue; "Memory.forciblyPurgeJavaScriptMemory": Memory.forciblyPurgeJavaScriptMemoryReturnValue; "Memory.setPressureNotificationsSuppressed": Memory.setPressureNotificationsSuppressedReturnValue; diff --git a/packages/playwright-core/src/server/codegen/csharp.ts b/packages/playwright-core/src/server/codegen/csharp.ts index 2244a372fc..13a5f17be8 100644 --- a/packages/playwright-core/src/server/codegen/csharp.ts +++ b/packages/playwright-core/src/server/codegen/csharp.ts @@ -68,7 +68,7 @@ export class CSharpLanguageGenerator implements LanguageGenerator { return formatter.format(); } - const locators = actionInContext.frame.framePath.map(selector => `.${this._asLocator(selector)}.ContentFrame()`); + const locators = actionInContext.frame.framePath.map(selector => `.${this._asLocator(selector)}.ContentFrame`); const subject = `${pageAlias}${locators.join('')}`; const signals = toSignalMap(action); diff --git a/packages/playwright-core/src/server/codegen/language.ts b/packages/playwright-core/src/server/codegen/language.ts index 4b1ba99b6f..7ee775b18b 100644 --- a/packages/playwright-core/src/server/codegen/language.ts +++ b/packages/playwright-core/src/server/codegen/language.ts @@ -20,7 +20,6 @@ import type * as types from '../types'; import type { ActionInContext, LanguageGenerator, LanguageGeneratorOptions } from './types'; export function generateCode(actions: ActionInContext[], languageGenerator: LanguageGenerator, options: LanguageGeneratorOptions) { - actions = collapseActions(actions); const header = languageGenerator.generateHeader(options); const footer = languageGenerator.generateFooter(options.saveStorage); const actionTexts = actions.map(a => languageGenerator.generateAction(a)).filter(Boolean); @@ -70,6 +69,23 @@ export function toKeyboardModifiers(modifiers: number): types.SmartKeyboardModif return result; } +export function fromKeyboardModifiers(modifiers?: types.SmartKeyboardModifier[]): number { + let result = 0; + if (!modifiers) + return result; + if (modifiers.includes('Alt')) + result |= 1; + if (modifiers.includes('Control')) + result |= 2; + if (modifiers.includes('ControlOrMeta')) + result |= 2; + if (modifiers.includes('Meta')) + result |= 4; + if (modifiers.includes('Shift')) + result |= 8; + return result; +} + export function toClickOptionsForSourceCode(action: actions.ClickAction): types.MouseClickOptions { const modifiers = toKeyboardModifiers(action.modifiers); const options: types.MouseClickOptions = {}; @@ -84,19 +100,3 @@ export function toClickOptionsForSourceCode(action: actions.ClickAction): types. options.position = action.position; return options; } - -function collapseActions(actions: ActionInContext[]): ActionInContext[] { - const result: ActionInContext[] = []; - for (const action of actions) { - const lastAction = result[result.length - 1]; - const isSameAction = lastAction && lastAction.action.name === action.action.name && lastAction.frame.pageAlias === action.frame.pageAlias && lastAction.frame.framePath.join('|') === action.frame.framePath.join('|'); - const isSameSelector = lastAction && 'selector' in lastAction.action && 'selector' in action.action && action.action.selector === lastAction.action.selector; - const shouldMerge = isSameAction && (action.action.name === 'navigate' || (action.action.name === 'fill' && isSameSelector)); - if (!shouldMerge) { - result.push(action); - continue; - } - result[result.length - 1] = action; - } - return result; -} diff --git a/packages/playwright-core/src/server/codegen/python.ts b/packages/playwright-core/src/server/codegen/python.ts index 6c2b60dc70..aadd455b3c 100644 --- a/packages/playwright-core/src/server/codegen/python.ts +++ b/packages/playwright-core/src/server/codegen/python.ts @@ -55,7 +55,7 @@ export class PythonLanguageGenerator implements LanguageGenerator { return formatter.format(); } - const locators = actionInContext.frame.framePath.map(selector => `.${this._asLocator(selector)}.content_frame()`); + const locators = actionInContext.frame.framePath.map(selector => `.${this._asLocator(selector)}.content_frame`); const subject = `${pageAlias}${locators.join('')}`; const signals = toSignalMap(action); diff --git a/packages/playwright-core/src/server/debugController.ts b/packages/playwright-core/src/server/debugController.ts index 2a950d7c6a..53c6c3d99e 100644 --- a/packages/playwright-core/src/server/debugController.ts +++ b/packages/playwright-core/src/server/debugController.ts @@ -197,7 +197,7 @@ export class DebugController extends SdkObject { const contexts = new Set(); for (const page of this._playwright.allPages()) contexts.add(page.context()); - const result = await Promise.all([...contexts].map(c => Recorder.show(c, () => Promise.resolve(new InspectingRecorderApp(this)), { omitCallTracking: true }))); + const result = await Promise.all([...contexts].map(c => Recorder.showInspector(c, { omitCallTracking: true }, () => Promise.resolve(new InspectingRecorderApp(this))))); return result.filter(Boolean) as Recorder[]; } diff --git a/packages/playwright-core/src/server/deviceDescriptorsSource.json b/packages/playwright-core/src/server/deviceDescriptorsSource.json index efb2801f2c..bd9b4021ed 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/129.0.6668.42 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.6 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/129.0.6668.42 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.6 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/129.0.6668.42 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.6 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/129.0.6668.42 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.6 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/129.0.6668.42 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.6 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/129.0.6668.42 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.6 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/129.0.6668.42 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.6 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/129.0.6668.42 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.6 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/129.0.6668.42 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/130.0.6723.6 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/129.0.6668.42 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/130.0.6723.6 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/129.0.6668.42 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/130.0.6723.6 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 640, "height": 360 @@ -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/129.0.6668.42 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/130.0.6723.6 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 360, "height": 640 @@ -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/129.0.6668.42 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/130.0.6723.6 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/129.0.6668.42 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/130.0.6723.6 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/129.0.6668.42 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.6 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/129.0.6668.42 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.6 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/129.0.6668.42 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.6 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/129.0.6668.42 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.6 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/129.0.6668.42 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.6 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/129.0.6668.42 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.6 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/129.0.6668.42 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/130.0.6723.6 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/129.0.6668.42 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/130.0.6723.6 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/129.0.6668.42 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.6 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/129.0.6668.42 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.6 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/129.0.6668.42 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/130.0.6723.6 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/129.0.6668.42 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/130.0.6723.6 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/129.0.6668.42 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.6 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/129.0.6668.42 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.6 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/129.0.6668.42 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/130.0.6723.6 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/129.0.6668.42 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/130.0.6723.6 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/129.0.6668.42 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/130.0.6723.6 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/129.0.6668.42 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/130.0.6723.6 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/129.0.6668.42 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/130.0.6723.6 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/129.0.6668.42 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/130.0.6723.6 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/129.0.6668.42 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.6 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/129.0.6668.42 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.6 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/129.0.6668.42 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.6 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/129.0.6668.42 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.6 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/129.0.6668.42 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.6 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/129.0.6668.42 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.6 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/129.0.6668.42 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.6 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/129.0.6668.42 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.6 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/129.0.6668.42 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.6 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/129.0.6668.42 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.6 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/129.0.6668.42 Safari/537.36", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.6 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/129.0.6668.42 Safari/537.36 Edg/129.0.6668.42", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.6 Safari/537.36 Edg/130.0.6723.6", "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/129.0.6668.42 Safari/537.36", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.6 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/129.0.6668.42 Safari/537.36 Edg/129.0.6668.42", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.6 Safari/537.36 Edg/130.0.6723.6", "screen": { "width": 1920, "height": 1080 diff --git a/packages/playwright-core/src/server/dialog.ts b/packages/playwright-core/src/server/dialog.ts index 51dcfc2fc9..f0793d43fb 100644 --- a/packages/playwright-core/src/server/dialog.ts +++ b/packages/playwright-core/src/server/dialog.ts @@ -39,6 +39,7 @@ export class Dialog extends SdkObject { this._onHandle = onHandle; this._defaultValue = defaultValue || ''; this._page._frameManager.dialogDidOpen(this); + this.instrumentation.onDialog(this); } page() { diff --git a/packages/playwright-core/src/server/dispatchers/DEPS.list b/packages/playwright-core/src/server/dispatchers/DEPS.list index 4a9ce35a5a..de1039af05 100644 --- a/packages/playwright-core/src/server/dispatchers/DEPS.list +++ b/packages/playwright-core/src/server/dispatchers/DEPS.list @@ -1,5 +1,6 @@ [*] ../../common/ +../../generated/ ../../protocol/ ../../utils/ ../../zipBundle.ts diff --git a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts index 5c8fa550a7..c6ffce49f7 100644 --- a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts @@ -41,12 +41,14 @@ import { serializeError } from '../errors'; import { ElementHandleDispatcher } from './elementHandlerDispatcher'; import { RecorderInTraceViewer } from '../recorder/recorderInTraceViewer'; import { RecorderApp } from '../recorder/recorderApp'; +import { WebSocketRouteDispatcher } from './webSocketRouteDispatcher'; export class BrowserContextDispatcher extends Dispatcher implements channels.BrowserContextChannel { _type_EventTarget = true; _type_BrowserContext = true; private _context: BrowserContext; private _subscriptions = new Set(); + _webSocketInterceptionPatterns: channels.BrowserContextSetWebSocketInterceptionPatternsParams['patterns'] = []; constructor(parentScope: DispatcherScope, context: BrowserContext) { // We will reparent these to the context below. @@ -283,6 +285,12 @@ export class BrowserContextDispatcher extends Dispatcher { + this._webSocketInterceptionPatterns = params.patterns; + if (params.patterns.length) + await WebSocketRouteDispatcher.installIfNeeded(this, this._context); + } + async storageState(params: channels.BrowserContextStorageStateParams, metadata: CallMetadata): Promise { return await this._context.storageState(); } @@ -292,9 +300,18 @@ export class BrowserContextDispatcher extends Dispatcher { - const factory = process.env.PW_RECORDER_IS_TRACE_VIEWER ? RecorderInTraceViewer.factory(this._context) : RecorderApp.factory(this._context); - await Recorder.show(this._context, factory, params); + async enableRecorder(params: channels.BrowserContextEnableRecorderParams): Promise { + if (params.codegenMode === 'trace-events') { + await this._context.tracing.start({ + name: 'trace', + snapshots: true, + screenshots: true, + live: true, + }); + await Recorder.show('trace-events', this._context, RecorderInTraceViewer.factory(this._context), params); + } else { + await Recorder.show('actions', this._context, RecorderApp.factory(this._context), params); + } } async pause(params: channels.BrowserContextPauseParams, metadata: CallMetadata) { diff --git a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts index a97ddbf1f0..265e37bd45 100644 --- a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts @@ -35,12 +35,14 @@ import { ArtifactDispatcher } from './artifactDispatcher'; import type { Download } from '../download'; import { createGuid, urlMatches } from '../../utils'; import type { BrowserContextDispatcher } from './browserContextDispatcher'; +import { WebSocketRouteDispatcher } from './webSocketRouteDispatcher'; export class PageDispatcher extends Dispatcher implements channels.PageChannel { _type_EventTarget = true; _type_Page = true; private _page: Page; _subscriptions = new Set(); + _webSocketInterceptionPatterns: channels.PageSetWebSocketInterceptionPatternsParams['patterns'] = []; static from(parentScope: BrowserContextDispatcher, page: Page): PageDispatcher { return PageDispatcher.fromNullable(parentScope, page)!; @@ -186,6 +188,12 @@ export class PageDispatcher extends Dispatcher { + this._webSocketInterceptionPatterns = params.patterns; + if (params.patterns.length) + await WebSocketRouteDispatcher.installIfNeeded(this.parentScope(), this._page); + } + async expectScreenshot(params: channels.PageExpectScreenshotParams, metadata: CallMetadata): Promise { const mask: { frame: Frame, selector: string }[] = (params.mask || []).map(({ frame, selector }) => ({ frame: (frame as FrameDispatcher)._object, diff --git a/packages/playwright-core/src/server/dispatchers/webSocketRouteDispatcher.ts b/packages/playwright-core/src/server/dispatchers/webSocketRouteDispatcher.ts new file mode 100644 index 0000000000..cc3f62cb11 --- /dev/null +++ b/packages/playwright-core/src/server/dispatchers/webSocketRouteDispatcher.ts @@ -0,0 +1,150 @@ +/** + * 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 type { BrowserContext } from '../browserContext'; +import type { Frame } from '../frames'; +import { Page } from '../page'; +import type * as channels from '@protocol/channels'; +import { Dispatcher } from './dispatcher'; +import { createGuid, urlMatches } from '../../utils'; +import { PageDispatcher } from './pageDispatcher'; +import type { BrowserContextDispatcher } from './browserContextDispatcher'; +import * as webSocketMockSource from '../../generated/webSocketMockSource'; +import type * as ws from '../injected/webSocketMock'; +import { eventsHelper } from '../../utils/eventsHelper'; + +const kBindingInstalledSymbol = Symbol('webSocketRouteBindingInstalled'); +const kInitScriptInstalledSymbol = Symbol('webSocketRouteInitScriptInstalled'); + +export class WebSocketRouteDispatcher extends Dispatcher<{ guid: string }, channels.WebSocketRouteChannel, PageDispatcher | BrowserContextDispatcher> implements channels.WebSocketRouteChannel { + _type_WebSocketRoute = true; + private _id: string; + private _frame: Frame; + private static _idToDispatcher = new Map(); + + constructor(scope: PageDispatcher | BrowserContextDispatcher, id: string, url: string, frame: Frame) { + super(scope, { guid: 'webSocketRoute@' + createGuid() }, 'WebSocketRoute', { url }); + this._id = id; + this._frame = frame; + this._eventListeners.push( + // When the frame navigates or detaches, there will be no more communication + // from the mock websocket, so pretend like it was closed. + eventsHelper.addEventListener(frame._page, Page.Events.InternalFrameNavigatedToNewDocument, (frame: Frame) => { + if (frame === this._frame) + this._onClose(); + }), + eventsHelper.addEventListener(frame._page, Page.Events.FrameDetached, (frame: Frame) => { + if (frame === this._frame) + this._onClose(); + }), + eventsHelper.addEventListener(frame._page, Page.Events.Close, () => this._onClose()), + eventsHelper.addEventListener(frame._page, Page.Events.Crash, () => this._onClose()), + ); + WebSocketRouteDispatcher._idToDispatcher.set(this._id, this); + (scope as any)._dispatchEvent('webSocketRoute', { webSocketRoute: this }); + } + + static async installIfNeeded(contextDispatcher: BrowserContextDispatcher, target: Page | BrowserContext) { + const context = target instanceof Page ? target.context() : target; + if (!(context as any)[kBindingInstalledSymbol]) { + (context as any)[kBindingInstalledSymbol] = true; + + await context.exposeBinding('__pwWebSocketBinding', false, (source, payload: ws.BindingPayload) => { + if (payload.type === 'onCreate') { + const pageDispatcher = PageDispatcher.fromNullable(contextDispatcher, source.page); + let scope: PageDispatcher | BrowserContextDispatcher | undefined; + if (pageDispatcher && matchesPattern(pageDispatcher, context._options.baseURL, payload.url)) + scope = pageDispatcher; + else if (matchesPattern(contextDispatcher, context._options.baseURL, payload.url)) + scope = contextDispatcher; + if (scope) { + new WebSocketRouteDispatcher(scope, payload.id, payload.url, source.frame); + } else { + const request: ws.PassthroughRequest = { id: payload.id, type: 'passthrough' }; + source.frame.evaluateExpression(`globalThis.__pwWebSocketDispatch(${JSON.stringify(request)})`).catch(() => {}); + } + return; + } + + const dispatcher = WebSocketRouteDispatcher._idToDispatcher.get(payload.id); + if (payload.type === 'onMessageFromPage') + dispatcher?._dispatchEvent('messageFromPage', { message: payload.data.data, isBase64: payload.data.isBase64 }); + if (payload.type === 'onMessageFromServer') + dispatcher?._dispatchEvent('messageFromServer', { message: payload.data.data, isBase64: payload.data.isBase64 }); + if (payload.type === 'onClose') + dispatcher?._onClose(); + }); + } + + if (!(target as any)[kInitScriptInstalledSymbol]) { + (target as any)[kInitScriptInstalledSymbol] = true; + await target.addInitScript(` + (() => { + const module = {}; + ${webSocketMockSource.source} + (module.exports.inject())(globalThis); + })(); + `); + } + } + + async connect(params: channels.WebSocketRouteConnectParams) { + await this._evaluateAPIRequest({ id: this._id, type: 'connect' }); + } + + async ensureOpened(params: channels.WebSocketRouteEnsureOpenedParams) { + await this._evaluateAPIRequest({ id: this._id, type: 'ensureOpened' }); + } + + async sendToPage(params: channels.WebSocketRouteSendToPageParams) { + await this._evaluateAPIRequest({ id: this._id, type: 'sendToPage', data: { data: params.message, isBase64: params.isBase64 } }); + } + + async sendToServer(params: channels.WebSocketRouteSendToServerParams) { + await this._evaluateAPIRequest({ id: this._id, type: 'sendToServer', data: { data: params.message, isBase64: params.isBase64 } }); + } + + async close(params: channels.WebSocketRouteCloseParams) { + await this._evaluateAPIRequest({ id: this._id, type: 'close', code: params.code, reason: params.reason, wasClean: true }); + } + + private async _evaluateAPIRequest(request: ws.APIRequest) { + await this._frame.evaluateExpression(`globalThis.__pwWebSocketDispatch(${JSON.stringify(request)})`).catch(() => {}); + } + + override _onDispose() { + WebSocketRouteDispatcher._idToDispatcher.delete(this._id); + } + + _onClose() { + // We could enter here twice upon page closure: + // - first from the recursive dispose inintiated by PageDispatcher; + // - then from our own page.on('close') listener. + if (this._disposed) + return; + this._dispatchEvent('close'); + this._dispose(); + } +} + +function matchesPattern(dispatcher: PageDispatcher | BrowserContextDispatcher, baseURL: string | undefined, url: string) { + for (const pattern of dispatcher._webSocketInterceptionPatterns || []) { + const urlMatch = pattern.regexSource ? new RegExp(pattern.regexSource, pattern.regexFlags) : pattern.glob; + if (urlMatches(baseURL, url, urlMatch)) + return true; + } + return false; +} diff --git a/packages/playwright-core/src/server/download.ts b/packages/playwright-core/src/server/download.ts index f7a92c8c7d..78a9c015dc 100644 --- a/packages/playwright-core/src/server/download.ts +++ b/packages/playwright-core/src/server/download.ts @@ -35,16 +35,25 @@ export class Download { this._suggestedFilename = suggestedFilename; page._browserContext._downloads.add(this); if (suggestedFilename !== undefined) - this._page.emit(Page.Events.Download, this); + this._fireDownloadEvent(); + } + + page(): Page { + return this._page; } _filenameSuggested(suggestedFilename: string) { assert(this._suggestedFilename === undefined); this._suggestedFilename = suggestedFilename; - this._page.emit(Page.Events.Download, this); + this._fireDownloadEvent(); } suggestedFilename(): string { return this._suggestedFilename!; } + + private _fireDownloadEvent() { + this._page.instrumentation.onDownload(this._page, this); + this._page.emit(Page.Events.Download, this); + } } diff --git a/packages/playwright-core/src/server/fetch.ts b/packages/playwright-core/src/server/fetch.ts index 2aa7f27fee..f07f8c63f3 100644 --- a/packages/playwright-core/src/server/fetch.ts +++ b/packages/playwright-core/src/server/fetch.ts @@ -41,6 +41,7 @@ import type * as types from './types'; import type { HeadersArray, ProxySettings } from './types'; import { getMatchingTLSOptionsForOrigin, rewriteOpenSSLErrorIfNeeded } from './socksClientCertificatesInterceptor'; import type * as har from '@trace/har'; +import { TLSSocket } from 'tls'; type FetchRequestOptions = { userAgent: string; @@ -73,6 +74,9 @@ export type APIRequestFinishedEvent = { statusMessage: string; body?: Buffer; timings: har.Timings; + serverIPAddress?: string; + serverPort?: number; + securityDetails?: har.SecurityDetails; }; type SendRequestOptions = https.RequestOptions & { @@ -259,6 +263,7 @@ export abstract class APIRequestContext extends SdkObject { try { return await this._sendRequest(progress, url, options, postData); } catch (e) { + e = rewriteOpenSSLErrorIfNeeded(e); if (maxRetries === 0) throw e; if (i === maxRetries || (options.deadline && monotonicTime() + backoff > options.deadline)) @@ -302,6 +307,10 @@ export abstract class APIRequestContext extends SdkObject { let tcpConnectionAt: number | undefined; let tlsHandshakeAt: number | undefined; let requestFinishAt: number | undefined; + let serverIPAddress: string | undefined; + let serverPort: number | undefined; + + let securityDetails: har.SecurityDetails | undefined; const request = requestConstructor(url, requestOptions as any, async response => { const responseAt = monotonicTime(); @@ -328,6 +337,9 @@ export abstract class APIRequestContext extends SdkObject { cookies, body, timings, + serverIPAddress, + serverPort, + securityDetails, }; this.emit(APIRequestContext.Events.RequestFinished, requestFinishedEvent); }; @@ -464,7 +476,7 @@ export abstract class APIRequestContext extends SdkObject { body.on('data', chunk => chunks.push(chunk)); body.on('end', notifyBodyFinished); }); - request.on('error', error => reject(rewriteOpenSSLErrorIfNeeded(error))); + request.on('error', reject); const disposeListener = () => { reject(new Error('Request context disposed.')); @@ -482,7 +494,23 @@ export abstract class APIRequestContext extends SdkObject { // non-happy-eyeballs sockets socket.on('lookup', () => { dnsLookupAt = monotonicTime(); }); socket.on('connect', () => { tcpConnectionAt = monotonicTime(); }); - socket.on('secureConnect', () => { tlsHandshakeAt = monotonicTime(); }); + socket.on('secureConnect', () => { + tlsHandshakeAt = monotonicTime(); + + if (socket instanceof TLSSocket) { + const peerCertificate = socket.getPeerCertificate(); + securityDetails = { + protocol: socket.getProtocol() ?? undefined, + subjectName: peerCertificate.subject.CN, + validFrom: new Date(peerCertificate.valid_from).getTime() / 1000, + validTo: new Date(peerCertificate.valid_to).getTime() / 1000, + issuer: peerCertificate.issuer.CN + }; + } + }); + + serverIPAddress = socket.remoteAddress; + serverPort = socket.remotePort; }); request.on('finish', () => { requestFinishAt = monotonicTime(); }); diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index 3b952ea02a..b7b626d09f 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -782,13 +782,16 @@ export class Frame extends SdkObject { throw new Error(`state: expected one of (attached|detached|visible|hidden)`); return controller.run(async progress => { progress.log(`waiting for ${this._asLocator(selector)}${state === 'attached' ? '' : ' to be ' + state}`); - return await this.waitForSelectorInternal(progress, selector, options, scope); + return await this.waitForSelectorInternal(progress, selector, true, options, scope); }, this._page._timeoutSettings.timeout(options)); } - async waitForSelectorInternal(progress: Progress, selector: string, options: types.WaitForElementOptions, scope?: dom.ElementHandle): Promise | null> { + async waitForSelectorInternal(progress: Progress, selector: string, performLocatorHandlersCheckpoint: boolean, options: types.WaitForElementOptions, scope?: dom.ElementHandle): Promise | null> { const { state = 'visible' } = options; const promise = this.retryWithProgressAndTimeouts(progress, [0, 20, 50, 100, 100, 500], async continuePolling => { + if (performLocatorHandlersCheckpoint) + await this._page.performLocatorHandlersCheckpoint(progress); + const resolved = await this.selectors.resolveInjectedForSelector(selector, options, scope); progress.throwIfAborted(); if (!resolved) { diff --git a/packages/playwright-core/src/server/har/harTracer.ts b/packages/playwright-core/src/server/har/harTracer.ts index 76da6682d4..b4b90d7976 100644 --- a/packages/playwright-core/src/server/har/harTracer.ts +++ b/packages/playwright-core/src/server/har/harTracer.ts @@ -213,11 +213,19 @@ export class HarTracer { harEntry.response.httpVersion = event.httpVersion; harEntry.response.redirectURL = event.headers.location || ''; + if (!this._options.omitServerIP) { + harEntry.serverIPAddress = event.serverIPAddress; + harEntry._serverPort = event.serverPort; + } + if (!this._options.omitTiming) { harEntry.timings = event.timings; this._computeHarEntryTotalTime(harEntry); } + if (!this._options.omitSecurityDetails) + harEntry._securityDetails = event.securityDetails; + for (let i = 0; i < event.rawHeaders.length; i += 2) { harEntry.response.headers.push({ name: event.rawHeaders[i], @@ -236,6 +244,8 @@ export class HarTracer { if (contentType) content.mimeType = contentType; this._storeResponseContent(event.body, content, 'other'); + if (!this._options.omitSizes) + harEntry.response.bodySize = event.body?.length ?? 0; if (this._started) this._delegate.onEntryFinished(harEntry); diff --git a/packages/playwright-core/src/server/injected/recorder/recorder.ts b/packages/playwright-core/src/server/injected/recorder/recorder.ts index dfbd4608f0..76d5791b64 100644 --- a/packages/playwright-core/src/server/injected/recorder/recorder.ts +++ b/packages/playwright-core/src/server/injected/recorder/recorder.ts @@ -1039,9 +1039,12 @@ export class Recorder { this.highlight.install(); // some frameworks erase the DOM on hydration, this ensures it's reattached - const recreationInterval = setInterval(() => { + let recreationInterval: number | undefined; + const recreate = () => { this.highlight.install(); - }, 500); + recreationInterval = this.injectedScript.builtinSetTimeout(recreate, 500); + }; + recreationInterval = this.injectedScript.builtinSetTimeout(recreate, 500); this._listeners.push(() => clearInterval(recreationInterval)); this.overlay?.install(); diff --git a/packages/playwright-core/src/server/injected/webSocketMock.ts b/packages/playwright-core/src/server/injected/webSocketMock.ts new file mode 100644 index 0000000000..9c42dbef80 --- /dev/null +++ b/packages/playwright-core/src/server/injected/webSocketMock.ts @@ -0,0 +1,343 @@ +/** + * 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. + */ + +export type WebSocketMessage = string | ArrayBufferLike | Blob | ArrayBufferView; +export type WSData = { data: string, isBase64: boolean }; + +export type OnCreatePayload = { type: 'onCreate', id: string, url: string }; +export type OnMessageFromPagePayload = { type: 'onMessageFromPage', id: string, data: WSData }; +export type OnClosePayload = { type: 'onClose', id: string, code: number | undefined, reason: string | undefined, wasClean: boolean }; +export type OnMessageFromServerPayload = { type: 'onMessageFromServer', id: string, data: WSData }; +export type BindingPayload = OnCreatePayload | OnMessageFromPagePayload | OnMessageFromServerPayload | OnClosePayload; + +export type ConnectRequest = { type: 'connect', id: string }; +export type PassthroughRequest = { type: 'passthrough', id: string }; +export type EnsureOpenedRequest = { type: 'ensureOpened', id: string }; +export type SendToPageRequest = { type: 'sendToPage', id: string, data: WSData }; +export type SendToServerRequest = { type: 'sendToServer', id: string, data: WSData }; +export type CloseRequest = { type: 'close', id: string, code: number | undefined, reason: string | undefined, wasClean: boolean }; +export type APIRequest = ConnectRequest | PassthroughRequest | EnsureOpenedRequest | SendToPageRequest | SendToServerRequest | CloseRequest; + +// eslint-disable-next-line no-restricted-globals +type GlobalThis = typeof globalThis; + +export function inject(globalThis: GlobalThis) { + if ((globalThis as any).__pwWebSocketDispatch) + return; + + function generateId() { + const bytes = new Uint8Array(32); + globalThis.crypto.getRandomValues(bytes); + const hex = '0123456789abcdef'; + return [...bytes].map(value => { + const high = Math.floor(value / 16); + const low = value % 16; + return hex[high] + hex[low]; + }).join(''); + } + + function bufferToData(b: Uint8Array): WSData { + let s = ''; + for (let i = 0; i < b.length; i++) + s += String.fromCharCode(b[i]); + return { data: globalThis.btoa(s), isBase64: true }; + } + + function stringToBuffer(s: string): ArrayBuffer { + s = globalThis.atob(s); + const b = new Uint8Array(s.length); + for (let i = 0; i < s.length; i++) + b[i] = s.charCodeAt(i); + return b.buffer; + } + + // Note: this function tries to be synchronous when it can to preserve the ability to send + // multiple messages synchronously in the same order and then synchronously close. + function messageToData(message: WebSocketMessage, cb: (data: WSData) => any) { + if (message instanceof globalThis.Blob) + return message.arrayBuffer().then(buffer => cb(bufferToData(new Uint8Array(buffer)))); + if (typeof message === 'string') + return cb({ data: message, isBase64: false }); + if (ArrayBuffer.isView(message)) + return cb(bufferToData(new Uint8Array(message.buffer, message.byteOffset, message.byteLength))); + return cb(bufferToData(new Uint8Array(message))); + } + + function dataToMessage(data: WSData, binaryType: 'blob' | 'arraybuffer'): WebSocketMessage { + if (!data.isBase64) + return data.data; + const buffer = stringToBuffer(data.data); + return binaryType === 'arraybuffer' ? buffer : new Blob([buffer]); + } + + const binding = (globalThis as any).__pwWebSocketBinding as (message: BindingPayload) => void; + const NativeWebSocket: typeof WebSocket = globalThis.WebSocket; + const idToWebSocket = new Map(); + (globalThis as any).__pwWebSocketDispatch = (request: APIRequest) => { + const ws = idToWebSocket.get(request.id); + if (!ws) + return; + if (request.type === 'connect') + ws._apiConnect(); + if (request.type === 'passthrough') + ws._apiPassThrough(); + if (request.type === 'ensureOpened') + ws._apiEnsureOpened(); + if (request.type === 'sendToPage') + ws._apiSendToPage(dataToMessage(request.data, ws.binaryType)); + if (request.type === 'close') + ws._apiClose(request.code, request.reason, request.wasClean); + if (request.type === 'sendToServer') + ws._apiSendToServer(dataToMessage(request.data, ws.binaryType)); + }; + + class WebSocketMock extends EventTarget { + static readonly CONNECTING: 0 = 0; // WebSocket.CONNECTING + static readonly OPEN: 1 = 1; // WebSocket.OPEN + static readonly CLOSING: 2 = 2; // WebSocket.CLOSING + static readonly CLOSED: 3 = 3; // WebSocket.CLOSED + + CONNECTING: 0 = 0; // WebSocket.CONNECTING + OPEN: 1 = 1; // WebSocket.OPEN + CLOSING: 2 = 2; // WebSocket.CLOSING + CLOSED: 3 = 3; // WebSocket.CLOSED + + private _oncloseListener: WebSocket['onclose'] = null; + private _onerrorListener: WebSocket['onerror'] = null; + private _onmessageListener: WebSocket['onmessage'] = null; + private _onopenListener: WebSocket['onopen'] = null; + + bufferedAmount: number = 0; + extensions: string = ''; + protocol: string = ''; + readyState: number = 0; + readonly url: string; + + private _id: string; + private _origin: string = ''; + private _protocols?: string | string[]; + private _ws?: WebSocket; + private _passthrough = false; + private _wsBufferedMessages: WebSocketMessage[] = []; + private _binaryType: BinaryType = 'blob'; + + constructor(url: string | URL, protocols?: string | string[]) { + super(); + + this.url = typeof url === 'string' ? url : url.href; + try { + this._origin = new URL(url).origin; + } catch { + } + this._protocols = protocols; + + this._id = generateId(); + idToWebSocket.set(this._id, this); + binding({ type: 'onCreate', id: this._id, url: this.url }); + } + + // --- native WebSocket implementation --- + + get binaryType() { + return this._binaryType; + } + + set binaryType(type) { + this._binaryType = type; + if (this._ws) + this._ws.binaryType = type; + } + + get onclose() { + return this._oncloseListener; + } + + set onclose(listener) { + if (this._oncloseListener) + this.removeEventListener('close', this._oncloseListener as any); + this._oncloseListener = listener; + if (this._oncloseListener) + this.addEventListener('close', this._oncloseListener as any); + } + + get onerror() { + return this._onerrorListener; + } + + set onerror(listener) { + if (this._onerrorListener) + this.removeEventListener('error', this._onerrorListener); + this._onerrorListener = listener; + if (this._onerrorListener) + this.addEventListener('error', this._onerrorListener); + } + + get onopen() { + return this._onopenListener; + } + + set onopen(listener) { + if (this._onopenListener) + this.removeEventListener('open', this._onopenListener); + this._onopenListener = listener; + if (this._onopenListener) + this.addEventListener('open', this._onopenListener); + } + + get onmessage() { + return this._onmessageListener; + } + + set onmessage(listener) { + if (this._onmessageListener) + this.removeEventListener('message', this._onmessageListener as any); + this._onmessageListener = listener; + if (this._onmessageListener) + this.addEventListener('message', this._onmessageListener as any); + } + + send(message: WebSocketMessage): void { + if (this.readyState === WebSocketMock.CONNECTING) + throw new DOMException(`Failed to execute 'send' on 'WebSocket': Still in CONNECTING state.`); + if (this.readyState !== WebSocketMock.OPEN) + throw new DOMException(`WebSocket is already in CLOSING or CLOSED state.`); + if (this._passthrough) + this._apiSendToServer(message); + else + messageToData(message, data => binding({ type: 'onMessageFromPage', id: this._id, data })); + } + + close(code?: number, reason?: string): void { + if (code !== undefined && code !== 1000 && (code < 3000 || code > 4999)) + throw new DOMException(`Failed to execute 'close' on 'WebSocket': The close code must be either 1000, or between 3000 and 4999. ${code} is neither.`); + if (this.readyState === WebSocketMock.OPEN || this.readyState === WebSocketMock.CONNECTING) + this.readyState = WebSocketMock.CLOSING; + if (this._ws) + this._ws.close(code, reason); + else + this._onWSClose(code, reason, true); + } + + // --- methods called from the routing API --- + + _apiEnsureOpened() { + // This is called at the end of the route handler. If we did not connect to the server, + // assume that websocket will be fully mocked. In this case, pretend that server + // connection is established right away. + if (!this._ws) + this._ensureOpened(); + } + + _apiSendToPage(message: WebSocketMessage) { + // Calling "sendToPage()" from the route handler. Allow this for easier testing. + this._ensureOpened(); + if (this.readyState !== WebSocketMock.OPEN) + throw new DOMException(`WebSocket is already in CLOSING or CLOSED state.`); + this.dispatchEvent(new MessageEvent('message', { data: message, origin: this._origin, cancelable: true })); + } + + _apiSendToServer(message: WebSocketMessage) { + if (!this._ws) + throw new Error('Cannot send a message before connecting to the server'); + if (this._ws.readyState === WebSocketMock.CONNECTING) + this._wsBufferedMessages.push(message); + else + this._ws.send(message); + } + + _apiConnect() { + if (this._ws) + throw new Error('Can only connect to the server once'); + + this._ws = new NativeWebSocket(this.url, this._protocols); + this._ws.binaryType = this._binaryType; + + this._ws.onopen = () => { + for (const message of this._wsBufferedMessages) + this._ws!.send(message); + this._wsBufferedMessages = []; + this._ensureOpened(); + }; + + this._ws.onclose = event => { + this._onWSClose(event.code, event.reason, event.wasClean); + }; + + this._ws.onmessage = event => { + if (this._passthrough) + this._apiSendToPage(event.data); + else + messageToData(event.data, data => binding({ type: 'onMessageFromServer', id: this._id, data })); + }; + + this._ws.onerror = () => { + // We do not expose errors in the API, so short-curcuit the error event. + const event = new Event('error', { cancelable: true }); + this.dispatchEvent(event); + }; + } + + // This method connects to the server, and passes all messages through, + // as if WebSocketMock was not engaged. + _apiPassThrough() { + this._passthrough = true; + this._apiConnect(); + } + + _apiClose(code: number | undefined, reason: string | undefined, wasClean: boolean) { + if (this.readyState !== WebSocketMock.CLOSED) { + this.readyState = WebSocketMock.CLOSED; + this.dispatchEvent(new CloseEvent('close', { code, reason, wasClean, cancelable: true })); + } + // Immediately close the real WS and imitate that it has closed. + this._ws?.close(code, reason); + this._cleanupWS(); + binding({ type: 'onClose', id: this._id, code, reason, wasClean }); + idToWebSocket.delete(this._id); + } + + // --- internals --- + + _ensureOpened() { + if (this.readyState !== WebSocketMock.CONNECTING) + return; + this.readyState = WebSocketMock.OPEN; + this.dispatchEvent(new Event('open', { cancelable: true })); + } + + private _onWSClose(code: number | undefined, reason: string | undefined, wasClean: boolean) { + this._cleanupWS(); + if (this.readyState !== WebSocketMock.CLOSED) { + this.readyState = WebSocketMock.CLOSED; + this.dispatchEvent(new CloseEvent('close', { code, reason, wasClean, cancelable: true })); + } + binding({ type: 'onClose', id: this._id, code, reason, wasClean }); + idToWebSocket.delete(this._id); + } + + private _cleanupWS() { + if (!this._ws) + return; + this._ws.onopen = null; + this._ws.onclose = null; + this._ws.onmessage = null; + this._ws.onerror = null; + this._ws = undefined; + this._wsBufferedMessages = []; + } + } + globalThis.WebSocket = class WebSocket extends WebSocketMock {}; +} diff --git a/packages/playwright-core/src/server/instrumentation.ts b/packages/playwright-core/src/server/instrumentation.ts index b4628ac904..4d29be0284 100644 --- a/packages/playwright-core/src/server/instrumentation.ts +++ b/packages/playwright-core/src/server/instrumentation.ts @@ -35,6 +35,8 @@ export type Attribution = { }; import type { CallMetadata } from '@protocol/callMetadata'; +import type { Dialog } from './dialog'; +import type { Download } from './download'; export type { CallMetadata } from '@protocol/callMetadata'; export class SdkObject extends EventEmitter { @@ -62,6 +64,8 @@ export interface Instrumentation { onPageClose(page: Page): void; onBrowserOpen(browser: Browser): void; onBrowserClose(browser: Browser): void; + onDialog(dialog: Dialog): void; + onDownload(page: Page, download: Download): void; } export interface InstrumentationListener { @@ -73,6 +77,8 @@ export interface InstrumentationListener { onPageClose?(page: Page): void; onBrowserOpen?(browser: Browser): void; onBrowserClose?(browser: Browser): void; + onDialog?(dialog: Dialog): void; + onDownload?(page: Page, download: Download): void; } export function createInstrumentation(): Instrumentation { diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index aeaeb0af88..04728b681e 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -473,7 +473,7 @@ export class Page extends SdkObject { progress.throwIfAborted(); if (!handler.noWaitAfter) { progress.log(` locator handler has finished, waiting for ${asLocator(this.attribution.playwright.options.sdkLanguage, handler.selector)} to be hidden`); - await this.mainFrame().waitForSelectorInternal(progress, handler.selector, { state: 'hidden' }); + await this.mainFrame().waitForSelectorInternal(progress, handler.selector, false, { state: 'hidden' }); } else { progress.log(` locator handler has finished`); } diff --git a/packages/playwright-core/src/server/recorder.ts b/packages/playwright-core/src/server/recorder.ts index ddaa035811..19776c8a29 100644 --- a/packages/playwright-core/src/server/recorder.ts +++ b/packages/playwright-core/src/server/recorder.ts @@ -45,32 +45,35 @@ export class Recorder implements InstrumentationListener, IRecorder { private _omitCallTracking = false; private _currentLanguage: Language; - static showInspector(context: BrowserContext, recorderAppFactory: IRecorderAppFactory) { - const params: channels.BrowserContextRecorderSupplementEnableParams = {}; + static async showInspector(context: BrowserContext, params: channels.BrowserContextEnableRecorderParams, recorderAppFactory: IRecorderAppFactory) { if (isUnderTest()) params.language = process.env.TEST_INSPECTOR_LANGUAGE; - Recorder.show(context, recorderAppFactory, params).catch(() => {}); + return await Recorder.show('actions', context, recorderAppFactory, params); } - static show(context: BrowserContext, recorderAppFactory: IRecorderAppFactory, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise { + static showInspectorNoReply(context: BrowserContext, recorderAppFactory: IRecorderAppFactory) { + Recorder.showInspector(context, {}, recorderAppFactory).catch(() => {}); + } + + static show(codegenMode: 'actions' | 'trace-events', context: BrowserContext, recorderAppFactory: IRecorderAppFactory, params: channels.BrowserContextEnableRecorderParams): Promise { let recorderPromise = (context as any)[recorderSymbol] as Promise; if (!recorderPromise) { - recorderPromise = Recorder._create(context, recorderAppFactory, params); + recorderPromise = Recorder._create(codegenMode, context, recorderAppFactory, params); (context as any)[recorderSymbol] = recorderPromise; } return recorderPromise; } - private static async _create(context: BrowserContext, recorderAppFactory: IRecorderAppFactory, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise { - const recorder = new Recorder(context, params); + private static async _create(codegenMode: 'actions' | 'trace-events', context: BrowserContext, recorderAppFactory: IRecorderAppFactory, params: channels.BrowserContextEnableRecorderParams = {}): Promise { + const recorder = new Recorder(codegenMode, context, params); const recorderApp = await recorderAppFactory(recorder); await recorder._install(recorderApp); return recorder; } - constructor(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams) { + constructor(codegenMode: 'actions' | 'trace-events', context: BrowserContext, params: channels.BrowserContextEnableRecorderParams) { this._mode = params.mode || 'none'; - this._contextRecorder = new ContextRecorder(context, params, {}); + this._contextRecorder = new ContextRecorder(codegenMode, context, params, {}); this._context = context; this._omitCallTracking = !!params.omitCallTracking; this._debugger = context.debugger(); diff --git a/packages/playwright-core/src/server/recorder/contextRecorder.ts b/packages/playwright-core/src/server/recorder/contextRecorder.ts index 71a1d3ec75..dc38866167 100644 --- a/packages/playwright-core/src/server/recorder/contextRecorder.ts +++ b/packages/playwright-core/src/server/recorder/contextRecorder.ts @@ -48,14 +48,14 @@ export class ContextRecorder extends EventEmitter { private _lastDialogOrdinal = -1; private _lastDownloadOrdinal = -1; private _context: BrowserContext; - private _params: channels.BrowserContextRecorderSupplementEnableParams; + private _params: channels.BrowserContextEnableRecorderParams; private _delegate: ContextRecorderDelegate; private _recorderSources: Source[]; private _throttledOutputFile: ThrottledFile | null = null; private _orderedLanguages: LanguageGenerator[] = []; private _listeners: RegisteredListener[] = []; - constructor(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams, delegate: ContextRecorderDelegate) { + constructor(codegenMode: 'actions' | 'trace-events', context: BrowserContext, params: channels.BrowserContextEnableRecorderParams, delegate: ContextRecorderDelegate) { super(); this._context = context; this._params = params; @@ -73,11 +73,11 @@ export class ContextRecorder extends EventEmitter { saveStorage: params.saveStorage, }; - const collection = new RecorderCollection(this._pageAliases, params.mode === 'recording'); - collection.on('change', () => { + this._collection = new RecorderCollection(codegenMode, context, this._pageAliases); + this._collection.on('change', (actions: ActionInContext[]) => { this._recorderSources = []; for (const languageGenerator of this._orderedLanguages) { - const { header, footer, actionTexts, text } = generateCode(collection.actions(), languageGenerator, languageGeneratorOptions); + const { header, footer, actionTexts, text } = generateCode(actions, languageGenerator, languageGeneratorOptions); const source: Source = { isRecorded: true, label: languageGenerator.name, @@ -103,7 +103,7 @@ export class ContextRecorder extends EventEmitter { this._listeners.push(eventsHelper.addEventListener(process, 'exit', () => { this._throttledOutputFile?.flush(); })); - this._collection = collection; + this.setEnabled(true); } setOutput(codegenId: string, outputFile?: string) { @@ -145,6 +145,10 @@ export class ContextRecorder extends EventEmitter { setEnabled(enabled: boolean) { this._collection.setEnabled(enabled); + if (enabled) + this._context.tracing.startChunk({ name: 'trace', title: 'trace' }).catch(() => {}); + else + this._context.tracing.stopChunk({ mode: 'discard' }).catch(() => {}); } dispose() { diff --git a/packages/playwright-core/src/server/recorder/recorderApp.ts b/packages/playwright-core/src/server/recorder/recorderApp.ts index 67d8b6e8dc..41c92a3198 100644 --- a/packages/playwright-core/src/server/recorder/recorderApp.ts +++ b/packages/playwright-core/src/server/recorder/recorderApp.ts @@ -43,6 +43,7 @@ declare global { } export class EmptyRecorderApp extends EventEmitter implements IRecorderApp { + wsEndpointForTest: undefined; async close(): Promise {} async setPaused(paused: boolean): Promise {} async setMode(mode: Mode): Promise {} @@ -54,7 +55,7 @@ export class EmptyRecorderApp extends EventEmitter implements IRecorderApp { export class RecorderApp extends EventEmitter implements IRecorderApp { private _page: Page; - readonly wsEndpoint: string | undefined; + readonly wsEndpointForTest: string | undefined; private _recorder: IRecorder; constructor(recorder: IRecorder, page: Page, wsEndpoint: string | undefined) { @@ -62,7 +63,7 @@ export class RecorderApp extends EventEmitter implements IRecorderApp { this.setMaxListeners(0); this._recorder = recorder; this._page = page; - this.wsEndpoint = wsEndpoint; + this.wsEndpointForTest = wsEndpoint; } async close() { @@ -80,7 +81,6 @@ export class RecorderApp extends EventEmitter implements IRecorderApp { const file = require.resolve('../../vite/recorder/' + uri); fs.promises.readFile(file).then(buffer => { route.fulfill({ - requestUrl: route.request().url(), status: 200, headers: [ { name: 'Content-Type', value: mime.getType(path.extname(file)) || 'application/octet-stream' } @@ -122,9 +122,8 @@ export class RecorderApp extends EventEmitter implements IRecorderApp { persistentContextOptions: { noDefaultViewport: true, headless: !!process.env.PWTEST_CLI_HEADLESS || (isUnderTest() && !headed), - useWebSocket: !!process.env.PWTEST_RECORDER_PORT, + useWebSocket: isUnderTest(), handleSIGINT: false, - args: process.env.PWTEST_RECORDER_PORT ? [`--remote-debugging-port=${process.env.PWTEST_RECORDER_PORT}`] : [], executablePath: inspectedContext._browser.options.isChromium ? inspectedContext._browser.options.customExecutablePath : undefined, } }); @@ -162,8 +161,10 @@ export class RecorderApp extends EventEmitter implements IRecorderApp { }).toString(), { isFunction: true }, sources).catch(() => {}); // Testing harness for runCLI mode. - if (process.env.PWTEST_CLI_IS_UNDER_TEST && sources.length) - (process as any)._didSetSourcesForTest(sources[0].text); + if (process.env.PWTEST_CLI_IS_UNDER_TEST && sources.length) { + if ((process as any)._didSetSourcesForTest(sources[0].text)) + this.close(); + } } async setSelector(selector: string, userGesture?: boolean): Promise { diff --git a/packages/playwright-core/src/server/recorder/recorderCollection.ts b/packages/playwright-core/src/server/recorder/recorderCollection.ts index e9c2b31427..1706de39ee 100644 --- a/packages/playwright-core/src/server/recorder/recorderCollection.ts +++ b/packages/playwright-core/src/server/recorder/recorderCollection.ts @@ -20,31 +20,35 @@ import type { Page } from '../page'; import type { Signal } from './recorderActions'; import type { ActionInContext } from '../codegen/types'; import { monotonicTime } from '../../utils/time'; -import { callMetadataForAction } from './recorderUtils'; +import { callMetadataForAction, collapseActions, traceEventsToAction } from './recorderUtils'; import { serializeError } from '../errors'; import { performAction } from './recorderRunner'; import type { CallMetadata } from '@protocol/callMetadata'; import { isUnderTest } from '../../utils/debug'; +import type { BrowserContext } from '../browserContext'; export class RecorderCollection extends EventEmitter { private _actions: ActionInContext[] = []; - private _enabled: boolean; + private _enabled = false; private _pageAliases: Map; + private _context: BrowserContext; - constructor(pageAliases: Map, enabled: boolean) { + constructor(codegenMode: 'actions' | 'trace-events', context: BrowserContext, pageAliases: Map) { super(); - this._enabled = enabled; + this._context = context; this._pageAliases = pageAliases; - this.restart(); + + if (codegenMode === 'trace-events') { + this._context.tracing.onMemoryEvents(events => { + this._actions = traceEventsToAction(events); + this._fireChange(); + }); + } } restart() { this._actions = []; - this.emit('change'); - } - - actions() { - return this._actions; + this._fireChange(); } setEnabled(enabled: boolean) { @@ -60,7 +64,7 @@ export class RecorderCollection extends EventEmitter { addRecordedAction(actionInContext: ActionInContext) { if (['openPage', 'closePage'].includes(actionInContext.action.name)) { this._actions.push(actionInContext); - this.emit('change'); + this._fireChange(); return; } this._addAction(actionInContext).catch(() => {}); @@ -69,11 +73,16 @@ export class RecorderCollection extends EventEmitter { private async _addAction(actionInContext: ActionInContext, callback?: (callMetadata: CallMetadata) => Promise) { if (!this._enabled) return; + if (actionInContext.action.name === 'openPage' || actionInContext.action.name === 'closePage') { + this._actions.push(actionInContext); + this._fireChange(); + return; + } const { callMetadata, mainFrame } = callMetadataForAction(this._pageAliases, actionInContext); await mainFrame.instrumentation.onBeforeCall(mainFrame, callMetadata); this._actions.push(actionInContext); - this.emit('change'); + this._fireChange(); const error = await callback?.(callMetadata).catch((e: Error) => e); callMetadata.endTime = monotonicTime(); callMetadata.error = error ? serializeError(error) : undefined; @@ -116,8 +125,12 @@ export class RecorderCollection extends EventEmitter { if (this._actions.length) { this._actions[this._actions.length - 1].action.signals.push(signal); - this.emit('change'); + this._fireChange(); return; } } + + private _fireChange() { + this.emit('change', collapseActions(this._actions)); + } } diff --git a/packages/playwright-core/src/server/recorder/recorderFrontend.ts b/packages/playwright-core/src/server/recorder/recorderFrontend.ts index 162c9f9964..d2cdffdca4 100644 --- a/packages/playwright-core/src/server/recorder/recorderFrontend.ts +++ b/packages/playwright-core/src/server/recorder/recorderFrontend.ts @@ -23,6 +23,7 @@ export interface IRecorder { } export interface IRecorderApp extends EventEmitter { + readonly wsEndpointForTest: string | undefined; close(): Promise; setPaused(paused: boolean): Promise; setMode(mode: Mode): Promise; diff --git a/packages/playwright-core/src/server/recorder/recorderInTraceViewer.ts b/packages/playwright-core/src/server/recorder/recorderInTraceViewer.ts index a9fd766141..8da0896497 100644 --- a/packages/playwright-core/src/server/recorder/recorderInTraceViewer.ts +++ b/packages/playwright-core/src/server/recorder/recorderInTraceViewer.ts @@ -21,74 +21,97 @@ import type { IRecorder, IRecorderApp, IRecorderAppFactory } from './recorderFro import { installRootRedirect, openTraceViewerApp, startTraceViewerServer } from '../trace/viewer/traceViewer'; import type { TraceViewerServerOptions } from '../trace/viewer/traceViewer'; import type { BrowserContext } from '../browserContext'; -import { gracefullyProcessExitDoNotHang } from '../../utils/processLauncher'; -import type { Transport } from '../../utils/httpServer'; +import type { HttpServer, Transport } from '../../utils/httpServer'; +import type { Page } from '../page'; +import { ManualPromise } from '../../utils/manualPromise'; export class RecorderInTraceViewer extends EventEmitter implements IRecorderApp { - private _recorder: IRecorder; - private _transport: Transport; + readonly wsEndpointForTest: string | undefined; + private _transport: RecorderTransport; + private _tracePage: Page; + private _traceServer: HttpServer; static factory(context: BrowserContext): IRecorderAppFactory { return async (recorder: IRecorder) => { const transport = new RecorderTransport(); const trace = path.join(context._browser.options.tracesDir, 'trace'); - await openApp(trace, { transport }); - return new RecorderInTraceViewer(context, recorder, transport); + const { wsEndpointForTest, tracePage, traceServer } = await openApp(trace, { transport, headless: !context._browser.options.headful }); + return new RecorderInTraceViewer(transport, tracePage, traceServer, wsEndpointForTest); }; } - constructor(context: BrowserContext, recorder: IRecorder, transport: Transport) { + constructor(transport: RecorderTransport, tracePage: Page, traceServer: HttpServer, wsEndpointForTest: string | undefined) { super(); - this._recorder = recorder; this._transport = transport; + this._tracePage = tracePage; + this._traceServer = traceServer; + this.wsEndpointForTest = wsEndpointForTest; + this._tracePage.once('close', () => { + this.close(); + }); } async close(): Promise { - this._transport.sendEvent?.('close', {}); + await this._tracePage.context().close({ reason: 'Recorder window closed' }); + await this._traceServer.stop(); } async setPaused(paused: boolean): Promise { - this._transport.sendEvent?.('setPaused', { paused }); + this._transport.deliverEvent('setPaused', { paused }); } async setMode(mode: Mode): Promise { - this._transport.sendEvent?.('setMode', { mode }); + this._transport.deliverEvent('setMode', { mode }); } async setFile(file: string): Promise { - this._transport.sendEvent?.('setFileIfNeeded', { file }); + this._transport.deliverEvent('setFileIfNeeded', { file }); } async setSelector(selector: string, userGesture?: boolean): Promise { - this._transport.sendEvent?.('setSelector', { selector, userGesture }); + this._transport.deliverEvent('setSelector', { selector, userGesture }); } async updateCallLogs(callLogs: CallLog[]): Promise { - this._transport.sendEvent?.('updateCallLogs', { callLogs }); + this._transport.deliverEvent('updateCallLogs', { callLogs }); } async setSources(sources: Source[]): Promise { - this._transport.sendEvent?.('setSources', { sources }); + this._transport.deliverEvent('setSources', { sources }); + if (process.env.PWTEST_CLI_IS_UNDER_TEST && sources.length) { + if ((process as any)._didSetSourcesForTest(sources[0].text)) + this.close(); + } } } -async function openApp(trace: string, options?: TraceViewerServerOptions & { headless?: boolean }) { - const server = await startTraceViewerServer(options); - await installRootRedirect(server, [trace], { ...options, webApp: 'recorder.html' }); - const page = await openTraceViewerApp(server.urlPrefix('precise'), 'chromium', options); - page.on('close', () => gracefullyProcessExitDoNotHang(0)); +async function openApp(trace: string, options?: TraceViewerServerOptions & { headless?: boolean }): Promise<{ wsEndpointForTest: string | undefined, tracePage: Page, traceServer: HttpServer }> { + const traceServer = await startTraceViewerServer(options); + await installRootRedirect(traceServer, [trace], { ...options, webApp: 'recorder.html' }); + const page = await openTraceViewerApp(traceServer.urlPrefix('precise'), 'chromium', options); + return { wsEndpointForTest: page.context()._browser.options.wsEndpoint, tracePage: page, traceServer }; } class RecorderTransport implements Transport { + private _connected = new ManualPromise(); + constructor() { } - async dispatch(method: string, params: any) { + onconnect() { + this._connected.resolve(); + } + + async dispatch(method: string, params: any): Promise { } onclose() { } + deliverEvent(method: string, params: any) { + this._connected.then(() => this.sendEvent?.(method, params)); + } + sendEvent?: (method: string, params: any) => void; close?: () => void; } diff --git a/packages/playwright-core/src/server/recorder/recorderUtils.ts b/packages/playwright-core/src/server/recorder/recorderUtils.ts index ac6c970489..3cde57052a 100644 --- a/packages/playwright-core/src/server/recorder/recorderUtils.ts +++ b/packages/playwright-core/src/server/recorder/recorderUtils.ts @@ -20,9 +20,13 @@ import type { Page } from '../page'; import type { ActionInContext } from '../codegen/types'; import type { Frame } from '../frames'; import type * as actions from './recorderActions'; -import { toKeyboardModifiers } from '../codegen/language'; +import type * as channels from '@protocol/channels'; +import type * as trace from '@trace/trace'; +import { fromKeyboardModifiers, toKeyboardModifiers } from '../codegen/language'; import { serializeExpectedTextValues } from '../../utils/expectUtils'; import { createGuid, monotonicTime } from '../../utils'; +import { parseSerializedValue, serializeValue } from '../../protocol/serializers'; +import type { SmartKeyboardModifier } from '../types'; export function metadataToCallLog(metadata: CallMetadata, status: CallLogStatus): CallLog { let title = metadata.apiName || metadata.method; @@ -76,76 +80,425 @@ export async function frameForAction(pageAliases: Map, actionInCon return result.frame; } -export function traceParamsForAction(actionInContext: ActionInContext) { +export function traceParamsForAction(actionInContext: ActionInContext): { method: string, params: any } { const { action } = actionInContext; switch (action.name) { - case 'navigate': return { url: action.url }; - case 'openPage': return {}; - case 'closePage': return {}; + case 'navigate': { + const params: channels.FrameGotoParams = { + url: action.url, + }; + return { method: 'goto', params }; + } + case 'openPage': + case 'closePage': + throw new Error('Not reached'); } const selector = buildFullSelector(actionInContext.frame.framePath, action.selector); switch (action.name) { - case 'click': return { selector, clickCount: action.clickCount }; - case 'press': { - const modifiers = toKeyboardModifiers(action.modifiers); - const shortcut = [...modifiers, action.key].join('+'); - return { selector, key: shortcut }; - } - case 'fill': return { selector, text: action.text }; - case 'setInputFiles': return { selector, files: action.files }; - case 'check': return { selector }; - case 'uncheck': return { selector }; - case 'select': return { selector, values: action.options.map(value => ({ value })) }; - case 'assertChecked': { - return { + case 'click': { + const params: channels.FrameClickParams = { selector, + strict: true, + modifiers: toKeyboardModifiers(action.modifiers), + button: action.button, + clickCount: action.clickCount, + position: action.position, + }; + return { method: 'click', params }; + } + case 'press': { + const params: channels.FramePressParams = { + selector, + strict: true, + key: [...toKeyboardModifiers(action.modifiers), action.key].join('+'), + }; + return { method: 'press', params }; + } + case 'fill': { + const params: channels.FrameFillParams = { + selector, + strict: true, + value: action.text, + }; + return { method: 'fill', params }; + } + case 'setInputFiles': { + const params: channels.FrameSetInputFilesParams = { + selector, + strict: true, + localPaths: action.files, + }; + return { method: 'setInputFiles', params }; + } + case 'check': { + const params: channels.FrameCheckParams = { + selector, + strict: true, + }; + return { method: 'check', params }; + } + case 'uncheck': { + const params: channels.FrameUncheckParams = { + selector, + strict: true, + }; + return { method: 'uncheck', params }; + } + case 'select': { + const params: channels.FrameSelectOptionParams = { + selector, + strict: true, + options: action.options.map(option => ({ value: option })), + }; + return { method: 'selectOption', params }; + } + case 'assertChecked': { + const params: channels.FrameExpectParams = { + selector: action.selector, expression: 'to.be.checked', isNot: !action.checked, }; + return { method: 'expect', params }; } case 'assertText': { - return { + const params: channels.FrameExpectParams = { selector, expression: 'to.have.text', - expectedText: serializeExpectedTextValues([action.text], { matchSubstring: true, normalizeWhiteSpace: true }), + expectedText: serializeExpectedTextValues([action.text], { matchSubstring: action.substring, normalizeWhiteSpace: true }), isNot: false, }; + return { method: 'expect', params }; } case 'assertValue': { - return { + const params: channels.FrameExpectParams = { selector, expression: 'to.have.value', - expectedValue: action.value, + expectedValue: { value: serializeValue(action.value, value => ({ fallThrough: value })), handles: [] }, isNot: false, }; + return { method: 'expect', params }; } case 'assertVisible': { - return { + const params: channels.FrameExpectParams = { selector, expression: 'to.be.visible', isNot: false, }; + return { method: 'expect', params }; } } } export function callMetadataForAction(pageAliases: Map, actionInContext: ActionInContext): { callMetadata: CallMetadata, mainFrame: Frame } { const mainFrame = mainFrameForAction(pageAliases, actionInContext); - const { action } = actionInContext; + const { method, params } = traceParamsForAction(actionInContext); + const callMetadata: CallMetadata = { id: `call@${createGuid()}`, - apiName: 'frame.' + action.name, + stepId: `recorder@${createGuid()}`, + apiName: 'page.' + method, objectId: mainFrame.guid, pageId: mainFrame._page.guid, frameId: mainFrame.guid, startTime: monotonicTime(), endTime: 0, type: 'Frame', - method: action.name, - params: traceParamsForAction(actionInContext), + method, + params, log: [], }; return { callMetadata, mainFrame }; } + +export function traceEventsToAction(events: trace.TraceEvent[]): ActionInContext[] { + const result: ActionInContext[] = []; + const pageAliases = new Map(); + let lastDownloadOrdinal = 0; + let lastDialogOrdinal = 0; + + const addSignal = (signal: actions.Signal) => { + const lastAction = result[result.length - 1]; + if (!lastAction) + return; + lastAction.action.signals.push(signal); + }; + + for (const event of events) { + if (event.type === 'event' && event.class === 'BrowserContext') { + const { method, params } = event; + if (method === 'page') { + const pageAlias = 'page' + (pageAliases.size || ''); + pageAliases.set(params.pageId, pageAlias); + addSignal({ + name: 'popup', + popupAlias: pageAlias, + }); + result.push({ + frame: { pageAlias, framePath: [] }, + action: { + name: 'openPage', + url: '', + signals: [], + }, + timestamp: event.time, + }); + continue; + } + + if (method === 'pageClosed') { + const pageAlias = pageAliases.get(event.params.pageId) || 'page'; + result.push({ + frame: { pageAlias, framePath: [] }, + action: { + name: 'closePage', + signals: [], + }, + timestamp: event.time, + }); + continue; + } + + if (method === 'download') { + const downloadAlias = lastDownloadOrdinal ? String(lastDownloadOrdinal) : ''; + ++lastDownloadOrdinal; + addSignal({ + name: 'download', + downloadAlias, + }); + continue; + } + + if (method === 'dialog') { + const dialogAlias = lastDialogOrdinal ? String(lastDialogOrdinal) : ''; + ++lastDialogOrdinal; + addSignal({ + name: 'dialog', + dialogAlias, + }); + continue; + } + continue; + } + + if (event.type !== 'before' || !event.pageId) + continue; + if (!event.stepId?.startsWith('recorder@')) + continue; + + const { method, params: untypedParams, pageId } = event; + + let pageAlias = pageAliases.get(pageId); + if (!pageAlias) { + pageAlias = 'page'; + pageAliases.set(pageId, pageAlias); + result.push({ + frame: { pageAlias, framePath: [] }, + action: { + name: 'openPage', + url: '', + signals: [], + }, + timestamp: event.startTime, + }); + } + + if (method === 'goto') { + const params = untypedParams as channels.FrameGotoParams; + result.push({ + frame: { pageAlias, framePath: [] }, + action: { + name: 'navigate', + url: params.url, + signals: [], + }, + timestamp: event.startTime, + }); + continue; + } + + if (method === 'click') { + const params = untypedParams as channels.FrameClickParams; + result.push({ + frame: { pageAlias, framePath: [] }, + action: { + name: 'click', + selector: params.selector, + signals: [], + button: params.button || 'left', + modifiers: fromKeyboardModifiers(params.modifiers), + clickCount: params.clickCount || 1, + position: params.position, + }, + timestamp: event.startTime + }); + continue; + } + if (method === 'fill') { + const params = untypedParams as channels.FrameFillParams; + result.push({ + frame: { pageAlias, framePath: [] }, + action: { + name: 'fill', + selector: params.selector, + signals: [], + text: params.value, + }, + timestamp: event.startTime + }); + continue; + } + if (method === 'press') { + const params = untypedParams as channels.FramePressParams; + const tokens = params.key.split('+'); + const modifiers = tokens.slice(0, tokens.length - 1) as SmartKeyboardModifier[]; + const key = tokens[tokens.length - 1]; + result.push({ + frame: { pageAlias, framePath: [] }, + action: { + name: 'press', + selector: params.selector, + signals: [], + key, + modifiers: fromKeyboardModifiers(modifiers), + }, + timestamp: event.startTime + }); + continue; + } + if (method === 'check') { + const params = untypedParams as channels.FrameCheckParams; + result.push({ + frame: { pageAlias, framePath: [] }, + action: { + name: 'check', + selector: params.selector, + signals: [], + }, + timestamp: event.startTime + }); + continue; + } + if (method === 'uncheck') { + const params = untypedParams as channels.FrameUncheckParams; + result.push({ + frame: { pageAlias, framePath: [] }, + action: { + name: 'uncheck', + selector: params.selector, + signals: [], + }, + timestamp: event.startTime + }); + continue; + } + if (method === 'selectOption') { + const params = untypedParams as channels.FrameSelectOptionParams; + result.push({ + frame: { pageAlias, framePath: [] }, + action: { + name: 'select', + selector: params.selector, + signals: [], + options: (params.options || []).map(option => option.value!), + }, + timestamp: event.startTime + }); + continue; + } + if (method === 'setInputFiles') { + const params = untypedParams as channels.FrameSetInputFilesParams; + result.push({ + frame: { pageAlias, framePath: [] }, + action: { + name: 'setInputFiles', + selector: params.selector, + signals: [], + files: params.localPaths || [], + }, + timestamp: event.startTime + }); + continue; + } + if (method === 'expect') { + const params = untypedParams as channels.FrameExpectParams; + if (params.expression === 'to.have.text') { + const entry = params.expectedText?.[0]; + result.push({ + frame: { pageAlias, framePath: [] }, + action: { + name: 'assertText', + selector: params.selector, + signals: [], + text: entry?.string!, + substring: !!entry?.matchSubstring, + }, + timestamp: event.startTime + }); + continue; + } + + if (params.expression === 'to.have.value') { + result.push({ + frame: { pageAlias, framePath: [] }, + action: { + name: 'assertValue', + selector: params.selector, + signals: [], + value: parseSerializedValue(params.expectedValue!.value, params.expectedValue!.handles), + }, + timestamp: event.startTime + }); + continue; + } + + if (params.expression === 'to.be.checked') { + result.push({ + frame: { pageAlias, framePath: [] }, + action: { + name: 'assertChecked', + selector: params.selector, + signals: [], + checked: !params.isNot, + }, + timestamp: event.startTime + }); + continue; + } + + if (params.expression === 'to.be.visible') { + result.push({ + frame: { pageAlias, framePath: [] }, + action: { + name: 'assertVisible', + selector: params.selector, + signals: [], + }, + timestamp: event.startTime + }); + continue; + } + + continue; + } + } + + return result; +} + +export function collapseActions(actions: ActionInContext[]): ActionInContext[] { + const result: ActionInContext[] = []; + for (const action of actions) { + const lastAction = result[result.length - 1]; + const isSameAction = lastAction && lastAction.action.name === action.action.name && lastAction.frame.pageAlias === action.frame.pageAlias && lastAction.frame.framePath.join('|') === action.frame.framePath.join('|'); + const isSameSelector = lastAction && 'selector' in lastAction.action && 'selector' in action.action && action.action.selector === lastAction.action.selector; + const shouldMerge = isSameAction && (action.action.name === 'navigate' || (action.action.name === 'fill' && isSameSelector)); + if (!shouldMerge) { + result.push(action); + continue; + } + result[result.length - 1] = action; + } + return result; +} diff --git a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts index 08aaa4f25a..b041ffb829 100644 --- a/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts +++ b/packages/playwright-core/src/server/socksClientCertificatesInterceptor.ts @@ -346,7 +346,7 @@ function rewriteToLocalhostIfNeeded(host: string): string { } export function rewriteOpenSSLErrorIfNeeded(error: Error): Error { - if (error.message !== 'unsupported') + if (error.message !== 'unsupported' && (error as NodeJS.ErrnoException).code !== 'ERR_CRYPTO_UNSUPPORTED_OPERATION') return error; return rewriteErrorMessage(error, [ 'Unsupported TLS certificate.', diff --git a/packages/playwright-core/src/server/trace/recorder/tracing.ts b/packages/playwright-core/src/server/trace/recorder/tracing.ts index b09bbe3134..83b7fe6120 100644 --- a/packages/playwright-core/src/server/trace/recorder/tracing.ts +++ b/packages/playwright-core/src/server/trace/recorder/tracing.ts @@ -38,6 +38,8 @@ import { Snapshotter } from './snapshotter'; import type { ConsoleMessage } from '../../console'; import { Dispatcher } from '../../dispatchers/dispatcher'; import { serializeError } from '../../errors'; +import type { Dialog } from '../../dialog'; +import type { Download } from '../../download'; const version: trace.VERSION = 7; @@ -79,6 +81,8 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps private _allResources = new Set(); private _contextCreatedEvent: trace.ContextCreatedTraceEvent; private _pendingHarEntries = new Set(); + private _inMemoryEvents: trace.TraceEvent[] | undefined; + private _inMemoryEventsCallback: ((events: trace.TraceEvent[]) => void) | undefined; constructor(context: BrowserContext | APIRequestContext, tracesDir: string | undefined) { super(context, 'tracing'); @@ -179,7 +183,7 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps wallTime: Date.now(), monotonicTime: monotonicTime() }; - this._fs.appendFile(this._state.traceFile, JSON.stringify(event) + '\n'); + this._appendTraceEvent(event); this._context.instrumentation.addListener(this, this._context); this._eventListeners.push( @@ -193,6 +197,11 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps return { traceName: this._state.traceName }; } + onMemoryEvents(callback: (events: trace.TraceEvent[]) => void) { + this._inMemoryEventsCallback = callback; + this._inMemoryEvents = []; + } + private _startScreencast() { if (!(this._context instanceof BrowserContext)) return; @@ -447,6 +456,50 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps this._appendTraceEvent(event); } + onDialog(dialog: Dialog) { + const event: trace.EventTraceEvent = { + type: 'event', + time: monotonicTime(), + class: 'BrowserContext', + method: 'dialog', + params: { pageId: dialog.page().guid, type: dialog.type(), message: dialog.message(), defaultValue: dialog.defaultValue() }, + }; + this._appendTraceEvent(event); + } + + onDownload(page: Page, download: Download) { + const event: trace.EventTraceEvent = { + type: 'event', + time: monotonicTime(), + class: 'BrowserContext', + method: 'download', + params: { pageId: page.guid, url: download.url, suggestedFilename: download.suggestedFilename() }, + }; + this._appendTraceEvent(event); + } + + onPageOpen(page: Page) { + const event: trace.EventTraceEvent = { + type: 'event', + time: monotonicTime(), + class: 'BrowserContext', + method: 'page', + params: { pageId: page.guid, openerPageId: page.opener()?.guid }, + }; + this._appendTraceEvent(event); + } + + onPageClose(page: Page) { + const event: trace.EventTraceEvent = { + type: 'event', + time: monotonicTime(), + class: 'BrowserContext', + method: 'pageClosed', + params: { pageId: page.guid }, + }; + this._appendTraceEvent(event); + } + private _onPageError(error: Error, page: Page) { const event: trace.EventTraceEvent = { type: 'event', @@ -487,6 +540,10 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps // Do not flush (console) events, they are too noisy, unless we are in ui mode (live). const flush = this._state!.options.live || (event.type !== 'event' && event.type !== 'console' && event.type !== 'log'); this._fs.appendFile(this._state!.traceFile, JSON.stringify(visited) + '\n', flush); + if (this._inMemoryEvents) { + this._inMemoryEvents.push(event); + this._inMemoryEventsCallback?.(this._inMemoryEvents); + } } private _appendResource(sha1: string, buffer: Buffer) { diff --git a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts index 6ca0319aa3..ee2ccac593 100644 --- a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts +++ b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts @@ -223,6 +223,9 @@ class StdinServer implements Transport { process.stdin.on('close', () => gracefullyProcessExitDoNotHang(0)); } + onconnect() { + } + async dispatch(method: string, params: any) { if (method === 'initialize') { if (this._traceUrl) diff --git a/packages/playwright-core/src/utils/httpServer.ts b/packages/playwright-core/src/utils/httpServer.ts index 24a84ea502..8da2a0e0d0 100644 --- a/packages/playwright-core/src/utils/httpServer.ts +++ b/packages/playwright-core/src/utils/httpServer.ts @@ -27,8 +27,9 @@ export type ServerRouteHandler = (request: http.IncomingMessage, response: http. export type Transport = { sendEvent?: (method: string, params: any) => void; - dispatch: (method: string, params: any) => Promise; close?: () => void; + onconnect: () => void; + dispatch: (method: string, params: any) => Promise; onclose: () => void; }; @@ -82,6 +83,7 @@ export class HttpServer { this._wsGuid = guid || createGuid(); const wss = new wsServer({ server: this._server, path: '/' + this._wsGuid }); wss.on('connection', ws => { + transport.onconnect(); transport.sendEvent = (method, params) => ws.send(JSON.stringify({ method, params })); transport.close = () => ws.close(); ws.on('message', async message => { diff --git a/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts b/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts index e51fa0b623..56252d02d3 100644 --- a/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts +++ b/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts @@ -50,16 +50,6 @@ export function asLocators(lang: Language, selector: string, isFrameLocator: boo function innerAsLocators(factory: LocatorFactory, parsed: ParsedSelector, isFrameLocator: boolean = false, maxOutputSize = 20): string[] { const parts = [...parsed.parts]; - // frameLocator('iframe').first is actually "iframe >> nth=0 >> internal:control=enter-frame" - // To make it easier to parse, we turn it into "iframe >> internal:control=enter-frame >> nth=0" - for (let index = 0; index < parts.length - 1; index++) { - if (parts[index].name === 'nth' && parts[index + 1].name === 'internal:control' && (parts[index + 1].body as string) === 'enter-frame') { - // Swap nth and enter-frame. - const [nth] = parts.splice(index, 1); - parts.splice(index + 1, 0, nth); - } - } - const tokens: string[][] = []; let nextBase: LocatorBase = isFrameLocator ? 'frame-locator' : 'page'; for (let index = 0; index < parts.length; index++) { @@ -167,15 +157,15 @@ function innerAsLocators(factory: LocatorFactory, parsed: ParsedSelector, isFram continue; } } + if (part.name === 'internal:control' && (part.body as string) === 'enter-frame') { + tokens.push([factory.generateLocator(base, 'frame', '')]); + nextBase = 'frame-locator'; + continue; + } let locatorType: LocatorType = 'default'; const nextPart = parts[index + 1]; - if (nextPart && nextPart.name === 'internal:control' && (nextPart.body as string) === 'enter-frame') { - locatorType = 'frame'; - nextBase = 'frame-locator'; - index++; - } const selectorPart = stringifySelector({ parts: [part] }); const locatorPart = factory.generateLocator(base, locatorType, selectorPart); @@ -264,7 +254,7 @@ export class JavaScriptLocatorFactory implements LocatorFactory { return `locator(${this.quote(body as string)}, { hasNotText: ${this.toHasText(options.hasNotText)} })`; return `locator(${this.quote(body as string)})`; case 'frame': - return `frameLocator(${this.quote(body as string)})`; + return `contentFrame()`; case 'nth': return `nth(${body})`; case 'first': @@ -356,7 +346,7 @@ export class PythonLocatorFactory implements LocatorFactory { return `locator(${this.quote(body as string)}, has_not_text=${this.toHasText(options.hasNotText)})`; return `locator(${this.quote(body as string)})`; case 'frame': - return `frame_locator(${this.quote(body as string)})`; + return `content_frame`; case 'nth': return `nth(${body})`; case 'first': @@ -461,7 +451,7 @@ export class JavaLocatorFactory implements LocatorFactory { return `locator(${this.quote(body as string)}, new ${clazz}.LocatorOptions().setHasNotText(${this.toHasText(options.hasNotText)}))`; return `locator(${this.quote(body as string)})`; case 'frame': - return `frameLocator(${this.quote(body as string)})`; + return `contentFrame()`; case 'nth': return `nth(${body})`; case 'first': @@ -556,7 +546,7 @@ export class CSharpLocatorFactory implements LocatorFactory { return `Locator(${this.quote(body as string)}, new() { ${this.toHasNotText(options.hasNotText)} })`; return `Locator(${this.quote(body as string)})`; case 'frame': - return `FrameLocator(${this.quote(body as string)})`; + return `ContentFrame`; case 'nth': return `Nth(${body})`; case 'first': diff --git a/packages/playwright-core/src/utils/isomorphic/locatorParser.ts b/packages/playwright-core/src/utils/isomorphic/locatorParser.ts index 2ad5fd29c6..e0c10b53fd 100644 --- a/packages/playwright-core/src/utils/isomorphic/locatorParser.ts +++ b/packages/playwright-core/src/utils/isomorphic/locatorParser.ts @@ -75,6 +75,7 @@ function parseLocator(locator: string, testIdAttributeName: string): { selector: .replace(/has_text/g, 'hastext') .replace(/has_not/g, 'hasnot') .replace(/frame_locator/g, 'framelocator') + .replace(/content_frame/g, 'contentframe') .replace(/[{}\s]/g, '') .replace(/new\(\)/g, '') .replace(/new[\w]+\.[\w]+options\(\)/g, '') @@ -154,6 +155,7 @@ function transform(template: string, params: TemplateParams, testIdAttributeName template = template .replace(/\,set([\w]+)\(([^)]+)\)/g, (_, group1, group2) => ',' + group1.toLowerCase() + '=' + group2.toLowerCase()) .replace(/framelocator\(([^)]+)\)/g, '$1.internal:control=enter-frame') + .replace(/contentframe(\(\))?/g, 'internal:control=enter-frame') .replace(/locator\(([^)]+),hastext=([^),]+)\)/g, 'locator($1).internal:has-text=$2') .replace(/locator\(([^)]+),hasnottext=([^),]+)\)/g, 'locator($1).internal:has-not-text=$2') .replace(/locator\(([^)]+),hastext=([^),]+)\)/g, 'locator($1).internal:has-text=$2') diff --git a/packages/playwright-core/types/protocol.d.ts b/packages/playwright-core/types/protocol.d.ts index caadb2a577..14416bde1e 100644 --- a/packages/playwright-core/types/protocol.d.ts +++ b/packages/playwright-core/types/protocol.d.ts @@ -840,7 +840,7 @@ CORS RFC1918 enforcement. resourceIPAddressSpace?: Network.IPAddressSpace; clientSecurityState?: Network.ClientSecurityState; } - export type AttributionReportingIssueType = "PermissionPolicyDisabled"|"UntrustworthyReportingOrigin"|"InsecureContext"|"InvalidHeader"|"InvalidRegisterTriggerHeader"|"SourceAndTriggerHeaders"|"SourceIgnored"|"TriggerIgnored"|"OsSourceIgnored"|"OsTriggerIgnored"|"InvalidRegisterOsSourceHeader"|"InvalidRegisterOsTriggerHeader"|"WebAndOsHeaders"|"NoWebOrOsSupport"|"NavigationRegistrationWithoutTransientUserActivation"|"InvalidInfoHeader"|"NoRegisterSourceHeader"|"NoRegisterTriggerHeader"|"NoRegisterOsSourceHeader"|"NoRegisterOsTriggerHeader"; + export type AttributionReportingIssueType = "PermissionPolicyDisabled"|"UntrustworthyReportingOrigin"|"InsecureContext"|"InvalidHeader"|"InvalidRegisterTriggerHeader"|"SourceAndTriggerHeaders"|"SourceIgnored"|"TriggerIgnored"|"OsSourceIgnored"|"OsTriggerIgnored"|"InvalidRegisterOsSourceHeader"|"InvalidRegisterOsTriggerHeader"|"WebAndOsHeaders"|"NoWebOrOsSupport"|"NavigationRegistrationWithoutTransientUserActivation"|"InvalidInfoHeader"|"NoRegisterSourceHeader"|"NoRegisterTriggerHeader"|"NoRegisterOsSourceHeader"|"NoRegisterOsTriggerHeader"|"NavigationRegistrationUniqueScopeAlreadySet"; export type SharedDictionaryError = "UseErrorCrossOriginNoCorsRequest"|"UseErrorDictionaryLoadFailure"|"UseErrorMatchingDictionaryNotUsed"|"UseErrorUnexpectedContentDictionaryHeader"|"WriteErrorCossOriginNoCorsRequest"|"WriteErrorDisallowedBySettings"|"WriteErrorExpiredResponse"|"WriteErrorFeatureDisabled"|"WriteErrorInsufficientResources"|"WriteErrorInvalidMatchField"|"WriteErrorInvalidStructuredHeader"|"WriteErrorNavigationRequest"|"WriteErrorNoMatchField"|"WriteErrorNonListMatchDestField"|"WriteErrorNonSecureContext"|"WriteErrorNonStringIdField"|"WriteErrorNonStringInMatchDestList"|"WriteErrorNonStringMatchField"|"WriteErrorNonTokenTypeField"|"WriteErrorRequestAborted"|"WriteErrorShuttingDown"|"WriteErrorTooLongIdField"|"WriteErrorUnsupportedType"; /** * Details for issues around "Attribution Reporting API" usage. @@ -1534,7 +1534,7 @@ events afterwards if enabled and recording. */ windowState?: WindowState; } - export type PermissionType = "accessibilityEvents"|"audioCapture"|"backgroundSync"|"backgroundFetch"|"capturedSurfaceControl"|"clipboardReadWrite"|"clipboardSanitizedWrite"|"displayCapture"|"durableStorage"|"flash"|"geolocation"|"idleDetection"|"localFonts"|"midi"|"midiSysex"|"nfc"|"notifications"|"paymentHandler"|"periodicBackgroundSync"|"protectedMediaIdentifier"|"sensors"|"storageAccess"|"speakerSelection"|"topLevelStorageAccess"|"videoCapture"|"videoCapturePanTiltZoom"|"wakeLockScreen"|"wakeLockSystem"|"windowManagement"; + export type PermissionType = "accessibilityEvents"|"audioCapture"|"backgroundSync"|"backgroundFetch"|"capturedSurfaceControl"|"clipboardReadWrite"|"clipboardSanitizedWrite"|"displayCapture"|"durableStorage"|"flash"|"geolocation"|"idleDetection"|"localFonts"|"midi"|"midiSysex"|"nfc"|"notifications"|"paymentHandler"|"periodicBackgroundSync"|"protectedMediaIdentifier"|"sensors"|"storageAccess"|"speakerSelection"|"topLevelStorageAccess"|"videoCapture"|"videoCapturePanTiltZoom"|"wakeLockScreen"|"wakeLockSystem"|"webAppInstallation"|"windowManagement"; export type PermissionSetting = "granted"|"denied"|"prompt"; /** * Definition of PermissionDescriptor defined in the Permissions API: @@ -3561,7 +3561,7 @@ front-end. /** * Pseudo element type. */ - export type PseudoType = "first-line"|"first-letter"|"before"|"after"|"marker"|"backdrop"|"selection"|"search-text"|"target-text"|"spelling-error"|"grammar-error"|"highlight"|"first-line-inherited"|"scroll-marker"|"scroll-marker-group"|"scroll-next-button"|"scroll-prev-button"|"scrollbar"|"scrollbar-thumb"|"scrollbar-button"|"scrollbar-track"|"scrollbar-track-piece"|"scrollbar-corner"|"resizer"|"input-list-button"|"view-transition"|"view-transition-group"|"view-transition-image-pair"|"view-transition-old"|"view-transition-new"; + export type PseudoType = "first-line"|"first-letter"|"before"|"after"|"marker"|"backdrop"|"column"|"selection"|"search-text"|"target-text"|"spelling-error"|"grammar-error"|"highlight"|"first-line-inherited"|"scroll-marker"|"scroll-marker-group"|"scroll-next-button"|"scroll-prev-button"|"scrollbar"|"scrollbar-thumb"|"scrollbar-button"|"scrollbar-track"|"scrollbar-track-piece"|"scrollbar-corner"|"resizer"|"input-list-button"|"view-transition"|"view-transition-group"|"view-transition-image-pair"|"view-transition-old"|"view-transition-new"|"placeholder"|"file-selector-button"|"details-content"|"select-fallback-button"|"select-fallback-button-text"|"picker"; /** * Shadow root type. */ @@ -3710,6 +3710,7 @@ The property is always undefined now. isSVG?: boolean; compatibilityMode?: CompatibilityMode; assignedSlot?: BackendNode; + isScrollable?: boolean; } /** * A structure to hold the top-level node of a detached tree and an array of its retained descendants. @@ -3954,6 +3955,19 @@ The property is always undefined now. * Called when top layer elements are changed. */ export type topLayerElementsUpdatedPayload = void; + /** + * Fired when a node's scrollability state changes. + */ + export type scrollableFlagUpdatedPayload = { + /** + * The id of the node. + */ + nodeId: DOM.NodeId; + /** + * If the node is scrollable. + */ + isScrollable: boolean; + } /** * Called when a pseudo element is removed from an element. */ @@ -8102,8 +8116,25 @@ or hexadecimal (0x prefixed) string. */ size: number; } + /** + * DOM object counter data. + */ + export interface DOMCounter { + /** + * Object name. Note: object names should be presumed volatile and clients should not expect +the returned names to be consistent across runs. + */ + name: string; + /** + * Object count. + */ + count: number; + } + /** + * Retruns current DOM object counters. + */ export type getDOMCountersParameters = { } export type getDOMCountersReturnValue = { @@ -8111,6 +8142,21 @@ or hexadecimal (0x prefixed) string. nodes: number; jsEventListeners: number; } + /** + * Retruns DOM object counters after preparing renderer for leak detection. + */ + export type getDOMCountersForLeakDetectionParameters = { + } + export type getDOMCountersForLeakDetectionReturnValue = { + /** + * DOM object counters. + */ + counters: DOMCounter[]; + } + /** + * Prepares for leak detection by terminating workers, stopping spellcheckers, +dropping non-essential internal caches, running garbage collections, etc. + */ export type prepareForLeakDetectionParameters = { } export type prepareForLeakDetectionReturnValue = { @@ -8902,7 +8948,7 @@ This is a temporary ability and it will be removed in the future. /** * Types of reasons why a cookie should have been blocked by 3PCD but is exempted for the request. */ - export type CookieExemptionReason = "None"|"UserSetting"|"TPCDMetadata"|"TPCDDeprecationTrial"|"TPCDHeuristics"|"EnterprisePolicy"|"StorageAccess"|"TopLevelStorageAccess"|"CorsOptIn"|"Scheme"; + export type CookieExemptionReason = "None"|"UserSetting"|"TPCDMetadata"|"TPCDDeprecationTrial"|"TopLevelTPCDDeprecationTrial"|"TPCDHeuristics"|"EnterprisePolicy"|"StorageAccess"|"TopLevelStorageAccess"|"Scheme"; /** * A cookie which was not stored from a response with the corresponding reason. */ @@ -11452,7 +11498,7 @@ as an ad. * All Permissions Policy features. This enum should match the one defined in third_party/blink/renderer/core/permissions_policy/permissions_policy_features.json5. */ - export type PermissionsPolicyFeature = "accelerometer"|"all-screens-capture"|"ambient-light-sensor"|"attribution-reporting"|"autoplay"|"bluetooth"|"browsing-topics"|"camera"|"captured-surface-control"|"ch-dpr"|"ch-device-memory"|"ch-downlink"|"ch-ect"|"ch-prefers-color-scheme"|"ch-prefers-reduced-motion"|"ch-prefers-reduced-transparency"|"ch-rtt"|"ch-save-data"|"ch-ua"|"ch-ua-arch"|"ch-ua-bitness"|"ch-ua-platform"|"ch-ua-model"|"ch-ua-mobile"|"ch-ua-form-factors"|"ch-ua-full-version"|"ch-ua-full-version-list"|"ch-ua-platform-version"|"ch-ua-wow64"|"ch-viewport-height"|"ch-viewport-width"|"ch-width"|"clipboard-read"|"clipboard-write"|"compute-pressure"|"cross-origin-isolated"|"deferred-fetch"|"digital-credentials-get"|"direct-sockets"|"display-capture"|"document-domain"|"encrypted-media"|"execution-while-out-of-viewport"|"execution-while-not-rendered"|"focus-without-user-activation"|"fullscreen"|"frobulate"|"gamepad"|"geolocation"|"gyroscope"|"hid"|"identity-credentials-get"|"idle-detection"|"interest-cohort"|"join-ad-interest-group"|"keyboard-map"|"local-fonts"|"magnetometer"|"media-playback-while-not-visible"|"microphone"|"midi"|"otp-credentials"|"payment"|"picture-in-picture"|"private-aggregation"|"private-state-token-issuance"|"private-state-token-redemption"|"publickey-credentials-create"|"publickey-credentials-get"|"run-ad-auction"|"screen-wake-lock"|"serial"|"shared-autofill"|"shared-storage"|"shared-storage-select-url"|"smart-card"|"speaker-selection"|"storage-access"|"sub-apps"|"sync-xhr"|"unload"|"usb"|"usb-unrestricted"|"vertical-scroll"|"web-printing"|"web-share"|"window-management"|"xr-spatial-tracking"; + export type PermissionsPolicyFeature = "accelerometer"|"all-screens-capture"|"ambient-light-sensor"|"attribution-reporting"|"autoplay"|"bluetooth"|"browsing-topics"|"camera"|"captured-surface-control"|"ch-dpr"|"ch-device-memory"|"ch-downlink"|"ch-ect"|"ch-prefers-color-scheme"|"ch-prefers-reduced-motion"|"ch-prefers-reduced-transparency"|"ch-rtt"|"ch-save-data"|"ch-ua"|"ch-ua-arch"|"ch-ua-bitness"|"ch-ua-platform"|"ch-ua-model"|"ch-ua-mobile"|"ch-ua-form-factors"|"ch-ua-full-version"|"ch-ua-full-version-list"|"ch-ua-platform-version"|"ch-ua-wow64"|"ch-viewport-height"|"ch-viewport-width"|"ch-width"|"clipboard-read"|"clipboard-write"|"compute-pressure"|"controlled-frame"|"cross-origin-isolated"|"deferred-fetch"|"digital-credentials-get"|"direct-sockets"|"display-capture"|"document-domain"|"encrypted-media"|"execution-while-out-of-viewport"|"execution-while-not-rendered"|"focus-without-user-activation"|"fullscreen"|"frobulate"|"gamepad"|"geolocation"|"gyroscope"|"hid"|"identity-credentials-get"|"idle-detection"|"interest-cohort"|"join-ad-interest-group"|"keyboard-map"|"local-fonts"|"magnetometer"|"media-playback-while-not-visible"|"microphone"|"midi"|"otp-credentials"|"payment"|"picture-in-picture"|"popins"|"private-aggregation"|"private-state-token-issuance"|"private-state-token-redemption"|"publickey-credentials-create"|"publickey-credentials-get"|"run-ad-auction"|"screen-wake-lock"|"serial"|"shared-autofill"|"shared-storage"|"shared-storage-select-url"|"smart-card"|"speaker-selection"|"storage-access"|"sub-apps"|"sync-xhr"|"unload"|"usb"|"usb-unrestricted"|"vertical-scroll"|"web-app-installation"|"web-printing"|"web-share"|"window-management"|"xr-spatial-tracking"; /** * Reason for a permissions policy feature to be disabled. */ @@ -12040,7 +12086,7 @@ https://github.com/WICG/manifest-incubations/blob/gh-pages/scope_extensions-expl /** * List of not restored reasons for back-forward cache. */ - export type BackForwardCacheNotRestoredReason = "NotPrimaryMainFrame"|"BackForwardCacheDisabled"|"RelatedActiveContentsExist"|"HTTPStatusNotOK"|"SchemeNotHTTPOrHTTPS"|"Loading"|"WasGrantedMediaAccess"|"DisableForRenderFrameHostCalled"|"DomainNotAllowed"|"HTTPMethodNotGET"|"SubframeIsNavigating"|"Timeout"|"CacheLimit"|"JavaScriptExecution"|"RendererProcessKilled"|"RendererProcessCrashed"|"SchedulerTrackedFeatureUsed"|"ConflictingBrowsingInstance"|"CacheFlushed"|"ServiceWorkerVersionActivation"|"SessionRestored"|"ServiceWorkerPostMessage"|"EnteredBackForwardCacheBeforeServiceWorkerHostAdded"|"RenderFrameHostReused_SameSite"|"RenderFrameHostReused_CrossSite"|"ServiceWorkerClaim"|"IgnoreEventAndEvict"|"HaveInnerContents"|"TimeoutPuttingInCache"|"BackForwardCacheDisabledByLowMemory"|"BackForwardCacheDisabledByCommandLine"|"NetworkRequestDatapipeDrainedAsBytesConsumer"|"NetworkRequestRedirected"|"NetworkRequestTimeout"|"NetworkExceedsBufferLimit"|"NavigationCancelledWhileRestoring"|"NotMostRecentNavigationEntry"|"BackForwardCacheDisabledForPrerender"|"UserAgentOverrideDiffers"|"ForegroundCacheLimit"|"BrowsingInstanceNotSwapped"|"BackForwardCacheDisabledForDelegate"|"UnloadHandlerExistsInMainFrame"|"UnloadHandlerExistsInSubFrame"|"ServiceWorkerUnregistration"|"CacheControlNoStore"|"CacheControlNoStoreCookieModified"|"CacheControlNoStoreHTTPOnlyCookieModified"|"NoResponseHead"|"Unknown"|"ActivationNavigationsDisallowedForBug1234857"|"ErrorDocument"|"FencedFramesEmbedder"|"CookieDisabled"|"HTTPAuthRequired"|"CookieFlushed"|"BroadcastChannelOnMessage"|"WebViewSettingsChanged"|"WebViewJavaScriptObjectChanged"|"WebViewMessageListenerInjected"|"WebViewSafeBrowsingAllowlistChanged"|"WebViewDocumentStartJavascriptChanged"|"WebSocket"|"WebTransport"|"WebRTC"|"MainResourceHasCacheControlNoStore"|"MainResourceHasCacheControlNoCache"|"SubresourceHasCacheControlNoStore"|"SubresourceHasCacheControlNoCache"|"ContainsPlugins"|"DocumentLoaded"|"OutstandingNetworkRequestOthers"|"RequestedMIDIPermission"|"RequestedAudioCapturePermission"|"RequestedVideoCapturePermission"|"RequestedBackForwardCacheBlockedSensors"|"RequestedBackgroundWorkPermission"|"BroadcastChannel"|"WebXR"|"SharedWorker"|"WebLocks"|"WebHID"|"WebShare"|"RequestedStorageAccessGrant"|"WebNfc"|"OutstandingNetworkRequestFetch"|"OutstandingNetworkRequestXHR"|"AppBanner"|"Printing"|"WebDatabase"|"PictureInPicture"|"SpeechRecognizer"|"IdleManager"|"PaymentManager"|"SpeechSynthesis"|"KeyboardLock"|"WebOTPService"|"OutstandingNetworkRequestDirectSocket"|"InjectedJavascript"|"InjectedStyleSheet"|"KeepaliveRequest"|"IndexedDBEvent"|"Dummy"|"JsNetworkRequestReceivedCacheControlNoStoreResource"|"WebRTCSticky"|"WebTransportSticky"|"WebSocketSticky"|"SmartCard"|"LiveMediaStreamTrack"|"UnloadHandler"|"ParserAborted"|"ContentSecurityHandler"|"ContentWebAuthenticationAPI"|"ContentFileChooser"|"ContentSerial"|"ContentFileSystemAccess"|"ContentMediaDevicesDispatcherHost"|"ContentWebBluetooth"|"ContentWebUSB"|"ContentMediaSessionService"|"ContentScreenReader"|"EmbedderPopupBlockerTabHelper"|"EmbedderSafeBrowsingTriggeredPopupBlocker"|"EmbedderSafeBrowsingThreatDetails"|"EmbedderAppBannerManager"|"EmbedderDomDistillerViewerSource"|"EmbedderDomDistillerSelfDeletingRequestDelegate"|"EmbedderOomInterventionTabHelper"|"EmbedderOfflinePage"|"EmbedderChromePasswordManagerClientBindCredentialManager"|"EmbedderPermissionRequestManager"|"EmbedderModalDialog"|"EmbedderExtensions"|"EmbedderExtensionMessaging"|"EmbedderExtensionMessagingForOpenPort"|"EmbedderExtensionSentMessageToCachedFrame"|"RequestedByWebViewClient"; + export type BackForwardCacheNotRestoredReason = "NotPrimaryMainFrame"|"BackForwardCacheDisabled"|"RelatedActiveContentsExist"|"HTTPStatusNotOK"|"SchemeNotHTTPOrHTTPS"|"Loading"|"WasGrantedMediaAccess"|"DisableForRenderFrameHostCalled"|"DomainNotAllowed"|"HTTPMethodNotGET"|"SubframeIsNavigating"|"Timeout"|"CacheLimit"|"JavaScriptExecution"|"RendererProcessKilled"|"RendererProcessCrashed"|"SchedulerTrackedFeatureUsed"|"ConflictingBrowsingInstance"|"CacheFlushed"|"ServiceWorkerVersionActivation"|"SessionRestored"|"ServiceWorkerPostMessage"|"EnteredBackForwardCacheBeforeServiceWorkerHostAdded"|"RenderFrameHostReused_SameSite"|"RenderFrameHostReused_CrossSite"|"ServiceWorkerClaim"|"IgnoreEventAndEvict"|"HaveInnerContents"|"TimeoutPuttingInCache"|"BackForwardCacheDisabledByLowMemory"|"BackForwardCacheDisabledByCommandLine"|"NetworkRequestDatapipeDrainedAsBytesConsumer"|"NetworkRequestRedirected"|"NetworkRequestTimeout"|"NetworkExceedsBufferLimit"|"NavigationCancelledWhileRestoring"|"NotMostRecentNavigationEntry"|"BackForwardCacheDisabledForPrerender"|"UserAgentOverrideDiffers"|"ForegroundCacheLimit"|"BrowsingInstanceNotSwapped"|"BackForwardCacheDisabledForDelegate"|"UnloadHandlerExistsInMainFrame"|"UnloadHandlerExistsInSubFrame"|"ServiceWorkerUnregistration"|"CacheControlNoStore"|"CacheControlNoStoreCookieModified"|"CacheControlNoStoreHTTPOnlyCookieModified"|"NoResponseHead"|"Unknown"|"ActivationNavigationsDisallowedForBug1234857"|"ErrorDocument"|"FencedFramesEmbedder"|"CookieDisabled"|"HTTPAuthRequired"|"CookieFlushed"|"BroadcastChannelOnMessage"|"WebViewSettingsChanged"|"WebViewJavaScriptObjectChanged"|"WebViewMessageListenerInjected"|"WebViewSafeBrowsingAllowlistChanged"|"WebViewDocumentStartJavascriptChanged"|"WebSocket"|"WebTransport"|"WebRTC"|"MainResourceHasCacheControlNoStore"|"MainResourceHasCacheControlNoCache"|"SubresourceHasCacheControlNoStore"|"SubresourceHasCacheControlNoCache"|"ContainsPlugins"|"DocumentLoaded"|"OutstandingNetworkRequestOthers"|"RequestedMIDIPermission"|"RequestedAudioCapturePermission"|"RequestedVideoCapturePermission"|"RequestedBackForwardCacheBlockedSensors"|"RequestedBackgroundWorkPermission"|"BroadcastChannel"|"WebXR"|"SharedWorker"|"WebLocks"|"WebHID"|"WebShare"|"RequestedStorageAccessGrant"|"WebNfc"|"OutstandingNetworkRequestFetch"|"OutstandingNetworkRequestXHR"|"AppBanner"|"Printing"|"WebDatabase"|"PictureInPicture"|"SpeechRecognizer"|"IdleManager"|"PaymentManager"|"SpeechSynthesis"|"KeyboardLock"|"WebOTPService"|"OutstandingNetworkRequestDirectSocket"|"InjectedJavascript"|"InjectedStyleSheet"|"KeepaliveRequest"|"IndexedDBEvent"|"Dummy"|"JsNetworkRequestReceivedCacheControlNoStoreResource"|"WebRTCSticky"|"WebTransportSticky"|"WebSocketSticky"|"SmartCard"|"LiveMediaStreamTrack"|"UnloadHandler"|"ParserAborted"|"ContentSecurityHandler"|"ContentWebAuthenticationAPI"|"ContentFileChooser"|"ContentSerial"|"ContentFileSystemAccess"|"ContentMediaDevicesDispatcherHost"|"ContentWebBluetooth"|"ContentWebUSB"|"ContentMediaSessionService"|"ContentScreenReader"|"ContentDiscarded"|"EmbedderPopupBlockerTabHelper"|"EmbedderSafeBrowsingTriggeredPopupBlocker"|"EmbedderSafeBrowsingThreatDetails"|"EmbedderAppBannerManager"|"EmbedderDomDistillerViewerSource"|"EmbedderDomDistillerSelfDeletingRequestDelegate"|"EmbedderOomInterventionTabHelper"|"EmbedderOfflinePage"|"EmbedderChromePasswordManagerClientBindCredentialManager"|"EmbedderPermissionRequestManager"|"EmbedderModalDialog"|"EmbedderExtensions"|"EmbedderExtensionMessaging"|"EmbedderExtensionMessagingForOpenPort"|"EmbedderExtensionSentMessageToCachedFrame"|"RequestedByWebViewClient"; /** * Types of not restored reasons for back-forward cache. */ @@ -12151,6 +12197,16 @@ dependent on the reason: frameId: FrameId; reason: "remove"|"swap"; } + /** + * Fired before frame subtree is detached. Emitted before any frame of the +subtree is actually detached. + */ + export type frameSubtreeWillBeDetachedPayload = { + /** + * Id of the frame that is the root of the subtree that will be detached. + */ + frameId: FrameId; + } /** * Fired once navigation of the frame has completed. Frame is now associated with the new loader. */ @@ -14250,6 +14306,15 @@ int, only present for source registrations debugData: AttributionReportingAggregatableDebugReportingData[]; aggregationCoordinatorOrigin?: string; } + export interface AttributionScopesData { + values: string[]; + /** + * number instead of integer because not all uint32 can be represented by +int + */ + limit: number; + maxEventStates: number; + } export interface AttributionReportingSourceRegistration { time: Network.TimeSinceEpoch; /** @@ -14273,8 +14338,9 @@ int, only present for source registrations triggerDataMatching: AttributionReportingTriggerDataMatching; destinationLimitPriority: SignedInt64AsBase10; aggregatableDebugReportingConfig: AttributionReportingAggregatableDebugReportingConfig; + scopesData?: AttributionScopesData; } - export type AttributionReportingSourceRegistrationResult = "success"|"internalError"|"insufficientSourceCapacity"|"insufficientUniqueDestinationCapacity"|"excessiveReportingOrigins"|"prohibitedByBrowserPolicy"|"successNoised"|"destinationReportingLimitReached"|"destinationGlobalLimitReached"|"destinationBothLimitsReached"|"reportingOriginsPerSiteLimitReached"|"exceedsMaxChannelCapacity"|"exceedsMaxTriggerStateCardinality"|"destinationPerDayReportingLimitReached"; + export type AttributionReportingSourceRegistrationResult = "success"|"internalError"|"insufficientSourceCapacity"|"insufficientUniqueDestinationCapacity"|"excessiveReportingOrigins"|"prohibitedByBrowserPolicy"|"successNoised"|"destinationReportingLimitReached"|"destinationGlobalLimitReached"|"destinationBothLimitsReached"|"reportingOriginsPerSiteLimitReached"|"exceedsMaxChannelCapacity"|"exceedsMaxScopesChannelCapacity"|"exceedsMaxTriggerStateCardinality"|"exceedsMaxEventStatesLimit"|"destinationPerDayReportingLimitReached"; export type AttributionReportingSourceRegistrationTimeConfig = "include"|"exclude"; export interface AttributionReportingAggregatableValueDictEntry { key: string; @@ -14317,6 +14383,7 @@ int sourceRegistrationTimeConfig: AttributionReportingSourceRegistrationTimeConfig; triggerContextId?: string; aggregatableDebugReportingConfig: AttributionReportingAggregatableDebugReportingConfig; + scopes: string[]; } export type AttributionReportingEventLevelResult = "success"|"successDroppedLowerPriority"|"internalError"|"noCapacityForAttributionDestination"|"noMatchingSources"|"deduplicated"|"excessiveAttributions"|"priorityTooLow"|"neverAttributedSource"|"excessiveReportingOrigins"|"noMatchingSourceFilterData"|"prohibitedByBrowserPolicy"|"noMatchingConfigurations"|"excessiveReports"|"falselyAttributedSource"|"reportWindowPassed"|"notRegistered"|"reportWindowNotStarted"|"noMatchingTriggerData"; export type AttributionReportingAggregatableResult = "success"|"internalError"|"noCapacityForAttributionDestination"|"noMatchingSources"|"excessiveAttributions"|"excessiveReportingOrigins"|"noHistograms"|"insufficientBudget"|"noMatchingSourceFilterData"|"notRegistered"|"prohibitedByBrowserPolicy"|"deduplicated"|"reportWindowPassed"|"excessiveReports"; @@ -17019,7 +17086,7 @@ status is shared by prefetchStatusUpdated and prerenderStatusUpdated. * TODO(https://crbug.com/1384419): revisit the list of PrefetchStatus and filter out the ones that aren't necessary to the developers. */ - export type PrefetchStatus = "PrefetchAllowed"|"PrefetchFailedIneligibleRedirect"|"PrefetchFailedInvalidRedirect"|"PrefetchFailedMIMENotSupported"|"PrefetchFailedNetError"|"PrefetchFailedNon2XX"|"PrefetchFailedPerPageLimitExceeded"|"PrefetchEvictedAfterCandidateRemoved"|"PrefetchEvictedForNewerPrefetch"|"PrefetchHeldback"|"PrefetchIneligibleRetryAfter"|"PrefetchIsPrivacyDecoy"|"PrefetchIsStale"|"PrefetchNotEligibleBrowserContextOffTheRecord"|"PrefetchNotEligibleDataSaverEnabled"|"PrefetchNotEligibleExistingProxy"|"PrefetchNotEligibleHostIsNonUnique"|"PrefetchNotEligibleNonDefaultStoragePartition"|"PrefetchNotEligibleSameSiteCrossOriginPrefetchRequiredProxy"|"PrefetchNotEligibleSchemeIsNotHttps"|"PrefetchNotEligibleUserHasCookies"|"PrefetchNotEligibleUserHasServiceWorker"|"PrefetchNotEligibleBatterySaverEnabled"|"PrefetchNotEligiblePreloadingDisabled"|"PrefetchNotFinishedInTime"|"PrefetchNotStarted"|"PrefetchNotUsedCookiesChanged"|"PrefetchProxyNotAvailable"|"PrefetchResponseUsed"|"PrefetchSuccessfulButNotUsed"|"PrefetchNotUsedProbeFailed"; + export type PrefetchStatus = "PrefetchAllowed"|"PrefetchFailedIneligibleRedirect"|"PrefetchFailedInvalidRedirect"|"PrefetchFailedMIMENotSupported"|"PrefetchFailedNetError"|"PrefetchFailedNon2XX"|"PrefetchEvictedAfterCandidateRemoved"|"PrefetchEvictedForNewerPrefetch"|"PrefetchHeldback"|"PrefetchIneligibleRetryAfter"|"PrefetchIsPrivacyDecoy"|"PrefetchIsStale"|"PrefetchNotEligibleBrowserContextOffTheRecord"|"PrefetchNotEligibleDataSaverEnabled"|"PrefetchNotEligibleExistingProxy"|"PrefetchNotEligibleHostIsNonUnique"|"PrefetchNotEligibleNonDefaultStoragePartition"|"PrefetchNotEligibleSameSiteCrossOriginPrefetchRequiredProxy"|"PrefetchNotEligibleSchemeIsNotHttps"|"PrefetchNotEligibleUserHasCookies"|"PrefetchNotEligibleUserHasServiceWorker"|"PrefetchNotEligibleBatterySaverEnabled"|"PrefetchNotEligiblePreloadingDisabled"|"PrefetchNotFinishedInTime"|"PrefetchNotStarted"|"PrefetchNotUsedCookiesChanged"|"PrefetchProxyNotAvailable"|"PrefetchResponseUsed"|"PrefetchSuccessfulButNotUsed"|"PrefetchNotUsedProbeFailed"; /** * Information of headers to be displayed when the header mismatch occurred. */ @@ -20115,6 +20182,7 @@ Error was thrown. "DOM.inlineStyleInvalidated": DOM.inlineStyleInvalidatedPayload; "DOM.pseudoElementAdded": DOM.pseudoElementAddedPayload; "DOM.topLayerElementsUpdated": DOM.topLayerElementsUpdatedPayload; + "DOM.scrollableFlagUpdated": DOM.scrollableFlagUpdatedPayload; "DOM.pseudoElementRemoved": DOM.pseudoElementRemovedPayload; "DOM.setChildNodes": DOM.setChildNodesPayload; "DOM.shadowRootPopped": DOM.shadowRootPoppedPayload; @@ -20173,6 +20241,7 @@ Error was thrown. "Page.frameAttached": Page.frameAttachedPayload; "Page.frameClearedScheduledNavigation": Page.frameClearedScheduledNavigationPayload; "Page.frameDetached": Page.frameDetachedPayload; + "Page.frameSubtreeWillBeDetached": Page.frameSubtreeWillBeDetachedPayload; "Page.frameNavigated": Page.frameNavigatedPayload; "Page.documentOpened": Page.documentOpenedPayload; "Page.frameResized": Page.frameResizedPayload; @@ -20539,6 +20608,7 @@ Error was thrown. "Log.startViolationsReport": Log.startViolationsReportParameters; "Log.stopViolationsReport": Log.stopViolationsReportParameters; "Memory.getDOMCounters": Memory.getDOMCountersParameters; + "Memory.getDOMCountersForLeakDetection": Memory.getDOMCountersForLeakDetectionParameters; "Memory.prepareForLeakDetection": Memory.prepareForLeakDetectionParameters; "Memory.forciblyPurgeJavaScriptMemory": Memory.forciblyPurgeJavaScriptMemoryParameters; "Memory.setPressureNotificationsSuppressed": Memory.setPressureNotificationsSuppressedParameters; @@ -21148,6 +21218,7 @@ Error was thrown. "Log.startViolationsReport": Log.startViolationsReportReturnValue; "Log.stopViolationsReport": Log.stopViolationsReportReturnValue; "Memory.getDOMCounters": Memory.getDOMCountersReturnValue; + "Memory.getDOMCountersForLeakDetection": Memory.getDOMCountersForLeakDetectionReturnValue; "Memory.prepareForLeakDetection": Memory.prepareForLeakDetectionReturnValue; "Memory.forciblyPurgeJavaScriptMemory": Memory.forciblyPurgeJavaScriptMemoryReturnValue; "Memory.setPressureNotificationsSuppressed": Memory.setPressureNotificationsSuppressedReturnValue; diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index ad102f9271..d6d4e7f39b 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -3826,6 +3826,34 @@ export interface Page { url?: string|RegExp; }): Promise; + /** + * This method allows to modify websocket connections that are made by the page. + * + * Note that only `WebSocket`s created after this method was called will be routed. It is recommended to call this + * method before navigating the page. + * + * **Usage** + * + * Below is an example of a simple handler that blocks some websocket messages. See {@link WebSocketRoute} for more + * details and examples. + * + * ```js + * await page.routeWebSocket('/ws', async ws => { + * ws.routeSend(message => { + * if (message === 'to-be-blocked') + * return; + * ws.send(message); + * }); + * await ws.connect(); + * }); + * ``` + * + * @param url Only WebSockets with the url matching this pattern will be routed. A string pattern can be relative to the + * `baseURL` from the context options. + * @param handler Handler function to route the WebSocket. + */ + routeWebSocket(url: string|RegExp|((url: URL) => boolean), handler: ((websocketroute: WebSocketRoute) => Promise|any)): Promise; + /** * Returns the buffer with the captured screenshot. * @param options @@ -8658,6 +8686,34 @@ export interface BrowserContext { url?: string|RegExp; }): Promise; + /** + * This method allows to modify websocket connections that are made by any page in the browser context. + * + * Note that only `WebSocket`s created after this method was called will be routed. It is recommended to call this + * method before creating any pages. + * + * **Usage** + * + * Below is an example of a simple handler that blocks some websocket messages. See {@link WebSocketRoute} for more + * details and examples. + * + * ```js + * await context.routeWebSocket('/ws', async ws => { + * ws.routeSend(message => { + * if (message === 'to-be-blocked') + * return; + * ws.send(message); + * }); + * await ws.connect(); + * }); + * ``` + * + * @param url Only WebSockets with the url matching this pattern will be routed. A string pattern can be relative to the + * `baseURL` from the context options. + * @param handler Handler function to route the WebSocket. + */ + routeWebSocket(url: string|RegExp|((url: URL) => boolean), handler: ((websocketroute: WebSocketRoute) => Promise|any)): Promise; + /** * **NOTE** Service workers are only supported on Chromium-based browsers. * @@ -14567,6 +14623,134 @@ export interface CDPSession { detach(): Promise; } +/** + * Whenever a [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) route is set up with + * [page.routeWebSocket(url, handler)](https://playwright.dev/docs/api/class-page#page-route-web-socket) or + * [browserContext.routeWebSocket(url, handler)](https://playwright.dev/docs/api/class-browsercontext#browser-context-route-web-socket), + * the `WebSocketRoute` object allows to handle the WebSocket. + * + * By default, the routed WebSocket will not actually connect to the server. This way, you can mock entire + * communcation over the WebSocket. Here is an example that responds to a `"query"` with a `"result"`. + * + * ```js + * await page.routeWebSocket('/ws', async ws => { + * ws.routeSend(message => { + * if (message === 'query') + * ws.receive('result'); + * }); + * }); + * ``` + * + */ +export interface WebSocketRoute { + /** + * This method allows to route messages that are sent by `WebSocket.send()` call in the page, instead of actually + * sending them to the server. Once this method is called, sent messages **are not** automatically forwarded to the + * server - you should do that manually by calling + * [webSocketRoute.send(message)](https://playwright.dev/docs/api/class-websocketroute#web-socket-route-send). + * + * Calling this method again times will override the handler with a new one. + * @param handler Handler function to route sent messages. + */ + routeSend(handler: (message: string | Buffer) => any): void; + /** + * This method allows to route messages that are received by the + * [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) object in the page from the server. This + * method only makes sense if you are also calling + * [webSocketRoute.connect()](https://playwright.dev/docs/api/class-websocketroute#web-socket-route-connect). + * + * Once this method is called, received messages are not automatically dispatched to the + * [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) object in the page - you should do that + * manually by calling + * [webSocketRoute.receive(message)](https://playwright.dev/docs/api/class-websocketroute#web-socket-route-receive). + * + * Calling this method again times will override the handler with a new one. + * @param handler Handler function to route received messages. + */ + routeReceive(handler: (message: string | Buffer) => any): void; + /** + * Emitted when the [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) closes. + */ + on(event: 'close', listener: () => any): this; + + /** + * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. + */ + once(event: 'close', listener: () => any): this; + + /** + * Emitted when the [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) closes. + */ + addListener(event: 'close', listener: () => any): this; + + /** + * Removes an event listener added by `on` or `addListener`. + */ + removeListener(event: 'close', listener: () => any): this; + + /** + * Removes an event listener added by `on` or `addListener`. + */ + off(event: 'close', listener: () => any): this; + + /** + * Emitted when the [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) closes. + */ + prependListener(event: 'close', listener: () => any): this; + + /** + * Closes the server connection and the [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) + * object in the page. + * @param options + */ + close(options?: { + /** + * Optional [close code](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close#code). + */ + code?: number; + + /** + * Optional [close reason](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close#reason). + */ + reason?: string; + }): Promise; + + /** + * By default, routed WebSocket does not connect to the server, so you can mock entire WebSocket communication. This + * method connects to the actual WebSocket server, giving the ability to send and receive messages from the server. + * + * Once connected: + * - Messages received from the server will be automatically dispatched to the + * [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) object in the page, unless + * [webSocketRoute.routeReceive(handler)](https://playwright.dev/docs/api/class-websocketroute#web-socket-route-route-receive) + * is called. + * - Messages sent by the `WebSocket.send()` call in the page will be automatically sent to the server, unless + * [webSocketRoute.routeSend(handler)](https://playwright.dev/docs/api/class-websocketroute#web-socket-route-route-send) + * is called. + */ + connect(): Promise; + + /** + * Dispatches a message to the [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) object in the + * page, like it was received from the server. + * @param message Message to receive. + */ + receive(message: string|Buffer): void; + + /** + * Sends a message to the server, like it was sent in the page with `WebSocket.send()`. + * @param message Message to send. + */ + send(message: string|Buffer): void; + + /** + * URL of the WebSocket created in the page. + */ + url(): string; + + [Symbol.asyncDispose](): Promise; +} + type DeviceDescriptor = { viewport: ViewportSize; userAgent: string; @@ -18240,11 +18424,12 @@ export interface FileChooser { /** * FrameLocator represents a view to the `iframe` on the page. It captures the logic sufficient to retrieve the * `iframe` and locate elements in that iframe. FrameLocator can be created with either + * [locator.contentFrame()](https://playwright.dev/docs/api/class-locator#locator-content-frame), * [page.frameLocator(selector)](https://playwright.dev/docs/api/class-page#page-frame-locator) or * [locator.frameLocator(selector)](https://playwright.dev/docs/api/class-locator#locator-frame-locator) method. * * ```js - * const locator = page.frameLocator('#my-frame').getByText('Submit'); + * const locator = page.locator('#my-frame').contentFrame().getByText('Submit'); * await locator.click(); * ``` * @@ -18255,10 +18440,10 @@ export interface FileChooser { * * ```js * // Throws if there are several frames in DOM: - * await page.frameLocator('.result-frame').getByRole('button').click(); + * await page.locator('.result-frame').contentFrame().getByRole('button').click(); * * // Works because we explicitly tell locator to pick the first frame: - * await page.frameLocator('.result-frame').first().getByRole('button').click(); + * await page.locator('.result-frame').contentFrame().first().getByRole('button').click(); * ``` * * **Converting Locator to FrameLocator** @@ -18274,6 +18459,8 @@ export interface FileChooser { export interface FrameLocator { /** * Returns locator to the first matching frame. + * @deprecated Use [locator.first()](https://playwright.dev/docs/api/class-locator#locator-first) followed by + * [locator.contentFrame()](https://playwright.dev/docs/api/class-locator#locator-content-frame) instead. */ first(): FrameLocator; @@ -18598,6 +18785,8 @@ export interface FrameLocator { /** * Returns locator to the last matching frame. + * @deprecated Use [locator.last()](https://playwright.dev/docs/api/class-locator#locator-last) followed by + * [locator.contentFrame()](https://playwright.dev/docs/api/class-locator#locator-content-frame) instead. */ last(): FrameLocator; @@ -18650,6 +18839,8 @@ export interface FrameLocator { /** * Returns locator to the n-th matching frame. It's zero based, `nth(0)` selects the first frame. + * @deprecated Use [locator.nth(index)](https://playwright.dev/docs/api/class-locator#locator-nth) followed by + * [locator.contentFrame()](https://playwright.dev/docs/api/class-locator#locator-content-frame) instead. * @param index */ nth(index: number): FrameLocator; @@ -18666,7 +18857,7 @@ export interface FrameLocator { * **Usage** * * ```js - * const frameLocator = page.frameLocator('iframe[name="embedded"]'); + * const frameLocator = page.locator('iframe[name="embedded"]').contentFrame(); * // ... * const locator = frameLocator.owner(); * await expect(locator).toBeVisible(); diff --git a/packages/playwright/bundles/babel/src/babelBundleImpl.ts b/packages/playwright/bundles/babel/src/babelBundleImpl.ts index 82610247f1..81e617ef27 100644 --- a/packages/playwright/bundles/babel/src/babelBundleImpl.ts +++ b/packages/playwright/bundles/babel/src/babelBundleImpl.ts @@ -45,6 +45,7 @@ function babelTransformOptions(isTypeScript: boolean, isModule: boolean, plugins [require('@babel/plugin-syntax-async-generators')], [require('@babel/plugin-syntax-object-rest-spread')], [require('@babel/plugin-transform-export-namespace-from')], + [require('@babel/plugin-syntax-import-attributes'), { deprecatedAssertSyntax: true }], [ // From https://github.com/G-Rath/babel-plugin-replace-ts-export-assignment/blob/8dfdca32c8aa428574b0cae341444fc5822f2dc6/src/index.ts ( @@ -86,8 +87,6 @@ function babelTransformOptions(isTypeScript: boolean, isModule: boolean, plugins } }) ]); - } else { - plugins.push([require('@babel/plugin-syntax-import-attributes'), { deprecatedAssertSyntax: true }]); } return { diff --git a/packages/playwright/src/reporters/html.ts b/packages/playwright/src/reporters/html.ts index fd1fb0cbdf..5aada7e495 100644 --- a/packages/playwright/src/reporters/html.ts +++ b/packages/playwright/src/reporters/html.ts @@ -143,7 +143,7 @@ class HtmlReporter implements ReporterV2 { const shouldOpen = !this._options._isTestServer && (this._open === 'always' || (!ok && this._open === 'on-failure')); if (shouldOpen) { await showHTMLReport(this._outputFolder, this._host, this._port, singleTestId); - } else if (this._options._mode === 'test') { + } else if (this._options._mode === 'test' && !this._options._isTestServer) { const packageManagerCommand = getPackageManagerExecCommand(); const relativeReportPath = this._outputFolder === standaloneDefaultFolder() ? '' : ' ' + path.relative(process.cwd(), this._outputFolder); const hostArg = this._host ? ` --host ${this._host}` : ''; diff --git a/packages/playwright/src/runner/testServer.ts b/packages/playwright/src/runner/testServer.ts index 5d67385dc5..d4433b2d85 100644 --- a/packages/playwright/src/runner/testServer.ts +++ b/packages/playwright/src/runner/testServer.ts @@ -84,6 +84,7 @@ export class TestServerDispatcher implements TestServerInterface { constructor(configLocation: ConfigLocation) { this._configLocation = configLocation; this.transport = { + onconnect: () => {}, dispatch: (method, params) => (this as any)[method](params), onclose: () => { if (this._closeOnDisconnect) diff --git a/packages/playwright/src/worker/timeoutManager.ts b/packages/playwright/src/worker/timeoutManager.ts index 71b853c463..ef7665a650 100644 --- a/packages/playwright/src/worker/timeoutManager.ts +++ b/packages/playwright/src/worker/timeoutManager.ts @@ -60,6 +60,8 @@ export class TimeoutManager { setIgnoreTimeouts() { this._ignoreTimeouts = true; + if (this._running) + this._updateTimeout(this._running); } interrupt() { diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index 3c02c5a334..d1495e04fc 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -7788,6 +7788,26 @@ interface SnapshotAssertions { }): void; } +/** + * Represents a location in the source code where [TestCase] or [Suite] is defined. + */ +export interface Location { + /** + * Column number in the source file. + */ + column: number; + + /** + * Path to the source file. + */ + file: string; + + /** + * Line number in the source file. + */ + line: number; +} + /** * `TestInfo` contains information about currently running test. It is available to test functions, * [test.beforeEach([title, hookFunction])](https://playwright.dev/docs/api/class-test#test-before-each), diff --git a/packages/playwright/types/testReporter.d.ts b/packages/playwright/types/testReporter.d.ts index 52073066f0..f2a2d62b0a 100644 --- a/packages/playwright/types/testReporter.d.ts +++ b/packages/playwright/types/testReporter.d.ts @@ -15,8 +15,8 @@ * limitations under the License. */ -import type { TestStatus, Metadata, PlaywrightTestOptions, PlaywrightWorkerOptions, ReporterDescription, FullConfig, FullProject } from './test'; -export type { FullConfig, FullProject, TestStatus } from './test'; +import type { TestStatus, Metadata, PlaywrightTestOptions, PlaywrightWorkerOptions, ReporterDescription, FullConfig, FullProject, Location } from './test'; +export type { FullConfig, FullProject, TestStatus, Location } from './test'; /** * Result of the full test run. @@ -319,26 +319,6 @@ export type JSONReportSTDIOEntry = { text: string } | { buffer: string }; export {}; -/** - * Represents a location in the source code where {@link TestCase} or {@link Suite} is defined. - */ -export interface Location { - /** - * Column number in the source file. - */ - column: number; - - /** - * Path to the source file. - */ - file: string; - - /** - * Line number in the source file. - */ - line: number; -} - /** * `Suite` is a group of tests. All tests in Playwright Test form the following hierarchy: * - Root suite has a child suite for each {@link FullProject}. diff --git a/packages/protocol/src/channels.ts b/packages/protocol/src/channels.ts index 689f0275b1..66cf417c87 100644 --- a/packages/protocol/src/channels.ts +++ b/packages/protocol/src/channels.ts @@ -40,6 +40,7 @@ export type InitializerTraits = T extends BindingCallChannel ? BindingCallInitializer : T extends WebSocketChannel ? WebSocketInitializer : T extends ResponseChannel ? ResponseInitializer : + T extends WebSocketRouteChannel ? WebSocketRouteInitializer : T extends RouteChannel ? RouteInitializer : T extends RequestChannel ? RequestInitializer : T extends ElementHandleChannel ? ElementHandleInitializer : @@ -77,6 +78,7 @@ export type EventsTraits = T extends BindingCallChannel ? BindingCallEvents : T extends WebSocketChannel ? WebSocketEvents : T extends ResponseChannel ? ResponseEvents : + T extends WebSocketRouteChannel ? WebSocketRouteEvents : T extends RouteChannel ? RouteEvents : T extends RequestChannel ? RequestEvents : T extends ElementHandleChannel ? ElementHandleEvents : @@ -114,6 +116,7 @@ export type EventTargetTraits = T extends BindingCallChannel ? BindingCallEventTarget : T extends WebSocketChannel ? WebSocketEventTarget : T extends ResponseChannel ? ResponseEventTarget : + T extends WebSocketRouteChannel ? WebSocketRouteEventTarget : T extends RouteChannel ? RouteEventTarget : T extends RequestChannel ? RequestEventTarget : T extends ElementHandleChannel ? ElementHandleEventTarget : @@ -1493,6 +1496,7 @@ export interface BrowserContextEventTarget { on(event: 'page', callback: (params: BrowserContextPageEvent) => void): this; on(event: 'pageError', callback: (params: BrowserContextPageErrorEvent) => void): this; on(event: 'route', callback: (params: BrowserContextRouteEvent) => void): this; + on(event: 'webSocketRoute', callback: (params: BrowserContextWebSocketRouteEvent) => void): this; on(event: 'video', callback: (params: BrowserContextVideoEvent) => void): this; on(event: 'backgroundPage', callback: (params: BrowserContextBackgroundPageEvent) => void): this; on(event: 'serviceWorker', callback: (params: BrowserContextServiceWorkerEvent) => void): this; @@ -1518,10 +1522,11 @@ export interface BrowserContextChannel extends BrowserContextEventTarget, EventT setGeolocation(params: BrowserContextSetGeolocationParams, metadata?: CallMetadata): Promise; setHTTPCredentials(params: BrowserContextSetHTTPCredentialsParams, metadata?: CallMetadata): Promise; setNetworkInterceptionPatterns(params: BrowserContextSetNetworkInterceptionPatternsParams, metadata?: CallMetadata): Promise; + setWebSocketInterceptionPatterns(params: BrowserContextSetWebSocketInterceptionPatternsParams, metadata?: CallMetadata): Promise; setOffline(params: BrowserContextSetOfflineParams, metadata?: CallMetadata): Promise; storageState(params?: BrowserContextStorageStateParams, metadata?: CallMetadata): Promise; pause(params?: BrowserContextPauseParams, metadata?: CallMetadata): Promise; - recorderSupplementEnable(params: BrowserContextRecorderSupplementEnableParams, metadata?: CallMetadata): Promise; + enableRecorder(params: BrowserContextEnableRecorderParams, metadata?: CallMetadata): Promise; newCDPSession(params: BrowserContextNewCDPSessionParams, metadata?: CallMetadata): Promise; harStart(params: BrowserContextHarStartParams, metadata?: CallMetadata): Promise; harExport(params: BrowserContextHarExportParams, metadata?: CallMetadata): Promise; @@ -1563,6 +1568,9 @@ export type BrowserContextPageErrorEvent = { export type BrowserContextRouteEvent = { route: RouteChannel, }; +export type BrowserContextWebSocketRouteEvent = { + webSocketRoute: WebSocketRouteChannel, +}; export type BrowserContextVideoEvent = { artifact: ArtifactChannel, }; @@ -1731,6 +1739,17 @@ export type BrowserContextSetNetworkInterceptionPatternsOptions = { }; export type BrowserContextSetNetworkInterceptionPatternsResult = void; +export type BrowserContextSetWebSocketInterceptionPatternsParams = { + patterns: { + glob?: string, + regexSource?: string, + regexFlags?: string, + }[], +}; +export type BrowserContextSetWebSocketInterceptionPatternsOptions = { + +}; +export type BrowserContextSetWebSocketInterceptionPatternsResult = void; export type BrowserContextSetOfflineParams = { offline: boolean, }; @@ -1747,9 +1766,10 @@ export type BrowserContextStorageStateResult = { export type BrowserContextPauseParams = {}; export type BrowserContextPauseOptions = {}; export type BrowserContextPauseResult = void; -export type BrowserContextRecorderSupplementEnableParams = { +export type BrowserContextEnableRecorderParams = { language?: string, mode?: 'inspecting' | 'recording', + codegenMode?: 'actions' | 'trace-events', pauseOnNextStatement?: boolean, testIdAttributeName?: string, launchOptions?: any, @@ -1759,9 +1779,10 @@ export type BrowserContextRecorderSupplementEnableParams = { outputFile?: string, omitCallTracking?: boolean, }; -export type BrowserContextRecorderSupplementEnableOptions = { +export type BrowserContextEnableRecorderOptions = { language?: string, mode?: 'inspecting' | 'recording', + codegenMode?: 'actions' | 'trace-events', pauseOnNextStatement?: boolean, testIdAttributeName?: string, launchOptions?: any, @@ -1771,7 +1792,7 @@ export type BrowserContextRecorderSupplementEnableOptions = { outputFile?: string, omitCallTracking?: boolean, }; -export type BrowserContextRecorderSupplementEnableResult = void; +export type BrowserContextEnableRecorderResult = void; export type BrowserContextNewCDPSessionParams = { page?: PageChannel, frame?: FrameChannel, @@ -1890,6 +1911,7 @@ export interface BrowserContextEvents { 'page': BrowserContextPageEvent; 'pageError': BrowserContextPageErrorEvent; 'route': BrowserContextRouteEvent; + 'webSocketRoute': BrowserContextWebSocketRouteEvent; 'video': BrowserContextVideoEvent; 'backgroundPage': BrowserContextBackgroundPageEvent; 'serviceWorker': BrowserContextServiceWorkerEvent; @@ -1919,6 +1941,7 @@ export interface PageEventTarget { on(event: 'frameDetached', callback: (params: PageFrameDetachedEvent) => void): this; on(event: 'locatorHandlerTriggered', callback: (params: PageLocatorHandlerTriggeredEvent) => void): this; on(event: 'route', callback: (params: PageRouteEvent) => void): this; + on(event: 'webSocketRoute', callback: (params: PageWebSocketRouteEvent) => void): this; on(event: 'video', callback: (params: PageVideoEvent) => void): this; on(event: 'webSocket', callback: (params: PageWebSocketEvent) => void): this; on(event: 'worker', callback: (params: PageWorkerEvent) => void): this; @@ -1942,6 +1965,7 @@ export interface PageChannel extends PageEventTarget, EventTargetChannel { screenshot(params: PageScreenshotParams, metadata?: CallMetadata): Promise; setExtraHTTPHeaders(params: PageSetExtraHTTPHeadersParams, metadata?: CallMetadata): Promise; setNetworkInterceptionPatterns(params: PageSetNetworkInterceptionPatternsParams, metadata?: CallMetadata): Promise; + setWebSocketInterceptionPatterns(params: PageSetWebSocketInterceptionPatternsParams, metadata?: CallMetadata): Promise; setViewportSize(params: PageSetViewportSizeParams, metadata?: CallMetadata): Promise; keyboardDown(params: PageKeyboardDownParams, metadata?: CallMetadata): Promise; keyboardUp(params: PageKeyboardUpParams, metadata?: CallMetadata): Promise; @@ -1989,6 +2013,9 @@ export type PageLocatorHandlerTriggeredEvent = { export type PageRouteEvent = { route: RouteChannel, }; +export type PageWebSocketRouteEvent = { + webSocketRoute: WebSocketRouteChannel, +}; export type PageVideoEvent = { artifact: ArtifactChannel, }; @@ -2221,6 +2248,17 @@ export type PageSetNetworkInterceptionPatternsOptions = { }; export type PageSetNetworkInterceptionPatternsResult = void; +export type PageSetWebSocketInterceptionPatternsParams = { + patterns: { + glob?: string, + regexSource?: string, + regexFlags?: string, + }[], +}; +export type PageSetWebSocketInterceptionPatternsOptions = { + +}; +export type PageSetWebSocketInterceptionPatternsResult = void; export type PageSetViewportSizeParams = { viewportSize: { width: number, @@ -2448,6 +2486,7 @@ export interface PageEvents { 'frameDetached': PageFrameDetachedEvent; 'locatorHandlerTriggered': PageLocatorHandlerTriggeredEvent; 'route': PageRouteEvent; + 'webSocketRoute': PageWebSocketRouteEvent; 'video': PageVideoEvent; 'webSocket': PageWebSocketEvent; 'worker': PageWorkerEvent; @@ -3732,7 +3771,6 @@ export type RouteRedirectNavigationRequestOptions = { export type RouteRedirectNavigationRequestResult = void; export type RouteAbortParams = { errorCode?: string, - requestUrl: string, }; export type RouteAbortOptions = { errorCode?: string, @@ -3743,7 +3781,6 @@ export type RouteContinueParams = { method?: string, headers?: NameValue[], postData?: Binary, - requestUrl: string, isFallback: boolean, }; export type RouteContinueOptions = { @@ -3759,7 +3796,6 @@ export type RouteFulfillParams = { body?: string, isBase64?: boolean, fetchResponseUid?: string, - requestUrl: string, }; export type RouteFulfillOptions = { status?: number, @@ -3773,6 +3809,70 @@ export type RouteFulfillResult = void; export interface RouteEvents { } +// ----------- WebSocketRoute ----------- +export type WebSocketRouteInitializer = { + url: string, +}; +export interface WebSocketRouteEventTarget { + on(event: 'messageFromPage', callback: (params: WebSocketRouteMessageFromPageEvent) => void): this; + on(event: 'messageFromServer', callback: (params: WebSocketRouteMessageFromServerEvent) => void): this; + on(event: 'close', callback: (params: WebSocketRouteCloseEvent) => void): this; +} +export interface WebSocketRouteChannel extends WebSocketRouteEventTarget, Channel { + _type_WebSocketRoute: boolean; + connect(params?: WebSocketRouteConnectParams, metadata?: CallMetadata): Promise; + ensureOpened(params?: WebSocketRouteEnsureOpenedParams, metadata?: CallMetadata): Promise; + sendToPage(params: WebSocketRouteSendToPageParams, metadata?: CallMetadata): Promise; + sendToServer(params: WebSocketRouteSendToServerParams, metadata?: CallMetadata): Promise; + close(params: WebSocketRouteCloseParams, metadata?: CallMetadata): Promise; +} +export type WebSocketRouteMessageFromPageEvent = { + message: string, + isBase64: boolean, +}; +export type WebSocketRouteMessageFromServerEvent = { + message: string, + isBase64: boolean, +}; +export type WebSocketRouteCloseEvent = {}; +export type WebSocketRouteConnectParams = {}; +export type WebSocketRouteConnectOptions = {}; +export type WebSocketRouteConnectResult = void; +export type WebSocketRouteEnsureOpenedParams = {}; +export type WebSocketRouteEnsureOpenedOptions = {}; +export type WebSocketRouteEnsureOpenedResult = void; +export type WebSocketRouteSendToPageParams = { + message: string, + isBase64: boolean, +}; +export type WebSocketRouteSendToPageOptions = { + +}; +export type WebSocketRouteSendToPageResult = void; +export type WebSocketRouteSendToServerParams = { + message: string, + isBase64: boolean, +}; +export type WebSocketRouteSendToServerOptions = { + +}; +export type WebSocketRouteSendToServerResult = void; +export type WebSocketRouteCloseParams = { + code?: number, + reason?: string, +}; +export type WebSocketRouteCloseOptions = { + code?: number, + reason?: string, +}; +export type WebSocketRouteCloseResult = void; + +export interface WebSocketRouteEvents { + 'messageFromPage': WebSocketRouteMessageFromPageEvent; + 'messageFromServer': WebSocketRouteMessageFromServerEvent; + 'close': WebSocketRouteCloseEvent; +} + export type ResourceTiming = { startTime: number, domainLookupStart: number, diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index ce206ab569..5133c0672e 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -1160,6 +1160,17 @@ BrowserContext: regexSource: string? regexFlags: string? + setWebSocketInterceptionPatterns: + parameters: + patterns: + type: array + items: + type: object + properties: + glob: string? + regexSource: string? + regexFlags: string? + setOffline: parameters: offline: boolean @@ -1176,7 +1187,7 @@ BrowserContext: pause: experimental: True - recorderSupplementEnable: + enableRecorder: experimental: True parameters: language: string? @@ -1185,6 +1196,11 @@ BrowserContext: literals: - inspecting - recording + codegenMode: + type: enum? + literals: + - actions + - trace-events pauseOnNextStatement: boolean? testIdAttributeName: string? launchOptions: json? @@ -1305,6 +1321,10 @@ BrowserContext: parameters: route: Route + webSocketRoute: + parameters: + webSocketRoute: WebSocketRoute + video: parameters: artifact: Artifact @@ -1520,6 +1540,17 @@ Page: regexSource: string? regexFlags: string? + setWebSocketInterceptionPatterns: + parameters: + patterns: + type: array + items: + type: object + properties: + glob: string? + regexSource: string? + regexFlags: string? + setViewportSize: parameters: viewportSize: @@ -1771,6 +1802,10 @@ Page: parameters: route: Route + webSocketRoute: + parameters: + webSocketRoute: WebSocketRoute + video: parameters: artifact: Artifact @@ -2911,7 +2946,6 @@ Route: abort: parameters: errorCode: string? - requestUrl: string continue: parameters: @@ -2921,7 +2955,6 @@ Route: type: array? items: NameValue postData: binary? - requestUrl: string isFallback: boolean fulfill: @@ -2934,7 +2967,49 @@ Route: body: string? isBase64: boolean? fetchResponseUid: string? - requestUrl: string + + +WebSocketRoute: + type: interface + + initializer: + url: string + + commands: + + connect: + + ensureOpened: + + sendToPage: + parameters: + message: string + isBase64: boolean + + sendToServer: + parameters: + message: string + isBase64: boolean + + close: + parameters: + code: number? + reason: string? + + events: + + messageFromPage: + parameters: + message: string + isBase64: boolean + + messageFromServer: + parameters: + message: string + isBase64: boolean + + close: + ResourceTiming: type: object diff --git a/packages/recorder/src/main.tsx b/packages/recorder/src/main.tsx index 2f7ea3ac4c..61ac9da67f 100644 --- a/packages/recorder/src/main.tsx +++ b/packages/recorder/src/main.tsx @@ -27,7 +27,10 @@ export const Main: React.FC = ({ const [mode, setMode] = React.useState('none'); window.playwrightSetMode = setMode; - window.playwrightSetSources = setSources; + window.playwrightSetSources = React.useCallback((sources: Source[]) => { + setSources(sources); + window.playwrightSourcesEchoForTest = sources; + }, []); window.playwrightSetPaused = setPaused; window.playwrightUpdateLogs = callLogs => { setLog(log => { @@ -40,6 +43,5 @@ export const Main: React.FC = ({ }); }; - window.playwrightSourcesEchoForTest = sources; return ; }; diff --git a/packages/trace-viewer/src/sw.ts b/packages/trace-viewer/src/sw.ts index 7888aa6a30..43029ed5bb 100644 --- a/packages/trace-viewer/src/sw.ts +++ b/packages/trace-viewer/src/sw.ts @@ -47,12 +47,14 @@ async function loadTrace(traceUrl: string, traceFileName: string | null, clientI } set.add(traceUrl); + const isRecorderMode = traceUrl.includes('/playwright-recorder-trace-'); + const traceModel = new TraceModel(); try { // Allow 10% to hop from sw to page. const [fetchProgress, unzipProgress] = splitProgress(progress, [0.5, 0.4, 0.1]); const backend = traceUrl.endsWith('json') ? new FetchTraceModelBackend(traceUrl) : new ZipTraceModelBackend(traceUrl, fetchProgress); - await traceModel.load(backend, unzipProgress); + await traceModel.load(backend, isRecorderMode, unzipProgress); } catch (error: any) { // eslint-disable-next-line no-console console.error(error); diff --git a/packages/trace-viewer/src/third_party/devtools.ts b/packages/trace-viewer/src/third_party/devtools.ts new file mode 100644 index 0000000000..27c520cbce --- /dev/null +++ b/packages/trace-viewer/src/third_party/devtools.ts @@ -0,0 +1,285 @@ +// Copyright 2014 The Chromium Authors. All rights reserved. +// Modifications copyright (c) Microsoft Corporation. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +/* + * Copyright (C) 2007, 2008 Apple Inc. All rights reserved. + * Copyright (C) 2008, 2009 Anthony Ricaud + * Copyright (C) 2011 Google Inc. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * 3. Neither the name of Apple Computer, Inc. ("Apple") nor the names of + * its contributors may be used to endorse or promote products derived + * from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY APPLE AND ITS CONTRIBUTORS "AS IS" AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL APPLE OR ITS CONTRIBUTORS BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF + * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import type { Entry } from '@trace/har'; + +// The following function is derived from Chromium's source code +// https://github.com/ChromeDevTools/devtools-frontend/blob/83cbe41b4107e188a1f66fdf6ea3a9cca42587c6/front_end/panels/network/NetworkLogView.ts#L2363 +export async function generateCurlCommand(resource: Entry): Promise { + const platform = navigator.platform.includes('Win') ? 'win' : 'unix'; + let command: string[] = []; + // Most of these headers are derived from the URL and are automatically added by cURL. + // The |Accept-Encoding| header is ignored to prevent decompression errors. crbug.com/1015321 + const ignoredHeaders = + new Set(['accept-encoding', 'host', 'method', 'path', 'scheme', 'version', 'authority', 'protocol']); + + function escapeStringWin(str: string): string { + /* Always escape the " characters so that we can use caret escaping. + + Because cmd.exe parser and MS Crt arguments parsers use some of the + same escape characters, they can interact with each other in + horrible ways, the order of operations is critical. + + Replace \ with \\ first because it is an escape character for certain + conditions in both parsers. + + Replace all " with \" to ensure the first parser does not remove it. + + Then escape all characters we are not sure about with ^ to ensure it + gets to MS Crt parser safely. + + The % character is special because MS Crt parser will try and look for + ENV variables and fill them in its place. We cannot escape them with % + and cannot escape them with ^ (because it's cmd.exe's escape not MS Crt + parser); So we can get cmd.exe parser to escape the character after it, + if it is followed by a valid beginning character of an ENV variable. + This ensures we do not try and double escape another ^ if it was placed + by the previous replace. + + Lastly we replace new lines with ^ and TWO new lines because the first + new line is there to enact the escape command the second is the character + to escape (in this case new line). + */ + const encapsChars = '^"'; + return encapsChars + + str.replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/[^a-zA-Z0-9\s_\-:=+~'\/.',?;()*`]/g, '^$&') + .replace(/%(?=[a-zA-Z0-9_])/g, '%^') + .replace(/\r?\n/g, '^\n\n') + + encapsChars; + } + + function escapeStringPosix(str: string): string { + function escapeCharacter(x: string): string { + const code = x.charCodeAt(0); + let hexString = code.toString(16); + // Zero pad to four digits to comply with ANSI-C Quoting: + // http://www.gnu.org/software/bash/manual/html_node/ANSI_002dC-Quoting.html + while (hexString.length < 4) + hexString = '0' + hexString; + + + return '\\u' + hexString; + } + + if (/[\0-\x1F\x7F-\x9F!]|\'/.test(str)) { + // Use ANSI-C quoting syntax. + return '$\'' + + str.replace(/\\/g, '\\\\') + .replace(/\'/g, '\\\'') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/[\0-\x1F\x7F-\x9F!]/g, escapeCharacter) + + '\''; + } + // Use single quote syntax. + return '\'' + str + '\''; + } + + // cURL command expected to run on the same platform that DevTools run + // (it may be different from the inspected page platform). + const escapeString = platform === 'win' ? escapeStringWin : escapeStringPosix; + + command.push(escapeString(resource.request.url).replace(/[[{}\]]/g, '\\$&')); + + let inferredMethod = 'GET'; + const data = []; + const formData = await fetchRequestPostData(resource); + if (formData) { + // Note that formData is not necessarily urlencoded because it might for example + // come from a fetch request made with an explicitly unencoded body. + data.push('--data-raw ' + escapeString(formData)); + ignoredHeaders.add('content-length'); + inferredMethod = 'POST'; + } + + if (resource.request.method !== inferredMethod) + command.push('-X ' + escapeString(resource.request.method)); + + + const requestHeaders = resource.request.headers; + for (let i = 0; i < requestHeaders.length; i++) { + const header = requestHeaders[i]; + const name = header.name.replace(/^:/, ''); // Translate SPDY v3 headers to HTTP headers. + if (ignoredHeaders.has(name.toLowerCase())) + continue; + + if (header.value.trim()) { + command.push('-H ' + escapeString(name + ': ' + header.value)); + } else { + // A header passed with -H with no value or only whitespace as its + // value tells curl to not set the header at all. To post an empty + // header, you have to terminate it with a semicolon. + command.push('-H ' + escapeString(name + ';')); + } + } + command = command.concat(data); + + return 'curl ' + command.join(command.length >= 3 ? (platform === 'win' ? ' ^\n ' : ' \\\n ') : ' '); +} + +const enum FetchStyle { + BROWSER = 0, + NODE_JS = 1, +} + +export async function generateFetchCall(resource: Entry, style: FetchStyle = FetchStyle.BROWSER): Promise { + const ignoredHeaders = new Set([ + // Internal headers + 'method', + 'path', + 'scheme', + 'version', + + // Unsafe headers + // Keep this list synchronized with src/net/http/http_util.cc + 'accept-charset', + 'accept-encoding', + 'access-control-request-headers', + 'access-control-request-method', + 'connection', + 'content-length', + 'cookie', + 'cookie2', + 'date', + 'dnt', + 'expect', + 'host', + 'keep-alive', + 'origin', + 'referer', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', + 'via', + // TODO(phistuck) - remove this once crbug.com/571722 is fixed. + 'user-agent', + ]); + + const credentialHeaders = new Set(['cookie', 'authorization']); + + const url = JSON.stringify(resource.request.url); + + const requestHeaders = resource.request.headers; + const headerData: Headers = requestHeaders.reduce((result, header) => { + const name = header.name; + + if (!ignoredHeaders.has(name.toLowerCase()) && !name.includes(':')) + result.append(name, header.value); + + + return result; + }, new Headers()); + + const headers: HeadersInit = {}; + for (const headerArray of headerData) + headers[headerArray[0]] = headerArray[1]; + + + const credentials = resource.request.cookies.length || + requestHeaders.some(({ name }) => credentialHeaders.has(name.toLowerCase())) ? + 'include' : + 'omit'; + + const referrerHeader = requestHeaders.find(({ name }) => name.toLowerCase() === 'referer'); + + const referrer = referrerHeader ? referrerHeader.value : void 0; + + const requestBody = await fetchRequestPostData(resource); + + const fetchOptions: RequestInit = { + headers: Object.keys(headers).length ? headers : void 0, + referrer, + body: requestBody, + method: resource.request.method, + mode: 'cors', + }; + + if (style === FetchStyle.NODE_JS) { + const cookieHeader = requestHeaders.find(header => header.name.toLowerCase() === 'cookie'); + const extraHeaders: HeadersInit = {}; + // According to https://www.npmjs.com/package/node-fetch#class-request the + // following properties are not implemented in Node.js. + delete fetchOptions.mode; + if (cookieHeader) + extraHeaders['cookie'] = cookieHeader.value; + + if (referrer) { + delete fetchOptions.referrer; + extraHeaders['Referer'] = referrer; + } + if (Object.keys(extraHeaders).length) { + fetchOptions.headers = { + ...headers, + ...extraHeaders, + }; + } + } else { + fetchOptions.credentials = credentials; + } + + const options = JSON.stringify(fetchOptions, null, 2); + return `fetch(${url}, ${options});`; +} + +async function fetchRequestPostData(resource: Entry) { + return resource.request.postData?._sha1 ? await fetch(`sha1/${resource.request.postData._sha1}`).then(r => r.text()) : resource.request.postData?.text; +} \ No newline at end of file diff --git a/packages/trace-viewer/src/traceModel.ts b/packages/trace-viewer/src/traceModel.ts index 1248dde967..893e928691 100644 --- a/packages/trace-viewer/src/traceModel.ts +++ b/packages/trace-viewer/src/traceModel.ts @@ -15,7 +15,7 @@ */ import { parseClientSideCallMetadata } from '../../../packages/playwright-core/src/utils/isomorphic/traceUtils'; -import type { ContextEntry } from './entries'; +import type { ActionEntry, ContextEntry } from './entries'; import { createEmptyContext } from './entries'; import { SnapshotStorage } from './snapshotStorage'; import { TraceModernizer } from './traceModernizer'; @@ -38,7 +38,7 @@ export class TraceModel { constructor() { } - async load(backend: TraceModelBackend, unzipProgress: (done: number, total: number) => void) { + async load(backend: TraceModelBackend, isRecorderMode: boolean, unzipProgress: (done: number, total: number) => void) { this._backend = backend; const ordinals: string[] = []; @@ -72,7 +72,9 @@ export class TraceModel { modernizer.appendTrace(network); unzipProgress(++done, total); - contextEntry.actions = modernizer.actions().sort((a1, a2) => a1.startTime - a2.startTime); + const actions = modernizer.actions().sort((a1, a2) => a1.startTime - a2.startTime); + contextEntry.actions = isRecorderMode ? collapseActionsForRecorder(actions) : actions; + if (!backend.isLive()) { // Terminate actions w/o after event gracefully. // This would close after hooks event that has not been closed because @@ -132,3 +134,19 @@ function stripEncodingFromContentType(contentType: string) { return charset[1]; return contentType; } + +function collapseActionsForRecorder(actions: ActionEntry[]): ActionEntry[] { + const result: ActionEntry[] = []; + for (const action of actions) { + const lastAction = result[result.length - 1]; + const isSameAction = lastAction && lastAction.method === action.method && lastAction.pageId === action.pageId; + const isSameSelector = lastAction && 'selector' in lastAction.params && 'selector' in action.params && action.params.selector === lastAction.params.selector; + const shouldMerge = isSameAction && (action.method === 'goto' || (action.method === 'fill' && isSameSelector)); + if (!shouldMerge) { + result.push(action); + continue; + } + result[result.length - 1] = action; + } + return result; +} diff --git a/packages/trace-viewer/src/ui/DEPS.list b/packages/trace-viewer/src/ui/DEPS.list index 3fab0da95b..0056375c05 100644 --- a/packages/trace-viewer/src/ui/DEPS.list +++ b/packages/trace-viewer/src/ui/DEPS.list @@ -6,3 +6,4 @@ ../entries.ts ../geometry.ts ../../../playwright/src/isomorphic/** +../third_party/devtools.ts diff --git a/packages/trace-viewer/src/ui/callTab.css b/packages/trace-viewer/src/ui/callTab.css index 56928088b5..f57f3f1529 100644 --- a/packages/trace-viewer/src/ui/callTab.css +++ b/packages/trace-viewer/src/ui/callTab.css @@ -36,6 +36,8 @@ .call-section { padding-left: 6px; + padding-top: 2px; + margin-top: 2px; font-weight: bold; text-transform: uppercase; font-size: 10px; @@ -53,9 +55,8 @@ align-items: center; text-overflow: ellipsis; overflow: hidden; - line-height: 18px; + line-height: 20px; white-space: nowrap; - max-height: 18px; } .call-line:not(:hover) .toolbar-button.copy { @@ -64,7 +65,8 @@ .call-line .toolbar-button.copy { margin-left: 5px; - transform: scale(0.8); + margin-top: -2px; + margin-bottom: -2px; } .call-value { diff --git a/packages/trace-viewer/src/ui/copyToClipboard.tsx b/packages/trace-viewer/src/ui/copyToClipboard.tsx index 3e570ede56..301be3dd03 100644 --- a/packages/trace-viewer/src/ui/copyToClipboard.tsx +++ b/packages/trace-viewer/src/ui/copyToClipboard.tsx @@ -18,19 +18,22 @@ import * as React from 'react'; import { ToolbarButton } from '@web/components/toolbarButton'; export const CopyToClipboard: React.FunctionComponent<{ - value: string, + value: string | (() => Promise), description?: string, }> = ({ value, description }) => { const [icon, setIcon] = React.useState('copy'); const handleCopy = React.useCallback(() => { - navigator.clipboard.writeText(value).then(() => { - setIcon('check'); - setTimeout(() => { - setIcon('copy'); - }, 3000); - }, () => { - setIcon('close'); + const valuePromise = typeof value === 'function' ? value() : Promise.resolve(value); + valuePromise.then(value => { + navigator.clipboard.writeText(value).then(() => { + setIcon('check'); + setTimeout(() => { + setIcon('copy'); + }, 3000); + }, () => { + setIcon('close'); + }); }); }, [value]); diff --git a/packages/trace-viewer/src/ui/modelUtil.ts b/packages/trace-viewer/src/ui/modelUtil.ts index a544d4dc3f..098b387c8f 100644 --- a/packages/trace-viewer/src/ui/modelUtil.ts +++ b/packages/trace-viewer/src/ui/modelUtil.ts @@ -312,7 +312,7 @@ function monotonicTimeDeltaBetweenLibraryAndRunner(nonPrimaryContexts: ContextEn for (const action of context.actions) { if (!action.startTime) continue; - const key = matchByStepId ? action.stepId! : `${action.apiName}@${(action as any).wallTime}`; + const key = matchByStepId ? action.callId! : `${action.apiName}@${(action as any).wallTime}`; const libraryAction = libraryActions.get(key); if (libraryAction) return action.startTime - libraryAction.startTime; diff --git a/packages/trace-viewer/src/ui/networkResourceDetails.css b/packages/trace-viewer/src/ui/networkResourceDetails.css index 59989b89dd..ac1245f70d 100644 --- a/packages/trace-viewer/src/ui/networkResourceDetails.css +++ b/packages/trace-viewer/src/ui/networkResourceDetails.css @@ -49,6 +49,15 @@ overflow: hidden; } +.network-request-details-copy { + display: flex; + margin-left: 10px; +} + +.network-request-details-copy button { + border-radius: 4px +} + .network-font-preview { font-family: font-preview; font-size: 30px; diff --git a/packages/trace-viewer/src/ui/networkResourceDetails.tsx b/packages/trace-viewer/src/ui/networkResourceDetails.tsx index 8df091b262..1f9fcc4581 100644 --- a/packages/trace-viewer/src/ui/networkResourceDetails.tsx +++ b/packages/trace-viewer/src/ui/networkResourceDetails.tsx @@ -20,6 +20,8 @@ import './networkResourceDetails.css'; import { TabbedPane } from '@web/components/tabbedPane'; import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper'; import { ToolbarButton } from '@web/components/toolbarButton'; +import { generateCurlCommand, generateFetchCall } from '../third_party/devtools'; +import { CopyToClipboard } from './copyToClipboard'; export const NetworkResourceDetails: React.FunctionComponent<{ resource: ResourceSnapshot; @@ -90,6 +92,13 @@ const RequestTab: React.FunctionComponent<{ : null}
Request Headers
{resource.request.headers.map(pair => `${pair.name}: ${pair.value}`).join('\n')}
+
Copy Request
+
+ As cURL: generateCurlCommand(resource)}/> +
+
+ As Fetch: generateFetchCall(resource)}/> +
{requestBody &&
Request Body
} {requestBody && } ; diff --git a/packages/trace-viewer/src/ui/recorderView.tsx b/packages/trace-viewer/src/ui/recorderView.tsx index 940fd146a9..945ac86fc0 100644 --- a/packages/trace-viewer/src/ui/recorderView.tsx +++ b/packages/trace-viewer/src/ui/recorderView.tsx @@ -102,6 +102,7 @@ export const TraceView: React.FC<{ showSourcesFirst={true} fallbackLocation={fallbackLocation} isLive={true} + hideTimeline={true} />; }; @@ -163,6 +164,7 @@ class Connection { if (method === 'setSources') { const { sources } = params as { sources: Source[] }; this._options.setSources(sources); + window.playwrightSourcesEchoForTest = sources; } } } diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index a97716bdc4..ba74e60092 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -105,7 +105,6 @@ export const UIModeView: React.FC<{}> = ({ const [singleWorker, setSingleWorker] = React.useState(queryParams.workers === '1'); const [showBrowser, setShowBrowser] = React.useState(queryParams.headed); const [updateSnapshots, setUpdateSnapshots] = React.useState(queryParams.updateSnapshots === 'all'); - const [showRouteActions, setShowRouteActions] = useSetting('show-route-actions', true); const [darkMode, setDarkMode] = useDarkModeSetting(); const [showScreenshot, setShowScreenshot] = useSetting('screenshot-instead-of-snapshot', false); @@ -526,7 +525,6 @@ export const UIModeView: React.FC<{}> = ({ {settingsVisible && } diff --git a/packages/trace-viewer/src/ui/workbench.tsx b/packages/trace-viewer/src/ui/workbench.tsx index e1ce2298ae..95c18d8d0a 100644 --- a/packages/trace-viewer/src/ui/workbench.tsx +++ b/packages/trace-viewer/src/ui/workbench.tsx @@ -24,7 +24,6 @@ import type { ErrorDescription } from './errorsTab'; import type { ConsoleEntry } from './consoleTab'; import { ConsoleTab, useConsoleTabModel } from './consoleTab'; import type * as modelUtil from './modelUtil'; -import { isRouteAction } from './modelUtil'; import { NetworkTab, useNetworkTabModel } from './networkTab'; import { SnapshotTab } from './snapshotTab'; import { SourceTab } from './sourceTab'; @@ -50,6 +49,7 @@ export const Workbench: React.FunctionComponent<{ rootDir?: string, fallbackLocation?: modelUtil.SourceLocation, isLive?: boolean, + hideTimeline?: boolean, status?: UITestStatus, annotations?: { type: string; description?: string; }[]; inert?: boolean, @@ -57,7 +57,7 @@ export const Workbench: React.FunctionComponent<{ onOpenExternally?: (location: modelUtil.SourceLocation) => void, revealSource?: boolean, showSettings?: boolean, -}> = ({ model, showSourcesFirst, rootDir, fallbackLocation, isLive, status, annotations, inert, openPage, onOpenExternally, revealSource, showSettings }) => { +}> = ({ model, showSourcesFirst, rootDir, fallbackLocation, isLive, hideTimeline, status, annotations, inert, openPage, onOpenExternally, revealSource, showSettings }) => { const [selectedCallId, setSelectedCallId] = React.useState(undefined); const [revealedError, setRevealedError] = React.useState(undefined); @@ -70,13 +70,8 @@ export const Workbench: React.FunctionComponent<{ const [highlightedLocator, setHighlightedLocator] = React.useState(''); const [selectedTime, setSelectedTime] = React.useState(); const [sidebarLocation, setSidebarLocation] = useSetting<'bottom' | 'right'>('propertiesSidebarLocation', 'bottom'); - const [showRouteActions, setShowRouteActions] = useSetting('show-route-actions', true); const [showScreenshot, setShowScreenshot] = useSetting('screenshot-instead-of-snapshot', false); - const filteredActions = React.useMemo(() => { - return (model?.actions || []).filter(action => showRouteActions || !isRouteAction(action)); - }, [model, showRouteActions]); - const setSelectedAction = React.useCallback((action: modelUtil.ActionTraceEventInContext | undefined) => { setSelectedCallId(action?.callId); setRevealedError(undefined); @@ -291,7 +286,7 @@ export const Workbench: React.FunctionComponent<{ } , }; return
- + />} testId, className, }) => { - className = (className || '') + ` toolbar-button ${icon}`; - if (toggled) - className += ' toggled'; return