@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,1143 @@
1
+ """
2
+ Inject TimingHealer and VisualIntentChecker into migrated Playwright code.
3
+
4
+ Scope: ALL Playwright interactions — page objects, step definitions, and
5
+ any helper file that contains raw page.click / page.fill calls.
6
+ """
7
+ from __future__ import annotations
8
+ import re
9
+
10
+ _HEALER_IMPORTS = """\
11
+ import { createLogger, transports } from 'winston';
12
+ import { LocatorHealer } from '@utils/locators/LocatorHealer';
13
+ import { LocatorRepository } from '@utils/locators/LocatorRepository';
14
+ import { TimingHealer } from '@utils/locators/TimingHealer';
15
+ import { VisualIntentChecker } from '@utils/locators/VisualIntentChecker';\
16
+ """
17
+
18
+ # Private field declarations injected after the class opening brace (page objects)
19
+ # _repo is a field initializer (no this.page needed); healers are assigned in ctor
20
+ _HEALER_FIELDS = (
21
+ " private _repo = new LocatorRepository();\n"
22
+ " private _logger = createLogger({ transports: [new transports.Console()] });\n"
23
+ " private _healer!: LocatorHealer;\n"
24
+ " private _timing!: TimingHealer;\n"
25
+ " private _visual!: VisualIntentChecker;\n"
26
+ )
27
+
28
+ # Assignments injected in the constructor body (after super() if present).
29
+ # {registrations} is replaced with locator registration calls when locator imports are detected.
30
+ _HEALER_CTOR_ASSIGN = (
31
+ "\n this._healer = new LocatorHealer(this.page, this._logger, this._repo);\n"
32
+ " this._timing = new TimingHealer(this.page, this._logger, this._repo);\n"
33
+ " this._visual = new VisualIntentChecker(this.page, this._logger, this._repo);\n"
34
+ "{registrations}"
35
+ )
36
+
37
+ # Detect `import { xxxLocators[, ...] } from '@locators/...'`
38
+ _LOCATOR_IMPORT_RE = re.compile(
39
+ r"import\s+\{([^}]+)\}\s+from\s+['\"]@locators/[^'\"]+['\"]"
40
+ )
41
+
42
+ # Healer setup for `function ()` step callbacks — `this` binds to Cucumber's World.
43
+ # `this.page!` definite-assignment assertion: World's type doesn't declare `page` in strict mode.
44
+ _STEP_HEALER_SETUP = (
45
+ "\n const _repo = new LocatorRepository();\n"
46
+ " const _logger = createLogger({ transports: [new transports.Console()] });\n"
47
+ " const _healer = new LocatorHealer(this.page!, _logger, _repo);\n"
48
+ " const _timing = new TimingHealer(this.page!, _logger, _repo);\n"
49
+ " const _visual = new VisualIntentChecker(this.page!, _logger, _repo);\n"
50
+ )
51
+
52
+ # Healer setup for `async () => {}` arrow step callbacks — no `this` binding,
53
+ # so we get the page via fixture() instead.
54
+ _STEP_ARROW_HEALER_SETUP = (
55
+ "\n const _repo = new LocatorRepository();\n"
56
+ " const _logger = createLogger({ transports: [new transports.Console()] });\n"
57
+ " const _page = fixture().page;\n"
58
+ " const _healer = new LocatorHealer(_page, _logger, _repo);\n"
59
+ " const _timing = new TimingHealer(_page, _logger, _repo);\n"
60
+ " const _visual = new VisualIntentChecker(_page, _logger, _repo);\n"
61
+ )
62
+
63
+ # Receivers for `<X>.click(...)` etc.
64
+ # • `page` / `this.page` / `world.page` / `ctx.page` — direct
65
+ # • `currentFixture.page` / `this.currentFixture.page` — common QA fixture
66
+ # Anything else (e.g. raw `popup.click`) stays untouched.
67
+ _PAGE_EXPR = r"(?:(?:this|world|ctx)\s*\.\s*)?(?:currentFixture\s*\.\s*)?page"
68
+
69
+ # `await` is optional: Playwright actions also appear unawaited inside
70
+ # `Promise.all([...])` blocks (the await is on the surrounding Promise.all).
71
+ # Allowing optional await covers both forms without inventing matches —
72
+ # the rest of the pattern still requires the exact `<receiver>.<verb>(...)`
73
+ # shape so we don't grab unrelated method calls.
74
+ _AWAIT_OPT = r"(?:await\s+)?"
75
+
76
+ _CLICK_RE = re.compile(rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*click\(\s*([^)]+)\s*\)")
77
+ _FILL_RE = re.compile(rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*fill\(\s*([^,)]+)\s*,\s*([^)]+)\s*\)")
78
+ _CHECK_RE = re.compile(rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*check\(\s*([^)]+)\s*\)")
79
+ _UNCHECK_RE = re.compile(rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*uncheck\(\s*([^)]+)\s*\)")
80
+ _SELECT_RE = re.compile(rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*selectOption\(\s*([^,)]+)\s*,\s*([^)]+)\s*\)")
81
+ _GOTO_RE = re.compile(rf"await\s+({_PAGE_EXPR})\s*\.\s*goto\(\s*([^)]+)\s*\)")
82
+ _EXPECT_VISIBLE_RE = re.compile(r"await\s+expect\(([^)]+)\)\s*\.\s*toBeVisible\([^)]*\)")
83
+
84
+ _counter: list[int] = [0]
85
+
86
+
87
+ def _next_key(prefix: str) -> str:
88
+ _counter[0] += 1
89
+ return f"{prefix}{_counter[0]}"
90
+
91
+
92
+ def _build_locator_registrations(content: str) -> str:
93
+ """
94
+ Scan the file for `import { xxxLocators } from '@locators/...'` statements
95
+ and build constructor registration calls so the LocatorRepository is populated.
96
+
97
+ For each imported locator object (xxxLocators), each entry is registered
98
+ under BOTH key shapes so callers using either convention resolve:
99
+
100
+ • Namespaced: `xxxPage.<key>` (emitted by this migration's healer rewriter)
101
+ • Bare: `<key>` (emitted by older migration tools / hand-written code)
102
+
103
+ Older migrated trees frequently have a mix — the rewriter touched some
104
+ `page.click(...)` sites and converted them to `_healer.clickWithHealing('xxx.key', ...)`
105
+ while leaving pre-existing `_healer.fillWithHealing("key", ...)` calls
106
+ untouched. Registering both shapes means neither convention silently
107
+ no-ops on `updateHealed` (the early-return on `store.get(key) === undefined`).
108
+
109
+ Only emitted when the locator file has already been converted to the
110
+ { selector, intent, stability } format by locator_registrar.
111
+ """
112
+ registrations: list[str] = []
113
+ for m in _LOCATOR_IMPORT_RE.finditer(content):
114
+ names_raw = m.group(1)
115
+ names = [n.strip() for n in names_raw.split(",") if n.strip().endswith("Locators")]
116
+ for name in names:
117
+ # Derive a namespace prefix: dashboardLocators → "dashboard"
118
+ ns = name[0].lower() + name[1:]
119
+ ns = ns.replace("Locators", "")
120
+ registrations.append(
121
+ f" Object.entries({name}).forEach(([key, val]) => {{\n"
122
+ f" if (typeof val === 'object' && val !== null && 'selector' in val) {{\n"
123
+ f" this._repo.register(`{ns}.${{key}}`, "
124
+ f"(val as any).selector, (val as any).intent, (val as any).stability ?? 0);\n"
125
+ f" // Bare-key alias for legacy call sites that didn't namespace the key.\n"
126
+ f" this._repo.register(key, "
127
+ f"(val as any).selector, (val as any).intent, (val as any).stability ?? 0);\n"
128
+ f" }}\n"
129
+ f" }});\n"
130
+ )
131
+ return "".join(registrations)
132
+
133
+
134
+ def _ensure_locator_registrations(content: str, changes: list[str]) -> str:
135
+ """
136
+ Patch a *page-object* file that already has the healer scaffolded (imports
137
+ + private fields + ctor assignments) but is missing the per-key
138
+ `this._repo.register(...)` calls that populate the in-memory store.
139
+
140
+ Without these calls every `updateHealed(...)` invocation finds no entry
141
+ for its key, returns early, and the heal store JSON is never written —
142
+ the symptom that surfaced on re-migration runs.
143
+
144
+ Idempotent: if `this._repo.register(` already appears anywhere in the
145
+ file, no change is made.
146
+ """
147
+ if "this._repo.register(" in content:
148
+ return content
149
+ registrations = _build_locator_registrations(content)
150
+ if not registrations:
151
+ return content
152
+ # Anchor on the `this._healer = new LocatorHealer(...)` assignment the
153
+ # previous migration emitted. Insert the registrations right after the
154
+ # full healer/timing/visual triple (after the line containing
155
+ # `new VisualIntentChecker(`). Fall back to inserting right after
156
+ # `new LocatorHealer(` if the visual line isn't present.
157
+ anchors = [
158
+ r"this\._visual\s*=\s*new\s+VisualIntentChecker\([^)]*\);[^\n]*\n",
159
+ r"this\._timing\s*=\s*new\s+TimingHealer\([^)]*\);[^\n]*\n",
160
+ r"this\._healer\s*=\s*new\s+LocatorHealer\([^)]*\);[^\n]*\n",
161
+ ]
162
+ for pat in anchors:
163
+ m = re.search(pat, content)
164
+ if m:
165
+ content = content[: m.end()] + registrations + content[m.end():]
166
+ changes.append("Injected missing this._repo.register() calls (re-migration patch)")
167
+ return content
168
+ return content
169
+
170
+
171
+ def inject_healers(
172
+ content: str,
173
+ role: str,
174
+ object_keys: dict[str, set[str]] | None = None,
175
+ ) -> tuple[str, list[str], int]:
176
+ """
177
+ Inject healer wrappers.
178
+
179
+ role: "page_object" | "step_def" | anything else (generic).
180
+ object_keys: optional map of `xxxLocators` name -> set of keys that are
181
+ object-valued ({selector, intent, stability}). Used to skip healing
182
+ wraps for string-valued display labels (e.g. `myBundleText: "My Bundle"`)
183
+ which would otherwise produce `.selector` accesses on a plain string.
184
+ When None, every locator reference is treated as object-valued (legacy
185
+ behavior — safe for callers that haven't been updated).
186
+ Returns (transformed_content, changes, todo_count).
187
+ """
188
+ _counter[0] = 0
189
+ changes: list[str] = []
190
+ todo_count = 0
191
+
192
+ # Detect re-migration: a file where a previous pass already wired up the
193
+ # healer's runtime objects (fields for page objects, const setup for step
194
+ # callbacks). We check for the actual assignments — `new LocatorHealer(`
195
+ # or `clickWithHealing(` — NOT just the import line, because an import
196
+ # alone doesn't establish `_healer` / `_timing` / `_visual` in scope.
197
+ # Misclassifying an import-only file as "already injected" caused the
198
+ # setup pass to be skipped and the rewriter to reference undefined
199
+ # `_timing` / `_visual` symbols in step callbacks.
200
+ already_injected = (
201
+ "new LocatorHealer(" in content
202
+ or "clickWithHealing(" in content
203
+ or "fillWithHealing(" in content
204
+ or "assertVisibleWithHealing(" in content
205
+ )
206
+
207
+ if not already_injected:
208
+ content = _prepend_imports(content, changes)
209
+
210
+ if role == "page_object":
211
+ if already_injected:
212
+ # The healer fields and ctor assignments exist but a previous
213
+ # migration pass may not have emitted the locator registrations.
214
+ # Without them every key passed to `updateHealed` is unknown to
215
+ # the in-memory store and the heal write silently no-ops. Patch
216
+ # in the registrations before the action rewriter runs.
217
+ content = _ensure_locator_registrations(content, changes)
218
+ content = _replace_actions(
219
+ content, changes, prefix="po", use_this=True, object_keys=object_keys,
220
+ )
221
+ else:
222
+ content = _inject_page_object(content, changes, object_keys)
223
+ elif role == "step_def":
224
+ if already_injected:
225
+ content = _replace_actions(
226
+ content, changes, prefix="step", use_this=False, object_keys=object_keys,
227
+ )
228
+ else:
229
+ content = _inject_step_defs(content, changes, object_keys)
230
+ else:
231
+ if already_injected:
232
+ content = _replace_actions(
233
+ content, changes, prefix="gen", use_this=False, object_keys=object_keys,
234
+ )
235
+ else:
236
+ content, todo_count = _inject_generic(content, changes, object_keys)
237
+
238
+ return content, changes, todo_count
239
+
240
+
241
+ # ---------------------------------------------------------------------------
242
+ # Import injection
243
+ # ---------------------------------------------------------------------------
244
+
245
+ def _prepend_imports(content: str, changes: list[str]) -> str:
246
+ if "LocatorHealer" in content:
247
+ return content
248
+
249
+ lines = content.split("\n")
250
+ # Find the last complete import / require statement.
251
+ # Multi-line imports like `import {\n Foo,\n} from '...'` must be tracked
252
+ # so we insert AFTER the closing `} from '...'` line, not inside the braces.
253
+ last_import = -1
254
+ in_multiline = False
255
+ for i, line in enumerate(lines):
256
+ stripped = line.strip()
257
+ if in_multiline:
258
+ # Wait for the closing `} from '...'` line
259
+ if re.match(r"^\}\s*from\s+['\"]", stripped):
260
+ last_import = i
261
+ in_multiline = False
262
+ continue
263
+ if stripped.startswith("import ") and stripped.endswith("{") and "from" not in stripped:
264
+ # Opening of a multi-line import: `import {` or `import Foo, {`
265
+ in_multiline = True
266
+ continue
267
+ if stripped.startswith("import "):
268
+ last_import = i
269
+ # Only match top-level require() — NOT indented ones inside class methods
270
+ elif line.startswith("const ") and "require" in line:
271
+ last_import = i
272
+
273
+ insert_at = last_import + 1 if last_import >= 0 else 0
274
+ lines.insert(insert_at, _HEALER_IMPORTS)
275
+ changes.append("Injected healer imports")
276
+ return "\n".join(lines)
277
+
278
+
279
+ # ---------------------------------------------------------------------------
280
+ # Page-object injection
281
+ # ---------------------------------------------------------------------------
282
+
283
+ def _inject_page_object(content: str, changes: list[str], object_keys: dict[str, set[str]] | None = None) -> str:
284
+ # Step A: inject private field declarations right after the class opening brace.
285
+ class_body_re = re.compile(
286
+ r"(class\s+\w+(?:\s+extends\s+[\w.<>,\s]+)?\s*\{)[ \t]*\n",
287
+ re.MULTILINE,
288
+ )
289
+ m_cls = class_body_re.search(content)
290
+ if m_cls:
291
+ content = content[: m_cls.end()] + _HEALER_FIELDS + content[m_cls.end() :]
292
+ changes.append("Injected healer field declarations into class body")
293
+
294
+ # Build locator registration lines for any imported locator objects
295
+ registrations = _build_locator_registrations(content)
296
+ ctor_assign = _HEALER_CTOR_ASSIGN.replace("{registrations}", registrations)
297
+ if registrations:
298
+ changes.append("Injected LocatorRepository.register() calls for imported locators")
299
+
300
+ # Step B: inject constructor assignments.
301
+ # 1. Constructor with super() — inject after super()
302
+ ctor_super_re = re.compile(
303
+ r"(constructor\s*\([^)]*\)\s*\{[^}]*?super\s*\([^)]*\)\s*;?)",
304
+ re.DOTALL,
305
+ )
306
+ m = ctor_super_re.search(content)
307
+ if m:
308
+ content = content[: m.end()] + ctor_assign + content[m.end() :]
309
+ changes.append("Injected healer assignments into constructor (after super)")
310
+ else:
311
+ # 2. Constructor without super() — inject at start of constructor body
312
+ ctor_re = re.compile(r"(constructor\s*\([^)]*\)\s*\{[ \t]*\n)", re.MULTILINE)
313
+ m2 = ctor_re.search(content)
314
+ if m2:
315
+ content = content[: m2.end()] + ctor_assign + content[m2.end() :]
316
+ changes.append("Injected healer assignments into constructor")
317
+ else:
318
+ changes.append("No constructor found — healer fields declared; add ctor to init them")
319
+
320
+ # Step C: replace raw Playwright actions with this._healer calls
321
+ content = _replace_actions(content, changes, prefix="po", use_this=True, object_keys=object_keys)
322
+ return content
323
+
324
+
325
+ # ---------------------------------------------------------------------------
326
+ # Step-definition injection
327
+ # ---------------------------------------------------------------------------
328
+
329
+ def _inject_step_defs(content: str, changes: list[str], object_keys: dict[str, set[str]] | None = None) -> str:
330
+ # Inject healer setup at the start of each step callback. The callback
331
+ # can take several syntactic forms — all of which must be matched:
332
+ #
333
+ # When("...", async function () { ... })
334
+ # When("...", async function (): Promise<void> { ... }) ← TS return type
335
+ # When(
336
+ # "...",
337
+ # async function (): Promise<void> { ... } ← multi-line wrap
338
+ # )
339
+ # When("...", async ({ page }) => { ... }) ← arrow
340
+ #
341
+ # The leading-string match `[^,]+?` is non-greedy to keep us from sliding
342
+ # past a comma inside the step name when scenarios accidentally include
343
+ # commas in their text (rare, but it has happened).
344
+ step_fn_re = re.compile(
345
+ r"((?:Given|When|Then|And|But)\s*\(\s*[^,]+?,\s*"
346
+ r"async\s+function\s*\([^)]*\)"
347
+ r"(?:\s*:\s*[^{}]+?)?" # optional `: Promise<void>` return type
348
+ r"\s*\{)",
349
+ re.DOTALL,
350
+ )
351
+
352
+ def _inject(m: re.Match) -> str:
353
+ changes.append("Injected healer setup into step function")
354
+ return m.group(0) + _STEP_HEALER_SETUP
355
+
356
+ content = step_fn_re.sub(_inject, content)
357
+
358
+ # Arrow-function callbacks
359
+ arrow_re = re.compile(
360
+ r"((?:Given|When|Then|And|But)\s*\(\s*[^,]+?,\s*"
361
+ r"async\s*\([^)]*\)"
362
+ r"(?:\s*:\s*[^{}=]+?)?" # optional return-type annotation
363
+ r"\s*=>\s*\{)"
364
+ )
365
+
366
+ def _inject_arrow(m: re.Match) -> str:
367
+ changes.append("Injected healer setup into arrow step function")
368
+ return m.group(0) + _STEP_ARROW_HEALER_SETUP
369
+
370
+ content = arrow_re.sub(_inject_arrow, content)
371
+
372
+ content = _replace_actions(content, changes, prefix="step", object_keys=object_keys)
373
+ return content
374
+
375
+
376
+ # ---------------------------------------------------------------------------
377
+ # Generic injection
378
+ # ---------------------------------------------------------------------------
379
+
380
+ def _inject_generic(content: str, changes: list[str], object_keys: dict[str, set[str]] | None = None) -> tuple[str, int]:
381
+ has_actions = bool(
382
+ _CLICK_RE.search(content)
383
+ or _FILL_RE.search(content)
384
+ or _CHECK_RE.search(content)
385
+ )
386
+ if not has_actions:
387
+ return content, 0
388
+
389
+ todo_line = "// TODO: initialize healers — const _repo = new LocatorRepository(); const _healer = new LocatorHealer(page, _repo);\n"
390
+ content = todo_line + content
391
+ content = _replace_actions(content, changes, prefix="gen", object_keys=object_keys)
392
+ changes.append("Generic healer injection applied (see TODO comment)")
393
+ return content, 1
394
+
395
+
396
+ # ---------------------------------------------------------------------------
397
+ # Shared action replacement
398
+ # ---------------------------------------------------------------------------
399
+
400
+ def _balanced(s: str) -> bool:
401
+ """Return True if parentheses in s are balanced (no unclosed subexpressions)."""
402
+ depth = 0
403
+ for ch in s:
404
+ if ch == "(":
405
+ depth += 1
406
+ elif ch == ")":
407
+ depth -= 1
408
+ if depth < 0:
409
+ return False
410
+ return depth == 0
411
+
412
+
413
+ def _extract_call_args(text: str, paren_idx: int) -> tuple[list[str], int] | None:
414
+ """
415
+ Given `text` and an index pointing at `(`, return `(args, end_idx)`
416
+ where `args` is the list of comma-separated top-level arguments
417
+ (stripped) and `end_idx` is one past the matching `)`.
418
+
419
+ Respects nested parens, brackets, braces, single/double quoted strings,
420
+ and template literals. Returns None if the call is unbalanced.
421
+ """
422
+ if paren_idx >= len(text) or text[paren_idx] != "(":
423
+ return None
424
+ depth = 1
425
+ i = paren_idx + 1
426
+ arg_start = i
427
+ args: list[str] = []
428
+ in_str: str | None = None
429
+ n = len(text)
430
+ while i < n:
431
+ c = text[i]
432
+ if in_str:
433
+ if c == "\\" and i + 1 < n:
434
+ i += 2
435
+ continue
436
+ if c == in_str:
437
+ in_str = None
438
+ i += 1
439
+ continue
440
+ if c in ('"', "'", "`"):
441
+ in_str = c
442
+ i += 1
443
+ continue
444
+ if c in "([{":
445
+ depth += 1
446
+ elif c in ")]}":
447
+ depth -= 1
448
+ if depth == 0:
449
+ tail = text[arg_start:i].strip()
450
+ # Append only non-empty trailing args. A trailing comma
451
+ # (`fill(a, b,)`) is common in multi-line formatting; we
452
+ # treat it as 2 args, not 3.
453
+ if tail:
454
+ args.append(tail)
455
+ return args, i + 1
456
+ elif c == "," and depth == 1:
457
+ args.append(text[arg_start:i].strip())
458
+ arg_start = i + 1
459
+ i += 1
460
+ return None
461
+
462
+
463
+ def _scan_and_rewrite(
464
+ content: str,
465
+ prefix_re: re.Pattern,
466
+ rewrite_args: callable,
467
+ ) -> str:
468
+ """
469
+ Find each match of `prefix_re` (a regex whose final char-class is `(`).
470
+ For each, extract the balanced argument list via _extract_call_args and
471
+ delegate to `rewrite_args(prefix_text, args)` to build the replacement.
472
+
473
+ `rewrite_args` returns the replacement string, or None to leave the
474
+ original call intact.
475
+ """
476
+ out: list[str] = []
477
+ last = 0
478
+ for m in prefix_re.finditer(content):
479
+ # prefix_re ends with `\(` — locate that paren in the matched text.
480
+ paren_idx = content.find("(", m.start(), m.end())
481
+ if paren_idx < 0:
482
+ continue
483
+ result = _extract_call_args(content, paren_idx)
484
+ if result is None:
485
+ continue
486
+ args, end_idx = result
487
+ replacement = rewrite_args(m.group(0)[: paren_idx - m.start()], args)
488
+ out.append(content[last : m.start()])
489
+ if replacement is None:
490
+ out.append(content[m.start() : end_idx])
491
+ else:
492
+ out.append(replacement)
493
+ last = end_idx
494
+ out.append(content[last:])
495
+ return "".join(out)
496
+
497
+
498
+ # Detect `xxxLocators.yyy` accesses (NOT followed by `(` → not a function call).
499
+ # This lets us derive a stable, semantic healing key from the locator itself
500
+ # instead of using an auto-generated counter like `po_vis1`.
501
+ _LOCATOR_ACCESS_RE = re.compile(r"\b(\w+Locators)\.(\w+)\b(?!\s*\()")
502
+
503
+ # Detect local variable assignments whose RHS references a locator-repo entry.
504
+ # Matches: `const x = ...`, `let x = ...`, `private x = ...`, `private readonly x = ...`, etc.
505
+ # DOTALL so multi-line RHS like `this.page.locator(\n fooLocators.bar.selector,\n)` is captured.
506
+ _VAR_ASSIGN_RE = re.compile(
507
+ r"\b(?:const|let|var|private|public|protected|readonly)"
508
+ r"(?:\s+(?:private|public|protected|readonly))*"
509
+ r"\s+(\w+)(?:\s*:\s*[^=;]+)?\s*=\s*(.+?);",
510
+ re.DOTALL,
511
+ )
512
+
513
+ # Detect chained-locator calls on a bare identifier: `await <var>.click()` / `.fill(...)`.
514
+ # The negative lookahead skips `page.`, `this.`, `world.`, `ctx.` so it doesn't shadow _CLICK_RE.
515
+ _CHAINED_CLICK_RE = re.compile(
516
+ r"await\s+(?!page\b|this\b|world\b|ctx\b)(\w+)\s*\.\s*click\(\s*\)"
517
+ )
518
+ _CHAINED_FILL_RE = re.compile(
519
+ r"await\s+(?!page\b|this\b|world\b|ctx\b)(\w+)\s*\.\s*fill\(\s*([^)]+)\s*\)"
520
+ )
521
+
522
+ # Custom wrappers most QA projects define around Playwright actions — they
523
+ # take a selector string and dispatch to page.click / page.fill. Routing them
524
+ # to the healer instead means *every* call site goes through the healing
525
+ # chain, not just direct page.click() / page.fill() calls.
526
+ #
527
+ # Matches: `base.waitAndClick(X)`, `this.base.waitAndClick(X)`,
528
+ # `base.waitAndClickByTestId(X)`, `this.base.waitAndClickByTestId(X)`
529
+ _WRAPPER_CLICK_RE = re.compile(
530
+ rf"{_AWAIT_OPT}(?:this\s*\.\s*)?base\s*\.\s*waitAndClick(?:ByTestId)?\(\s*([^)]+)\s*\)"
531
+ )
532
+ _WRAPPER_FILL_RE = re.compile(
533
+ rf"{_AWAIT_OPT}(?:this\s*\.\s*)?base\s*\.\s*waitAndFill(?:ByTestId)?\(\s*([^,]+)\s*,\s*([^)]+)\s*\)"
534
+ )
535
+
536
+ # Chained .locator(X).click() / .fill(X, v) — only when there are no
537
+ # intermediate filter calls (.first(), .nth(), .filter()) that would change
538
+ # the selector's semantics. The healer takes a raw selector string and
539
+ # handles filtering itself, so wrapping these is safe; chains with filters
540
+ # stay as raw Playwright since their semantics aren't reducible to a key.
541
+ _LOC_CHAIN_CLICK_RE = re.compile(
542
+ rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*locator\(\s*([^)]+?)\s*\)\.click\(\s*\)"
543
+ )
544
+ _LOC_CHAIN_FILL_RE = re.compile(
545
+ rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*locator\(\s*([^)]+?)\s*\)\.fill\(\s*([^)]+)\s*\)"
546
+ )
547
+
548
+
549
+ def _semantic_for(
550
+ loc_name: str,
551
+ prop: str,
552
+ object_keys: dict[str, set[str]] | None,
553
+ ) -> tuple[str, str, str] | None:
554
+ """Build (key, intent_expr, selector_expr) for a recognised locator ref."""
555
+ if object_keys is not None and prop not in object_keys.get(loc_name, set()):
556
+ return None
557
+ namespace = loc_name[0].lower() + loc_name[1:]
558
+ namespace = namespace.replace("Locators", "")
559
+ return (
560
+ f"{namespace}.{prop}",
561
+ f"{loc_name}.{prop}.intent",
562
+ f"{loc_name}.{prop}.selector",
563
+ )
564
+
565
+
566
+ def _derive_semantic(
567
+ expr: str,
568
+ object_keys: dict[str, set[str]] | None = None,
569
+ alias_map: dict[str, str] | None = None,
570
+ inline_field_map: dict[tuple[str, str], tuple[str, str]] | None = None,
571
+ ) -> tuple[str, str, str] | None:
572
+ """
573
+ If `expr` contains a recognised locator reference, return:
574
+ (healing_key, intent_expr, selector_expr)
575
+
576
+ Recognised forms:
577
+ • Direct: `xxxLocators.yyy` (with or without `.selector`)
578
+ • Aliased: `this.Elements.yyy` / `E.yyy` (via _collect_alias_map)
579
+ • Inline field: `this.MassInviteElements.yyy` (via _collect_inline_field_map)
580
+
581
+ When `object_keys` is provided, only keys that are object-valued
582
+ ({selector, intent, stability}) qualify. String-valued display labels
583
+ return None so the call stays raw instead of producing a `.selector`
584
+ access on a plain string.
585
+
586
+ Returns None when no qualifying locator access is found.
587
+ """
588
+ # 1. Direct: xxxLocators.prop (optionally followed by .selector)
589
+ for m in _LOCATOR_ACCESS_RE.finditer(expr):
590
+ sem = _semantic_for(m.group(1), m.group(2), object_keys)
591
+ if sem:
592
+ return sem
593
+
594
+ # 2. Aliased: <alias>.prop — alias_map carries `this.Elements → xxxLocators`
595
+ # Match each alias's access pattern. We iterate longest first so a
596
+ # longer alias like `this.Elements` wins over a colliding bare `Elements`.
597
+ if alias_map:
598
+ for alias in sorted(alias_map, key=len, reverse=True):
599
+ loc_name = alias_map[alias]
600
+ alias_re = re.compile(
601
+ rf"(?<![.\w]){re.escape(alias)}\.(\w+)\b(?!\s*\()"
602
+ )
603
+ for m in alias_re.finditer(expr):
604
+ sem = _semantic_for(loc_name, m.group(1), object_keys)
605
+ if sem:
606
+ return sem
607
+
608
+ # 3. Inline-field re-export: `this.<FieldName>.<inline_key>` whose value
609
+ # in the class field is `<xxxLocators>.<orig_key>.selector`. Routes
610
+ # the healer call through the ORIGINAL locator's semantic info, but
611
+ # keeps the user-facing selector expression as the field reference
612
+ # (preserving the runtime string they wrote).
613
+ if inline_field_map:
614
+ # Sort aliases longest-first so `this.MassInviteElements` wins over
615
+ # any colliding shorter alias.
616
+ aliases = sorted({alias for (alias, _) in inline_field_map}, key=len, reverse=True)
617
+ for alias in aliases:
618
+ alias_re = re.compile(
619
+ rf"(?<![.\w]){re.escape(alias)}\.(\w+)\b(?!\s*\()"
620
+ )
621
+ for m in alias_re.finditer(expr):
622
+ inline_key = m.group(1)
623
+ mapped = inline_field_map.get((alias, inline_key))
624
+ if not mapped:
625
+ continue
626
+ loc_name, orig_key = mapped
627
+ # Build a custom semantic tuple: keep `selector_expr` as the
628
+ # field reference so the runtime string-typed value passes
629
+ # straight through to the healer.
630
+ if object_keys is not None and orig_key not in object_keys.get(loc_name, set()):
631
+ continue
632
+ ns = loc_name[0].lower() + loc_name[1:]
633
+ ns = ns.replace("Locators", "")
634
+ return (
635
+ f"{ns}.{orig_key}",
636
+ f"{loc_name}.{orig_key}.intent",
637
+ f"{alias}.{inline_key}",
638
+ )
639
+ return None
640
+
641
+
642
+ _IDENT_RE = re.compile(r"^\w+$")
643
+
644
+
645
+ def _build_var_to_semantic(
646
+ content: str,
647
+ object_keys: dict[str, set[str]] | None = None,
648
+ alias_map: dict[str, str] | None = None,
649
+ inline_field_map: dict[tuple[str, str], tuple[str, str]] | None = None,
650
+ ) -> dict[str, tuple[str, str, str]]:
651
+ """
652
+ Scan local variable assignments and map each variable name to the semantic
653
+ key/intent/selector triple derived from the locator-repo entry referenced on
654
+ its RHS. Skips RHS that only references string-valued locator entries when
655
+ object_keys is provided.
656
+ """
657
+ result: dict[str, tuple[str, str, str]] = {}
658
+ for m in _VAR_ASSIGN_RE.finditer(content):
659
+ var_name = m.group(1)
660
+ rhs = m.group(2)
661
+ sem = _derive_semantic(rhs, object_keys, alias_map, inline_field_map)
662
+ if sem:
663
+ result[var_name] = sem
664
+ return result
665
+
666
+
667
+ def _resolve_semantic(
668
+ expr: str,
669
+ var_map: dict[str, tuple[str, str, str]],
670
+ object_keys: dict[str, set[str]] | None = None,
671
+ alias_map: dict[str, str] | None = None,
672
+ inline_field_map: dict[tuple[str, str], tuple[str, str]] | None = None,
673
+ ) -> tuple[str, str, str] | None:
674
+ """Try direct locator-repo access first; fall back to local-variable lookup."""
675
+ sem = _derive_semantic(expr, object_keys, alias_map, inline_field_map)
676
+ if sem:
677
+ return sem
678
+ if _IDENT_RE.match(expr):
679
+ return var_map.get(expr)
680
+ return None
681
+
682
+
683
+ def _replace_actions(
684
+ content: str,
685
+ changes: list[str],
686
+ prefix: str,
687
+ use_this: bool = False,
688
+ object_keys: dict[str, set[str]] | None = None,
689
+ ) -> str:
690
+ # For page objects, healers are class properties — accessed via this._healer
691
+ # For step defs and generic, healers are local const variables — accessed directly
692
+ h = "this._healer" if use_this else "_healer"
693
+ t = "this._timing" if use_this else "_timing"
694
+ v = "this._visual" if use_this else "_visual"
695
+
696
+ # Collect alias + inline-field maps BEFORE the rewrites so wrapper /
697
+ # action calls that reference `this.Elements.foo`, `this.MassInvite.foo`,
698
+ # or bare aliased identifiers route through the healer too.
699
+ alias_map = _collect_alias_map(content)
700
+ inline_field_map = _collect_inline_field_map(content)
701
+ var_map = _build_var_to_semantic(content, object_keys, alias_map, inline_field_map)
702
+
703
+ # Counter for synthetic keys when a call site uses a runtime-computed
704
+ # selector (template literal, variable, function-call result, raw
705
+ # string literal). Routing these through the healer still adds value:
706
+ # the healer's chain logs failures, retries with role/label/text/AI
707
+ # strategies, and persists healed selectors to the repository.
708
+ dyn_counter = [0]
709
+
710
+ def _next_dyn_key() -> str:
711
+ dyn_counter[0] += 1
712
+ return f"dyn.{prefix}.{dyn_counter[0]}"
713
+
714
+ def _intent_for_dyn(sel_expr: str) -> str:
715
+ """
716
+ Synthesize an intent string from a dynamic selector expression. Bare
717
+ identifier → camel-cased to words; everything else → "dynamic locator".
718
+ The intent feeds the healer's role/label/text fallback heuristics.
719
+ """
720
+ stripped = sel_expr.strip()
721
+ if _IDENT_RE.match(stripped):
722
+ spaced = re.sub(r"([A-Z])", r" \1", stripped).strip().lower()
723
+ spaced = re.sub(r"selector\b", "", spaced).strip() or stripped
724
+ return f'"{spaced}"'
725
+ return '"dynamic locator"'
726
+
727
+ def _click(m: re.Match) -> str:
728
+ sel = m.group(1).strip().rstrip(",").strip()
729
+ if not _balanced(sel):
730
+ return m.group(0)
731
+ semantic = _resolve_semantic(sel, var_map, object_keys, alias_map, inline_field_map)
732
+ if semantic:
733
+ key, intent_expr, sel_expr = semantic
734
+ changes.append(f"page.click → {h}.clickWithHealing (key={key})")
735
+ return f"await {h}.clickWithHealing('{key}', {sel_expr}, {intent_expr})"
736
+ # Dynamic-selector fallback: synthesise a key and use the original
737
+ # expression as both selector and intent source.
738
+ key = _next_dyn_key()
739
+ changes.append(f"page.click → {h}.clickWithHealing (dyn key={key})")
740
+ return f"await {h}.clickWithHealing('{key}', {sel}, {_intent_for_dyn(sel)})"
741
+
742
+ def _fill(m: re.Match) -> str:
743
+ sel = m.group(1).strip().rstrip(",").strip()
744
+ val = m.group(2).strip().rstrip(",").strip()
745
+ if not _balanced(sel) or not _balanced(val):
746
+ return m.group(0)
747
+ semantic = _resolve_semantic(sel, var_map, object_keys, alias_map, inline_field_map)
748
+ if semantic:
749
+ key, intent_expr, sel_expr = semantic
750
+ changes.append(f"page.fill → {h}.fillWithHealing (key={key})")
751
+ return f"await {h}.fillWithHealing('{key}', {sel_expr}, {val}, {intent_expr})"
752
+ key = _next_dyn_key()
753
+ changes.append(f"page.fill → {h}.fillWithHealing (dyn key={key})")
754
+ return f"await {h}.fillWithHealing('{key}', {sel}, {val}, {_intent_for_dyn(sel)})"
755
+
756
+ def _wrapper_click(m: re.Match) -> str:
757
+ """`base.waitAndClick(X)` / `base.waitAndClickByTestId(X)` → healer."""
758
+ sel = m.group(1).strip().rstrip(",").strip()
759
+ if not _balanced(sel):
760
+ return m.group(0)
761
+ semantic = _resolve_semantic(sel, var_map, object_keys, alias_map, inline_field_map)
762
+ if semantic:
763
+ key, intent_expr, sel_expr = semantic
764
+ changes.append(f"base.waitAndClick* → {h}.clickWithHealing (key={key})")
765
+ return f"await {h}.clickWithHealing('{key}', {sel_expr}, {intent_expr})"
766
+ key = _next_dyn_key()
767
+ changes.append(f"base.waitAndClick* → {h}.clickWithHealing (dyn key={key})")
768
+ return f"await {h}.clickWithHealing('{key}', {sel}, {_intent_for_dyn(sel)})"
769
+
770
+ def _wrapper_fill(m: re.Match) -> str:
771
+ """`base.waitAndFill(X, v)` / `base.waitAndFillByTestId(X, v)` → healer."""
772
+ sel = m.group(1).strip().rstrip(",").strip()
773
+ val = m.group(2).strip().rstrip(",").strip()
774
+ if not _balanced(sel) or not _balanced(val):
775
+ return m.group(0)
776
+ semantic = _resolve_semantic(sel, var_map, object_keys, alias_map, inline_field_map)
777
+ if semantic:
778
+ key, intent_expr, sel_expr = semantic
779
+ changes.append(f"base.waitAndFill* → {h}.fillWithHealing (key={key})")
780
+ return f"await {h}.fillWithHealing('{key}', {sel_expr}, {val}, {intent_expr})"
781
+ key = _next_dyn_key()
782
+ changes.append(f"base.waitAndFill* → {h}.fillWithHealing (dyn key={key})")
783
+ return f"await {h}.fillWithHealing('{key}', {sel}, {val}, {_intent_for_dyn(sel)})"
784
+
785
+ def _loc_chain_click(m: re.Match) -> str:
786
+ """`<recv>.locator(X).click()` (no intermediate filters) → healer."""
787
+ sel = m.group(1).strip().rstrip(",").strip()
788
+ if not _balanced(sel):
789
+ return m.group(0)
790
+ semantic = _resolve_semantic(sel, var_map, object_keys, alias_map, inline_field_map)
791
+ if semantic:
792
+ key, intent_expr, sel_expr = semantic
793
+ changes.append(f"locator(...).click → {h}.clickWithHealing (key={key})")
794
+ return f"await {h}.clickWithHealing('{key}', {sel_expr}, {intent_expr})"
795
+ key = _next_dyn_key()
796
+ changes.append(f"locator(...).click → {h}.clickWithHealing (dyn key={key})")
797
+ return f"await {h}.clickWithHealing('{key}', {sel}, {_intent_for_dyn(sel)})"
798
+
799
+ def _loc_chain_fill(m: re.Match) -> str:
800
+ sel = m.group(1).strip().rstrip(",").strip()
801
+ val = m.group(2).strip().rstrip(",").strip()
802
+ if not _balanced(sel) or not _balanced(val):
803
+ return m.group(0)
804
+ semantic = _resolve_semantic(sel, var_map, object_keys, alias_map, inline_field_map)
805
+ if semantic:
806
+ key, intent_expr, sel_expr = semantic
807
+ changes.append(f"locator(...).fill → {h}.fillWithHealing (key={key})")
808
+ return f"await {h}.fillWithHealing('{key}', {sel_expr}, {val}, {intent_expr})"
809
+ key = _next_dyn_key()
810
+ changes.append(f"locator(...).fill → {h}.fillWithHealing (dyn key={key})")
811
+ return f"await {h}.fillWithHealing('{key}', {sel}, {val}, {_intent_for_dyn(sel)})"
812
+
813
+ def _goto(m: re.Match) -> str:
814
+ page_expr = m.group(1)
815
+ url = m.group(2).strip()
816
+ action_key = _next_key(f"{prefix}_nav")
817
+ changes.append(f"page.goto + TimingHealer.waitForNetworkIdle (key={action_key})")
818
+ return (
819
+ f"await {page_expr}.goto({url}); "
820
+ f"await {t}.waitForNetworkIdle('{action_key}')"
821
+ )
822
+
823
+ def _expect_visible(m: re.Match) -> str:
824
+ locator = m.group(1).strip()
825
+ semantic = _resolve_semantic(locator, var_map, object_keys, alias_map, inline_field_map)
826
+ if semantic:
827
+ key, intent_expr, sel_expr = semantic
828
+ changes.append(f"expect.toBeVisible → {h}.assertVisibleWithHealing (key={key})")
829
+ return f"await {h}.assertVisibleWithHealing('{key}', {sel_expr}, {intent_expr})"
830
+ return m.group(0)
831
+
832
+ def _chained_click(m: re.Match) -> str:
833
+ var_name = m.group(1)
834
+ semantic = var_map.get(var_name)
835
+ if semantic:
836
+ key, intent_expr, sel_expr = semantic
837
+ changes.append(f"{var_name}.click → {h}.clickWithHealing (key={key})")
838
+ return f"await {h}.clickWithHealing('{key}', {sel_expr}, {intent_expr})"
839
+ return m.group(0)
840
+
841
+ def _chained_fill(m: re.Match) -> str:
842
+ var_name = m.group(1)
843
+ val = m.group(2).strip()
844
+ if not _balanced(val):
845
+ return m.group(0)
846
+ semantic = var_map.get(var_name)
847
+ if semantic:
848
+ key, intent_expr, sel_expr = semantic
849
+ changes.append(f"{var_name}.fill → {h}.fillWithHealing (key={key})")
850
+ return f"await {h}.fillWithHealing('{key}', {sel_expr}, {val}, {intent_expr})"
851
+ return m.group(0)
852
+
853
+ # ── Balanced-paren rewriters ─────────────────────────────────────────────
854
+ # The action regexes below match only up to the `(` — the actual argument
855
+ # list is extracted via _extract_call_args which honours nested parens,
856
+ # strings, and template literals (so `fill(X, String(visitorCount))` is
857
+ # captured correctly). Each helper inspects the args and returns either
858
+ # the rewritten call or None to leave the original intact.
859
+
860
+ def _build_click_rewriter(label: str):
861
+ def rewrite(_prefix: str, args: list[str]) -> str | None:
862
+ if len(args) != 1:
863
+ return None
864
+ sel = args[0].strip()
865
+ if not sel:
866
+ return None
867
+ semantic = _resolve_semantic(sel, var_map, object_keys, alias_map, inline_field_map)
868
+ if semantic:
869
+ key, intent_expr, sel_expr = semantic
870
+ changes.append(f"{label} → {h}.clickWithHealing (key={key})")
871
+ return f"await {h}.clickWithHealing('{key}', {sel_expr}, {intent_expr})"
872
+ key = _next_dyn_key()
873
+ changes.append(f"{label} → {h}.clickWithHealing (dyn key={key})")
874
+ return f"await {h}.clickWithHealing('{key}', {sel}, {_intent_for_dyn(sel)})"
875
+ return rewrite
876
+
877
+ def _build_fill_rewriter(label: str):
878
+ def rewrite(_prefix: str, args: list[str]) -> str | None:
879
+ if len(args) != 2:
880
+ return None
881
+ sel, val = args[0].strip(), args[1].strip()
882
+ if not sel or not val:
883
+ return None
884
+ semantic = _resolve_semantic(sel, var_map, object_keys, alias_map, inline_field_map)
885
+ if semantic:
886
+ key, intent_expr, sel_expr = semantic
887
+ changes.append(f"{label} → {h}.fillWithHealing (key={key})")
888
+ return f"await {h}.fillWithHealing('{key}', {sel_expr}, {val}, {intent_expr})"
889
+ key = _next_dyn_key()
890
+ changes.append(f"{label} → {h}.fillWithHealing (dyn key={key})")
891
+ return f"await {h}.fillWithHealing('{key}', {sel}, {val}, {_intent_for_dyn(sel)})"
892
+ return rewrite
893
+
894
+ # Prefix patterns end at `(` so the scanner takes over from there. Order
895
+ # matters: longer / more-specific patterns are scanned first so a chained
896
+ # `.locator(X).click()` is consumed before the bare `page.click(...)`
897
+ # rule would even see it.
898
+ _PRE_LOC_CHAIN_FILL = re.compile(
899
+ rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*locator\([^)]*?\)\s*\.\s*fill\("
900
+ )
901
+ _PRE_LOC_CHAIN_CLICK = re.compile(
902
+ rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*locator\([^)]*?\)\s*\.\s*click\("
903
+ )
904
+ _PRE_WRAPPER_FILL = re.compile(
905
+ rf"{_AWAIT_OPT}(?:this\s*\.\s*)?base\s*\.\s*waitAndFill(?:ByTestId)?\("
906
+ )
907
+ _PRE_WRAPPER_CLICK = re.compile(
908
+ rf"{_AWAIT_OPT}(?:this\s*\.\s*)?base\s*\.\s*waitAndClick(?:ByTestId)?\("
909
+ )
910
+ _PRE_FILL = re.compile(rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*fill\(")
911
+ _PRE_CLICK = re.compile(rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*click\(")
912
+
913
+ # Chained-locator click/fill: the selector is the first arg of
914
+ # `locator(SEL)`, NOT an arg of `click()` / `fill()`. We need a
915
+ # bespoke rewriter that hops back to the inner locator() args.
916
+ def _build_loc_chain_rewriter(action: str, label: str):
917
+ def rewrite(prefix_text: str, args: list[str]) -> str | None:
918
+ # `args` here are the args of click(...) / fill(...). The
919
+ # selector lives in the preceding `locator(...)` call.
920
+ loc_match = re.search(rf"locator\(([^)]*?)\)\s*\.\s*{action}\(", prefix_text)
921
+ if not loc_match:
922
+ return None
923
+ sel = loc_match.group(1).strip()
924
+ if not sel:
925
+ return None
926
+ if action == "click":
927
+ if args and any(a for a in args):
928
+ return None # click() must be no-arg
929
+ semantic = _resolve_semantic(sel, var_map, object_keys, alias_map, inline_field_map)
930
+ if semantic:
931
+ key, intent_expr, sel_expr = semantic
932
+ changes.append(f"{label} → {h}.clickWithHealing (key={key})")
933
+ return f"await {h}.clickWithHealing('{key}', {sel_expr}, {intent_expr})"
934
+ key = _next_dyn_key()
935
+ changes.append(f"{label} → {h}.clickWithHealing (dyn key={key})")
936
+ return f"await {h}.clickWithHealing('{key}', {sel}, {_intent_for_dyn(sel)})"
937
+ if action == "fill":
938
+ if len(args) != 1:
939
+ return None
940
+ val = args[0].strip()
941
+ if not val:
942
+ return None
943
+ semantic = _resolve_semantic(sel, var_map, object_keys, alias_map, inline_field_map)
944
+ if semantic:
945
+ key, intent_expr, sel_expr = semantic
946
+ changes.append(f"{label} → {h}.fillWithHealing (key={key})")
947
+ return f"await {h}.fillWithHealing('{key}', {sel_expr}, {val}, {intent_expr})"
948
+ key = _next_dyn_key()
949
+ changes.append(f"{label} → {h}.fillWithHealing (dyn key={key})")
950
+ return f"await {h}.fillWithHealing('{key}', {sel}, {val}, {_intent_for_dyn(sel)})"
951
+ return None
952
+ return rewrite
953
+
954
+ content = _scan_and_rewrite(content, _PRE_LOC_CHAIN_FILL, _build_loc_chain_rewriter("fill", "locator(...).fill"))
955
+ content = _scan_and_rewrite(content, _PRE_LOC_CHAIN_CLICK, _build_loc_chain_rewriter("click", "locator(...).click"))
956
+ content = _scan_and_rewrite(content, _PRE_WRAPPER_FILL, _build_fill_rewriter("base.waitAndFill*"))
957
+ content = _scan_and_rewrite(content, _PRE_WRAPPER_CLICK, _build_click_rewriter("base.waitAndClick*"))
958
+ content = _scan_and_rewrite(content, _PRE_FILL, _build_fill_rewriter("page.fill"))
959
+ content = _scan_and_rewrite(content, _PRE_CLICK, _build_click_rewriter("page.click"))
960
+
961
+ # Chained-on-variable patterns are still simple enough for regex; they
962
+ # don't take user-supplied args with nested parens.
963
+ content = _CHAINED_CLICK_RE.sub(_chained_click, content)
964
+ content = _CHAINED_FILL_RE.sub(_chained_fill, content)
965
+ # check/uncheck/selectOption: no healing wrapper in scaffold — leave as raw Playwright
966
+ content = _GOTO_RE.sub(_goto, content)
967
+ content = _EXPECT_VISIBLE_RE.sub(_expect_visible, content)
968
+ content = _inject_selector_accesses(content, changes, object_keys, alias_map, inline_field_map)
969
+ return content
970
+
971
+
972
+ # ---------------------------------------------------------------------------
973
+ # Selector-access injection — post-pass that appends `.selector` to bare
974
+ # locator references (`xxxLocators.foo`, aliased `this.Elements.foo`, etc.)
975
+ # that the healer-rewrite passes above didn't already cover.
976
+ #
977
+ # Why this exists: `locator_registrar.transform_locator_file` turns plain
978
+ # string locator values into `{ selector, intent, stability }` objects so the
979
+ # LocatorHealer has metadata to work with. Call sites that still take a raw
980
+ # selector (`page.locator(...)`, `page.selectOption(...)`, custom wrappers like
981
+ # `base.waitAndClick(...)`) compile against `string`, so without an explicit
982
+ # `.selector` access they would receive the object and fail with TS2345.
983
+ #
984
+ # We only wrap keys known to be object-valued (`object_keys` map), and we skip
985
+ # accesses that are already followed by `.selector`/`.intent`/`.stability` or
986
+ # a function call (`(`). This keeps the transform idempotent and side-effect
987
+ # free on already-fixed code.
988
+ # ---------------------------------------------------------------------------
989
+
990
+ # `<obj>.<key>` not followed by another `.something` or `(`. Used for both the
991
+ # direct `xxxLocators.foo` form and aliased `this.Elements.foo` form (built
992
+ # below at runtime from detected alias assignments).
993
+ _BARE_LOCATOR_ACCESS_RE = re.compile(
994
+ r"\b(\w+Locators)\.(\w+)\b"
995
+ r"(?!\s*\.\s*\w)" # not followed by another property access
996
+ r"(?!\s*\()" # not followed by a function call
997
+ )
998
+
999
+ # Alias detection — locator object stored under another name.
1000
+ # `const E = fooLocators;`
1001
+ # `let E = fooLocators;`
1002
+ # `private Elements = fooLocators;` (with optional access modifiers / readonly)
1003
+ # `private readonly Elements: <type> = fooLocators;`
1004
+ _ALIAS_VAR_RE = re.compile(
1005
+ r"\b(?:const|let|var)\s+(\w+)(?:\s*:\s*[^=;]+)?\s*=\s*(\w+Locators)\s*;"
1006
+ )
1007
+ _ALIAS_FIELD_RE = re.compile(
1008
+ r"\b(?:(?:private|public|protected|readonly|static)\s+)+(\w+)"
1009
+ r"(?:\s*:\s*[^=;]+)?\s*=\s*(\w+Locators)\s*;"
1010
+ )
1011
+
1012
+ # Inline-object class field that re-exports selectors from existing locator
1013
+ # objects, e.g.:
1014
+ #
1015
+ # private MassInviteElements = {
1016
+ # selectAllCheckbox: membersListPageLocators.selectAllCheckbox.selector,
1017
+ # memberCheckbox: membersListPageLocators.memberCheckbox.selector,
1018
+ # ...
1019
+ # };
1020
+ #
1021
+ # Each value is a `.selector` reference into a registered xxxLocators object,
1022
+ # so callers like `this.MassInviteElements.selectAllCheckbox` are runtime
1023
+ # strings. We extract these inline fields so the healer rewriter can route
1024
+ # them through `_healer.<...>WithHealing` using the original locator's
1025
+ # semantic info (key, intent, selector).
1026
+ _INLINE_FIELD_OPEN_RE = re.compile(
1027
+ r"\b(?:(?:private|public|protected|readonly|static)\s+)+(\w+)"
1028
+ r"(?:\s*:\s*[^={};]+)?\s*=\s*\{",
1029
+ )
1030
+ _INLINE_FIELD_ENTRY_RE = re.compile(
1031
+ r"^\s*(\w+)\s*:\s*(\w+Locators)\.(\w+)\.selector\s*,?\s*$",
1032
+ re.MULTILINE,
1033
+ )
1034
+
1035
+
1036
+ def _collect_alias_map(content: str) -> dict[str, str]:
1037
+ """
1038
+ Return aliases: `{ alias_access → locator_obj_name }`.
1039
+
1040
+ For local vars (`const E = fooLocators;`), the alias access is the bare
1041
+ name (`E`). For class fields (`private Elements = fooLocators;`), the
1042
+ alias access is `this.<name>` (e.g. `this.Elements`).
1043
+ """
1044
+ aliases: dict[str, str] = {}
1045
+ for m in _ALIAS_VAR_RE.finditer(content):
1046
+ aliases[m.group(1)] = m.group(2)
1047
+ for m in _ALIAS_FIELD_RE.finditer(content):
1048
+ aliases[f"this.{m.group(1)}"] = m.group(2)
1049
+ return aliases
1050
+
1051
+
1052
+ def _collect_inline_field_map(content: str) -> dict[tuple[str, str], tuple[str, str]]:
1053
+ """
1054
+ Return `{ (alias_access, inline_key) → (locator_obj_name, original_key) }`
1055
+ for inline class-field locator dictionaries that re-export
1056
+ `<xxxLocators>.<key>.selector` entries.
1057
+
1058
+ Allows the healer rewriter to treat
1059
+ `this.MassInviteElements.selectAllCheckbox`
1060
+ as the equivalent of
1061
+ `membersListPageLocators.selectAllCheckbox`
1062
+ when generating `_healer.clickWithHealing(...)` calls.
1063
+ """
1064
+ mapping: dict[tuple[str, str], tuple[str, str]] = {}
1065
+ for m in _INLINE_FIELD_OPEN_RE.finditer(content):
1066
+ field_name = m.group(1)
1067
+ brace_idx = content.find("{", m.start())
1068
+ if brace_idx < 0:
1069
+ continue
1070
+ # Find matching `}` with depth counting so nested object literals
1071
+ # inside the field don't confuse us.
1072
+ depth = 1
1073
+ i = brace_idx + 1
1074
+ while i < len(content) and depth > 0:
1075
+ c = content[i]
1076
+ if c == "{":
1077
+ depth += 1
1078
+ elif c == "}":
1079
+ depth -= 1
1080
+ i += 1
1081
+ body = content[brace_idx + 1 : i - 1]
1082
+ alias_access = f"this.{field_name}"
1083
+ # Each well-formed entry: `key: xxxLocators.prop.selector,`
1084
+ for em in _INLINE_FIELD_ENTRY_RE.finditer(body):
1085
+ inline_key = em.group(1)
1086
+ loc_name = em.group(2)
1087
+ orig_key = em.group(3)
1088
+ mapping[(alias_access, inline_key)] = (loc_name, orig_key)
1089
+ return mapping
1090
+
1091
+
1092
+ def _inject_selector_accesses(
1093
+ content: str,
1094
+ changes: list[str],
1095
+ object_keys: dict[str, set[str]] | None,
1096
+ alias_map: dict[str, str] | None = None,
1097
+ inline_field_map: dict[tuple[str, str], tuple[str, str]] | None = None,
1098
+ ) -> str:
1099
+ if not object_keys:
1100
+ return content
1101
+
1102
+ n_changes = 0
1103
+
1104
+ # Pass 1 — direct `xxxLocators.key` references.
1105
+ def _wrap_direct(m: re.Match) -> str:
1106
+ nonlocal n_changes
1107
+ obj, key = m.group(1), m.group(2)
1108
+ if key not in object_keys.get(obj, set()):
1109
+ return m.group(0)
1110
+ n_changes += 1
1111
+ return f"{obj}.{key}.selector"
1112
+
1113
+ content = _BARE_LOCATOR_ACCESS_RE.sub(_wrap_direct, content)
1114
+
1115
+ # Pass 2 — aliased references (`this.Elements.key`, `E.key`). Re-detect
1116
+ # if no map was supplied (back-compat); callers in _replace_actions
1117
+ # pass the already-collected map to avoid double work.
1118
+ aliases = alias_map if alias_map is not None else _collect_alias_map(content)
1119
+ for alias, locator_obj in aliases.items():
1120
+ keys = object_keys.get(locator_obj)
1121
+ if not keys:
1122
+ continue
1123
+ # Build a regex matching `<alias>.<key>` for the keys in this object.
1124
+ # `alias` may contain `.` (e.g. `this.Elements`); escape it.
1125
+ alias_re = re.compile(
1126
+ rf"(?<!\.\.\.){re.escape(alias)}\.(\w+)\b"
1127
+ rf"(?!\s*\.\s*\w)"
1128
+ rf"(?!\s*\()"
1129
+ )
1130
+
1131
+ def _wrap_alias(m: re.Match) -> str:
1132
+ nonlocal n_changes
1133
+ key = m.group(1)
1134
+ if key not in keys:
1135
+ return m.group(0)
1136
+ n_changes += 1
1137
+ return f"{alias}.{key}.selector"
1138
+
1139
+ content = alias_re.sub(_wrap_alias, content)
1140
+
1141
+ if n_changes:
1142
+ changes.append(f"Appended .selector to {n_changes} locator references")
1143
+ return content