@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.
Files changed (90) hide show
  1. package/ARCHITECTURE-ADO.md +350 -0
  2. package/ARCHITECTURE-JIRA.md +203 -0
  3. package/QUICKSTART-ADO.md +400 -0
  4. package/QUICKSTART-JIRA.md +334 -0
  5. package/README.md +49 -0
  6. package/package.json +18 -6
  7. package/skills/migrate-framework/SKILL.md +207 -0
  8. package/src/stlc_agents/__pycache__/__init__.cpython-313.pyc +0 -0
  9. package/src/stlc_agents/agent_gherkin_generator/__pycache__/__init__.cpython-313.pyc +0 -0
  10. package/src/stlc_agents/agent_gherkin_generator/__pycache__/server.cpython-313.pyc +0 -0
  11. package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/__init__.cpython-313.pyc +0 -0
  12. package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/ado_gherkin.cpython-313.pyc +0 -0
  13. package/src/stlc_agents/agent_helix_writer/__pycache__/__init__.cpython-313.pyc +0 -0
  14. package/src/stlc_agents/agent_helix_writer/__pycache__/server.cpython-313.pyc +0 -0
  15. package/src/stlc_agents/agent_helix_writer/tools/__pycache__/__init__.cpython-313.pyc +0 -0
  16. package/src/stlc_agents/agent_helix_writer/tools/__pycache__/boilerplate.cpython-313.pyc +0 -0
  17. package/src/stlc_agents/agent_helix_writer/tools/__pycache__/helix_write.cpython-313.pyc +0 -0
  18. package/src/stlc_agents/agent_jira_manager/__pycache__/__init__.cpython-313.pyc +0 -0
  19. package/src/stlc_agents/agent_jira_manager/__pycache__/server.cpython-313.pyc +0 -0
  20. package/src/stlc_agents/agent_jira_manager/tools/__pycache__/__init__.cpython-313.pyc +0 -0
  21. package/src/stlc_agents/agent_jira_manager/tools/__pycache__/jira_workitem.cpython-313.pyc +0 -0
  22. package/src/stlc_agents/agent_migration/__init__.py +0 -0
  23. package/src/stlc_agents/agent_migration/__pycache__/__init__.cpython-313.pyc +0 -0
  24. package/src/stlc_agents/agent_migration/__pycache__/_migrate.cpython-313.pyc +0 -0
  25. package/src/stlc_agents/agent_migration/__pycache__/cli.cpython-313.pyc +0 -0
  26. package/src/stlc_agents/agent_migration/__pycache__/detector.cpython-313.pyc +0 -0
  27. package/src/stlc_agents/agent_migration/__pycache__/mapper.cpython-313.pyc +0 -0
  28. package/src/stlc_agents/agent_migration/__pycache__/reporter.cpython-313.pyc +0 -0
  29. package/src/stlc_agents/agent_migration/__pycache__/server.cpython-313.pyc +0 -0
  30. package/src/stlc_agents/agent_migration/_migrate.py +1398 -0
  31. package/src/stlc_agents/agent_migration/cli.py +217 -0
  32. package/src/stlc_agents/agent_migration/detector.py +81 -0
  33. package/src/stlc_agents/agent_migration/mapper.py +439 -0
  34. package/src/stlc_agents/agent_migration/reporter.py +86 -0
  35. package/src/stlc_agents/agent_migration/server.py +267 -0
  36. package/src/stlc_agents/agent_migration/transformer/__init__.py +0 -0
  37. package/src/stlc_agents/agent_migration/transformer/__pycache__/__init__.cpython-313.pyc +0 -0
  38. package/src/stlc_agents/agent_migration/transformer/__pycache__/config_merger.cpython-313.pyc +0 -0
  39. package/src/stlc_agents/agent_migration/transformer/__pycache__/healer_injector.cpython-313.pyc +0 -0
  40. package/src/stlc_agents/agent_migration/transformer/__pycache__/import_fixer.cpython-313.pyc +0 -0
  41. package/src/stlc_agents/agent_migration/transformer/__pycache__/js_to_ts.cpython-313.pyc +0 -0
  42. package/src/stlc_agents/agent_migration/transformer/__pycache__/local_var_hoister.cpython-313.pyc +0 -0
  43. package/src/stlc_agents/agent_migration/transformer/__pycache__/locator_moderniser.cpython-313.pyc +0 -0
  44. package/src/stlc_agents/agent_migration/transformer/__pycache__/locator_registrar.cpython-313.pyc +0 -0
  45. package/src/stlc_agents/agent_migration/transformer/__pycache__/spec_to_bdd.cpython-313.pyc +0 -0
  46. package/src/stlc_agents/agent_migration/transformer/config_merger.py +513 -0
  47. package/src/stlc_agents/agent_migration/transformer/healer_injector.py +1143 -0
  48. package/src/stlc_agents/agent_migration/transformer/import_fixer.py +419 -0
  49. package/src/stlc_agents/agent_migration/transformer/js_to_ts.py +413 -0
  50. package/src/stlc_agents/agent_migration/transformer/local_var_hoister.py +378 -0
  51. package/src/stlc_agents/agent_migration/transformer/locator_moderniser.py +132 -0
  52. package/src/stlc_agents/agent_migration/transformer/locator_registrar.py +328 -0
  53. package/src/stlc_agents/agent_migration/transformer/spec_to_bdd.py +820 -0
  54. package/src/stlc_agents/agent_playwright_generator/__pycache__/__init__.cpython-313.pyc +0 -0
  55. package/src/stlc_agents/agent_playwright_generator/__pycache__/server.cpython-313.pyc +0 -0
  56. package/src/stlc_agents/agent_playwright_generator/server.py +926 -91
  57. package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/__init__.cpython-313.pyc +0 -0
  58. package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/ado_attach.cpython-313.pyc +0 -0
  59. package/src/stlc_agents/agent_test_case_manager/__pycache__/__init__.cpython-313.pyc +0 -0
  60. package/src/stlc_agents/agent_test_case_manager/__pycache__/server.cpython-313.pyc +0 -0
  61. package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/__init__.cpython-313.pyc +0 -0
  62. package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/ado_workitem.cpython-313.pyc +0 -0
  63. package/src/stlc_agents/shared/__pycache__/__init__.cpython-313.pyc +0 -0
  64. package/src/stlc_agents/shared/__pycache__/auth.cpython-313.pyc +0 -0
  65. package/src/stlc_agents/shared/__pycache__/cost_tracker.cpython-313.pyc +0 -0
  66. package/src/stlc_agents/shared/__pycache__/pricing.cpython-313.pyc +0 -0
  67. package/src/stlc_agents/shared_jira/__pycache__/__init__.cpython-313.pyc +0 -0
  68. package/src/stlc_agents/shared_jira/__pycache__/auth.cpython-313.pyc +0 -0
  69. package/src/stlc_agents/webhook_orchestrator/__pycache__/__init__.cpython-313.pyc +0 -0
  70. package/src/stlc_agents/webhook_orchestrator/__pycache__/agent_runner.cpython-313.pyc +0 -0
  71. package/src/stlc_agents/webhook_orchestrator/__pycache__/models.cpython-313.pyc +0 -0
  72. package/src/stlc_agents/webhook_orchestrator/__pycache__/orchestrator.cpython-313.pyc +0 -0
  73. package/src/stlc_agents/webhook_orchestrator/__pycache__/webhook_bridge.cpython-313.pyc +0 -0
  74. package/src/stlc_agents/agent_gherkin_generator/__pycache__/__init__.cpython-314.pyc +0 -0
  75. package/src/stlc_agents/agent_gherkin_generator/__pycache__/server.cpython-314.pyc +0 -0
  76. package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/__init__.cpython-314.pyc +0 -0
  77. package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/ado_gherkin.cpython-314.pyc +0 -0
  78. package/src/stlc_agents/agent_helix_writer/__pycache__/server.cpython-314.pyc +0 -0
  79. package/src/stlc_agents/agent_playwright_generator/__pycache__/__init__.cpython-314.pyc +0 -0
  80. package/src/stlc_agents/agent_playwright_generator/__pycache__/server.cpython-314.pyc +0 -0
  81. package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/__init__.cpython-314.pyc +0 -0
  82. package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/ado_attach.cpython-314.pyc +0 -0
  83. package/src/stlc_agents/agent_test_case_manager/__pycache__/__init__.cpython-314.pyc +0 -0
  84. package/src/stlc_agents/agent_test_case_manager/__pycache__/server.cpython-314.pyc +0 -0
  85. package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/__init__.cpython-314.pyc +0 -0
  86. package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/ado_workitem.cpython-314.pyc +0 -0
  87. package/src/stlc_agents/shared/__pycache__/__init__.cpython-314.pyc +0 -0
  88. package/src/stlc_agents/shared/__pycache__/auth.cpython-314.pyc +0 -0
  89. package/src/stlc_agents/shared/__pycache__/cost_tracker.cpython-314.pyc +0 -0
  90. package/src/stlc_agents/shared/__pycache__/pricing.cpython-314.pyc +0 -0
@@ -0,0 +1,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
+ )