@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,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"]