@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,608 @@
|
|
|
1
|
+
"""Deterministic Playwright test generation from a blackbox discovery.
|
|
2
|
+
|
|
3
|
+
Reuses the existing frontend exporter wrapper (``_HEADER``/``_RUNNER``) so
|
|
4
|
+
generated tests inherit the whole evidence pipeline unchanged: per-step
|
|
5
|
+
screenshots, video, ``.result.json`` sidecars, runner + publish compatibility.
|
|
6
|
+
Only the ``_body`` per test is minted here — from DISCOVERED locators, never
|
|
7
|
+
from any hardcoded app convention.
|
|
8
|
+
|
|
9
|
+
SafeMode invariants:
|
|
10
|
+
* destructive controls are never clicked (see ``detector.is_destructive``)
|
|
11
|
+
* forms are only submitted EMPTY (validation probe) unless
|
|
12
|
+
``testGeneration.allowMutation`` is true
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from suitest_lifecycle.models import PlanCase, PlanStep, Priority
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from suitest_lifecycle.blackbox.models import BlackboxUiConfig, DiscoveryResult, PageInfo
|
|
23
|
+
from suitest_lifecycle.paths import Paths
|
|
24
|
+
|
|
25
|
+
# Heuristic safe-fill values (docs/BLACKBOX_UI_TESTING.md §safe form filling).
|
|
26
|
+
SAFE_FILL_SNIPPET = """
|
|
27
|
+
def _safe_value(kind, label=""):
|
|
28
|
+
import datetime
|
|
29
|
+
l = (label or "").lower()
|
|
30
|
+
if kind == "email" or "email" in l:
|
|
31
|
+
return "qa+" + uuid.uuid4().hex[:6] + "@example.com"
|
|
32
|
+
if kind == "number":
|
|
33
|
+
return "1"
|
|
34
|
+
if kind == "date":
|
|
35
|
+
return datetime.date.today().isoformat()
|
|
36
|
+
if kind == "textarea":
|
|
37
|
+
return "Automated QA test value"
|
|
38
|
+
return "Test value"
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _slugify(text: str) -> str:
|
|
43
|
+
import re
|
|
44
|
+
|
|
45
|
+
return re.sub(r"[^a-z0-9]+", "_", text.lower()).strip("_")[:80]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _case(
|
|
49
|
+
n: int,
|
|
50
|
+
title: str,
|
|
51
|
+
desc: str,
|
|
52
|
+
category: str,
|
|
53
|
+
prio: Priority,
|
|
54
|
+
ref: str,
|
|
55
|
+
steps: list[tuple[str, str]],
|
|
56
|
+
) -> PlanCase:
|
|
57
|
+
return PlanCase(
|
|
58
|
+
id=f"TC{n:03d}",
|
|
59
|
+
title=title,
|
|
60
|
+
description=desc,
|
|
61
|
+
category=category,
|
|
62
|
+
priority=prio,
|
|
63
|
+
source_ref=ref,
|
|
64
|
+
steps=[PlanStep(type=t, description=d) for t, d in steps],
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _login_snippet(discovery: DiscoveryResult) -> str:
|
|
69
|
+
"""Inline login helper built from DISCOVERED locators (not testids)."""
|
|
70
|
+
form = discovery.login
|
|
71
|
+
if form is None:
|
|
72
|
+
return ""
|
|
73
|
+
return f'''
|
|
74
|
+
async def _bb_login(page):
|
|
75
|
+
_begin("action", "Open the login page")
|
|
76
|
+
await page.goto(f"{{BASE_URL}}{form.route}", wait_until="domcontentloaded")
|
|
77
|
+
await _ok(page)
|
|
78
|
+
_begin("action", "Fill the username and password")
|
|
79
|
+
await {form.username}.fill(USERNAME)
|
|
80
|
+
await {form.password}.fill(PASSWORD)
|
|
81
|
+
await _ok(page)
|
|
82
|
+
_begin("action", "Submit the login form")
|
|
83
|
+
await {form.submit}.click()
|
|
84
|
+
try:
|
|
85
|
+
await page.wait_for_url(lambda u: "{form.route}" not in u, timeout=10000)
|
|
86
|
+
except Exception:
|
|
87
|
+
pass
|
|
88
|
+
await _ok(page)
|
|
89
|
+
_begin("assertion", "Login succeeded (left the login route)")
|
|
90
|
+
assert "{form.route}" not in page.url, f"still on login: {{page.url}}"
|
|
91
|
+
await _ok(page)
|
|
92
|
+
'''
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def generate_cases(discovery: DiscoveryResult, cfg: BlackboxUiConfig) -> list[tuple[PlanCase, str]]:
|
|
96
|
+
"""Return ``(case, body_source)`` pairs, deterministic order."""
|
|
97
|
+
out: list[tuple[PlanCase, str]] = []
|
|
98
|
+
n = 0
|
|
99
|
+
gen = cfg.test_generation
|
|
100
|
+
login = discovery.login
|
|
101
|
+
needs_login = login is not None and discovery.login_probe.success
|
|
102
|
+
|
|
103
|
+
def nid() -> int:
|
|
104
|
+
nonlocal n
|
|
105
|
+
n += 1
|
|
106
|
+
return n
|
|
107
|
+
|
|
108
|
+
prelude = " await _bb_login(page)\n" if needs_login else ""
|
|
109
|
+
|
|
110
|
+
# -- smoke ---------------------------------------------------------------
|
|
111
|
+
if gen.include_smoke:
|
|
112
|
+
out.append(
|
|
113
|
+
(
|
|
114
|
+
_case(
|
|
115
|
+
nid(),
|
|
116
|
+
"app_loads_successfully",
|
|
117
|
+
"The application root responds and renders visible content.",
|
|
118
|
+
"Smoke",
|
|
119
|
+
Priority.HIGH,
|
|
120
|
+
"bb:smoke /",
|
|
121
|
+
[
|
|
122
|
+
("action", "Open the application root"),
|
|
123
|
+
("assertion", "Body has visible content and a title"),
|
|
124
|
+
],
|
|
125
|
+
),
|
|
126
|
+
"""
|
|
127
|
+
async def _body(page):
|
|
128
|
+
_begin("action", "Open the application root")
|
|
129
|
+
await page.goto(f"{BASE_URL}/", wait_until="domcontentloaded")
|
|
130
|
+
await _ok(page)
|
|
131
|
+
_begin("assertion", "Body has visible content")
|
|
132
|
+
await expect(page.locator("body")).to_be_visible(timeout=TIMEOUT)
|
|
133
|
+
text = await page.evaluate("() => document.body.innerText.trim().length")
|
|
134
|
+
assert text > 0, "page rendered no visible text (blank page)"
|
|
135
|
+
await _ok(page)
|
|
136
|
+
""",
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
out.append(
|
|
140
|
+
(
|
|
141
|
+
_case(
|
|
142
|
+
nid(),
|
|
143
|
+
"no_critical_console_errors_on_load",
|
|
144
|
+
"Loading the app produces no uncaught exceptions or console errors.",
|
|
145
|
+
"Smoke",
|
|
146
|
+
Priority.MEDIUM,
|
|
147
|
+
"bb:smoke /",
|
|
148
|
+
[
|
|
149
|
+
("action", "Open the application root with a console listener"),
|
|
150
|
+
("assertion", "No console.error / uncaught exceptions were emitted"),
|
|
151
|
+
],
|
|
152
|
+
),
|
|
153
|
+
"""
|
|
154
|
+
async def _body(page):
|
|
155
|
+
errors = []
|
|
156
|
+
page.on("console", lambda m: errors.append(m.text[:200]) if m.type == "error" else None)
|
|
157
|
+
page.on("pageerror", lambda e: errors.append(str(e)[:200]))
|
|
158
|
+
_begin("action", "Open the application root")
|
|
159
|
+
await page.goto(f"{BASE_URL}/", wait_until="domcontentloaded")
|
|
160
|
+
await page.wait_for_timeout(1500)
|
|
161
|
+
await _ok(page)
|
|
162
|
+
_begin("assertion", "No critical console errors")
|
|
163
|
+
critical = [e for e in errors if "favicon" not in e.lower()]
|
|
164
|
+
assert not critical, f"console errors: {critical[:3]}"
|
|
165
|
+
await _ok(page)
|
|
166
|
+
""",
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# -- auth ------------------------------------------------------------------
|
|
171
|
+
if gen.include_auth and login is not None:
|
|
172
|
+
out.append(
|
|
173
|
+
(
|
|
174
|
+
_case(
|
|
175
|
+
nid(),
|
|
176
|
+
"login_with_valid_credentials_succeeds",
|
|
177
|
+
"Valid credentials authenticate and leave the login route.",
|
|
178
|
+
"Auth",
|
|
179
|
+
Priority.HIGH,
|
|
180
|
+
f"bb:auth {login.route}",
|
|
181
|
+
[
|
|
182
|
+
("action", "Log in with valid credentials"),
|
|
183
|
+
("assertion", "The app navigates away from the login route"),
|
|
184
|
+
],
|
|
185
|
+
),
|
|
186
|
+
"""
|
|
187
|
+
async def _body(page):
|
|
188
|
+
await _bb_login(page)
|
|
189
|
+
""",
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
out.append(
|
|
193
|
+
(
|
|
194
|
+
_case(
|
|
195
|
+
nid(),
|
|
196
|
+
"login_with_invalid_credentials_fails",
|
|
197
|
+
"A wrong password keeps the user on the login route (with feedback).",
|
|
198
|
+
"Auth",
|
|
199
|
+
Priority.HIGH,
|
|
200
|
+
f"bb:auth {login.route}",
|
|
201
|
+
[
|
|
202
|
+
("action", "Submit the login form with a wrong password"),
|
|
203
|
+
("assertion", "Still on the login route"),
|
|
204
|
+
],
|
|
205
|
+
),
|
|
206
|
+
f'''
|
|
207
|
+
async def _body(page):
|
|
208
|
+
_begin("action", "Open the login page")
|
|
209
|
+
await page.goto(f"{{BASE_URL}}{login.route}", wait_until="domcontentloaded")
|
|
210
|
+
await _ok(page)
|
|
211
|
+
_begin("action", "Submit a wrong password")
|
|
212
|
+
await {login.username}.fill(USERNAME)
|
|
213
|
+
await {login.password}.fill("wrong-password-" + uuid.uuid4().hex[:6])
|
|
214
|
+
await {login.submit}.click()
|
|
215
|
+
await page.wait_for_timeout(1500)
|
|
216
|
+
await _ok(page)
|
|
217
|
+
_begin("assertion", "Still on the login route")
|
|
218
|
+
assert "{login.route}" in page.url, f"unexpectedly left login: {{page.url}}"
|
|
219
|
+
await _ok(page)
|
|
220
|
+
''',
|
|
221
|
+
)
|
|
222
|
+
)
|
|
223
|
+
if needs_login:
|
|
224
|
+
landed = discovery.login_probe.landed_route
|
|
225
|
+
out.append(
|
|
226
|
+
(
|
|
227
|
+
_case(
|
|
228
|
+
nid(),
|
|
229
|
+
"authenticated_landing_page_renders",
|
|
230
|
+
f"After login the app lands on {landed} and renders content.",
|
|
231
|
+
"Auth",
|
|
232
|
+
Priority.HIGH,
|
|
233
|
+
f"bb:auth {landed}",
|
|
234
|
+
[
|
|
235
|
+
("action", "Log in"),
|
|
236
|
+
("assertion", "Landing page renders visible content"),
|
|
237
|
+
],
|
|
238
|
+
),
|
|
239
|
+
"""
|
|
240
|
+
async def _body(page):
|
|
241
|
+
await _bb_login(page)
|
|
242
|
+
_begin("assertion", "Landing page renders visible content")
|
|
243
|
+
text = await page.evaluate("() => document.body.innerText.trim().length")
|
|
244
|
+
assert text > 50, "landing page looks blank"
|
|
245
|
+
await _ok(page)
|
|
246
|
+
""",
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
# -- navigation -------------------------------------------------------------
|
|
251
|
+
crawled = [
|
|
252
|
+
p for p in discovery.pages if p.pattern not in ("login", "not_found") and not p.protected
|
|
253
|
+
]
|
|
254
|
+
if gen.include_navigation and crawled:
|
|
255
|
+
routes = [p.route for p in crawled][:8]
|
|
256
|
+
routes_literal = ", ".join(f'"{r}"' for r in routes)
|
|
257
|
+
out.append(
|
|
258
|
+
(
|
|
259
|
+
_case(
|
|
260
|
+
nid(),
|
|
261
|
+
"main_navigation_does_not_crash",
|
|
262
|
+
"Every discovered route loads without a blank page or crash.",
|
|
263
|
+
"Navigation",
|
|
264
|
+
Priority.HIGH,
|
|
265
|
+
"bb:navigation /",
|
|
266
|
+
[
|
|
267
|
+
("action", "Visit every discovered route"),
|
|
268
|
+
("assertion", "Each route renders visible content"),
|
|
269
|
+
],
|
|
270
|
+
),
|
|
271
|
+
f"""
|
|
272
|
+
async def _body(page):
|
|
273
|
+
{prelude} for route in [{routes_literal}]:
|
|
274
|
+
_begin("action", "Open " + route)
|
|
275
|
+
await page.goto(f"{{BASE_URL}}" + route, wait_until="domcontentloaded")
|
|
276
|
+
await page.wait_for_timeout(600)
|
|
277
|
+
await _ok(page)
|
|
278
|
+
_begin("assertion", route + " renders content and no crash text")
|
|
279
|
+
text = await page.evaluate("() => document.body.innerText.trim()")
|
|
280
|
+
assert len(text) > 0, route + " rendered blank"
|
|
281
|
+
low = text.lower()
|
|
282
|
+
assert "internal server error" not in low and "traceback" not in low, route + " crashed"
|
|
283
|
+
await _ok(page)
|
|
284
|
+
""",
|
|
285
|
+
)
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
# -- tables / lists -----------------------------------------------------------
|
|
289
|
+
if gen.include_tables:
|
|
290
|
+
for p in [p for p in crawled if p.has_table and p.row_locator][:3]:
|
|
291
|
+
out.append(
|
|
292
|
+
(
|
|
293
|
+
_case(
|
|
294
|
+
nid(),
|
|
295
|
+
f"list_page_{_slugify(p.route)}_renders_rows_or_empty_state",
|
|
296
|
+
f"The list on {p.route} renders rows (or a legitimate empty state).",
|
|
297
|
+
"Lists",
|
|
298
|
+
Priority.MEDIUM,
|
|
299
|
+
f"bb:table {p.route}",
|
|
300
|
+
[
|
|
301
|
+
("action", f"Open {p.route}"),
|
|
302
|
+
("assertion", "Rows are rendered or an empty state is shown"),
|
|
303
|
+
],
|
|
304
|
+
),
|
|
305
|
+
f"""
|
|
306
|
+
async def _body(page):
|
|
307
|
+
{prelude} _begin("action", "Open {p.route}")
|
|
308
|
+
await page.goto(f"{{BASE_URL}}{p.route}", wait_until="domcontentloaded")
|
|
309
|
+
await page.wait_for_timeout(800)
|
|
310
|
+
await _ok(page)
|
|
311
|
+
_begin("assertion", "Rows render or an empty state is visible")
|
|
312
|
+
rows = await {p.row_locator}.count()
|
|
313
|
+
if rows == 0:
|
|
314
|
+
text = (await page.evaluate("() => document.body.innerText")).lower()
|
|
315
|
+
assert any(k in text for k in ("no ", "empty", "tidak ada")), "no rows and no empty state"
|
|
316
|
+
await _ok(page)
|
|
317
|
+
""",
|
|
318
|
+
)
|
|
319
|
+
)
|
|
320
|
+
if p.search_locator:
|
|
321
|
+
out.append(
|
|
322
|
+
(
|
|
323
|
+
_case(
|
|
324
|
+
nid(),
|
|
325
|
+
f"search_on_{_slugify(p.route)}_filters_results",
|
|
326
|
+
f"Typing an unlikely query into the search on {p.route} narrows the list.",
|
|
327
|
+
"Lists",
|
|
328
|
+
Priority.LOW,
|
|
329
|
+
f"bb:search {p.route}",
|
|
330
|
+
[
|
|
331
|
+
("action", "Type an unlikely search query"),
|
|
332
|
+
("assertion", "Fewer/zero rows or an empty state"),
|
|
333
|
+
],
|
|
334
|
+
),
|
|
335
|
+
f"""
|
|
336
|
+
async def _body(page):
|
|
337
|
+
{prelude} _begin("action", "Open {p.route}")
|
|
338
|
+
await page.goto(f"{{BASE_URL}}{p.route}", wait_until="domcontentloaded")
|
|
339
|
+
await page.wait_for_timeout(600)
|
|
340
|
+
before = await {p.row_locator}.count()
|
|
341
|
+
await _ok(page)
|
|
342
|
+
_begin("action", "Type an unlikely search query")
|
|
343
|
+
await {p.search_locator}.fill("zzz-no-match-" + uuid.uuid4().hex[:6])
|
|
344
|
+
await page.wait_for_timeout(800)
|
|
345
|
+
await _ok(page)
|
|
346
|
+
_begin("assertion", "Result set narrowed")
|
|
347
|
+
after = await {p.row_locator}.count()
|
|
348
|
+
assert after <= before, f"rows grew from {{before}} to {{after}} after searching"
|
|
349
|
+
await _ok(page)
|
|
350
|
+
""",
|
|
351
|
+
)
|
|
352
|
+
)
|
|
353
|
+
if p.pagination_locator:
|
|
354
|
+
out.append(
|
|
355
|
+
(
|
|
356
|
+
_case(
|
|
357
|
+
nid(),
|
|
358
|
+
f"pagination_on_{_slugify(p.route)}_is_operable",
|
|
359
|
+
f"The pagination control on {p.route} can be activated without a crash.",
|
|
360
|
+
"Lists",
|
|
361
|
+
Priority.LOW,
|
|
362
|
+
f"bb:pagination {p.route}",
|
|
363
|
+
[
|
|
364
|
+
("action", "Click the next-page control if enabled"),
|
|
365
|
+
("assertion", "The list still renders"),
|
|
366
|
+
],
|
|
367
|
+
),
|
|
368
|
+
f"""
|
|
369
|
+
async def _body(page):
|
|
370
|
+
{prelude} _begin("action", "Open {p.route}")
|
|
371
|
+
await page.goto(f"{{BASE_URL}}{p.route}", wait_until="domcontentloaded")
|
|
372
|
+
await page.wait_for_timeout(600)
|
|
373
|
+
await _ok(page)
|
|
374
|
+
_begin("action", "Activate the next-page control when enabled")
|
|
375
|
+
pager = {p.pagination_locator}
|
|
376
|
+
if await pager.count() > 0 and await pager.first.is_enabled():
|
|
377
|
+
await pager.first.click()
|
|
378
|
+
await page.wait_for_timeout(800)
|
|
379
|
+
await _ok(page)
|
|
380
|
+
_begin("assertion", "The page still renders content")
|
|
381
|
+
text = await page.evaluate("() => document.body.innerText.trim()")
|
|
382
|
+
assert len(text) > 0, "page went blank after pagination"
|
|
383
|
+
await _ok(page)
|
|
384
|
+
""",
|
|
385
|
+
)
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
# -- forms (safe validation probe only) ----------------------------------------
|
|
389
|
+
if gen.include_forms:
|
|
390
|
+
form_pages = [p for p in crawled if p.has_form and p.pattern == "form"][:2]
|
|
391
|
+
for p in form_pages:
|
|
392
|
+
submit = _first_safe_submit(p)
|
|
393
|
+
if submit is None:
|
|
394
|
+
continue
|
|
395
|
+
out.append(
|
|
396
|
+
(
|
|
397
|
+
_case(
|
|
398
|
+
nid(),
|
|
399
|
+
f"form_on_{_slugify(p.route)}_validates_empty_required_fields",
|
|
400
|
+
f"Submitting the form on {p.route} empty keeps the user on the form "
|
|
401
|
+
"(client validation) — a SAFE probe, nothing is persisted.",
|
|
402
|
+
"Forms",
|
|
403
|
+
Priority.MEDIUM,
|
|
404
|
+
f"bb:form {p.route}",
|
|
405
|
+
[
|
|
406
|
+
("action", f"Open {p.route} and submit the form empty"),
|
|
407
|
+
("assertion", "Still on the form (validation blocked the submit)"),
|
|
408
|
+
],
|
|
409
|
+
),
|
|
410
|
+
f'''
|
|
411
|
+
async def _body(page):
|
|
412
|
+
{prelude} _begin("action", "Open {p.route}")
|
|
413
|
+
await page.goto(f"{{BASE_URL}}{p.route}", wait_until="domcontentloaded")
|
|
414
|
+
await page.wait_for_timeout(600)
|
|
415
|
+
await _ok(page)
|
|
416
|
+
_begin("action", "Submit the form with every field left empty")
|
|
417
|
+
await {submit}.click()
|
|
418
|
+
await page.wait_for_timeout(800)
|
|
419
|
+
await _ok(page)
|
|
420
|
+
_begin("assertion", "Still on the form route (validation held)")
|
|
421
|
+
assert "{p.route}" in page.url, f"empty submit navigated away to {{page.url}}"
|
|
422
|
+
await _ok(page)
|
|
423
|
+
''',
|
|
424
|
+
)
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
# -- modal open/close ------------------------------------------------------------
|
|
428
|
+
modal_pages = [p for p in crawled if p.has_modal][:1]
|
|
429
|
+
for p in modal_pages:
|
|
430
|
+
out.append(
|
|
431
|
+
(
|
|
432
|
+
_case(
|
|
433
|
+
nid(),
|
|
434
|
+
f"modal_on_{_slugify(p.route)}_can_be_dismissed",
|
|
435
|
+
f"A dialog observed on {p.route} can be dismissed with Escape.",
|
|
436
|
+
"Modals",
|
|
437
|
+
Priority.LOW,
|
|
438
|
+
f"bb:modal {p.route}",
|
|
439
|
+
[
|
|
440
|
+
("action", f"Open {p.route} and press Escape"),
|
|
441
|
+
("assertion", "No blocking dialog remains"),
|
|
442
|
+
],
|
|
443
|
+
),
|
|
444
|
+
f"""
|
|
445
|
+
async def _body(page):
|
|
446
|
+
{prelude} _begin("action", "Open {p.route}")
|
|
447
|
+
await page.goto(f"{{BASE_URL}}{p.route}", wait_until="domcontentloaded")
|
|
448
|
+
await page.wait_for_timeout(800)
|
|
449
|
+
await _ok(page)
|
|
450
|
+
_begin("action", "Press Escape to dismiss any dialog")
|
|
451
|
+
await page.keyboard.press("Escape")
|
|
452
|
+
await page.wait_for_timeout(500)
|
|
453
|
+
await _ok(page)
|
|
454
|
+
_begin("assertion", "Page still renders after dismissing")
|
|
455
|
+
await expect(page.locator("body")).to_be_visible(timeout=TIMEOUT)
|
|
456
|
+
await _ok(page)
|
|
457
|
+
""",
|
|
458
|
+
)
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
return out
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _first_safe_submit(p: PageInfo) -> str | None:
|
|
465
|
+
from suitest_lifecycle.blackbox.detector import is_destructive
|
|
466
|
+
from suitest_lifecycle.blackbox.selector import build_locator
|
|
467
|
+
|
|
468
|
+
for b in p.buttons:
|
|
469
|
+
if is_destructive(b):
|
|
470
|
+
continue
|
|
471
|
+
blob = f"{b.text} {b.input_type}".lower()
|
|
472
|
+
if b.input_type == "submit" or any(
|
|
473
|
+
k in blob for k in ("save", "create", "submit", "simpan", "add")
|
|
474
|
+
):
|
|
475
|
+
return build_locator(b)
|
|
476
|
+
return None
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
_PRIORITY_FROM_STR = {
|
|
480
|
+
"high": Priority.HIGH,
|
|
481
|
+
"medium": Priority.MEDIUM,
|
|
482
|
+
"low": Priority.LOW,
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def prd_cases(
|
|
487
|
+
discovery: DiscoveryResult,
|
|
488
|
+
cfg: BlackboxUiConfig,
|
|
489
|
+
llm: object,
|
|
490
|
+
prd_context: str,
|
|
491
|
+
existing_titles: set[str],
|
|
492
|
+
*,
|
|
493
|
+
start_n: int,
|
|
494
|
+
) -> list[tuple[PlanCase, str]]:
|
|
495
|
+
"""PRD-driven cases: LLM plans from the uploaded spec, then writes each
|
|
496
|
+
body against the DISCOVERED locators. A case whose generated body fails
|
|
497
|
+
validation degrades to a safe route-render probe instead of vanishing —
|
|
498
|
+
the plan stays PRD-complete either way.
|
|
499
|
+
"""
|
|
500
|
+
from suitest_lifecycle.llm_bridge import build_dom_context_from_discovery
|
|
501
|
+
|
|
502
|
+
plan = getattr(llm, "plan_from_prd", None)
|
|
503
|
+
codegen = getattr(llm, "generate_frontend_body", None)
|
|
504
|
+
if plan is None or codegen is None:
|
|
505
|
+
return []
|
|
506
|
+
app_context = build_dom_context_from_discovery(discovery)
|
|
507
|
+
known_routes = {p.route for p in discovery.pages}
|
|
508
|
+
raw = plan(
|
|
509
|
+
prd_context,
|
|
510
|
+
app_context,
|
|
511
|
+
existing_titles,
|
|
512
|
+
allow_mutation=cfg.test_generation.allow_mutation,
|
|
513
|
+
)
|
|
514
|
+
out: list[tuple[PlanCase, str]] = []
|
|
515
|
+
n = start_n
|
|
516
|
+
for item in raw:
|
|
517
|
+
route = str(item.get("route", "/"))
|
|
518
|
+
if route not in known_routes:
|
|
519
|
+
route = "/"
|
|
520
|
+
steps_raw = item.get("steps") or []
|
|
521
|
+
steps: list[tuple[str, str]] = []
|
|
522
|
+
for st in steps_raw:
|
|
523
|
+
if isinstance(st, (list, tuple)) and len(st) == 2:
|
|
524
|
+
kind = "assertion" if str(st[0]).lower() == "assertion" else "action"
|
|
525
|
+
steps.append((kind, str(st[1])[:200]))
|
|
526
|
+
if not steps:
|
|
527
|
+
continue
|
|
528
|
+
n += 1
|
|
529
|
+
case = _case(
|
|
530
|
+
n,
|
|
531
|
+
str(item.get("title", f"prd_case_{n}"))[:200],
|
|
532
|
+
str(item.get("description", "Verifies an uploaded-PRD requirement."))[:500],
|
|
533
|
+
str(item.get("category", "PRD"))[:60] or "PRD",
|
|
534
|
+
_PRIORITY_FROM_STR.get(str(item.get("priority", "")).lower(), Priority.MEDIUM),
|
|
535
|
+
f"bb:prd {route}",
|
|
536
|
+
steps,
|
|
537
|
+
)
|
|
538
|
+
body = codegen(case, app_context)
|
|
539
|
+
if not body:
|
|
540
|
+
body = f"""
|
|
541
|
+
async def _body(page):
|
|
542
|
+
await _bb_login(page)
|
|
543
|
+
_begin("action", "Open {route} (PRD fallback probe)")
|
|
544
|
+
await page.goto(f"{{BASE_URL}}{route}", wait_until="domcontentloaded")
|
|
545
|
+
await page.wait_for_timeout(800)
|
|
546
|
+
await _ok(page)
|
|
547
|
+
_begin("assertion", "Route renders content (PRD case degraded to render probe)")
|
|
548
|
+
text = await page.evaluate("() => document.body.innerText.trim()")
|
|
549
|
+
assert len(text) > 0, "{route} rendered blank"
|
|
550
|
+
await _ok(page)
|
|
551
|
+
"""
|
|
552
|
+
else:
|
|
553
|
+
body = "\n" + body.rstrip() + "\n"
|
|
554
|
+
out.append((case, body))
|
|
555
|
+
return out
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _write_pairs(
|
|
559
|
+
pairs: list[tuple[PlanCase, str]],
|
|
560
|
+
discovery: DiscoveryResult,
|
|
561
|
+
cfg: BlackboxUiConfig,
|
|
562
|
+
paths: Paths,
|
|
563
|
+
) -> list[PlanCase]:
|
|
564
|
+
from suitest_lifecycle.exporters.frontend import _HEADER, _RUNNER
|
|
565
|
+
|
|
566
|
+
paths.ensure()
|
|
567
|
+
login_helper = _login_snippet(discovery)
|
|
568
|
+
cases: list[PlanCase] = []
|
|
569
|
+
for case, body in pairs:
|
|
570
|
+
header = _HEADER.format(
|
|
571
|
+
base_url=cfg.target_url,
|
|
572
|
+
username=cfg.auth.username,
|
|
573
|
+
password=cfg.auth.password,
|
|
574
|
+
cid=case.id,
|
|
575
|
+
)
|
|
576
|
+
# Drop the exporter's legacy testid-bound ``_login`` helper — blackbox
|
|
577
|
+
# bodies use ``_bb_login`` built from DISCOVERED locators instead.
|
|
578
|
+
header = header.split("\nasync def _login(page):", 1)[0] + "\n"
|
|
579
|
+
code = header + SAFE_FILL_SNIPPET + login_helper + body + _RUNNER
|
|
580
|
+
filename = f"{case.id}_{case.title}.py"
|
|
581
|
+
paths.test_file(filename).write_text(code, encoding="utf-8")
|
|
582
|
+
case.automation_file = filename
|
|
583
|
+
cases.append(case)
|
|
584
|
+
return cases
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def export_blackbox_tests(
|
|
588
|
+
discovery: DiscoveryResult,
|
|
589
|
+
cfg: BlackboxUiConfig,
|
|
590
|
+
paths: Paths,
|
|
591
|
+
*,
|
|
592
|
+
llm: object | None = None,
|
|
593
|
+
prd_context: str = "",
|
|
594
|
+
) -> list[PlanCase]:
|
|
595
|
+
"""Write runnable TCxxx.py files (evidence wrapper included); return cases.
|
|
596
|
+
|
|
597
|
+
Deterministic baseline always; when an uploaded PRD + LLM bridge are
|
|
598
|
+
available, PRD-driven semantic cases are appended (TestSprite-parity
|
|
599
|
+
upload flow).
|
|
600
|
+
"""
|
|
601
|
+
pairs = generate_cases(discovery, cfg)
|
|
602
|
+
if llm is not None and prd_context:
|
|
603
|
+
existing = {c.title for c, _ in pairs}
|
|
604
|
+
pairs += prd_cases(discovery, cfg, llm, prd_context, existing, start_n=len(pairs))
|
|
605
|
+
return _write_pairs(pairs, discovery, cfg, paths)
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
__all__ = ["export_blackbox_tests", "generate_cases", "prd_cases"]
|