@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.
Files changed (90) hide show
  1. package/ARCHITECTURE-ADO.md +350 -0
  2. package/ARCHITECTURE-JIRA.md +203 -0
  3. package/QUICKSTART-ADO.md +400 -0
  4. package/QUICKSTART-JIRA.md +334 -0
  5. package/README.md +49 -0
  6. package/package.json +18 -6
  7. package/skills/migrate-framework/SKILL.md +207 -0
  8. package/src/stlc_agents/__pycache__/__init__.cpython-313.pyc +0 -0
  9. package/src/stlc_agents/agent_gherkin_generator/__pycache__/__init__.cpython-313.pyc +0 -0
  10. package/src/stlc_agents/agent_gherkin_generator/__pycache__/server.cpython-313.pyc +0 -0
  11. package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/__init__.cpython-313.pyc +0 -0
  12. package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/ado_gherkin.cpython-313.pyc +0 -0
  13. package/src/stlc_agents/agent_helix_writer/__pycache__/__init__.cpython-313.pyc +0 -0
  14. package/src/stlc_agents/agent_helix_writer/__pycache__/server.cpython-313.pyc +0 -0
  15. package/src/stlc_agents/agent_helix_writer/tools/__pycache__/__init__.cpython-313.pyc +0 -0
  16. package/src/stlc_agents/agent_helix_writer/tools/__pycache__/boilerplate.cpython-313.pyc +0 -0
  17. package/src/stlc_agents/agent_helix_writer/tools/__pycache__/helix_write.cpython-313.pyc +0 -0
  18. package/src/stlc_agents/agent_jira_manager/__pycache__/__init__.cpython-313.pyc +0 -0
  19. package/src/stlc_agents/agent_jira_manager/__pycache__/server.cpython-313.pyc +0 -0
  20. package/src/stlc_agents/agent_jira_manager/tools/__pycache__/__init__.cpython-313.pyc +0 -0
  21. package/src/stlc_agents/agent_jira_manager/tools/__pycache__/jira_workitem.cpython-313.pyc +0 -0
  22. package/src/stlc_agents/agent_migration/__init__.py +0 -0
  23. package/src/stlc_agents/agent_migration/__pycache__/__init__.cpython-313.pyc +0 -0
  24. package/src/stlc_agents/agent_migration/__pycache__/_migrate.cpython-313.pyc +0 -0
  25. package/src/stlc_agents/agent_migration/__pycache__/cli.cpython-313.pyc +0 -0
  26. package/src/stlc_agents/agent_migration/__pycache__/detector.cpython-313.pyc +0 -0
  27. package/src/stlc_agents/agent_migration/__pycache__/mapper.cpython-313.pyc +0 -0
  28. package/src/stlc_agents/agent_migration/__pycache__/reporter.cpython-313.pyc +0 -0
  29. package/src/stlc_agents/agent_migration/__pycache__/server.cpython-313.pyc +0 -0
  30. package/src/stlc_agents/agent_migration/_migrate.py +1398 -0
  31. package/src/stlc_agents/agent_migration/cli.py +217 -0
  32. package/src/stlc_agents/agent_migration/detector.py +81 -0
  33. package/src/stlc_agents/agent_migration/mapper.py +439 -0
  34. package/src/stlc_agents/agent_migration/reporter.py +86 -0
  35. package/src/stlc_agents/agent_migration/server.py +267 -0
  36. package/src/stlc_agents/agent_migration/transformer/__init__.py +0 -0
  37. package/src/stlc_agents/agent_migration/transformer/__pycache__/__init__.cpython-313.pyc +0 -0
  38. package/src/stlc_agents/agent_migration/transformer/__pycache__/config_merger.cpython-313.pyc +0 -0
  39. package/src/stlc_agents/agent_migration/transformer/__pycache__/healer_injector.cpython-313.pyc +0 -0
  40. package/src/stlc_agents/agent_migration/transformer/__pycache__/import_fixer.cpython-313.pyc +0 -0
  41. package/src/stlc_agents/agent_migration/transformer/__pycache__/js_to_ts.cpython-313.pyc +0 -0
  42. package/src/stlc_agents/agent_migration/transformer/__pycache__/local_var_hoister.cpython-313.pyc +0 -0
  43. package/src/stlc_agents/agent_migration/transformer/__pycache__/locator_moderniser.cpython-313.pyc +0 -0
  44. package/src/stlc_agents/agent_migration/transformer/__pycache__/locator_registrar.cpython-313.pyc +0 -0
  45. package/src/stlc_agents/agent_migration/transformer/__pycache__/spec_to_bdd.cpython-313.pyc +0 -0
  46. package/src/stlc_agents/agent_migration/transformer/config_merger.py +513 -0
  47. package/src/stlc_agents/agent_migration/transformer/healer_injector.py +1143 -0
  48. package/src/stlc_agents/agent_migration/transformer/import_fixer.py +419 -0
  49. package/src/stlc_agents/agent_migration/transformer/js_to_ts.py +413 -0
  50. package/src/stlc_agents/agent_migration/transformer/local_var_hoister.py +378 -0
  51. package/src/stlc_agents/agent_migration/transformer/locator_moderniser.py +132 -0
  52. package/src/stlc_agents/agent_migration/transformer/locator_registrar.py +328 -0
  53. package/src/stlc_agents/agent_migration/transformer/spec_to_bdd.py +820 -0
  54. package/src/stlc_agents/agent_playwright_generator/__pycache__/__init__.cpython-313.pyc +0 -0
  55. package/src/stlc_agents/agent_playwright_generator/__pycache__/server.cpython-313.pyc +0 -0
  56. package/src/stlc_agents/agent_playwright_generator/server.py +926 -91
  57. package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/__init__.cpython-313.pyc +0 -0
  58. package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/ado_attach.cpython-313.pyc +0 -0
  59. package/src/stlc_agents/agent_test_case_manager/__pycache__/__init__.cpython-313.pyc +0 -0
  60. package/src/stlc_agents/agent_test_case_manager/__pycache__/server.cpython-313.pyc +0 -0
  61. package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/__init__.cpython-313.pyc +0 -0
  62. package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/ado_workitem.cpython-313.pyc +0 -0
  63. package/src/stlc_agents/shared/__pycache__/__init__.cpython-313.pyc +0 -0
  64. package/src/stlc_agents/shared/__pycache__/auth.cpython-313.pyc +0 -0
  65. package/src/stlc_agents/shared/__pycache__/cost_tracker.cpython-313.pyc +0 -0
  66. package/src/stlc_agents/shared/__pycache__/pricing.cpython-313.pyc +0 -0
  67. package/src/stlc_agents/shared_jira/__pycache__/__init__.cpython-313.pyc +0 -0
  68. package/src/stlc_agents/shared_jira/__pycache__/auth.cpython-313.pyc +0 -0
  69. package/src/stlc_agents/webhook_orchestrator/__pycache__/__init__.cpython-313.pyc +0 -0
  70. package/src/stlc_agents/webhook_orchestrator/__pycache__/agent_runner.cpython-313.pyc +0 -0
  71. package/src/stlc_agents/webhook_orchestrator/__pycache__/models.cpython-313.pyc +0 -0
  72. package/src/stlc_agents/webhook_orchestrator/__pycache__/orchestrator.cpython-313.pyc +0 -0
  73. package/src/stlc_agents/webhook_orchestrator/__pycache__/webhook_bridge.cpython-313.pyc +0 -0
  74. package/src/stlc_agents/agent_gherkin_generator/__pycache__/__init__.cpython-314.pyc +0 -0
  75. package/src/stlc_agents/agent_gherkin_generator/__pycache__/server.cpython-314.pyc +0 -0
  76. package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/__init__.cpython-314.pyc +0 -0
  77. package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/ado_gherkin.cpython-314.pyc +0 -0
  78. package/src/stlc_agents/agent_helix_writer/__pycache__/server.cpython-314.pyc +0 -0
  79. package/src/stlc_agents/agent_playwright_generator/__pycache__/__init__.cpython-314.pyc +0 -0
  80. package/src/stlc_agents/agent_playwright_generator/__pycache__/server.cpython-314.pyc +0 -0
  81. package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/__init__.cpython-314.pyc +0 -0
  82. package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/ado_attach.cpython-314.pyc +0 -0
  83. package/src/stlc_agents/agent_test_case_manager/__pycache__/__init__.cpython-314.pyc +0 -0
  84. package/src/stlc_agents/agent_test_case_manager/__pycache__/server.cpython-314.pyc +0 -0
  85. package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/__init__.cpython-314.pyc +0 -0
  86. package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/ado_workitem.cpython-314.pyc +0 -0
  87. package/src/stlc_agents/shared/__pycache__/__init__.cpython-314.pyc +0 -0
  88. package/src/stlc_agents/shared/__pycache__/auth.cpython-314.pyc +0 -0
  89. package/src/stlc_agents/shared/__pycache__/cost_tracker.cpython-314.pyc +0 -0
  90. 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