@qa-gentic/stlc-agents 1.0.26 → 1.0.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE-ADO.md +350 -0
- package/ARCHITECTURE-JIRA.md +203 -0
- package/QUICKSTART-ADO.md +400 -0
- package/QUICKSTART-JIRA.md +334 -0
- package/README.md +49 -0
- package/package.json +18 -6
- package/skills/migrate-framework/SKILL.md +207 -0
- package/src/stlc_agents/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/__pycache__/server.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/ado_gherkin.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/__pycache__/server.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/tools/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/tools/__pycache__/boilerplate.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/tools/__pycache__/helix_write.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_jira_manager/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_jira_manager/__pycache__/server.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_jira_manager/tools/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_jira_manager/tools/__pycache__/jira_workitem.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/__init__.py +0 -0
- package/src/stlc_agents/agent_migration/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/__pycache__/_migrate.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/__pycache__/cli.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/__pycache__/detector.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/__pycache__/mapper.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/__pycache__/reporter.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/__pycache__/server.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/_migrate.py +1398 -0
- package/src/stlc_agents/agent_migration/cli.py +217 -0
- package/src/stlc_agents/agent_migration/detector.py +81 -0
- package/src/stlc_agents/agent_migration/mapper.py +439 -0
- package/src/stlc_agents/agent_migration/reporter.py +86 -0
- package/src/stlc_agents/agent_migration/server.py +267 -0
- package/src/stlc_agents/agent_migration/transformer/__init__.py +0 -0
- package/src/stlc_agents/agent_migration/transformer/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/transformer/__pycache__/config_merger.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/transformer/__pycache__/healer_injector.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/transformer/__pycache__/import_fixer.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/transformer/__pycache__/js_to_ts.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/transformer/__pycache__/local_var_hoister.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/transformer/__pycache__/locator_moderniser.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/transformer/__pycache__/locator_registrar.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/transformer/__pycache__/spec_to_bdd.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_migration/transformer/config_merger.py +513 -0
- package/src/stlc_agents/agent_migration/transformer/healer_injector.py +1143 -0
- package/src/stlc_agents/agent_migration/transformer/import_fixer.py +419 -0
- package/src/stlc_agents/agent_migration/transformer/js_to_ts.py +413 -0
- package/src/stlc_agents/agent_migration/transformer/local_var_hoister.py +378 -0
- package/src/stlc_agents/agent_migration/transformer/locator_moderniser.py +132 -0
- package/src/stlc_agents/agent_migration/transformer/locator_registrar.py +328 -0
- package/src/stlc_agents/agent_migration/transformer/spec_to_bdd.py +820 -0
- package/src/stlc_agents/agent_playwright_generator/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/__pycache__/server.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/server.py +926 -91
- package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/ado_attach.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/__pycache__/server.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/ado_workitem.cpython-313.pyc +0 -0
- package/src/stlc_agents/shared/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/shared/__pycache__/auth.cpython-313.pyc +0 -0
- package/src/stlc_agents/shared/__pycache__/cost_tracker.cpython-313.pyc +0 -0
- package/src/stlc_agents/shared/__pycache__/pricing.cpython-313.pyc +0 -0
- package/src/stlc_agents/shared_jira/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/shared_jira/__pycache__/auth.cpython-313.pyc +0 -0
- package/src/stlc_agents/webhook_orchestrator/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/stlc_agents/webhook_orchestrator/__pycache__/agent_runner.cpython-313.pyc +0 -0
- package/src/stlc_agents/webhook_orchestrator/__pycache__/models.cpython-313.pyc +0 -0
- package/src/stlc_agents/webhook_orchestrator/__pycache__/orchestrator.cpython-313.pyc +0 -0
- package/src/stlc_agents/webhook_orchestrator/__pycache__/webhook_bridge.cpython-313.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/__pycache__/server.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/ado_gherkin.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/__pycache__/server.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/__pycache__/server.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/ado_attach.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/__pycache__/server.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/ado_workitem.cpython-314.pyc +0 -0
- package/src/stlc_agents/shared/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/shared/__pycache__/auth.cpython-314.pyc +0 -0
- package/src/stlc_agents/shared/__pycache__/cost_tracker.cpython-314.pyc +0 -0
- package/src/stlc_agents/shared/__pycache__/pricing.cpython-314.pyc +0 -0
|
@@ -0,0 +1,820 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Convert a Playwright JS/TS spec file into a Cucumber Gherkin feature + a step
|
|
3
|
+
definition file — **losslessly** and without fabricating step text.
|
|
4
|
+
|
|
5
|
+
Design principles (zero-inference contract):
|
|
6
|
+
|
|
7
|
+
• Each `test('name', body)` → one `Scenario: name` with one step
|
|
8
|
+
`When name`. The full original body is placed inside that step's
|
|
9
|
+
definition verbatim. We do not invent generic step text like
|
|
10
|
+
"I run step #1" or split arbitrary statements into Given/When/Then.
|
|
11
|
+
|
|
12
|
+
• `test.skip` / `test.only` / `test.fixme` / `test.fail` modifiers
|
|
13
|
+
surface as `@skip` / `@only` / `@fixme` / `@fail` tags on the scenario.
|
|
14
|
+
|
|
15
|
+
• `test.beforeEach` → Cucumber `Before(...)`
|
|
16
|
+
`test.afterEach` → Cucumber `After(...)`
|
|
17
|
+
`test.beforeAll` → Cucumber `BeforeAll(...)`
|
|
18
|
+
`test.afterAll` → Cucumber `AfterAll(...)`
|
|
19
|
+
|
|
20
|
+
• Every statement that lives at top level of a `test.describe(...)`
|
|
21
|
+
body (helpers, classes, top-level `let`/`const`/`var`) is carried
|
|
22
|
+
into the steps file. Variables that are *closure-captured* across
|
|
23
|
+
hooks and tests are hoisted onto Cucumber's World (`this`); everything
|
|
24
|
+
else is emitted at module scope.
|
|
25
|
+
|
|
26
|
+
• Comments and whitespace inside test/hook bodies are preserved.
|
|
27
|
+
|
|
28
|
+
What the converter does NOT do:
|
|
29
|
+
• It does not classify per-statement intent into Given/When/Then.
|
|
30
|
+
• It does not dedupe or rename step texts beyond escaping inner quotes.
|
|
31
|
+
"""
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
import re
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
# Brace / paren matching (string- and comment-aware)
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
def _find_matching_brace(text: str, open_idx: int) -> int:
|
|
42
|
+
assert text[open_idx] == "{"
|
|
43
|
+
depth = 1
|
|
44
|
+
i = open_idx + 1
|
|
45
|
+
in_str: str | None = None
|
|
46
|
+
while i < len(text):
|
|
47
|
+
c = text[i]
|
|
48
|
+
if in_str:
|
|
49
|
+
if c == "\\":
|
|
50
|
+
i += 2
|
|
51
|
+
continue
|
|
52
|
+
if c == in_str:
|
|
53
|
+
in_str = None
|
|
54
|
+
i += 1
|
|
55
|
+
continue
|
|
56
|
+
if c in ('"', "'", "`"):
|
|
57
|
+
in_str = c
|
|
58
|
+
i += 1
|
|
59
|
+
continue
|
|
60
|
+
if c == "/" and i + 1 < len(text):
|
|
61
|
+
if text[i + 1] == "/":
|
|
62
|
+
nl = text.find("\n", i)
|
|
63
|
+
i = nl + 1 if nl >= 0 else len(text)
|
|
64
|
+
continue
|
|
65
|
+
if text[i + 1] == "*":
|
|
66
|
+
end = text.find("*/", i + 2)
|
|
67
|
+
i = end + 2 if end >= 0 else len(text)
|
|
68
|
+
continue
|
|
69
|
+
if c == "{":
|
|
70
|
+
depth += 1
|
|
71
|
+
elif c == "}":
|
|
72
|
+
depth -= 1
|
|
73
|
+
if depth == 0:
|
|
74
|
+
return i
|
|
75
|
+
i += 1
|
|
76
|
+
return -1
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _find_matching_paren(text: str, open_idx: int) -> int:
|
|
80
|
+
assert text[open_idx] == "("
|
|
81
|
+
depth = 1
|
|
82
|
+
i = open_idx + 1
|
|
83
|
+
in_str: str | None = None
|
|
84
|
+
while i < len(text):
|
|
85
|
+
c = text[i]
|
|
86
|
+
if in_str:
|
|
87
|
+
if c == "\\":
|
|
88
|
+
i += 2
|
|
89
|
+
continue
|
|
90
|
+
if c == in_str:
|
|
91
|
+
in_str = None
|
|
92
|
+
i += 1
|
|
93
|
+
continue
|
|
94
|
+
if c in ('"', "'", "`"):
|
|
95
|
+
in_str = c
|
|
96
|
+
i += 1
|
|
97
|
+
continue
|
|
98
|
+
if c == "/" and i + 1 < len(text):
|
|
99
|
+
if text[i + 1] == "/":
|
|
100
|
+
nl = text.find("\n", i)
|
|
101
|
+
i = nl + 1 if nl >= 0 else len(text)
|
|
102
|
+
continue
|
|
103
|
+
if text[i + 1] == "*":
|
|
104
|
+
end = text.find("*/", i + 2)
|
|
105
|
+
i = end + 2 if end >= 0 else len(text)
|
|
106
|
+
continue
|
|
107
|
+
if c == "(":
|
|
108
|
+
depth += 1
|
|
109
|
+
elif c == ")":
|
|
110
|
+
depth -= 1
|
|
111
|
+
if depth == 0:
|
|
112
|
+
return i
|
|
113
|
+
i += 1
|
|
114
|
+
return -1
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
# Data model
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
# Recognised test modifiers and their Gherkin tag equivalents.
|
|
122
|
+
_TEST_MODIFIERS = {"skip", "only", "fixme", "fail", "slow"}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass
|
|
126
|
+
class TestBlock:
|
|
127
|
+
name: str
|
|
128
|
+
body: str
|
|
129
|
+
modifier: str = "" # one of _TEST_MODIFIERS or ""
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass
|
|
133
|
+
class Suite:
|
|
134
|
+
name: str
|
|
135
|
+
before_each: str = ""
|
|
136
|
+
before_all: str = ""
|
|
137
|
+
after_each: str = ""
|
|
138
|
+
after_all: str = ""
|
|
139
|
+
tests: list[TestBlock] = field(default_factory=list)
|
|
140
|
+
shared_vars: set[str] = field(default_factory=set)
|
|
141
|
+
preamble: str = ""
|
|
142
|
+
is_synth: bool = False # True when the file has no top-level test.describe
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
# Spec parsing
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
_DESCRIBE_RE = re.compile(
|
|
150
|
+
r"test\.describe(?:\.\w+)?\s*\(\s*['\"]([^'\"]+)['\"]\s*,",
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# Captures `test(`, `test.skip(`, `test.only(`, `test.fixme(`, `test.fail(`, `test.slow(`.
|
|
154
|
+
_TEST_RE = re.compile(
|
|
155
|
+
r"\btest(?:\.(\w+))?\s*\(\s*['\"]([^'\"]+)['\"]\s*,",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
_HOOK_RE = re.compile(
|
|
159
|
+
r"\btest\.(beforeEach|beforeAll|afterEach|afterAll)\s*\(",
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _strip_async_arrow_prefix(rest: str) -> str:
|
|
164
|
+
"""
|
|
165
|
+
Given text starting INSIDE the parens of a test/hook call, return the
|
|
166
|
+
body of the callback function. Handles a leading title string + comma,
|
|
167
|
+
`async`, `function`, destructured param lists, and arrow vs function
|
|
168
|
+
expressions.
|
|
169
|
+
"""
|
|
170
|
+
i = 0
|
|
171
|
+
n = len(rest)
|
|
172
|
+
while i < n and rest[i].isspace():
|
|
173
|
+
i += 1
|
|
174
|
+
if i < n and rest[i] in ("'", '"'):
|
|
175
|
+
quote = rest[i]
|
|
176
|
+
i += 1
|
|
177
|
+
while i < n and rest[i] != quote:
|
|
178
|
+
if rest[i] == "\\" and i + 1 < n:
|
|
179
|
+
i += 2
|
|
180
|
+
continue
|
|
181
|
+
i += 1
|
|
182
|
+
i += 1
|
|
183
|
+
while i < n and rest[i].isspace():
|
|
184
|
+
i += 1
|
|
185
|
+
if i < n and rest[i] == ",":
|
|
186
|
+
i += 1
|
|
187
|
+
while i < n and rest[i].isspace():
|
|
188
|
+
i += 1
|
|
189
|
+
if rest[i:i+5] == "async":
|
|
190
|
+
i += 5
|
|
191
|
+
while i < n and rest[i].isspace():
|
|
192
|
+
i += 1
|
|
193
|
+
if rest[i:i+8] == "function":
|
|
194
|
+
i += 8
|
|
195
|
+
while i < n and (rest[i].isspace() or rest[i].isalnum() or rest[i] == "_"):
|
|
196
|
+
i += 1
|
|
197
|
+
if i < n and rest[i] == "(":
|
|
198
|
+
end = _find_matching_paren(rest, i)
|
|
199
|
+
if end < 0:
|
|
200
|
+
return ""
|
|
201
|
+
i = end + 1
|
|
202
|
+
while i < n and rest[i].isspace():
|
|
203
|
+
i += 1
|
|
204
|
+
if rest[i:i+2] == "=>":
|
|
205
|
+
i += 2
|
|
206
|
+
while i < n and rest[i].isspace():
|
|
207
|
+
i += 1
|
|
208
|
+
if i < n and rest[i] == "{":
|
|
209
|
+
end = _find_matching_brace(rest, i)
|
|
210
|
+
if end < 0:
|
|
211
|
+
return ""
|
|
212
|
+
return rest[i + 1 : end]
|
|
213
|
+
brace = rest.find("{", i)
|
|
214
|
+
if brace < 0:
|
|
215
|
+
return ""
|
|
216
|
+
end = _find_matching_brace(rest, brace)
|
|
217
|
+
if end < 0:
|
|
218
|
+
return ""
|
|
219
|
+
return rest[brace + 1 : end]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def parse_spec(content: str) -> list[Suite]:
|
|
223
|
+
"""Extract top-level test.describe blocks and the tests inside each."""
|
|
224
|
+
suites: list[Suite] = []
|
|
225
|
+
for m in _DESCRIBE_RE.finditer(content):
|
|
226
|
+
paren_open = content.find("(", m.start())
|
|
227
|
+
if paren_open < 0:
|
|
228
|
+
continue
|
|
229
|
+
paren_close = _find_matching_paren(content, paren_open)
|
|
230
|
+
if paren_close < 0:
|
|
231
|
+
continue
|
|
232
|
+
describe_body = _strip_async_arrow_prefix(content[m.end():paren_close])
|
|
233
|
+
suite = Suite(name=m.group(1))
|
|
234
|
+
_populate_suite_from_body(suite, describe_body)
|
|
235
|
+
suites.append(suite)
|
|
236
|
+
if not suites and _TEST_RE.search(content):
|
|
237
|
+
synth = Suite(name="Migrated tests", is_synth=True)
|
|
238
|
+
_populate_suite_from_body(synth, content)
|
|
239
|
+
if synth.tests:
|
|
240
|
+
suites.append(synth)
|
|
241
|
+
return suites
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# ---------------------------------------------------------------------------
|
|
245
|
+
# Strip Playwright-runner-only calls (test.skip / test.use / test.step / ...)
|
|
246
|
+
# from a body. These have no Cucumber equivalent; the user surfaces intent
|
|
247
|
+
# via `@skip` / `@fixme` tags or step-level conditionals. We comment them out
|
|
248
|
+
# rather than drop so the original intent stays visible in source.
|
|
249
|
+
# ---------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
_RUNNER_CALL_RE = re.compile(
|
|
252
|
+
r"^(\s*)(test\.(?:skip|only|fixme|fail|slow|info|step|use|extend)\b[^\n;]*;?)",
|
|
253
|
+
re.MULTILINE,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _comment_runner_only_calls(body: str) -> str:
|
|
258
|
+
return _RUNNER_CALL_RE.sub(
|
|
259
|
+
lambda m: f"{m.group(1)}// MIGRATION TODO: {m.group(2)}",
|
|
260
|
+
body,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
_IMPORT_LINE_RE = re.compile(
|
|
265
|
+
r"^\s*(?:import\b.*$|(?:const|let|var)\s+(?:\{[^}]*\}|\w+)\s*=\s*require\([^)]*\)\s*;?\s*$)",
|
|
266
|
+
re.MULTILINE,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _strip_imports(body: str) -> str:
|
|
271
|
+
return _IMPORT_LINE_RE.sub("", body)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# ---------------------------------------------------------------------------
|
|
275
|
+
# Statement splitting (used only for hoisting shared vars from describe body)
|
|
276
|
+
# ---------------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
def split_statements(body: str) -> list[str]:
|
|
279
|
+
body = body.strip()
|
|
280
|
+
stmts: list[str] = []
|
|
281
|
+
i = 0
|
|
282
|
+
n = len(body)
|
|
283
|
+
while i < n:
|
|
284
|
+
while i < n and body[i].isspace():
|
|
285
|
+
i += 1
|
|
286
|
+
if i >= n:
|
|
287
|
+
break
|
|
288
|
+
|
|
289
|
+
start = i
|
|
290
|
+
block_kw_match = re.match(
|
|
291
|
+
r"(if|for|while|switch|try)\b",
|
|
292
|
+
body[i:],
|
|
293
|
+
)
|
|
294
|
+
if block_kw_match:
|
|
295
|
+
j = i + block_kw_match.end()
|
|
296
|
+
while j < n and body[j].isspace():
|
|
297
|
+
j += 1
|
|
298
|
+
if j < n and body[j] == "(":
|
|
299
|
+
j = _find_matching_paren(body, j) + 1
|
|
300
|
+
while j < n and body[j].isspace():
|
|
301
|
+
j += 1
|
|
302
|
+
if j < n and body[j] == "{":
|
|
303
|
+
j = _find_matching_brace(body, j) + 1
|
|
304
|
+
while True:
|
|
305
|
+
k = j
|
|
306
|
+
while k < n and body[k].isspace():
|
|
307
|
+
k += 1
|
|
308
|
+
tail = re.match(r"(else\s+if|else|catch|finally)\b", body[k:])
|
|
309
|
+
if not tail:
|
|
310
|
+
break
|
|
311
|
+
k += tail.end()
|
|
312
|
+
while k < n and body[k].isspace():
|
|
313
|
+
k += 1
|
|
314
|
+
if k < n and body[k] == "(":
|
|
315
|
+
k = _find_matching_paren(body, k) + 1
|
|
316
|
+
while k < n and body[k].isspace():
|
|
317
|
+
k += 1
|
|
318
|
+
if k < n and body[k] == "{":
|
|
319
|
+
k = _find_matching_brace(body, k) + 1
|
|
320
|
+
j = k
|
|
321
|
+
stmts.append(body[start:j].strip())
|
|
322
|
+
i = j
|
|
323
|
+
continue
|
|
324
|
+
|
|
325
|
+
# function / class declaration — grab through the matching `}`
|
|
326
|
+
if re.match(r"(?:async\s+)?function\s+\w+\s*\(", body[i:]) or \
|
|
327
|
+
re.match(r"class\s+\w+", body[i:]):
|
|
328
|
+
j = i
|
|
329
|
+
paren_idx = body.find("(", j)
|
|
330
|
+
brace_idx = body.find("{", j)
|
|
331
|
+
if paren_idx >= 0 and (brace_idx < 0 or paren_idx < brace_idx):
|
|
332
|
+
j = _find_matching_paren(body, paren_idx) + 1
|
|
333
|
+
while j < n and body[j].isspace():
|
|
334
|
+
j += 1
|
|
335
|
+
if j < n and body[j] == "{":
|
|
336
|
+
j = _find_matching_brace(body, j) + 1
|
|
337
|
+
stmts.append(body[start:j].strip())
|
|
338
|
+
i = j
|
|
339
|
+
continue
|
|
340
|
+
|
|
341
|
+
depth = 0
|
|
342
|
+
in_str: str | None = None
|
|
343
|
+
j = i
|
|
344
|
+
while j < n:
|
|
345
|
+
c = body[j]
|
|
346
|
+
if in_str:
|
|
347
|
+
if c == "\\":
|
|
348
|
+
j += 2
|
|
349
|
+
continue
|
|
350
|
+
if c == in_str:
|
|
351
|
+
in_str = None
|
|
352
|
+
j += 1
|
|
353
|
+
continue
|
|
354
|
+
if c in ('"', "'", "`"):
|
|
355
|
+
in_str = c
|
|
356
|
+
j += 1
|
|
357
|
+
continue
|
|
358
|
+
if c == "/" and j + 1 < n:
|
|
359
|
+
if body[j + 1] == "/":
|
|
360
|
+
nl = body.find("\n", j)
|
|
361
|
+
j = nl + 1 if nl >= 0 else n
|
|
362
|
+
continue
|
|
363
|
+
if body[j + 1] == "*":
|
|
364
|
+
end = body.find("*/", j + 2)
|
|
365
|
+
j = end + 2 if end >= 0 else n
|
|
366
|
+
continue
|
|
367
|
+
if c in "([{":
|
|
368
|
+
depth += 1
|
|
369
|
+
elif c in ")]}":
|
|
370
|
+
depth -= 1
|
|
371
|
+
elif c == ";" and depth == 0:
|
|
372
|
+
stmts.append(body[start:j].strip())
|
|
373
|
+
j += 1
|
|
374
|
+
break
|
|
375
|
+
j += 1
|
|
376
|
+
else:
|
|
377
|
+
tail = body[start:].strip()
|
|
378
|
+
if tail:
|
|
379
|
+
stmts.append(tail)
|
|
380
|
+
break
|
|
381
|
+
i = j
|
|
382
|
+
return [s for s in stmts if s]
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
# ---------------------------------------------------------------------------
|
|
386
|
+
# Shared-variable lifting (closure capture across hooks/tests)
|
|
387
|
+
# ---------------------------------------------------------------------------
|
|
388
|
+
|
|
389
|
+
def _strip_leading_comments(stmt: str) -> str:
|
|
390
|
+
i = 0
|
|
391
|
+
n = len(stmt)
|
|
392
|
+
while i < n:
|
|
393
|
+
while i < n and stmt[i].isspace():
|
|
394
|
+
i += 1
|
|
395
|
+
if i + 1 < n and stmt[i:i+2] == "//":
|
|
396
|
+
nl = stmt.find("\n", i)
|
|
397
|
+
i = nl + 1 if nl >= 0 else n
|
|
398
|
+
continue
|
|
399
|
+
if i + 1 < n and stmt[i:i+2] == "/*":
|
|
400
|
+
end = stmt.find("*/", i + 2)
|
|
401
|
+
i = end + 2 if end >= 0 else n
|
|
402
|
+
continue
|
|
403
|
+
break
|
|
404
|
+
return stmt[i:]
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _collect_shared_vars(suite_body: str) -> set[str]:
|
|
408
|
+
"""
|
|
409
|
+
Find variable names declared at the top level of the describe body —
|
|
410
|
+
`let foo;`, `let foo = ...;`, `var bar;`, `const baz = ...;`. These are
|
|
411
|
+
shared via closure across hooks and tests, so generated step/hook
|
|
412
|
+
bodies must reference them as `(this as any).foo`.
|
|
413
|
+
"""
|
|
414
|
+
names: set[str] = set()
|
|
415
|
+
for stmt in split_statements(suite_body):
|
|
416
|
+
cleaned = _strip_leading_comments(stmt)
|
|
417
|
+
m = re.match(r"^\s*(?:let|var|const)\s+(\w+)", cleaned)
|
|
418
|
+
if m:
|
|
419
|
+
names.add(m.group(1))
|
|
420
|
+
return names
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _rewrite_shared_refs(body: str, shared: set[str]) -> str:
|
|
424
|
+
if not shared:
|
|
425
|
+
return body
|
|
426
|
+
out = body
|
|
427
|
+
for name in shared:
|
|
428
|
+
out = re.sub(rf"\b(?:let|var|const)\s+{re.escape(name)}\s*;\s*\n?", "", out)
|
|
429
|
+
out = re.sub(
|
|
430
|
+
rf"\b(?:let|var|const)\s+{re.escape(name)}\s*=",
|
|
431
|
+
f"(this as any).{name} =",
|
|
432
|
+
out,
|
|
433
|
+
)
|
|
434
|
+
for name in shared:
|
|
435
|
+
# Spread `...name` → `...(this as any).name`. Must run before the
|
|
436
|
+
# plain-ref rewrite because the lookbehind in that rule excludes `.`,
|
|
437
|
+
# which would skip spread occurrences entirely.
|
|
438
|
+
out = re.sub(
|
|
439
|
+
rf"(?<!\w)\.\.\.{re.escape(name)}\b",
|
|
440
|
+
f"...(this as any).{name}",
|
|
441
|
+
out,
|
|
442
|
+
)
|
|
443
|
+
out = re.sub(
|
|
444
|
+
rf"(?<![.\w])(?<!any\)\.){re.escape(name)}\b(?!\s*:)",
|
|
445
|
+
f"(this as any).{name}",
|
|
446
|
+
out,
|
|
447
|
+
)
|
|
448
|
+
return out
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
# ---------------------------------------------------------------------------
|
|
452
|
+
# Suite body → hooks + tests + preamble
|
|
453
|
+
# ---------------------------------------------------------------------------
|
|
454
|
+
|
|
455
|
+
def _populate_suite_from_body(suite: Suite, body: str) -> None:
|
|
456
|
+
suite.shared_vars = _collect_shared_vars(body)
|
|
457
|
+
suite.preamble = _extract_describe_preamble(body)
|
|
458
|
+
|
|
459
|
+
# Hooks
|
|
460
|
+
for hm in _HOOK_RE.finditer(body):
|
|
461
|
+
kind = hm.group(1)
|
|
462
|
+
paren_open = body.find("(", hm.start())
|
|
463
|
+
paren_close = _find_matching_paren(body, paren_open)
|
|
464
|
+
if paren_close < 0:
|
|
465
|
+
continue
|
|
466
|
+
hook_body = _strip_async_arrow_prefix(body[paren_open + 1 : paren_close])
|
|
467
|
+
attr = {
|
|
468
|
+
"beforeEach": "before_each",
|
|
469
|
+
"beforeAll": "before_all",
|
|
470
|
+
"afterEach": "after_each",
|
|
471
|
+
"afterAll": "after_all",
|
|
472
|
+
}[kind]
|
|
473
|
+
setattr(suite, attr, hook_body)
|
|
474
|
+
|
|
475
|
+
# Tests (incl. modifiers test.skip / test.only / test.fixme / test.fail / test.slow)
|
|
476
|
+
for tm in _TEST_RE.finditer(body):
|
|
477
|
+
modifier = (tm.group(1) or "").strip()
|
|
478
|
+
# Filter out hook calls — _HOOK_RE handled those.
|
|
479
|
+
if modifier in {"beforeEach", "beforeAll", "afterEach", "afterAll", "describe"}:
|
|
480
|
+
continue
|
|
481
|
+
# Filter out non-test method calls accidentally matching `test.<x>(...)`
|
|
482
|
+
# where <x> is something like `step`, `use`, `info`, `extend` — these
|
|
483
|
+
# aren't test definitions. Keep only the known modifiers (and bare test).
|
|
484
|
+
if modifier and modifier not in _TEST_MODIFIERS:
|
|
485
|
+
continue
|
|
486
|
+
paren_open = body.find("(", tm.start())
|
|
487
|
+
paren_close = _find_matching_paren(body, paren_open)
|
|
488
|
+
if paren_close < 0:
|
|
489
|
+
continue
|
|
490
|
+
test_body = _strip_async_arrow_prefix(body[paren_open + 1 : paren_close])
|
|
491
|
+
suite.tests.append(TestBlock(name=tm.group(2), body=test_body, modifier=modifier))
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
# ---------------------------------------------------------------------------
|
|
495
|
+
# Preamble extraction — KEEP EVERYTHING that isn't a test/hook call
|
|
496
|
+
# ---------------------------------------------------------------------------
|
|
497
|
+
|
|
498
|
+
def _extract_describe_preamble(body: str) -> str:
|
|
499
|
+
"""
|
|
500
|
+
Return everything in the describe body that ISN'T a `test(...)` or
|
|
501
|
+
`test.<hook>(...)` call. Helpers (functions, classes), shared `let`/
|
|
502
|
+
`const`/`var` bindings, and stray expressions are all preserved.
|
|
503
|
+
|
|
504
|
+
Variable bindings that are shared via closure across hooks/tests are
|
|
505
|
+
rewritten onto Cucumber's World (`this`) by the caller via
|
|
506
|
+
_rewrite_shared_refs. Statements left here are emitted at module scope.
|
|
507
|
+
"""
|
|
508
|
+
out: list[str] = []
|
|
509
|
+
i = 0
|
|
510
|
+
n = len(body)
|
|
511
|
+
block_start_re = re.compile(r"\btest(?:\.\w+)?\s*\(")
|
|
512
|
+
while i < n:
|
|
513
|
+
m = block_start_re.search(body, i)
|
|
514
|
+
if not m:
|
|
515
|
+
out.append(body[i:])
|
|
516
|
+
break
|
|
517
|
+
out.append(body[i:m.start()])
|
|
518
|
+
paren_open = body.find("(", m.start())
|
|
519
|
+
if paren_open < 0:
|
|
520
|
+
i = m.end()
|
|
521
|
+
continue
|
|
522
|
+
paren_close = _find_matching_paren(body, paren_open)
|
|
523
|
+
if paren_close < 0:
|
|
524
|
+
i = m.end()
|
|
525
|
+
continue
|
|
526
|
+
end = paren_close + 1
|
|
527
|
+
while end < n and body[end] in ";\n\r ":
|
|
528
|
+
end += 1
|
|
529
|
+
i = end
|
|
530
|
+
return "".join(out).strip()
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def extract_module_preamble(content: str) -> str:
|
|
534
|
+
"""
|
|
535
|
+
Module-level code (outside any test.describe/test/hook).
|
|
536
|
+
Imports / requires are stripped — they're re-emitted by rewrite_imports().
|
|
537
|
+
"""
|
|
538
|
+
lines = content.splitlines()
|
|
539
|
+
kept: list[str] = []
|
|
540
|
+
for line in lines:
|
|
541
|
+
stripped = line.strip()
|
|
542
|
+
if stripped.startswith("import ") or "= require(" in stripped or \
|
|
543
|
+
(stripped.startswith("const {") and "require(" in stripped):
|
|
544
|
+
continue
|
|
545
|
+
kept.append(line)
|
|
546
|
+
text = "\n".join(kept)
|
|
547
|
+
|
|
548
|
+
out: list[str] = []
|
|
549
|
+
i = 0
|
|
550
|
+
n = len(text)
|
|
551
|
+
block_start_re = re.compile(r"\btest(?:\.\w+)?\s*\(")
|
|
552
|
+
while i < n:
|
|
553
|
+
m = block_start_re.search(text, i)
|
|
554
|
+
if not m:
|
|
555
|
+
out.append(text[i:])
|
|
556
|
+
break
|
|
557
|
+
out.append(text[i:m.start()])
|
|
558
|
+
paren_open = text.find("(", m.start())
|
|
559
|
+
if paren_open < 0:
|
|
560
|
+
i = m.end()
|
|
561
|
+
continue
|
|
562
|
+
paren_close = _find_matching_paren(text, paren_open)
|
|
563
|
+
if paren_close < 0:
|
|
564
|
+
i = m.end()
|
|
565
|
+
continue
|
|
566
|
+
end = paren_close + 1
|
|
567
|
+
while end < n and text[end] in ";\n\r ":
|
|
568
|
+
end += 1
|
|
569
|
+
i = end
|
|
570
|
+
return "".join(out).strip()
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
# ---------------------------------------------------------------------------
|
|
574
|
+
# Imports
|
|
575
|
+
# ---------------------------------------------------------------------------
|
|
576
|
+
|
|
577
|
+
_REQUIRE_RE = re.compile(
|
|
578
|
+
r"(?:const|let|var)\s+\{([^}]+)\}\s*=\s*require\(\s*['\"]([^'\"]+)['\"]\s*\)\s*;?"
|
|
579
|
+
)
|
|
580
|
+
_DEFAULT_REQUIRE_RE = re.compile(
|
|
581
|
+
r"(?:const|let|var)\s+(\w+)\s*=\s*require\(\s*['\"]([^'\"]+)['\"]\s*\)\s*;?"
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def rewrite_imports(content: str) -> list[str]:
|
|
586
|
+
imports: list[str] = []
|
|
587
|
+
seen: set[str] = set()
|
|
588
|
+
|
|
589
|
+
for m in _REQUIRE_RE.finditer(content):
|
|
590
|
+
names_raw = m.group(1).strip()
|
|
591
|
+
src = m.group(2).strip()
|
|
592
|
+
names = [n.strip() for n in names_raw.split(",") if n.strip()]
|
|
593
|
+
if src == "@playwright/test":
|
|
594
|
+
keep = [n for n in names if n != "test"]
|
|
595
|
+
if not keep:
|
|
596
|
+
continue
|
|
597
|
+
line = f'import {{ {", ".join(keep)} }} from "@playwright/test";'
|
|
598
|
+
else:
|
|
599
|
+
line = f'import {{ {", ".join(names)} }} from "{src}";'
|
|
600
|
+
if line not in seen:
|
|
601
|
+
imports.append(line)
|
|
602
|
+
seen.add(line)
|
|
603
|
+
|
|
604
|
+
for m in _DEFAULT_REQUIRE_RE.finditer(content):
|
|
605
|
+
name = m.group(1)
|
|
606
|
+
src = m.group(2)
|
|
607
|
+
if src == "@playwright/test":
|
|
608
|
+
continue
|
|
609
|
+
line = f'import {name} from "{src}";'
|
|
610
|
+
if line not in seen:
|
|
611
|
+
imports.append(line)
|
|
612
|
+
seen.add(line)
|
|
613
|
+
|
|
614
|
+
return imports
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
# ---------------------------------------------------------------------------
|
|
618
|
+
# Public converter
|
|
619
|
+
# ---------------------------------------------------------------------------
|
|
620
|
+
|
|
621
|
+
def convert_spec_to_bdd(content: str, base_stem: str) -> tuple[str, str, list[str]]:
|
|
622
|
+
"""
|
|
623
|
+
Convert a Playwright spec file → (feature_content, steps_content, changes).
|
|
624
|
+
|
|
625
|
+
One scenario per test, one step per scenario. The test name is reused as
|
|
626
|
+
both the scenario name and the step text (escaping inner quotes only).
|
|
627
|
+
The original test body is preserved verbatim inside that single step.
|
|
628
|
+
Hooks become real Cucumber Before/After/BeforeAll/AfterAll. Test
|
|
629
|
+
modifiers (.skip / .only / .fixme / .fail / .slow) surface as Gherkin tags.
|
|
630
|
+
"""
|
|
631
|
+
changes: list[str] = []
|
|
632
|
+
suites = parse_spec(content)
|
|
633
|
+
if not suites:
|
|
634
|
+
return "", "", []
|
|
635
|
+
|
|
636
|
+
# ── Imports & module-level preamble ──────────────────────────────────────
|
|
637
|
+
source_imports = rewrite_imports(content)
|
|
638
|
+
pw_imports = [imp for imp in source_imports if '"@playwright/test"' in imp]
|
|
639
|
+
if not pw_imports:
|
|
640
|
+
source_imports.insert(0, 'import { expect } from "@playwright/test";')
|
|
641
|
+
|
|
642
|
+
module_preamble = extract_module_preamble(content)
|
|
643
|
+
|
|
644
|
+
steps_lines: list[str] = [
|
|
645
|
+
'import { Before, After, BeforeAll, AfterAll, Given, When, Then } from "@cucumber/cucumber";',
|
|
646
|
+
'import { fixture } from "@hooks/pageFixture";',
|
|
647
|
+
*source_imports,
|
|
648
|
+
"",
|
|
649
|
+
]
|
|
650
|
+
module_preamble = _comment_runner_only_calls(module_preamble)
|
|
651
|
+
if module_preamble:
|
|
652
|
+
steps_lines.append("// ── Module-level helpers carried over from the source spec ──")
|
|
653
|
+
steps_lines.append(module_preamble)
|
|
654
|
+
steps_lines.append("")
|
|
655
|
+
|
|
656
|
+
# ── Feature header ───────────────────────────────────────────────────────
|
|
657
|
+
feature_title = suites[0].name
|
|
658
|
+
feature_lines: list[str] = [f"Feature: {feature_title}", ""]
|
|
659
|
+
|
|
660
|
+
# Track scenario-name uniqueness inside this feature; if two tests share
|
|
661
|
+
# the same name, suffix with index so Cucumber can resolve them. We do not
|
|
662
|
+
# invent names — only disambiguate identical ones.
|
|
663
|
+
seen_scenarios: dict[str, int] = {}
|
|
664
|
+
# Same for step text inside the steps file.
|
|
665
|
+
seen_steps: set[str] = set()
|
|
666
|
+
# Step text is prefixed with the file slug to keep it unique across
|
|
667
|
+
# features (Cucumber registers step text globally — two specs each with a
|
|
668
|
+
# test named "Wizard Test" would otherwise collide).
|
|
669
|
+
step_prefix = f"[{base_stem}] " if base_stem else ""
|
|
670
|
+
|
|
671
|
+
for suite in suites:
|
|
672
|
+
# When the source file has no top-level test.describe, the synth
|
|
673
|
+
# suite's preamble overlaps entirely with the module preamble we
|
|
674
|
+
# already emitted — skip it to avoid duplicate declarations.
|
|
675
|
+
if not suite.is_synth:
|
|
676
|
+
suite_preamble = _strip_imports(suite.preamble)
|
|
677
|
+
suite_preamble = _comment_runner_only_calls(suite_preamble)
|
|
678
|
+
suite_preamble = _rewrite_shared_refs(suite_preamble, suite.shared_vars)
|
|
679
|
+
if suite_preamble.strip():
|
|
680
|
+
steps_lines.append(f"// ── Suite-scope code carried over from describe('{suite.name}') ──")
|
|
681
|
+
steps_lines.append(suite_preamble)
|
|
682
|
+
steps_lines.append("")
|
|
683
|
+
|
|
684
|
+
# Hooks → real Cucumber hooks.
|
|
685
|
+
def _hook_body(b: str) -> str:
|
|
686
|
+
return _comment_runner_only_calls(_rewrite_shared_refs(b, suite.shared_vars))
|
|
687
|
+
|
|
688
|
+
if suite.before_all.strip():
|
|
689
|
+
steps_lines.append(_render_hook(
|
|
690
|
+
"BeforeAll", suite.name, _hook_body(suite.before_all), bind_page=False,
|
|
691
|
+
))
|
|
692
|
+
if suite.before_each.strip():
|
|
693
|
+
steps_lines.append(_render_hook(
|
|
694
|
+
"Before", suite.name, _hook_body(suite.before_each),
|
|
695
|
+
))
|
|
696
|
+
if suite.after_each.strip():
|
|
697
|
+
steps_lines.append(_render_hook(
|
|
698
|
+
"After", suite.name, _hook_body(suite.after_each),
|
|
699
|
+
))
|
|
700
|
+
if suite.after_all.strip():
|
|
701
|
+
steps_lines.append(_render_hook(
|
|
702
|
+
"AfterAll", suite.name, _hook_body(suite.after_all), bind_page=False,
|
|
703
|
+
))
|
|
704
|
+
|
|
705
|
+
# One scenario per test.
|
|
706
|
+
for test in suite.tests:
|
|
707
|
+
# Disambiguate identical test names (rare; preserves source intent).
|
|
708
|
+
base_name = test.name
|
|
709
|
+
n = seen_scenarios.get(base_name, 0)
|
|
710
|
+
seen_scenarios[base_name] = n + 1
|
|
711
|
+
scenario_name = base_name if n == 0 else f"{base_name} #{n + 1}"
|
|
712
|
+
|
|
713
|
+
tag = _modifier_to_tag(test.modifier)
|
|
714
|
+
if tag:
|
|
715
|
+
feature_lines.append(f" {tag}")
|
|
716
|
+
# Normalize whitespace once so the feature line and step
|
|
717
|
+
# definition string are byte-identical. Gherkin's own parser
|
|
718
|
+
# collapses inner whitespace and strips trailing whitespace,
|
|
719
|
+
# so the step definition must match that normalized form to
|
|
720
|
+
# bind correctly at runtime.
|
|
721
|
+
step_text = _escape_gherkin(step_prefix + scenario_name)
|
|
722
|
+
feature_lines.append(f" Scenario: {scenario_name}")
|
|
723
|
+
feature_lines.append(f" When {step_text}")
|
|
724
|
+
feature_lines.append("")
|
|
725
|
+
|
|
726
|
+
# Step body = test body verbatim, with shared-var rewriting and
|
|
727
|
+
# Playwright-runner-only calls commented out.
|
|
728
|
+
step_body = _rewrite_shared_refs(test.body, suite.shared_vars)
|
|
729
|
+
step_body = _comment_runner_only_calls(step_body)
|
|
730
|
+
|
|
731
|
+
if step_text in seen_steps:
|
|
732
|
+
changes.append(f"duplicate step text suppressed: {step_text}")
|
|
733
|
+
else:
|
|
734
|
+
seen_steps.add(step_text)
|
|
735
|
+
steps_lines.append(_render_step("When", step_text, step_body))
|
|
736
|
+
changes.append(f"converted test → scenario: {scenario_name}")
|
|
737
|
+
|
|
738
|
+
feature_content = "\n".join(feature_lines).rstrip() + "\n"
|
|
739
|
+
steps_content = "\n".join(steps_lines).rstrip() + "\n"
|
|
740
|
+
return feature_content, steps_content, changes
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
# ---------------------------------------------------------------------------
|
|
744
|
+
# Rendering helpers
|
|
745
|
+
# ---------------------------------------------------------------------------
|
|
746
|
+
|
|
747
|
+
def _modifier_to_tag(modifier: str) -> str:
|
|
748
|
+
if not modifier:
|
|
749
|
+
return ""
|
|
750
|
+
if modifier in {"skip", "only", "fixme", "fail", "slow"}:
|
|
751
|
+
return f"@{modifier}"
|
|
752
|
+
return ""
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _escape_gherkin(text: str) -> str:
|
|
756
|
+
"""Gherkin step text is a single line — collapse newlines, keep quotes."""
|
|
757
|
+
return re.sub(r"\s+", " ", text).strip()
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
_CUKE_EXPR_META_RE = re.compile(r"[/(){}]")
|
|
761
|
+
# JavaScript regex metacharacters that must be backslash-escaped inside a
|
|
762
|
+
# `/.../ ` literal. Note `-` and ` ` are NOT in this set (Python's `re.escape`
|
|
763
|
+
# escapes them, but those backslashes are illegal in JS regex syntax).
|
|
764
|
+
_JS_REGEX_ESCAPE_RE = re.compile(r"[.*+?^${}()|\[\]\\/]")
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def _js_regex_escape(text: str) -> str:
|
|
768
|
+
return _JS_REGEX_ESCAPE_RE.sub(lambda m: "\\" + m.group(0), text)
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def _render_step(keyword: str, text: str, body: str) -> str:
|
|
772
|
+
indented = "\n".join((" " + ln) if ln.strip() else "" for ln in body.splitlines())
|
|
773
|
+
# Cucumber Expressions treat `/` as alternation and `{...}` / `(...)` as
|
|
774
|
+
# parameter / optional groups. When the step text contains any of those,
|
|
775
|
+
# render the step as a RegExp literal so the original text is matched
|
|
776
|
+
# verbatim instead of being parsed.
|
|
777
|
+
if _CUKE_EXPR_META_RE.search(text):
|
|
778
|
+
regex_src = _js_regex_escape(text)
|
|
779
|
+
return (
|
|
780
|
+
f'{keyword}(/^{regex_src}$/, async function () {{\n'
|
|
781
|
+
f' const page = fixture().page;\n'
|
|
782
|
+
f'{indented}\n'
|
|
783
|
+
f'}});\n'
|
|
784
|
+
)
|
|
785
|
+
text_esc = text.replace("\\", "\\\\").replace('"', '\\"')
|
|
786
|
+
return (
|
|
787
|
+
f'{keyword}("{text_esc}", async function () {{\n'
|
|
788
|
+
f' const page = fixture().page;\n'
|
|
789
|
+
f'{indented}\n'
|
|
790
|
+
f'}});\n'
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def _render_hook(kind: str, suite_name: str, body: str, bind_page: bool = True) -> str:
|
|
795
|
+
"""
|
|
796
|
+
Render a Cucumber Before/After/BeforeAll/AfterAll hook.
|
|
797
|
+
|
|
798
|
+
BeforeAll/AfterAll do not have a per-scenario `page`, so we skip the
|
|
799
|
+
fixture binding in those.
|
|
800
|
+
|
|
801
|
+
Note: Cucumber's BeforeAll/AfterAll run once per process, not once per
|
|
802
|
+
feature — the closest faithful mapping for Playwright's test.beforeAll.
|
|
803
|
+
Per-feature scoping can be added later via tagged Before hooks if needed.
|
|
804
|
+
"""
|
|
805
|
+
indented = "\n".join((" " + ln) if ln.strip() else "" for ln in body.splitlines())
|
|
806
|
+
header = f"// ── Hook: {kind} carried over from describe('{suite_name}') ──"
|
|
807
|
+
if bind_page:
|
|
808
|
+
return (
|
|
809
|
+
f"{header}\n"
|
|
810
|
+
f"{kind}(async function () {{\n"
|
|
811
|
+
f" const page = fixture().page;\n"
|
|
812
|
+
f"{indented}\n"
|
|
813
|
+
f"}});\n"
|
|
814
|
+
)
|
|
815
|
+
return (
|
|
816
|
+
f"{header}\n"
|
|
817
|
+
f"{kind}(async function () {{\n"
|
|
818
|
+
f"{indented}\n"
|
|
819
|
+
f"}});\n"
|
|
820
|
+
)
|