@suiflex/suitest-mcp 0.1.0

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.
Files changed (46) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +77 -0
  3. package/bin/suitest-mcp.js +123 -0
  4. package/package.json +50 -0
  5. package/python/suitest_lifecycle/__init__.py +3 -0
  6. package/python/suitest_lifecycle/analyzers/__init__.py +1 -0
  7. package/python/suitest_lifecycle/analyzers/crawl.py +187 -0
  8. package/python/suitest_lifecycle/analyzers/express.py +226 -0
  9. package/python/suitest_lifecycle/analyzers/openapi.py +163 -0
  10. package/python/suitest_lifecycle/analyzers/postman.py +132 -0
  11. package/python/suitest_lifecycle/analyzers/react.py +107 -0
  12. package/python/suitest_lifecycle/analyzers/zod_schema.py +131 -0
  13. package/python/suitest_lifecycle/blackbox/__init__.py +11 -0
  14. package/python/suitest_lifecycle/blackbox/bootstrap.py +249 -0
  15. package/python/suitest_lifecycle/blackbox/crawler.py +383 -0
  16. package/python/suitest_lifecycle/blackbox/detector.py +169 -0
  17. package/python/suitest_lifecycle/blackbox/generator.py +608 -0
  18. package/python/suitest_lifecycle/blackbox/graph.py +107 -0
  19. package/python/suitest_lifecycle/blackbox/mcp.py +546 -0
  20. package/python/suitest_lifecycle/blackbox/models.py +299 -0
  21. package/python/suitest_lifecycle/blackbox/prd_ingest.py +108 -0
  22. package/python/suitest_lifecycle/blackbox/reporter.py +76 -0
  23. package/python/suitest_lifecycle/blackbox/selector.py +111 -0
  24. package/python/suitest_lifecycle/cli.py +127 -0
  25. package/python/suitest_lifecycle/config.py +314 -0
  26. package/python/suitest_lifecycle/enrich.py +140 -0
  27. package/python/suitest_lifecycle/exporters/__init__.py +1 -0
  28. package/python/suitest_lifecycle/exporters/backend.py +345 -0
  29. package/python/suitest_lifecycle/exporters/frontend.py +459 -0
  30. package/python/suitest_lifecycle/frontend_runtime.py +77 -0
  31. package/python/suitest_lifecycle/llm_bridge.py +365 -0
  32. package/python/suitest_lifecycle/mcp_server.py +187 -0
  33. package/python/suitest_lifecycle/models.py +166 -0
  34. package/python/suitest_lifecycle/orchestrator.py +500 -0
  35. package/python/suitest_lifecycle/paths.py +90 -0
  36. package/python/suitest_lifecycle/plan.py +366 -0
  37. package/python/suitest_lifecycle/plan_frontend.py +252 -0
  38. package/python/suitest_lifecycle/prd.py +92 -0
  39. package/python/suitest_lifecycle/process.py +111 -0
  40. package/python/suitest_lifecycle/publish.py +218 -0
  41. package/python/suitest_lifecycle/readiness.py +83 -0
  42. package/python/suitest_lifecycle/report.py +179 -0
  43. package/python/suitest_lifecycle/runner.py +138 -0
  44. package/python/suitest_lifecycle/serialize.py +131 -0
  45. package/python/suitest_lifecycle/tcm.py +149 -0
  46. package/python/suitest_lifecycle/tools.py +217 -0
@@ -0,0 +1,299 @@
1
+ """Serializable data model for the blackbox engine.
2
+
3
+ Everything round-trips through plain JSON (``to_json``/``from_json``) so the
4
+ same discovery artifact feeds Zero's deterministic generator, the MCP tools,
5
+ and (optionally) an LLM as reasoning context. Stdlib-only.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import asdict, dataclass, field
11
+ from typing import Any
12
+
13
+ # --------------------------------------------------------------------------- #
14
+ # DOM elements
15
+ # --------------------------------------------------------------------------- #
16
+
17
+
18
+ @dataclass
19
+ class ElementInfo:
20
+ """One interactive element as captured from the live DOM.
21
+
22
+ Carries every attribute the selector strategy can rank — the element is
23
+ addressable even when the app has no ``data-testid`` convention at all.
24
+ """
25
+
26
+ tag: str = ""
27
+ kind: str = "" # input | button | link | select | textarea | checkbox | radio
28
+ testid: str = "" # data-testid | data-cy | data-test (first found)
29
+ testid_attr: str = "" # which attribute carried it
30
+ role: str = "" # explicit role attr or implicit (button/link/...)
31
+ aria_label: str = ""
32
+ label: str = "" # associated <label> text
33
+ placeholder: str = ""
34
+ name: str = ""
35
+ input_type: str = ""
36
+ autocomplete: str = ""
37
+ text: str = "" # visible text (buttons/links)
38
+ dom_id: str = ""
39
+ href: str = ""
40
+ css: str = "" # stable-ish css path fallback
41
+ required: bool = False
42
+
43
+ def to_json(self) -> dict[str, Any]:
44
+ return asdict(self)
45
+
46
+ @staticmethod
47
+ def from_json(raw: dict[str, Any]) -> ElementInfo:
48
+ known = {f for f in ElementInfo.__dataclass_fields__}
49
+ return ElementInfo(**{k: v for k, v in raw.items() if k in known})
50
+
51
+
52
+ # --------------------------------------------------------------------------- #
53
+ # Login
54
+ # --------------------------------------------------------------------------- #
55
+
56
+
57
+ @dataclass
58
+ class LoginForm:
59
+ """Detected login form — locator EXPRESSIONS (Playwright, python) per part."""
60
+
61
+ route: str = ""
62
+ username: str = "" # locator expression, e.g. page.get_by_label("Email")
63
+ password: str = ""
64
+ submit: str = ""
65
+ remember: str = ""
66
+ error: str = "" # error region locator (may be empty until observed)
67
+
68
+ def found(self) -> bool:
69
+ return bool(self.username and self.password and self.submit)
70
+
71
+ def to_json(self) -> dict[str, Any]:
72
+ return asdict(self)
73
+
74
+ @staticmethod
75
+ def from_json(raw: dict[str, Any]) -> LoginForm:
76
+ known = {f for f in LoginForm.__dataclass_fields__}
77
+ return LoginForm(**{k: v for k, v in raw.items() if k in known})
78
+
79
+
80
+ # --------------------------------------------------------------------------- #
81
+ # Pages
82
+ # --------------------------------------------------------------------------- #
83
+
84
+ PAGE_PATTERNS = (
85
+ "login",
86
+ "dashboard",
87
+ "list",
88
+ "detail",
89
+ "form",
90
+ "modal",
91
+ "empty",
92
+ "error",
93
+ "forbidden",
94
+ "not_found",
95
+ "blank",
96
+ "unknown",
97
+ )
98
+
99
+
100
+ @dataclass
101
+ class PageInfo:
102
+ """One crawled route with its digest + evidence pointers."""
103
+
104
+ route: str
105
+ url: str = ""
106
+ title: str = ""
107
+ pattern: str = "unknown" # one of PAGE_PATTERNS
108
+ protected: bool = False
109
+ depth: int = 0
110
+ inputs: list[ElementInfo] = field(default_factory=list)
111
+ buttons: list[ElementInfo] = field(default_factory=list)
112
+ links: list[ElementInfo] = field(default_factory=list)
113
+ nav_routes: list[str] = field(default_factory=list) # internal hrefs found here
114
+ testids: list[str] = field(default_factory=list)
115
+ has_table: bool = False
116
+ row_locator: str = "" # repeated-row locator when a list/table was detected
117
+ has_form: bool = False
118
+ has_modal: bool = False
119
+ search_locator: str = ""
120
+ pagination_locator: str = ""
121
+ console_errors: list[str] = field(default_factory=list)
122
+ network_errors: list[str] = field(default_factory=list)
123
+ screenshot: str = "" # evidence path
124
+ blank: bool = False
125
+ visible_text_sample: str = ""
126
+
127
+ def to_json(self) -> dict[str, Any]:
128
+ d = asdict(self)
129
+ d["inputs"] = [e.to_json() for e in self.inputs]
130
+ d["buttons"] = [e.to_json() for e in self.buttons]
131
+ d["links"] = [e.to_json() for e in self.links]
132
+ return d
133
+
134
+ @staticmethod
135
+ def from_json(raw: dict[str, Any]) -> PageInfo:
136
+ known = {f for f in PageInfo.__dataclass_fields__}
137
+ data = {k: v for k, v in raw.items() if k in known}
138
+ for key in ("inputs", "buttons", "links"):
139
+ data[key] = [ElementInfo.from_json(e) for e in raw.get(key, [])]
140
+ return PageInfo(**data)
141
+
142
+
143
+ # --------------------------------------------------------------------------- #
144
+ # Discovery result (the engine's central artifact)
145
+ # --------------------------------------------------------------------------- #
146
+
147
+
148
+ @dataclass
149
+ class LoginProbe:
150
+ """Outcome of actually performing the login during discovery."""
151
+
152
+ attempted: bool = False
153
+ success: bool = False
154
+ landed_route: str = ""
155
+ error_locator: str = "" # observed error region on a failed probe
156
+ detail: str = ""
157
+
158
+ def to_json(self) -> dict[str, Any]:
159
+ return asdict(self)
160
+
161
+ @staticmethod
162
+ def from_json(raw: dict[str, Any]) -> LoginProbe:
163
+ known = {f for f in LoginProbe.__dataclass_fields__}
164
+ return LoginProbe(**{k: v for k, v in raw.items() if k in known})
165
+
166
+
167
+ @dataclass
168
+ class DiscoveryResult:
169
+ base_url: str = ""
170
+ login: LoginForm | None = None
171
+ login_probe: LoginProbe = field(default_factory=LoginProbe)
172
+ pages: list[PageInfo] = field(default_factory=list)
173
+ skipped_routes: list[str] = field(default_factory=list) # safeMode / excluded
174
+ errors: list[str] = field(default_factory=list)
175
+
176
+ def page(self, route: str) -> PageInfo | None:
177
+ for p in self.pages:
178
+ if p.route == route:
179
+ return p
180
+ return None
181
+
182
+ def to_json(self) -> dict[str, Any]:
183
+ return {
184
+ "baseUrl": self.base_url,
185
+ "login": self.login.to_json() if self.login else None,
186
+ "loginProbe": self.login_probe.to_json(),
187
+ "pages": [p.to_json() for p in self.pages],
188
+ "skippedRoutes": self.skipped_routes,
189
+ "errors": self.errors,
190
+ }
191
+
192
+ @staticmethod
193
+ def from_json(raw: dict[str, Any]) -> DiscoveryResult:
194
+ return DiscoveryResult(
195
+ base_url=str(raw.get("baseUrl", "")),
196
+ login=LoginForm.from_json(raw["login"]) if raw.get("login") else None,
197
+ login_probe=LoginProbe.from_json(raw.get("loginProbe", {})),
198
+ pages=[PageInfo.from_json(p) for p in raw.get("pages", [])],
199
+ skipped_routes=list(raw.get("skippedRoutes", [])),
200
+ errors=list(raw.get("errors", [])),
201
+ )
202
+
203
+
204
+ # --------------------------------------------------------------------------- #
205
+ # Config (suitest.config.json "ui" section)
206
+ # --------------------------------------------------------------------------- #
207
+
208
+
209
+ @dataclass
210
+ class BlackboxAuth:
211
+ strategy: str = "form"
212
+ login_url: str = "/login"
213
+ username: str = ""
214
+ password: str = ""
215
+
216
+
217
+ @dataclass
218
+ class BlackboxCrawl:
219
+ max_depth: int = 3
220
+ max_routes: int = 30
221
+ max_actions_per_page: int = 20
222
+ include: list[str] = field(default_factory=list)
223
+ exclude: list[str] = field(default_factory=list)
224
+ safe_mode: bool = True
225
+ # Validation aid: pretend the app has no data-testid convention so the
226
+ # heuristic tiers (role/label/placeholder/name/text) get exercised.
227
+ ignore_testids: bool = False
228
+
229
+
230
+ @dataclass
231
+ class BlackboxSelectors:
232
+ """Optional manual overrides — locator expressions or raw CSS."""
233
+
234
+ login_username: str = ""
235
+ login_password: str = ""
236
+ login_submit: str = ""
237
+
238
+
239
+ @dataclass
240
+ class BlackboxTestGeneration:
241
+ include_smoke: bool = True
242
+ include_auth: bool = True
243
+ include_navigation: bool = True
244
+ include_forms: bool = True
245
+ include_tables: bool = True
246
+ allow_mutation: bool = False
247
+
248
+
249
+ @dataclass
250
+ class BlackboxUiConfig:
251
+ mode: str = "blackbox"
252
+ target_url: str = ""
253
+ auth: BlackboxAuth = field(default_factory=BlackboxAuth)
254
+ crawl: BlackboxCrawl = field(default_factory=BlackboxCrawl)
255
+ selectors: BlackboxSelectors = field(default_factory=BlackboxSelectors)
256
+ test_generation: BlackboxTestGeneration = field(default_factory=BlackboxTestGeneration)
257
+ headed: bool = False
258
+ record_video: bool = True
259
+
260
+ @staticmethod
261
+ def from_raw(raw: dict[str, Any]) -> BlackboxUiConfig:
262
+ auth = raw.get("auth") or {}
263
+ crawl = raw.get("crawl") or {}
264
+ selectors = raw.get("selectors") or {}
265
+ gen = raw.get("testGeneration") or {}
266
+ return BlackboxUiConfig(
267
+ mode=str(raw.get("mode", "blackbox")),
268
+ target_url=str(raw.get("targetUrl", "")).rstrip("/"),
269
+ auth=BlackboxAuth(
270
+ strategy=str(auth.get("strategy", "form")),
271
+ login_url=str(auth.get("loginUrl", "/login")),
272
+ username=str(auth.get("username", "")),
273
+ password=str(auth.get("password", "")),
274
+ ),
275
+ crawl=BlackboxCrawl(
276
+ max_depth=int(crawl.get("maxDepth", 3)),
277
+ max_routes=int(crawl.get("maxRoutes", 30)),
278
+ max_actions_per_page=int(crawl.get("maxActionsPerPage", 20)),
279
+ include=[str(x) for x in crawl.get("include", [])],
280
+ exclude=[str(x) for x in crawl.get("exclude", [])],
281
+ safe_mode=bool(crawl.get("safeMode", True)),
282
+ ignore_testids=bool(crawl.get("ignoreTestIds", False)),
283
+ ),
284
+ selectors=BlackboxSelectors(
285
+ login_username=str(selectors.get("loginUsername", "")),
286
+ login_password=str(selectors.get("loginPassword", "")),
287
+ login_submit=str(selectors.get("loginSubmit", "")),
288
+ ),
289
+ test_generation=BlackboxTestGeneration(
290
+ include_smoke=bool(gen.get("includeSmoke", True)),
291
+ include_auth=bool(gen.get("includeAuth", True)),
292
+ include_navigation=bool(gen.get("includeNavigation", True)),
293
+ include_forms=bool(gen.get("includeForms", True)),
294
+ include_tables=bool(gen.get("includeTables", True)),
295
+ allow_mutation=bool(gen.get("allowMutation", False)),
296
+ ),
297
+ headed=bool(raw.get("headed", False)),
298
+ record_video=bool(raw.get("recordVideo", True)),
299
+ )
@@ -0,0 +1,108 @@
1
+ """Uploaded-PRD ingestion — markdown in, structured requirements out.
2
+
3
+ TestSprite-parity flow: the user brings a **markdown** product spec (that is
4
+ the required format); Suitest turns it into a test plan. Parsing here is
5
+ deterministic (stdlib only) — headings become features, bullet lines become
6
+ requirements — so the artifact is stable context for the LLM planner and
7
+ readable on its own in ``prd_ingest.json``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from dataclasses import asdict, dataclass, field
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ _HEADING = re.compile(r"^(#{1,6})\s+(.+?)\s*$")
18
+ _BULLET = re.compile(r"^\s*(?:[-*+]|\d+[.)])\s+(.+?)\s*$")
19
+
20
+
21
+ @dataclass
22
+ class PrdSection:
23
+ heading: str
24
+ level: int
25
+ requirements: list[str] = field(default_factory=list)
26
+ text: str = ""
27
+
28
+ def to_json(self) -> dict[str, Any]:
29
+ return asdict(self)
30
+
31
+
32
+ @dataclass
33
+ class PrdDocument:
34
+ title: str = ""
35
+ source_file: str = ""
36
+ sections: list[PrdSection] = field(default_factory=list)
37
+
38
+ @property
39
+ def requirements(self) -> list[str]:
40
+ return [r for s in self.sections for r in s.requirements]
41
+
42
+ def to_json(self) -> dict[str, Any]:
43
+ return {
44
+ "title": self.title,
45
+ "sourceFile": self.source_file,
46
+ "sections": [s.to_json() for s in self.sections],
47
+ "requirementCount": len(self.requirements),
48
+ }
49
+
50
+ def as_prompt_context(self, *, max_chars: int = 12_000) -> str:
51
+ """Compact PRD rendering for the LLM planner prompt."""
52
+ lines: list[str] = [f"PRD: {self.title}" if self.title else "PRD"]
53
+ for s in self.sections:
54
+ lines.append(f"\n{'#' * max(s.level, 1)} {s.heading}")
55
+ if s.text:
56
+ lines.append(s.text)
57
+ for r in s.requirements:
58
+ lines.append(f"- {r}")
59
+ return "\n".join(lines)[:max_chars]
60
+
61
+
62
+ def parse_prd_markdown(text: str, *, source_file: str = "") -> PrdDocument:
63
+ doc = PrdDocument(source_file=source_file)
64
+ current: PrdSection | None = None
65
+ prose: list[str] = []
66
+
67
+ def flush_prose() -> None:
68
+ nonlocal prose
69
+ if current is not None and prose:
70
+ current.text = " ".join(prose)[:600]
71
+ prose = []
72
+
73
+ for raw in text.splitlines():
74
+ line = raw.rstrip()
75
+ m = _HEADING.match(line)
76
+ if m:
77
+ flush_prose()
78
+ level = len(m.group(1))
79
+ heading = m.group(2).strip()
80
+ if not doc.title and level == 1:
81
+ doc.title = heading
82
+ current = None
83
+ continue
84
+ current = PrdSection(heading=heading, level=level)
85
+ doc.sections.append(current)
86
+ continue
87
+ b = _BULLET.match(line)
88
+ if b and current is not None:
89
+ current.requirements.append(b.group(1)[:300])
90
+ continue
91
+ if line.strip() and current is not None:
92
+ prose.append(line.strip())
93
+ flush_prose()
94
+
95
+ # PRD with no headings at all → one implicit section from the whole body.
96
+ if not doc.sections and text.strip():
97
+ doc.sections.append(PrdSection(heading="Requirements", level=2, text=text.strip()[:600]))
98
+ return doc
99
+
100
+
101
+ def load_prd(path: str | Path) -> PrdDocument:
102
+ p = Path(path)
103
+ if p.suffix.lower() not in (".md", ".markdown"):
104
+ raise ValueError(f"PRD must be a markdown file (.md), got: {p.name}")
105
+ return parse_prd_markdown(p.read_text(encoding="utf-8"), source_file=str(p))
106
+
107
+
108
+ __all__ = ["PrdDocument", "PrdSection", "load_prd", "parse_prd_markdown"]
@@ -0,0 +1,76 @@
1
+ """Blackbox reporter — route map, evidence index, bug candidates, summary JSON."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ if TYPE_CHECKING:
10
+ from suitest_lifecycle.blackbox.models import DiscoveryResult
11
+
12
+
13
+ def bug_candidates(discovery: DiscoveryResult) -> list[dict[str, Any]]:
14
+ """Deterministic findings worth a human look — NOT failed tests, DOM smells."""
15
+ out: list[dict[str, Any]] = []
16
+ for p in discovery.pages:
17
+ if p.blank:
18
+ out.append(
19
+ {"route": p.route, "kind": "blank_page", "detail": "page rendered no content"}
20
+ )
21
+ if p.pattern == "error":
22
+ out.append(
23
+ {"route": p.route, "kind": "error_page", "detail": p.visible_text_sample[:160]}
24
+ )
25
+ for err in p.console_errors[:5]:
26
+ out.append({"route": p.route, "kind": "console_error", "detail": err})
27
+ for err in p.network_errors[:5]:
28
+ out.append({"route": p.route, "kind": "network_error", "detail": err})
29
+ if (
30
+ discovery.login is not None
31
+ and discovery.login_probe.attempted
32
+ and not discovery.login_probe.success
33
+ ):
34
+ out.append(
35
+ {
36
+ "route": discovery.login.route,
37
+ "kind": "login_failed",
38
+ "detail": discovery.login_probe.detail,
39
+ }
40
+ )
41
+ return out
42
+
43
+
44
+ def summarize(
45
+ discovery: DiscoveryResult,
46
+ *,
47
+ graph: dict[str, Any] | None = None,
48
+ test_results: list[dict[str, Any]] | None = None,
49
+ ) -> dict[str, Any]:
50
+ routes = {p.route: p.pattern for p in discovery.pages}
51
+ return {
52
+ "baseUrl": discovery.base_url,
53
+ "loginDetected": discovery.login is not None,
54
+ "loginSucceeded": discovery.login_probe.success,
55
+ "routesDiscovered": len(discovery.pages),
56
+ "routeMap": routes,
57
+ "skippedRoutes": discovery.skipped_routes,
58
+ "screenshots": [p.screenshot for p in discovery.pages if p.screenshot],
59
+ "consoleErrorPages": [p.route for p in discovery.pages if p.console_errors],
60
+ "networkErrorPages": [p.route for p in discovery.pages if p.network_errors],
61
+ "bugCandidates": bug_candidates(discovery),
62
+ "graphNodes": len(graph["nodes"]) if graph else None,
63
+ "graphEdges": len(graph["edges"]) if graph else None,
64
+ "testResults": test_results or [],
65
+ "engineErrors": discovery.errors,
66
+ }
67
+
68
+
69
+ def write_report(report: dict[str, Any], out_dir: str | Path) -> str:
70
+ path = Path(out_dir) / "blackbox_report.json"
71
+ path.parent.mkdir(parents=True, exist_ok=True)
72
+ path.write_text(json.dumps(report, indent=2), encoding="utf-8")
73
+ return str(path)
74
+
75
+
76
+ __all__ = ["bug_candidates", "summarize", "write_report"]
@@ -0,0 +1,111 @@
1
+ """General selector strategy — rank an element's addressing options.
2
+
3
+ Priority (docs/BLACKBOX_UI_TESTING.md):
4
+ 1. data-testid / data-cy / data-test
5
+ 2. ARIA role + accessible name
6
+ 3. associated label text
7
+ 4. placeholder
8
+ 5. input type / name / autocomplete
9
+ 6. button text / link text
10
+ 7. stable CSS path fallback
11
+ 8. XPath only when literally nothing else exists (we synthesize one from the
12
+ CSS path, so in practice tier 7 always wins first)
13
+
14
+ The output is a **Playwright (python, async) locator expression string** that
15
+ generated tests embed verbatim, e.g. ``page.get_by_label("Email")``. The old
16
+ suitest-example testid convention is therefore just tier 1 — never a
17
+ requirement.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import TYPE_CHECKING
23
+
24
+ if TYPE_CHECKING:
25
+ from suitest_lifecycle.blackbox.models import ElementInfo
26
+
27
+ _IMPLICIT_ROLE = {
28
+ "button": "button",
29
+ "a": "link",
30
+ "select": "combobox",
31
+ "textarea": "textbox",
32
+ }
33
+
34
+
35
+ def _q(value: str) -> str:
36
+ """Escape a python double-quoted string literal."""
37
+ return value.replace("\\", "\\\\").replace('"', '\\"')
38
+
39
+
40
+ def _role_of(el: ElementInfo) -> str:
41
+ if el.role:
42
+ return el.role
43
+ if el.tag == "input":
44
+ t = el.input_type.lower()
45
+ if t in ("submit", "button"):
46
+ return "button"
47
+ if t == "checkbox":
48
+ return "checkbox"
49
+ if t == "radio":
50
+ return "radio"
51
+ return "textbox"
52
+ return _IMPLICIT_ROLE.get(el.tag, "")
53
+
54
+
55
+ def _accessible_name(el: ElementInfo) -> str:
56
+ return el.aria_label or el.label or el.text.strip()
57
+
58
+
59
+ def build_locator(el: ElementInfo, *, ignore_testids: bool = False) -> str:
60
+ """Best locator expression for ``el`` per the strategy above."""
61
+ # 1 — test attributes
62
+ if el.testid and not ignore_testids:
63
+ if el.testid_attr in ("", "data-testid"):
64
+ return f'page.get_by_test_id("{_q(el.testid)}")'
65
+ return f"page.locator('[{el.testid_attr}=\"{_q(el.testid)}\"]')"
66
+ # 2 — role + accessible name
67
+ role = _role_of(el)
68
+ name = _accessible_name(el)
69
+ if role in ("button", "link", "checkbox", "radio", "combobox") and name:
70
+ return f'page.get_by_role("{role}", name="{_q(name)}").first'
71
+ # 3 — label
72
+ if el.label:
73
+ return f'page.get_by_label("{_q(el.label)}").first'
74
+ # 4 — placeholder
75
+ if el.placeholder:
76
+ return f'page.get_by_placeholder("{_q(el.placeholder)}").first'
77
+ # 5 — input name / type / autocomplete
78
+ if el.tag in ("input", "textarea", "select"):
79
+ if el.name:
80
+ return f"page.locator('{el.tag}[name=\"{_q(el.name)}\"]').first"
81
+ if el.autocomplete:
82
+ return f"page.locator('{el.tag}[autocomplete=\"{_q(el.autocomplete)}\"]').first"
83
+ if el.input_type:
84
+ return f"page.locator('input[type=\"{_q(el.input_type)}\"]').first"
85
+ # 6 — visible text (buttons / links)
86
+ if el.text.strip():
87
+ if role:
88
+ return f'page.get_by_role("{role}", name="{_q(el.text.strip())}").first'
89
+ return f'page.get_by_text("{_q(el.text.strip())}", exact=False).first'
90
+ # 5b — dom id (stable-ish, before raw css)
91
+ if el.dom_id:
92
+ return f'page.locator("#{_q(el.dom_id)}")'
93
+ # 7 — stable CSS path
94
+ if el.css:
95
+ return f'page.locator("{_q(el.css)}").first'
96
+ # 8 — XPath as the absolute last resort
97
+ return f'page.locator("xpath=//{el.tag or "*"}").first'
98
+
99
+
100
+ def describe(el: ElementInfo) -> str:
101
+ """Short human label for reports/step descriptions."""
102
+ return (
103
+ el.label
104
+ or el.aria_label
105
+ or el.placeholder
106
+ or el.text.strip()
107
+ or el.name
108
+ or el.testid
109
+ or el.dom_id
110
+ or f"<{el.tag}>"
111
+ )[:60]