@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,459 @@
1
+ """Render runnable frontend ``TCxxx.py`` files (Playwright async) with recording.
2
+
3
+ Matches the TestSprite detail view: structured async session (chromium launch
4
+ args, ``new_context``, ``set_default_timeout(15000)``), one block per step, a
5
+ **video** recorded per test (``record_video_dir``), a per-step trace + final
6
+ screenshot written to a ``<TC>.result.json`` sidecar. Each file is standalone;
7
+ Suitest provides the browser (user installs nothing).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING
13
+
14
+ if TYPE_CHECKING:
15
+ from suitest_lifecycle.config import Config
16
+ from suitest_lifecycle.models import CodeSummary, PlanCase
17
+ from suitest_lifecycle.paths import Paths
18
+
19
+ _HEADER = """import asyncio
20
+ import glob
21
+ import json
22
+ import os
23
+ import uuid
24
+
25
+ from playwright.async_api import async_playwright, expect
26
+
27
+ BASE_URL = "{base_url}"
28
+ USERNAME = "{username}"
29
+ PASSWORD = "{password}"
30
+ TIMEOUT = 15000
31
+ TC_ID = "{cid}"
32
+
33
+ # --- evidence pacing (watchable, step-by-step video) -------------------------
34
+ # The lifecycle video is human EVIDENCE, so it must NOT run at machine speed.
35
+ # We hold each step on screen (``_STEP_PAUSE_MS``) and slow every Playwright
36
+ # action (``_SLOWMO_MS``) so the recording reads step-by-step. Defaults are on;
37
+ # set SUITEST_EVIDENCE_RECORDING=false (or the pause to 0) for a fast run.
38
+ _EVIDENCE = os.environ.get("SUITEST_EVIDENCE_RECORDING", "true").lower() not in ("0", "false", "no")
39
+ _STEP_PAUSE_MS = int(os.environ.get("SUITEST_EVIDENCE_PAUSE_MS", "1200"))
40
+ _SLOWMO_MS = int(os.environ.get("SUITEST_EVIDENCE_SLOWMO_MS", "300"))
41
+
42
+ _HERE = os.path.dirname(os.path.abspath(__file__))
43
+ _TMP = os.path.join(_HERE, "tmp")
44
+ _VIDEO_DIR = os.path.join(_TMP, "videos", TC_ID)
45
+ _RESULT = os.path.join(_HERE, TC_ID + ".result.json")
46
+
47
+ # --- per-step recorder (feeds the web Steps panel) ---------------------------
48
+ STEPS = []
49
+ _cur = {{"index": 0, "type": "action", "description": ""}}
50
+ _N = [0]
51
+
52
+
53
+ def _begin(step_type, description):
54
+ _N[0] += 1
55
+ _cur.update(index=_N[0], type=step_type, description=description)
56
+
57
+
58
+ async def _shot(page):
59
+ # Per-step screenshot — powers the web "Preview: Step N" (TestSprite parity).
60
+ path = os.path.join(_TMP, TC_ID + "_step" + str(_cur["index"]) + ".png")
61
+ try:
62
+ os.makedirs(_TMP, exist_ok=True)
63
+ await page.screenshot(path=path)
64
+ return path
65
+ except Exception:
66
+ return ""
67
+
68
+
69
+ async def _ok(page):
70
+ shot = await _shot(page)
71
+ # Hold the finished step on screen so the recorded video reads step-by-step
72
+ # instead of flashing past in ~1s. Screenshot is taken BEFORE the hold so it
73
+ # captures the step's end state, not the idle pause.
74
+ if _EVIDENCE and _STEP_PAUSE_MS > 0:
75
+ try:
76
+ await page.wait_for_timeout(_STEP_PAUSE_MS)
77
+ except Exception:
78
+ pass
79
+ STEPS.append(dict(_cur, status="PASSED", screenshot=shot))
80
+
81
+
82
+ async def _login(page):
83
+ # Authenticate through the real login form.
84
+ _begin("action", "Navigate to /login")
85
+ await page.goto(f"{{BASE_URL}}/login")
86
+ await _ok(page)
87
+ _begin("action", f"Fill '{{USERNAME}}' into the email field")
88
+ await page.get_by_test_id("login-email-input").fill(USERNAME)
89
+ await _ok(page)
90
+ _begin("action", "Fill the password field")
91
+ await page.get_by_test_id("login-password-input").fill(PASSWORD)
92
+ await _ok(page)
93
+ _begin("action", "Click 'Sign in'")
94
+ await page.get_by_test_id("login-submit-button").click()
95
+ await _ok(page)
96
+ _begin("assertion", "Dashboard is visible")
97
+ await expect(page.get_by_test_id("dashboard-page")).to_be_visible(timeout=TIMEOUT)
98
+ await _ok(page)
99
+ """
100
+
101
+ _RUNNER = """
102
+
103
+ async def run_test():
104
+ pw = browser = context = page = None
105
+ status = "PASSED"
106
+ error = ""
107
+ screenshot = os.path.join(_TMP, TC_ID + "_final.png")
108
+ try:
109
+ os.makedirs(_VIDEO_DIR, exist_ok=True)
110
+ # Start a Playwright session in asynchronous mode.
111
+ pw = await async_playwright().start()
112
+ # Launch a Chromium browser in headless mode with custom arguments.
113
+ browser = await pw.chromium.launch(
114
+ headless=True,
115
+ # slow_mo delays each action so intra-step motion (typing, clicks,
116
+ # navigation) is visible in the recorded video, not just the gaps.
117
+ slow_mo=_SLOWMO_MS if _EVIDENCE else 0,
118
+ args=["--window-size=1280,720", "--disable-dev-shm-usage", "--ipc=host"],
119
+ )
120
+ # Create a new browser context that records video of the whole run.
121
+ context = await browser.new_context(
122
+ record_video_dir=_VIDEO_DIR, viewport={"width": 1280, "height": 720}
123
+ )
124
+ # Wider default timeout so auto-waiting Playwright APIs inherit it.
125
+ context.set_default_timeout(TIMEOUT)
126
+ page = await context.new_page()
127
+ await _body(page)
128
+ # Hold the final state so the video's last frames aren't clipped when the
129
+ # context closes (Playwright stops recording on context.close()).
130
+ if _EVIDENCE and _STEP_PAUSE_MS > 0:
131
+ try:
132
+ await page.wait_for_timeout(_STEP_PAUSE_MS)
133
+ except Exception:
134
+ pass
135
+ try:
136
+ await page.screenshot(path=screenshot)
137
+ except Exception:
138
+ pass
139
+ except Exception as exc: # record the failing step + capture a screenshot
140
+ status = "FAILED"
141
+ error = str(exc)
142
+ _fail_shot = ""
143
+ if page is not None:
144
+ try:
145
+ _fail_shot = os.path.join(_TMP, TC_ID + "_step" + str(_cur["index"]) + ".png")
146
+ await page.screenshot(path=_fail_shot)
147
+ except Exception:
148
+ _fail_shot = ""
149
+ try:
150
+ await page.screenshot(path=screenshot)
151
+ except Exception:
152
+ pass
153
+ STEPS.append(dict(_cur, status="FAILED", screenshot=_fail_shot))
154
+ finally:
155
+ if context is not None:
156
+ try:
157
+ await context.close()
158
+ except Exception:
159
+ pass
160
+ if browser is not None:
161
+ try:
162
+ await browser.close()
163
+ except Exception:
164
+ pass
165
+ if pw is not None:
166
+ try:
167
+ await pw.stop()
168
+ except Exception:
169
+ pass
170
+ vids = sorted(glob.glob(os.path.join(_VIDEO_DIR, "*.webm")))
171
+ with open(_RESULT, "w", encoding="utf-8") as fh:
172
+ json.dump(
173
+ {
174
+ "testId": TC_ID,
175
+ "status": status,
176
+ "error": error,
177
+ "steps": STEPS,
178
+ "video": vids[-1] if vids else None,
179
+ "screenshot": screenshot if os.path.exists(screenshot) else None,
180
+ },
181
+ fh,
182
+ )
183
+ if status != "PASSED":
184
+ raise AssertionError(error or "test failed")
185
+
186
+
187
+ if __name__ == "__main__":
188
+ asyncio.run(run_test())
189
+ print("PASS " + TC_ID)
190
+ """
191
+
192
+
193
+ def _body(case: PlanCase) -> str:
194
+ arch = case.source_ref.split(" ", 1)[0].replace("fe:", "")
195
+ route = case.source_ref.split(" ", 1)[1] if " " in case.source_ref else "/"
196
+
197
+ if arch == "login_success":
198
+ return """
199
+ async def _body(page):
200
+ await _login(page)
201
+ _begin("assertion", "Dashboard summary is visible")
202
+ await expect(page.get_by_test_id("dashboard-page")).to_be_visible(timeout=TIMEOUT)
203
+ await _ok(page)
204
+ """
205
+ if arch == "invalid_login":
206
+ return """
207
+ async def _body(page):
208
+ _begin("action", "Navigate to /login")
209
+ await page.goto(f"{BASE_URL}/login")
210
+ await _ok(page)
211
+ _begin("action", "Fill an invalid password")
212
+ await page.get_by_test_id("login-email-input").fill(USERNAME)
213
+ await page.get_by_test_id("login-password-input").fill("wrong-password-xyz")
214
+ await _ok(page)
215
+ _begin("action", "Click 'Sign in'")
216
+ await page.get_by_test_id("login-submit-button").click()
217
+ await _ok(page)
218
+ _begin("assertion", "An error message is shown and the URL stays /login")
219
+ await expect(page.get_by_test_id("login-error-message")).to_be_visible(timeout=TIMEOUT)
220
+ assert "/login" in page.url
221
+ await _ok(page)
222
+ """
223
+ if arch == "protected_redirect":
224
+ return f"""
225
+ async def _body(page):
226
+ _begin("action", "Navigate directly to {route} with no session")
227
+ await page.goto(f"{{BASE_URL}}{route}")
228
+ await _ok(page)
229
+ _begin("assertion", "Login page is shown")
230
+ await expect(page.get_by_test_id("login-page")).to_be_visible(timeout=TIMEOUT)
231
+ await _ok(page)
232
+ """
233
+ if arch == "dashboard_loads":
234
+ return """
235
+ async def _body(page):
236
+ await _login(page)
237
+ _begin("assertion", "Dashboard summary is visible")
238
+ await expect(page.get_by_test_id("dashboard-page")).to_be_visible(timeout=TIMEOUT)
239
+ await _ok(page)
240
+ """
241
+ if arch == "products_list":
242
+ return """
243
+ async def _body(page):
244
+ await _login(page)
245
+ _begin("action", "Open /products")
246
+ await page.goto(f"{BASE_URL}/products")
247
+ await _ok(page)
248
+ _begin("assertion", "Products page is visible")
249
+ await expect(page.get_by_test_id("products-page")).to_be_visible(timeout=TIMEOUT)
250
+ await _ok(page)
251
+ """
252
+ if arch == "search_empty":
253
+ return """
254
+ async def _body(page):
255
+ await _login(page)
256
+ _begin("action", "Open /products")
257
+ await page.goto(f"{BASE_URL}/products")
258
+ await expect(page.get_by_test_id("products-page")).to_be_visible(timeout=TIMEOUT)
259
+ await _ok(page)
260
+ _begin("action", "Type an unlikely search query")
261
+ await page.get_by_test_id("product-search-input").fill("zzz-no-such-" + uuid.uuid4().hex[:6])
262
+ await page.wait_for_timeout(600)
263
+ await _ok(page)
264
+ _begin("assertion", "No matching rows are shown")
265
+ assert await page.get_by_test_id("product-row").count() == 0
266
+ await _ok(page)
267
+ """
268
+ if arch == "create_product":
269
+ return """
270
+ async def _body(page):
271
+ await _login(page)
272
+ _begin("action", "Open the product create form")
273
+ await page.goto(f"{BASE_URL}/products/new")
274
+ await expect(page.get_by_test_id("product-form-page")).to_be_visible(timeout=TIMEOUT)
275
+ await _ok(page)
276
+ token = uuid.uuid4().hex[:8]
277
+ _begin("action", "Fill the required product fields")
278
+ await page.get_by_test_id("product-name-input").fill(f"Suitest Product {token}")
279
+ await page.get_by_test_id("product-sku-input").fill(f"SKU-{token}")
280
+ await page.get_by_test_id("product-price-input").fill("19.99")
281
+ await page.get_by_test_id("product-stock-input").fill("5")
282
+ await _ok(page)
283
+ _begin("action", "Submit the form")
284
+ await page.get_by_test_id("product-submit-button").click()
285
+ await _ok(page)
286
+ _begin("assertion", "Returns to the products list")
287
+ await expect(page.get_by_test_id("products-page")).to_be_visible(timeout=TIMEOUT)
288
+ await _ok(page)
289
+ """
290
+ if arch == "empty_login":
291
+ return """
292
+ async def _body(page):
293
+ _begin("action", "Navigate to /login")
294
+ await page.goto(f"{BASE_URL}/login")
295
+ await _ok(page)
296
+ _begin("action", "Click 'Sign in' with both fields empty")
297
+ await page.get_by_test_id("login-submit-button").click()
298
+ await _ok(page)
299
+ _begin("assertion", "A validation error is shown and the URL stays /login")
300
+ await expect(page.get_by_test_id("login-error-message")).to_be_visible(timeout=TIMEOUT)
301
+ assert "/login" in page.url
302
+ await _ok(page)
303
+ """
304
+ if arch == "logout":
305
+ return """
306
+ async def _body(page):
307
+ await _login(page)
308
+ _begin("action", "Click the logout button")
309
+ await page.get_by_test_id("logout-button").click()
310
+ await _ok(page)
311
+ _begin("assertion", "Login page is shown")
312
+ await expect(page.get_by_test_id("login-page")).to_be_visible(timeout=TIMEOUT)
313
+ await _ok(page)
314
+ _begin("action", "Navigate to /dashboard again")
315
+ await page.goto(f"{BASE_URL}/dashboard")
316
+ await _ok(page)
317
+ _begin("assertion", "Still on the login page (session cleared)")
318
+ await expect(page.get_by_test_id("login-page")).to_be_visible(timeout=TIMEOUT)
319
+ await _ok(page)
320
+ """
321
+ if arch == "search_match":
322
+ return """
323
+ async def _body(page):
324
+ await _login(page)
325
+ token = uuid.uuid4().hex[:8]
326
+ name = f"Suitest Match {token}"
327
+ _begin("action", "Create a uniquely-named product via the form")
328
+ await page.goto(f"{BASE_URL}/products/new")
329
+ await expect(page.get_by_test_id("product-form-page")).to_be_visible(timeout=TIMEOUT)
330
+ await page.get_by_test_id("product-name-input").fill(name)
331
+ await page.get_by_test_id("product-sku-input").fill(f"SKU-{token}")
332
+ await page.get_by_test_id("product-price-input").fill("9.99")
333
+ await page.get_by_test_id("product-stock-input").fill("3")
334
+ await page.get_by_test_id("product-submit-button").click()
335
+ await expect(page.get_by_test_id("products-page")).to_be_visible(timeout=TIMEOUT)
336
+ await _ok(page)
337
+ _begin("action", "Search for the exact product name")
338
+ await page.get_by_test_id("product-search-input").fill(name)
339
+ await page.wait_for_timeout(600)
340
+ await _ok(page)
341
+ _begin("assertion", "Exactly the matching product row is shown")
342
+ rows = page.get_by_test_id("product-row")
343
+ assert await rows.count() == 1, f"expected 1 matching row, got {await rows.count()}"
344
+ await expect(rows.first).to_contain_text(name)
345
+ await _ok(page)
346
+ """
347
+ if arch == "delete_product":
348
+ return """
349
+ async def _body(page):
350
+ await _login(page)
351
+ token = uuid.uuid4().hex[:8]
352
+ name = f"Suitest Delete {token}"
353
+ _begin("action", "Create a uniquely-named product via the form")
354
+ await page.goto(f"{BASE_URL}/products/new")
355
+ await expect(page.get_by_test_id("product-form-page")).to_be_visible(timeout=TIMEOUT)
356
+ await page.get_by_test_id("product-name-input").fill(name)
357
+ await page.get_by_test_id("product-sku-input").fill(f"SKU-{token}")
358
+ await page.get_by_test_id("product-price-input").fill("9.99")
359
+ await page.get_by_test_id("product-stock-input").fill("3")
360
+ await page.get_by_test_id("product-submit-button").click()
361
+ await expect(page.get_by_test_id("products-page")).to_be_visible(timeout=TIMEOUT)
362
+ await _ok(page)
363
+ _begin("action", "Search for it and delete it, accepting the confirm dialog")
364
+ await page.get_by_test_id("product-search-input").fill(name)
365
+ await page.wait_for_timeout(600)
366
+ page.on("dialog", lambda dialog: asyncio.ensure_future(dialog.accept()))
367
+ await page.get_by_test_id("product-delete-button").first.click()
368
+ await page.wait_for_timeout(800)
369
+ await _ok(page)
370
+ _begin("assertion", "The product row disappears from the list")
371
+ await page.get_by_test_id("product-search-input").fill(name)
372
+ await page.wait_for_timeout(600)
373
+ assert await page.get_by_test_id("product-row").count() == 0, "deleted product still listed"
374
+ await _ok(page)
375
+ """
376
+ if arch == "create_invalid":
377
+ return """
378
+ async def _body(page):
379
+ await _login(page)
380
+ _begin("action", "Open the product create form")
381
+ await page.goto(f"{BASE_URL}/products/new")
382
+ await expect(page.get_by_test_id("product-form-page")).to_be_visible(timeout=TIMEOUT)
383
+ await _ok(page)
384
+ _begin("action", "Fill a too-short name and no SKU, then submit")
385
+ await page.get_by_test_id("product-name-input").fill("ab")
386
+ await page.get_by_test_id("product-submit-button").click()
387
+ await page.wait_for_timeout(600)
388
+ await _ok(page)
389
+ _begin("assertion", "Form stays visible with validation errors; no navigation")
390
+ await expect(page.get_by_test_id("product-form-page")).to_be_visible(timeout=TIMEOUT)
391
+ assert "/products/new" in page.url, f"unexpected navigation to {page.url}"
392
+ await _ok(page)
393
+ """
394
+ return f"""
395
+ async def _body(page):
396
+ raise AssertionError("unsupported frontend archetype: {arch}")
397
+ """
398
+
399
+
400
+ _UNSUPPORTED_MARKER = "unsupported frontend archetype"
401
+
402
+
403
+ def _resolve_body(
404
+ case: PlanCase,
405
+ config: Config,
406
+ llm: object | None,
407
+ dom_context: str,
408
+ ) -> str:
409
+ """Pick the test body: deterministic archetype vs LLM-generated.
410
+
411
+ ``codegen`` policy (config):
412
+ deterministic — archetypes only (ZERO baseline).
413
+ auto — archetypes first; the LLM covers cases no archetype
414
+ supports (LLM-proposed plans, unconventional apps).
415
+ llm — the LLM writes every body (TestSprite-style; needed for
416
+ apps that don't follow the data-testid convention).
417
+ Any LLM failure falls back to the deterministic result so a run never
418
+ breaks because of the model.
419
+ """
420
+ deterministic = _body(case)
421
+ if llm is None or config.codegen == "deterministic":
422
+ return deterministic
423
+ wants_llm = config.codegen == "llm" or _UNSUPPORTED_MARKER in deterministic
424
+ if not wants_llm:
425
+ return deterministic
426
+ generate = getattr(llm, "generate_frontend_body", None)
427
+ if generate is None:
428
+ return deterministic
429
+ generated = generate(case, dom_context)
430
+ if not generated:
431
+ return deterministic
432
+ return "\n" + generated.rstrip() + "\n"
433
+
434
+
435
+ def export_frontend_tests(
436
+ cases: list[PlanCase],
437
+ summary: CodeSummary,
438
+ config: Config,
439
+ paths: Paths,
440
+ *,
441
+ llm: object | None = None,
442
+ dom_context: str = "",
443
+ ) -> list[PlanCase]:
444
+ paths.ensure()
445
+ for case in cases:
446
+ header = _HEADER.format(
447
+ base_url=config.base_url,
448
+ username=config.auth.username,
449
+ password=config.auth.password,
450
+ cid=case.id,
451
+ )
452
+ code = header + _resolve_body(case, config, llm, dom_context) + _RUNNER
453
+ filename = f"{case.id}_{case.title}.py"
454
+ paths.test_file(filename).write_text(code, encoding="utf-8")
455
+ case.automation_file = filename
456
+ return cases
457
+
458
+
459
+ __all__ = ["export_frontend_tests"]
@@ -0,0 +1,77 @@
1
+ """Frontend execution runtime — Suitest owns the browser, not the user.
2
+
3
+ Like TestSprite (which bundles its own browser-driving agent), Suitest ships the
4
+ Playwright runtime and auto-provisions the Chromium binary on first use. The
5
+ person testing their app only runs their app — they never `pip install playwright`
6
+ or `playwright install` themselves.
7
+
8
+ ``ensure_browser`` is idempotent and fast when the browser is already cached.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import subprocess
14
+ import sys
15
+ from dataclasses import dataclass
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class BrowserStatus:
20
+ ready: bool
21
+ detail: str
22
+
23
+
24
+ def _playwright_importable() -> bool:
25
+ try:
26
+ import playwright.async_api # noqa: F401
27
+ except ImportError:
28
+ return False
29
+ return True
30
+
31
+
32
+ def _chromium_present() -> bool:
33
+ """True if the Chromium binary is installed (cheap, no browser launch)."""
34
+ from importlib.util import find_spec
35
+
36
+ if find_spec("playwright") is None:
37
+ return False
38
+ try:
39
+ from playwright.sync_api import sync_playwright
40
+
41
+ with sync_playwright() as p:
42
+ path = p.chromium.executable_path
43
+ import os
44
+
45
+ return bool(path) and os.path.exists(path)
46
+ except Exception:
47
+ return False
48
+
49
+
50
+ def ensure_browser(*, auto_install: bool = True, timeout_sec: int = 600) -> BrowserStatus:
51
+ """Ensure Playwright + Chromium are usable; install the browser if missing."""
52
+ if not _playwright_importable():
53
+ return BrowserStatus(
54
+ False,
55
+ "playwright not installed in the Suitest environment "
56
+ "(install the 'frontend' extra: pip install 'suiflex-suitest-lifecycle[frontend]')",
57
+ )
58
+ if _chromium_present():
59
+ return BrowserStatus(True, "chromium already installed")
60
+ if not auto_install:
61
+ return BrowserStatus(False, "chromium not installed (auto-install disabled)")
62
+ try:
63
+ proc = subprocess.run(
64
+ [sys.executable, "-m", "playwright", "install", "chromium"],
65
+ capture_output=True,
66
+ text=True,
67
+ timeout=timeout_sec,
68
+ )
69
+ except (subprocess.TimeoutExpired, OSError) as exc:
70
+ return BrowserStatus(False, f"playwright install failed: {exc}")
71
+ if proc.returncode != 0:
72
+ tail = (proc.stderr or proc.stdout or "").strip().splitlines()[-3:]
73
+ return BrowserStatus(False, "playwright install chromium failed: " + " | ".join(tail))
74
+ return BrowserStatus(True, "chromium installed on demand")
75
+
76
+
77
+ __all__ = ["BrowserStatus", "ensure_browser"]