@qa-gentic/stlc-agents 1.0.25 → 1.0.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/generate-test-cases/SKILL.md +5 -0
- package/src/cli/cmd-cost.js +61 -30
- package/src/cli/cmd-init.js +88 -8
- package/src/stlc_agents/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/__pycache__/server.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/ado_gherkin.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/__pycache__/server.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/server.py +41 -6
- package/src/stlc_agents/agent_helix_writer/tools/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/tools/__pycache__/boilerplate.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/tools/__pycache__/helix_write.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/__pycache__/server.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/server.py +419 -213
- package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/ado_attach.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/__pycache__/server.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/server.py +12 -0
- package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/ado_workitem.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/tools/ado_workitem.py +65 -1
- package/src/stlc_agents/shared/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/shared/__pycache__/auth.cpython-314.pyc +0 -0
- package/src/stlc_agents/shared/__pycache__/cost_tracker.cpython-314.pyc +0 -0
- package/src/stlc_agents/shared/__pycache__/pricing.cpython-314.pyc +0 -0
- package/src/stlc_agents/shared/cost_tracker.py +378 -70
- package/src/stlc_agents/shared/pricing.py +115 -24
- package/src/stlc_agents/webhook_orchestrator/__init__.py +0 -0
- package/src/stlc_agents/webhook_orchestrator/agent_runner.py +599 -0
- package/src/stlc_agents/webhook_orchestrator/main.py +43 -0
- package/src/stlc_agents/webhook_orchestrator/models.py +63 -0
- package/src/stlc_agents/webhook_orchestrator/orchestrator.py +103 -0
- package/src/stlc_agents/webhook_orchestrator/pipelines/__init__.py +0 -0
- package/src/stlc_agents/webhook_orchestrator/pipelines/_base.py +57 -0
- package/src/stlc_agents/webhook_orchestrator/pipelines/ado_test_cases.py +55 -0
- package/src/stlc_agents/webhook_orchestrator/pipelines/full_pipeline.py +202 -0
- package/src/stlc_agents/webhook_orchestrator/pipelines/gherkin_playwright.py +156 -0
- package/src/stlc_agents/webhook_orchestrator/pipelines/jira_test_cases.py +48 -0
- package/src/stlc_agents/webhook_orchestrator/webhook_bridge.py +368 -0
- package/src/stlc_agents/agent_gherkin_generator/__pycache__/server.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/__pycache__/server.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_jira_manager/__pycache__/server.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/__pycache__/server.cpython-310.pyc +0 -0
- package/src/stlc_agents/shared/__pycache__/cost_tracker.cpython-310.pyc +0 -0
- 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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
1651
|
-
|
|
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
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
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
|
-
|
|
1665
|
-
pmcp.call("browser_press", {"ref": pass_ref, "key": "Enter"})
|
|
1682
|
+
pmcp.call("browser_click", {"element": "Login button"})
|
|
1666
1683
|
|
|
1667
|
-
|
|
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
|
-
# ──
|
|
1696
|
+
# ── Gherkin-driven multi-step capture ────────────────────────────────────────
|
|
1671
1697
|
|
|
1672
|
-
|
|
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
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
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
|
-
#
|
|
1689
|
-
|
|
1690
|
-
|
|
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":
|
|
1791
|
+
pmcp.call("browser_navigate", {"url": app_url})
|
|
1792
|
+
current_snap = pmcp.call("browser_snapshot", {})
|
|
1695
1793
|
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
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
|
-
|
|
1798
|
+
if not gherkin:
|
|
1799
|
+
return _merge_maps(*all_maps)
|
|
1702
1800
|
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1729
|
-
|
|
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
|
-
|
|
1743
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
1810
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1990
|
-
|
|
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
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
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
|
-
|
|
2101
|
-
|
|
2102
|
-
"successIndicator", this.loc.successIndicator.selector, this.loc.successIndicator.intent);
|
|
2103
|
-
}}
|
|
2154
|
+
# ── conditional template methods ─────────────────────────────────────────
|
|
2155
|
+
parts: list = []
|
|
2104
2156
|
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
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
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2271
|
-
|
|
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
|
-
|
|
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:
|