@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.
- package/LICENSE +201 -0
- package/README.md +77 -0
- package/bin/suitest-mcp.js +123 -0
- package/package.json +50 -0
- package/python/suitest_lifecycle/__init__.py +3 -0
- package/python/suitest_lifecycle/analyzers/__init__.py +1 -0
- package/python/suitest_lifecycle/analyzers/crawl.py +187 -0
- package/python/suitest_lifecycle/analyzers/express.py +226 -0
- package/python/suitest_lifecycle/analyzers/openapi.py +163 -0
- package/python/suitest_lifecycle/analyzers/postman.py +132 -0
- package/python/suitest_lifecycle/analyzers/react.py +107 -0
- package/python/suitest_lifecycle/analyzers/zod_schema.py +131 -0
- package/python/suitest_lifecycle/blackbox/__init__.py +11 -0
- package/python/suitest_lifecycle/blackbox/bootstrap.py +249 -0
- package/python/suitest_lifecycle/blackbox/crawler.py +383 -0
- package/python/suitest_lifecycle/blackbox/detector.py +169 -0
- package/python/suitest_lifecycle/blackbox/generator.py +608 -0
- package/python/suitest_lifecycle/blackbox/graph.py +107 -0
- package/python/suitest_lifecycle/blackbox/mcp.py +546 -0
- package/python/suitest_lifecycle/blackbox/models.py +299 -0
- package/python/suitest_lifecycle/blackbox/prd_ingest.py +108 -0
- package/python/suitest_lifecycle/blackbox/reporter.py +76 -0
- package/python/suitest_lifecycle/blackbox/selector.py +111 -0
- package/python/suitest_lifecycle/cli.py +127 -0
- package/python/suitest_lifecycle/config.py +314 -0
- package/python/suitest_lifecycle/enrich.py +140 -0
- package/python/suitest_lifecycle/exporters/__init__.py +1 -0
- package/python/suitest_lifecycle/exporters/backend.py +345 -0
- package/python/suitest_lifecycle/exporters/frontend.py +459 -0
- package/python/suitest_lifecycle/frontend_runtime.py +77 -0
- package/python/suitest_lifecycle/llm_bridge.py +365 -0
- package/python/suitest_lifecycle/mcp_server.py +187 -0
- package/python/suitest_lifecycle/models.py +166 -0
- package/python/suitest_lifecycle/orchestrator.py +500 -0
- package/python/suitest_lifecycle/paths.py +90 -0
- package/python/suitest_lifecycle/plan.py +366 -0
- package/python/suitest_lifecycle/plan_frontend.py +252 -0
- package/python/suitest_lifecycle/prd.py +92 -0
- package/python/suitest_lifecycle/process.py +111 -0
- package/python/suitest_lifecycle/publish.py +218 -0
- package/python/suitest_lifecycle/readiness.py +83 -0
- package/python/suitest_lifecycle/report.py +179 -0
- package/python/suitest_lifecycle/runner.py +138 -0
- package/python/suitest_lifecycle/serialize.py +131 -0
- package/python/suitest_lifecycle/tcm.py +149 -0
- 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]
|