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