Merge branch 'main' into html-reporter-attachments
This commit is contained in:
commit
71be4b982c
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -35,3 +35,4 @@ test-results
|
|||
.cache/
|
||||
.eslintcache
|
||||
playwright.env
|
||||
firefox
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# 🎭 Playwright
|
||||
|
||||
[](https://www.npmjs.com/package/playwright) <!-- GEN:chromium-version-badge -->[](https://www.chromium.org/Home)<!-- GEN:stop --> <!-- GEN:firefox-version-badge -->[](https://www.mozilla.org/en-US/firefox/new/)<!-- GEN:stop --> <!-- GEN:webkit-version-badge -->[](https://webkit.org/)<!-- GEN:stop --> [](https://aka.ms/playwright/discord)
|
||||
[](https://www.npmjs.com/package/playwright) <!-- GEN:chromium-version-badge -->[](https://www.chromium.org/Home)<!-- GEN:stop --> <!-- GEN:firefox-version-badge -->[](https://www.mozilla.org/en-US/firefox/new/)<!-- GEN:stop --> <!-- GEN:webkit-version-badge -->[](https://webkit.org/)<!-- GEN:stop --> [](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 <!-- GEN:chromium-version -->130.0.6723.31<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| Chromium <!-- GEN:chromium-version -->130.0.6723.44<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| WebKit <!-- GEN:webkit-version -->18.0<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| Firefox <!-- GEN:firefox-version -->131.0<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
|
||||
|
|
|
|||
|
|
@ -150,6 +150,63 @@ var button = page.GetByRole(AriaRole.Button).And(page.GetByTitle("Subscribe"));
|
|||
|
||||
Additional locator to match.
|
||||
|
||||
## async method: Locator.ariaSnapshot
|
||||
* since: v1.49
|
||||
- returns: <[string]>
|
||||
|
||||
Captures the aria snapshot of the given element. See [`method: LocatorAssertions.toMatchAriaSnapshot`] for the corresponding assertion.
|
||||
|
||||
**Usage**
|
||||
|
||||
```js
|
||||
await page.getByRole('link').ariaSnapshot();
|
||||
```
|
||||
|
||||
```java
|
||||
page.getByRole(AriaRole.LINK).ariaSnapshot();
|
||||
```
|
||||
|
||||
```python async
|
||||
await page.get_by_role("link").aria_snapshot()
|
||||
```
|
||||
|
||||
```python sync
|
||||
page.get_by_role("link").aria_snapshot()
|
||||
```
|
||||
|
||||
```csharp
|
||||
await page.GetByRole(AriaRole.Link).AriaSnapshotAsync();
|
||||
```
|
||||
|
||||
**Details**
|
||||
|
||||
This method captures the aria snapshot of the given element. The snapshot is a string that represents the state of the element and its children.
|
||||
The snapshot can be used to assert the state of the element in the test, or to compare it to state in the future.
|
||||
|
||||
The ARIA snapshot is represented using [YAML](https://yaml.org/spec/1.2.2/) markup language:
|
||||
* The keys of the objects are the roles and optional accessible names of the elements.
|
||||
* The values are either text content or an array of child elements.
|
||||
* Generic static text can be represented with the `text` key.
|
||||
|
||||
Below is the HTML markup and the respective ARIA snapshot:
|
||||
|
||||
```html
|
||||
<ul aria-label="Links">
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/about">About</a></li>
|
||||
<ul>
|
||||
```
|
||||
|
||||
```yml
|
||||
- list "Links":
|
||||
- listitem:
|
||||
- link "Home"
|
||||
- listitem:
|
||||
- link "About"
|
||||
```
|
||||
|
||||
### option: Locator.ariaSnapshot.timeout = %%-input-timeout-js-%%
|
||||
* since: v1.49
|
||||
|
||||
## async method: Locator.blur
|
||||
* since: v1.28
|
||||
|
|
|
|||
|
|
@ -2103,3 +2103,59 @@ Expected options currently selected.
|
|||
### option: LocatorAssertions.toHaveValues.timeout = %%-csharp-java-python-assertions-timeout-%%
|
||||
* since: v1.23
|
||||
|
||||
|
||||
## async method: LocatorAssertions.toMatchAriaSnapshot
|
||||
* since: v1.49
|
||||
* langs:
|
||||
- alias-java: matchesAriaSnapshot
|
||||
|
||||
Asserts that the target element matches the given accessibility snapshot.
|
||||
|
||||
**Usage**
|
||||
|
||||
```js
|
||||
await page.goto('https://demo.playwright.dev/todomvc/');
|
||||
await expect(page.locator('body')).toMatchAriaSnapshot(`
|
||||
- heading "todos"
|
||||
- textbox "What needs to be done?"
|
||||
`);
|
||||
```
|
||||
|
||||
```python async
|
||||
await page.goto('https://demo.playwright.dev/todomvc/')
|
||||
await expect(page.locator('body')).to_match_aria_snapshot('''
|
||||
- heading "todos"
|
||||
- textbox "What needs to be done?"
|
||||
''')
|
||||
```
|
||||
|
||||
```python sync
|
||||
page.goto('https://demo.playwright.dev/todomvc/')
|
||||
expect(page.locator('body')).to_match_aria_snapshot('''
|
||||
- heading "todos"
|
||||
- textbox "What needs to be done?"
|
||||
''')
|
||||
```
|
||||
|
||||
```csharp
|
||||
await page.GotoAsync("https://demo.playwright.dev/todomvc/");
|
||||
await Expect(page.Locator("body")).ToMatchAriaSnapshotAsync(@"
|
||||
- heading ""todos""
|
||||
- textbox ""What needs to be done?""
|
||||
");
|
||||
```
|
||||
|
||||
```java
|
||||
page.navigate("https://demo.playwright.dev/todomvc/");
|
||||
assertThat(page.locator("body")).matchesAriaSnapshot("""
|
||||
- heading "todos"
|
||||
- textbox "What needs to be done?"
|
||||
""");
|
||||
```
|
||||
|
||||
### param: LocatorAssertions.toMatchAriaSnapshot.expected
|
||||
* since: v1.49
|
||||
- `expected` <string>
|
||||
|
||||
### option: LocatorAssertions.toMatchAriaSnapshot.timeout = %%-js-assertions-timeout-%%
|
||||
* since: v1.49
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ Whenever a [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSoc
|
|||
By default, the routed WebSocket will not connect to the server. This way, you can mock entire communcation over the WebSocket. Here is an example that responds to a `"request"` with a `"response"`.
|
||||
|
||||
```js
|
||||
await page.routeWebSocket('/ws', ws => {
|
||||
await page.routeWebSocket('wss://example.com/ws', ws => {
|
||||
ws.onMessage(message => {
|
||||
if (message === 'request')
|
||||
ws.send('response');
|
||||
|
|
@ -17,7 +17,7 @@ await page.routeWebSocket('/ws', ws => {
|
|||
```
|
||||
|
||||
```java
|
||||
page.routeWebSocket("/ws", ws -> {
|
||||
page.routeWebSocket("wss://example.com/ws", ws -> {
|
||||
ws.onMessage(message -> {
|
||||
if ("request".equals(message))
|
||||
ws.send("response");
|
||||
|
|
@ -30,7 +30,7 @@ def message_handler(ws: WebSocketRoute, message: Union[str, bytes]):
|
|||
if message == "request":
|
||||
ws.send("response")
|
||||
|
||||
await page.route_web_socket("/ws", lambda ws: ws.on_message(
|
||||
await page.route_web_socket("wss://example.com/ws", lambda ws: ws.on_message(
|
||||
lambda message: message_handler(ws, message)
|
||||
))
|
||||
```
|
||||
|
|
@ -40,13 +40,13 @@ def message_handler(ws: WebSocketRoute, message: Union[str, bytes]):
|
|||
if message == "request":
|
||||
ws.send("response")
|
||||
|
||||
page.route_web_socket("/ws", lambda ws: ws.on_message(
|
||||
page.route_web_socket("wss://example.com/ws", lambda ws: ws.on_message(
|
||||
lambda message: message_handler(ws, message)
|
||||
))
|
||||
```
|
||||
|
||||
```csharp
|
||||
await page.RouteWebSocketAsync("/ws", ws => {
|
||||
await page.RouteWebSocketAsync("wss://example.com/ws", ws => {
|
||||
ws.OnMessage(message => {
|
||||
if (message == "request")
|
||||
ws.Send("response");
|
||||
|
|
@ -56,6 +56,69 @@ await page.RouteWebSocketAsync("/ws", ws => {
|
|||
|
||||
Since we do not call [`method: WebSocketRoute.connectToServer`] inside the WebSocket route handler, Playwright assumes that WebSocket will be mocked, and opens the WebSocket inside the page automatically.
|
||||
|
||||
Here is another example that handles JSON messages:
|
||||
|
||||
```js
|
||||
await page.routeWebSocket('wss://example.com/ws', ws => {
|
||||
ws.onMessage(message => {
|
||||
const json = JSON.parse(message);
|
||||
if (json.request === 'question')
|
||||
ws.send(JSON.stringify({ response: 'answer' }));
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
```java
|
||||
page.routeWebSocket("wss://example.com/ws", ws -> {
|
||||
ws.onMessage(message -> {
|
||||
JsonObject json = new JsonParser().parse(message).getAsJsonObject();
|
||||
if ("question".equals(json.get("request").getAsString())) {
|
||||
Map<String, String> result = new HashMap();
|
||||
result.put("response", "answer");
|
||||
ws.send(gson.toJson(result));
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
```python async
|
||||
def message_handler(ws: WebSocketRoute, message: Union[str, bytes]):
|
||||
json_message = json.loads(message)
|
||||
if json_message["request"] == "question":
|
||||
ws.send(json.dumps({ "response": "answer" }))
|
||||
|
||||
await page.route_web_socket("wss://example.com/ws", lambda ws: ws.on_message(
|
||||
lambda message: message_handler(ws, message)
|
||||
))
|
||||
```
|
||||
|
||||
```python sync
|
||||
def message_handler(ws: WebSocketRoute, message: Union[str, bytes]):
|
||||
json_message = json.loads(message)
|
||||
if json_message["request"] == "question":
|
||||
ws.send(json.dumps({ "response": "answer" }))
|
||||
|
||||
page.route_web_socket("wss://example.com/ws", lambda ws: ws.on_message(
|
||||
lambda message: message_handler(ws, message)
|
||||
))
|
||||
```
|
||||
|
||||
```csharp
|
||||
await page.RouteWebSocketAsync("wss://example.com/ws", ws => {
|
||||
ws.OnMessage(message => {
|
||||
using var jsonDoc = JsonDocument.Parse(message);
|
||||
JsonElement root = jsonDoc.RootElement;
|
||||
if (root.TryGetProperty("request", out JsonElement requestElement) && requestElement.GetString() == "question")
|
||||
{
|
||||
var response = new Dictionary<string, string> { ["response"] = "answer" };
|
||||
string jsonResponse = JsonSerializer.Serialize(response);
|
||||
ws.Send(jsonResponse);
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
**Intercepting**
|
||||
|
||||
Alternatively, you may want to connect to the actual server, but intercept messages in-between and modify or block them. Calling [`method: WebSocketRoute.connectToServer`] returns a server-side `WebSocketRoute` instance that you can send messages to, or handle incoming messages.
|
||||
|
|
@ -256,19 +319,25 @@ By default, closing one side of the connection, either in the page or on the ser
|
|||
### param: WebSocketRoute.onClose.handler
|
||||
* since: v1.48
|
||||
* langs: js, python
|
||||
- `handler` <[function]\([number]|[undefined], [string]|[undefined]\): [Promise<any>|any]>
|
||||
- `handler` <[function]\([int]|[undefined], [string]|[undefined]\): [Promise<any>|any]>
|
||||
|
||||
Function that will handle WebSocket closure. Received an optional [close code](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close#code) and an optional [close reason](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close#reason).
|
||||
|
||||
### param: WebSocketRoute.onClose.handler
|
||||
* since: v1.48
|
||||
* langs: java, csharp
|
||||
- `handler` <[function]\([null]|[number], [null]|[string]\)>
|
||||
* langs: java
|
||||
- `handler` <[function]\([null]|[int], [null]|[string]\)>
|
||||
|
||||
Function that will handle WebSocket closure. Received an optional [close code](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close#code) and an optional [close reason](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close#reason).
|
||||
|
||||
### param: WebSocketRoute.onClose.handler
|
||||
* since: v1.48
|
||||
* langs: csharp
|
||||
- `handler` <[function]\([int?], [string]\)>
|
||||
|
||||
## async method: WebSocketRoute.onMessage
|
||||
Function that will handle WebSocket closure. Received an optional [close code](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close#code) and an optional [close reason](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close#reason).
|
||||
|
||||
## method: WebSocketRoute.onMessage
|
||||
* since: v1.48
|
||||
|
||||
This method allows to handle messages that are sent by the WebSocket, either from the page or from the server.
|
||||
|
|
|
|||
|
|
@ -363,7 +363,7 @@ Target URL.
|
|||
|
||||
## js-fetch-option-params
|
||||
* langs: js
|
||||
- `params` <[Object]<[string], [string]|[number]|[boolean]>|[URLSearchParams]|[string]>
|
||||
- `params` <[Object]<[string], [string]|[float]|[boolean]>|[URLSearchParams]|[string]>
|
||||
|
||||
Query parameters to be sent with the URL.
|
||||
|
||||
|
|
|
|||
|
|
@ -1218,7 +1218,7 @@ var button = page.GetByRole(AriaRole.Button).And(page.GetByTitle("Subscribe"));
|
|||
|
||||
### Matching one of the two alternative locators
|
||||
|
||||
If you'd like to target one of the two or more elements, and you don't know which one it will be, use [`method: Locator.or`] to create a locator that matches all of the alternatives.
|
||||
If you'd like to target one of the two or more elements, and you don't know which one it will be, use [`method: Locator.or`] to create a locator that matches any one or both of the alternatives.
|
||||
|
||||
For example, consider a scenario where you'd like to click on a "New email" button, but sometimes a security settings dialog shows up instead. In this case, you can wait for either a "New email" button, or a dialog and act accordingly.
|
||||
|
||||
|
|
|
|||
119
docs/src/mock.md
119
docs/src/mock.md
|
|
@ -435,3 +435,122 @@ pwsh bin/Debug/netX/playwright.ps1 open --save-har=example.har --save-har-glob="
|
|||
```
|
||||
|
||||
Read more about [advanced networking](./network.md).
|
||||
|
||||
## Mock WebSockets
|
||||
|
||||
The following code will intercept WebSocket connections and mock entire communcation over the WebSocket, instead of connecting to the server. This example responds to a `"request"` with a `"response"`.
|
||||
|
||||
```js
|
||||
await page.routeWebSocket('wss://example.com/ws', ws => {
|
||||
ws.onMessage(message => {
|
||||
if (message === 'request')
|
||||
ws.send('response');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
```java
|
||||
page.routeWebSocket("wss://example.com/ws", ws -> {
|
||||
ws.onMessage(message -> {
|
||||
if ("request".equals(message))
|
||||
ws.send("response");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
```python async
|
||||
def message_handler(ws: WebSocketRoute, message: Union[str, bytes]):
|
||||
if message == "request":
|
||||
ws.send("response")
|
||||
|
||||
await page.route_web_socket("wss://example.com/ws", lambda ws: ws.on_message(
|
||||
lambda message: message_handler(ws, message)
|
||||
))
|
||||
```
|
||||
|
||||
```python sync
|
||||
def message_handler(ws: WebSocketRoute, message: Union[str, bytes]):
|
||||
if message == "request":
|
||||
ws.send("response")
|
||||
|
||||
page.route_web_socket("wss://example.com/ws", lambda ws: ws.on_message(
|
||||
lambda message: message_handler(ws, message)
|
||||
))
|
||||
```
|
||||
|
||||
```csharp
|
||||
await page.RouteWebSocketAsync("wss://example.com/ws", ws => {
|
||||
ws.OnMessage(message => {
|
||||
if (message == "request")
|
||||
ws.Send("response");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Alternatively, you may want to connect to the actual server, but intercept messages in-between and modify or block them. Here is an example that modifies some of the messages sent by the page to the server, and leaves the rest unmodified.
|
||||
|
||||
```js
|
||||
await page.routeWebSocket('wss://example.com/ws', ws => {
|
||||
const server = ws.connectToServer();
|
||||
ws.onMessage(message => {
|
||||
if (message === 'request')
|
||||
server.send('request2');
|
||||
else
|
||||
server.send(message);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
```java
|
||||
page.routeWebSocket("wss://example.com/ws", ws -> {
|
||||
WebSocketRoute server = ws.connectToServer();
|
||||
ws.onMessage(message -> {
|
||||
if ("request".equals(message))
|
||||
server.send("request2");
|
||||
else
|
||||
server.send(message);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
```python async
|
||||
def message_handler(server: WebSocketRoute, message: Union[str, bytes]):
|
||||
if message == "request":
|
||||
server.send("request2")
|
||||
else:
|
||||
server.send(message)
|
||||
|
||||
def handler(ws: WebSocketRoute):
|
||||
server = ws.connect_to_server()
|
||||
ws.on_message(lambda message: message_handler(server, message))
|
||||
|
||||
await page.route_web_socket("wss://example.com/ws", handler)
|
||||
```
|
||||
|
||||
```python sync
|
||||
def message_handler(server: WebSocketRoute, message: Union[str, bytes]):
|
||||
if message == "request":
|
||||
server.send("request2")
|
||||
else:
|
||||
server.send(message)
|
||||
|
||||
def handler(ws: WebSocketRoute):
|
||||
server = ws.connect_to_server()
|
||||
ws.on_message(lambda message: message_handler(server, message))
|
||||
|
||||
page.route_web_socket("wss://example.com/ws", handler)
|
||||
```
|
||||
|
||||
```csharp
|
||||
await page.RouteWebSocketAsync("wss://example.com/ws", ws => {
|
||||
var server = ws.ConnectToServer();
|
||||
ws.OnMessage(message => {
|
||||
if (message == "request")
|
||||
server.Send("request2");
|
||||
else
|
||||
server.Send(message);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
For more details, see [WebSocketRoute].
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ Playwright provides APIs to **monitor** and **modify** browser network traffic,
|
|||
|
||||
## Mock APIs
|
||||
|
||||
Check out our [API mocking guide](./mock.md) to learn more on how to
|
||||
Check out our [API mocking guide](./mock.md) to learn more on how to
|
||||
- mock API requests and never hit the API
|
||||
- perform the API request and modify the response
|
||||
- use HAR files to mock network requests.
|
||||
|
|
@ -723,7 +723,9 @@ Important notes:
|
|||
|
||||
## WebSockets
|
||||
|
||||
Playwright supports [WebSockets](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API) inspection out of the box. Every time a WebSocket is created, the [`event: Page.webSocket`] event is fired. This event contains the [WebSocket] instance for further web socket frames inspection:
|
||||
Playwright supports [WebSockets](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API) inspection, mocking and modifying out of the box. See our [API mocking guide](./mock.md#mock-websockets) to learn how to mock WebSockets.
|
||||
|
||||
Every time a WebSocket is created, the [`event: Page.webSocket`] event is fired. This event contains the [WebSocket] instance for further web socket frames inspection:
|
||||
|
||||
```js
|
||||
page.on('websocket', ws => {
|
||||
|
|
|
|||
|
|
@ -552,6 +552,22 @@ export default defineConfig({
|
|||
});
|
||||
```
|
||||
|
||||
## property: TestConfig.tsconfig
|
||||
* since: v1.49
|
||||
- type: ?<[string]>
|
||||
|
||||
Path to a single `tsconfig` applicable to all imported files. By default, `tsconfig` for each imported file is looked up separately. Note that `tsconfig` property has no effect while the configuration file or any of its dependencies are loaded. Ignored when `--tsconfig` command line option is specified.
|
||||
|
||||
**Usage**
|
||||
|
||||
```js title="playwright.config.ts"
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
tsconfig: './tsconfig.test.json',
|
||||
});
|
||||
```
|
||||
|
||||
## property: TestConfig.updateSnapshots
|
||||
* since: v1.10
|
||||
- type: ?<[UpdateSnapshots]<"all"|"none"|"missing">>
|
||||
|
|
|
|||
|
|
@ -90,6 +90,16 @@ Alternatively, you can specify a single tsconfig file to use in the command line
|
|||
npx playwright test --tsconfig=tsconfig.test.json
|
||||
```
|
||||
|
||||
You can specify a single tsconfig file in the config file, that will be used for loading test files, reporters, etc. However, it will not be used while loading the playwright config itself or any files imported from it.
|
||||
|
||||
```js title="playwright.config.ts"
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
tsconfig: './tsconfig.test.json',
|
||||
});
|
||||
```
|
||||
|
||||
## Manually compile tests with TypeScript
|
||||
|
||||
Sometimes, Playwright Test will not be able to transform your TypeScript code correctly, for example when you are using experimental or very recent features of TypeScript, usually configured in `tsconfig.json`.
|
||||
|
|
|
|||
11
package-lock.json
generated
11
package-lock.json
generated
|
|
@ -64,7 +64,7 @@
|
|||
"vite": "^5.4.6",
|
||||
"ws": "^8.17.1",
|
||||
"xml2js": "^0.5.0",
|
||||
"yaml": "^2.2.2"
|
||||
"yaml": "^2.5.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
|
@ -7852,10 +7852,13 @@
|
|||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.3.4",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.4.tgz",
|
||||
"integrity": "sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==",
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz",
|
||||
"integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,6 +103,6 @@
|
|||
"vite": "^5.4.6",
|
||||
"ws": "^8.17.1",
|
||||
"xml2js": "^0.5.0",
|
||||
"yaml": "^2.2.2"
|
||||
"yaml": "^2.5.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,9 +14,8 @@
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
.test-error-message {
|
||||
.test-error-view {
|
||||
white-space: pre;
|
||||
font-family: monospace;
|
||||
overflow: auto;
|
||||
flex: none;
|
||||
padding: 0;
|
||||
|
|
@ -26,3 +25,7 @@
|
|||
line-height: initial;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.test-error-text {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,21 +17,39 @@
|
|||
import ansi2html from 'ansi-to-html';
|
||||
import * as React from 'react';
|
||||
import './testErrorView.css';
|
||||
import type { ImageDiff } from '@web/shared/imageDiffView';
|
||||
import { ImageDiffView } from '@web/shared/imageDiffView';
|
||||
|
||||
export const TestErrorView: React.FC<{
|
||||
error: string;
|
||||
}> = ({ error }) => {
|
||||
const html = React.useMemo(() => {
|
||||
const config: any = {
|
||||
bg: 'var(--color-canvas-subtle)',
|
||||
fg: 'var(--color-fg-default)',
|
||||
};
|
||||
config.colors = ansiColors;
|
||||
return new ansi2html(config).toHtml(escapeHTML(error));
|
||||
}, [error]);
|
||||
return <div className='test-error-message' dangerouslySetInnerHTML={{ __html: html || '' }}></div>;
|
||||
const html = React.useMemo(() => ansiErrorToHtml(error), [error]);
|
||||
return <div className='test-error-view test-error-text' dangerouslySetInnerHTML={{ __html: html || '' }}></div>;
|
||||
};
|
||||
|
||||
export const TestScreenshotErrorView: React.FC<{
|
||||
errorPrefix?: string,
|
||||
diff: ImageDiff,
|
||||
errorSuffix?: string,
|
||||
}> = ({ errorPrefix, diff, errorSuffix }) => {
|
||||
const prefixHtml = React.useMemo(() => ansiErrorToHtml(errorPrefix), [errorPrefix]);
|
||||
const suffixHtml = React.useMemo(() => ansiErrorToHtml(errorSuffix), [errorSuffix]);
|
||||
return <div data-testid='test-screenshot-error-view' className='test-error-view'>
|
||||
<div dangerouslySetInnerHTML={{ __html: prefixHtml || '' }} className='test-error-text' style={{ marginBottom: 20 }}></div>
|
||||
<ImageDiffView key='image-diff' diff={diff} hideDetails={true}></ImageDiffView>
|
||||
<div data-testid='error-suffix' dangerouslySetInnerHTML={{ __html: suffixHtml || '' }} className='test-error-text'></div>
|
||||
</div>;
|
||||
};
|
||||
|
||||
function ansiErrorToHtml(text?: string): string {
|
||||
const config: any = {
|
||||
bg: 'var(--color-canvas-subtle)',
|
||||
fg: 'var(--color-fg-default)',
|
||||
};
|
||||
config.colors = ansiColors;
|
||||
return new ansi2html(config).toHtml(escapeHTML(text || ''));
|
||||
}
|
||||
|
||||
const ansiColors = {
|
||||
0: '#000',
|
||||
1: '#C00',
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import { AttachmentLink, generateTraceUrl } from './links';
|
|||
import { statusIcon } from './statusIcon';
|
||||
import type { ImageDiff } from '@web/shared/imageDiffView';
|
||||
import { ImageDiffView } from '@web/shared/imageDiffView';
|
||||
import { TestErrorView } from './testErrorView';
|
||||
import { TestErrorView, TestScreenshotErrorView } from './testErrorView';
|
||||
import './testResultView.css';
|
||||
|
||||
function groupImageDiffs(screenshots: Set<TestAttachment>): ImageDiff[] {
|
||||
|
|
@ -78,8 +78,7 @@ export const TestResultView: React.FC<{
|
|||
result: TestResult,
|
||||
anchor: 'video' | 'diff' | '',
|
||||
}> = ({ result, anchor }) => {
|
||||
|
||||
const { screenshots, videos, traces, otherAttachments, diffs, htmls } = React.useMemo(() => {
|
||||
const { screenshots, videos, traces, otherAttachments, diffs, errors, htmls } = React.useMemo(() => {
|
||||
const attachments = result.attachments;
|
||||
const screenshots = new Set(attachments.filter(a => getAttachmentCategory(a) === 'screenshot'));
|
||||
const videos = attachments.filter(a => getAttachmentCategory(a) === 'video');
|
||||
|
|
@ -87,8 +86,9 @@ export const TestResultView: React.FC<{
|
|||
const htmls = attachments.filter(a => getAttachmentCategory(a) === 'html');
|
||||
const otherAttachments = attachments.filter(a => getAttachmentCategory(a) === 'other');
|
||||
const diffs = groupImageDiffs(screenshots);
|
||||
return { screenshots: [...screenshots], videos, traces, otherAttachments, diffs, htmls };
|
||||
}, [result.attachments]);
|
||||
const errors = classifyErrors(result.errors, diffs);
|
||||
return { screenshots: [...screenshots], videos, traces, otherAttachments, diffs, errors, htmls };
|
||||
}, [result.attachments, result.errors]);
|
||||
|
||||
const videoRef = React.useRef<HTMLDivElement>(null);
|
||||
const imageDiffRef = React.useRef<HTMLDivElement>(null);
|
||||
|
|
@ -105,15 +105,19 @@ export const TestResultView: React.FC<{
|
|||
}, [scrolled, anchor, setScrolled, videoRef]);
|
||||
|
||||
return <div className='test-result'>
|
||||
{!!result.errors.length && <AutoChip header='Errors'>
|
||||
{result.errors.map((error, index) => <TestErrorView key={'test-result-error-message-' + index} error={error}></TestErrorView>)}
|
||||
{!!errors.length && <AutoChip header='Errors'>
|
||||
{errors.map((error, index) => {
|
||||
if (error.type === 'screenshot')
|
||||
return <TestScreenshotErrorView key={'test-result-error-message-' + index} errorPrefix={error.errorPrefix} diff={error.diff!} errorSuffix={error.errorSuffix}></TestScreenshotErrorView>;
|
||||
return <TestErrorView key={'test-result-error-message-' + index} error={error.error!}></TestErrorView>;
|
||||
})}
|
||||
</AutoChip>}
|
||||
{!!result.steps.length && <AutoChip header='Test Steps' dataTestId='test-steps-chip'>
|
||||
{result.steps.map((step, i) => <StepTreeItem key={`step-${i}`} step={step} attachments={result.attachments} depth={0}></StepTreeItem>)}
|
||||
</AutoChip>}
|
||||
|
||||
{diffs.map((diff, index) =>
|
||||
<AutoChip key={`diff-${index}`} header={`Image mismatch: ${diff.name}`} targetRef={imageDiffRef}>
|
||||
<AutoChip key={`diff-${index}`} dataTestId='test-results-image-diff' header={`Image mismatch: ${diff.name}`} targetRef={imageDiffRef}>
|
||||
<ImageDiffView key='image-diff' diff={diff}></ImageDiffView>
|
||||
</AutoChip>
|
||||
)}
|
||||
|
|
@ -156,6 +160,29 @@ export const TestResultView: React.FC<{
|
|||
</div>;
|
||||
};
|
||||
|
||||
function classifyErrors(testErrors: string[], diffs: ImageDiff[]) {
|
||||
return testErrors.map(error => {
|
||||
if (error.includes('Screenshot comparison failed:')) {
|
||||
const matchingDiff = diffs.find(diff => {
|
||||
const attachmentName = diff.actual?.attachment.name;
|
||||
return attachmentName && error.includes(attachmentName);
|
||||
});
|
||||
|
||||
if (matchingDiff) {
|
||||
const lines = error.split('\n');
|
||||
const index = lines.findIndex(line => /Expected:|Previous:|Received:/.test(line));
|
||||
const errorPrefix = index !== -1 ? lines.slice(0, index).join('\n') : lines[0];
|
||||
|
||||
const diffIndex = lines.findIndex(line => / +Diff:/.test(line));
|
||||
const errorSuffix = diffIndex !== -1 ? lines.slice(diffIndex + 2).join('\n') : lines.slice(1).join('\n');
|
||||
|
||||
return { type: 'screenshot', diff: matchingDiff, errorPrefix, errorSuffix };
|
||||
}
|
||||
}
|
||||
return { type: 'regular', error };
|
||||
});
|
||||
}
|
||||
|
||||
const StepTreeItem: React.FC<{
|
||||
step: TestStep;
|
||||
depth: number,
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ This project incorporates components from the projects listed below. The origina
|
|||
- stack-utils@2.0.5 (https://github.com/tapjs/stack-utils)
|
||||
- wrappy@1.0.2 (https://github.com/npm/wrappy)
|
||||
- ws@8.17.1 (https://github.com/websockets/ws)
|
||||
- yaml@2.6.0 (https://github.com/eemeli/yaml)
|
||||
- yauzl@2.10.0 (https://github.com/thejoshwolfe/yauzl)
|
||||
- yazl@2.5.1 (https://github.com/thejoshwolfe/yazl)
|
||||
|
||||
|
|
@ -1121,6 +1122,24 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|||
=========================================
|
||||
END OF ws@8.17.1 AND INFORMATION
|
||||
|
||||
%% yaml@2.6.0 NOTICES AND INFORMATION BEGIN HERE
|
||||
=========================================
|
||||
Copyright Eemeli Aro <eemeli@gmail.com>
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any purpose
|
||||
with or without fee is hereby granted, provided that the above copyright notice
|
||||
and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
|
||||
FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
|
||||
OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
|
||||
TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
|
||||
THIS SOFTWARE.
|
||||
=========================================
|
||||
END OF yaml@2.6.0 AND INFORMATION
|
||||
|
||||
%% yauzl@2.10.0 NOTICES AND INFORMATION BEGIN HERE
|
||||
=========================================
|
||||
The MIT License (MIT)
|
||||
|
|
@ -1175,6 +1194,6 @@ END OF yazl@2.5.1 AND INFORMATION
|
|||
|
||||
SUMMARY BEGIN HERE
|
||||
=========================================
|
||||
Total Packages: 46
|
||||
Total Packages: 47
|
||||
=========================================
|
||||
END OF SUMMARY
|
||||
|
|
@ -3,15 +3,15 @@
|
|||
"browsers": [
|
||||
{
|
||||
"name": "chromium",
|
||||
"revision": "1141",
|
||||
"revision": "1142",
|
||||
"installByDefault": true,
|
||||
"browserVersion": "130.0.6723.31"
|
||||
"browserVersion": "130.0.6723.44"
|
||||
},
|
||||
{
|
||||
"name": "chromium-tip-of-tree",
|
||||
"revision": "1267",
|
||||
"revision": "1269",
|
||||
"installByDefault": false,
|
||||
"browserVersion": "131.0.6764.0"
|
||||
"browserVersion": "131.0.6778.0"
|
||||
},
|
||||
{
|
||||
"name": "firefox",
|
||||
|
|
@ -27,7 +27,7 @@
|
|||
},
|
||||
{
|
||||
"name": "webkit",
|
||||
"revision": "2084",
|
||||
"revision": "2092",
|
||||
"installByDefault": true,
|
||||
"revisionOverrides": {
|
||||
"mac10.14": "1446",
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@
|
|||
"signal-exit": "3.0.7",
|
||||
"socks-proxy-agent": "8.0.4",
|
||||
"stack-utils": "2.0.5",
|
||||
"ws": "8.17.1"
|
||||
"ws": "8.17.1",
|
||||
"yaml": "^2.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/debug": "^4.1.7",
|
||||
|
|
@ -432,6 +433,17 @@
|
|||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.0.tgz",
|
||||
"integrity": "sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ==",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
@ -726,6 +738,11 @@
|
|||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
|
||||
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
|
||||
"requires": {}
|
||||
},
|
||||
"yaml": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.0.tgz",
|
||||
"integrity": "sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ=="
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
"signal-exit": "3.0.7",
|
||||
"socks-proxy-agent": "8.0.4",
|
||||
"stack-utils": "2.0.5",
|
||||
"yaml": "^2.5.1",
|
||||
"ws": "8.17.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -54,6 +54,9 @@ export { SocksProxyAgent } from 'socks-proxy-agent';
|
|||
import StackUtilsLibrary from 'stack-utils';
|
||||
export const StackUtils = StackUtilsLibrary;
|
||||
|
||||
import yamlLibrary from 'yaml';
|
||||
export const yaml = yamlLibrary;
|
||||
|
||||
// @ts-ignore
|
||||
import wsLibrary, { WebSocketServer, Receiver, Sender } from 'ws';
|
||||
export const ws = wsLibrary;
|
||||
|
|
|
|||
|
|
@ -288,6 +288,11 @@ export class Locator implements api.Locator {
|
|||
return await this._withElement((h, timeout) => h.screenshot({ ...options, timeout }), options.timeout);
|
||||
}
|
||||
|
||||
async ariaSnapshot(options?: TimeoutOptions): Promise<string> {
|
||||
const result = await this._frame._channel.ariaSnapshot({ ...options, selector: this._selector });
|
||||
return result.snapshot;
|
||||
}
|
||||
|
||||
async scrollIntoViewIfNeeded(options: channels.ElementHandleScrollIntoViewIfNeededOptions = {}) {
|
||||
return await this._withElement((h, timeout) => h.scrollIntoViewIfNeeded({ ...options, timeout }), options.timeout);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1424,6 +1424,13 @@ scheme.FrameAddStyleTagParams = tObject({
|
|||
scheme.FrameAddStyleTagResult = tObject({
|
||||
element: tChannel(['ElementHandle']),
|
||||
});
|
||||
scheme.FrameAriaSnapshotParams = tObject({
|
||||
selector: tString,
|
||||
timeout: tOptional(tNumber),
|
||||
});
|
||||
scheme.FrameAriaSnapshotResult = tObject({
|
||||
snapshot: tString,
|
||||
});
|
||||
scheme.FrameBlurParams = tObject({
|
||||
selector: tString,
|
||||
strict: tOptional(tBoolean),
|
||||
|
|
|
|||
74
packages/playwright-core/src/server/ariaSnapshot.ts
Normal file
74
packages/playwright-core/src/server/ariaSnapshot.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
/**
|
||||
* 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 { AriaTemplateNode } from './injected/ariaSnapshot';
|
||||
import { yaml } from '../utilsBundle';
|
||||
|
||||
export function parseAriaSnapshot(text: string): AriaTemplateNode {
|
||||
type YamlNode = Record<string, Array<YamlNode> | string>;
|
||||
|
||||
const parseKey = (key: string): AriaTemplateNode => {
|
||||
if (!key)
|
||||
return { role: '' };
|
||||
|
||||
const match = key.match(/^([a-z]+)(?:\s+(?:"([^"]*)"|\/([^\/]*)\/))?$/);
|
||||
|
||||
if (!match)
|
||||
throw new Error(`Invalid key ${key}`);
|
||||
|
||||
const role = match[1];
|
||||
if (role && role !== 'text' && !allRoles.includes(role))
|
||||
throw new Error(`Invalid role ${role}`);
|
||||
|
||||
if (match[2])
|
||||
return { role, name: match[2] };
|
||||
if (match[3])
|
||||
return { role, name: new RegExp(match[3]) };
|
||||
return { role };
|
||||
};
|
||||
|
||||
const valueOrRegex = (value: string): string | RegExp => {
|
||||
return value.startsWith('/') && value.endsWith('/') ? new RegExp(value.slice(1, -1)) : value;
|
||||
};
|
||||
|
||||
const convert = (object: YamlNode | string): AriaTemplateNode | RegExp | string => {
|
||||
const key = typeof object === 'string' ? object : Object.keys(object)[0];
|
||||
const value = typeof object === 'string' ? undefined : object[key];
|
||||
const parsed = parseKey(key);
|
||||
if (parsed.role === 'text') {
|
||||
if (typeof value !== 'string')
|
||||
throw new Error(`Generic role must have a text value`);
|
||||
return valueOrRegex(value as string);
|
||||
}
|
||||
if (Array.isArray(value))
|
||||
parsed.children = value.map(convert);
|
||||
else if (value)
|
||||
parsed.children = [valueOrRegex(value)];
|
||||
return parsed;
|
||||
};
|
||||
const fragment = yaml.parse(text) as YamlNode[];
|
||||
return convert({ '': fragment }) as AriaTemplateNode;
|
||||
}
|
||||
|
||||
const allRoles = [
|
||||
'alert', 'alertdialog', 'application', 'article', 'banner', 'blockquote', 'button', 'caption', 'cell', 'checkbox', 'code', 'columnheader', 'combobox', 'command',
|
||||
'complementary', 'composite', 'contentinfo', 'definition', 'deletion', 'dialog', 'directory', 'document', 'emphasis', 'feed', 'figure', 'form', 'generic', 'grid',
|
||||
'gridcell', 'group', 'heading', 'img', 'input', 'insertion', 'landmark', 'link', 'list', 'listbox', 'listitem', 'log', 'main', 'marquee', 'math', 'meter', 'menu',
|
||||
'menubar', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'navigation', 'none', 'note', 'option', 'paragraph', 'presentation', 'progressbar', 'radio', 'radiogroup',
|
||||
'range', 'region', 'roletype', 'row', 'rowgroup', 'rowheader', 'scrollbar', 'search', 'searchbox', 'section', 'sectionhead', 'select', 'separator', 'slider',
|
||||
'spinbutton', 'status', 'strong', 'structure', 'subscript', 'superscript', 'switch', 'tab', 'table', 'tablist', 'tabpanel', 'term', 'textbox', 'time', 'timer',
|
||||
'toolbar', 'tooltip', 'tree', 'treegrid', 'treeitem', 'widget', 'window'
|
||||
];
|
||||
|
|
@ -15,7 +15,6 @@
|
|||
*/
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
import { assert } from '../../utils';
|
||||
import type { ConnectionTransport, ProtocolRequest, ProtocolResponse } from '../transport';
|
||||
import type { RecentLogsCollector } from '../../utils/debugLogger';
|
||||
import { debugLogger } from '../../utils/debugLogger';
|
||||
|
|
@ -224,7 +223,6 @@ export class BidiSession extends EventEmitter {
|
|||
}
|
||||
} else if (object.id) {
|
||||
// Response might come after session has been disposed and rejected all callbacks.
|
||||
assert(this.isDisposed());
|
||||
} else {
|
||||
Promise.resolve().then(() => this.emit(object.method, object.params));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,8 @@ export const chromiumSwitches = [
|
|||
// PaintHolding - https://github.com/microsoft/playwright/issues/28023
|
||||
// ThirdPartyStoragePartitioning - https://github.com/microsoft/playwright/issues/32230
|
||||
// LensOverlay - Hides the Lens feature in the URL address bar. Its not working in unofficial builds.
|
||||
'--disable-features=ImprovedCookieControls,LazyFrameLoading,GlobalMediaControls,DestroyProfileOnBrowserClose,MediaRouter,DialMediaRouteProvider,AcceptCHFrame,AutoExpandDetailsElement,CertificateTransparencyComponentUpdater,AvoidUnnecessaryBeforeUnloadCheckSync,Translate,HttpsUpgrades,PaintHolding,ThirdPartyStoragePartitioning,LensOverlay',
|
||||
// PlzDedicatedWorker - https://github.com/microsoft/playwright/issues/31747
|
||||
'--disable-features=ImprovedCookieControls,LazyFrameLoading,GlobalMediaControls,DestroyProfileOnBrowserClose,MediaRouter,DialMediaRouteProvider,AcceptCHFrame,AutoExpandDetailsElement,CertificateTransparencyComponentUpdater,AvoidUnnecessaryBeforeUnloadCheckSync,Translate,HttpsUpgrades,PaintHolding,ThirdPartyStoragePartitioning,LensOverlay,PlzDedicatedWorker',
|
||||
'--allow-pre-commit-input',
|
||||
'--disable-hang-monitor',
|
||||
'--disable-ipc-flooding-protection',
|
||||
|
|
|
|||
|
|
@ -146,6 +146,8 @@ export class CSharpLanguageGenerator implements LanguageGenerator {
|
|||
const assertion = action.value ? `ToHaveValueAsync(${quote(action.value)})` : `ToBeEmptyAsync()`;
|
||||
return `await Expect(${subject}.${this._asLocator(action.selector)}).${assertion};`;
|
||||
}
|
||||
case 'assertSnapshot':
|
||||
return `await Expect(${subject}.${this._asLocator(action.selector)}).ToMatchAriaSnapshotAsync(${quote(action.snapshot)});`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -133,6 +133,8 @@ export class JavaLanguageGenerator implements LanguageGenerator {
|
|||
const assertion = action.value ? `hasValue(${quote(action.value)})` : `isEmpty()`;
|
||||
return `assertThat(${subject}.${this._asLocator(action.selector, inFrameLocator)}).${assertion};`;
|
||||
}
|
||||
case 'assertSnapshot':
|
||||
return `assertThat(${subject}.${this._asLocator(action.selector, inFrameLocator)}).matchesAriaSnapshot(${quote(action.snapshot)});`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -117,6 +117,8 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator {
|
|||
const assertion = action.value ? `toHaveValue(${quote(action.value)})` : `toBeEmpty()`;
|
||||
return `${this._isTest ? '' : '// '}await expect(${subject}.${this._asLocator(action.selector)}).${assertion};`;
|
||||
}
|
||||
case 'assertSnapshot':
|
||||
return `${this._isTest ? '' : '// '}await expect(${subject}.${this._asLocator(action.selector)}).toMatchAriaSnapshot(${quoteMultiline(action.snapshot)});`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -228,11 +230,13 @@ export class JavaScriptFormatter {
|
|||
}
|
||||
|
||||
prepend(text: string) {
|
||||
this._lines = text.trim().split('\n').map(line => line.trim()).concat(this._lines);
|
||||
const trim = isMultilineString(text) ? (line: string) => line : (line: string) => line.trim();
|
||||
this._lines = text.trim().split('\n').map(trim).concat(this._lines);
|
||||
}
|
||||
|
||||
add(text: string) {
|
||||
this._lines.push(...text.trim().split('\n').map(line => line.trim()));
|
||||
const trim = isMultilineString(text) ? (line: string) => line : (line: string) => line.trim();
|
||||
this._lines.push(...text.trim().split('\n').map(trim));
|
||||
}
|
||||
|
||||
newLine() {
|
||||
|
|
@ -269,3 +273,14 @@ function wrapWithStep(description: string | undefined, body: string) {
|
|||
${body}
|
||||
});` : body;
|
||||
}
|
||||
|
||||
export function quoteMultiline(text: string, indent = ' ') {
|
||||
const lines = text.split('\n');
|
||||
if (lines.length === 1)
|
||||
return '`' + text.replace(/`/g, '\\`').replace(/\${/g, '\\${') + '`';
|
||||
return '`\n' + lines.map(line => indent + line.replace(/`/g, '\\`').replace(/\${/g, '\\${')).join('\n') + `\n${indent}\``;
|
||||
}
|
||||
|
||||
function isMultilineString(text: string) {
|
||||
return text.match(/`[\S\s]*`/)?.[0].includes('\n');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -126,6 +126,8 @@ export class PythonLanguageGenerator implements LanguageGenerator {
|
|||
const assertion = action.value ? `to_have_value(${quote(action.value)})` : `to_be_empty()`;
|
||||
return `expect(${subject}.${this._asLocator(action.selector)}).${assertion};`;
|
||||
}
|
||||
case 'assertSnapshot':
|
||||
return `expect(${subject}.${this._asLocator(action.selector)}).to_match_aria_snapshot(${quote(action.snapshot)})`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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/130.0.6723.31 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.44 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/130.0.6723.31 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.44 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/130.0.6723.31 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.44 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/130.0.6723.31 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.44 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/130.0.6723.31 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.44 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/130.0.6723.31 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.44 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/130.0.6723.31 Safari/537.36",
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 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/130.0.6723.31 Safari/537.36",
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 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/130.0.6723.31 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.44 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/130.0.6723.31 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.44 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/130.0.6723.31 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.44 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/130.0.6723.31 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.44 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/130.0.6723.31 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.44 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/130.0.6723.31 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.44 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/130.0.6723.31 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.44 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/130.0.6723.31 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.44 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/130.0.6723.31 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.44 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/130.0.6723.31 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.44 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/130.0.6723.31 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.44 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/130.0.6723.31 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.44 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/130.0.6723.31 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.44 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/130.0.6723.31 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.44 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/130.0.6723.31 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.44 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/130.0.6723.31 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.44 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/130.0.6723.31 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.44 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/130.0.6723.31 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.44 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/130.0.6723.31 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.44 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/130.0.6723.31 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.44 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/130.0.6723.31 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.44 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/130.0.6723.31 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.44 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/130.0.6723.31 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.44 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/130.0.6723.31 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.44 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/130.0.6723.31 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.44 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/130.0.6723.31 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.44 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/130.0.6723.31 Mobile Safari/537.36",
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 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/130.0.6723.31 Mobile Safari/537.36",
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 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/130.0.6723.31 Mobile Safari/537.36",
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 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/130.0.6723.31 Mobile Safari/537.36",
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 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/130.0.6723.31 Mobile Safari/537.36",
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 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/130.0.6723.31 Mobile Safari/537.36",
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 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/130.0.6723.31 Mobile Safari/537.36",
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 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/130.0.6723.31 Mobile Safari/537.36",
|
||||
"userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 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/130.0.6723.31 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.44 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/130.0.6723.31 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.44 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/130.0.6723.31 Safari/537.36",
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 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/130.0.6723.31 Safari/537.36 Edg/130.0.6723.31",
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 Safari/537.36 Edg/130.0.6723.44",
|
||||
"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/130.0.6723.31 Safari/537.36",
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 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/130.0.6723.31 Safari/537.36 Edg/130.0.6723.31",
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.44 Safari/537.36 Edg/130.0.6723.44",
|
||||
"screen": {
|
||||
"width": 1920,
|
||||
"height": 1080
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import type { CallMetadata } from '../instrumentation';
|
|||
import type { BrowserContextDispatcher } from './browserContextDispatcher';
|
||||
import type { PageDispatcher } from './pageDispatcher';
|
||||
import { debugAssert } from '../../utils';
|
||||
import { parseAriaSnapshot } from '../ariaSnapshot';
|
||||
|
||||
export class FrameDispatcher extends Dispatcher<Frame, channels.FrameChannel, BrowserContextDispatcher | PageDispatcher> implements channels.FrameChannel {
|
||||
_type_Frame = true;
|
||||
|
|
@ -258,10 +259,16 @@ export class FrameDispatcher extends Dispatcher<Frame, channels.FrameChannel, Br
|
|||
|
||||
async expect(params: channels.FrameExpectParams, metadata: CallMetadata): Promise<channels.FrameExpectResult> {
|
||||
metadata.potentiallyClosesScope = true;
|
||||
const expectedValue = params.expectedValue ? parseArgument(params.expectedValue) : undefined;
|
||||
let expectedValue = params.expectedValue ? parseArgument(params.expectedValue) : undefined;
|
||||
if (params.expression === 'to.match.aria' && expectedValue)
|
||||
expectedValue = parseAriaSnapshot(expectedValue);
|
||||
const result = await this._frame.expect(metadata, params.selector, { ...params, expectedValue });
|
||||
if (result.received !== undefined)
|
||||
result.received = serializeResult(result.received);
|
||||
return result;
|
||||
}
|
||||
|
||||
async ariaSnapshot(params: channels.FrameAriaSnapshotParams, metadata: CallMetadata): Promise<channels.FrameAriaSnapshotResult> {
|
||||
return { snapshot: await this._frame.ariaSnapshot(metadata, params.selector, params) };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -789,6 +789,10 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
|
|||
return this._page._delegate.getBoundingBox(this);
|
||||
}
|
||||
|
||||
async ariaSnapshot(): Promise<string> {
|
||||
return await this.evaluateInUtility(([injected, element]) => injected.ariaSnapshot(element), {});
|
||||
}
|
||||
|
||||
async screenshot(metadata: CallMetadata, options: ScreenshotOptions & TimeoutOptions = {}): Promise<Buffer> {
|
||||
const controller = new ProgressController(metadata, this);
|
||||
return controller.run(
|
||||
|
|
|
|||
|
|
@ -302,6 +302,7 @@ export abstract class APIRequestContext extends SdkObject {
|
|||
const requestOptions = { ...options, agent };
|
||||
|
||||
const startAt = monotonicTime();
|
||||
let reusedSocketAt: number | undefined;
|
||||
let dnsLookupAt: number | undefined;
|
||||
let tcpConnectionAt: number | undefined;
|
||||
let tlsHandshakeAt: number | undefined;
|
||||
|
|
@ -319,14 +320,15 @@ export abstract class APIRequestContext extends SdkObject {
|
|||
const notifyRequestFinished = (body?: Buffer) => {
|
||||
const endAt = monotonicTime();
|
||||
// spec: http://www.softwareishard.com/blog/har-12-spec/#timings
|
||||
const connectEnd = tlsHandshakeAt ?? tcpConnectionAt;
|
||||
const timings: har.Timings = {
|
||||
send: requestFinishAt! - startAt,
|
||||
wait: responseAt - requestFinishAt!,
|
||||
receive: endAt - responseAt,
|
||||
dns: dnsLookupAt ? dnsLookupAt - startAt : -1,
|
||||
connect: (tlsHandshakeAt ?? tcpConnectionAt!) - startAt, // "If [ssl] is defined then the time is also included in the connect field "
|
||||
connect: connectEnd ? connectEnd - startAt : -1, // "If [ssl] is defined then the time is also included in the connect field "
|
||||
ssl: tlsHandshakeAt ? tlsHandshakeAt - tcpConnectionAt! : -1,
|
||||
blocked: -1,
|
||||
blocked: reusedSocketAt ? reusedSocketAt - startAt : -1,
|
||||
};
|
||||
|
||||
const requestFinishedEvent: APIRequestFinishedEvent = {
|
||||
|
|
@ -489,6 +491,11 @@ export abstract class APIRequestContext extends SdkObject {
|
|||
request.on('close', () => eventsHelper.removeEventListeners(listeners));
|
||||
|
||||
request.on('socket', socket => {
|
||||
if (request.reusedSocket) {
|
||||
reusedSocketAt = monotonicTime();
|
||||
return;
|
||||
}
|
||||
|
||||
// happy eyeballs don't emit lookup and connect events, so we use our custom ones
|
||||
const happyEyeBallsTimings = timingForSocket(socket);
|
||||
dnsLookupAt = happyEyeBallsTimings.dnsLookupAt;
|
||||
|
|
|
|||
|
|
@ -1405,6 +1405,13 @@ export class Frame extends SdkObject {
|
|||
});
|
||||
}
|
||||
|
||||
async ariaSnapshot(metadata: CallMetadata, selector: string, options: types.TimeoutOptions = {}): Promise<string> {
|
||||
const controller = new ProgressController(metadata, this);
|
||||
return controller.run(async progress => {
|
||||
return await this._retryWithProgressIfNotConnected(progress, selector, true /* strict */, true /* performActionPreChecks */, handle => handle.ariaSnapshot());
|
||||
}, this._page._timeoutSettings.timeout(options));
|
||||
}
|
||||
|
||||
async expect(metadata: CallMetadata, selector: string, options: FrameExpectParams): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }> {
|
||||
const result = await this._expectImpl(metadata, selector, options);
|
||||
// Library mode special case for the expect errors which are return values, not exceptions.
|
||||
|
|
|
|||
282
packages/playwright-core/src/server/injected/ariaSnapshot.ts
Normal file
282
packages/playwright-core/src/server/injected/ariaSnapshot.ts
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
/**
|
||||
* 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 { escapeWithQuotes } from '@isomorphic/stringUtils';
|
||||
import { beginAriaCaches, endAriaCaches, getAriaRole, getElementAccessibleName, isElementIgnoredForAria } from './roleUtils';
|
||||
import { isElementVisible, isElementStyleVisibilityVisible } from './domUtils';
|
||||
|
||||
type AriaNode = {
|
||||
role: string;
|
||||
name?: string;
|
||||
children?: (AriaNode | string)[];
|
||||
};
|
||||
|
||||
export type AriaTemplateNode = {
|
||||
role: string;
|
||||
name?: RegExp | string;
|
||||
children?: (AriaTemplateNode | string | RegExp)[];
|
||||
};
|
||||
|
||||
export function generateAriaTree(rootElement: Element): AriaNode {
|
||||
const toAriaNode = (element: Element): { ariaNode: AriaNode, isLeaf: boolean } | null => {
|
||||
const role = getAriaRole(element);
|
||||
if (!role)
|
||||
return null;
|
||||
|
||||
const name = role ? getElementAccessibleName(element, false) || undefined : undefined;
|
||||
const isLeaf = leafRoles.has(role);
|
||||
const result: AriaNode = { role, name };
|
||||
if (isLeaf && !name && element.textContent)
|
||||
result.children = [element.textContent];
|
||||
return { isLeaf, ariaNode: result };
|
||||
};
|
||||
|
||||
const visit = (ariaNode: AriaNode, node: Node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE && node.nodeValue) {
|
||||
ariaNode.children = ariaNode.children || [];
|
||||
ariaNode.children.push(node.nodeValue);
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.nodeType !== Node.ELEMENT_NODE)
|
||||
return;
|
||||
|
||||
const element = node as Element;
|
||||
if (isElementIgnoredForAria(element))
|
||||
return;
|
||||
|
||||
const visible = isElementVisible(element);
|
||||
const hasVisibleChildren = isElementStyleVisibilityVisible(element);
|
||||
|
||||
if (!hasVisibleChildren)
|
||||
return;
|
||||
|
||||
if (visible) {
|
||||
const childAriaNode = toAriaNode(element);
|
||||
const isHiddenContainer = childAriaNode && hiddenContainerRoles.has(childAriaNode.ariaNode.role);
|
||||
if (childAriaNode && !isHiddenContainer) {
|
||||
ariaNode.children = ariaNode.children || [];
|
||||
ariaNode.children.push(childAriaNode.ariaNode);
|
||||
}
|
||||
if (isHiddenContainer || !childAriaNode?.isLeaf)
|
||||
processChildNodes(childAriaNode?.ariaNode || ariaNode, element);
|
||||
} else {
|
||||
processChildNodes(ariaNode, element);
|
||||
}
|
||||
};
|
||||
|
||||
function processChildNodes(ariaNode: AriaNode, element: Element) {
|
||||
// Process light DOM children
|
||||
for (let child = element.firstChild; child; child = child.nextSibling)
|
||||
visit(ariaNode, child);
|
||||
// Process shadow DOM children, if any
|
||||
if (element.shadowRoot) {
|
||||
for (let child = element.shadowRoot.firstChild; child; child = child.nextSibling)
|
||||
visit(ariaNode, child);
|
||||
}
|
||||
}
|
||||
|
||||
beginAriaCaches();
|
||||
const ariaRoot: AriaNode = { role: '' };
|
||||
try {
|
||||
visit(ariaRoot, rootElement);
|
||||
} finally {
|
||||
endAriaCaches();
|
||||
}
|
||||
|
||||
normalizeStringChildren(ariaRoot);
|
||||
return ariaRoot;
|
||||
}
|
||||
|
||||
export function renderedAriaTree(rootElement: Element): string {
|
||||
return renderAriaTree(generateAriaTree(rootElement));
|
||||
}
|
||||
|
||||
function normalizeStringChildren(rootA11yNode: AriaNode) {
|
||||
const flushChildren = (buffer: string[], normalizedChildren: (AriaNode | string)[]) => {
|
||||
if (!buffer.length)
|
||||
return;
|
||||
const text = normalizeWhitespaceWithin(buffer.join('')).trim();
|
||||
if (text)
|
||||
normalizedChildren.push(text);
|
||||
buffer.length = 0;
|
||||
};
|
||||
|
||||
const visit = (ariaNode: AriaNode) => {
|
||||
const normalizedChildren: (AriaNode | string)[] = [];
|
||||
const buffer: string[] = [];
|
||||
for (const child of ariaNode.children || []) {
|
||||
if (typeof child === 'string') {
|
||||
buffer.push(child);
|
||||
} else {
|
||||
flushChildren(buffer, normalizedChildren);
|
||||
visit(child);
|
||||
normalizedChildren.push(child);
|
||||
}
|
||||
}
|
||||
flushChildren(buffer, normalizedChildren);
|
||||
ariaNode.children = normalizedChildren.length ? normalizedChildren : undefined;
|
||||
};
|
||||
visit(rootA11yNode);
|
||||
}
|
||||
|
||||
const hiddenContainerRoles = new Set(['none', 'presentation']);
|
||||
|
||||
const leafRoles = new Set([
|
||||
'alert', 'blockquote', 'button', 'caption', 'checkbox', 'code', 'columnheader',
|
||||
'definition', 'deletion', 'emphasis', 'generic', 'heading', 'img', 'insertion',
|
||||
'link', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'meter', 'option',
|
||||
'progressbar', 'radio', 'rowheader', 'scrollbar', 'searchbox', 'separator',
|
||||
'slider', 'spinbutton', 'strong', 'subscript', 'superscript', 'switch', 'tab', 'term',
|
||||
'textbox', 'time', 'tooltip'
|
||||
]);
|
||||
|
||||
const normalizeWhitespaceWithin = (text: string) => text.replace(/[\s\n]+/g, ' ');
|
||||
|
||||
function matchesText(text: string | undefined, template: RegExp | string | undefined) {
|
||||
if (!template)
|
||||
return true;
|
||||
if (!text)
|
||||
return false;
|
||||
if (typeof template === 'string')
|
||||
return text === template;
|
||||
return !!text.match(template);
|
||||
}
|
||||
|
||||
export function matchesAriaTree(rootElement: Element, template: AriaTemplateNode): { matches: boolean, received: string } {
|
||||
const root = generateAriaTree(rootElement);
|
||||
const matches = nodeMatches(root, template);
|
||||
return { matches, received: renderAriaTree(root) };
|
||||
}
|
||||
|
||||
function matchesNode(node: AriaNode | string, template: AriaTemplateNode | RegExp | string, depth: number): boolean {
|
||||
if (typeof node === 'string' && (typeof template === 'string' || template instanceof RegExp))
|
||||
return matchesText(node, template);
|
||||
|
||||
if (typeof node === 'object' && typeof template === 'object' && !(template instanceof RegExp)) {
|
||||
if (template.role && template.role !== node.role)
|
||||
return false;
|
||||
if (!matchesText(node.name, template.name))
|
||||
return false;
|
||||
if (!containsList(node.children || [], template.children || [], depth))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function containsList(children: (AriaNode | string)[], template: (AriaTemplateNode | RegExp | string)[], depth: number): boolean {
|
||||
if (template.length > children.length)
|
||||
return false;
|
||||
const cc = children.slice();
|
||||
const tt = template.slice();
|
||||
for (const t of tt) {
|
||||
let c = cc.shift();
|
||||
while (c) {
|
||||
if (matchesNode(c, t, depth + 1))
|
||||
break;
|
||||
c = cc.shift();
|
||||
}
|
||||
if (!c)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function nodeMatches(root: AriaNode, template: AriaTemplateNode): boolean {
|
||||
const results: (AriaNode | string)[] = [];
|
||||
const visit = (node: AriaNode | string): boolean => {
|
||||
if (matchesNode(node, template, 0)) {
|
||||
results.push(node);
|
||||
return true;
|
||||
}
|
||||
if (typeof node === 'string')
|
||||
return false;
|
||||
for (const child of node.children || []) {
|
||||
if (visit(child))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
visit(root);
|
||||
return !!results.length;
|
||||
}
|
||||
|
||||
export function renderAriaTree(ariaNode: AriaNode): string {
|
||||
const lines: string[] = [];
|
||||
const visit = (ariaNode: AriaNode | string, indent: string) => {
|
||||
if (typeof ariaNode === 'string') {
|
||||
lines.push(indent + '- text: ' + escapeYamlString(ariaNode));
|
||||
return;
|
||||
}
|
||||
let line = `${indent}- ${ariaNode.role}`;
|
||||
if (ariaNode.name)
|
||||
line += ` ${escapeWithQuotes(ariaNode.name, '"')}`;
|
||||
const noChild = !ariaNode.name && !ariaNode.children?.length;
|
||||
const oneChild = !ariaNode.name && ariaNode.children?.length === 1 && typeof ariaNode.children[0] === 'string';
|
||||
if (noChild || oneChild) {
|
||||
if (oneChild)
|
||||
line += ': ' + escapeYamlString(ariaNode.children?.[0] as string);
|
||||
lines.push(line);
|
||||
return;
|
||||
}
|
||||
lines.push(line + (ariaNode.children ? ':' : ''));
|
||||
for (const child of ariaNode.children || [])
|
||||
visit(child, indent + ' ');
|
||||
};
|
||||
if (ariaNode.role === '') {
|
||||
// Render fragment.
|
||||
for (const child of ariaNode.children || [])
|
||||
visit(child, '');
|
||||
} else {
|
||||
visit(ariaNode, '');
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function escapeYamlString(str: string) {
|
||||
if (str === '')
|
||||
return '""';
|
||||
|
||||
const needQuotes = (
|
||||
// Starts or ends with whitespace
|
||||
/^\s|\s$/.test(str) ||
|
||||
// Contains control characters
|
||||
/[\x00-\x1f]/.test(str) ||
|
||||
// Contains special YAML characters that could cause parsing issues
|
||||
/[\[\]{}&*!,|>%@`]/.test(str) ||
|
||||
// Contains a colon followed by a space (could be interpreted as a key-value pair)
|
||||
/:\s/.test(str) ||
|
||||
// Is a YAML boolean or null value
|
||||
/^(?:y|Y|yes|Yes|YES|n|N|no|No|NO|true|True|TRUE|false|False|FALSE|null|Null|NULL|~)$/.test(str) ||
|
||||
// Could be interpreted as a number
|
||||
/^[-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?$/.test(str) ||
|
||||
// Contains a newline character
|
||||
/\n/.test(str) ||
|
||||
// Starts with a special character
|
||||
/^[\-?:,>|%@"`]/.test(str)
|
||||
);
|
||||
|
||||
if (needQuotes) {
|
||||
return `"${str
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\n/g, '\\n')
|
||||
.replace(/\r/g, '\\r')}"`;
|
||||
}
|
||||
|
||||
return str;
|
||||
}
|
||||
|
|
@ -85,6 +85,7 @@ class ConsoleAPI {
|
|||
inspect: (selector: string) => this._inspect(selector),
|
||||
selector: (element: Element) => this._selector(element),
|
||||
generateLocator: (element: Element, language?: Language) => this._generateLocator(element, language),
|
||||
ariaSnapshot: (element?: Element) => this._injectedScript.ariaSnapshot(element || this._injectedScript.document.body),
|
||||
resume: () => this._resume(),
|
||||
...new Locator(injectedScript, ''),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -220,6 +220,11 @@ x-pw-tool-item.value > x-div {
|
|||
clip-path: url(#icon-symbol-constant);
|
||||
}
|
||||
|
||||
x-pw-tool-item.snapshot > x-div {
|
||||
/* codicon: eye */
|
||||
clip-path: url(#icon-gist);
|
||||
}
|
||||
|
||||
x-pw-tool-item.accept > x-div {
|
||||
clip-path: url(#icon-check);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,13 +29,12 @@ import type { CSSComplexSelectorList } from '../../utils/isomorphic/cssParser';
|
|||
import { generateSelector, type GenerateSelectorOptions } from './selectorGenerator';
|
||||
import type * as channels from '@protocol/channels';
|
||||
import { Highlight } from './highlight';
|
||||
import { getChecked, getAriaDisabled, getAriaRole, getElementAccessibleName, getElementAccessibleDescription, beginAriaCaches, endAriaCaches } from './roleUtils';
|
||||
import { getChecked, getAriaDisabled, getAriaRole, getElementAccessibleName, getElementAccessibleDescription } from './roleUtils';
|
||||
import { kLayoutSelectorNames, type LayoutSelectorName, layoutSelectorScore } from './layoutSelectorUtils';
|
||||
import { asLocator } from '../../utils/isomorphic/locatorGenerators';
|
||||
import type { Language } from '../../utils/isomorphic/locatorGenerators';
|
||||
import { cacheNormalizedWhitespaces, escapeHTML, escapeHTMLAttribute, normalizeWhiteSpace, trimStringWithEllipsis } from '../../utils/isomorphic/stringUtils';
|
||||
import { selectorForSimpleDomNodeId, generateSimpleDomNode } from './simpleDom';
|
||||
import type { SimpleDomNode } from './simpleDom';
|
||||
import { cacheNormalizedWhitespaces, normalizeWhiteSpace, trimStringWithEllipsis } from '../../utils/isomorphic/stringUtils';
|
||||
import { matchesAriaTree, renderedAriaTree } from './ariaSnapshot';
|
||||
|
||||
export type FrameExpectParams = Omit<channels.FrameExpectParams, 'expectedValue'> & { expectedValue?: any };
|
||||
|
||||
|
|
@ -75,12 +74,8 @@ export class InjectedScript {
|
|||
// module-level globals will be duplicated, which leads to subtle bugs.
|
||||
readonly utils = {
|
||||
asLocator,
|
||||
beginAriaCaches,
|
||||
cacheNormalizedWhitespaces,
|
||||
elementText,
|
||||
endAriaCaches,
|
||||
escapeHTML,
|
||||
escapeHTMLAttribute,
|
||||
getAriaRole,
|
||||
getElementAccessibleDescription,
|
||||
getElementAccessibleName,
|
||||
|
|
@ -211,6 +206,12 @@ export class InjectedScript {
|
|||
return new Set<Element>(result.map(r => r.element));
|
||||
}
|
||||
|
||||
ariaSnapshot(node: Node): string {
|
||||
if (node.nodeType !== Node.ELEMENT_NODE)
|
||||
throw this.createStacklessError('Can only capture aria snapshot of Element nodes.');
|
||||
return renderedAriaTree(node as Element);
|
||||
}
|
||||
|
||||
querySelectorAll(selector: ParsedSelector, root: Node): Element[] {
|
||||
if (selector.capture !== undefined) {
|
||||
if (selector.parts.some(part => part.name === 'nth'))
|
||||
|
|
@ -1255,6 +1256,11 @@ export class InjectedScript {
|
|||
}
|
||||
}
|
||||
|
||||
{
|
||||
if (expression === 'to.match.aria')
|
||||
return matchesAriaTree(element, options.expectedValue);
|
||||
}
|
||||
|
||||
{
|
||||
// Single text value.
|
||||
let received: string | undefined;
|
||||
|
|
@ -1332,17 +1338,6 @@ export class InjectedScript {
|
|||
}
|
||||
throw this.createStacklessError('Unknown expect matcher: ' + expression);
|
||||
}
|
||||
|
||||
generateSimpleDomNode(selector: string): SimpleDomNode | undefined {
|
||||
const element = this.querySelector(this.parseSelector(selector), this.document.documentElement, true);
|
||||
if (!element)
|
||||
return;
|
||||
return generateSimpleDomNode(this, element);
|
||||
}
|
||||
|
||||
selectorForSimpleDomNodeId(nodeId: string) {
|
||||
return selectorForSimpleDomNodeId(this, nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
const autoClosingTags = new Set(['AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'MENUITEM', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR']);
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1 @@
|
|||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.57 1.14l3.28 3.3.15.36v9.7l-.5.5h-11l-.5-.5v-13l.5-.5h7.72l.35.14zM10 5h3l-3-3v3zM3 2v12h10V6H9.5L9 5.5V2H3zm2.062 7.533l1.817-1.828L6.17 7 4 9.179v.707l2.171 2.174.707-.707-1.816-1.82zM8.8 7.714l.7-.709 2.189 2.175v.709L9.5 12.062l-.705-.709 1.831-1.82L8.8 7.714z"/></svg>
|
||||
|
After Width: | Height: | Size: 429 B |
|
|
@ -608,9 +608,9 @@ class TextAssertionTool implements RecorderTool {
|
|||
private _action: actions.AssertAction | null = null;
|
||||
private _dialog: Dialog;
|
||||
private _textCache = new Map<Element | ShadowRoot, ElementText>();
|
||||
private _kind: 'text' | 'value';
|
||||
private _kind: 'text' | 'value' | 'snapshot';
|
||||
|
||||
constructor(recorder: Recorder, kind: 'text' | 'value') {
|
||||
constructor(recorder: Recorder, kind: 'text' | 'value' | 'snapshot') {
|
||||
this._recorder = recorder;
|
||||
this._kind = kind;
|
||||
this._dialog = new Dialog(recorder);
|
||||
|
|
@ -656,7 +656,7 @@ class TextAssertionTool implements RecorderTool {
|
|||
const target = this._recorder.deepEventTarget(event);
|
||||
if (this._hoverHighlight?.elements[0] === target)
|
||||
return;
|
||||
if (this._kind === 'text')
|
||||
if (this._kind === 'text' || this._kind === 'snapshot')
|
||||
this._hoverHighlight = this._recorder.injectedScript.utils.elementText(this._textCache, target).full ? { elements: [target], selector: '' } : null;
|
||||
else
|
||||
this._hoverHighlight = this._elementHasValue(target) ? this._recorder.injectedScript.generateSelector(target, { testIdAttributeName: this._recorder.state.testIdAttributeName }) : null;
|
||||
|
|
@ -704,6 +704,18 @@ class TextAssertionTool implements RecorderTool {
|
|||
value: (target as (HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement)).value,
|
||||
};
|
||||
}
|
||||
} else if (this._kind === 'snapshot') {
|
||||
this._hoverHighlight = this._recorder.injectedScript.generateSelector(target, { testIdAttributeName: this._recorder.state.testIdAttributeName, forTextExpect: true });
|
||||
this._hoverHighlight.color = '#8acae480';
|
||||
// forTextExpect can update the target, re-highlight it.
|
||||
this._recorder.updateHighlight(this._hoverHighlight, true);
|
||||
|
||||
return {
|
||||
name: 'assertSnapshot',
|
||||
selector: this._hoverHighlight.selector,
|
||||
signals: [],
|
||||
snapshot: this._recorder.injectedScript.ariaSnapshot(target),
|
||||
};
|
||||
} else {
|
||||
this._hoverHighlight = this._recorder.injectedScript.generateSelector(target, { testIdAttributeName: this._recorder.state.testIdAttributeName, forTextExpect: true });
|
||||
this._hoverHighlight.color = '#8acae480';
|
||||
|
|
@ -727,6 +739,8 @@ class TextAssertionTool implements RecorderTool {
|
|||
return String(action.checked);
|
||||
if (action?.name === 'assertValue')
|
||||
return action.value;
|
||||
if (action?.name === 'assertSnapshot')
|
||||
return action.snapshot;
|
||||
return '';
|
||||
}
|
||||
|
||||
|
|
@ -742,13 +756,19 @@ class TextAssertionTool implements RecorderTool {
|
|||
if (!this._hoverHighlight?.elements[0])
|
||||
return;
|
||||
this._action = this._generateAction();
|
||||
if (!this._action || this._action.name !== 'assertText')
|
||||
return;
|
||||
if (this._action?.name === 'assertText') {
|
||||
this._showTextDialog(this._action);
|
||||
} else if (this._action?.name === 'assertSnapshot') {
|
||||
this._recorder.recordAction(this._action);
|
||||
this._recorder.setMode('recording');
|
||||
this._recorder.overlay?.flashToolSucceeded('assertingSnapshot');
|
||||
}
|
||||
}
|
||||
|
||||
const action = this._action;
|
||||
private _showTextDialog(action: actions.AssertTextAction) {
|
||||
const textElement = this._recorder.document.createElement('textarea');
|
||||
textElement.setAttribute('spellcheck', 'false');
|
||||
textElement.value = this._renderValue(this._action);
|
||||
textElement.value = this._renderValue(action);
|
||||
textElement.classList.add('text-editor');
|
||||
|
||||
const updateAndValidate = () => {
|
||||
|
|
@ -796,6 +816,7 @@ class Overlay {
|
|||
private _assertVisibilityToggle: HTMLElement;
|
||||
private _assertTextToggle: HTMLElement;
|
||||
private _assertValuesToggle: HTMLElement;
|
||||
private _assertSnapshotToggle: HTMLElement;
|
||||
private _offsetX = 0;
|
||||
private _dragState: { offsetX: number, dragStart: { x: number, y: number } } | undefined;
|
||||
private _measure: { width: number, height: number } = { width: 0, height: 0 };
|
||||
|
|
@ -842,6 +863,12 @@ class Overlay {
|
|||
this._assertValuesToggle.appendChild(this._recorder.document.createElement('x-div'));
|
||||
toolsListElement.appendChild(this._assertValuesToggle);
|
||||
|
||||
this._assertSnapshotToggle = this._recorder.document.createElement('x-pw-tool-item');
|
||||
this._assertSnapshotToggle.title = 'Assert snapshot';
|
||||
this._assertSnapshotToggle.classList.add('snapshot');
|
||||
this._assertSnapshotToggle.appendChild(this._recorder.document.createElement('x-div'));
|
||||
toolsListElement.appendChild(this._assertSnapshotToggle);
|
||||
|
||||
this._updateVisualPosition();
|
||||
this._refreshListeners();
|
||||
}
|
||||
|
|
@ -865,6 +892,7 @@ class Overlay {
|
|||
'assertingText': 'recording-inspecting',
|
||||
'assertingVisibility': 'recording-inspecting',
|
||||
'assertingValue': 'recording-inspecting',
|
||||
'assertingSnapshot': 'recording-inspecting',
|
||||
};
|
||||
this._recorder.setMode(newMode[this._recorder.state.mode]);
|
||||
}),
|
||||
|
|
@ -880,6 +908,10 @@ class Overlay {
|
|||
if (!this._assertValuesToggle.classList.contains('disabled'))
|
||||
this._recorder.setMode(this._recorder.state.mode === 'assertingValue' ? 'recording' : 'assertingValue');
|
||||
}),
|
||||
addEventListener(this._assertSnapshotToggle, 'click', () => {
|
||||
if (!this._assertSnapshotToggle.classList.contains('disabled'))
|
||||
this._recorder.setMode(this._recorder.state.mode === 'assertingSnapshot' ? 'recording' : 'assertingSnapshot');
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -902,6 +934,8 @@ class Overlay {
|
|||
this._assertTextToggle.classList.toggle('disabled', state.mode === 'none' || state.mode === 'standby' || state.mode === 'inspecting');
|
||||
this._assertValuesToggle.classList.toggle('active', state.mode === 'assertingValue');
|
||||
this._assertValuesToggle.classList.toggle('disabled', state.mode === 'none' || state.mode === 'standby' || state.mode === 'inspecting');
|
||||
this._assertSnapshotToggle.classList.toggle('active', state.mode === 'assertingSnapshot');
|
||||
this._assertSnapshotToggle.classList.toggle('disabled', state.mode === 'none' || state.mode === 'standby' || state.mode === 'inspecting');
|
||||
if (this._offsetX !== state.overlay.offsetX) {
|
||||
this._offsetX = state.overlay.offsetX;
|
||||
this._updateVisualPosition();
|
||||
|
|
@ -912,8 +946,14 @@ class Overlay {
|
|||
this._showOverlay();
|
||||
}
|
||||
|
||||
flashToolSucceeded(tool: 'assertingVisibility' | 'assertingValue') {
|
||||
const element = tool === 'assertingVisibility' ? this._assertVisibilityToggle : this._assertValuesToggle;
|
||||
flashToolSucceeded(tool: 'assertingVisibility' | 'assertingSnapshot' | 'assertingValue') {
|
||||
let element: Element;
|
||||
if (tool === 'assertingVisibility')
|
||||
element = this._assertVisibilityToggle;
|
||||
else if (tool === 'assertingSnapshot')
|
||||
element = this._assertSnapshotToggle;
|
||||
else
|
||||
element = this._assertValuesToggle;
|
||||
element.classList.add('succeeded');
|
||||
this._recorder.injectedScript.builtinSetTimeout(() => element.classList.remove('succeeded'), 2000);
|
||||
}
|
||||
|
|
@ -1004,6 +1044,7 @@ export class Recorder {
|
|||
'assertingText': new TextAssertionTool(this, 'text'),
|
||||
'assertingVisibility': new InspectTool(this, true),
|
||||
'assertingValue': new TextAssertionTool(this, 'value'),
|
||||
'assertingSnapshot': new TextAssertionTool(this, 'snapshot'),
|
||||
};
|
||||
this._currentTool = this._tools.none;
|
||||
if (injectedScript.window.top === injectedScript.window) {
|
||||
|
|
|
|||
|
|
@ -261,7 +261,7 @@ function getAriaBoolean(attr: string | null) {
|
|||
return attr === null ? undefined : attr.toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
function isElementIgnoredForAria(element: Element) {
|
||||
export function isElementIgnoredForAria(element: Element) {
|
||||
return ['STYLE', 'SCRIPT', 'NOSCRIPT', 'TEMPLATE'].includes(elementSafeTagName(element));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,120 +0,0 @@
|
|||
/**
|
||||
* 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 { InjectedScript } from './injectedScript';
|
||||
|
||||
const leafRoles = new Set([
|
||||
'button',
|
||||
'checkbox',
|
||||
'combobox',
|
||||
'link',
|
||||
'textbox',
|
||||
]);
|
||||
|
||||
export type SimpleDom = {
|
||||
markup: string;
|
||||
elements: Map<string, Element>;
|
||||
};
|
||||
|
||||
export type SimpleDomNode = {
|
||||
dom: SimpleDom;
|
||||
id: string;
|
||||
tag: string;
|
||||
};
|
||||
|
||||
let lastDom: SimpleDom | undefined;
|
||||
|
||||
export function generateSimpleDom(injectedScript: InjectedScript): SimpleDom {
|
||||
return generate(injectedScript).dom;
|
||||
}
|
||||
|
||||
export function generateSimpleDomNode(injectedScript: InjectedScript, target: Element): SimpleDomNode {
|
||||
return generate(injectedScript, target).node!;
|
||||
}
|
||||
|
||||
export function selectorForSimpleDomNodeId(injectedScript: InjectedScript, id: string): string {
|
||||
const element = lastDom?.elements.get(id);
|
||||
if (!element)
|
||||
throw new Error(`Internal error: element with id "${id}" not found`);
|
||||
return injectedScript.generateSelectorSimple(element);
|
||||
}
|
||||
|
||||
function generate(injectedScript: InjectedScript, target?: Element): { dom: SimpleDom, node?: SimpleDomNode } {
|
||||
const normalizeWhitespace = (text: string) => text.replace(/[\s\n]+/g, match => match.includes('\n') ? '\n' : ' ');
|
||||
const tokens: string[] = [];
|
||||
const elements = new Map<string, Element>();
|
||||
let lastId = 0;
|
||||
let resultTarget: { tag: string, id: string } | undefined;
|
||||
const visit = (node: Node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
tokens.push(node.nodeValue!);
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const element = node as Element;
|
||||
if (element.nodeName === 'SCRIPT' || element.nodeName === 'STYLE' || element.nodeName === 'NOSCRIPT')
|
||||
return;
|
||||
if (injectedScript.utils.isElementVisible(element)) {
|
||||
const role = injectedScript.utils.getAriaRole(element) as string;
|
||||
if (role && leafRoles.has(role)) {
|
||||
let value: string | undefined;
|
||||
if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA')
|
||||
value = (element as HTMLInputElement | HTMLTextAreaElement).value;
|
||||
const name = injectedScript.utils.getElementAccessibleName(element, false);
|
||||
const structuralId = String(++lastId);
|
||||
elements.set(structuralId, element);
|
||||
tokens.push(renderTag(injectedScript, role, name, structuralId, { value }));
|
||||
if (element === target) {
|
||||
const tagNoValue = renderTag(injectedScript, role, name, structuralId);
|
||||
resultTarget = { tag: tagNoValue, id: structuralId };
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (let child = element.firstChild; child; child = child.nextSibling)
|
||||
visit(child);
|
||||
}
|
||||
};
|
||||
injectedScript.utils.beginAriaCaches();
|
||||
try {
|
||||
visit(injectedScript.document.body);
|
||||
} finally {
|
||||
injectedScript.utils.endAriaCaches();
|
||||
}
|
||||
const dom = {
|
||||
markup: normalizeWhitespace(tokens.join(' ')),
|
||||
elements
|
||||
};
|
||||
|
||||
if (target && !resultTarget)
|
||||
throw new Error('Target element is not in the simple DOM');
|
||||
|
||||
lastDom = dom;
|
||||
|
||||
return { dom, node: resultTarget ? { dom, ...resultTarget } : undefined };
|
||||
}
|
||||
|
||||
function renderTag(injectedScript: InjectedScript, role: string, name: string, id: string, params?: { value?: string }): string {
|
||||
const escapedTextContent = injectedScript.utils.escapeHTML(name);
|
||||
const escapedValue = injectedScript.utils.escapeHTMLAttribute(params?.value || '');
|
||||
switch (role) {
|
||||
case 'button': return `<button id="${id}">${escapedTextContent}</button>`;
|
||||
case 'link': return `<a id="${id}">${escapedTextContent}</a>`;
|
||||
case 'textbox': return `<input id="${id}" title="${escapedTextContent}" value="${escapedValue}"></input>`;
|
||||
}
|
||||
return `<div role=${role} id="${id}">${escapedTextContent}</div>`;
|
||||
}
|
||||
|
|
@ -143,6 +143,7 @@ export function inject(globalThis: GlobalThis) {
|
|||
|
||||
this.url = typeof url === 'string' ? url : url.href;
|
||||
try {
|
||||
this.url = new URL(url).href;
|
||||
this._origin = new URL(url).origin;
|
||||
} catch {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ import type { TimeoutOptions } from '../common/types';
|
|||
import { isInvalidSelectorError } from '../utils/isomorphic/selectorParser';
|
||||
import { parseEvaluationResultValue, source } from './isomorphic/utilityScriptSerializers';
|
||||
import type { SerializedValue } from './isomorphic/utilityScriptSerializers';
|
||||
import { TargetClosedError } from './errors';
|
||||
import { TargetClosedError, TimeoutError } from './errors';
|
||||
import { asLocator } from '../utils';
|
||||
import { helper } from './helper';
|
||||
|
||||
|
|
@ -662,7 +662,7 @@ export class Page extends SdkObject {
|
|||
return {};
|
||||
}
|
||||
|
||||
if (areEqualScreenshots(actual, options.expected, previous)) {
|
||||
if (areEqualScreenshots(actual, options.expected, undefined)) {
|
||||
progress.log(`screenshot matched expectation`);
|
||||
return {};
|
||||
}
|
||||
|
|
@ -672,10 +672,13 @@ export class Page extends SdkObject {
|
|||
// A: We want user to receive a friendly diff between actual and expected/previous.
|
||||
if (js.isJavaScriptErrorInEvaluate(e) || isInvalidSelectorError(e))
|
||||
throw e;
|
||||
let errorMessage = e.message;
|
||||
if (e instanceof TimeoutError && intermediateResult?.previous)
|
||||
errorMessage = `Failed to take two consecutive stable screenshots. ${e.message}`;
|
||||
return {
|
||||
log: e.message ? [...metadata.log, e.message] : metadata.log,
|
||||
...intermediateResult,
|
||||
errorMessage: e.message,
|
||||
errorMessage,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -140,6 +140,7 @@ export class Recorder implements InstrumentationListener, IRecorder {
|
|||
this._contextRecorder.on(ContextRecorder.Events.Change, (data: { sources: Source[], actions: actions.ActionInContext[] }) => {
|
||||
this._recorderSources = data.sources;
|
||||
recorderApp.setActions(data.actions, data.sources);
|
||||
recorderApp.setRunningFile(undefined);
|
||||
this._pushAllSources();
|
||||
});
|
||||
|
||||
|
|
@ -215,7 +216,7 @@ export class Recorder implements InstrumentationListener, IRecorder {
|
|||
this._highlightedSelector = '';
|
||||
this._mode = mode;
|
||||
this._recorderApp?.setMode(this._mode);
|
||||
this._contextRecorder.setEnabled(this._mode === 'recording' || this._mode === 'assertingText' || this._mode === 'assertingVisibility' || this._mode === 'assertingValue');
|
||||
this._contextRecorder.setEnabled(this._mode === 'recording' || this._mode === 'assertingText' || this._mode === 'assertingVisibility' || this._mode === 'assertingValue' || this._mode === 'assertingSnapshot');
|
||||
this._debugger.setMuted(this._mode === 'recording' || this._mode === 'assertingText' || this._mode === 'assertingVisibility' || this._mode === 'assertingValue');
|
||||
if (this._mode !== 'none' && this._mode !== 'standby' && this._context.pages().length === 1)
|
||||
this._context.pages()[0].bringToFront().catch(() => {});
|
||||
|
|
@ -299,7 +300,7 @@ export class Recorder implements InstrumentationListener, IRecorder {
|
|||
}
|
||||
this._pushAllSources();
|
||||
if (fileToSelect)
|
||||
this._recorderApp?.setFile(fileToSelect);
|
||||
this._recorderApp?.setRunningFile(fileToSelect);
|
||||
}
|
||||
|
||||
private _pushAllSources() {
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export class EmptyRecorderApp extends EventEmitter implements IRecorderApp {
|
|||
async close(): Promise<void> {}
|
||||
async setPaused(paused: boolean): Promise<void> {}
|
||||
async setMode(mode: Mode): Promise<void> {}
|
||||
async setFile(file: string): Promise<void> {}
|
||||
async setRunningFile(file: string | undefined): Promise<void> {}
|
||||
async setSelector(selector: string, userGesture?: boolean): Promise<void> {}
|
||||
async updateCallLogs(callLogs: CallLog[]): Promise<void> {}
|
||||
async setSources(sources: Source[]): Promise<void> {}
|
||||
|
|
@ -131,9 +131,9 @@ export class RecorderApp extends EventEmitter implements IRecorderApp {
|
|||
}).toString(), { isFunction: true }, mode).catch(() => {});
|
||||
}
|
||||
|
||||
async setFile(file: string): Promise<void> {
|
||||
async setRunningFile(file: string | undefined): Promise<void> {
|
||||
await this._page.mainFrame().evaluateExpression(((file: string) => {
|
||||
window.playwrightSetFile(file);
|
||||
window.playwrightSetRunningFile(file);
|
||||
}).toString(), { isFunction: true }, file).catch(() => {});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export interface IRecorderApp extends EventEmitter {
|
|||
close(): Promise<void>;
|
||||
setPaused(paused: boolean): Promise<void>;
|
||||
setMode(mode: Mode): Promise<void>;
|
||||
setFile(file: string): Promise<void>;
|
||||
setRunningFile(file: string | undefined): Promise<void>;
|
||||
setSelector(selector: string, userGesture?: boolean): Promise<void>;
|
||||
updateCallLogs(callLogs: CallLog[]): Promise<void>;
|
||||
setSources(sources: Source[]): Promise<void>;
|
||||
|
|
|
|||
|
|
@ -66,8 +66,8 @@ export class RecorderInTraceViewer extends EventEmitter implements IRecorderApp
|
|||
this._transport.deliverEvent('setMode', { mode });
|
||||
}
|
||||
|
||||
async setFile(file: string): Promise<void> {
|
||||
this._transport.deliverEvent('setFileIfNeeded', { file });
|
||||
async setRunningFile(file: string | undefined): Promise<void> {
|
||||
this._transport.deliverEvent('setRunningFile', { file });
|
||||
}
|
||||
|
||||
async setSelector(selector: string, userGesture?: boolean): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -6510,7 +6510,7 @@ the top of the viewport and Y increases as it proceeds towards the bottom of the
|
|||
/**
|
||||
* List of settings able to be overridden by WebInspector.
|
||||
*/
|
||||
export type Setting = "PrivateClickMeasurementDebugModeEnabled"|"AuthorAndUserStylesEnabled"|"ICECandidateFilteringEnabled"|"ITPDebugModeEnabled"|"ImagesEnabled"|"MediaCaptureRequiresSecureConnection"|"MockCaptureDevicesEnabled"|"NeedsSiteSpecificQuirks"|"ScriptEnabled"|"ShowDebugBorders"|"ShowRepaintCounter"|"WebSecurityEnabled"|"DeviceOrientationEventEnabled"|"SpeechRecognitionEnabled"|"PointerLockEnabled"|"NotificationsEnabled"|"FullScreenEnabled"|"InputTypeMonthEnabled"|"InputTypeWeekEnabled";
|
||||
export type Setting = "PrivateClickMeasurementDebugModeEnabled"|"AuthorAndUserStylesEnabled"|"ICECandidateFilteringEnabled"|"ITPDebugModeEnabled"|"ImagesEnabled"|"MediaCaptureRequiresSecureConnection"|"MockCaptureDevicesEnabled"|"NeedsSiteSpecificQuirks"|"ScriptEnabled"|"ShowDebugBorders"|"ShowRepaintCounter"|"WebSecurityEnabled"|"DeviceOrientationEventEnabled"|"SpeechRecognitionEnabled"|"PointerLockEnabled"|"NotificationsEnabled"|"FullScreenEnabled"|"InputTypeMonthEnabled"|"InputTypeWeekEnabled"|"FixedBackgroundsPaintRelativeToDocument";
|
||||
/**
|
||||
* A user preference that can be overriden by Web Inspector, like an accessibility preference.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -231,6 +231,7 @@ export class WKPage implements PageDelegate {
|
|||
promises.push(session.send('Page.overrideSetting', { setting: 'PointerLockEnabled', value: !contextOptions.isMobile }));
|
||||
promises.push(session.send('Page.overrideSetting', { setting: 'InputTypeMonthEnabled', value: contextOptions.isMobile }));
|
||||
promises.push(session.send('Page.overrideSetting', { setting: 'InputTypeWeekEnabled', value: contextOptions.isMobile }));
|
||||
promises.push(session.send('Page.overrideSetting', { setting: 'FixedBackgroundsPaintRelativeToDocument', value: contextOptions.isMobile }));
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -130,6 +130,15 @@ export function traceParamsForAction(actionInContext: recorderActions.ActionInCo
|
|||
};
|
||||
return { method: 'expect', params };
|
||||
}
|
||||
case 'assertSnapshot': {
|
||||
const params: channels.FrameExpectParams = {
|
||||
selector,
|
||||
expression: 'to.match.snapshot',
|
||||
expectedText: [],
|
||||
isNot: false,
|
||||
};
|
||||
return { method: 'expect', params };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export const PNG: typeof import('../bundles/utils/node_modules/@types/pngjs').PN
|
|||
export const program: typeof import('../bundles/utils/node_modules/commander').program = require('./utilsBundleImpl').program;
|
||||
export const progress: typeof import('../bundles/utils/node_modules/@types/progress') = require('./utilsBundleImpl').progress;
|
||||
export const SocksProxyAgent: typeof import('../bundles/utils/node_modules/socks-proxy-agent').SocksProxyAgent = require('./utilsBundleImpl').SocksProxyAgent;
|
||||
export const yaml: typeof import('../bundles/utils/node_modules/yaml') = require('./utilsBundleImpl').yaml;
|
||||
export const ws: typeof import('../bundles/utils/node_modules/@types/ws') = require('./utilsBundleImpl').ws;
|
||||
export const wsServer: typeof import('../bundles/utils/node_modules/@types/ws').WebSocketServer = require('./utilsBundleImpl').wsServer;
|
||||
export const wsReceiver = require('./utilsBundleImpl').wsReceiver;
|
||||
|
|
|
|||
65
packages/playwright-core/types/types.d.ts
vendored
65
packages/playwright-core/types/types.d.ts
vendored
|
|
@ -12424,6 +12424,57 @@ export interface Locator {
|
|||
*/
|
||||
and(locator: Locator): Locator;
|
||||
|
||||
/**
|
||||
* Captures the aria snapshot of the given element. See
|
||||
* [expect(locator).toMatchAriaSnapshot(expected[, options])](https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-match-aria-snapshot)
|
||||
* for the corresponding assertion.
|
||||
*
|
||||
* **Usage**
|
||||
*
|
||||
* ```js
|
||||
* await page.getByRole('link').ariaSnapshot();
|
||||
* ```
|
||||
*
|
||||
* **Details**
|
||||
*
|
||||
* This method captures the aria snapshot of the given element. The snapshot is a string that represents the state of
|
||||
* the element and its children. The snapshot can be used to assert the state of the element in the test, or to
|
||||
* compare it to state in the future.
|
||||
*
|
||||
* The ARIA snapshot is represented using [YAML](https://yaml.org/spec/1.2.2/) markup language:
|
||||
* - The keys of the objects are the roles and optional accessible names of the elements.
|
||||
* - The values are either text content or an array of child elements.
|
||||
* - Generic static text can be represented with the `text` key.
|
||||
*
|
||||
* Below is the HTML markup and the respective ARIA snapshot:
|
||||
*
|
||||
* ```html
|
||||
* <ul aria-label="Links">
|
||||
* <li><a href="/">Home</a></li>
|
||||
* <li><a href="/about">About</a></li>
|
||||
* <ul>
|
||||
* ```
|
||||
*
|
||||
* ```yml
|
||||
* - list "Links":
|
||||
* - listitem:
|
||||
* - link "Home"
|
||||
* - listitem:
|
||||
* - link "About"
|
||||
* ```
|
||||
*
|
||||
* @param options
|
||||
*/
|
||||
ariaSnapshot(options?: {
|
||||
/**
|
||||
* Maximum time in milliseconds. Defaults to `0` - no timeout. The default value can be changed via `actionTimeout`
|
||||
* option in the config, or by using the
|
||||
* [browserContext.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-default-timeout)
|
||||
* or [page.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-page#page-set-default-timeout) methods.
|
||||
*/
|
||||
timeout?: number;
|
||||
}): Promise<string>;
|
||||
|
||||
/**
|
||||
* Calls [blur](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/blur) on the element.
|
||||
* @param options
|
||||
|
|
@ -15356,7 +15407,7 @@ export interface CDPSession {
|
|||
* the WebSocket. Here is an example that responds to a `"request"` with a `"response"`.
|
||||
*
|
||||
* ```js
|
||||
* await page.routeWebSocket('/ws', ws => {
|
||||
* await page.routeWebSocket('wss://example.com/ws', ws => {
|
||||
* ws.onMessage(message => {
|
||||
* if (message === 'request')
|
||||
* ws.send('response');
|
||||
|
|
@ -15369,6 +15420,18 @@ export interface CDPSession {
|
|||
* inside the WebSocket route handler, Playwright assumes that WebSocket will be mocked, and opens the WebSocket
|
||||
* inside the page automatically.
|
||||
*
|
||||
* Here is another example that handles JSON messages:
|
||||
*
|
||||
* ```js
|
||||
* await page.routeWebSocket('wss://example.com/ws', ws => {
|
||||
* ws.onMessage(message => {
|
||||
* const json = JSON.parse(message);
|
||||
* if (json.request === 'question')
|
||||
* ws.send(JSON.stringify({ response: 'answer' }));
|
||||
* });
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* **Intercepting**
|
||||
*
|
||||
* Alternatively, you may want to connect to the actual server, but intercept messages in-between and modify or block
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ export const matcherUtils = {
|
|||
};
|
||||
|
||||
export {
|
||||
EXPECTED_COLOR,
|
||||
INVERTED_COLOR,
|
||||
RECEIVED_COLOR,
|
||||
printReceived,
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ export class FullConfigInternal {
|
|||
readonly webServers: NonNullable<FullConfig['webServer']>[];
|
||||
readonly plugins: TestRunnerPluginRegistration[];
|
||||
readonly projects: FullProjectInternal[] = [];
|
||||
readonly singleTSConfigPath?: string;
|
||||
cliArgs: string[] = [];
|
||||
cliGrep: string | undefined;
|
||||
cliGrepInvert: string | undefined;
|
||||
|
|
@ -69,6 +70,7 @@ export class FullConfigInternal {
|
|||
this.configCLIOverrides = configCLIOverrides;
|
||||
const privateConfiguration = (userConfig as any)['@playwright/test'];
|
||||
this.plugins = (privateConfiguration?.plugins || []).map((p: any) => ({ factory: p }));
|
||||
this.singleTSConfigPath = pathResolve(configDir, userConfig.tsconfig);
|
||||
|
||||
this.config = {
|
||||
configFile: resolvedConfigFile,
|
||||
|
|
|
|||
|
|
@ -118,6 +118,8 @@ export async function loadConfig(location: ConfigLocation, overrides?: ConfigCLI
|
|||
const babelPlugins = (userConfig as any)['@playwright/test']?.babelPlugins || [];
|
||||
const external = userConfig.build?.external || [];
|
||||
setTransformConfig({ babelPlugins, external });
|
||||
if (!overrides?.tsconfig)
|
||||
setSingleTSConfig(fullConfig?.singleTSConfigPath);
|
||||
|
||||
// 4. Send transform options to ESM loader.
|
||||
await configureESMLoaderTransformConfig();
|
||||
|
|
|
|||
|
|
@ -77,5 +77,6 @@ export async function configureESMLoader() {
|
|||
export async function configureESMLoaderTransformConfig() {
|
||||
if (!loaderChannel)
|
||||
return;
|
||||
await loaderChannel.send('setSingleTSConfig', { tsconfig: singleTSConfig() });
|
||||
await loaderChannel.send('setTransformConfig', { config: transformConfig() });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
[*]
|
||||
../common/
|
||||
../util.ts
|
||||
../utilsBundle.ts
|
||||
../worker/testInfo.ts
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ import {
|
|||
import { zones } from 'playwright-core/lib/utils';
|
||||
import { TestInfoImpl } from '../worker/testInfo';
|
||||
import { ExpectError, isExpectError } from './matcherHint';
|
||||
import { toMatchAriaSnapshot } from './toMatchAriaSnapshot';
|
||||
|
||||
// #region
|
||||
// Mirrored from https://github.com/facebook/jest/blob/f13abff8df9a0e1148baf3584bcde6d1b479edc7/packages/expect/src/print.ts
|
||||
|
|
@ -236,6 +237,7 @@ const customAsyncMatchers = {
|
|||
toHaveValue,
|
||||
toHaveValues,
|
||||
toHaveScreenshot,
|
||||
toMatchAriaSnapshot,
|
||||
toPass,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ import { TestInfoImpl } from '../worker/testInfo';
|
|||
import type { ExpectMatcherState } from '../../types/test';
|
||||
import { takeFirst } from '../common/config';
|
||||
|
||||
interface LocatorEx extends Locator {
|
||||
export interface LocatorEx extends Locator {
|
||||
_expect(expression: string, options: Omit<FrameExpectOptions, 'expectedValue'> & { expectedValue?: any }): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }>;
|
||||
}
|
||||
|
||||
|
|
|
|||
75
packages/playwright/src/matchers/toMatchAriaSnapshot.ts
Normal file
75
packages/playwright/src/matchers/toMatchAriaSnapshot.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* Copyright Microsoft Corporation. All rights reserved.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
import type { LocatorEx } from './matchers';
|
||||
import type { ExpectMatcherState } from '../../types/test';
|
||||
import { kNoElementsFoundError, matcherHint, type MatcherResult } from './matcherHint';
|
||||
import { colors } from 'playwright-core/lib/utilsBundle';
|
||||
import { EXPECTED_COLOR } from '../common/expectBundle';
|
||||
import { callLogText } from '../util';
|
||||
import { printReceivedStringContainExpectedSubstring } from './expect';
|
||||
|
||||
export async function toMatchAriaSnapshot(
|
||||
this: ExpectMatcherState,
|
||||
receiver: LocatorEx,
|
||||
expected: string,
|
||||
options: { timeout?: number, matchSubstring?: boolean } = {},
|
||||
): Promise<MatcherResult<string | RegExp, string>> {
|
||||
const matcherName = 'toMatchAriaSnapshot';
|
||||
|
||||
const matcherOptions = {
|
||||
isNot: this.isNot,
|
||||
promise: this.promise,
|
||||
};
|
||||
|
||||
if (typeof expected !== 'string') {
|
||||
throw new Error([
|
||||
matcherHint(this, receiver, matcherName, receiver, expected, matcherOptions),
|
||||
`${colors.bold('Matcher error')}: ${EXPECTED_COLOR('expected',)} value must be a string`,
|
||||
this.utils.printWithType('Expected', expected, this.utils.printExpected)
|
||||
].join('\n\n'));
|
||||
}
|
||||
|
||||
const timeout = options.timeout ?? this.timeout;
|
||||
const { matches: pass, received, log, timedOut } = await receiver._expect('to.match.aria', { expectedValue: expected, isNot: this.isNot, timeout });
|
||||
|
||||
const messagePrefix = matcherHint(this, receiver, matcherName, 'locator', undefined, matcherOptions, timedOut ? timeout : undefined);
|
||||
const notFound = received === kNoElementsFoundError;
|
||||
const message = () => {
|
||||
if (pass) {
|
||||
if (notFound)
|
||||
return messagePrefix + `Expected: not ${this.utils.printExpected(expected)}\nReceived: ${received}` + callLogText(log);
|
||||
const printedReceived = printReceivedStringContainExpectedSubstring(received, received.indexOf(expected), expected.length);
|
||||
return messagePrefix + `Expected: not ${this.utils.printExpected(expected)}\nReceived string: ${printedReceived}` + callLogText(log);
|
||||
} else {
|
||||
const labelExpected = `Expected`;
|
||||
if (notFound)
|
||||
return messagePrefix + `${labelExpected}: ${this.utils.printExpected(expected)}\nReceived: ${received}` + callLogText(log);
|
||||
return messagePrefix + this.utils.printDiffOrStringify(expected, received, labelExpected, 'Received string', false) + callLogText(log);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
name: matcherName,
|
||||
expected,
|
||||
message,
|
||||
pass,
|
||||
actual: received,
|
||||
log,
|
||||
timeout: timedOut ? timeout : undefined,
|
||||
};
|
||||
}
|
||||
|
|
@ -423,7 +423,7 @@ export async function toHaveScreenshot(
|
|||
// - regular matcher (i.e. not a `.not`)
|
||||
// - perhaps an 'all' flag to update non-matching screenshots
|
||||
expectScreenshotOptions.expected = await fs.promises.readFile(helper.expectedPath);
|
||||
const { actual, diff, errorMessage, log } = await page._expectScreenshot(expectScreenshotOptions);
|
||||
const { actual, previous, diff, errorMessage, log } = await page._expectScreenshot(expectScreenshotOptions);
|
||||
|
||||
if (!errorMessage)
|
||||
return helper.handleMatching();
|
||||
|
|
@ -436,7 +436,7 @@ export async function toHaveScreenshot(
|
|||
return helper.createMatcherResult(helper.expectedPath + ' running with --update-snapshots, writing actual.', true);
|
||||
}
|
||||
|
||||
return helper.handleDifferent(actual, expectScreenshotOptions.expected, undefined, diff, errorMessage, log);
|
||||
return helper.handleDifferent(actual, expectScreenshotOptions.expected, previous, diff, errorMessage, log);
|
||||
}
|
||||
|
||||
function writeFileSync(aPath: string, content: Buffer | string) {
|
||||
|
|
|
|||
|
|
@ -176,8 +176,11 @@ export async function createRootSuite(testRun: TestRun, errors: TestError[], sho
|
|||
if (config.config.shard) {
|
||||
// Create test groups for top-level projects.
|
||||
const testGroups: TestGroup[] = [];
|
||||
for (const projectSuite of rootSuite.suites)
|
||||
testGroups.push(...createTestGroups(projectSuite, config.config.workers));
|
||||
for (const projectSuite of rootSuite.suites) {
|
||||
// Split beforeAll-grouped tests into "config.shard.total" groups when needed.
|
||||
// Later on, we'll re-split them between workers by using "config.workers" instead.
|
||||
testGroups.push(...createTestGroups(projectSuite, config.config.shard.total));
|
||||
}
|
||||
|
||||
// Shard test groups.
|
||||
const testGroupsInThisShard = filterForShard(config.config.shard, testGroups);
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export type TestGroup = {
|
|||
tests: TestCase[];
|
||||
};
|
||||
|
||||
export function createTestGroups(projectSuite: Suite, workers: number): TestGroup[] {
|
||||
export function createTestGroups(projectSuite: Suite, expectedParallelism: number): TestGroup[] {
|
||||
// This function groups tests that can be run together.
|
||||
// Tests cannot be run together when:
|
||||
// - They belong to different projects - requires different workers.
|
||||
|
|
@ -116,7 +116,7 @@ export function createTestGroups(projectSuite: Suite, workers: number): TestGrou
|
|||
result.push(...withRequireFile.parallel.values());
|
||||
|
||||
// Tests with beforeAll/afterAll should try to share workers as much as possible.
|
||||
const parallelWithHooksGroupSize = Math.ceil(withRequireFile.parallelWithHooks.tests.length / workers);
|
||||
const parallelWithHooksGroupSize = Math.ceil(withRequireFile.parallelWithHooks.tests.length / expectedParallelism);
|
||||
let lastGroup: TestGroup | undefined;
|
||||
for (const test of withRequireFile.parallelWithHooks.tests) {
|
||||
if (!lastGroup || lastGroup.tests.length >= parallelWithHooksGroupSize) {
|
||||
|
|
|
|||
42
packages/playwright/types/test.d.ts
vendored
42
packages/playwright/types/test.d.ts
vendored
|
|
@ -1642,6 +1642,25 @@ interface TestConfig<TestArgs = {}, WorkerArgs = {}> {
|
|||
*/
|
||||
timeout?: number;
|
||||
|
||||
/**
|
||||
* Path to a single `tsconfig` applicable to all imported files. By default, `tsconfig` for each imported file is
|
||||
* looked up separately. Note that `tsconfig` property has no effect while the configuration file or any of its
|
||||
* dependencies are loaded. Ignored when `--tsconfig` command line option is specified.
|
||||
*
|
||||
* **Usage**
|
||||
*
|
||||
* ```js
|
||||
* // playwright.config.ts
|
||||
* import { defineConfig } from '@playwright/test';
|
||||
*
|
||||
* export default defineConfig({
|
||||
* tsconfig: './tsconfig.test.json',
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
*/
|
||||
tsconfig?: string;
|
||||
|
||||
/**
|
||||
* Whether to update expected snapshots with the actual results produced by the test run. Defaults to `'missing'`.
|
||||
* - `'all'` - All tests that are executed will update snapshots that did not match. Matching snapshots will not be
|
||||
|
|
@ -7619,6 +7638,29 @@ interface LocatorAssertions {
|
|||
timeout?: number;
|
||||
}): Promise<void>;
|
||||
|
||||
/**
|
||||
* Asserts that the target element matches the given accessibility snapshot.
|
||||
*
|
||||
* **Usage**
|
||||
*
|
||||
* ```js
|
||||
* await page.goto('https://demo.playwright.dev/todomvc/');
|
||||
* await expect(page.locator('body')).toMatchAriaSnapshot(`
|
||||
* - heading "todos"
|
||||
* - textbox "What needs to be done?"
|
||||
* `);
|
||||
* ```
|
||||
*
|
||||
* @param expected
|
||||
* @param options
|
||||
*/
|
||||
toMatchAriaSnapshot(expected: string, options?: {
|
||||
/**
|
||||
* Time to retry the assertion for in milliseconds. Defaults to `timeout` in `TestConfig.expect`.
|
||||
*/
|
||||
timeout?: number;
|
||||
}): Promise<void>;
|
||||
|
||||
/**
|
||||
* Makes the assertion check for the opposite condition. For example, this code tests that the Locator doesn't contain
|
||||
* text `"error"`:
|
||||
|
|
|
|||
|
|
@ -2509,6 +2509,7 @@ export interface FrameChannel extends FrameEventTarget, Channel {
|
|||
evalOnSelectorAll(params: FrameEvalOnSelectorAllParams, metadata?: CallMetadata): Promise<FrameEvalOnSelectorAllResult>;
|
||||
addScriptTag(params: FrameAddScriptTagParams, metadata?: CallMetadata): Promise<FrameAddScriptTagResult>;
|
||||
addStyleTag(params: FrameAddStyleTagParams, metadata?: CallMetadata): Promise<FrameAddStyleTagResult>;
|
||||
ariaSnapshot(params: FrameAriaSnapshotParams, metadata?: CallMetadata): Promise<FrameAriaSnapshotResult>;
|
||||
blur(params: FrameBlurParams, metadata?: CallMetadata): Promise<FrameBlurResult>;
|
||||
check(params: FrameCheckParams, metadata?: CallMetadata): Promise<FrameCheckResult>;
|
||||
click(params: FrameClickParams, metadata?: CallMetadata): Promise<FrameClickResult>;
|
||||
|
|
@ -2613,6 +2614,16 @@ export type FrameAddStyleTagOptions = {
|
|||
export type FrameAddStyleTagResult = {
|
||||
element: ElementHandleChannel,
|
||||
};
|
||||
export type FrameAriaSnapshotParams = {
|
||||
selector: string,
|
||||
timeout?: number,
|
||||
};
|
||||
export type FrameAriaSnapshotOptions = {
|
||||
timeout?: number,
|
||||
};
|
||||
export type FrameAriaSnapshotResult = {
|
||||
snapshot: string,
|
||||
};
|
||||
export type FrameBlurParams = {
|
||||
selector: string,
|
||||
strict?: boolean,
|
||||
|
|
|
|||
|
|
@ -1875,6 +1875,13 @@ Frame:
|
|||
flags:
|
||||
snapshot: true
|
||||
|
||||
ariaSnapshot:
|
||||
parameters:
|
||||
selector: string
|
||||
timeout: number?
|
||||
returns:
|
||||
snapshot: string
|
||||
|
||||
blur:
|
||||
parameters:
|
||||
selector: string
|
||||
|
|
|
|||
|
|
@ -30,7 +30,8 @@ export type ActionName =
|
|||
'assertText' |
|
||||
'assertValue' |
|
||||
'assertChecked' |
|
||||
'assertVisible';
|
||||
'assertVisible' |
|
||||
'assertSnapshot';
|
||||
|
||||
export type ActionBase = {
|
||||
name: ActionName,
|
||||
|
|
@ -113,8 +114,13 @@ export type AssertVisibleAction = ActionWithSelector & {
|
|||
name: 'assertVisible',
|
||||
};
|
||||
|
||||
export type Action = ClickAction | CheckAction | ClosesPageAction | OpenPageAction | UncheckAction | FillAction | NavigateAction | PressAction | SelectAction | SetInputFilesAction | AssertTextAction | AssertValueAction | AssertCheckedAction | AssertVisibleAction;
|
||||
export type AssertAction = AssertCheckedAction | AssertValueAction | AssertTextAction | AssertVisibleAction;
|
||||
export type AssertSnapshotAction = ActionWithSelector & {
|
||||
name: 'assertSnapshot',
|
||||
snapshot: string,
|
||||
};
|
||||
|
||||
export type Action = ClickAction | CheckAction | ClosesPageAction | OpenPageAction | UncheckAction | FillAction | NavigateAction | PressAction | SelectAction | SetInputFilesAction | AssertTextAction | AssertValueAction | AssertCheckedAction | AssertVisibleAction | AssertSnapshotAction;
|
||||
export type AssertAction = AssertCheckedAction | AssertValueAction | AssertTextAction | AssertVisibleAction | AssertSnapshotAction;
|
||||
export type PerformOnRecordAction = ClickAction | CheckAction | UncheckAction | PressAction | SelectAction;
|
||||
|
||||
// Signals.
|
||||
|
|
|
|||
|
|
@ -41,13 +41,11 @@ export const Recorder: React.FC<RecorderProps> = ({
|
|||
log,
|
||||
mode,
|
||||
}) => {
|
||||
const [fileId, setFileId] = React.useState<string | undefined>();
|
||||
const [selectedFileId, setSelectedFileId] = React.useState<string | undefined>();
|
||||
const [runningFileId, setRunningFileId] = React.useState<string | undefined>();
|
||||
const [selectedTab, setSelectedTab] = React.useState<string>('log');
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!fileId && sources.length > 0)
|
||||
setFileId(sources[0].id);
|
||||
}, [fileId, sources]);
|
||||
const fileId = selectedFileId || runningFileId || sources[0]?.id;
|
||||
|
||||
const source = React.useMemo(() => {
|
||||
if (fileId) {
|
||||
|
|
@ -66,7 +64,7 @@ export const Recorder: React.FC<RecorderProps> = ({
|
|||
setLocator(asLocator(language, selector));
|
||||
};
|
||||
|
||||
window.playwrightSetFile = setFileId;
|
||||
window.playwrightSetRunningFile = setRunningFileId;
|
||||
|
||||
const messagesEndRef = React.useRef<HTMLDivElement>(null);
|
||||
React.useLayoutEffect(() => {
|
||||
|
|
@ -118,6 +116,7 @@ export const Recorder: React.FC<RecorderProps> = ({
|
|||
'assertingText': 'recording-inspecting',
|
||||
'assertingVisibility': 'recording-inspecting',
|
||||
'assertingValue': 'recording-inspecting',
|
||||
'assertingSnapshot': 'recording-inspecting',
|
||||
}[mode];
|
||||
window.dispatch({ event: 'setMode', params: { mode: newMode } }).catch(() => { });
|
||||
}}></ToolbarButton>
|
||||
|
|
@ -134,19 +133,19 @@ export const Recorder: React.FC<RecorderProps> = ({
|
|||
<ToolbarButton icon='files' title='Copy' disabled={!source || !source.text} onClick={() => {
|
||||
copy(source.text);
|
||||
}}></ToolbarButton>
|
||||
<ToolbarButton icon='debug-continue' title='Resume (F8)' disabled={!paused} onClick={() => {
|
||||
<ToolbarButton icon='debug-continue' title='Resume (F8)' ariaLabel='Resume' disabled={!paused} onClick={() => {
|
||||
window.dispatch({ event: 'resume' });
|
||||
}}></ToolbarButton>
|
||||
<ToolbarButton icon='debug-pause' title='Pause (F8)' disabled={paused} onClick={() => {
|
||||
<ToolbarButton icon='debug-pause' title='Pause (F8)' ariaLabel='Pause' disabled={paused} onClick={() => {
|
||||
window.dispatch({ event: 'pause' });
|
||||
}}></ToolbarButton>
|
||||
<ToolbarButton icon='debug-step-over' title='Step over (F10)' disabled={!paused} onClick={() => {
|
||||
<ToolbarButton icon='debug-step-over' title='Step over (F10)' ariaLabel='Step over' disabled={!paused} onClick={() => {
|
||||
window.dispatch({ event: 'step' });
|
||||
}}></ToolbarButton>
|
||||
<div style={{ flex: 'auto' }}></div>
|
||||
<div>Target:</div>
|
||||
<SourceChooser fileId={fileId} sources={sources} setFileId={fileId => {
|
||||
setFileId(fileId);
|
||||
setSelectedFileId(fileId);
|
||||
window.dispatch({ event: 'fileChanged', params: { file: fileId } });
|
||||
}} />
|
||||
<ToolbarButton icon='clear-all' title='Clear' disabled={!source || !source.text} onClick={() => {
|
||||
|
|
|
|||
|
|
@ -26,7 +26,8 @@ export type Mode =
|
|||
| 'recording-inspecting'
|
||||
| 'standby'
|
||||
| 'assertingVisibility'
|
||||
| 'assertingValue';
|
||||
| 'assertingValue'
|
||||
| 'assertingSnapshot';
|
||||
|
||||
export type EventData = {
|
||||
event:
|
||||
|
|
@ -96,7 +97,7 @@ declare global {
|
|||
playwrightSetSources: (sources: Source[]) => void;
|
||||
playwrightSetOverlayVisible: (visible: boolean) => void;
|
||||
playwrightUpdateLogs: (callLogs: CallLog[]) => void;
|
||||
playwrightSetFile: (file: string) => void;
|
||||
playwrightSetRunningFile: (file: string | undefined) => void;
|
||||
playwrightSetSelector: (selector: string, focus?: boolean) => void;
|
||||
playwrightSourcesEchoForTest: Source[];
|
||||
dispatch(data: any): Promise<void>;
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export const SourceChooser: React.FC<{
|
|||
fileId: string | undefined,
|
||||
setFileId: (fileId: string) => void,
|
||||
}> = ({ sources, fileId, setFileId }) => {
|
||||
return <select className='source-chooser' hidden={!sources.length} value={fileId} onChange={event => {
|
||||
return <select className='source-chooser' hidden={!sources.length} title='Source chooser' value={fileId} onChange={event => {
|
||||
setFileId(event.target.selectedOptions[0].value);
|
||||
}}>{renderSourceOptions(sources)}</select>;
|
||||
};
|
||||
|
|
@ -33,17 +33,21 @@ function renderSourceOptions(sources: Source[]): React.ReactNode {
|
|||
<option key={source.id} value={source.id}>{transformTitle(source.label)}</option>
|
||||
);
|
||||
|
||||
const hasGroup = sources.some(s => s.group);
|
||||
if (hasGroup) {
|
||||
const groups = new Set(sources.map(s => s.group));
|
||||
return [...groups].filter(Boolean).map(group => (
|
||||
<optgroup label={group} key={group}>
|
||||
{sources.filter(s => s.group === group).map(source => renderOption(source))}
|
||||
</optgroup>
|
||||
));
|
||||
const sourcesByGroups = new Map<string, Source[]>();
|
||||
for (const source of sources) {
|
||||
let list = sourcesByGroups.get(source.group || 'Debugger');
|
||||
if (!list) {
|
||||
list = [];
|
||||
sourcesByGroups.set(source.group || 'Debugger', list);
|
||||
}
|
||||
list.push(source);
|
||||
}
|
||||
|
||||
return sources.map(source => renderOption(source));
|
||||
return [...sourcesByGroups.entries()].map(([group, sources]) => (
|
||||
<optgroup label={group} key={group}>
|
||||
{sources.filter(s => (s.group || 'Debugger') === group).map(source => renderOption(source))}
|
||||
</optgroup>
|
||||
));
|
||||
}
|
||||
|
||||
export function emptySource(): Source {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export interface ToolbarButtonProps {
|
|||
style?: React.CSSProperties,
|
||||
testId?: string,
|
||||
className?: string,
|
||||
ariaLabel?: string,
|
||||
}
|
||||
|
||||
export const ToolbarButton: React.FC<React.PropsWithChildren<ToolbarButtonProps>> = ({
|
||||
|
|
@ -40,6 +41,7 @@ export const ToolbarButton: React.FC<React.PropsWithChildren<ToolbarButtonProps>
|
|||
style,
|
||||
testId,
|
||||
className,
|
||||
ariaLabel,
|
||||
}) => {
|
||||
return <button
|
||||
className={clsx(className, 'toolbar-button', icon, toggled && 'toggled')}
|
||||
|
|
@ -50,6 +52,7 @@ export const ToolbarButton: React.FC<React.PropsWithChildren<ToolbarButtonProps>
|
|||
disabled={!!disabled}
|
||||
style={style}
|
||||
data-testid={testId}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{icon && <span className={`codicon codicon-${icon}`} style={children ? { marginRight: 5 } : {}}></span>}
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -61,11 +61,13 @@ const checkerboardStyle: React.CSSProperties = {
|
|||
export const ImageDiffView: React.FC<{
|
||||
diff: ImageDiff,
|
||||
noTargetBlank?: boolean,
|
||||
}> = ({ diff, noTargetBlank }) => {
|
||||
hideDetails?: boolean,
|
||||
}> = ({ diff, noTargetBlank, hideDetails }) => {
|
||||
const [mode, setMode] = React.useState<'diff' | 'actual' | 'expected' | 'slider' | 'sxs'>(diff.diff ? 'diff' : 'actual');
|
||||
const [showSxsDiff, setShowSxsDiff] = React.useState<boolean>(false);
|
||||
|
||||
const [expectedImage, setExpectedImage] = React.useState<HTMLImageElement | null>(null);
|
||||
const [expectedImageTitle, setExpectedImageTitle] = React.useState<string>('Expected');
|
||||
const [actualImage, setActualImage] = React.useState<HTMLImageElement | null>(null);
|
||||
const [diffImage, setDiffImage] = React.useState<HTMLImageElement | null>(null);
|
||||
const [measure, ref] = useMeasure<HTMLDivElement>();
|
||||
|
|
@ -73,6 +75,7 @@ export const ImageDiffView: React.FC<{
|
|||
React.useEffect(() => {
|
||||
(async () => {
|
||||
setExpectedImage(await loadImage(diff.expected?.attachment.path));
|
||||
setExpectedImageTitle(diff.expected?.title || 'Expected');
|
||||
setActualImage(await loadImage(diff.actual?.attachment.path));
|
||||
setDiffImage(await loadImage(diff.diff?.attachment.path));
|
||||
})();
|
||||
|
|
@ -98,31 +101,31 @@ export const ImageDiffView: React.FC<{
|
|||
<div data-testid='test-result-image-mismatch-tabs' style={{ display: 'flex', margin: '10px 0 20px' }}>
|
||||
{diff.diff && <div style={{ ...modeStyle, fontWeight: mode === 'diff' ? 600 : 'initial' }} onClick={() => setMode('diff')}>Diff</div>}
|
||||
<div style={{ ...modeStyle, fontWeight: mode === 'actual' ? 600 : 'initial' }} onClick={() => setMode('actual')}>Actual</div>
|
||||
<div style={{ ...modeStyle, fontWeight: mode === 'expected' ? 600 : 'initial' }} onClick={() => setMode('expected')}>Expected</div>
|
||||
<div style={{ ...modeStyle, fontWeight: mode === 'expected' ? 600 : 'initial' }} onClick={() => setMode('expected')}>{expectedImageTitle}</div>
|
||||
<div style={{ ...modeStyle, fontWeight: mode === 'sxs' ? 600 : 'initial' }} onClick={() => setMode('sxs')}>Side by side</div>
|
||||
<div style={{ ...modeStyle, fontWeight: mode === 'slider' ? 600 : 'initial' }} onClick={() => setMode('slider')}>Slider</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', flex: 'auto', minHeight: fitHeight + 60 }}>
|
||||
{diff.diff && mode === 'diff' && <ImageWithSize image={diffImage} alt='Diff' canvasWidth={fitWidth} canvasHeight={fitHeight} scale={scale}/>}
|
||||
{diff.diff && mode === 'actual' && <ImageWithSize image={actualImage} alt='Actual' canvasWidth={fitWidth} canvasHeight={fitHeight} scale={scale}/>}
|
||||
{diff.diff && mode === 'expected' && <ImageWithSize image={expectedImage} alt='Expected' canvasWidth={fitWidth} canvasHeight={fitHeight} scale={scale}/>}
|
||||
{diff.diff && mode === 'slider' && <ImageDiffSlider expectedImage={expectedImage} actualImage={actualImage} canvasWidth={fitWidth} canvasHeight={fitHeight} scale={scale} />}
|
||||
{diff.diff && mode === 'diff' && <ImageWithSize image={diffImage} alt='Diff' hideSize={hideDetails} canvasWidth={fitWidth} canvasHeight={fitHeight} scale={scale}/>}
|
||||
{diff.diff && mode === 'actual' && <ImageWithSize image={actualImage} alt='Actual' hideSize={hideDetails} canvasWidth={fitWidth} canvasHeight={fitHeight} scale={scale}/>}
|
||||
{diff.diff && mode === 'expected' && <ImageWithSize image={expectedImage} alt={expectedImageTitle} hideSize={hideDetails} canvasWidth={fitWidth} canvasHeight={fitHeight} scale={scale}/>}
|
||||
{diff.diff && mode === 'slider' && <ImageDiffSlider expectedImage={expectedImage} actualImage={actualImage} hideSize={hideDetails} canvasWidth={fitWidth} canvasHeight={fitHeight} scale={scale} expectedTitle={expectedImageTitle} />}
|
||||
{diff.diff && mode === 'sxs' && <div style={{ display: 'flex' }}>
|
||||
<ImageWithSize image={expectedImage} title='Expected' canvasWidth={sxsScale * imageWidth} canvasHeight={sxsScale * imageHeight} scale={sxsScale} />
|
||||
<ImageWithSize image={showSxsDiff ? diffImage : actualImage} title={showSxsDiff ? 'Diff' : 'Actual'} onClick={() => setShowSxsDiff(!showSxsDiff)} canvasWidth={sxsScale * imageWidth} canvasHeight={sxsScale * imageHeight} scale={sxsScale} />
|
||||
<ImageWithSize image={expectedImage} title={expectedImageTitle} hideSize={hideDetails} canvasWidth={sxsScale * imageWidth} canvasHeight={sxsScale * imageHeight} scale={sxsScale} />
|
||||
<ImageWithSize image={showSxsDiff ? diffImage : actualImage} title={showSxsDiff ? 'Diff' : 'Actual'} onClick={() => setShowSxsDiff(!showSxsDiff)} hideSize={hideDetails} canvasWidth={sxsScale * imageWidth} canvasHeight={sxsScale * imageHeight} scale={sxsScale} />
|
||||
</div>}
|
||||
{!diff.diff && mode === 'actual' && <ImageWithSize image={actualImage} title='Actual' canvasWidth={fitWidth} canvasHeight={fitHeight} scale={scale}/>}
|
||||
{!diff.diff && mode === 'expected' && <ImageWithSize image={expectedImage} title='Expected' canvasWidth={fitWidth} canvasHeight={fitHeight} scale={scale}/>}
|
||||
{!diff.diff && mode === 'actual' && <ImageWithSize image={actualImage} title='Actual' hideSize={hideDetails} canvasWidth={fitWidth} canvasHeight={fitHeight} scale={scale}/>}
|
||||
{!diff.diff && mode === 'expected' && <ImageWithSize image={expectedImage} title={expectedImageTitle} hideSize={hideDetails} canvasWidth={fitWidth} canvasHeight={fitHeight} scale={scale}/>}
|
||||
{!diff.diff && mode === 'sxs' && <div style={{ display: 'flex' }}>
|
||||
<ImageWithSize image={expectedImage} title='Expected' canvasWidth={sxsScale * imageWidth} canvasHeight={sxsScale * imageHeight} scale={sxsScale} />
|
||||
<ImageWithSize image={expectedImage} title={expectedImageTitle} canvasWidth={sxsScale * imageWidth} canvasHeight={sxsScale * imageHeight} scale={sxsScale} />
|
||||
<ImageWithSize image={actualImage} title='Actual' canvasWidth={sxsScale * imageWidth} canvasHeight={sxsScale * imageHeight} scale={sxsScale} />
|
||||
</div>}
|
||||
</div>
|
||||
<div style={{ alignSelf: 'start', lineHeight: '18px', marginLeft: '15px' }}>
|
||||
{!hideDetails && <div style={{ alignSelf: 'start', lineHeight: '18px', marginLeft: '15px' }}>
|
||||
<div>{diff.diff && <a target='_blank' href={diff.diff.attachment.path} rel='noreferrer'>{diff.diff.attachment.name}</a>}</div>
|
||||
<div><a target={noTargetBlank ? '' : '_blank'} href={diff.actual!.attachment.path} rel='noreferrer'>{diff.actual!.attachment.name}</a></div>
|
||||
<div><a target={noTargetBlank ? '' : '_blank'} href={diff.expected!.attachment.path} rel='noreferrer'>{diff.expected!.attachment.name}</a></div>
|
||||
</div>
|
||||
</div>}
|
||||
</>}
|
||||
</div>;
|
||||
};
|
||||
|
|
@ -133,7 +136,9 @@ export const ImageDiffSlider: React.FC<{
|
|||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
scale: number,
|
||||
}> = ({ expectedImage, actualImage, canvasWidth, canvasHeight, scale }) => {
|
||||
expectedTitle: string,
|
||||
hideSize?: boolean,
|
||||
}> = ({ expectedImage, actualImage, canvasWidth, canvasHeight, scale, expectedTitle, hideSize }) => {
|
||||
const absoluteStyle: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
|
|
@ -144,7 +149,7 @@ export const ImageDiffSlider: React.FC<{
|
|||
const sameSize = expectedImage.naturalWidth === actualImage.naturalWidth && expectedImage.naturalHeight === actualImage.naturalHeight;
|
||||
|
||||
return <div style={{ flex: 'none', display: 'flex', alignItems: 'center', flexDirection: 'column', userSelect: 'none' }}>
|
||||
<div style={{ margin: 5 }}>
|
||||
{!hideSize && <div style={{ margin: 5 }}>
|
||||
{!sameSize && <span style={{ flex: 'none', margin: '0 5px' }}>Expected </span>}
|
||||
<span>{expectedImage.naturalWidth}</span>
|
||||
<span style={{ flex: 'none', margin: '0 5px' }}>x</span>
|
||||
|
|
@ -153,7 +158,7 @@ export const ImageDiffSlider: React.FC<{
|
|||
{!sameSize && <span>{actualImage.naturalWidth}</span>}
|
||||
{!sameSize && <span style={{ flex: 'none', margin: '0 5px' }}>x</span>}
|
||||
{!sameSize && <span>{actualImage.naturalHeight}</span>}
|
||||
</div>
|
||||
</div>}
|
||||
<div style={{ position: 'relative', width: canvasWidth, height: canvasHeight, margin: 15, ...checkerboardStyle }}>
|
||||
<ResizeView
|
||||
orientation={'horizontal'}
|
||||
|
|
@ -161,7 +166,7 @@ export const ImageDiffSlider: React.FC<{
|
|||
setOffsets={offsets => setSlider(offsets[0])}
|
||||
resizerColor={'#57606a80'}
|
||||
resizerWidth={6}></ResizeView>
|
||||
<img alt='Expected' style={{
|
||||
<img alt={expectedTitle} style={{
|
||||
width: expectedImage.naturalWidth * scale,
|
||||
height: expectedImage.naturalHeight * scale,
|
||||
}} draggable='false' src={expectedImage.src} />
|
||||
|
|
@ -179,18 +184,19 @@ const ImageWithSize: React.FunctionComponent<{
|
|||
image: HTMLImageElement,
|
||||
title?: string,
|
||||
alt?: string,
|
||||
hideSize?: boolean,
|
||||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
scale: number,
|
||||
onClick?: () => void;
|
||||
}> = ({ image, title, alt, canvasWidth, canvasHeight, scale, onClick }) => {
|
||||
}> = ({ image, title, alt, hideSize, canvasWidth, canvasHeight, scale, onClick }) => {
|
||||
return <div style={{ flex: 'none', display: 'flex', alignItems: 'center', flexDirection: 'column' }}>
|
||||
<div style={{ margin: 5 }}>
|
||||
{!hideSize && <div style={{ margin: 5 }}>
|
||||
{title && <span style={{ flex: 'none', margin: '0 5px' }}>{title}</span>}
|
||||
<span>{image.naturalWidth}</span>
|
||||
<span style={{ flex: 'none', margin: '0 5px' }}>x</span>
|
||||
<span>{image.naturalHeight}</span>
|
||||
</div>
|
||||
</div>}
|
||||
<div style={{ display: 'flex', flex: 'none', width: canvasWidth, height: canvasHeight, margin: 15, ...checkerboardStyle }}>
|
||||
<img
|
||||
width={image.naturalWidth * scale}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ it.describe('mobile viewport', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('should be detectable', async ({ playwright, browser, server, browserName, platform }) => {
|
||||
it('should be detectable', async ({ playwright, browser }) => {
|
||||
const iPhone = playwright.devices['iPhone 6'];
|
||||
const context = await browser.newContext({ ...iPhone });
|
||||
const page = await context.newPage();
|
||||
|
|
@ -62,7 +62,7 @@ it.describe('mobile viewport', () => {
|
|||
await context.close();
|
||||
});
|
||||
|
||||
it('should detect touch when applying viewport with touches', async ({ browser, server, browserName, platform }) => {
|
||||
it('should detect touch when applying viewport with touches', async ({ browser, server }) => {
|
||||
const context = await browser.newContext({ viewport: { width: 800, height: 600 }, hasTouch: true });
|
||||
const page = await context.newPage();
|
||||
await page.goto(server.EMPTY_PAGE);
|
||||
|
|
@ -154,7 +154,7 @@ it.describe('mobile viewport', () => {
|
|||
await desktopPage.close();
|
||||
});
|
||||
|
||||
it('mouse should work with mobile viewports and cross process navigations', async ({ browser, server, browserName }) => {
|
||||
it('mouse should work with mobile viewports and cross process navigations', async ({ browser, server }) => {
|
||||
// @see https://crbug.com/929806
|
||||
const context = await browser.newContext({ viewport: { width: 360, height: 640 }, isMobile: true });
|
||||
const page = await context.newPage();
|
||||
|
|
@ -193,8 +193,7 @@ it.describe('mobile viewport', () => {
|
|||
{ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/31551' },
|
||||
{ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/23573' },
|
||||
]
|
||||
}, async ({ playwright, browser, server, browserName, isLinux, headless }) => {
|
||||
it.fixme(browserName === 'webkit' && isLinux && headless, 'Fails on WPE apparently due to accelerated compositing + fixed layout');
|
||||
}, async ({ playwright, browser, server }) => {
|
||||
const iPhone = playwright.devices['iPhone 12'];
|
||||
const context = await browser.newContext({ ...iPhone });
|
||||
const page = await context.newPage();
|
||||
|
|
@ -204,7 +203,7 @@ it.describe('mobile viewport', () => {
|
|||
await context.close();
|
||||
});
|
||||
|
||||
it('view scale should reset after navigation', async ({ browser, browserName }) => {
|
||||
it('view scale should reset after navigation', async ({ browser }) => {
|
||||
it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/26876' });
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 390, height: 664 },
|
||||
|
|
|
|||
|
|
@ -877,6 +877,19 @@ it('should include timings when using socks proxy', async ({ contextFactory, ser
|
|||
expect(log.entries[0].timings.connect).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should not have connect and dns timings when socket is reused', async ({ contextFactory, server }, testInfo) => {
|
||||
const { page, getLog } = await pageWithHar(contextFactory, testInfo);
|
||||
await page.request.get(server.EMPTY_PAGE);
|
||||
await page.request.get(server.EMPTY_PAGE);
|
||||
|
||||
const log = await getLog();
|
||||
expect(log.entries).toHaveLength(2);
|
||||
const request2 = log.entries[1];
|
||||
expect.soft(request2.timings.connect).toBe(-1);
|
||||
expect.soft(request2.timings.dns).toBe(-1);
|
||||
expect.soft(request2.timings.blocked).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should include redirects from API request', async ({ contextFactory, server }, testInfo) => {
|
||||
server.setRedirect('/redirect-me', '/simple.json');
|
||||
const { page, getLog } = await pageWithHar(contextFactory, testInfo);
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
*/
|
||||
|
||||
import { test, expect } from './inspectorTest';
|
||||
import type { ConsoleMessage, Locator } from 'playwright';
|
||||
import type { ConsoleMessage } from 'playwright';
|
||||
|
||||
test.describe('cli codegen', () => {
|
||||
test.skip(({ mode }) => mode !== 'default');
|
||||
|
|
@ -682,93 +682,6 @@ await page.Locator(\"#age\").SelectOptionAsync(new[] { \"2\" });`);
|
|||
expect(message.text()).toBe('2');
|
||||
});
|
||||
|
||||
const clickMultipleSelectOption = async (locator: Locator, withCtrlOrMeta = false) => {
|
||||
const page = locator.page();
|
||||
|
||||
// Webkit can't click multiple select options
|
||||
// https://github.com/microsoft/playwright/issues/32126
|
||||
if (page.context().browser().browserType().name() === 'webkit') {
|
||||
const elem = await locator.elementHandle();
|
||||
const rect = await elem!.evaluate(e => {
|
||||
return e.getBoundingClientRect()!;
|
||||
});
|
||||
if (withCtrlOrMeta)
|
||||
await page.keyboard.down('ControlOrMeta');
|
||||
|
||||
await page.mouse.click(rect.x + rect.width / 2, rect.y + rect.height / 2);
|
||||
if (withCtrlOrMeta)
|
||||
await page.keyboard.up('ControlOrMeta');
|
||||
|
||||
} else {
|
||||
await locator.click({ modifiers: withCtrlOrMeta ? ['ControlOrMeta'] : [] });
|
||||
}
|
||||
};
|
||||
|
||||
test('should select with multiple attribute', async ({ openRecorder }) => {
|
||||
const { page, recorder } = await openRecorder();
|
||||
|
||||
await recorder.setContentAndWait(`<select id="age" multiple onchange="console.log('[' + [...age.selectedOptions].map(x => x.value).join(',') + ']')"><option value="1">1</option><option value="2">2</option></select>`);
|
||||
|
||||
const locator = await recorder.hoverOverElement('select');
|
||||
expect(locator).toBe(`locator('#age')`);
|
||||
await clickMultipleSelectOption(page.getByRole('option', { name: '1' }));
|
||||
|
||||
const [message, sources] = await Promise.all([
|
||||
page.waitForEvent('console', msg => msg.type() !== 'error' && msg.text().includes('2')),
|
||||
recorder.waitForOutput('JavaScript', 'selectOption(['),
|
||||
clickMultipleSelectOption(page.getByRole('option', { name: '2' }), true)
|
||||
]);
|
||||
|
||||
expect(sources.get('JavaScript')!.text).toContain(`
|
||||
await page.locator('#age').selectOption(['1', '2']);`);
|
||||
|
||||
expect(sources.get('Java')!.text).toContain(`
|
||||
page.locator("#age").selectOption(new String[] {"1", "2"});`);
|
||||
|
||||
expect(sources.get('Python')!.text).toContain(`
|
||||
page.locator("#age").select_option(["1", "2"])`);
|
||||
|
||||
expect(sources.get('Python Async')!.text).toContain(`
|
||||
await page.locator("#age").select_option(["1", "2"])`);
|
||||
|
||||
expect(sources.get('C#')!.text).toContain(`
|
||||
await page.Locator("#age").SelectOptionAsync(new[] { "1", "2" });`);
|
||||
|
||||
expect(message.text()).toBe('[1,2]');
|
||||
});
|
||||
|
||||
test('should unselect with multiple attribute', async ({ openRecorder }) => {
|
||||
const { page, recorder } = await openRecorder();
|
||||
|
||||
await recorder.setContentAndWait(`<select id="age" multiple onchange="console.log('[' + [...age.selectedOptions].map(x => x.value).join(',') + ']')"><option value="1">1</option><option value="2">2</option></select>`);
|
||||
const locator = await recorder.hoverOverElement('select');
|
||||
expect(locator).toBe(`locator('#age')`);
|
||||
await clickMultipleSelectOption(page.getByRole('option', { name: '1' }));
|
||||
|
||||
const [message, sources] = await Promise.all([
|
||||
page.waitForEvent('console', msg => msg.type() !== 'error' && msg.text() === '[]'),
|
||||
recorder.waitForOutput('JavaScript', 'selectOption(['),
|
||||
clickMultipleSelectOption(page.getByRole('option', { name: '1' }), true)
|
||||
]);
|
||||
|
||||
expect(sources.get('JavaScript')!.text).toContain(`
|
||||
await page.locator('#age').selectOption([]);`);
|
||||
|
||||
expect(sources.get('Java')!.text).toContain(`
|
||||
page.locator("#age").selectOption(new String[0]);`);
|
||||
|
||||
expect(sources.get('Python')!.text).toContain(`
|
||||
page.locator("#age").select_option([])`);
|
||||
|
||||
expect(sources.get('Python Async')!.text).toContain(`
|
||||
await page.locator("#age").select_option([])`);
|
||||
|
||||
expect(sources.get('C#')!.text).toContain(`
|
||||
await page.Locator("#age").SelectOptionAsync(new[] { });`);
|
||||
|
||||
expect(message.text()).toBe('[]');
|
||||
});
|
||||
|
||||
test('should await popup', async ({ openRecorder }) => {
|
||||
const { page, recorder } = await openRecorder();
|
||||
await recorder.setContentAndWait('<a target=_blank rel=noopener href="about:blank">link</a>');
|
||||
|
|
|
|||
42
tests/library/inspector/cli-codegen-aria.spec.ts
Normal file
42
tests/library/inspector/cli-codegen-aria.spec.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* 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 { test, expect } from './inspectorTest';
|
||||
|
||||
test.describe(() => {
|
||||
test.skip(({ mode }) => mode !== 'default');
|
||||
test.skip(({ trace, codegenMode }) => trace === 'on' && codegenMode === 'trace-events');
|
||||
|
||||
test('should generate aria snapshot', async ({ openRecorder }) => {
|
||||
const { recorder } = await openRecorder();
|
||||
await recorder.setContentAndWait(`<main><button>Submit</button></main>`);
|
||||
|
||||
await recorder.page.click('x-pw-tool-item.snapshot');
|
||||
await recorder.page.hover('button');
|
||||
await recorder.trustedClick();
|
||||
|
||||
await expect.poll(() =>
|
||||
recorder.text('JavaScript')).toContain(`await expect(page.getByRole('button')).toMatchAriaSnapshot(\`- button "Submit"\`);`);
|
||||
await expect.poll(() =>
|
||||
recorder.text('Python')).toContain(`expect(page.get_by_role("button")).to_match_aria_snapshot("- button \\"Submit\\"")`);
|
||||
await expect.poll(() =>
|
||||
recorder.text('Python Async')).toContain(`await expect(page.get_by_role(\"button\")).to_match_aria_snapshot("- button \\"Submit\\"")`);
|
||||
await expect.poll(() =>
|
||||
recorder.text('Java')).toContain(`assertThat(page.getByRole(AriaRole.BUTTON)).matchesAriaSnapshot("- button \\"Submit\\"");`);
|
||||
await expect.poll(() =>
|
||||
recorder.text('C#')).toContain(`await Expect(page.GetByRole(AriaRole.Button)).ToMatchAriaSnapshotAsync("- button \\"Submit\\"");`);
|
||||
});
|
||||
});
|
||||
|
|
@ -107,6 +107,7 @@ it('expected properties on playwright object', async ({ page }) => {
|
|||
'inspect',
|
||||
'selector',
|
||||
'generateLocator',
|
||||
'ariaSnapshot',
|
||||
'resume',
|
||||
'locator',
|
||||
'getByTestId',
|
||||
|
|
|
|||
|
|
@ -160,6 +160,15 @@ export class Recorder {
|
|||
return this._sources;
|
||||
}
|
||||
|
||||
async text(file: string): Promise<string> {
|
||||
const sources: Source[] = await this.recorderPage.evaluate(() => (window as any).playwrightSourcesEchoForTest || []);
|
||||
for (const source of sources) {
|
||||
if (codegenLangId2lang.get(source.id) === file)
|
||||
return source.text;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
async waitForHighlight(action: () => Promise<void>): Promise<string> {
|
||||
await this.page.$$eval('x-pw-highlight', els => els.forEach(e => e.remove()));
|
||||
await this.page.$$eval('x-pw-tooltip', els => els.forEach(e => e.remove()));
|
||||
|
|
@ -171,6 +180,13 @@ export class Recorder {
|
|||
return this.page.locator('x-pw-tooltip').textContent();
|
||||
}
|
||||
|
||||
async waitForHighlightNoTooltip(action: () => Promise<void>): Promise<string> {
|
||||
await this.page.$$eval('x-pw-highlight', els => els.forEach(e => e.remove()));
|
||||
await action();
|
||||
await this.page.locator('x-pw-highlight').waitFor();
|
||||
return '';
|
||||
}
|
||||
|
||||
async waitForActionPerformed(): Promise<{ hovered: string | null, active: string | null }> {
|
||||
let callback;
|
||||
const listener = async msg => {
|
||||
|
|
@ -185,8 +201,8 @@ export class Recorder {
|
|||
return new Promise(f => callback = f);
|
||||
}
|
||||
|
||||
async hoverOverElement(selector: string, options?: { position?: { x: number, y: number }}): Promise<string> {
|
||||
return this.waitForHighlight(async () => {
|
||||
async hoverOverElement(selector: string, options?: { position?: { x: number, y: number }, omitTooltip?: boolean }): Promise<string> {
|
||||
return (options?.omitTooltip ? this.waitForHighlightNoTooltip : this.waitForHighlight).call(this, async () => {
|
||||
const box = await this.page.locator(selector).first().boundingBox();
|
||||
const offset = options?.position || { x: box.width / 2, y: box.height / 2 };
|
||||
await this.page.mouse.move(box.x + offset.x, box.y + offset.y);
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
*/
|
||||
|
||||
import type { Page } from 'playwright-core';
|
||||
import { test as it, expect } from './inspectorTest';
|
||||
import { test as it, expect, Recorder } from './inspectorTest';
|
||||
import { waitForTestLog } from '../../config/utils';
|
||||
|
||||
|
||||
|
|
@ -103,6 +103,7 @@ it.describe('pause', () => {
|
|||
await page.pause();
|
||||
})();
|
||||
const recorderPage = await recorderPageGetter();
|
||||
await expect(recorderPage.getByRole('combobox', { name: 'Source chooser' })).toHaveValue(/pause\.spec\.ts/);
|
||||
const source = await recorderPage.textContent('.source-line-paused');
|
||||
expect(source).toContain('page.pause()');
|
||||
await recorderPage.click('[title="Resume (F8)"]');
|
||||
|
|
@ -480,6 +481,26 @@ it.describe('pause', () => {
|
|||
await recorderPage.click('[title="Resume (F8)"]');
|
||||
await scriptPromise;
|
||||
});
|
||||
|
||||
it('should record from debugger', async ({ page, recorderPageGetter }) => {
|
||||
await page.setContent('<body style="width: 100%; height: 100%"></body>');
|
||||
const scriptPromise = (async () => {
|
||||
await page.pause();
|
||||
})();
|
||||
const recorderPage = await recorderPageGetter();
|
||||
await expect(recorderPage.getByRole('combobox', { name: 'Source chooser' })).toHaveValue(/pause\.spec\.ts/);
|
||||
await expect(recorderPage.locator('.source-line-paused')).toHaveText(/await page\.pause\(\)/);
|
||||
await recorderPage.getByRole('button', { name: 'Record' }).click();
|
||||
|
||||
const recorder = new Recorder(page, recorderPage);
|
||||
await recorder.hoverOverElement('body', { omitTooltip: true });
|
||||
await recorder.trustedClick();
|
||||
|
||||
await expect(recorderPage.getByRole('combobox', { name: 'Source chooser' })).toHaveValue('javascript');
|
||||
await expect(recorderPage.locator('.cm-wrapper')).toContainText(`await page.locator('body').click();`);
|
||||
await recorderPage.getByRole('button', { name: 'Resume' }).click();
|
||||
await scriptPromise;
|
||||
});
|
||||
});
|
||||
|
||||
async function sanitizeLog(recorderPage: Page): Promise<string[]> {
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ it('Safari Desktop', async ({ browser, browserName, platform, server, headless }
|
|||
expected.publickeycredential = false;
|
||||
expected.mediastream = false;
|
||||
if (headless)
|
||||
expected.todataurljpeg = false;
|
||||
expected.todataurlwebp = true;
|
||||
|
||||
// GHA
|
||||
delete actual.variablefonts;
|
||||
|
|
@ -124,7 +124,7 @@ it('Mobile Safari', async ({ playwright, browser, browserName, platform, server,
|
|||
expected.publickeycredential = false;
|
||||
expected.mediastream = false;
|
||||
if (headless)
|
||||
expected.todataurljpeg = false;
|
||||
expected.todataurlwebp = true;
|
||||
|
||||
// GHA
|
||||
delete actual.variablefonts;
|
||||
|
|
|
|||
|
|
@ -508,3 +508,27 @@ test('should throw when connecting twice', async ({ page, server }) => {
|
|||
const error = await promise;
|
||||
expect(error.message).toContain('Already connected to the server');
|
||||
});
|
||||
|
||||
test('should work with no trailing slash', async ({ page, server }) => {
|
||||
const log: string[] = [];
|
||||
// No trailing slash!
|
||||
await page.routeWebSocket('ws://localhost:' + server.PORT, ws => {
|
||||
ws.onMessage(message => {
|
||||
log.push(message as string);
|
||||
ws.send('response');
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('about:blank');
|
||||
await page.evaluate(({ port }) => {
|
||||
window.log = [];
|
||||
// No trailing slash!
|
||||
window.ws = new WebSocket('ws://localhost:' + port);
|
||||
window.ws.addEventListener('message', event => window.log.push(event.data));
|
||||
}, { port: server.PORT });
|
||||
|
||||
await expect.poll(() => page.evaluate(() => window.ws.readyState)).toBe(1);
|
||||
await page.evaluate(() => window.ws.send('query'));
|
||||
await expect.poll(() => log).toEqual(['query']);
|
||||
expect(await page.evaluate(() => window.log)).toEqual(['response']);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -126,6 +126,7 @@ it('should intercept worker requests when enabled after worker creation', {
|
|||
}, async ({ page, server, isAndroid, browserName, browserMajorVersion }) => {
|
||||
it.skip(isAndroid);
|
||||
it.skip(browserName === 'chromium' && browserMajorVersion < 130, 'fixed in Chromium 130');
|
||||
it.fixme(browserName === 'chromium', 'requires PlzDedicatedWorker to be enabled');
|
||||
|
||||
await page.goto(server.EMPTY_PAGE);
|
||||
server.setRoute('/data_for_worker', (req, res) => res.end('failed to intercept'));
|
||||
|
|
|
|||
40
tests/page/page-aria-snapshot.spec.ts
Normal file
40
tests/page/page-aria-snapshot.spec.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* Copyright 2018 Google Inc. All rights reserved.
|
||||
* Modifications 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 { test as it, expect } from './pageTest';
|
||||
|
||||
it('should snapshot the check box @smoke', async ({ page }) => {
|
||||
await page.setContent(`<input id='checkbox' type='checkbox'></input>`);
|
||||
expect(await page.locator('body').ariaSnapshot()).toBe('- checkbox');
|
||||
});
|
||||
|
||||
it('should snapshot nested element', async ({ page }) => {
|
||||
await page.setContent(`
|
||||
<div>
|
||||
<input id='checkbox' type='checkbox'></input>
|
||||
</div>`);
|
||||
expect(await page.locator('body').ariaSnapshot()).toBe('- checkbox');
|
||||
});
|
||||
|
||||
it('should snapshot fragment', async ({ page }) => {
|
||||
await page.setContent(`
|
||||
<div>
|
||||
<a href="about:blank">Link</a>
|
||||
<a href="about:blank">Link</a>
|
||||
</div>`);
|
||||
expect(await page.locator('body').ariaSnapshot()).toBe(`- link "Link"\n- link "Link"`);
|
||||
});
|
||||
|
|
@ -258,7 +258,8 @@ it('should work with subframes return 204 with domcontentloaded', async ({ page,
|
|||
await page.goto(server.PREFIX + '/frames/one-frame.html', { waitUntil: 'domcontentloaded' });
|
||||
});
|
||||
|
||||
it('should fail when server returns 204', async ({ page, server, browserName }) => {
|
||||
it('should fail when server returns 204', async ({ page, server, browserName, isLinux }) => {
|
||||
it.fixme(browserName === 'webkit' && isLinux, 'Regressed in https://github.com/microsoft/playwright-browsers/pull/1297');
|
||||
// WebKit just loads an empty page.
|
||||
server.setRoute('/empty.html', (req, res) => {
|
||||
res.statusCode = 204;
|
||||
|
|
|
|||
|
|
@ -280,12 +280,13 @@ it.describe('page screenshot', () => {
|
|||
expect(screenshot).toMatchSnapshot('screenshot-clip-odd-size.png');
|
||||
});
|
||||
|
||||
it('should work for canvas', async ({ page, server, isElectron, isMac, macVersion, browserName, headless }) => {
|
||||
it('should work for canvas', async ({ page, server, isElectron, isMac, isLinux, macVersion, browserName, headless }) => {
|
||||
it.fixme(isElectron && isMac, 'Fails on the bots');
|
||||
await page.setViewportSize({ width: 500, height: 500 });
|
||||
await page.goto(server.PREFIX + '/screenshots/canvas.html');
|
||||
const screenshot = await page.screenshot();
|
||||
if (!headless && browserName === 'chromium' && isMac && os.arch() === 'arm64' && macVersion >= 14)
|
||||
if ((!headless && browserName === 'chromium' && isMac && os.arch() === 'arm64' && macVersion >= 14) ||
|
||||
(browserName === 'webkit' && isLinux))
|
||||
expect(screenshot).toMatchSnapshot('screenshot-canvas-with-accurate-corners.png');
|
||||
else
|
||||
expect(screenshot).toMatchSnapshot('screenshot-canvas.png');
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
194
tests/page/to-match-aria-snapshot.spec.ts
Normal file
194
tests/page/to-match-aria-snapshot.spec.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
/**
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { stripAnsi } from 'tests/config/utils';
|
||||
import { test, expect } from './pageTest';
|
||||
|
||||
test('should match', async ({ page }) => {
|
||||
await page.setContent(`<h1>title</h1>`);
|
||||
await expect(page.locator('body')).toMatchAriaSnapshot(`
|
||||
- heading "title"
|
||||
`);
|
||||
});
|
||||
|
||||
test('should match in list', async ({ page }) => {
|
||||
await page.setContent(`
|
||||
<h1>title</h1>
|
||||
<h1>title 2</h1>
|
||||
`);
|
||||
await expect(page.locator('body')).toMatchAriaSnapshot(`
|
||||
- heading "title"
|
||||
`);
|
||||
});
|
||||
|
||||
test('should match list with accessible name', async ({ page }) => {
|
||||
await page.setContent(`
|
||||
<ul aria-label="my list">
|
||||
<li>one</li>
|
||||
<li>two</li>
|
||||
</ul>
|
||||
`);
|
||||
await expect(page.locator('body')).toMatchAriaSnapshot(`
|
||||
- list "my list":
|
||||
- listitem: one
|
||||
- listitem: two
|
||||
`);
|
||||
});
|
||||
|
||||
test('should match deep item', async ({ page }) => {
|
||||
await page.setContent(`
|
||||
<div>
|
||||
<h1>title</h1>
|
||||
<h1>title 2</h1>
|
||||
</div>
|
||||
`);
|
||||
await expect(page.locator('body')).toMatchAriaSnapshot(`
|
||||
- heading "title"
|
||||
`);
|
||||
});
|
||||
|
||||
test('should match complex', async ({ page }) => {
|
||||
await page.setContent(`
|
||||
<ul>
|
||||
<li>
|
||||
<a href='about:blank'>link</a>
|
||||
</li>
|
||||
</ul>
|
||||
`);
|
||||
await expect(page.locator('body')).toMatchAriaSnapshot(`
|
||||
- list:
|
||||
- listitem:
|
||||
- link "link"
|
||||
`);
|
||||
});
|
||||
|
||||
test('should match regex', async ({ page }) => {
|
||||
await page.setContent(`<h1>Issues 12</h1>`);
|
||||
await expect(page.locator('body')).toMatchAriaSnapshot(`
|
||||
- heading ${/Issues \d+/}
|
||||
`);
|
||||
});
|
||||
|
||||
test('should allow text nodes', async ({ page }) => {
|
||||
await page.setContent(`
|
||||
<h1>Microsoft</h1>
|
||||
<div>Open source projects and samples from Microsoft</div>
|
||||
`);
|
||||
|
||||
await expect(page.locator('body')).toMatchAriaSnapshot(`
|
||||
- heading "Microsoft"
|
||||
- text: Open source projects and samples from Microsoft
|
||||
`);
|
||||
});
|
||||
|
||||
test('details visibility', async ({ page, browserName }) => {
|
||||
await page.setContent(`
|
||||
<details>
|
||||
<summary>Summary</summary>
|
||||
<div>Details</div>
|
||||
</details>
|
||||
`);
|
||||
|
||||
await expect(page.locator('body')).toMatchAriaSnapshot(`
|
||||
- group: Summary
|
||||
`);
|
||||
});
|
||||
|
||||
test('integration test', async ({ page, browserName }) => {
|
||||
await page.setContent(`
|
||||
<h1>Microsoft</h1>
|
||||
<div>Open source projects and samples from Microsoft</div>
|
||||
<ul>
|
||||
<li>
|
||||
<details>
|
||||
<summary>
|
||||
Verified
|
||||
</summary>
|
||||
<div>
|
||||
<div>
|
||||
<p>
|
||||
We've verified that the organization <strong>microsoft</strong> controls the domain:
|
||||
</p>
|
||||
<ul>
|
||||
<li class="mb-1">
|
||||
<strong>opensource.microsoft.com</strong>
|
||||
</li>
|
||||
</ul>
|
||||
<div>
|
||||
<a href="about: blank">Learn more about verified organizations</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</li>
|
||||
<li>
|
||||
<a href="about:blank">
|
||||
<summary title="Label: GitHub Sponsor">Sponsor</summary>
|
||||
</a>
|
||||
</li>
|
||||
</ul>`);
|
||||
|
||||
await expect(page.locator('body')).toMatchAriaSnapshot(`
|
||||
- heading "Microsoft"
|
||||
- text: Open source projects and samples from Microsoft
|
||||
- list:
|
||||
- listitem:
|
||||
- group: Verified
|
||||
- listitem:
|
||||
- link "Sponsor"
|
||||
`);
|
||||
});
|
||||
|
||||
test('integration test 2', async ({ page }) => {
|
||||
await page.setContent(`
|
||||
<div>
|
||||
<header>
|
||||
<h1>todos</h1>
|
||||
<input placeholder="What needs to be done?">
|
||||
</header>
|
||||
</div>`);
|
||||
await expect(page.locator('body')).toMatchAriaSnapshot(`
|
||||
- heading "todos"
|
||||
- textbox "What needs to be done?"
|
||||
`);
|
||||
});
|
||||
|
||||
test('expected formatter', async ({ page }) => {
|
||||
await page.setContent(`
|
||||
<div>
|
||||
<header>
|
||||
<h1>todos</h1>
|
||||
<input placeholder="What needs to be done?">
|
||||
</header>
|
||||
</div>`);
|
||||
const error = await expect(page.locator('body')).toMatchAriaSnapshot(`
|
||||
- heading "todos"
|
||||
- textbox "Wrong text"
|
||||
`, { timeout: 1 }).catch(e => e);
|
||||
|
||||
expect(stripAnsi(error.message)).toContain(`
|
||||
Locator: locator('body')
|
||||
- Expected - 4
|
||||
+ Received string + 3
|
||||
|
||||
-
|
||||
+ - banner:
|
||||
- - heading "todos"
|
||||
+ - heading "todos"
|
||||
- - textbox "Wrong text"
|
||||
-
|
||||
+ - textbox "What needs to be done?"`);
|
||||
});
|
||||
|
|
@ -167,7 +167,6 @@ it('should report network activity', async function({ page, server, browserName,
|
|||
|
||||
it('should report network activity on worker creation', async function({ page, server, browserName, browserMajorVersion }) {
|
||||
it.skip(browserName === 'firefox' && browserMajorVersion < 114, 'https://github.com/microsoft/playwright/issues/21760');
|
||||
// Chromium needs waitForDebugger enabled for this one.
|
||||
await page.goto(server.EMPTY_PAGE);
|
||||
const url = server.PREFIX + '/one-style.css';
|
||||
const requestPromise = page.waitForRequest(url);
|
||||
|
|
@ -182,6 +181,19 @@ it('should report network activity on worker creation', async function({ page, s
|
|||
expect(response.ok()).toBe(true);
|
||||
});
|
||||
|
||||
it('should report worker script as network request', {
|
||||
annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/33107' },
|
||||
}, async function({ page, server }) {
|
||||
await page.goto(server.EMPTY_PAGE);
|
||||
const [request1, request2] = await Promise.all([
|
||||
page.waitForEvent('request', r => r.url().includes('worker.js')),
|
||||
page.waitForEvent('requestfinished', r => r.url().includes('worker.js')),
|
||||
page.evaluate(() => (window as any).w = new Worker('/worker/worker.js')),
|
||||
]);
|
||||
expect.soft(request1.url()).toBe(server.PREFIX + '/worker/worker.js');
|
||||
expect.soft(request1).toBe(request2);
|
||||
});
|
||||
|
||||
it('should dispatch console messages when page has workers', async function({ page, server }) {
|
||||
it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/15550' });
|
||||
await page.goto(server.EMPTY_PAGE);
|
||||
|
|
|
|||
|
|
@ -179,7 +179,7 @@ for (const useIntermediateMergeReport of [false] as const) {
|
|||
await expect(page.locator('text=Image mismatch')).toBeVisible();
|
||||
await expect(page.locator('text=Snapshot mismatch')).toHaveCount(0);
|
||||
|
||||
await expect(page.getByTestId('test-result-image-mismatch-tabs').locator('div')).toHaveText([
|
||||
await expect(page.getByTestId('test-screenshot-error-view').getByTestId('test-result-image-mismatch-tabs').locator('div')).toHaveText([
|
||||
'Diff',
|
||||
'Actual',
|
||||
'Expected',
|
||||
|
|
@ -187,36 +187,40 @@ for (const useIntermediateMergeReport of [false] as const) {
|
|||
'Slider',
|
||||
]);
|
||||
|
||||
const imageDiff = page.getByTestId('test-result-image-mismatch');
|
||||
await test.step('Diff', async () => {
|
||||
await expect(imageDiff.locator('img')).toHaveAttribute('alt', 'Diff');
|
||||
});
|
||||
for (const testId of ['test-results-image-diff', 'test-screenshot-error-view']) {
|
||||
await test.step(testId, async () => {
|
||||
const imageDiff = page.getByTestId(testId).getByTestId('test-result-image-mismatch');
|
||||
await test.step('Diff', async () => {
|
||||
await expect(imageDiff.locator('img')).toHaveAttribute('alt', 'Diff');
|
||||
});
|
||||
|
||||
await test.step('Actual', async () => {
|
||||
await imageDiff.getByText('Actual', { exact: true }).click();
|
||||
await expect(imageDiff.locator('img')).toHaveAttribute('alt', 'Actual');
|
||||
});
|
||||
await test.step('Actual', async () => {
|
||||
await imageDiff.getByText('Actual', { exact: true }).click();
|
||||
await expect(imageDiff.locator('img')).toHaveAttribute('alt', 'Actual');
|
||||
});
|
||||
|
||||
await test.step('Expected', async () => {
|
||||
await imageDiff.getByText('Expected', { exact: true }).click();
|
||||
await expect(imageDiff.locator('img')).toHaveAttribute('alt', 'Expected');
|
||||
});
|
||||
await test.step('Expected', async () => {
|
||||
await imageDiff.getByText('Expected', { exact: true }).click();
|
||||
await expect(imageDiff.locator('img')).toHaveAttribute('alt', 'Expected');
|
||||
});
|
||||
|
||||
await test.step('Side by side', async () => {
|
||||
await imageDiff.getByText('Side by side').click();
|
||||
await expect(imageDiff.locator('img')).toHaveCount(2);
|
||||
await expect(imageDiff.locator('img').first()).toHaveAttribute('alt', 'Expected');
|
||||
await expect(imageDiff.locator('img').last()).toHaveAttribute('alt', 'Actual');
|
||||
await imageDiff.locator('img').last().click();
|
||||
await expect(imageDiff.locator('img').last()).toHaveAttribute('alt', 'Diff');
|
||||
});
|
||||
await test.step('Side by side', async () => {
|
||||
await imageDiff.getByText('Side by side').click();
|
||||
await expect(imageDiff.locator('img')).toHaveCount(2);
|
||||
await expect(imageDiff.locator('img').first()).toHaveAttribute('alt', 'Expected');
|
||||
await expect(imageDiff.locator('img').last()).toHaveAttribute('alt', 'Actual');
|
||||
await imageDiff.locator('img').last().click();
|
||||
await expect(imageDiff.locator('img').last()).toHaveAttribute('alt', 'Diff');
|
||||
});
|
||||
|
||||
await test.step('Slider', async () => {
|
||||
await imageDiff.getByText('Slider', { exact: true }).click();
|
||||
await expect(imageDiff.locator('img')).toHaveCount(2);
|
||||
await expect(imageDiff.locator('img').first()).toHaveAttribute('alt', 'Expected');
|
||||
await expect(imageDiff.locator('img').last()).toHaveAttribute('alt', 'Actual');
|
||||
});
|
||||
await test.step('Slider', async () => {
|
||||
await imageDiff.getByText('Slider', { exact: true }).click();
|
||||
await expect(imageDiff.locator('img')).toHaveCount(2);
|
||||
await expect(imageDiff.locator('img').first()).toHaveAttribute('alt', 'Expected');
|
||||
await expect(imageDiff.locator('img').last()).toHaveAttribute('alt', 'Actual');
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('should include multiple image diffs', async ({ runInlineTest, page, showReport }) => {
|
||||
|
|
@ -285,8 +289,14 @@ for (const useIntermediateMergeReport of [false] as const) {
|
|||
|
||||
await showReport();
|
||||
await page.click('text=fails');
|
||||
await expect(page.locator('data-testid=test-result-image-mismatch')).toHaveCount(3);
|
||||
await expect(page.locator('text=Image mismatch:')).toHaveText([
|
||||
await expect(page.getByTestId('test-screenshot-error-view').getByTestId('error-suffix')).toContainText([
|
||||
`> 6 | await expect.soft(screenshot).toMatchSnapshot('expected.png');`,
|
||||
`> 7 | await expect.soft(screenshot).toMatchSnapshot('expected.png');`,
|
||||
`> 8 | await expect.soft(screenshot).toMatchSnapshot('expected.png');`,
|
||||
]);
|
||||
const imageDiffs = page.getByTestId('test-results-image-diff');
|
||||
await expect(imageDiffs.getByTestId('test-result-image-mismatch')).toHaveCount(3);
|
||||
await expect(imageDiffs.getByText('Image mismatch:')).toHaveText([
|
||||
'Image mismatch: expected.png',
|
||||
'Image mismatch: expected-1.png',
|
||||
'Image mismatch: expected-2.png',
|
||||
|
|
@ -323,7 +333,7 @@ for (const useIntermediateMergeReport of [false] as const) {
|
|||
await expect(page.getByTestId('test-result-image-mismatch-tabs').locator('div')).toHaveText([
|
||||
'Diff',
|
||||
'Actual',
|
||||
'Expected',
|
||||
'Previous',
|
||||
'Side by side',
|
||||
'Slider',
|
||||
]);
|
||||
|
|
@ -460,7 +470,7 @@ for (const useIntermediateMergeReport of [false] as const) {
|
|||
|
||||
await showReport();
|
||||
await page.click('text=fails');
|
||||
await expect(page.locator('.test-error-message span:has-text("received")').nth(1)).toHaveCSS('color', 'rgb(204, 0, 0)');
|
||||
await expect(page.locator('.test-error-view span:has-text("received")').nth(1)).toHaveCSS('color', 'rgb(204, 0, 0)');
|
||||
});
|
||||
|
||||
test('should show trace source', async ({ runInlineTest, page, showReport }) => {
|
||||
|
|
|
|||
|
|
@ -675,6 +675,65 @@ test('should respect --tsconfig option', async ({ runInlineTest }) => {
|
|||
expect(result.output).not.toContain(`Could not`);
|
||||
});
|
||||
|
||||
test('should respect config.tsconfig option', async ({ runInlineTest }) => {
|
||||
const result = await runInlineTest({
|
||||
'playwright.config.ts': `
|
||||
export { configFoo } from '~/foo';
|
||||
export default {
|
||||
testDir: './tests',
|
||||
tsconfig: './tsconfig.tests.json',
|
||||
};
|
||||
`,
|
||||
'tsconfig.json': `{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["./mapped-from-config/*"],
|
||||
},
|
||||
},
|
||||
}`,
|
||||
'mapped-from-config/foo.ts': `
|
||||
export const configFoo = 17;
|
||||
`,
|
||||
'tsconfig.tests.json': `{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["./mapped-from-tests/*"],
|
||||
},
|
||||
},
|
||||
}`,
|
||||
'mapped-from-tests/foo.ts': `
|
||||
export const testFoo = 42;
|
||||
`,
|
||||
'tests/tsconfig.json': `{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["../should-be-ignored/*"],
|
||||
},
|
||||
},
|
||||
}`,
|
||||
'tests/a.test.ts': `
|
||||
import { testFoo } from '~/foo';
|
||||
import { configFoo } from '../playwright.config';
|
||||
import { test, expect } from '@playwright/test';
|
||||
test('test', ({}) => {
|
||||
expect(testFoo).toBe(42);
|
||||
expect(configFoo).toBe(17);
|
||||
});
|
||||
`,
|
||||
'should-be-ignored/foo.ts': `
|
||||
export const testFoo = 43;
|
||||
export const configFoo = 18;
|
||||
`,
|
||||
});
|
||||
|
||||
expect(result.passed).toBe(1);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.output).not.toContain(`Could not`);
|
||||
});
|
||||
|
||||
test.describe('directory imports', () => {
|
||||
test('should resolve index.js without path mapping in CJS', async ({ runInlineTest, runTSC }) => {
|
||||
const files = {
|
||||
|
|
|
|||
|
|
@ -284,3 +284,43 @@ test('should not shard mode:default suites', async ({ runInlineTest }) => {
|
|||
expect(result.outputLines).toEqual(['beforeAll2', 'test4', 'test5']);
|
||||
}
|
||||
});
|
||||
|
||||
test('should shard tests with beforeAll based on shards total instead of workers', {
|
||||
annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/33077' },
|
||||
}, async ({ runInlineTest }) => {
|
||||
const tests = {
|
||||
'a.spec.ts': `
|
||||
import { test } from '@playwright/test';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
test.beforeAll(() => {
|
||||
console.log('\\n%%beforeAll');
|
||||
});
|
||||
|
||||
for (let i = 1; i <= 8; i++) {
|
||||
test('test ' + i, async ({ }) => {
|
||||
console.log('\\n%%test' + i);
|
||||
});
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
{
|
||||
const result = await runInlineTest(tests, { shard: '1/4', workers: 1 });
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.passed).toBe(2);
|
||||
expect(result.outputLines).toEqual(['beforeAll', 'test1', 'test2']);
|
||||
}
|
||||
{
|
||||
const result = await runInlineTest(tests, { shard: '2/4', workers: 1 });
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.passed).toBe(2);
|
||||
expect(result.outputLines).toEqual(['beforeAll', 'test3', 'test4']);
|
||||
}
|
||||
{
|
||||
const result = await runInlineTest(tests, { shard: '7/8', workers: 6 });
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.passed).toBe(1);
|
||||
expect(result.outputLines).toEqual(['beforeAll', 'test7']);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
// @ts-check
|
||||
const Documentation = require('./documentation');
|
||||
const { visitAll } = require('../markdown');
|
||||
const { visitAll, render } = require('../markdown');
|
||||
/**
|
||||
* @param {Documentation.MarkdownNode[]} nodes
|
||||
* @param {number} maxColumns
|
||||
|
|
@ -64,7 +64,10 @@ function _innerRenderNodes(nodes, maxColumns = 80, wrapParagraphs = true) {
|
|||
} else if (node.type === 'li') {
|
||||
_wrapInNode('item><description', _wrapAndEscape(node, maxColumns), summary, '/description></item');
|
||||
} else if (node.type === 'note') {
|
||||
_wrapInNode('para', _wrapAndEscape(node, maxColumns), remarks);
|
||||
_wrapInNode('para', _wrapAndEscape({
|
||||
type: 'text',
|
||||
text: render(node.children ?? []).replaceAll('\n', '↵'),
|
||||
}, maxColumns), remarks);
|
||||
}
|
||||
lastNode = node;
|
||||
});
|
||||
|
|
@ -75,11 +78,11 @@ function _innerRenderNodes(nodes, maxColumns = 80, wrapParagraphs = true) {
|
|||
|
||||
function _wrapCode(lines) {
|
||||
let i = 0;
|
||||
let out = [];
|
||||
const out = [];
|
||||
for (let line of lines) {
|
||||
line = line.replace(/[&]/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
if (i < lines.length - 1)
|
||||
line = line + "<br/>";
|
||||
line = line + '<br/>';
|
||||
out.push(line);
|
||||
i++;
|
||||
}
|
||||
|
|
@ -163,4 +166,4 @@ function renderTextOnly(nodes, maxColumns = 80) {
|
|||
return result.summary;
|
||||
}
|
||||
|
||||
module.exports = { renderXmlDoc, renderTextOnly }
|
||||
module.exports = { renderXmlDoc, renderTextOnly };
|
||||
|
|
@ -520,7 +520,8 @@ function renderMethod(member, parent, name, options, out) {
|
|||
&& !name.startsWith('Get')
|
||||
&& name !== 'CreateFormData'
|
||||
&& !name.startsWith('PostDataJSON')
|
||||
&& !name.startsWith('As')) {
|
||||
&& !name.startsWith('As')
|
||||
&& name !== 'ConnectToServer') {
|
||||
if (!member.async) {
|
||||
if (member.spec && !options.nodocs)
|
||||
out.push(...XmlDoc.renderXmlDoc(member.spec, maxDocumentationColumnWidth));
|
||||
|
|
@ -718,7 +719,7 @@ function translateType(type, parent, generateNameCallback = t => t.name, optiona
|
|||
if (type.expression === '[null]|[Error]')
|
||||
return 'void';
|
||||
|
||||
if (type.name == 'Promise' && type.templates?.[0].name === 'any')
|
||||
if (type.name === 'Promise' && type.templates?.[0].name === 'any')
|
||||
return 'Task';
|
||||
|
||||
if (type.union) {
|
||||
|
|
|
|||
|
|
@ -152,6 +152,7 @@ class JSLintingService extends LintingService {
|
|||
'notice/notice': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'max-len': ['error', { code: 100 }],
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
},
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ const iconNames = [
|
|||
'check',
|
||||
'close',
|
||||
'pass',
|
||||
'gist',
|
||||
];
|
||||
|
||||
(async () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue