Skip to content

Commit b3ce99e

Browse files
authored
Add mode, depth and boxes arguments to Get Aria Snapshot #5101 (#5163)
Add mode, depth and boxes arguments and a parsed return type to Get Aria Snapshot (#5163) Get Aria Snapshot now exposes the three options that locator.ariaSnapshot() already offers in the pinned Playwright version, and gains a return type that makes a snapshot usable from a test without string parsing. New named-only arguments: - mode=ai returns the snapshot optimized for AI consumption. It adds element references such as [ref=e2], includes snapshots of iframes inside the target, and does not wait for a matching element: it fails immediately instead of running into the usual timeout. That last difference is documented on the keyword and covered by its own test. - depth limits the snapshot to the given number of tree levels. Values of zero or less are rejected with a ValueError. - boxes appends [box=x,y,width,height] to every node. New return type: return_type=dict loads Playwright's yaml as it is, which keeps role, name and every annotation inside the dictionary keys. boxes= and mode=ai made that worse by adding more annotations. return_type=parsed splits a snapshot into a tree of nodes with role, name, text, props and children. Annotations without a value, such as [selected], become True, [level=2] becomes an integer, and box uses the same keys as Get BoundingBox. The /url entry of a link is an annotation in Playwright rather than an element, so it becomes the url property of the link. Playwright's own parseAriaSnapshot is bundled but not exported, so the parsing happens on the Python side in Browser/utils/aria_snapshot.py. Everything is additive. The defaults reproduce the previous output byte for byte, yaml and dict are untouched, and no existing return value changed. Tests: unit tests for the parser covering escaped quotes, valueless annotations, text nodes, hoisted /url and unknown future label syntax, plus acceptance tests for every argument and return type. One acceptance test compares a complete parsed tree against a full expected structure on a page that exercises every node key in both of its shapes; it was mutation-tested against dropped children, swapped name and text, and an unstripped /url. Closes #5101
1 parent 35fe86a commit b3ce99e

8 files changed

Lines changed: 707 additions & 7 deletions

File tree

Browser/gen_stub.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ def parse_kw_stubs():
6969
from Browser.utils .data_types import (
7070
ClientCredential, MouseButton, KeyboardModifier, ScrollBehavior, ScrollBehavior, DialogAction, MouseButtonAction,
7171
NotSet, Dimensions, SizeFields, AreaFields, BoundingBoxFields, SelectionStrategy, ElementRole,
72-
AriaSnapshotReturnType, KeyboardInputAction, KeyAction, TextType
72+
AriaSnapshotMode, AriaSnapshotReturnType, KeyboardInputAction, KeyAction, TextType
7373
)
7474
from Browser.utils.types import Secret
7575
"""

Browser/keywords/getters.py

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,11 @@
3737
from ..base import LibraryComponent
3838
from ..generated.playwright_pb2 import Request
3939
from ..utils import keyword, logger
40+
from ..utils.aria_snapshot import parse_aria_snapshot
4041
from ..utils.data_types import (
4142
ROBOT_FRAMEWORK_BROWSER_NO_SET,
4243
AreaFields,
44+
AriaSnapshotMode,
4345
AriaSnapshotReturnType,
4446
BoundingBox,
4547
BoundingBoxFields,
@@ -69,18 +71,35 @@ def get_aria_snapshot(
6971
assertion_operator: AssertionOperator | None = None,
7072
assertion_expected: Any | None = None,
7173
message: str | None = None,
72-
) -> str | dict | tuple:
73-
"""Returns the aria snapshot of the element found by ``selector``.
74+
*,
75+
mode: AriaSnapshotMode = AriaSnapshotMode.default,
76+
depth: int | None = None,
77+
boxes: bool = False,
78+
) -> str | dict | list | tuple:
79+
"""Returns the aria snapshot of the element found by ``selector``. See `AriaSnapshotReturnType` for more details and examples.
7480
7581
| =Arguments= | =Description= |
7682
| ``selector`` | Selector from which the info is to be retrieved. See the `Finding elements` section for details about the selectors. |
77-
| ``return_type`` | Defines the return type. Possible values are ``yaml`` (default) and ``dict``. If ``yaml`` is selected, the returned value is a string in YAML format. If ``dict`` is selected, the returned value is a dictionary. |
83+
| ``return_type`` | Defines the return type. Possible values are ``yaml`` (default), ``dict`` and ``parsed``. If ``yaml`` is selected, the returned value is a string in YAML format. If ``dict`` is selected, the returned value is a dictionary. If ``parsed`` is selected, the returned value is a tree of node dictionaries. |
7884
| ``assertion_operator`` | See `Assertions` for further details. Defaults to None. |
7985
| ``assertion_expected`` | Expected value for the state |
8086
| ``message`` | overrides the default error message for assertion. |
87+
| ``mode`` | Defines the snapshot mode. Possible values are ``default`` (default) and ``ai``. See `AriaSnapshotMode` for more details. |
88+
| ``depth`` | Limits the snapshot to the given number of tree levels. Must be a positive integer. Defaults to ``None``, which does not limit the depth. |
89+
| ``boxes`` | If ``True``, the bounding box of each element is appended to its line as ``[box=x,y,width,height]``. Coordinates are relative to the viewport, in CSS pixels. Defaults to ``False``. |
8190
8291
Keyword uses strict mode, see `Finding elements` for more details about strict mode.
8392
93+
With ``mode=ai`` the snapshot is optimized for AI consumption: it contains element
94+
references like ``[ref=e2]`` and the content of iframes inside the element. It also
95+
does not wait for a matching element, but fails immediately when no element matches,
96+
instead of failing with a timeout like the ``default`` mode does.
97+
98+
With ``return_type=dict`` the YAML returned by Playwright is loaded as is. The
99+
``[ref=...]`` and ``[box=...]`` annotations are therefore part of the dictionary
100+
keys, not separate entries. Use ``return_type=parsed`` to get them as separate
101+
values of each node.
102+
84103
Optionally asserts that the snapshot matches the specified assertion. See
85104
`Assertions` for further details for the assertion arguments. By default assertion
86105
is not done.
@@ -93,16 +112,26 @@ def get_aria_snapshot(
93112
94113
[https://forum.robotframework.org/t//4303|Comment >>]
95114
"""
115+
if depth is not None and depth <= 0:
116+
raise ValueError(f"depth must be a positive integer, but got: {depth}")
96117
selector = self.presenter_mode(selector, self.strict_mode)
97118
with self.playwright.grpc_channel() as stub:
98119
response = stub.AriaSnapShot(
99-
Request.AriaSnapShot(locator=selector, strict=self.strict_mode)
120+
Request.AriaSnapShot(
121+
locator=selector,
122+
strict=self.strict_mode,
123+
mode="ai" if mode is AriaSnapshotMode.ai else "",
124+
depth=depth or 0,
125+
boxes=boxes,
126+
)
100127
)
101128
logger.info(response.log)
102129
value = response.body
103130
logger.info(f"Aria Snapshot: {value}")
104131
if return_type is AriaSnapshotReturnType.dict:
105132
value = yaml.safe_load(value) if value else {}
133+
elif return_type is AriaSnapshotReturnType.parsed:
134+
value = parse_aria_snapshot(value)
106135
formatter = self.get_assertion_formatter("Get Aria Snapshot")
107136
return verify_assertion(
108137
value,

Browser/utils/aria_snapshot.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# Copyright 2020- Robot Framework Foundation
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
import re
15+
from typing import Any
16+
17+
import yaml
18+
from robot.utils import DotDict
19+
20+
_LABEL = re.compile(
21+
r"""
22+
^(?P<role>\S+)
23+
(?:\s+"(?P<name>(?:[^"\\]|\\.)*)")?
24+
(?P<properties>(?:\s*\[[^\]]*\])*)
25+
\s*$
26+
""",
27+
re.VERBOSE,
28+
)
29+
_PROPERTY = re.compile(r"\[([^\]]*)\]")
30+
_INTEGER = re.compile(r"^-?\d+$")
31+
_BOX_FIELDS = ("x", "y", "width", "height")
32+
33+
34+
def parse_aria_snapshot(snapshot: str) -> list[DotDict]:
35+
"""Turns Playwright's aria snapshot YAML into a tree of node dictionaries.
36+
37+
Every node has the keys ``role``, ``name``, ``text``, ``props`` and
38+
``children``. Nodes are ``DotDict``s, so their values are reachable both as
39+
``node["role"]`` and as ``node.role``.
40+
"""
41+
if not snapshot:
42+
return []
43+
return _to_nodes(yaml.safe_load(snapshot))
44+
45+
46+
def _to_nodes(loaded: Any) -> list[DotDict]:
47+
if not loaded:
48+
return []
49+
return [_to_node(entry) for entry in loaded]
50+
51+
52+
def _to_node(entry: Any) -> DotDict:
53+
if isinstance(entry, dict):
54+
[(label, content)] = entry.items()
55+
return _node(label, content)
56+
return _node(entry, None)
57+
58+
59+
def _node(label: str, content: Any) -> DotDict:
60+
node = _parse_label(label)
61+
if isinstance(content, list):
62+
node.children = _hoist_properties(node, content)
63+
elif content is not None:
64+
node.text = str(content)
65+
return node
66+
67+
68+
def _hoist_properties(node: DotDict, content: list) -> list[DotDict]:
69+
"""Playwright renders link targets as a ``/url`` child, not as an element."""
70+
children = []
71+
for entry in content:
72+
if isinstance(entry, dict):
73+
[(label, value)] = entry.items()
74+
if label.startswith("/"):
75+
node.props[label[1:]] = value
76+
continue
77+
children.append(_to_node(entry))
78+
return children
79+
80+
81+
def _parse_label(label: str) -> DotDict:
82+
match = _LABEL.match(label)
83+
if not match:
84+
return DotDict(role=label, name=None, text=None, props=DotDict(), children=[])
85+
name = match["name"]
86+
return DotDict(
87+
role=match["role"],
88+
name=None if name is None else name.replace('\\"', '"'),
89+
text=None,
90+
props=_parse_properties(match["properties"]),
91+
children=[],
92+
)
93+
94+
95+
def _parse_properties(properties: str) -> DotDict:
96+
parsed = DotDict()
97+
for property_ in _PROPERTY.findall(properties):
98+
name, separator, value = property_.partition("=")
99+
parsed[name] = _parse_property_value(name, value) if separator else True
100+
return parsed
101+
102+
103+
def _parse_property_value(name: str, value: str) -> Any:
104+
if name == "box":
105+
coordinates = value.split(",")
106+
if len(coordinates) == len(_BOX_FIELDS):
107+
return DotDict(zip(_BOX_FIELDS, [int(c) for c in coordinates], strict=True))
108+
if _INTEGER.match(value):
109+
return int(value)
110+
return value

0 commit comments

Comments
 (0)