@qa-gentic/stlc-agents 1.0.16 → 1.0.17
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/README.md +59 -314
- package/bin/postinstall.js +17 -1
- package/bin/qa-stlc.js +23 -0
- package/package.json +1 -1
- package/skills/write-helix-files/SKILL.md +6 -0
- package/src/cli/cmd-cost.js +253 -0
- package/src/cli/cmd-mcp-config.js +124 -59
- package/src/stlc_agents/agent_gherkin_generator/server.py +88 -4
- package/src/stlc_agents/agent_helix_writer/tools/helix_write.py +60 -28
- package/src/stlc_agents/agent_jira_manager/server.py +209 -2
- package/src/stlc_agents/agent_jira_manager/tools/jira_workitem.py +36 -0
- package/src/stlc_agents/agent_playwright_generator/server.py +968 -105
- package/src/stlc_agents/agent_test_case_manager/server.py +121 -2
- package/src/stlc_agents/shared/cost_tracker.py +395 -0
- package/src/stlc_agents/shared/pricing.py +72 -0
- package/src/stlc_agents/__pycache__/__init__.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/__pycache__/__init__.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/__pycache__/server.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/__init__.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/ado_gherkin.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/__pycache__/__init__.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/__pycache__/server.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/tools/__pycache__/__init__.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/tools/__pycache__/boilerplate.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/tools/__pycache__/helix_write.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_jira_manager/__pycache__/__init__.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_jira_manager/__pycache__/server.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_jira_manager/tools/__pycache__/__init__.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_jira_manager/tools/__pycache__/jira_workitem.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/__pycache__/__init__.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/__pycache__/server.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/__init__.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/ado_attach.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/__pycache__/__init__.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/__pycache__/server.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/__init__.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/ado_workitem.cpython-310.pyc +0 -0
- package/src/stlc_agents/shared/__pycache__/__init__.cpython-310.pyc +0 -0
- package/src/stlc_agents/shared/__pycache__/auth.cpython-310.pyc +0 -0
- package/src/stlc_agents/shared_jira/__pycache__/__init__.cpython-310.pyc +0 -0
- package/src/stlc_agents/shared_jira/__pycache__/auth.cpython-310.pyc +0 -0
|
@@ -1,29 +1,30 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Agent 3: QA Playwright Code Generator — MCP Server
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
4
|
+
This agent owns all browser interaction. The pipeline is a pure orchestrator
|
|
5
|
+
that passes credentials/URL; this server does the browser walk and returns a
|
|
6
|
+
verified context_map.
|
|
7
|
+
|
|
8
|
+
Tools exposed:
|
|
9
|
+
capture_app_context — navigate app, snapshot AX tree, return context_map
|
|
10
|
+
generate_playwright_code — generate TS files from gherkin + context_map
|
|
11
|
+
scaffold_locator_repository — generate self-healing infrastructure once per project
|
|
12
|
+
get_generated_files — retrieve cached TS file contents
|
|
13
|
+
attach_code_to_work_item — attach generated files to an ADO work item
|
|
14
|
+
validate_gherkin_steps — parse and return unique steps by keyword
|
|
15
|
+
pre_validate_cucumber_steps — validate step-def syntax before write_helix_files
|
|
12
16
|
|
|
13
17
|
Three layers of self-healing at TEST RUNTIME:
|
|
14
18
|
1. Locator Healing — selector chain + AI Vision
|
|
15
19
|
2. Timing Healing — network trace drift detection → auto-adjusted timeouts
|
|
16
20
|
3. Visual Regression — element screenshot diff against approved baseline
|
|
17
21
|
|
|
18
|
-
DevToolsHealer (Layer 4) runs at test runtime via Playwright's built-in CDPSession —
|
|
19
|
-
no external process or port required.
|
|
20
|
-
|
|
21
22
|
CI/CD Telemetry:
|
|
22
23
|
HealingDashboard — HTTP server on :7890, engineers review/approve AI suggestions
|
|
23
24
|
before they are permanently committed to the repository.
|
|
24
25
|
"""
|
|
25
26
|
from __future__ import annotations
|
|
26
|
-
import asyncio, json, re, sys, os, uuid
|
|
27
|
+
import asyncio, json, re, sys, os, uuid, socket, subprocess, time, threading, queue
|
|
27
28
|
from pathlib import Path
|
|
28
29
|
from dotenv import load_dotenv
|
|
29
30
|
from mcp.server import Server
|
|
@@ -39,8 +40,29 @@ app = Server("qa-playwright-generator")
|
|
|
39
40
|
# File content cache — keeps large TS blobs out of conversation history.
|
|
40
41
|
# generate_playwright_code and scaffold_locator_repository store files here
|
|
41
42
|
# keyed by a short cache_key; get_generated_files retrieves them on demand.
|
|
43
|
+
#
|
|
44
|
+
# Files are persisted to disk so the cache survives MCP server restarts.
|
|
45
|
+
# Each entry is written as JSON under _CACHE_DIR/<cache_key>.json.
|
|
42
46
|
# ---------------------------------------------------------------------------
|
|
43
|
-
|
|
47
|
+
import tempfile as _tempfile
|
|
48
|
+
|
|
49
|
+
_CACHE_DIR = Path(_tempfile.gettempdir()) / "stlc_file_cache"
|
|
50
|
+
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _cache_write(cache_key: str, files: dict) -> None:
|
|
54
|
+
(_CACHE_DIR / f"{cache_key}.json").write_text(json.dumps(files))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _cache_read(cache_key: str) -> dict | None:
|
|
58
|
+
p = _CACHE_DIR / f"{cache_key}.json"
|
|
59
|
+
if not p.exists():
|
|
60
|
+
return None
|
|
61
|
+
return json.loads(p.read_text())
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _cache_has(cache_key: str) -> bool:
|
|
65
|
+
return (_CACHE_DIR / f"{cache_key}.json").exists()
|
|
44
66
|
|
|
45
67
|
|
|
46
68
|
# ---------------------------------------------------------------------------
|
|
@@ -136,6 +158,48 @@ def _validate_attach_inputs(files: list[dict]) -> dict:
|
|
|
136
158
|
@app.list_tools()
|
|
137
159
|
async def list_tools() -> list[types.Tool]:
|
|
138
160
|
return [
|
|
161
|
+
types.Tool(
|
|
162
|
+
name="capture_app_context",
|
|
163
|
+
description=(
|
|
164
|
+
"Navigate the live application using the Playwright MCP server, capture the "
|
|
165
|
+
"AX accessibility tree via browser_snapshot, and return a verified context_map "
|
|
166
|
+
"of locators. Handles login flows automatically when app_username and "
|
|
167
|
+
"app_password are supplied. The returned context_map should be passed directly "
|
|
168
|
+
"to generate_playwright_code so selectors come from the real DOM rather than "
|
|
169
|
+
"Gherkin inference.\n\n"
|
|
170
|
+
"When APP_BASE_URL is not supplied this tool returns an empty context_map and "
|
|
171
|
+
"locator_source=\"gherkin-inferred\" — generation still proceeds."
|
|
172
|
+
),
|
|
173
|
+
inputSchema={
|
|
174
|
+
"type": "object",
|
|
175
|
+
"properties": {
|
|
176
|
+
"app_url": {
|
|
177
|
+
"type": "string",
|
|
178
|
+
"description": "Base URL of the application under test.",
|
|
179
|
+
},
|
|
180
|
+
"app_username": {
|
|
181
|
+
"type": "string",
|
|
182
|
+
"description": "Optional username for login flows.",
|
|
183
|
+
},
|
|
184
|
+
"app_password": {
|
|
185
|
+
"type": "string",
|
|
186
|
+
"description": "Optional password for login flows.",
|
|
187
|
+
},
|
|
188
|
+
"gherkin_content": {
|
|
189
|
+
"type": "string",
|
|
190
|
+
"description": (
|
|
191
|
+
"Optional Gherkin feature text. When supplied the agent uses it "
|
|
192
|
+
"to decide which pages to navigate to (e.g. checkout flows)."
|
|
193
|
+
),
|
|
194
|
+
},
|
|
195
|
+
"playwright_mcp_url": {
|
|
196
|
+
"type": "string",
|
|
197
|
+
"description": "Override the Playwright MCP server URL (default: http://localhost:8931/mcp).",
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
"required": [],
|
|
201
|
+
},
|
|
202
|
+
),
|
|
139
203
|
types.Tool(
|
|
140
204
|
name="generate_playwright_code",
|
|
141
205
|
description=(
|
|
@@ -178,13 +242,25 @@ async def list_tools() -> list[types.Tool]:
|
|
|
178
242
|
"> aria-label(70) > placeholder(60)."
|
|
179
243
|
),
|
|
180
244
|
},
|
|
245
|
+
"app_url": {
|
|
246
|
+
"type": "string",
|
|
247
|
+
"description": (
|
|
248
|
+
"Optional base URL of the application under test "
|
|
249
|
+
"(e.g. 'https://account.stg.myamplify.io'). "
|
|
250
|
+
"When provided AND context_map is absent, Agent 3 will embed the URL "
|
|
251
|
+
"as a comment in locators.ts so it is available for manual AX-tree "
|
|
252
|
+
"snapshot runs. The URL is also echoed back in the result as app_url "
|
|
253
|
+
"for reference. Agent 3 does not navigate to it directly — use "
|
|
254
|
+
"browser_navigate + browser_snapshot via the Playwright MCP server "
|
|
255
|
+
"to build a verified context_map, then pass context_map to this tool."
|
|
256
|
+
),
|
|
257
|
+
},
|
|
181
258
|
"helix_project_root": {
|
|
182
259
|
"type": "string",
|
|
183
260
|
"description": (
|
|
184
261
|
"Optional path to Helix-QA project root. When provided, generated code "
|
|
185
262
|
"is merged with existing files (append-only, deduplication, conflict renaming). "
|
|
186
|
-
"No overwrites ever occur. Locators with naming conflicts get unique names.
|
|
187
|
-
"Pass this to enable seamless integration with your automation framework."
|
|
263
|
+
"No overwrites ever occur. Locators with naming conflicts get unique names."
|
|
188
264
|
),
|
|
189
265
|
},
|
|
190
266
|
},
|
|
@@ -349,7 +425,17 @@ async def list_tools() -> list[types.Tool]:
|
|
|
349
425
|
@app.call_tool()
|
|
350
426
|
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
|
|
351
427
|
try:
|
|
352
|
-
if name == "
|
|
428
|
+
if name == "capture_app_context":
|
|
429
|
+
result = await asyncio.to_thread(
|
|
430
|
+
_capture_app_context,
|
|
431
|
+
arguments.get("app_url", os.getenv("APP_BASE_URL", "")),
|
|
432
|
+
arguments.get("app_username", os.getenv("APP_USERNAME", "")),
|
|
433
|
+
arguments.get("app_password", os.getenv("APP_PASSWORD", "")),
|
|
434
|
+
arguments.get("gherkin_content", ""),
|
|
435
|
+
arguments.get("playwright_mcp_url",
|
|
436
|
+
os.getenv("PLAYWRIGHT_MCP_URL", "http://localhost:8931/mcp")),
|
|
437
|
+
)
|
|
438
|
+
elif name == "generate_playwright_code":
|
|
353
439
|
result = await asyncio.to_thread(
|
|
354
440
|
_generate_playwright_code,
|
|
355
441
|
arguments["gherkin_content"],
|
|
@@ -361,14 +447,19 @@ async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
|
|
|
361
447
|
arguments.get("enable_timing_healing", True),
|
|
362
448
|
arguments.get("context_map"),
|
|
363
449
|
arguments.get("helix_project_root"),
|
|
450
|
+
arguments.get("app_url"),
|
|
364
451
|
)
|
|
365
452
|
# Cache files; return manifest so TS content stays out of conversation
|
|
366
453
|
cache_key = uuid.uuid4().hex[:8]
|
|
367
|
-
|
|
454
|
+
_files = result.pop("files")
|
|
455
|
+
_cache_write(cache_key, _files)
|
|
368
456
|
result["cache_key"] = cache_key
|
|
369
|
-
result["files_manifest"] = list(
|
|
457
|
+
result["files_manifest"] = list(_files.keys())
|
|
370
458
|
result.pop("healing_layers", None)
|
|
371
459
|
result.pop("scaffold_note", None)
|
|
460
|
+
# Echo app_url so the caller knows which URL a live snapshot should target
|
|
461
|
+
if arguments.get("app_url"):
|
|
462
|
+
result["app_url"] = arguments["app_url"]
|
|
372
463
|
elif name == "scaffold_locator_repository":
|
|
373
464
|
result = await asyncio.to_thread(
|
|
374
465
|
_scaffold_locator_repository,
|
|
@@ -384,21 +475,22 @@ async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
|
|
|
384
475
|
result["_validation"] = _validate_scaffold_output(result)
|
|
385
476
|
# Cache files; return manifest so TS content stays out of conversation
|
|
386
477
|
cache_key = uuid.uuid4().hex[:8]
|
|
387
|
-
|
|
478
|
+
_files = result.pop("files")
|
|
479
|
+
_cache_write(cache_key, _files)
|
|
388
480
|
result["cache_key"] = cache_key
|
|
389
|
-
result["files_manifest"] = list(
|
|
481
|
+
result["files_manifest"] = list(_files.keys())
|
|
390
482
|
result.pop("healing_strategies", None)
|
|
391
483
|
result.pop("healing_layers", None)
|
|
392
484
|
result.pop("env_vars", None)
|
|
393
485
|
elif name == "get_generated_files":
|
|
394
486
|
cache_key = arguments["cache_key"]
|
|
395
|
-
if
|
|
487
|
+
if not _cache_has(cache_key):
|
|
396
488
|
result = {
|
|
397
489
|
"error": f"Cache key '{cache_key}' not found. "
|
|
398
490
|
"Re-run generate_playwright_code or scaffold_locator_repository.",
|
|
399
491
|
}
|
|
400
492
|
else:
|
|
401
|
-
result = {"cache_key": cache_key, "files":
|
|
493
|
+
result = {"cache_key": cache_key, "files": _cache_read(cache_key)}
|
|
402
494
|
elif name == "attach_code_to_work_item":
|
|
403
495
|
# ── Pre-attach input validation ────────────────────────────────
|
|
404
496
|
input_validation = _validate_attach_inputs(arguments.get("files", []))
|
|
@@ -1163,6 +1255,521 @@ def _merge_feature_file(new_feature_content: str, existing_content: str) -> str:
|
|
|
1163
1255
|
return merged
|
|
1164
1256
|
|
|
1165
1257
|
|
|
1258
|
+
# ============================================================================
|
|
1259
|
+
# capture_app_context
|
|
1260
|
+
# ============================================================================
|
|
1261
|
+
|
|
1262
|
+
# ── Playwright MCP helpers (owned by Agent 3, not by the pipeline) ────────────
|
|
1263
|
+
|
|
1264
|
+
_PMCP_CALL_TIMEOUT = 120
|
|
1265
|
+
_PMCP_STARTUP_WAIT = 8
|
|
1266
|
+
_PMCP_CONNECT_TIMEOUT = 10
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
def _port_is_open(host: str, port: int, timeout: float = 1.0) -> bool:
|
|
1270
|
+
try:
|
|
1271
|
+
with socket.create_connection((host, port), timeout=timeout):
|
|
1272
|
+
return True
|
|
1273
|
+
except (OSError, ConnectionRefusedError, socket.timeout):
|
|
1274
|
+
return False
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
def _parse_mcp_url_port(url: str):
|
|
1278
|
+
m = re.match(r"https?://([^/:]+):(\d+)", url)
|
|
1279
|
+
if m:
|
|
1280
|
+
return m.group(1), int(m.group(2))
|
|
1281
|
+
return "localhost", 8931
|
|
1282
|
+
|
|
1283
|
+
|
|
1284
|
+
def _find_free_port(start: int = 8932, end: int = 8999) -> int:
|
|
1285
|
+
for port in range(start, end + 1):
|
|
1286
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
1287
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
1288
|
+
try:
|
|
1289
|
+
s.bind(("127.0.0.1", port))
|
|
1290
|
+
return port
|
|
1291
|
+
except OSError:
|
|
1292
|
+
continue
|
|
1293
|
+
raise RuntimeError(f"No free TCP port found in {start}–{end}")
|
|
1294
|
+
|
|
1295
|
+
|
|
1296
|
+
class _PlaywrightMcp:
|
|
1297
|
+
"""Minimal synchronous Playwright MCP HTTP/SSE client used only by this agent."""
|
|
1298
|
+
|
|
1299
|
+
def __init__(self, base_url: str):
|
|
1300
|
+
self._base_url = base_url.rstrip("/")
|
|
1301
|
+
self._session_id: str | None = None
|
|
1302
|
+
self._id = 0
|
|
1303
|
+
self._proc: subprocess.Popen | None = None
|
|
1304
|
+
|
|
1305
|
+
def ensure_running(self) -> None:
|
|
1306
|
+
import urllib.request, urllib.error
|
|
1307
|
+
host, port = _parse_mcp_url_port(self._base_url)
|
|
1308
|
+
if _port_is_open(host, port):
|
|
1309
|
+
self._init_session()
|
|
1310
|
+
return
|
|
1311
|
+
# not running — launch on a free port
|
|
1312
|
+
try:
|
|
1313
|
+
new_port = _find_free_port(port, 8999)
|
|
1314
|
+
except RuntimeError:
|
|
1315
|
+
new_port = port
|
|
1316
|
+
self._base_url = f"http://127.0.0.1:{new_port}/mcp"
|
|
1317
|
+
cmd = ["npx", "@playwright/mcp@latest", "--port", str(new_port), "--headless"]
|
|
1318
|
+
self._proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
1319
|
+
deadline = time.monotonic() + _PMCP_STARTUP_WAIT
|
|
1320
|
+
while time.monotonic() < deadline:
|
|
1321
|
+
if _port_is_open("127.0.0.1", new_port, 0.5):
|
|
1322
|
+
break
|
|
1323
|
+
if self._proc.poll() is not None:
|
|
1324
|
+
raise RuntimeError(f"Playwright MCP npx exited during startup: {self._proc.stderr.read()[:300]}")
|
|
1325
|
+
time.sleep(0.5)
|
|
1326
|
+
self._init_session()
|
|
1327
|
+
|
|
1328
|
+
def call(self, tool: str, arguments: dict) -> dict:
|
|
1329
|
+
msg = self._build_rpc("tools/call", {"name": tool, "arguments": arguments})
|
|
1330
|
+
resp = self._post(msg)
|
|
1331
|
+
content = resp.get("result", {}).get("content", [])
|
|
1332
|
+
if not content:
|
|
1333
|
+
return {}
|
|
1334
|
+
parts = [b.get("text", "") for b in content if b.get("type") == "text"]
|
|
1335
|
+
text = "\n".join(parts)
|
|
1336
|
+
try:
|
|
1337
|
+
return json.loads(text)
|
|
1338
|
+
except json.JSONDecodeError:
|
|
1339
|
+
return {"text": text}
|
|
1340
|
+
|
|
1341
|
+
def close(self) -> None:
|
|
1342
|
+
if self._proc:
|
|
1343
|
+
try:
|
|
1344
|
+
self._proc.terminate()
|
|
1345
|
+
self._proc.wait(timeout=5)
|
|
1346
|
+
except Exception:
|
|
1347
|
+
try:
|
|
1348
|
+
self._proc.kill()
|
|
1349
|
+
except Exception:
|
|
1350
|
+
pass
|
|
1351
|
+
finally:
|
|
1352
|
+
self._proc = None
|
|
1353
|
+
|
|
1354
|
+
# ── internals ────────────────────────────────────────────────────────────
|
|
1355
|
+
|
|
1356
|
+
def _init_session(self) -> None:
|
|
1357
|
+
msg = self._build_rpc("initialize", {
|
|
1358
|
+
"protocolVersion": "2024-11-05",
|
|
1359
|
+
"capabilities": {},
|
|
1360
|
+
"clientInfo": {"name": "qa-playwright-generator", "version": "1.0"},
|
|
1361
|
+
})
|
|
1362
|
+
self._post(msg)
|
|
1363
|
+
# fire-and-forget initialized notification
|
|
1364
|
+
try:
|
|
1365
|
+
self._notify("notifications/initialized")
|
|
1366
|
+
except Exception:
|
|
1367
|
+
pass
|
|
1368
|
+
|
|
1369
|
+
def _notify(self, method: str) -> None:
|
|
1370
|
+
import urllib.request
|
|
1371
|
+
body = json.dumps({"jsonrpc": "2.0", "method": method, "params": {}}).encode()
|
|
1372
|
+
headers = {"Content-Type": "application/json"}
|
|
1373
|
+
if self._session_id:
|
|
1374
|
+
headers["mcp-session-id"] = self._session_id
|
|
1375
|
+
req = urllib.request.Request(self._base_url, data=body, headers=headers, method="POST")
|
|
1376
|
+
try:
|
|
1377
|
+
urllib.request.urlopen(req, timeout=5)
|
|
1378
|
+
except Exception:
|
|
1379
|
+
pass
|
|
1380
|
+
|
|
1381
|
+
def _build_rpc(self, method: str, params: dict) -> str:
|
|
1382
|
+
self._id += 1
|
|
1383
|
+
return json.dumps({"jsonrpc": "2.0", "id": self._id, "method": method, "params": params})
|
|
1384
|
+
|
|
1385
|
+
def _post(self, body: str) -> dict:
|
|
1386
|
+
import urllib.request, urllib.error
|
|
1387
|
+
headers: dict = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"}
|
|
1388
|
+
if self._session_id:
|
|
1389
|
+
headers["mcp-session-id"] = self._session_id
|
|
1390
|
+
req = urllib.request.Request(self._base_url, data=body.encode(), headers=headers, method="POST")
|
|
1391
|
+
try:
|
|
1392
|
+
resp = urllib.request.urlopen(req, timeout=_PMCP_CALL_TIMEOUT)
|
|
1393
|
+
except urllib.error.HTTPError as e:
|
|
1394
|
+
raise RuntimeError(f"Playwright MCP HTTP {e.code}: {e.read(200).decode(errors='replace')}")
|
|
1395
|
+
except urllib.error.URLError as e:
|
|
1396
|
+
raise RuntimeError(f"Playwright MCP connection error: {e.reason}")
|
|
1397
|
+
|
|
1398
|
+
sid = resp.headers.get("mcp-session-id") or resp.headers.get("Mcp-Session-Id")
|
|
1399
|
+
if sid and not self._session_id:
|
|
1400
|
+
self._session_id = sid
|
|
1401
|
+
|
|
1402
|
+
content_type = resp.headers.get("Content-Type", "")
|
|
1403
|
+
raw = resp.read()
|
|
1404
|
+
|
|
1405
|
+
if "text/event-stream" in content_type:
|
|
1406
|
+
return self._parse_sse(raw.decode(errors="replace")) or self._read_sse_get()
|
|
1407
|
+
|
|
1408
|
+
try:
|
|
1409
|
+
data = json.loads(raw.decode(errors="replace"))
|
|
1410
|
+
if "result" in data or "error" in data:
|
|
1411
|
+
return data
|
|
1412
|
+
except json.JSONDecodeError:
|
|
1413
|
+
pass
|
|
1414
|
+
|
|
1415
|
+
return self._read_sse_get()
|
|
1416
|
+
|
|
1417
|
+
def _parse_sse(self, text: str) -> dict | None:
|
|
1418
|
+
for line in text.splitlines():
|
|
1419
|
+
if line.startswith("data:"):
|
|
1420
|
+
payload = line[5:].strip()
|
|
1421
|
+
if not payload or payload == "[DONE]":
|
|
1422
|
+
continue
|
|
1423
|
+
try:
|
|
1424
|
+
data = json.loads(payload)
|
|
1425
|
+
if "result" in data or "error" in data:
|
|
1426
|
+
return data
|
|
1427
|
+
except json.JSONDecodeError:
|
|
1428
|
+
continue
|
|
1429
|
+
return None # fall through to GET SSE stream
|
|
1430
|
+
|
|
1431
|
+
def _read_sse_get(self) -> dict:
|
|
1432
|
+
import urllib.request, urllib.error
|
|
1433
|
+
headers: dict = {"Accept": "text/event-stream"}
|
|
1434
|
+
if self._session_id:
|
|
1435
|
+
headers["mcp-session-id"] = self._session_id
|
|
1436
|
+
req = urllib.request.Request(self._base_url, headers=headers, method="GET")
|
|
1437
|
+
try:
|
|
1438
|
+
stream = urllib.request.urlopen(req, timeout=_PMCP_CALL_TIMEOUT)
|
|
1439
|
+
except (urllib.error.URLError, urllib.error.HTTPError) as e:
|
|
1440
|
+
raise RuntimeError(f"Playwright MCP SSE GET failed: {e}")
|
|
1441
|
+
deadline = time.monotonic() + _PMCP_CALL_TIMEOUT
|
|
1442
|
+
buf = ""
|
|
1443
|
+
while time.monotonic() < deadline:
|
|
1444
|
+
chunk = stream.read(4096)
|
|
1445
|
+
if not chunk:
|
|
1446
|
+
break
|
|
1447
|
+
buf += chunk.decode(errors="replace")
|
|
1448
|
+
for line in buf.splitlines():
|
|
1449
|
+
if line.startswith("data:"):
|
|
1450
|
+
payload = line[5:].strip()
|
|
1451
|
+
if not payload or payload == "[DONE]":
|
|
1452
|
+
continue
|
|
1453
|
+
try:
|
|
1454
|
+
data = json.loads(payload)
|
|
1455
|
+
if "result" in data or "error" in data:
|
|
1456
|
+
stream.close()
|
|
1457
|
+
return data
|
|
1458
|
+
except json.JSONDecodeError:
|
|
1459
|
+
continue
|
|
1460
|
+
raise RuntimeError("Playwright MCP SSE stream ended without a JSON-RPC result")
|
|
1461
|
+
|
|
1462
|
+
|
|
1463
|
+
# ── AX-tree snapshot helpers ──────────────────────────────────────────────────
|
|
1464
|
+
|
|
1465
|
+
_AX_REF_RE = re.compile(
|
|
1466
|
+
r"^\s*-\s+(?P<role>[\w-]+)(?:\s+\"(?P<name>[^\"]+)\")?\s+\[ref=(?P<ref>[^\]]+)\]"
|
|
1467
|
+
)
|
|
1468
|
+
_AX_TEXT_RE = re.compile(r"^\s*-\s+text:\s+(?P<text>.+)$")
|
|
1469
|
+
|
|
1470
|
+
|
|
1471
|
+
def _snapshot_to_text(snapshot: dict) -> str:
|
|
1472
|
+
raw = ""
|
|
1473
|
+
for block in snapshot.get("content", []):
|
|
1474
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
1475
|
+
raw += block.get("text", "")
|
|
1476
|
+
return raw or snapshot.get("text", "")
|
|
1477
|
+
|
|
1478
|
+
|
|
1479
|
+
def _parse_ax_elements(snapshot: dict) -> list[dict]:
|
|
1480
|
+
elements: list[dict] = []
|
|
1481
|
+
current: dict | None = None
|
|
1482
|
+
for line in _snapshot_to_text(snapshot).splitlines():
|
|
1483
|
+
m = _AX_REF_RE.match(line)
|
|
1484
|
+
if m:
|
|
1485
|
+
current = {"role": m.group("role") or "", "name": (m.group("name") or "").strip(), "ref": m.group("ref") or ""}
|
|
1486
|
+
elements.append(current)
|
|
1487
|
+
continue
|
|
1488
|
+
tm = _AX_TEXT_RE.match(line)
|
|
1489
|
+
if tm and current is not None and not current.get("name"):
|
|
1490
|
+
current["name"] = tm.group("text").strip().strip('"')
|
|
1491
|
+
return elements
|
|
1492
|
+
|
|
1493
|
+
|
|
1494
|
+
def _find_ref(snapshot: dict, *, role: str | None = None, name_patterns: list[str] | None = None) -> str | None:
|
|
1495
|
+
compiled = [re.compile(p, re.IGNORECASE) for p in (name_patterns or [])]
|
|
1496
|
+
for el in _parse_ax_elements(snapshot):
|
|
1497
|
+
if role and el.get("role") != role:
|
|
1498
|
+
continue
|
|
1499
|
+
name = el.get("name", "")
|
|
1500
|
+
if compiled and not any(p.search(name) for p in compiled):
|
|
1501
|
+
continue
|
|
1502
|
+
return el.get("ref")
|
|
1503
|
+
return None
|
|
1504
|
+
|
|
1505
|
+
|
|
1506
|
+
def _to_camel(s: str) -> str:
|
|
1507
|
+
words = re.split(r"[\s\-_]+", s.strip())
|
|
1508
|
+
if not words:
|
|
1509
|
+
return "element"
|
|
1510
|
+
return words[0].lower() + "".join(w.capitalize() for w in words[1:])
|
|
1511
|
+
|
|
1512
|
+
|
|
1513
|
+
def _score_selector(sel: str) -> int:
|
|
1514
|
+
if "data-testid" in sel or "data-test=" in sel:
|
|
1515
|
+
return 100
|
|
1516
|
+
if sel.startswith("[id=") or sel.startswith("#"):
|
|
1517
|
+
return 80
|
|
1518
|
+
if "aria-label" in sel:
|
|
1519
|
+
return 70
|
|
1520
|
+
if "placeholder" in sel:
|
|
1521
|
+
return 60
|
|
1522
|
+
return 40
|
|
1523
|
+
|
|
1524
|
+
|
|
1525
|
+
# Matches Playwright MCP YAML AX-tree lines:
|
|
1526
|
+
# - button "Add to cart" [ref=e54] [cursor=pointer]
|
|
1527
|
+
# - link "Sauce Labs Backpack" [ref=e45] [cursor=pointer]
|
|
1528
|
+
_AX_ROLE_LINE = re.compile(
|
|
1529
|
+
r"^\s*-\s+(?P<role>[\w-]+)\s+\"(?P<name>[^\"]+)\"\s+\[ref=(?P<ref>[^\]]+)\]"
|
|
1530
|
+
)
|
|
1531
|
+
|
|
1532
|
+
# Interactable roles we want to capture as locators
|
|
1533
|
+
_INTERACTABLE_ROLES = frozenset({
|
|
1534
|
+
"button", "link", "textbox", "combobox", "checkbox",
|
|
1535
|
+
"radio", "menuitem", "tab", "option", "searchbox", "input",
|
|
1536
|
+
})
|
|
1537
|
+
|
|
1538
|
+
|
|
1539
|
+
def _build_context_map_from_snapshot(snapshot: dict) -> dict:
|
|
1540
|
+
"""
|
|
1541
|
+
Convert a Playwright MCP browser_snapshot into a context_map dict.
|
|
1542
|
+
|
|
1543
|
+
Handles three snapshot formats:
|
|
1544
|
+
1. Structured tree/nodes dict (some MCP versions)
|
|
1545
|
+
2. YAML AX-tree text in content[].text (Playwright MCP ≥0.0.14 default)
|
|
1546
|
+
3. Raw HTML text fallback (data-testid / id attribute scan)
|
|
1547
|
+
"""
|
|
1548
|
+
context_map: dict = {}
|
|
1549
|
+
seen: set = set()
|
|
1550
|
+
|
|
1551
|
+
# ── Format 1: structured JSON tree ───────────────────────────────────────
|
|
1552
|
+
for node in (snapshot.get("tree") or snapshot.get("nodes") or []):
|
|
1553
|
+
if not isinstance(node, dict):
|
|
1554
|
+
continue
|
|
1555
|
+
role = node.get("role", "")
|
|
1556
|
+
name = node.get("name", "") or node.get("text", "")
|
|
1557
|
+
selector = node.get("selector") or node.get("css") or ""
|
|
1558
|
+
if not selector or not name:
|
|
1559
|
+
continue
|
|
1560
|
+
key = _to_camel(name)[:40]
|
|
1561
|
+
if key in seen:
|
|
1562
|
+
key = f"{key}_{len(seen)}"
|
|
1563
|
+
seen.add(key)
|
|
1564
|
+
stab = _score_selector(selector)
|
|
1565
|
+
context_map[key] = {
|
|
1566
|
+
"selector": selector,
|
|
1567
|
+
"intent": f"{role} — {name}".strip(" —"),
|
|
1568
|
+
"stability": stab,
|
|
1569
|
+
**({"visualIntent": True} if stab >= 70 else {}),
|
|
1570
|
+
}
|
|
1571
|
+
if context_map:
|
|
1572
|
+
return context_map
|
|
1573
|
+
|
|
1574
|
+
# ── Format 2: YAML AX-tree text (Playwright MCP ref= format) ─────────────
|
|
1575
|
+
# Build role+name → aria selector with stability 90 (high confidence from AX tree)
|
|
1576
|
+
raw = _snapshot_to_text(snapshot)
|
|
1577
|
+
for line in raw.splitlines():
|
|
1578
|
+
m = _AX_ROLE_LINE.match(line)
|
|
1579
|
+
if not m:
|
|
1580
|
+
continue
|
|
1581
|
+
role = m.group("role")
|
|
1582
|
+
name = m.group("name").strip()
|
|
1583
|
+
if not name or role not in _INTERACTABLE_ROLES:
|
|
1584
|
+
continue
|
|
1585
|
+
key = _to_camel(name)[:40]
|
|
1586
|
+
if key in seen:
|
|
1587
|
+
key = f"{key}_{len(seen)}"
|
|
1588
|
+
seen.add(key)
|
|
1589
|
+
# role+name aria selector — stable for interactable elements
|
|
1590
|
+
safe_name = name.replace('"', '\\"')
|
|
1591
|
+
selector = f'[role="{role}"][name="{safe_name}"]'
|
|
1592
|
+
context_map[key] = {
|
|
1593
|
+
"selector": selector,
|
|
1594
|
+
"intent": f"{role} — {name}",
|
|
1595
|
+
"stability": 90,
|
|
1596
|
+
"visualIntent": role in ("button", "link"),
|
|
1597
|
+
}
|
|
1598
|
+
if context_map:
|
|
1599
|
+
return context_map
|
|
1600
|
+
|
|
1601
|
+
# ── Format 3: HTML text fallback — data-testid / id attribute scan ────────
|
|
1602
|
+
for m in re.finditer(r'data-test(?:id)?=["\']([^"\']+)["\']', raw):
|
|
1603
|
+
tid = m.group(1)
|
|
1604
|
+
key = _to_camel(tid.replace("-", " "))
|
|
1605
|
+
if key not in seen:
|
|
1606
|
+
seen.add(key)
|
|
1607
|
+
context_map[key] = {
|
|
1608
|
+
"selector": f"[data-testid='{tid}']",
|
|
1609
|
+
"intent": tid.replace("-", " "),
|
|
1610
|
+
"stability": 100,
|
|
1611
|
+
"visualIntent": True,
|
|
1612
|
+
}
|
|
1613
|
+
for m in re.finditer(r'\bid=["\']([A-Za-z][A-Za-z0-9_\-]+)["\']', raw):
|
|
1614
|
+
eid = m.group(1)
|
|
1615
|
+
key = _to_camel(eid.replace("-", " "))
|
|
1616
|
+
if key not in seen:
|
|
1617
|
+
seen.add(key)
|
|
1618
|
+
context_map[key] = {
|
|
1619
|
+
"selector": f"#{eid}",
|
|
1620
|
+
"intent": eid.replace("-", " "),
|
|
1621
|
+
"stability": 80,
|
|
1622
|
+
}
|
|
1623
|
+
return context_map
|
|
1624
|
+
|
|
1625
|
+
|
|
1626
|
+
def _merge_maps(*maps: dict) -> dict:
|
|
1627
|
+
merged: dict = {}
|
|
1628
|
+
for cmap in maps:
|
|
1629
|
+
for k, v in (cmap or {}).items():
|
|
1630
|
+
if k not in merged or v.get("stability", 0) > merged[k].get("stability", 0):
|
|
1631
|
+
merged[k] = v
|
|
1632
|
+
return merged
|
|
1633
|
+
|
|
1634
|
+
|
|
1635
|
+
# ── Login flow detection / execution ─────────────────────────────────────────
|
|
1636
|
+
|
|
1637
|
+
def _needs_login(app_url: str, username: str, password: str) -> bool:
|
|
1638
|
+
return bool(username and password)
|
|
1639
|
+
|
|
1640
|
+
|
|
1641
|
+
def _do_login(pmcp: _PlaywrightMcp, app_url: str, username: str, password: str) -> dict:
|
|
1642
|
+
"""Navigate to app_url, fill login form, click submit, return post-login snapshot."""
|
|
1643
|
+
pmcp.call("browser_navigate", {"url": app_url.rstrip("/")})
|
|
1644
|
+
login_snap = pmcp.call("browser_snapshot", {})
|
|
1645
|
+
|
|
1646
|
+
user_ref = _find_ref(login_snap, role="textbox", name_patterns=[r"user(?:name)?|email|login"])
|
|
1647
|
+
pass_ref = _find_ref(login_snap, role="textbox", name_patterns=[r"pass(?:word)?"])
|
|
1648
|
+
|
|
1649
|
+
if not user_ref or not pass_ref:
|
|
1650
|
+
# no login form detected — return the snapshot as-is
|
|
1651
|
+
return login_snap
|
|
1652
|
+
|
|
1653
|
+
# Try role=button first, then role=input (e.g. SauceDemo uses input[type=submit])
|
|
1654
|
+
btn_ref = (
|
|
1655
|
+
_find_ref(login_snap, role="button", name_patterns=[r"log.?in|sign.?in|submit"])
|
|
1656
|
+
or _find_ref(login_snap, role="input", name_patterns=[r"log.?in|sign.?in|submit"])
|
|
1657
|
+
)
|
|
1658
|
+
|
|
1659
|
+
pmcp.call("browser_fill", {"ref": user_ref, "value": username})
|
|
1660
|
+
pmcp.call("browser_fill", {"ref": pass_ref, "value": password})
|
|
1661
|
+
if btn_ref:
|
|
1662
|
+
pmcp.call("browser_click", {"ref": btn_ref})
|
|
1663
|
+
else:
|
|
1664
|
+
# Last resort: submit via Enter key on the password field
|
|
1665
|
+
pmcp.call("browser_press", {"ref": pass_ref, "key": "Enter"})
|
|
1666
|
+
|
|
1667
|
+
return pmcp.call("browser_snapshot", {})
|
|
1668
|
+
|
|
1669
|
+
|
|
1670
|
+
# ── Multi-step flows driven by gherkin hints ──────────────────────────────────
|
|
1671
|
+
|
|
1672
|
+
def _capture_checkout_flow(pmcp: _PlaywrightMcp, app_url: str, username: str, password: str) -> dict:
|
|
1673
|
+
"""
|
|
1674
|
+
Checkout-specific multi-step capture:
|
|
1675
|
+
login → add item to cart → open cart → proceed to checkout → snapshot checkout form.
|
|
1676
|
+
Cart navigation uses the cart icon (browser_click) rather than a direct URL
|
|
1677
|
+
to keep browser session state intact.
|
|
1678
|
+
"""
|
|
1679
|
+
base = app_url.rstrip("/")
|
|
1680
|
+
|
|
1681
|
+
post_login = _do_login(pmcp, base, username, password) if _needs_login(base, username, password) else pmcp.call("browser_snapshot", {})
|
|
1682
|
+
|
|
1683
|
+
inventory_snap = pmcp.call("browser_snapshot", {})
|
|
1684
|
+
add_ref = _find_ref(inventory_snap, role="button", name_patterns=[r"add to cart"])
|
|
1685
|
+
if add_ref:
|
|
1686
|
+
pmcp.call("browser_click", {"ref": add_ref})
|
|
1687
|
+
|
|
1688
|
+
# use cart icon link rather than hard-coded /cart.html
|
|
1689
|
+
after_add_snap = pmcp.call("browser_snapshot", {})
|
|
1690
|
+
cart_icon_ref = _find_ref(after_add_snap, role="link", name_patterns=[r"shopping cart|cart"])
|
|
1691
|
+
if cart_icon_ref:
|
|
1692
|
+
pmcp.call("browser_click", {"ref": cart_icon_ref})
|
|
1693
|
+
else:
|
|
1694
|
+
pmcp.call("browser_navigate", {"url": f"{base}/cart.html"})
|
|
1695
|
+
|
|
1696
|
+
cart_snap = pmcp.call("browser_snapshot", {})
|
|
1697
|
+
checkout_ref = _find_ref(cart_snap, role="button", name_patterns=[r"checkout"])
|
|
1698
|
+
if checkout_ref:
|
|
1699
|
+
pmcp.call("browser_click", {"ref": checkout_ref})
|
|
1700
|
+
|
|
1701
|
+
checkout_snap = pmcp.call("browser_snapshot", {})
|
|
1702
|
+
|
|
1703
|
+
return _merge_maps(
|
|
1704
|
+
_build_context_map_from_snapshot(post_login),
|
|
1705
|
+
_build_context_map_from_snapshot(inventory_snap),
|
|
1706
|
+
_build_context_map_from_snapshot(cart_snap),
|
|
1707
|
+
_build_context_map_from_snapshot(checkout_snap),
|
|
1708
|
+
)
|
|
1709
|
+
|
|
1710
|
+
|
|
1711
|
+
def _is_checkout_flow(app_url: str, gherkin: str) -> bool:
|
|
1712
|
+
haystack = (app_url + " " + gherkin).lower()
|
|
1713
|
+
return all(kw in haystack for kw in ("checkout", "first name", "last name"))
|
|
1714
|
+
|
|
1715
|
+
|
|
1716
|
+
# ── Public implementation ─────────────────────────────────────────────────────
|
|
1717
|
+
|
|
1718
|
+
def _capture_app_context(
|
|
1719
|
+
app_url: str,
|
|
1720
|
+
app_username: str,
|
|
1721
|
+
app_password: str,
|
|
1722
|
+
gherkin_content: str,
|
|
1723
|
+
playwright_mcp_url: str,
|
|
1724
|
+
) -> dict:
|
|
1725
|
+
"""
|
|
1726
|
+
Navigate the live application and return a verified context_map.
|
|
1727
|
+
|
|
1728
|
+
Owns the full browser lifecycle: start Playwright MCP if needed,
|
|
1729
|
+
navigate / login / snapshot, build context_map, shut down.
|
|
1730
|
+
"""
|
|
1731
|
+
if not app_url:
|
|
1732
|
+
return {
|
|
1733
|
+
"context_map": {},
|
|
1734
|
+
"locator_source": "gherkin-inferred",
|
|
1735
|
+
"note": "app_url not provided — skipping browser capture",
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
pmcp = _PlaywrightMcp(playwright_mcp_url)
|
|
1739
|
+
try:
|
|
1740
|
+
pmcp.ensure_running()
|
|
1741
|
+
|
|
1742
|
+
if _is_checkout_flow(app_url, gherkin_content):
|
|
1743
|
+
# Multi-step flow: login → add to cart → checkout form
|
|
1744
|
+
context_map = _capture_checkout_flow(pmcp, app_url, app_username, app_password)
|
|
1745
|
+
else:
|
|
1746
|
+
# Standard flow: navigate (with login if credentials supplied) → snapshot
|
|
1747
|
+
if _needs_login(app_url, app_username, app_password):
|
|
1748
|
+
# _do_login navigates to app_url, fills credentials, returns post-login snap
|
|
1749
|
+
landing_snap = _do_login(pmcp, app_url, app_username, app_password)
|
|
1750
|
+
else:
|
|
1751
|
+
pmcp.call("browser_navigate", {"url": app_url})
|
|
1752
|
+
landing_snap = pmcp.call("browser_snapshot", {})
|
|
1753
|
+
context_map = _build_context_map_from_snapshot(landing_snap)
|
|
1754
|
+
|
|
1755
|
+
locator_source = "playwright-mcp-verified" if context_map else "gherkin-inferred"
|
|
1756
|
+
return {
|
|
1757
|
+
"context_map": context_map,
|
|
1758
|
+
"locator_source": locator_source,
|
|
1759
|
+
"locator_count": len(context_map),
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
except Exception as exc:
|
|
1763
|
+
return {
|
|
1764
|
+
"context_map": {},
|
|
1765
|
+
"locator_source": "gherkin-inferred",
|
|
1766
|
+
"error": str(exc),
|
|
1767
|
+
"note": "Browser capture failed — caller may proceed with gherkin-inferred locators",
|
|
1768
|
+
}
|
|
1769
|
+
finally:
|
|
1770
|
+
pmcp.close()
|
|
1771
|
+
|
|
1772
|
+
|
|
1166
1773
|
# ============================================================================
|
|
1167
1774
|
# generate_playwright_code
|
|
1168
1775
|
# ============================================================================
|
|
@@ -1170,7 +1777,7 @@ def _merge_feature_file(new_feature_content: str, existing_content: str) -> str:
|
|
|
1170
1777
|
def _generate_playwright_code(
|
|
1171
1778
|
gherkin, page_class, app_name, healing_strategy,
|
|
1172
1779
|
auth_hook, enable_visual_regression, enable_timing_healing,
|
|
1173
|
-
context_map=None, helix_project_root=None,
|
|
1780
|
+
context_map=None, helix_project_root=None, app_url=None,
|
|
1174
1781
|
):
|
|
1175
1782
|
kebab = _to_kebab(page_class)
|
|
1176
1783
|
camel = page_class[0].lower() + page_class[1:]
|
|
@@ -1185,6 +1792,17 @@ def _generate_playwright_code(
|
|
|
1185
1792
|
|
|
1186
1793
|
# Generate new content
|
|
1187
1794
|
new_locators = _gen_locators(page_class, kebab, gherkin, context_map)
|
|
1795
|
+
|
|
1796
|
+
# If app_url is provided and no live context_map, prepend a snapshot hint comment
|
|
1797
|
+
# so engineers know the URL to navigate to when running AX-tree capture manually.
|
|
1798
|
+
if app_url and not context_map:
|
|
1799
|
+
snapshot_hint = (
|
|
1800
|
+
f"// APP URL: {app_url}\n"
|
|
1801
|
+
f"// Locator source: gherkin-inferred (no context_map provided).\n"
|
|
1802
|
+
f"// To replace with verified selectors, run:\n"
|
|
1803
|
+
f"// browser_navigate('{app_url}') → browser_snapshot() → pass result as context_map\n"
|
|
1804
|
+
)
|
|
1805
|
+
new_locators = snapshot_hint + new_locators
|
|
1188
1806
|
new_page_object = _gen_page_object(
|
|
1189
1807
|
page_class, kebab, camel, gherkin, healing_strategy,
|
|
1190
1808
|
enable_visual_regression, enable_timing_healing)
|
|
@@ -1372,22 +1990,23 @@ def _gen_page_object(page_class, kebab, camel, gherkin, healing_strategy,
|
|
|
1372
1990
|
stubs = _build_method_stubs(camel, steps)
|
|
1373
1991
|
vi_import = 'import { VisualIntentChecker } from "@utils/locators/VisualIntentChecker";' if enable_visual_regression else ""
|
|
1374
1992
|
th_import = 'import { TimingHealer } from "@utils/locators/TimingHealer";' if enable_timing_healing else ""
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1993
|
+
# BasePage provides visual & timing fields and initialisation; avoid redeclaring them
|
|
1994
|
+
vi_field = ""
|
|
1995
|
+
th_field = ""
|
|
1996
|
+
vi_init = "" # initialised by BasePage
|
|
1997
|
+
th_init = "" # initialised by BasePage
|
|
1379
1998
|
tn = ' await this.timing.waitForNetworkIdle("navigate");' if enable_timing_healing else ""
|
|
1380
1999
|
ts_ = ' await this.timing.waitForNetworkIdle("submitForm");' if enable_timing_healing else ""
|
|
1381
2000
|
vc = ' await this.visual.check("successToast", this.loc.successToast.selector, this.loc.successToast.intent);' if enable_visual_regression else ""
|
|
1382
2001
|
|
|
1383
2002
|
return f'''import {{ Page, expect }} from "@playwright/test";
|
|
1384
2003
|
import {{ fixture }} from "@hooks/pageFixture";
|
|
1385
|
-
import {{ {page_class}Locators }} from "
|
|
2004
|
+
import {{ {page_class}Locators }} from "../locators/{kebab}.locators";
|
|
1386
2005
|
import {{ LocatorHealer }} from "@utils/locators/LocatorHealer";
|
|
1387
2006
|
import {{ LocatorRepository }} from "@utils/locators/LocatorRepository";
|
|
2007
|
+
import {{ BasePage }} from "./BasePage";
|
|
1388
2008
|
{vi_import}
|
|
1389
2009
|
{th_import}
|
|
1390
|
-
import {{ EnvironmentManager }} from "@helper/environment/environmentManager.util";
|
|
1391
2010
|
|
|
1392
2011
|
/**
|
|
1393
2012
|
* {page_class}Page — Three-Layer Self-Healing Page Object
|
|
@@ -1405,25 +2024,14 @@ import {{ EnvironmentManager }} from "@helper/environment/environmentManager.uti
|
|
|
1405
2024
|
*
|
|
1406
2025
|
* RULE: Never call page.click / page.fill / page.locator directly.
|
|
1407
2026
|
*/
|
|
1408
|
-
export default class {page_class}Page {{
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
constructor(page?: Page) {{
|
|
1418
|
-
this.page = page ?? fixture().page;
|
|
1419
|
-
this.env = new EnvironmentManager();
|
|
1420
|
-
this.repo = fixture().locatorRepository ?? new LocatorRepository();
|
|
1421
|
-
this.healer = new LocatorHealer(this.page, fixture().logger, this.repo);
|
|
1422
|
-
{vi_init}
|
|
1423
|
-
{th_init}
|
|
1424
|
-
Object.entries(this.loc).forEach(([key, val]) =>
|
|
1425
|
-
this.repo.register(key, val.selector, val.intent));
|
|
1426
|
-
}}
|
|
2027
|
+
export default class {page_class}Page extends BasePage {{
|
|
2028
|
+
private readonly loc = {page_class}Locators;
|
|
2029
|
+
|
|
2030
|
+
constructor(page?: Page) {{
|
|
2031
|
+
super(page ?? fixture().page);
|
|
2032
|
+
Object.entries(this.loc).forEach(([key, val]) =>
|
|
2033
|
+
this.repo.register(key, val.selector, val.intent));
|
|
2034
|
+
}}
|
|
1427
2035
|
|
|
1428
2036
|
async navigate(): Promise<void> {{
|
|
1429
2037
|
const url = `${{this.env.getBaseUrl()}}/${{this.env.getPath("{kebab}")}}`;
|
|
@@ -1539,71 +2147,326 @@ def _build_method_stubs(camel, steps):
|
|
|
1539
2147
|
return "\n\n".join(stubs)
|
|
1540
2148
|
|
|
1541
2149
|
|
|
2150
|
+
def _step_text_to_method_name(text: str) -> str:
|
|
2151
|
+
"""Derive a camelCase page-method name from a Gherkin step text."""
|
|
2152
|
+
lower = text.lower()
|
|
2153
|
+
is_assertion = any(kw in lower for kw in (
|
|
2154
|
+
"should", "must", "is displayed", "are displayed",
|
|
2155
|
+
"is shown", "are shown", "should see", "should be",
|
|
2156
|
+
"should have", "is visible", "are visible", "is empty",
|
|
2157
|
+
"is present", "contains", "count", "equal",
|
|
2158
|
+
))
|
|
2159
|
+
is_nav = any(kw in lower for kw in (
|
|
2160
|
+
"navigates to", "navigate to", "is on the", "visits",
|
|
2161
|
+
"goes to", "opens", "on the page",
|
|
2162
|
+
))
|
|
2163
|
+
is_click = any(kw in lower for kw in (
|
|
2164
|
+
"clicks", "click", "taps", "tap", "presses", "press",
|
|
2165
|
+
"selects", "select", "chooses", "choose",
|
|
2166
|
+
))
|
|
2167
|
+
is_fill = any(kw in lower for kw in (
|
|
2168
|
+
"enters", "enter", "fills", "fill", "types", "type",
|
|
2169
|
+
"inputs", "input", "sets", "set",
|
|
2170
|
+
))
|
|
2171
|
+
is_given = any(kw in lower for kw in (
|
|
2172
|
+
"has", "have", "with", "contains", "there is", "there are", "exists",
|
|
2173
|
+
))
|
|
2174
|
+
clean = re.sub(r'\{[^}]+\}', '', text)
|
|
2175
|
+
clean = re.sub(r'"[^"]*"', '', clean)
|
|
2176
|
+
clean = re.sub(r"'[^']*'", '', clean)
|
|
2177
|
+
stops = {
|
|
2178
|
+
'a', 'an', 'the', 'is', 'are', 'has', 'have', 'be', 'been',
|
|
2179
|
+
'to', 'of', 'on', 'in', 'at', 'for', 'by', 'with', 'and',
|
|
2180
|
+
'or', 'it', 'its', 'that', 'this', 'their', 'from', 'as',
|
|
2181
|
+
'user', 'page', 'screen', 'should', 'will', 'can',
|
|
2182
|
+
'then', 'when', 'given', 'but', 'after', 'before',
|
|
2183
|
+
}
|
|
2184
|
+
tokens = [t for t in re.findall(r"[a-zA-Z]+", clean) if t.lower() not in stops][:4]
|
|
2185
|
+
if not tokens:
|
|
2186
|
+
tokens = re.findall(r"[a-zA-Z]+", clean)[:2] or ["action"]
|
|
2187
|
+
body = "".join(t.capitalize() for t in tokens)
|
|
2188
|
+
if is_assertion:
|
|
2189
|
+
return f"verify{body}"
|
|
2190
|
+
if is_nav:
|
|
2191
|
+
return f"navigateTo{body}" if body else "navigate"
|
|
2192
|
+
if is_click:
|
|
2193
|
+
return f"click{body}"
|
|
2194
|
+
if is_fill:
|
|
2195
|
+
return f"enter{body}"
|
|
2196
|
+
if is_given:
|
|
2197
|
+
return f"setup{body}" if body else "setup"
|
|
2198
|
+
return f"action{body}" if body else "performAction"
|
|
2199
|
+
|
|
2200
|
+
|
|
2201
|
+
def _step_text_to_cucumber_pattern(text: str) -> str:
|
|
2202
|
+
"""Convert step text to a Cucumber expression string (no raw regex)."""
|
|
2203
|
+
pattern = re.sub(r'"[^"]*"', '{string}', text)
|
|
2204
|
+
pattern = re.sub(r'\b\d+\.\d+\b', '{float}', pattern)
|
|
2205
|
+
pattern = re.sub(r'\b\d+\b', '{int}', pattern)
|
|
2206
|
+
return pattern
|
|
2207
|
+
|
|
2208
|
+
|
|
2209
|
+
def _build_step_callback(kw, text, method_call, camel, page_class):
|
|
2210
|
+
"""Build one Cucumber step definition block using Cucumber expressions."""
|
|
2211
|
+
pattern = _step_text_to_cucumber_pattern(text)
|
|
2212
|
+
param_types = re.findall(r'\{(string|int|float|word)\}', pattern)
|
|
2213
|
+
if param_types:
|
|
2214
|
+
params = ", ".join(
|
|
2215
|
+
f"p{i}: {'string' if t == 'string' else 'number'}"
|
|
2216
|
+
for i, t in enumerate(param_types)
|
|
2217
|
+
)
|
|
2218
|
+
fn_sig = f"async function ({params}): Promise<void>"
|
|
2219
|
+
if "()" in method_call:
|
|
2220
|
+
arg_list = ", ".join(f"p{i}" for i in range(len(param_types)))
|
|
2221
|
+
actual_call = method_call.replace("()", f"({arg_list})")
|
|
2222
|
+
else:
|
|
2223
|
+
actual_call = method_call
|
|
2224
|
+
else:
|
|
2225
|
+
fn_sig = "async function (): Promise<void>"
|
|
2226
|
+
actual_call = method_call
|
|
2227
|
+
return (
|
|
2228
|
+
f"{kw}(\n"
|
|
2229
|
+
f" '{pattern}',\n"
|
|
2230
|
+
f" {fn_sig} {{\n"
|
|
2231
|
+
f" if (!{camel}Page) {{ {camel}Page = new {page_class}Page(); }}\n"
|
|
2232
|
+
f" await {actual_call};\n"
|
|
2233
|
+
f" }},\n"
|
|
2234
|
+
f");\n"
|
|
2235
|
+
)
|
|
2236
|
+
|
|
2237
|
+
|
|
1542
2238
|
def _gen_step_defs(page_class, kebab, camel, all_steps, auth_hook):
|
|
2239
|
+
"""Generate a complete *.steps.ts for EVERY step in the feature.
|
|
2240
|
+
|
|
2241
|
+
Previous behaviour silently dropped any step whose text didn't match one of
|
|
2242
|
+
14 hardcoded keyword phrases, leaving the file with only the two SSO Given
|
|
2243
|
+
stubs. New behaviour:
|
|
2244
|
+
- Extended mappings cover common e-commerce/SaaS patterns.
|
|
2245
|
+
- Any step not matched gets a derived method name and typed stub.
|
|
2246
|
+
- All patterns use Cucumber expression strings (not raw /^...$/ regex) to
|
|
2247
|
+
avoid the paren-balance validation errors in write_helix_files.
|
|
2248
|
+
"""
|
|
1543
2249
|
bg = ""
|
|
1544
2250
|
if auth_hook == "microsoft-sso":
|
|
1545
|
-
bg =
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
async function (userType: string): Promise<void> {
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
)
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
(["
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
(["
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
(["
|
|
2251
|
+
bg = (
|
|
2252
|
+
"\nGiven(\n"
|
|
2253
|
+
" 'user logs in with Microsoft SSO as {string}',\n"
|
|
2254
|
+
" async function (userType: string): Promise<void> {\n"
|
|
2255
|
+
f" if (!{camel}Page) {{ {camel}Page = new {page_class}Page(); }}\n"
|
|
2256
|
+
f" fixture().logger.info(`SSO login as ${{userType}}`);\n"
|
|
2257
|
+
" },\n"
|
|
2258
|
+
");\n"
|
|
2259
|
+
"Given(\n"
|
|
2260
|
+
" 'user of type {string} is ready to login',\n"
|
|
2261
|
+
" async function (userType: string): Promise<void> {\n"
|
|
2262
|
+
f" if (!{camel}Page) {{ {camel}Page = new {page_class}Page(); }}\n"
|
|
2263
|
+
f" fixture().logger.info(`Preparing login for ${{userType}}`);\n"
|
|
2264
|
+
" },\n"
|
|
2265
|
+
");\n"
|
|
2266
|
+
)
|
|
2267
|
+
|
|
2268
|
+
mappings: list = [
|
|
2269
|
+
# navigation
|
|
2270
|
+
(["navigates to", "is on the", "navigate to", "visits the",
|
|
2271
|
+
"goes to", "opens the", "on the page", "on the site"],
|
|
2272
|
+
f"{camel}Page.navigate()"),
|
|
2273
|
+
# cart setup
|
|
2274
|
+
(["has items in", "has item in", "items in the cart",
|
|
2275
|
+
"item in the cart", "product in the cart",
|
|
2276
|
+
"item in their cart", "items in their cart"],
|
|
2277
|
+
f"{camel}Page.setupCartWithItems()"),
|
|
2278
|
+
# add to cart
|
|
2279
|
+
(["adds the item", "add the item", "adds item", "add item",
|
|
2280
|
+
"clicks add to cart", "click add to cart",
|
|
2281
|
+
"adds a product", "add a product",
|
|
2282
|
+
"adds to cart", "add to cart"],
|
|
2283
|
+
f"{camel}Page.addItemToCart()"),
|
|
2284
|
+
# remove from cart
|
|
2285
|
+
(["remove button", "clicks the remove", "click the remove",
|
|
2286
|
+
"clicks remove", "click remove",
|
|
2287
|
+
"removes the item", "remove the item",
|
|
2288
|
+
"removes item", "remove item",
|
|
2289
|
+
"removes the product", "remove the product"],
|
|
2290
|
+
f"{camel}Page.clickRemoveButton()"),
|
|
2291
|
+
# verify removed
|
|
2292
|
+
(["item is removed", "item should be removed",
|
|
2293
|
+
"item no longer", "product is removed",
|
|
2294
|
+
"product should be removed", "product no longer"],
|
|
2295
|
+
f"{camel}Page.verifyItemRemoved()"),
|
|
2296
|
+
# cart empty
|
|
2297
|
+
(["cart is empty", "cart should be empty",
|
|
2298
|
+
"empty cart", "no items in the cart"],
|
|
2299
|
+
f"{camel}Page.verifyCartEmpty()"),
|
|
2300
|
+
# cart count / badge
|
|
2301
|
+
(["cart count", "cart badge", "item count",
|
|
2302
|
+
"number of items", "quantity in cart",
|
|
2303
|
+
"shopping cart count"],
|
|
2304
|
+
f"{camel}Page.verifyCartCount()"),
|
|
2305
|
+
# cart total
|
|
2306
|
+
(["cart total", "total price", "cart subtotal",
|
|
2307
|
+
"subtotal", "order total"],
|
|
2308
|
+
f"{camel}Page.verifyCartTotal()"),
|
|
2309
|
+
# product display
|
|
2310
|
+
(["product page", "product detail", "product listing",
|
|
2311
|
+
"product is visible", "products are displayed",
|
|
2312
|
+
"products are listed", "product list"],
|
|
2313
|
+
f"{camel}Page.verifyProductsDisplayed()"),
|
|
2314
|
+
# click product
|
|
2315
|
+
(["clicks on the product", "click on the product",
|
|
2316
|
+
"clicks the product", "click the product",
|
|
2317
|
+
"selects the product", "select the product"],
|
|
2318
|
+
f"{camel}Page.clickProduct()"),
|
|
2319
|
+
# product info
|
|
2320
|
+
(["product name", "product title", "product description",
|
|
2321
|
+
"product price", "product detail is"],
|
|
2322
|
+
f"{camel}Page.verifyProductDetails()"),
|
|
2323
|
+
# checkout
|
|
2324
|
+
(["proceeds to checkout", "proceed to checkout",
|
|
2325
|
+
"clicks checkout", "click checkout"],
|
|
2326
|
+
f"{camel}Page.proceedToCheckout()"),
|
|
2327
|
+
# order confirmation
|
|
2328
|
+
(["order is placed", "order placed", "order confirmed",
|
|
2329
|
+
"order confirmation", "purchase complete"],
|
|
2330
|
+
f"{camel}Page.verifyOrderConfirmation()"),
|
|
2331
|
+
# form fill
|
|
2332
|
+
(["fills in", "fill in", "fills out", "fill out",
|
|
2333
|
+
"enters details", "completes the form"],
|
|
2334
|
+
f"{camel}Page.fillForm()"),
|
|
2335
|
+
# submit
|
|
2336
|
+
(["submits the form", "submit the form",
|
|
2337
|
+
"clicks submit", "click submit"],
|
|
2338
|
+
f"{camel}Page.submitForm()"),
|
|
2339
|
+
# confirm
|
|
2340
|
+
(["confirms the action", "confirm the action",
|
|
2341
|
+
"clicks confirm", "click confirm"],
|
|
2342
|
+
f"{camel}Page.confirmAction()"),
|
|
2343
|
+
# cancel / close
|
|
2344
|
+
(["clicks cancel", "click cancel",
|
|
2345
|
+
"clicks close", "click close"],
|
|
2346
|
+
f"{camel}Page.clickCancel()"),
|
|
2347
|
+
# save
|
|
2348
|
+
(["clicks save", "click save"],
|
|
2349
|
+
f"{camel}Page.clickSave()"),
|
|
2350
|
+
# text input
|
|
2351
|
+
(["enters", "enter", "types", "type", "fills", "fill",
|
|
2352
|
+
"inputs", "input"],
|
|
2353
|
+
f"{camel}Page.fillPrimaryInput()"),
|
|
2354
|
+
# file upload
|
|
2355
|
+
(["selects a valid file", "uploads a valid file",
|
|
2356
|
+
"valid file", "valid image"],
|
|
2357
|
+
f"{camel}Page.uploadFile(Buffer.alloc(5*1024*1024),'test.jpg','image/jpeg')"),
|
|
2358
|
+
(["exceeds the maximum", "oversized file", "file too large"],
|
|
2359
|
+
f"{camel}Page.uploadFile(Buffer.alloc(5*1024*1024+1),'over.jpg','image/jpeg')"),
|
|
2360
|
+
# success
|
|
2361
|
+
(["success toast", "success notification",
|
|
2362
|
+
"success message", "successfully"],
|
|
2363
|
+
f"{camel}Page.verifySuccessToast()"),
|
|
2364
|
+
# error
|
|
2365
|
+
(["error toast", "error notification",
|
|
2366
|
+
"error message is displayed", "error is displayed"],
|
|
2367
|
+
f"{camel}Page.verifyErrorToast()"),
|
|
2368
|
+
# validation
|
|
2369
|
+
(["validation error", "validation message",
|
|
2370
|
+
"field error", "required field"],
|
|
2371
|
+
f"{camel}Page.verifyValidationErrors()"),
|
|
2372
|
+
# success state
|
|
2373
|
+
(["operation completes", "updated state",
|
|
2374
|
+
"action is complete", "successfully updated",
|
|
2375
|
+
"successfully deleted"],
|
|
2376
|
+
f"{camel}Page.verifySuccessState()"),
|
|
2377
|
+
# persisted
|
|
2378
|
+
(["still be visible", "persists", "is still",
|
|
2379
|
+
"remains", "after reload", "after refresh"],
|
|
2380
|
+
f"{camel}Page.verifyStatePersisted()"),
|
|
2381
|
+
# reload
|
|
2382
|
+
(["refreshes the page", "refresh the page",
|
|
2383
|
+
"reloads the page", "reload the page"],
|
|
2384
|
+
f"{camel}Page.refreshPage()"),
|
|
2385
|
+
# generic display check
|
|
2386
|
+
(["is displayed", "are displayed", "is shown",
|
|
2387
|
+
"are shown", "is visible", "are visible",
|
|
2388
|
+
"should see", "should display"],
|
|
2389
|
+
f"{camel}Page.verifySuccessState()"),
|
|
2390
|
+
# avatar
|
|
2391
|
+
(["initials", "fallback avatar", "avatar"],
|
|
2392
|
+
f"{camel}Page.verifyInitialsAvatar()"),
|
|
2393
|
+
# modal
|
|
2394
|
+
(["modal", "dialog", "popup", "overlay"],
|
|
2395
|
+
f"{camel}Page.verifyModalVisible()"),
|
|
2396
|
+
# sort
|
|
2397
|
+
(["sorted", "sort order", "in order"],
|
|
2398
|
+
f"{camel}Page.verifySortOrder()"),
|
|
2399
|
+
# filter
|
|
2400
|
+
(["filter", "filtered", "filters"],
|
|
2401
|
+
f"{camel}Page.applyFilter()"),
|
|
2402
|
+
# search
|
|
2403
|
+
(["search", "searches"],
|
|
2404
|
+
f"{camel}Page.search()"),
|
|
1574
2405
|
]
|
|
1575
|
-
|
|
2406
|
+
|
|
2407
|
+
blocks: list = []
|
|
2408
|
+
seen_patterns: set = set()
|
|
2409
|
+
unmapped_steps: list = []
|
|
2410
|
+
|
|
1576
2411
|
for step in all_steps:
|
|
1577
2412
|
m = re.match(r"^(Given|When|Then|And|But)\s+", step)
|
|
1578
|
-
if not m:
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
2413
|
+
if not m:
|
|
2414
|
+
continue
|
|
2415
|
+
kw = m.group(1)
|
|
2416
|
+
text = step[len(m.group(0)):]
|
|
2417
|
+
norm = re.sub(r"\s+", " ", text.lower().strip())
|
|
2418
|
+
if norm in seen_patterns:
|
|
2419
|
+
continue
|
|
2420
|
+
seen_patterns.add(norm)
|
|
2421
|
+
|
|
1583
2422
|
lower = step.lower()
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
/^{pat}$/,
|
|
1589
|
-
async function (): Promise<void> {{
|
|
1590
|
-
await {method};
|
|
1591
|
-
}},
|
|
1592
|
-
);
|
|
1593
|
-
''')
|
|
1594
|
-
return f'''import {{ Given, When, Then }} from "@cucumber/cucumber";
|
|
1595
|
-
import {{ expect }} from "@playwright/test";
|
|
1596
|
-
import {{ fixture }} from "@hooks/pageFixture";
|
|
1597
|
-
import {page_class}Page from "@pages/{kebab}/{kebab}.page";
|
|
2423
|
+
method_call = next(
|
|
2424
|
+
(mth for kws, mth in mappings if any(k in lower for k in kws)),
|
|
2425
|
+
None,
|
|
2426
|
+
)
|
|
1598
2427
|
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
2428
|
+
if method_call:
|
|
2429
|
+
blocks.append(
|
|
2430
|
+
_build_step_callback(kw, text, method_call, camel, page_class)
|
|
2431
|
+
)
|
|
2432
|
+
else:
|
|
2433
|
+
method_name = _step_text_to_method_name(text)
|
|
2434
|
+
unmapped_steps.append((kw, text, method_name))
|
|
2435
|
+
blocks.append(
|
|
2436
|
+
_build_step_callback(
|
|
2437
|
+
kw, text, f"{camel}Page.{method_name}()", camel, page_class,
|
|
2438
|
+
)
|
|
2439
|
+
)
|
|
2440
|
+
|
|
2441
|
+
unmapped_comment = ""
|
|
2442
|
+
if unmapped_steps:
|
|
2443
|
+
lines_u = [
|
|
2444
|
+
f" * {kw}: {text} → {camel}Page.{mn}()"
|
|
2445
|
+
for kw, text, mn in unmapped_steps
|
|
2446
|
+
]
|
|
2447
|
+
unmapped_comment = (
|
|
2448
|
+
"\n/**\n"
|
|
2449
|
+
" * Derived step implementations — add these methods to the page object:\n"
|
|
2450
|
+
+ "\n".join(lines_u) + "\n"
|
|
2451
|
+
" * (auto-generated by qa-playwright-generator)\n"
|
|
2452
|
+
" */\n"
|
|
2453
|
+
)
|
|
2454
|
+
|
|
2455
|
+
return (
|
|
2456
|
+
f'import {{ Given, When, Then }} from "@cucumber/cucumber";\n'
|
|
2457
|
+
f'import {{ expect }} from "@playwright/test";\n'
|
|
2458
|
+
f'import {{ fixture }} from "@hooks/pageFixture";\n'
|
|
2459
|
+
f'import {page_class}Page from "@pages/{kebab}.page";\n'
|
|
2460
|
+
f'\n'
|
|
2461
|
+
f'/**\n'
|
|
2462
|
+
f' * Step Definitions: {page_class}\n'
|
|
2463
|
+
f' * HealingDashboard: http://localhost:7890 — review pending suggestions.\n'
|
|
2464
|
+
f' */\n'
|
|
2465
|
+
f'let {camel}Page: {page_class}Page;\n'
|
|
2466
|
+
f'{bg}\n'
|
|
2467
|
+
f'{unmapped_comment}'
|
|
2468
|
+
+ "".join(blocks)
|
|
2469
|
+
)
|
|
1607
2470
|
|
|
1608
2471
|
|
|
1609
2472
|
def _gen_feature_file(gherkin: str, kebab: str, page_class: str) -> str:
|