artest-core 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/core/__init__.py +158 -0
- artest/core/artifacts.py +19 -0
- artest/core/base.py +17 -0
- artest/core/capture.py +323 -0
- artest/core/events.py +125 -0
- artest/core/findings.py +179 -0
- artest/core/funnel.py +229 -0
- artest/core/geometry.py +54 -0
- artest/core/ids.py +26 -0
- artest/core/links.py +21 -0
- artest/core/plugins.py +117 -0
- artest/core/product.json +9 -0
- artest/core/product.py +18 -0
- artest/core/py.typed +0 -0
- artest/core/testing.py +134 -0
- artest/core/variants.py +23 -0
- artest_core-0.2.0.dist-info/METADATA +19 -0
- artest_core-0.2.0.dist-info/RECORD +20 -0
- artest_core-0.2.0.dist-info/WHEEL +4 -0
- artest_core-0.2.0.dist-info/licenses/LICENSE +202 -0
artest/core/__init__.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Domain models, run events and plugin protocols for Artest.
|
|
2
|
+
|
|
3
|
+
Every pipeline stage reads and writes these types, and the TypeScript contract is generated from
|
|
4
|
+
them. This package depends on pydantic only, so plugin authors can depend on it cheaply.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from artest.core.artifacts import ArtifactRef
|
|
8
|
+
from artest.core.capture import (
|
|
9
|
+
AxeImpact,
|
|
10
|
+
AxeReport,
|
|
11
|
+
AxeViolation,
|
|
12
|
+
Capture,
|
|
13
|
+
ConsoleEntry,
|
|
14
|
+
ConsoleLevel,
|
|
15
|
+
ElementRef,
|
|
16
|
+
FieldMessage,
|
|
17
|
+
FixedLayer,
|
|
18
|
+
FormProbe,
|
|
19
|
+
NetworkSummary,
|
|
20
|
+
PastEdge,
|
|
21
|
+
ResourceType,
|
|
22
|
+
ResourceWeight,
|
|
23
|
+
ValidityReason,
|
|
24
|
+
Viewport,
|
|
25
|
+
WebVitals,
|
|
26
|
+
)
|
|
27
|
+
from artest.core.events import (
|
|
28
|
+
DomainEvent,
|
|
29
|
+
ExplorerActed,
|
|
30
|
+
FindingMerged,
|
|
31
|
+
FindingRejected,
|
|
32
|
+
FindingReported,
|
|
33
|
+
FunnelModelBuilt,
|
|
34
|
+
PageCaptured,
|
|
35
|
+
StepLeftOut,
|
|
36
|
+
TrainingProvidersAllowed,
|
|
37
|
+
WalkEnded,
|
|
38
|
+
domain_event,
|
|
39
|
+
)
|
|
40
|
+
from artest.core.findings import (
|
|
41
|
+
Claim,
|
|
42
|
+
Evidence,
|
|
43
|
+
Finding,
|
|
44
|
+
FindingCategory,
|
|
45
|
+
FindingSource,
|
|
46
|
+
RejectionReason,
|
|
47
|
+
Report,
|
|
48
|
+
Severity,
|
|
49
|
+
SourceKind,
|
|
50
|
+
)
|
|
51
|
+
from artest.core.funnel import (
|
|
52
|
+
Action,
|
|
53
|
+
ActionKind,
|
|
54
|
+
Cta,
|
|
55
|
+
ExploreOutcome,
|
|
56
|
+
Form,
|
|
57
|
+
FormField,
|
|
58
|
+
FunnelFacts,
|
|
59
|
+
FunnelGraph,
|
|
60
|
+
FunnelStep,
|
|
61
|
+
MessageKind,
|
|
62
|
+
PageMessage,
|
|
63
|
+
PriceKind,
|
|
64
|
+
PriceMention,
|
|
65
|
+
StepKind,
|
|
66
|
+
StepView,
|
|
67
|
+
Transition,
|
|
68
|
+
TrustKind,
|
|
69
|
+
TrustSignal,
|
|
70
|
+
)
|
|
71
|
+
from artest.core.geometry import Box, Size
|
|
72
|
+
from artest.core.ids import CaptureId, ElementId, FindingId, StepId
|
|
73
|
+
from artest.core.links import link_address
|
|
74
|
+
from artest.core.plugins import RULE_API_VERSION, RULES_GROUP, Rule, RuleClaim, RuleContext, StepPage
|
|
75
|
+
from artest.core.product import PRODUCT, Product
|
|
76
|
+
from artest.core.variants import DomPatch
|
|
77
|
+
|
|
78
|
+
CONTRACT_VERSION = "0.18.0"
|
|
79
|
+
"""Semver of the data contract shared with the web app and run bundles (ADR-0015).
|
|
80
|
+
|
|
81
|
+
Additive changes bump the minor version. Removing or renaming anything bumps the major version.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
__all__ = [
|
|
85
|
+
"CONTRACT_VERSION",
|
|
86
|
+
"PRODUCT",
|
|
87
|
+
"RULES_GROUP",
|
|
88
|
+
"RULE_API_VERSION",
|
|
89
|
+
"Action",
|
|
90
|
+
"ActionKind",
|
|
91
|
+
"ArtifactRef",
|
|
92
|
+
"AxeImpact",
|
|
93
|
+
"AxeReport",
|
|
94
|
+
"AxeViolation",
|
|
95
|
+
"Box",
|
|
96
|
+
"Capture",
|
|
97
|
+
"CaptureId",
|
|
98
|
+
"Claim",
|
|
99
|
+
"ConsoleEntry",
|
|
100
|
+
"ConsoleLevel",
|
|
101
|
+
"Cta",
|
|
102
|
+
"DomPatch",
|
|
103
|
+
"DomainEvent",
|
|
104
|
+
"ElementId",
|
|
105
|
+
"ElementRef",
|
|
106
|
+
"Evidence",
|
|
107
|
+
"ExploreOutcome",
|
|
108
|
+
"ExplorerActed",
|
|
109
|
+
"FieldMessage",
|
|
110
|
+
"Finding",
|
|
111
|
+
"FindingCategory",
|
|
112
|
+
"FindingId",
|
|
113
|
+
"FindingMerged",
|
|
114
|
+
"FindingRejected",
|
|
115
|
+
"FindingReported",
|
|
116
|
+
"FindingSource",
|
|
117
|
+
"FixedLayer",
|
|
118
|
+
"Form",
|
|
119
|
+
"FormField",
|
|
120
|
+
"FormProbe",
|
|
121
|
+
"FunnelFacts",
|
|
122
|
+
"FunnelGraph",
|
|
123
|
+
"FunnelModelBuilt",
|
|
124
|
+
"FunnelStep",
|
|
125
|
+
"MessageKind",
|
|
126
|
+
"NetworkSummary",
|
|
127
|
+
"PageCaptured",
|
|
128
|
+
"PageMessage",
|
|
129
|
+
"PastEdge",
|
|
130
|
+
"PriceKind",
|
|
131
|
+
"PriceMention",
|
|
132
|
+
"Product",
|
|
133
|
+
"RejectionReason",
|
|
134
|
+
"Report",
|
|
135
|
+
"ResourceType",
|
|
136
|
+
"ResourceWeight",
|
|
137
|
+
"Rule",
|
|
138
|
+
"RuleClaim",
|
|
139
|
+
"RuleContext",
|
|
140
|
+
"Severity",
|
|
141
|
+
"Size",
|
|
142
|
+
"SourceKind",
|
|
143
|
+
"StepId",
|
|
144
|
+
"StepKind",
|
|
145
|
+
"StepLeftOut",
|
|
146
|
+
"StepPage",
|
|
147
|
+
"StepView",
|
|
148
|
+
"TrainingProvidersAllowed",
|
|
149
|
+
"Transition",
|
|
150
|
+
"TrustKind",
|
|
151
|
+
"TrustSignal",
|
|
152
|
+
"ValidityReason",
|
|
153
|
+
"Viewport",
|
|
154
|
+
"WalkEnded",
|
|
155
|
+
"WebVitals",
|
|
156
|
+
"domain_event",
|
|
157
|
+
"link_address",
|
|
158
|
+
]
|
artest/core/artifacts.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
|
|
3
|
+
from pydantic import Field, StringConstraints
|
|
4
|
+
|
|
5
|
+
from artest.core.base import CoreModel
|
|
6
|
+
|
|
7
|
+
Sha256 = Annotated[str, StringConstraints(pattern=r"^[0-9a-f]{64}$")]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ArtifactRef(CoreModel):
|
|
11
|
+
"""A pointer to immutable content in the artifact store, addressed by its SHA-256.
|
|
12
|
+
|
|
13
|
+
Events carry these references instead of inlining screenshots or DOM snapshots, so the journal stays
|
|
14
|
+
small and the same bytes are stored once however often they're referenced.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
sha256: Sha256
|
|
18
|
+
media_type: str = Field(pattern=r"^[a-z]+/[a-z0-9.+-]+$")
|
|
19
|
+
size: int = Field(ge=0)
|
artest/core/base.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from pydantic import BaseModel, ConfigDict
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CoreModel(BaseModel):
|
|
5
|
+
"""Base for every contract model.
|
|
6
|
+
|
|
7
|
+
Models are immutable, so a value read from the journal can't be changed by accident, and strict
|
|
8
|
+
about unknown fields, so a typo or an invented field fails loudly instead of being dropped. Fields
|
|
9
|
+
with defaults are always serialised, so the generated TypeScript marks them as required too.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
model_config = ConfigDict(
|
|
13
|
+
frozen=True,
|
|
14
|
+
extra="forbid",
|
|
15
|
+
allow_inf_nan=False,
|
|
16
|
+
json_schema_serialization_defaults_required=True,
|
|
17
|
+
)
|
artest/core/capture.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
from enum import StrEnum
|
|
2
|
+
from typing import Self
|
|
3
|
+
|
|
4
|
+
from pydantic import AwareDatetime, Field, model_validator
|
|
5
|
+
|
|
6
|
+
from artest.core.artifacts import ArtifactRef
|
|
7
|
+
from artest.core.base import CoreModel
|
|
8
|
+
from artest.core.geometry import Box, Size
|
|
9
|
+
from artest.core.ids import CaptureId, ElementId, capture_of
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Viewport(StrEnum):
|
|
13
|
+
DESKTOP = "desktop"
|
|
14
|
+
MOBILE = "mobile"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PastEdge(StrEnum):
|
|
18
|
+
"""How a shopper gets at what reaches past the screen's right edge."""
|
|
19
|
+
|
|
20
|
+
SCROLLS = "scrolls"
|
|
21
|
+
"""A box around it scrolls sideways, with room to."""
|
|
22
|
+
CLIPPED = "clipped"
|
|
23
|
+
"""Something cuts it off, so no scrolling brings it on the screen."""
|
|
24
|
+
PAGE = "page"
|
|
25
|
+
"""Nothing clips it, so the page itself goes on sideways."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ElementRef(CoreModel):
|
|
29
|
+
"""An element of a capture that an agent may cite as evidence.
|
|
30
|
+
|
|
31
|
+
The selector and box are measured by the browser. A model only ever refers to the element by ``eid``.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
eid: ElementId
|
|
35
|
+
role: str
|
|
36
|
+
name: str = Field(max_length=300, description="Accessible name")
|
|
37
|
+
tag: str
|
|
38
|
+
text: str = Field(default="", max_length=500, description="Visible text, truncated")
|
|
39
|
+
selector: str
|
|
40
|
+
box: Box
|
|
41
|
+
visible: bool
|
|
42
|
+
covered: bool = Field(
|
|
43
|
+
default=False,
|
|
44
|
+
description="As the page stands, nothing of it shows at any scroll position a shopper can reach: "
|
|
45
|
+
"something solid is drawn over it wherever it is on the screen, it is clipped away where it is, or "
|
|
46
|
+
"it is see-through, or holds only words a shopper can't read and shows nothing else. A sticky bar it "
|
|
47
|
+
"scrolls clear of doesn't count. Where the browser can't say, and in captures from before this was "
|
|
48
|
+
"measured, it reads False",
|
|
49
|
+
)
|
|
50
|
+
words_shown: str | None = Field(
|
|
51
|
+
default=None,
|
|
52
|
+
max_length=500,
|
|
53
|
+
description="Of its text, the words a shopper can read, where that isn't all of them: others are "
|
|
54
|
+
"see-through, tiny, moved off the page or clipped away, though the browser lays them out. Empty if "
|
|
55
|
+
"none can be read, though it may still show, by a picture or a background. None if all can, and in "
|
|
56
|
+
"captures from before this was measured. Only what agents are told and may quote goes by it",
|
|
57
|
+
)
|
|
58
|
+
overlaid: bool = Field(
|
|
59
|
+
default=False,
|
|
60
|
+
description="Something solid is drawn over most of it in the capture's screenshot, such as a popup "
|
|
61
|
+
"fixed to the screen. Unless it is also covered, a shopper can scroll it clear",
|
|
62
|
+
)
|
|
63
|
+
fixed: bool = Field(
|
|
64
|
+
default=False,
|
|
65
|
+
description="It keeps its place on the screen however far the page scrolls, and the capture's fixed "
|
|
66
|
+
"layer draws it. In captures from before this was measured, it reads False",
|
|
67
|
+
)
|
|
68
|
+
past_edge: PastEdge | None = Field(
|
|
69
|
+
default=None,
|
|
70
|
+
description="For an element reaching past the screen's right edge, how a shopper gets at the rest of "
|
|
71
|
+
"it. None for any other, and in captures from before this was measured, which don't say",
|
|
72
|
+
)
|
|
73
|
+
attributes: dict[str, str] = Field(
|
|
74
|
+
default_factory=dict[str, str],
|
|
75
|
+
description="The attributes rules care about, such as type, autocomplete, required and href",
|
|
76
|
+
)
|
|
77
|
+
form: ElementId | None = Field(default=None, description="The form this control belongs to, if any")
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def capture_id(self) -> str:
|
|
81
|
+
return capture_of(self.eid)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class WebVitals(CoreModel):
|
|
85
|
+
"""Lab Web Vitals of the page a capture shows, measured by the web-vitals library in the browser.
|
|
86
|
+
|
|
87
|
+
They come from one visit on the explorer's machine and connection, with nothing cached, so they
|
|
88
|
+
show how a page behaves, not what shoppers experience. A page reached by client-side routing is a
|
|
89
|
+
soft navigation: its paints count from the click that routed to it, it made no request of its own,
|
|
90
|
+
and its layout shifts start afresh. A metric the page gave no reading for is None.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
navigation_url: str = Field(
|
|
94
|
+
pattern=r"^https?://",
|
|
95
|
+
description="Where the measured navigation went; another page than the capture's when the page "
|
|
96
|
+
"changed its address without a navigation the browser recognised",
|
|
97
|
+
)
|
|
98
|
+
soft_navigation: bool = Field(description="Whether the page was reached by client-side routing")
|
|
99
|
+
ttfb_ms: float | None = Field(ge=0, description="Time to first byte; None after a soft navigation")
|
|
100
|
+
fcp_ms: float | None = Field(ge=0, description="First Contentful Paint")
|
|
101
|
+
lcp_ms: float | None = Field(ge=0, description="Largest Contentful Paint")
|
|
102
|
+
cls: float | None = Field(ge=0, description="Cumulative Layout Shift, unitless")
|
|
103
|
+
inp_lab_ms: float | None = Field(
|
|
104
|
+
ge=0,
|
|
105
|
+
description="Lab INP: the slowest response, from input to the next paint, to the explorer's own "
|
|
106
|
+
"clicks on this page since the previous capture. Field INP comes from real shoppers' "
|
|
107
|
+
"interactions and can differ a lot. 16 for a click answered within 16 ms, the shortest time the "
|
|
108
|
+
"browser reports. None when there was no such click. A click that loads another document can't be "
|
|
109
|
+
"timed: its page is gone before the next paint",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class ResourceType(StrEnum):
|
|
114
|
+
DOCUMENT = "document"
|
|
115
|
+
SCRIPT = "script"
|
|
116
|
+
STYLESHEET = "stylesheet"
|
|
117
|
+
IMAGE = "image"
|
|
118
|
+
FONT = "font"
|
|
119
|
+
MEDIA = "media"
|
|
120
|
+
FETCH = "fetch"
|
|
121
|
+
OTHER = "other"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class ResourceWeight(CoreModel):
|
|
125
|
+
requests: int = Field(ge=0)
|
|
126
|
+
transfer_bytes: int = Field(ge=0, description="Response headers and bodies as received, compressed")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class NetworkSummary(CoreModel):
|
|
130
|
+
"""What the page loaded over the network since the previous capture, from its own document on.
|
|
131
|
+
|
|
132
|
+
The explorer's browser keeps no HTTP cache, so a page loads everything it needs as on a first
|
|
133
|
+
visit. A request still running at the capture is counted with the next capture of the same document.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
requests: int = Field(ge=0)
|
|
137
|
+
transfer_bytes: int = Field(ge=0, description="Response headers and bodies as received, compressed")
|
|
138
|
+
failed: int = Field(
|
|
139
|
+
ge=0,
|
|
140
|
+
description="Requests that got an HTTP error status, or no response for a reason other than "
|
|
141
|
+
"the page cancelling them",
|
|
142
|
+
)
|
|
143
|
+
by_type: dict[ResourceType, ResourceWeight] = Field(default_factory=dict[ResourceType, ResourceWeight])
|
|
144
|
+
|
|
145
|
+
@model_validator(mode="after")
|
|
146
|
+
def _totals_add_up(self) -> Self:
|
|
147
|
+
weights = self.by_type.values()
|
|
148
|
+
if (self.requests, self.transfer_bytes) != (
|
|
149
|
+
sum(weight.requests for weight in weights),
|
|
150
|
+
sum(weight.transfer_bytes for weight in weights),
|
|
151
|
+
):
|
|
152
|
+
msg = "the network totals must add up to the weights by type"
|
|
153
|
+
raise ValueError(msg)
|
|
154
|
+
return self
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class ConsoleLevel(StrEnum):
|
|
158
|
+
ERROR = "error"
|
|
159
|
+
WARNING = "warning"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class ConsoleEntry(CoreModel):
|
|
163
|
+
"""A console error or warning, or an exception nothing on the page caught."""
|
|
164
|
+
|
|
165
|
+
level: ConsoleLevel
|
|
166
|
+
text: str = Field(max_length=500, description="The message, truncated")
|
|
167
|
+
uncaught: bool = Field(default=False, description="Whether it is an exception nothing caught")
|
|
168
|
+
url: str | None = Field(default=None, description="The script or resource it came from, if known")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class AxeImpact(StrEnum):
|
|
172
|
+
MINOR = "minor"
|
|
173
|
+
MODERATE = "moderate"
|
|
174
|
+
SERIOUS = "serious"
|
|
175
|
+
CRITICAL = "critical"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class AxeViolation(CoreModel):
|
|
179
|
+
rule: str = Field(description="The axe-core rule ID, such as color-contrast")
|
|
180
|
+
impact: AxeImpact | None
|
|
181
|
+
description: str = Field(description="What the rule asks for, in axe-core's words")
|
|
182
|
+
help_url: str
|
|
183
|
+
tags: tuple[str, ...] = Field(description="axe-core's tags, such as wcag2aa and wcag143")
|
|
184
|
+
nodes: int = Field(ge=1, description="How many elements on the page fail the rule")
|
|
185
|
+
elements: tuple[ElementId, ...] = Field(
|
|
186
|
+
default=(),
|
|
187
|
+
description="The failing elements that are in the capture's element list, which leaves out "
|
|
188
|
+
"containers such as a list's",
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class AxeReport(CoreModel):
|
|
193
|
+
"""What axe-core found wrong on the page, as the capture saw it. Frames on the page aren't checked."""
|
|
194
|
+
|
|
195
|
+
version: str = Field(description="The axe-core version that checked the page")
|
|
196
|
+
violations: tuple[AxeViolation, ...] = ()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class ValidityReason(StrEnum):
|
|
200
|
+
"""Why the browser's own validation flagged a field, after the browser's ValidityState."""
|
|
201
|
+
|
|
202
|
+
VALUE_MISSING = "value-missing"
|
|
203
|
+
CUSTOM_ERROR = "custom-error"
|
|
204
|
+
TYPE_MISMATCH = "type-mismatch"
|
|
205
|
+
PATTERN_MISMATCH = "pattern-mismatch"
|
|
206
|
+
TOO_SHORT = "too-short"
|
|
207
|
+
TOO_LONG = "too-long"
|
|
208
|
+
RANGE_UNDERFLOW = "range-underflow"
|
|
209
|
+
RANGE_OVERFLOW = "range-overflow"
|
|
210
|
+
STEP_MISMATCH = "step-mismatch"
|
|
211
|
+
BAD_INPUT = "bad-input"
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class FieldMessage(CoreModel):
|
|
215
|
+
field: ElementId
|
|
216
|
+
reason: ValidityReason
|
|
217
|
+
message: str = Field(
|
|
218
|
+
max_length=300, description="What the browser showed: its own words, or the page's if it set some"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class FormProbe(CoreModel):
|
|
223
|
+
"""The explorer submitted a checkout form empty just before this capture (ADR-0011).
|
|
224
|
+
|
|
225
|
+
The messages a page writes into itself are among the capture's elements. Those of the browser's own
|
|
226
|
+
validation are not: the browser shows them in a bubble outside the page, one at a time from the
|
|
227
|
+
first, and the screenshot leaves it out, so they are kept here. Only fields the browser flagged at
|
|
228
|
+
the submit have one: a form that turns that validation off, or a script that stops the submit
|
|
229
|
+
first, leaves none.
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
submitted_on: CaptureId = Field(description="The capture the form was submitted from")
|
|
233
|
+
form: ElementId | None = Field(description="The form in this capture; None once the page has none")
|
|
234
|
+
browser_messages: tuple[FieldMessage, ...] = Field(
|
|
235
|
+
default=(), description="The browser's own validation messages, by field in this capture"
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class FixedLayer(CoreModel):
|
|
240
|
+
"""What it takes to draw the elements fixed to the screen where each screen would show them (ADR-0012).
|
|
241
|
+
|
|
242
|
+
A full-page screenshot draws them once, where the capture's screen had them, so a tile further down the
|
|
243
|
+
page wouldn't show them where a shopper there sees them. Boxes are in the page's terms, as elements' are.
|
|
244
|
+
"""
|
|
245
|
+
|
|
246
|
+
page: ArtifactRef = Field(description="The full-page screenshot with those elements left out")
|
|
247
|
+
layer: ArtifactRef = Field(description="One screen with only those elements on it, see-through elsewhere")
|
|
248
|
+
screen: Box = Field(description="The part of the page the capture's screen showed")
|
|
249
|
+
solid: tuple[Box, ...] = Field(default=(), description="What is solid among those elements")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class Capture(CoreModel):
|
|
253
|
+
"""One page state in one viewport, as the explorer saw it.
|
|
254
|
+
|
|
255
|
+
The heavy parts (screenshot, DOM, accessibility tree) live in the artifact store; the capture keeps
|
|
256
|
+
references to them plus the element list agents and rules work from, and what the browser measured.
|
|
257
|
+
Captures recorded before Artest measured something hold None for it.
|
|
258
|
+
"""
|
|
259
|
+
|
|
260
|
+
id: CaptureId
|
|
261
|
+
url: str = Field(pattern=r"^https?://")
|
|
262
|
+
title: str
|
|
263
|
+
viewport: Viewport
|
|
264
|
+
viewport_size: Size
|
|
265
|
+
page_size: Size
|
|
266
|
+
captured_at: AwareDatetime
|
|
267
|
+
screenshot: ArtifactRef
|
|
268
|
+
dom: ArtifactRef
|
|
269
|
+
aria: ArtifactRef
|
|
270
|
+
elements: tuple[ElementRef, ...] = ()
|
|
271
|
+
vitals: WebVitals | None = None
|
|
272
|
+
network: NetworkSummary | None = None
|
|
273
|
+
console: tuple[ConsoleEntry, ...] | None = Field(
|
|
274
|
+
default=None,
|
|
275
|
+
description="Errors and warnings since the previous capture, from this page's document on",
|
|
276
|
+
)
|
|
277
|
+
axe: AxeReport | None = None
|
|
278
|
+
probe: FormProbe | None = Field(default=None, description="The empty submit that led to this capture")
|
|
279
|
+
settled: bool = Field(
|
|
280
|
+
default=True,
|
|
281
|
+
description="Whether the page had stopped asking its server for things when it was captured. False "
|
|
282
|
+
"when it was taken as it stood, as a request stayed open, such as a chat widget's long poll. "
|
|
283
|
+
"Captures from before this was measured read True",
|
|
284
|
+
)
|
|
285
|
+
fixed_layer: FixedLayer | None = Field(
|
|
286
|
+
default=None,
|
|
287
|
+
description="What draws the elements fixed to the screen on each screen. None on a page with none "
|
|
288
|
+
"that can be drawn alone, and in captures from before this was measured",
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
@property
|
|
292
|
+
def images(self) -> tuple[ArtifactRef, ...]:
|
|
293
|
+
"""Every picture of the page in the artifact store, which is what a vision agent may be shown."""
|
|
294
|
+
layer = self.fixed_layer
|
|
295
|
+
return (self.screenshot,) if layer is None else (self.screenshot, layer.page, layer.layer)
|
|
296
|
+
|
|
297
|
+
@model_validator(mode="after")
|
|
298
|
+
def _elements_belong_to_this_capture(self) -> Self:
|
|
299
|
+
seen: set[str] = set()
|
|
300
|
+
for element in self.elements:
|
|
301
|
+
if element.capture_id != self.id:
|
|
302
|
+
msg = f"element {element.eid} does not belong to capture {self.id}"
|
|
303
|
+
raise ValueError(msg)
|
|
304
|
+
if element.eid in seen:
|
|
305
|
+
msg = f"duplicate element id {element.eid}"
|
|
306
|
+
raise ValueError(msg)
|
|
307
|
+
seen.add(element.eid)
|
|
308
|
+
for eid in self._cited():
|
|
309
|
+
if eid not in seen:
|
|
310
|
+
msg = f"{eid} is cited but is not an element of capture {self.id}"
|
|
311
|
+
raise ValueError(msg)
|
|
312
|
+
return self
|
|
313
|
+
|
|
314
|
+
def _cited(self) -> list[str]:
|
|
315
|
+
"""The elements that what the browser measured points at."""
|
|
316
|
+
cited = [eid for violation in self.axe.violations for eid in violation.elements] if self.axe else []
|
|
317
|
+
if self.probe is not None:
|
|
318
|
+
cited += [message.field for message in self.probe.browser_messages]
|
|
319
|
+
cited += [self.probe.form] if self.probe.form is not None else []
|
|
320
|
+
return cited
|
|
321
|
+
|
|
322
|
+
def element(self, eid: str) -> ElementRef | None:
|
|
323
|
+
return next((element for element in self.elements if element.eid == eid), None)
|
artest/core/events.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Domain events: what the pipeline tells the journal, the live UI and the exported report.
|
|
2
|
+
|
|
3
|
+
Control events (a step started, a run paused) belong to the runtime, which knows nothing about CRO. The
|
|
4
|
+
engine combines both into the full run event union.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Annotated, Literal
|
|
8
|
+
|
|
9
|
+
from pydantic import Field, TypeAdapter
|
|
10
|
+
|
|
11
|
+
from artest.core.artifacts import ArtifactRef
|
|
12
|
+
from artest.core.base import CoreModel
|
|
13
|
+
from artest.core.capture import Viewport
|
|
14
|
+
from artest.core.findings import Finding, FindingCategory, FindingSource, RejectionReason
|
|
15
|
+
from artest.core.funnel import Action, ExploreOutcome, FunnelFacts
|
|
16
|
+
from artest.core.geometry import Box
|
|
17
|
+
from artest.core.ids import CaptureId, FindingId, StepId
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PageCaptured(CoreModel):
|
|
21
|
+
type: Literal["page_captured"] = "page_captured"
|
|
22
|
+
capture_id: CaptureId
|
|
23
|
+
url: str
|
|
24
|
+
title: str
|
|
25
|
+
viewport: Viewport
|
|
26
|
+
screenshot: ArtifactRef
|
|
27
|
+
capture: ArtifactRef = Field(description="The full Capture as JSON in the artifact store")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ExplorerActed(CoreModel):
|
|
31
|
+
type: Literal["explorer_acted"] = "explorer_acted"
|
|
32
|
+
viewport: Viewport
|
|
33
|
+
capture_id: CaptureId = Field(description="The page the action was taken on")
|
|
34
|
+
action: Action
|
|
35
|
+
target: Box | None = Field(default=None, description="Where the element was, for the UI's agent cursor")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class WalkEnded(CoreModel):
|
|
39
|
+
"""How one viewport's walk ended, journaled before anything reviews what it reached, so that whoever runs
|
|
40
|
+
the audit and whoever reads its report know what the review never saw."""
|
|
41
|
+
|
|
42
|
+
type: Literal["walk_ended"] = "walk_ended"
|
|
43
|
+
viewport: Viewport
|
|
44
|
+
outcome: ExploreOutcome
|
|
45
|
+
reason: str = Field(description="Why it ended there, in a sentence of Artest's own")
|
|
46
|
+
url: str | None = Field(default=None, description="The last page it captured, if it captured any")
|
|
47
|
+
stopped_at: str | None = Field(
|
|
48
|
+
default=None,
|
|
49
|
+
description="The words of the button or field it stopped at, if it did: the page's, not Artest's",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class FunnelModelBuilt(CoreModel):
|
|
54
|
+
type: Literal["funnel_model_built"] = "funnel_model_built"
|
|
55
|
+
graph: ArtifactRef
|
|
56
|
+
step_count: int = Field(ge=1)
|
|
57
|
+
facts: FunnelFacts
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class FindingReported(CoreModel):
|
|
61
|
+
type: Literal["finding_reported"] = "finding_reported"
|
|
62
|
+
finding: Finding
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class FindingMerged(CoreModel):
|
|
66
|
+
"""A finding about a problem already found, merged into the earlier finding instead of reported apart.
|
|
67
|
+
|
|
68
|
+
It carries the earlier finding as it is now: the same ID, with the new source's report, evidence
|
|
69
|
+
and source added, and a higher confidence when a rule and an agent agree (ADR-0012).
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
type: Literal["finding_merged"] = "finding_merged"
|
|
73
|
+
finding: Finding
|
|
74
|
+
merged: FindingId = Field(
|
|
75
|
+
description="The ID the evidence gate gave the finding that merged in, which no finding keeps"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class FindingRejected(CoreModel):
|
|
80
|
+
"""A claimed finding the evidence gate dropped, kept on record so the UI can show what and why."""
|
|
81
|
+
|
|
82
|
+
type: Literal["finding_rejected"] = "finding_rejected"
|
|
83
|
+
source: FindingSource
|
|
84
|
+
step_id: str = Field(description="The step the claim was about, which may not exist")
|
|
85
|
+
viewport: Viewport
|
|
86
|
+
category: FindingCategory
|
|
87
|
+
title: str
|
|
88
|
+
reason: RejectionReason
|
|
89
|
+
detail: str = Field(description="What failed, such as which element was not in the capture")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class TrainingProvidersAllowed(CoreModel):
|
|
93
|
+
"""The user let this site's pages go to providers that may train on prompts, or whose terms Artest doesn't
|
|
94
|
+
know (ADR-0006). Kept on record so the run and its report say so."""
|
|
95
|
+
|
|
96
|
+
type: Literal["training_providers_allowed"] = "training_providers_allowed"
|
|
97
|
+
url: str = Field(description="The site whose pages may go to them")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class StepLeftOut(CoreModel):
|
|
101
|
+
"""A step an agent was to review but left out, kept on record so the UI can show what wasn't reviewed
|
|
102
|
+
and why."""
|
|
103
|
+
|
|
104
|
+
type: Literal["step_left_out"] = "step_left_out"
|
|
105
|
+
source: FindingSource = Field(description="The agent that left the step out")
|
|
106
|
+
step_id: StepId
|
|
107
|
+
viewport: Viewport
|
|
108
|
+
reason: str = Field(description="Why, in a sentence")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
DomainEvent = Annotated[
|
|
112
|
+
PageCaptured
|
|
113
|
+
| ExplorerActed
|
|
114
|
+
| WalkEnded
|
|
115
|
+
| FunnelModelBuilt
|
|
116
|
+
| FindingReported
|
|
117
|
+
| FindingMerged
|
|
118
|
+
| FindingRejected
|
|
119
|
+
| StepLeftOut
|
|
120
|
+
| TrainingProvidersAllowed,
|
|
121
|
+
Field(discriminator="type"),
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
domain_event: TypeAdapter[DomainEvent] = TypeAdapter(DomainEvent)
|
|
125
|
+
"""Validates and serialises any domain event, dispatching on its ``type``."""
|