@qa-gentic/stlc-agents 1.0.27 → 1.0.29
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/bin/postinstall.js +14 -4
- package/package.json +19 -7
- package/skills/migrate-framework/SKILL.md +207 -0
- package/src/stlc_agents/agent_migration/__init__.py +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/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/server.py +926 -91
- package/src/stlc_agents/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/__pycache__/server.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/ado_gherkin.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/__pycache__/server.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/tools/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/tools/__pycache__/boilerplate.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/tools/__pycache__/helix_write.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/__pycache__/server.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/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,1398 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core migration pipeline — shared by both the MCP server and the CLI.
|
|
3
|
+
|
|
4
|
+
Pipeline per file:
|
|
5
|
+
1. JS → TS conversion (only when source is .js)
|
|
6
|
+
2. Locator modernisation (locators, page objects, step defs)
|
|
7
|
+
3. Healer injection (page objects and step defs)
|
|
8
|
+
4. Import path fixing (all .ts files)
|
|
9
|
+
|
|
10
|
+
Config files are handled separately via merge helpers.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
import json
|
|
14
|
+
import shutil
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from .transformer.js_to_ts import convert_js_to_ts
|
|
19
|
+
from .transformer.locator_moderniser import modernise_locators
|
|
20
|
+
from .transformer.locator_registrar import transform_locator_file, collect_object_keys, fix_selector_accesses
|
|
21
|
+
from .transformer.local_var_hoister import (
|
|
22
|
+
HoistAction,
|
|
23
|
+
hoist_inline_locators,
|
|
24
|
+
append_entries_to_locator_file,
|
|
25
|
+
)
|
|
26
|
+
from .transformer.healer_injector import inject_healers
|
|
27
|
+
from .transformer.import_fixer import fix_imports
|
|
28
|
+
from .transformer.spec_to_bdd import convert_spec_to_bdd
|
|
29
|
+
from .transformer.js_to_ts import _fix_tobe_typo, _cast_intl_format_options # type: ignore
|
|
30
|
+
from .transformer.config_merger import (
|
|
31
|
+
merge_playwright_config,
|
|
32
|
+
merge_cucumber_config,
|
|
33
|
+
merge_package_json,
|
|
34
|
+
merge_tsconfig_json,
|
|
35
|
+
)
|
|
36
|
+
from .reporter import generate_report
|
|
37
|
+
|
|
38
|
+
_TRANSFORM_ROLES = {"locator", "page_object", "step_def"}
|
|
39
|
+
|
|
40
|
+
# Roles that are TypeScript/JavaScript code but don't need locator/healer transformation —
|
|
41
|
+
# they just need JS→TS conversion (if .js) and import-path rewriting.
|
|
42
|
+
_CODE_COPY_ROLES = {"helper", "hook", "fixture", "support", "api_client"}
|
|
43
|
+
|
|
44
|
+
# Roles that are copied byte-for-byte (no source transformation).
|
|
45
|
+
# "unknown" is the catch-all: any file the mapper didn't classify (README.md,
|
|
46
|
+
# LICENSE, .gitignore, vrt-baselines, arbitrary docs) lands here and is
|
|
47
|
+
# preserved at its original relative path so the migration never silently
|
|
48
|
+
# drops content from the source project.
|
|
49
|
+
_RAW_COPY_ROLES = {"docker", "env", "ci", "script", "unknown"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def run_migration(
|
|
53
|
+
source_dir: str,
|
|
54
|
+
helix_root: str,
|
|
55
|
+
file_mappings: list[dict],
|
|
56
|
+
conflict_strategy: str = "interactive",
|
|
57
|
+
framework: str = "playwright-bdd-ts",
|
|
58
|
+
integration: str = "both",
|
|
59
|
+
) -> dict:
|
|
60
|
+
"""
|
|
61
|
+
Execute the full migration for a list of file mappings.
|
|
62
|
+
|
|
63
|
+
conflict_strategy:
|
|
64
|
+
"overwrite" — always replace existing Helix files
|
|
65
|
+
"skip" — never touch existing Helix files
|
|
66
|
+
"interactive" — report conflicts in result; caller decides per file
|
|
67
|
+
|
|
68
|
+
integration:
|
|
69
|
+
"ado" — install ADO skills (qa-test-case-manager + shared)
|
|
70
|
+
"jira" — install Jira skills (qa-jira-manager + shared)
|
|
71
|
+
"both" — install everything (default — every agent reachable)
|
|
72
|
+
|
|
73
|
+
Returns {
|
|
74
|
+
written: list[dict],
|
|
75
|
+
skipped: list[dict],
|
|
76
|
+
conflicts: list[dict],
|
|
77
|
+
todo_count: int,
|
|
78
|
+
todos: list[str],
|
|
79
|
+
report_path: str | None,
|
|
80
|
+
}
|
|
81
|
+
"""
|
|
82
|
+
written: list[dict] = []
|
|
83
|
+
skipped: list[dict] = []
|
|
84
|
+
conflicts: list[dict] = []
|
|
85
|
+
todos: list[str] = []
|
|
86
|
+
root = Path(helix_root)
|
|
87
|
+
|
|
88
|
+
# Authoritative lookup so import_fixer can rewrite imports against the
|
|
89
|
+
# mapper's real output paths instead of re-predicting them (which would
|
|
90
|
+
# drift apart after disambiguation, kebab tweaks, etc.). Keyed by the
|
|
91
|
+
# resolved absolute source path; value is the helix-relative target.
|
|
92
|
+
source_to_target: dict[str, str] = {
|
|
93
|
+
str(Path(m["source"]).resolve()): m["target"]
|
|
94
|
+
for m in file_mappings
|
|
95
|
+
if m.get("target")
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
# Accumulator for HoistActions emitted while transforming page_object /
|
|
99
|
+
# step_def files. Applied to locator files in a post-pass so all hoisted
|
|
100
|
+
# entries land in their target xxxLocators block.
|
|
101
|
+
hoist_buffer: dict[str, list[HoistAction]] = {}
|
|
102
|
+
|
|
103
|
+
# Pre-pass: scan every locator-source file so we know which keys are
|
|
104
|
+
# object-valued ({selector, intent, stability}) versus string-valued
|
|
105
|
+
# (display labels, URLs). The healer must not wrap references whose key
|
|
106
|
+
# is string-valued — `.selector` would not exist on the plain string.
|
|
107
|
+
object_keys_by_name: dict[str, set[str]] = {}
|
|
108
|
+
for mp in file_mappings:
|
|
109
|
+
if mp.get("role") != "locator":
|
|
110
|
+
continue
|
|
111
|
+
try:
|
|
112
|
+
src_content = Path(mp["source"]).read_text(encoding="utf-8")
|
|
113
|
+
# modernise_locators returns (content, changes, todo_count) —
|
|
114
|
+
# only the first matters here. transform_locator_file returns
|
|
115
|
+
# (content, changes). Use `*_` to absorb any future trailing
|
|
116
|
+
# values so a signature change can't silently re-break this.
|
|
117
|
+
modernised, *_ = modernise_locators(src_content)
|
|
118
|
+
transformed, *_ = transform_locator_file(modernised)
|
|
119
|
+
for name, keys in collect_object_keys(transformed).items():
|
|
120
|
+
object_keys_by_name.setdefault(name, set()).update(keys)
|
|
121
|
+
except Exception as exc:
|
|
122
|
+
# Surface the failure: a silent `pass` here was hiding a tuple
|
|
123
|
+
# unpacking bug for who knows how long, which left every
|
|
124
|
+
# consumer's `object_keys` map empty (so the healer wrapped
|
|
125
|
+
# everything indiscriminately, including string-valued labels).
|
|
126
|
+
import sys
|
|
127
|
+
print(f"warning: object_keys pre-pass failed for {mp['relative']}: {exc}", file=sys.stderr)
|
|
128
|
+
|
|
129
|
+
for mapping in file_mappings:
|
|
130
|
+
role = mapping["role"]
|
|
131
|
+
|
|
132
|
+
if role.startswith("config_"):
|
|
133
|
+
_process_config(mapping, source_dir, helix_root, written, skipped)
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
source_path = Path(mapping["source"])
|
|
137
|
+
target_rel = mapping["target"]
|
|
138
|
+
if not target_rel:
|
|
139
|
+
skipped.append({"source": mapping["relative"], "reason": f"no target for role={role}"})
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
# Per-app `.env.<APP>` files are NOT raw-copied — their values are
|
|
143
|
+
# folded into the single `.env.example` at helix-qa root by the
|
|
144
|
+
# _write_root_env_example post-pass below. Skipping prevents stale
|
|
145
|
+
# per-app files from sticking around alongside the consolidated file.
|
|
146
|
+
if role == "env" and source_path.name.startswith(".env.") and source_path.name != ".env.example":
|
|
147
|
+
skipped.append({
|
|
148
|
+
"source": mapping["relative"],
|
|
149
|
+
"reason": "per-app .env folded into single .env.example",
|
|
150
|
+
})
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
# ── Playwright spec → feature + steps split ─────────────────────────
|
|
154
|
+
# Non-BDD Playwright spec files are converted into a Gherkin .feature
|
|
155
|
+
# file plus a Cucumber .steps.ts file. The .steps.ts goes to the
|
|
156
|
+
# target_rel computed by the mapper; the .feature is derived from it.
|
|
157
|
+
if role == "playwright_spec":
|
|
158
|
+
try:
|
|
159
|
+
content = source_path.read_text(encoding="utf-8")
|
|
160
|
+
feature_content, steps_content, changes = convert_spec_to_bdd(
|
|
161
|
+
content, Path(target_rel).stem.replace(".steps", ""),
|
|
162
|
+
)
|
|
163
|
+
if not steps_content:
|
|
164
|
+
skipped.append({
|
|
165
|
+
"source": mapping["relative"],
|
|
166
|
+
"reason": "no test() or test.describe() blocks found",
|
|
167
|
+
})
|
|
168
|
+
continue
|
|
169
|
+
# Run import_fixer on the generated step file so imports of
|
|
170
|
+
# page_objects/helpers (preserved from the source's require())
|
|
171
|
+
# get rewritten to the new Helix locations.
|
|
172
|
+
steps_content, imp_changes = fix_imports(
|
|
173
|
+
steps_content, mapping["source"], target_rel, source_dir,
|
|
174
|
+
source_to_target=source_to_target,
|
|
175
|
+
)
|
|
176
|
+
# Apply the targeted JS→TS fixes that also matter inside step
|
|
177
|
+
# bodies (typo normalisation, Intl.* option casts). The other
|
|
178
|
+
# js_to_ts passes are skipped because they're aimed at class
|
|
179
|
+
# bodies, not free-standing step callbacks.
|
|
180
|
+
steps_content, tobe_changes = _fix_tobe_typo(steps_content)
|
|
181
|
+
steps_content, intl_changes2 = _cast_intl_format_options(steps_content)
|
|
182
|
+
changes.extend(tobe_changes)
|
|
183
|
+
changes.extend(intl_changes2)
|
|
184
|
+
# Steps file
|
|
185
|
+
steps_path = root / target_rel
|
|
186
|
+
steps_path.parent.mkdir(parents=True, exist_ok=True)
|
|
187
|
+
steps_path.write_text(steps_content, encoding="utf-8")
|
|
188
|
+
# Feature file — derive from steps target.
|
|
189
|
+
feature_rel = target_rel.replace("src/test/steps/", "src/test/features/", 1)
|
|
190
|
+
feature_rel = feature_rel.replace(".steps.ts", ".feature")
|
|
191
|
+
feature_path = root / feature_rel
|
|
192
|
+
feature_path.parent.mkdir(parents=True, exist_ok=True)
|
|
193
|
+
feature_path.write_text(feature_content, encoding="utf-8")
|
|
194
|
+
written.append({
|
|
195
|
+
"source": mapping["relative"],
|
|
196
|
+
"target": target_rel,
|
|
197
|
+
"role": "step_def",
|
|
198
|
+
"change_count": len(changes) + len(imp_changes),
|
|
199
|
+
})
|
|
200
|
+
written.append({
|
|
201
|
+
"source": mapping["relative"],
|
|
202
|
+
"target": feature_rel,
|
|
203
|
+
"role": "feature",
|
|
204
|
+
"change_count": len(changes),
|
|
205
|
+
})
|
|
206
|
+
except Exception as exc:
|
|
207
|
+
skipped.append({
|
|
208
|
+
"source": mapping["relative"],
|
|
209
|
+
"reason": f"spec→BDD conversion error: {exc}",
|
|
210
|
+
})
|
|
211
|
+
continue
|
|
212
|
+
|
|
213
|
+
# ── Raw-copy roles: Dockerfile, .env, CI yaml, shell scripts ────────
|
|
214
|
+
# Also raw-copy JSON data files under helper/ (test data, fixtures).
|
|
215
|
+
is_raw_copy = role in _RAW_COPY_ROLES or (
|
|
216
|
+
role in _CODE_COPY_ROLES and source_path.suffix == ".json"
|
|
217
|
+
)
|
|
218
|
+
if is_raw_copy:
|
|
219
|
+
try:
|
|
220
|
+
_copy_raw(source_path, root / target_rel)
|
|
221
|
+
written.append({
|
|
222
|
+
"source": mapping["relative"],
|
|
223
|
+
"target": target_rel,
|
|
224
|
+
"role": role,
|
|
225
|
+
"change_count": 0,
|
|
226
|
+
})
|
|
227
|
+
except Exception as exc:
|
|
228
|
+
skipped.append({"source": mapping["relative"], "reason": f"raw-copy error: {exc}"})
|
|
229
|
+
continue
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
content = source_path.read_text(encoding="utf-8")
|
|
233
|
+
except Exception as exc:
|
|
234
|
+
skipped.append({"source": mapping["relative"], "reason": f"read error: {exc}"})
|
|
235
|
+
continue
|
|
236
|
+
|
|
237
|
+
# ── Transformation pipeline ─────────────────────────────────────
|
|
238
|
+
change_count = 0
|
|
239
|
+
file_todos: list[str] = []
|
|
240
|
+
|
|
241
|
+
# 1. JS → TS
|
|
242
|
+
if mapping.get("needs_js_to_ts"):
|
|
243
|
+
content, js_changes = convert_js_to_ts(content)
|
|
244
|
+
change_count += len(js_changes)
|
|
245
|
+
|
|
246
|
+
# 2. Locator modernisation
|
|
247
|
+
if role in _TRANSFORM_ROLES:
|
|
248
|
+
content, loc_changes, loc_todos = modernise_locators(content)
|
|
249
|
+
change_count += len(loc_changes)
|
|
250
|
+
for _ in range(loc_todos):
|
|
251
|
+
file_todos.append(
|
|
252
|
+
f"`{target_rel}` — locator needs manual review (complex CSS/XPath kept as-is)"
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
# 2b. Locator registration — convert plain string values to { selector, intent, stability }
|
|
256
|
+
if role == "locator":
|
|
257
|
+
content, reg_changes = transform_locator_file(content)
|
|
258
|
+
change_count += len(reg_changes)
|
|
259
|
+
|
|
260
|
+
# 2c. Hoist inline-string locators from local variables into the
|
|
261
|
+
# centralised xxxLocators. Buffered; applied to locator files in a
|
|
262
|
+
# post-pass. Must run BEFORE healer injection so the rewritten RHS
|
|
263
|
+
# (xxxLocators.foo.selector) is visible to healer_injector's var_map.
|
|
264
|
+
if role in ("page_object", "step_def"):
|
|
265
|
+
content, hoist_actions, hoist_changes = hoist_inline_locators(content)
|
|
266
|
+
change_count += len(hoist_changes)
|
|
267
|
+
for action in hoist_actions:
|
|
268
|
+
hoist_buffer.setdefault(action.locator_object, []).append(action)
|
|
269
|
+
# Hoisted entries are by construction object-valued, so register
|
|
270
|
+
# them in object_keys_by_name immediately. Without this, the
|
|
271
|
+
# healer call below would not recognise them as wrappable.
|
|
272
|
+
object_keys_by_name.setdefault(action.locator_object, set()).add(action.entry_name)
|
|
273
|
+
|
|
274
|
+
# 3. Healer injection
|
|
275
|
+
if role in ("page_object", "step_def"):
|
|
276
|
+
content, heal_changes, heal_todos = inject_healers(
|
|
277
|
+
content, role, object_keys=object_keys_by_name,
|
|
278
|
+
)
|
|
279
|
+
change_count += len(heal_changes)
|
|
280
|
+
if heal_todos:
|
|
281
|
+
file_todos.append(
|
|
282
|
+
f"`{target_rel}` — healer init needed (see inline TODO comment)"
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# 4. Import path fixing (applies to all code roles, including helper/hook/fixture/support)
|
|
286
|
+
content, imp_changes = fix_imports(
|
|
287
|
+
content, mapping["source"], target_rel, source_dir,
|
|
288
|
+
source_to_target=source_to_target,
|
|
289
|
+
)
|
|
290
|
+
change_count += len(imp_changes)
|
|
291
|
+
|
|
292
|
+
todos.extend(file_todos)
|
|
293
|
+
|
|
294
|
+
# ── Conflict check ──────────────────────────────────────────────
|
|
295
|
+
target_path = root / target_rel
|
|
296
|
+
|
|
297
|
+
if target_path.exists():
|
|
298
|
+
if conflict_strategy == "skip":
|
|
299
|
+
skipped.append({
|
|
300
|
+
"source": mapping["relative"],
|
|
301
|
+
"reason": "target already exists (skipped per strategy)",
|
|
302
|
+
})
|
|
303
|
+
continue
|
|
304
|
+
elif conflict_strategy == "overwrite":
|
|
305
|
+
conflicts.append({"path": target_rel, "resolution": "overwritten"})
|
|
306
|
+
else: # interactive
|
|
307
|
+
conflicts.append({
|
|
308
|
+
"path": target_rel,
|
|
309
|
+
"source": mapping["relative"],
|
|
310
|
+
"resolution": "pending — awaiting user decision",
|
|
311
|
+
})
|
|
312
|
+
# In interactive mode we still write; MCP callers can re-invoke
|
|
313
|
+
# with conflict_strategy="skip" for specific files if desired.
|
|
314
|
+
|
|
315
|
+
# ── Write ──────────────────────────────────────────────────────
|
|
316
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
317
|
+
target_path.write_text(content, encoding="utf-8")
|
|
318
|
+
|
|
319
|
+
written.append({
|
|
320
|
+
"source": mapping["relative"],
|
|
321
|
+
"target": target_rel,
|
|
322
|
+
"role": role,
|
|
323
|
+
"change_count": change_count,
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
# ── Post-pass: ensure tsconfig.json exists ──────────────────────────────
|
|
327
|
+
# JS source projects (e.g. playwright-js) don't ship a tsconfig.json, but
|
|
328
|
+
# the migrated tree is always TypeScript — synthesise one so the user can
|
|
329
|
+
# `tsc` / `cucumber-js` against the migrated code without manual setup.
|
|
330
|
+
_ensure_tsconfig(root)
|
|
331
|
+
|
|
332
|
+
# ── Post-pass: flush buffered hoist actions into the centralised locator files ──
|
|
333
|
+
# During the per-file loop we rewrote local-var RHS to reference xxxLocators
|
|
334
|
+
# entries that don't exist yet. This post-pass appends those entries to the
|
|
335
|
+
# right xxxLocators block in the right .locators.ts file on disk.
|
|
336
|
+
_flush_hoist_buffer(root, hoist_buffer)
|
|
337
|
+
|
|
338
|
+
# ── Post-pass: fix locator .selector accesses in page objects / step defs ──
|
|
339
|
+
# Locator files are already written with { selector, intent, stability } format.
|
|
340
|
+
# Now read them all to collect which keys are objects, then update page objects.
|
|
341
|
+
_fix_selector_accesses_in_tree(root, written)
|
|
342
|
+
|
|
343
|
+
# ── Post-pass: emit single `.env.example` at helix-qa root ───────────────
|
|
344
|
+
# Folds any source per-app `.env.<APP>` files into KEY_<APP>=… entries so
|
|
345
|
+
# the framework runs from one consolidated env file.
|
|
346
|
+
_write_root_env_example(root, written, source_dir, conflict_strategy)
|
|
347
|
+
|
|
348
|
+
# ── Post-pass: install the single-source env loader ──────────────────────
|
|
349
|
+
# Replaces the migrated `environment.util.ts` with one that reads root .env
|
|
350
|
+
# and resolves APP-suffixed vars (DOMAIN_<APP> → DOMAIN) at runtime.
|
|
351
|
+
_patch_environment_util(root)
|
|
352
|
+
|
|
353
|
+
# ── Post-pass: scaffold the self-healing locator infrastructure ─────────
|
|
354
|
+
# All migrated page objects / step defs import from @utils/locators/* — they
|
|
355
|
+
# need LocatorHealer, LocatorRepository, TimingHealer, VisualIntentChecker
|
|
356
|
+
# and HealingDashboard to be present in the target tree. The scaffold
|
|
357
|
+
# honors the same conflict strategy as the main migration loop so users
|
|
358
|
+
# who pass --conflict overwrite get a fresh scaffold that picks up
|
|
359
|
+
# tool-side enhancements (new env-var wiring, new strategies, etc.).
|
|
360
|
+
_scaffold_locators(root, written, conflict_strategy)
|
|
361
|
+
|
|
362
|
+
# ── Scaffold pageFixture.ts when missing ────────────────────────────────
|
|
363
|
+
# Spec→BDD-generated step files import `fixture` from `@hooks/pageFixture`.
|
|
364
|
+
# When the source didn't carry its own hook (typical for non-BDD Playwright
|
|
365
|
+
# projects), drop in a minimal fixture so the generated steps resolve.
|
|
366
|
+
_scaffold_page_fixture(root)
|
|
367
|
+
|
|
368
|
+
# ── Scaffold globals.d.ts for browser-context identifiers ───────────────
|
|
369
|
+
# page.evaluate() callbacks run in the browser and may reference functions
|
|
370
|
+
# defined by the SUT (e.g. `toggleGridFiltersMenu()`). TypeScript can't
|
|
371
|
+
# see those — emit `declare const` shims so tsc doesn't flag them.
|
|
372
|
+
_scaffold_browser_globals(root)
|
|
373
|
+
|
|
374
|
+
# ── Fill in the full Helix-QA boilerplate ──────────────────────────────
|
|
375
|
+
# The qa-helix-writer / qa-playwright-generator agents expect a fully
|
|
376
|
+
# populated Helix-QA tree (base page, base locators, AI assistant
|
|
377
|
+
# helpers, retry/wait/logger utils, auth manager, templates, ...). The
|
|
378
|
+
# tool's own scaffold only emits the 6 core locator-infra files; the
|
|
379
|
+
# remaining ~25 files come from agent_helix_writer.boilerplate.BOILERPLATE,
|
|
380
|
+
# which is the authoritative source the agents themselves use when they
|
|
381
|
+
# bootstrap a fresh project. Fill in any file that the migration hasn't
|
|
382
|
+
# already produced (and never clobber the enhanced scaffold variants).
|
|
383
|
+
_fill_helix_boilerplate(root, written, conflict_strategy)
|
|
384
|
+
|
|
385
|
+
# ── Install agent skills + MCP config + governance docs ─────────────────
|
|
386
|
+
# The migration's whole point is to produce a tree the QA STLC agents
|
|
387
|
+
# (qa-test-case-manager, qa-gherkin-generator, qa-playwright-generator,
|
|
388
|
+
# qa-helix-writer, qa-jira-manager) can operate against. Drop in:
|
|
389
|
+
# • .claude/skills/<name>/SKILL.md — instructions consumed by Claude Code
|
|
390
|
+
# • .mcp.json — MCP server registry (auto-discovered)
|
|
391
|
+
# • AGENT-BEHAVIOR.md / ORCHESTRATION_RULES.md — governance documents
|
|
392
|
+
# Honors the same conflict strategy so re-migrations refresh stale files.
|
|
393
|
+
_install_agent_assets(root, written, integration, conflict_strategy)
|
|
394
|
+
|
|
395
|
+
# ── Report ─────────────────────────────────────────────────────────────
|
|
396
|
+
report_path: str | None = None
|
|
397
|
+
try:
|
|
398
|
+
report_path = generate_report(
|
|
399
|
+
helix_root=helix_root,
|
|
400
|
+
source_dir=source_dir,
|
|
401
|
+
framework=framework,
|
|
402
|
+
written=written,
|
|
403
|
+
skipped=skipped,
|
|
404
|
+
conflicts=conflicts,
|
|
405
|
+
todos=todos,
|
|
406
|
+
)
|
|
407
|
+
except Exception:
|
|
408
|
+
pass
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
"written": written,
|
|
412
|
+
"skipped": skipped,
|
|
413
|
+
"conflicts": conflicts,
|
|
414
|
+
"todo_count": len(todos),
|
|
415
|
+
"todos": todos,
|
|
416
|
+
"report_path": report_path,
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
# ---------------------------------------------------------------------------
|
|
421
|
+
# Config helpers
|
|
422
|
+
# ---------------------------------------------------------------------------
|
|
423
|
+
|
|
424
|
+
def _process_config(
|
|
425
|
+
mapping: dict,
|
|
426
|
+
source_dir: str,
|
|
427
|
+
helix_root: str,
|
|
428
|
+
written: list[dict],
|
|
429
|
+
skipped: list[dict],
|
|
430
|
+
) -> None:
|
|
431
|
+
role = mapping["role"]
|
|
432
|
+
source_path = Path(mapping["source"])
|
|
433
|
+
|
|
434
|
+
try:
|
|
435
|
+
content = source_path.read_text(encoding="utf-8")
|
|
436
|
+
except Exception as exc:
|
|
437
|
+
skipped.append({"source": mapping["relative"], "reason": f"read error: {exc}"})
|
|
438
|
+
return
|
|
439
|
+
|
|
440
|
+
try:
|
|
441
|
+
if role == "config_playwright":
|
|
442
|
+
# The migrated tree is always cucumber-js BDD (runner = cucumber-js,
|
|
443
|
+
# not @playwright/test). The Playwright Test runner config is dead
|
|
444
|
+
# weight and confuses tsc, so skip emitting it.
|
|
445
|
+
skipped.append({
|
|
446
|
+
"source": mapping["relative"],
|
|
447
|
+
"reason": "playwright.config not needed in cucumber-js BDD output",
|
|
448
|
+
})
|
|
449
|
+
return
|
|
450
|
+
elif role == "config_cucumber":
|
|
451
|
+
result = merge_cucumber_config(content, helix_root)
|
|
452
|
+
elif role == "config_package":
|
|
453
|
+
result = merge_package_json(content, helix_root)
|
|
454
|
+
elif role == "config_tsconfig":
|
|
455
|
+
result = merge_tsconfig_json(content, helix_root)
|
|
456
|
+
else:
|
|
457
|
+
skipped.append({"source": mapping["relative"], "reason": f"config type '{role}' not handled"})
|
|
458
|
+
return
|
|
459
|
+
|
|
460
|
+
target = Path(helix_root) / result["target"]
|
|
461
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
462
|
+
target.write_text(result["content"], encoding="utf-8")
|
|
463
|
+
|
|
464
|
+
written.append({
|
|
465
|
+
"source": mapping["relative"],
|
|
466
|
+
"target": result["target"],
|
|
467
|
+
"role": role,
|
|
468
|
+
"change_count": len(result.get("changes", [])),
|
|
469
|
+
})
|
|
470
|
+
except Exception as exc:
|
|
471
|
+
skipped.append({"source": mapping["relative"], "reason": f"config merge error: {exc}"})
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
# ---------------------------------------------------------------------------
|
|
475
|
+
# Raw-copy helper (Docker, .env, CI yaml, scripts)
|
|
476
|
+
# ---------------------------------------------------------------------------
|
|
477
|
+
|
|
478
|
+
def _copy_raw(source: Path, target: Path) -> None:
|
|
479
|
+
"""Byte-for-byte copy preserving file mode (e.g. executable bit for .sh)."""
|
|
480
|
+
import shutil
|
|
481
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
482
|
+
shutil.copy2(source, target)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
# ---------------------------------------------------------------------------
|
|
486
|
+
# Project-root .env.example template
|
|
487
|
+
# ---------------------------------------------------------------------------
|
|
488
|
+
|
|
489
|
+
_ENV_EXAMPLE_HEADER = """\
|
|
490
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
491
|
+
# Helix-QA — runtime configuration (single source of truth)
|
|
492
|
+
#
|
|
493
|
+
# Copy this file to `.env` and fill in the values you need.
|
|
494
|
+
# Variables prefixed with the active APP (e.g. DOMAIN_AMPLIFY) are resolved to
|
|
495
|
+
# their canonical names (DOMAIN) automatically by environment.util.ts based on
|
|
496
|
+
# the APP env var supplied by the npm script.
|
|
497
|
+
# Lines starting with # are comments — do NOT put inline comments after values.
|
|
498
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
499
|
+
"""
|
|
500
|
+
|
|
501
|
+
_ENV_EXAMPLE_TAIL = """\
|
|
502
|
+
|
|
503
|
+
# ── Azure Key Vault (test data + secret resolution) ──────────────────────────
|
|
504
|
+
# Required by `currentFixture.azureKeyVault.getSecrets(...)` calls in helpers.
|
|
505
|
+
# In CI, these are typically set by the npm scripts via cross-env. Locally:
|
|
506
|
+
# AZURE_TENANT_ID=
|
|
507
|
+
# AZURE_CLIENT_ID=
|
|
508
|
+
# AZURE_CLIENT_SECRET=
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
# ── Self-healing AI vision (strategy 6 of LocatorHealer) ─────────────────────
|
|
512
|
+
#
|
|
513
|
+
# ENABLE_AI_HEALING
|
|
514
|
+
# true (default) — strategy 6 is active; calls the provider below on failure
|
|
515
|
+
# false — strategy 6 is skipped entirely; strategies 7 & 8
|
|
516
|
+
# (CDPSession AX tree + bounding box) still run
|
|
517
|
+
#
|
|
518
|
+
# AI_HEALING_PROVIDER
|
|
519
|
+
# anthropic (default) — direct Anthropic API; requires AI_HEALING_API_KEY
|
|
520
|
+
# copilot — GitHub Copilot chat completions; requires GITHUB_TOKEN
|
|
521
|
+
# claude-code — Claude Code local proxy; requires ANTHROPIC_API_KEY
|
|
522
|
+
ENABLE_AI_HEALING=true
|
|
523
|
+
AI_HEALING_PROVIDER=anthropic
|
|
524
|
+
|
|
525
|
+
# Required when AI_HEALING_PROVIDER=anthropic
|
|
526
|
+
# AI_HEALING_API_KEY=sk-ant-...
|
|
527
|
+
|
|
528
|
+
# Required when AI_HEALING_PROVIDER=copilot
|
|
529
|
+
# GITHUB_TOKEN=ghp_...
|
|
530
|
+
|
|
531
|
+
# Required when AI_HEALING_PROVIDER=claude-code
|
|
532
|
+
# (also set automatically inside a `claude` CLI session — no manual key needed)
|
|
533
|
+
# ANTHROPIC_API_KEY=sk-ant-...
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
# ── Healing Dashboard ────────────────────────────────────────────────────────
|
|
537
|
+
# Set HEALING_DASHBOARD_PORT=0 to disable the HTTP dashboard in CI pipelines.
|
|
538
|
+
# HEALIX_REVIEW_PORT, when set, starts a second READ-ONLY HTTP server that
|
|
539
|
+
# mirrors the dashboard's GET endpoints but rejects POST. Use it for QA leads
|
|
540
|
+
# / managers / external dashboards that need to inspect heals without write
|
|
541
|
+
# access. Off by default (0 = disabled).
|
|
542
|
+
# HEALING_DASHBOARD_PORT=7890
|
|
543
|
+
# HEALIX_REVIEW_PORT=7891
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
# ── LocatorRepository (heal store) ───────────────────────────────────────────
|
|
547
|
+
# HEAL_STORE_PATH Path to the heal-store JSON. The LocatorRepository
|
|
548
|
+
# reads it on startup and writes (debounced) on every
|
|
549
|
+
# successful heal. Default: ./self-heals/healed-locators.json
|
|
550
|
+
# HEAL_LOG_PATH One-line-per-heal audit log (tab-separated:
|
|
551
|
+
# timestamp / key / strategy / selector). Defaults to
|
|
552
|
+
# ./self-heals/heal.log next to the heal store.
|
|
553
|
+
# HEAL_LOG_DISABLED Set to "true" to skip the audit log entirely.
|
|
554
|
+
# ENV / HELIX_ENV When either is set, the heal store path is auto-
|
|
555
|
+
# suffixed with the env slug — `healed-locators.json`
|
|
556
|
+
# → `healed-locators.qa4.json`. Stops parallel runs
|
|
557
|
+
# against different test envs from trampling each
|
|
558
|
+
# other's healed selectors.
|
|
559
|
+
# ENABLE_LOCATOR_VERSIONING "true" (default) keeps the full healingHistory[]
|
|
560
|
+
# per locator. "false" keeps only the latest heal
|
|
561
|
+
# entry (smaller file).
|
|
562
|
+
# HEAL_HISTORY_CAP Cap on healingHistory length when versioning is on
|
|
563
|
+
# (default 50; set to 0 for unlimited).
|
|
564
|
+
# HEAL_STORE_PATH=./self-heals/healed-locators.json
|
|
565
|
+
# HEAL_LOG_PATH=./self-heals/heal.log
|
|
566
|
+
# ENABLE_LOCATOR_VERSIONING=true
|
|
567
|
+
# HEAL_HISTORY_CAP=50
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
# ── LocatorHealer chain tuning ───────────────────────────────────────────────
|
|
571
|
+
# LOCATOR_TIMEOUT Per-strategy attach-probe timeout (ms). Overrides
|
|
572
|
+
# the default 3000ms. Higher values give slow pages
|
|
573
|
+
# more headroom; lower values fail faster.
|
|
574
|
+
# LOCATOR_HEAL_ATTEMPTS Max probes the resolver runs across the chain
|
|
575
|
+
# before falling through to AI Vision. 0 = unlimited
|
|
576
|
+
# (default, original behaviour).
|
|
577
|
+
# HEAL_STRATEGY_ORDER CSV of strategy names to run, in order. Valid names:
|
|
578
|
+
# attribute, type-hint, role, label, text
|
|
579
|
+
# Default order is the same five strategies left-to-
|
|
580
|
+
# right. Omit a name to disable it, or reorder to
|
|
581
|
+
# prioritise a strategy your app favours.
|
|
582
|
+
# LOCATOR_TIMEOUT=3000
|
|
583
|
+
# LOCATOR_HEAL_ATTEMPTS=0
|
|
584
|
+
# HEAL_STRATEGY_ORDER=attribute,type-hint,role,label,text
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
# ── Visual Regression Testing ────────────────────────────────────────────────
|
|
588
|
+
# VRT_MODE baseline | test — overridden by `npm run vrt:*`
|
|
589
|
+
# VRT_SPRINT sprint identifier appended to baseline filenames
|
|
590
|
+
# VRT_THRESHOLD pixelmatch threshold (0.0–1.0, default 0.1)
|
|
591
|
+
# VRT_FAILURE_THRESHOLD percentage of diff pixels above which a test fails
|
|
592
|
+
# VRT_STABILISATION_DELAY ms to wait before screenshot (default 500)
|
|
593
|
+
# VRT_MODE=test
|
|
594
|
+
# VRT_SPRINT=
|
|
595
|
+
# VRT_THRESHOLD=0.1
|
|
596
|
+
# VRT_FAILURE_THRESHOLD=1.0
|
|
597
|
+
# VRT_STABILISATION_DELAY=500
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
# ── Browser / runtime flags ──────────────────────────────────────────────────
|
|
601
|
+
# Usually set by the npm script via cross-env. These are fallbacks for ad-hoc runs.
|
|
602
|
+
# APP=amplify # amplify | parishsoft
|
|
603
|
+
# ENV=dev1 # dev1 | test1 | stg | prod | localhost
|
|
604
|
+
# BROWSER=chrome # chrome | firefox | webkit | edge
|
|
605
|
+
# DOCKER=false
|
|
606
|
+
# CI=false
|
|
607
|
+
"""
|
|
608
|
+
|
|
609
|
+
# Single-source-of-truth env.util.ts template. The migration tool always
|
|
610
|
+
# replaces the migrated copy with this so the loader semantics match the
|
|
611
|
+
# generated .env.example.
|
|
612
|
+
_ENV_UTIL_TEMPLATE = '''\
|
|
613
|
+
import * as dotenv from "dotenv";
|
|
614
|
+
import * as path from "path";
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Single source of truth: project-root `.env`.
|
|
618
|
+
*
|
|
619
|
+
* Per-app values live in the same file with `_<APP>` suffixes
|
|
620
|
+
* (e.g. DOMAIN_AMPLIFY=..., DOMAIN_PARISHSOFT=...). After loading, the
|
|
621
|
+
* APP-suffixed variant for the active app is resolved into its canonical
|
|
622
|
+
* unsuffixed name (DOMAIN, HEADLESS, ...) so existing helpers keep working.
|
|
623
|
+
*
|
|
624
|
+
* Cross-env / shell-exported values are NEVER overridden — they win over
|
|
625
|
+
* the .env file in all cases.
|
|
626
|
+
*/
|
|
627
|
+
const _APP_SCOPED_VARS = ["DOMAIN", "HEADLESS"] as const;
|
|
628
|
+
|
|
629
|
+
export const getEnvFile = (): void => {
|
|
630
|
+
dotenv.config({ path: path.resolve(process.cwd(), ".env"), override: false });
|
|
631
|
+
|
|
632
|
+
if (!process.env.ENV) {
|
|
633
|
+
console.error("NO ENV PASSED!");
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const app = (process.env.APP || "amplify").toUpperCase();
|
|
637
|
+
for (const key of _APP_SCOPED_VARS) {
|
|
638
|
+
const scoped = `${key}_${app}`;
|
|
639
|
+
if (process.env[scoped] !== undefined && !process.env[key]) {
|
|
640
|
+
process.env[key] = process.env[scoped];
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
'''
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _patch_environment_util(root: Path) -> None:
|
|
648
|
+
"""
|
|
649
|
+
Replace `src/helper/environment/environment.util.ts` with the
|
|
650
|
+
single-source-of-truth loader template.
|
|
651
|
+
|
|
652
|
+
Idempotent: skips files already matching the template.
|
|
653
|
+
"""
|
|
654
|
+
target = root / "src" / "helper" / "environment" / "environment.util.ts"
|
|
655
|
+
if not target.exists():
|
|
656
|
+
return
|
|
657
|
+
try:
|
|
658
|
+
existing = target.read_text(encoding="utf-8")
|
|
659
|
+
except Exception:
|
|
660
|
+
return
|
|
661
|
+
if existing == _ENV_UTIL_TEMPLATE:
|
|
662
|
+
return
|
|
663
|
+
target.write_text(_ENV_UTIL_TEMPLATE, encoding="utf-8")
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _harvest_per_app_env(source_dir: str) -> list[tuple[str, dict[str, str]]]:
|
|
667
|
+
"""
|
|
668
|
+
Find any `.env.<APP>` files in the source tree and return their values as
|
|
669
|
+
[(app, {KEY: VALUE, ...}), ...]. App is upper-cased.
|
|
670
|
+
Used to fold legacy per-app env into the single-file .env.example.
|
|
671
|
+
"""
|
|
672
|
+
src = Path(source_dir)
|
|
673
|
+
if not src.exists():
|
|
674
|
+
return []
|
|
675
|
+
results: list[tuple[str, dict[str, str]]] = []
|
|
676
|
+
for p in src.rglob(".env.*"):
|
|
677
|
+
if not p.is_file():
|
|
678
|
+
continue
|
|
679
|
+
app = p.name[len(".env."):]
|
|
680
|
+
if not app or app == "example":
|
|
681
|
+
continue
|
|
682
|
+
try:
|
|
683
|
+
text = p.read_text(encoding="utf-8")
|
|
684
|
+
except Exception:
|
|
685
|
+
continue
|
|
686
|
+
kv: dict[str, str] = {}
|
|
687
|
+
for line in text.splitlines():
|
|
688
|
+
stripped = line.strip()
|
|
689
|
+
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
|
690
|
+
continue
|
|
691
|
+
k, _, v = stripped.partition("=")
|
|
692
|
+
kv[k.strip()] = v.strip()
|
|
693
|
+
if kv:
|
|
694
|
+
results.append((app.upper(), kv))
|
|
695
|
+
return results
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _scaffold_locators(
|
|
699
|
+
root: Path,
|
|
700
|
+
written: list[dict],
|
|
701
|
+
conflict_strategy: str = "interactive",
|
|
702
|
+
) -> None:
|
|
703
|
+
"""
|
|
704
|
+
Emit the LocatorHealer / LocatorRepository / TimingHealer / VisualIntentChecker
|
|
705
|
+
/ HealingDashboard scaffolds into src/utils/locators/.
|
|
706
|
+
|
|
707
|
+
All migrated page objects + step defs reference these modules — without the
|
|
708
|
+
scaffold, the project fails to compile with TS2307 "Cannot find module
|
|
709
|
+
'@utils/locators/...'".
|
|
710
|
+
|
|
711
|
+
Conflict handling:
|
|
712
|
+
• "overwrite" — replace every scaffold file with the freshly generated
|
|
713
|
+
version. Lets users pick up generator improvements
|
|
714
|
+
(new env-var wiring, expanded strategy chain, etc.).
|
|
715
|
+
• "skip" — only write scaffold files that are missing.
|
|
716
|
+
• "interactive" — same as "skip" but appended to the conflicts list so
|
|
717
|
+
the caller can decide per-file.
|
|
718
|
+
|
|
719
|
+
Defaults are conservative: AI vision + timing healing + visual checker all on,
|
|
720
|
+
repository persisted under ./self-heals/healed-locators.json, dashboard on port 7890.
|
|
721
|
+
"""
|
|
722
|
+
try:
|
|
723
|
+
from stlc_agents.agent_playwright_generator.server import _scaffold_locator_repository
|
|
724
|
+
except Exception:
|
|
725
|
+
return
|
|
726
|
+
|
|
727
|
+
try:
|
|
728
|
+
result = _scaffold_locator_repository(
|
|
729
|
+
output_dir="src/utils/locators",
|
|
730
|
+
enable_ai_vision=True,
|
|
731
|
+
repository_path="./self-heals/healed-locators.json",
|
|
732
|
+
dashboard_port=7890,
|
|
733
|
+
enable_timing_healing=True,
|
|
734
|
+
enable_visual_regression=True,
|
|
735
|
+
)
|
|
736
|
+
except Exception:
|
|
737
|
+
return
|
|
738
|
+
|
|
739
|
+
for rel_path, body in result.get("files", {}).items():
|
|
740
|
+
target = root / rel_path
|
|
741
|
+
already_exists = target.exists()
|
|
742
|
+
if already_exists and conflict_strategy != "overwrite":
|
|
743
|
+
# Skip (matches the historical "missing-only" behaviour). The
|
|
744
|
+
# caller stays in charge of when to refresh the scaffold.
|
|
745
|
+
continue
|
|
746
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
747
|
+
target.write_text(body, encoding="utf-8")
|
|
748
|
+
written.append({
|
|
749
|
+
"source": "(scaffold)",
|
|
750
|
+
"target": rel_path,
|
|
751
|
+
"role": "scaffold",
|
|
752
|
+
"change_count": 0,
|
|
753
|
+
})
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def _write_root_env_example(
|
|
757
|
+
root: Path,
|
|
758
|
+
written: list[dict],
|
|
759
|
+
source_dir: str | None = None,
|
|
760
|
+
conflict_strategy: str = "interactive",
|
|
761
|
+
) -> None:
|
|
762
|
+
"""
|
|
763
|
+
Emit a single `.env.example` at the helix-qa root.
|
|
764
|
+
|
|
765
|
+
If the source project has per-app `.env.<APP>` files, their values are
|
|
766
|
+
folded into the template as `KEY_<APP>` entries — preserving the existing
|
|
767
|
+
project's app-specific config in the single new file.
|
|
768
|
+
|
|
769
|
+
Conflict handling matches the main migration loop:
|
|
770
|
+
• "overwrite" — refresh the file so users pick up new template keys
|
|
771
|
+
(e.g. new healing env vars).
|
|
772
|
+
• else — skip when the file already exists.
|
|
773
|
+
"""
|
|
774
|
+
target = root / ".env.example"
|
|
775
|
+
if target.exists() and conflict_strategy != "overwrite":
|
|
776
|
+
return
|
|
777
|
+
|
|
778
|
+
per_app_section = ""
|
|
779
|
+
if source_dir:
|
|
780
|
+
harvested = _harvest_per_app_env(source_dir)
|
|
781
|
+
if harvested:
|
|
782
|
+
# Collect every key seen across all apps so we can group nicely.
|
|
783
|
+
apps_sorted = sorted({app for app, _ in harvested})
|
|
784
|
+
keys_sorted: list[str] = []
|
|
785
|
+
seen: set[str] = set()
|
|
786
|
+
for _, kv in harvested:
|
|
787
|
+
for k in kv:
|
|
788
|
+
if k not in seen:
|
|
789
|
+
keys_sorted.append(k)
|
|
790
|
+
seen.add(k)
|
|
791
|
+
|
|
792
|
+
lines = ["# ── Per-app overrides ────────────────────────────────────────────────────────",
|
|
793
|
+
"# Resolved to the canonical name (DOMAIN, HEADLESS, ...) by environment.util.ts",
|
|
794
|
+
"# based on the active APP. Each row is grouped by the original variable name.",
|
|
795
|
+
""]
|
|
796
|
+
for k in keys_sorted:
|
|
797
|
+
for app in apps_sorted:
|
|
798
|
+
val = dict(harvested).get(app, {}).get(k)
|
|
799
|
+
if val is not None:
|
|
800
|
+
lines.append(f"{k}_{app}={val}")
|
|
801
|
+
lines.append("")
|
|
802
|
+
per_app_section = "\n".join(lines).rstrip() + "\n\n"
|
|
803
|
+
|
|
804
|
+
body = _ENV_EXAMPLE_HEADER + "\n" + per_app_section + _ENV_EXAMPLE_TAIL
|
|
805
|
+
target.write_text(body, encoding="utf-8")
|
|
806
|
+
written.append({
|
|
807
|
+
"source": "(generated)",
|
|
808
|
+
"target": ".env.example",
|
|
809
|
+
"role": "env",
|
|
810
|
+
"change_count": 0,
|
|
811
|
+
})
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
# ---------------------------------------------------------------------------
|
|
815
|
+
# Post-pass: fix locator .selector accesses
|
|
816
|
+
# ---------------------------------------------------------------------------
|
|
817
|
+
|
|
818
|
+
def _scaffold_browser_globals(root: Path) -> None:
|
|
819
|
+
"""
|
|
820
|
+
Walk every migrated .ts file, find `page.evaluate(...)` callbacks, and
|
|
821
|
+
collect any bare identifier function calls inside them. These reference
|
|
822
|
+
browser-context functions defined by the SUT — TypeScript can't see them
|
|
823
|
+
without ambient declarations. Emit `src/types/browser-globals.d.ts` with
|
|
824
|
+
`declare const <name>: any;` for each name.
|
|
825
|
+
|
|
826
|
+
Idempotent: rewrites the file each run with the union of discovered names.
|
|
827
|
+
"""
|
|
828
|
+
import re as _re
|
|
829
|
+
|
|
830
|
+
# Recurse over .ts files under the migrated tree (skip node_modules etc.).
|
|
831
|
+
skip_dirs = {"node_modules", "dist", "build", ".git", "test-results"}
|
|
832
|
+
files: list[Path] = []
|
|
833
|
+
for ts_file in root.rglob("*.ts"):
|
|
834
|
+
if any(s in ts_file.parts for s in skip_dirs):
|
|
835
|
+
continue
|
|
836
|
+
files.append(ts_file)
|
|
837
|
+
if not files:
|
|
838
|
+
return
|
|
839
|
+
|
|
840
|
+
# Always include Playwright Test runner fixtures that the converted steps
|
|
841
|
+
# may reference (request, testInfo) — there's no clean Cucumber equivalent
|
|
842
|
+
# to inject them, but declaring them as `any` makes tsc happy and the user
|
|
843
|
+
# can wire them through later.
|
|
844
|
+
seed_names: set[str] = {"request", "testInfo"}
|
|
845
|
+
|
|
846
|
+
# Collect names called as functions inside page.evaluate callbacks. We use
|
|
847
|
+
# a balanced-paren scan to extract the callback text, then look for bare
|
|
848
|
+
# `<ident>(` calls in it.
|
|
849
|
+
evaluate_open_re = _re.compile(r"\.evaluate\s*\(")
|
|
850
|
+
call_re = _re.compile(r"\b([A-Za-z_$][\w$]*)\s*\(")
|
|
851
|
+
builtin_blacklist = {
|
|
852
|
+
"if", "for", "while", "return", "switch", "case", "throw", "new",
|
|
853
|
+
"typeof", "instanceof", "delete", "void", "await", "async", "function",
|
|
854
|
+
"Array", "Object", "Math", "JSON", "Promise", "Date", "Number", "String",
|
|
855
|
+
"Boolean", "RegExp", "Error", "Map", "Set", "Symbol", "console",
|
|
856
|
+
"window", "document", "globalThis", "parseInt", "parseFloat",
|
|
857
|
+
"isNaN", "isFinite", "encodeURIComponent", "decodeURIComponent",
|
|
858
|
+
"setTimeout", "setInterval", "clearTimeout", "clearInterval",
|
|
859
|
+
"fetch", "btoa", "atob", "alert", "confirm", "prompt",
|
|
860
|
+
"Array", "Object", "Boolean", "Number", "String", "Symbol",
|
|
861
|
+
"Reflect", "Proxy", "WeakMap", "WeakSet", "DataView",
|
|
862
|
+
"Int8Array", "Uint8Array", "Float32Array", "Float64Array",
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
names: set[str] = set(seed_names)
|
|
866
|
+
for f in files:
|
|
867
|
+
try:
|
|
868
|
+
txt = f.read_text(encoding="utf-8")
|
|
869
|
+
except Exception:
|
|
870
|
+
continue
|
|
871
|
+
# Walk each .evaluate( call; extract its callback body via paren-counting.
|
|
872
|
+
for m in evaluate_open_re.finditer(txt):
|
|
873
|
+
paren_open = m.end() - 1
|
|
874
|
+
depth = 1
|
|
875
|
+
i = paren_open + 1
|
|
876
|
+
in_str: str | None = None
|
|
877
|
+
n = len(txt)
|
|
878
|
+
while i < n and depth > 0:
|
|
879
|
+
c = txt[i]
|
|
880
|
+
if in_str:
|
|
881
|
+
if c == "\\":
|
|
882
|
+
i += 2; continue
|
|
883
|
+
if c == in_str:
|
|
884
|
+
in_str = None
|
|
885
|
+
i += 1; continue
|
|
886
|
+
if c in ("'", '"', "`"):
|
|
887
|
+
in_str = c
|
|
888
|
+
i += 1; continue
|
|
889
|
+
if c == "(":
|
|
890
|
+
depth += 1
|
|
891
|
+
elif c == ")":
|
|
892
|
+
depth -= 1
|
|
893
|
+
i += 1
|
|
894
|
+
if depth != 0:
|
|
895
|
+
continue
|
|
896
|
+
callback = txt[paren_open + 1 : i - 1]
|
|
897
|
+
for cm in call_re.finditer(callback):
|
|
898
|
+
name = cm.group(1)
|
|
899
|
+
if name in builtin_blacklist:
|
|
900
|
+
continue
|
|
901
|
+
# Skip names that are clearly local: declared with `function`,
|
|
902
|
+
# `let`, `const`, `var` immediately preceding them, OR named
|
|
903
|
+
# exports / class member calls (`.foo()`).
|
|
904
|
+
start = cm.start()
|
|
905
|
+
# Skip member-access calls — `foo.bar(`.
|
|
906
|
+
if start > 0 and callback[start - 1] == ".":
|
|
907
|
+
continue
|
|
908
|
+
names.add(name)
|
|
909
|
+
|
|
910
|
+
if not names:
|
|
911
|
+
return
|
|
912
|
+
|
|
913
|
+
types_dir = root / "src" / "types"
|
|
914
|
+
types_dir.mkdir(parents=True, exist_ok=True)
|
|
915
|
+
out_path = types_dir / "browser-globals.d.ts"
|
|
916
|
+
body = "// Auto-generated by stlc-migrate.\n"
|
|
917
|
+
body += "// Ambient declarations for identifiers referenced inside\n"
|
|
918
|
+
body += "// page.evaluate(...) callbacks. These functions live in the\n"
|
|
919
|
+
body += "// browser context (defined by the SUT), not the Node test code,\n"
|
|
920
|
+
body += "// so TypeScript can't see them — declare them as `any` shims.\n"
|
|
921
|
+
body += "//\n"
|
|
922
|
+
body += "// Safe to edit: replace `any` with real signatures, or remove\n"
|
|
923
|
+
body += "// entries whose underlying call sites have been rewritten.\n\n"
|
|
924
|
+
for name in sorted(names):
|
|
925
|
+
body += f"declare const {name}: any;\n"
|
|
926
|
+
out_path.write_text(body, encoding="utf-8")
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def _scaffold_page_fixture(root: Path) -> None:
|
|
930
|
+
"""
|
|
931
|
+
Drop a minimal `src/hooks/pageFixture.ts` (and matching Before/After hooks
|
|
932
|
+
in `src/hooks/hooks.ts`) when neither already exists. Generated step files
|
|
933
|
+
import `fixture` from `@hooks/pageFixture`; without this scaffold those
|
|
934
|
+
imports fail to resolve.
|
|
935
|
+
|
|
936
|
+
Idempotent: skips both files if they already exist.
|
|
937
|
+
"""
|
|
938
|
+
hooks_dir = root / "src" / "hooks"
|
|
939
|
+
fixture_path = hooks_dir / "pageFixture.ts"
|
|
940
|
+
hooks_path = hooks_dir / "hooks.ts"
|
|
941
|
+
if fixture_path.exists() and hooks_path.exists():
|
|
942
|
+
return
|
|
943
|
+
hooks_dir.mkdir(parents=True, exist_ok=True)
|
|
944
|
+
if not fixture_path.exists():
|
|
945
|
+
fixture_path.write_text(
|
|
946
|
+
'import { Page, Browser, BrowserContext } from "@playwright/test";\n'
|
|
947
|
+
"\n"
|
|
948
|
+
"export interface PageFixture {\n"
|
|
949
|
+
" page: Page;\n"
|
|
950
|
+
" browser?: Browser;\n"
|
|
951
|
+
" context?: BrowserContext;\n"
|
|
952
|
+
" logger?: { info: (m: string) => void; error: (m: string) => void };\n"
|
|
953
|
+
"}\n"
|
|
954
|
+
"\n"
|
|
955
|
+
"let _current: PageFixture | undefined;\n"
|
|
956
|
+
"\n"
|
|
957
|
+
"export function setFixture(f: PageFixture): void {\n"
|
|
958
|
+
" _current = f;\n"
|
|
959
|
+
"}\n"
|
|
960
|
+
"\n"
|
|
961
|
+
"export function fixture(): PageFixture {\n"
|
|
962
|
+
' if (!_current) throw new Error("PageFixture not initialised — Before hook must call setFixture(...)");\n'
|
|
963
|
+
" return _current;\n"
|
|
964
|
+
"}\n",
|
|
965
|
+
encoding="utf-8",
|
|
966
|
+
)
|
|
967
|
+
if not hooks_path.exists():
|
|
968
|
+
hooks_path.write_text(
|
|
969
|
+
'import { Before, After, BeforeAll, AfterAll } from "@cucumber/cucumber";\n'
|
|
970
|
+
'import { chromium, Browser, Page } from "@playwright/test";\n'
|
|
971
|
+
'import { setFixture } from "./pageFixture";\n'
|
|
972
|
+
"\n"
|
|
973
|
+
"let browser: Browser;\n"
|
|
974
|
+
"let page: Page;\n"
|
|
975
|
+
"\n"
|
|
976
|
+
"BeforeAll(async () => {\n"
|
|
977
|
+
" browser = await chromium.launch({ headless: process.env.HEADLESS !== 'false' });\n"
|
|
978
|
+
"});\n"
|
|
979
|
+
"\n"
|
|
980
|
+
"Before(async function () {\n"
|
|
981
|
+
" const context = await browser.newContext();\n"
|
|
982
|
+
" page = await context.newPage();\n"
|
|
983
|
+
" setFixture({ page, browser, context });\n"
|
|
984
|
+
"});\n"
|
|
985
|
+
"\n"
|
|
986
|
+
"After(async function () {\n"
|
|
987
|
+
" await page.context().close();\n"
|
|
988
|
+
"});\n"
|
|
989
|
+
"\n"
|
|
990
|
+
"AfterAll(async () => {\n"
|
|
991
|
+
" await browser.close();\n"
|
|
992
|
+
"});\n",
|
|
993
|
+
encoding="utf-8",
|
|
994
|
+
)
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def _ensure_tsconfig(root: Path) -> None:
|
|
998
|
+
"""
|
|
999
|
+
If the migrated tree has no tsconfig.json (e.g. when the source was a
|
|
1000
|
+
pure-JS Playwright project), write a minimal one that wires the same
|
|
1001
|
+
Helix path aliases used elsewhere in the migrator. Idempotent: no-op
|
|
1002
|
+
when a tsconfig already exists.
|
|
1003
|
+
"""
|
|
1004
|
+
target = root / "tsconfig.json"
|
|
1005
|
+
if target.exists():
|
|
1006
|
+
return
|
|
1007
|
+
cfg = {
|
|
1008
|
+
"compilerOptions": {
|
|
1009
|
+
"target": "ES2021",
|
|
1010
|
+
"module": "commonjs",
|
|
1011
|
+
"lib": ["dom", "es2021"],
|
|
1012
|
+
"esModuleInterop": True,
|
|
1013
|
+
"resolveJsonModule": True,
|
|
1014
|
+
"skipLibCheck": True,
|
|
1015
|
+
# Pragmatic defaults for a synthesised tsconfig — most source
|
|
1016
|
+
# projects we migrate are JS-flavoured Playwright/cucumber repos
|
|
1017
|
+
# that wouldn't survive `strict: true`. The user can tighten any
|
|
1018
|
+
# of these once they've added explicit types.
|
|
1019
|
+
"strict": False,
|
|
1020
|
+
"noImplicitAny": False,
|
|
1021
|
+
"strictNullChecks": False,
|
|
1022
|
+
"allowJs": True,
|
|
1023
|
+
"checkJs": False,
|
|
1024
|
+
"baseUrl": "src",
|
|
1025
|
+
"paths": {
|
|
1026
|
+
"@pages/*": ["pages/*"],
|
|
1027
|
+
"@steps/*": ["test/steps/*"],
|
|
1028
|
+
"@features/*": ["test/features/*"],
|
|
1029
|
+
"@locators/*": ["locators/*"],
|
|
1030
|
+
"@config/*": ["config/*"],
|
|
1031
|
+
"@utils/*": ["utils/*"],
|
|
1032
|
+
"@helper/*": ["helper/*"],
|
|
1033
|
+
"@hooks/*": ["hooks/*"],
|
|
1034
|
+
},
|
|
1035
|
+
},
|
|
1036
|
+
# The migrated tree may have raw-copied helpers/page_objects/scripts
|
|
1037
|
+
# at the project root (not under src/), so use a broad include with an
|
|
1038
|
+
# explicit exclude list rather than the default `["src"]`.
|
|
1039
|
+
"include": ["**/*.ts", "**/*.js"],
|
|
1040
|
+
"exclude": ["node_modules", "dist", "build", "test-results"],
|
|
1041
|
+
"ts-node": {
|
|
1042
|
+
"transpileOnly": True,
|
|
1043
|
+
"require": ["tsconfig-paths/register"],
|
|
1044
|
+
},
|
|
1045
|
+
}
|
|
1046
|
+
import json as _json
|
|
1047
|
+
target.write_text(_json.dumps(cfg, indent=2) + "\n", encoding="utf-8")
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def _flush_hoist_buffer(root: Path, hoist_buffer: dict[str, list[HoistAction]]) -> None:
|
|
1051
|
+
"""
|
|
1052
|
+
Append buffered HoistActions to the .locators.ts file(s) on disk that
|
|
1053
|
+
contain the matching xxxLocators block.
|
|
1054
|
+
|
|
1055
|
+
Strategy: walk every .locators.ts under <root>/src/locators/, look for
|
|
1056
|
+
`export const xxxLocators = {` blocks, and pass through any actions
|
|
1057
|
+
whose `locator_object` matches.
|
|
1058
|
+
"""
|
|
1059
|
+
if not hoist_buffer:
|
|
1060
|
+
return
|
|
1061
|
+
|
|
1062
|
+
locator_dir = root / "src" / "locators"
|
|
1063
|
+
if not locator_dir.exists():
|
|
1064
|
+
return
|
|
1065
|
+
|
|
1066
|
+
for locator_path in locator_dir.glob("*.ts"):
|
|
1067
|
+
try:
|
|
1068
|
+
content = locator_path.read_text(encoding="utf-8")
|
|
1069
|
+
except Exception:
|
|
1070
|
+
continue
|
|
1071
|
+
# Skip files that don't define any of our target xxxLocators.
|
|
1072
|
+
relevant = {
|
|
1073
|
+
name: actions
|
|
1074
|
+
for name, actions in hoist_buffer.items()
|
|
1075
|
+
if f"export const {name}" in content
|
|
1076
|
+
}
|
|
1077
|
+
if not relevant:
|
|
1078
|
+
continue
|
|
1079
|
+
new_content, _changes = append_entries_to_locator_file(content, relevant)
|
|
1080
|
+
if new_content != content:
|
|
1081
|
+
try:
|
|
1082
|
+
locator_path.write_text(new_content, encoding="utf-8")
|
|
1083
|
+
except Exception:
|
|
1084
|
+
pass
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
def _fix_selector_accesses_in_tree(root: Path, written: list[dict]) -> None:
|
|
1088
|
+
"""
|
|
1089
|
+
After all files are written, collect structured keys from locator files and
|
|
1090
|
+
rewrite page_object / step_def files so that `locatorGroup.key` becomes
|
|
1091
|
+
`locatorGroup.key.selector` wherever the key is now a { selector, intent, stability } object.
|
|
1092
|
+
"""
|
|
1093
|
+
# Collect object keys from all written locator files
|
|
1094
|
+
object_keys_by_name: dict[str, set[str]] = {}
|
|
1095
|
+
for entry in written:
|
|
1096
|
+
if entry.get("role") != "locator":
|
|
1097
|
+
continue
|
|
1098
|
+
locator_path = root / entry["target"]
|
|
1099
|
+
try:
|
|
1100
|
+
lc = locator_path.read_text(encoding="utf-8")
|
|
1101
|
+
object_keys_by_name.update(collect_object_keys(lc))
|
|
1102
|
+
except Exception:
|
|
1103
|
+
pass
|
|
1104
|
+
|
|
1105
|
+
if not object_keys_by_name:
|
|
1106
|
+
return
|
|
1107
|
+
|
|
1108
|
+
# Apply fixes to every TS/JS code role that may reference locators
|
|
1109
|
+
_FIXABLE_ROLES = {"page_object", "step_def", "helper", "hook", "fixture", "support", "api_client"}
|
|
1110
|
+
for entry in written:
|
|
1111
|
+
if entry.get("role") not in _FIXABLE_ROLES:
|
|
1112
|
+
continue
|
|
1113
|
+
target_path = root / entry["target"]
|
|
1114
|
+
try:
|
|
1115
|
+
content = target_path.read_text(encoding="utf-8")
|
|
1116
|
+
new_content, changes = fix_selector_accesses(content, object_keys_by_name)
|
|
1117
|
+
if changes:
|
|
1118
|
+
target_path.write_text(new_content, encoding="utf-8")
|
|
1119
|
+
entry["change_count"] = entry.get("change_count", 0) + len(changes)
|
|
1120
|
+
except Exception:
|
|
1121
|
+
pass
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
# ---------------------------------------------------------------------------
|
|
1125
|
+
# Fill missing files from agent_helix_writer's BOILERPLATE
|
|
1126
|
+
# ---------------------------------------------------------------------------
|
|
1127
|
+
|
|
1128
|
+
# Allow-list of BOILERPLATE entries that drop cleanly into a migrated tree
|
|
1129
|
+
# without depending on APIs the user's existing source code may not expose.
|
|
1130
|
+
# Everything else is held back because:
|
|
1131
|
+
#
|
|
1132
|
+
# • `src/pages/BasePage.ts` / `src/templates/_TemplatePage.ts` — assume a
|
|
1133
|
+
# `fixture.locatorRepository` property and a 3-arg LocatorHealer
|
|
1134
|
+
# constructor that the enhanced scaffold doesn't expose.
|
|
1135
|
+
# • `src/utils/locators/index.ts` / `PlaywrightHealerLogger.ts` /
|
|
1136
|
+
# `LocatorManager.ts` — re-export `HealerLogger` / `HealEvent` symbols
|
|
1137
|
+
# that exist only in the BOILERPLATE scaffold variant, not in the
|
|
1138
|
+
# enhanced one written by `_scaffold_locator_repository`.
|
|
1139
|
+
# • `src/utils/ai-assistant/*`, `src/utils/storage-state/*`,
|
|
1140
|
+
# `src/utils/helpers/*`, `src/config/*` — depend on the `@config/*`
|
|
1141
|
+
# tsconfig path mapping pointing at `src/config`, but most existing
|
|
1142
|
+
# migrated trees have `@config/*` pointing at `../config` (root-level).
|
|
1143
|
+
# Filling them in would surface a wall of TS2307 module-not-found
|
|
1144
|
+
# errors instead of "all features working".
|
|
1145
|
+
#
|
|
1146
|
+
# Files held back are listed in MIGRATION-REPORT.md so the operator knows
|
|
1147
|
+
# they're available and what adaptation is needed before enabling them.
|
|
1148
|
+
_BOILERPLATE_FILL_ALLOWLIST = frozenset({
|
|
1149
|
+
# Base locator file referenced by generated page objects from
|
|
1150
|
+
# qa-playwright-generator. Pure addition; doesn't conflict.
|
|
1151
|
+
"src/locators/base.locators.ts",
|
|
1152
|
+
# Healing-infrastructure layer the agents reference but the enhanced
|
|
1153
|
+
# scaffold doesn't replace — pure additions, each independently
|
|
1154
|
+
# compilable against the enhanced LocatorHealer / LocatorRepository.
|
|
1155
|
+
"src/utils/locators/ElementContextHelper.ts",
|
|
1156
|
+
"src/utils/locators/HealApplicator.ts",
|
|
1157
|
+
"src/utils/locators/LocatorRules.ts",
|
|
1158
|
+
"src/utils/locators/LocatorStrategy.ts",
|
|
1159
|
+
"src/utils/locators/healix-ci-apply.ts",
|
|
1160
|
+
"src/utils/locators/review-server.ts",
|
|
1161
|
+
# Helpers the locator-strategy layer depends on. Logger imports the
|
|
1162
|
+
# config module via a *relative* path, so we ship environment.ts in
|
|
1163
|
+
# the same drop — without depending on any `@config/*` tsconfig alias.
|
|
1164
|
+
"src/config/environment.ts",
|
|
1165
|
+
"src/utils/helpers/Logger.ts",
|
|
1166
|
+
"src/utils/helpers/RetryHandler.ts",
|
|
1167
|
+
"src/utils/helpers/WaitHelper.ts",
|
|
1168
|
+
})
|
|
1169
|
+
|
|
1170
|
+
|
|
1171
|
+
def _fill_helix_boilerplate(
|
|
1172
|
+
root: Path,
|
|
1173
|
+
written: list[dict],
|
|
1174
|
+
conflict_strategy: str,
|
|
1175
|
+
) -> None:
|
|
1176
|
+
"""
|
|
1177
|
+
Drop in every file the qa-helix-writer / qa-playwright-generator agents
|
|
1178
|
+
expect a fully-populated Helix-QA tree to contain — base classes,
|
|
1179
|
+
AI-assistant helpers, locator strategy primitives, auth setup, retry /
|
|
1180
|
+
wait / logger utilities, templates, example specs.
|
|
1181
|
+
|
|
1182
|
+
Source of truth: `agent_helix_writer.tools.boilerplate.BOILERPLATE` (the
|
|
1183
|
+
same map the agents use when bootstrapping a fresh project). The
|
|
1184
|
+
migration's own scaffold takes precedence for files we've enhanced
|
|
1185
|
+
(LocatorHealer, LocatorRepository, TimingHealer, VisualIntentChecker,
|
|
1186
|
+
HealingDashboard, dashboard-server) — those land first and we never
|
|
1187
|
+
overwrite them with the BOILERPLATE version.
|
|
1188
|
+
|
|
1189
|
+
Top-level config files (package.json, tsconfig.json, .env, etc.) are
|
|
1190
|
+
skipped here — they're already produced by the merge passes.
|
|
1191
|
+
|
|
1192
|
+
Honors `--conflict overwrite` for everything else: missing files always
|
|
1193
|
+
get written; existing files are only refreshed when overwrite is on.
|
|
1194
|
+
"""
|
|
1195
|
+
try:
|
|
1196
|
+
from stlc_agents.agent_helix_writer.tools.boilerplate import BOILERPLATE
|
|
1197
|
+
except Exception:
|
|
1198
|
+
# Boilerplate package not importable — nothing to fill.
|
|
1199
|
+
return
|
|
1200
|
+
|
|
1201
|
+
# FILL-ONLY: never overwrite an existing file regardless of
|
|
1202
|
+
# conflict_strategy. Only files in the allowlist are even considered,
|
|
1203
|
+
# because the broader BOILERPLATE set contains components that assume
|
|
1204
|
+
# API surfaces the user's migrated code doesn't expose.
|
|
1205
|
+
for rel_path, body in BOILERPLATE.items():
|
|
1206
|
+
if rel_path not in _BOILERPLATE_FILL_ALLOWLIST:
|
|
1207
|
+
continue
|
|
1208
|
+
target = root / rel_path
|
|
1209
|
+
if target.exists():
|
|
1210
|
+
continue
|
|
1211
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
1212
|
+
target.write_text(body, encoding="utf-8")
|
|
1213
|
+
written.append({
|
|
1214
|
+
"source": "(boilerplate) agent_helix_writer",
|
|
1215
|
+
"target": rel_path,
|
|
1216
|
+
"role": "boilerplate",
|
|
1217
|
+
"change_count": 0,
|
|
1218
|
+
})
|
|
1219
|
+
|
|
1220
|
+
|
|
1221
|
+
# ---------------------------------------------------------------------------
|
|
1222
|
+
# Agent-asset installation (skills, .mcp.json, governance docs)
|
|
1223
|
+
# ---------------------------------------------------------------------------
|
|
1224
|
+
|
|
1225
|
+
_ADO_SKILL_DIRS = (
|
|
1226
|
+
"generate-test-cases",
|
|
1227
|
+
"generate-gherkin",
|
|
1228
|
+
"generate-playwright-code",
|
|
1229
|
+
"write-helix-files",
|
|
1230
|
+
"deduplication-protocol",
|
|
1231
|
+
"migrate-framework",
|
|
1232
|
+
)
|
|
1233
|
+
_JIRA_SKILL_DIRS = (
|
|
1234
|
+
"qa-jira-manager",
|
|
1235
|
+
"generate-gherkin",
|
|
1236
|
+
"generate-playwright-code",
|
|
1237
|
+
"write-helix-files",
|
|
1238
|
+
"deduplication-protocol",
|
|
1239
|
+
"migrate-framework",
|
|
1240
|
+
)
|
|
1241
|
+
|
|
1242
|
+
|
|
1243
|
+
def _stlc_agents_root() -> Path:
|
|
1244
|
+
"""
|
|
1245
|
+
Absolute path to the stlc-agents package install — used to locate the
|
|
1246
|
+
skill source tree, governance docs, and agent binaries. We resolve from
|
|
1247
|
+
*this* file's location so the same code works whether stlc-agents is
|
|
1248
|
+
installed editable (`pip install -e .`) or from a wheel.
|
|
1249
|
+
"""
|
|
1250
|
+
# _migrate.py lives at <root>/src/stlc_agents/agent_migration/_migrate.py
|
|
1251
|
+
return Path(__file__).resolve().parents[3]
|
|
1252
|
+
|
|
1253
|
+
|
|
1254
|
+
def _agent_binary_path(name: str) -> str:
|
|
1255
|
+
"""
|
|
1256
|
+
Locate an installed agent's console-script binary. Falls back to a bare
|
|
1257
|
+
name (which only works when the user has stlc-agents' venv on PATH) so
|
|
1258
|
+
the .mcp.json is always emittable.
|
|
1259
|
+
"""
|
|
1260
|
+
venv_bin = Path(sys.executable).parent
|
|
1261
|
+
candidate = venv_bin / name
|
|
1262
|
+
return str(candidate) if candidate.exists() else name
|
|
1263
|
+
|
|
1264
|
+
|
|
1265
|
+
def _install_agent_assets(
|
|
1266
|
+
root: Path,
|
|
1267
|
+
written: list[dict],
|
|
1268
|
+
integration: str,
|
|
1269
|
+
conflict_strategy: str,
|
|
1270
|
+
) -> None:
|
|
1271
|
+
"""
|
|
1272
|
+
Drop in the assets every QA-STLC agent expects to find in a Helix-QA tree:
|
|
1273
|
+
|
|
1274
|
+
• `.claude/skills/<name>/SKILL.md` per integration (ado / jira / both)
|
|
1275
|
+
• `.claude/AGENT-BEHAVIOR.md` zero-inference contract
|
|
1276
|
+
• `AGENT-BEHAVIOR.md` (root) readable from the project root
|
|
1277
|
+
• `ORCHESTRATION_RULES.md` multi-step workflow rules (if present)
|
|
1278
|
+
• `.mcp.json` MCP server registry — Claude Code,
|
|
1279
|
+
Cursor, and other MCP-aware clients
|
|
1280
|
+
auto-discover agents from this file
|
|
1281
|
+
• `.github/copilot-instructions/` same skills, flattened for VS Code
|
|
1282
|
+
|
|
1283
|
+
Behavior is gated on conflict_strategy:
|
|
1284
|
+
"overwrite" → always refresh (picks up tool-side improvements)
|
|
1285
|
+
else → only write missing files
|
|
1286
|
+
"""
|
|
1287
|
+
stlc_root = _stlc_agents_root()
|
|
1288
|
+
skills_dir = stlc_root / "skills"
|
|
1289
|
+
if not skills_dir.exists():
|
|
1290
|
+
# stlc-agents not installed editable & wheel didn't ship skills/ —
|
|
1291
|
+
# nothing to do (no warning since this path is rare).
|
|
1292
|
+
return
|
|
1293
|
+
|
|
1294
|
+
overwrite = conflict_strategy == "overwrite"
|
|
1295
|
+
chosen: tuple[str, ...]
|
|
1296
|
+
if integration == "jira":
|
|
1297
|
+
chosen = _JIRA_SKILL_DIRS
|
|
1298
|
+
elif integration == "ado":
|
|
1299
|
+
chosen = _ADO_SKILL_DIRS
|
|
1300
|
+
else: # "both" / default
|
|
1301
|
+
chosen = tuple(dict.fromkeys((*_ADO_SKILL_DIRS, *_JIRA_SKILL_DIRS)))
|
|
1302
|
+
|
|
1303
|
+
# 1. Claude Code: nested SKILL.md inside .claude/skills/<name>/
|
|
1304
|
+
claude_skills = root / ".claude" / "skills"
|
|
1305
|
+
claude_skills.mkdir(parents=True, exist_ok=True)
|
|
1306
|
+
for name in chosen:
|
|
1307
|
+
src = skills_dir / name / "SKILL.md"
|
|
1308
|
+
if not src.exists():
|
|
1309
|
+
continue
|
|
1310
|
+
dst = claude_skills / name / "SKILL.md"
|
|
1311
|
+
if dst.exists() and not overwrite:
|
|
1312
|
+
continue
|
|
1313
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
1314
|
+
shutil.copyfile(src, dst)
|
|
1315
|
+
written.append({
|
|
1316
|
+
"source": f"(stlc-agents) skills/{name}/SKILL.md",
|
|
1317
|
+
"target": str(dst.relative_to(root)),
|
|
1318
|
+
"role": "skill",
|
|
1319
|
+
"change_count": 0,
|
|
1320
|
+
})
|
|
1321
|
+
|
|
1322
|
+
# 2. VS Code: flat .github/copilot-instructions/<name>.md
|
|
1323
|
+
vscode_skills = root / ".github" / "copilot-instructions"
|
|
1324
|
+
vscode_skills.mkdir(parents=True, exist_ok=True)
|
|
1325
|
+
for name in chosen:
|
|
1326
|
+
src = skills_dir / name / "SKILL.md"
|
|
1327
|
+
if not src.exists():
|
|
1328
|
+
continue
|
|
1329
|
+
dst = vscode_skills / f"{name}.md"
|
|
1330
|
+
if dst.exists() and not overwrite:
|
|
1331
|
+
continue
|
|
1332
|
+
shutil.copyfile(src, dst)
|
|
1333
|
+
written.append({
|
|
1334
|
+
"source": f"(stlc-agents) skills/{name}/SKILL.md",
|
|
1335
|
+
"target": str(dst.relative_to(root)),
|
|
1336
|
+
"role": "skill",
|
|
1337
|
+
"change_count": 0,
|
|
1338
|
+
})
|
|
1339
|
+
|
|
1340
|
+
# 3. Governance docs (AGENT-BEHAVIOR.md, ORCHESTRATION_RULES.md)
|
|
1341
|
+
for doc_name in ("AGENT-BEHAVIOR.md", "ORCHESTRATION_RULES.md"):
|
|
1342
|
+
src = stlc_root / doc_name
|
|
1343
|
+
# AGENT-BEHAVIOR.md actually lives under skills/ — try both paths.
|
|
1344
|
+
if not src.exists():
|
|
1345
|
+
src = skills_dir / doc_name
|
|
1346
|
+
if not src.exists():
|
|
1347
|
+
continue
|
|
1348
|
+
for dst in (root / doc_name, root / ".claude" / doc_name,
|
|
1349
|
+
root / ".github" / "copilot-instructions" / doc_name):
|
|
1350
|
+
if dst.exists() and not overwrite:
|
|
1351
|
+
continue
|
|
1352
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
1353
|
+
shutil.copyfile(src, dst)
|
|
1354
|
+
written.append({
|
|
1355
|
+
"source": f"(stlc-agents) {src.relative_to(stlc_root)}",
|
|
1356
|
+
"target": str(dst.relative_to(root)),
|
|
1357
|
+
"role": "agent-doc",
|
|
1358
|
+
"change_count": 0,
|
|
1359
|
+
})
|
|
1360
|
+
|
|
1361
|
+
# 4. .mcp.json — MCP server registry (Claude Code / Cursor pick this up)
|
|
1362
|
+
mcp_path = root / ".mcp.json"
|
|
1363
|
+
if not mcp_path.exists() or overwrite:
|
|
1364
|
+
mcp_path.write_text(_render_mcp_config(integration), encoding="utf-8")
|
|
1365
|
+
written.append({
|
|
1366
|
+
"source": "(stlc-agents) auto-generated",
|
|
1367
|
+
"target": ".mcp.json",
|
|
1368
|
+
"role": "mcp-config",
|
|
1369
|
+
"change_count": 0,
|
|
1370
|
+
})
|
|
1371
|
+
|
|
1372
|
+
|
|
1373
|
+
def _render_mcp_config(integration: str) -> str:
|
|
1374
|
+
"""
|
|
1375
|
+
Emit a .mcp.json listing the agent MCP servers for the selected
|
|
1376
|
+
integration. Always includes the shared agents (gherkin / playwright /
|
|
1377
|
+
helix / migration) and the Playwright MCP. ADO/Jira-specific entries
|
|
1378
|
+
are added based on `integration`.
|
|
1379
|
+
"""
|
|
1380
|
+
servers: dict[str, dict] = {
|
|
1381
|
+
"qa-gherkin-generator": {"command": _agent_binary_path("qa-gherkin-generator")},
|
|
1382
|
+
"qa-playwright-generator": {"command": _agent_binary_path("qa-playwright-generator")},
|
|
1383
|
+
"qa-helix-writer": {"command": _agent_binary_path("qa-helix-writer")},
|
|
1384
|
+
"qa-migration": {"command": _agent_binary_path("stlc-migrate")},
|
|
1385
|
+
"playwright": {"command": "npx", "args": ["@playwright/mcp@latest", "--isolated"]},
|
|
1386
|
+
}
|
|
1387
|
+
if integration in ("ado", "both"):
|
|
1388
|
+
servers["qa-test-case-manager"] = {"command": _agent_binary_path("qa-test-case-manager")}
|
|
1389
|
+
if integration in ("jira", "both"):
|
|
1390
|
+
servers["qa-jira-manager"] = {
|
|
1391
|
+
"command": _agent_binary_path("qa-jira-manager"),
|
|
1392
|
+
"env": {
|
|
1393
|
+
"JIRA_CLIENT_ID": "${JIRA_CLIENT_ID}",
|
|
1394
|
+
"JIRA_CLIENT_SECRET": "${JIRA_CLIENT_SECRET}",
|
|
1395
|
+
"JIRA_CLOUD_ID": "${JIRA_CLOUD_ID}",
|
|
1396
|
+
},
|
|
1397
|
+
}
|
|
1398
|
+
return json.dumps({"mcpServers": servers}, indent=2) + "\n"
|