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.
@@ -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
+ )