kernhell 0.1.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.
Potentially problematic release.
This version of kernhell might be problematic. Click here for more details.
- kernhell/__init__.py +8 -0
- kernhell/__main__.py +6 -0
- kernhell/analyzer/analyzer.py +178 -0
- kernhell/analyzer/healer.py +249 -0
- kernhell/analyzer/prompts.py +89 -0
- kernhell/api/__init__.py +5 -0
- kernhell/api/server.py +367 -0
- kernhell/cache/__init__.py +13 -0
- kernhell/cache/cache_manager.py +221 -0
- kernhell/cache/embedder.py +104 -0
- kernhell/cache/schemas.py +42 -0
- kernhell/cli/__init__.py +1 -0
- kernhell/cli/app.py +62 -0
- kernhell/cli/commands/__init__.py +1 -0
- kernhell/cli/commands/cache.py +96 -0
- kernhell/cli/commands/config.py +111 -0
- kernhell/cli/commands/dashboard.py +55 -0
- kernhell/cli/commands/heal.py +141 -0
- kernhell/cli/commands/scan.py +87 -0
- kernhell/cli/ui.py +176 -0
- kernhell/core/__init__.py +1 -0
- kernhell/core/config.py +219 -0
- kernhell/core/exceptions.py +104 -0
- kernhell/core/models.py +174 -0
- kernhell/core/orchestrator.py +245 -0
- kernhell/patcher/patcher.py +215 -0
- kernhell/patcher/verifier.py +82 -0
- kernhell/pruner/__init__.py +1 -0
- kernhell/pruner/dom_pruner.py +240 -0
- kernhell/reporter/__init__.py +5 -0
- kernhell/reporter/reporter.py +124 -0
- kernhell/scanner/__init__.py +1 -0
- kernhell/scanner/parser.py +276 -0
- kernhell/scanner/runner.py +242 -0
- kernhell/utils/__init__.py +1 -0
- kernhell/utils/file_utils.py +117 -0
- kernhell/utils/logger.py +78 -0
- kernhell-0.1.0.dist-info/METADATA +245 -0
- kernhell-0.1.0.dist-info/RECORD +42 -0
- kernhell-0.1.0.dist-info/WHEEL +4 -0
- kernhell-0.1.0.dist-info/entry_points.txt +2 -0
- kernhell-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""DOM Pruner — strips noise from raw HTML for LLM consumption.
|
|
2
|
+
|
|
3
|
+
The live DOM captured by Playwright is typically 200–500 KB of raw HTML.
|
|
4
|
+
Sending all of that to an LLM is wasteful, slow, and often exceeds context
|
|
5
|
+
limits. The pruner retains only **semantically meaningful** elements and
|
|
6
|
+
attributes, cutting token usage by 80–90%.
|
|
7
|
+
|
|
8
|
+
**What we keep:**
|
|
9
|
+
- Interactive elements: ``<button>``, ``<a>``, ``<input>``, ``<select>``,
|
|
10
|
+
``<textarea>``, ``<form>``, ``<label>``
|
|
11
|
+
- Semantic landmarks: ``<header>``, ``<nav>``, ``<main>``, ``<footer>``,
|
|
12
|
+
``<section>``, ``<article>``, ``<aside>``
|
|
13
|
+
- Content elements: ``<h1>``–``<h6>``, ``<p>``, ``<li>``, ``<td>``, ``<th>``
|
|
14
|
+
- Important attributes: ``id``, ``class``, ``role``, ``aria-*``,
|
|
15
|
+
``data-testid``, ``data-test``, ``name``, ``type``, ``href``, ``value``,
|
|
16
|
+
``placeholder``, ``title``, ``alt``
|
|
17
|
+
- Visible text content (trimmed)
|
|
18
|
+
|
|
19
|
+
**What we strip:**
|
|
20
|
+
- ``<script>``, ``<style>``, ``<svg>``, ``<noscript>``, ``<link>``, ``<meta>``
|
|
21
|
+
- Inline ``style`` attributes
|
|
22
|
+
- ``data:`` URIs and base64 content
|
|
23
|
+
- Empty/whitespace-only text nodes
|
|
24
|
+
- Deeply nested non-semantic ``<div>``/``<span>`` wrappers (flattened)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import re
|
|
30
|
+
|
|
31
|
+
import structlog
|
|
32
|
+
from selectolax.lexbor import LexborHTMLParser, LexborNode
|
|
33
|
+
|
|
34
|
+
from kernhell.core.models import PrunedDOM
|
|
35
|
+
|
|
36
|
+
logger = structlog.get_logger()
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# Configuration
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
# Tags to remove entirely (including children)
|
|
43
|
+
_STRIP_TAGS: frozenset[str] = frozenset({
|
|
44
|
+
"script", "style", "svg", "noscript", "link", "meta",
|
|
45
|
+
"iframe", "object", "embed", "template", "picture",
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
# Tags whose children we keep but the tag itself may be meaningful
|
|
49
|
+
_SEMANTIC_TAGS: frozenset[str] = frozenset({
|
|
50
|
+
# Interactive
|
|
51
|
+
"button", "a", "input", "select", "textarea", "form", "label", "option",
|
|
52
|
+
# Landmarks
|
|
53
|
+
"header", "nav", "main", "footer", "section", "article", "aside", "dialog",
|
|
54
|
+
# Content
|
|
55
|
+
"h1", "h2", "h3", "h4", "h5", "h6", "p", "li", "ul", "ol",
|
|
56
|
+
"table", "thead", "tbody", "tr", "td", "th",
|
|
57
|
+
"img", "figure", "figcaption", "details", "summary",
|
|
58
|
+
# Structure (kept when they have semantic attributes)
|
|
59
|
+
"div", "span",
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
# Attributes worth preserving
|
|
63
|
+
_KEEP_ATTRS: frozenset[str] = frozenset({
|
|
64
|
+
"id", "class", "role", "name", "type", "href", "value",
|
|
65
|
+
"placeholder", "title", "alt", "for", "action", "method",
|
|
66
|
+
"data-testid", "data-test", "data-cy",
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
# Prefix-based attribute preservation (aria-*)
|
|
70
|
+
_KEEP_ATTR_PREFIXES: tuple[str, ...] = ("aria-", "data-testid", "data-test", "data-cy")
|
|
71
|
+
|
|
72
|
+
# Regex for base64 / data URIs
|
|
73
|
+
_DATA_URI_PATTERN = re.compile(r"data:[^;]+;base64,[A-Za-z0-9+/=]+")
|
|
74
|
+
|
|
75
|
+
# Maximum text length to keep per element
|
|
76
|
+
_MAX_TEXT_LENGTH = 120
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# Pruner Implementation
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _should_keep_attr(name: str) -> bool:
|
|
85
|
+
"""Determine if an HTML attribute should be preserved."""
|
|
86
|
+
if name in _KEEP_ATTRS:
|
|
87
|
+
return True
|
|
88
|
+
return any(name.startswith(prefix) for prefix in _KEEP_ATTR_PREFIXES)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _clean_attr_value(value: str) -> str:
|
|
92
|
+
"""Clean an attribute value — strip data URIs, excessive whitespace."""
|
|
93
|
+
if not value:
|
|
94
|
+
return value
|
|
95
|
+
# Remove base64 data URIs
|
|
96
|
+
value = _DATA_URI_PATTERN.sub("[data-uri]", value)
|
|
97
|
+
# Collapse whitespace
|
|
98
|
+
value = " ".join(value.split())
|
|
99
|
+
# Truncate extremely long values (e.g. inline SVG paths)
|
|
100
|
+
if len(value) > 200:
|
|
101
|
+
value = value[:200] + "…"
|
|
102
|
+
return value
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _has_semantic_value(node: LexborNode) -> bool:
|
|
106
|
+
"""Check if a div/span has attributes that make it semantically meaningful.
|
|
107
|
+
|
|
108
|
+
Naked ``<div>`` and ``<span>`` wrappers with no id, class, role, or
|
|
109
|
+
aria attributes are noise — we flatten them (keep children, drop the tag).
|
|
110
|
+
"""
|
|
111
|
+
if node.tag in ("div", "span"):
|
|
112
|
+
attrs = node.attributes
|
|
113
|
+
if not attrs:
|
|
114
|
+
return False
|
|
115
|
+
return any(_should_keep_attr(name) for name in attrs)
|
|
116
|
+
return True
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _prune_node(node: LexborNode) -> str:
|
|
120
|
+
"""Recursively prune a DOM node, returning cleaned HTML."""
|
|
121
|
+
tag = node.tag
|
|
122
|
+
|
|
123
|
+
# Skip stripped tags entirely
|
|
124
|
+
if tag and tag.lower() in _STRIP_TAGS:
|
|
125
|
+
return ""
|
|
126
|
+
|
|
127
|
+
# Handle text nodes
|
|
128
|
+
if tag is None or tag == "-text":
|
|
129
|
+
text = (node.text(deep=False) or "").strip()
|
|
130
|
+
if not text:
|
|
131
|
+
return ""
|
|
132
|
+
# Truncate long text
|
|
133
|
+
if len(text) > _MAX_TEXT_LENGTH:
|
|
134
|
+
text = text[:_MAX_TEXT_LENGTH] + "…"
|
|
135
|
+
return text
|
|
136
|
+
|
|
137
|
+
# For non-semantic div/span: flatten (emit children only)
|
|
138
|
+
if tag.lower() in ("div", "span") and not _has_semantic_value(node):
|
|
139
|
+
children_html = ""
|
|
140
|
+
child = node.child
|
|
141
|
+
while child is not None:
|
|
142
|
+
children_html += _prune_node(child)
|
|
143
|
+
child = child.next
|
|
144
|
+
return children_html
|
|
145
|
+
|
|
146
|
+
# Build the pruned tag with only meaningful attributes
|
|
147
|
+
attrs_str = ""
|
|
148
|
+
raw_attrs = node.attributes
|
|
149
|
+
if raw_attrs:
|
|
150
|
+
kept_attrs: list[str] = []
|
|
151
|
+
for name, value in raw_attrs.items():
|
|
152
|
+
if _should_keep_attr(name):
|
|
153
|
+
clean_value = _clean_attr_value(value or "")
|
|
154
|
+
if name == "style":
|
|
155
|
+
continue # Always strip inline styles
|
|
156
|
+
kept_attrs.append(f'{name}="{clean_value}"')
|
|
157
|
+
if kept_attrs:
|
|
158
|
+
attrs_str = " " + " ".join(kept_attrs)
|
|
159
|
+
|
|
160
|
+
# Recurse into children
|
|
161
|
+
children_html = ""
|
|
162
|
+
child = node.child
|
|
163
|
+
while child is not None:
|
|
164
|
+
children_html += _prune_node(child)
|
|
165
|
+
child = child.next
|
|
166
|
+
|
|
167
|
+
# Self-closing tags
|
|
168
|
+
if tag.lower() in ("input", "img", "br", "hr"):
|
|
169
|
+
return f"<{tag}{attrs_str}/>"
|
|
170
|
+
|
|
171
|
+
# Get direct text content
|
|
172
|
+
text = (node.text(deep=False) or "").strip()
|
|
173
|
+
if text and len(text) > _MAX_TEXT_LENGTH:
|
|
174
|
+
text = text[:_MAX_TEXT_LENGTH] + "…"
|
|
175
|
+
|
|
176
|
+
inner = text + children_html
|
|
177
|
+
if not inner.strip() and tag.lower() not in _SEMANTIC_TAGS:
|
|
178
|
+
return ""
|
|
179
|
+
|
|
180
|
+
return f"<{tag}{attrs_str}>{inner}</{tag}>"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def prune_dom(raw_html: str) -> PrunedDOM:
|
|
184
|
+
"""Prune raw HTML to a semantic skeleton for LLM consumption.
|
|
185
|
+
|
|
186
|
+
Parameters
|
|
187
|
+
----------
|
|
188
|
+
raw_html:
|
|
189
|
+
The full ``page.content()`` HTML captured by Playwright.
|
|
190
|
+
|
|
191
|
+
Returns
|
|
192
|
+
-------
|
|
193
|
+
PrunedDOM
|
|
194
|
+
The pruned HTML along with size reduction metrics.
|
|
195
|
+
|
|
196
|
+
Examples
|
|
197
|
+
--------
|
|
198
|
+
>>> result = prune_dom("<html><body><script>...</script><button id='buy'>Buy</button></body></html>")
|
|
199
|
+
>>> "script" not in result.html
|
|
200
|
+
True
|
|
201
|
+
>>> "buy" in result.html.lower()
|
|
202
|
+
True
|
|
203
|
+
"""
|
|
204
|
+
original_size = len(raw_html.encode("utf-8"))
|
|
205
|
+
logger.debug("pruner.start", original_size=original_size)
|
|
206
|
+
|
|
207
|
+
if not raw_html.strip():
|
|
208
|
+
return PrunedDOM(html="", original_size=0, pruned_size=0)
|
|
209
|
+
|
|
210
|
+
parser = LexborHTMLParser(raw_html)
|
|
211
|
+
body = parser.body
|
|
212
|
+
|
|
213
|
+
if body is None:
|
|
214
|
+
# Fallback: try the root
|
|
215
|
+
body = parser.root
|
|
216
|
+
if body is None:
|
|
217
|
+
logger.warning("pruner.no_body")
|
|
218
|
+
return PrunedDOM(html="", original_size=original_size, pruned_size=0)
|
|
219
|
+
|
|
220
|
+
pruned_html = _prune_node(body)
|
|
221
|
+
|
|
222
|
+
# Clean up excessive whitespace and empty lines in the result
|
|
223
|
+
pruned_html = re.sub(r"\n{3,}", "\n\n", pruned_html)
|
|
224
|
+
pruned_html = pruned_html.strip()
|
|
225
|
+
|
|
226
|
+
pruned_size = len(pruned_html.encode("utf-8"))
|
|
227
|
+
|
|
228
|
+
result = PrunedDOM(
|
|
229
|
+
html=pruned_html,
|
|
230
|
+
original_size=original_size,
|
|
231
|
+
pruned_size=pruned_size,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
logger.info(
|
|
235
|
+
"pruner.done",
|
|
236
|
+
original_size=original_size,
|
|
237
|
+
pruned_size=pruned_size,
|
|
238
|
+
reduction_pct=result.reduction_pct,
|
|
239
|
+
)
|
|
240
|
+
return result
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Reporter module — produces beautiful terminal summaries and machine-readable JSON exports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from rich import box
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.panel import Panel
|
|
12
|
+
from rich.syntax import Syntax
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
|
|
15
|
+
from kernhell.core.models import HealingReport, HealingResult
|
|
16
|
+
|
|
17
|
+
console = Console(stderr=True)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HealingReporter:
|
|
21
|
+
"""Renders healing run results to terminal and files."""
|
|
22
|
+
|
|
23
|
+
@staticmethod
|
|
24
|
+
def render_diff(old_code: str, new_code: str, filename: str = "") -> None:
|
|
25
|
+
"""Display a syntax-highlighted diff of changes."""
|
|
26
|
+
import difflib
|
|
27
|
+
|
|
28
|
+
diff_lines = list(
|
|
29
|
+
difflib.unified_diff(
|
|
30
|
+
old_code.splitlines(),
|
|
31
|
+
new_code.splitlines(),
|
|
32
|
+
fromfile=f"a/{filename}" if filename else "original",
|
|
33
|
+
tofile=f"b/{filename}" if filename else "healed",
|
|
34
|
+
lineterm="",
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
if not diff_lines:
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
diff_text = "\n".join(diff_lines)
|
|
41
|
+
syntax = Syntax(diff_text, "diff", theme="monokai", line_numbers=False)
|
|
42
|
+
panel = Panel(
|
|
43
|
+
syntax,
|
|
44
|
+
title="[bold green]Surgical AST Diff (Comments & Spacing Preserved)[/bold green]",
|
|
45
|
+
border_style="green",
|
|
46
|
+
box=box.ROUNDED,
|
|
47
|
+
)
|
|
48
|
+
console.print(panel)
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def print_summary(report: HealingReport) -> None:
|
|
52
|
+
"""Print full healing summary table to terminal."""
|
|
53
|
+
table = Table(
|
|
54
|
+
title=f"KernHell Healing Report: {report.test_file.name}",
|
|
55
|
+
box=box.ROUNDED,
|
|
56
|
+
show_lines=True,
|
|
57
|
+
)
|
|
58
|
+
table.add_column("#", style="dim", width=4)
|
|
59
|
+
table.add_column("Original Selector", style="red")
|
|
60
|
+
table.add_column("Healed Selector", style="bold green")
|
|
61
|
+
table.add_column("Strategy", style="cyan")
|
|
62
|
+
table.add_column("Confidence", justify="right")
|
|
63
|
+
table.add_column("Source", style="yellow")
|
|
64
|
+
table.add_column("Verified", justify="center")
|
|
65
|
+
|
|
66
|
+
for i, res in enumerate(report.results, start=1):
|
|
67
|
+
if res.fix:
|
|
68
|
+
source = "Semantic Cache" if res.from_cache else "AI Healer"
|
|
69
|
+
verified = "✅ YES" if res.fix.verified else "❌ NO"
|
|
70
|
+
conf_str = f"{int(res.fix.confidence * 100)}%"
|
|
71
|
+
table.add_row(
|
|
72
|
+
str(i),
|
|
73
|
+
res.failure.broken_selector,
|
|
74
|
+
res.fix.new_selector,
|
|
75
|
+
res.fix.strategy.value,
|
|
76
|
+
conf_str,
|
|
77
|
+
source,
|
|
78
|
+
verified,
|
|
79
|
+
)
|
|
80
|
+
else:
|
|
81
|
+
table.add_row(
|
|
82
|
+
str(i),
|
|
83
|
+
res.failure.broken_selector,
|
|
84
|
+
"[yellow](SKIPPED)[/yellow]",
|
|
85
|
+
"-",
|
|
86
|
+
"-",
|
|
87
|
+
"-",
|
|
88
|
+
f"[dim]{res.skip_reason[:30]}...[/dim]" if res.skip_reason else "-",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
console.print(table)
|
|
92
|
+
console.print()
|
|
93
|
+
|
|
94
|
+
@staticmethod
|
|
95
|
+
def export_json(report: HealingReport, output_path: Optional[Path] = None) -> str:
|
|
96
|
+
"""Export healing report to structured JSON."""
|
|
97
|
+
data = {
|
|
98
|
+
"test_file": str(report.test_file),
|
|
99
|
+
"total_failures": report.total_failures,
|
|
100
|
+
"healed_count": report.healed_count,
|
|
101
|
+
"skipped_count": report.skipped_count,
|
|
102
|
+
"cache_hits": report.cache_hits,
|
|
103
|
+
"llm_calls": report.llm_calls,
|
|
104
|
+
"verification_passed": report.verification_passed,
|
|
105
|
+
"duration_ms": report.duration_ms,
|
|
106
|
+
"results": [
|
|
107
|
+
{
|
|
108
|
+
"broken_selector": r.failure.broken_selector,
|
|
109
|
+
"line_number": r.failure.line_number,
|
|
110
|
+
"healed_selector": r.fix.new_selector if r.fix else None,
|
|
111
|
+
"strategy": r.fix.strategy.value if r.fix else None,
|
|
112
|
+
"confidence": r.fix.confidence if r.fix else None,
|
|
113
|
+
"from_cache": r.from_cache,
|
|
114
|
+
"skipped": r.skipped,
|
|
115
|
+
"skip_reason": r.skip_reason,
|
|
116
|
+
"verified": r.fix.verified if r.fix else False,
|
|
117
|
+
}
|
|
118
|
+
for r in report.results
|
|
119
|
+
],
|
|
120
|
+
}
|
|
121
|
+
json_str = json.dumps(data, indent=2)
|
|
122
|
+
if output_path:
|
|
123
|
+
output_path.write_text(json_str, encoding="utf-8")
|
|
124
|
+
return json_str
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Scanner package — test execution and failure capture."""
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Playwright error output parser.
|
|
2
|
+
|
|
3
|
+
Parses structured error output from Playwright/pytest to extract:
|
|
4
|
+
- The broken selector string
|
|
5
|
+
- The line number in the test file where the failure occurred
|
|
6
|
+
- The type of selector (CSS, XPath, text, role, etc.)
|
|
7
|
+
- The error type (timeout, not found, strict mode, etc.)
|
|
8
|
+
|
|
9
|
+
This parser handles the standard Playwright error format from both
|
|
10
|
+
``pytest-playwright`` and raw Playwright Python output.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import structlog
|
|
20
|
+
|
|
21
|
+
from kernhell.core.models import ErrorType, FailureContext, SelectorType
|
|
22
|
+
from kernhell.utils.file_utils import extract_surrounding_lines, read_file
|
|
23
|
+
|
|
24
|
+
logger = structlog.get_logger()
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Regex patterns for Playwright error parsing
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
# Matches: "locator.click: Timeout 5000ms exceeded."
|
|
31
|
+
_TIMEOUT_PATTERN = re.compile(
|
|
32
|
+
r"(locator\.\w+|page\.\w+):\s*Timeout\s+\d+ms\s+exceeded",
|
|
33
|
+
re.IGNORECASE,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Matches: "waiting for locator("button#buy-now")" or 'locator("#buy-now")'
|
|
37
|
+
_LOCATOR_PATTERN = re.compile(
|
|
38
|
+
r"""locator\(\s*["'](.+?)["']\s*\)""",
|
|
39
|
+
re.IGNORECASE,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Matches: page.click("#buy-now") or page.fill("input.email", ...)
|
|
43
|
+
_PAGE_ACTION_PATTERN = re.compile(
|
|
44
|
+
r"""page\.(?:click|fill|type|check|uncheck|select_option|hover|dblclick|focus)\(\s*["'](.+?)["']""",
|
|
45
|
+
re.IGNORECASE,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Matches: page.get_by_role("button", name="Buy Now")
|
|
49
|
+
_GET_BY_ROLE_PATTERN = re.compile(
|
|
50
|
+
r"""page\.get_by_role\(\s*["'](\w+)["'](?:\s*,\s*name\s*=\s*["'](.+?)["'])?\s*\)""",
|
|
51
|
+
re.IGNORECASE,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Matches: page.get_by_text("Buy Now")
|
|
55
|
+
_GET_BY_TEXT_PATTERN = re.compile(
|
|
56
|
+
r"""page\.get_by_text\(\s*["'](.+?)["']\s*\)""",
|
|
57
|
+
re.IGNORECASE,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Matches: page.get_by_test_id("checkout-btn")
|
|
61
|
+
_GET_BY_TESTID_PATTERN = re.compile(
|
|
62
|
+
r"""page\.get_by_test_id\(\s*["'](.+?)["']\s*\)""",
|
|
63
|
+
re.IGNORECASE,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Matches: page.get_by_label("Email")
|
|
67
|
+
_GET_BY_LABEL_PATTERN = re.compile(
|
|
68
|
+
r"""page\.get_by_label\(\s*["'](.+?)["']\s*\)""",
|
|
69
|
+
re.IGNORECASE,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Matches: page.get_by_placeholder("Enter email")
|
|
73
|
+
_GET_BY_PLACEHOLDER_PATTERN = re.compile(
|
|
74
|
+
r"""page\.get_by_placeholder\(\s*["'](.+?)["']\s*\)""",
|
|
75
|
+
re.IGNORECASE,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Matches pytest traceback lines like: "tests/test_checkout.py:15: TimeoutError"
|
|
79
|
+
_PYTEST_LOCATION_PATTERN = re.compile(
|
|
80
|
+
r"^(.+\.py):(\d+):\s*(TimeoutError|Error|AssertionError)",
|
|
81
|
+
re.MULTILINE,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Matches: "strict mode violation" errors
|
|
85
|
+
_STRICT_MODE_PATTERN = re.compile(
|
|
86
|
+
r"strict\s+mode\s+violation",
|
|
87
|
+
re.IGNORECASE,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class ParsedFailure:
|
|
93
|
+
"""Intermediate result from parsing a single Playwright error block."""
|
|
94
|
+
|
|
95
|
+
selector: str = ""
|
|
96
|
+
selector_type: SelectorType = SelectorType.UNKNOWN
|
|
97
|
+
error_type: ErrorType = ErrorType.OTHER
|
|
98
|
+
line_number: int = 0
|
|
99
|
+
error_message: str = ""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def classify_selector(selector: str) -> SelectorType:
|
|
103
|
+
"""Determine the type of a Playwright selector string.
|
|
104
|
+
|
|
105
|
+
Examples
|
|
106
|
+
--------
|
|
107
|
+
>>> classify_selector("#buy-now")
|
|
108
|
+
SelectorType.CSS
|
|
109
|
+
>>> classify_selector("//div[@class='cart']")
|
|
110
|
+
SelectorType.XPATH
|
|
111
|
+
>>> classify_selector("text=Buy Now")
|
|
112
|
+
SelectorType.TEXT
|
|
113
|
+
"""
|
|
114
|
+
s = selector.strip()
|
|
115
|
+
if s.startswith("//") or s.startswith("(//"):
|
|
116
|
+
return SelectorType.XPATH
|
|
117
|
+
if s.startswith("text=") or s.startswith('"') or s.startswith("'"):
|
|
118
|
+
return SelectorType.TEXT
|
|
119
|
+
if s.startswith("role="):
|
|
120
|
+
return SelectorType.ROLE
|
|
121
|
+
if s.startswith("data-testid=") or s.startswith("[data-testid"):
|
|
122
|
+
return SelectorType.TEST_ID
|
|
123
|
+
# Default to CSS for #id, .class, tag, [attr] patterns
|
|
124
|
+
return SelectorType.CSS
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _detect_error_type(text: str) -> ErrorType:
|
|
128
|
+
"""Classify the Playwright error type from the error output."""
|
|
129
|
+
if _STRICT_MODE_PATTERN.search(text):
|
|
130
|
+
return ErrorType.STRICT_MODE
|
|
131
|
+
if _TIMEOUT_PATTERN.search(text):
|
|
132
|
+
return ErrorType.TIMEOUT
|
|
133
|
+
if "waiting for" in text.lower() or "not found" in text.lower():
|
|
134
|
+
return ErrorType.NOT_FOUND
|
|
135
|
+
if "navigation" in text.lower():
|
|
136
|
+
return ErrorType.NAVIGATION
|
|
137
|
+
return ErrorType.OTHER
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _extract_selector(text: str) -> tuple[str, SelectorType]:
|
|
141
|
+
"""Extract the broken selector and its type from error output.
|
|
142
|
+
|
|
143
|
+
Tries multiple patterns in priority order and returns the first match.
|
|
144
|
+
"""
|
|
145
|
+
# Try get_by_role first (most semantic)
|
|
146
|
+
m = _GET_BY_ROLE_PATTERN.search(text)
|
|
147
|
+
if m:
|
|
148
|
+
role = m.group(1)
|
|
149
|
+
name = m.group(2) or ""
|
|
150
|
+
selector = f'get_by_role("{role}", name="{name}")' if name else f'get_by_role("{role}")'
|
|
151
|
+
return selector, SelectorType.ROLE
|
|
152
|
+
|
|
153
|
+
# get_by_text
|
|
154
|
+
m = _GET_BY_TEXT_PATTERN.search(text)
|
|
155
|
+
if m:
|
|
156
|
+
return f'get_by_text("{m.group(1)}")', SelectorType.TEXT
|
|
157
|
+
|
|
158
|
+
# get_by_test_id
|
|
159
|
+
m = _GET_BY_TESTID_PATTERN.search(text)
|
|
160
|
+
if m:
|
|
161
|
+
return f'get_by_test_id("{m.group(1)}")', SelectorType.TEST_ID
|
|
162
|
+
|
|
163
|
+
# get_by_label
|
|
164
|
+
m = _GET_BY_LABEL_PATTERN.search(text)
|
|
165
|
+
if m:
|
|
166
|
+
return f'get_by_label("{m.group(1)}")', SelectorType.LABEL
|
|
167
|
+
|
|
168
|
+
# get_by_placeholder
|
|
169
|
+
m = _GET_BY_PLACEHOLDER_PATTERN.search(text)
|
|
170
|
+
if m:
|
|
171
|
+
return f'get_by_placeholder("{m.group(1)}")', SelectorType.PLACEHOLDER
|
|
172
|
+
|
|
173
|
+
# locator("selector")
|
|
174
|
+
m = _LOCATOR_PATTERN.search(text)
|
|
175
|
+
if m:
|
|
176
|
+
sel = m.group(1)
|
|
177
|
+
return sel, classify_selector(sel)
|
|
178
|
+
|
|
179
|
+
# page.click("selector") etc.
|
|
180
|
+
m = _PAGE_ACTION_PATTERN.search(text)
|
|
181
|
+
if m:
|
|
182
|
+
sel = m.group(1)
|
|
183
|
+
return sel, classify_selector(sel)
|
|
184
|
+
|
|
185
|
+
return "", SelectorType.UNKNOWN
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _extract_line_number(error_text: str, test_file: Path) -> int:
|
|
189
|
+
"""Extract the failing line number from pytest traceback output."""
|
|
190
|
+
filename = test_file.name
|
|
191
|
+
for m in _PYTEST_LOCATION_PATTERN.finditer(error_text):
|
|
192
|
+
if filename in m.group(1):
|
|
193
|
+
return int(m.group(2))
|
|
194
|
+
|
|
195
|
+
# Fallback: look for any ":<number>:" pattern near the test filename
|
|
196
|
+
fallback = re.search(
|
|
197
|
+
rf"{re.escape(filename)}:(\d+)",
|
|
198
|
+
error_text,
|
|
199
|
+
)
|
|
200
|
+
if fallback:
|
|
201
|
+
return int(fallback.group(1))
|
|
202
|
+
|
|
203
|
+
return 0
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def parse_playwright_errors(
|
|
207
|
+
error_output: str,
|
|
208
|
+
test_file: Path,
|
|
209
|
+
) -> list[FailureContext]:
|
|
210
|
+
"""Parse Playwright/pytest error output into structured FailureContexts.
|
|
211
|
+
|
|
212
|
+
Parameters
|
|
213
|
+
----------
|
|
214
|
+
error_output:
|
|
215
|
+
The combined stdout+stderr from a failed ``pytest`` run.
|
|
216
|
+
test_file:
|
|
217
|
+
Path to the test file that was executed.
|
|
218
|
+
|
|
219
|
+
Returns
|
|
220
|
+
-------
|
|
221
|
+
list[FailureContext]
|
|
222
|
+
One context per detected selector failure. May be empty if no
|
|
223
|
+
Playwright selector errors are found in the output.
|
|
224
|
+
"""
|
|
225
|
+
logger.debug("parser.start", test_file=str(test_file), output_len=len(error_output))
|
|
226
|
+
|
|
227
|
+
failures: list[FailureContext] = []
|
|
228
|
+
|
|
229
|
+
# Split on "FAILED" or "ERROR" markers to handle multiple failures.
|
|
230
|
+
# Playwright test output typically has sections separated by "_ _ _" or "==="
|
|
231
|
+
sections = re.split(r"_{3,}|={3,}", error_output)
|
|
232
|
+
|
|
233
|
+
# Also try parsing the whole output as a single failure
|
|
234
|
+
if len(sections) <= 1:
|
|
235
|
+
sections = [error_output]
|
|
236
|
+
|
|
237
|
+
seen_selectors: set[str] = set()
|
|
238
|
+
|
|
239
|
+
for section in sections:
|
|
240
|
+
selector, sel_type = _extract_selector(section)
|
|
241
|
+
if not selector or selector in seen_selectors:
|
|
242
|
+
continue
|
|
243
|
+
|
|
244
|
+
seen_selectors.add(selector)
|
|
245
|
+
error_type = _detect_error_type(section)
|
|
246
|
+
line_number = _extract_line_number(section, test_file)
|
|
247
|
+
|
|
248
|
+
# Read surrounding code context if we have a valid line number
|
|
249
|
+
surrounding_code = ""
|
|
250
|
+
if line_number > 0 and test_file.is_file():
|
|
251
|
+
try:
|
|
252
|
+
content = read_file(test_file)
|
|
253
|
+
surrounding_code = extract_surrounding_lines(content, line_number)
|
|
254
|
+
except Exception:
|
|
255
|
+
logger.warning("parser.surrounding_code.failed", line=line_number)
|
|
256
|
+
|
|
257
|
+
ctx = FailureContext(
|
|
258
|
+
test_file=test_file,
|
|
259
|
+
line_number=line_number,
|
|
260
|
+
broken_selector=selector,
|
|
261
|
+
selector_type=sel_type,
|
|
262
|
+
error_message=section.strip()[:500], # Cap at 500 chars
|
|
263
|
+
error_type=error_type,
|
|
264
|
+
surrounding_code=surrounding_code,
|
|
265
|
+
)
|
|
266
|
+
failures.append(ctx)
|
|
267
|
+
logger.info(
|
|
268
|
+
"parser.failure_found",
|
|
269
|
+
selector=selector,
|
|
270
|
+
selector_type=sel_type.value,
|
|
271
|
+
line=line_number,
|
|
272
|
+
error_type=error_type.value,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
logger.debug("parser.done", failures_found=len(failures))
|
|
276
|
+
return failures
|