siesa-agents 2.1.72-qa.13 → 2.1.72-qa.15
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/package.json +1 -1
- package/siesa-agents/bmm/workflows/3-solutioning/quality-process/workflow.md +24 -3
- package/siesa-agents/scripts/bmad_to_agiletest.py +239 -408
- package/siesa-agents/scripts/playwright/yaml-to-playwright-spec.ts +203 -195
- package/siesa-agents/scripts/__pycache__/bmad_to_agiletest.cpython-36.pyc +0 -0
- package/siesa-agents/scripts/__pycache__/merge_test_design.cpython-36.pyc +0 -0
package/package.json
CHANGED
|
@@ -935,9 +935,20 @@ Después de guardar `shards/test-design-phase4-test-matrix.md` (Sección V — M
|
|
|
935
935
|
**Exportar el diseño completo a YAML estructurado:**
|
|
936
936
|
|
|
937
937
|
Además del `test-design.md` (narrativo) y el `test-cases.csv` (casos tabulares), generar un
|
|
938
|
-
`test-design.yml` que represente **todo el diseño en formato datos** (machine-readable).
|
|
939
|
-
|
|
940
|
-
|
|
938
|
+
`test-design.yml` que represente **todo el diseño en formato datos** (machine-readable).
|
|
939
|
+
|
|
940
|
+
> **🚨 OBLIGATORIO — también (y especialmente) en Modo Completo / batch.** El `test-design.yml`
|
|
941
|
+
> NO es opcional: es el insumo directo de AgileTest (Fase 5), del Motor de Datos (Fase 4) y del
|
|
942
|
+
> Agente Ejecutor E2E (Fase 6). Debe generarse para el batch diseñado en esta corrida, sin importar
|
|
943
|
+
> cuántos casos tenga la matriz.
|
|
944
|
+
>
|
|
945
|
+
> **Fuente de verdad = los shards ya guardados, NO la memoria.** En corridas largas (Modo Completo
|
|
946
|
+
> con varios features) la matriz puede haber salido del contexto. Para construir el YAML de forma
|
|
947
|
+
> robusta, **re-lee los archivos recién escritos** en `shards/` — sobre todo
|
|
948
|
+
> `shards/test-design-phase4-test-matrix.md` (Sección V — Matriz) y los demás shards (I, II/III, VI)
|
|
949
|
+
> — y serialízalos. Así el `.yml` no depende de que el diseño siga "en memoria".
|
|
950
|
+
|
|
951
|
+
Serializar a YAML con esta estructura:
|
|
941
952
|
|
|
942
953
|
```yaml
|
|
943
954
|
metadata:
|
|
@@ -1024,6 +1035,16 @@ traceability: # Apéndice — Matriz de Trazabilidad
|
|
|
1024
1035
|
|
|
1025
1036
|
Guardar como: `{implementation_artifacts}/quality-process/diseno/test-design-YYYY-MM-DD-HHmmss/test-design.yml` (en la raíz, junto a `test-cases.csv`, NO en shards/)
|
|
1026
1037
|
|
|
1038
|
+
**Verificación obligatoria (guard) — no completar la Fase 3 sin el YAML:**
|
|
1039
|
+
|
|
1040
|
+
Tras el Write, **verificar que `test-design.yml` exista y no esté vacío** (y que su `test_matrix`
|
|
1041
|
+
tenga el mismo número de entradas que filas tiene la matriz del CSV/Markdown):
|
|
1042
|
+
|
|
1043
|
+
- Si el archivo **no existe, quedó vacío o truncado** (caso típico en batch grande): **regenerarlo
|
|
1044
|
+
re-leyendo los shards** (`shards/test-design-phase4-test-matrix.md` + I, II/III, VI) y volver a
|
|
1045
|
+
escribirlo. Repetir hasta que el `.yml` esté completo.
|
|
1046
|
+
- **No avanzar a F3.5** ni reportar la fase como completada mientras falte el `test-design.yml`.
|
|
1047
|
+
|
|
1027
1048
|
---
|
|
1028
1049
|
|
|
1029
1050
|
### F3.5: Completion
|
|
@@ -1,22 +1,25 @@
|
|
|
1
1
|
"""
|
|
2
|
-
bmad_to_agiletest.py — Parser + Cargador de artefactos BMAD
|
|
2
|
+
bmad_to_agiletest.py — Parser + Cargador de artefactos BMAD V7.8 → AgileTest/Jira
|
|
3
3
|
|
|
4
|
-
Lee
|
|
5
|
-
- Stories
|
|
6
|
-
- FACs Gherkin desde
|
|
7
|
-
- Test Cases
|
|
8
|
-
- Traceability desde
|
|
9
|
-
Luego puede crear todo en Jira/AgileTest via API REST.
|
|
4
|
+
Lee el `test-design.yml` (fuente única de verdad, BMAD V7.8) con yaml.safe_load y extrae:
|
|
5
|
+
- Stories desde `gatekeeper[]`
|
|
6
|
+
- FACs Gherkin desde `features[].fac[]`
|
|
7
|
+
- Test Cases desde `test_matrix[]` (con `steps[]` atómicos: paso/accion/resultado/datos)
|
|
8
|
+
- Traceability desde `traceability[]`
|
|
9
|
+
Luego puede crear todo en Jira/AgileTest via API REST. Cada `step` lleva su `data` (datos por
|
|
10
|
+
paso del YAML), de modo que los Test Steps en AgileTest quedan con su testData real.
|
|
10
11
|
|
|
11
12
|
Uso:
|
|
12
13
|
python bmad_to_agiletest.py --dry-run # Parsea y muestra resumen
|
|
13
14
|
python bmad_to_agiletest.py --dry-run --detail # Muestra cada TC con steps
|
|
14
15
|
python bmad_to_agiletest.py --dry-run --export # Exporta a JSON
|
|
15
|
-
python bmad_to_agiletest.py --input
|
|
16
|
+
python bmad_to_agiletest.py --input test-design.yml --dry-run # Archivo custom (.yml)
|
|
16
17
|
python bmad_to_agiletest.py --create # Crea en Jira/AgileTest
|
|
17
18
|
|
|
19
|
+
Requiere: PyYAML (`pip install pyyaml`).
|
|
20
|
+
|
|
18
21
|
Autor: Juan Manuel Reina Montoya — Lider QA, SIESA
|
|
19
|
-
Fecha: 29 marzo 2026 | Actualizado:
|
|
22
|
+
Fecha: 29 marzo 2026 | Actualizado: 11 junio 2026 (V7.8 — lectura YAML vía yaml.safe_load)
|
|
20
23
|
"""
|
|
21
24
|
|
|
22
25
|
import re
|
|
@@ -53,8 +56,8 @@ def _http_with_retry(fn, max_retries=4, base_delay=1.5):
|
|
|
53
56
|
# ============================================================
|
|
54
57
|
|
|
55
58
|
CONFIG = {
|
|
56
|
-
# Archivo BMAD
|
|
57
|
-
'input_file': '
|
|
59
|
+
# Archivo BMAD V7.8 — test-design.yml (ruta relativa al script)
|
|
60
|
+
'input_file': 'test-design.yml',
|
|
58
61
|
|
|
59
62
|
# Jira
|
|
60
63
|
'jira_base_url': 'https://siesa-team.atlassian.net',
|
|
@@ -134,101 +137,85 @@ class TestCase:
|
|
|
134
137
|
steps: list = field(default_factory=list)
|
|
135
138
|
|
|
136
139
|
# ============================================================
|
|
137
|
-
#
|
|
140
|
+
# Loader: test-design.yml (BMAD V7.8) via yaml.safe_load
|
|
138
141
|
# ============================================================
|
|
139
142
|
|
|
140
|
-
def
|
|
141
|
-
"""
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
143
|
+
def load_design(yaml_path: str) -> dict:
|
|
144
|
+
"""Carga el test-design.yml (V7.8) como dict. Requiere PyYAML."""
|
|
145
|
+
try:
|
|
146
|
+
import yaml
|
|
147
|
+
except ImportError:
|
|
148
|
+
print(' ERROR: falta PyYAML. Instala con: pip install pyyaml')
|
|
149
|
+
sys.exit(1)
|
|
150
|
+
with open(yaml_path, 'r', encoding='utf-8') as f:
|
|
151
|
+
data = yaml.safe_load(f)
|
|
152
|
+
if not isinstance(data, dict):
|
|
153
|
+
raise ValueError('test-design.yml no tiene un mapeo (dict) en la raiz — revisa el YAML')
|
|
154
|
+
return data
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def build_metadata(design: dict) -> dict:
|
|
158
|
+
"""Deriva meta {feature, feature_code, date, author, mode} desde metadata + features."""
|
|
159
|
+
md = design.get('metadata', {}) or {}
|
|
160
|
+
features = design.get('features', []) or []
|
|
161
|
+
project = md.get('project_name') or md.get('project') or ''
|
|
162
|
+
# feature_code: un solo feature -> su code; varios (Modo Completo) -> el proyecto
|
|
163
|
+
if len(features) == 1 and features[0].get('code'):
|
|
164
|
+
feature_code = features[0]['code']
|
|
165
|
+
feature = features[0].get('name') or project
|
|
166
|
+
else:
|
|
167
|
+
feature_code = project or 'BMAD'
|
|
168
|
+
feature = project or 'BMAD'
|
|
169
|
+
return {
|
|
170
|
+
'feature': feature,
|
|
171
|
+
'feature_code': feature_code,
|
|
172
|
+
'date': str(md.get('generated_date', '')),
|
|
173
|
+
'author': '',
|
|
174
|
+
'mode': md.get('mode', ''),
|
|
175
|
+
}
|
|
159
176
|
|
|
160
177
|
# ============================================================
|
|
161
|
-
#
|
|
178
|
+
# Builder: gatekeeper[] -> Stories
|
|
162
179
|
# ============================================================
|
|
163
180
|
|
|
164
|
-
def
|
|
165
|
-
"""
|
|
181
|
+
def build_stories(design: dict) -> list:
|
|
182
|
+
"""Construye Stories desde `gatekeeper[]` del YAML V7.8.
|
|
166
183
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
- Quality-process: Story X.Y (ej: Story 2.3)
|
|
184
|
+
Cada entrada: {story_id, name, feature, classification, justification}.
|
|
185
|
+
Clasificacion BL ('BL' / 'Logica de Negocio' / '✅') -> risk_level High.
|
|
170
186
|
"""
|
|
171
187
|
stories = []
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
gk_match = re.search(
|
|
175
|
-
r'## I\.\s*REPORTE DEL GATEKEEPER.*?\n(.*?)(?=\n---\s*\n\n## II|\n## Resumen|\n# FASE 2|\Z)',
|
|
176
|
-
content, re.DOTALL
|
|
177
|
-
)
|
|
178
|
-
if not gk_match:
|
|
179
|
-
return stories
|
|
180
|
-
|
|
181
|
-
section = gk_match.group(1)
|
|
182
|
-
|
|
183
|
-
for line in section.split('\n'):
|
|
184
|
-
line = line.strip()
|
|
185
|
-
if not line.startswith('|'):
|
|
188
|
+
for e in design.get('gatekeeper', []) or []:
|
|
189
|
+
if not isinstance(e, dict):
|
|
186
190
|
continue
|
|
187
|
-
|
|
188
|
-
if
|
|
189
|
-
continue
|
|
190
|
-
story_id = cols[0].strip()
|
|
191
|
-
|
|
192
|
-
# Detectar formato del ID de historia
|
|
193
|
-
is_classic = bool(re.search(r'E\d+-S\d+', story_id)) # FEATURE-E001-S001
|
|
194
|
-
# quality-process: "Story 2.3" o, en V7.8, el numero pelado "2.3"
|
|
195
|
-
is_qp = bool(re.match(r'(?:Story\s+)?\d+\.\d+\s*$', story_id))
|
|
196
|
-
|
|
197
|
-
if not is_classic and not is_qp:
|
|
191
|
+
story_id = str(e.get('story_id') or e.get('id') or '').strip()
|
|
192
|
+
if not story_id:
|
|
198
193
|
continue
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
# - 5 cols (V7.8): ID | Nombre/Epica | Feature | Clasificacion | Justificacion
|
|
204
|
-
if len(cols) >= 5:
|
|
205
|
-
classification = cols[3].strip()
|
|
206
|
-
justification = cols[4].strip()
|
|
207
|
-
else:
|
|
208
|
-
classification = cols[2].strip()
|
|
209
|
-
justification = cols[3].strip() if len(cols) > 3 else ''
|
|
194
|
+
title = str(e.get('name', '')).strip()
|
|
195
|
+
justification = str(e.get('justification', '')).strip()
|
|
196
|
+
classification = str(e.get('classification', '')).strip()
|
|
197
|
+
feature = str(e.get('feature', '')).strip()
|
|
210
198
|
|
|
211
199
|
# Epic ID
|
|
212
|
-
if
|
|
213
|
-
|
|
214
|
-
epic_id =
|
|
200
|
+
if re.search(r'E\d+-S\d+', story_id):
|
|
201
|
+
em = re.search(r'(.*-E\d+)', story_id)
|
|
202
|
+
epic_id = em.group(1) if em else ''
|
|
215
203
|
else:
|
|
216
|
-
|
|
217
|
-
epic_id = f'Epic {
|
|
204
|
+
em = re.match(r'(?:Story\s+)?(\d+)\.\d+', story_id)
|
|
205
|
+
epic_id = f'Epic {em.group(1)}' if em else (feature or '')
|
|
218
206
|
|
|
219
|
-
# Layer
|
|
207
|
+
# Layer (heuristica por contenido)
|
|
208
|
+
lower_all = (title + ' ' + justification + ' ' + feature).lower()
|
|
220
209
|
layer = 'Backend'
|
|
221
|
-
lower_all = (title + justification).lower()
|
|
222
210
|
if any(kw in lower_all for kw in [
|
|
223
211
|
'frontend', 'react', 'ui ', 'typescript', 'hook', 'page',
|
|
224
212
|
'drawer', 'form', 'navigation', 'fe+be', 'fe ',
|
|
225
213
|
]):
|
|
226
214
|
layer = 'Frontend'
|
|
227
215
|
|
|
228
|
-
# Clasificación BL: soporta "BL", "Lógica de Negocio", "Logica de Negocio", "✅"
|
|
229
216
|
is_bl = (
|
|
230
217
|
'BL' in classification or
|
|
231
|
-
'gica de Negocio' in classification or #
|
|
218
|
+
'gica de Negocio' in classification or # Logica / Lógica de Negocio
|
|
232
219
|
'✅' in classification
|
|
233
220
|
)
|
|
234
221
|
|
|
@@ -241,332 +228,178 @@ def parse_stories(content: str, feature_code: str) -> list:
|
|
|
241
228
|
layer=layer,
|
|
242
229
|
risk_level='High' if is_bl else 'Low',
|
|
243
230
|
))
|
|
244
|
-
|
|
245
231
|
return stories
|
|
246
232
|
|
|
247
233
|
# ============================================================
|
|
248
|
-
#
|
|
234
|
+
# Builder: features[].fac[] -> FACs Gherkin
|
|
249
235
|
# ============================================================
|
|
250
236
|
|
|
251
|
-
def
|
|
252
|
-
"""
|
|
253
|
-
|
|
254
|
-
Soporta dos convenciones de ID observadas en archivos reales:
|
|
255
|
-
- ID en el titulo del Scenario: 'Scenario: F1-FAC-001 ...'
|
|
256
|
-
- ID en un comentario previo: '# FAC-F1-F01: ...' seguido de
|
|
257
|
-
'Scenario: <descripcion>'
|
|
258
|
-
"""
|
|
237
|
+
def build_facs(design: dict) -> dict:
|
|
238
|
+
"""Construye dict fac_id -> texto Gherkin desde `features[].fac[]`."""
|
|
259
239
|
facs = {}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
# - V7.8: 'Scenario: FAC-FND-01 — ...' (FAC/NF + codigo de feature)
|
|
266
|
-
id_in_title = re.compile(
|
|
267
|
-
r'Scenario(?:\s+Outline)?:\s*(F\d+-(?:FAC|NF)-\d+|(?:FAC|NF)-[A-Z]+-\d+)\b'
|
|
268
|
-
)
|
|
269
|
-
# ID en un comentario Gherkin (formato por fases): '# FAC-F1-F01' o '# F1-FAC-001'
|
|
270
|
-
id_in_comment = re.compile(r'#\s*((?:FAC|NF)-F\d+-\w+|F\d+-(?:FAC|NF)-\d+)\b')
|
|
271
|
-
is_scenario = re.compile(r'Scenario(?:\s+Outline)?:')
|
|
272
|
-
|
|
273
|
-
for block in gherkin_blocks:
|
|
274
|
-
pending_id = None # id de comentario aun sin asociar a un Scenario
|
|
275
|
-
current_id = None
|
|
276
|
-
current_lines = []
|
|
277
|
-
|
|
278
|
-
def flush():
|
|
279
|
-
nonlocal current_id, current_lines
|
|
280
|
-
if current_id and current_lines:
|
|
281
|
-
facs[current_id] = '\n'.join(current_lines).strip()
|
|
282
|
-
current_id = None
|
|
283
|
-
current_lines = []
|
|
284
|
-
|
|
285
|
-
for raw in block.split('\n'):
|
|
286
|
-
line = raw.strip()
|
|
287
|
-
if is_scenario.match(line):
|
|
288
|
-
flush()
|
|
289
|
-
title_m = id_in_title.match(line)
|
|
290
|
-
if title_m:
|
|
291
|
-
current_id = title_m.group(1)
|
|
292
|
-
else:
|
|
293
|
-
current_id = pending_id # usar el id del comentario previo
|
|
294
|
-
pending_id = None
|
|
295
|
-
current_lines = [line]
|
|
296
|
-
continue
|
|
297
|
-
# Comentario con ID de FAC (solo fuera de un Scenario en curso)
|
|
298
|
-
cmt = id_in_comment.match(line)
|
|
299
|
-
if cmt and current_id is None:
|
|
300
|
-
pending_id = cmt.group(1)
|
|
240
|
+
for feat in design.get('features', []) or []:
|
|
241
|
+
if not isinstance(feat, dict):
|
|
242
|
+
continue
|
|
243
|
+
for fac in feat.get('fac', []) or []:
|
|
244
|
+
if not isinstance(fac, dict):
|
|
301
245
|
continue
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
246
|
+
fid = str(fac.get('id', '')).strip()
|
|
247
|
+
gherkin = str(fac.get('gherkin', '')).strip()
|
|
248
|
+
if fid:
|
|
249
|
+
facs[fid] = gherkin
|
|
306
250
|
return facs
|
|
307
251
|
|
|
308
252
|
# ============================================================
|
|
309
|
-
#
|
|
253
|
+
# Builder: test_matrix[] -> Test Cases (con steps atomicos)
|
|
310
254
|
# ============================================================
|
|
311
255
|
|
|
312
|
-
def
|
|
313
|
-
"""
|
|
256
|
+
def build_test_cases(design: dict) -> list:
|
|
257
|
+
"""Construye Test Cases desde `test_matrix[]` (V7.8).
|
|
314
258
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
- Clasico (12 cols): ID | Funcionalidad | Features | Nivel | Tecnica |
|
|
318
|
-
Escenario | Precondiciones | Pasos | Resultado | Riesgo | Prioridad | Estrategia
|
|
319
|
-
- V7.8 (13 cols): ID | Feature | Epicas | Modo | Nivel | Tipo Interfaz |
|
|
320
|
-
Tecnica | Escenario | Precondiciones | Pasos | Resultado | Riesgo | Prioridad
|
|
259
|
+
Cada caso trae `steps[]` como array de {paso, accion, resultado, datos}. Cada step
|
|
260
|
+
se mapea a un TestStep con su `data` (datos por paso) — insumo del testData de AgileTest.
|
|
321
261
|
"""
|
|
322
|
-
def col(i):
|
|
323
|
-
return cols[i].strip() if len(cols) > i else ''
|
|
324
|
-
|
|
325
|
-
tc_id = col(0)
|
|
326
|
-
modo = ''
|
|
327
|
-
tipo_interfaz = ''
|
|
328
|
-
if len(cols) >= 13:
|
|
329
|
-
# Layout V7.8 (13 columnas): Modo y Tipo Interfaz insertadas
|
|
330
|
-
feature = col(1)
|
|
331
|
-
epics = col(2)
|
|
332
|
-
modo = col(3)
|
|
333
|
-
level_raw = col(4)
|
|
334
|
-
tipo_interfaz = col(5)
|
|
335
|
-
technique = col(6)
|
|
336
|
-
scenario = col(7)
|
|
337
|
-
preconditions = col(8)
|
|
338
|
-
pasos = col(9)
|
|
339
|
-
expected = col(10)
|
|
340
|
-
risk_score = col(11)
|
|
341
|
-
priority = col(12)
|
|
342
|
-
strategy = tipo_interfaz # V7.8 no tiene columna Estrategia
|
|
343
|
-
else:
|
|
344
|
-
# Layout clasico (12 columnas)
|
|
345
|
-
feature = col(1)
|
|
346
|
-
epics = col(2)
|
|
347
|
-
level_raw = col(3)
|
|
348
|
-
technique = col(4)
|
|
349
|
-
scenario = col(5)
|
|
350
|
-
preconditions = col(6)
|
|
351
|
-
pasos = col(7)
|
|
352
|
-
expected = col(8)
|
|
353
|
-
risk_score = col(9)
|
|
354
|
-
priority = col(10)
|
|
355
|
-
strategy = col(11)
|
|
356
|
-
|
|
357
|
-
# Normalizar nivel
|
|
358
|
-
level = LEVEL_NORMALIZE.get(level_raw, level_raw)
|
|
359
|
-
|
|
360
|
-
# Construir steps: Precondiciones -> Setup, Pasos -> Action, Expected -> Result
|
|
361
|
-
steps = []
|
|
362
|
-
if preconditions and preconditions != '—' and preconditions != '-':
|
|
363
|
-
steps.append(TestStep(
|
|
364
|
-
action=f'SETUP: {preconditions}',
|
|
365
|
-
expected_result='Precondiciones verificadas',
|
|
366
|
-
))
|
|
367
|
-
if pasos:
|
|
368
|
-
# Separar pasos numerados (1. xxx 2. yyy)
|
|
369
|
-
numbered = re.split(r'\d+\.\s+', pasos)
|
|
370
|
-
numbered = [p.strip() for p in numbered if p.strip()]
|
|
371
|
-
if len(numbered) > 1:
|
|
372
|
-
# Multiples pasos: uno por cada numerado, el ultimo lleva el expected
|
|
373
|
-
for i, paso in enumerate(numbered):
|
|
374
|
-
if i < len(numbered) - 1:
|
|
375
|
-
steps.append(TestStep(
|
|
376
|
-
action=paso,
|
|
377
|
-
expected_result='Paso ejecutado correctamente',
|
|
378
|
-
))
|
|
379
|
-
else:
|
|
380
|
-
steps.append(TestStep(
|
|
381
|
-
action=paso,
|
|
382
|
-
expected_result=expected,
|
|
383
|
-
))
|
|
384
|
-
else:
|
|
385
|
-
steps.append(TestStep(
|
|
386
|
-
action=pasos,
|
|
387
|
-
expected_result=expected,
|
|
388
|
-
))
|
|
389
|
-
elif expected:
|
|
390
|
-
steps.append(TestStep(
|
|
391
|
-
action=scenario or 'Ejecutar escenario',
|
|
392
|
-
expected_result=expected,
|
|
393
|
-
))
|
|
394
|
-
|
|
395
|
-
# Extraer story refs del campo Epicas
|
|
396
|
-
source_stories = []
|
|
397
|
-
story_refs = re.findall(r'E\d+-S\d+', epics)
|
|
398
|
-
# Se enriquecen despues con feature_code prefix
|
|
399
|
-
|
|
400
|
-
# Construir titulo: escenario como titulo descriptivo
|
|
401
|
-
title = scenario if scenario else tc_id
|
|
402
|
-
|
|
403
|
-
# Raw body para descripcion en Jira
|
|
404
|
-
raw_body = (
|
|
405
|
-
f'Escenario: {scenario}\n'
|
|
406
|
-
f'Precondiciones: {preconditions}\n'
|
|
407
|
-
f'Pasos: {pasos}\n'
|
|
408
|
-
f'Resultado Esperado: {expected}\n'
|
|
409
|
-
f'Tecnica: {technique}\n'
|
|
410
|
-
+ (f'Modo: {modo}\n' if modo else '')
|
|
411
|
-
+ (f'Tipo Interfaz: {tipo_interfaz}\n' if tipo_interfaz else '')
|
|
412
|
-
+ f'Riesgo: {risk_score}'
|
|
413
|
-
)
|
|
414
|
-
|
|
415
|
-
return TestCase(
|
|
416
|
-
tc_id=tc_id,
|
|
417
|
-
title=title,
|
|
418
|
-
priority=priority,
|
|
419
|
-
level=level,
|
|
420
|
-
feature=feature,
|
|
421
|
-
epics=epics,
|
|
422
|
-
technique=technique,
|
|
423
|
-
scenario=scenario,
|
|
424
|
-
preconditions=preconditions,
|
|
425
|
-
risk_score=risk_score,
|
|
426
|
-
strategy=strategy,
|
|
427
|
-
source_stories=source_stories,
|
|
428
|
-
raw_body=raw_body,
|
|
429
|
-
steps=steps,
|
|
430
|
-
)
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
def parse_test_cases(content: str) -> list:
|
|
434
|
-
"""Extrae Test Cases de Seccion IV (Matriz 360°)."""
|
|
435
262
|
test_cases = []
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
# tolera los tres formatos observados en archivos reales:
|
|
439
|
-
# - clasico: "## IV. MATRIZ INTEGRAL ..."
|
|
440
|
-
# - V7.8: "## V. MATRIZ INTEGRAL ..."
|
|
441
|
-
# - por fases:"# FASE 4 — MATRIZ INTEGRAL DE PRUEBAS (DISEÑO 360°)"
|
|
442
|
-
# Captura hasta el siguiente encabezado de fase (h1 "# FASE N") o la
|
|
443
|
-
# siguiente seccion romana (h2 "## V."/"## VI.") o el fin del documento.
|
|
444
|
-
matrix_match = re.search(
|
|
445
|
-
r'#+[^\n]*MATRIZ INTEGRAL[^\n]*\n(.*?)(?=\n#\s+FASE\s|\n##\s+[IVX]+[.\s]|\Z)',
|
|
446
|
-
content, re.DOTALL
|
|
447
|
-
)
|
|
448
|
-
if not matrix_match:
|
|
449
|
-
return test_cases
|
|
450
|
-
|
|
451
|
-
section = matrix_match.group(1)
|
|
452
|
-
|
|
453
|
-
# Patron de ID de caso. Acepta tanto el clasico "TC-F1-001" como el V7.8
|
|
454
|
-
# "[FEATURE]-[NIVEL]-NNN" (ej. CLI-INT-001, ASO-INTX-001, VNT-E2EX-001).
|
|
455
|
-
case_id_re = re.compile(r'^[A-Z][A-Z0-9]+-[A-Z0-9]+-\d+$')
|
|
456
|
-
|
|
457
|
-
for line in section.split('\n'):
|
|
458
|
-
line = line.strip()
|
|
459
|
-
if not line.startswith('|'):
|
|
263
|
+
for c in design.get('test_matrix', []) or []:
|
|
264
|
+
if not isinstance(c, dict):
|
|
460
265
|
continue
|
|
461
|
-
|
|
462
|
-
if
|
|
266
|
+
tc_id = str(c.get('id', '')).strip()
|
|
267
|
+
if not tc_id:
|
|
463
268
|
continue
|
|
464
269
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
270
|
+
feature = str(c.get('feature', '')).strip()
|
|
271
|
+
epics_raw = c.get('epics', [])
|
|
272
|
+
if isinstance(epics_raw, list):
|
|
273
|
+
epics = ', '.join(str(x) for x in epics_raw)
|
|
274
|
+
else:
|
|
275
|
+
epics = str(epics_raw or '')
|
|
276
|
+
modo = str(c.get('mode', '') or '').strip()
|
|
277
|
+
level_raw = str(c.get('level', '') or '').strip()
|
|
278
|
+
tipo_interfaz = str(c.get('interface_type', '') or '').strip()
|
|
279
|
+
technique = str(c.get('technique', '') or '').strip()
|
|
280
|
+
scenario = str(c.get('scenario', '') or '').strip()
|
|
281
|
+
preconditions = str(c.get('preconditions', '') or '').strip()
|
|
282
|
+
expected = str(c.get('expected_result', '') or '').strip()
|
|
283
|
+
risk_score = str(c.get('risk', '') or '').strip()
|
|
284
|
+
priority = str(c.get('priority', '') or '').strip()
|
|
285
|
+
level = LEVEL_NORMALIZE.get(level_raw, level_raw)
|
|
286
|
+
|
|
287
|
+
# Steps: precondiciones como SETUP + un step por cada paso atomico del YAML
|
|
288
|
+
steps = []
|
|
289
|
+
if preconditions and preconditions not in ('—', '-'):
|
|
290
|
+
steps.append(TestStep(
|
|
291
|
+
action=f'SETUP: {preconditions}',
|
|
292
|
+
expected_result='Precondiciones verificadas',
|
|
293
|
+
))
|
|
294
|
+
for s in c.get('steps', []) or []:
|
|
295
|
+
if isinstance(s, dict):
|
|
296
|
+
accion = str(s.get('accion', '') or '').strip()
|
|
297
|
+
resultado = str(s.get('resultado', '') or '').strip()
|
|
298
|
+
datos = str(s.get('datos', '') or '').strip()
|
|
299
|
+
else:
|
|
300
|
+
# tolerancia: step como string suelto
|
|
301
|
+
accion, resultado, datos = str(s).strip(), '', ''
|
|
302
|
+
if accion:
|
|
303
|
+
steps.append(TestStep(
|
|
304
|
+
action=accion,
|
|
305
|
+
expected_result=resultado or expected,
|
|
306
|
+
data=datos,
|
|
307
|
+
))
|
|
308
|
+
# Fallback: si el YAML no trajo pasos atomicos pero hay resultado esperado
|
|
309
|
+
if not any(not st.action.startswith('SETUP:') for st in steps) and expected:
|
|
310
|
+
steps.append(TestStep(
|
|
311
|
+
action=scenario or 'Ejecutar escenario',
|
|
312
|
+
expected_result=expected,
|
|
313
|
+
))
|
|
471
314
|
|
|
472
|
-
|
|
473
|
-
|
|
315
|
+
title = scenario if scenario else tc_id
|
|
316
|
+
raw_body = (
|
|
317
|
+
f'Escenario: {scenario}\n'
|
|
318
|
+
f'Precondiciones: {preconditions}\n'
|
|
319
|
+
f'Resultado Esperado: {expected}\n'
|
|
320
|
+
f'Tecnica: {technique}\n'
|
|
321
|
+
+ (f'Modo: {modo}\n' if modo else '')
|
|
322
|
+
+ (f'Tipo Interfaz: {tipo_interfaz}\n' if tipo_interfaz else '')
|
|
323
|
+
+ f'Riesgo: {risk_score}'
|
|
324
|
+
)
|
|
474
325
|
|
|
326
|
+
test_cases.append(TestCase(
|
|
327
|
+
tc_id=tc_id,
|
|
328
|
+
title=title,
|
|
329
|
+
priority=priority,
|
|
330
|
+
level=level,
|
|
331
|
+
feature=feature,
|
|
332
|
+
epics=epics,
|
|
333
|
+
technique=technique,
|
|
334
|
+
scenario=scenario,
|
|
335
|
+
preconditions=preconditions,
|
|
336
|
+
risk_score=risk_score,
|
|
337
|
+
strategy=tipo_interfaz,
|
|
338
|
+
source_stories=[],
|
|
339
|
+
raw_body=raw_body,
|
|
340
|
+
steps=steps,
|
|
341
|
+
))
|
|
475
342
|
return test_cases
|
|
476
343
|
|
|
477
344
|
# ============================================================
|
|
478
|
-
#
|
|
345
|
+
# Builder: traceability[] -> mapping requirement -> [tc_ids]
|
|
479
346
|
# ============================================================
|
|
480
347
|
|
|
481
|
-
def
|
|
482
|
-
"""
|
|
348
|
+
def build_traceability(design: dict) -> dict:
|
|
349
|
+
"""Construye mapping requisito/criterio -> [tc_ids] desde `traceability[]`."""
|
|
483
350
|
mapping = {}
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
351
|
+
for t in design.get('traceability', []) or []:
|
|
352
|
+
if not isinstance(t, dict):
|
|
353
|
+
continue
|
|
354
|
+
req = str(t.get('requirement', '') or '').strip()
|
|
355
|
+
if not req:
|
|
356
|
+
continue
|
|
357
|
+
cases = t.get('cases', []) or []
|
|
358
|
+
mapping.setdefault(req, [])
|
|
359
|
+
for cid in cases:
|
|
360
|
+
cid = str(cid).strip()
|
|
361
|
+
if cid and cid not in mapping[req]:
|
|
362
|
+
mapping[req].append(cid)
|
|
495
363
|
return mapping
|
|
496
364
|
|
|
497
365
|
|
|
498
|
-
def
|
|
499
|
-
"""
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
├── Story 1.2 (Navigation)
|
|
503
|
-
│ ├── TC-F1-01 (desc)
|
|
504
|
-
└── ...
|
|
366
|
+
def augment_trace_with_stories(trace_map: dict, stories: list, test_cases: list):
|
|
367
|
+
"""Agrega al trace_map los vinculos story_id -> [tc_ids] inferidos de la columna
|
|
368
|
+
`epics` de cada caso, para que el link TC -> Requisito (gatekeeper) funcione aunque
|
|
369
|
+
el bloque `traceability` apunte a FACs en vez de a historias.
|
|
505
370
|
"""
|
|
506
|
-
|
|
507
|
-
current_story = None
|
|
508
|
-
current_tcs = []
|
|
509
|
-
|
|
510
|
-
for line in section.split('\n'):
|
|
511
|
-
# Detectar línea que menciona una Story
|
|
512
|
-
story_match = re.search(r'\bStory\s+(\d+\.\d+)\b', line)
|
|
513
|
-
if story_match:
|
|
514
|
-
# Cerrar story anterior
|
|
515
|
-
if current_story and current_tcs:
|
|
516
|
-
mapping.setdefault(current_story, [])
|
|
517
|
-
mapping[current_story] = list(set(mapping[current_story] + current_tcs))
|
|
518
|
-
|
|
519
|
-
current_story = f'Story {story_match.group(1)}'
|
|
520
|
-
current_tcs = []
|
|
521
|
-
|
|
522
|
-
# TCs en la misma línea: Story 2.1 → TC-F2-01, TC-F2-02
|
|
523
|
-
arrow_match = re.search(r'(?:→|->)\s*(.+)$', line)
|
|
524
|
-
if arrow_match:
|
|
525
|
-
current_tcs.extend(re.findall(r'TC-[A-Z0-9]+-\d+', arrow_match.group(1)))
|
|
526
|
-
|
|
527
|
-
elif current_story:
|
|
528
|
-
# TCs en líneas hijo: │ ├── TC-F1-01 (desc)
|
|
529
|
-
tcs = re.findall(r'TC-[A-Z0-9]+-\d+', line)
|
|
530
|
-
current_tcs.extend(tcs)
|
|
531
|
-
|
|
532
|
-
# Guardar el último story
|
|
533
|
-
if current_story and current_tcs:
|
|
534
|
-
mapping.setdefault(current_story, [])
|
|
535
|
-
mapping[current_story] = list(set(mapping[current_story] + current_tcs))
|
|
371
|
+
story_ids = {s.story_id for s in stories}
|
|
536
372
|
|
|
537
|
-
|
|
373
|
+
def norm(sid):
|
|
374
|
+
m = re.search(r'(\d+\.\d+)', sid)
|
|
375
|
+
return m.group(1) if m else sid
|
|
538
376
|
|
|
377
|
+
norm_index = {norm(sid): sid for sid in story_ids}
|
|
539
378
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
if not trace_match:
|
|
553
|
-
trace_match = re.search(
|
|
554
|
-
r'(?:APÉNDICE|APENDICE).*?TRAZABILIDAD.*?\n(.*?)(?=\n### Trazabilidad|\n### Requerimientos|\Z)',
|
|
555
|
-
content, re.DOTALL
|
|
556
|
-
)
|
|
557
|
-
if not trace_match:
|
|
558
|
-
return {}
|
|
559
|
-
|
|
560
|
-
section = trace_match.group(1)
|
|
379
|
+
for tc in test_cases:
|
|
380
|
+
if not tc.epics:
|
|
381
|
+
continue
|
|
382
|
+
refs = re.findall(r'E\d+-S\d+', tc.epics) + re.findall(r'\b\d+\.\d+\b', tc.epics)
|
|
383
|
+
for ref in refs:
|
|
384
|
+
sid = ref if ref in story_ids else norm_index.get(norm(ref))
|
|
385
|
+
if not sid:
|
|
386
|
+
continue
|
|
387
|
+
trace_map.setdefault(sid, [])
|
|
388
|
+
if tc.tc_id not in trace_map[sid]:
|
|
389
|
+
trace_map[sid].append(tc.tc_id)
|
|
390
|
+
return trace_map
|
|
561
391
|
|
|
562
|
-
|
|
563
|
-
|
|
392
|
+
# ============================================================
|
|
393
|
+
# (legacy) Parser: Seccion II — FACs Gherkin
|
|
394
|
+
# ============================================================
|
|
564
395
|
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
396
|
+
# ============================================================
|
|
397
|
+
# (legacy) Parser: Seccion IV — Matriz 360° (Test Cases)
|
|
398
|
+
# ============================================================
|
|
568
399
|
|
|
569
|
-
|
|
400
|
+
# ============================================================
|
|
401
|
+
# (legacy) Parser: Apendice — Traceability
|
|
402
|
+
# ============================================================
|
|
570
403
|
|
|
571
404
|
# ============================================================
|
|
572
405
|
# Enriquecimiento: propagar traceability y FACs a TCs
|
|
@@ -605,27 +438,24 @@ def enrich_test_cases(test_cases: list, trace_map: dict, facs: dict, feature_cod
|
|
|
605
438
|
# Enriquecimiento: epic names en stories
|
|
606
439
|
# ============================================================
|
|
607
440
|
|
|
608
|
-
def
|
|
609
|
-
"""
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
epic_names = {}
|
|
619
|
-
for m in re.finditer(r'-\s*(E\d+-S\d+)\s*\((.+?)\)', content):
|
|
620
|
-
epic_names[m.group(1)] = m.group(2).strip()
|
|
441
|
+
def enrich_stories_from_features(design: dict, stories: list):
|
|
442
|
+
"""Asigna epic_name a las stories usando los nombres de `features[]` del YAML."""
|
|
443
|
+
feature_names = {}
|
|
444
|
+
for feat in design.get('features', []) or []:
|
|
445
|
+
if not isinstance(feat, dict):
|
|
446
|
+
continue
|
|
447
|
+
code = str(feat.get('code', '') or '').strip()
|
|
448
|
+
name = str(feat.get('name', '') or '').strip()
|
|
449
|
+
if code and name:
|
|
450
|
+
feature_names[code] = name
|
|
621
451
|
|
|
622
452
|
for s in stories:
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
453
|
+
if s.epic_name:
|
|
454
|
+
continue
|
|
455
|
+
# epic_id tipo "VNT-E001" -> prefijo de feature antes del primer '-'
|
|
456
|
+
code = s.epic_id.split('-')[0] if s.epic_id else ''
|
|
457
|
+
if code in feature_names:
|
|
458
|
+
s.epic_name = feature_names[code]
|
|
629
459
|
|
|
630
460
|
# ============================================================
|
|
631
461
|
# API: Jira REST
|
|
@@ -944,7 +774,7 @@ def build_plan_tc_map(test_cases):
|
|
|
944
774
|
def print_summary(stories, test_cases, trace_map, facs, meta):
|
|
945
775
|
"""Muestra resumen del parseo."""
|
|
946
776
|
safe_print('\n' + '=' * 70)
|
|
947
|
-
safe_print(' BMAD
|
|
777
|
+
safe_print(' BMAD V7.8 (YAML) -> AgileTest -- Resumen del parseo')
|
|
948
778
|
safe_print('=' * 70)
|
|
949
779
|
|
|
950
780
|
safe_print(f'\n Feature: {meta.get("feature", "?")}')
|
|
@@ -1544,14 +1374,14 @@ def load_bmm_config(config_path: str):
|
|
|
1544
1374
|
|
|
1545
1375
|
def main():
|
|
1546
1376
|
parser = argparse.ArgumentParser(
|
|
1547
|
-
description='BMAD
|
|
1377
|
+
description='BMAD V7.8 (YAML) -> AgileTest: Parser + Cargador',
|
|
1548
1378
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1549
1379
|
epilog="""
|
|
1550
1380
|
Ejemplos:
|
|
1551
1381
|
python bmad_to_agiletest.py --dry-run Parsea y muestra resumen
|
|
1552
1382
|
python bmad_to_agiletest.py --dry-run --detail Muestra cada TC con steps
|
|
1553
1383
|
python bmad_to_agiletest.py --dry-run --export Exporta a JSON
|
|
1554
|
-
python bmad_to_agiletest.py --input
|
|
1384
|
+
python bmad_to_agiletest.py --input test-design.yml --dry-run
|
|
1555
1385
|
python bmad_to_agiletest.py --create Crea todo en Jira/AgileTest
|
|
1556
1386
|
""",
|
|
1557
1387
|
)
|
|
@@ -1564,7 +1394,7 @@ Ejemplos:
|
|
|
1564
1394
|
parser.add_argument('--feature-filter', default=None,
|
|
1565
1395
|
help='Filtrar por feature key (ej: F4, F1). Solo procesa TCs de esa feature')
|
|
1566
1396
|
parser.add_argument('--input', default=CONFIG['input_file'],
|
|
1567
|
-
help='Ruta al archivo test-design.
|
|
1397
|
+
help='Ruta al archivo test-design.yml generado por quality-process')
|
|
1568
1398
|
parser.add_argument('--config', default=None,
|
|
1569
1399
|
help='Ruta a _bmad/bmm/config.yaml (para leer sección agiletest)')
|
|
1570
1400
|
parser.add_argument('--state-dir', default=None,
|
|
@@ -1584,25 +1414,26 @@ Ejemplos:
|
|
|
1584
1414
|
print(f' ERROR: Archivo no encontrado: {input_path}')
|
|
1585
1415
|
sys.exit(1)
|
|
1586
1416
|
|
|
1587
|
-
#
|
|
1588
|
-
print(f'
|
|
1589
|
-
|
|
1590
|
-
content = f.read()
|
|
1417
|
+
# Cargar el test-design.yml (V7.8) via yaml.safe_load
|
|
1418
|
+
print(f'Cargando YAML: {os.path.basename(input_path)}')
|
|
1419
|
+
design = load_design(input_path)
|
|
1591
1420
|
|
|
1592
|
-
#
|
|
1593
|
-
meta =
|
|
1421
|
+
# Metadata
|
|
1422
|
+
meta = build_metadata(design)
|
|
1594
1423
|
feature_code = meta.get('feature_code', 'UNKNOWN')
|
|
1595
|
-
print(f' Feature: {meta.get("feature", "?")} ({feature_code})')
|
|
1424
|
+
print(f' Feature: {meta.get("feature", "?")} ({feature_code}) | Modo: {meta.get("mode", "?")}')
|
|
1596
1425
|
|
|
1597
|
-
#
|
|
1598
|
-
stories =
|
|
1599
|
-
facs =
|
|
1600
|
-
test_cases =
|
|
1601
|
-
trace_map =
|
|
1426
|
+
# Construir entidades desde el YAML
|
|
1427
|
+
stories = build_stories(design)
|
|
1428
|
+
facs = build_facs(design)
|
|
1429
|
+
test_cases = build_test_cases(design)
|
|
1430
|
+
trace_map = build_traceability(design)
|
|
1602
1431
|
|
|
1603
1432
|
# Enriquecer datos cruzados
|
|
1604
1433
|
enrich_test_cases(test_cases, trace_map, facs, feature_code)
|
|
1605
|
-
|
|
1434
|
+
enrich_stories_from_features(design, stories)
|
|
1435
|
+
# Link story -> TC inferido de la columna epics (para TC -> Requisito en el create)
|
|
1436
|
+
augment_trace_with_stories(trace_map, stories, test_cases)
|
|
1606
1437
|
|
|
1607
1438
|
# Filtrado por feature (--pilot usa F4, --feature-filter usa lo indicado)
|
|
1608
1439
|
feat_filter = None
|
|
@@ -1,195 +1,203 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* yaml-to-playwright-spec.ts
|
|
3
|
-
* Convierte test-design
|
|
4
|
-
* que espera el Playwright Test Generator Agent.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
let
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
return
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
lines.push(
|
|
108
|
-
lines.push(
|
|
109
|
-
lines.push(
|
|
110
|
-
lines.push(
|
|
111
|
-
lines.push(
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
lines.push(
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
1
|
+
/**
|
|
2
|
+
* yaml-to-playwright-spec.ts
|
|
3
|
+
* Convierte el test-design.yml (BMAD V7.8, fuente única de verdad) al formato Markdown
|
|
4
|
+
* que espera el Playwright Test Generator Agent.
|
|
5
|
+
*
|
|
6
|
+
* V7.8: la fuente es `test_matrix[]` (varios casos), cada uno con `steps[]` atómicos
|
|
7
|
+
* { paso, accion, resultado, datos }. (Antes V6.0 usaba `caso_prueba.pasos[].parte`.)
|
|
8
|
+
*
|
|
9
|
+
* Uso:
|
|
10
|
+
* npx ts-node yaml-to-playwright-spec.ts <yaml-file> [--feature CLI] [--id CLI-INT-001] [--output specs/plan.md]
|
|
11
|
+
*
|
|
12
|
+
* Si no se indica --feature ni --id, convierte todos los casos del test_matrix.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import * as fs from 'fs';
|
|
16
|
+
import * as path from 'path';
|
|
17
|
+
import * as yaml from 'js-yaml';
|
|
18
|
+
|
|
19
|
+
// ── Estructura V7.8 ────────────────────────────────────────────────
|
|
20
|
+
interface Step {
|
|
21
|
+
paso: number;
|
|
22
|
+
accion: string;
|
|
23
|
+
resultado: string;
|
|
24
|
+
datos?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface TestCase {
|
|
28
|
+
id: string;
|
|
29
|
+
feature?: string;
|
|
30
|
+
epics?: string[] | string;
|
|
31
|
+
mode?: string;
|
|
32
|
+
level?: string;
|
|
33
|
+
interface_type?: string | null;
|
|
34
|
+
technique?: string;
|
|
35
|
+
scenario?: string;
|
|
36
|
+
preconditions?: string;
|
|
37
|
+
steps?: Step[];
|
|
38
|
+
expected_result?: string;
|
|
39
|
+
risk?: string;
|
|
40
|
+
priority?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface TestDesignYaml {
|
|
44
|
+
metadata?: {
|
|
45
|
+
project_name?: string;
|
|
46
|
+
methodology?: string;
|
|
47
|
+
mode?: string;
|
|
48
|
+
interface_type?: string | null;
|
|
49
|
+
generated_date?: string;
|
|
50
|
+
};
|
|
51
|
+
test_matrix?: TestCase[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseArgs(args: string[]): { yamlFile: string; feature: string | null; id: string | null; output: string | null } {
|
|
55
|
+
const yamlFile = args[0];
|
|
56
|
+
if (!yamlFile) {
|
|
57
|
+
console.error('Uso: npx ts-node yaml-to-playwright-spec.ts <yaml-file> [--feature CLI] [--id CLI-INT-001] [--output specs/plan.md]');
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let feature: string | null = null;
|
|
62
|
+
let id: string | null = null;
|
|
63
|
+
let output: string | null = null;
|
|
64
|
+
|
|
65
|
+
for (let i = 1; i < args.length; i++) {
|
|
66
|
+
if (args[i] === '--feature' && args[i + 1]) {
|
|
67
|
+
feature = args[++i].toUpperCase();
|
|
68
|
+
} else if (args[i] === '--id' && args[i + 1]) {
|
|
69
|
+
id = args[++i].toUpperCase();
|
|
70
|
+
} else if (args[i] === '--output' && args[i + 1]) {
|
|
71
|
+
output = args[++i];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { yamlFile, feature, id, output };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function epicsToText(epics: TestCase['epics']): string {
|
|
79
|
+
if (Array.isArray(epics)) return epics.join(', ');
|
|
80
|
+
return epics || '';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function filterCases(cases: TestCase[], feature: string | null, id: string | null): TestCase[] {
|
|
84
|
+
return cases.filter(tc => {
|
|
85
|
+
if (id && (tc.id || '').toUpperCase() !== id) return false;
|
|
86
|
+
if (feature && (tc.feature || '').toUpperCase() !== feature) return false;
|
|
87
|
+
return true;
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function generateMarkdown(data: TestDesignYaml, cases: TestCase[]): string {
|
|
92
|
+
const lines: string[] = [];
|
|
93
|
+
const md = data.metadata || {};
|
|
94
|
+
const title = md.project_name ? `Plan de Pruebas — ${md.project_name}` : 'Plan de Pruebas';
|
|
95
|
+
|
|
96
|
+
// Header
|
|
97
|
+
lines.push(`# ${title}`);
|
|
98
|
+
lines.push('');
|
|
99
|
+
if (md.methodology) lines.push(`**Metodología:** ${md.methodology} `);
|
|
100
|
+
if (md.mode) lines.push(`**Modo:** ${md.mode} `);
|
|
101
|
+
if (md.generated_date) {
|
|
102
|
+
// js-yaml parsea fechas ISO sin comillas como Date; normalizar a YYYY-MM-DD.
|
|
103
|
+
const d: any = md.generated_date;
|
|
104
|
+
const dateStr = d instanceof Date ? d.toISOString().slice(0, 10) : String(d);
|
|
105
|
+
lines.push(`**Fecha:** ${dateStr}`);
|
|
106
|
+
}
|
|
107
|
+
lines.push('');
|
|
108
|
+
lines.push(`**Seed:** \`tests/seed-example.spec.ts\``);
|
|
109
|
+
lines.push('');
|
|
110
|
+
lines.push(`> ${cases.length} caso(s) de prueba a automatizar.`);
|
|
111
|
+
lines.push('');
|
|
112
|
+
|
|
113
|
+
// Una sección por caso de prueba
|
|
114
|
+
let sectionNum = 0;
|
|
115
|
+
for (const tc of cases) {
|
|
116
|
+
sectionNum++;
|
|
117
|
+
const prio = tc.priority ? ` [${tc.priority}]` : '';
|
|
118
|
+
lines.push(`## ${sectionNum}. ${tc.id}${prio} — ${tc.scenario || ''}`.trimEnd());
|
|
119
|
+
lines.push('');
|
|
120
|
+
|
|
121
|
+
const metaBits: string[] = [];
|
|
122
|
+
if (tc.feature) metaBits.push(`**Feature:** ${tc.feature}`);
|
|
123
|
+
if (tc.level) metaBits.push(`**Nivel:** ${tc.level}`);
|
|
124
|
+
if (tc.technique) metaBits.push(`**Técnica:** ${tc.technique}`);
|
|
125
|
+
if (tc.interface_type) metaBits.push(`**Interfaz:** ${tc.interface_type}`);
|
|
126
|
+
const epicsTxt = epicsToText(tc.epics);
|
|
127
|
+
if (epicsTxt) metaBits.push(`**Épicas:** ${epicsTxt}`);
|
|
128
|
+
if (metaBits.length) {
|
|
129
|
+
lines.push(metaBits.join(' · '));
|
|
130
|
+
lines.push('');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (tc.preconditions) {
|
|
134
|
+
lines.push(`**Precondiciones:** ${tc.preconditions}`);
|
|
135
|
+
lines.push('');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Steps atómicos V7.8
|
|
139
|
+
lines.push('**Steps:**');
|
|
140
|
+
const steps = tc.steps || [];
|
|
141
|
+
if (steps.length === 0) {
|
|
142
|
+
lines.push('1. _(sin pasos atómicos en el YAML)_');
|
|
143
|
+
} else {
|
|
144
|
+
for (const step of steps) {
|
|
145
|
+
const n = step.paso != null ? step.paso : '-';
|
|
146
|
+
let line = `${n}. ${(step.accion || '').trim()}`;
|
|
147
|
+
if (step.datos && String(step.datos).trim()) {
|
|
148
|
+
line += ` — _Datos:_ ${String(step.datos).trim()}`;
|
|
149
|
+
}
|
|
150
|
+
lines.push(line);
|
|
151
|
+
if (step.resultado && step.resultado.trim()) {
|
|
152
|
+
lines.push(` - _Esperado del paso:_ ${step.resultado.trim()}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
lines.push('');
|
|
157
|
+
|
|
158
|
+
if (tc.expected_result) {
|
|
159
|
+
lines.push(`**Expected (caso):** ${tc.expected_result}`);
|
|
160
|
+
lines.push('');
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return lines.join('\n');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// --- Main ---
|
|
168
|
+
const args = process.argv.slice(2);
|
|
169
|
+
const { yamlFile, feature, id, output } = parseArgs(args);
|
|
170
|
+
|
|
171
|
+
const yamlContent = fs.readFileSync(yamlFile, 'utf-8');
|
|
172
|
+
const data = yaml.load(yamlContent) as TestDesignYaml;
|
|
173
|
+
|
|
174
|
+
const allCases = data.test_matrix || [];
|
|
175
|
+
if (allCases.length === 0) {
|
|
176
|
+
console.error('⚠️ El YAML no tiene `test_matrix[]` o está vacío. ¿Es un test-design.yml V7.8?');
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const cases = filterCases(allCases, feature, id);
|
|
181
|
+
if (cases.length === 0) {
|
|
182
|
+
console.error(`⚠️ Ningún caso coincide con el filtro (feature=${feature ?? 'todas'}, id=${id ?? 'todos'}).`);
|
|
183
|
+
process.exit(1);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const markdown = generateMarkdown(data, cases);
|
|
187
|
+
|
|
188
|
+
// Determinar archivo de salida
|
|
189
|
+
const projectSlug = (data.metadata?.project_name || 'test-design').toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
|
190
|
+
const filterSlug = id ? id.toLowerCase() : feature ? feature.toLowerCase() : 'completo';
|
|
191
|
+
const defaultName = `${projectSlug}-${filterSlug}.plan.md`;
|
|
192
|
+
const outFile = output || path.join('specs', defaultName);
|
|
193
|
+
|
|
194
|
+
// Crear directorio si no existe
|
|
195
|
+
const outDir = path.dirname(outFile);
|
|
196
|
+
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
|
|
197
|
+
|
|
198
|
+
fs.writeFileSync(outFile, markdown, 'utf-8');
|
|
199
|
+
console.log(`✅ Plan generado: ${outFile}`);
|
|
200
|
+
console.log(` Filtro: feature=${feature ?? 'todas'} | id=${id ?? 'todos'}`);
|
|
201
|
+
console.log(` Casos: ${cases.length}`);
|
|
202
|
+
const totalSteps = cases.reduce((acc, tc) => acc + (tc.steps?.length || 0), 0);
|
|
203
|
+
console.log(` Steps atómicos: ${totalSteps}`);
|
|
Binary file
|
|
Binary file
|