@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.
Files changed (41) hide show
  1. package/README.md +59 -314
  2. package/bin/postinstall.js +17 -1
  3. package/bin/qa-stlc.js +23 -0
  4. package/package.json +1 -1
  5. package/skills/write-helix-files/SKILL.md +6 -0
  6. package/src/cli/cmd-cost.js +253 -0
  7. package/src/cli/cmd-mcp-config.js +124 -59
  8. package/src/stlc_agents/agent_gherkin_generator/server.py +88 -4
  9. package/src/stlc_agents/agent_helix_writer/tools/helix_write.py +60 -28
  10. package/src/stlc_agents/agent_jira_manager/server.py +209 -2
  11. package/src/stlc_agents/agent_jira_manager/tools/jira_workitem.py +36 -0
  12. package/src/stlc_agents/agent_playwright_generator/server.py +968 -105
  13. package/src/stlc_agents/agent_test_case_manager/server.py +121 -2
  14. package/src/stlc_agents/shared/cost_tracker.py +395 -0
  15. package/src/stlc_agents/shared/pricing.py +72 -0
  16. package/src/stlc_agents/__pycache__/__init__.cpython-310.pyc +0 -0
  17. package/src/stlc_agents/agent_gherkin_generator/__pycache__/__init__.cpython-310.pyc +0 -0
  18. package/src/stlc_agents/agent_gherkin_generator/__pycache__/server.cpython-310.pyc +0 -0
  19. package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/__init__.cpython-310.pyc +0 -0
  20. package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/ado_gherkin.cpython-310.pyc +0 -0
  21. package/src/stlc_agents/agent_helix_writer/__pycache__/__init__.cpython-310.pyc +0 -0
  22. package/src/stlc_agents/agent_helix_writer/__pycache__/server.cpython-310.pyc +0 -0
  23. package/src/stlc_agents/agent_helix_writer/tools/__pycache__/__init__.cpython-310.pyc +0 -0
  24. package/src/stlc_agents/agent_helix_writer/tools/__pycache__/boilerplate.cpython-310.pyc +0 -0
  25. package/src/stlc_agents/agent_helix_writer/tools/__pycache__/helix_write.cpython-310.pyc +0 -0
  26. package/src/stlc_agents/agent_jira_manager/__pycache__/__init__.cpython-310.pyc +0 -0
  27. package/src/stlc_agents/agent_jira_manager/__pycache__/server.cpython-310.pyc +0 -0
  28. package/src/stlc_agents/agent_jira_manager/tools/__pycache__/__init__.cpython-310.pyc +0 -0
  29. package/src/stlc_agents/agent_jira_manager/tools/__pycache__/jira_workitem.cpython-310.pyc +0 -0
  30. package/src/stlc_agents/agent_playwright_generator/__pycache__/__init__.cpython-310.pyc +0 -0
  31. package/src/stlc_agents/agent_playwright_generator/__pycache__/server.cpython-310.pyc +0 -0
  32. package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/__init__.cpython-310.pyc +0 -0
  33. package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/ado_attach.cpython-310.pyc +0 -0
  34. package/src/stlc_agents/agent_test_case_manager/__pycache__/__init__.cpython-310.pyc +0 -0
  35. package/src/stlc_agents/agent_test_case_manager/__pycache__/server.cpython-310.pyc +0 -0
  36. package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/__init__.cpython-310.pyc +0 -0
  37. package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/ado_workitem.cpython-310.pyc +0 -0
  38. package/src/stlc_agents/shared/__pycache__/__init__.cpython-310.pyc +0 -0
  39. package/src/stlc_agents/shared/__pycache__/auth.cpython-310.pyc +0 -0
  40. package/src/stlc_agents/shared_jira/__pycache__/__init__.cpython-310.pyc +0 -0
  41. 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
- Browser interaction at generation time is handled by the external Playwright MCP
5
- server (github.com/microsoft/playwright-mcp). Start it before running workflows:
6
- npx @playwright/mcp@latest --port 8931
7
-
8
- The coding agent uses Playwright MCP tools (browser_navigate, browser_snapshot,
9
- browser_fill, browser_click, browser_wait_for_url) to build a verified context_map
10
- of locators from the live accessibility tree, then passes that map to
11
- generate_playwright_code.
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
- _file_cache: dict[str, dict] = {}
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 == "generate_playwright_code":
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
- _file_cache[cache_key] = result.pop("files")
454
+ _files = result.pop("files")
455
+ _cache_write(cache_key, _files)
368
456
  result["cache_key"] = cache_key
369
- result["files_manifest"] = list(_file_cache[cache_key].keys())
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
- _file_cache[cache_key] = result.pop("files")
478
+ _files = result.pop("files")
479
+ _cache_write(cache_key, _files)
388
480
  result["cache_key"] = cache_key
389
- result["files_manifest"] = list(_file_cache[cache_key].keys())
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 cache_key not in _file_cache:
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": _file_cache[cache_key]}
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
- vi_field = "private readonly visual: VisualIntentChecker;" if enable_visual_regression else ""
1376
- th_field = "private readonly timing: TimingHealer;" if enable_timing_healing else ""
1377
- vi_init = "this.visual = new VisualIntentChecker(this.page, fixture().logger, this.repo);" if enable_visual_regression else ""
1378
- th_init = "this.timing = new TimingHealer(this.page, fixture().logger, this.repo);" if enable_timing_healing else ""
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 "./locators";
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
- private readonly page: Page;
1410
- private readonly healer: LocatorHealer;
1411
- private readonly repo: LocatorRepository;
1412
- private readonly loc = {page_class}Locators;
1413
- private readonly env: EnvironmentManager;
1414
- {vi_field}
1415
- {th_field}
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 = f'''
1546
- Given(
1547
- /^user logs in with Microsoft SSO as "(.+)"$/,
1548
- async function (userType: string): Promise<void> {{
1549
- fixture().logger.info(`SSO login as ${{userType}}`);
1550
- }},
1551
- );
1552
- Given(
1553
- /^user of type "(.+)" is ready to login$/,
1554
- async function (userType: string): Promise<void> {{
1555
- fixture().logger.info(`Preparing login for ${{userType}}`);
1556
- }},
1557
- );
1558
- '''
1559
- mappings = [
1560
- (["navigates to", "is on the"], f"{camel}Page.navigate()"),
1561
- (["submits the form", "clicks submit"], f"{camel}Page.submitForm()"),
1562
- (["confirms the action"], f"{camel}Page.confirmAction()"),
1563
- (["clicks cancel", "clicks close"], f"{camel}Page.clickCancel()"),
1564
- (["clicks save"], f"{camel}Page.clickSave()"),
1565
- (["refreshes the page"], f"{camel}Page.refreshPage()"),
1566
- (["selects a valid file", "uploads a valid"], f"{camel}Page.uploadFile(Buffer.alloc(5*1024*1024),'test.jpg','image/jpeg')"),
1567
- (["exceeds the maximum", "oversized"], f"{camel}Page.uploadFile(Buffer.alloc(5*1024*1024+1),'over.jpg','image/jpeg')"),
1568
- (["success toast", "success notification"], f"{camel}Page.verifySuccessToast()"),
1569
- (["error toast", "error notification"], f"{camel}Page.verifyErrorToast()"),
1570
- (["validation error"], f"{camel}Page.verifyValidationErrors()"),
1571
- (["operation completes", "updated state"], f"{camel}Page.verifySuccessState()"),
1572
- (["still be visible", "persists"], f"{camel}Page.verifyStatePersisted()"),
1573
- (["initials", "fallback avatar"], f"{camel}Page.verifyInitialsAvatar()"),
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
- lines, seen = [], set()
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: continue
1579
- kw, text = m.group(1), step[len(m.group(0)):]
1580
- norm = re.sub(r"\s+", " ", text.lower())
1581
- if norm in seen: continue
1582
- seen.add(norm)
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
- method = next((mth for kws, mth in mappings if any(k in lower for k in kws)), None)
1585
- if not method: continue
1586
- pat = re.sub(r'"[^"]+"', '(.+)', re.escape(text)).replace(r"\ ", " ")
1587
- lines.append(f'''{kw}(
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
- * Step Definitions: {page_class}
1601
- * HealingDashboard: http://localhost:7890 — review pending suggestions.
1602
- */
1603
- let {camel}Page: {page_class}Page;
1604
- {bg}
1605
- {"".join(lines)}
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: