@qa-gentic/stlc-agents 1.0.27 → 1.0.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE-ADO.md +350 -0
- package/ARCHITECTURE-JIRA.md +203 -0
- package/QUICKSTART-ADO.md +400 -0
- package/QUICKSTART-JIRA.md +334 -0
- package/README.md +49 -0
- package/package.json +18 -6
- package/skills/migrate-framework/SKILL.md +207 -0
- package/src/stlc_agents/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/__pycache__/server.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/ado_gherkin.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/__pycache__/server.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/tools/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/tools/__pycache__/boilerplate.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/tools/__pycache__/helix_write.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_jira_manager/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_jira_manager/__pycache__/server.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_jira_manager/tools/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_jira_manager/tools/__pycache__/jira_workitem.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/__init__.py +0 -0
- package/src/stlc_agents/agent_migration/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/__pycache__/_migrate.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/__pycache__/cli.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/__pycache__/detector.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/__pycache__/mapper.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/__pycache__/reporter.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/__pycache__/server.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/_migrate.py +1398 -0
- package/src/stlc_agents/agent_migration/cli.py +217 -0
- package/src/stlc_agents/agent_migration/detector.py +81 -0
- package/src/stlc_agents/agent_migration/mapper.py +439 -0
- package/src/stlc_agents/agent_migration/reporter.py +86 -0
- package/src/stlc_agents/agent_migration/server.py +267 -0
- package/src/stlc_agents/agent_migration/transformer/__init__.py +0 -0
- package/src/stlc_agents/agent_migration/transformer/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/transformer/__pycache__/config_merger.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/transformer/__pycache__/healer_injector.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/transformer/__pycache__/import_fixer.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/transformer/__pycache__/js_to_ts.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/transformer/__pycache__/local_var_hoister.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/transformer/__pycache__/locator_moderniser.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/transformer/__pycache__/locator_registrar.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/transformer/__pycache__/spec_to_bdd.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/transformer/config_merger.py +513 -0
- package/src/stlc_agents/agent_migration/transformer/healer_injector.py +1143 -0
- package/src/stlc_agents/agent_migration/transformer/import_fixer.py +419 -0
- package/src/stlc_agents/agent_migration/transformer/js_to_ts.py +413 -0
- package/src/stlc_agents/agent_migration/transformer/local_var_hoister.py +378 -0
- package/src/stlc_agents/agent_migration/transformer/locator_moderniser.py +132 -0
- package/src/stlc_agents/agent_migration/transformer/locator_registrar.py +328 -0
- package/src/stlc_agents/agent_migration/transformer/spec_to_bdd.py +820 -0
- package/src/stlc_agents/agent_playwright_generator/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/__pycache__/server.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/server.py +926 -91
- package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/ado_attach.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/__pycache__/server.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/ado_workitem.cpython-313.pyc +0 -0
- package/src/stlc_agents/shared/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/shared/__pycache__/auth.cpython-313.pyc +0 -0
- package/src/stlc_agents/shared/__pycache__/cost_tracker.cpython-313.pyc +0 -0
- package/src/stlc_agents/shared/__pycache__/pricing.cpython-313.pyc +0 -0
- package/src/stlc_agents/shared_jira/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/shared_jira/__pycache__/auth.cpython-313.pyc +0 -0
- package/src/stlc_agents/webhook_orchestrator/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/webhook_orchestrator/__pycache__/agent_runner.cpython-313.pyc +0 -0
- package/src/stlc_agents/webhook_orchestrator/__pycache__/models.cpython-313.pyc +0 -0
- package/src/stlc_agents/webhook_orchestrator/__pycache__/orchestrator.cpython-313.pyc +0 -0
- package/src/stlc_agents/webhook_orchestrator/__pycache__/webhook_bridge.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/__pycache__/server.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/ado_gherkin.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/__pycache__/server.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/__pycache__/server.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/ado_attach.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/__pycache__/server.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/ado_workitem.cpython-314.pyc +0 -0
- package/src/stlc_agents/shared/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/shared/__pycache__/auth.cpython-314.pyc +0 -0
- package/src/stlc_agents/shared/__pycache__/cost_tracker.cpython-314.pyc +0 -0
- package/src/stlc_agents/shared/__pycache__/pricing.cpython-314.pyc +0 -0
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
"""Merge source playwright/cucumber config values into Helix equivalents."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import textwrap
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# ---------------------------------------------------------------------------
|
|
10
|
+
# Playwright config
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
def merge_playwright_config(source_content: str, helix_root: str) -> dict:
|
|
14
|
+
"""
|
|
15
|
+
Extract key settings from a source playwright.config.js/ts and merge
|
|
16
|
+
them into the Helix playwright.config.ts.
|
|
17
|
+
|
|
18
|
+
Returns { content: str, target: str, changes: list[str] }
|
|
19
|
+
"""
|
|
20
|
+
settings = _extract_playwright_settings(source_content)
|
|
21
|
+
helix_cfg = Path(helix_root) / "playwright.config.ts"
|
|
22
|
+
|
|
23
|
+
if helix_cfg.exists():
|
|
24
|
+
existing = helix_cfg.read_text(encoding="utf-8")
|
|
25
|
+
merged, changes = _merge_pw_into_existing(existing, settings)
|
|
26
|
+
else:
|
|
27
|
+
merged = _generate_pw_config(settings)
|
|
28
|
+
changes = ["Generated new playwright.config.ts from source settings"]
|
|
29
|
+
|
|
30
|
+
return {"content": merged, "target": "playwright.config.ts", "changes": changes}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _extract_playwright_settings(cfg: str) -> dict:
|
|
34
|
+
settings: dict = {}
|
|
35
|
+
|
|
36
|
+
_find = lambda pattern: (re.search(pattern, cfg) or None) and re.search(pattern, cfg).group(1)
|
|
37
|
+
|
|
38
|
+
raw_url = _find(r"baseURL\s*:\s*['\"]([^'\"]+)['\"]")
|
|
39
|
+
if raw_url:
|
|
40
|
+
settings["baseURL"] = raw_url
|
|
41
|
+
|
|
42
|
+
raw_timeout = _find(r"\btimeout\s*:\s*(\d+)")
|
|
43
|
+
if raw_timeout:
|
|
44
|
+
settings["timeout"] = int(raw_timeout)
|
|
45
|
+
|
|
46
|
+
raw_retries = _find(r"\bretries\s*:\s*(\d+)")
|
|
47
|
+
if raw_retries:
|
|
48
|
+
settings["retries"] = int(raw_retries)
|
|
49
|
+
|
|
50
|
+
raw_workers = _find(r"\bworkers\s*:\s*(\d+)")
|
|
51
|
+
if raw_workers:
|
|
52
|
+
settings["workers"] = int(raw_workers)
|
|
53
|
+
|
|
54
|
+
for k in ("screenshot", "video", "trace"):
|
|
55
|
+
m = re.search(rf"\b{k}\s*:\s*['\"]([^'\"]+)['\"]", cfg)
|
|
56
|
+
if m:
|
|
57
|
+
settings[k] = m.group(1)
|
|
58
|
+
|
|
59
|
+
return settings
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _merge_pw_into_existing(existing: str, settings: dict) -> tuple[str, list[str]]:
|
|
63
|
+
changes: list[str] = []
|
|
64
|
+
result = existing
|
|
65
|
+
|
|
66
|
+
for key, value in settings.items():
|
|
67
|
+
if re.search(rf"\b{re.escape(key)}\s*:", result):
|
|
68
|
+
continue # already present
|
|
69
|
+
|
|
70
|
+
val_str = f"'{value}'" if isinstance(value, str) else str(value)
|
|
71
|
+
|
|
72
|
+
if key in ("baseURL", "screenshot", "video", "trace"):
|
|
73
|
+
use_m = re.search(r"(use\s*:\s*\{)", result, re.DOTALL)
|
|
74
|
+
if use_m:
|
|
75
|
+
pos = use_m.end()
|
|
76
|
+
result = result[:pos] + f"\n {key}: {val_str}," + result[pos:]
|
|
77
|
+
changes.append(f"Merged use.{key} = {val_str}")
|
|
78
|
+
continue
|
|
79
|
+
|
|
80
|
+
# Top-level setting
|
|
81
|
+
close = result.rfind("}")
|
|
82
|
+
if close >= 0:
|
|
83
|
+
result = result[:close] + f" {key}: {val_str},\n" + result[close:]
|
|
84
|
+
changes.append(f"Merged top-level {key} = {val_str}")
|
|
85
|
+
|
|
86
|
+
return result, changes
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _generate_pw_config(settings: dict) -> str:
|
|
90
|
+
base_url = settings.get("baseURL", "http://localhost:3000")
|
|
91
|
+
timeout = settings.get("timeout", 30_000)
|
|
92
|
+
retries = settings.get("retries", 1)
|
|
93
|
+
workers = settings.get("workers", 1)
|
|
94
|
+
ss = settings.get("screenshot", "only-on-failure")
|
|
95
|
+
video = settings.get("video", "retain-on-failure")
|
|
96
|
+
trace = settings.get("trace", "on-first-retry")
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
"import { defineConfig } from '@playwright/test';\n\n"
|
|
100
|
+
"export default defineConfig({\n"
|
|
101
|
+
" testDir: './src/test',\n"
|
|
102
|
+
f" timeout: {timeout},\n"
|
|
103
|
+
f" retries: {retries},\n"
|
|
104
|
+
f" workers: {workers},\n"
|
|
105
|
+
" use: {\n"
|
|
106
|
+
f" baseURL: '{base_url}',\n"
|
|
107
|
+
f" screenshot: '{ss}',\n"
|
|
108
|
+
f" video: '{video}',\n"
|
|
109
|
+
f" trace: '{trace}',\n"
|
|
110
|
+
" },\n"
|
|
111
|
+
"});\n"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
# Cucumber config
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
def merge_cucumber_config(source_content: str, helix_root: str) -> dict:
|
|
120
|
+
"""
|
|
121
|
+
Extract profile definitions from a source cucumber.js/cjs/mjs and emit
|
|
122
|
+
a Helix cucumber.js at config/cucumber.js.
|
|
123
|
+
|
|
124
|
+
The file is intentionally written as CommonJS .js (NOT .ts) because:
|
|
125
|
+
• cucumber-js auto-loads its config file BEFORE ts-node is registered,
|
|
126
|
+
so a .ts config can't be parsed unless callers manually bootstrap ts-node.
|
|
127
|
+
• Keeping the path stable (config/cucumber.js) means existing npm scripts
|
|
128
|
+
that reference `--config=config/cucumber.js` continue to work after migration.
|
|
129
|
+
|
|
130
|
+
Returns { content: str, target: str, changes: list[str] }
|
|
131
|
+
"""
|
|
132
|
+
profiles = _extract_cucumber_profiles(source_content)
|
|
133
|
+
target = "config/cucumber.js"
|
|
134
|
+
|
|
135
|
+
changes = []
|
|
136
|
+
|
|
137
|
+
if not profiles:
|
|
138
|
+
return {"content": "module.exports = {};\n", "target": target, "changes": []}
|
|
139
|
+
|
|
140
|
+
# Build fresh content — regenerate each profile as properly indented JavaScript
|
|
141
|
+
lines = ["module.exports = {"]
|
|
142
|
+
for name, body in profiles.items():
|
|
143
|
+
changes.append(f"Merged cucumber profile '{name}'")
|
|
144
|
+
dedented = textwrap.dedent(body).strip()
|
|
145
|
+
# Ensure dotenv loads BEFORE ts-node compiles any step / page-object
|
|
146
|
+
# module. Without this, `.env` is never read and runtime config knobs
|
|
147
|
+
# like HEADLESS, HEAL_STORE_PATH, ENABLE_AI_HEALING etc. silently
|
|
148
|
+
# fall back to their compile-time defaults.
|
|
149
|
+
dedented, injected = _ensure_dotenv_in_require_module(dedented)
|
|
150
|
+
if injected:
|
|
151
|
+
changes.append(f"Injected dotenv/config into '{name}' requireModule")
|
|
152
|
+
indented_body = "\n".join(
|
|
153
|
+
" " + ln if ln.strip() else ln
|
|
154
|
+
for ln in dedented.splitlines()
|
|
155
|
+
)
|
|
156
|
+
lines.append(f" {name}: {{")
|
|
157
|
+
lines.append(indented_body)
|
|
158
|
+
lines.append(" },")
|
|
159
|
+
lines.append("};\n")
|
|
160
|
+
|
|
161
|
+
return {"content": "\n".join(lines), "target": target, "changes": changes}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _ensure_dotenv_in_require_module(body: str) -> tuple[str, bool]:
|
|
165
|
+
"""
|
|
166
|
+
Prepend `"dotenv/config"` to the profile's `requireModule: [ ... ]` array
|
|
167
|
+
so dotenv loads before ts-node / tsconfig-paths bootstrap. If the profile
|
|
168
|
+
has no requireModule key, leave the body alone (signals a non-TS profile
|
|
169
|
+
that doesn't need dotenv pre-loading either).
|
|
170
|
+
|
|
171
|
+
Returns (new_body, injected_flag).
|
|
172
|
+
"""
|
|
173
|
+
# Already present — nothing to do.
|
|
174
|
+
if re.search(r'requireModule\s*:\s*\[[^\]]*["\']dotenv/config["\']', body):
|
|
175
|
+
return body, False
|
|
176
|
+
pattern = re.compile(r'(requireModule\s*:\s*\[\s*)')
|
|
177
|
+
new, n = pattern.subn(r'\1"dotenv/config", ', body, count=1)
|
|
178
|
+
if n == 0:
|
|
179
|
+
return body, False
|
|
180
|
+
return new, True
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _extract_cucumber_profiles(cfg: str) -> dict[str, str]:
|
|
184
|
+
"""
|
|
185
|
+
Extract top-level profile blocks using brace counting so nested objects
|
|
186
|
+
(e.g. formatOptions: { ... }) are captured correctly.
|
|
187
|
+
"""
|
|
188
|
+
profiles: dict[str, str] = {}
|
|
189
|
+
# Skip JS builtins/keywords that appear as object/variable names, but NOT
|
|
190
|
+
# "default" — that is a valid cucumber profile name (module.exports.default).
|
|
191
|
+
_skip = {"module", "exports", "require", "const", "let", "var"}
|
|
192
|
+
|
|
193
|
+
# Find each <name>: { at the top-level of the outer object
|
|
194
|
+
key_re = re.compile(r"\b(\w+)\s*:\s*\{")
|
|
195
|
+
i = 0
|
|
196
|
+
while i < len(cfg):
|
|
197
|
+
m = key_re.search(cfg, i)
|
|
198
|
+
if not m:
|
|
199
|
+
break
|
|
200
|
+
name = m.group(1)
|
|
201
|
+
brace_start = m.end() - 1 # position of the opening '{'
|
|
202
|
+
|
|
203
|
+
# Count braces to find the matching closing '}'
|
|
204
|
+
depth = 0
|
|
205
|
+
j = brace_start
|
|
206
|
+
while j < len(cfg):
|
|
207
|
+
if cfg[j] == "{":
|
|
208
|
+
depth += 1
|
|
209
|
+
elif cfg[j] == "}":
|
|
210
|
+
depth -= 1
|
|
211
|
+
if depth == 0:
|
|
212
|
+
break
|
|
213
|
+
j += 1
|
|
214
|
+
|
|
215
|
+
# Keep leading newline so textwrap.dedent can detect common indent
|
|
216
|
+
body = cfg[brace_start + 1: j]
|
|
217
|
+
|
|
218
|
+
if name not in _skip:
|
|
219
|
+
profiles[name] = body
|
|
220
|
+
|
|
221
|
+
i = j + 1
|
|
222
|
+
|
|
223
|
+
return profiles
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ---------------------------------------------------------------------------
|
|
227
|
+
# package.json — merge npm scripts
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
def merge_package_json(source_content: str, helix_root: str) -> dict:
|
|
231
|
+
"""
|
|
232
|
+
Merge source package.json into the Helix one, preserving everything needed
|
|
233
|
+
to actually run the migrated test suite:
|
|
234
|
+
|
|
235
|
+
• scripts that invoke cucumber-js / playwright / cross-env
|
|
236
|
+
• dependencies — required at runtime by the migrated code
|
|
237
|
+
• devDependencies — required to build / type-check / install browsers
|
|
238
|
+
• engines / type / private fields if present
|
|
239
|
+
|
|
240
|
+
Existing Helix entries are NEVER overwritten (target wins on conflict).
|
|
241
|
+
|
|
242
|
+
Returns { content: str, target: str, changes: list[str] }
|
|
243
|
+
"""
|
|
244
|
+
target = "package.json"
|
|
245
|
+
target_path = Path(helix_root) / target
|
|
246
|
+
changes: list[str] = []
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
source_pkg = json.loads(source_content)
|
|
250
|
+
except Exception as exc:
|
|
251
|
+
return {"content": source_content, "target": target,
|
|
252
|
+
"changes": [f"Could not parse source package.json: {exc}"]}
|
|
253
|
+
|
|
254
|
+
if target_path.exists():
|
|
255
|
+
try:
|
|
256
|
+
target_pkg = json.loads(target_path.read_text(encoding="utf-8"))
|
|
257
|
+
except Exception:
|
|
258
|
+
target_pkg = _default_pkg()
|
|
259
|
+
else:
|
|
260
|
+
target_pkg = _default_pkg()
|
|
261
|
+
|
|
262
|
+
# ── scripts: bring over runner scripts only ─────────────────────────────
|
|
263
|
+
tgt_scripts = target_pkg.setdefault("scripts", {})
|
|
264
|
+
_RUNNERS = ("cucumber-js", "playwright", "cross-env")
|
|
265
|
+
for name, cmd in (source_pkg.get("scripts") or {}).items():
|
|
266
|
+
if name in tgt_scripts:
|
|
267
|
+
continue
|
|
268
|
+
if not any(r in cmd for r in _RUNNERS):
|
|
269
|
+
continue
|
|
270
|
+
tgt_scripts[name] = cmd
|
|
271
|
+
changes.append(f"Merged npm script '{name}'")
|
|
272
|
+
|
|
273
|
+
# ── healix dashboard launchers ──────────────────────────────────────────
|
|
274
|
+
# `healix:dashboard` starts the full read/write HealingDashboard at
|
|
275
|
+
# HEALING_DASHBOARD_PORT (default 7890).
|
|
276
|
+
# `healix:review` starts just the read-only review server at
|
|
277
|
+
# HEALIX_REVIEW_PORT (default 7891). Both honor whatever values are in
|
|
278
|
+
# .env — the only difference is `healix:review` disables the dashboard
|
|
279
|
+
# write port by default so QA leads can't accidentally approve/reject
|
|
280
|
+
# while running the review-only view.
|
|
281
|
+
_LAUNCHER = (
|
|
282
|
+
"ts-node -r tsconfig-paths/register src/utils/locators/dashboard-server.ts"
|
|
283
|
+
)
|
|
284
|
+
_HEALIX_SCRIPTS = {
|
|
285
|
+
"healix:dashboard": _LAUNCHER,
|
|
286
|
+
"healix:review": f"cross-env HEALING_DASHBOARD_PORT=0 HEALIX_REVIEW_PORT=${{HEALIX_REVIEW_PORT:-7891}} {_LAUNCHER}",
|
|
287
|
+
}
|
|
288
|
+
for name, cmd in _HEALIX_SCRIPTS.items():
|
|
289
|
+
if name in tgt_scripts:
|
|
290
|
+
continue
|
|
291
|
+
tgt_scripts[name] = cmd
|
|
292
|
+
changes.append(f"Added healix npm script: {name}")
|
|
293
|
+
|
|
294
|
+
# ── dependencies + devDependencies: bring over all unless already set ──
|
|
295
|
+
for key in ("dependencies", "devDependencies"):
|
|
296
|
+
src_block = source_pkg.get(key) or {}
|
|
297
|
+
if not src_block:
|
|
298
|
+
continue
|
|
299
|
+
tgt_block = target_pkg.setdefault(key, {})
|
|
300
|
+
for dep, version in src_block.items():
|
|
301
|
+
if dep in tgt_block:
|
|
302
|
+
continue
|
|
303
|
+
tgt_block[dep] = version
|
|
304
|
+
changes.append(f"Merged {key[:-1]}: {dep}@{version}")
|
|
305
|
+
|
|
306
|
+
# ── ensure the migrated framework's runtime deps are present ───────────
|
|
307
|
+
# winston is required by every healer-injected page object / step def.
|
|
308
|
+
deps = target_pkg.setdefault("dependencies", {})
|
|
309
|
+
if "winston" not in deps and "winston" not in target_pkg.get("devDependencies", {}):
|
|
310
|
+
deps["winston"] = "^3.13.0"
|
|
311
|
+
changes.append("Added runtime dep: winston (healer logging)")
|
|
312
|
+
# dotenv is required so the .env at the migrated tree root is loaded
|
|
313
|
+
# BEFORE any module reads process.env. The generated cucumber.js wires
|
|
314
|
+
# it into `requireModule` (see merge_cucumber_config), but the package
|
|
315
|
+
# itself must be installable too.
|
|
316
|
+
if "dotenv" not in deps and "dotenv" not in target_pkg.get("devDependencies", {}):
|
|
317
|
+
deps["dotenv"] = "^16.4.5"
|
|
318
|
+
changes.append("Added runtime dep: dotenv (.env loader for cucumber)")
|
|
319
|
+
|
|
320
|
+
# ── TypeScript toolchain — migrated tree is always .ts ─────────────────
|
|
321
|
+
# We always emit TypeScript step defs / page objects / locators, so the
|
|
322
|
+
# migrated project must be able to build and run them. cucumber-js needs
|
|
323
|
+
# ts-node to load .ts files; tsconfig-paths resolves @-aliases at runtime.
|
|
324
|
+
dev_deps = target_pkg.setdefault("devDependencies", {})
|
|
325
|
+
_REQUIRED_DEV: dict[str, str] = {
|
|
326
|
+
"typescript": "^5.4.5",
|
|
327
|
+
"ts-node": "^10.9.2",
|
|
328
|
+
"@cucumber/cucumber": "^10.8.0",
|
|
329
|
+
"@types/node": "^20.14.2",
|
|
330
|
+
"tsconfig-paths": "^4.2.0",
|
|
331
|
+
}
|
|
332
|
+
for dep, version in _REQUIRED_DEV.items():
|
|
333
|
+
if dep not in dev_deps and dep not in deps:
|
|
334
|
+
dev_deps[dep] = version
|
|
335
|
+
changes.append(f"Added devDependency: {dep}@{version} (TS/BDD toolchain)")
|
|
336
|
+
|
|
337
|
+
# ── @types/* companions for JS-only packages the source depends on ─────
|
|
338
|
+
# Curated list of packages that don't ship TypeScript declarations and
|
|
339
|
+
# have an `@types/<name>` counterpart on DefinitelyTyped. When the source
|
|
340
|
+
# uses one of these, the migrated tree needs the types for tsc to resolve
|
|
341
|
+
# the imports under TypeScript. We only add the @types/* when:
|
|
342
|
+
# 1. The package itself appears in deps or devDeps (source or merged), AND
|
|
343
|
+
# 2. The @types counterpart isn't already declared (anywhere).
|
|
344
|
+
#
|
|
345
|
+
# Versions are intentionally generic (`*`) — types packages are loosely
|
|
346
|
+
# versioned and don't need to match the runtime version exactly.
|
|
347
|
+
_TYPES_FOR: dict[str, str] = {
|
|
348
|
+
"fs-extra": "^11.0.0",
|
|
349
|
+
"lodash": "^4.17.0",
|
|
350
|
+
"glob": "^8.1.0",
|
|
351
|
+
"mkdirp": "^2.0.0",
|
|
352
|
+
"rimraf": "^4.0.0",
|
|
353
|
+
"uuid": "^9.0.0",
|
|
354
|
+
"js-yaml": "^4.0.0",
|
|
355
|
+
"jsonwebtoken": "^9.0.0",
|
|
356
|
+
"module-alias": "^2.0.0",
|
|
357
|
+
"cookie": "^0.6.0",
|
|
358
|
+
"express": "^4.17.0",
|
|
359
|
+
"cors": "^2.8.0",
|
|
360
|
+
"node-fetch": "^2.6.0",
|
|
361
|
+
"minimist": "^1.2.0",
|
|
362
|
+
"yargs": "^17.0.0",
|
|
363
|
+
"semver": "^7.5.0",
|
|
364
|
+
}
|
|
365
|
+
all_pkg_names = set(deps.keys()) | set(dev_deps.keys())
|
|
366
|
+
src_dep_names: set[str] = set()
|
|
367
|
+
for key in ("dependencies", "devDependencies"):
|
|
368
|
+
src_dep_names.update((source_pkg.get(key) or {}).keys())
|
|
369
|
+
needs_types = all_pkg_names | src_dep_names
|
|
370
|
+
for js_pkg, types_version in _TYPES_FOR.items():
|
|
371
|
+
if js_pkg not in needs_types:
|
|
372
|
+
continue
|
|
373
|
+
types_pkg = f"@types/{js_pkg}"
|
|
374
|
+
if types_pkg in dev_deps or types_pkg in deps:
|
|
375
|
+
continue
|
|
376
|
+
dev_deps[types_pkg] = types_version
|
|
377
|
+
changes.append(
|
|
378
|
+
f"Added devDependency: {types_pkg}@{types_version} "
|
|
379
|
+
f"(TS types for {js_pkg})"
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
# ── single-field passthroughs the user almost always wants ─────────────
|
|
383
|
+
for field in ("type", "private", "engines"):
|
|
384
|
+
if field in source_pkg and field not in target_pkg:
|
|
385
|
+
target_pkg[field] = source_pkg[field]
|
|
386
|
+
changes.append(f"Carried over field: {field}")
|
|
387
|
+
|
|
388
|
+
content = json.dumps(target_pkg, indent=2) + "\n"
|
|
389
|
+
return {"content": content, "target": target, "changes": changes}
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _default_pkg() -> dict:
|
|
393
|
+
return {
|
|
394
|
+
"name": "helix-qa",
|
|
395
|
+
"version": "1.0.0",
|
|
396
|
+
"scripts": {},
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
# ---------------------------------------------------------------------------
|
|
401
|
+
# tsconfig.json — preserve source compiler options, ensure Helix path aliases
|
|
402
|
+
# ---------------------------------------------------------------------------
|
|
403
|
+
|
|
404
|
+
_HELIX_PATHS = {
|
|
405
|
+
"@pages/*": ["pages/*"],
|
|
406
|
+
"@steps/*": ["test/steps/*"],
|
|
407
|
+
"@features/*": ["test/features/*"],
|
|
408
|
+
"@locators/*": ["locators/*"],
|
|
409
|
+
"@config/*": ["config/*"],
|
|
410
|
+
"@utils/*": ["utils/*"],
|
|
411
|
+
"@helper/*": ["helper/*"],
|
|
412
|
+
"@hooks/*": ["hooks/*"],
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def merge_tsconfig_json(source_content: str, helix_root: str) -> dict:
|
|
417
|
+
"""
|
|
418
|
+
Take the source tsconfig.json as a baseline and ensure the Helix path
|
|
419
|
+
aliases (@pages, @steps, @helper, @hooks, ...) are all present. Source
|
|
420
|
+
options are preserved verbatim — we only add what's truly missing for
|
|
421
|
+
the migrated tree to load (path aliases, baseUrl).
|
|
422
|
+
|
|
423
|
+
Notably: we do NOT add `strict: true` or `strictNullChecks: false`. The
|
|
424
|
+
strictness setting is a project-wide policy decision that belongs to the
|
|
425
|
+
source author; introducing strictness the source didn't have would
|
|
426
|
+
surface cascading errors on code that compiled fine before migration.
|
|
427
|
+
If the source omitted `strict`, the migrated project also omits it
|
|
428
|
+
(TypeScript's default is non-strict).
|
|
429
|
+
|
|
430
|
+
Returns { content: str, target: str, changes: list[str] }
|
|
431
|
+
"""
|
|
432
|
+
target = "tsconfig.json"
|
|
433
|
+
changes: list[str] = []
|
|
434
|
+
|
|
435
|
+
try:
|
|
436
|
+
# Strip // and /* */ comments — tsconfig allows them, json doesn't
|
|
437
|
+
cleaned = _strip_jsonc(source_content)
|
|
438
|
+
cfg = json.loads(cleaned)
|
|
439
|
+
except Exception as exc:
|
|
440
|
+
# If parsing failed, fall back to a minimal Helix tsconfig.
|
|
441
|
+
cfg = {}
|
|
442
|
+
changes.append(f"Could not parse source tsconfig.json ({exc}); using defaults")
|
|
443
|
+
|
|
444
|
+
opts = cfg.setdefault("compilerOptions", {})
|
|
445
|
+
|
|
446
|
+
# baseUrl + paths — needed so @-aliased imports resolve under src/.
|
|
447
|
+
opts.setdefault("baseUrl", "src")
|
|
448
|
+
paths = opts.setdefault("paths", {})
|
|
449
|
+
for alias, mapping in _HELIX_PATHS.items():
|
|
450
|
+
if alias not in paths:
|
|
451
|
+
paths[alias] = mapping
|
|
452
|
+
changes.append(f"Added tsconfig alias: {alias}")
|
|
453
|
+
|
|
454
|
+
# Toolchain defaults — only when source omits them, so the migrated
|
|
455
|
+
# project can at least parse and emit. These are non-policy options
|
|
456
|
+
# (output format, lib targeting) so adding them is safe.
|
|
457
|
+
opts.setdefault("skipLibCheck", True)
|
|
458
|
+
opts.setdefault("esModuleInterop", True)
|
|
459
|
+
opts.setdefault("resolveJsonModule", True)
|
|
460
|
+
opts.setdefault("target", "ES2021")
|
|
461
|
+
opts.setdefault("module", "commonjs")
|
|
462
|
+
opts.setdefault("lib", ["dom", "es2021"])
|
|
463
|
+
|
|
464
|
+
if "include" not in cfg:
|
|
465
|
+
cfg["include"] = ["src"]
|
|
466
|
+
changes.append("Set include: ['src']")
|
|
467
|
+
|
|
468
|
+
content = json.dumps(cfg, indent=2) + "\n"
|
|
469
|
+
return {"content": content, "target": target, "changes": changes}
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _strip_jsonc(text: str) -> str:
|
|
473
|
+
"""Strip line- and block-comments so json.loads can parse tsconfig."""
|
|
474
|
+
out: list[str] = []
|
|
475
|
+
i = 0
|
|
476
|
+
n = len(text)
|
|
477
|
+
in_str = False
|
|
478
|
+
str_ch = ""
|
|
479
|
+
while i < n:
|
|
480
|
+
c = text[i]
|
|
481
|
+
if in_str:
|
|
482
|
+
out.append(c)
|
|
483
|
+
if c == "\\" and i + 1 < n:
|
|
484
|
+
out.append(text[i + 1])
|
|
485
|
+
i += 2
|
|
486
|
+
continue
|
|
487
|
+
if c == str_ch:
|
|
488
|
+
in_str = False
|
|
489
|
+
i += 1
|
|
490
|
+
continue
|
|
491
|
+
if c in ('"', "'"):
|
|
492
|
+
in_str = True
|
|
493
|
+
str_ch = c
|
|
494
|
+
out.append(c)
|
|
495
|
+
i += 1
|
|
496
|
+
continue
|
|
497
|
+
if c == "/" and i + 1 < n:
|
|
498
|
+
if text[i + 1] == "/":
|
|
499
|
+
# line comment — skip to end of line
|
|
500
|
+
j = text.find("\n", i)
|
|
501
|
+
if j < 0:
|
|
502
|
+
break
|
|
503
|
+
i = j
|
|
504
|
+
continue
|
|
505
|
+
if text[i + 1] == "*":
|
|
506
|
+
j = text.find("*/", i + 2)
|
|
507
|
+
if j < 0:
|
|
508
|
+
break
|
|
509
|
+
i = j + 2
|
|
510
|
+
continue
|
|
511
|
+
out.append(c)
|
|
512
|
+
i += 1
|
|
513
|
+
return "".join(out)
|