@qa-gentic/stlc-agents 1.0.27 → 1.0.28
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/ARCHITECTURE-ADO.md +350 -0
- package/ARCHITECTURE-JIRA.md +203 -0
- package/QUICKSTART-ADO.md +400 -0
- package/QUICKSTART-JIRA.md +334 -0
- package/README.md +49 -0
- package/package.json +18 -6
- package/skills/migrate-framework/SKILL.md +207 -0
- package/src/stlc_agents/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/__pycache__/server.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/ado_gherkin.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/__pycache__/server.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/tools/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/tools/__pycache__/boilerplate.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/tools/__pycache__/helix_write.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_jira_manager/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_jira_manager/__pycache__/server.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_jira_manager/tools/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_jira_manager/tools/__pycache__/jira_workitem.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/__init__.py +0 -0
- package/src/stlc_agents/agent_migration/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/__pycache__/_migrate.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/__pycache__/cli.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/__pycache__/detector.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/__pycache__/mapper.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/__pycache__/reporter.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/__pycache__/server.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/_migrate.py +1398 -0
- package/src/stlc_agents/agent_migration/cli.py +217 -0
- package/src/stlc_agents/agent_migration/detector.py +81 -0
- package/src/stlc_agents/agent_migration/mapper.py +439 -0
- package/src/stlc_agents/agent_migration/reporter.py +86 -0
- package/src/stlc_agents/agent_migration/server.py +267 -0
- package/src/stlc_agents/agent_migration/transformer/__init__.py +0 -0
- package/src/stlc_agents/agent_migration/transformer/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/transformer/__pycache__/config_merger.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/transformer/__pycache__/healer_injector.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/transformer/__pycache__/import_fixer.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/transformer/__pycache__/js_to_ts.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/transformer/__pycache__/local_var_hoister.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/transformer/__pycache__/locator_moderniser.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/transformer/__pycache__/locator_registrar.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/transformer/__pycache__/spec_to_bdd.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/transformer/config_merger.py +513 -0
- package/src/stlc_agents/agent_migration/transformer/healer_injector.py +1143 -0
- package/src/stlc_agents/agent_migration/transformer/import_fixer.py +419 -0
- package/src/stlc_agents/agent_migration/transformer/js_to_ts.py +413 -0
- package/src/stlc_agents/agent_migration/transformer/local_var_hoister.py +378 -0
- package/src/stlc_agents/agent_migration/transformer/locator_moderniser.py +132 -0
- package/src/stlc_agents/agent_migration/transformer/locator_registrar.py +328 -0
- package/src/stlc_agents/agent_migration/transformer/spec_to_bdd.py +820 -0
- package/src/stlc_agents/agent_playwright_generator/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/__pycache__/server.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/server.py +926 -91
- package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/ado_attach.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/__pycache__/server.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/ado_workitem.cpython-313.pyc +0 -0
- package/src/stlc_agents/shared/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/shared/__pycache__/auth.cpython-313.pyc +0 -0
- package/src/stlc_agents/shared/__pycache__/cost_tracker.cpython-313.pyc +0 -0
- package/src/stlc_agents/shared/__pycache__/pricing.cpython-313.pyc +0 -0
- package/src/stlc_agents/shared_jira/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/shared_jira/__pycache__/auth.cpython-313.pyc +0 -0
- package/src/stlc_agents/webhook_orchestrator/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/webhook_orchestrator/__pycache__/agent_runner.cpython-313.pyc +0 -0
- package/src/stlc_agents/webhook_orchestrator/__pycache__/models.cpython-313.pyc +0 -0
- package/src/stlc_agents/webhook_orchestrator/__pycache__/orchestrator.cpython-313.pyc +0 -0
- package/src/stlc_agents/webhook_orchestrator/__pycache__/webhook_bridge.cpython-313.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__/server.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/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/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/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
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Hoist inline-string locators from local variables into the centralised
|
|
3
|
+
xxxLocators objects, then rewrite the variable RHS to reference the new entry.
|
|
4
|
+
|
|
5
|
+
Input (page object):
|
|
6
|
+
const banner = this.page.getByTestId("nova_impersonate-banner");
|
|
7
|
+
const saveBtn = this.page.getByRole("button", { name: "Save" });
|
|
8
|
+
|
|
9
|
+
Output (page object):
|
|
10
|
+
const banner = this.page.getByTestId(novaImpersonateLocators.banner.selector);
|
|
11
|
+
const saveBtn = this.page.locator(novaImpersonateLocators.saveBtn.selector);
|
|
12
|
+
|
|
13
|
+
Side effect: emits hoist actions describing entries to be appended to the
|
|
14
|
+
target locator file's xxxLocators object literal during a post-pass.
|
|
15
|
+
|
|
16
|
+
Hoist matrix:
|
|
17
|
+
.locator("CSS") → selector "CSS"
|
|
18
|
+
.getByTestId("X") → selector "[data-testid=\"X\"]"
|
|
19
|
+
.getByRole("R") → selector "role=R"
|
|
20
|
+
.getByRole("R", { name: "N" }) → selector "role=R[name=\"N\"]"
|
|
21
|
+
.getByText("T") → selector "text=\"T\""
|
|
22
|
+
.getByLabel(...) → skipped (no clean string form)
|
|
23
|
+
template strings with ${} → skipped
|
|
24
|
+
already-centralised xxxLocators refs → skipped
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
import re
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Hoist action — emitted during per-file scan, applied in the post-pass.
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class HoistAction:
|
|
37
|
+
locator_object: str # e.g. "invoiceManagementLocators"
|
|
38
|
+
entry_name: str # e.g. "chargebeeContent"
|
|
39
|
+
selector: str # the static-string selector value
|
|
40
|
+
intent: str # human-readable
|
|
41
|
+
stability: int # 0-100
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Heuristics — mirrored from locator_registrar.py so hoisted entries have the
|
|
46
|
+
# same shape and stability scoring as the original locator-file entries.
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
def _camel_to_words(name: str) -> str:
|
|
50
|
+
s = re.sub(r"([A-Z])", r" \1", name)
|
|
51
|
+
return s.strip().lower()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _infer_stability(selector: str) -> int:
|
|
55
|
+
"""0-100 confidence. Mirrors locator_registrar._infer_stability."""
|
|
56
|
+
if "[data-testid" in selector or "data-testid=" in selector:
|
|
57
|
+
return 100
|
|
58
|
+
if selector.startswith("role="):
|
|
59
|
+
return 80
|
|
60
|
+
if selector.startswith("text="):
|
|
61
|
+
return 50
|
|
62
|
+
if selector.startswith("#"):
|
|
63
|
+
return 80
|
|
64
|
+
if re.match(r"^[a-z_][\w-]*$", selector):
|
|
65
|
+
return 80
|
|
66
|
+
if "[aria-label=" in selector or "[placeholder=" in selector:
|
|
67
|
+
return 70
|
|
68
|
+
if "[dusk=" in selector or "[data-" in selector:
|
|
69
|
+
return 70
|
|
70
|
+
if selector.startswith(".") or any(c in selector for c in ">+~"):
|
|
71
|
+
return 30
|
|
72
|
+
return 50
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# Regexes for the various RHS shapes we know how to hoist.
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
# `const|let|var|private|public|protected|readonly [readonly] <name> [: T] = <rhs>;`
|
|
80
|
+
# DOTALL so multi-line RHS is captured.
|
|
81
|
+
_VAR_ASSIGN_RE = re.compile(
|
|
82
|
+
r"\b((?:const|let|var|private|public|protected|readonly)"
|
|
83
|
+
r"(?:\s+(?:private|public|protected|readonly))*"
|
|
84
|
+
r"\s+(\w+)(?:\s*:\s*[^=;]+)?\s*=\s*)"
|
|
85
|
+
r"(.+?);",
|
|
86
|
+
re.DOTALL,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Inside an RHS, match the LAST `.locator("...")` / `.getByXxx(...)` call.
|
|
90
|
+
# Quoted strings: prefer double-quoted, fall back to single-quoted. We avoid
|
|
91
|
+
# backreferences because backreference numbers shift when patterns are composed.
|
|
92
|
+
_DQ = r'"([^"]*)"' # 1 capture: content
|
|
93
|
+
_SQ = r"'([^']*)'" # 1 capture: content
|
|
94
|
+
_QSTR = rf"(?:{_DQ}|{_SQ})" # 2 captures: (dq, sq) — one will be non-None
|
|
95
|
+
|
|
96
|
+
_LOCATOR_CALL_RE = re.compile(rf"\.locator\(\s*{_QSTR}\s*,?\s*\)", re.DOTALL)
|
|
97
|
+
_TESTID_CALL_RE = re.compile(rf"\.getByTestId\(\s*{_QSTR}\s*,?\s*\)", re.DOTALL)
|
|
98
|
+
_TEXT_CALL_RE = re.compile(rf"\.getByText\(\s*{_QSTR}\s*,?\s*\)", re.DOTALL)
|
|
99
|
+
# getByRole supports an optional second arg `{ name: "..." }` (strings only — we skip regex).
|
|
100
|
+
_ROLE_CALL_RE = re.compile(
|
|
101
|
+
rf"\.getByRole\(\s*{_QSTR}"
|
|
102
|
+
rf"(?:\s*,\s*\{{\s*name\s*:\s*{_QSTR}\s*\}})?"
|
|
103
|
+
rf"\s*,?\s*\)",
|
|
104
|
+
re.DOTALL,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _qstr_pick(m: re.Match, base: int) -> str:
|
|
109
|
+
"""Given a 2-capture _QSTR pair starting at group `base`, return the matched string."""
|
|
110
|
+
return m.group(base) if m.group(base) is not None else (m.group(base + 1) or "")
|
|
111
|
+
|
|
112
|
+
# Detect already-centralised access — skip hoisting if RHS already references it.
|
|
113
|
+
_LOCATOR_ACCESS_RE = re.compile(r"\b\w+Locators\.\w+\b")
|
|
114
|
+
|
|
115
|
+
# Detect template-string interpolation — skip if present anywhere in RHS.
|
|
116
|
+
_INTERP_RE = re.compile(r"`[^`]*\$\{[^}]*\}[^`]*`")
|
|
117
|
+
|
|
118
|
+
# Identify the page object's primary xxxLocators import.
|
|
119
|
+
# `import { fooLocators[, barLocators...] } from "<path>";`
|
|
120
|
+
_LOCATOR_IMPORT_RE = re.compile(
|
|
121
|
+
r"""import\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]\s*;?"""
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
# Per-RHS hoist detection — returns (new_rhs, selector_value, stability) or None.
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
def _try_hoist_rhs(rhs: str, var_name: str) -> tuple[str, str, str, int] | None:
|
|
130
|
+
"""
|
|
131
|
+
If `rhs` contains a hoistable static-string locator call, return:
|
|
132
|
+
(receiver_prefix, trailing_suffix, selector_value, stability)
|
|
133
|
+
|
|
134
|
+
`receiver_prefix` is everything in `rhs` BEFORE the matched call
|
|
135
|
+
(e.g. `this.page` or `await this.page.frameLocator(x)`).
|
|
136
|
+
`trailing_suffix` is everything AFTER the call (e.g. `.textContent()` or
|
|
137
|
+
a closing `)` from an outer wrapper like `.or(...)`).
|
|
138
|
+
|
|
139
|
+
Callers reassemble as: <prefix>.locator(<repo_ref>)<suffix>.
|
|
140
|
+
"""
|
|
141
|
+
# Skip if anything in the RHS is already centralised — no point hoisting.
|
|
142
|
+
if _LOCATOR_ACCESS_RE.search(rhs):
|
|
143
|
+
return None
|
|
144
|
+
# Skip template-string interpolation — selector is dynamic.
|
|
145
|
+
if _INTERP_RE.search(rhs):
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
# Find ALL call-site matches and pick the LAST one so we hoist the
|
|
149
|
+
# innermost-applied selector (the rightmost in the chain). The receiver
|
|
150
|
+
# (everything to its left) is kept verbatim.
|
|
151
|
+
candidates: list[tuple[int, int, str]] = [] # (start, end, selector_value)
|
|
152
|
+
for m in _LOCATOR_CALL_RE.finditer(rhs):
|
|
153
|
+
sel = _qstr_pick(m, 1)
|
|
154
|
+
candidates.append((m.start(), m.end(), sel))
|
|
155
|
+
for m in _TESTID_CALL_RE.finditer(rhs):
|
|
156
|
+
v = _qstr_pick(m, 1)
|
|
157
|
+
candidates.append((m.start(), m.end(), f"[data-testid=\"{v}\"]"))
|
|
158
|
+
for m in _TEXT_CALL_RE.finditer(rhs):
|
|
159
|
+
v = _qstr_pick(m, 1)
|
|
160
|
+
candidates.append((m.start(), m.end(), f"text=\"{v}\""))
|
|
161
|
+
for m in _ROLE_CALL_RE.finditer(rhs):
|
|
162
|
+
role = _qstr_pick(m, 1)
|
|
163
|
+
# Groups 3+4 are the optional `name:` value (one of dq/sq); may both be None.
|
|
164
|
+
name = m.group(3) if m.group(3) is not None else m.group(4)
|
|
165
|
+
sel = f"role={role}[name=\"{name}\"]" if name else f"role={role}"
|
|
166
|
+
candidates.append((m.start(), m.end(), sel))
|
|
167
|
+
|
|
168
|
+
if not candidates:
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
candidates.sort(key=lambda t: t[0])
|
|
172
|
+
last_start, last_end, selector_value = candidates[-1]
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
rhs[:last_start],
|
|
176
|
+
rhs[last_end:],
|
|
177
|
+
selector_value,
|
|
178
|
+
_infer_stability(selector_value),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
# Public entry — invoked from the per-file pipeline for page_object/step_def.
|
|
184
|
+
# ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
def hoist_inline_locators(content: str) -> tuple[str, list[HoistAction], list[str]]:
|
|
187
|
+
"""
|
|
188
|
+
Scan `content` for hoistable local variable assignments and rewrite them
|
|
189
|
+
in place.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
(new_content, hoist_actions, changes)
|
|
193
|
+
|
|
194
|
+
hoist_actions: HoistAction list to be merged into the target locator file
|
|
195
|
+
during a post-pass. The post-pass groups by `locator_object` and appends
|
|
196
|
+
entries to the matching xxxLocators object literal.
|
|
197
|
+
|
|
198
|
+
changes: human-readable change descriptions for the migration report.
|
|
199
|
+
"""
|
|
200
|
+
# Find the primary xxxLocators import. If none, nothing to hoist into.
|
|
201
|
+
primary_locator_obj = _primary_locator_object(content)
|
|
202
|
+
if not primary_locator_obj:
|
|
203
|
+
return content, [], []
|
|
204
|
+
|
|
205
|
+
actions: list[HoistAction] = []
|
|
206
|
+
changes: list[str] = []
|
|
207
|
+
used_entry_names: set[str] = set()
|
|
208
|
+
|
|
209
|
+
def _replace(m: re.Match) -> str:
|
|
210
|
+
prefix = m.group(1)
|
|
211
|
+
var_name = m.group(2)
|
|
212
|
+
rhs = m.group(3)
|
|
213
|
+
hoist = _try_hoist_rhs(rhs, var_name)
|
|
214
|
+
if not hoist:
|
|
215
|
+
return m.group(0)
|
|
216
|
+
receiver_prefix, trailing_suffix, selector_value, stability = hoist
|
|
217
|
+
|
|
218
|
+
# Pick an entry name. Default to the var name; suffix on collision.
|
|
219
|
+
entry_name = var_name
|
|
220
|
+
n = 2
|
|
221
|
+
while entry_name in used_entry_names:
|
|
222
|
+
entry_name = f"{var_name}{n}"
|
|
223
|
+
n += 1
|
|
224
|
+
used_entry_names.add(entry_name)
|
|
225
|
+
|
|
226
|
+
intent = _camel_to_words(entry_name) or entry_name
|
|
227
|
+
actions.append(HoistAction(
|
|
228
|
+
locator_object=primary_locator_obj,
|
|
229
|
+
entry_name=entry_name,
|
|
230
|
+
selector=selector_value,
|
|
231
|
+
intent=intent,
|
|
232
|
+
stability=stability,
|
|
233
|
+
))
|
|
234
|
+
changes.append(
|
|
235
|
+
f"hoist {var_name} → {primary_locator_obj}.{entry_name} "
|
|
236
|
+
f"(selector={selector_value!r}, stability={stability})"
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# Reconstruct the RHS: receiver + `.locator(<repo-ref>)` + whatever came
|
|
240
|
+
# after the matched call (`.textContent()`, `.first()`, outer `.or(...)`'s
|
|
241
|
+
# closing `)`, etc.). Even if the original was getByRole/getByText/
|
|
242
|
+
# getByTestId, we collapse to `.locator(...)` because the hoisted selector
|
|
243
|
+
# string is in Playwright's universal selector syntax.
|
|
244
|
+
repo_ref = f"{primary_locator_obj}.{entry_name}.selector"
|
|
245
|
+
new_rhs = f"{receiver_prefix}.locator({repo_ref}){trailing_suffix}"
|
|
246
|
+
return f"{prefix}{new_rhs};"
|
|
247
|
+
|
|
248
|
+
new_content = _VAR_ASSIGN_RE.sub(_replace, content)
|
|
249
|
+
return new_content, actions, changes
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _primary_locator_object(content: str) -> str | None:
|
|
253
|
+
"""
|
|
254
|
+
Return the name of the xxxLocators object imported by this file. If the
|
|
255
|
+
file imports multiple xxxLocators in the same statement, prefer the first
|
|
256
|
+
one that ISN'T `sharedLocators` — page-specific entries should land in
|
|
257
|
+
the page-specific object, not the shared bucket.
|
|
258
|
+
"""
|
|
259
|
+
fallback = None
|
|
260
|
+
for m in _LOCATOR_IMPORT_RE.finditer(content):
|
|
261
|
+
names_raw = m.group(1)
|
|
262
|
+
names = [n.strip() for n in names_raw.split(",") if n.strip().endswith("Locators")]
|
|
263
|
+
if not names:
|
|
264
|
+
continue
|
|
265
|
+
# Prefer a non-shared object.
|
|
266
|
+
for n in names:
|
|
267
|
+
if n != "sharedLocators":
|
|
268
|
+
return n
|
|
269
|
+
fallback = fallback or names[0]
|
|
270
|
+
return fallback
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# ---------------------------------------------------------------------------
|
|
274
|
+
# Post-pass — apply buffered HoistActions to an already-modernised locator file.
|
|
275
|
+
# ---------------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
# Match `export const xxxLocators = {` and locate its closing `} as const;` /
|
|
278
|
+
# `};` so we can splice new entries in just before it.
|
|
279
|
+
_BLOCK_OPEN_RE = re.compile(
|
|
280
|
+
r"(export\s+const\s+(\w+Locators)\s*=\s*)\{",
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def append_entries_to_locator_file(
|
|
285
|
+
content: str,
|
|
286
|
+
entries_by_object: dict[str, list[HoistAction]],
|
|
287
|
+
) -> tuple[str, list[str]]:
|
|
288
|
+
"""
|
|
289
|
+
For each xxxLocators block in the file, append any HoistActions targeted
|
|
290
|
+
at it just before the closing brace.
|
|
291
|
+
|
|
292
|
+
Idempotent: skips entries whose `entry_name` is already a key in the block.
|
|
293
|
+
"""
|
|
294
|
+
changes: list[str] = []
|
|
295
|
+
output: list[str] = []
|
|
296
|
+
cursor = 0
|
|
297
|
+
|
|
298
|
+
for m in _BLOCK_OPEN_RE.finditer(content):
|
|
299
|
+
block_name = m.group(2)
|
|
300
|
+
body_start = m.end() # one past the `{`
|
|
301
|
+
|
|
302
|
+
depth = 1
|
|
303
|
+
j = body_start
|
|
304
|
+
while j < len(content) and depth > 0:
|
|
305
|
+
c = content[j]
|
|
306
|
+
if c == "{":
|
|
307
|
+
depth += 1
|
|
308
|
+
elif c == "}":
|
|
309
|
+
depth -= 1
|
|
310
|
+
j += 1
|
|
311
|
+
# j now points to one past the closing `}`.
|
|
312
|
+
body = content[body_start : j - 1]
|
|
313
|
+
close_brace = content[j - 1 : j]
|
|
314
|
+
|
|
315
|
+
entries = entries_by_object.get(block_name, [])
|
|
316
|
+
if not entries:
|
|
317
|
+
output.append(content[cursor:j])
|
|
318
|
+
cursor = j
|
|
319
|
+
continue
|
|
320
|
+
|
|
321
|
+
# Dedupe — skip entries already present as keys in the block body.
|
|
322
|
+
existing_keys = set(re.findall(r"^\s*(\w+)\s*:", body, re.MULTILINE))
|
|
323
|
+
to_add = [e for e in entries if e.entry_name not in existing_keys]
|
|
324
|
+
if not to_add:
|
|
325
|
+
output.append(content[cursor:j])
|
|
326
|
+
cursor = j
|
|
327
|
+
continue
|
|
328
|
+
|
|
329
|
+
# Find the last non-empty line of the body to figure out the indent.
|
|
330
|
+
body_lines = body.rstrip("\n").splitlines()
|
|
331
|
+
indent = " "
|
|
332
|
+
for line in reversed(body_lines):
|
|
333
|
+
stripped = line.lstrip()
|
|
334
|
+
if stripped:
|
|
335
|
+
indent = line[: len(line) - len(stripped)]
|
|
336
|
+
break
|
|
337
|
+
|
|
338
|
+
# Ensure the body's last entry ends with a comma so our appends are valid.
|
|
339
|
+
body_rstripped = body.rstrip()
|
|
340
|
+
needs_comma = bool(body_rstripped) and not body_rstripped.endswith((",", "{"))
|
|
341
|
+
appended_lines = []
|
|
342
|
+
if needs_comma:
|
|
343
|
+
appended_lines.append(",")
|
|
344
|
+
for e in to_add:
|
|
345
|
+
sel_lit = _ts_string_literal(e.selector)
|
|
346
|
+
intent_lit = _ts_string_literal(e.intent)
|
|
347
|
+
appended_lines.append(
|
|
348
|
+
f"\n{indent}{e.entry_name}: {{ selector: {sel_lit}, "
|
|
349
|
+
f"intent: {intent_lit}, stability: {e.stability} }},"
|
|
350
|
+
)
|
|
351
|
+
changes.append(f"hoisted {block_name}.{e.entry_name} ({e.selector!r})")
|
|
352
|
+
|
|
353
|
+
# Splice: everything up to the last newline before `}`, then our appends,
|
|
354
|
+
# then the original trailing whitespace + `}`.
|
|
355
|
+
# Find where the closing brace's leading whitespace starts (so the `}`
|
|
356
|
+
# stays correctly indented).
|
|
357
|
+
close_lead_start = j - 1
|
|
358
|
+
while close_lead_start > body_start and content[close_lead_start - 1] in " \t":
|
|
359
|
+
close_lead_start -= 1
|
|
360
|
+
if close_lead_start > body_start and content[close_lead_start - 1] == "\n":
|
|
361
|
+
close_lead_start -= 1
|
|
362
|
+
|
|
363
|
+
output.append(content[cursor:close_lead_start])
|
|
364
|
+
output.append("".join(appended_lines))
|
|
365
|
+
output.append(content[close_lead_start:j])
|
|
366
|
+
cursor = j
|
|
367
|
+
|
|
368
|
+
output.append(content[cursor:])
|
|
369
|
+
return "".join(output), changes
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _ts_string_literal(s: str) -> str:
|
|
373
|
+
"""Render a Python string as a TypeScript double-quoted literal, escaping as needed."""
|
|
374
|
+
if "\"" in s and "'" not in s:
|
|
375
|
+
# Use single quotes so we don't need to escape the double quotes.
|
|
376
|
+
return "'" + s.replace("\\", "\\\\") + "'"
|
|
377
|
+
escaped = s.replace("\\", "\\\\").replace("\"", "\\\"")
|
|
378
|
+
return f"\"{escaped}\""
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Modernise Playwright locator patterns to the recommended accessible-name API."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
# ---------------------------------------------------------------------------
|
|
6
|
+
# Patterns that map cleanly to a modern Playwright locator method
|
|
7
|
+
# ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
# page.locator('[data-testid="x"]') → page.getByTestId('x')
|
|
10
|
+
_TESTID_RE = re.compile(
|
|
11
|
+
r"""(?:page|this\.page)\s*\.\s*locator\(\s*['\"]\[data-testid=['\"]([^'\"]+)['\"]\]['\"]"""
|
|
12
|
+
r"""\s*\)""",
|
|
13
|
+
re.DOTALL,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
# page.locator('text=Foo') → page.getByText('Foo')
|
|
17
|
+
_TEXT_EQ_RE = re.compile(
|
|
18
|
+
r"""(?:page|this\.page)\s*\.\s*locator\(\s*['\"]\s*text\s*=\s*([^'\"]+?)\s*['\"]"""
|
|
19
|
+
r"""\s*\)"""
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# page.locator(':text("Foo")') / page.locator(':text-is("Foo")')
|
|
23
|
+
_TEXT_PSEUDO_RE = re.compile(
|
|
24
|
+
r"""(?:page|this\.page)\s*\.\s*locator\(\s*['\"]\s*:text(?:-is)?\(['\"](.*?)['\"]\)"""
|
|
25
|
+
r"""['\"]"""
|
|
26
|
+
r"""\s*\)"""
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# page.locator('[placeholder="Foo"]')
|
|
30
|
+
_PLACEHOLDER_RE = re.compile(
|
|
31
|
+
r"""(?:page|this\.page)\s*\.\s*locator\(\s*['\"]\[placeholder=['\"]([^'\"]+)['\"]\]['\"]"""
|
|
32
|
+
r"""\s*\)"""
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# page.locator('[aria-label="Foo"]')
|
|
36
|
+
_ARIA_LABEL_RE = re.compile(
|
|
37
|
+
r"""(?:page|this\.page)\s*\.\s*locator\(\s*['\"]\[aria-label=['\"]([^'\"]+)['\"]\]['\"]"""
|
|
38
|
+
r"""\s*\)"""
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# page.locator('role=button[name="Submit"]')
|
|
42
|
+
_ROLE_RE = re.compile(
|
|
43
|
+
r"""(?:page|this\.page)\s*\.\s*locator\(\s*['\"]\s*role\s*=\s*(\w+)\[name=['\"]([^'\"]+)['\"]\]['\"]"""
|
|
44
|
+
r"""\s*\)"""
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# Patterns that are too complex for auto-conversion — flag with TODO
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
_XPATH_RE = re.compile(
|
|
52
|
+
r"""(?:page|this\.page)\s*\.\s*locator\(\s*"""
|
|
53
|
+
r"""(?:'[^']*(?:xpath=)?//[^']*'|"[^"]*(?:xpath=)?//[^"]*")"""
|
|
54
|
+
r"""\s*\)"""
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
_COMPLEX_CSS_RE = re.compile(
|
|
58
|
+
r"""(?:page|this\.page)\s*\.\s*locator\(\s*['\"]\s*[^'\"]*"""
|
|
59
|
+
r"""(?:\s+>\s+|\s+\+\s+|\s+~\s+|:{1,2}[\w-]+\(|nth-child|nth-of-type)[^'\"]*['\"]"""
|
|
60
|
+
r"""\s*\)"""
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
_TODO_COMMENT = "/* TODO: convert to getByRole when accessible name is known */"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def modernise_locators(content: str) -> tuple[str, list[str], int]:
|
|
67
|
+
"""
|
|
68
|
+
Modernise Playwright locator patterns.
|
|
69
|
+
Returns (transformed_content, changes, todo_count).
|
|
70
|
+
"""
|
|
71
|
+
changes: list[str] = []
|
|
72
|
+
todo_count = 0
|
|
73
|
+
|
|
74
|
+
def _sub_testid(m: re.Match) -> str:
|
|
75
|
+
tid = m.group(1)
|
|
76
|
+
prefix = "this.page" if "this.page" in m.group(0) else "page"
|
|
77
|
+
changes.append(f"[data-testid='{tid}'] → getByTestId('{tid}')")
|
|
78
|
+
return f"{prefix}.getByTestId('{tid}')"
|
|
79
|
+
|
|
80
|
+
def _sub_text_eq(m: re.Match) -> str:
|
|
81
|
+
text = m.group(1).strip()
|
|
82
|
+
prefix = "this.page" if "this.page" in m.group(0) else "page"
|
|
83
|
+
changes.append(f"text={text!r} → getByText('{text}')")
|
|
84
|
+
return f"{prefix}.getByText('{text}')"
|
|
85
|
+
|
|
86
|
+
def _sub_text_pseudo(m: re.Match) -> str:
|
|
87
|
+
text = m.group(1)
|
|
88
|
+
prefix = "this.page" if "this.page" in m.group(0) else "page"
|
|
89
|
+
changes.append(f":text('{text}') → getByText('{text}')")
|
|
90
|
+
return f"{prefix}.getByText('{text}')"
|
|
91
|
+
|
|
92
|
+
def _sub_placeholder(m: re.Match) -> str:
|
|
93
|
+
ph = m.group(1)
|
|
94
|
+
prefix = "this.page" if "this.page" in m.group(0) else "page"
|
|
95
|
+
changes.append(f"[placeholder='{ph}'] → getByPlaceholder('{ph}')")
|
|
96
|
+
return f"{prefix}.getByPlaceholder('{ph}')"
|
|
97
|
+
|
|
98
|
+
def _sub_aria_label(m: re.Match) -> str:
|
|
99
|
+
lbl = m.group(1)
|
|
100
|
+
prefix = "this.page" if "this.page" in m.group(0) else "page"
|
|
101
|
+
changes.append(f"[aria-label='{lbl}'] → getByLabel('{lbl}')")
|
|
102
|
+
return f"{prefix}.getByLabel('{lbl}')"
|
|
103
|
+
|
|
104
|
+
def _sub_role(m: re.Match) -> str:
|
|
105
|
+
role, name = m.group(1), m.group(2)
|
|
106
|
+
prefix = "this.page" if "this.page" in m.group(0) else "page"
|
|
107
|
+
changes.append(f"role={role}[name='{name}'] → getByRole('{role}', {{ name: '{name}' }})")
|
|
108
|
+
return f"{prefix}.getByRole('{role}', {{ name: '{name}' }})"
|
|
109
|
+
|
|
110
|
+
def _flag_xpath(m: re.Match) -> str:
|
|
111
|
+
nonlocal todo_count
|
|
112
|
+
todo_count += 1
|
|
113
|
+
return f"{_TODO_COMMENT} {m.group(0)}"
|
|
114
|
+
|
|
115
|
+
def _flag_complex(m: re.Match) -> str:
|
|
116
|
+
nonlocal todo_count
|
|
117
|
+
original = m.group(0)
|
|
118
|
+
if _TODO_COMMENT in original:
|
|
119
|
+
return original
|
|
120
|
+
todo_count += 1
|
|
121
|
+
return f"{_TODO_COMMENT} {original}"
|
|
122
|
+
|
|
123
|
+
content = _TESTID_RE.sub(_sub_testid, content)
|
|
124
|
+
content = _TEXT_EQ_RE.sub(_sub_text_eq, content)
|
|
125
|
+
content = _TEXT_PSEUDO_RE.sub(_sub_text_pseudo, content)
|
|
126
|
+
content = _PLACEHOLDER_RE.sub(_sub_placeholder, content)
|
|
127
|
+
content = _ARIA_LABEL_RE.sub(_sub_aria_label, content)
|
|
128
|
+
content = _ROLE_RE.sub(_sub_role, content)
|
|
129
|
+
content = _XPATH_RE.sub(_flag_xpath, content)
|
|
130
|
+
content = _COMPLEX_CSS_RE.sub(_flag_complex, content)
|
|
131
|
+
|
|
132
|
+
return content, changes, todo_count
|