@qa-gentic/stlc-agents 1.0.26 → 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,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
|