artest-browser 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- artest/browser/__init__.py +73 -0
- artest/browser/capture.py +456 -0
- artest/browser/decide.py +986 -0
- artest/browser/explorer.py +488 -0
- artest/browser/identity.py +31 -0
- artest/browser/js/axe.js +30 -0
- artest/browser/js/covers.js +435 -0
- artest/browser/js/elements.js +453 -0
- artest/browser/js/frame_solid.js +88 -0
- artest/browser/js/layout_shifts.js +65 -0
- artest/browser/js/patch.js +98 -0
- artest/browser/js/pixel_diff.js +54 -0
- artest/browser/js/validity.js +43 -0
- artest/browser/js/vendor/README.md +10 -0
- artest/browser/js/vendor/axe.min.js +12 -0
- artest/browser/js/vendor/web-vitals.iife.js +2 -0
- artest/browser/js/vitals.js +64 -0
- artest/browser/launch.py +55 -0
- artest/browser/measure.py +343 -0
- artest/browser/py.typed +0 -0
- artest/browser/render.py +240 -0
- artest/browser/robots.py +126 -0
- artest/browser/testing.py +50 -0
- artest/browser/thumbnails.py +35 -0
- artest/browser/trace.py +198 -0
- artest/browser/viewports.py +57 -0
- artest/browser/vision.py +578 -0
- artest_browser-0.2.0.dist-info/METADATA +21 -0
- artest_browser-0.2.0.dist-info/RECORD +31 -0
- artest_browser-0.2.0.dist-info/WHEEL +4 -0
- artest_browser-0.2.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Browser automation for Artest: funnel explorer, captures, vision tiles and variant rendering.
|
|
2
|
+
|
|
3
|
+
This package owns Playwright. The explorer and the variant renderer share one browser pool, as persona
|
|
4
|
+
sessions would, which are parked (ADR-0020).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from artest.browser.capture import BlobSink, capture_page
|
|
8
|
+
from artest.browser.decide import ExploreState, Move, MoveKind, Stop, next_move
|
|
9
|
+
from artest.browser.explorer import (
|
|
10
|
+
Decider,
|
|
11
|
+
ExploreOptions,
|
|
12
|
+
ExploreResult,
|
|
13
|
+
RobotsDisallowedError,
|
|
14
|
+
StuckWalkError,
|
|
15
|
+
explore_funnel,
|
|
16
|
+
)
|
|
17
|
+
from artest.browser.identity import SYNTHETIC_SHOPPER, ShopAccount, Shopper
|
|
18
|
+
from artest.browser.render import PixelDiff, RenderRefusedError, pixel_diff, render
|
|
19
|
+
from artest.browser.trace import FunnelTrace, TraceStep, perform, replay
|
|
20
|
+
from artest.browser.viewports import VIEWPORTS, new_context
|
|
21
|
+
from artest.browser.vision import (
|
|
22
|
+
Mark,
|
|
23
|
+
Tile,
|
|
24
|
+
TileError,
|
|
25
|
+
TilePlan,
|
|
26
|
+
TokenRule,
|
|
27
|
+
cut_tiles,
|
|
28
|
+
numbered_elements,
|
|
29
|
+
plan_screen,
|
|
30
|
+
plan_tiles,
|
|
31
|
+
render_tiles,
|
|
32
|
+
)
|
|
33
|
+
from artest.core import ExploreOutcome
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"SYNTHETIC_SHOPPER",
|
|
37
|
+
"VIEWPORTS",
|
|
38
|
+
"BlobSink",
|
|
39
|
+
"Decider",
|
|
40
|
+
"ExploreOptions",
|
|
41
|
+
"ExploreOutcome",
|
|
42
|
+
"ExploreResult",
|
|
43
|
+
"ExploreState",
|
|
44
|
+
"FunnelTrace",
|
|
45
|
+
"Mark",
|
|
46
|
+
"Move",
|
|
47
|
+
"MoveKind",
|
|
48
|
+
"PixelDiff",
|
|
49
|
+
"RenderRefusedError",
|
|
50
|
+
"RobotsDisallowedError",
|
|
51
|
+
"ShopAccount",
|
|
52
|
+
"Shopper",
|
|
53
|
+
"Stop",
|
|
54
|
+
"StuckWalkError",
|
|
55
|
+
"Tile",
|
|
56
|
+
"TileError",
|
|
57
|
+
"TilePlan",
|
|
58
|
+
"TokenRule",
|
|
59
|
+
"TraceStep",
|
|
60
|
+
"capture_page",
|
|
61
|
+
"cut_tiles",
|
|
62
|
+
"explore_funnel",
|
|
63
|
+
"new_context",
|
|
64
|
+
"next_move",
|
|
65
|
+
"numbered_elements",
|
|
66
|
+
"perform",
|
|
67
|
+
"pixel_diff",
|
|
68
|
+
"plan_screen",
|
|
69
|
+
"plan_tiles",
|
|
70
|
+
"render",
|
|
71
|
+
"render_tiles",
|
|
72
|
+
"replay",
|
|
73
|
+
]
|
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
"""Capture one page state: the record (screenshot, DOM, accessibility tree), the elements agents cite, and
|
|
2
|
+
what the browser measured on the page (see ``measure``)."""
|
|
3
|
+
|
|
4
|
+
from collections.abc import AsyncGenerator, Callable
|
|
5
|
+
from contextlib import asynccontextmanager, suppress
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from importlib.resources import files
|
|
9
|
+
from typing import Any, Protocol
|
|
10
|
+
from urllib.parse import urldefrag
|
|
11
|
+
|
|
12
|
+
from playwright.async_api import Error as PlaywrightError
|
|
13
|
+
from playwright.async_api import Page
|
|
14
|
+
from playwright.async_api import TimeoutError as PlaywrightTimeoutError
|
|
15
|
+
from pydantic import BaseModel, TypeAdapter
|
|
16
|
+
|
|
17
|
+
from artest.browser.measure import (
|
|
18
|
+
AxeRun,
|
|
19
|
+
Covers,
|
|
20
|
+
PageActivity,
|
|
21
|
+
PageMonitor,
|
|
22
|
+
check_accessibility,
|
|
23
|
+
check_covers,
|
|
24
|
+
own_resize,
|
|
25
|
+
)
|
|
26
|
+
from artest.core import (
|
|
27
|
+
ArtifactRef,
|
|
28
|
+
AxeImpact,
|
|
29
|
+
AxeReport,
|
|
30
|
+
AxeViolation,
|
|
31
|
+
Box,
|
|
32
|
+
Capture,
|
|
33
|
+
ElementRef,
|
|
34
|
+
FieldMessage,
|
|
35
|
+
FixedLayer,
|
|
36
|
+
FormProbe,
|
|
37
|
+
PastEdge,
|
|
38
|
+
Size,
|
|
39
|
+
ValidityReason,
|
|
40
|
+
Viewport,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
_ELEMENTS_SCRIPT = files("artest.browser").joinpath("js/elements.js").read_text("utf-8")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class BlobSink(Protocol):
|
|
47
|
+
"""Where captured bytes go. The engine passes the run's artifact store; tests pass memory."""
|
|
48
|
+
|
|
49
|
+
async def put(self, data: bytes, media_type: str) -> ArtifactRef: ...
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _MeasuredElement(BaseModel):
|
|
53
|
+
role: str
|
|
54
|
+
name: str
|
|
55
|
+
tag: str
|
|
56
|
+
text: str
|
|
57
|
+
selector: str
|
|
58
|
+
box: Box
|
|
59
|
+
visible: bool
|
|
60
|
+
wordsShown: str | None # noqa: N815 - the key the in-page script returns
|
|
61
|
+
blank: bool
|
|
62
|
+
fixed: bool
|
|
63
|
+
pastEdge: PastEdge | None # noqa: N815 - the key the in-page script returns
|
|
64
|
+
attributes: dict[str, str]
|
|
65
|
+
form: int | None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class _MeasuredFixed(BaseModel):
|
|
69
|
+
roots: list[str]
|
|
70
|
+
screen: Box
|
|
71
|
+
ratio: float
|
|
72
|
+
solid: list[Box]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class _Measured(BaseModel):
|
|
76
|
+
title: str
|
|
77
|
+
pageSize: Size # noqa: N815 - the key the in-page script returns
|
|
78
|
+
fixed: _MeasuredFixed | None
|
|
79
|
+
elements: list[_MeasuredElement]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
_measured = TypeAdapter(_Measured)
|
|
83
|
+
_ATTEMPTS = 3
|
|
84
|
+
_IDLE_PATIENCE = 15.0
|
|
85
|
+
"""Seconds a page gets to go idle before what it holds open reads as something that won't end."""
|
|
86
|
+
_SHOT: dict[str, Any] = {"type": "png", "animations": "disabled", "caret": "hide"}
|
|
87
|
+
_HAS_FOCUS = "() => ![null, document.body, document.documentElement].includes(document.activeElement)"
|
|
88
|
+
_ROOT_PAINTS = """() => [document.documentElement, document.body].some((node) => {
|
|
89
|
+
const { backgroundColor, backgroundImage } = getComputedStyle(node);
|
|
90
|
+
return backgroundImage !== "none" || !/^rgba\\(.*, 0\\)$|transparent/.test(backgroundColor);
|
|
91
|
+
})"""
|
|
92
|
+
_ADD_SHEET = """(css) => {
|
|
93
|
+
const sheet = new CSSStyleSheet();
|
|
94
|
+
sheet.replaceSync(css);
|
|
95
|
+
document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];
|
|
96
|
+
return sheet;
|
|
97
|
+
}"""
|
|
98
|
+
_TAKE_SHEET = """(sheet) => {
|
|
99
|
+
document.adoptedStyleSheets = document.adoptedStyleSheets.filter((other) => other !== sheet);
|
|
100
|
+
for (const animation of document.getAnimations()) {
|
|
101
|
+
if (animation instanceof CSSTransition && /^(visibility|opacity)$/.test(animation.transitionProperty)) {
|
|
102
|
+
animation.finish();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}"""
|
|
106
|
+
_FONTS_READY = (
|
|
107
|
+
"Promise.race([document.fonts.ready, new Promise((done) => setTimeout(done, 5000))]).then(() => true)"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class CaptureError(RuntimeError):
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True)
|
|
116
|
+
class EmptySubmit:
|
|
117
|
+
"""A form the explorer submitted empty, for the capture that shows what came of it."""
|
|
118
|
+
|
|
119
|
+
capture_id: str
|
|
120
|
+
form: str
|
|
121
|
+
"""The form's selector."""
|
|
122
|
+
browser_messages: tuple[tuple[str, ValidityReason, str], ...] = ()
|
|
123
|
+
"""The fields the browser's own validation flagged: each one's selector, why, and the message shown."""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True)
|
|
127
|
+
class _FixedShots:
|
|
128
|
+
page: bytes
|
|
129
|
+
layer: bytes
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True)
|
|
133
|
+
class _Read:
|
|
134
|
+
url: str
|
|
135
|
+
measured: _Measured
|
|
136
|
+
screenshot: bytes
|
|
137
|
+
dom: str
|
|
138
|
+
aria: str
|
|
139
|
+
axe: AxeRun | None
|
|
140
|
+
settled: bool
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass(frozen=True)
|
|
144
|
+
class PageRead:
|
|
145
|
+
"""All a capture read from one page. Nothing of it is stored yet, so that it can still be refused."""
|
|
146
|
+
|
|
147
|
+
read: _Read
|
|
148
|
+
covers: Covers
|
|
149
|
+
activity: PageActivity | None
|
|
150
|
+
size: Size
|
|
151
|
+
fixed_shots: _FixedShots | None = None
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def url(self) -> str:
|
|
155
|
+
return self.read.url
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def utc_now() -> datetime:
|
|
159
|
+
return datetime.now(UTC)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
async def settle(page: Page, monitor: PageMonitor | None = None) -> bool:
|
|
163
|
+
"""Wait until the page has stopped loading and its fonts are ready, so captures are stable, and say
|
|
164
|
+
whether it did.
|
|
165
|
+
|
|
166
|
+
A page can hold a request open for as long as it lives, as a chat widget's long poll does, and look
|
|
167
|
+
finished to a shopper. With a ``monitor`` to say what it is waiting for, such a page gets
|
|
168
|
+
``_IDLE_PATIENCE`` seconds and is then taken as it stands, which says False. What a page can't be drawn
|
|
169
|
+
without gets the browser's own wait, whose timeout ends the walk. Without a monitor, anything does.
|
|
170
|
+
"""
|
|
171
|
+
settled = await _went_idle(page, monitor) if monitor is not None else None
|
|
172
|
+
if monitor is None:
|
|
173
|
+
await page.wait_for_load_state("networkidle")
|
|
174
|
+
# With a time limit, as a font a page asked for after it went idle may never come.
|
|
175
|
+
await page.evaluate(_FONTS_READY)
|
|
176
|
+
return settled is not False
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
async def _went_idle(page: Page, monitor: PageMonitor) -> bool:
|
|
180
|
+
# One wait a document, as the capture after an action asks again. It may still have gone idle since.
|
|
181
|
+
patience = 0.1 if monitor.gave_up_waiting else None if monitor.waits_whole else _IDLE_PATIENCE
|
|
182
|
+
try:
|
|
183
|
+
if patience is not None:
|
|
184
|
+
try:
|
|
185
|
+
await page.wait_for_load_state("networkidle", timeout=patience * 1000)
|
|
186
|
+
except PlaywrightTimeoutError:
|
|
187
|
+
if monitor.still_needs_something():
|
|
188
|
+
await page.wait_for_load_state("networkidle")
|
|
189
|
+
else:
|
|
190
|
+
raise
|
|
191
|
+
else:
|
|
192
|
+
await page.wait_for_load_state("networkidle")
|
|
193
|
+
except PlaywrightTimeoutError:
|
|
194
|
+
if monitor.still_needs_something():
|
|
195
|
+
raise
|
|
196
|
+
monitor.gave_up_waiting = True
|
|
197
|
+
return False
|
|
198
|
+
return True
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
async def capture_page(
|
|
202
|
+
page: Page,
|
|
203
|
+
*,
|
|
204
|
+
capture_id: str,
|
|
205
|
+
viewport: Viewport,
|
|
206
|
+
sink: BlobSink,
|
|
207
|
+
clock: Callable[[], datetime] = utc_now,
|
|
208
|
+
monitor: PageMonitor | None = None,
|
|
209
|
+
probe: EmptySubmit | None = None,
|
|
210
|
+
) -> Capture:
|
|
211
|
+
"""Read the page once it has settled (see ``read_page``), and keep what was read (see ``keep_page``)."""
|
|
212
|
+
read = await read_page(page, monitor=monitor)
|
|
213
|
+
return await keep_page(
|
|
214
|
+
read, capture_id=capture_id, viewport=viewport, sink=sink, clock=clock, probe=probe
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
async def read_page(page: Page, *, monitor: PageMonitor | None = None) -> PageRead:
|
|
219
|
+
"""Read the page once it has settled.
|
|
220
|
+
|
|
221
|
+
A page can start navigating after it looked settled, say when a form posts and redirects a moment
|
|
222
|
+
after the click. A read that sees a navigation starts again on the page it lands on, so the
|
|
223
|
+
screenshot, DOM, elements and accessibility check always come from one page. A new #fragment, as a
|
|
224
|
+
carousel may set, is the same page. Web Vitals, console and network come from ``monitor``; without
|
|
225
|
+
one, the capture has none.
|
|
226
|
+
"""
|
|
227
|
+
for _ in range(_ATTEMPTS):
|
|
228
|
+
url = page.url
|
|
229
|
+
try:
|
|
230
|
+
read = await _read(page, monitor)
|
|
231
|
+
if read is None or urldefrag(read.url).url != urldefrag(url).url:
|
|
232
|
+
continue
|
|
233
|
+
covers = await _covers(page, read.measured)
|
|
234
|
+
# A navigation doesn't always say so in what it breaks, so what the probe missed has to be asked.
|
|
235
|
+
if urldefrag(page.url).url != urldefrag(url).url:
|
|
236
|
+
continue
|
|
237
|
+
activity = await monitor.take() if monitor is not None else None
|
|
238
|
+
except PlaywrightError as error:
|
|
239
|
+
# Whatever goes wrong on a page that has since moved on says nothing about where it is now:
|
|
240
|
+
# a screenshot that a new document overtakes can sit out its whole timeout.
|
|
241
|
+
if "navigat" not in error.message and urldefrag(page.url).url == urldefrag(url).url:
|
|
242
|
+
raise
|
|
243
|
+
continue
|
|
244
|
+
size = page.viewport_size or {"width": 0, "height": 0}
|
|
245
|
+
shots = await _shoot_fixed(page, read.measured.fixed, read.screenshot)
|
|
246
|
+
# What the shots hold is only worth having if they are of this page.
|
|
247
|
+
same = urldefrag(page.url).url == urldefrag(url).url
|
|
248
|
+
return PageRead(
|
|
249
|
+
read, covers, activity, Size(width=size["width"], height=size["height"]), shots if same else None
|
|
250
|
+
)
|
|
251
|
+
msg = f"{page.url} kept navigating while it was captured"
|
|
252
|
+
raise CaptureError(msg)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
async def _read(page: Page, monitor: PageMonitor | None) -> _Read | None:
|
|
256
|
+
"""Read the page, or nothing if it has moved on by the time of the screenshot: painting a page before its
|
|
257
|
+
stylesheets have come would make the styled page read as a shift no shopper sees."""
|
|
258
|
+
start = urldefrag(page.url).url
|
|
259
|
+
settled = await settle(page, monitor)
|
|
260
|
+
screen = page.viewport_size
|
|
261
|
+
measured = _measured.validate_python(
|
|
262
|
+
await page.evaluate(_ELEMENTS_SCRIPT, screen["width"] if screen is not None else None)
|
|
263
|
+
)
|
|
264
|
+
if urldefrag(page.url).url != start:
|
|
265
|
+
return None
|
|
266
|
+
async with own_resize(page):
|
|
267
|
+
screenshot = await page.screenshot(**_SHOT, full_page=True)
|
|
268
|
+
dom = await page.content()
|
|
269
|
+
aria = await page.locator("body").aria_snapshot()
|
|
270
|
+
# Last, so that nothing axe-core does to look at the page can show in the record.
|
|
271
|
+
try:
|
|
272
|
+
axe = await check_accessibility(page, [element.selector for element in measured.elements])
|
|
273
|
+
except PlaywrightError as error:
|
|
274
|
+
# A navigation starts the capture again; a page axe-core can't check is left unchecked.
|
|
275
|
+
if "navigat" in error.message:
|
|
276
|
+
raise
|
|
277
|
+
axe = None
|
|
278
|
+
return _Read(
|
|
279
|
+
url=page.url,
|
|
280
|
+
measured=measured,
|
|
281
|
+
screenshot=screenshot,
|
|
282
|
+
dom=dom,
|
|
283
|
+
aria=aria,
|
|
284
|
+
axe=axe,
|
|
285
|
+
settled=settled,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
async def _shoot_fixed(page: Page, fixed: _MeasuredFixed | None, whole: bytes) -> _FixedShots | None:
|
|
290
|
+
"""The page without what is fixed to the screen, and one screen of nothing else, for drawing tiles.
|
|
291
|
+
|
|
292
|
+
It comes last in a read, as hiding things can start what the page does about that, so nothing else read
|
|
293
|
+
from the page can show it. Hiding what has focus would make the page think a shopper left a field, so a
|
|
294
|
+
page with something focused is drawn as the record draws it. So is one a style sheet can't clear, one that
|
|
295
|
+
changes size meanwhile, and a phone's page laid out wider than the phone's screen, whose screen isn't what
|
|
296
|
+
it measured. Nothing here can cost the capture what it has.
|
|
297
|
+
"""
|
|
298
|
+
if fixed is None:
|
|
299
|
+
return None
|
|
300
|
+
roots = ", ".join(fixed.roots)
|
|
301
|
+
within = ", ".join(f"{root} *" for root in fixed.roots)
|
|
302
|
+
only = (
|
|
303
|
+
"html:root:root:root, html:root:root:root > body "
|
|
304
|
+
"{ background: transparent !important; transition: none !important; } "
|
|
305
|
+
f"*:not({roots}, {within}) {{ visibility: hidden !important; }} "
|
|
306
|
+
# A root shows again by inheriting its own, as what its page hides inside it stays hidden.
|
|
307
|
+
f"{roots} {{ visibility: visible !important; }}"
|
|
308
|
+
)
|
|
309
|
+
try:
|
|
310
|
+
if await page.evaluate(_HAS_FOCUS):
|
|
311
|
+
return None
|
|
312
|
+
hidden = f"html:root:root:root :is({roots}) {{ opacity: 0 !important; }}"
|
|
313
|
+
async with _style_sheet(page, hidden), own_resize(page):
|
|
314
|
+
without = await page.screenshot(**_SHOT, full_page=True)
|
|
315
|
+
async with _style_sheet(page, only):
|
|
316
|
+
if await page.evaluate(_ROOT_PAINTS):
|
|
317
|
+
return None
|
|
318
|
+
alone = await page.screenshot(**_SHOT, omit_background=True)
|
|
319
|
+
except PlaywrightError:
|
|
320
|
+
return None
|
|
321
|
+
screen = (round(fixed.screen.width * fixed.ratio), round(fixed.screen.height * fixed.ratio))
|
|
322
|
+
if _png_size(without) != _png_size(whole) or _png_size(alone) != screen:
|
|
323
|
+
return None
|
|
324
|
+
return _FixedShots(without, alone)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@asynccontextmanager
|
|
328
|
+
async def _style_sheet(page: Page, css: str) -> AsyncGenerator[None]:
|
|
329
|
+
"""A style sheet on the page's own document for a moment: it reaches into neither frames nor shadow roots,
|
|
330
|
+
and adds nothing a page's observers can see."""
|
|
331
|
+
sheet = await page.evaluate_handle(_ADD_SHEET, css)
|
|
332
|
+
try:
|
|
333
|
+
yield
|
|
334
|
+
finally:
|
|
335
|
+
with suppress(PlaywrightError):
|
|
336
|
+
await sheet.evaluate(_TAKE_SHEET)
|
|
337
|
+
await sheet.dispose()
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _png_size(png: bytes) -> tuple[int, int]:
|
|
341
|
+
"""A PNG's width and height, from its header."""
|
|
342
|
+
return int.from_bytes(png[16:20]), int.from_bytes(png[20:24])
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
async def _covers(page: Page, measured: _Measured) -> Covers:
|
|
346
|
+
"""What is drawn over the elements. It moves the page for a moment, and forces a layout a page still
|
|
347
|
+
loading wouldn't have painted, which would read as a shift, so it only runs once a read has held."""
|
|
348
|
+
try:
|
|
349
|
+
return await check_covers(
|
|
350
|
+
page, [element.selector if element.visible else None for element in measured.elements]
|
|
351
|
+
)
|
|
352
|
+
except PlaywrightError as error:
|
|
353
|
+
if "navigat" in error.message:
|
|
354
|
+
raise
|
|
355
|
+
return Covers()
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
async def keep_page(
|
|
359
|
+
page: PageRead,
|
|
360
|
+
*,
|
|
361
|
+
capture_id: str,
|
|
362
|
+
viewport: Viewport,
|
|
363
|
+
sink: BlobSink,
|
|
364
|
+
clock: Callable[[], datetime] = utc_now,
|
|
365
|
+
probe: EmptySubmit | None = None,
|
|
366
|
+
) -> Capture:
|
|
367
|
+
"""Store what was read, as a capture. ``probe`` is the empty submit that led to the page, if one did."""
|
|
368
|
+
read, covers, activity = page.read, page.covers, page.activity
|
|
369
|
+
measured = read.measured
|
|
370
|
+
eids = [f"{capture_id}:e{index}" for index in range(1, len(measured.elements) + 1)]
|
|
371
|
+
covered, overlaid = set(covers.covered), set(covers.overlaid)
|
|
372
|
+
elements = tuple(
|
|
373
|
+
ElementRef(
|
|
374
|
+
eid=eid,
|
|
375
|
+
role=raw.role,
|
|
376
|
+
name=raw.name,
|
|
377
|
+
tag=raw.tag,
|
|
378
|
+
text=raw.text,
|
|
379
|
+
selector=raw.selector,
|
|
380
|
+
box=raw.box,
|
|
381
|
+
visible=raw.visible,
|
|
382
|
+
fixed=raw.fixed,
|
|
383
|
+
past_edge=raw.pastEdge,
|
|
384
|
+
covered=raw.blank or index in covered,
|
|
385
|
+
overlaid=index in overlaid,
|
|
386
|
+
words_shown=raw.wordsShown,
|
|
387
|
+
attributes=raw.attributes,
|
|
388
|
+
form=eids[raw.form] if raw.form is not None else None,
|
|
389
|
+
)
|
|
390
|
+
for index, (eid, raw) in enumerate(zip(eids, measured.elements, strict=True))
|
|
391
|
+
)
|
|
392
|
+
return Capture(
|
|
393
|
+
id=capture_id,
|
|
394
|
+
url=read.url,
|
|
395
|
+
title=measured.title,
|
|
396
|
+
viewport=viewport,
|
|
397
|
+
viewport_size=page.size,
|
|
398
|
+
page_size=measured.pageSize,
|
|
399
|
+
captured_at=clock(),
|
|
400
|
+
screenshot=await sink.put(read.screenshot, "image/png"),
|
|
401
|
+
dom=await sink.put(read.dom.encode(), "text/html"),
|
|
402
|
+
aria=await sink.put(read.aria.encode(), "text/yaml"),
|
|
403
|
+
elements=elements,
|
|
404
|
+
vitals=activity.vitals if activity is not None else None,
|
|
405
|
+
network=activity.network if activity is not None else None,
|
|
406
|
+
console=activity.console if activity is not None else None,
|
|
407
|
+
axe=_axe_report(read.axe, eids) if read.axe is not None else None,
|
|
408
|
+
probe=_form_probe(probe, elements) if probe is not None else None,
|
|
409
|
+
settled=read.settled,
|
|
410
|
+
fixed_layer=await _fixed_layer(page.fixed_shots, measured.fixed, sink),
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
async def _fixed_layer(
|
|
415
|
+
shots: _FixedShots | None, fixed: _MeasuredFixed | None, sink: BlobSink
|
|
416
|
+
) -> FixedLayer | None:
|
|
417
|
+
if shots is None or fixed is None:
|
|
418
|
+
return None
|
|
419
|
+
return FixedLayer(
|
|
420
|
+
page=await sink.put(shots.page, "image/png"),
|
|
421
|
+
layer=await sink.put(shots.layer, "image/png"),
|
|
422
|
+
screen=fixed.screen,
|
|
423
|
+
solid=tuple(fixed.solid),
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _axe_report(run: AxeRun, eids: list[str]) -> AxeReport:
|
|
428
|
+
return AxeReport(
|
|
429
|
+
version=run.version,
|
|
430
|
+
violations=tuple(
|
|
431
|
+
AxeViolation(
|
|
432
|
+
rule=found.rule,
|
|
433
|
+
impact=AxeImpact(found.impact) if found.impact is not None else None,
|
|
434
|
+
description=found.description,
|
|
435
|
+
help_url=found.helpUrl,
|
|
436
|
+
tags=tuple(found.tags),
|
|
437
|
+
nodes=found.nodes,
|
|
438
|
+
elements=tuple(eids[position] for position in found.elements),
|
|
439
|
+
)
|
|
440
|
+
for found in run.violations
|
|
441
|
+
),
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _form_probe(probe: EmptySubmit, elements: tuple[ElementRef, ...]) -> FormProbe:
|
|
446
|
+
"""The probe in this capture's terms. A field the page no longer has keeps no message."""
|
|
447
|
+
by_selector = {element.selector: element.eid for element in reversed(elements)}
|
|
448
|
+
return FormProbe(
|
|
449
|
+
submitted_on=probe.capture_id,
|
|
450
|
+
form=by_selector.get(probe.form),
|
|
451
|
+
browser_messages=tuple(
|
|
452
|
+
FieldMessage(field=by_selector[selector], reason=reason, message=message[:300])
|
|
453
|
+
for selector, reason, message in probe.browser_messages
|
|
454
|
+
if selector in by_selector
|
|
455
|
+
),
|
|
456
|
+
)
|