@qa-gentic/stlc-agents 1.0.25 → 1.0.27

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 (50) hide show
  1. package/package.json +1 -1
  2. package/skills/generate-test-cases/SKILL.md +5 -0
  3. package/src/cli/cmd-cost.js +61 -30
  4. package/src/cli/cmd-init.js +88 -8
  5. package/src/stlc_agents/__pycache__/__init__.cpython-314.pyc +0 -0
  6. package/src/stlc_agents/agent_gherkin_generator/__pycache__/__init__.cpython-314.pyc +0 -0
  7. package/src/stlc_agents/agent_gherkin_generator/__pycache__/server.cpython-314.pyc +0 -0
  8. package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/__init__.cpython-314.pyc +0 -0
  9. package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/ado_gherkin.cpython-314.pyc +0 -0
  10. package/src/stlc_agents/agent_helix_writer/__pycache__/__init__.cpython-314.pyc +0 -0
  11. package/src/stlc_agents/agent_helix_writer/__pycache__/server.cpython-314.pyc +0 -0
  12. package/src/stlc_agents/agent_helix_writer/server.py +41 -6
  13. package/src/stlc_agents/agent_helix_writer/tools/__pycache__/__init__.cpython-314.pyc +0 -0
  14. package/src/stlc_agents/agent_helix_writer/tools/__pycache__/boilerplate.cpython-314.pyc +0 -0
  15. package/src/stlc_agents/agent_helix_writer/tools/__pycache__/helix_write.cpython-314.pyc +0 -0
  16. package/src/stlc_agents/agent_playwright_generator/__pycache__/__init__.cpython-314.pyc +0 -0
  17. package/src/stlc_agents/agent_playwright_generator/__pycache__/server.cpython-314.pyc +0 -0
  18. package/src/stlc_agents/agent_playwright_generator/server.py +419 -213
  19. package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/__init__.cpython-314.pyc +0 -0
  20. package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/ado_attach.cpython-314.pyc +0 -0
  21. package/src/stlc_agents/agent_test_case_manager/__pycache__/__init__.cpython-314.pyc +0 -0
  22. package/src/stlc_agents/agent_test_case_manager/__pycache__/server.cpython-314.pyc +0 -0
  23. package/src/stlc_agents/agent_test_case_manager/server.py +12 -0
  24. package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/__init__.cpython-314.pyc +0 -0
  25. package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/ado_workitem.cpython-314.pyc +0 -0
  26. package/src/stlc_agents/agent_test_case_manager/tools/ado_workitem.py +65 -1
  27. package/src/stlc_agents/shared/__pycache__/__init__.cpython-314.pyc +0 -0
  28. package/src/stlc_agents/shared/__pycache__/auth.cpython-314.pyc +0 -0
  29. package/src/stlc_agents/shared/__pycache__/cost_tracker.cpython-314.pyc +0 -0
  30. package/src/stlc_agents/shared/__pycache__/pricing.cpython-314.pyc +0 -0
  31. package/src/stlc_agents/shared/cost_tracker.py +378 -70
  32. package/src/stlc_agents/shared/pricing.py +115 -24
  33. package/src/stlc_agents/webhook_orchestrator/__init__.py +0 -0
  34. package/src/stlc_agents/webhook_orchestrator/agent_runner.py +599 -0
  35. package/src/stlc_agents/webhook_orchestrator/main.py +43 -0
  36. package/src/stlc_agents/webhook_orchestrator/models.py +63 -0
  37. package/src/stlc_agents/webhook_orchestrator/orchestrator.py +103 -0
  38. package/src/stlc_agents/webhook_orchestrator/pipelines/__init__.py +0 -0
  39. package/src/stlc_agents/webhook_orchestrator/pipelines/_base.py +57 -0
  40. package/src/stlc_agents/webhook_orchestrator/pipelines/ado_test_cases.py +55 -0
  41. package/src/stlc_agents/webhook_orchestrator/pipelines/full_pipeline.py +202 -0
  42. package/src/stlc_agents/webhook_orchestrator/pipelines/gherkin_playwright.py +156 -0
  43. package/src/stlc_agents/webhook_orchestrator/pipelines/jira_test_cases.py +48 -0
  44. package/src/stlc_agents/webhook_orchestrator/webhook_bridge.py +368 -0
  45. package/src/stlc_agents/agent_gherkin_generator/__pycache__/server.cpython-310.pyc +0 -0
  46. package/src/stlc_agents/agent_helix_writer/__pycache__/server.cpython-310.pyc +0 -0
  47. package/src/stlc_agents/agent_jira_manager/__pycache__/server.cpython-310.pyc +0 -0
  48. package/src/stlc_agents/agent_test_case_manager/__pycache__/server.cpython-310.pyc +0 -0
  49. package/src/stlc_agents/shared/__pycache__/cost_tracker.cpython-310.pyc +0 -0
  50. package/src/stlc_agents/shared/__pycache__/pricing.cpython-310.pyc +0 -0
@@ -24,7 +24,7 @@ CI/CD Telemetry:
24
24
  before they are permanently committed to the repository.
25
25
  """
26
26
  from __future__ import annotations
27
- import asyncio, json, re, sys, os, uuid, socket, subprocess, time, threading, queue
27
+ import asyncio, json, logging, re, sys, os, uuid, socket, subprocess, time, threading, queue
28
28
  from pathlib import Path
29
29
  from dotenv import load_dotenv
30
30
  from mcp.server import Server
@@ -1081,7 +1081,9 @@ def _merge_locators_ts(new_locators_section: str, existing_content: str, page_cl
1081
1081
 
1082
1082
  for match in re.finditer(new_pattern, new_locators_section):
1083
1083
  key_name, selector, intent = match.groups()
1084
- new_locators_dict[key_name] = {"selector": selector, "intent": intent}
1084
+ stab_m = re.search(r'stability:\s*(\d+)', match.group(0))
1085
+ stability = int(stab_m.group(1)) if stab_m else 0
1086
+ new_locators_dict[key_name] = {"selector": selector, "intent": intent, "stability": stability}
1085
1087
 
1086
1088
  # Filter out existing keys
1087
1089
  unique_locators = {}
@@ -1113,7 +1115,8 @@ def _merge_locators_ts(new_locators_section: str, existing_content: str, page_cl
1113
1115
  for key_name, entry in {**unique_locators, **locator_conflicts}.items():
1114
1116
  selector = entry["selector"]
1115
1117
  intent = entry["intent"]
1116
- new_entries.append(f' {key_name}: {{ selector: "{selector}", intent: \'{intent}\', stability: 0 }},')
1118
+ stability = entry.get("stability", 0)
1119
+ new_entries.append(f' {key_name}: {{ selector: "{selector}", intent: \'{intent}\', stability: {stability} }},')
1117
1120
 
1118
1121
  if new_entries:
1119
1122
  merged = (
@@ -1326,8 +1329,12 @@ class _PlaywrightMcp:
1326
1329
  self._init_session()
1327
1330
 
1328
1331
  def call(self, tool: str, arguments: dict) -> dict:
1332
+ _log = logging.getLogger("stlc.playwright_mcp")
1329
1333
  msg = self._build_rpc("tools/call", {"name": tool, "arguments": arguments})
1330
1334
  resp = self._post(msg)
1335
+ if "error" in resp:
1336
+ _log.error("Playwright MCP tool '%s' error: %s", tool, resp["error"])
1337
+ raise RuntimeError(f"Playwright MCP tool '{tool}' error: {resp['error']}")
1331
1338
  content = resp.get("result", {}).get("content", [])
1332
1339
  if not content:
1333
1340
  return {}
@@ -1503,6 +1510,11 @@ def _find_ref(snapshot: dict, *, role: str | None = None, name_patterns: list[st
1503
1510
  return None
1504
1511
 
1505
1512
 
1513
+ def _find_refs_by_role(snapshot: dict, role: str) -> list[str]:
1514
+ """Return all refs with the given role, in document order."""
1515
+ return [el["ref"] for el in _parse_ax_elements(snapshot) if el.get("role") == role and el.get("ref")]
1516
+
1517
+
1506
1518
  def _to_camel(s: str) -> str:
1507
1519
  words = re.split(r"[\s\-_]+", s.strip())
1508
1520
  if not words:
@@ -1640,77 +1652,181 @@ def _needs_login(app_url: str, username: str, password: str) -> bool:
1640
1652
 
1641
1653
  def _do_login(pmcp: _PlaywrightMcp, app_url: str, username: str, password: str) -> dict:
1642
1654
  """Navigate to app_url, fill login form, click submit, return post-login snapshot."""
1655
+ _log = logging.getLogger("stlc.playwright_mcp")
1643
1656
  pmcp.call("browser_navigate", {"url": app_url.rstrip("/")})
1657
+
1658
+ # Snapshot AFTER navigate to get fresh refs — Playwright MCP requires ref= to target
1659
+ # a specific element; the element= field is only a human-readable permission label.
1644
1660
  login_snap = pmcp.call("browser_snapshot", {})
1661
+ _log.info("Pre-login snapshot (first 600):\n%s", _snapshot_to_text(login_snap)[:600])
1645
1662
 
1646
- user_ref = _find_ref(login_snap, role="textbox", name_patterns=[r"user(?:name)?|email|login"])
1663
+ user_ref = _find_ref(login_snap, role="textbox", name_patterns=[r"user(?:name)?"])
1647
1664
  pass_ref = _find_ref(login_snap, role="textbox", name_patterns=[r"pass(?:word)?"])
1665
+ btn_ref = _find_ref(login_snap, role="button", name_patterns=[r"login|sign.?in|submit"])
1666
+ _log.info("Login refs: user=%s pass=%s btn=%s", user_ref, pass_ref, btn_ref)
1648
1667
 
1649
1668
  if not user_ref or not pass_ref:
1650
- # no login form detected — return the snapshot as-is
1651
- return login_snap
1669
+ raise RuntimeError(
1670
+ f"Login form not found on page (user_ref={user_ref!r}, pass_ref={pass_ref!r}). "
1671
+ "Check APP_BASE_URL and that Playwright MCP can reach the app."
1672
+ )
1652
1673
 
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
- )
1674
+ pmcp.call("browser_fill_form", {"fields": [
1675
+ {"name": "Username", "type": "textbox", "ref": user_ref, "value": username},
1676
+ {"name": "Password", "type": "textbox", "ref": pass_ref, "value": password},
1677
+ ]})
1658
1678
 
1659
- pmcp.call("browser_fill", {"ref": user_ref, "value": username})
1660
- pmcp.call("browser_fill", {"ref": pass_ref, "value": password})
1661
1679
  if btn_ref:
1662
- pmcp.call("browser_click", {"ref": btn_ref})
1680
+ pmcp.call("browser_click", {"element": "Login button", "ref": btn_ref})
1663
1681
  else:
1664
- # Last resort: submit via Enter key on the password field
1665
- pmcp.call("browser_press", {"ref": pass_ref, "key": "Enter"})
1682
+ pmcp.call("browser_click", {"element": "Login button"})
1666
1683
 
1667
- return pmcp.call("browser_snapshot", {})
1684
+ post_snap = pmcp.call("browser_snapshot", {})
1685
+ _log.info("Post-login snapshot (first 400):\n%s", _snapshot_to_text(post_snap)[:400])
1686
+
1687
+ # Login failed if the username input is still present on the page
1688
+ if _find_ref(post_snap, role="textbox", name_patterns=[r"user(?:name)?|email"]):
1689
+ raise RuntimeError(
1690
+ "Login failed — still on login page after submit. "
1691
+ "Check APP_USERNAME, APP_PASSWORD, and that Playwright MCP can reach the app."
1692
+ )
1693
+ return post_snap
1668
1694
 
1669
1695
 
1670
- # ── Multi-step flows driven by gherkin hints ──────────────────────────────────
1696
+ # ── Gherkin-driven multi-step capture ────────────────────────────────────────
1671
1697
 
1672
- def _capture_checkout_flow(pmcp: _PlaywrightMcp, app_url: str, username: str, password: str) -> dict:
1698
+ # Interaction verbs we look for in When/And Gherkin steps
1699
+ _INTERACTION_VERBS = re.compile(
1700
+ r"^(?:click|tap|press|open|select|expand|toggle|choose|navigate\s+to|go\s+to)\b",
1701
+ re.IGNORECASE,
1702
+ )
1703
+
1704
+ # Strip leading "I " or keyword prefix before matching verb
1705
+ _STEP_PREFIX = re.compile(
1706
+ r"^(?:Given|When|Then|And|But)\s+(?:I\s+)?",
1707
+ re.IGNORECASE,
1708
+ )
1709
+
1710
+ # Trailing noise words that follow the real target name
1711
+ _TRAILING_NOISE = re.compile(
1712
+ r"\s+(?:button|link|tab|icon|item|option|element|toggle|"
1713
+ r"in\s+the\s+.+|from\s+the\s+.+|on\s+the\s+.+)\s*$",
1714
+ re.IGNORECASE,
1715
+ )
1716
+
1717
+ # Filler words between the verb and the target
1718
+ _VERB_FILLER = re.compile(
1719
+ r"^(?:click|tap|press|open|select|expand|toggle|choose|navigate\s+to|go\s+to)\s+"
1720
+ r"(?:on\s+)?(?:the\s+|a\s+|an\s+)?",
1721
+ re.IGNORECASE,
1722
+ )
1723
+
1724
+
1725
+ def _extract_interaction_target(step_body: str) -> str | None:
1726
+ """
1727
+ Return the interaction target from a Gherkin step body, or None if the step
1728
+ does not describe an interaction.
1729
+
1730
+ Examples:
1731
+ "click the hamburger menu" → "hamburger menu"
1732
+ "open the About link in the sidebar"→ "About"
1733
+ "tap the Submit button" → "Submit"
1734
+ "I am logged in as a user" → None (no interaction verb)
1735
+ """
1736
+ body = _STEP_PREFIX.sub("", step_body).strip()
1737
+ if not _INTERACTION_VERBS.match(body):
1738
+ return None
1739
+ target = _VERB_FILLER.sub("", body).strip()
1740
+ target = _TRAILING_NOISE.sub("", target).strip().strip("\"'")
1741
+ return target if len(target) >= 2 else None
1742
+
1743
+
1744
+ def _find_element_ref(snapshot: dict, target: str) -> str | None:
1673
1745
  """
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.
1746
+ Find a clickable element ref whose accessible name fuzzy-matches target.
1747
+
1748
+ Strategy: build a regex from the meaningful words in target and search
1749
+ through interactive roles in priority order, falling back to any role.
1678
1750
  """
1679
- base = app_url.rstrip("/")
1751
+ words = [w for w in re.split(r"\s+", target.lower()) if len(w) >= 3]
1752
+ if not words:
1753
+ return None
1754
+ pattern = "|".join(re.escape(w) for w in words)
1755
+ for role in ("button", "link", "menuitem", "tab", "option", None):
1756
+ ref = _find_ref(snapshot, role=role, name_patterns=[pattern])
1757
+ if ref:
1758
+ return ref
1759
+ return None
1680
1760
 
1681
- post_login = _do_login(pmcp, base, username, password) if _needs_login(base, username, password) else pmcp.call("browser_snapshot", {})
1682
1761
 
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})
1762
+ def _capture_gherkin_driven(
1763
+ pmcp: _PlaywrightMcp,
1764
+ app_url: str,
1765
+ username: str,
1766
+ password: str,
1767
+ gherkin: str,
1768
+ ) -> dict:
1769
+ """
1770
+ Gherkin-driven capture that works for any application.
1771
+
1772
+ Algorithm:
1773
+ 1. Login (if credentials) or navigate to app_url → snapshot landing page.
1774
+ 2. Parse each When/And/Given/Then step for interaction verbs (click, open,
1775
+ select, expand, toggle, …).
1776
+ 3. For each interaction: find the target element in the current snapshot,
1777
+ click it, wait for the DOM to settle, snapshot the new page state.
1778
+ 4. Return the merged context_map from all captured states.
1779
+
1780
+ Steps where the target element cannot be found in the current snapshot are
1781
+ skipped with a warning — this handles cases where an element is only revealed
1782
+ after a prior interaction.
1783
+ """
1784
+ _log = logging.getLogger("stlc.playwright_mcp")
1785
+ all_maps: list[dict] = []
1687
1786
 
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})
1787
+ # Phase 1 reach the starting page
1788
+ if _needs_login(app_url, username, password):
1789
+ current_snap = _do_login(pmcp, app_url, username, password)
1693
1790
  else:
1694
- pmcp.call("browser_navigate", {"url": f"{base}/cart.html"})
1791
+ pmcp.call("browser_navigate", {"url": app_url})
1792
+ current_snap = pmcp.call("browser_snapshot", {})
1695
1793
 
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})
1794
+ landing_map = _build_context_map_from_snapshot(current_snap)
1795
+ all_maps.append(landing_map)
1796
+ _log.info("gherkin capture: landing page elements=%s", list(landing_map.keys())[:10])
1700
1797
 
1701
- checkout_snap = pmcp.call("browser_snapshot", {})
1798
+ if not gherkin:
1799
+ return _merge_maps(*all_maps)
1702
1800
 
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
- )
1801
+ # Phase 2 — replay interaction steps
1802
+ steps = _parse_gherkin_steps(gherkin)
1803
+ for step_text in steps.get("all_steps", []):
1804
+ target = _extract_interaction_target(step_text)
1805
+ if not target:
1806
+ continue
1807
+
1808
+ _log.info("gherkin capture: looking for '%s'", target)
1809
+ ref = _find_element_ref(current_snap, target)
1810
+ if not ref:
1811
+ _log.info("gherkin capture: '%s' not found in snapshot — skipping", target)
1812
+ continue
1709
1813
 
1814
+ try:
1815
+ pmcp.call("browser_click", {"element": target, "ref": ref})
1816
+ time.sleep(0.4) # let DOM/animation settle
1817
+ new_snap = pmcp.call("browser_snapshot", {})
1818
+ new_map = _build_context_map_from_snapshot(new_snap)
1819
+ if new_map:
1820
+ all_maps.append(new_map)
1821
+ current_snap = new_snap
1822
+ _log.info(
1823
+ "gherkin capture: after '%s' → %d elements (total states=%d)",
1824
+ target, len(new_map), len(all_maps),
1825
+ )
1826
+ except Exception as exc:
1827
+ _log.warning("gherkin capture: interaction '%s' failed: %s", target, exc)
1710
1828
 
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"))
1829
+ return _merge_maps(*all_maps)
1714
1830
 
1715
1831
 
1716
1832
  # ── Public implementation ─────────────────────────────────────────────────────
@@ -1725,9 +1841,18 @@ def _capture_app_context(
1725
1841
  """
1726
1842
  Navigate the live application and return a verified context_map.
1727
1843
 
1728
- Owns the full browser lifecycle: start Playwright MCP if needed,
1729
- navigate / login / snapshot, build context_map, shut down.
1844
+ Uses Gherkin-driven capture: replays the interaction steps described in the
1845
+ Gherkin to visit every relevant page state. Works for any application —
1846
+ no app-specific flow knowledge required.
1847
+
1848
+ Owns the full browser lifecycle: start Playwright MCP if needed, run the
1849
+ capture, shut down.
1730
1850
  """
1851
+ _log = logging.getLogger("stlc.playwright_mcp")
1852
+ _log.info(
1853
+ "capture_app_context: url=%s username=%s password=%s mcp=%s",
1854
+ app_url, bool(app_username), bool(app_password), playwright_mcp_url,
1855
+ )
1731
1856
  if not app_url:
1732
1857
  return {
1733
1858
  "context_map": {},
@@ -1738,20 +1863,9 @@ def _capture_app_context(
1738
1863
  pmcp = _PlaywrightMcp(playwright_mcp_url)
1739
1864
  try:
1740
1865
  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
-
1866
+ context_map = _capture_gherkin_driven(
1867
+ pmcp, app_url, app_username, app_password, gherkin_content,
1868
+ )
1755
1869
  locator_source = "playwright-mcp-verified" if context_map else "gherkin-inferred"
1756
1870
  return {
1757
1871
  "context_map": context_map,
@@ -1760,11 +1874,13 @@ def _capture_app_context(
1760
1874
  }
1761
1875
 
1762
1876
  except Exception as exc:
1877
+ _log.error("capture_app_context failed: %s", exc, exc_info=True)
1763
1878
  return {
1764
1879
  "context_map": {},
1765
1880
  "locator_source": "gherkin-inferred",
1766
1881
  "error": str(exc),
1767
- "note": "Browser capture failed — caller may proceed with gherkin-inferred locators",
1882
+ "abort": True,
1883
+ "note": "Browser capture failed. STOP — do NOT call generate_playwright_code. Fix the error above, ensure the Playwright MCP server is reachable, then retry capture_app_context.",
1768
1884
  }
1769
1885
  finally:
1770
1886
  pmcp.close()
@@ -1803,11 +1919,14 @@ def _generate_playwright_code(
1803
1919
  f"// browser_navigate('{app_url}') → browser_snapshot() → pass result as context_map\n"
1804
1920
  )
1805
1921
  new_locators = snapshot_hint + new_locators
1922
+ new_step_defs, derived_methods = _gen_step_defs(
1923
+ page_class, kebab, camel, all_steps, auth_hook)
1806
1924
  new_page_object = _gen_page_object(
1807
1925
  page_class, kebab, camel, gherkin, healing_strategy,
1808
- enable_visual_regression, enable_timing_healing)
1809
- new_step_defs = _gen_step_defs(
1810
- page_class, kebab, camel, all_steps, auth_hook)
1926
+ enable_visual_regression, enable_timing_healing,
1927
+ derived_methods=derived_methods,
1928
+ context_map=context_map,
1929
+ app_url=app_url)
1811
1930
  new_feature = _gen_feature_file(gherkin, kebab, page_class)
1812
1931
 
1813
1932
  # Normalize feature file: auto-quote unquoted string parameters so they
@@ -1915,14 +2034,30 @@ def _gen_locators(page_class, kebab, gherkin, context_map=None):
1915
2034
  " * Run Playwright MCP (npx @playwright/mcp@latest --port 8931) and use\n"
1916
2035
  " * browser_navigate + browser_snapshot to replace them with real selectors."
1917
2036
  )
2037
+ body = (
2038
+ f" pageContainer: {{ selector: \"[data-testid='{kebab}-container']\", intent: '{page_class} main page container', stability: 0 }},\n"
2039
+ f" primaryActionBtn: {{ selector: \"[data-testid='{kebab}-action-btn']\", intent: 'primary action button', stability: 0, visualIntent: true }},\n"
2040
+ f" submitBtn: {{ selector: \"[data-testid='{kebab}-submit']\", intent: 'submit form button', stability: 0, visualIntent: true }},\n"
2041
+ f" confirmBtn: {{ selector: \"[data-testid='{kebab}-confirm']\", intent: 'confirm action button', stability: 0, visualIntent: true }},\n"
2042
+ f" cancelBtn: {{ selector: \"[data-testid='{kebab}-cancel']\", intent: 'cancel or close button', stability: 0 }},\n"
2043
+ f" saveBtn: {{ selector: \"[data-testid='{kebab}-save']\", intent: 'save changes button', stability: 0, visualIntent: true }},\n"
2044
+ f" primaryInputField: {{ selector: \"[data-testid='{kebab}-primary-input']\", intent: 'primary text input', stability: 0 }},\n"
2045
+ f" fileInput: {{ selector: \"input[type='file']\", intent: 'file upload input', stability: 70 }},\n"
2046
+ f" successToast: {{ selector: \"[data-testid='toast-success']\", intent: 'success notification toast', stability: 0, visualIntent: true }},\n"
2047
+ f" errorToast: {{ selector: \"[data-testid='toast-error']\", intent: 'error notification toast', stability: 0, visualIntent: true }},\n"
2048
+ f" validationError: {{ selector: \"[data-testid='validation-error']\", intent: 'form validation error message', stability: 0, visualIntent: true }},\n"
2049
+ f" successIndicator: {{ selector: \"[data-testid='{kebab}-success']\", intent: 'success state indicator', stability: 0, visualIntent: true }},\n"
2050
+ f" updatedIndicator: {{ selector: \"[data-testid='{kebab}-updated']\", intent: 'updated state indicator', stability: 0 }},\n"
2051
+ f"{extras}"
2052
+ )
1918
2053
  else:
1919
- extras = cdp_entries
1920
2054
  source_note = (
1921
2055
  " *\n"
1922
2056
  " * SOURCE: Playwright MCP accessibility tree snapshot.\n"
1923
2057
  " * Stability rank: data-testid(100) > aria-role+name(90) > id(80) > aria-label(70) > placeholder(60).\n"
1924
2058
  " * All selectors derived from real AX nodes — zero hallucinated values."
1925
2059
  )
2060
+ body = cdp_entries
1926
2061
 
1927
2062
  return f'''/**
1928
2063
  * Locators: {page_class}Page
@@ -1933,20 +2068,7 @@ def _gen_locators(page_class, kebab, gherkin, context_map=None):
1933
2068
  * visualIntent: when true, VisualIntentChecker screenshots this element at assertions{source_note}
1934
2069
  */
1935
2070
  export const {page_class}Locators = {{
1936
- pageContainer: {{ selector: "[data-testid='{kebab}-container']", intent: '{page_class} main page container', stability: 0 }},
1937
- primaryActionBtn: {{ selector: "[data-testid='{kebab}-action-btn']", intent: 'primary action button', stability: 0, visualIntent: true }},
1938
- submitBtn: {{ selector: "[data-testid='{kebab}-submit']", intent: 'submit form button', stability: 0, visualIntent: true }},
1939
- confirmBtn: {{ selector: "[data-testid='{kebab}-confirm']", intent: 'confirm action button', stability: 0, visualIntent: true }},
1940
- cancelBtn: {{ selector: "[data-testid='{kebab}-cancel']", intent: 'cancel or close button', stability: 0 }},
1941
- saveBtn: {{ selector: "[data-testid='{kebab}-save']", intent: 'save changes button', stability: 0, visualIntent: true }},
1942
- primaryInputField: {{ selector: "[data-testid='{kebab}-primary-input']", intent: 'primary text input', stability: 0 }},
1943
- fileInput: {{ selector: "input[type='file']", intent: 'file upload input', stability: 70 }},
1944
- successToast: {{ selector: "[data-testid='toast-success']", intent: 'success notification toast', stability: 0, visualIntent: true }},
1945
- errorToast: {{ selector: "[data-testid='toast-error']", intent: 'error notification toast', stability: 0, visualIntent: true }},
1946
- validationError: {{ selector: "[data-testid='validation-error']", intent: 'form validation error message', stability: 0, visualIntent: true }},
1947
- successIndicator: {{ selector: "[data-testid='{kebab}-success']", intent: 'success state indicator', stability: 0, visualIntent: true }},
1948
- updatedIndicator: {{ selector: "[data-testid='{kebab}-updated']", intent: 'updated state indicator', stability: 0 }},
1949
- {extras}}} as const;
2071
+ {body}}} as const;
1950
2072
  export type LocatorKey = keyof typeof {page_class}Locators;
1951
2073
  '''
1952
2074
 
@@ -1985,139 +2107,193 @@ def _gen_locators_from_flat_map(context_map: dict) -> str:
1985
2107
 
1986
2108
 
1987
2109
  def _gen_page_object(page_class, kebab, camel, gherkin, healing_strategy,
1988
- enable_visual_regression, enable_timing_healing):
1989
- steps = _parse_gherkin_steps(gherkin)["all_steps"]
1990
- stubs = _build_method_stubs(camel, steps)
2110
+ enable_visual_regression, enable_timing_healing,
2111
+ derived_methods=None, context_map=None, app_url=None):
2112
+ steps = _parse_gherkin_steps(gherkin)["all_steps"]
2113
+ stubs = _build_method_stubs(camel, steps, derived_methods or [])
1991
2114
  vi_import = 'import { VisualIntentChecker } from "@utils/locators/VisualIntentChecker";' if enable_visual_regression else ""
1992
2115
  th_import = 'import { TimingHealer } from "@utils/locators/TimingHealer";' 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
1998
- tn = ' await this.timing.waitForNetworkIdle("navigate");' if enable_timing_healing else ""
1999
- ts_ = ' await this.timing.waitForNetworkIdle("submitForm");' if enable_timing_healing else ""
2000
- vc = ' await this.visual.check("successToast", this.loc.successToast.selector, this.loc.successToast.intent);' if enable_visual_regression else ""
2001
-
2002
- return f'''import {{ Page, expect }} from "@playwright/test";
2003
- import {{ fixture }} from "@hooks/pageFixture";
2004
- import {{ {page_class}Locators }} from "../locators/{kebab}.locators";
2005
- import {{ LocatorHealer }} from "@utils/locators/LocatorHealer";
2006
- import {{ LocatorRepository }} from "@utils/locators/LocatorRepository";
2007
- import {{ BasePage }} from "./BasePage";
2008
- {vi_import}
2009
- {th_import}
2010
-
2011
- /**
2012
- * {page_class}Page — Three-Layer Self-Healing Page Object
2013
- *
2014
- * Layer 1 Locator Healing ({healing_strategy}):
2015
- * primary selector → role-based → label-based → text-based → playwright-cli + Claude AI Vision
2016
- * → DevToolsHealer AX tree (CDPSession) → DevToolsHealer bounding box (CDPSession)
2017
- * Healed selectors persisted in LocatorRepository — zero overhead on repeat runs.
2018
- *
2019
- {" * Layer 2 — Timing Healing (TimingHealer): intercepts network traces, auto-heals timeout drift." if enable_timing_healing else ""}
2020
- {" *" if enable_timing_healing else ""}
2021
- {" * Layer 3 Visual Intent (VisualIntentChecker): element screenshot diff at assertions." if enable_visual_regression else ""}
2022
- {" *" if enable_visual_regression else ""}
2023
- {" * HealingDashboard: http://localhost:7890 — approve/reject all AI suggestions." if (enable_timing_healing or enable_visual_regression) else ""}
2024
- *
2025
- * RULE: Never call page.click / page.fill / page.locator directly.
2026
- */
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
- }}
2035
-
2036
- async navigate(): Promise<void> {{
2037
- const url = `${{this.env.getBaseUrl()}}/${{this.env.getPath("{kebab}")}}`;
2038
- fixture().logger.info(`Navigating to ${{url}}`);
2039
- await this.page.goto(url, {{ waitUntil: "networkidle" }});
2040
- {tn}
2041
- await this.healer.assertVisibleWithHealing(
2042
- "pageContainer", this.loc.pageContainer.selector, this.loc.pageContainer.intent);
2043
- }}
2044
-
2045
- async waitForPageLoad(): Promise<void> {{
2046
- await this.healer.assertVisibleWithHealing(
2047
- "pageContainer", this.loc.pageContainer.selector, this.loc.pageContainer.intent);
2048
- }}
2049
-
2050
- async submitForm(): Promise<void> {{
2051
- await this.healer.clickWithHealing(
2052
- "submitBtn", this.loc.submitBtn.selector, this.loc.submitBtn.intent);
2053
- {ts_}
2054
- }}
2055
-
2056
- async confirmAction(): Promise<void> {{
2057
- await this.healer.clickWithHealing(
2058
- "confirmBtn", this.loc.confirmBtn.selector, this.loc.confirmBtn.intent);
2059
- }}
2060
-
2061
- async clickCancel(): Promise<void> {{
2062
- await this.healer.clickWithHealing(
2063
- "cancelBtn", this.loc.cancelBtn.selector, this.loc.cancelBtn.intent);
2064
- }}
2065
-
2066
- async clickSave(): Promise<void> {{
2067
- await this.healer.clickWithHealing(
2068
- "saveBtn", this.loc.saveBtn.selector, this.loc.saveBtn.intent);
2069
- }}
2070
-
2071
- async fillPrimaryInput(value: string): Promise<void> {{
2072
- await this.healer.fillWithHealing(
2073
- "primaryInputField", this.loc.primaryInputField.selector, value, this.loc.primaryInputField.intent);
2074
- }}
2075
-
2076
- async uploadFile(buffer: Buffer, fileName: string, mimeType: string): Promise<void> {{
2077
- await this.page.locator(this.loc.fileInput.selector).setInputFiles({{ name: fileName, mimeType, buffer }});
2078
- }}
2079
-
2080
- async verifySuccessToast(): Promise<void> {{
2081
- await this.healer.assertVisibleWithHealing(
2082
- "successToast", this.loc.successToast.selector, this.loc.successToast.intent);
2083
- {vc}
2084
- fixture().logger.info("✓ Success toast verified (locator + visual)");
2085
- }}
2086
-
2087
- async verifyErrorToast(): Promise<void> {{
2088
- await this.healer.assertVisibleWithHealing(
2089
- "errorToast", this.loc.errorToast.selector, this.loc.errorToast.intent);
2090
- fixture().logger.info("✓ Error toast verified");
2091
- }}
2092
-
2093
- async verifyValidationErrors(): Promise<void> {{
2094
- await this.healer.assertVisibleWithHealing(
2095
- "validationError", this.loc.validationError.selector, this.loc.validationError.intent);
2096
- const count = await this.page.locator(this.loc.validationError.selector).count();
2097
- expect(count).toBeGreaterThan(0);
2098
- }}
2116
+ tn = ' await this.timing.waitForNetworkIdle("navigate");' if enable_timing_healing else ""
2117
+ ts_ = ' await this.timing.waitForNetworkIdle("submitForm");' if enable_timing_healing else ""
2118
+ vc = ' await this.visual.check("successToast", this.loc.successToast.selector, this.loc.successToast.intent);' if enable_visual_regression else ""
2119
+
2120
+ # Extract verified locator keys from context_map.
2121
+ # When avail is non-empty (real capture), template methods are emitted only for
2122
+ # locator keys that actually exist — prevents TypeScript errors and invented code.
2123
+ avail: set = set()
2124
+ if context_map and context_map.get("locators"):
2125
+ avail = {loc["key"] for loc in context_map["locators"]}
2126
+ elif context_map and isinstance(context_map, dict):
2127
+ avail = {k for k, v in context_map.items() if isinstance(v, dict) and "selector" in v}
2128
+
2129
+ def _has(*keys: str) -> bool:
2130
+ return not avail or all(k in avail for k in keys)
2131
+
2132
+ # ── navigate() ──────────────────────────────────────────────────────────
2133
+ if app_url:
2134
+ nav = (
2135
+ "\n\n async navigate(): Promise<void> {\n"
2136
+ f' this.logger.info("Navigating to {app_url}");\n'
2137
+ f' await this.page.goto("{app_url}", {{ waitUntil: "domcontentloaded" }});\n'
2138
+ f"{tn}\n"
2139
+ " }"
2140
+ )
2141
+ elif not avail:
2142
+ nav = (
2143
+ "\n\n async navigate(): Promise<void> {\n"
2144
+ " // TODO: replace with actual URL (e.g. this.env.baseUrl + '/inventory.html')\n"
2145
+ " const url = `${this.env.baseUrl}`;\n"
2146
+ " this.logger.info(`Navigating to ${url}`);\n"
2147
+ " await this.page.goto(url, { waitUntil: 'domcontentloaded' });\n"
2148
+ f"{tn}\n"
2149
+ " }"
2150
+ )
2151
+ else:
2152
+ nav = "" # real context_map but no URL — omit rather than invent a path
2099
2153
 
2100
- async verifySuccessState(): Promise<void> {{
2101
- await this.healer.assertVisibleWithHealing(
2102
- "successIndicator", this.loc.successIndicator.selector, this.loc.successIndicator.intent);
2103
- }}
2154
+ # ── conditional template methods ─────────────────────────────────────────
2155
+ parts: list = []
2104
2156
 
2105
- async verifyStatePersisted(): Promise<void> {{
2106
- await this.healer.assertVisibleWithHealing(
2107
- "updatedIndicator", this.loc.updatedIndicator.selector, this.loc.updatedIndicator.intent);
2108
- }}
2157
+ parts.append(
2158
+ " async waitForPageLoad(): Promise<void> {\n"
2159
+ " await this.waitHelper.waitForPageLoad();\n"
2160
+ " }"
2161
+ )
2162
+ if _has("submitBtn"):
2163
+ parts.append(
2164
+ " async submitForm(): Promise<void> {\n"
2165
+ " await this.healer.clickWithHealing(\n"
2166
+ ' "submitBtn", this.loc.submitBtn.selector, this.loc.submitBtn.intent);\n'
2167
+ f"{ts_}\n"
2168
+ " }"
2169
+ )
2170
+ if _has("confirmBtn"):
2171
+ parts.append(
2172
+ " async confirmAction(): Promise<void> {\n"
2173
+ " await this.healer.clickWithHealing(\n"
2174
+ ' "confirmBtn", this.loc.confirmBtn.selector, this.loc.confirmBtn.intent);\n'
2175
+ " }"
2176
+ )
2177
+ if _has("cancelBtn"):
2178
+ parts.append(
2179
+ " async clickCancel(): Promise<void> {\n"
2180
+ " await this.healer.clickWithHealing(\n"
2181
+ ' "cancelBtn", this.loc.cancelBtn.selector, this.loc.cancelBtn.intent);\n'
2182
+ " }"
2183
+ )
2184
+ if _has("saveBtn"):
2185
+ parts.append(
2186
+ " async clickSave(): Promise<void> {\n"
2187
+ " await this.healer.clickWithHealing(\n"
2188
+ ' "saveBtn", this.loc.saveBtn.selector, this.loc.saveBtn.intent);\n'
2189
+ " }"
2190
+ )
2191
+ if _has("primaryInputField"):
2192
+ parts.append(
2193
+ " async fillPrimaryInput(value: string): Promise<void> {\n"
2194
+ " await this.healer.fillWithHealing(\n"
2195
+ ' "primaryInputField", this.loc.primaryInputField.selector, value, this.loc.primaryInputField.intent);\n'
2196
+ " }"
2197
+ )
2198
+ if _has("fileInput"):
2199
+ parts.append(
2200
+ " async uploadFile(buffer: Buffer, fileName: string, mimeType: string): Promise<void> {\n"
2201
+ " await this.page.locator(this.loc.fileInput.selector).setInputFiles({ name: fileName, mimeType, buffer });\n"
2202
+ " }"
2203
+ )
2204
+ if _has("successToast"):
2205
+ parts.append(
2206
+ " async verifySuccessToast(): Promise<void> {\n"
2207
+ " await this.healer.assertVisibleWithHealing(\n"
2208
+ ' "successToast", this.loc.successToast.selector, this.loc.successToast.intent);\n'
2209
+ f"{vc}\n"
2210
+ ' fixture().logger.info("✓ Success toast verified (locator + visual)");\n'
2211
+ " }"
2212
+ )
2213
+ if _has("errorToast"):
2214
+ parts.append(
2215
+ " async verifyErrorToast(): Promise<void> {\n"
2216
+ " await this.healer.assertVisibleWithHealing(\n"
2217
+ ' "errorToast", this.loc.errorToast.selector, this.loc.errorToast.intent);\n'
2218
+ ' fixture().logger.info("✓ Error toast verified");\n'
2219
+ " }"
2220
+ )
2221
+ if _has("validationError"):
2222
+ parts.append(
2223
+ " async verifyValidationErrors(): Promise<void> {\n"
2224
+ " await this.healer.assertVisibleWithHealing(\n"
2225
+ ' "validationError", this.loc.validationError.selector, this.loc.validationError.intent);\n'
2226
+ " const count = await this.page.locator(this.loc.validationError.selector).count();\n"
2227
+ " expect(count).toBeGreaterThan(0);\n"
2228
+ " }"
2229
+ )
2230
+ if _has("successIndicator"):
2231
+ parts.append(
2232
+ " async verifySuccessState(): Promise<void> {\n"
2233
+ " await this.healer.assertVisibleWithHealing(\n"
2234
+ ' "successIndicator", this.loc.successIndicator.selector, this.loc.successIndicator.intent);\n'
2235
+ " }"
2236
+ )
2237
+ if _has("updatedIndicator"):
2238
+ parts.append(
2239
+ " async verifyStatePersisted(): Promise<void> {\n"
2240
+ " await this.healer.assertVisibleWithHealing(\n"
2241
+ ' "updatedIndicator", this.loc.updatedIndicator.selector, this.loc.updatedIndicator.intent);\n'
2242
+ " }"
2243
+ )
2244
+ parts.append(
2245
+ " async reloadAndVerify(): Promise<void> {\n"
2246
+ " await this.page.reload({ waitUntil: 'networkidle' });\n"
2247
+ " await this.verifyStatePersisted();\n"
2248
+ " }"
2249
+ )
2109
2250
 
2110
- async reloadAndVerify(): Promise<void> {{
2111
- await this.page.reload({{ waitUntil: "networkidle" }});
2112
- await this.verifyStatePersisted();
2113
- }}
2251
+ methods_block = "\n\n".join(parts)
2252
+ layer2 = " * Layer 2 — Timing Healing (TimingHealer): intercepts network traces, auto-heals timeout drift.\n *" if enable_timing_healing else ""
2253
+ layer3 = " * Layer 3 — Visual Intent (VisualIntentChecker): element screenshot diff at assertions.\n *" if enable_visual_regression else ""
2254
+ dashboard = " * HealingDashboard: http://localhost:7890 — approve/reject all AI suggestions." if (enable_timing_healing or enable_visual_regression) else ""
2114
2255
 
2115
- {stubs}
2116
- }}
2117
- '''
2256
+ return (
2257
+ f'import {{ Page, expect }} from "@playwright/test";\n'
2258
+ f'import {{ fixture }} from "@hooks/pageFixture";\n'
2259
+ f'import {{ {page_class}Locators }} from "../locators/{kebab}.locators";\n'
2260
+ f'import {{ LocatorHealer }} from "@utils/locators/LocatorHealer";\n'
2261
+ f'import {{ LocatorRepository }} from "@utils/locators/LocatorRepository";\n'
2262
+ f'import {{ BasePage }} from "./BasePage";\n'
2263
+ f'{vi_import}\n'
2264
+ f'{th_import}\n'
2265
+ f'\n'
2266
+ f'/**\n'
2267
+ f' * {page_class}Page — Three-Layer Self-Healing Page Object\n'
2268
+ f' *\n'
2269
+ f' * Layer 1 — Locator Healing ({healing_strategy}):\n'
2270
+ f' * primary selector → role-based → label-based → text-based → playwright-cli + Claude AI Vision\n'
2271
+ f' * → DevToolsHealer AX tree (CDPSession) → DevToolsHealer bounding box (CDPSession)\n'
2272
+ f' * Healed selectors persisted in LocatorRepository — zero overhead on repeat runs.\n'
2273
+ f' *\n'
2274
+ + (f' * Layer 2 — Timing Healing (TimingHealer): intercepts network traces, auto-heals timeout drift.\n *\n' if enable_timing_healing else '')
2275
+ + (f' * Layer 3 — Visual Intent (VisualIntentChecker): element screenshot diff at assertions.\n *\n' if enable_visual_regression else '')
2276
+ + (f' * HealingDashboard: http://localhost:7890 — approve/reject all AI suggestions.\n' if (enable_timing_healing or enable_visual_regression) else '')
2277
+ + f' *\n'
2278
+ f' * RULE: Never call page.click / page.fill / page.locator directly.\n'
2279
+ f' */\n'
2280
+ f'export default class {page_class}Page extends BasePage {{\n'
2281
+ f' private readonly loc = {page_class}Locators;\n'
2282
+ f'\n'
2283
+ f' constructor(page?: Page) {{\n'
2284
+ f' super(page ?? fixture().page);\n'
2285
+ f' Object.entries(this.loc).forEach(([key, val]) =>\n'
2286
+ f' this.repo.register(key, val.selector, val.intent));\n'
2287
+ f' }}'
2288
+ + nav
2289
+ + '\n\n'
2290
+ + methods_block
2291
+ + ('\n\n' + stubs if stubs.strip() else '')
2292
+ + '\n}\n'
2293
+ )
2118
2294
 
2119
2295
 
2120
- def _build_method_stubs(camel, steps):
2296
+ def _build_method_stubs(camel, steps, derived_methods=None):
2121
2297
  stubs, seen = [], set()
2122
2298
  for step in steps:
2123
2299
  lower = step.lower()
@@ -2144,6 +2320,18 @@ def _build_method_stubs(camel, steps):
2144
2320
  await this.verifyStatePersisted();
2145
2321
  fixture().logger.info("✓ State persisted after refresh");
2146
2322
  }''')
2323
+
2324
+ # Emit stubs for every derived method called by the step definitions.
2325
+ # These throw so tests fail loudly instead of silently passing with no-ops.
2326
+ for method_name in (derived_methods or []):
2327
+ if method_name not in seen:
2328
+ seen.add(method_name)
2329
+ stubs.append(
2330
+ f' async {method_name}(...args: unknown[]): Promise<void> {{\n'
2331
+ f' throw new Error("not yet implemented: {method_name}");\n'
2332
+ f' }}'
2333
+ )
2334
+
2147
2335
  return "\n\n".join(stubs)
2148
2336
 
2149
2337
 
@@ -2208,6 +2396,18 @@ def _step_text_to_cucumber_pattern(text: str) -> str:
2208
2396
 
2209
2397
  def _build_step_callback(kw, text, method_call, camel, page_class):
2210
2398
  """Build one Cucumber step definition block using Cucumber expressions."""
2399
+ # @cucumber/cucumber does not export And/But as step-definition functions.
2400
+ # Normalise them to When (action) or Then (assertion) based on the method name.
2401
+ if kw in ("And", "But"):
2402
+ _lower_m = method_call.lower()
2403
+ _lower_t = text.lower()
2404
+ _assertion_words = (
2405
+ "verify", "assert", "should", "expect", "check",
2406
+ "visible", "enabled", "disabled", "present", "contain",
2407
+ "navigated", "remain", "not_", "available", "reachable",
2408
+ )
2409
+ kw = "Then" if any(w in _lower_m or w in _lower_t for w in _assertion_words) else "When"
2410
+
2211
2411
  pattern = _step_text_to_cucumber_pattern(text)
2212
2412
  param_types = re.findall(r'\{(string|int|float|word)\}', pattern)
2213
2413
  if param_types:
@@ -2266,9 +2466,12 @@ def _gen_step_defs(page_class, kebab, camel, all_steps, auth_hook):
2266
2466
  )
2267
2467
 
2268
2468
  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"],
2469
+ # navigation — only match unambiguously page-level navigation phrases;
2470
+ # "opens the" and "is on the" are intentionally excluded: they are too broad
2471
+ # and incorrectly match steps like "opens the sidebar menu" or "is on the cart page".
2472
+ # Those steps fall through to _step_text_to_method_name and get correct derived names.
2473
+ (["navigates to", "navigate to", "visits the", "goes to",
2474
+ "is on the main page", "is on the home page", "on the site"],
2272
2475
  f"{camel}Page.navigate()"),
2273
2476
  # cart setup
2274
2477
  (["has items in", "has item in", "items in the cart",
@@ -2452,7 +2655,9 @@ def _gen_step_defs(page_class, kebab, camel, all_steps, auth_hook):
2452
2655
  " */\n"
2453
2656
  )
2454
2657
 
2455
- return (
2658
+ derived_method_names = [mn for _kw, _text, mn in unmapped_steps]
2659
+
2660
+ content = (
2456
2661
  f'import {{ Given, When, Then }} from "@cucumber/cucumber";\n'
2457
2662
  f'import {{ expect }} from "@playwright/test";\n'
2458
2663
  f'import {{ fixture }} from "@hooks/pageFixture";\n'
@@ -2467,6 +2672,7 @@ def _gen_step_defs(page_class, kebab, camel, all_steps, auth_hook):
2467
2672
  f'{unmapped_comment}'
2468
2673
  + "".join(blocks)
2469
2674
  )
2675
+ return content, derived_method_names
2470
2676
 
2471
2677
 
2472
2678
  def _gen_feature_file(gherkin: str, kebab: str, page_class: str) -> str: